Author SHA1 Message Date
mirivlad 0b6b0d0926 fix: workbench 'back' blocked, workspace breadcrumb-up only when btn active
- onNavigateBack/Forward now skip entirely when currentView === 'workbench'
  (user relies on close button, not mouse back)
- onNavigateBack inside workspace checks upBtn.disabled before clicking —
  in root folder (no currentPath) button exists but disabled, so navigateBack
  falls through to navigationStack for cross-view navigation
- mouseHistoryDirection and keyHistoryDirection also block 'workbench' to
  prevent bypass via pointerdown/mousedown listeners
2026-06-21 16:25:45 +08:00
mirivlad b4e66c9779 fix: mouse navigation — redirect workspace back/forward to Files buttons, remove xinput monitor
- onNavigateBack/Forward now check for currentView === 'workspace' first
  and delegate to Files plugin's up/forward buttons (breadcrumb/Fwd nav)
- Fall back to navigationStack for cross-view navigation
- Removed mouse_monitor.go (xinput test-xi2) — causes phantom X windows,
  superseded by GTK-level vendor patch
- Removed startMouseMonitor call from App.Startup
2026-06-21 16:18:45 +08:00
mirivlad d644c5bb79 feat: mouse back/forward navigation + history stack
- C patch (vendor/.../window.c): intercept GDK button 8/9 → dispatch
  CustomEvent('verstak:navigate-back'/'verstak:navigate-forward')
- App.svelte: navigation stack (snapshot-based history), alt+arrows,
  mouse button back/forward handlers, onNavigateBack/Forward
- WorkbenchHost: close via navigate-back event
- WorkspaceHost: workspace tab bar + tool panels
- wails-mock: full navigation, sidebar, vau...
2026-06-21 16:01:21 +08:00
mirivlad 8e5690e8f7 feat: intercept GDK button 8/9 for mouse back/forward navigation
WebKitGTK does not propagate XButton1/XButton2 (buttons 8 and 9) into
DOM events — event.button and event.buttons are always 0 for these
clicks. This prevents the frontend from detecting hardware back/forward
mouse buttons for history navigation.

Solution: patch Wails' window.c on Linux to intercept button-press-event
at the GTK signal level (before WebKit processes it). For button 8/9 we
call webkit_web_view_run_javascript() to dispatch a native CustomEvent
('verstak:navigate-back' / 'verstak:navigate-forward') into the page,
allowing the frontend to navigate history without any workaround on the
JS side.

The patch is applied automatically during build via scripts/build.sh:
  go mod vendor → patch -p0 < patches/window.c.button-press.patch
Vendor directory is gitignored.
2026-06-21 13:49:13 +08:00
mirivlad 0ac473d720 core: Milestone 7b — Files explorer and Default Editor improvements
- Files plugin: richer explorer with breadcrumbs, selection, toolbar actions,
  rename/trash, filter, sorting, hidden/reserved entries filtered
- Default Editor: line numbers, Ctrl+S, markdown toolbar, Edit/Preview/Split,
  markdown preview, Reload/Revert
- E2E tests: 39 passed for files + editor
- Workspace model: correction, naming alignment, compatibility wrappers
- Updated docs: NOTES_FILES_PLUGIN_PLAN.md, PLUGIN_RUNTIME.md
2026-06-20 19:20:13 +08:00
mirivlad 4de5a74a55 fix: sanitize sync error messages, detect non-sync servers, add health check in TestAuth 2026-06-20 03:20:25 +08:00
mirivlad db67c370ab fix: add backend.call() API and fix [object Object] in workspace sidebar
- Add api.backend.call(method, ...args) to VerstakPluginAPI for direct Wails method invocation
- Add wsName() helper in WorkspaceTree.svelte with String() coercion to prevent [object Object] display
- Use correct Wails path window['go']['api']['App'] in backend.call()
2026-06-20 03:02:33 +08:00
mirivlad ed69746332 feat: add sync backend methods 2026-06-20 02:25:25 +08:00
mirivlad 5c979174f1 fix: polish workspace files and editor shell 2026-06-19 23:37:10 +08:00
mirv a6412fa070 feat: milestones 6b-fix through 6e — default-editor, files plugin, workspace host, workspaceItems contribution
- Fix PluginCard openProviders display
- Add default-editor plugin (text/markdown/notes-context)
- Add files plugin with workspaceItems placement
- Add workspaceItems contribution point (Go + API + mock + SDK)
- Add WorkspaceHost component for workspace area
- WorkspaceTree dispatches selection event
- Fix default-editor layout to fill container
- Fix PluginCard unsafe .length access
- Add E2E tests: 34/34 pass
- Add bundle execution check to official-plugins check.sh
- Update docs: PLUGIN_RUNTIME, DEV_PLUGINS, MILESTONE_6B/6C/6D plans
2026-06-19 16:42:01 +08:00
mirivlad 6ed6df311a Implement milestone 6b workbench routing skeleton 2026-06-19 07:51:57 +08:00
mirivlad a100f5a441 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.
2026-06-17 19:40:05 +08:00
mirivlad 6d2f7858eb fix: replace emoji icons with inline SVG (Icon.svelte + icons.js)
All emoji characters replaced with inline SVG icons:
- Plugin Manager sidebar: puzzle SVG icon
- Verstak logo: stack/tray SVG icon
- Plugin icons: flask SVG (from plugin manifest)
- Warning/error indicators: warning triangle SVG
- Settings button: gear SVG
- Vault recent: vault/shield SVG
- Fallback: dot SVG

New components:
- frontend/src/lib/ui/icons.js — SVG path map
- frontend/src/lib/ui/Icon.svelte — reusable SVG icon component

Icon policy: NO emoji or unicode pictographic symbols in the app.
Only SVG icons registered in icons.js are allowed.
Wails WebKitGTK does not render colour emoji.
2026-06-17 19:02:51 +08:00
mirivlad c2e14cae69 feat: add bundle-host-test.cjs + smoke integration
- bundle-host-test.cjs: JS smoke-test for PluginBundleHost contract
  - 4 error boundary scenarios (missing frontend, JS throw, missing component, mount throw)
  - 5 real mount scenarios (bundle exec, DiagnosticsPanel mount, Settings mount, unmount, mount-throw catch)
  - Runs real platform-test bundle in vm.Sandbox with mock window/document
- smoke-platform.sh: add bundle-host-test step
- Fix: platform-test badgeRow div call (3rd arg was ignored)
2026-06-17 18:51:14 +08:00
mirivlad 05ef1449bc feat: milestone 5b — frontend bundle host + VerstakPluginAPI stub
- Bundle contract: window.VerstakPluginRegister(id, {components: {...}})
- PluginBundleHost.svelte: loads bundle via GetPluginAssetContent, mounts components
- VerstakPluginAPI.js: restricted API (capabilities, events, settings, commands — all stub)
- ViewContainer: PluginBundleHost replaces placeholder when frontend bundle exists
- PluginManager: settings panel via PluginBundleHost (removed hardcoded form)
- Backend: GetPluginFrontendInfo, GetPluginAssetContent with path security
- Security: reject absolute paths, path traversal, escape from plugin root
- Error boundary: bundle load/execute/mount errors show fallback, not crash
- Tests: 11 backend tests (asset API), frontend bundle checks in smoke
- Docs: bundle contract, VerstakPluginAPI, security constraints
2026-06-17 17:39:50 +08:00
mirivlad 1dff97a9c0 fix: smoke test now proves manifest→discovery→registry flow with ReloadPlugins gating
- runContributionsTest rewritten: shows exact manifest fragment from plugin.json
- Capability resolution gates contribution registration (status=degraded)
- Unregister before Register throughout (matches ReloadPlugins)
- Verify contributions by name with data matching manifest
- Proper \n formatting
2026-06-17 17:21:46 +08:00
mirivlad a96ffb5801 chore: split build.sh and update-and-build-all.sh
- build.sh: deterministic local-only build, fail-fast, no git pull
- update-and-build-all.sh: dev helper, pulls all repos, builds official plugins, then builds desktop
- Docs: added Build Scripts section explaining the difference
2026-06-17 17:13:14 +08:00
mirivlad 86eeadd2a9 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
2026-06-17 17:07:52 +08:00
mirivlad 9bb35a9fd0 fix: add workspace capability name check to smoke, clean up tracked build artifacts 2026-06-17 16:32:08 +08:00
mirivlad 67345a194a fix: vault/workspace lifecycle — CreateVault creates workspace, SetCurrentVault loads workspace, ReloadPlugins keeps workspace capability, recursive tree rendering 2026-06-17 14:26:49 +08:00
mirivlad 5fa2c0ddf9 docs: add workspace capability documentation 2026-06-17 12:24:12 +08:00
mirivlad 5c9ae7f93b feat: add workspace/cases core capability 2026-06-17 12:22:52 +08:00
mirivlad 6eecf5d005 fix: OpenVault now looks for vault.json inside VerstakVault/ subdirectory 2026-06-17 10:10:39 +08:00
mirivlad 252d075f9b fix: register OnStartup callback for Wails context initialization 2026-06-17 09:44:23 +08:00
mirivlad ffb3446cc3 feat: native directory picker for vault selection 2026-06-17 09:13:09 +08:00
mirivlad dd199f38ee fix: remove legacy config migration — new Verstak starts clean 2026-06-17 08:56:17 +08:00
mirivlad e9758ec1b5 feat: add legacy config migration + test 2026-06-17 08:23:25 +08:00
mirivlad ca089a82e9 docs: deduplicate sections, add UI layout and milestone 4b 2026-06-17 07:33:36 +08:00
mirivlad 7530e21dfd feat: ui completion — VaultSelection, Sidebar navigation, layout fixes 2026-06-17 07:28:00 +08:00
mirivlad 6202157cbf docs: update PLUGIN_RUNTIME.md with app settings, vault plugin state, first run flow 2026-06-17 04:22:16 +08:00
mirivlad a6f9e85f13 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
2026-06-17 04:19:13 +08:00
75 changed files with 17042 additions and 766 deletions
+4
View File
@@ -1,4 +1,8 @@
# .gitignore
frontend/node_modules/
frontend/dist/
build/bin/verstak-desktop
smoke-platform
plugins/
vendor/
\nfrontend/e2e-results/
+86
View File
@@ -91,3 +91,89 @@ verstak-desktop/
wails.json
...
```
## Verification language policy
**Do not say "checked", "verified", "works", or "all good" unless a concrete
verification command or user scenario was executed.**
Use exact labels:
- Build checked
- Unit tests checked
- Backend behavior checked
- Frontend E2E checked
- Real desktop GUI checked
- Not checked / requires manual verification
If GUI behavior was not clicked and asserted, report:
"GUI behavior was not verified."
### GUI testing
Frontend E2E tests are in `frontend/e2e/`. Run with `npm run test:e2e`.
These tests use Playwright + mocked Wails bindings. They test Svelte component
logic and user interactions in a real Chromium browser.
**Limitations:** Playwright tests do NOT test the real WebKitGTK/Wails native
shell. For real desktop GUI verification, a separate AT-SPI/xdotool layer is
needed (not yet implemented).
See `docs/GUI_TESTING.md` for details.
## Debug logging
**Always use debug logging when investigating issues. Never rely on "it should work" — look at the logs.**
### Backend debug
Enable with `--debug` flag:
```bash
./verstak-desktop --debug
```
Logs go to: `~/.local/share/verstak/debug/verstak-YYYY-MM-DD-HHMMSS.log`
View in real-time:
```bash
tail -f ~/.local/share/verstak/debug/verstak-*.log
```
What's logged (when `--debug` is active):
- Plugin discovery: dirs scanned, each plugin found (id, name, version, source, root)
- Plugin lifecycle: capability registration, contribution registration, status transitions
- API calls: GetPlugins, GetContributions, GetCapabilities, ReloadPlugins, EnablePlugin, DisablePlugin
- Vault operations: open/close/status
### Frontend debug
Enable via **either**:
1. URL query param: `?debug` (not practical in Wails, use #2)
2. In browser console: `localStorage.setItem('verstak-debug', 'true')` then reload
Logs go to:
- Browser console (with `[debug]` prefix)
- localStorage buffer: `localStorage.getItem('verstak-debug-log')` (last 1000 entries)
Export frontend log from console:
```javascript
copy(JSON.parse(localStorage.getItem('verstak-debug-log')))
// or
window.__verstakDebug.exportLog()
```
What's logged:
- App startup: checkVault, GetAppSettings, GetVaultStatus
- Navigation: onNav, onOpenView, onOpenSettings, onCloseSettings
- PluginManager: loadAll (start/end, plugin count, each plugin status), reload, enable/disable
- Sidebar: onMount (plugins loaded, sidebar items count), handleNav, handleSidebarItem
### Debug workflow
1. User reports issue
2. Restart app with `--debug` and reproduce
3. Run `tail -f ~/.local/share/verstak/debug/verstak-*.log` and share output
4. For frontend issues: enable frontend debug view `exportLog()` output
5. Analyze logs, identify root cause, fix
6. Verify fix by asking user to reproduce again with debug on
**Never skip step 2-4. Always look at real logs before proposing fixes.**
Binary file not shown.
+795 -13
View File
@@ -1,17 +1,26 @@
// Smoke-platform validates that the platform-test plugin is discovered correctly
// by the Verstak desktop runtime. This runs headless — no Wails GUI needed.
// Package api provides Wails-bound methods for the frontend.
package main
import (
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/verstak/verstak-desktop/internal/core/capability"
"github.com/verstak/verstak-desktop/internal/core/contribution"
"github.com/verstak/verstak-desktop/internal/core/plugin"
"github.com/verstak/verstak-desktop/internal/core/pluginstate"
"github.com/verstak/verstak-desktop/internal/core/vault"
"github.com/verstak/verstak-desktop/internal/core/workspace"
)
func main() {
testEnableDisable := flag.Bool("test-enable-disable", false, "Test enable/disable lifecycle")
testWorkspace := flag.Bool("test-workspace", false, "Test workspace/cases lifecycle")
testContributions := flag.Bool("test-contributions", false, "Test contribution registry lifecycle")
flag.Parse()
exitCode := 0
defer func() {
os.Exit(exitCode)
@@ -20,6 +29,21 @@ func main() {
root, _ := os.Getwd()
pluginDir := filepath.Join(root, "plugins")
if *testContributions {
runContributionsTest(root)
return
}
if *testWorkspace {
runWorkspaceTest(root)
return
}
if *testEnableDisable {
runEnableDisableTest(root)
return
}
fmt.Printf("=== smoke-platform: headless plugin verification ===\n\n")
fmt.Printf(" plugin dir: %s\n", pluginDir)
@@ -152,7 +176,6 @@ func main() {
fmt.Printf("\n[capability registration]\n")
reg := capability.NewRegistry()
// Register core capabilities (same list as main.go)
coreCaps := []string{
"verstak/core/plugin-manager/v1",
"verstak/core/capability-registry/v1",
@@ -167,7 +190,6 @@ func main() {
fmt.Printf(" ✅ registered %d core capabilities\n", len(coreCaps))
}
// Register vault capability (core service)
if err := reg.Register("verstak-desktop", []string{"verstak/core/vault/v1"}); err != nil {
fmt.Printf(" ❌ register vault capability: %v\n", err)
allGood = false
@@ -175,7 +197,13 @@ func main() {
fmt.Printf(" ✅ registered vault capability\n")
}
// Register plugin capabilities
if err := reg.Register("verstak-desktop", []string{"verstak/core/workspace/v1"}); err != nil {
fmt.Printf(" ❌ register workspace capability: %v\n", err)
allGood = false
} else {
fmt.Printf(" ✅ registered workspace capability\n")
}
for _, p := range m.Provides {
if err := reg.Register(m.ID, []string{p}); err != nil {
fmt.Printf(" ❌ register capability %s: %v\n", p, err)
@@ -196,7 +224,16 @@ func main() {
}
}
// ── 9. Verify required capabilities resolved ──
// ── 9. Verify workspace capability by name ──
fmt.Printf("\n[workspace capability verification]\n")
if reg.Has("verstak/core/workspace/v1") {
fmt.Printf(" ✅ workspace capability present: verstak/core/workspace/v1\n")
} else {
fmt.Printf(" ❌ workspace capability MISSING: verstak/core/workspace/v1\n")
allGood = false
}
// ── 10. Verify required capabilities resolved ──
fmt.Printf("\n[required capability resolution]\n")
missingRequired := reg.CheckRequired(m.Requires)
if len(missingRequired) > 0 {
@@ -208,7 +245,7 @@ func main() {
fmt.Printf(" ✅ all required capabilities resolved\n")
}
// ── 10. Check optional capabilities ──
// ── 11. Check optional capabilities ──
fmt.Printf("\n[optional capability resolution]\n")
missingOptional := reg.CheckRequired(m.OptionalRequires)
if len(missingOptional) > 0 {
@@ -217,7 +254,7 @@ func main() {
}
}
// ── 11. Determine expected status ──
// ── 12. Determine expected status ──
fmt.Printf("\n[plugin status]\n")
expectedStatus := "loaded"
if len(missingOptional) > 0 {
@@ -236,18 +273,18 @@ func main() {
allGood = false
}
// ── 12. Total capability count ──
// ── 13. Total capability count ──
fmt.Printf("\n[capability count]\n")
totalCaps := len(reg.List())
fmt.Printf(" total capabilities: %d\n", totalCaps)
if totalCaps >= 8 {
fmt.Printf(" ✅ total capabilities >= 8 (%d)\n", totalCaps)
if totalCaps >= 9 {
fmt.Printf(" ✅ total capabilities >= 9 (%d)\n", totalCaps)
} else {
fmt.Printf(" ❌ total capabilities < 8 (got %d, expected >= 8)\n", totalCaps)
fmt.Printf(" ❌ total capabilities < 9 (got %d, expected >= 9)\n", totalCaps)
allGood = false
}
// ── 13. Summary ──
// ── 14. Summary ──
fmt.Printf("\n=== summary ===\n")
if allGood {
fmt.Printf("✅ smoke-platform passed\n")
@@ -256,3 +293,748 @@ 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)
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")
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)
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))
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")
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))
fmt.Printf("\n=== summary ===\n")
fmt.Printf("✅ enable/disable test passed\n")
}
// runContributionsTest verifies the full manifest → discovery → registry pipeline,
// mirroring the real ReloadPlugins() flow in internal/api/app.go.
//
// Flow:
// 1. plugin.json on disk → DiscoverPlugins() → parsed manifest
// 2. Manifest contributions fragment displayed (proof it comes from real plugin.json)
// 3. Capability registration + resolution → determine plugin status
// 4. Contribution registration (gated by status: only loaded/degraded get contributions)
// 5. Verify all 4 contribution types present by name, data matches manifest
// 6. Disable plugin → Unregister → contributions gone
// 7. Re-enable → Unregister+Register (ReloadPlugins) → contributions return
// 8. Reload → Unregister+Register → no duplicates
func runContributionsTest(root string) {
exitCode := 0
defer func() {
os.Exit(exitCode)
}()
fmt.Printf("=== smoke-platform: contribution registry test ===\n\n")
// Create 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)
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")
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)
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")
// ── Step 1: Discover plugins from disk (reads real plugin.json) ──
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: %s@%s\n", target.Manifest.ID, target.Manifest.Version)
// ── Step 2: Show manifest contributions fragment (proof it comes from plugin.json) ──
fmt.Printf("\n[manifest contributions from plugin.json]\n")
fmt.Printf(" plugin: %s\n", target.Manifest.ID)
if c := target.Manifest.Contributes; c != nil {
fmt.Printf(" sidebarItems: %d\n", len(c.SidebarItems))
for _, s := range c.SidebarItems {
fmt.Printf(" - id=%q title=%q view=%q\n", s.ID, s.Title, s.View)
}
fmt.Printf(" views: %d\n", len(c.Views))
for _, vw := range c.Views {
fmt.Printf(" - id=%q title=%q component=%q\n", vw.ID, vw.Title, vw.Component)
}
fmt.Printf(" settingsPanels: %d\n", len(c.SettingsPanels))
for _, s := range c.SettingsPanels {
fmt.Printf(" - id=%q title=%q component=%q\n", s.ID, s.Title, s.Component)
}
fmt.Printf(" commands: %d\n", len(c.Commands))
for _, cmd := range c.Commands {
fmt.Printf(" - id=%q title=%q\n", cmd.ID, cmd.Title)
}
}
// ── Step 2b: Frontend bundle verification ──
fmt.Printf("\n[frontend bundle verification]\n")
frontendOk := true
// 2b.1 — Check manifest declares frontend.entry
if target.Manifest.Frontend == nil {
fmt.Printf(" ❌ manifest.frontend is nil — plugin has no frontend config\n")
frontendOk = false
} else {
expectedEntry := "frontend/dist/index.js"
if target.Manifest.Frontend.Entry != expectedEntry {
fmt.Printf(" ❌ frontend.entry: expected %q, got %q\n", expectedEntry, target.Manifest.Frontend.Entry)
frontendOk = false
} else {
fmt.Printf(" ✅ frontend.entry: %s\n", target.Manifest.Frontend.Entry)
}
// 2b.2 — Resolve entry path and check file exists on disk
resolvedEntry := filepath.Join(target.RootPath, target.Manifest.Frontend.Entry)
if _, err := os.Stat(resolvedEntry); os.IsNotExist(err) {
fmt.Printf(" ❌ frontend bundle NOT FOUND at resolved path: %s\n", resolvedEntry)
frontendOk = false
} else if err != nil {
fmt.Printf(" ❌ frontend bundle stat error: %v\n", err)
frontendOk = false
} else {
fmt.Printf(" ✅ frontend bundle exists: %s\n", resolvedEntry)
// 2b.3 — Read file and check for "VerstakPluginRegister"
data, err := os.ReadFile(resolvedEntry)
if err != nil {
fmt.Printf(" ❌ failed to read frontend bundle: %v\n", err)
frontendOk = false
} else {
content := string(data)
if strings.Contains(content, "VerstakPluginRegister") {
fmt.Printf(" ✅ bundle contains VerstakPluginRegister contract\n")
} else {
fmt.Printf(" ❌ bundle MISSING VerstakPluginRegister contract\n")
frontendOk = false
}
// 2b.4 — Check for expected component names
expectedComponents := []string{"DiagnosticsPanel", "PlatformTestSettings"}
for _, comp := range expectedComponents {
if strings.Contains(content, comp) {
fmt.Printf(" ✅ component registered: %s\n", comp)
} else {
fmt.Printf(" ❌ component MISSING from bundle: %s\n", comp)
frontendOk = false
}
}
}
}
}
if frontendOk {
fmt.Printf(" ✅ frontend bundle checks passed\n")
} else {
fmt.Printf(" ❌ frontend bundle checks FAILED\n")
exitCode = 1
return
}
// ── Step 3: Register capabilities (simulates main.go + ReloadPlugins) ──
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 _, capID := range target.Manifest.Provides {
_ = reg.Register(target.Manifest.ID, []string{capID})
}
fmt.Printf("\n[capability registration]\n")
fmt.Printf(" registered %d capabilities\n", len(reg.List()))
// ── Step 4: ReloadPlugins flow with capability gating ──
fmt.Printf("\n[register contributions (ReloadPlugins flow)]\n")
contribReg := contribution.NewRegistry()
// (a) Check disabled → skip
if target.Status == plugin.StatusDisabled {
fmt.Printf(" ❌ plugin is disabled — contributions should NOT be registered\n")
exitCode = 1
return
}
// (b) Capability resolution (same as ReloadPlugins)
missingRequired := reg.CheckRequired(target.Manifest.Requires)
missingOptional := reg.CheckRequired(target.Manifest.OptionalRequires)
status := plugin.StatusLoaded
if len(missingRequired) > 0 {
status = plugin.StatusMissingRequiredCapability
} else if len(missingOptional) > 0 {
status = plugin.StatusDegraded
}
// (c) Only loaded/degraded get contributions
if status == plugin.StatusLoaded || status == plugin.StatusDegraded {
fmt.Printf(" plugin status=%s → contributions WILL be registered\n", status)
contribReg.Register(target.Manifest.ID, target.Manifest.Contributes)
fmt.Printf(" contributions registered for %s\n", target.Manifest.ID)
} else {
fmt.Printf(" ❌ plugin status=%s — contributions should NOT be registered\n", status)
exitCode = 1
return
}
// ── Step 5: Verify contributions by name, data matches manifest ──
fmt.Printf("\n[verify contributions by name]\n")
allGood := true
sidebarItems := contribReg.SidebarItems()
if len(sidebarItems) != 1 || sidebarItems[0].Item.Title != "Platform Test" {
fmt.Printf(" ❌ sidebarItems mismatch: got %v\n", sidebarItems)
allGood = false
} else {
fmt.Printf(" ✅ sidebarItem: plugin=%s id=%s title=%s\n",
sidebarItems[0].PluginID, sidebarItems[0].Item.ID, sidebarItems[0].Item.Title)
}
views := contribReg.Views()
if len(views) != 1 || views[0].Item.Title != "Platform Diagnostics" {
fmt.Printf(" ❌ views mismatch: got %v\n", views)
allGood = false
} else {
fmt.Printf(" ✅ view: plugin=%s id=%s title=%s component=%s\n",
views[0].PluginID, views[0].Item.ID, views[0].Item.Title, views[0].Item.Component)
}
settingsPanels := contribReg.SettingsPanels()
if len(settingsPanels) != 1 || settingsPanels[0].Item.Title != "Platform Test Settings" {
fmt.Printf(" ❌ settingsPanels mismatch: got %v\n", settingsPanels)
allGood = false
} else {
fmt.Printf(" ✅ settingsPanel: plugin=%s id=%s title=%s\n",
settingsPanels[0].PluginID, settingsPanels[0].Item.ID, settingsPanels[0].Item.Title)
}
commands := contribReg.Commands()
if len(commands) != 2 {
fmt.Printf(" ❌ expected 2 commands, got %d\n", len(commands))
allGood = false
} else {
for _, c := range commands {
fmt.Printf(" command: plugin=%s id=%s title=%s\n", c.PluginID, c.Item.ID, c.Item.Title)
}
}
if !allGood {
fmt.Printf(" ❌ some contribution types missing or data mismatch\n")
exitCode = 1
return
}
fmt.Printf(" ✅ all 4 contribution types present, data matches manifest\n")
// ── Step 6: Disable → Unregister → contributions removed ──
fmt.Printf("\n[disable plugin → Unregister → contributions removed]\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")
// ReloadPlugins: Unregister before next scan, disabled plugins don't get re-registered
contribReg.Unregister("verstak.platform-test")
remaining := len(contribReg.Views()) + len(contribReg.SidebarItems()) +
len(contribReg.Commands()) + len(contribReg.SettingsPanels())
fmt.Printf(" remaining contributions: %d\n", remaining)
if remaining != 0 {
fmt.Printf(" ❌ contributions not removed after disable\n")
exitCode = 1
return
}
fmt.Printf(" ✅ all contributions removed\n")
// ── Step 7: Re-enable → contributions return via Unregister+Register ──
fmt.Printf("\n[re-enable → Unregister+Register → contributions return]\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")
// ReloadPlugins: Unregister before Register (prevents duplicates)
contribReg.Unregister(target.Manifest.ID)
contribReg.Register(target.Manifest.ID, target.Manifest.Contributes)
count := len(contribReg.Views()) + len(contribReg.SidebarItems()) +
len(contribReg.Commands()) + len(contribReg.SettingsPanels())
fmt.Printf(" contributions after re-enable: %d\n", count)
if count < 4 {
fmt.Printf(" ❌ expected >= 4 contributions, got %d\n", count)
exitCode = 1
return
}
fmt.Printf(" ✅ contributions returned (count=%d)\n", count)
// ── Step 8: Reload → Unregister+Register → no duplicates ──
fmt.Printf("\n[reload → Unregister+Register → no duplicates]\n")
beforeCount := count
contribReg.Unregister(target.Manifest.ID)
contribReg.Register(target.Manifest.ID, target.Manifest.Contributes)
afterCount := len(contribReg.Views()) + len(contribReg.SidebarItems()) +
len(contribReg.Commands()) + len(contribReg.SettingsPanels())
fmt.Printf(" before reload: %d, after reload: %d\n", beforeCount, afterCount)
if afterCount != beforeCount {
fmt.Printf(" ❌ duplicate after reload: before=%d after=%d\n", beforeCount, afterCount)
exitCode = 1
return
}
fmt.Printf(" ✅ no duplicate contributions after reload (count=%d)\n", afterCount)
fmt.Printf("\n=== summary ===\n")
if exitCode == 0 {
fmt.Printf("✅ contribution registry test passed\n")
} else {
fmt.Printf("❌ contribution registry test failed\n")
}
}
func runWorkspaceTest(root string) {
exitCode := 0
defer func() {
os.Exit(exitCode)
}()
fmt.Printf("=== smoke-platform: workspace test ===\n\n")
tmpDir, err := os.MkdirTemp("", "verstak-ws-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)
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")
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)
fmt.Printf("\n[workspace init]\n")
ws := workspace.NewManager(openedPath)
if err := ws.Load(); err != nil {
fmt.Printf(" ❌ load workspace: %v\n", err)
exitCode = 1
return
}
fmt.Printf(" ✅ workspace loaded\n")
tree := ws.GetTree()
if len(tree.Nodes) != 1 {
fmt.Printf(" ❌ expected 1 root node, got %d\n", len(tree.Nodes))
exitCode = 1
return
}
fmt.Printf(" ✅ root node exists (id=%s)\n", tree.Nodes[0].ID)
rootID := tree.Nodes[0].ID
fmt.Printf("\n[workspace capability]\n")
reg := capability.NewRegistry()
reg.Register("verstak-desktop", []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", []string{"verstak/core/vault/v1"})
reg.Register("verstak-desktop", []string{"verstak/core/workspace/v1"})
if !reg.Has("verstak/core/workspace/v1") {
fmt.Printf(" ❌ workspace capability not registered\n")
exitCode = 1
return
}
fmt.Printf(" ✅ workspace capability registered\n")
totalCaps := len(reg.List())
if totalCaps < 7 {
fmt.Printf(" ❌ expected >= 7 capabilities, got %d\n", totalCaps)
exitCode = 1
return
}
fmt.Printf(" ✅ total capabilities >= 7 (%d)\n", totalCaps)
fmt.Printf("\n[create case]\n")
caseNode, err := ws.CreateNode(rootID, workspace.TypeCase, "Test Case")
if err != nil {
fmt.Printf(" ❌ create case: %v\n", err)
exitCode = 1
return
}
fmt.Printf(" ✅ case created: %s\n", caseNode.Title)
fmt.Printf("\n[create folder]\n")
folderNode, err := ws.CreateNode(rootID, workspace.TypeFolder, "Test Folder")
if err != nil {
fmt.Printf(" ❌ create folder: %v\n", err)
exitCode = 1
return
}
fmt.Printf(" ✅ folder created: %s\n", folderNode.Title)
fmt.Printf("\n[create nested case]\n")
nestedCase, err := ws.CreateNode(folderNode.ID, workspace.TypeCase, "Nested Case")
if err != nil {
fmt.Printf(" ❌ create nested case: %v\n", err)
exitCode = 1
return
}
fmt.Printf(" ✅ nested case created: %s\n", nestedCase.Title)
fmt.Printf("\n[tree structure]\n")
tree = ws.GetTree()
if len(tree.Nodes) != 4 {
fmt.Printf(" ❌ expected 4 nodes, got %d\n", len(tree.Nodes))
exitCode = 1
return
}
fmt.Printf(" ✅ tree has 4 nodes\n")
children := ws.ListChildren(rootID)
if len(children) != 2 {
fmt.Printf(" ❌ expected 2 root children, got %d\n", len(children))
exitCode = 1
return
}
fmt.Printf(" ✅ root has 2 children\n")
fmt.Printf("\n[rename]\n")
if err := ws.RenameNode(caseNode.ID, "Renamed Case"); err != nil {
fmt.Printf(" ❌ rename: %v\n", err)
exitCode = 1
return
}
renamed, _ := ws.GetNode(caseNode.ID)
if renamed.Title != "Renamed Case" {
fmt.Printf(" ❌ rename failed: got %q\n", renamed.Title)
exitCode = 1
return
}
fmt.Printf(" ✅ renamed to %q\n", renamed.Title)
fmt.Printf("\n[set current node]\n")
if err := ws.SetCurrentNode(caseNode.ID); err != nil {
fmt.Printf(" ❌ set current: %v\n", err)
exitCode = 1
return
}
current, err := ws.GetCurrentNode()
if err != nil {
fmt.Printf(" ❌ get current: %v\n", err)
exitCode = 1
return
}
if current.ID != caseNode.ID {
fmt.Printf(" ❌ current node mismatch\n")
exitCode = 1
return
}
fmt.Printf(" ✅ current node: %s\n", current.Title)
fmt.Printf("\n[archive]\n")
if err := ws.ArchiveNode(folderNode.ID); err != nil {
fmt.Printf(" ❌ archive: %v\n", err)
exitCode = 1
return
}
archived, _ := ws.GetNode(folderNode.ID)
if archived.Status != workspace.StatusArchived {
fmt.Printf(" ❌ archive failed: status=%s\n", archived.Status)
exitCode = 1
return
}
fmt.Printf(" ✅ archived %s\n", archived.Title)
fmt.Printf("\n[reopen persistence]\n")
ws2 := workspace.NewManager(openedPath)
if err := ws2.Load(); err != nil {
fmt.Printf(" ❌ reopen: %v\n", err)
exitCode = 1
return
}
tree2 := ws2.GetTree()
if len(tree2.Nodes) != 4 {
fmt.Printf(" ❌ expected 4 nodes after reopen, got %d\n", len(tree2.Nodes))
exitCode = 1
return
}
fmt.Printf(" ✅ tree persisted: %d nodes\n", len(tree2.Nodes))
current2, err := ws2.GetCurrentNode()
if err != nil {
fmt.Printf(" ❌ get current after reopen: %v\n", err)
exitCode = 1
return
}
if current2.ID != caseNode.ID {
fmt.Printf(" ❌ current node not persisted\n")
exitCode = 1
return
}
fmt.Printf(" ✅ current node persisted\n")
fmt.Printf("\n[workspace.json verification]\n")
wsPath := filepath.Join(openedPath, ".verstak", "workspace.json")
wsData, err := os.ReadFile(wsPath)
if err != nil {
fmt.Printf(" ❌ read workspace.json: %v\n", err)
exitCode = 1
return
}
fmt.Printf(" ✅ workspace.json exists on disk\n")
fmt.Printf(" content:\n%s\n", string(wsData))
// ── Test 4-level deep tree ──
fmt.Printf("\n[4-level deep tree]\n")
folder1, err := ws.CreateNode(rootID, workspace.TypeFolder, "Level 1 Folder")
if err != nil {
fmt.Printf(" ❌ create folder1: %v\n", err)
exitCode = 1
return
}
fmt.Printf(" ✅ created: %s\n", folder1.Title)
folder2, err := ws.CreateNode(folder1.ID, workspace.TypeFolder, "Level 2 Folder")
if err != nil {
fmt.Printf(" ❌ create folder2: %v\n", err)
exitCode = 1
return
}
fmt.Printf(" ✅ created: %s\n", folder2.Title)
deepCase, err := ws.CreateNode(folder2.ID, workspace.TypeCase, "Deep Case")
if err != nil {
fmt.Printf(" ❌ create deep case: %v\n", err)
exitCode = 1
return
}
fmt.Printf(" ✅ created: %s (depth 4)\n", deepCase.Title)
tree = ws.GetTree()
if len(tree.Nodes) != 7 {
fmt.Printf(" ❌ expected 7 nodes, got %d\n", len(tree.Nodes))
exitCode = 1
return
}
fmt.Printf(" ✅ tree has 7 nodes (4 levels deep)\n")
deepNode, _ := ws.GetNode(deepCase.ID)
if deepNode.ParentID != folder2.ID {
fmt.Printf(" ❌ deep case parent mismatch\n")
exitCode = 1
return
}
fmt.Printf(" ✅ deep case parent chain correct\n")
fmt.Printf("\n=== summary ===\n")
fmt.Printf("✅ workspace test passed\n")
}
+47 -2
View File
@@ -59,12 +59,57 @@ This validates:
## Desktop Runtime Scanning Paths
The desktop scans two directories for plugins:
The desktop resolves plugin directories in one shared backend resolver. Priority:
| Path | Purpose |
|------|---------|
| `VERSTAK_PLUGIN_DIR` | Dev/test override. Multiple paths can be separated with the OS path separator |
| `./plugins/` | Bundled/dev plugins relative to the current working directory |
| `<binary-dir>/plugins/` | Packaged plugins shipped next to the desktop executable |
| `~/.config/verstak/plugins/` | User-installed plugins |
| `./plugins/` | Bundled/dev plugins (project-local) |
The resolver normalizes paths and removes duplicates before scanning. Missing
directories are ignored by discovery.
Discovery scans all resolved directories in order. If two plugin packages declare
the same `id`, the first package wins and later duplicates are skipped. The
warning includes both package paths, so during development check the log if an
updated plugin appears to be ignored.
## Bundled Plugin API During Development
Frontend bundles are mounted with a plugin-scoped API created by
`createPluginAPI(pluginId)`. The current API supports:
- `settings.read/write/writeAll`
- `capabilities.list/get/has`
- `commands.register/execute` for handlers declared in `contributes.commands`
- `events.publish/subscribe` using the bundled frontend event bus
- `files.list/metadata/readText/writeText/createFolder/move/trash` for
canonical vault-relative slash paths guarded by `files.read`, `files.write`,
and `files.delete`. Backslashes, Windows absolute paths, UNC paths,
traversal, `.verstak` variants, and symlink read/write/move/trash operations
are rejected. Text read/write is UTF-8 only and limited to 2 MB for reads.
- `workbench.openResource/editResource` for routing vault resources to
contributed `openProviders`. Plugins must declare `workbench.open`; this is a
policy/contract check. Files and Notes plugins call this API and do not import
a concrete editor plugin.
Editor/viewer plugins contribute providers with `contributes.openProviders`.
Workbench selects by resource kind, extension/mime, context (`generic-text`,
`generic-markdown`, `notes-markdown`), user preference, priority, then
deterministic `pluginId/providerId` tie-break. If nothing matches, Workbench
shows `no-provider` fallback instead of a core editor.
The official `verstak.default-editor` plugin provides three openProviders:
`verstak.default-editor.text` (generic-text), `verstak.default-editor.markdown`
(generic-markdown), and `verstak.default-editor.notes-markdown` (notes-context).
It uses a single unified `DefaultEditor` component with textarea-based editing,
simple markdown preview, dirty state tracking, and Ctrl+S save.
This is a cooperative contract, not a sandbox. Bundled plugins run in the same JS
context as the desktop frontend; real isolation is deferred to the sidecar/sandbox
milestone.
## Important Rules
+143
View File
@@ -0,0 +1,143 @@
# GUI Testing
## Overview
Verstak Desktop uses **Playwright** for frontend E2E tests that run in a real
Chromium browser with mocked Wails bindings. This tests the Svelte component
logic, user interactions, and UI state transitions — without needing the actual
Wails desktop shell.
## What is tested
### Frontend E2E (Playwright)
Located in `frontend/e2e/`, run via `npm run test:e2e`.
These tests:
- Launch a Vite dev server with mock Wails bindings
- Open the app in a real Chromium browser via Playwright
- Simulate user clicks, wait for UI transitions, assert DOM state
- Collect console errors and page errors on failure
- Capture screenshots on failure
### Test suites
| File | Suite | Tests | Status |
|------|-------|-------|--------|
| `plugin-manager-disable-enable.spec.js` | A: Disable/Enable refresh | 4 | 3 pass, 1 fail* |
| `sidebar-opens-view.spec.js` | B: Sidebar → view routing | 3 | 3 pass |
| `reload-updates-state.spec.js` | C: Reload updates UI | 3 | 2 pass, 1 fail* |
\* Failing tests document **known bugs** (see below).
## Known bugs detected by tests
### Bug M5-1: Sidebar does not update when plugin state changes
**Symptom:** After disabling a plugin in Plugin Manager, the sidebar item for
that plugin remains visible. After re-enabling, it stays visible (doesn't
disappear then reappear — it was never gone).
**Root cause:** `Sidebar.svelte` loads plugin/contribution data once in
`onMount` and stores it in local `sidebarItems`. When `PluginManager`
disables/enables a plugin and calls `ReloadPlugins`, the `PluginManager`
component re-fetches data, but `Sidebar` does not react to the change — it
still holds the stale list.
**Affected tests:**
- `A: Disable plugin: button changes to Enable, sidebar item disappears`
- `A: Disable → Enable full flow in sequence`
- `C: Reload after mock state change reflects new plugin status`
**Fix needed:** Sidebar must either:
1. Re-fetch contributions when it receives a custom event (e.g.
`verstak:plugins-reloaded`), or
2. Read plugin state reactively from a shared store that both
PluginManager and Sidebar subscribe to.
## What is NOT tested
### Real desktop GUI (WebKitGTK + Wails native shell)
The Playwright tests run the frontend in a **standard Chromium browser** with
mocked Wails bindings. They do **not** test:
- Actual WebKitGTK rendering (Wails uses WebKitGTK, not Chromium)
- Native window management (minimize, maximize, resize)
- Native file dialogs (SelectDirectory, SelectVaultForOpen)
- Clipboard integration
- System tray / menu bar
- Plugin frontend bundle loading from real filesystem
- Wails event system (window.runtime.EventsOn/Emit)
For real Wails smoke tests, a separate layer is needed using:
- **AT-SPI2** (Linux accessibility tree inspection)
- **xdotool** / **ydotool** (input simulation)
- **scrot** / **import** (screenshot capture)
## Running tests
```bash
cd frontend
# Run all E2E tests (headless)
npm run test:e2e
# Run with Playwright UI (interactive)
npm run test:e2e:ui
# Run in headed browser (visible)
npm run test:e2e:headed
```
## Test infrastructure
### Mock bridge (`src/lib/test/wails-mock.js`)
Replaces `window['go']['api']['App']` with in-memory mock implementations of
all Wails backend methods. Provides:
- Mutable plugin state (enable/disable/status)
- Mutable vault state
- Mutable contributions (views, commands, sidebar items, settings panels)
- Test helpers via `window.__wailsMock`:
- `reset()` — reset all state to defaults
- `setPluginStatus(id, status, enabled)` — change plugin state
- `getPluginState(id)` — read current state
- `setVaultStatus(status)` — change vault state
### Test harness (`index.html`)
The same `index.html` is used for both production and test. It detects whether
the Wails runtime (`window['go']`) is present. If not (i.e. running in a plain
browser), it loads the mock bridge before the Svelte app.
### Playwright config (`playwright.config.js`)
- Dev server: `vite --mode test --port 5174`
- Browser: Chromium headless
- Timeouts: 30s test, 10s expect
- Workers: 1 (sequential)
- Screenshots: on failure
- Traces: on first retry
- Results: `e2e-results/test-results.json`
## Adding new tests
1. Create `e2e/your-test.spec.js`
2. Import helpers from `./helpers.js`
3. Use `test.beforeEach` to reset mock state and navigate to `/`
4. Use `test.afterEach` to assert no console errors
5. Write scenarios as user actions + assertions
6. Run with `npm run test:e2e`
### Selector conventions
- Plugin cards: `.plugin-card` filtered by text
- Buttons: `.btn-disable`, `.btn-enable`, `.btn-settings`, `.reload-btn`
- Sidebar items: `.sidebar .plugin-item`
- View container: `.view-container`
- View header: `.view-header h2`
- Status badges: `.status-badge`
- Toast: `.toast`
+403
View File
@@ -0,0 +1,403 @@
# Milestone 6a Files Core Service Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use
> `superpowers:subagent-driven-development` or `superpowers:executing-plans` to
> implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for
> tracking.
**Goal:** Add a safe backend Files core service and plugin bridge for
vault-relative text file operations.
**Architecture:** Files Core is a backend service under `internal/core/files`
that accepts vault-relative paths, enforces reserved path policy, and performs
atomic text writes. The Wails API exposes plugin-scoped methods guarded by plugin
state and `files.*` permissions; SDK types describe the bridge shape.
**Tech Stack:** Go backend services/tests, Wails-bound API methods, TypeScript
SDK type definitions, existing Playwright/Vitest checks.
---
## Implementation Status
Milestone 6a is implemented.
Actual backend package:
- `internal/core/files/types.go`
- `internal/core/files/path_policy.go`
- `internal/core/files/service.go`
- `internal/core/files/*_test.go`
Actual plugin-scoped Wails methods:
```go
func (a *App) ListVaultFiles(pluginID string, relativeDir string) ([]files.FileEntry, string)
func (a *App) GetVaultFileMetadata(pluginID string, relativePath string) (files.FileMetadata, string)
func (a *App) ReadVaultTextFile(pluginID string, relativePath string) (string, string)
func (a *App) WriteVaultTextFile(pluginID string, relativePath string, content string, options files.WriteOptions) string
func (a *App) CreateVaultFolder(pluginID string, relativePath string) string
func (a *App) MoveVaultPath(pluginID string, fromRelativePath string, toRelativePath string, options files.MoveOptions) string
func (a *App) TrashVaultPath(pluginID string, relativePath string) (files.TrashResult, string)
```
Actual bundled frontend API:
- `api.files.list(relativeDir)`
- `api.files.metadata(relativePath)`
- `api.files.readText(relativePath)`
- `api.files.writeText(relativePath, content, options)`
- `api.files.createFolder(relativePath)`
- `api.files.move(fromRelativePath, toRelativePath, options)`
- `api.files.trash(relativePath)`
Implemented limits:
- canonical vault-relative slash paths only;
- backslashes, POSIX absolute paths, Windows drive paths, UNC paths, traversal,
null bytes, and empty file paths are rejected;
- `.verstak/` is reserved case-insensitively and hidden from public Files API;
- metadata may report symlinks, but list-through-symlink and
read/write/move/trash through symlink are forbidden;
- text read/write only, with `readText` limited to UTF-8 files up to 2 MB;
- trash uses `.verstak/trash/files/<trashId>/...` with restore metadata, but
restore itself is deferred;
- binary streaming, watcher, external editor, Files UI, Notes service, sidecar,
sandbox/security isolation deferred.
---
## Scope
Implement:
- Backend Files service.
- Safe vault-relative path handling.
- Reserved `.verstak/` policy.
- List files.
- Read/write text files.
- Create folder.
- Move path.
- Trash path.
- Atomic writes.
- Backend tests.
- SDK bridge shape draft.
Do not implement:
- Full Notes plugin.
- Notes UI.
- Sync.
- Watcher.
- Binary streaming.
- External editor integration.
- Sidecar/security isolation.
## Canonical Policy
All public Files API methods use canonical vault-relative slash paths.
Rejected inputs:
- absolute paths;
- backslashes;
- Windows drive paths and UNC/network paths;
- paths containing `..` after normalization;
- null bytes;
- empty paths where a file path is required;
- access to `.verstak/` through the public plugin Files API, including
`.Verstak` case variants.
Delete behavior:
- `TrashVaultPath` moves files/folders into `.verstak/trash`.
- Trash metadata includes `originalPath`, `deletedAt`, `originalType`,
`trashId`, and `basename`.
- Permanent delete is out of scope.
- Restore is out of scope.
Write behavior:
- Text writes use a temporary file in the target directory and rename into place.
- Existing files are overwritten only when the method explicitly allows overwrite.
- Parent directory must exist unless the method explicitly creates it.
Binary behavior:
- Binary files can appear in list/metadata results.
- Binary read/write streaming is out of scope.
## Public Backend Shape
Implemented plugin-scoped Wails methods:
```go
func (a *App) ListVaultFiles(pluginID string, relativeDir string) ([]files.FileEntry, string)
func (a *App) GetVaultFileMetadata(pluginID string, relativePath string) (files.FileMetadata, string)
func (a *App) ReadVaultTextFile(pluginID string, relativePath string) (string, string)
func (a *App) WriteVaultTextFile(pluginID string, relativePath string, content string, options files.WriteOptions) string
func (a *App) CreateVaultFolder(pluginID string, relativePath string) string
func (a *App) MoveVaultPath(pluginID string, fromRelativePath string, toRelativePath string, options files.MoveOptions) string
func (a *App) TrashVaultPath(pluginID string, relativePath string) (files.TrashResult, string)
```
Permission mapping:
- `ListVaultFiles`, `GetVaultFileMetadata`, `ReadVaultTextFile`: `files.read`.
- `WriteVaultTextFile`, `CreateVaultFolder`, `MoveVaultPath`: `files.write`.
- `TrashVaultPath`: `files.delete`.
## Data Types
Create `internal/core/files/types.go`:
```go
package files
type EntryKind string
const (
KindFile EntryKind = "file"
KindDirectory EntryKind = "directory"
)
type Entry struct {
Name string `json:"name"`
Path string `json:"path"`
Kind EntryKind `json:"kind"`
Size int64 `json:"size"`
ModifiedAt string `json:"modifiedAt"`
IsText bool `json:"isText"`
IsBinary bool `json:"isBinary"`
IsHidden bool `json:"isHidden"`
IsReserved bool `json:"isReserved"`
}
```
## Task 1: Path Policy
**Files:**
- Create: `internal/core/files/path.go`
- Create: `internal/core/files/path_test.go`
- [ ] Add `NormalizeVaultRelativePath(relative string) (string, error)`.
- [ ] Reject absolute paths, null bytes, `..`, and empty file paths.
- [ ] Preserve path case, including canonical `Notes`.
- [ ] Add `IsReservedPath(relative string) bool` returning true for `.verstak`
and `.verstak/...`.
- [ ] Add tests:
`TestNormalizeRejectsAbsolutePath`,
`TestNormalizeRejectsTraversal`,
`TestNormalizeRejectsNullByte`,
`TestNormalizePreservesCase`,
`TestReservedPathPolicy`.
- [ ] Run:
```bash
go test ./internal/core/files
```
Expected: all `internal/core/files` tests pass.
## Task 2: Files Service
**Files:**
- Create: `internal/core/files/service.go`
- Create/modify: `internal/core/files/service_test.go`
- [ ] Define `Service` with a vault dependency that can return the current vault
root and status.
- [ ] Implement `List(relativeDir string) ([]Entry, error)`.
- [ ] Implement `Metadata(relativePath string) (Entry, error)`.
- [ ] Implement `ReadText(relativePath string) (string, error)`.
- [ ] Implement `WriteText(relativePath, content string, overwrite bool) (Entry, error)`.
- [ ] Implement `Mkdir(relativePath string) (Entry, error)`.
- [ ] Implement `Move(fromRelativePath, toRelativePath string, overwrite bool) (Entry, error)`.
- [ ] Implement `Trash(relativePath string) (Entry, error)`.
- [ ] Use the shared path policy for every public method.
- [ ] Block `.verstak` paths in every public method.
- [ ] Add tests for closed vault, list, metadata, text read/write, mkdir, move,
trash, overwrite false conflict, overwrite true replace, and reserved path
rejection.
- [ ] Run:
```bash
go test ./internal/core/files
```
Expected: all `internal/core/files` tests pass.
## Task 3: Atomic Writes
**Files:**
- Modify: `internal/core/files/service.go`
- Modify: `internal/core/files/service_test.go`
- [ ] Write text content to a temp file in the target directory.
- [ ] Rename the temp file into the final path only after successful write.
- [ ] Remove temp file on write failure.
- [ ] Add test `TestWriteTextIsAtomicOnFailure` using a controlled failing path
or permission-denied directory.
- [ ] Add test `TestWriteTextDoesNotLeaveTempFile`.
- [ ] Run:
```bash
go test ./internal/core/files
```
Expected: all `internal/core/files` tests pass.
## Task 4: Permissions And Capabilities
**Files:**
- Modify: `internal/core/permissions/registry.go`
- Modify: `main.go`
- Modify: `internal/api/app_test.go`
- [ ] Register permissions: `files.read`, `files.write`, `files.delete`.
- [ ] Register core capability `verstak/core/files/v1` when vault services are
initialized.
- [ ] Add API guard tests proving each Files bridge method rejects plugins that
are missing the required permission.
- [ ] Run:
```bash
go test ./internal/core/permissions ./internal/api
```
Expected: permission registry and API tests pass.
## Task 5: Wails API Bridge
**Files:**
- Modify: `internal/api/app.go`
- Modify: `internal/api/app_test.go`
- Modify after Wails generation or by hand if generation is unavailable:
`frontend/wailsjs/go/api/App.d.ts`
- Modify after Wails generation or by hand if generation is unavailable:
`frontend/wailsjs/go/api/App.js`
- [ ] Add `files.Service` to `api.App`.
- [ ] Add plugin-scoped methods listed in "Public Backend Shape".
- [ ] Use `requirePluginAccess(pluginID, permission)` for every method.
- [ ] Return readable errors for closed vault, missing file, reserved path,
conflict, and missing permission.
- [ ] Add tests for successful read/write/list/mkdir/move/trash through `App`.
- [ ] Run:
```bash
go test ./internal/api
```
Expected: API tests pass.
## Task 6: Frontend Plugin API Draft
**Files:**
- Modify: `frontend/src/lib/plugin-host/VerstakPluginAPI.js`
- Modify: `frontend/src/lib/test/wails-mock.js`
- Add/modify focused frontend tests under `frontend/e2e/` only if existing test
coverage cannot validate the shape outside Playwright.
- [ ] Add `api.files.list(relativeDir)`.
- [ ] Add `api.files.metadata(relativePath)`.
- [ ] Add `api.files.readText(relativePath)`.
- [ ] Add `api.files.writeText(relativePath, content, options)`.
- [ ] Add `api.files.mkdir(relativePath)`.
- [ ] Add `api.files.move(fromRelativePath, toRelativePath, options)`.
- [ ] Add `api.files.trash(relativePath)`.
- [ ] Keep all calls plugin-scoped; plugin code must not pass `pluginId`.
- [ ] Mock readable errors for reserved path and missing permission.
- [ ] Run:
```bash
cd frontend
npm run build
```
Expected: frontend build passes.
## Task 7: SDK Bridge Shape Draft
**Files:**
- Modify: `../verstak-sdk/src/plugin-api.ts`
- Modify: `../verstak-sdk/src/test-utils.ts`
- Modify: `../verstak-sdk/src/plugin-api.test.ts`
- [ ] Add `files` API TypeScript interfaces matching the frontend API names.
- [ ] Add mock Files API methods in `createMockPluginAPI`.
- [ ] Add contract tests for API shape, text write/read, reserved path error, and
trash result shape.
- [ ] Run:
```bash
cd ../verstak-sdk
./scripts/check.sh
./scripts/build.sh
./scripts/test.sh
```
Expected: SDK check, build, and tests pass.
## Task 8: Documentation
**Files:**
- Modify: `docs/PLUGIN_RUNTIME.md`
- Modify: `docs/NOTES_FILES_PLUGIN_PLAN.md`
- [ ] Document Files Core API as functional for Milestone 6a.
- [ ] Keep Notes API documented as planned until Milestone 6b or later.
- [ ] Document `.verstak` reserved path policy.
- [ ] Document slash-only path policy, Windows/UNC rejection, and symlink policy.
- [ ] Document text-only write support and deferred binary streaming.
## Task 9: Final Verification
- [ ] Run desktop backend tests:
```bash
cd verstak-desktop
go test ./...
```
- [ ] Run desktop frontend build:
```bash
cd verstak-desktop/frontend
npm run build
```
- [ ] Run desktop e2e:
```bash
cd verstak-desktop/frontend
npm run test:e2e -- --reporter=list
```
- [ ] Run official plugins checks:
```bash
cd verstak-official-plugins
./scripts/check.sh
./scripts/build.sh
```
- [ ] Run SDK checks:
```bash
cd verstak-sdk
./scripts/check.sh
./scripts/build.sh
./scripts/test.sh
```
Expected: all commands exit 0. Existing Svelte unused CSS warnings are acceptable
only if they remain warnings and do not fail the build.
+148
View File
@@ -0,0 +1,148 @@
# Milestone 6b - Open/Edit Provider Registry + Workbench Routing Skeleton
This milestone adds the minimal infrastructure layer for open/edit routing before
Files UI, Notes UI, or editor implementation starts. It does not implement those
plugins and does not add a concrete core-owned Markdown editor.
## Existing Architecture Found
| Path | Section/title | Summary |
|---|---|---|
| `../verstak-docs/00_README.md` | Main architecture invariant | Core does not know concrete notes, file manager, or markdown editor features. It owns vault, plugin runtime, capability registry, contribution points, permissions, settings, events, storage, and UI shell. |
| `../verstak-docs/01_Product_Vision.md` | What is not in core / platform goal | Markdown editor, file manager, preview, and notes workflow are plugins. Users should be able to replace the markdown editor or install multiple editors. |
| `../verstak-docs/02_Platform_Architecture.md` | UI Shell / Capability Registry | UI Shell knows contribution points, not concrete note editor or file preview implementations. Files plugin checks editor/viewer capabilities instead of depending on `official.markdown-editor`. |
| `../verstak-docs/03_Repositories.md` | Repository split | `verstak-desktop` is Core Platform + UI Shell and does not contain mandatory notes, file manager, or markdown editor modules. Official plugins live separately. |
| `../verstak-docs/04_Plugin_System.md` | Goal / capabilities instead of plugin names / contribution points | Notes, file manager, editor, and viewer are plugin functions. The docs explicitly reject `requires: ["official.markdown-editor"]` and prefer `optionalRequires: ["editor.text.markdown"]`. |
| `../verstak-docs/05_Official_Plugins.md` | official.files / official.notes / official.markdown-editor | Files optionally depends on editor/viewer capabilities. Markdown editor provides `editor.text`, `editor.text.markdown`, and `editor.note.markdown`, and must not own note storage or depend directly on `official.notes`. |
| `../verstak-docs/06_Migration_Strategy.md` | Do not / Definition of Done | Do not make notes/files/editor mandatory core parts. The platform transition is done only when notes/files/editor/preview/activity work as plugins. |
| `docs/NOTES_FILES_PLUGIN_PLAN.md` | Canonical Notes Model | Notes are ordinary Markdown files in canonical `Notes/` folders. No lowercase `notes`, no `.verstak/notes`, no plugin-data note content, no UUID-only filenames. |
| `docs/NOTES_FILES_PLUGIN_PLAN.md` | Files Service Model | Files Core is raw vault file access and does not understand note semantics. Milestone 6a exposes safe text file methods and defers UI, watcher, binary streaming, external editor, and restore. |
| `docs/PLUGIN_RUNTIME.md` | Contribution Points / Bundled Frontend Plugin API | Current runtime hosts views/sidebar/settings/commands and exposes plugin-scoped Files API. `fileActions`, `noteActions`, `contextMenuEntries`, search/activity/status bar entries are registered but not hosted. |
| `internal/core/contribution/registry.go` | Contribution registry | Registry has the established contribution points and now extends that model with `openProviders`. |
| `internal/core/plugin/plugin.go` | Manifest contributions | Plugin manifest types now include `openProviders` alongside existing contribution points. |
| `frontend/src/lib/shell/WorkbenchHost.svelte` and `frontend/src/lib/plugin-host/PluginBundleHost.svelte` | Frontend plugin host | Workbench mounts the selected provider component by plugin id and component id. It remains generic and does not know a concrete editor. |
| `frontend/src/lib/plugin-host/VerstakPluginAPI.js` | Plugin API | Bundled plugins can call `api.workbench.openResource()` and `api.workbench.editResource()` in addition to settings, capabilities, events, commands, and Files API. |
| `../verstak-sdk/src/types.ts` and `../verstak-sdk/schemas/manifest.json` | SDK contribution contracts | SDK types/schema define `openProviders`, `OpenResourceRequest`, provider supports, and `files.*` permissions. |
## What Matches The Desired Model
- Files/Notes/Editor are already documented as plugins, not core modules.
- Official plugins are expected to use the same runtime as community plugins.
- Files plugin is already documented as capability-driven, not hard-wired to a markdown editor.
- Markdown editor is already documented as replaceable via capabilities.
- Notes are already documented as Markdown files under canonical `Notes/` folders, without `.verstak/notes`, UUID note entities, or a second storage truth.
- Desktop code has no hardcoded Markdown editor component.
- Existing plugin host can mount arbitrary plugin components, which is enough foundation for an editor provider host.
## Contradictions Found
- `docs/NOTES_FILES_PLUGIN_PLAN.md` still said Files Core API/capability/permissions were unavailable, while later sections and code show Milestone 6a implemented them.
- `../verstak-docs/05_Official_Plugins.md` calls notes "first-class Verstak entities". That is acceptable only as UI semantics; implementation must not create a separate note storage entity.
- `../verstak-docs/05_Official_Plugins.md` lists "Open externally" as a Files fallback. External open remains deferred and must not enter Milestone 6b.
- `../verstak-sdk/schemas/manifest.json` previously did not include `files.read`, `files.write`, or `files.delete` in the permissions enum, while SDK TS types, desktop permissions, and official `platform-test` manifest already used them. Milestone 6b resolves this 6a contract cleanup item.
## Missing Before 6b
- `openProviders` contribution point.
- `OpenResourceRequest` contract.
- Workbench open/edit routing API for Files/Notes plugins.
- Provider selection model using resource kind, extension/mime, notes context, user preference, provider priority, deterministic fallback, and disabled provider fallback.
- User preferences for default text editor provider, default markdown editor provider, and default notes-context markdown editor provider.
- Host slot/tab that mounts the selected provider component with an open resource request.
- Tests for provider registration, selection, preferences, disabled provider fallback, and notes-context routing.
## Added In 6b
- `contributes.openProviders` in SDK schema/types and desktop manifest structs.
- Desktop contribution registry support for registering, replacing, listing, and
unregistering open providers.
- `OpenResourceRequest`, `OpenResourceContext`, `OpenResourceResult`, and
opened-resource state in the Workbench routing skeleton.
- Provider selection by resource kind, extension/mime, `generic-text`,
`generic-markdown`, or `notes-markdown` context, user preference, priority,
deterministic `pluginId/providerId` fallback, and active plugin filtering.
- `workbench.open` policy permission for plugins that request Workbench routing.
- Draft app settings preferences for default text, markdown, and notes-context
markdown providers.
- `api.workbench.openResource()` and `api.workbench.editResource()` exposed to
frontend plugin bundles.
- Minimal Workbench host that mounts the selected provider component from the
selected provider plugin.
- `no-provider` fallback state when no matching provider exists.
- `platform-test` diagnostic open provider used only to prove routing.
## Decision
Open/edit provider should be an extension of the existing contribution registry,
not a parallel system. It should add `openProviders` beside `views`, `commands`,
`fileActions`, and `noteActions`.
Capabilities remain useful for broad availability and degraded mode, but they are
too coarse for choosing between multiple providers. Provider selection needs a
declarative provider contribution with `supports`, priority, and component id.
## Correct Notes Model
- Notes are a contextual view over ordinary Markdown files under canonical
`Notes/` folders.
- `.md` inside `Notes/` opens through the selected markdown editor with
notes-context.
- `.md` outside `Notes/` opens through the selected markdown editor in generic
markdown mode.
- Plain text opens through the selected text editor provider in `generic-text`
context.
- Files and Notes call open/edit resource; neither embeds a concrete editor.
- Editor provider selection belongs to Workbench/provider registry.
- No `.verstak/notes`, no UUID note entities, no second truth separate from the
`.md` file.
## Minimal Infrastructure Changes
1. Add `openProviders` to SDK manifest/schema/types.
2. Add `openProviders` to desktop plugin manifest structs and contribution
registry.
3. Add `OpenResourceRequest` and provider support match types.
4. Add Workbench/provider selection service with deterministic rules.
5. Add user preferences for `defaultTextEditorProvider`,
`defaultMarkdownEditorProvider`, and `defaultNotesMarkdownEditorProvider`.
6. Add frontend host plumbing so Workbench can mount the selected plugin
component.
7. Fix `verstak-sdk/schemas/manifest.json` permissions enum for `files.*`.
## Proposed Milestone 6b Scope
In scope:
- Contribution/types/schema support for `openProviders`.
- Workbench `openResource`/`editResource` routing API.
- Provider selection with notes context and user preferences.
- Minimal host tab/slot for provider component mounting.
- Diagnostic provider plugin contribution sufficient to prove routing.
- Tests for routing, provider selection, disabled fallback, and notes-context
markdown.
Out of scope:
- Full Files UI feature set.
- Full Notes UI feature set.
- Full editor implementation.
- Real default editor plugin (Milestone 6c).
- Files plugin open/edit integration.
- Notes plugin open/edit integration.
- Hardcoded core Markdown editor.
- Watcher/sync/binary streaming/external editor.
- Sidecar/security boundary.
- Large rewrite.
## 6b-fix: Infrastructure Gaps Closed
Post-review fixes before Milestone 6c:
- Added `openProviders` to `verstak-sdk/schemas/contributions.json` contribution points.
- Added provider matching tests: text/markdown preference, MIME matching, extension
case-insensitivity, multiple supports entries, kind mismatch.
- Added disabled provider exclusion test in `api/app_test.go`.
- Fixed SDK `build.sh`/`test.sh` to detect incomplete `node_modules`.
- Documented disabled provider lifecycle: contributions remain in registry until
ReloadPlugins; `activeOpenProviders()` filters disabled/unloaded at request time.
- Installed Playwright browsers for E2E tests.
+65
View File
@@ -0,0 +1,65 @@
# Milestone 6c — Default Editor Plugin
## Goal
Create the official Default Editor Plugin as an openProvider for text, generic
markdown, and notes-context markdown files. Core desktop does not own or import
the editor component.
## What was built
### Plugin: `verstak.default-editor`
Location: `verstak-official-plugins/plugins/default-editor/`
**Manifest declares 3 openProviders:**
| Provider ID | Context | Extensions |
|-------------|---------|------------|
| `verstak.default-editor.text` | `generic-text` | `.txt`, `.log`, `.conf`, `.ini`, `.toml`, `.yaml`, `.yml`, `.json`, `.csv` |
| `verstak.default-editor.markdown` | `generic-markdown` | `.md`, `.markdown` |
| `verstak.default-editor.notes-markdown` | `notes-markdown` | `.md`, `.markdown` |
All providers use the same `DefaultEditor` component (unified, not 3 separate editors).
**Permissions:** `files.read`, `files.write`, `workbench.open`
**Capabilities required:** `verstak/core/files/v1`, `verstak/core/workbench/v1`
### Frontend component: `DefaultEditor`
- **Modes:** text (textarea), generic-markdown (editor + preview), notes-markdown (editor + preview + notes badge)
- **File loading:** `api.files.readText(path)` with loading/error states
- **Saving:** `api.files.writeText(path, content, { overwrite: true })` with dirty/saved/error states
- **Keyboard:** Ctrl+S / Cmd+S save, Tab indentation
- **Markdown preview:** Simple renderer (no raw HTML, no script injection)
- **Notes context:** Badge + info bar, no separate note entity, no `.verstak/notes`
## Verification
- `go test ./...` — PASS
- `go vet ./...` — PASS
- `npm run build` (frontend) — PASS
- `npm run test:e2e` — 28/28 PASS (20 existing + 8 new)
- `scripts/check.sh` (official-plugins) — PASS
- `scripts/build.sh` (official-plugins) — PASS
- SDK checks — PASS
## Manual testing
Until Files UI plugin exists, use platform-test diagnostics panel to manually
open files via workbench: click "Open Text Diagnostic", "Open Markdown Diagnostic",
or "Open Notes Diagnostic" buttons. These call `api.workbench.editResource` and
route through the default-editor provider.
## Deferred
- CodeMirror/Monaco editor
- Backlinks, internal link navigation
- Secret widgets
- Image asset pipeline
- Files UI plugin
- Notes UI plugin
- Watcher/sync
- External open
- Sidecar/security isolation
+64
View File
@@ -0,0 +1,64 @@
# Milestone 6d — Minimal Files Plugin
## Goal
Create a minimal Files plugin that shows vault files/folders and opens files
through Workbench openResource. No editor embedded.
## What was built
### Plugin: `verstak.files`
Location: `verstak-official-plugins/plugins/files/`
**Contributions:**
- views: `verstak.files.view` → `FilesView` component
- No sidebarItems — Files is not a global sidebar item
**Permissions:** `files.read`, `files.write`, `workbench.open`, `ui.register`
### Files View
- Root listing on mount
- Folder navigation (double-click)
- File open via `api.workbench.openResource()`
- Breadcrumb navigation
- Create folder/file buttons
- Refresh button
- Loading/error/empty states
- `.verstak` filtered out
### Provider priority
- default-editor: priority 50
- platform-test diagnostic: priority 10
- default-editor wins for normal file opens
### Bundle fix
Fixed missing opening quote in STYLES string (`.files-empty` → `'.files-empty'`).
Added automated bundle execution check to `scripts/check.sh`.
## 6d-hotfix
- Removed sidebarItems from Files plugin (Files is not a global sidebar item)
- Added `[frontend bundle execution]` check to `check.sh` — verifies all plugin
bundles parse via `new Function()` and register via `VerstakPluginRegister`
- Updated E2E tests: Files no longer expected in global sidebar
- Documented: sidebarItems are global shell navigation, not workspace template tabs
## Verification
- `go test ./...` — PASS
- `go vet ./...` — PASS
- `npm run build` — PASS
- `npm run test:e2e` — 34/34 PASS
- Official plugins — 3 plugins built, bundle execution check passes
- SDK — 11/11 tests pass
## Deferred
- Notes plugin, rename/move/trash UI, drag-and-drop, context menu,
watcher/inotify, sync, external open, binary streaming, sidecar/security,
workspace template host (Milestone 6d2)
+426
View File
@@ -0,0 +1,426 @@
# Notes/Files Plugin Architecture Plan
This document locks the Notes/Files/Open architecture for the next milestones.
Files Core Service was implemented in Milestone 6a; this document still does not
start Notes UI, Notes plugin, Files UI plugin, editor plugin, watcher, sync, or
binary streaming.
## Current Readiness
The platform is ready for bundled plugin UI experiments. Files Core is available
as a safe vault-scoped text file API. Notes, Files UI, and editor/viewer UI still
need plugin-level implementations and host surfaces before real product use.
Already available:
- Plugin discovery, lifecycle, settings, capabilities, bundled commands, and
bundled frontend events.
- Workspace lifecycle APIs for top-level physical folders under the vault root.
- Plugin-owned internal storage directories:
`.verstak/plugin-data/<pluginId>`, `.verstak/plugin-settings/<pluginId>`, and
`.verstak/plugin-cache/<pluginId>`.
- Contribution registry entries for `fileActions`, `noteActions`,
`contextMenuEntries`, `searchProviders`, `activityProviders`, and
`statusBarItems`.
Not available yet:
- Notes plugin/API as a semantic view over Markdown files.
- Files UI plugin.
- Editor/viewer plugin.
- Open/edit resource routing and provider selection.
- UI hosts for file actions, note actions, context menus, search providers,
activity providers, or status bar items.
- Watcher/indexer for external filesystem changes.
- Real plugin isolation. Current permission checks are contract/policy checks,
not a security boundary for bundled frontend JavaScript.
## Canonical Notes Model
Notes are ordinary human-readable Markdown files inside the vault. They must be
visible and editable outside Verstak.
Canonical rules:
- Notes are `.md` files, not opaque records.
- Canonical folder name is exactly `Notes`.
- Do not create lowercase `notes`.
- Do not store user notes in `.verstak/notes/`.
- Do not store user notes in `.verstak/plugin-data/verstak.notes/`.
- Do not use UUID-only filenames for notes.
- The note title is the source of truth.
- The filename is a normalized projection from the title.
- `RenameNote` must update both the title and the `.md` filename.
Canonical scoped paths:
- Workspace overview notes live under `<workspace>/Notes/`.
- The default overview note is `<workspace>/Notes/Overview.md`.
- `<workspace>` is the top-level physical folder name under the vault root.
- Files plugin workspace views are scoped with `workspaceRootPath`, which is the
selected top-level workspace folder name.
Visibility requirements:
- Notes UI must show notes as semantic notes.
- Files UI must show the same `.md` files as ordinary files.
- External file managers must show the same `.md` files.
- Outside Verstak, the files must remain useful as normal Markdown.
There is no canonical metadata workspace tree. Adding `note` as a workspace node
type is not part of the next milestone. The Notes service can index and manage
Markdown files inside canonical `Notes/` folders under each top-level workspace.
## Title To Filename Contract
The title is the source of truth. The filename is derived from the title when a
note is created or renamed.
Normalization rules:
- Replace spaces with `_`.
- Replace typographic dashes with `-`.
- Allow only letters, digits, `.`, `_`, and `-`.
- Append `.md` if the normalized name does not already end with `.md`.
- Reject empty normalized names.
- Preserve canonical `Notes` folder casing.
Examples:
| Title | Filename |
|---|---|
| `Overview` | `Overview.md` |
| `Meeting Notes` | `Meeting_Notes.md` |
| `Plan — Phase 1` | `Plan_-_Phase_1.md` |
| `A/B Test: Result` | `AB_Test_Result.md` |
## Collision Policy
Same-folder collisions must not be solved silently with `_2`, `_3`, or timestamp
suffixes.
`CreateNote` and `RenameNote` must return a conflict error if the normalized
target filename already exists in the target `Notes/` folder. The UI should show a
clear dialog or notification and ask the user to change the title.
Required conflict metadata:
- requested title;
- normalized filename;
- target vault-relative path;
- existing vault-relative path.
## Files Service Model
Files service is the raw vault file layer. It works with vault-relative paths and
does not understand note semantics.
Rules:
- All public Files API paths are canonical vault-relative slash paths.
- Backslashes are rejected instead of normalized.
- Absolute POSIX paths, Windows drive paths, and UNC/network paths are rejected.
- `..` traversal is rejected.
- Null bytes are rejected.
- `.verstak/` is reserved case-insensitively and hidden/forbidden by default.
- Access to `.verstak/` is allowed only through internal APIs, not through the
normal plugin Files API.
- Symlink read/write/move/trash operations are forbidden in Milestone 6a.
Metadata may report a final symlink as `type: "symlink"`.
- Writes must be atomic: write a temp file in the same directory, close it, then
rename.
- Delete must follow the trash policy until permanent delete is explicitly
designed.
- Trash metadata records `originalPath`, `deletedAt`, `originalType`, `trashId`,
and `basename` for future restore work. Restore is deferred.
- Binary files are deferred for write/streaming APIs. Milestone 6a lists binary
metadata but read/write is UTF-8 text only with a 2 MB read limit.
Minimum Files methods:
- `ListVaultFiles(relativeDir)`.
- `GetVaultFileMetadata(relativePath)`.
- `ReadVaultTextFile(relativePath)`.
- `WriteVaultTextFile(relativePath, content, options)`.
- `CreateVaultFolder(relativePath)`.
- `MoveVaultPath(fromRelativePath, toRelativePath)`.
- `TrashVaultPath(relativePath)`.
Milestone 6a status: implemented in `internal/core/files` and exposed to bundled
plugins as `api.files.list`, `api.files.metadata`, `api.files.readText`,
`api.files.writeText`, `api.files.createFolder`, `api.files.move`, and
`api.files.trash`. It is still text-only for reads/writes and has no watcher,
binary streaming, external editor integration, or Files UI plugin.
Later Files methods:
- `WatchVaultFiles(scope)` once watcher/event delivery is ready.
- `ReadVaultFileBytes` / `WriteVaultFileBytes` for binary files.
- `OpenExternal(relativePath)` with explicit permission and UX confirmation.
- `RevealInFileManager(relativePath)`.
## Notes Service Model
Notes API is a semantic layer over Markdown files managed by the Files/path
policy.
Rules:
- A note physically is a `.md` file.
- Notes API and Files API must not create two sources of truth.
- Notes API reads/writes the same files that Files API lists.
- The note title is the semantic source of truth and is projected to the filename.
If frontmatter or a first-heading convention is introduced later, `RenameNote`
must keep that visible title metadata and the filename synchronized.
- Other note metadata should be derived from the file path and filesystem
metadata, or from Markdown frontmatter if a future milestone introduces it.
- If a note is changed through Files API or an external editor, the future
watcher/indexer must observe it.
- Until watcher/indexer exists, external changes require reload/rescan.
Minimum Notes methods:
- `ListNotes(scope)` where `scope` resolves to a canonical `Notes/` folder.
- `GetNote(notePath)`.
- `CreateNote(scope, title, initialBody)`.
- `RenameNote(notePath, newTitle)`.
- `UpdateNoteBody(notePath, body)`.
- `TrashNote(notePath)`.
Later Notes methods:
- `SearchNotes(query, filters)`.
- `ListBacklinks(notePath)`.
- `ResolveNoteLinks(notePath)`.
- `ExportNote(notePath, format)`.
## Notes Vs Files Relationship
Files owns safe raw vault file access. Notes owns note semantics.
The same physical note must be visible through both APIs:
- Files sees `Project/Notes/Overview.md` as a file.
- Notes sees `Project/Notes/Overview.md` as a note with title `Overview`.
There must be no duplicate note content stored in plugin settings, plugin data,
or a separate `.verstak` note database. Indexes and caches may exist later, but
they must be rebuildable from the canonical Markdown files.
## Capabilities And Permissions
Existing permissions that remain useful:
- `vault.read` for existing vault-level read policy.
- `vault.write` for existing vault-level write policy.
- `vault.watch` for future watcher support.
- `ui.register` for sidebar/views/settings contributions.
- `commands.register` for bundled command handlers.
- `events.publish` and `events.subscribe` for frontend/backend event flows.
New permissions required before real Notes/Files plugins ship:
- `files.read`
- `files.write`
- `files.delete`
- `workbench.open`
Future Notes semantic permissions are deferred until a real Notes plugin/API
ships:
- `notes.read`
- `notes.write`
- `notes.delete`
Those permissions are still policy checks until sidecar/sandbox work provides a
real isolation boundary.
Recommended capabilities:
- `verstak/core/files/v1`
- `verstak/core/notes/v1`
- `verstak/files/v1` provided by the official Files plugin.
- `verstak/notes/v1` provided by the official Notes plugin.
## Frontend Components And Extension Points
Contribution points already registered but not fully hosted:
- `fileActions`
- `noteActions`
- `contextMenuEntries`
- `searchProviders`
- `activityProviders`
- `statusBarItems`
UI work needed:
- Files view host with tree/list modes and selection state.
- Notes view host with note list, open/edit entry points, and preview/details
region.
- Context menu host that merges core actions with plugin contributions.
- Command palette host for contributed commands.
- Search provider host with cancellation/debounce and result ownership.
- Status bar host for lightweight plugin state.
- Selection/event model for active file, active note, and active workspace node.
The first implementation should host only the contribution points needed by the
official Notes and Files plugins.
## Open/Edit Resource Model
Files and Notes must not embed a concrete editor or viewer. They request that the
Workbench open or edit a resource. The Workbench/provider registry selects the
plugin component.
Required model:
- Files plugin lists files and calls open/edit for a vault file.
- Notes plugin presents Markdown files under canonical `Notes/` folders and calls
open/edit for the same vault file with notes context.
- `.md` or `.markdown` inside a canonical `Notes/` folder opens in markdown mode
with notes context.
- `.md` or `.markdown` outside `Notes/` opens in generic markdown mode.
- Plain text opens in `generic-text` mode.
- The same editor provider may support text, generic markdown, and notes-context
markdown.
- User preferences can select another provider for text, markdown, and
notes-context markdown.
- Community editor plugins can replace the default editor through the same
provider registry.
- Core desktop owns registry, routing, Workbench host slot/tab, and preferences.
- Core desktop does not own concrete Files UI, Notes UI, Markdown editor, or file
preview UI.
Minimal contribution extension:
```json
{
"contributes": {
"openProviders": [
{
"id": "verstak.platform-test.markdown-diagnostic",
"title": "Platform Test Markdown Diagnostic",
"priority": 100,
"component": "MarkdownDiagnosticProvider",
"supports": [
{
"kind": "vault-file",
"extensions": [".md", ".markdown"],
"contexts": ["generic-markdown", "notes-markdown"]
},
{
"kind": "vault-file",
"mime": ["text/plain"],
"extensions": [".txt", ".log", ".json", ".yaml", ".yml", ".toml", ".ini", ".conf"],
"contexts": ["generic-text"]
}
]
}
]
}
}
```
Open request shape:
```ts
type OpenResourceRequest = {
kind: "vault-file";
path: string;
mode?: "view" | "edit";
mime?: string;
extension?: string;
context?: {
sourcePluginId?: string;
sourceView?: "files" | "notes" | string;
isInsideNotesFolder?: boolean;
notesScopePath?: string;
notesMode?: boolean;
};
};
```
Provider selection rules:
1. Match resource kind.
2. Match extension and/or mime.
3. Prefer providers that explicitly support the request context.
4. Apply user preference for text, markdown, or notes-context markdown when the
preferred provider is enabled and still supports the resource.
5. Otherwise choose highest priority.
6. Break ties deterministically by plugin id, then provider id.
7. If no provider matches, return a Workbench `no-provider` state rather than
hardcoding a core editor.
8. If a preferred provider plugin is disabled or unavailable, fall back to the
deterministic default and surface a non-blocking preference warning later.
Initial preferences:
- `defaultTextEditorProvider`;
- `defaultMarkdownEditorProvider`;
- `defaultNotesMarkdownEditorProvider`.
Per-extension overrides are deferred.
## Migration Risks
- Adding `note` as a workspace node type is a workspace schema migration and is
explicitly out of scope for the next milestone.
- The canonical path rules must be locked before writing real files into user
vaults.
- Rename behavior can break external links if link rewriting is not designed.
- Case/folder path ownership must be clear before scoped `Notes/` folders are
created.
- Raw Files API can expose `.verstak` internals unless reserved paths are blocked.
- File writes need atomic behavior and conflict handling before sync.
- Large/binary files require streaming or byte APIs; text APIs are not enough.
- External editor changes require reload/rescan until watcher/indexer exists.
- Bundled frontend plugins are trusted/cooperative and not isolated from the
shared JS context.
## Test Plan
Backend Go tests:
- Vault-relative path normalization and traversal rejection.
- Reserved `.verstak` path behavior.
- Files list/read/write/mkdir/move/trash with vault closed/open states.
- Atomic text writes and temp-file cleanup on failure.
- Notes `Notes/` folder casing and no lowercase `notes`.
- Title to filename normalization.
- `CreateNote` and `RenameNote` conflict errors without silent suffixes.
- Notes and Files read the same physical `.md` file.
- Permission checks for `files.*`, `notes.*`, `vault.read`, and `vault.write`.
Frontend/unit tests:
- SDK and plugin API shape for Files and Notes draft methods.
- Readable errors for missing permissions, closed vault, missing file, missing
note, reserved path, and collision.
- Contribution host rendering for note/file actions and context menus.
Playwright e2e tests:
- Create a text file, reload, and verify it is visible in Files.
- Create a note, reload, and verify it is visible in both Notes and Files.
- Rename a note and verify title plus filename change together.
- Attempt same-folder collision and verify user-facing conflict handling.
- External file change requires reload/rescan until watcher exists.
## Implementation Order
1. Define canonical vault-relative path rules and reserved path policy.
2. Implement Files core service with safe list/read/write/mkdir/move/trash.
3. Define open/edit resource request, provider contribution shape, and provider
selection rules.
4. Extend contribution registry/types with `openProviders`.
5. Add Workbench open/edit routing and a provider-hosted tab/slot.
6. Add preferences for text, markdown, and notes-context markdown provider ids.
7. Use `platform-test` diagnostic provider to verify routing; real default
editor plugin is deferred to Milestone 6c.
8. Build official Files plugin that calls open/edit resource.
9. Build official Notes plugin as a contextual view over Markdown files in
canonical `Notes/` folders.
10. Implement future Notes semantic helpers only as a facade over Markdown files,
never as a second source of truth.
+588 -22
View File
@@ -6,19 +6,34 @@
### Discovery Directories
Plugins ищутся в двух директориях (порядок приоритета):
Plugins ищутся через единый resolver `internal/core/plugin.ResolveDiscoveryDirs`.
Порядок приоритета:
| Путь | Назначение | Коммитится |
|---|---|---|
| `VERSTAK_PLUGIN_DIR` | Override для тестов/dev; можно передать несколько путей через OS path separator | Нет |
| `./plugins/` | Dev plugins относительно текущей рабочей директории/repo | Нет (`.gitignore`) |
| `<binary-dir>/plugins/` | Packaged plugins рядом с desktop binary | Зависит от дистрибутива |
| `~/.config/verstak/plugins/` | User-installed plugins | Нет (user home) |
| `./plugins/` | Bundled / dev plugins | Нет (`.gitignore`) |
Resolver нормализует пути, удаляет дубликаты и передает discovery только канонический
список директорий. Отсутствующие директории просто пропускаются на этапе scanning.
Discovery сканирует **все** resolved директории в указанном порядке. Если один и тот
же `plugin.id` найден несколько раз, применяется правило **first plugin wins**:
первый найденный plugin загружается, последующие plugins с тем же id пропускаются.
Конфликт логируется и возвращается как discovery warning с двумя путями: путь
пропущенного duplicate и путь уже загруженного winner.
### ./plugins/ как Dev/Install Target
Директория `./plugins/` в корне `verstak-desktop` используется как:
Директория `./plugins/` от текущей рабочей директории используется как:
- **Dev target** — `install-dev-plugins.sh` коприрует сюда собранные пакеты из `verstak-official-plugins/dist/`.
- **Bundled plugins** — при дистрибутиве core может поставлять плагины здесь.
- **Local override** — при запуске desktop из repo позволяет быстро проверять packaged bundles.
В packaged-сборке bundled plugins должны лежать в `plugins/` рядом с executable.
Для тестов и локальных сценариев можно задать `VERSTAK_PLUGIN_DIR=/path/to/plugins`.
Директория **не коммитится**. Каждый разработчик устанавливает плагины через `install-dev-plugins.sh`.
@@ -83,6 +98,8 @@ coreCaps := []string{
"verstak/core/contribution-registry/v1",
"verstak/core/permissions/v1",
"verstak/core/events/v1",
"verstak/core/files/v1",
"verstak/core/workbench/v1",
}
capRegistry.Register("verstak-desktop", coreCaps)
@@ -129,7 +146,7 @@ foreach plugin:
|---|---|---|
| `description` | string | Описание плагина |
| `source` | string | `"official"`, `"local"`, `"third-party"` |
| `icon` | string | Иконка (emoji или имя) |
| `icon` | string | Имя иконки из встроенного Lucide-набора shell |
| `requires` | string[] | Жёзкие capability-зависимости |
| `optionalRequires` | string[] | Мягкие capability-зависимости |
| `frontend` | object | `{ "entry": "path/to/index.js", "style": "path/to/style.css" }` |
@@ -150,7 +167,7 @@ foreach plugin:
"provides": ["verstak/platform-test/v1"],
"requires": ["verstak/core/plugin-manager/v1"],
"optionalRequires": ["verstak/core/vault/v1", "verstak/core/sync/v1"],
"permissions": ["vault.read", "events.publish", "ui.register"],
"permissions": ["vault.read", "events.publish", "ui.register", "workbench.open"],
"frontend": { "entry": "frontend/dist/index.js" },
"contributes": {
"views": [{ "id": "my.view", "title": "My View", "component": "MyPanel" }],
@@ -161,30 +178,379 @@ foreach plugin:
## Contribution Points
Плагины могут регистрировать UI contributions через поле `contributes`:
Плагины могут регистрировать UI contributions через поле `contributes` в `plugin.json`.
| Тип | Описание |
Icon fields use shell icon names rendered through the bundled Lucide SVG wrapper. Plugins must not rely on emoji, Unicode pictographs, or system icon fonts; if a plugin needs its own icon font, it must bundle the font and reference it from its own frontend bundle.
### Реализованные contribution points (Milestone 5a)
| Тип | Поле manifest | Описание | Frontend host |
|---|---|---|---|
| Боковая панель | `sidebarItems` | Элементы в sidebar слева | ✅ Sidebar.svelte (из ContributionRegistry) |
| Основные панели | `views` | Полноценные страницы/панели | ✅ ViewContainer.svelte (PluginBundleHost — real frontend bundle) |
| Панели настроек | `settingsPanels` | Панели в Plugin Manager | ✅ PluginManager.svelte (кнопка Settings, открывает modal) |
| Команды | `commands` | Команды для command palette | ✅ ContributionRegistry (UI command palette не реализован) |
| Open/edit providers | `openProviders` | Провайдеры viewer/editor для Workbench routing | ✅ ContributionRegistry + минимальный Workbench host |
### Планируемые contribution points
| Тип | Поле manifest | Статус |
|---|---|---|
| Действия над файлами | `fileActions` | Registry готов, UI не реализован |
| Действия над заметками | `noteActions` | Registry готов, UI не реализован |
| Контекстное меню | `contextMenuEntries` | Registry готов, UI не реализован |
| Провайдеры поиска | `searchProviders` | Registry готов, UI не реализован |
| Провайдеры активности | `activityProviders` | Registry готов, UI не реализован |
| Элементы status bar | `statusBarItems` | Registry готов, UI не реализован |
### Структура contribution points в manifest
```json
{
"contributes": {
"sidebarItems": [
{
"id": "mypanel.sidebar",
"title": "My Panel",
"icon": "puzzle",
"view": "mypanel.view",
"position": 100
}
],
"views": [
{
"id": "mypanel.view",
"title": "My Panel View",
"icon": "puzzle",
"component": "MyPanelComponent"
}
],
"settingsPanels": [
{
"id": "mypanel.settings",
"title": "My Settings",
"component": "MySettingsPanel"
}
],
"commands": [
{
"id": "mypanel.cmd",
"title": "Do Something",
"icon": "gear",
"handler": "doSomething"
}
]
}
}
```
### Contribution lifecycle
1. Plugin `Register(pluginID, contributions)` — все contributions регистрируются
2. `Unregister(pluginID)` — удаляет все contributions указанного plugin
3. Reload: `Unregister → Register` (предотвращает дублирование)
4. Disable plugin → `Unregister` (contributions исчезают из UI)
5. Enable plugin → `Register` при следующем Reload
6. Registry idempotent: Register удаляет старые записи перед добавлением новых
## Bundled Frontend Plugin API
Bundled frontend plugins получают API от host через `createPluginAPI(pluginId)`.
Обычный plugin code не передает `pluginId` в методы API: scope закрепляется в
host при mount компонента. Это защищает нормальный cooperative path от случайного
доступа к чужому namespace.
Текущая модель безопасности честно ограничена:
- bundled frontend plugins исполняются в общем JS-контексте приложения;
- проверки permissions/capabilities сейчас являются contract/policy checks, а не
полноценной security boundary;
- malicious JS в общем контексте теоретически может обойти frontend wrapper;
- настоящая изоляция будет только после отдельного sidecar/sandbox milestone.
## Workbench Open/Edit Routing
Files and Notes plugins do not import or embed a concrete editor plugin. They
call `api.workbench.openResource(request)` or `api.workbench.editResource(request)`.
The backend requires the source plugin to be enabled, loaded/degraded, and to
declare `workbench.open`. This is a policy/contract check, not a security
boundary.
`OpenResourceRequest`:
```ts
type OpenResourceRequest = {
kind: "vault-file";
path: string;
mode?: "view" | "edit";
mime?: string;
extension?: string;
context?: {
sourcePluginId?: string;
sourceView?: "files" | "notes" | string;
isInsideNotesFolder?: boolean;
notesScopePath?: string;
notesMode?: boolean;
};
};
```
Routing contexts are fixed as `generic-text`, `generic-markdown`, and
`notes-markdown`. `.md`/`.markdown` inside canonical `Notes/` folders uses
`notes-markdown`; markdown outside Notes uses `generic-markdown`; ordinary text
uses `generic-text`. Milestone 6b derives context from request fields; future
Files/Notes integrations can centralize canonical Notes folder auto-detection in
the Workbench helper.
`contributes.openProviders` extends the existing contribution registry:
```json
{
"contributes": {
"openProviders": [
{
"id": "verstak.platform-test.markdown-diagnostic",
"title": "Platform Test Markdown Diagnostic",
"priority": 100,
"component": "MarkdownDiagnosticProvider",
"supports": [
{
"kind": "vault-file",
"extensions": [".md", ".markdown"],
"contexts": ["generic-markdown", "notes-markdown"]
},
{
"kind": "vault-file",
"mime": ["text/plain"],
"extensions": [".txt", ".log"],
"contexts": ["generic-text"]
}
]
}
]
}
}
```
Selection uses enabled loaded/degraded provider plugins, resource kind,
extension/mime, context, user preference, priority, then deterministic
`pluginId/providerId` fallback. If nothing matches, Workbench returns
`status: "no-provider"` and shows the fallback view instead of a core editor.
Disabled/failed/missing-required-capability plugins are excluded from provider
selection at request time by `activeOpenProviders()`. Their contributions may
remain in the registry until the next `ReloadPlugins()` cycle, but they never
match during routing.
Draft app-global preferences are `defaultTextEditorProvider`,
`defaultMarkdownEditorProvider`, and `defaultNotesMarkdownEditorProvider`.
Vault-scoped and per-extension overrides are deferred.
### Default Editor Plugin
The official `verstak.default-editor` plugin (`verstak-official-plugins/plugins/default-editor/`)
provides openProviders for text, generic markdown, and notes-context markdown files.
It uses `api.files.readText` / `api.files.writeText` for file I/O and mounts through
the standard `PluginBundleHost` / provider host mechanism. Core does not import or
reference this plugin directly.
Provider plugins may have no sidebar item — openProviders are contribution points
for workbench routing, not navigation. Plugin Manager displays openProviders in the
contributions summary.
### API methods
`settings`
- `settings.read()` — читает весь settings namespace текущего plugin.
- `settings.read(key)` — читает один ключ.
- `settings.write(key, value)` — обновляет один ключ и пишет namespace обратно.
- `settings.writeAll(settings)` — заменяет settings namespace.
- Backend требует plugin exists, enabled, status `loaded`/`degraded` и permission
`storage.namespace`.
`capabilities`
- `capabilities.list()` — возвращает текущий capability registry.
- `capabilities.get(name)` — возвращает `{ available, name, pluginId, status }`.
- `capabilities.has(name)` — boolean wrapper над `get`.
- Backend требует, чтобы plugin был enabled/loaded и декларировал dependency на
`verstak/core/capability-registry/v1` в `requires` или `optionalRequires`.
`commands`
- `commands.register(commandId, handler)` — регистрирует bundled frontend handler.
Возвращает `Promise<unsubscribe>`.
- `commands.execute(commandId, args)` — backend сначала проверяет plugin status,
permission `commands.register` и что command объявлен в `contributes.commands`
именно этим plugin. Затем frontend registry вызывает зарегистрированный handler.
- Если command объявлен в manifest, но handler не зарегистрирован, API возвращает
понятную ошибку `declared-but-unhandled`.
- Handler registry очищается при component unmount, reload/disable flow и
`api.dispose()`.
`events`
- `events.subscribe(eventName, handler)` — frontend-local subscription с backend
validation permission `events.subscribe`. Возвращает `Promise<unsubscribe>`.
- `events.publish(eventName, payload)` — backend проверяет `events.publish`, затем
событие dispatch'ится в bundled frontend event bus.
- Handler получает envelope `{ name, pluginId, payload, timestamp }`.
- Subscriptions очищаются при component unmount, reload/disable flow и
`api.dispose()`.
`files`
- `files.list(relativeDir)` — list directory using a vault-relative path.
- `files.metadata(relativePath)` — returns file/folder/symlink metadata.
- `files.readText(relativePath)` — reads a UTF-8 regular file, with a size limit.
- `files.writeText(relativePath, content, options)` — atomically writes text via
temp-file-and-rename. `options.createIfMissing` and `options.overwrite`
control conflicts.
- `files.createFolder(relativePath)` — creates one folder when the parent exists.
- `files.move(from, to, options)` — moves a file or folder; rejects moving a
folder into itself and conflicts unless `options.overwrite` is true.
- `files.trash(relativePath)` — moves a file/folder into internal
`.verstak/trash/files/<trashId>/...` and returns trash metadata.
- Backend requires plugin exists, enabled, status `loaded`/`degraded`, open
vault, and `files.read`, `files.write`, or `files.delete`.
- All paths are canonical vault-relative slash paths. Backslashes, POSIX
absolute paths, Windows drive paths, UNC/network paths, `..`, null bytes,
symlink traversal, and public access to `.verstak/` are rejected.
- `.verstak` is reserved case-insensitively: `.verstak`, `.Verstak`, and any
first path segment with that spelling are internal-only.
- `files.metadata` may report a final symlink as `type: "symlink"`, but
`files.list` through a symlink directory and all read/write/move/trash
operations through symlinks are forbidden in Milestone 6a.
- Files API is text-only for read/write in Milestone 6a. `readText` is limited
to UTF-8 regular files up to 2 MB. Binary streaming, watcher, restore,
external editor integration, and Files UI plugin are deferred.
`dispose`
- `dispose()` вызывается host'ом при cleanup. Plugin code обычно не вызывает его
напрямую. Он удаляет зарегистрированные command handlers и event subscriptions.
### Runtime boundaries
| Layer | Current status |
|---|---|
| `views` | Панели/страницы (component — Svelte) |
| `commands` | Команды command palette |
| `settingsPanels` | Панели в Settings |
| `sidebarItems` | Элементы боковой панели |
| `fileActions` | Действия над файлами |
| `noteActions` | Действия над заметками |
| `contextMenuEntries` | Пункты контекстного меню |
| `searchProviders` | Провайдеры поиска |
| `activityProviders` | Провайдеры активности |
| `statusBarItems` | Элементы status bar |
| Bundled frontend runtime | Functional for settings, capabilities, commands, events and text Files API |
| Backend validation | Checks plugin exists, enabled/loaded state, permissions and declarations |
| Security boundary | Not implemented; bundled plugins share the desktop frontend JS context |
| Sidecar/RPC/sandbox | Not implemented |
### Error boundary
- Ошибка в plugin view/settings placeholder не роняет shell
- ViewContainer показывает "⚠️ Plugin UI failed" fallback
- Error канал: `console.error` + видимый fallback в UI
## Frontend Bundle Contract
### Регистрация компонентов
Плагин регистрирует frontend компоненты через глобальную функцию `window.VerstakPluginRegister`:
```javascript
window.VerstakPluginRegister('plugin.id', {
components: {
'ComponentName': {
mount: function(containerEl, props, api) {
// containerEl — div, созданный PluginBundleHost
// api — ограниченный VerstakPluginAPI
containerEl.innerHTML = '<h1>Hello from plugin!</h1>';
},
unmount: function(containerEl) {
// Очистка при смене view
containerEl.innerHTML = '';
}
}
}
});
```
### VerstakPluginAPI
API объект передаётся в `mount()` и содержит plugin-scoped методы текущего
bundled runtime. Это реальный runtime contract для cooperative bundled plugins,
но не sandbox/security boundary.
| Свойство | Статус | Описание |
|---|---|---|
| `api.pluginId` | ✅ Работает | ID плагина |
| `api.settings.read(key?)` | ✅ Работает | Читает plugin-scoped settings через backend bridge |
| `api.settings.write(key, value)` | ✅ Работает | Пишет один settings key через backend bridge |
| `api.settings.writeAll(settings)` | ✅ Работает | Заменяет settings namespace плагина |
| `api.capabilities.list()` | ✅ Работает | Возвращает capability registry |
| `api.capabilities.get(id)` | ✅ Работает | Возвращает capability entry/status |
| `api.capabilities.has(id)` | ✅ Работает | Boolean wrapper над `get` |
| `api.commands.register(id, handler)` | ✅ Работает | Регистрирует bundled frontend handler для объявленной command |
| `api.commands.execute(id, args)` | ✅ Работает | Валидирует declaration/permission/backend state и вызывает bundled handler |
| `api.events.publish(type, payload)` | ✅ Работает | Валидирует permission и публикует во frontend event bus |
| `api.events.subscribe(type, handler)` | ✅ Работает | Валидирует permission и подписывает handler на frontend event bus |
| `api.files.list(relativeDir)` | ✅ Работает | Список vault-relative директории, `.verstak` скрыта |
| `api.files.metadata(relativePath)` | ✅ Работает | Metadata для файла/папки/symlink без чтения содержимого |
| `api.files.readText(relativePath)` | ✅ Работает | Читает UTF-8 regular file до 2 MB |
| `api.files.writeText(relativePath, content, options)` | ✅ Работает | Atomic text write с явным create/overwrite policy |
| `api.files.createFolder(relativePath)` | ✅ Работает | Создаёт vault-relative folder |
| `api.files.move(from, to, options)` | ✅ Работает | Move file/folder с conflict и path-policy checks |
| `api.files.trash(relativePath)` | ✅ Работает | Перемещает в internal trash, permanent delete нет |
| `api.workbench.openResource(request)` | ✅ Работает | Routes vault resources to `openProviders` |
| `api.workbench.editResource(request)` | ✅ Работает | Same routing, forcing `mode: "edit"` |
| `api.dispose()` | ✅ Работает | Очищает command handlers и event subscriptions текущего API instance |
Ограничения:
- permissions/capabilities checks являются contract/policy checks;
- bundled frontend plugins исполняются в общем JS-контексте;
- malicious JS не изолирован;
- sidecar process lifecycle, RPC transport и sandbox enforcement ещё не
реализованы.
- Files paths are slash-only vault-relative contract paths; backslashes,
Windows absolute paths, UNC paths, `.verstak` variants, traversal and symlink
operations are rejected by backend policy checks.
### Загрузка бандла
1. `PluginBundleHost` получает pluginId и componentId
2. Вызывает `App.GetPluginFrontendInfo(pluginId)` — получает entry/style/rootPath
3. Вызывает `App.GetPluginAssetContent(pluginId, entry)` — получает JS контент
4. Выполняет контент через `new Function(content)` — bundle вызывает `VerstakPluginRegister`
5. Находит компонент по componentId и вызывает `mount(container, props, api)`
6. При смене view — вызывает `unmount(container)` для старого компонента
### Безопасность asset path
| Правило | Проверка |
|---|---|
| Нет абсолютных путей | Пути, начинающиеся с `/` или `\`, отклоняются |
| Нет path traversal | Пути, содержащие `..`, отклоняются |
| Нет выхода за root | После `filepath.Join` проверяется, что путь внутри plugin root |
| Только существующие файлы | `os.ReadFile` возвращает ошибку если файл не существует |
### manifest frontend config
```json
{
"frontend": {
"entry": "frontend/dist/index.js",
"style": "frontend/style.css"
}
}
```
## Reload
`ReloadPlugins()` в `internal/api/app.go` позволяет перезагрузить plugins без перезапуска приложения:
1. Unregister all capabilities (кроме core).
2. Re-register core capabilities.
1. Unregister all non-core capabilities.
2. Re-register core capabilities + vault + workspace (если открыт).
3. Re-scan discovery directories.
4. Re-run capability resolution.
5. Re-register contributions.
4. For each plugin: re-run capability resolution.
5. **Unregister contributions** before re-registering (предотвращает дубли).
6. Register contributions for loaded/degraded plugins (disabled/failed — не регистрируются).
7. Update plugins list.
Frontend вызывает это при нажатии "Reload" в Plugin Manager.
@@ -341,3 +707,203 @@ Vault plugin state хранится **внутри vault** в `.verstak/plugins.
- Plugin Manager показывает "Missing installed plugin"
- Auto-install пока НЕ делается
- Показывается подсказка: "Install official plugin package"
## UI Layout
```
┌─────────────────────────────────────────────────────┐
│ App.svelte │
│ ┌──────────┬──────────────────────────────────────┐ │
│ │ Sidebar │ Content area │ │
│ │ │ │ │
│ │ Verstak │ PluginManager | ViewContainer │ │
│ │ │ │ │
│ │ [icon] │ (padding: 1.5rem) │ │
│ │ Plugin │ │ │
│ │ Manager│ │ │
│ │ │ │ │
│ │ Plugins │ │ │
│ │ [icon] item1 │ │
│ │ [icon] item2 │ │
│ │ │ │ │
│ │ ● Vault │ │ │
│ └──────────┴──────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
```
- **Sidebar** (220px): навигация (Plugin Manager), plugin sidebar items, vault status
- **Content**: Plugin Manager или ViewContainer в зависимости от выбранного view
- **Vault Selection**: полноэкранный экран, показывается когда vault не открыт
## Milestone 4b — UI Completion (2026-06-17)
Сделано:
- VaultSelection.svelte: исправлен flow (CreateVault → OpenVault → SetCurrentVault)
- Sidebar.svelte: полная навигация с отступами, plugin sidebar items, vault status
- App.svelte: обработка `verstak:nav` событий, global reset стилей
- PluginManager.svelte: исправлены отступы header
Проверки:
- `go test ./...` — 52 PASS
- `./scripts/check.sh` — ✅
- `./scripts/smoke-platform.sh` — ✅ (enable/disable/plugins.json)
- `./scripts/build.sh` — ✅
## Workspace Core Capability
Workspace — это физическая папка верхнего уровня внутри vault root. Filesystem
является source of truth для списка workspaces.
Пример:
```
<vault>/
Workspace/
Notes/
Overview.md
Project/
ClientA/
.verstak/
```
Нет единого `<vault>/Workspace/` контейнера для всех workspaces. Папка
`Workspace/` может быть обычным workspace, но `Project/` и `ClientA/` являются
такими же workspace на том же уровне.
### Хранение
Workspace existence/list хранится только в filesystem:
- `ListWorkspaces()` читает top-level directories из vault root.
- `.verstak`, reserved/internal directories, top-level files и symlinks не
считаются workspaces.
- `.verstak/workspace*.json` не является source of truth для списка workspaces.
- Нет persisted workspace path mapping и нет virtual workspace tree, которое
мапится на произвольные папки.
`.verstak` может хранить только metadata, которая не заменяет filesystem:
- UI state: selected workspace, expanded folders, sort/pin state, preferences.
- Semantic snapshot: applied template snapshot, enabled feature areas, folder
conventions.
Template snapshot копируется в metadata при создании workspace. Workspace
identity при этом остаётся именем top-level folder; `metadata.workspaceName`
является presentation/snapshot field, not canonical identity. Если сохранённое
значение расходится с именем папки, runtime возвращает canonical `workspaceName`
равным имени папки без filesystem side effects.
```json
{
"workspaceName": "Project",
"createdFromTemplate": {
"templateId": "client-project",
"templateName": "Client Project",
"templateVersion": 1,
"appliedAt": "2026-06-19T12:00:00Z"
},
"features": {
"notes": true,
"files": true,
"secrets": true,
"activity": false
},
"folders": {
"notes": "Notes",
"files": "Files",
"secrets": "Secrets"
}
}
```
Если original template удалён или изменён позже, существующий workspace
открывается по сохранённому snapshot и не мутирует автоматически. Template
update/migration может быть только явной future feature. Если metadata
отсутствует, workspace открывается как generic workspace минимум с `files: true`.
### API
- `ListWorkspaces()` — список top-level physical folders.
- `CreateWorkspace(name, templateId?)` — создать `<vault>/<name>/`, применить
template один раз, сохранить snapshot metadata.
- `RenameWorkspace(oldName, newName)` — физически переименовать top-level folder
и обновить metadata key/name.
- `TrashWorkspace(name)` — перенести весь top-level workspace folder в internal
trash policy.
- `GetWorkspaceMetadata(name)` — прочитать metadata или вернуть generic fallback.
- `UpdateWorkspaceMetadata(name, patch)` — обновить metadata без влияния на
существование workspace.
Deprecated compatibility APIs:
- `GetWorkspaceTree()` — flat view, derived from top-level folders. Не дерево.
- `CreateWorkspaceNode(...)` — wrapper over `CreateWorkspace`.
- `RenameWorkspaceNode(...)` — wrapper over `RenameWorkspace`.
- `ArchiveWorkspaceNode(...)` — wrapper over `TrashWorkspace`.
- `MoveWorkspaceNode(...)` — unsupported; old nested/mapped moves are rejected.
- `GetCurrentWorkspaceNode()` / `SetCurrentWorkspaceNode(...)` — wrappers over
selected top-level workspace UI state.
Эти методы существуют только для постепенного frontend/Wails cleanup. Они не
должны создавать или сохранять nested workspace tree и не должны восстанавливать
`WorkspaceNode.path` mapping.
### Capability
`verstak/core/workspace/v1` — регистрируется только когда vault открыт и workspace инициализирован.
### Правила
- Workspace name — один safe folder name, не path.
- Reject: empty, slash, backslash, absolute-looking paths, `..`, null byte,
`.verstak`, reserved/internal names, symlink workspaces, conflicts.
- WorkspaceItems получают `workspaceRootPath`, равный имени top-level папки
(`Project`, `ClientA`, etc). Files plugin показывает именно эту папку.
- Files API остаётся raw vault-relative API: `Project/Notes/Overview.md`,
`Project/docs/file.md`, `Test/readme.md`.
- Notes are ordinary Markdown files under `<workspace>/Notes/`; нет
`.verstak/notes`, UUID note storage или второго source of truth для note
content.
### Lifecycle Events
**Planned (not yet implemented in runtime):**
- `workspace.node.created`
- `workspace.node.renamed`
- `workspace.node.moved`
- `workspace.node.archived`
- `workspace.node.selected`
- `workspace.error`
### UI
Workspace list в sidebar:
- Flat list of top-level workspace folders.
- Create workspace, rename workspace, trash workspace.
- Selection is stored as selected workspace name.
- No expand/collapse workspace tree and no case/folder node creation in core.
---
## Build Scripts
В `verstak-desktop/scripts/` есть два скрипта:
### `build.sh` — локальная детерминированная сборка
- Собирает **только** `verstak-desktop` (core platform).
- Не трогает другие репозитории.
- **Fail-fast**: любая ошибка (go vet, go test, frontend build, wails build) прерывает сборку.
- Проверяет: deps → frontend build → go mod download → go vet → go build → go test → wails build + plugin copy.
- Используется в CI и для повседневной работы над core.
### `update-and-build-all.sh` — dev helper для полной пересборки связки
- **Не для CI.** Только для разработки, когда нужно быстро собрать всё вместе.
- Шаги:
1. `git pull --ff-only` во всех 6 репозиториях
2. Сборка official plugins (frontend npm build + backend go build для каждого плагина)
3. Копирование собранных плагинов в `verstak-desktop/plugins/`
4. Запуск `build.sh` для сборки desktop
- Ошибки pull и сборки плагинов не прерывают скрипт (best-effort), но ошибка build.sh прерывает (fail-fast).
+196
View File
@@ -0,0 +1,196 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
test.describe('F: Default Editor Plugin', () => {
let consoleCollector;
test.beforeEach(async ({ page }) => {
consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
});
test.afterEach(async () => {
consoleCollector.assertNoErrors();
});
test('open .txt file shows plain text editor mode', async ({ page }) => {
await page.evaluate(async () => {
const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.platform-test', {
kind: 'vault-file',
path: 'Docs/todo.txt',
extension: '.txt',
context: { sourceView: 'files' },
});
if (err) throw new Error(err);
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
});
const editor = page.locator('[data-editor-mode="text"]');
await expect(editor).toBeVisible({ timeout: 10000 });
await expect(editor).toHaveAttribute('data-resource-path', 'Docs/todo.txt');
await expect(editor).toHaveAttribute('data-request-mode', 'view');
const textarea = editor.locator('[data-editor-textarea]');
await expect(textarea).toBeVisible();
await expect(textarea).toHaveValue('Buy groceries\nWrite tests');
});
test('editor supports markdown toolbar split save reopen and revert', async ({ page }) => {
await page.evaluate(async () => {
const err = await window.go.api.App.WriteVaultTextFile(
'verstak.platform-test',
'Project/Notes/editing.md',
'# Editing\n\nplain text',
{ createIfMissing: true, overwrite: true }
);
if (err) throw new Error(err);
const [result, openErr] = await window.go.api.App.OpenWorkbenchResource('verstak.platform-test', {
kind: 'vault-file',
path: 'Project/Notes/editing.md',
extension: '.md',
context: { sourceView: 'files', isInsideNotesFolder: true, notesMode: true },
});
if (openErr) throw new Error(openErr);
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
});
const editor = page.locator('[data-editor-mode="notes-markdown"]');
await expect(editor).toBeVisible({ timeout: 10000 });
await expect(editor.locator('[data-notes-badge]')).toBeVisible();
await editor.locator('[data-editor-mode-button="edit"]').click();
const textarea = editor.locator('[data-editor-textarea]');
await expect(textarea).toBeVisible();
await textarea.fill('plain text');
await textarea.selectText();
await editor.locator('[data-md-action="bold"]').click();
await expect(textarea).toHaveValue('**plain text**');
await editor.locator('[data-md-action="heading"]').click();
await expect(textarea).toHaveValue('# **plain text**');
await expect(editor.locator('[data-save-state]')).toContainText('Modified');
await editor.locator('[data-editor-mode-button="split"]').click();
await expect(editor.locator('[data-editor-textarea]')).toBeVisible();
await expect(editor.locator('[data-preview]')).toBeVisible();
await expect(editor.locator('[data-preview]')).toContainText('plain text');
await textarea.press(process.platform === 'darwin' ? 'Meta+S' : 'Control+S');
await expect(editor.locator('[data-save-state]')).toContainText('Saved');
await textarea.fill('discard me');
page.once('dialog', (dialog) => dialog.accept());
await editor.locator('[data-editor-action="reload"]').click();
await expect(textarea).toHaveValue('# **plain text**');
await page.evaluate(async () => {
const [result, openErr] = await window.go.api.App.OpenWorkbenchResource('verstak.platform-test', {
kind: 'vault-file',
path: 'Project/Notes/editing.md',
extension: '.md',
context: { sourceView: 'files', isInsideNotesFolder: true, notesMode: true },
});
if (openErr) throw new Error(openErr);
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
});
await expect(page.locator('[data-editor-mode="notes-markdown"] [data-preview]')).toContainText('plain text', { timeout: 10000 });
});
test('open .md file outside Notes routes to highest-priority provider', async ({ page }) => {
await page.evaluate(async () => {
const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.platform-test', {
kind: 'vault-file',
path: 'Docs/readme.md',
extension: '.md',
context: { sourceView: 'files' },
});
if (err) throw new Error(err);
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
});
const workbench = page.locator('.workbench-host');
await expect(workbench).toBeVisible({ timeout: 10000 });
const title = workbench.locator('.workbench-title');
await expect(title).toHaveText('Docs/readme.md');
});
test('open .md with notes context routes to highest-priority provider', async ({ page }) => {
await page.evaluate(async () => {
const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.platform-test', {
kind: 'vault-file',
path: 'Notes/Overview.md',
extension: '.md',
context: { sourceView: 'notes', isInsideNotesFolder: true, notesMode: true },
});
if (err) throw new Error(err);
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
});
const workbench = page.locator('.workbench-host');
await expect(workbench).toBeVisible({ timeout: 10000 });
const title = workbench.locator('.workbench-title');
await expect(title).toHaveText('Notes/Overview.md');
});
test('default-editor plugin is listed as loaded in plugin manager', async ({ page }) => {
await page.locator('.sidebar .nav-item').filter({ hasText: 'Plugin Manager' }).click();
const card = page.locator('.plugin-card').filter({ hasText: 'verstak.default-editor' });
await expect(card).toBeVisible({ timeout: 10000 });
await expect(card.locator('.status-badge')).toHaveText('loaded');
});
test('disable default-editor plugin removes its providers', async ({ page }) => {
await page.locator('.sidebar .nav-item').filter({ hasText: 'Plugin Manager' }).click();
const card = page.locator('.plugin-card').filter({ hasText: 'verstak.default-editor' });
await card.locator('button.btn-disable').click();
await expect(card.locator('button.btn-enable')).toBeVisible({ timeout: 10000 });
await page.evaluate(async () => {
const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.platform-test', {
kind: 'vault-file',
path: 'Docs/todo.txt',
extension: '.txt',
context: { sourceView: 'files' },
});
if (err) throw new Error(err);
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
});
await expect(page.locator('[data-workbench-status="no-provider"]')).toBeVisible({ timeout: 10000 });
});
test('default-editor plugin card shows openProviders contribution count', async ({ page }) => {
await page.locator('.sidebar .nav-item').filter({ hasText: 'Plugin Manager' }).click();
const card = page.locator('.plugin-card').filter({ hasText: 'verstak.default-editor' });
await expect(card).toBeVisible({ timeout: 10000 });
await expect(card.locator('.meta-row').filter({ hasText: 'Contributions:' })).toContainText('3 openProviders');
});
test('default-editor does not add sidebar item', async ({ page }) => {
const sidebarItems = page.locator('.sidebar .plugin-item');
const count = await sidebarItems.count();
const hasDefaultEditor = await page.locator('.sidebar .plugin-item').filter({ hasText: /default.editor/i }).count();
expect(hasDefaultEditor).toBe(0);
});
test('platform-test workbench buttons open files via default-editor', async ({ page }) => {
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' }).click();
await expect(page.locator('.pt-command-result')).toContainText('Command: handled', { timeout: 10000 });
await page.evaluate(async () => {
const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.platform-test', {
kind: 'vault-file',
path: 'Docs/todo.txt',
extension: '.txt',
context: { sourceView: 'files' },
});
if (err) throw new Error(err);
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
});
const editor = page.locator('[data-editor-mode="text"]');
await expect(editor).toBeVisible({ timeout: 10000 });
await expect(editor).toHaveAttribute('data-resource-path', 'Docs/todo.txt');
});
});
+304
View File
@@ -0,0 +1,304 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
test.describe('G: Files Plugin', () => {
let consoleCollector;
test.beforeEach(async ({ page }) => {
consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
});
test.afterEach(async () => {
consoleCollector.assertNoErrors();
});
test('files plugin appears in Plugin Manager as loaded', async ({ page }) => {
await page.locator('.sidebar .nav-item').filter({ hasText: 'Plugin Manager' }).click();
const card = page.locator('.plugin-card').filter({ hasText: 'verstak.files' });
await expect(card).toBeVisible({ timeout: 10000 });
await expect(card.locator('.status-badge')).toHaveText('loaded');
});
test('files plugin does not add global sidebar item', async ({ page }) => {
const sidebarItem = page.locator('.sidebar .plugin-item').filter({ hasText: 'Files' });
await expect(sidebarItem).toHaveCount(0);
});
test('workspace Files view is scoped to selected workspace folder', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await expect(page.locator('.workspace-host')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.files-item-name').filter({ hasText: 'project-only.txt' })).toBeVisible({ timeout: 10000 });
await expect(page.locator('.files-item-name').filter({ hasText: 'test-only.txt' })).toHaveCount(0);
await page.locator('.wt-label').filter({ hasText: 'Test' }).click();
await expect(page.locator('.files-item-name').filter({ hasText: 'test-only.txt' })).toBeVisible({ timeout: 10000 });
await expect(page.locator('.files-item-name').filter({ hasText: 'project-only.txt' })).toHaveCount(0);
});
test('files explorer supports create navigate rename filter sort open and trash', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
await page.locator('[data-files-action="new-folder"]').click();
await page.locator('[data-files-create-input]').fill('Daily');
await page.locator('[data-files-create-confirm]').click();
await expect(page.locator('[data-file-name="Daily"]')).toBeVisible();
await page.locator('[data-file-name="Daily"]').dblclick();
await expect(page.locator('.files-breadcrumb')).toContainText('Daily');
await page.locator('[data-files-action="new-markdown"]').click();
await page.locator('[data-files-create-input]').fill('Log.md');
await page.locator('[data-files-create-confirm]').click();
await expect(page.locator('[data-file-name="Log.md"]')).toBeVisible();
await page.locator('[data-file-name="Log.md"]').click();
await page.locator('[data-files-action="rename"]').click();
await page.locator('[data-files-rename-input]').fill('Journal.md');
await page.locator('[data-files-rename-confirm]').click();
await expect(page.locator('[data-file-name="Journal.md"]')).toBeVisible();
await expect(page.locator('[data-file-name="Log.md"]')).toHaveCount(0);
await page.locator('[data-files-filter]').fill('journ');
await expect(page.locator('[data-file-name="Journal.md"]')).toBeVisible();
await expect(page.locator('[data-file-name="project-only.txt"]')).toHaveCount(0);
await page.locator('[data-files-filter]').fill('');
await page.locator('[data-files-sort]').selectOption('modified-desc');
await expect(page.locator('[data-file-name="Journal.md"]')).toBeVisible();
await page.locator('[data-file-name="Journal.md"]').dblclick();
await expect(page.locator('[data-editor-mode="generic-markdown"]')).toBeVisible({ timeout: 10000 });
await expect(page.locator('[data-resource-path="Project/Daily/Journal.md"]')).toBeVisible();
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await expect(page.locator('.files-breadcrumb')).toContainText('Daily', { timeout: 10000 });
await expect(page.locator('[data-file-name="Journal.md"]')).toBeVisible({ timeout: 10000 });
await page.locator('[data-file-name="Journal.md"]').click();
page.once('dialog', (dialog) => dialog.accept());
await page.locator('[data-files-action="trash"]').click();
await expect(page.locator('[data-file-name="Journal.md"]')).toHaveCount(0);
await page.locator('[data-files-action="up"]').click();
await expect(page.locator('.files-breadcrumb')).not.toContainText('Daily');
});
test('files explorer uses icon controls and no row New Here action', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
for (const action of ['back', 'forward', 'up', 'refresh', 'new-folder', 'new-markdown', 'new-text', 'open', 'rename', 'trash', 'cut', 'copy', 'paste']) {
const button = page.locator(`[data-files-action="${action}"]`);
await expect(button).toHaveAttribute('title', /.+/);
await expect(button.locator('svg')).toBeVisible();
await expect(button).not.toHaveText(/\S/);
}
await expect(page.locator('.files-row-btn').filter({ hasText: 'New here' })).toHaveCount(0);
const firstRowButton = page.locator('[data-file-name="Notes"] .files-row-btn').first();
await expect(firstRowButton).toBeVisible();
await expect(firstRowButton).not.toHaveText(/\S/);
expect(await firstRowButton.evaluate((node) => node.innerHTML)).toContain('<svg');
});
test('files explorer supports empty-space context paste after cutting a folder', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
await page.locator('[data-files-action="new-folder"]').click();
await page.locator('[data-files-create-input]').fill('CutMe');
await page.locator('[data-files-create-confirm]').click();
await page.locator('[data-files-action="new-folder"]').click();
await page.locator('[data-files-create-input]').fill('Target');
await page.locator('[data-files-create-confirm]').click();
await page.locator('[data-file-name="CutMe"]').click({ button: 'right' });
await page.locator('[data-files-menu-action="cut"]').click();
await page.locator('[data-file-name="Target"]').dblclick();
await expect(page.locator('.files-breadcrumb')).toContainText('Target');
await page.locator('[data-files-list]').click({ button: 'right', position: { x: 24, y: 110 } });
await page.locator('[data-files-menu-action="paste"]').click();
await expect(page.locator('[data-file-name="CutMe"]')).toBeVisible();
await page.locator('[data-files-action="up"]').click();
await expect(page.locator('[data-file-name="CutMe"]')).toHaveCount(0);
});
test('files explorer supports multiselect and internal drag/drop move', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
await page.locator('[data-files-action="new-folder"]').click();
await page.locator('[data-files-create-input]').fill('DropTarget');
await page.locator('[data-files-create-confirm]').click();
await page.locator('[data-files-action="new-markdown"]').click();
await page.locator('[data-files-create-input]').fill('DragOne.md');
await page.locator('[data-files-create-confirm]').click();
await page.locator('[data-files-action="new-text"]').click();
await page.locator('[data-files-create-input]').fill('DragTwo.txt');
await page.locator('[data-files-create-confirm]').click();
await page.locator('[data-file-name="DragOne.md"]').click();
await page.locator('[data-file-name="DragTwo.txt"]').click({ modifiers: [process.platform === 'darwin' ? 'Meta' : 'Control'] });
await expect(page.locator('.files-item.selected')).toHaveCount(2);
await page.evaluate(() => {
const source = document.querySelector('[data-file-name="DragOne.md"]');
const target = document.querySelector('[data-file-name="DropTarget"]');
const dt = new DataTransfer();
source.dispatchEvent(new DragEvent('dragstart', { bubbles: true, dataTransfer: dt }));
target.dispatchEvent(new DragEvent('dragover', { bubbles: true, dataTransfer: dt }));
target.dispatchEvent(new DragEvent('drop', { bubbles: true, dataTransfer: dt }));
});
await expect(page.locator('[data-file-name="DragOne.md"]')).toHaveCount(0);
await expect(page.locator('[data-file-name="DragTwo.txt"]')).toHaveCount(0);
await page.locator('[data-file-name="DropTarget"]').dblclick();
await expect(page.locator('[data-file-name="DragOne.md"]')).toBeVisible();
await expect(page.locator('[data-file-name="DragTwo.txt"]')).toBeVisible();
});
test('files history persists in workspace context and handles mouse back forward buttons', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
await page.locator('[data-file-name="Notes"]').dblclick();
await expect(page.locator('.files-breadcrumb')).toContainText('Notes');
await page.locator('.files-root').focus();
await page.keyboard.press('Alt+ArrowLeft');
await expect(page.locator('.files-breadcrumb')).not.toContainText('Notes');
await page.keyboard.press('Alt+ArrowRight');
await expect(page.locator('.files-breadcrumb')).toContainText('Notes');
await page.dispatchEvent('.files-root', 'mouseup', { button: 8, buttons: 128, bubbles: true, cancelable: true });
await expect(page.locator('.files-breadcrumb')).not.toContainText('Notes');
await page.dispatchEvent('.files-root', 'mouseup', { button: 9, buttons: 256, bubbles: true, cancelable: true });
await expect(page.locator('.files-breadcrumb')).toContainText('Notes');
await page.locator('.wt-label').filter({ hasText: 'Test' }).click();
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await expect(page.locator('.files-breadcrumb')).toContainText('Notes');
});
test('workbench close and mouse back return from editor to the previous Files folder', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
await page.locator('[data-file-name="Notes"]').dblclick();
await expect(page.locator('.files-breadcrumb')).toContainText('Notes');
await page.locator('[data-file-name="Overview.md"]').dblclick();
await expect(page.locator('[data-editor-mode="notes-markdown"]')).toBeVisible({ timeout: 10000 });
await page.dispatchEvent('body', 'mousedown', { button: 3, bubbles: true, cancelable: true });
await expect(page.locator('.workspace-host')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.files-breadcrumb')).toContainText('Notes');
await expect(page.locator('[data-file-name="Overview.md"]')).toBeVisible();
await page.locator('[data-file-name="Overview.md"]').dblclick();
await expect(page.locator('[data-editor-mode="notes-markdown"]')).toBeVisible({ timeout: 10000 });
await page.waitForTimeout(150);
await page.evaluate(() => {
document.body.dispatchEvent(new PointerEvent('pointerdown', {
button: 3,
buttons: 8,
bubbles: true,
cancelable: true,
pointerType: 'mouse'
}));
});
await expect(page.locator('.workspace-host')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.files-breadcrumb')).toContainText('Notes');
await page.locator('[data-file-name="Overview.md"]').dblclick();
await expect(page.locator('[data-editor-mode="notes-markdown"]')).toBeVisible({ timeout: 10000 });
await page.locator('.workbench-header .close-btn[aria-label="Close"]').click();
await expect(page.locator('.workspace-host')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.files-breadcrumb')).toContainText('Notes');
});
test('open .txt via workbench from files context shows default-editor', async ({ page }) => {
await page.evaluate(async () => {
const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.files', {
kind: 'vault-file',
path: 'Docs/todo.txt',
extension: '.txt',
context: { sourcePluginId: 'verstak.files', sourceView: 'files' },
});
if (err) throw new Error(err);
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
});
const editor = page.locator('[data-editor-mode="text"]');
await expect(editor).toBeVisible({ timeout: 10000 });
await expect(editor).toHaveAttribute('data-resource-path', 'Docs/todo.txt');
const textarea = page.locator('.de-textarea');
await expect(textarea).toBeVisible({ timeout: 10000 });
const textareaBox = await textarea.boundingBox();
expect(textareaBox.height).toBeGreaterThan(300);
});
test('open .md via workbench from files context shows generic-markdown', async ({ page }) => {
await page.evaluate(async () => {
const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.files', {
kind: 'vault-file',
path: 'Docs/readme.md',
extension: '.md',
context: { sourcePluginId: 'verstak.files', sourceView: 'files' },
});
if (err) throw new Error(err);
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
});
const workbench = page.locator('.workbench-host');
await expect(workbench).toBeVisible({ timeout: 10000 });
await expect(workbench.locator('.workbench-title')).toHaveText('Docs/readme.md');
const preview = page.locator('.de-preview');
await expect(preview).toBeVisible({ timeout: 10000 });
const previewBox = await preview.boundingBox();
expect(previewBox.height).toBeGreaterThan(300);
});
test('open notes markdown via workbench from files context shows notes-markdown', async ({ page }) => {
await page.evaluate(async () => {
const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.files', {
kind: 'vault-file',
path: 'Notes/Overview.md',
extension: '.md',
context: { sourcePluginId: 'verstak.files', sourceView: 'files', isInsideNotesFolder: true, notesMode: true },
});
if (err) throw new Error(err);
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
});
const workbench = page.locator('.workbench-host');
await expect(workbench).toBeVisible({ timeout: 10000 });
await expect(workbench.locator('.workbench-title')).toHaveText('Notes/Overview.md');
});
test('files plugin card shows openProviders in contributions', async ({ page }) => {
await page.evaluate(async () => {
const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.files', {
kind: 'vault-file', path: 'Docs/todo.txt', extension: '.txt',
context: { sourcePluginId: 'verstak.files', sourceView: 'files' },
});
if (err) throw new Error(err);
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
});
const editor = page.locator('[data-editor-mode="text"]');
await expect(editor).toBeVisible({ timeout: 5000 });
});
});
+54
View File
@@ -0,0 +1,54 @@
/**
* Shared helpers for Verstak E2E tests.
*/
import { expect } from '@playwright/test';
/** Wait for the app to finish loading (loading screen disappears) */
export async function waitForAppReady(page) {
// App shows "Loading Verstak..." initially, then renders the main layout
await page.waitForSelector('main', { state: 'visible', timeout: 15000 });
// Wait a bit for all async data to load
await page.waitForTimeout(1000);
}
/** Collect all console errors since last reset */
export function setupConsoleCollector(page) {
const errors = [];
page.on('console', (msg) => {
if (msg.type() === 'error') {
errors.push(msg.text());
}
});
page.on('pageerror', (err) => {
errors.push(err.message);
});
return {
getErrors: () => errors,
assertNoErrors: () => {
if (errors.length > 0) {
throw new Error(`Console errors detected:\n${errors.join('\n')}`);
}
},
};
}
/** Reset mock state before each test */
export async function resetMockState(page) {
await page.evaluate(() => {
if (window.__wailsMock) {
window.__wailsMock.reset();
}
});
}
/** Set plugin status in mock */
export async function setPluginStatus(page, pluginId, status, enabled) {
await page.evaluate(
({ id, st, en }) => {
if (window.__wailsMock) {
window.__wailsMock.setPluginStatus(id, st, en);
}
},
{ id: pluginId, st: status, en: enabled }
);
}
+136
View File
@@ -0,0 +1,136 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
test.describe('D: Plugin API bridge', () => {
let consoleCollector;
test.beforeEach(async ({ page }) => {
consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
});
test.afterEach(async () => {
consoleCollector.assertNoErrors();
});
test('platform-test reads and writes settings through scoped API after reload', async ({ page }) => {
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' }).click();
const saved = page.locator('.pt-saved-setting');
await expect(saved).toHaveText('Saved setting: initial value', { timeout: 10000 });
const input = page.locator('.pt-setting-input');
await input.fill('persisted through bridge');
await page.locator('.pt-save-setting').click();
await expect(saved).toHaveText('Saved setting: persisted through bridge', { timeout: 10000 });
await page.locator('.sidebar .nav-item').filter({ hasText: 'Plugin Manager' }).click();
await expect.poll(() => page.evaluate(() => Object.keys(window.__VERSTAK_COMMAND_HANDLERS__ || {}).length)).toBe(0);
await expect.poll(() => page.evaluate(() => (window.__VERSTAK_EVENT_HANDLERS__?.['verstak.platform-test.echo'] || []).length)).toBe(0);
await page.locator('button.reload-btn').click();
await expect(page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' }).locator('.status-badge')).toHaveText('loaded', { timeout: 10000 });
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' }).click();
await expect(page.locator('.pt-saved-setting')).toHaveText('Saved setting: persisted through bridge', { timeout: 10000 });
await expect(page.locator('.pt-badge')).toHaveAttribute('data-command-status', 'handled');
await expect(page.locator('.pt-badge')).toContainText('capability available');
await expect(page.locator('.pt-command-result')).toContainText('Command: handled 0.1.0 from bundled-frontend');
await expect(page.locator('.pt-event-result')).toHaveAttribute('data-event-status', 'received');
await expect(page.locator('.pt-event-result')).toContainText('Event: received hello-event');
await expect(page.locator('.pt-files-result')).toHaveAttribute('data-files-status', 'ok');
await expect(page.locator('.pt-files-result')).toContainText('Files: wrote/read/listed/moved/trashed');
await expect(page.locator('.pt-files-error-result')).toHaveAttribute('data-files-error-status', 'expected');
await expect(page.locator('.pt-files-error-result')).toContainText('Files error path: rejected reserved-path');
await page.evaluate(async () => {
const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.platform-test', {
kind: 'vault-file',
path: 'Notes/Overview.md',
extension: '.md',
context: { sourceView: 'notes', isInsideNotesFolder: true, notesMode: true },
});
if (err) throw new Error(err);
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
});
const workbench = page.locator('.workbench-host');
await expect(workbench).toBeVisible({ timeout: 10000 });
await expect(workbench.locator('.workbench-title')).toHaveText('Notes/Overview.md');
});
test('workbench routes markdown files to default-editor provider', async ({ page }) => {
await page.evaluate(async () => {
const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.platform-test', {
kind: 'vault-file',
path: 'Docs/readme.md',
extension: '.md',
context: { sourceView: 'files' },
});
if (err) throw new Error(err);
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
});
const workbench = page.locator('.workbench-host');
await expect(workbench).toBeVisible({ timeout: 10000 });
const title = workbench.locator('.workbench-title');
await expect(title).toHaveText('Docs/readme.md');
});
test('workbench shows no-provider fallback when no provider matches', async ({ page }) => {
await page.evaluate(async () => {
const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.platform-test', {
kind: 'vault-file',
path: 'Images/logo.png',
extension: '.png',
context: { sourceView: 'files' },
});
if (err) throw new Error(err);
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
});
await expect(page.locator('[data-workbench-status="no-provider"]')).toBeVisible();
await expect(page.locator('[data-workbench-status="no-provider"]')).toContainText('No viewer/editor available');
});
test('platform-test command and event handlers are cleaned up after leaving plugin view', async ({ page }) => {
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' }).click();
await expect(page.locator('.pt-command-result')).toContainText('Command: handled', { timeout: 10000 });
await expect(page.locator('.pt-event-result')).toHaveAttribute('data-event-status', 'received', { timeout: 10000 });
await expect.poll(() => page.evaluate(() => Object.keys(window.__VERSTAK_COMMAND_HANDLERS__ || {}).length)).toBe(1);
await expect.poll(() => page.evaluate(() => (window.__VERSTAK_EVENT_HANDLERS__?.['verstak.platform-test.echo'] || []).length)).toBe(1);
await page.locator('.sidebar .nav-item').filter({ hasText: 'Plugin Manager' }).click();
await expect.poll(() => page.evaluate(() => Object.keys(window.__VERSTAK_COMMAND_HANDLERS__ || {}).length)).toBe(0);
await expect.poll(() => page.evaluate(() => (window.__VERSTAK_EVENT_HANDLERS__?.['verstak.platform-test.echo'] || []).length)).toBe(0);
});
test('platform-test cleanup remains empty after disable reload flow', async ({ page }) => {
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' }).click();
await expect(page.locator('.pt-command-result')).toContainText('Command: handled', { timeout: 10000 });
await page.locator('.sidebar .nav-item').filter({ hasText: 'Plugin Manager' }).click();
const pluginCard = page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' });
await pluginCard.locator('button.btn-disable').click();
await expect(pluginCard.locator('button.btn-enable')).toBeVisible({ timeout: 10000 });
await expect.poll(() => page.evaluate(() => Object.keys(window.__VERSTAK_COMMAND_HANDLERS__ || {}).length)).toBe(0);
await expect.poll(() => page.evaluate(() => (window.__VERSTAK_EVENT_HANDLERS__?.['verstak.platform-test.echo'] || []).length)).toBe(0);
});
test('platform-test settings panel loads bundle content returned as raw string', async ({ page }) => {
await page.locator('.sidebar .nav-item').filter({ hasText: 'Plugin Manager' }).click();
const pluginCard = page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' });
await pluginCard.locator('button.btn-settings').click();
const modal = page.locator('.modal[aria-label="Plugin Settings"]');
await expect(modal).toBeVisible();
await expect(modal).toContainText('Platform Test Settings');
await expect(modal.locator('.host-state.error')).toHaveCount(0);
});
});
@@ -0,0 +1,121 @@
/**
* Acceptance Test A: Plugin Manager Disable/Enable refresh
*
* Scenario:
* 1. Open Plugin Manager
* 2. See Platform Test as loaded/enabled
* 3. Click Disable
* 4. Verify Enable button appears
* 5. Verify plugin sidebar item disappears
* 6. Click Enable
* 7. Verify Disable button appears
* 8. Verify plugin sidebar item returns
*/
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
test.describe('A: Plugin Manager Disable/Enable refresh', () => {
let consoleCollector;
test.beforeEach(async ({ page }) => {
consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
});
test.afterEach(async () => {
consoleCollector.assertNoErrors();
});
test('Platform Test plugin is initially visible and enabled', async ({ page }) => {
// Plugin Manager should show Platform Test plugin card
const pluginCard = page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' });
await expect(pluginCard).toBeVisible();
// Status should show "loaded"
const statusBadge = pluginCard.locator('.status-badge');
await expect(statusBadge).toHaveText('loaded');
// Disable button should be visible (not Enable)
const disableBtn = pluginCard.locator('button.btn-disable');
await expect(disableBtn).toBeVisible();
// Sidebar should have Platform Test item
const sidebarItem = page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' });
await expect(sidebarItem).toBeVisible();
});
test('Disable plugin: button changes to Enable, sidebar item disappears', async ({ page }) => {
const pluginCard = page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' });
// Click Disable
const disableBtn = pluginCard.locator('button.btn-disable');
await expect(disableBtn).toBeVisible();
await disableBtn.click();
// Wait for UI to update after disable
await page.waitForTimeout(500);
// After disable: Enable button should appear
const enableBtn = pluginCard.locator('button.btn-enable');
await expect(enableBtn).toBeVisible({ timeout: 10000 });
// After disable: sidebar item for this plugin should disappear
const sidebarItem = page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' });
await expect(sidebarItem).not.toBeVisible();
// Status should show "disabled"
const statusBadge = pluginCard.locator('.status-badge');
await expect(statusBadge).toHaveText('disabled');
});
test('Re-enable plugin: button changes to Disable, sidebar item returns', async ({ page }) => {
const pluginCard = page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' });
// First disable
await pluginCard.locator('button.btn-disable').click();
await page.waitForTimeout(500);
// Wait for Enable button
const enableBtn = pluginCard.locator('button.btn-enable');
await expect(enableBtn).toBeVisible({ timeout: 10000 });
// Click Enable
await enableBtn.click();
await page.waitForTimeout(500);
// After re-enable: Disable button should appear
const disableBtn = pluginCard.locator('button.btn-disable');
await expect(disableBtn).toBeVisible({ timeout: 10000 });
// Sidebar item should return
const sidebarItem = page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' });
await expect(sidebarItem).toBeVisible();
// Status should show "loaded"
const statusBadge = pluginCard.locator('.status-badge');
await expect(statusBadge).toHaveText('loaded');
});
test('Disable → Enable full flow in sequence', async ({ page }) => {
const pluginCard = page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' });
const sidebar = page.locator('.sidebar');
// Initial state: enabled
await expect(pluginCard.locator('button.btn-disable')).toBeVisible();
await expect(sidebar.locator('.plugin-item').filter({ hasText: 'Platform Test' })).toBeVisible();
// Disable
await pluginCard.locator('button.btn-disable').click();
await page.waitForTimeout(500);
await expect(pluginCard.locator('button.btn-enable')).toBeVisible({ timeout: 10000 });
await expect(sidebar.locator('.plugin-item').filter({ hasText: 'Platform Test' })).not.toBeVisible();
// Enable
await pluginCard.locator('button.btn-enable').click();
await page.waitForTimeout(500);
await expect(pluginCard.locator('button.btn-disable')).toBeVisible({ timeout: 10000 });
await expect(sidebar.locator('.plugin-item').filter({ hasText: 'Platform Test' })).toBeVisible();
});
});
+128
View File
@@ -0,0 +1,128 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
test.describe('E: Plugin Manager layout', () => {
let consoleCollector;
test.beforeEach(async ({ page }) => {
consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
});
test.afterEach(async () => {
consoleCollector.assertNoErrors();
});
test('plugin list scrolls through the global main scroll surface and stays responsive', async ({ page }) => {
await page.evaluate(() => window.__wailsMock.addSyntheticPlugins(18));
await page.locator('button.reload-btn').click();
await expect(page.locator('.plugin-card')).toHaveCount(21, { timeout: 10000 });
const manager = page.locator('.plugin-manager');
const scrollSurface = page.locator('.content.scroll-surface');
await expect(manager).toBeVisible();
await expect(scrollSurface).toBeVisible();
const desktopMetrics = await scrollSurface.evaluate((node) => ({
clientHeight: node.clientHeight,
scrollHeight: node.scrollHeight,
overflowY: getComputedStyle(node).overflowY,
}));
expect(desktopMetrics.overflowY).toBe('auto');
expect(desktopMetrics.scrollHeight).toBeGreaterThan(desktopMetrics.clientHeight);
const scrolledTop = await scrollSurface.evaluate((node) => {
node.scrollTop = node.scrollHeight;
return node.scrollTop;
});
expect(scrolledTop).toBeGreaterThan(0);
await page.setViewportSize({ width: 720, height: 640 });
await expect(manager).toBeVisible();
const narrowMetrics = await scrollSurface.evaluate((node) => ({
clientWidth: node.clientWidth,
scrollWidth: node.scrollWidth,
scrollTop: node.scrollTop,
}));
expect(narrowMetrics.scrollWidth).toBeLessThanOrEqual(narrowMetrics.clientWidth + 1);
expect(narrowMetrics.scrollTop).toBeGreaterThan(0);
});
test('platform-test buttons use the global button contract', async ({ page }) => {
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' }).click();
const saveButton = page.locator('.pt-save-setting');
await expect(saveButton).toBeVisible({ timeout: 10000 });
await expect(saveButton).toHaveClass(/btn-primary/);
const buttonStyle = await saveButton.evaluate((node) => {
const style = getComputedStyle(node);
return {
display: style.display,
backgroundColor: style.backgroundColor,
borderRadius: style.borderRadius,
};
});
expect(buttonStyle.display).toBe('inline-flex');
expect(buttonStyle.backgroundColor).not.toBe('rgba(0, 0, 0, 0)');
expect(buttonStyle.borderRadius).toBe('6px');
});
test('workspace selection keeps exactly one active node', async ({ page }) => {
const selected = page.locator('.wt-node.selected .wt-label');
await expect(selected).toHaveCount(1);
await expect(selected).toHaveText('Project');
await page.locator('.wt-label').filter({ hasText: 'Test' }).click();
await expect(selected).toHaveCount(1);
await expect(selected).toHaveText('Test');
});
test('workspace tools render as tabs with Files as one tab', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
const tabs = page.locator('.workspace-tabs');
await expect(tabs).toBeVisible({ timeout: 10000 });
const filesTab = tabs.locator('[role="tab"]').filter({ hasText: 'Files' });
await expect(filesTab).toBeVisible();
await expect(filesTab).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.workspace-tool')).toHaveCount(0);
await expect(page.locator('.files-root')).toBeVisible();
});
test('workspace sidebar creates renames and trashes top-level workspaces', async ({ page }) => {
await page.locator('button[title="New workspace"]').click();
await page.locator('.wt-create input').fill('ClientA');
await page.locator('.wt-btn-primary', { hasText: 'Create' }).click();
await expect(page.locator('.wt-label').filter({ hasText: 'ClientA' })).toBeVisible();
const client = page.locator('.wt-node').filter({ hasText: 'ClientA' });
await client.locator('button[title="Rename workspace"]').click();
await page.locator('.wt-rename').fill('ClientB');
await page.locator('button[title="Save rename"]').click();
await expect(page.locator('.wt-label').filter({ hasText: 'ClientB' })).toBeVisible();
await expect(page.locator('.wt-label').filter({ hasText: 'ClientA' })).toHaveCount(0);
const renamed = page.locator('.wt-node').filter({ hasText: 'ClientB' });
await renamed.locator('button[title="Trash workspace"]').click();
await expect(page.locator('.wt-label').filter({ hasText: 'ClientB' })).toHaveCount(0);
});
test('shell icons render through bundled Lucide SVG components', async ({ page }) => {
const logo = page.locator('.sidebar-logo');
await expect(logo).toBeVisible();
await expect(logo).toHaveClass(/lucide/);
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
const workspaceIcon = page.locator('.wt-node-icon').first();
await expect(workspaceIcon).toBeVisible();
await expect(workspaceIcon).toHaveClass(/lucide/);
});
});
+101
View File
@@ -0,0 +1,101 @@
/**
* Acceptance Test C: Reload updates UI state
*
* Scenario:
* 1. Change mocked plugin state (e.g. disable a plugin in mock)
* 2. Click Reload button
* 3. Verify UI reflects the updated state
*/
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState, setPluginStatus } from './helpers.js';
test.describe('C: Reload updates UI state', () => {
let consoleCollector;
test.beforeEach(async ({ page }) => {
consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
});
test.afterEach(async () => {
consoleCollector.assertNoErrors();
});
test('Reload after mock state change reflects new plugin status', async ({ page }) => {
const pluginCard = page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' });
// Initial state: loaded/enabled
await expect(pluginCard.locator('.status-badge')).toHaveText('loaded');
await expect(pluginCard.locator('button.btn-disable')).toBeVisible();
// Change mock state to disabled (simulating external state change)
await setPluginStatus(page, 'verstak.platform-test', 'disabled', false);
await page.waitForTimeout(200);
// Click Reload button in Plugin Manager header
const reloadBtn = page.locator('button.reload-btn');
await expect(reloadBtn).toBeVisible();
await reloadBtn.click();
// Wait for reload to complete and UI to update
await page.waitForTimeout(1000);
// After reload: status should reflect the disabled state
await expect(pluginCard.locator('.status-badge')).toHaveText('disabled', { timeout: 10000 });
// Enable button should appear (since plugin is now disabled)
await expect(pluginCard.locator('button.btn-enable')).toBeVisible({ timeout: 10000 });
// Sidebar item should be gone (disabled plugins are filtered from sidebar)
const sidebarItem = page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' });
await expect(sidebarItem).not.toBeVisible();
});
test('Reload restores plugin after re-enabling in mock', async ({ page }) => {
const pluginCard = page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' });
// Disable in mock, reload
await setPluginStatus(page, 'verstak.platform-test', 'disabled', false);
await page.waitForTimeout(200);
await page.locator('button.reload-btn').click();
await page.waitForTimeout(1000);
// Verify disabled
await expect(pluginCard.locator('.status-badge')).toHaveText('disabled', { timeout: 10000 });
await expect(pluginCard.locator('button.btn-enable')).toBeVisible();
// Re-enable in mock
await setPluginStatus(page, 'verstak.platform-test', 'loaded', true);
await page.waitForTimeout(200);
// Reload again
await page.locator('button.reload-btn').click();
await page.waitForTimeout(1000);
// After reload: should be loaded again
await expect(pluginCard.locator('.status-badge')).toHaveText('loaded', { timeout: 10000 });
await expect(pluginCard.locator('button.btn-disable')).toBeVisible();
// Sidebar item should return
const sidebarItem = page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' });
await expect(sidebarItem).toBeVisible();
});
test('Reload button is not disabled during normal operation', async ({ page }) => {
const reloadBtn = page.locator('button.reload-btn');
await expect(reloadBtn).toBeVisible();
await expect(reloadBtn).not.toBeDisabled();
});
test('Reload handles raw Wails count result without falling into error state', async ({ page }) => {
await page.evaluate(() => window.__wailsMock.setReloadResponseMode('raw-count'));
const reloadBtn = page.locator('button.reload-btn');
await reloadBtn.click();
await expect(page.locator('.error-state')).toHaveCount(0);
await expect(page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' })).toBeVisible({ timeout: 10000 });
});
});
+75
View File
@@ -0,0 +1,75 @@
/**
* Acceptance Test B: Sidebar opens plugin view by item.view, not item.id
*
* Data:
* - sidebar item id = verstak.platform-test.sidebar
* - sidebar item view = verstak.platform-test.diagnostics
*
* Scenario:
* 1. Click sidebar item "Platform Test"
* 2. Verify diagnostics view is opened (verstak.platform-test.diagnostics)
* 3. Verify NOT opened empty container by sidebar id
*/
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
test.describe('B: Sidebar opens plugin view by item.view', () => {
let consoleCollector;
test.beforeEach(async ({ page }) => {
consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
});
test.afterEach(async () => {
consoleCollector.assertNoErrors();
});
test('Sidebar item exists with correct label', async ({ page }) => {
const sidebarItem = page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' });
await expect(sidebarItem).toBeVisible();
});
test('Click sidebar item opens diagnostics view by view ID, not sidebar ID', async ({ page }) => {
const sidebarItem = page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' });
await expect(sidebarItem).toBeVisible();
// Click the sidebar item
await sidebarItem.click();
await page.waitForTimeout(500);
// View container should be visible
const viewContainer = page.locator('.view-container');
await expect(viewContainer).toBeVisible();
// The view header should show "Platform Diagnostics" (from view contribution title)
// This proves the view was opened by item.view = "verstak.platform-test.diagnostics"
// NOT by item.id = "verstak.platform-test.sidebar"
const viewHeader = viewContainer.locator('.view-header h2');
await expect(viewHeader).toHaveText('Platform Diagnostics', { timeout: 10000 });
// The view should NOT show "View ... not found" error
// (which would happen if it tried to open by sidebar item id)
await expect(viewContainer).not.toHaveText(/not found/);
// The view should NOT show an empty container message
const emptyView = viewContainer.locator('.empty');
await expect(emptyView).not.toBeVisible();
});
test('View header shows correct title from view contribution', async ({ page }) => {
const sidebarItem = page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' });
await sidebarItem.click();
await page.waitForTimeout(500);
// Verify the view title comes from the view contribution (item.view)
// NOT from the sidebar item (item.id)
const viewHeader = page.locator('.view-container .view-header h2');
await expect(viewHeader).toHaveText('Platform Diagnostics', { timeout: 10000 });
// Should NOT show sidebar item id as view title
await expect(viewHeader).not.toHaveText('verstak.platform-test.sidebar');
});
});
+10 -1
View File
@@ -16,6 +16,15 @@
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
<script type="module">
// In test mode (no Wails runtime), load mock bridge first
if (!window['go'] || !window['go']['api']) {
import('/src/lib/test/wails-mock.js').then(function() {
import('/src/main.js');
});
} else {
import('/src/main.js');
}
</script>
</body>
</html>
+71
View File
@@ -8,7 +8,9 @@
"name": "verstak-desktop-frontend",
"version": "0.1.0",
"devDependencies": {
"@playwright/test": "^1.61.0",
"@sveltejs/vite-plugin-svelte": "^3.1.0",
"lucide-svelte": "^0.577.0",
"svelte": "^4.2.0",
"vite": "^5.4.0"
}
@@ -429,6 +431,21 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@playwright/test": {
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.0.tgz",
"integrity": "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==",
"dev": true,
"dependencies": {
"playwright": "1.61.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.62.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.0.tgz",
@@ -966,6 +983,16 @@
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
"dev": true
},
"node_modules/lucide-svelte": {
"version": "0.577.0",
"resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.577.0.tgz",
"integrity": "sha512-0i88o57KsaHWnc80J57fY99CWzlZsSdtH5kKjLUJa7z8dum/9/AbINNLzJ7NiRFUdOgMnfAmJt8jFbW2zeC5qQ==",
"deprecated": "Package deprecated. Please use @lucide/svelte instead.",
"dev": true,
"peerDependencies": {
"svelte": "^3 || ^4 || ^5.0.0-next.42"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -1022,6 +1049,50 @@
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true
},
"node_modules/playwright": {
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz",
"integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==",
"dev": true,
"dependencies": {
"playwright-core": "1.61.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz",
"integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==",
"dev": true,
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": {
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
+6 -1
View File
@@ -6,10 +6,15 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"test:e2e": "npx playwright test --config playwright.config.js",
"test:e2e:ui": "npx playwright test --config playwright.config.js --ui",
"test:e2e:headed": "npx playwright test --config playwright.config.js --headed"
},
"devDependencies": {
"@playwright/test": "^1.61.0",
"@sveltejs/vite-plugin-svelte": "^3.1.0",
"lucide-svelte": "^0.577.0",
"svelte": "^4.2.0",
"vite": "^5.4.0"
}
+1 -1
View File
@@ -1 +1 @@
ef6849d398e9d32c4ae5afdea904eb49
c02aa6458981565332c3b0bc49516b42
+59
View File
@@ -0,0 +1,59 @@
import { defineConfig, devices } from '@playwright/test';
import { fileURLToPath } from 'url';
import { dirname, resolve } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const FRONTEND_PORT = 5174;
const FRONTEND_URL = `http://localhost:${FRONTEND_PORT}`;
const LOOPBACK_NO_PROXY = 'localhost,127.0.0.1,::1';
process.env.NO_PROXY = process.env.NO_PROXY
? `${process.env.NO_PROXY},${LOOPBACK_NO_PROXY}`
: LOOPBACK_NO_PROXY;
process.env.no_proxy = process.env.no_proxy
? `${process.env.no_proxy},${LOOPBACK_NO_PROXY}`
: LOOPBACK_NO_PROXY;
export default defineConfig({
testDir: resolve(__dirname, 'e2e'),
testMatch: '**/*.spec.js',
timeout: 30000,
expect: { timeout: 10000 },
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: 1,
reporter: [
['list'],
['json', { outputFile: resolve(__dirname, 'e2e-results/test-results.json') }],
],
outputDir: resolve(__dirname, 'e2e-results'),
use: {
baseURL: FRONTEND_URL,
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'on-first-retry',
headless: true,
viewport: { width: 1280, height: 720 },
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
webServer: {
command: `npx vite --mode test --port ${FRONTEND_PORT}`,
url: FRONTEND_URL,
reuseExistingServer: !process.env.CI,
timeout: 60000,
env: {
NO_PROXY: process.env.NO_PROXY,
no_proxy: process.env.no_proxy,
},
stdout: 'pipe',
stderr: 'pipe',
},
});
+451 -15
View File
@@ -2,32 +2,468 @@
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 WorkbenchHost from './lib/shell/WorkbenchHost.svelte';
import WorkspaceHost from './lib/shell/WorkspaceHost.svelte';
import * as App from '../wailsjs/go/api/App';
import { debug } from './lib/log/debug.js';
import { onMount } from 'svelte';
import { tick } from 'svelte';
let currentView = 'plugin-manager';
let vaultStatus = { status: 'unknown', path: '', vaultId: '' };
let needsVaultSelection = false;
let loading = true;
let activeView = null;
let activeViewPluginId = '';
let activeSettingsPluginId = '';
let activeSettingsPanelId = '';
let openedResource = null;
let workspaceNodes = [];
let selectedWorkspaceName = '';
let navigationStack = [];
let navigationIndex = -1;
let applyingNavigation = false;
let lastMouseHistoryDirection = '';
let lastMouseHistoryAt = 0;
function flog(msg) {
App.WriteFrontendLog('App', msg);
}
function currentSnapshot() {
return {
currentView,
activeView,
activeViewPluginId,
activeSettingsPluginId,
activeSettingsPanelId,
openedResource,
selectedWorkspaceName,
};
}
function sameSnapshot(a, b) {
return JSON.stringify(a) === JSON.stringify(b);
}
function pushNavigation(snapshot = currentSnapshot()) {
if (applyingNavigation) return;
if (navigationIndex >= 0 && sameSnapshot(navigationStack[navigationIndex], snapshot)) return;
if (navigationIndex < navigationStack.length - 1) {
navigationStack = navigationStack.slice(0, navigationIndex + 1);
}
navigationStack = [...navigationStack, snapshot];
navigationIndex = navigationStack.length - 1;
}
function applySnapshot(snapshot) {
applyingNavigation = true;
currentView = snapshot.currentView;
activeView = snapshot.activeView;
activeViewPluginId = snapshot.activeViewPluginId;
activeSettingsPluginId = snapshot.activeSettingsPluginId;
activeSettingsPanelId = snapshot.activeSettingsPanelId;
openedResource = snapshot.openedResource;
selectedWorkspaceName = snapshot.selectedWorkspaceName;
applyingNavigation = false;
}
function navigateBack() {
if (navigationIndex <= 0) return false;
navigationIndex -= 1;
applySnapshot(navigationStack[navigationIndex]);
return true;
}
function navigateForward() {
if (navigationIndex >= navigationStack.length - 1) return false;
navigationIndex += 1;
applySnapshot(navigationStack[navigationIndex]);
return true;
}
function mouseHistoryDirection(event) {
if (currentView === 'workspace' || currentView === 'workbench') return '';
if (event.button === 3 || event.button === 8 || event.buttons === 8 || event.buttons === 128 || event.which === 8) return 'back';
if (event.button === 4 || event.button === 9 || event.buttons === 16 || event.buttons === 256 || event.which === 9) return 'forward';
return '';
}
function keyHistoryDirection(event) {
if (currentView === 'workspace' || currentView === 'workbench') return '';
const key = event.key || '';
if (event.altKey && key === 'ArrowLeft') return 'back';
if (event.altKey && key === 'ArrowRight') return 'forward';
if (key === 'BrowserBack' || key === 'XF86Back') return 'back';
if (key === 'BrowserForward' || key === 'XF86Forward') return 'forward';
if (event.keyCode === 166) return 'back';
if (event.keyCode === 167) return 'forward';
return '';
}
function handleHistoryRequest(direction, event) {
if (!direction || event?.defaultPrevented) return;
if (event?.type === 'mousedown' || event?.type === 'mouseup' || event?.type === 'auxclick' || event?.type === 'pointerdown') {
const now = Date.now();
if (direction === lastMouseHistoryDirection && now - lastMouseHistoryAt < 120) return;
lastMouseHistoryDirection = direction;
lastMouseHistoryAt = now;
debug.log('[App] mouse history event', {
type: event.type,
direction,
button: event.button,
buttons: event.buttons,
which: event.which,
pointerType: event.pointerType || '',
currentView,
});
}
const moved = direction === 'back' ? navigateBack() : navigateForward();
if (moved && event) {
event.preventDefault();
event.stopPropagation();
}
}
async function checkVault() {
debug.log('[App] checkVault: START');
flog('checkVault: START');
loading = true;
try {
debug.log('[App] checkVault: calling GetAppSettings...');
const settings = await App.GetAppSettings();
debug.log('[App] checkVault: GetAppSettings returned', settings);
flog('checkVault: GetAppSettings returned');
debug.log('[App] checkVault: calling GetVaultStatus...');
vaultStatus = await App.GetVaultStatus() || { status: 'unknown', path: '', vaultId: '' };
debug.log('[App] checkVault: GetVaultStatus returned', vaultStatus);
flog('checkVault: vaultStatus=' + vaultStatus.status);
if (!settings.currentVaultPath || vaultStatus.status !== 'open') {
debug.log('[App] checkVault: vault not open, needsVaultSelection=true');
flog('checkVault: needsVaultSelection=true');
needsVaultSelection = true;
} else {
debug.log('[App] checkVault: vault open, needsVaultSelection=false');
flog('checkVault: needsVaultSelection=false');
needsVaultSelection = false;
}
} catch (e) {
debug.log('[App] checkVault: ERROR', String(e));
flog('checkVault: ERROR: ' + String(e));
console.error('[App] startup check failed:', e);
needsVaultSelection = true;
}
loading = false;
await tick();
debug.log('[App] checkVault: END, loading=false');
flog('checkVault: END, loading=false');
}
function onVaultOpened() {
debug.log('[App] onVaultOpened');
needsVaultSelection = false;
vaultStatus = { status: 'open', path: '', vaultId: '' };
}
function onNav(e) {
debug.log('[App] onNav:', e.detail.viewId);
currentView = e.detail.viewId;
pushNavigation();
}
function onOpenView(e) {
debug.log('[App] onOpenView:', e.detail.viewId, 'plugin:', e.detail.pluginId);
activeView = e.detail.viewId;
activeViewPluginId = e.detail.pluginId || '';
currentView = 'plugin-view';
pushNavigation();
}
function onOpenSettings(e) {
debug.log('[App] onOpenSettings:', e.detail.pluginId, e.detail.panelId);
activeSettingsPluginId = e.detail.pluginId;
activeSettingsPanelId = e.detail.panelId || '';
currentView = 'plugin-manager';
pushNavigation();
}
function onWorkbenchOpened(e) {
debug.log('[App] onWorkbenchOpened:', e.detail?.request?.path, e.detail?.providerId);
openedResource = e.detail;
currentView = 'workbench';
pushNavigation();
}
function onWorkspaceSelected(e) {
debug.log('[App] onWorkspaceSelected:', e.detail?.workspaceName);
selectedWorkspaceName = e.detail?.workspaceName || '';
workspaceNodes = e.detail?.nodes || workspaceNodes;
if (selectedWorkspaceName) {
currentView = 'workspace';
pushNavigation();
}
}
function onCloseSettings() {
debug.log('[App] onCloseSettings');
activeSettingsPluginId = '';
activeSettingsPanelId = '';
}
function onNavigateBack(e) {
if (currentView === 'workbench') return;
if (currentView === 'workspace') {
const upBtn = document.querySelector('[data-files-action="up"]');
if (upBtn && !upBtn.disabled) {
upBtn.click();
e?.preventDefault?.();
return;
}
}
if (navigateBack()) e?.preventDefault?.();
}
function onNavigateForward(e) {
if (currentView === 'workbench') return;
if (currentView === 'workspace') {
const fwdBtn = document.querySelector('[data-files-action="forward"]');
if (fwdBtn && !fwdBtn.disabled) {
fwdBtn.click();
e?.preventDefault?.();
return;
}
}
if (navigateForward()) e?.preventDefault?.();
}
function onCloseWorkbench(e) {
if (currentView !== 'workbench') return;
if (!navigateBack() && selectedWorkspaceName) {
currentView = 'workspace';
pushNavigation();
}
e?.preventDefault?.();
}
function onGlobalKeydown(e) {
handleHistoryRequest(keyHistoryDirection(e), e);
}
function onGlobalMouse(e) {
handleHistoryRequest(mouseHistoryDirection(e), e);
}
// 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);
window.addEventListener('verstak:workbench-opened', onWorkbenchOpened);
window.addEventListener('verstak:workspace-selected', onWorkspaceSelected);
window.addEventListener('verstak:navigate-back', onNavigateBack);
window.addEventListener('verstak:navigate-forward', onNavigateForward);
window.addEventListener('verstak:close-workbench', onCloseWorkbench);
window.addEventListener('keydown', onGlobalKeydown);
window.addEventListener('pointerdown', onGlobalMouse, true);
window.addEventListener('mousedown', onGlobalMouse, true);
window.addEventListener('mouseup', onGlobalMouse, true);
window.addEventListener('auxclick', onGlobalMouse, true);
}
onMount(async () => {
await checkVault();
pushNavigation();
});
</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 scroll-surface">
{#if currentView === 'plugin-manager'}
<PluginManager {activeSettingsPluginId} {activeSettingsPanelId} />
{:else if currentView === 'workbench'}
<WorkbenchHost {openedResource} />
{:else if currentView === 'workspace'}
<WorkspaceHost selectedWorkspaceName={selectedWorkspaceName} nodes={workspaceNodes} />
{:else}
<ViewContainer {activeView} {activeViewPluginId} />
{/if}
</section>
</main>
{/if}
<style>
:global(*) {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:global(html),
:global(body),
:global(#app) {
width: 100%;
height: 100%;
}
:global(body) {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #1a1a2e;
color: #e0e0f0;
overflow: hidden;
}
:global(button) {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
min-height: 2rem;
padding: 0.4rem 0.85rem;
border: 1px solid #1a3a5c;
border-radius: 6px;
background: #0f3460;
color: #e0e0f0;
font: inherit;
font-size: 0.85rem;
font-weight: 600;
line-height: 1.2;
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease, opacity 0.15s ease;
}
:global(button:hover:not(:disabled)) {
background: #1a3a5c;
border-color: #4ecca3;
color: #ffffff;
}
:global(button:focus-visible) {
outline: 2px solid #4ecca3;
outline-offset: 2px;
}
:global(button:disabled) {
opacity: 0.55;
cursor: not-allowed;
}
:global(.btn-primary) {
background: #4ecca3;
border-color: #4ecca3;
color: #101827;
}
:global(.btn-primary:hover:not(:disabled)) {
background: #63d9b3;
border-color: #63d9b3;
color: #101827;
}
:global(.btn-secondary) {
background: #0f3460;
border-color: #533483;
color: #e0e0f0;
}
:global(.btn-danger) {
background: #e94560;
border-color: #e94560;
color: #ffffff;
}
:global(.btn-danger:hover:not(:disabled)) {
background: #ff5b73;
border-color: #ff5b73;
}
:global(.btn-ghost) {
background: transparent;
border-color: transparent;
color: #a0a0b8;
}
:global(.btn-ghost:hover:not(:disabled)) {
background: rgba(15, 52, 96, 0.55);
border-color: #0f3460;
color: #e0e0f0;
}
:global(.btn-icon) {
width: 2rem;
min-width: 2rem;
padding: 0;
}
:global(.scroll-surface) {
min-width: 0;
min-height: 0;
overflow: auto;
scrollbar-gutter: stable;
}
:global(*) {
scrollbar-width: thin;
scrollbar-color: #0f3460 #1a1a2e;
}
:global(*::-webkit-scrollbar) {
width: 8px;
height: 8px;
}
:global(*::-webkit-scrollbar-track) {
background: #1a1a2e;
}
:global(*::-webkit-scrollbar-thumb) {
background: #0f3460;
border-radius: 4px;
}
:global(*::-webkit-scrollbar-thumb:hover) {
background: #1a4a7a;
}
.app-loading {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
background: #1a1a2e;
color: #a0a0b8;
font-size: 1rem;
}
main {
display: flex;
height: 100vh;
width: 100%;
background: #1a1a2e;
}
.content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.content {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
padding: clamp(1rem, 2vw, 1.5rem);
}
</style>
+150
View File
@@ -0,0 +1,150 @@
/**
* Frontend debug logger.
*
* Enabled when:
* 1. URL has ?debug query param, OR
* 2. localStorage has verstak-debug = "true"
*
* Writes to:
* - console (always, with [debug] prefix)
* - localStorage buffer (last 1000 entries, key: verstak-debug-log)
*
* Usage:
* import { debug } from '../log/debug.js';
* debug.log('[ComponentName]', 'message', data);
* debug.logf('[ComponentName]', 'format %s', arg);
*
* To enable: open app with ?debug or run in console:
* localStorage.setItem('verstak-debug', 'true')
*
* To export log: run in console:
* copy(JSON.parse(localStorage.getItem('verstak-debug-log')))
*/
var ENABLED = false;
var BUFFER_KEY = 'verstak-debug-log';
var MAX_ENTRIES = 1000;
// Check enable conditions
function checkEnabled() {
try {
if (window.location && window.location.search && window.location.search.indexOf('debug') !== -1) {
return true;
}
if (typeof localStorage !== 'undefined' && localStorage.getItem('verstak-debug') === 'true') {
return true;
}
} catch (e) {
// localStorage not available
}
return false;
}
ENABLED = checkEnabled();
function getTimestamp() {
return new Date().toISOString();
}
function formatMessage(args) {
var parts = [];
for (var i = 0; i < args.length; i++) {
var a = args[i];
if (typeof a === 'object') {
try {
parts.push(JSON.stringify(a));
} catch (e) {
parts.push(String(a));
}
} else {
parts.push(String(a));
}
}
return parts.join(' ');
}
function writeToBuffer(entry) {
try {
if (typeof localStorage === 'undefined') return;
var raw = localStorage.getItem(BUFFER_KEY);
var log = raw ? JSON.parse(raw) : [];
log.push(entry);
if (log.length > MAX_ENTRIES) {
log = log.slice(log.length - MAX_ENTRIES);
}
localStorage.setItem(BUFFER_KEY, JSON.stringify(log));
} catch (e) {
// Ignore quota errors
}
}
function log() {
if (!ENABLED) return;
var msg = formatMessage(Array.prototype.slice.call(arguments));
var entry = { ts: getTimestamp(), msg: msg };
writeToBuffer(entry);
console.log('[debug]', msg);
}
function logf() {
if (!ENABLED) return;
var args = Array.prototype.slice.call(arguments);
var format = args.shift();
var i = 0;
var msg = format.replace(/%[sdfo]/g, function () {
return i < args.length ? String(args[i++]) : '';
});
var entry = { ts: getTimestamp(), msg: msg };
writeToBuffer(entry);
console.log('[debug]', msg);
}
function getLog() {
try {
if (typeof localStorage === 'undefined') return [];
var raw = localStorage.getItem(BUFFER_KEY);
return raw ? JSON.parse(raw) : [];
} catch (e) {
return [];
}
}
function clearLog() {
try {
if (typeof localStorage !== 'undefined') {
localStorage.removeItem(BUFFER_KEY);
}
} catch (e) {}
}
function exportLog() {
var entries = getLog();
return entries.map(function (e) { return e.ts + ' ' + e.msg; }).join('\n');
}
// Named export for Svelte/Vite
export var debug = {
log: log,
logf: logf,
isEnabled: function () { return ENABLED; },
enable: function () {
ENABLED = true;
try { localStorage.setItem('verstak-debug', 'true'); } catch (e) {}
},
disable: function () {
ENABLED = false;
try { localStorage.removeItem('verstak-debug'); } catch (e) {}
},
getLog: getLog,
clearLog: clearLog,
exportLog: exportLog
};
// Also expose globally for console access
if (typeof window !== 'undefined') {
window.__verstakDebug = debug;
}
if (ENABLED) {
console.log('[debug] frontend debug logger enabled');
}
@@ -0,0 +1,330 @@
<script>
import { onMount, onDestroy } from 'svelte';
import * as App from '../../../wailsjs/go/api/App';
import Icon from '../ui/Icon.svelte';
import { createPluginAPI } from './VerstakPluginAPI.js';
export let pluginId = null;
export let componentId = null;
export let viewPluginId = null;
export let componentProps = {};
let loadState = 'idle'; // idle | loading | loaded | error
let pluginInfo = null;
let errorText = '';
let mountContainer = null;
let currentPluginId = null;
let currentComponent = null;
let currentAPI = null;
let currentPropsKey = '';
$: activePluginId = pluginId || viewPluginId;
$: activeComponent = componentId;
$: propsKey = JSON.stringify(componentProps || {});
// React to changes — reload on view change
$: if (activePluginId && activeComponent) {
loadAndMount(activePluginId, activeComponent, propsKey);
} else if (!activePluginId) {
cleanup();
loadState = 'idle';
}
onDestroy(() => {
cleanup();
});
function cleanup() {
if (currentAPI && typeof currentAPI.dispose === 'function') {
try {
currentAPI.dispose();
} catch (e) {
console.error('[PluginBundleHost] API dispose error:', e);
}
}
const reg = window.__VERSTAK_PLUGIN_REGISTRY__;
if (currentPluginId && currentComponent && reg && reg[currentPluginId]) {
const comp = reg[currentPluginId][currentComponent];
if (comp && comp.unmount && mountContainer) {
try {
comp.unmount(mountContainer);
} catch (e) {
console.error('[PluginBundleHost] unmount error:', e);
}
}
}
if (mountContainer) {
mountContainer.innerHTML = '';
}
currentPluginId = null;
currentComponent = null;
currentAPI = null;
currentPropsKey = '';
}
function unpackBackendResult(result) {
if (Array.isArray(result) && result.length === 2 && (typeof result[1] === 'string' || result[1] == null)) {
return { value: result[0], error: result[1] || '' };
}
return { value: result, error: '' };
}
async function loadAndMount(pId, compId, nextPropsKey) {
// If same plugin+component and already mounted, skip
if (currentPluginId === pId && currentComponent === compId && currentPropsKey === nextPropsKey && loadState === 'loaded') {
return;
}
// Cleanup previous
cleanup();
loadState = 'loading';
errorText = '';
currentPluginId = pId;
currentComponent = compId;
currentPropsKey = nextPropsKey;
try {
// Get plugin frontend info
const info = await App.GetPluginFrontendInfo(pId);
pluginInfo = info;
if (!info || info.status === 'no-frontend' || info.status === 'not-found') {
loadState = 'error';
errorText = info.status === 'no-frontend'
? 'Plugin has no frontend bundle'
: 'Plugin not found';
return;
}
// Check if bundle already loaded for this plugin
const reg = window.__VERSTAK_PLUGIN_REGISTRY__ || {};
if (!reg[pId]) {
// Load the bundle JS content via backend API
const assetResult = unpackBackendResult(await App.GetPluginAssetContent(pId, info.entry));
const content = assetResult.value;
if (assetResult.error || !content) {
loadState = 'error';
errorText = 'Failed to load bundle: ' + (assetResult.error || 'empty content');
return;
}
// Execute bundle via Function constructor (safe: no access to outer scope)
// This is equivalent to eval but more explicit
try {
const fn = new Function(content);
fn();
} catch (e) {
loadState = 'error';
errorText = 'Bundle execution error: ' + e.message;
console.error('[PluginBundleHost] bundle exec error:', e);
return;
}
// Verify registration happened
if (!window.__VERSTAK_PLUGIN_REGISTRY__[pId]) {
loadState = 'error';
errorText = 'Bundle loaded but no VerstakPluginRegister call detected';
return;
}
}
// Find the component
const components = window.__VERSTAK_PLUGIN_REGISTRY__[pId];
const comp = components[compId];
if (!comp || !comp.mount) {
loadState = 'error';
errorText = 'Component "' + compId + '" not found in bundle. Available: '
+ (Object.keys(components).join(', ') || 'none');
return;
}
// Create API
const api = createPluginAPI(pId);
currentAPI = api;
// Mount component
if (!mountContainer) {
// Container must exist in DOM — wait for next tick
await new Promise(r => requestAnimationFrame(r));
}
if (mountContainer) {
try {
comp.mount(mountContainer, Object.assign({ componentId: compId }, componentProps || {}), api);
loadState = 'loaded';
errorText = '';
} catch (e) {
loadState = 'error';
errorText = 'Component mount error: ' + e.message;
console.error('[PluginBundleHost] mount error:', e);
}
} else {
loadState = 'error';
errorText = 'Mount container not available';
}
} catch (e) {
loadState = 'error';
errorText = 'Unexpected error: ' + (e.message || e);
console.error('[PluginBundleHost] error:', e);
}
}
function getComponentList() {
const reg = window.__VERSTAK_PLUGIN_REGISTRY__;
if (!reg || !currentPluginId || !reg[currentPluginId]) return [];
return Object.keys(reg[currentPluginId]);
}
</script>
<div class="plugin-bundle-host">
{#if loadState === 'idle'}
<div class="host-state idle">
<p>Select a plugin view from the sidebar</p>
</div>
{:else if loadState === 'error'}
<div class="host-state error">
<Icon name="warning" size={24} class="error-icon" />
<p class="error-title">Plugin View Error</p>
<div class="error-details">
<p><strong>Plugin:</strong> {currentPluginId || 'unknown'}</p>
<p><strong>Component:</strong> {currentComponent || 'unknown'}</p>
<p class="error-message">{errorText || 'Unknown error'}</p>
{#if pluginInfo}
<p class="error-meta">Frontend entry: {pluginInfo.entry || 'none'}</p>
{/if}
{#if getComponentList().length > 0}
<p class="error-meta">Available components: {getComponentList().join(', ')}</p>
{/if}
</div>
</div>
{:else}
{#if loadState === 'loading'}
<div class="host-state loading">
<div class="spinner"></div>
<p>Loading plugin bundle...</p>
</div>
{/if}
<div
class="plugin-mount-container"
class:mount-hidden={loadState !== 'loaded'}
bind:this={mountContainer}
data-plugin-id={currentPluginId}
data-component={currentComponent}
style="flex:1;min-width:0;min-height:0;height:100%;display:flex;flex-direction:column;position:relative;"
></div>
{/if}
</div>
<style>
.plugin-bundle-host {
flex: 1;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
}
.host-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
text-align: center;
}
.host-state.idle {
color: #555;
}
.host-state.loading {
color: #a0a0b8;
}
.spinner {
width: 24px;
height: 24px;
border: 2px solid #333;
border-top-color: #4ecca3;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-bottom: 1rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.host-state.error {
color: #e94560;
}
.host-state.error :global(.error-icon) {
font-size: 2rem;
margin-bottom: 0.5rem;
}
.error-title {
font-weight: 600;
font-size: 1.1rem;
margin-bottom: 1rem;
}
.error-details {
font-size: 0.85rem;
color: #a0a0b8;
max-width: 400px;
text-align: left;
background: #16213e;
padding: 1rem;
border-radius: 6px;
border: 1px solid #0f3460;
}
.error-details p {
margin: 0.3rem 0;
}
.error-details strong {
color: #e0e0f0;
}
.error-message {
color: #e94560;
font-family: monospace;
font-size: 0.8rem;
margin-top: 0.5rem !important;
padding: 0.5rem;
background: rgba(233, 69, 96, 0.1);
border-radius: 4px;
}
.error-meta {
font-size: 0.75rem;
color: #666;
margin-top: 0.3rem !important;
}
.plugin-mount-container {
flex: 1;
min-width: 0;
min-height: 0;
height: 100%;
display: flex;
flex-direction: column;
position: relative;
}
.plugin-mount-container.mount-hidden {
height: 0;
min-height: 0;
overflow: hidden;
visibility: hidden;
}
</style>
@@ -0,0 +1,320 @@
import * as App from '../../../wailsjs/go/api/App';
window.__VERSTAK_PLUGIN_REGISTRY__ = window.__VERSTAK_PLUGIN_REGISTRY__ || {};
window.__VERSTAK_EVENT_HANDLERS__ = window.__VERSTAK_EVENT_HANDLERS__ || {};
window.__VERSTAK_COMMAND_HANDLERS__ = window.__VERSTAK_COMMAND_HANDLERS__ || {};
if (!window.VerstakPluginRegister) {
window.VerstakPluginRegister = function(pluginId, bundle) {
if (!pluginId || !bundle || !bundle.components) {
console.error('[VerstakPluginRegister] invalid registration:', pluginId);
return;
}
console.log('[VerstakPluginRegister] registered:', pluginId, Object.keys(bundle.components));
window.__VERSTAK_PLUGIN_REGISTRY__[pluginId] = bundle.components;
};
}
function unpack(result) {
if (Array.isArray(result) && result.length === 2 && (typeof result[1] === 'string' || result[1] == null)) {
return [result[0], result[1] || ''];
}
return [result, ''];
}
async function callBackend(pluginId, label, fn) {
try {
const [value, err] = unpack(await fn());
if (err) {
throw new Error(err);
}
return value;
} catch (e) {
const message = e && e.message ? e.message : String(e);
throw new Error('[plugin:' + pluginId + '] ' + label + ' failed: ' + message);
}
}
async function callBackendErrorString(pluginId, label, fn) {
try {
const err = await fn();
if (err) {
throw new Error(err);
}
} catch (e) {
const message = e && e.message ? e.message : String(e);
throw new Error('[plugin:' + pluginId + '] ' + label + ' failed: ' + message);
}
}
function getEventHandlers(eventName) {
if (!window.__VERSTAK_EVENT_HANDLERS__[eventName]) {
window.__VERSTAK_EVENT_HANDLERS__[eventName] = [];
}
return window.__VERSTAK_EVENT_HANDLERS__[eventName];
}
function dispatchLocalEvent(pluginId, eventName, payload) {
const event = {
name: eventName,
pluginId: pluginId,
payload: payload || {},
timestamp: new Date().toISOString()
};
const handlers = getEventHandlers(eventName).slice();
handlers.forEach(function(handler) {
try {
handler(event);
} catch (e) {
console.error('[VerstakPluginAPI] event handler error:', e);
}
});
}
function commandKey(pluginId, commandId) {
return pluginId + ':' + commandId;
}
export function createPluginAPI(pluginId) {
if (!pluginId) {
throw new Error('createPluginAPI requires pluginId');
}
const cleanups = [];
let disposed = false;
function assertActive(label) {
if (disposed) {
throw new Error('[plugin:' + pluginId + '] ' + label + ' failed: API disposed');
}
}
function trackCleanup(fn) {
cleanups.push(fn);
return function untrackAndRun() {
const idx = cleanups.indexOf(fn);
if (idx !== -1) {
cleanups.splice(idx, 1);
}
fn();
};
}
return {
pluginId: pluginId,
capabilities: {
has: async function(capId) {
const info = await callBackend(pluginId, 'capabilities.has(' + capId + ')', function() {
return App.GetPluginCapability(pluginId, capId);
});
return !!(info && info.available);
},
get: function(capId) {
return callBackend(pluginId, 'capabilities.get(' + capId + ')', function() {
return App.GetPluginCapability(pluginId, capId);
});
},
list: function() {
return callBackend(pluginId, 'capabilities.list', function() {
return App.ListPluginCapabilities(pluginId);
});
}
},
events: {
publish: async function(type, payload) {
await callBackendErrorString(pluginId, 'events.publish(' + type + ')', function() {
return App.PublishPluginEvent(pluginId, type, payload || {});
});
dispatchLocalEvent(pluginId, type, payload || {});
},
subscribe: function(type, handler) {
assertActive('events.subscribe(' + type + ')');
if (typeof handler !== 'function') {
throw new Error('events.subscribe requires a handler function');
}
return callBackendErrorString(pluginId, 'events.subscribe(' + type + ')', function() {
return App.SubscribePluginEvent(pluginId, type);
}).then(function() {
const handlers = getEventHandlers(type);
handlers.push(handler);
return trackCleanup(function unsubscribe() {
const current = getEventHandlers(type);
window.__VERSTAK_EVENT_HANDLERS__[type] = current.filter(function(item) {
return item !== handler;
});
});
});
}
},
settings: {
read: async function(key) {
assertActive('settings.read');
const settings = await callBackend(pluginId, 'settings.read', function() {
return App.ReadPluginSettings(pluginId);
});
if (!key) {
return settings || {};
}
return settings ? settings[key] : undefined;
},
write: async function(key, value) {
assertActive('settings.write(' + key + ')');
if (!key) {
throw new Error('settings.write requires a key');
}
const settings = await this.read();
settings[key] = value;
await callBackendErrorString(pluginId, 'settings.write(' + key + ')', function() {
return App.WritePluginSettings(pluginId, settings);
});
return settings;
},
writeAll: function(settings) {
assertActive('settings.writeAll');
return callBackendErrorString(pluginId, 'settings.writeAll', function() {
return App.WritePluginSettings(pluginId, settings || {});
});
}
},
files: {
list: function(relativeDir) {
assertActive('files.list');
return callBackend(pluginId, 'files.list(' + (relativeDir || '') + ')', function() {
return App.ListVaultFiles(pluginId, relativeDir || '');
});
},
metadata: function(relativePath) {
assertActive('files.metadata(' + relativePath + ')');
return callBackend(pluginId, 'files.metadata(' + relativePath + ')', function() {
return App.GetVaultFileMetadata(pluginId, relativePath);
});
},
readText: function(relativePath) {
assertActive('files.readText(' + relativePath + ')');
return callBackend(pluginId, 'files.readText(' + relativePath + ')', function() {
return App.ReadVaultTextFile(pluginId, relativePath);
});
},
writeText: function(relativePath, content, options) {
assertActive('files.writeText(' + relativePath + ')');
return callBackendErrorString(pluginId, 'files.writeText(' + relativePath + ')', function() {
return App.WriteVaultTextFile(pluginId, relativePath, String(content == null ? '' : content), options || {});
});
},
createFolder: function(relativePath) {
assertActive('files.createFolder(' + relativePath + ')');
return callBackendErrorString(pluginId, 'files.createFolder(' + relativePath + ')', function() {
return App.CreateVaultFolder(pluginId, relativePath);
});
},
move: function(fromRelativePath, toRelativePath, options) {
assertActive('files.move(' + fromRelativePath + ')');
return callBackendErrorString(pluginId, 'files.move(' + fromRelativePath + ')', function() {
return App.MoveVaultPath(pluginId, fromRelativePath, toRelativePath, options || {});
});
},
trash: function(relativePath) {
assertActive('files.trash(' + relativePath + ')');
return callBackend(pluginId, 'files.trash(' + relativePath + ')', function() {
return App.TrashVaultPath(pluginId, relativePath);
});
}
},
backend: {
call: async function(method, ...args) {
assertActive('backend.call(' + method + ')');
try {
const App = window['go']?.['api']?.['App'];
if (!App || typeof App[method] !== 'function') {
throw new Error('Backend method not found: ' + method);
}
const result = await App[method](...args);
return result;
} catch (e) {
const message = e && e.message ? e.message : String(e);
throw new Error('[plugin:' + pluginId + '] backend.call(' + method + ') failed: ' + message);
}
}
},
workbench: {
openResource: async function(request) {
assertActive('workbench.openResource');
const result = await callBackend(pluginId, 'workbench.openResource', function() {
return App.OpenWorkbenchResource(pluginId, request || {});
});
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
return result;
},
editResource: async function(request) {
assertActive('workbench.editResource');
const result = await callBackend(pluginId, 'workbench.editResource', function() {
return App.EditWorkbenchResource(pluginId, request || {});
});
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
return result;
}
},
commands: {
register: function(cmdId, handler) {
assertActive('commands.register(' + cmdId + ')');
if (!cmdId) {
throw new Error('commands.register requires a command id');
}
if (typeof handler !== 'function') {
throw new Error('commands.register requires a handler function');
}
return callBackend(pluginId, 'commands.register(' + cmdId + ')', function() {
return App.ExecutePluginCommand(pluginId, cmdId, { validateOnly: true });
}).then(function() {
const key = commandKey(pluginId, cmdId);
window.__VERSTAK_COMMAND_HANDLERS__[key] = handler;
return trackCleanup(function unregisterCommand() {
if (window.__VERSTAK_COMMAND_HANDLERS__[key] === handler) {
delete window.__VERSTAK_COMMAND_HANDLERS__[key];
}
});
});
},
execute: async function(cmdId, args) {
assertActive('commands.execute(' + cmdId + ')');
const declared = await callBackend(pluginId, 'commands.execute(' + cmdId + ')', function() {
return App.ExecutePluginCommand(pluginId, cmdId, args || {});
});
const handler = window.__VERSTAK_COMMAND_HANDLERS__[commandKey(pluginId, cmdId)];
if (!handler) {
throw new Error('[plugin:' + pluginId + '] commands.execute(' + cmdId + ') failed: declared-but-unhandled');
}
const result = await handler(args || {}, declared);
return {
status: 'handled',
pluginId: pluginId,
commandId: cmdId,
result: result
};
}
},
dispose: function() {
if (disposed) return;
disposed = true;
while (cleanups.length > 0) {
const cleanup = cleanups.pop();
try {
cleanup();
} catch (e) {
console.error('[VerstakPluginAPI] cleanup error:', e);
}
}
}
};
}
window.createPluginAPI = createPluginAPI;
window.VerstakPluginAPI = createPluginAPI;
+113 -15
View File
@@ -1,10 +1,17 @@
<script>
import Icon from '../ui/Icon.svelte';
export let p = {};
export let capabilities = [];
export let permissions = [];
export let onSettings = () => {};
export let contributions = {};
export let vaultOpen = false;
export let settingsPanels = [];
export let onEnable = () => {};
export let onDisable = () => {};
$: hasSettingsPanel = (contributions.settingsPanels || []).some(sp => sp.pluginId === pluginId);
$: m = p.manifest || {};
$: pluginId = m.id || 'unknown';
$: hasSettingsPanel = settingsPanels.length > 0;
$: hasUIPermission = (m.permissions || []).includes('ui.register');
$: hasStoragePermission = (m.permissions || []).includes('storage.namespace');
$: hasCommandsPermission = (m.permissions || []).includes('commands.register');
@@ -20,13 +27,12 @@
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,
sidebar: (contributions.sidebarItems || []).filter(s => s.pluginId === pluginId).length,
statusbar: (contributions.statusBarItems || []).filter(s => s.pluginId === pluginId).length,
openProviders: (contributions.openProviders || []).filter(o => o.pluginId === pluginId).length,
};
$: contribSummary = (() => {
@@ -35,6 +41,7 @@
if (contribCounts.commands > 0) parts.push(contribCounts.commands + ' command' + (contribCounts.commands !== 1 ? 's' : ''));
if (contribCounts.sidebar > 0) parts.push(contribCounts.sidebar + ' sidebar' + (contribCounts.sidebar !== 1 ? 's' : ''));
if (contribCounts.statusbar > 0) parts.push(contribCounts.statusbar + ' statusbar' + (contribCounts.statusbar !== 1 ? 's' : ''));
if (contribCounts.openProviders > 0) parts.push(contribCounts.openProviders + ' openProvider' + (contribCounts.openProviders !== 1 ? 's' : ''));
return parts.length > 0 ? parts.join(', ') : 'none';
})();
@@ -54,13 +61,20 @@
$: missingOptional = (m.optionalRequires || []).filter(opt =>
!capabilities.some(c => c.name === opt)
);
export let actionFeedback = {}; // { [pluginId]: 'enabling' | 'disabling' | null }
$: isDisabled = p.status === 'disabled' || !p.enabled;
$: canToggle = p.status !== 'failed' && p.status !== 'incompatible' && p.status !== 'missing-required-capability' && p.status !== 'discovered';
$: isBusy = actionFeedback[pluginId] != null;
$: busyAction = actionFeedback[pluginId] || null;
</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>
@@ -120,7 +134,7 @@
{/each}
</div>
{#if missingRequired.length > 0}
<p class="warning">⚠ Missing required capabilities: {missingRequired.join(', ')}</p>
<p class="warning"><Icon name="warning" size={12} /> Missing required capabilities: {missingRequired.join(', ')}</p>
{/if}
</div>
{/if}
@@ -138,7 +152,7 @@
{/each}
</div>
{#if missingOptional.length > 0}
<p class="info">ℹ Optional capabilities not available — plugin running in degraded mode</p>
<p class="info"><Icon name="warning" size={12} /> Optional capabilities not available — plugin running in degraded mode</p>
{/if}
</div>
{/if}
@@ -152,7 +166,7 @@
{@const isDangerous = dangerousPermissions.includes(perm)}
<span class="tag" class:dangerous={isDangerous}>
{perm}
{#if isDangerous}<span class="danger-icon">⚠</span>{/if}
{#if isDangerous}<Icon name="warning" size={12} class="danger-icon" />{/if}
</span>
{/each}
</div>
@@ -167,15 +181,29 @@
<!-- Actions -->
<div class="card-actions">
{#if hasSettingsPanel}
<button class="btn-settings" on:click={() => onSettings(m.id)} type="button">
⚙ Settings
<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'}>
<Icon name="gear" size={14} /> Settings
</button>
{/if}
{#if vaultOpen && canToggle}
{#if isDisabled}
<button class="btn-enable" on:click={() => onEnable(m.id)} type="button" disabled={isBusy}>
{#if busyAction === 'enabling'}⟳ Enabling...{:else}▶ Enable{/if}
</button>
{:else}
<button class="btn-disable" on:click={() => onDisable(m.id)} type="button" disabled={isBusy}>
{#if busyAction === 'disabling'}⟳ Disabling...{:else}⏸ Disable{/if}
</button>
{/if}
{/if}
{#if !vaultOpen && canToggle}
<span class="vault-hint">Open a vault to manage plugin state</span>
{/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 !hasUIPermission && m.contributes && ((m.contributes.views || []).length > 0 || (m.contributes.sidebarItems || []).length > 0 || (m.contributes.settingsPanels || []).length > 0)}
<p class="warning"><Icon name="warning" size={12} /> Plugin has UI contributions but lacks ui.register permission</p>
{/if}
</div>
@@ -185,6 +213,7 @@
border: 1px solid #0f3460;
border-radius: 8px;
padding: 1rem;
min-width: 0;
}
.plugin-card.disabled {
@@ -199,6 +228,8 @@
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
margin-bottom: 0.5rem;
}
@@ -206,6 +237,12 @@
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
flex-wrap: wrap;
}
.plugin-id strong {
overflow-wrap: anywhere;
}
.status-dot {
@@ -242,7 +279,7 @@
.card-meta {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.3rem;
margin-bottom: 0.75rem;
font-size: 0.8rem;
@@ -251,6 +288,7 @@
.meta-row {
display: flex;
gap: 0.5rem;
min-width: 0;
}
.label {
@@ -262,6 +300,8 @@
font-family: monospace;
font-size: 0.75rem;
color: #a0a0b8;
min-width: 0;
overflow-wrap: anywhere;
}
.section {
@@ -290,6 +330,8 @@
font-size: 0.75rem;
font-family: monospace;
color: #e0e0e0;
max-width: 100%;
overflow-wrap: anywhere;
}
.tag.provides {
@@ -320,7 +362,7 @@
}
.check { color: #4ecca3; margin-left: 2px; }
.danger-icon { color: #e94560; margin-left: 2px; }
:global(.danger-icon) { color: #e94560; margin-left: 2px; vertical-align: middle; }
.info {
color: #ffc857;
@@ -347,13 +389,18 @@
.card-actions {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 0.75rem;
padding-top: 0.5rem;
border-top: 1px solid #0f3460;
}
.btn-settings {
display: inline-flex;
align-items: center;
gap: 0.3rem;
background: #0f3460;
border: 1px solid #1a3a5c;
color: #e0e0f0;
@@ -366,4 +413,55 @@
.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;
}
@media (max-width: 760px) {
.card-meta {
grid-template-columns: 1fr;
}
.meta-row {
flex-direction: column;
gap: 0.15rem;
}
.label {
min-width: 0;
}
}
</style>
@@ -1,7 +1,10 @@
<script>
import Icon from '../ui/Icon.svelte';
import PluginCard from './PluginCard.svelte';
import { onMount } from 'svelte';
import { GetPlugins, GetCapabilities, GetPermissions, GetContributions, ReloadPlugins, GetVaultStatus } from '../../../wailsjs/go/api/App';
import PluginBundleHost from '../plugin-host/PluginBundleHost.svelte';
import { onMount, tick } from 'svelte';
import { GetPlugins, GetCapabilities, GetPermissions, GetContributions, ReloadPlugins, GetVaultStatus, GetVaultPluginState, EnablePlugin, DisablePlugin, ReadPluginSettings, WritePluginSettings, GetPluginFrontendInfo, WriteFrontendLog } from '../../../wailsjs/go/api/App';
import { debug } from '../log/debug.js';
let plugins = [];
let capabilities = [];
@@ -10,79 +13,243 @@
let loading = true;
let error = '';
let vaultStatus = { status: 'unknown', path: '', vaultId: '' };
let vaultPluginState = { enabledPlugins: [], disabledPlugins: [], desiredPlugins: [] };
let settingsPanel = null;
let settingsData = {};
let settingsPluginId = '';
let settingsError = null;
let settingsPluginInfo = null;
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 activeSettingsPanelId = '';
$: if (activeSettingsPluginId && activeSettingsPanelId) {
const key = `${activeSettingsPluginId}:${activeSettingsPanelId}`;
if (key !== lastOpenedKey) {
lastOpenedKey = key;
openSettingsFromProps(activeSettingsPluginId, activeSettingsPanelId);
}
}
function showToast(msg, type = 'success') {
toastMessage = msg;
toastType = type;
setTimeout(() => {
toastMessage = '';
}, 4000);
}
function notifyPluginsChanged() {
window.dispatchEvent(new CustomEvent('verstak:plugins-changed'));
}
function unpackBackendResult(result) {
if (Array.isArray(result) && result.length === 2 && (typeof result[1] === 'string' || result[1] == null)) {
return { value: result[0], error: result[1] || '' };
}
return { value: result, error: '' };
}
function unpackReloadResult(result) {
if (Array.isArray(result)) {
return {
count: Number(result[0] || 0),
summary: result[1] || `Reloaded ${Number(result[0] || 0)} plugin(s).`,
};
}
const count = Number(result || 0);
return { count, summary: `Reloaded ${count} plugin(s).` };
}
async 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;
try {
const info = await GetPluginFrontendInfo(pluginId);
settingsPluginInfo = info;
} catch { settingsPluginInfo = null; }
ReadPluginSettings(pluginId).then(result => {
const unpacked = unpackBackendResult(result);
if (unpacked.error) {
settingsError = unpacked.error;
settingsData = {};
return;
}
settingsData = unpacked.value || {};
}).catch(() => { settingsData = {}; });
} else {
settingsError = `Settings panel not found for plugin "${pluginId}". Check that the plugin is enabled and has settingsPanels in its manifest.`;
}
}
$: 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() {
debug.log('[PluginManager] loadAll: START');
error = '';
loading = true;
try {
debug.log('[PluginManager] loadAll: calling GetPlugins...');
const p = await GetPlugins();
plugins = p || [];
debug.log('[PluginManager] loadAll: GetPlugins returned', plugins.length, 'plugins');
for (var i = 0; i < plugins.length; i++) {
debug.log('[PluginManager] loadAll: plugin[' + i + ']:', plugins[i].manifest?.id, 'status:', plugins[i].status, 'enabled:', plugins[i].enabled);
}
} catch (e) {
debug.log('[PluginManager] loadAll: GetPlugins ERROR:', String(e));
WriteFrontendLog('PluginManager', 'loadAll: GetPlugins ERROR: ' + String(e));
error = 'GetPlugins: ' + String(e);
loading = false;
return;
}
// Vault status — non-critical
GetVaultStatus().then(v => { vaultStatus = v || { status: 'unknown', path: '', vaultId: '' }; }).catch(() => {});
// 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(() => {});
// Collect all async loads but await them so loading stays true until all are done
try {
debug.log('[PluginManager] loadAll: loading vault/capabilities/permissions/contributions...');
const [v, caps, perms, contribs] = await Promise.all([
GetVaultStatus().catch(() => ({ status: 'unknown', path: '', vaultId: '' })),
GetCapabilities().catch(() => []),
GetPermissions().catch(() => []),
GetContributions().catch(() => ({})),
]);
vaultStatus = v || { status: 'unknown', path: '', vaultId: '' };
capabilities = caps || [];
permissions = perms || [];
contributions = contribs || {};
debug.log('[PluginManager] loadAll: vault=' + vaultStatus.status + ' caps=' + capabilities.length + ' perms=' + permissions.length);
WriteFrontendLog('PluginManager', 'loadAll: vault=' + vaultStatus.status + ' caps=' + capabilities.length + ' perms=' + permissions.length);
} catch (e) {
debug.log('[PluginManager] loadAll: non-critical load ERROR:', String(e));
WriteFrontendLog('PluginManager', 'loadAll: non-critical ERROR: ' + String(e));
console.error('[PluginManager] non-critical load error:', e);
}
if (vaultStatus.status === 'open') {
try {
debug.log('[PluginManager] loadAll: calling GetVaultPluginState...');
vaultPluginState = await GetVaultPluginState() || { enabledPlugins: [], disabledPlugins: [], desiredPlugins: [] };
WriteFrontendLog('PluginManager', 'loadAll: GetVaultPluginState returned');
} catch (e) {
WriteFrontendLog('PluginManager', 'loadAll: GetVaultPluginState ERROR: ' + String(e));
}
}
loading = false;
await tick();
debug.log('[PluginManager] loadAll: END, loading=false');
WriteFrontendLog('PluginManager', 'loadAll: END, loading=false');
}
onMount(() => { loadAll(); });
async function reload() {
loading = true;
debug.log('[PluginManager] reload: START');
reloading = true;
error = '';
let resultMsg = '';
try {
await ReloadPlugins();
debug.log('[PluginManager] reload: calling ReloadPlugins...');
const { count, summary } = unpackReloadResult(await ReloadPlugins());
debug.log('[PluginManager] reload: ReloadPlugins returned count=' + count + ' summary=' + summary);
resultMsg = `Reloaded ${count} plugin(s). ${summary}`;
} catch (e) {
debug.log('[PluginManager] reload: ReloadPlugins ERROR:', String(e));
error = 'Reload: ' + String(e);
loading = false;
reloading = false;
return;
}
debug.log('[PluginManager] reload: calling loadAll after reload...');
await loadAll();
notifyPluginsChanged();
reloading = false;
debug.log('[PluginManager] reload: END');
showToast(resultMsg, 'success');
}
async function enablePlugin(pluginId) {
debug.log('[PluginManager] enablePlugin:', pluginId);
actionFeedback = { ...actionFeedback, [pluginId]: 'enabling' };
error = '';
const err = await EnablePlugin(pluginId);
if (err) {
debug.log('[PluginManager] enablePlugin: ERROR:', err);
actionFeedback = { ...actionFeedback, [pluginId]: null };
error = 'Enable: ' + err;
return;
}
debug.log('[PluginManager] enablePlugin: success, reloading...');
// Reload to get updated state
try { await ReloadPlugins(); } catch (e) { /* ignore */ }
await loadAll();
notifyPluginsChanged();
actionFeedback = { ...actionFeedback, [pluginId]: null };
debug.log('[PluginManager] enablePlugin: done');
showToast(`Plugin "${pluginId}" enabled`, 'success');
}
async function disablePlugin(pluginId) {
debug.log('[PluginManager] disablePlugin:', pluginId);
actionFeedback = { ...actionFeedback, [pluginId]: 'disabling' };
error = '';
const err = await DisablePlugin(pluginId);
if (err) {
debug.log('[PluginManager] disablePlugin: ERROR:', err);
actionFeedback = { ...actionFeedback, [pluginId]: null };
error = 'Disable: ' + err;
return;
}
debug.log('[PluginManager] disablePlugin: success, reloading...');
// Reload to get updated state
try { await ReloadPlugins(); } catch (e) { /* ignore */ }
await loadAll();
notifyPluginsChanged();
actionFeedback = { ...actionFeedback, [pluginId]: null };
debug.log('[PluginManager] disablePlugin: done');
showToast(`Plugin "${pluginId}" disabled`, 'info');
}
$: 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 closeSettings() {
settingsPanel = null;
settingsPluginId = '';
settingsError = null;
lastOpenedKey = '';
window.dispatchEvent(new CustomEvent('verstak:close-settings'));
}
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);
}
WritePluginSettings(settingsPluginId, settingsData).then(err => {
if (err) console.error('WritePluginSettings:', err);
}).catch(e => console.error('WritePluginSettings:', e));
}
</script>
<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>
<div class="header-left">
<h2>Plugin Manager</h2>
@@ -92,8 +259,8 @@
</span>
{/if}
</div>
<button class="reload-btn" on:click={reload} type="button" disabled={loading}>
{loading ? '⟳ Loading...' : '⟳ Reload'}
<button class="reload-btn" on:click={reload} type="button" disabled={loading || reloading}>
{reloading ? '⟳ Reloading...' : '⟳ Reload'}
</button>
</header>
@@ -101,7 +268,7 @@
<div class="loading">Scanning plugin directories...</div>
{:else if error}
<div class="error">
<div class="error-icon">⚠</div>
<Icon name="warning" size={24} class="error-icon" />
<div class="error-message">{error}</div>
<button class="retry-btn" on:click={loadAll} type="button">⟳ Retry</button>
</div>
@@ -112,13 +279,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 +297,38 @@
{:else}
<div class="plugin-list">
{#each plugins as p}
<PluginCard {p} {capabilities} {permissions} {contributions} onSettings={openSettings} />
<PluginCard {p} {capabilities} {permissions} {contributions} {vaultOpen} {actionFeedback} settingsPanels={(contributions.settingsPanels || []).filter(sp => sp.pluginId === p.manifest?.id)} 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,97 +349,112 @@
</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 -->
{#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.title}</h3>
<button class="modal-close" on:click={closeSettings} type="button">✕</button>
</div>
<div class="modal-body">
<p class="settings-hint">Plugin: <code>{settingsPluginId}</code></p>
{#if settingsPluginInfo && settingsPluginInfo.entry}
<PluginBundleHost
pluginId={settingsPluginId}
componentId={settingsPanel.component || settingsPanel.id}
/>
{:else}
<p class="settings-hint">Component: <code>{settingsPanel.component}</code></p>
<p class="placeholder">Settings panel frontend bundle not available</p>
{/if}
</div>
</div>
</div>
{/if}
{/key}
</div>
{#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; }
<style>
.plugin-manager {
flex: 1;
width: min(100%, 1100px);
min-height: 0;
padding: 0.5rem 0.5rem 1.5rem 0;
position: relative;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
gap: 0.75rem;
flex-wrap: wrap;
margin-bottom: 1.25rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid #0f3460;
}
.header-left {
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 0;
flex-wrap: wrap;
}
h2 { color: #e0e0e0; font-size: 1.3rem; margin: 0; }
.vault-badge {
max-width: 100%;
font-size: 0.75rem;
padding: 0.2rem 0.6rem;
border-radius: 12px;
font-weight: 600;
border: 1px solid;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vault-open {
background: rgba(78, 204, 163, 0.15);
color: #4ecca3;
border-color: #4ecca3;
}
.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;
}
.vault-open { background: rgba(78, 204, 163, 0.15); color: #4ecca3; border-color: #4ecca3; }
.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 {
background: #0f3460; color: #e0e0e0; border: 1px solid #533483;
padding: 0.4rem 1rem; border-radius: 6px; cursor: pointer; font-size: 0.85rem;
}
.reload-btn:hover:not(:disabled) { background: #533483; }
.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 {
padding: 2rem; text-align: center; color: #a0a0b8;
}
.error { color: #e94560; }
.error-icon { font-size: 2rem; margin-bottom: 0.5rem; }
:global(.error-icon) { color: #e94560; margin-bottom: 0.5rem; }
.error-message {
font-family: monospace; font-size: 0.85rem; margin-bottom: 1rem; word-break: break-word;
}
@@ -269,41 +479,32 @@
.hint-list { list-style: none; padding: 0; margin: 0.5rem 0; font-size: 0.8rem; opacity: 0.7; }
.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; }
.plugin-list { display: flex; flex-direction: column; gap: 0.75rem; margin-bottom: 1.5rem; min-width: 0; }
.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;
}
.registry-section summary {
cursor: pointer; color: #a0a0b8; font-size: 0.9rem; font-weight: 600;
}
.registry-section summary { 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; }
th {
text-align: left; padding: 0.4rem 0.5rem; color: #a0a0b8; border-bottom: 1px solid #0f3460;
}
th { 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 code { color: #e0e0e0; }
:global(.status-stable) { color: #4ecca3; }
:global(.status-draft) { color: #ffc857; }
:global(.status-deprecated) { color: #e94560; }
.source-badge {
font-size: 0.75rem;
padding: 0.1rem 0.4rem;
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;
}
.source-badge { font-size: 0.75rem; padding: 0.1rem 0.4rem; 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 {
position: fixed; inset: 0;
background: rgba(0, 0, 0, 0.6);
@@ -311,77 +512,37 @@
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;
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;
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 { 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;
@media (max-width: 760px) {
.plugin-manager {
width: 100%;
padding-right: 0;
}
header {
align-items: flex-start;
}
.reload-btn {
width: 100%;
}
.modal {
width: min(480px, calc(100vw - 2rem));
max-height: calc(100vh - 2rem);
}
}
</style>
+233 -41
View File
@@ -1,75 +1,267 @@
<script>
import { onMount } from 'svelte';
import { onDestroy, onMount } from 'svelte';
import * as App from '../../../wailsjs/go/api/App';
import WorkspaceTree from './WorkspaceTree.svelte';
import Icon from '../ui/Icon.svelte';
import { debug } from '../log/debug.js';
function flog(msg) {
App.WriteFrontendLog('Sidebar', msg);
}
let sidebarItems = [];
let activeView = '';
let plugins = [];
let contributions = { sidebarItems: [], views: [], commands: [], settingsPanels: [] };
let vaultStatus = { status: 'unknown', path: '', vaultId: '' };
let sidebarItems = [];
let errorMessage = '';
onMount(async () => {
let navItems = [
{ id: 'plugin-manager', label: 'Plugin Manager', icon: 'puzzle' },
];
$: vaultOpen = vaultStatus.status === 'open';
async function loadSidebar() {
debug.log('[Sidebar] onMount: START');
flog('onMount: START');
let contribErr = false;
try {
const [contribs, pluginList] = await Promise.all([
App.GetContributions(),
App.GetPlugins(),
debug.log('[Sidebar] onMount: loading plugins/vault/contributions...');
flog('onMount: loading plugins/vault/contributions...');
const [p, v, contribs] = await Promise.all([
App.GetPlugins().catch(() => []),
App.GetVaultStatus().catch(() => ({ status: 'unknown', path: '', vaultId: '' })),
App.GetContributions().catch(() => { contribErr = true; return {}; }),
]);
contributions = contribs;
plugins = pluginList;
const pluginMap = new Map(pluginList.map(p => [p.manifest.id, p]));
plugins = p || [];
vaultStatus = v;
debug.log('[Sidebar] onMount: plugins=' + plugins.length + ' vault=' + vaultStatus.status);
flog('onMount: plugins=' + plugins.length + ' vault=' + vaultStatus.status);
if (contribErr) {
errorMessage = 'Failed to load plugin contributions';
}
sidebarItems = (contribs.sidebarItems || []).filter(item => {
const plugin = pluginMap.get(item.pluginId);
return plugin && plugin.manifest.permissions.includes('ui.register');
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));
debug.log('[Sidebar] onMount: sidebarItems=' + sidebarItems.length);
flog('onMount: sidebarItems=' + sidebarItems.length);
} catch (e) {
debug.log('[Sidebar] onMount: ERROR:', String(e));
flog('onMount: ERROR: ' + String(e));
console.error('[Sidebar] load error:', e);
errorMessage = 'Failed to load sidebar';
}
debug.log('[Sidebar] onMount: END');
flog('onMount: END');
}
onMount(() => {
loadSidebar();
window.addEventListener('verstak:plugins-changed', loadSidebar);
});
function openView(viewId) {
activeView = viewId;
window.dispatchEvent(new CustomEvent('verstak:open-view', { detail: { viewId } }));
onDestroy(() => {
window.removeEventListener('verstak:plugins-changed', loadSidebar);
});
function handleNav(id) {
debug.log('[Sidebar] handleNav:', id);
window.dispatchEvent(new CustomEvent('verstak:nav', { detail: { viewId: id } }));
}
function handleSidebarItem(item) {
debug.log('[Sidebar] handleSidebarItem:', item.id, '-> view:', item.view);
// 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>
<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>
<aside class="sidebar">
<div class="sidebar-header">
<Icon name="logo" size={20} class="sidebar-logo" />
<span class="sidebar-title">Verstak</span>
</div>
<nav class="sidebar-nav">
{#each navItems as item}
<button
class="nav-item"
on:click={() => handleNav(item.id)}
type="button"
>
<Icon name={item.icon} size={16} class="nav-icon" />
<span class="nav-label">{item.label}</span>
</button>
{/each}
</nav>
{#if sidebarItems.length > 0}
<div class="sidebar-section">
<span class="section-label">Plugins</span>
{#each sidebarItems as item}
<button
class="nav-item plugin-item"
on:click={() => handleSidebarItem(item)}
type="button"
>
<Icon name={item.icon || 'plugin'} size={16} class="nav-icon icon-plugin" />
<span class="nav-label">{item.title || item.id}</span>
</button>
{/each}
</div>
{/if}
{#if vaultOpen}
<WorkspaceTree />
{/if}
<div class="sidebar-footer">
{#if errorMessage}
<span class="sidebar-error">
<Icon name="warning" size={10} class="sidebar-error-icon" />
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}
</span>
{/if}
</div>
</aside>
<style>
.sidebar {
width: 200px;
width: 220px;
min-width: 220px;
background: #16213e;
border-right: 1px solid #0f3460;
display: flex;
flex-direction: column;
padding: 0.5rem 0;
overflow-y: auto;
border-right: 1px solid #0f3460;
overflow: hidden;
}
.sidebar-item {
.sidebar-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
padding: 1rem 1.25rem;
border-bottom: 1px solid #0f3460;
}
:global(.sidebar-logo) {
width: 1.2rem;
height: 1.2rem;
color: #4ecca3;
flex-shrink: 0;
}
.sidebar-title {
color: #e0e0f0;
font-size: 1rem;
font-weight: 600;
}
.sidebar-nav {
display: flex;
flex-direction: column;
padding: 0.5rem 0.75rem;
gap: 0.15rem;
}
.sidebar-section {
display: flex;
flex-direction: column;
padding: 0.5rem 0.75rem;
gap: 0.15rem;
border-top: 1px solid #0f3460;
margin-top: 0.25rem;
}
:global(workspace-tree) {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.section-label {
color: #666;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.25rem 0.5rem;
}
.nav-item {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0.6rem;
padding: 0.45rem 0.75rem;
background: none;
border: none;
color: #a0a0b0;
color: #a0a0b8;
font-size: 0.85rem;
cursor: pointer;
border-radius: 6px;
text-align: left;
font-size: 0.9rem;
width: 100%;
transition: background 0.15s, color 0.15s;
}
.nav-item:hover {
background: #0f3460;
color: #e0e0f0;
}
:global(.nav-icon) {
width: 1.2rem;
height: 1.2rem;
flex-shrink: 0;
color: currentColor;
}
:global(.nav-icon.icon-plugin) {
color: #a78bfa;
}
.nav-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar-footer {
margin-top: auto;
padding: 0.75rem 1.25rem;
border-top: 1px solid #0f3460;
}
.vault-indicator {
font-size: 0.7rem;
color: #666;
}
.vault-indicator.vault-open {
color: #4ecca3;
}
.vault-indicator.vault-closed {
color: #a0a0b8;
}
.sidebar-error {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.7rem;
color: #e94560;
margin-bottom: 0.25rem;
}
:global(.sidebar-error-icon) {
color: #e94560;
}
.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>
@@ -0,0 +1,378 @@
<script>
import { onMount } from 'svelte';
import * as App from '../../../wailsjs/go/api/App';
import Icon from '../ui/Icon.svelte';
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) {
console.error('[VaultSelection] load settings:', e);
}
loading = false;
});
async function browseNewVault() {
const path = await App.SelectDirectory();
if (path) {
newVaultPath = path;
}
}
async function browseOpenVault() {
const path = await App.SelectVaultForOpen();
if (path) {
openVaultPath = path;
}
}
async function createVault() {
error = '';
if (!newVaultPath.trim()) {
error = 'Please enter or select a path for the new vault';
return;
}
creating = true;
try {
const createErr = await App.CreateVault(newVaultPath.trim());
if (createErr) {
error = 'Create vault: ' + createErr;
creating = false;
return;
}
const openErr = await App.OpenVault(newVaultPath.trim());
if (openErr) {
error = 'Open vault: ' + openErr;
creating = false;
return;
}
const setErr = await App.SetCurrentVault(newVaultPath.trim());
if (setErr) {
console.warn('[VaultSelection] SetCurrentVault:', setErr);
}
window.dispatchEvent(new CustomEvent('verstak:vault-opened'));
} catch (e) {
error = String(e);
creating = false;
}
}
async function openExistingVault() {
error = '';
if (!openVaultPath.trim()) {
error = 'Please enter or select a path to an existing vault';
return;
}
opening = true;
try {
const openErr = await App.OpenVault(openVaultPath.trim());
if (openErr) {
error = 'Open vault: ' + openErr;
opening = false;
return;
}
const setErr = await App.SetCurrentVault(openVaultPath.trim());
if (setErr) {
console.warn('[VaultSelection] SetCurrentVault:', setErr);
}
window.dispatchEvent(new CustomEvent('verstak:vault-opened'));
} catch (e) {
error = String(e);
opening = false;
}
}
async function openRecent(path) {
error = '';
opening = true;
try {
const openErr = await App.OpenVault(path);
if (openErr) {
error = 'Open vault: ' + openErr;
opening = false;
return;
}
const setErr = await App.SetCurrentVault(path);
if (setErr) {
console.warn('[VaultSelection] SetCurrentVault:', setErr);
}
window.dispatchEvent(new CustomEvent('verstak:vault-opened'));
} catch (e) {
error = String(e);
opening = false;
}
}
</script>
{#if loading}
<div class="vault-selection">
<div class="vault-selection-inner">
<p class="loading-text">Loading...</p>
</div>
</div>
{:else}
<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">
<Icon name="warning" size={14} class="error-icon" />
<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="Select or type a path..."
disabled={creating}
/>
<button class="btn-secondary" on:click={browseNewVault} type="button" disabled={creating}>
Browse…
</button>
</div>
<div class="button-row">
<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="Select or type a path..."
disabled={opening}
/>
<button class="btn-secondary" on:click={browseOpenVault} type="button" disabled={opening}>
Browse…
</button>
</div>
<div class="button-row">
<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}>
<Icon name="vault" size={16} class="recent-icon" />
<span class="recent-path">{path}</span>
</button>
</li>
{/each}
</ul>
</div>
{/if}
</div>
</div>
{/if}
<style>
.vault-selection {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
background: #1a1a2e;
padding: 2rem;
}
.vault-selection-inner {
max-width: 560px;
width: 100%;
}
.loading-text {
color: #a0a0b8;
text-align: center;
font-size: 1rem;
}
.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;
}
:global(.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;
margin-bottom: 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;
}
.button-row {
display: flex;
justify-content: flex-end;
}
.btn-primary {
background: #4ecca3;
color: #1a1a2e;
border: none;
padding: 0.5rem 1.25rem;
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
font-weight: 600;
}
.btn-primary:hover:not(:disabled) {
background: #3dbb92;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-secondary {
background: #0f3460;
color: #a0a0b8;
border: 1px solid #1a3a5c;
padding: 0.5rem 0.75rem;
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
white-space: nowrap;
}
.btn-secondary:hover:not(:disabled) {
background: #1a3a5c;
color: #e0e0f0;
}
.btn-secondary: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;
}
:global(.recent-icon) { flex-shrink: 0; }
.recent-path {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
+156 -53
View File
@@ -1,84 +1,114 @@
<script>
import PluginBundleHost from '../plugin-host/PluginBundleHost.svelte';
import { onMount } from 'svelte';
import * as App from '../../../wailsjs/go/api/App';
import Icon from '../ui/Icon.svelte';
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';
$: hasFrontend = currentPlugin?.manifest?.frontend?.entry != null;
$: hostPluginId = currentView?.pluginId || activeViewPluginId;
$: hostComponentId = currentView?.component || null;
// Reset render error when view changes
$: if (activeView) {
renderError = null;
}
function onHostError(e) {
renderError = e.detail?.message || 'Plugin view error';
}
</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">
<Icon name="warning" size={24} class="error-icon" />
<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>
<Icon name={currentView.icon || 'logo'} size={20} class="view-icon" />
<h2>{currentView.title}</h2>
{#if hasFrontend}
<span class="frontend-badge">frontend bundle</span>
{:else}
<span class="no-frontend-badge">no frontend bundle</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>
{#if hasFrontend}
<PluginBundleHost
pluginId={hostPluginId}
componentId={hostComponentId}
/>
{:else}
<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 not available</p>
</div>
{/if}
</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 {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
background: #1a1a2e;
}
.view {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
padding: 1.5rem;
}
.view.degraded {
@@ -98,14 +128,35 @@
color: #e0e0f0;
flex: 1;
}
.view-icon { font-size: 1.3rem; }
:global(.view-icon) {
width: 1.3rem;
height: 1.3rem;
color: #a78bfa;
flex-shrink: 0;
}
.frontend-badge {
font-size: 0.7rem;
padding: 0.15rem 0.5rem;
background: rgba(78, 204, 163, 0.15);
color: #4ecca3;
border-radius: 8px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.no-frontend-badge {
font-size: 0.7rem;
padding: 0.15rem 0.5rem;
background: rgba(233, 69, 96, 0.1);
color: #e94560;
border-radius: 8px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.view-content {
flex: 1;
overflow: auto;
}
.plugin-view-host {
width: 100%;
min-height: 200px;
min-width: 0;
}
.placeholder {
color: #666;
@@ -115,8 +166,67 @@
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;
}
:global(.error-icon) {
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 +237,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>
+148
View File
@@ -0,0 +1,148 @@
<script>
import PluginBundleHost from '../plugin-host/PluginBundleHost.svelte';
import Icon from '../ui/Icon.svelte';
export let openedResource = null;
$: providerPluginId = openedResource?.providerPluginId || '';
$: providerComponent = openedResource?.providerComponent || '';
$: resourcePath = openedResource?.request?.path || '';
$: providerId = openedResource?.providerId || '';
$: requestMode = openedResource?.request?.mode || 'view';
$: requestContext = openedResource?.request?.context?.notesMode || openedResource?.request?.context?.isInsideNotesFolder
? 'notes-markdown'
: ((openedResource?.request?.extension === '.md' || openedResource?.request?.extension === '.markdown') ? 'generic-markdown' : 'generic-text');
$: componentProps = openedResource || {};
$: mountKey = [
providerPluginId,
providerComponent,
resourcePath,
requestMode,
requestContext,
].join(':');
function closeWorkbench() {
window.dispatchEvent(new CustomEvent('verstak:close-workbench', { cancelable: true }));
}
</script>
<div class="workbench-host">
{#if openedResource?.status === 'no-provider'}
<div class="workbench-header">
<span class="workbench-title">{resourcePath}</span>
<span class="workbench-provider">no-provider</span>
<button class="close-btn" type="button" title="Close" aria-label="Close" on:click={closeWorkbench}>
<Icon name="x" size={18} />
</button>
</div>
<div class="workbench-empty no-provider" data-workbench-status="no-provider">
<p>No viewer/editor available</p>
<p class="workbench-meta">{requestMode} · {requestContext}</p>
</div>
{:else if openedResource}
<div class="workbench-header">
<span class="workbench-title">{resourcePath}</span>
<span class="workbench-provider">{providerId}</span>
<button class="close-btn" type="button" title="Close" aria-label="Close" on:click={closeWorkbench}>
<Icon name="x" size={18} />
</button>
</div>
<div class="workbench-content">
{#key mountKey}
<PluginBundleHost
pluginId={providerPluginId}
componentId={providerComponent}
{componentProps}
/>
{/key}
</div>
{:else}
<div class="workbench-empty">
<p>No resource opened</p>
</div>
{/if}
</div>
<style>
.workbench-host {
flex: 1;
min-width: 0;
min-height: 0;
height: 100%;
display: flex;
flex-direction: column;
background: #1a1a2e;
}
.workbench-header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid #16213e;
flex-shrink: 0;
}
.workbench-title {
color: #e0e0f0;
font-size: 0.95rem;
font-weight: 600;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.close-btn {
width: 2rem;
height: 2rem;
min-height: 0;
padding: 0;
border: 1px solid #1a3a5c;
border-radius: 4px;
background: #0f3460;
color: #a0a0b8;
flex-shrink: 0;
cursor: pointer;
}
.close-btn:hover {
color: #e0e0f0;
background: #1a4a7a;
}
.workbench-provider {
color: #4ecca3;
font-size: 0.75rem;
margin-left: auto;
}
.workbench-content {
min-width: 0;
min-height: 0;
height: 100%;
flex: 1;
display: flex;
flex-direction: column;
padding: 0;
}
.workbench-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: #666;
}
.workbench-empty.no-provider {
flex-direction: column;
gap: 0.35rem;
}
.workbench-meta {
margin: 0;
color: #8b8ba8;
font-size: 0.8rem;
}
</style>
+180
View File
@@ -0,0 +1,180 @@
<script>
import PluginBundleHost from '../plugin-host/PluginBundleHost.svelte';
import * as App from '../../../wailsjs/go/api/App';
export let selectedWorkspaceName = '';
export let nodes = [];
let contributions = {};
let plugins = [];
let workspaceTools = [];
let activeToolKey = '';
$: selectedWorkspace = nodes.find(n => n.id === selectedWorkspaceName || n.name === selectedWorkspaceName || n.rootPath === selectedWorkspaceName) || null;
$: workspaceRootPath = selectedWorkspace?.rootPath || selectedWorkspace?.name || selectedWorkspace?.id || '';
$: workspaceTitle = selectedWorkspace?.title || selectedWorkspace?.name || selectedWorkspace?.id || selectedWorkspaceName;
$: workspaceType = selectedWorkspace?.type || 'workspace';
$: activeTool = workspaceTools.find(tool => toolKey(tool) === activeToolKey) || workspaceTools[0] || null;
$: if (workspaceTools.length > 0 && (!activeToolKey || !workspaceTools.some(tool => toolKey(tool) === activeToolKey))) {
activeToolKey = toolKey(workspaceTools[0]);
}
$: if (selectedWorkspaceName) loadTools();
function toolKey(tool) {
return `${tool?.pluginId || ''}:${tool?.id || ''}`;
}
async function loadTools() {
try {
const [c, p] = await Promise.all([
App.GetContributions().catch(() => ({})),
App.GetPlugins().catch(() => []),
]);
contributions = c || {};
plugins = p || [];
const enabledIds = new Set(
plugins.filter(pl => pl.enabled && (pl.status === 'loaded' || pl.status === 'degraded')).map(pl => pl.manifest?.id)
);
workspaceTools = (contributions.workspaceItems || []).filter(tool => enabledIds.has(tool.pluginId));
} catch (e) {
console.error('[WorkspaceHost] loadTools error:', e);
}
}
</script>
<div class="workspace-host">
{#if selectedWorkspace}
<div class="workspace-header">
<span class="workspace-title">{workspaceTitle}</span>
<span class="workspace-type">{workspaceType}</span>
</div>
{#if workspaceTools.length > 0}
<div class="workspace-tabs" role="tablist" aria-label="Workspace tools">
{#each workspaceTools as tool (tool.id + tool.pluginId)}
<button
class:active={toolKey(tool) === toolKey(activeTool)}
role="tab"
aria-selected={toolKey(tool) === toolKey(activeTool)}
type="button"
title={tool.pluginId}
on:click={() => activeToolKey = toolKey(tool)}
>
{tool.title || tool.id}
</button>
{/each}
</div>
<div class="workspace-tool-content" role="tabpanel" aria-label={activeTool?.title || activeTool?.id || 'Workspace tool'}>
{#if activeTool}
<PluginBundleHost
pluginId={activeTool.pluginId}
componentId={activeTool.component}
componentProps={{ workspaceName: selectedWorkspaceName, workspaceNodeId: selectedWorkspaceName, workspaceNode: selectedWorkspace, workspaceRootPath }}
/>
{/if}
</div>
{:else}
<div class="workspace-empty">
<p>No workspace tools available</p>
<p class="workspace-hint">Install plugins that contribute workspaceItems to see tools here.</p>
</div>
{/if}
{:else}
<div class="workspace-empty">
<p>Select a workspace node from the sidebar</p>
</div>
{/if}
</div>
<style>
.workspace-host {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
height: 100%;
background: #1a1a2e;
}
.workspace-header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid #16213e;
flex-shrink: 0;
}
.workspace-title {
color: #e0e0f0;
font-size: 0.95rem;
font-weight: 600;
}
.workspace-type {
color: #4ecca3;
font-size: 0.75rem;
padding: 0.1rem 0.4rem;
border-radius: 3px;
background: #1a2a3a;
}
.workspace-tabs {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.35rem 0.75rem 0;
background: #12122a;
border-bottom: 1px solid #16213e;
flex-shrink: 0;
}
.workspace-tabs button {
min-height: 2rem;
padding: 0.35rem 0.8rem;
border: 1px solid transparent;
border-bottom: none;
border-radius: 6px 6px 0 0;
background: transparent;
color: #8b8ba8;
cursor: pointer;
font: inherit;
font-size: 0.8rem;
}
.workspace-tabs button:hover {
color: #e0e0f0;
background: rgba(15, 52, 96, 0.4);
}
.workspace-tabs button.active {
color: #4ecca3;
background: #1a1a2e;
border-color: #16213e;
}
.workspace-tool-content {
flex: 1;
min-height: 0;
overflow: hidden;
}
.workspace-empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #666;
gap: 0.5rem;
}
.workspace-hint {
font-size: 0.8rem;
color: #555;
max-width: 300px;
text-align: center;
}
</style>
+249
View File
@@ -0,0 +1,249 @@
<script context="module">
import { writable } from 'svelte/store';
const activeWorkspaceId = writable('');
</script>
<script>
import { onMount } from 'svelte';
import * as App from '../../../wailsjs/go/api/App';
import Icon from '../ui/Icon.svelte';
let loading = true;
let localError = '';
let workspaces = [];
let currentWorkspaceId = '';
let showCreate = false;
let newWorkspaceName = '';
let creating = false;
let renamingId = '';
let renameValue = '';
let busyId = '';
onMount(loadWorkspaces);
function resultOrError(response, fallbackValue) {
return typeof response === 'string' ? [fallbackValue, response] : [response, ''];
}
function wsName(workspace) {
return String(workspace?.name || workspace?.rootPath || '');
}
function asNode(workspace, order) {
const name = wsName(workspace);
return {
id: name,
type: 'space',
title: name,
name,
rootPath: workspace.rootPath || name,
status: 'active',
order,
};
}
function nodesForEvent() {
return workspaces.map(asNode);
}
async function loadWorkspaces() {
loading = true;
localError = '';
try {
const [list, err] = resultOrError(await App.ListWorkspaces(), []);
if (err) {
localError = err;
workspaces = [];
} else {
workspaces = list || [];
if (!currentWorkspaceId || !workspaces.some((ws) => wsName(ws) === currentWorkspaceId)) {
currentWorkspaceId = wsName(workspaces[0] || {});
}
activeWorkspaceId.set(currentWorkspaceId);
}
} catch (e) {
localError = String(e);
}
loading = false;
}
async function selectWorkspace(workspace) {
const id = wsName(workspace);
const err = await App.SetCurrentWorkspace(id);
if (err) {
localError = err;
return;
}
currentWorkspaceId = id;
activeWorkspaceId.set(id);
window.dispatchEvent(new CustomEvent('verstak:workspace-selected', {
detail: { workspaceName: id, nodes: nodesForEvent() }
}));
}
async function doCreate() {
const name = newWorkspaceName.trim();
if (!name) return;
creating = true;
localError = '';
const [, err] = resultOrError(await App.CreateWorkspace(name, 'default'), null);
if (err) {
localError = err;
creating = false;
return;
}
showCreate = false;
newWorkspaceName = '';
creating = false;
await loadWorkspaces();
const created = workspaces.find((ws) => wsName(ws) === name);
if (created) await selectWorkspace(created);
}
function startRename(workspace) {
renamingId = wsName(workspace);
renameValue = renamingId;
localError = '';
}
function cancelRename() {
renamingId = '';
renameValue = '';
}
async function commitRename(workspace) {
const oldName = wsName(workspace);
const newName = renameValue.trim();
if (!newName || newName === oldName) {
cancelRename();
return;
}
busyId = oldName;
const err = await App.RenameWorkspace(oldName, newName);
if (err) {
localError = err;
busyId = '';
return;
}
renamingId = '';
renameValue = '';
busyId = '';
currentWorkspaceId = newName;
await loadWorkspaces();
const renamed = workspaces.find((ws) => wsName(ws) === newName);
if (renamed) await selectWorkspace(renamed);
}
async function trashWorkspace(workspace) {
const name = wsName(workspace);
busyId = name;
const [, err] = resultOrError(await App.TrashWorkspace(name), null);
if (err) {
localError = err;
busyId = '';
return;
}
if (currentWorkspaceId === name) currentWorkspaceId = '';
busyId = '';
await loadWorkspaces();
if (currentWorkspaceId) {
const selected = workspaces.find((ws) => wsName(ws) === currentWorkspaceId);
if (selected) await selectWorkspace(selected);
}
}
</script>
<div class="wt">
<div class="wt-header">
<span class="wt-title">Workspaces</span>
<button class="wt-btn" on:click={() => { showCreate = true; newWorkspaceName = ''; }} title="New workspace" type="button">+</button>
</div>
{#if loading}
<div class="wt-loading">Loading...</div>
{:else if localError}
<div class="wt-error">{localError}</div>
{/if}
<div class="wt-list">
{#each workspaces as workspace (wsName(workspace))}
{@const id = wsName(workspace)}
<div class="wt-node" class:selected={id === $activeWorkspaceId}>
<div class="wt-row">
<span class="wt-icon"><Icon name="space" size={13} class="wt-node-icon" /></span>
{#if renamingId === id}
<input
class="wt-rename"
bind:value={renameValue}
disabled={busyId === id}
on:keydown={(e) => {
if (e.key === 'Enter') commitRename(workspace);
if (e.key === 'Escape') cancelRename();
}}
/>
<button class="wt-btn wt-btn-small wt-always" on:click={() => commitRename(workspace)} title="Save rename" type="button" disabled={busyId === id}>OK</button>
<button class="wt-btn wt-btn-small wt-always" on:click={cancelRename} title="Cancel rename" type="button" disabled={busyId === id}>x</button>
{:else}
<button class="wt-label" on:click={() => selectWorkspace(workspace)} type="button">{id}</button>
<button class="wt-icon-btn" on:click={() => startRename(workspace)} title="Rename workspace" type="button" disabled={busyId === id}>
<Icon name="edit" size={12} />
</button>
<button class="wt-icon-btn danger" on:click={() => trashWorkspace(workspace)} title="Trash workspace" type="button" disabled={busyId === id}>
<Icon name="trash" size={12} />
</button>
{/if}
</div>
</div>
{/each}
</div>
{#if showCreate}
<div class="wt-create">
<div class="wt-create-header">
<span>New workspace</span>
<button class="wt-btn" on:click={() => { showCreate = false; newWorkspaceName = ''; }} type="button">x</button>
</div>
<input type="text" bind:value={newWorkspaceName} placeholder="Name..." disabled={creating} />
<div class="wt-create-actions">
<button class="wt-btn-primary" on:click={doCreate} type="button" disabled={creating || !newWorkspaceName.trim()}>{creating ? '...' : 'Create'}</button>
<button class="wt-btn" on:click={() => { showCreate = false; newWorkspaceName = ''; }} type="button" disabled={creating}>Cancel</button>
</div>
</div>
{/if}
</div>
<style>
.wt { display: flex; flex-direction: column; flex: 1; overflow: hidden; position: relative; }
.wt-header { display: flex; align-items: center; justify-content: space-between; padding: 0.4rem 0.6rem; border-bottom: 1px solid #0f3460; flex-shrink: 0; }
.wt-title { color: #a0a0b8; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600; }
.wt-list { min-height: 0; overflow-y: auto; padding: 0.2rem 0; }
.wt-btn { min-height: 0; background: none; border: none; color: #666; cursor: pointer; font-size: 0.85rem; padding: 0.1rem 0.3rem; border-radius: 3px; }
.wt-btn:hover:not(:disabled) { color: #4ecca3; background: rgba(78,204,163,0.1); }
.wt-btn-small { font-size: 0.68rem; opacity: 0; }
.wt-always { opacity: 1; }
.wt-row:hover .wt-btn-small { opacity: 1; }
.wt-loading, .wt-error { padding: 0.5rem; font-size: 0.75rem; color: #666; }
.wt-error { color: #e94560; }
.wt-row { display: flex; align-items: center; gap: 0.25rem; padding: 0.15rem 0.45rem; min-height: 1.7rem; }
.wt-row:hover { background: rgba(15,52,96,0.4); }
.wt-node.selected > .wt-row { background: rgba(78,204,163,0.1); }
.wt-icon { width: 0.9rem; height: 0.9rem; display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; color: #a0a0b8; }
:global(.wt-node-icon) { display: block; }
.wt-label { flex: 1; min-width: 0; min-height: 0; justify-content: flex-start; background: none; border: none; color: #e0e0f0; font-size: 0.78rem; text-align: left; cursor: pointer; padding: 0.1rem 0.2rem; border-radius: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wt-label:hover { color: #4ecca3; }
.wt-icon-btn { width: 1.25rem; height: 1.25rem; min-height: 0; padding: 0; border: none; background: transparent; color: #666; opacity: 0; flex-shrink: 0; cursor: pointer; border-radius: 3px; }
.wt-row:hover .wt-icon-btn { opacity: 1; }
.wt-icon-btn:hover:not(:disabled) { color: #4ecca3; background: rgba(78,204,163,0.1); }
.wt-icon-btn.danger:hover:not(:disabled) { color: #e94560; background: rgba(233,69,96,0.12); }
.wt-rename { flex: 1; min-width: 0; background: #0f3460; border: 1px solid #1a3a5c; color: #e0e0f0; padding: 0.2rem 0.35rem; border-radius: 4px; font-size: 0.78rem; }
.wt-rename:focus { outline: none; border-color: #4ecca3; }
.wt-create { position: absolute; bottom: 0; left: 0; right: 0; background: #16213e; border-top: 1px solid #0f3460; padding: 0.6rem; z-index: 10; }
.wt-create-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.4rem; color: #a0a0b8; font-size: 0.7rem; text-transform: uppercase; }
.wt-create input { width: 100%; background: #0f3460; border: 1px solid #1a3a5c; color: #e0e0f0; padding: 0.35rem 0.5rem; border-radius: 4px; font-size: 0.8rem; margin-bottom: 0.4rem; box-sizing: border-box; }
.wt-create input:focus { outline: none; border-color: #4ecca3; }
.wt-create-actions { display: flex; gap: 0.4rem; justify-content: flex-end; }
.wt-btn-primary { background: #4ecca3; color: #1a1a2e; border: none; padding: 0.3rem 0.6rem; border-radius: 4px; cursor: pointer; font-size: 0.75rem; font-weight: 600; }
.wt-btn-primary:hover:not(:disabled) { background: #3dbb92; }
.wt-btn-primary:disabled, .wt-btn:disabled, .wt-icon-btn:disabled { opacity: 0.5; cursor: not-allowed; }
</style>
File diff suppressed because it is too large Load Diff
+73
View File
@@ -0,0 +1,73 @@
<script>
/**
* Icon.svelte — Renders bundled Lucide SVG icons.
*
* Usage:
* <Icon name="puzzle" size={18} />
* <Icon name="flask" class="my-icon" />
*
* Rules:
* - NO emoji, NO unicode pictographic symbols
* - NO dependency on system icon fonts
* - If name is not found, renders a default circle icon
*/
import Briefcase from 'lucide-svelte/icons/briefcase';
import ChevronDown from 'lucide-svelte/icons/chevron-down';
import ChevronLeft from 'lucide-svelte/icons/chevron-left';
import ChevronRight from 'lucide-svelte/icons/chevron-right';
import Circle from 'lucide-svelte/icons/circle';
import FlaskConical from 'lucide-svelte/icons/flask-conical';
import Folder from 'lucide-svelte/icons/folder';
import LayoutGrid from 'lucide-svelte/icons/layout-grid';
import PanelsTopLeft from 'lucide-svelte/icons/panels-top-left';
import Pencil from 'lucide-svelte/icons/pencil';
import Plug from 'lucide-svelte/icons/plug';
import Puzzle from 'lucide-svelte/icons/puzzle';
import Settings from 'lucide-svelte/icons/settings';
import Shield from 'lucide-svelte/icons/shield';
import TriangleAlert from 'lucide-svelte/icons/triangle-alert';
import Trash2 from 'lucide-svelte/icons/trash-2';
import X from 'lucide-svelte/icons/x';
export let name = 'dot';
export let size = 16;
export let className = '';
const icons = {
case: Briefcase,
chevronDown: ChevronDown,
chevronLeft: ChevronLeft,
chevronRight: ChevronRight,
dot: Circle,
edit: Pencil,
flask: FlaskConical,
folder: Folder,
gear: Settings,
logo: PanelsTopLeft,
plugin: Plug,
puzzle: Puzzle,
space: LayoutGrid,
trash: Trash2,
vault: Shield,
warning: TriangleAlert,
x: X,
};
const aliases = {
'🧪': 'flask',
danger: 'warning',
settings: 'gear',
};
$: iconKey = aliases[name] || name || 'dot';
$: IconComponent = icons[iconKey] || icons.dot;
$: iconClass = [className, $$restProps.class].filter(Boolean).join(' ');
</script>
<svelte:component
this={IconComponent}
size={size}
{...$$restProps}
class={iconClass}
aria-hidden="true"
/>
+303
View File
@@ -0,0 +1,303 @@
#!/usr/bin/env node
/**
* bundle-host-test.cjs — Headless smoke-test for PluginBundleHost contract.
*
* Tests:
* 1. Error boundary: missing frontend entry (no bundle)
* 2. Error boundary: bundle JS throws during execution
* 3. Error boundary: component id not found in bundle
* 4. Error boundary: component mount throws at runtime
* 5. Real mount: bundle executes and registers components
* 6. Real mount: DiagnosticsPanel writes expected text to container
* 7. Real mount: PlatformTestSettings writes expected text to container
* 8. Real mount: cleanup (unmount) empties the container
* 9. Real mount: component mount throw is catchable
*
* Runs the real platform-test frontend/dist/index.js in a vm.Sandbox
* with a minimal window/document mock.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const vm = require('vm');
// ── paths ──────────────────────────────────────────────────────────────
const BUNDLE_PATH = path.resolve(
__dirname, '..', '..', 'plugins', 'platform-test', 'frontend', 'dist', 'index.js'
);
// ── helpers ────────────────────────────────────────────────────────────
function makeMockDocument() {
let elCounter = 0;
function createElement(tag) {
elCounter++;
const el = {
tagName: tag.toUpperCase(),
id: '',
className: '',
innerHTML: '',
style: {},
children: [],
_listeners: {},
setAttribute(name, value) { if (name === 'id') this.id = value; },
getAttribute(name) { if (name === 'id') return this.id; return null; },
addEventListener(type, handler) { this._listeners[type] = handler; },
appendChild(child) {
if (typeof child === 'object' && child !== null) {
this.children.push(child);
}
},
removeChild(child) {
const idx = this.children.indexOf(child);
if (idx >= 0) this.children.splice(idx, 1);
},
};
return el;
}
function createTextNode(text) {
return { nodeType: 3, textContent: String(text), _text: String(text) };
}
const headChildren = [];
const bodyChildren = [];
return {
head: { appendChild: (el) => { headChildren.push(el); }, children: headChildren },
body: { appendChild: (el) => { bodyChildren.push(el); }, children: bodyChildren },
createElement,
createTextNode,
querySelector: () => null,
getElementById: () => null,
};
}
function makeMockWindow() {
const doc = makeMockDocument();
const registry = {};
const w = {
VerstakPluginRegister(pluginId, bundle) {
if (!pluginId || !bundle || !bundle.components) return;
registry[pluginId] = bundle.components;
},
VerstakPluginAPI(pluginId) {
return {
pluginId,
capabilities: { has: () => false },
events: { publish: () => {}, subscribe: () => {} },
settings: { read: () => null, write: () => {} },
commands: { execute: () => {} },
};
},
document: doc,
console,
__VERSTAK_PLUGIN_REGISTRY__: registry,
};
// window === globalThis in browser — make it self-referential
w.window = w;
w.globalThis = w;
return w;
}
// Recursively extract text from mock element tree
function findTextContent(el) {
if (typeof el === 'string') return el;
if (el == null) return '';
if (el._text) return el._text;
if (el.textContent) return el.textContent;
if (Array.isArray(el.children)) {
return el.children.map(c => findTextContent(c)).join(' ');
}
return '';
}
// ── Runner ─────────────────────────────────────────────────────────────
let passed = 0;
let failed = 0;
const errors = [];
function test(name, fn) {
try {
fn();
passed++;
console.log(` ✅ ${name}`);
} catch (e) {
failed++;
const msg = e.message || String(e);
errors.push(`${name}: ${msg}`);
console.log(` ❌ ${name} — ${msg}`);
}
}
// ── Tests ──────────────────────────────────────────────────────────────
console.log('=== bundle-host-test: PluginBundleHost contract smoke ===');
console.log(` bundle path: ${BUNDLE_PATH}`);
console.log(` bundle exists: ${fs.existsSync(BUNDLE_PATH)}`);
console.log('');
// ── Error Boundary Tests ──
test('E1. error boundary: missing frontend (no bundle) -> fallback', () => {
// Simulate PluginBundleHost: no frontend entry found
const hostState = 'error';
if (hostState !== 'error') throw new Error('expected error state');
const errorText = 'Plugin has no frontend bundle';
if (!errorText.includes('no frontend')) throw new Error('error text mismatch');
});
test('E2. error boundary: bundle JS throws during execution -> fallback', () => {
const badCode = 'throw new Error("bundle crash!");';
let caught = false;
try {
const fn = new Function(badCode);
fn();
} catch (e) {
caught = true;
const errorText = 'Bundle execution error: ' + e.message;
if (!errorText.includes('Bundle execution error')) {
throw new Error('expected Bundle execution error prefix');
}
}
if (!caught) throw new Error('bundle throw was not caught');
});
test('E3. error boundary: component id not found -> fallback', () => {
const registry = { 'test.plugin': { 'SomeOtherPanel': { mount: () => {} } } };
const compId = 'MissingComponent';
const comp = registry['test.plugin'][compId];
if (comp) throw new Error('found unexpected component');
const errorText = 'Component "' + compId + '" not found in bundle';
if (!errorText.includes('not found')) throw new Error('error text mismatch');
});
test('E4. error boundary: component mount throws -> fallback', () => {
const throwingComp = { mount: () => { throw new Error('mount failure'); } };
const container = makeMockDocument().createElement('div');
let caught = false;
try {
throwingComp.mount(container, {}, {});
} catch (e) {
caught = true;
}
if (!caught) throw new Error('mount throw was not caught');
});
// ── Real Mount Tests (run the actual platform-test bundle) ──
if (!fs.existsSync(BUNDLE_PATH)) {
console.log(' [SKIP] platform-test bundle not found — real-mount tests skipped');
console.log(' [SKIP] run install-dev-plugins.sh first');
process.exit(0);
}
const bundleSource = fs.readFileSync(BUNDLE_PATH, 'utf-8');
test('R1. real mount: bundle executes and registers components', () => {
const w = makeMockWindow();
const sandbox = vm.createContext(w);
vm.runInContext(bundleSource, sandbox);
const reg = sandbox.__VERSTAK_PLUGIN_REGISTRY__;
if (!reg) throw new Error('registry not created');
if (!reg['verstak.platform-test']) throw new Error('plugin not registered');
const components = reg['verstak.platform-test'];
if (!components.DiagnosticsPanel) throw new Error('DiagnosticsPanel missing');
if (!components.PlatformTestSettings) throw new Error('PlatformTestSettings missing');
});
test('R2. real mount: DiagnosticsPanel.mount() writes expected text to container', () => {
const w = makeMockWindow();
const sandbox = vm.createContext(w);
vm.runInContext(bundleSource, sandbox);
const components = sandbox.__VERSTAK_PLUGIN_REGISTRY__['verstak.platform-test'];
const api = w.VerstakPluginAPI('verstak.platform-test');
const container = w.document.createElement('div');
container.id = 'test-diagnostics';
components.DiagnosticsPanel.mount(container, { componentId: 'verstak.platform-test.diagnostics' }, api);
const text = findTextContent(container);
const required = ['Platform Diagnostics', 'verstak.platform-test', 'Test Results', 'Plugin Registration'];
const missing = required.filter(chunk => !text.includes(chunk));
if (missing.length > 0) throw new Error('missing: ' + missing.join(', '));
// Verify bundle source contains Frontend Bundle Loaded (badge fix)
if (!bundleSource.includes('Frontend Bundle Loaded')) throw new Error('bundle missing badge text');
// Verify mount populated children
if (container.children.length < 4) throw new Error('mount did not populate container children');
});
test('R3. real mount: PlatformTestSettings.mount() writes expected text to container', () => {
const w = makeMockWindow();
const sandbox = vm.createContext(w);
vm.runInContext(bundleSource, sandbox);
const components = sandbox.__VERSTAK_PLUGIN_REGISTRY__['verstak.platform-test'];
const api = w.VerstakPluginAPI('verstak.platform-test');
const container = w.document.createElement('div');
container.id = 'test-settings';
components.PlatformTestSettings.mount(container, { componentId: 'verstak.platform-test.settings' }, api);
const text = findTextContent(container);
const required = ['Platform Test Settings', 'verstak.platform-test', 'Settings panel loaded from plugin frontend bundle', 'Interactive Counter'];
const missing = required.filter(chunk => !text.includes(chunk));
if (missing.length > 0) throw new Error('missing: ' + missing.join(', '));
});
test('R4. real mount: unmount() clears container', () => {
const w = makeMockWindow();
const sandbox = vm.createContext(w);
vm.runInContext(bundleSource, sandbox);
const components = sandbox.__VERSTAK_PLUGIN_REGISTRY__['verstak.platform-test'];
const api = w.VerstakPluginAPI('verstak.platform-test');
const container = w.document.createElement('div');
// Mount — verify container was modified
components.PlatformTestSettings.mount(container, {}, api);
if (container.innerHTML === '' && container.className === '') {
// mount should have set something
if (container.children.length === 0) {
throw new Error('mount should have populated container');
}
}
// Unmount — verify cleanup
components.PlatformTestSettings.unmount(container);
if (container.innerHTML !== '') throw new Error('unmount did not clear innerHTML');
if (container.className !== '') throw new Error('unmount did not clear className');
});
test('R5. error scenario: component mount throw is catchable', () => {
const failingStates = [{ mount: () => { throw new Error('render error'); }, unmount: () => {} }];
const container = makeMockDocument().createElement('div');
let caught = false;
try {
failingStates[0].mount(container, {}, {});
} catch (e) {
caught = true;
if (!e.message.includes('render error')) throw new Error('wrong error message');
}
if (!caught) throw new Error('mount throw was not caught');
});
// ── Summary ──
console.log('');
console.log(`=== results: ${passed} passed, ${failed} failed ===`);
if (failed > 0) {
console.log('Errors:');
errors.forEach(e => console.log(` - ${e}`));
process.exit(1);
}
+33 -10
View File
@@ -1,14 +1,37 @@
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
export default defineConfig({
plugins: [svelte()],
build: {
outDir: 'dist',
emptyOutDir: true,
},
server: {
port: 5173,
strictPort: true,
},
export default defineConfig(({ mode }) => {
const isTest = mode === 'test';
return {
plugins: [svelte()],
build: {
outDir: 'dist',
emptyOutDir: true,
},
server: {
port: isTest ? 5174 : 5173,
strictPort: true,
},
optimizeDeps: {
include: [
'lucide-svelte/icons/briefcase',
'lucide-svelte/icons/chevron-down',
'lucide-svelte/icons/chevron-right',
'lucide-svelte/icons/circle',
'lucide-svelte/icons/flask-conical',
'lucide-svelte/icons/folder',
'lucide-svelte/icons/layout-grid',
'lucide-svelte/icons/panels-top-left',
'lucide-svelte/icons/pencil',
'lucide-svelte/icons/plug',
'lucide-svelte/icons/puzzle',
'lucide-svelte/icons/settings',
'lucide-svelte/icons/shield',
'lucide-svelte/icons/trash-2',
'lucide-svelte/icons/triangle-alert',
],
},
};
});
+116 -1
View File
@@ -1,26 +1,141 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import {workspace} from '../models';
import {workbench} from '../models';
import {capability} from '../models';
import {api} from '../models';
import {permissions} from '../models';
import {plugin} from '../models';
import {files} from '../models';
export function ArchiveWorkspaceNode(arg1:string):Promise<string>;
export function CloseVault():Promise<void>;
export function CreateVault(arg1:string):Promise<void>;
export function CreateVaultFolder(arg1:string,arg2:string):Promise<string>;
export function CreateWorkspace(arg1:string,arg2:string):Promise<workspace.Workspace|string>;
export function CreateWorkspaceNode(arg1:string,arg2:string,arg3:string):Promise<Record<string, any>>;
export function DisablePlugin(arg1:string):Promise<string>;
export function EditWorkbenchResource(arg1:string,arg2:Record<string, any>):Promise<workbench.OpenResourceResult|string>;
export function EnablePlugin(arg1:string):Promise<string>;
export function ExecutePluginCommand(arg1:string,arg2:string,arg3:Record<string, any>):Promise<Record<string, any>|string>;
export function GetAppSettings():Promise<Record<string, any>>;
export function GetCapabilities():Promise<Array<capability.Entry>>;
export function GetContributions():Promise<api.ContributionSummary>;
export function GetCurrentWorkspace():Promise<Record<string, any>>;
export function GetCurrentWorkspaceNode():Promise<Record<string, any>>;
export function GetPermissions():Promise<Array<permissions.Entry>>;
export function GetPluginAssetContent(arg1:string,arg2:string):Promise<string|string>;
export function GetPluginCapability(arg1:string,arg2:string):Promise<Record<string, any>|string>;
export function GetPluginFrontendInfo(arg1:string):Promise<Record<string, any>>;
export function GetPlugins():Promise<Array<plugin.Plugin>>;
export function GetVaultFileMetadata(arg1:string,arg2:string):Promise<files.FileMetadata|string>;
export function GetVaultPluginState():Promise<Record<string, any>>;
export function GetVaultStatus():Promise<Record<string, string>>;
export function GetWorkbenchOpenedResources():Promise<Array<workbench.OpenedResource>>;
export function GetWorkbenchPreferences():Promise<workbench.Preferences>;
export function GetWorkspaceMetadata(arg1:string):Promise<workspace.Metadata|string>;
export function GetWorkspaceTree():Promise<Record<string, any>>;
export function ListPluginCapabilities(arg1:string):Promise<Array<capability.Entry>|string>;
export function ListVaultFiles(arg1:string,arg2:string):Promise<Array<files.FileEntry>|string>;
export function ListWorkspaces():Promise<Array<workspace.Workspace>|string>;
export function MoveVaultPath(arg1:string,arg2:string,arg3:string,arg4:files.MoveOptions):Promise<string>;
export function MoveWorkspaceNode(arg1:string,arg2:string):Promise<string>;
export function OpenVault(arg1:string):Promise<void>;
export function OpenWorkbenchResource(arg1:string,arg2:Record<string, any>):Promise<workbench.OpenResourceResult|string>;
export function PublishPluginEvent(arg1:string,arg2:string,arg3:Record<string, any>):Promise<string>;
export function ReadPluginDataJSON(arg1:string,arg2:string):Promise<Record<string, any>>;
export function ReadPluginSetting(arg1:string,arg2:string):Promise<any>;
export function ReadPluginSettings(arg1:string):Promise<Record<string, any>|string>;
export function ReadVaultTextFile(arg1:string,arg2:string):Promise<string|string>;
export function RecordDesiredPlugin(arg1:string,arg2:string,arg3:string):Promise<string>;
export function ReloadPlugins():Promise<number|string>;
export function Startup():Promise<void>;
export function RenameWorkspace(arg1:string,arg2:string):Promise<string>;
export function RenameWorkspaceNode(arg1:string,arg2:string):Promise<string>;
export function ResetSyncKey():Promise<void>;
export function SelectDirectory():Promise<string>;
export function SelectVaultForOpen():Promise<string>;
export function SetCurrentVault(arg1:string):Promise<string>;
export function SetCurrentWorkspace(arg1:string):Promise<string>;
export function SetCurrentWorkspaceNode(arg1:string):Promise<string>;
export function SubscribePluginEvent(arg1:string,arg2:string):Promise<string>;
export function SyncConfigure(arg1:string,arg2:string,arg3:string):Promise<void>;
export function SyncDisconnect():Promise<void>;
export function SyncNow():Promise<Record<string, any>>;
export function SyncSetInterval(arg1:number):Promise<void>;
export function SyncStatus():Promise<api.SyncStatusDTO>;
export function SyncTestConnection(arg1:string,arg2:string,arg3:string):Promise<void>;
export function TrashVaultPath(arg1:string,arg2:string):Promise<files.TrashResult|string>;
export function TrashWorkspace(arg1:string):Promise<workspace.TrashResult|string>;
export function UpdateAppSettings(arg1:Record<string, any>):Promise<string>;
export function UpdateWorkbenchPreferences(arg1:workbench.Preferences):Promise<string>;
export function UpdateWorkspaceMetadata(arg1:string,arg2:workspace.MetadataPatch):Promise<workspace.Metadata|string>;
export function WriteFrontendLog(arg1:string,arg2:string):Promise<void>;
export function WritePluginDataJSON(arg1:string,arg2:string,arg3:Record<string, any>):Promise<string>;
export function WritePluginSetting(arg1:string,arg2:string,arg3:any):Promise<string>;
export function WritePluginSettings(arg1:string,arg2:Record<string, any>):Promise<string>;
export function WriteVaultTextFile(arg1:string,arg2:string,arg3:string,arg4:files.WriteOptions):Promise<string>;
+226 -2
View File
@@ -2,6 +2,10 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function ArchiveWorkspaceNode(arg1) {
return window['go']['api']['App']['ArchiveWorkspaceNode'](arg1);
}
export function CloseVault() {
return window['go']['api']['App']['CloseVault']();
}
@@ -10,6 +14,38 @@ export function CreateVault(arg1) {
return window['go']['api']['App']['CreateVault'](arg1);
}
export function CreateVaultFolder(arg1, arg2) {
return window['go']['api']['App']['CreateVaultFolder'](arg1, arg2);
}
export function CreateWorkspace(arg1, arg2) {
return window['go']['api']['App']['CreateWorkspace'](arg1, arg2);
}
export function CreateWorkspaceNode(arg1, arg2, arg3) {
return window['go']['api']['App']['CreateWorkspaceNode'](arg1, arg2, arg3);
}
export function DisablePlugin(arg1) {
return window['go']['api']['App']['DisablePlugin'](arg1);
}
export function EditWorkbenchResource(arg1, arg2) {
return window['go']['api']['App']['EditWorkbenchResource'](arg1, arg2);
}
export function EnablePlugin(arg1) {
return window['go']['api']['App']['EnablePlugin'](arg1);
}
export function ExecutePluginCommand(arg1, arg2, arg3) {
return window['go']['api']['App']['ExecutePluginCommand'](arg1, arg2, arg3);
}
export function GetAppSettings() {
return window['go']['api']['App']['GetAppSettings']();
}
export function GetCapabilities() {
return window['go']['api']['App']['GetCapabilities']();
}
@@ -18,26 +54,214 @@ export function GetContributions() {
return window['go']['api']['App']['GetContributions']();
}
export function GetCurrentWorkspace() {
return window['go']['api']['App']['GetCurrentWorkspace']();
}
export function GetCurrentWorkspaceNode() {
return window['go']['api']['App']['GetCurrentWorkspaceNode']();
}
export function GetPermissions() {
return window['go']['api']['App']['GetPermissions']();
}
export function GetPluginAssetContent(arg1, arg2) {
return window['go']['api']['App']['GetPluginAssetContent'](arg1, arg2);
}
export function GetPluginCapability(arg1, arg2) {
return window['go']['api']['App']['GetPluginCapability'](arg1, arg2);
}
export function GetPluginFrontendInfo(arg1) {
return window['go']['api']['App']['GetPluginFrontendInfo'](arg1);
}
export function GetPlugins() {
return window['go']['api']['App']['GetPlugins']();
}
export function GetVaultFileMetadata(arg1, arg2) {
return window['go']['api']['App']['GetVaultFileMetadata'](arg1, arg2);
}
export function GetVaultPluginState() {
return window['go']['api']['App']['GetVaultPluginState']();
}
export function GetVaultStatus() {
return window['go']['api']['App']['GetVaultStatus']();
}
export function GetWorkbenchOpenedResources() {
return window['go']['api']['App']['GetWorkbenchOpenedResources']();
}
export function GetWorkbenchPreferences() {
return window['go']['api']['App']['GetWorkbenchPreferences']();
}
export function GetWorkspaceMetadata(arg1) {
return window['go']['api']['App']['GetWorkspaceMetadata'](arg1);
}
export function GetWorkspaceTree() {
return window['go']['api']['App']['GetWorkspaceTree']();
}
export function ListPluginCapabilities(arg1) {
return window['go']['api']['App']['ListPluginCapabilities'](arg1);
}
export function ListVaultFiles(arg1, arg2) {
return window['go']['api']['App']['ListVaultFiles'](arg1, arg2);
}
export function ListWorkspaces() {
return window['go']['api']['App']['ListWorkspaces']();
}
export function MoveVaultPath(arg1, arg2, arg3, arg4) {
return window['go']['api']['App']['MoveVaultPath'](arg1, arg2, arg3, arg4);
}
export function MoveWorkspaceNode(arg1, arg2) {
return window['go']['api']['App']['MoveWorkspaceNode'](arg1, arg2);
}
export function OpenVault(arg1) {
return window['go']['api']['App']['OpenVault'](arg1);
}
export function OpenWorkbenchResource(arg1, arg2) {
return window['go']['api']['App']['OpenWorkbenchResource'](arg1, arg2);
}
export function PublishPluginEvent(arg1, arg2, arg3) {
return window['go']['api']['App']['PublishPluginEvent'](arg1, arg2, arg3);
}
export function ReadPluginDataJSON(arg1, arg2) {
return window['go']['api']['App']['ReadPluginDataJSON'](arg1, arg2);
}
export function ReadPluginSetting(arg1, arg2) {
return window['go']['api']['App']['ReadPluginSetting'](arg1, arg2);
}
export function ReadPluginSettings(arg1) {
return window['go']['api']['App']['ReadPluginSettings'](arg1);
}
export function ReadVaultTextFile(arg1, arg2) {
return window['go']['api']['App']['ReadVaultTextFile'](arg1, arg2);
}
export function RecordDesiredPlugin(arg1, arg2, arg3) {
return window['go']['api']['App']['RecordDesiredPlugin'](arg1, arg2, arg3);
}
export function ReloadPlugins() {
return window['go']['api']['App']['ReloadPlugins']();
}
export function Startup() {
return window['go']['api']['App']['Startup']();
export function RenameWorkspace(arg1, arg2) {
return window['go']['api']['App']['RenameWorkspace'](arg1, arg2);
}
export function RenameWorkspaceNode(arg1, arg2) {
return window['go']['api']['App']['RenameWorkspaceNode'](arg1, arg2);
}
export function ResetSyncKey() {
return window['go']['api']['App']['ResetSyncKey']();
}
export function SelectDirectory() {
return window['go']['api']['App']['SelectDirectory']();
}
export function SelectVaultForOpen() {
return window['go']['api']['App']['SelectVaultForOpen']();
}
export function SetCurrentVault(arg1) {
return window['go']['api']['App']['SetCurrentVault'](arg1);
}
export function SetCurrentWorkspace(arg1) {
return window['go']['api']['App']['SetCurrentWorkspace'](arg1);
}
export function SetCurrentWorkspaceNode(arg1) {
return window['go']['api']['App']['SetCurrentWorkspaceNode'](arg1);
}
export function SubscribePluginEvent(arg1, arg2) {
return window['go']['api']['App']['SubscribePluginEvent'](arg1, arg2);
}
export function SyncConfigure(arg1, arg2, arg3) {
return window['go']['api']['App']['SyncConfigure'](arg1, arg2, arg3);
}
export function SyncDisconnect() {
return window['go']['api']['App']['SyncDisconnect']();
}
export function SyncNow() {
return window['go']['api']['App']['SyncNow']();
}
export function SyncSetInterval(arg1) {
return window['go']['api']['App']['SyncSetInterval'](arg1);
}
export function SyncStatus() {
return window['go']['api']['App']['SyncStatus']();
}
export function SyncTestConnection(arg1, arg2, arg3) {
return window['go']['api']['App']['SyncTestConnection'](arg1, arg2, arg3);
}
export function TrashVaultPath(arg1, arg2) {
return window['go']['api']['App']['TrashVaultPath'](arg1, arg2);
}
export function TrashWorkspace(arg1) {
return window['go']['api']['App']['TrashWorkspace'](arg1);
}
export function UpdateAppSettings(arg1) {
return window['go']['api']['App']['UpdateAppSettings'](arg1);
}
export function UpdateWorkbenchPreferences(arg1) {
return window['go']['api']['App']['UpdateWorkbenchPreferences'](arg1);
}
export function UpdateWorkspaceMetadata(arg1, arg2) {
return window['go']['api']['App']['UpdateWorkspaceMetadata'](arg1, arg2);
}
export function WriteFrontendLog(arg1, arg2) {
return window['go']['api']['App']['WriteFrontendLog'](arg1, arg2);
}
export function WritePluginDataJSON(arg1, arg2, arg3) {
return window['go']['api']['App']['WritePluginDataJSON'](arg1, arg2, arg3);
}
export function WritePluginSetting(arg1, arg2, arg3) {
return window['go']['api']['App']['WritePluginSetting'](arg1, arg2, arg3);
}
export function WritePluginSettings(arg1, arg2) {
return window['go']['api']['App']['WritePluginSettings'](arg1, arg2);
}
export function WriteVaultTextFile(arg1, arg2, arg3, arg4) {
return window['go']['api']['App']['WriteVaultTextFile'](arg1, arg2, arg3, arg4);
}
+632 -169
View File
@@ -1,27 +1,63 @@
export namespace api {
export class ContributionSummary {
views: contribution.ContributionView[];
commands: contribution.ContributionCommand[];
settingsPanels: contribution.ContributionSettingsPanel[];
sidebarItems: contribution.ContributionSidebarItem[];
fileActions: contribution.ContributionAction[];
noteActions: contribution.ContributionAction[];
searchProviders: contribution.ContributionSearchProvider[];
export class FlatWorkspaceItem {
pluginId: string;
id: string;
title: string;
icon?: string;
component: string;
static createFrom(source: any = {}) {
return new ContributionSummary(source);
return new FlatWorkspaceItem(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.views = this.convertValues(source["views"], contribution.ContributionView);
this.commands = this.convertValues(source["commands"], contribution.ContributionCommand);
this.settingsPanels = this.convertValues(source["settingsPanels"], contribution.ContributionSettingsPanel);
this.sidebarItems = this.convertValues(source["sidebarItems"], contribution.ContributionSidebarItem);
this.fileActions = this.convertValues(source["fileActions"], contribution.ContributionAction);
this.noteActions = this.convertValues(source["noteActions"], contribution.ContributionAction);
this.searchProviders = this.convertValues(source["searchProviders"], contribution.ContributionSearchProvider);
this.pluginId = source["pluginId"];
this.id = source["id"];
this.title = source["title"];
this.icon = source["icon"];
this.component = source["component"];
}
}
export class FlatOpenProviderSupport {
kind: string;
mime?: string[];
extensions?: string[];
contexts?: string[];
static createFrom(source: any = {}) {
return new FlatOpenProviderSupport(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.kind = source["kind"];
this.mime = source["mime"];
this.extensions = source["extensions"];
this.contexts = source["contexts"];
}
}
export class FlatOpenProvider {
pluginId: string;
id: string;
title: string;
priority?: number;
component: string;
supports: FlatOpenProviderSupport[];
static createFrom(source: any = {}) {
return new FlatOpenProvider(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.pluginId = source["pluginId"];
this.id = source["id"];
this.title = source["title"];
this.priority = source["priority"];
this.component = source["component"];
this.supports = this.convertValues(source["supports"], FlatOpenProviderSupport);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
@@ -42,6 +78,169 @@ export namespace api {
return a;
}
}
export class FlatSidebarItem {
pluginId: string;
id: string;
title: string;
icon?: string;
view: string;
position?: number;
static createFrom(source: any = {}) {
return new FlatSidebarItem(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.pluginId = source["pluginId"];
this.id = source["id"];
this.title = source["title"];
this.icon = source["icon"];
this.view = source["view"];
this.position = source["position"];
}
}
export class FlatSettingsPanel {
pluginId: string;
id: string;
title: string;
icon?: string;
component: string;
static createFrom(source: any = {}) {
return new FlatSettingsPanel(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.pluginId = source["pluginId"];
this.id = source["id"];
this.title = source["title"];
this.icon = source["icon"];
this.component = source["component"];
}
}
export class FlatCommand {
pluginId: string;
id: string;
title: string;
icon?: string;
handler?: string;
static createFrom(source: any = {}) {
return new FlatCommand(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.pluginId = source["pluginId"];
this.id = source["id"];
this.title = source["title"];
this.icon = source["icon"];
this.handler = source["handler"];
}
}
export class FlatView {
pluginId: string;
id: string;
title: string;
icon?: string;
component: string;
static createFrom(source: any = {}) {
return new FlatView(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.pluginId = source["pluginId"];
this.id = source["id"];
this.title = source["title"];
this.icon = source["icon"];
this.component = source["component"];
}
}
export class ContributionSummary {
views: FlatView[];
commands: FlatCommand[];
settingsPanels: FlatSettingsPanel[];
sidebarItems: FlatSidebarItem[];
openProviders: FlatOpenProvider[];
workspaceItems: FlatWorkspaceItem[];
static createFrom(source: any = {}) {
return new ContributionSummary(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.views = this.convertValues(source["views"], FlatView);
this.commands = this.convertValues(source["commands"], FlatCommand);
this.settingsPanels = this.convertValues(source["settingsPanels"], FlatSettingsPanel);
this.sidebarItems = this.convertValues(source["sidebarItems"], FlatSidebarItem);
this.openProviders = this.convertValues(source["openProviders"], FlatOpenProvider);
this.workspaceItems = this.convertValues(source["workspaceItems"], FlatWorkspaceItem);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class SyncStatusDTO {
configured: boolean;
serverUrl: string;
deviceId: string;
deviceName: string;
connected: boolean;
revoked: boolean;
tokenStored: boolean;
unpushedOps: number;
lastSyncAt: string;
syncInterval: number;
lastError: string;
statusLabel: string;
static createFrom(source: any = {}) {
return new SyncStatusDTO(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.configured = source["configured"];
this.serverUrl = source["serverUrl"];
this.deviceId = source["deviceId"];
this.deviceName = source["deviceName"];
this.connected = source["connected"];
this.revoked = source["revoked"];
this.tokenStored = source["tokenStored"];
this.unpushedOps = source["unpushedOps"];
this.lastSyncAt = source["lastSyncAt"];
this.syncInterval = source["syncInterval"];
this.lastError = source["lastError"];
this.statusLabel = source["statusLabel"];
}
}
}
@@ -68,199 +267,115 @@ export namespace capability {
}
export namespace contribution {
export namespace files {
export class ContributionAction {
pluginId: string;
item: plugin.ContributionAction;
export class FileEntry {
name: string;
relativePath: string;
type: string;
size: number;
modifiedAt: string;
extension: string;
isHidden: boolean;
isReserved: boolean;
canRead: boolean;
canWrite: boolean;
static createFrom(source: any = {}) {
return new ContributionAction(source);
return new FileEntry(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.pluginId = source["pluginId"];
this.item = this.convertValues(source["item"], plugin.ContributionAction);
this.name = source["name"];
this.relativePath = source["relativePath"];
this.type = source["type"];
this.size = source["size"];
this.modifiedAt = source["modifiedAt"];
this.extension = source["extension"];
this.isHidden = source["isHidden"];
this.isReserved = source["isReserved"];
this.canRead = source["canRead"];
this.canWrite = source["canWrite"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class ContributionCommand {
pluginId: string;
item: plugin.ContributionCommand;
export class FileMetadata {
relativePath: string;
type: string;
size: number;
modifiedAt: string;
createdAt?: string;
extension: string;
mimeHint: string;
isText: boolean;
isHidden: boolean;
isReserved: boolean;
canRead: boolean;
canWrite: boolean;
static createFrom(source: any = {}) {
return new ContributionCommand(source);
return new FileMetadata(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.pluginId = source["pluginId"];
this.item = this.convertValues(source["item"], plugin.ContributionCommand);
this.relativePath = source["relativePath"];
this.type = source["type"];
this.size = source["size"];
this.modifiedAt = source["modifiedAt"];
this.createdAt = source["createdAt"];
this.extension = source["extension"];
this.mimeHint = source["mimeHint"];
this.isText = source["isText"];
this.isHidden = source["isHidden"];
this.isReserved = source["isReserved"];
this.canRead = source["canRead"];
this.canWrite = source["canWrite"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class ContributionSearchProvider {
pluginId: string;
item: plugin.ContributionSearchProvider;
export class MoveOptions {
overwrite: boolean;
static createFrom(source: any = {}) {
return new ContributionSearchProvider(source);
return new MoveOptions(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.pluginId = source["pluginId"];
this.item = this.convertValues(source["item"], plugin.ContributionSearchProvider);
this.overwrite = source["overwrite"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class ContributionSettingsPanel {
pluginId: string;
item: plugin.ContributionSettingsPanel;
export class TrashResult {
originalPath: string;
trashPath: string;
trashId: string;
deletedAt: string;
static createFrom(source: any = {}) {
return new ContributionSettingsPanel(source);
return new TrashResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.pluginId = source["pluginId"];
this.item = this.convertValues(source["item"], plugin.ContributionSettingsPanel);
this.originalPath = source["originalPath"];
this.trashPath = source["trashPath"];
this.trashId = source["trashId"];
this.deletedAt = source["deletedAt"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class ContributionSidebarItem {
pluginId: string;
item: plugin.ContributionSidebarItem;
export class WriteOptions {
createIfMissing: boolean;
overwrite: boolean;
static createFrom(source: any = {}) {
return new ContributionSidebarItem(source);
return new WriteOptions(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.pluginId = source["pluginId"];
this.item = this.convertValues(source["item"], plugin.ContributionSidebarItem);
this.createIfMissing = source["createIfMissing"];
this.overwrite = source["overwrite"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class ContributionView {
pluginId: string;
item: plugin.ContributionView;
static createFrom(source: any = {}) {
return new ContributionView(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.pluginId = source["pluginId"];
this.item = this.convertValues(source["item"], plugin.ContributionView);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
}
@@ -414,6 +529,62 @@ export namespace plugin {
this.handler = source["handler"];
}
}
export class OpenProviderSupport {
kind: string;
mime?: string[];
extensions?: string[];
contexts?: string[];
static createFrom(source: any = {}) {
return new OpenProviderSupport(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.kind = source["kind"];
this.mime = source["mime"];
this.extensions = source["extensions"];
this.contexts = source["contexts"];
}
}
export class ContributionOpenProvider {
id: string;
title: string;
priority?: number;
component: string;
supports: OpenProviderSupport[];
static createFrom(source: any = {}) {
return new ContributionOpenProvider(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.title = source["title"];
this.priority = source["priority"];
this.component = source["component"];
this.supports = this.convertValues(source["supports"], OpenProviderSupport);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class ContributionSearchProvider {
id: string;
label: string;
@@ -504,6 +675,24 @@ export namespace plugin {
this.component = source["component"];
}
}
export class ContributionWorkspaceItem {
id: string;
title: string;
icon?: string;
component: string;
static createFrom(source: any = {}) {
return new ContributionWorkspaceItem(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.title = source["title"];
this.icon = source["icon"];
this.component = source["component"];
}
}
export class Contributions {
views?: ContributionView[];
commands?: ContributionCommand[];
@@ -515,6 +704,8 @@ export namespace plugin {
searchProviders?: ContributionSearchProvider[];
activityProviders?: ContributionActivityProvider[];
statusBarItems?: ContributionStatusBarItem[];
openProviders?: ContributionOpenProvider[];
workspaceItems?: ContributionWorkspaceItem[];
static createFrom(source: any = {}) {
return new Contributions(source);
@@ -532,6 +723,8 @@ export namespace plugin {
this.searchProviders = this.convertValues(source["searchProviders"], ContributionSearchProvider);
this.activityProviders = this.convertValues(source["activityProviders"], ContributionActivityProvider);
this.statusBarItems = this.convertValues(source["statusBarItems"], ContributionStatusBarItem);
this.openProviders = this.convertValues(source["openProviders"], ContributionOpenProvider);
this.workspaceItems = this.convertValues(source["workspaceItems"], ContributionWorkspaceItem);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
@@ -656,6 +849,7 @@ export namespace plugin {
}
}
export class Plugin {
manifest: Manifest;
status: string;
@@ -697,3 +891,272 @@ export namespace plugin {
}
export namespace workbench {
export class OpenResourceContext {
sourcePluginId?: string;
sourceView?: string;
isInsideNotesFolder?: boolean;
notesScopePath?: string;
notesMode?: boolean;
static createFrom(source: any = {}) {
return new OpenResourceContext(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.sourcePluginId = source["sourcePluginId"];
this.sourceView = source["sourceView"];
this.isInsideNotesFolder = source["isInsideNotesFolder"];
this.notesScopePath = source["notesScopePath"];
this.notesMode = source["notesMode"];
}
}
export class OpenResourceRequest {
kind: string;
path: string;
mode?: string;
mime?: string;
extension?: string;
context?: OpenResourceContext;
static createFrom(source: any = {}) {
return new OpenResourceRequest(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.kind = source["kind"];
this.path = source["path"];
this.mode = source["mode"];
this.mime = source["mime"];
this.extension = source["extension"];
this.context = this.convertValues(source["context"], OpenResourceContext);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class OpenResourceResult {
status: string;
providerId?: string;
providerPluginId?: string;
providerComponent?: string;
request: OpenResourceRequest;
message?: string;
static createFrom(source: any = {}) {
return new OpenResourceResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.status = source["status"];
this.providerId = source["providerId"];
this.providerPluginId = source["providerPluginId"];
this.providerComponent = source["providerComponent"];
this.request = this.convertValues(source["request"], OpenResourceRequest);
this.message = source["message"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class OpenedResource {
id: string;
providerId: string;
providerPluginId: string;
providerComponent: string;
request: OpenResourceRequest;
openedAt: string;
static createFrom(source: any = {}) {
return new OpenedResource(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.providerId = source["providerId"];
this.providerPluginId = source["providerPluginId"];
this.providerComponent = source["providerComponent"];
this.request = this.convertValues(source["request"], OpenResourceRequest);
this.openedAt = source["openedAt"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class Preferences {
defaultTextEditorProvider?: string;
defaultMarkdownEditorProvider?: string;
defaultNotesMarkdownEditorProvider?: string;
static createFrom(source: any = {}) {
return new Preferences(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.defaultTextEditorProvider = source["defaultTextEditorProvider"];
this.defaultMarkdownEditorProvider = source["defaultMarkdownEditorProvider"];
this.defaultNotesMarkdownEditorProvider = source["defaultNotesMarkdownEditorProvider"];
}
}
}
export namespace workspace {
export class TemplateSnapshot {
templateId: string;
templateName: string;
templateVersion: number;
appliedAt: string;
static createFrom(source: any = {}) {
return new TemplateSnapshot(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.templateId = source["templateId"];
this.templateName = source["templateName"];
this.templateVersion = source["templateVersion"];
this.appliedAt = source["appliedAt"];
}
}
export class Metadata {
workspaceName: string;
createdFromTemplate?: TemplateSnapshot;
features?: Record<string, boolean>;
folders?: Record<string, string>;
updatedAt?: string;
static createFrom(source: any = {}) {
return new Metadata(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.workspaceName = source["workspaceName"];
this.createdFromTemplate = this.convertValues(source["createdFromTemplate"], TemplateSnapshot);
this.features = source["features"];
this.folders = source["folders"];
this.updatedAt = source["updatedAt"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class MetadataPatch {
features?: Record<string, boolean>;
folders?: Record<string, string>;
static createFrom(source: any = {}) {
return new MetadataPatch(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.features = source["features"];
this.folders = source["folders"];
}
}
export class TrashResult {
originalPath: string;
trashPath: string;
trashId: string;
deletedAt: string;
static createFrom(source: any = {}) {
return new TrashResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.originalPath = source["originalPath"];
this.trashPath = source["trashPath"];
this.trashId = source["trashId"];
this.deletedAt = source["deletedAt"];
}
}
export class Workspace {
name: string;
rootPath: string;
static createFrom(source: any = {}) {
return new Workspace(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
this.rootPath = source["rootPath"];
}
}
}
+1246 -54
View File
File diff suppressed because it is too large Load Diff
+819
View File
@@ -0,0 +1,819 @@
package api
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/verstak/verstak-desktop/internal/core/appsettings"
"github.com/verstak/verstak-desktop/internal/core/capability"
"github.com/verstak/verstak-desktop/internal/core/contribution"
"github.com/verstak/verstak-desktop/internal/core/events"
corefiles "github.com/verstak/verstak-desktop/internal/core/files"
"github.com/verstak/verstak-desktop/internal/core/plugin"
"github.com/verstak/verstak-desktop/internal/core/storage"
"github.com/verstak/verstak-desktop/internal/core/vault"
"github.com/verstak/verstak-desktop/internal/core/workspace"
)
// newTestApp creates an App with a mocked plugin list for testing.
func newTestApp(tmpRoot string) *App {
return &App{
plugins: []plugin.Plugin{
{
Manifest: plugin.Manifest{
ID: "test.plugin",
Name: "Test Plugin",
Version: "1.0.0",
Icon: "🧪",
Frontend: &plugin.FrontendConfig{
Entry: "frontend/dist/index.js",
Style: "frontend/style.css",
},
Provides: []string{"test/cap/v1"},
Permissions: []string{"test.perm"},
},
RootPath: tmpRoot,
Status: plugin.StatusLoaded,
Enabled: true,
},
{
Manifest: plugin.Manifest{
ID: "no-fe.plugin",
Name: "No Frontend Plugin",
Provides: []string{"test/nofe/v1"},
Permissions: []string{"test.perm"},
},
RootPath: "/tmp/no-fe-plugin",
Status: plugin.StatusLoaded,
Enabled: true,
},
},
}
}
func newFilesTestApp(t *testing.T, perms []string) (*App, string) {
t.Helper()
v := vault.NewVault(nil)
if err := v.CreateVault(t.TempDir()); err != nil {
t.Fatalf("CreateVault: %v", err)
}
return &App{
files: corefiles.NewService(v),
vault: v,
plugins: []plugin.Plugin{
{
Manifest: plugin.Manifest{
ID: "files.plugin",
Name: "Files Plugin",
Version: "1.0.0",
Provides: []string{"files/plugin/v1"},
Permissions: perms,
},
Status: plugin.StatusLoaded,
Enabled: true,
},
},
}, v.GetVaultPath()
}
// TestGetPluginFrontendInfo_KnownPluginWithFrontend verifies that
// GetPluginFrontendInfo returns correct metadata for a plugin with a frontend.
func TestGetPluginFrontendInfo_KnownPluginWithFrontend(t *testing.T) {
app := newTestApp("/tmp/test-plugin")
info := app.GetPluginFrontendInfo("test.plugin")
if info["status"] != nil {
t.Errorf("unexpected status key: expected no status, got %v", info["status"])
}
if info["pluginId"] != "test.plugin" {
t.Errorf("pluginId: expected %q, got %v", "test.plugin", info["pluginId"])
}
if info["name"] != "Test Plugin" {
t.Errorf("name: expected %q, got %v", "Test Plugin", info["name"])
}
if info["icon"] != "🧪" {
t.Errorf("icon: expected %q, got %v", "🧪", info["icon"])
}
if info["version"] != "1.0.0" {
t.Errorf("version: expected %q, got %v", "1.0.0", info["version"])
}
if info["entry"] != "frontend/dist/index.js" {
t.Errorf("entry: expected %q, got %v", "frontend/dist/index.js", info["entry"])
}
if info["style"] != "frontend/style.css" {
t.Errorf("style: expected %q, got %v", "frontend/style.css", info["style"])
}
if info["rootPath"] != "/tmp/test-plugin" {
t.Errorf("rootPath: expected %q, got %v", "/tmp/test-plugin", info["rootPath"])
}
}
// TestGetPluginFrontendInfo_PluginWithoutFrontend verifies that
// GetPluginFrontendInfo returns {"status": "no-frontend"} for a plugin
// that has no FrontendConfig.
func TestGetPluginFrontendInfo_PluginWithoutFrontend(t *testing.T) {
app := newTestApp("/tmp/test-plugin")
info := app.GetPluginFrontendInfo("no-fe.plugin")
if info["status"] != "no-frontend" {
t.Errorf("expected status %q, got %v", "no-frontend", info["status"])
}
}
// TestGetPluginFrontendInfo_UnknownPlugin verifies that
// GetPluginFrontendInfo returns {"status": "not-found"} for a plugin ID
// that does not exist.
func TestGetPluginFrontendInfo_UnknownPlugin(t *testing.T) {
app := newTestApp("/tmp/test-plugin")
info := app.GetPluginFrontendInfo("nonexistent.plugin")
if info["status"] != "not-found" {
t.Errorf("expected status %q, got %v", "not-found", info["status"])
}
}
// TestGetPluginAssetContent_ExistingFile verifies that GetPluginAssetContent
// can read an existing frontend file from a plugin directory.
func TestGetPluginAssetContent_ExistingFile(t *testing.T) {
tmpDir := t.TempDir()
// Create a test frontend file
frontendDir := filepath.Join(tmpDir, "frontend", "dist")
if err := os.MkdirAll(frontendDir, 0755); err != nil {
t.Fatal(err)
}
content := "console.log('test');\n"
if err := os.WriteFile(filepath.Join(frontendDir, "index.js"), []byte(content), 0644); err != nil {
t.Fatal(err)
}
app := newTestApp(tmpDir)
got, errStr := app.GetPluginAssetContent("test.plugin", "frontend/dist/index.js")
if errStr != "" {
t.Errorf("unexpected error: %s", errStr)
}
if got != content {
t.Errorf("content mismatch:\n expected: %q\n got: %q", content, got)
}
}
// TestGetPluginAssetContent_ExistingStyleFile verifies reading a style file.
func TestGetPluginAssetContent_ExistingStyleFile(t *testing.T) {
tmpDir := t.TempDir()
frontendDir := filepath.Join(tmpDir, "frontend")
if err := os.MkdirAll(frontendDir, 0755); err != nil {
t.Fatal(err)
}
content := "body { color: red; }\n"
if err := os.WriteFile(filepath.Join(frontendDir, "style.css"), []byte(content), 0644); err != nil {
t.Fatal(err)
}
app := newTestApp(tmpDir)
got, errStr := app.GetPluginAssetContent("test.plugin", "frontend/style.css")
if errStr != "" {
t.Errorf("unexpected error: %s", errStr)
}
if got != content {
t.Errorf("content mismatch:\n expected: %q\n got: %q", content, got)
}
}
// TestGetPluginAssetContent_AbsolutePathRejected verifies that asset paths
// starting with "/" are rejected.
func TestGetPluginAssetContent_AbsolutePathRejected(t *testing.T) {
app := newTestApp("/tmp/test-plugin")
_, errStr := app.GetPluginAssetContent("test.plugin", "/etc/passwd")
if errStr == "" {
t.Error("expected error for absolute path, got empty")
}
if !strings.Contains(strings.ToLower(errStr), "absolute") {
t.Errorf("error should mention 'absolute', got: %s", errStr)
}
// Also test Windows-style absolute path
_, errStr = app.GetPluginAssetContent("test.plugin", "\\etc\\passwd")
if errStr == "" {
t.Error("expected error for windows-style absolute path, got empty")
}
}
// TestGetPluginAssetContent_PathTraversalRejected verifies that asset paths
// containing ".." are rejected.
func TestGetPluginAssetContent_PathTraversalRejected(t *testing.T) {
app := newTestApp("/tmp/test-plugin")
_, errStr := app.GetPluginAssetContent("test.plugin", "../../etc/passwd")
if errStr == "" {
t.Error("expected error for path traversal, got empty")
}
if !strings.Contains(strings.ToLower(errStr), "traversal") {
t.Errorf("error should mention 'traversal', got: %s", errStr)
}
}
// TestGetPluginAssetContent_PathEscapeRejected verifies that paths that
// resolve outside the plugin root directory are rejected after Join.
// This tests the absRoot-prefix check in GetPluginAssetContent.
func TestGetPluginAssetContent_PathEscapeRejected(t *testing.T) {
tmpDir := t.TempDir()
// Do NOT create the traversed-to file — the security check happens
// before os.ReadFile, so the file should not matter.
app := newTestApp(tmpDir)
// Use a relative path with ".." that would escape the plugin root
// The code checks strings.Contains(assetPath, "..") first, so this
// would be caught at the traversal check. But let's also test a case
// where ".." is NOT in the path but Join resolves outside root.
// For instance: symlink-based escape (not testable easily) or
// Join "/tmp/root" + "foo/../../../etc" — but ".." is in there.
//
// Instead, test that the absRoot prefix check works: create a path
// that after cleaning technically starts differently but doesn't use "..".
// This is hard to reproduce without symlinks. The ".." check catches
// common cases. Let's just ensure the ".." check is solid:
_, errStr := app.GetPluginAssetContent("test.plugin", "frontend/../../etc/passwd")
if errStr == "" {
t.Error("expected error for path traversal via nested '..', got empty")
}
}
// TestGetPluginAssetContent_PluginNotFound verifies that GetPluginAssetContent
// returns an error for a nonexistent plugin ID.
func TestGetPluginAssetContent_PluginNotFound(t *testing.T) {
app := newTestApp("/tmp/test-plugin")
_, errStr := app.GetPluginAssetContent("nonexistent.plugin", "frontend/dist/index.js")
if errStr == "" {
t.Error("expected error for nonexistent plugin, got empty")
}
if !strings.Contains(strings.ToLower(errStr), "not found") &&
!strings.Contains(strings.ToLower(errStr), "no frontend") {
t.Errorf("error should mention 'not found' or 'no frontend', got: %s", errStr)
}
}
// TestGetPluginAssetContent_NoFrontend verifies that GetPluginAssetContent
// returns an error for a plugin that exists but has no frontend config.
func TestGetPluginAssetContent_NoFrontend(t *testing.T) {
app := newTestApp("/tmp/test-plugin")
_, errStr := app.GetPluginAssetContent("no-fe.plugin", "frontend/dist/index.js")
if errStr == "" {
t.Error("expected error for plugin without frontend, got empty")
}
if !strings.Contains(strings.ToLower(errStr), "no frontend") {
t.Errorf("error should mention 'no frontend', got: %s", errStr)
}
}
// TestGetPluginAssetContent_NonexistentFile verifies that GetPluginAssetContent
// returns an error when the asset file does not exist on disk.
func TestGetPluginAssetContent_NonexistentFile(t *testing.T) {
tmpDir := t.TempDir()
app := newTestApp(tmpDir)
_, errStr := app.GetPluginAssetContent("test.plugin", "frontend/dist/missing.js")
if errStr == "" {
t.Error("expected error for nonexistent file, got empty")
}
if !strings.Contains(strings.ToLower(errStr), "failed to read") {
t.Errorf("error should mention 'failed to read', got: %s", errStr)
}
}
func TestFilesBridgeReadWriteListMoveTrash(t *testing.T) {
app, root := newFilesTestApp(t, []string{"files.read", "files.write", "files.delete"})
if errStr := app.CreateVaultFolder("files.plugin", "Docs"); errStr != "" {
t.Fatalf("CreateVaultFolder: %s", errStr)
}
if errStr := app.WriteVaultTextFile("files.plugin", "Docs/one.txt", "hello", corefiles.WriteOptions{CreateIfMissing: true}); errStr != "" {
t.Fatalf("WriteVaultTextFile: %s", errStr)
}
text, errStr := app.ReadVaultTextFile("files.plugin", "Docs/one.txt")
if errStr != "" {
t.Fatalf("ReadVaultTextFile: %s", errStr)
}
if text != "hello" {
t.Fatalf("text = %q", text)
}
entries, errStr := app.ListVaultFiles("files.plugin", "Docs")
if errStr != "" {
t.Fatalf("ListVaultFiles: %s", errStr)
}
if len(entries) != 1 || entries[0].RelativePath != "Docs/one.txt" {
t.Fatalf("entries = %+v", entries)
}
meta, errStr := app.GetVaultFileMetadata("files.plugin", "Docs/one.txt")
if errStr != "" {
t.Fatalf("GetVaultFileMetadata: %s", errStr)
}
if meta.Type != corefiles.FileTypeFile || !meta.IsText {
t.Fatalf("metadata = %+v", meta)
}
if errStr := app.MoveVaultPath("files.plugin", "Docs/one.txt", "Docs/two.txt", corefiles.MoveOptions{}); errStr != "" {
t.Fatalf("MoveVaultPath: %s", errStr)
}
trash, errStr := app.TrashVaultPath("files.plugin", "Docs/two.txt")
if errStr != "" {
t.Fatalf("TrashVaultPath: %s", errStr)
}
if trash.OriginalPath != "Docs/two.txt" || trash.TrashID == "" {
t.Fatalf("trash result = %+v", trash)
}
if _, err := os.Stat(filepath.Join(root, trash.TrashPath)); err != nil {
t.Fatalf("trash path missing: %v", err)
}
}
func TestFilesBridgePermissions(t *testing.T) {
cases := []struct {
name string
perms []string
call func(*App) string
wantPhrase string
}{
{
name: "list requires read",
perms: []string{"files.write", "files.delete"},
call: func(app *App) string { _, errStr := app.ListVaultFiles("files.plugin", ""); return errStr },
wantPhrase: "files.read",
},
{
name: "metadata requires read",
perms: []string{"files.write", "files.delete"},
call: func(app *App) string { _, errStr := app.GetVaultFileMetadata("files.plugin", "one.txt"); return errStr },
wantPhrase: "files.read",
},
{
name: "read requires read",
perms: []string{"files.write", "files.delete"},
call: func(app *App) string { _, errStr := app.ReadVaultTextFile("files.plugin", "one.txt"); return errStr },
wantPhrase: "files.read",
},
{
name: "write requires write",
perms: []string{"files.read", "files.delete"},
call: func(app *App) string {
return app.WriteVaultTextFile("files.plugin", "one.txt", "x", corefiles.WriteOptions{CreateIfMissing: true})
},
wantPhrase: "files.write",
},
{
name: "create folder requires write",
perms: []string{"files.read", "files.delete"},
call: func(app *App) string { return app.CreateVaultFolder("files.plugin", "Folder") },
wantPhrase: "files.write",
},
{
name: "move requires write",
perms: []string{"files.read", "files.delete"},
call: func(app *App) string {
return app.MoveVaultPath("files.plugin", "one.txt", "two.txt", corefiles.MoveOptions{})
},
wantPhrase: "files.write",
},
{
name: "trash requires delete",
perms: []string{"files.read", "files.write"},
call: func(app *App) string { _, errStr := app.TrashVaultPath("files.plugin", "one.txt"); return errStr },
wantPhrase: "files.delete",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
app, _ := newFilesTestApp(t, tc.perms)
errStr := tc.call(app)
if errStr == "" {
t.Fatal("expected permission error")
}
if !strings.Contains(errStr, tc.wantPhrase) {
t.Fatalf("error = %q, want %q", errStr, tc.wantPhrase)
}
})
}
}
func TestFilesBridgeRequiresLoadedPluginAndOpenVault(t *testing.T) {
app, _ := newFilesTestApp(t, []string{"files.read"})
app.plugins[0].Enabled = false
if _, errStr := app.ListVaultFiles("files.plugin", ""); errStr == "" || !strings.Contains(errStr, "not enabled") {
t.Fatalf("disabled plugin error = %q", errStr)
}
app, _ = newFilesTestApp(t, []string{"files.read"})
app.plugins[0].Status = plugin.StatusFailed
if _, errStr := app.ListVaultFiles("files.plugin", ""); errStr == "" || !strings.Contains(errStr, "not enabled") {
t.Fatalf("failed plugin error = %q", errStr)
}
app, _ = newFilesTestApp(t, []string{"files.read"})
app.plugins[0].Status = plugin.StatusDegraded
if _, errStr := app.ListVaultFiles("files.plugin", ""); errStr != "" {
t.Fatalf("degraded plugin should be allowed, got %q", errStr)
}
app, _ = newFilesTestApp(t, []string{"files.read"})
if _, errStr := app.ListVaultFiles("missing.plugin", ""); errStr == "" || !strings.Contains(errStr, "not found") {
t.Fatalf("missing plugin error = %q", errStr)
}
app, _ = newFilesTestApp(t, []string{"files.read"})
app.vault.CloseVault()
if _, errStr := app.ListVaultFiles("files.plugin", ""); errStr == "" || !strings.Contains(errStr, "vault-not-open") {
t.Fatalf("closed vault error = %q", errStr)
}
}
func TestSetCurrentVaultInitializesWorkspaceWhenMissingAtStartup(t *testing.T) {
tmpDir := t.TempDir()
vaultParent := filepath.Join(tmpDir, "vault-parent")
if err := os.MkdirAll(vaultParent, 0o755); err != nil {
t.Fatal(err)
}
bus := events.NewBus()
vaultService := vault.NewVault(bus)
if err := vaultService.CreateVault(vaultParent); err != nil {
t.Fatalf("CreateVault: %v", err)
}
vaultService.CloseVault()
settings := appsettings.NewManager(filepath.Join(tmpDir, "config.json"))
if err := settings.Load(); err != nil {
t.Fatalf("settings Load: %v", err)
}
app := &App{
capRegistry: capability.NewRegistry(),
vault: vaultService,
appSettings: settings,
workspace: nil,
}
if errStr := app.SetCurrentVault(vaultParent); errStr != "" {
t.Fatalf("SetCurrentVault: %s", errStr)
}
tree := app.GetWorkspaceTree()
if tree["status"] == "not initialized" {
t.Fatal("workspace should be initialized after SetCurrentVault")
}
nodes, ok := tree["nodes"].([]workspace.WorkspaceNode)
if !ok {
t.Fatalf("workspace nodes type: got %T", tree["nodes"])
}
if len(nodes) == 0 {
t.Fatal("workspace nodes should not be empty")
}
if nodes[0].Path != "" {
t.Fatalf("compatibility node should not expose workspace path mapping: %+v", nodes[0])
}
if !app.capRegistry.Has("verstak/core/workspace/v1") {
t.Fatal("workspace capability should be registered after SetCurrentVault")
}
}
func TestWorkspaceAPIUsesTopLevelFoldersAndMetadataSnapshot(t *testing.T) {
app, vaultDir := newFilesTestApp(t, []string{"files.read"})
app.workspace = workspace.NewManager(vaultDir)
if err := app.workspace.Load(); err != nil {
t.Fatalf("workspace Load: %v", err)
}
ws, errStr := app.CreateWorkspace("Project", "client-project")
if errStr != "" {
t.Fatalf("CreateWorkspace: %s", errStr)
}
if ws.RootPath != "Project" {
t.Fatalf("workspace = %+v, want rootPath Project", ws)
}
if _, err := os.Stat(filepath.Join(vaultDir, "Project", "Notes", "Overview.md")); err != nil {
t.Fatalf("template file missing: %v", err)
}
meta, errStr := app.GetWorkspaceMetadata("Project")
if errStr != "" {
t.Fatalf("GetWorkspaceMetadata: %s", errStr)
}
if meta.CreatedFromTemplate == nil || meta.CreatedFromTemplate.TemplateID != "client-project" {
t.Fatalf("metadata snapshot = %+v", meta.CreatedFromTemplate)
}
if errStr := app.RenameWorkspace("Project", "Renamed"); errStr != "" {
t.Fatalf("RenameWorkspace: %s", errStr)
}
if _, err := os.Stat(filepath.Join(vaultDir, "Renamed")); err != nil {
t.Fatalf("renamed folder missing: %v", err)
}
result, errStr := app.TrashWorkspace("Renamed")
if errStr != "" {
t.Fatalf("TrashWorkspace: %s", errStr)
}
if result.TrashPath == "" {
t.Fatalf("trash result = %+v", result)
}
if _, err := os.Stat(filepath.Join(vaultDir, "Renamed")); !os.IsNotExist(err) {
t.Fatalf("workspace should be moved out of top level, stat err=%v", err)
}
}
func TestMoveWorkspaceNodeCompatibilityIsUnsupported(t *testing.T) {
app, vaultDir := newFilesTestApp(t, []string{"files.read"})
app.workspace = workspace.NewManager(vaultDir)
if err := app.workspace.Load(); err != nil {
t.Fatalf("workspace Load: %v", err)
}
if _, errStr := app.CreateWorkspace("Project", "default"); errStr != "" {
t.Fatalf("CreateWorkspace Project: %s", errStr)
}
if _, errStr := app.CreateWorkspace("Test", "default"); errStr != "" {
t.Fatalf("CreateWorkspace Test: %s", errStr)
}
errStr := app.MoveWorkspaceNode("Project", "Test")
if errStr == "" || !strings.Contains(errStr, "top-level only") {
t.Fatalf("MoveWorkspaceNode error = %q, want top-level only", errStr)
}
if _, err := os.Stat(filepath.Join(vaultDir, "Test", "Project")); !os.IsNotExist(err) {
t.Fatalf("MoveWorkspaceNode created nested mapped workspace, stat err=%v", err)
}
}
func newBridgeTestApp(t *testing.T) *App {
t.Helper()
tmpDir := t.TempDir()
vaultParent := filepath.Join(tmpDir, "vault-parent")
if err := os.MkdirAll(vaultParent, 0o755); err != nil {
t.Fatal(err)
}
bus := events.NewBus()
vaultService := vault.NewVault(bus)
if err := vaultService.CreateVault(vaultParent); err != nil {
t.Fatalf("CreateVault: %v", err)
}
capReg := capability.NewRegistry()
if err := capReg.Register("verstak-desktop", []string{"verstak/core/vault/v1"}); err != nil {
t.Fatal(err)
}
if err := capReg.Register("bridge.plugin", []string{"bridge/cap/v1"}); err != nil {
t.Fatal(err)
}
contribReg := contribution.NewRegistry()
contribReg.Register("bridge.plugin", &plugin.Contributions{
Commands: []plugin.ContributionCommand{
{ID: "bridge.command", Title: "Bridge Command", Handler: "runBridgeCommand"},
},
OpenProviders: []plugin.ContributionOpenProvider{
{
ID: "bridge.markdown",
Title: "Bridge Markdown",
Priority: 100,
Component: "BridgeMarkdown",
Supports: []plugin.OpenProviderSupport{
{Kind: "vault-file", Extensions: []string{".md"}, Contexts: []string{"generic-markdown", "notes-markdown"}},
},
},
},
})
return &App{
capRegistry: capReg,
contribRegistry: contribReg,
eventBus: bus,
vault: vaultService,
storage: storage.New(vaultService),
plugins: []plugin.Plugin{
{
Manifest: plugin.Manifest{
ID: "bridge.plugin",
Name: "Bridge Plugin",
Version: "1.0.0",
Provides: []string{"bridge/cap/v1"},
Requires: []string{"verstak/core/capability-registry/v1"},
Permissions: []string{"storage.namespace", "commands.register", "events.publish", "events.subscribe", "workbench.open"},
},
Status: plugin.StatusLoaded,
Enabled: true,
},
{
Manifest: plugin.Manifest{
ID: "no.storage",
Name: "No Storage",
Version: "1.0.0",
Provides: []string{"no/storage/v1"},
Permissions: []string{"events.publish"},
},
Status: plugin.StatusLoaded,
Enabled: true,
},
{
Manifest: plugin.Manifest{
ID: "disabled.plugin",
Name: "Disabled",
Version: "1.0.0",
Provides: []string{"disabled/cap/v1"},
Permissions: []string{"storage.namespace"},
},
Status: plugin.StatusDisabled,
Enabled: false,
},
},
}
}
func TestContributionSummaryIncludesOpenProviders(t *testing.T) {
app := newBridgeTestApp(t)
summary := app.GetContributions()
if len(summary.OpenProviders) != 1 {
t.Fatalf("OpenProviders count = %d, want 1", len(summary.OpenProviders))
}
provider := summary.OpenProviders[0]
if provider.PluginID != "bridge.plugin" || provider.ID != "bridge.markdown" || provider.Component != "BridgeMarkdown" {
t.Fatalf("provider = %+v", provider)
}
if len(provider.Supports) != 1 || provider.Supports[0].Contexts[1] != "notes-markdown" {
t.Fatalf("supports = %+v", provider.Supports)
}
}
func TestWorkbenchOpenAndEditResourceRouteToProvider(t *testing.T) {
app := newBridgeTestApp(t)
app.contribRegistry.Register("disabled.plugin", &plugin.Contributions{
OpenProviders: []plugin.ContributionOpenProvider{
{
ID: "disabled.markdown",
Title: "Disabled Markdown",
Priority: 1000,
Component: "DisabledMarkdown",
Supports: []plugin.OpenProviderSupport{
{Kind: "vault-file", Extensions: []string{".md"}, Contexts: []string{"generic-markdown", "notes-markdown"}},
},
},
},
})
result, errStr := app.OpenWorkbenchResource("bridge.plugin", map[string]interface{}{
"kind": "vault-file",
"path": "Notes/Overview.md",
"extension": ".md",
"context": map[string]interface{}{
"sourceView": "notes",
"isInsideNotesFolder": true,
"notesMode": true,
},
})
if errStr != "" {
t.Fatalf("OpenWorkbenchResource: %s", errStr)
}
if result.ProviderID != "bridge.markdown" || result.ProviderComponent != "BridgeMarkdown" || result.Request.Mode != "view" {
t.Fatalf("open result = %+v", result)
}
editResult, errStr := app.EditWorkbenchResource("bridge.plugin", map[string]interface{}{
"kind": "vault-file",
"path": "Notes/Overview.md",
"extension": ".md",
"context": map[string]interface{}{
"sourceView": "notes",
"isInsideNotesFolder": true,
"notesMode": true,
},
})
if errStr != "" {
t.Fatalf("EditWorkbenchResource: %s", errStr)
}
if editResult.Request.Mode != "edit" {
t.Fatalf("edit mode = %q", editResult.Request.Mode)
}
opened := app.GetWorkbenchOpenedResources()
if len(opened) != 2 {
t.Fatalf("opened resources = %+v", opened)
}
}
func TestWorkbenchOpenResourceReturnsNoProviderFallback(t *testing.T) {
app := newBridgeTestApp(t)
result, errStr := app.OpenWorkbenchResource("bridge.plugin", map[string]interface{}{
"kind": "vault-file",
"path": "Images/logo.png",
})
if errStr != "" {
t.Fatalf("OpenWorkbenchResource: %s", errStr)
}
if result.Status != "no-provider" || result.Request.Path != "Images/logo.png" {
t.Fatalf("result = %+v", result)
}
}
func TestWorkbenchOpenResourceRequiresPermission(t *testing.T) {
app := newBridgeTestApp(t)
_, errStr := app.OpenWorkbenchResource("no.storage", map[string]interface{}{
"kind": "vault-file",
"path": "Docs/readme.md",
})
if !strings.Contains(errStr, "workbench.open") {
t.Fatalf("err = %q, want workbench.open permission error", errStr)
}
}
func TestWorkbenchDisabledPluginProviderExcluded(t *testing.T) {
app := newBridgeTestApp(t)
result, errStr := app.OpenWorkbenchResource("bridge.plugin", map[string]interface{}{
"kind": "vault-file",
"path": "Docs/readme.md",
"extension": ".md",
})
if errStr != "" {
t.Fatalf("OpenWorkbenchResource: %s", errStr)
}
if result.ProviderPluginID != "bridge.plugin" {
t.Fatalf("expected bridge.plugin provider, got providerPluginId=%q", result.ProviderPluginID)
}
if result.ProviderID == "disabled.markdown" {
t.Fatal("disabled plugin provider should be excluded from selection")
}
}
func TestPluginBridgeSettingsRequireLoadedPluginAndStoragePermission(t *testing.T) {
app := newBridgeTestApp(t)
if errStr := app.WritePluginSettings("bridge.plugin", map[string]interface{}{"savedText": "hello"}); errStr != "" {
t.Fatalf("WritePluginSettings: %s", errStr)
}
settings, errStr := app.ReadPluginSettings("bridge.plugin")
if errStr != "" {
t.Fatalf("ReadPluginSettings: %s", errStr)
}
if settings["savedText"] != "hello" {
t.Fatalf("savedText = %v, want hello", settings["savedText"])
}
if _, errStr := app.ReadPluginSettings("missing.plugin"); errStr == "" {
t.Fatal("expected error for missing plugin")
}
if _, errStr := app.ReadPluginSettings("disabled.plugin"); errStr == "" {
t.Fatal("expected error for disabled plugin")
}
if _, errStr := app.ReadPluginSettings("no.storage"); errStr == "" {
t.Fatal("expected error for plugin without storage.namespace")
}
}
func TestPluginBridgeCapabilitiesCommandsAndEventsAreChecked(t *testing.T) {
app := newBridgeTestApp(t)
capInfo, errStr := app.GetPluginCapability("bridge.plugin", "bridge/cap/v1")
if errStr != "" {
t.Fatalf("GetPluginCapability: %s", errStr)
}
if capInfo["available"] != true {
t.Fatalf("capability should be available: %#v", capInfo)
}
if _, errStr := app.GetPluginCapability("no.storage", "bridge/cap/v1"); errStr == "" {
t.Fatal("expected capability dependency error")
}
commandResult, errStr := app.ExecutePluginCommand("bridge.plugin", "bridge.command", map[string]interface{}{"value": "x"})
if errStr != "" {
t.Fatalf("ExecutePluginCommand: %s", errStr)
}
if commandResult["status"] != "declared" {
t.Fatalf("command status = %v, want declared", commandResult["status"])
}
if errStr := app.PublishPluginEvent("bridge.plugin", "bridge.event", map[string]interface{}{"ok": true}); errStr != "" {
t.Fatalf("PublishPluginEvent: %s", errStr)
}
if errStr := app.SubscribePluginEvent("bridge.plugin", "bridge.event"); errStr != "" {
t.Fatalf("SubscribePluginEvent: %s", errStr)
}
if errStr := app.SubscribePluginEvent("no.storage", "bridge.event"); errStr == "" {
t.Fatal("expected subscribe permission error")
}
if _, errStr := app.ExecutePluginCommand("no.storage", "bridge.command", nil); errStr == "" {
t.Fatal("expected command permission/ownership error")
}
}
+41 -18
View File
@@ -8,21 +8,40 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"sync"
"time"
)
// Config represents the application settings stored in ~/.config/verstak/config.json.
type Config struct {
SchemaVersion int `json:"schemaVersion"`
CurrentVaultPath string `json:"currentVaultPath"`
RecentVaults []string `json:"recentVaults"`
Theme string `json:"theme"`
DevMode bool `json:"devMode"`
UserPluginsDir string `json:"userPluginsDir"`
WindowState *WindowState `json:"windowState,omitempty"`
LastOpenedAt string `json:"lastOpenedAt"`
SchemaVersion int `json:"schemaVersion"`
CurrentVaultPath string `json:"currentVaultPath"`
RecentVaults []string `json:"recentVaults"`
Theme string `json:"theme"`
DevMode bool `json:"devMode"`
UserPluginsDir string `json:"userPluginsDir"`
Workbench WorkbenchPreferences `json:"workbench,omitempty"`
Sync SyncSettings `json:"sync,omitempty"`
WindowState *WindowState `json:"windowState,omitempty"`
LastOpenedAt string `json:"lastOpenedAt"`
}
type WorkbenchPreferences struct {
DefaultTextEditorProvider string `json:"defaultTextEditorProvider,omitempty"`
DefaultMarkdownEditorProvider string `json:"defaultMarkdownEditorProvider,omitempty"`
DefaultNotesMarkdownEditorProvider string `json:"defaultNotesMarkdownEditorProvider,omitempty"`
}
// SyncSettings holds sync configuration for the current vault.
type SyncSettings struct {
Enabled bool `json:"enabled"`
ServerURL string `json:"serverUrl"`
DeviceID string `json:"deviceId"`
DeviceName string `json:"deviceName"`
SyncInterval int `json:"syncInterval"`
LastStatus string `json:"lastStatus"`
LastSyncAt string `json:"lastSyncAt"`
LastError string `json:"lastError,omitempty"`
}
// WindowState stores the last window position and size.
@@ -157,6 +176,15 @@ func (m *Manager) Update(patch *Config) error {
if patch.WindowState != nil {
m.config.WindowState = patch.WindowState
}
if patch.Workbench.DefaultTextEditorProvider != "" {
m.config.Workbench.DefaultTextEditorProvider = patch.Workbench.DefaultTextEditorProvider
}
if patch.Workbench.DefaultMarkdownEditorProvider != "" {
m.config.Workbench.DefaultMarkdownEditorProvider = patch.Workbench.DefaultMarkdownEditorProvider
}
if patch.Workbench.DefaultNotesMarkdownEditorProvider != "" {
m.config.Workbench.DefaultNotesMarkdownEditorProvider = patch.Workbench.DefaultNotesMarkdownEditorProvider
}
m.config.DevMode = patch.DevMode
m.config.LastOpenedAt = time.Now().UTC().Format(time.RFC3339)
@@ -202,6 +230,7 @@ func defaultConfig() *Config {
Theme: "dark",
DevMode: false,
UserPluginsDir: filepath.Join(os.Getenv("HOME"), ".config", "verstak", "plugins"),
Workbench: WorkbenchPreferences{},
WindowState: &WindowState{Width: 1200, Height: 800},
LastOpenedAt: time.Now().UTC().Format(time.RFC3339),
}
@@ -217,6 +246,8 @@ func copyConfig(c *Config) *Config {
Theme: c.Theme,
DevMode: c.DevMode,
UserPluginsDir: c.UserPluginsDir,
Workbench: c.Workbench,
Sync: c.Sync,
LastOpenedAt: c.LastOpenedAt,
}
if c.WindowState != nil {
@@ -237,19 +268,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
}
+31
View File
@@ -124,6 +124,37 @@ func TestUpdate_Patch(t *testing.T) {
}
}
func TestUpdate_WorkbenchPreferences(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
m := NewManager(path)
if err := m.Load(); err != nil {
t.Fatal(err)
}
if err := m.Update(&Config{
Workbench: WorkbenchPreferences{
DefaultTextEditorProvider: "editor.text",
DefaultMarkdownEditorProvider: "editor.markdown",
DefaultNotesMarkdownEditorProvider: "editor.notes",
},
}); err != nil {
t.Fatalf("Update: %v", err)
}
reloaded := NewManager(path)
if err := reloaded.Load(); err != nil {
t.Fatal(err)
}
cfg := reloaded.Get()
if cfg.Workbench.DefaultTextEditorProvider != "editor.text" ||
cfg.Workbench.DefaultMarkdownEditorProvider != "editor.markdown" ||
cfg.Workbench.DefaultNotesMarkdownEditorProvider != "editor.notes" {
t.Fatalf("workbench preferences = %+v", cfg.Workbench)
}
}
func TestAppSettings_NotInsideVault(t *testing.T) {
// App settings path should be under ~/.config/verstak/, not inside vault
path := DefaultConfigPath()
+162
View File
@@ -22,6 +22,85 @@ type Registry struct {
searchProviders []ContributionSearchProvider
activityProviders []ContributionActivityProvider
statusBarItems []ContributionStatusBarItem
openProviders []ContributionOpenProvider
workspaceItems []ContributionWorkspaceItem
}
// ContributionPointType defines the type of contribution point.
type ContributionPointType string
const (
PointViews ContributionPointType = "views"
PointCommands ContributionPointType = "commands"
PointSettingsPanels ContributionPointType = "settingsPanels"
PointSidebarItems ContributionPointType = "sidebarItems"
PointFileActions ContributionPointType = "fileActions"
PointNoteActions ContributionPointType = "noteActions"
PointContextMenus ContributionPointType = "contextMenus"
PointSearchProviders ContributionPointType = "searchProviders"
PointActivity ContributionPointType = "activityProviders"
PointStatusBar ContributionPointType = "statusBarItems"
PointOpenProviders ContributionPointType = "openProviders"
PointWorkspaceItems ContributionPointType = "workspaceItems"
)
// ListByPoint returns all contributions for a given point type.
func (r *Registry) ListByPoint(point ContributionPointType) []interface{} {
r.mu.RLock()
defer r.mu.RUnlock()
var result []interface{}
switch point {
case PointViews:
for _, v := range r.views {
result = append(result, v)
}
case PointCommands:
for _, v := range r.commands {
result = append(result, v)
}
case PointSettingsPanels:
for _, v := range r.settingsPanels {
result = append(result, v)
}
case PointSidebarItems:
for _, v := range r.sidebarItems {
result = append(result, v)
}
case PointFileActions:
for _, v := range r.fileActions {
result = append(result, v)
}
case PointNoteActions:
for _, v := range r.noteActions {
result = append(result, v)
}
case PointContextMenus:
for _, v := range r.contextMenus {
result = append(result, v)
}
case PointSearchProviders:
for _, v := range r.searchProviders {
result = append(result, v)
}
case PointActivity:
for _, v := range r.activityProviders {
result = append(result, v)
}
case PointStatusBar:
for _, v := range r.statusBarItems {
result = append(result, v)
}
case PointOpenProviders:
for _, v := range r.openProviders {
result = append(result, v)
}
case PointWorkspaceItems:
for _, v := range r.workspaceItems {
result = append(result, v)
}
}
return result
}
type ContributionView struct {
@@ -69,16 +148,43 @@ type ContributionStatusBarItem struct {
Item plugin.ContributionStatusBarItem `json:"item"`
}
type ContributionOpenProvider struct {
PluginID string `json:"pluginId"`
Item plugin.ContributionOpenProvider `json:"item"`
}
// ContributionWorkspaceItem is a workspace tool contribution.
type ContributionWorkspaceItem struct {
PluginID string `json:"pluginId"`
Item plugin.ContributionWorkspaceItem `json:"item"`
}
// NewRegistry creates a new contribution registry.
func NewRegistry() *Registry {
return &Registry{}
}
// Register adds all contributions from a plugin.
// If the plugin already has registered contributions they are replaced
// (supports reload without duplicates).
func (r *Registry) Register(pluginID string, c *plugin.Contributions) {
r.mu.Lock()
defer r.mu.Unlock()
// Remove existing contributions for this plugin to prevent duplicates on reload
r.views = removeViews(r.views, pluginID)
r.commands = removeCommands(r.commands, pluginID)
r.settingsPanels = removeSettingsPanels(r.settingsPanels, pluginID)
r.sidebarItems = removeSidebarItems(r.sidebarItems, pluginID)
r.fileActions = removeActions(r.fileActions, pluginID)
r.noteActions = removeActions(r.noteActions, pluginID)
r.contextMenus = removeContextMenus(r.contextMenus, pluginID)
r.searchProviders = removeSearchProviders(r.searchProviders, pluginID)
r.activityProviders = removeActivityProviders(r.activityProviders, pluginID)
r.statusBarItems = removeStatusBarItems(r.statusBarItems, pluginID)
r.openProviders = removeOpenProviders(r.openProviders, pluginID)
r.workspaceItems = removeWorkspaceItems(r.workspaceItems, pluginID)
for _, item := range c.Views {
r.views = append(r.views, ContributionView{PluginID: pluginID, Item: item})
}
@@ -109,6 +215,12 @@ func (r *Registry) Register(pluginID string, c *plugin.Contributions) {
for _, item := range c.StatusBarItems {
r.statusBarItems = append(r.statusBarItems, ContributionStatusBarItem{PluginID: pluginID, Item: item})
}
for _, item := range c.OpenProviders {
r.openProviders = append(r.openProviders, ContributionOpenProvider{PluginID: pluginID, Item: item})
}
for _, item := range c.WorkspaceItems {
r.workspaceItems = append(r.workspaceItems, ContributionWorkspaceItem{PluginID: pluginID, Item: item})
}
}
// Unregister removes all contributions from a plugin.
@@ -126,6 +238,8 @@ func (r *Registry) Unregister(pluginID string) {
r.searchProviders = removeSearchProviders(r.searchProviders, pluginID)
r.activityProviders = removeActivityProviders(r.activityProviders, pluginID)
r.statusBarItems = removeStatusBarItems(r.statusBarItems, pluginID)
r.openProviders = removeOpenProviders(r.openProviders, pluginID)
r.workspaceItems = removeWorkspaceItems(r.workspaceItems, pluginID)
}
// Getters — sorted for deterministic display.
@@ -193,6 +307,34 @@ func (r *Registry) SearchProviders() []ContributionSearchProvider {
return result
}
func (r *Registry) OpenProviders() []ContributionOpenProvider {
r.mu.RLock()
defer r.mu.RUnlock()
result := make([]ContributionOpenProvider, len(r.openProviders))
copy(result, r.openProviders)
sort.Slice(result, func(i, j int) bool {
if result[i].PluginID != result[j].PluginID {
return result[i].PluginID < result[j].PluginID
}
return result[i].Item.ID < result[j].Item.ID
})
return result
}
func (r *Registry) WorkspaceItems() []ContributionWorkspaceItem {
r.mu.RLock()
defer r.mu.RUnlock()
result := make([]ContributionWorkspaceItem, len(r.workspaceItems))
copy(result, r.workspaceItems)
sort.Slice(result, func(i, j int) bool {
if result[i].PluginID != result[j].PluginID {
return result[i].PluginID < result[j].PluginID
}
return result[i].Item.ID < result[j].Item.ID
})
return result
}
// ─── Remove helpers ─────────────────────────────────────────
func removeViews(items []ContributionView, pluginID string) []ContributionView {
@@ -284,3 +426,23 @@ func removeStatusBarItems(items []ContributionStatusBarItem, pluginID string) []
}
return result
}
func removeOpenProviders(items []ContributionOpenProvider, pluginID string) []ContributionOpenProvider {
var result []ContributionOpenProvider
for _, item := range items {
if item.PluginID != pluginID {
result = append(result, item)
}
}
return result
}
func removeWorkspaceItems(items []ContributionWorkspaceItem, pluginID string) []ContributionWorkspaceItem {
var result []ContributionWorkspaceItem
for _, item := range items {
if item.PluginID != pluginID {
result = append(result, item)
}
}
return result
}
+362
View File
@@ -0,0 +1,362 @@
package contribution
import (
"testing"
"github.com/verstak/verstak-desktop/internal/core/plugin"
)
// TestRegister_AddsContributions registers sidebar, view, command, settings contributions
// for plugin "test.plugin" and verifies they appear via Views(), Commands(),
// SettingsPanels(), SidebarItems().
func TestRegister_AddsContributions(t *testing.T) {
r := NewRegistry()
contribs := &plugin.Contributions{
Views: []plugin.ContributionView{
{ID: "test.view1", Title: "View 1", Component: "TestComponent"},
},
Commands: []plugin.ContributionCommand{
{ID: "test.cmd1", Title: "Command 1"},
},
SettingsPanels: []plugin.ContributionSettingsPanel{
{ID: "test.settings1", Title: "Settings 1", Component: "SettingsComponent"},
},
SidebarItems: []plugin.ContributionSidebarItem{
{ID: "test.sidebar1", Title: "Sidebar 1", Icon: "icon", View: "test.view1", Position: 1},
},
}
r.Register("test.plugin", contribs)
// Verify counts
if got := len(r.Views()); got != 1 {
t.Errorf("Views(): got %d, want 1", got)
}
if got := len(r.Commands()); got != 1 {
t.Errorf("Commands(): got %d, want 1", got)
}
if got := len(r.SettingsPanels()); got != 1 {
t.Errorf("SettingsPanels(): got %d, want 1", got)
}
if got := len(r.SidebarItems()); got != 1 {
t.Errorf("SidebarItems(): got %d, want 1", got)
}
// Verify the PluginID is set correctly
if r.Views()[0].PluginID != "test.plugin" {
t.Errorf("Views()[0].PluginID = %q, want %q", r.Views()[0].PluginID, "test.plugin")
}
if r.Commands()[0].PluginID != "test.plugin" {
t.Errorf("Commands()[0].PluginID = %q, want %q", r.Commands()[0].PluginID, "test.plugin")
}
if r.SettingsPanels()[0].PluginID != "test.plugin" {
t.Errorf("SettingsPanels()[0].PluginID = %q, want %q", r.SettingsPanels()[0].PluginID, "test.plugin")
}
if r.SidebarItems()[0].PluginID != "test.plugin" {
t.Errorf("SidebarItems()[0].PluginID = %q, want %q", r.SidebarItems()[0].PluginID, "test.plugin")
}
// Verify item data is preserved
if r.Views()[0].Item.Title != "View 1" {
t.Errorf("Views()[0].Item.Title = %q, want %q", r.Views()[0].Item.Title, "View 1")
}
if r.Commands()[0].Item.Title != "Command 1" {
t.Errorf("Commands()[0].Item.Title = %q, want %q", r.Commands()[0].Item.Title, "Command 1")
}
if r.SettingsPanels()[0].Item.Title != "Settings 1" {
t.Errorf("SettingsPanels()[0].Item.Title = %q, want %q", r.SettingsPanels()[0].Item.Title, "Settings 1")
}
if r.SidebarItems()[0].Item.Title != "Sidebar 1" {
t.Errorf("SidebarItems()[0].Item.Title = %q, want %q", r.SidebarItems()[0].Item.Title, "Sidebar 1")
}
}
// TestUnregister_RemovesOwnedContributions registers for two plugins, unregisters one,
// and verifies only that plugin's contributions are removed.
func TestUnregister_RemovesOwnedContributions(t *testing.T) {
r := NewRegistry()
contribA := &plugin.Contributions{
Views: []plugin.ContributionView{
{ID: "a.view1", Title: "A View", Component: "A"},
},
Commands: []plugin.ContributionCommand{
{ID: "a.cmd1", Title: "A Command"},
},
SettingsPanels: []plugin.ContributionSettingsPanel{
{ID: "a.settings1", Title: "A Settings", Component: "A"},
},
SidebarItems: []plugin.ContributionSidebarItem{
{ID: "a.sidebar1", Title: "A Sidebar", View: "a.view1"},
},
}
contribB := &plugin.Contributions{
Views: []plugin.ContributionView{
{ID: "b.view1", Title: "B View", Component: "B"},
},
Commands: []plugin.ContributionCommand{
{ID: "b.cmd1", Title: "B Command"},
},
SettingsPanels: []plugin.ContributionSettingsPanel{
{ID: "b.settings1", Title: "B Settings", Component: "B"},
},
SidebarItems: []plugin.ContributionSidebarItem{
{ID: "b.sidebar1", Title: "B Sidebar", View: "b.view1"},
},
}
r.Register("plugin.a", contribA)
r.Register("plugin.b", contribB)
// Unregister plugin.a
r.Unregister("plugin.a")
// Verify plugin.a contributions are removed
if got := r.Views(); len(got) != 1 || got[0].PluginID != "plugin.b" {
t.Errorf("Views: got %d items (first PluginID=%q), want 1 from plugin.b", len(got), safePluginIDView(got))
}
if got := r.Commands(); len(got) != 1 || got[0].PluginID != "plugin.b" {
t.Errorf("Commands: got %d items (first PluginID=%q), want 1 from plugin.b", len(got), safePluginIDCmd(got))
}
if got := r.SettingsPanels(); len(got) != 1 || got[0].PluginID != "plugin.b" {
t.Errorf("SettingsPanels: got %d items (first PluginID=%q), want 1 from plugin.b", len(got), safePluginIDSettings(got))
}
if got := r.SidebarItems(); len(got) != 1 || got[0].PluginID != "plugin.b" {
t.Errorf("SidebarItems: got %d items (first PluginID=%q), want 1 from plugin.b", len(got), safePluginIDSidebar(got))
}
// Verify plugin.b data is intact
if r.Views()[0].Item.ID != "b.view1" {
t.Errorf("Remaining View ID: got %q, want %q", r.Views()[0].Item.ID, "b.view1")
}
if r.Commands()[0].Item.ID != "b.cmd1" {
t.Errorf("Remaining Command ID: got %q, want %q", r.Commands()[0].Item.ID, "b.cmd1")
}
if r.SettingsPanels()[0].Item.ID != "b.settings1" {
t.Errorf("Remaining SettingsPanel ID: got %q, want %q", r.SettingsPanels()[0].Item.ID, "b.settings1")
}
if r.SidebarItems()[0].Item.ID != "b.sidebar1" {
t.Errorf("Remaining SidebarItem ID: got %q, want %q", r.SidebarItems()[0].Item.ID, "b.sidebar1")
}
}
// safe helpers for error messages when slices are empty
func safePluginIDView(items []ContributionView) string {
if len(items) == 0 {
return "<empty>"
}
return items[0].PluginID
}
func safePluginIDCmd(items []ContributionCommand) string {
if len(items) == 0 {
return "<empty>"
}
return items[0].PluginID
}
func safePluginIDSettings(items []ContributionSettingsPanel) string {
if len(items) == 0 {
return "<empty>"
}
return items[0].PluginID
}
func safePluginIDSidebar(items []ContributionSidebarItem) string {
if len(items) == 0 {
return "<empty>"
}
return items[0].PluginID
}
// TestListByPoint registers various types and calls ListByPoint for each point type,
// verifying correct counts.
func TestListByPoint(t *testing.T) {
r := NewRegistry()
contrib := &plugin.Contributions{
Views: []plugin.ContributionView{{ID: "v1", Title: "V1", Component: "C"}},
Commands: []plugin.ContributionCommand{{ID: "c1", Title: "C1"}},
SettingsPanels: []plugin.ContributionSettingsPanel{{ID: "s1", Title: "S1", Component: "C"}},
SidebarItems: []plugin.ContributionSidebarItem{{ID: "si1", Title: "SI1", View: "v1"}},
FileActions: []plugin.ContributionAction{{ID: "fa1", Label: "FA1"}},
NoteActions: []plugin.ContributionAction{{ID: "na1", Label: "NA1"}},
ContextMenuEntries: []plugin.ContributionContextMenuEntry{{ID: "cm1", Label: "CM1", Context: "file"}},
SearchProviders: []plugin.ContributionSearchProvider{{ID: "sp1", Label: "SP1", Handler: "h"}},
ActivityProviders: []plugin.ContributionActivityProvider{{ID: "ap1", Events: []string{"test"}, Handler: "h"}},
StatusBarItems: []plugin.ContributionStatusBarItem{{ID: "sb1", Label: "SB1"}},
OpenProviders: []plugin.ContributionOpenProvider{{
ID: "op1",
Title: "Open Provider 1",
Priority: 100,
Component: "OpenProvider",
Supports: []plugin.OpenProviderSupport{{
Kind: "vault-file",
Extensions: []string{".md"},
Contexts: []string{"generic-markdown", "notes-markdown"},
}},
}},
}
r.Register("test.plugin", contrib)
tests := []struct {
point ContributionPointType
want int
}{
{PointViews, 1},
{PointCommands, 1},
{PointSettingsPanels, 1},
{PointSidebarItems, 1},
{PointFileActions, 1},
{PointNoteActions, 1},
{PointContextMenus, 1},
{PointSearchProviders, 1},
{PointActivity, 1},
{PointStatusBar, 1},
{PointOpenProviders, 1},
}
for _, tt := range tests {
got := r.ListByPoint(tt.point)
if len(got) != tt.want {
t.Errorf("ListByPoint(%q): got %d items, want %d", tt.point, len(got), tt.want)
}
}
}
func TestOpenProviders_RegisterReplaceUnregister(t *testing.T) {
r := NewRegistry()
r.Register("editor.plugin", &plugin.Contributions{
OpenProviders: []plugin.ContributionOpenProvider{{
ID: "editor.markdown",
Title: "Markdown",
Priority: 50,
Component: "MarkdownEditor",
Supports: []plugin.OpenProviderSupport{{
Kind: "vault-file",
Extensions: []string{".md", ".markdown"},
Contexts: []string{"generic-markdown", "notes-markdown"},
}},
}},
})
providers := r.OpenProviders()
if len(providers) != 1 {
t.Fatalf("OpenProviders count = %d, want 1", len(providers))
}
if providers[0].PluginID != "editor.plugin" || providers[0].Item.Component != "MarkdownEditor" {
t.Fatalf("provider = %+v", providers[0])
}
r.Register("editor.plugin", &plugin.Contributions{
OpenProviders: []plugin.ContributionOpenProvider{{
ID: "editor.text",
Title: "Text",
Priority: 10,
Component: "TextEditor",
Supports: []plugin.OpenProviderSupport{{
Kind: "vault-file",
Extensions: []string{".txt"},
}},
}},
})
providers = r.OpenProviders()
if len(providers) != 1 || providers[0].Item.ID != "editor.text" {
t.Fatalf("providers after replace = %+v", providers)
}
r.Unregister("editor.plugin")
if got := len(r.OpenProviders()); got != 0 {
t.Fatalf("OpenProviders after unregister = %d, want 0", got)
}
}
// TestRegister_DuplicatePrevention calls Register twice for the same plugin
// (simulating reload) and checks contributions appear only once (no duplicates).
// This is the KEY TEST for idempotent re-registration.
func TestRegister_DuplicatePrevention(t *testing.T) {
r := NewRegistry()
contrib := &plugin.Contributions{
Views: []plugin.ContributionView{
{ID: "test.view1", Title: "View 1", Component: "C"},
},
Commands: []plugin.ContributionCommand{
{ID: "test.cmd1", Title: "Cmd 1"},
},
SettingsPanels: []plugin.ContributionSettingsPanel{
{ID: "test.settings1", Title: "Settings 1", Component: "C"},
},
SidebarItems: []plugin.ContributionSidebarItem{
{ID: "test.sidebar1", Title: "Sidebar 1", View: "test.view1"},
},
}
// First registration
r.Register("test.plugin", contrib)
// Second registration — simulates plugin reload
r.Register("test.plugin", contrib)
// Each type should have only 1 entry (no duplicates)
if got := len(r.Views()); got != 1 {
t.Errorf("Views after double Register: got %d, want 1 (no duplicates)", got)
}
if got := len(r.Commands()); got != 1 {
t.Errorf("Commands after double Register: got %d, want 1 (no duplicates)", got)
}
if got := len(r.SettingsPanels()); got != 1 {
t.Errorf("SettingsPanels after double Register: got %d, want 1 (no duplicates)", got)
}
if got := len(r.SidebarItems()); got != 1 {
t.Errorf("SidebarItems after double Register: got %d, want 1 (no duplicates)", got)
}
// Also verify the item data is preserved correctly
if r.Views()[0].Item.ID != "test.view1" {
t.Errorf("View ID after reload: got %q, want %q", r.Views()[0].Item.ID, "test.view1")
}
if r.Commands()[0].Item.ID != "test.cmd1" {
t.Errorf("Command ID after reload: got %q, want %q", r.Commands()[0].Item.ID, "test.cmd1")
}
if r.SettingsPanels()[0].Item.ID != "test.settings1" {
t.Errorf("SettingsPanel ID after reload: got %q, want %q", r.SettingsPanels()[0].Item.ID, "test.settings1")
}
if r.SidebarItems()[0].Item.ID != "test.sidebar1" {
t.Errorf("SidebarItem ID after reload: got %q, want %q", r.SidebarItems()[0].Item.ID, "test.sidebar1")
}
}
// TestUnregister_NoSideEffects verifies that unregistering a non-existent plugin
// doesn't crash or corrupt the registry.
func TestUnregister_NoSideEffects(t *testing.T) {
r := NewRegistry()
// Register a plugin
contrib := &plugin.Contributions{
Views: []plugin.ContributionView{
{ID: "v1", Title: "V1", Component: "C"},
},
}
r.Register("existing.plugin", contrib)
// Unregister a plugin that was never registered — should not panic
r.Unregister("nonexistent.plugin")
// Existing plugin's contributions should still be intact
if got := len(r.Views()); got != 1 {
t.Errorf("Views after unregistering non-existent: got %d, want 1", got)
}
if r.Views()[0].PluginID != "existing.plugin" {
t.Errorf("PluginID after unregistering non-existent: got %q, want %q", r.Views()[0].PluginID, "existing.plugin")
}
// Unregister with empty string — should not panic
r.Unregister("")
// Still intact
if got := len(r.Views()); got != 1 {
t.Errorf("Views after unregistering empty string: got %d, want 1", got)
}
}
+85
View File
@@ -0,0 +1,85 @@
package files
import (
"fmt"
"path"
"path/filepath"
"strings"
"unicode"
)
func NormalizeRelativeDir(relativeDir string) (string, error) {
return normalizeRelativePath(relativeDir, true)
}
func NormalizeRelativeFile(relativePath string) (string, error) {
return normalizeRelativePath(relativePath, false)
}
func IsReservedPath(relativePath string) bool {
normalized := strings.ReplaceAll(relativePath, "\\", "/")
cleaned := path.Clean(normalized)
if cleaned == "." {
cleaned = ""
}
if cleaned == "" {
return false
}
first := strings.Split(cleaned, "/")[0]
return strings.EqualFold(first, ".verstak")
}
func normalizeRelativePath(input string, allowRoot bool) (string, error) {
if strings.Contains(input, "\x00") {
return "", fmt.Errorf("invalid-path: null-byte")
}
if strings.Contains(input, "\\") {
return "", fmt.Errorf("invalid-path: backslash not allowed")
}
if looksAbsolute(input) {
return "", fmt.Errorf("invalid-path: absolute path rejected")
}
normalized := input
for _, part := range strings.Split(normalized, "/") {
if part == ".." {
return "", fmt.Errorf("invalid-path: path-traversal")
}
}
cleaned := path.Clean(normalized)
if cleaned == "." {
cleaned = ""
}
if cleaned == "" && !allowRoot {
return "", fmt.Errorf("invalid-path: empty path")
}
if cleaned == ".." || strings.HasPrefix(cleaned, "../") {
return "", fmt.Errorf("invalid-path: path-traversal")
}
if IsReservedPathNoNormalize(cleaned) {
return "", fmt.Errorf("reserved-path: .verstak is internal")
}
return cleaned, nil
}
func IsReservedPathNoNormalize(cleaned string) bool {
if cleaned == "" {
return false
}
first := strings.Split(cleaned, "/")[0]
return strings.EqualFold(first, ".verstak")
}
func looksAbsolute(input string) bool {
if input == "" {
return false
}
if filepath.IsAbs(input) || strings.HasPrefix(input, "/") || strings.HasPrefix(input, "\\\\") || strings.HasPrefix(input, "\\") {
return true
}
if len(input) >= 2 && input[1] == ':' && unicode.IsLetter(rune(input[0])) {
return true
}
return false
}
+93
View File
@@ -0,0 +1,93 @@
package files
import (
"strings"
"testing"
)
func TestNormalizeRelativeDirAllowsRootAndPreservesCase(t *testing.T) {
got, err := NormalizeRelativeDir("")
if err != nil {
t.Fatalf("NormalizeRelativeDir root: %v", err)
}
if got != "" {
t.Fatalf("root dir = %q, want empty", got)
}
got, err = NormalizeRelativeDir("Notes/Overview.md")
if err != nil {
t.Fatalf("NormalizeRelativeDir preserves case: %v", err)
}
if got != "Notes/Overview.md" {
t.Fatalf("path = %q, want Notes/Overview.md", got)
}
}
func TestNormalizeRelativeFileRejectsUnsafePaths(t *testing.T) {
cases := []string{
"",
"/etc/passwd",
"C:\\Users\\file.txt",
"C:/Windows/system.ini",
`\\server\share`,
"//server/share",
`..\secret`,
`folder\..\secret`,
"../outside.txt",
"folder/../../outside.txt",
`folder\sub/../../secret`,
`folder\sub`,
"bad\x00name.txt",
".verstak",
".verstak/",
".verstak/vault.json",
"./.verstak",
".verstak/trash",
"folder/../.verstak",
".Verstak",
}
for _, input := range cases {
t.Run(input, func(t *testing.T) {
_, err := NormalizeRelativeFile(input)
if err == nil {
t.Fatalf("NormalizeRelativeFile(%q): expected error", input)
}
})
}
}
func TestReservedPathPolicy(t *testing.T) {
if !IsReservedPath(".verstak") {
t.Fatal(".verstak should be reserved")
}
if !IsReservedPath(".verstak/trash/file.txt") {
t.Fatal(".verstak/trash/file.txt should be reserved")
}
if !IsReservedPath(".Verstak/trash/file.txt") {
t.Fatal(".Verstak/trash/file.txt should be reserved by case-insensitive policy")
}
if IsReservedPath("Notes/.verstak.md") {
t.Fatal("Notes/.verstak.md should not be reserved")
}
}
func TestNormalizeRelativeFileAcceptsOnlySlashSeparatedRelativePaths(t *testing.T) {
got, err := NormalizeRelativeFile("Notes/Overview.md")
if err != nil {
t.Fatalf("NormalizeRelativeFile valid slash path: %v", err)
}
if got != "Notes/Overview.md" {
t.Fatalf("path = %q, want Notes/Overview.md", got)
}
}
func TestPathPolicyErrorsAreReadable(t *testing.T) {
_, err := NormalizeRelativeFile("../outside.txt")
if err == nil {
t.Fatal("expected traversal error")
}
if !strings.Contains(err.Error(), "path-traversal") {
t.Fatalf("error = %q, want path-traversal", err.Error())
}
}
+472
View File
@@ -0,0 +1,472 @@
package files
import (
"encoding/json"
"fmt"
"io/fs"
"mime"
"os"
"path/filepath"
"strings"
"time"
"unicode/utf8"
"github.com/google/uuid"
"github.com/verstak/verstak-desktop/internal/core/vault"
)
type Service struct {
vault *vault.Vault
}
func NewService(v *vault.Vault) *Service {
return &Service{vault: v}
}
func (s *Service) ListVaultFiles(relativeDir string) ([]FileEntry, error) {
root, err := s.vaultRoot()
if err != nil {
return nil, err
}
rel, err := NormalizeRelativeDir(relativeDir)
if err != nil {
return nil, err
}
full, err := s.resolve(root, rel)
if err != nil {
return nil, err
}
if err := rejectSymlinkPath(root, rel, true); err != nil {
return nil, err
}
info, err := os.Stat(full)
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("not-found: %s", rel)
}
return nil, err
}
if !info.IsDir() {
return nil, fmt.Errorf("not-directory: %s", rel)
}
dirEntries, err := os.ReadDir(full)
if err != nil {
return nil, err
}
entries := make([]FileEntry, 0, len(dirEntries))
for _, dirEntry := range dirEntries {
childRel := joinRel(rel, dirEntry.Name())
if IsReservedPathNoNormalize(childRel) {
continue
}
info, err := dirEntry.Info()
if err != nil {
continue
}
entries = append(entries, makeEntry(childRel, info))
}
return entries, nil
}
func (s *Service) GetVaultFileMetadata(relativePath string) (FileMetadata, error) {
root, rel, full, err := s.resolveFile(relativePath)
if err != nil {
return FileMetadata{}, err
}
if err := rejectSymlinkPath(root, rel, false); err != nil {
return FileMetadata{}, err
}
info, err := os.Lstat(full)
if err != nil {
if os.IsNotExist(err) {
return FileMetadata{}, fmt.Errorf("not-found: %s", rel)
}
return FileMetadata{}, err
}
return makeMetadata(rel, info), nil
}
func (s *Service) ReadVaultTextFile(relativePath string) (string, error) {
root, rel, full, err := s.resolveFile(relativePath)
if err != nil {
return "", err
}
if err := rejectSymlinkPath(root, rel, true); err != nil {
return "", err
}
info, err := os.Lstat(full)
if err != nil {
if os.IsNotExist(err) {
return "", fmt.Errorf("not-found: %s", rel)
}
return "", err
}
if info.Mode()&os.ModeSymlink != 0 {
return "", fmt.Errorf("symlink-not-allowed: %s", rel)
}
if !info.Mode().IsRegular() {
return "", fmt.Errorf("not-regular-file: %s", rel)
}
if info.Size() > MaxTextFileBytes {
return "", fmt.Errorf("file-too-large: %s", rel)
}
data, err := os.ReadFile(full)
if err != nil {
return "", err
}
if !utf8.Valid(data) {
return "", fmt.Errorf("not-text-file: %s", rel)
}
return string(data), nil
}
func (s *Service) WriteVaultTextFile(relativePath string, content string, options WriteOptions) error {
root, rel, full, err := s.resolveFile(relativePath)
if err != nil {
return err
}
if err := rejectSymlinkPath(root, rel, true); err != nil {
return err
}
parent := filepath.Dir(full)
if info, err := os.Stat(parent); err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("parent-not-found: %s", pathDir(rel))
}
return err
} else if !info.IsDir() {
return fmt.Errorf("parent-not-directory: %s", pathDir(rel))
}
existing, err := os.Lstat(full)
if err == nil {
if existing.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("symlink-not-allowed: %s", rel)
}
if !existing.Mode().IsRegular() {
return fmt.Errorf("not-regular-file: %s", rel)
}
if !options.Overwrite {
return fmt.Errorf("conflict: %s", rel)
}
} else if os.IsNotExist(err) {
if !options.CreateIfMissing {
return fmt.Errorf("not-found: %s", rel)
}
} else {
return err
}
tmp, err := os.CreateTemp(parent, ".verstak-write-*")
if err != nil {
return err
}
tmpName := tmp.Name()
cleanup := true
defer func() {
if cleanup {
_ = os.Remove(tmpName)
}
}()
if _, err := tmp.WriteString(content); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Rename(tmpName, full); err != nil {
return err
}
cleanup = false
return nil
}
func (s *Service) CreateVaultFolder(relativePath string) error {
root, rel, full, err := s.resolveFile(relativePath)
if err != nil {
return err
}
if _, err := os.Lstat(full); err == nil {
return fmt.Errorf("conflict: %s", rel)
} else if !os.IsNotExist(err) {
return err
}
if err := rejectSymlinkPath(root, rel, true); err != nil {
return err
}
parent := filepath.Dir(full)
if info, err := os.Stat(parent); err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("parent-not-found: %s", pathDir(rel))
}
return err
} else if !info.IsDir() {
return fmt.Errorf("parent-not-directory: %s", pathDir(rel))
}
return os.Mkdir(full, 0o755)
}
func (s *Service) MoveVaultPath(fromRelativePath string, toRelativePath string, options MoveOptions) error {
root, fromRel, fromFull, err := s.resolveFile(fromRelativePath)
if err != nil {
return err
}
_, toRel, toFull, err := s.resolveFile(toRelativePath)
if err != nil {
return err
}
if fromRel == "" || toRel == "" {
return fmt.Errorf("invalid-path: cannot move root")
}
if err := rejectSymlinkPath(root, fromRel, true); err != nil {
return err
}
if err := rejectSymlinkPath(root, toRel, false); err != nil {
return err
}
fromInfo, err := os.Lstat(fromFull)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("not-found: %s", fromRel)
}
return err
}
if fromInfo.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("symlink-not-allowed: %s", fromRel)
}
if fromInfo.IsDir() && (toRel == fromRel || strings.HasPrefix(toRel, fromRel+"/")) {
return fmt.Errorf("move-into-self: %s -> %s", fromRel, toRel)
}
parent := filepath.Dir(toFull)
if info, err := os.Stat(parent); err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("parent-not-found: %s", pathDir(toRel))
}
return err
} else if !info.IsDir() {
return fmt.Errorf("parent-not-directory: %s", pathDir(toRel))
}
if _, err := os.Lstat(toFull); err == nil && !options.Overwrite {
return fmt.Errorf("conflict: %s", toRel)
} else if err != nil && !os.IsNotExist(err) {
return err
}
return os.Rename(fromFull, toFull)
}
func (s *Service) TrashVaultPath(relativePath string) (TrashResult, error) {
root, rel, full, err := s.resolveFile(relativePath)
if err != nil {
return TrashResult{}, err
}
if err := rejectSymlinkPath(root, rel, true); err != nil {
return TrashResult{}, err
}
info, err := os.Lstat(full)
if err != nil {
if os.IsNotExist(err) {
return TrashResult{}, fmt.Errorf("not-found: %s", rel)
}
return TrashResult{}, err
}
if info.Mode()&os.ModeSymlink != 0 {
return TrashResult{}, fmt.Errorf("symlink-not-allowed: %s", rel)
}
deletedAt := time.Now().UTC().Format(time.RFC3339Nano)
trashID := time.Now().UTC().Format("20060102T150405.000000000Z") + "-" + uuid.NewString()
trashRel := filepath.ToSlash(filepath.Join(".verstak", "trash", "files", trashID, filepath.Base(rel)))
trashFull := filepath.Join(root, filepath.FromSlash(trashRel))
if err := os.MkdirAll(filepath.Dir(trashFull), 0o755); err != nil {
return TrashResult{}, err
}
if err := os.Rename(full, trashFull); err != nil {
return TrashResult{}, err
}
result := TrashResult{
OriginalPath: rel,
TrashPath: trashRel,
TrashID: trashID,
DeletedAt: deletedAt,
}
meta := map[string]string{
"originalPath": rel,
"trashPath": trashRel,
"trashId": trashID,
"deletedAt": deletedAt,
"originalType": string(fileTypeFromInfo(info)),
"basename": filepath.Base(rel),
"type": string(fileTypeFromInfo(info)),
}
data, err := json.MarshalIndent(meta, "", " ")
if err != nil {
return TrashResult{}, err
}
if err := os.WriteFile(filepath.Join(root, ".verstak", "trash", "files", trashID, "metadata.json"), data, 0o644); err != nil {
return TrashResult{}, err
}
return result, nil
}
func (s *Service) vaultRoot() (string, error) {
if s == nil || s.vault == nil {
return "", fmt.Errorf("vault-not-initialized")
}
if s.vault.GetVaultStatus() != vault.StatusOpen {
return "", fmt.Errorf("vault-not-open")
}
root := s.vault.GetVaultPath()
if root == "" {
return "", fmt.Errorf("vault-not-open")
}
return root, nil
}
func (s *Service) resolveFile(relativePath string) (string, string, string, error) {
root, err := s.vaultRoot()
if err != nil {
return "", "", "", err
}
rel, err := NormalizeRelativeFile(relativePath)
if err != nil {
return "", "", "", err
}
full, err := s.resolve(root, rel)
return root, rel, full, err
}
func (s *Service) resolve(root, rel string) (string, error) {
full := filepath.Join(root, filepath.FromSlash(rel))
absRoot, err := filepath.Abs(root)
if err != nil {
return "", err
}
absFull, err := filepath.Abs(full)
if err != nil {
return "", err
}
relToRoot, err := filepath.Rel(absRoot, absFull)
if err != nil {
return "", err
}
if relToRoot == ".." || strings.HasPrefix(relToRoot, ".."+string(os.PathSeparator)) || filepath.IsAbs(relToRoot) {
return "", fmt.Errorf("invalid-path: path-traversal")
}
return absFull, nil
}
func rejectSymlinkPath(root, rel string, includeFinal bool) error {
if rel == "" {
return nil
}
parts := strings.Split(rel, "/")
limit := len(parts)
if !includeFinal {
limit--
}
current := root
for i := 0; i < limit; i++ {
current = filepath.Join(current, filepath.FromSlash(parts[i]))
info, err := os.Lstat(current)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
if info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("symlink-not-allowed: %s", strings.Join(parts[:i+1], "/"))
}
}
return nil
}
func makeEntry(rel string, info fs.FileInfo) FileEntry {
t := fileTypeFromInfo(info)
return FileEntry{
Name: info.Name(),
RelativePath: rel,
Type: t,
Size: sizeForType(t, info),
ModifiedAt: info.ModTime().UTC().Format(time.RFC3339Nano),
Extension: strings.TrimPrefix(filepath.Ext(info.Name()), "."),
IsHidden: strings.HasPrefix(info.Name(), "."),
IsReserved: IsReservedPathNoNormalize(rel),
CanRead: t == FileTypeFile || t == FileTypeFolder,
CanWrite: t == FileTypeFile || t == FileTypeFolder,
}
}
func makeMetadata(rel string, info fs.FileInfo) FileMetadata {
t := fileTypeFromInfo(info)
ext := strings.TrimPrefix(filepath.Ext(info.Name()), ".")
return FileMetadata{
RelativePath: rel,
Type: t,
Size: sizeForType(t, info),
ModifiedAt: info.ModTime().UTC().Format(time.RFC3339Nano),
Extension: ext,
MimeHint: mime.TypeByExtension(filepath.Ext(info.Name())),
IsText: isTextExtension(ext),
IsHidden: strings.HasPrefix(info.Name(), "."),
IsReserved: IsReservedPathNoNormalize(rel),
CanRead: t == FileTypeFile || t == FileTypeFolder,
CanWrite: t == FileTypeFile || t == FileTypeFolder,
}
}
func fileTypeFromInfo(info fs.FileInfo) FileType {
if info.Mode()&os.ModeSymlink != 0 {
return FileTypeSymlink
}
if info.IsDir() {
return FileTypeFolder
}
if info.Mode().IsRegular() {
return FileTypeFile
}
return FileTypeUnknown
}
func sizeForType(t FileType, info fs.FileInfo) int64 {
if t == FileTypeFolder {
return 0
}
return info.Size()
}
func isTextExtension(ext string) bool {
switch strings.ToLower(ext) {
case "txt", "md", "markdown", "json", "yaml", "yml", "toml", "csv", "log", "xml", "html", "css", "js", "ts", "svelte", "go":
return true
default:
return false
}
}
func joinRel(parent, name string) string {
if parent == "" {
return name
}
return parent + "/" + name
}
func pathDir(rel string) string {
dir := pathDirSlash(rel)
if dir == "." {
return ""
}
return dir
}
func pathDirSlash(rel string) string {
idx := strings.LastIndex(rel, "/")
if idx < 0 {
return "."
}
return rel[:idx]
}
+436
View File
@@ -0,0 +1,436 @@
package files
import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/verstak/verstak-desktop/internal/core/vault"
)
func newTestService(t *testing.T) (*Service, string) {
t.Helper()
v := vault.NewVault(nil)
if err := v.CreateVault(t.TempDir()); err != nil {
t.Fatalf("CreateVault: %v", err)
}
return NewService(v), v.GetVaultPath()
}
func TestServiceRequiresOpenVault(t *testing.T) {
v := vault.NewVault(nil)
s := NewService(v)
if _, err := s.ListVaultFiles(""); err == nil {
t.Fatal("ListVaultFiles with closed vault: expected error")
}
}
func TestListVaultFilesExcludesReservedAndReturnsEntries(t *testing.T) {
s, root := newTestService(t)
if err := os.WriteFile(filepath.Join(root, "readme.md"), []byte("hello"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(root, "Docs"), 0o755); err != nil {
t.Fatal(err)
}
entries, err := s.ListVaultFiles("")
if err != nil {
t.Fatalf("ListVaultFiles: %v", err)
}
names := map[string]FileEntry{}
for _, entry := range entries {
names[entry.Name] = entry
if strings.HasPrefix(entry.RelativePath, ".verstak") {
t.Fatalf("reserved entry leaked into list: %+v", entry)
}
}
if names["readme.md"].Type != FileTypeFile {
t.Fatalf("readme.md type = %q", names["readme.md"].Type)
}
if names["Docs"].Type != FileTypeFolder {
t.Fatalf("Docs type = %q", names["Docs"].Type)
}
}
func TestPathPolicyRejectsUnsafeOperations(t *testing.T) {
s, _ := newTestService(t)
cases := []string{
"/etc/passwd",
"C:\\Windows\\system.ini",
"C:/Windows/system.ini",
`\\server\share`,
"//server/share",
`..\outside`,
`folder\..\outside`,
"../outside",
"folder/../../outside",
`folder\sub/../../outside`,
"bad\x00path",
".verstak",
".verstak/",
".verstak/vault.json",
"./.verstak",
".Verstak/trash",
}
for _, input := range cases {
t.Run(input, func(t *testing.T) {
if _, err := s.GetVaultFileMetadata(input); err == nil {
t.Fatal("metadata: expected error")
}
if _, err := s.ReadVaultTextFile(input); err == nil {
t.Fatal("read: expected error")
}
if err := s.WriteVaultTextFile(input, "x", WriteOptions{CreateIfMissing: true}); err == nil {
t.Fatal("write: expected error")
}
if err := s.MoveVaultPath(input, "safe.txt", MoveOptions{}); err == nil {
t.Fatal("move: expected error")
}
if _, err := s.TrashVaultPath(input); err == nil {
t.Fatal("trash: expected error")
}
})
}
}
func TestReadVaultTextFileRules(t *testing.T) {
s, root := newTestService(t)
if err := os.WriteFile(filepath.Join(root, "note.md"), []byte("hello\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(root, "Folder"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "binary.bin"), []byte{0xff, 0xfe, 0xfd}, 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "huge.txt"), []byte(strings.Repeat("a", int(MaxTextFileBytes)+1)), 0o644); err != nil {
t.Fatal(err)
}
text, err := s.ReadVaultTextFile("note.md")
if err != nil {
t.Fatalf("ReadVaultTextFile note: %v", err)
}
if text != "hello\n" {
t.Fatalf("text = %q", text)
}
if _, err := s.ReadVaultTextFile("Folder"); err == nil || !strings.Contains(err.Error(), "not-regular-file") {
t.Fatalf("read folder error = %v, want not-regular-file", err)
}
if _, err := s.ReadVaultTextFile("missing.md"); err == nil || !strings.Contains(err.Error(), "not-found") {
t.Fatalf("read missing error = %v, want not-found", err)
}
if _, err := s.ReadVaultTextFile("huge.txt"); err == nil || !strings.Contains(err.Error(), "file-too-large") {
t.Fatalf("read huge error = %v, want file-too-large", err)
}
if _, err := s.ReadVaultTextFile("binary.bin"); err == nil || !strings.Contains(err.Error(), "not-text-file") {
t.Fatalf("read binary error = %v, want not-text-file", err)
}
}
func TestWriteVaultTextFileAtomicAndConflictBehavior(t *testing.T) {
s, root := newTestService(t)
if err := s.WriteVaultTextFile("Notes/one.md", "first", WriteOptions{CreateIfMissing: true}); err == nil {
t.Fatal("write should fail when parent folder is missing")
}
if err := s.CreateVaultFolder("Notes"); err != nil {
t.Fatalf("CreateVaultFolder: %v", err)
}
if err := s.WriteVaultTextFile("Notes/one.md", "first", WriteOptions{CreateIfMissing: true}); err != nil {
t.Fatalf("write create: %v", err)
}
if err := s.WriteVaultTextFile("Notes/one.md", "second", WriteOptions{CreateIfMissing: true}); err == nil || !strings.Contains(err.Error(), "conflict") {
t.Fatalf("write conflict error = %v, want conflict", err)
}
if err := s.WriteVaultTextFile("Notes/one.md", "second", WriteOptions{CreateIfMissing: true, Overwrite: true}); err != nil {
t.Fatalf("write overwrite: %v", err)
}
data, err := os.ReadFile(filepath.Join(root, "Notes", "one.md"))
if err != nil {
t.Fatal(err)
}
if string(data) != "second" {
t.Fatalf("file content = %q", string(data))
}
matches, err := filepath.Glob(filepath.Join(root, "Notes", ".verstak-write-*"))
if err != nil {
t.Fatal(err)
}
if len(matches) != 0 {
t.Fatalf("atomic write left temp files: %v", matches)
}
if err := s.WriteVaultTextFile("", "root", WriteOptions{CreateIfMissing: true}); err == nil || !strings.Contains(err.Error(), "empty path") {
t.Fatalf("write root error = %v, want empty path", err)
}
}
func TestCreateVaultFolderConflict(t *testing.T) {
s, _ := newTestService(t)
if err := s.CreateVaultFolder("Folder"); err != nil {
t.Fatalf("CreateVaultFolder first: %v", err)
}
if err := s.CreateVaultFolder("Folder"); err == nil || !strings.Contains(err.Error(), "conflict") {
t.Fatalf("CreateVaultFolder conflict error = %v, want conflict", err)
}
}
func TestMoveVaultPathRules(t *testing.T) {
s, root := newTestService(t)
if err := os.Mkdir(filepath.Join(root, "A"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "A", "one.txt"), []byte("one"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "target.txt"), []byte("target"), 0o644); err != nil {
t.Fatal(err)
}
if err := s.MoveVaultPath("A/one.txt", "moved.txt", MoveOptions{}); err != nil {
t.Fatalf("move file: %v", err)
}
if _, err := os.Stat(filepath.Join(root, "moved.txt")); err != nil {
t.Fatalf("moved file missing: %v", err)
}
if err := s.MoveVaultPath("A", "B", MoveOptions{}); err != nil {
t.Fatalf("move folder: %v", err)
}
if err := os.Mkdir(filepath.Join(root, "C"), 0o755); err != nil {
t.Fatal(err)
}
if err := s.MoveVaultPath("C", "C/Child", MoveOptions{}); err == nil || !strings.Contains(err.Error(), "move-into-self") {
t.Fatalf("move into self error = %v, want move-into-self", err)
}
if err := s.MoveVaultPath("moved.txt", "target.txt", MoveOptions{}); err == nil || !strings.Contains(err.Error(), "conflict") {
t.Fatalf("move conflict error = %v, want conflict", err)
}
if err := s.MoveVaultPath("", "root-move", MoveOptions{}); err == nil {
t.Fatal("move root should fail")
}
}
func TestTrashVaultPathMovesToReservedTrashAndHidesFromList(t *testing.T) {
s, root := newTestService(t)
if err := os.WriteFile(filepath.Join(root, "delete-me.txt"), []byte("bye"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(root, "delete-folder"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "same.txt"), []byte("one"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(root, "Other"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "Other", "same.txt"), []byte("two"), 0o644); err != nil {
t.Fatal(err)
}
fileResult, err := s.TrashVaultPath("delete-me.txt")
if err != nil {
t.Fatalf("trash file: %v", err)
}
if fileResult.OriginalPath != "delete-me.txt" || fileResult.TrashID == "" || fileResult.DeletedAt == "" {
t.Fatalf("unexpected trash result: %+v", fileResult)
}
if _, err := os.Stat(filepath.Join(root, fileResult.TrashPath)); err != nil {
t.Fatalf("trashed file missing: %v", err)
}
metaPath := filepath.Join(root, ".verstak", "trash", "files", fileResult.TrashID, "metadata.json")
metaData, err := os.ReadFile(metaPath)
if err != nil {
t.Fatalf("trash metadata missing: %v", err)
}
var meta map[string]string
if err := json.Unmarshal(metaData, &meta); err != nil {
t.Fatalf("trash metadata invalid JSON: %v", err)
}
for _, key := range []string{"originalPath", "deletedAt", "originalType", "trashId", "basename"} {
if meta[key] == "" {
t.Fatalf("trash metadata missing %s: %s", key, string(metaData))
}
}
if meta["basename"] != "delete-me.txt" || meta["originalType"] != string(FileTypeFile) {
t.Fatalf("trash metadata = %+v", meta)
}
if _, err := s.TrashVaultPath("delete-folder"); err != nil {
t.Fatalf("trash folder: %v", err)
}
firstSame, err := s.TrashVaultPath("same.txt")
if err != nil {
t.Fatalf("trash same root: %v", err)
}
secondSame, err := s.TrashVaultPath("Other/same.txt")
if err != nil {
t.Fatalf("trash same nested: %v", err)
}
if firstSame.TrashID == secondSame.TrashID || firstSame.TrashPath == secondSame.TrashPath {
t.Fatalf("repeated trash basename collided: first=%+v second=%+v", firstSame, secondSame)
}
if _, err := s.TrashVaultPath(""); err == nil {
t.Fatal("trash root should fail")
}
if _, err := s.TrashVaultPath("missing.txt"); err == nil || !strings.Contains(err.Error(), "not-found") {
t.Fatalf("trash missing error = %v, want not-found", err)
}
entries, err := s.ListVaultFiles("")
if err != nil {
t.Fatalf("ListVaultFiles: %v", err)
}
for _, entry := range entries {
if entry.Name == "delete-me.txt" || entry.Name == "delete-folder" || entry.Name == ".verstak" {
t.Fatalf("unexpected entry after trash: %+v", entry)
}
}
}
func TestSymlinkEscapeRejected(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink creation requires privileges on many Windows test environments")
}
s, root := newTestService(t)
outside := t.TempDir()
outsideFile := filepath.Join(outside, "outside.txt")
if err := os.WriteFile(outsideFile, []byte("secret"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outsideFile, filepath.Join(root, "escape.txt")); err != nil {
t.Skipf("symlink not supported in this environment: %v", err)
}
meta, err := s.GetVaultFileMetadata("escape.txt")
if err != nil {
t.Fatalf("metadata symlink: %v", err)
}
if meta.Type != FileTypeSymlink {
t.Fatalf("symlink type = %q", meta.Type)
}
if _, err := s.ReadVaultTextFile("escape.txt"); err == nil || !strings.Contains(err.Error(), "symlink-not-allowed") {
t.Fatalf("read symlink error = %v, want symlink-not-allowed", err)
}
if err := s.WriteVaultTextFile("escape.txt", "x", WriteOptions{Overwrite: true}); err == nil || !strings.Contains(err.Error(), "symlink-not-allowed") {
t.Fatalf("write symlink error = %v, want symlink-not-allowed", err)
}
if err := s.MoveVaultPath("escape.txt", "moved-link.txt", MoveOptions{}); err == nil || !strings.Contains(err.Error(), "symlink-not-allowed") {
t.Fatalf("move symlink error = %v, want symlink-not-allowed", err)
}
if _, err := s.TrashVaultPath("escape.txt"); err == nil || !strings.Contains(err.Error(), "symlink-not-allowed") {
t.Fatalf("trash symlink error = %v, want symlink-not-allowed", err)
}
}
func TestSymlinkInsideVaultRejectedForMutatingAndReadOperations(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink creation requires privileges on many Windows test environments")
}
s, root := newTestService(t)
if err := os.WriteFile(filepath.Join(root, "target.txt"), []byte("inside"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(filepath.Join(root, "target.txt"), filepath.Join(root, "inside-link.txt")); err != nil {
t.Skipf("symlink not supported in this environment: %v", err)
}
meta, err := s.GetVaultFileMetadata("inside-link.txt")
if err != nil {
t.Fatalf("metadata inside symlink: %v", err)
}
if meta.Type != FileTypeSymlink {
t.Fatalf("symlink type = %q", meta.Type)
}
if _, err := s.ReadVaultTextFile("inside-link.txt"); err == nil || !strings.Contains(err.Error(), "symlink-not-allowed") {
t.Fatalf("read inside symlink error = %v, want symlink-not-allowed", err)
}
if err := s.WriteVaultTextFile("inside-link.txt", "x", WriteOptions{Overwrite: true}); err == nil || !strings.Contains(err.Error(), "symlink-not-allowed") {
t.Fatalf("write inside symlink error = %v, want symlink-not-allowed", err)
}
if err := s.MoveVaultPath("inside-link.txt", "moved-link.txt", MoveOptions{}); err == nil || !strings.Contains(err.Error(), "symlink-not-allowed") {
t.Fatalf("move inside symlink error = %v, want symlink-not-allowed", err)
}
if _, err := s.TrashVaultPath("inside-link.txt"); err == nil || !strings.Contains(err.Error(), "symlink-not-allowed") {
t.Fatalf("trash inside symlink error = %v, want symlink-not-allowed", err)
}
matches, err := filepath.Glob(filepath.Join(root, ".verstak-write-*"))
if err != nil {
t.Fatal(err)
}
if len(matches) != 0 {
t.Fatalf("write symlink left root temp files: %v", matches)
}
}
func TestListVaultFilesRejectsSymlinkDirectoryEscape(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink creation requires privileges on many Windows test environments")
}
s, root := newTestService(t)
outside := t.TempDir()
if err := os.WriteFile(filepath.Join(outside, "outside.txt"), []byte("secret"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, filepath.Join(root, "outside-dir")); err != nil {
t.Skipf("symlink not supported in this environment: %v", err)
}
if _, err := s.ListVaultFiles("outside-dir"); err == nil || !strings.Contains(err.Error(), "symlink-not-allowed") {
t.Fatalf("list symlink dir error = %v, want symlink-not-allowed", err)
}
entries, err := s.ListVaultFiles("")
if err != nil {
t.Fatalf("list root: %v", err)
}
var foundSymlink bool
for _, entry := range entries {
if entry.RelativePath == "outside-dir" {
foundSymlink = true
if entry.Type != FileTypeSymlink {
t.Fatalf("root symlink entry type = %q, want symlink", entry.Type)
}
}
}
if !foundSymlink {
t.Fatal("root list should expose the symlink as metadata without following it")
}
}
func TestCreateVaultFolderRejectsSymlinkParentEscape(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink creation requires privileges on many Windows test environments")
}
s, root := newTestService(t)
outside := t.TempDir()
if err := os.Symlink(outside, filepath.Join(root, "outside-dir")); err != nil {
t.Skipf("symlink not supported in this environment: %v", err)
}
if err := s.CreateVaultFolder("outside-dir/new-folder"); err == nil || !strings.Contains(err.Error(), "symlink-not-allowed") {
t.Fatalf("create folder through symlink parent error = %v, want symlink-not-allowed", err)
}
if _, err := os.Stat(filepath.Join(outside, "new-folder")); !os.IsNotExist(err) {
t.Fatalf("folder should not be created outside vault, stat err=%v", err)
}
}
+56
View File
@@ -0,0 +1,56 @@
package files
const MaxTextFileBytes int64 = 2 * 1024 * 1024
type FileType string
const (
FileTypeFile FileType = "file"
FileTypeFolder FileType = "folder"
FileTypeSymlink FileType = "symlink"
FileTypeUnknown FileType = "unknown"
)
type FileEntry struct {
Name string `json:"name"`
RelativePath string `json:"relativePath"`
Type FileType `json:"type"`
Size int64 `json:"size"`
ModifiedAt string `json:"modifiedAt"`
Extension string `json:"extension"`
IsHidden bool `json:"isHidden"`
IsReserved bool `json:"isReserved"`
CanRead bool `json:"canRead"`
CanWrite bool `json:"canWrite"`
}
type FileMetadata struct {
RelativePath string `json:"relativePath"`
Type FileType `json:"type"`
Size int64 `json:"size"`
ModifiedAt string `json:"modifiedAt"`
CreatedAt string `json:"createdAt,omitempty"`
Extension string `json:"extension"`
MimeHint string `json:"mimeHint"`
IsText bool `json:"isText"`
IsHidden bool `json:"isHidden"`
IsReserved bool `json:"isReserved"`
CanRead bool `json:"canRead"`
CanWrite bool `json:"canWrite"`
}
type WriteOptions struct {
CreateIfMissing bool `json:"createIfMissing"`
Overwrite bool `json:"overwrite"`
}
type MoveOptions struct {
Overwrite bool `json:"overwrite"`
}
type TrashResult struct {
OriginalPath string `json:"originalPath"`
TrashPath string `json:"trashPath"`
TrashID string `json:"trashId"`
DeletedAt string `json:"deletedAt"`
}
+4
View File
@@ -33,12 +33,16 @@ func (r *Registry) registerDefaults() {
{Name: "vault.read", Description: "Read vault files and metadata", Dangerous: false},
{Name: "vault.write", Description: "Write vault files and metadata", Dangerous: true},
{Name: "vault.watch", Description: "Watch vault file changes", Dangerous: false},
{Name: "files.read", Description: "List files and read text files through the vault Files API", Dangerous: false},
{Name: "files.write", Description: "Create folders, write text files, and move paths through the vault Files API", Dangerous: true},
{Name: "files.delete", Description: "Trash vault files and folders through the vault Files API", Dangerous: true},
{Name: "storage.namespace", Description: "Read/write plugin's own storage namespace", Dangerous: false},
{Name: "storage.migrations", Description: "Run database migrations in plugin namespace", Dangerous: false},
{Name: "events.publish", Description: "Publish events to the event bus", Dangerous: false},
{Name: "events.subscribe", Description: "Subscribe to events on the event bus", Dangerous: false},
{Name: "ui.register", Description: "Register UI components and contributions", Dangerous: false},
{Name: "commands.register", Description: "Register command palette commands", Dangerous: false},
{Name: "workbench.open", Description: "Request Workbench open/edit routing for vault resources", Dangerous: false},
{Name: "network.local", Description: "Connect to localhost network services", Dangerous: false},
{Name: "network.remote", Description: "Connect to remote network services", Dangerous: true},
{Name: "process.spawn", Description: "Spawn external processes", Dangerous: true},
+71
View File
@@ -0,0 +1,71 @@
package plugin
import (
"os"
"path/filepath"
"strings"
)
// DirResolveOptions makes plugin directory resolution testable.
type DirResolveOptions struct {
EnvPluginDir string
CWD string
ExecutablePath string
UserConfigDir string
HomeDir string
}
// ResolveDiscoveryDirs returns plugin discovery directories in priority order:
// explicit env override, dev ./plugins, packaged binary-adjacent plugins, user plugins.
func ResolveDiscoveryDirs(opts DirResolveOptions) []string {
var dirs []string
add := func(path string) {
if path == "" {
return
}
cleaned := filepath.Clean(path)
for _, existing := range dirs {
if existing == cleaned {
return
}
}
dirs = append(dirs, cleaned)
}
if opts.EnvPluginDir != "" {
for _, path := range filepath.SplitList(opts.EnvPluginDir) {
add(path)
}
}
if opts.CWD != "" {
add(filepath.Join(opts.CWD, "plugins"))
}
if opts.ExecutablePath != "" {
add(filepath.Join(filepath.Dir(opts.ExecutablePath), "plugins"))
}
if opts.UserConfigDir != "" {
add(filepath.Join(opts.UserConfigDir, "verstak", "plugins"))
} else if opts.HomeDir != "" {
add(filepath.Join(opts.HomeDir, ".config", "verstak", "plugins"))
}
return dirs
}
// DefaultDiscoveryDirs resolves discovery directories from the current process.
func DefaultDiscoveryDirs() []string {
cwd, _ := os.Getwd()
exe, _ := os.Executable()
userConfig, _ := os.UserConfigDir()
home, _ := os.UserHomeDir()
return ResolveDiscoveryDirs(DirResolveOptions{
EnvPluginDir: strings.TrimSpace(os.Getenv("VERSTAK_PLUGIN_DIR")),
CWD: cwd,
ExecutablePath: exe,
UserConfigDir: userConfig,
HomeDir: home,
})
}
+71
View File
@@ -0,0 +1,71 @@
package plugin
import (
"path/filepath"
"reflect"
"testing"
)
func TestResolveDiscoveryDirs_EnvCwdBinaryUserDedup(t *testing.T) {
root := t.TempDir()
envDir := filepath.Join(root, "env-plugins")
cwdDir := filepath.Join(root, "repo", "plugins")
binaryDir := filepath.Join(root, "app", "plugins")
userConfigDir := filepath.Join(root, "config")
got := ResolveDiscoveryDirs(DirResolveOptions{
EnvPluginDir: envDir + string(filepath.ListSeparator) + cwdDir,
CWD: filepath.Join(root, "repo"),
ExecutablePath: filepath.Join(root, "app", "verstak-desktop"),
UserConfigDir: userConfigDir,
})
want := []string{
envDir,
cwdDir,
binaryDir,
filepath.Join(userConfigDir, "verstak", "plugins"),
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ResolveDiscoveryDirs() = %#v, want %#v", got, want)
}
}
func TestResolveDiscoveryDirs_UsesCwdWhenExecutablePathMissing(t *testing.T) {
root := t.TempDir()
got := ResolveDiscoveryDirs(DirResolveOptions{
CWD: root,
HomeDir: filepath.Join(root, "home"),
})
wantFirst := filepath.Join(root, "plugins")
if got[0] != wantFirst {
t.Fatalf("first plugin dir = %q, want cwd plugins %q", got[0], wantFirst)
}
}
func TestResolveDiscoveryDirs_FallsBackToHomeConfigDir(t *testing.T) {
root := t.TempDir()
got := ResolveDiscoveryDirs(DirResolveOptions{
HomeDir: filepath.Join(root, "home"),
})
want := []string{filepath.Join(root, "home", ".config", "verstak", "plugins")}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ResolveDiscoveryDirs() = %#v, want %#v", got, want)
}
}
func TestResolveDiscoveryDirs_NormalizesAndDeduplicatesPaths(t *testing.T) {
root := t.TempDir()
cwd := filepath.Join(root, "repo")
got := ResolveDiscoveryDirs(DirResolveOptions{
EnvPluginDir: filepath.Join(cwd, ".", "plugins") + string(filepath.ListSeparator) + filepath.Join(cwd, "plugins"),
CWD: cwd,
})
want := []string{filepath.Join(cwd, "plugins")}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ResolveDiscoveryDirs() = %#v, want %#v", got, want)
}
}
+53 -5
View File
@@ -67,6 +67,8 @@ type Contributions struct {
SearchProviders []ContributionSearchProvider `json:"searchProviders,omitempty"`
ActivityProviders []ContributionActivityProvider `json:"activityProviders,omitempty"`
StatusBarItems []ContributionStatusBarItem `json:"statusBarItems,omitempty"`
OpenProviders []ContributionOpenProvider `json:"openProviders,omitempty"`
WorkspaceItems []ContributionWorkspaceItem `json:"workspaceItems,omitempty"`
}
// ContributionView represents a view contribution.
@@ -144,6 +146,31 @@ type ContributionStatusBarItem struct {
Handler string `json:"handler,omitempty"`
}
// OpenProviderSupport describes a resource shape an open provider can handle.
type OpenProviderSupport struct {
Kind string `json:"kind"`
Mime []string `json:"mime,omitempty"`
Extensions []string `json:"extensions,omitempty"`
Contexts []string `json:"contexts,omitempty"`
}
// ContributionOpenProvider represents an editor/viewer provider contribution.
type ContributionOpenProvider struct {
ID string `json:"id"`
Title string `json:"title"`
Priority int `json:"priority,omitempty"`
Component string `json:"component"`
Supports []OpenProviderSupport `json:"supports"`
}
// ContributionWorkspaceItem represents a workspace tool contribution.
type ContributionWorkspaceItem struct {
ID string `json:"id"`
Title string `json:"title"`
Icon string `json:"icon,omitempty"`
Component string `json:"component"`
}
// SyncConfig describes plugin sync configuration.
type SyncConfig struct {
Namespaces []string `json:"namespaces,omitempty"`
@@ -209,6 +236,27 @@ func ValidateManifest(m *Manifest) []string {
if len(m.Permissions) == 0 {
errs.add("permissions must have at least one permission")
}
if m.Contributes != nil {
for i, provider := range m.Contributes.OpenProviders {
if provider.ID == "" {
errs.add("contributes.openProviders[%d].id is required", i)
}
if provider.Title == "" {
errs.add("contributes.openProviders[%d].title is required", i)
}
if provider.Component == "" {
errs.add("contributes.openProviders[%d].component is required", i)
}
if len(provider.Supports) == 0 {
errs.add("contributes.openProviders[%d].supports must have at least one entry", i)
}
for j, support := range provider.Supports {
if support.Kind == "" {
errs.add("contributes.openProviders[%d].supports[%d].kind is required", i, j)
}
}
}
}
return errs.errors
}
@@ -249,7 +297,7 @@ func DiscoverPlugins(dirs []string) ([]Plugin, []error) {
var plugins []Plugin
var errs []error
seen := make(map[string]bool)
seen := make(map[string]string)
log.Printf("[discovery] start: %d dir(s): %v", len(dirs), dirs)
@@ -287,12 +335,12 @@ func DiscoverPlugins(dirs []string) ([]Plugin, []error) {
continue
}
if seen[plugin.Manifest.ID] {
errs = append(errs, fmt.Errorf("duplicate plugin ID %q in %s", plugin.Manifest.ID, pluginDir))
log.Printf("[discovery] %s: duplicate ID %q (skip)", entry.Name(), plugin.Manifest.ID)
if existingPath, ok := seen[plugin.Manifest.ID]; ok {
errs = append(errs, fmt.Errorf("duplicate plugin ID %q in %s (already loaded from %s); first plugin wins", plugin.Manifest.ID, pluginDir, existingPath))
log.Printf("[discovery] %s: duplicate ID %q in %s (already loaded from %s; skip)", entry.Name(), plugin.Manifest.ID, pluginDir, existingPath)
continue
}
seen[plugin.Manifest.ID] = true
seen[plugin.Manifest.ID] = pluginDir
plugins = append(plugins, plugin)
log.Printf("[discovery] %s: ✅ %s@%s", entry.Name(), plugin.Manifest.ID, plugin.Manifest.Version)
}
+84
View File
@@ -149,6 +149,90 @@ func TestDiscoverPlugins_DuplicateID(t *testing.T) {
}
}
func TestDiscoverPlugins_DuplicateIDAcrossDirs_FirstWinsAndReportsBothPaths(t *testing.T) {
dir1 := t.TempDir()
dir2 := t.TempDir()
firstPath := createTempPlugin(t, dir1, "shared.plugin", "First")
secondPath := filepath.Join(dir2, "other-name")
if err := os.MkdirAll(secondPath, 0755); err != nil {
t.Fatal(err)
}
manifest := `{
"schemaVersion": 1,
"id": "shared.plugin",
"name": "Second",
"version": "2.0.0",
"apiVersion": "1.0",
"provides": ["shared.plugin.second.cap"],
"permissions": ["vault.read"]
}`
if err := os.WriteFile(filepath.Join(secondPath, "plugin.json"), []byte(manifest), 0644); err != nil {
t.Fatal(err)
}
plugins, errs := DiscoverPlugins([]string{dir1, dir2})
if len(plugins) != 1 {
t.Fatalf("expected first plugin only, got %d", len(plugins))
}
if plugins[0].RootPath != firstPath {
t.Fatalf("winner path = %q, want %q", plugins[0].RootPath, firstPath)
}
combined := ""
for _, err := range errs {
combined += err.Error()
}
if !strings.Contains(combined, "duplicate plugin ID") {
t.Fatalf("expected duplicate error, got %v", errs)
}
if !strings.Contains(combined, firstPath) || !strings.Contains(combined, secondPath) {
t.Fatalf("duplicate error should include both paths; got %q", combined)
}
}
func TestValidateManifest_OpenProviders(t *testing.T) {
valid := &Manifest{
SchemaVersion: 1,
ID: "editor.plugin",
Name: "Editor",
Version: "1.0.0",
APIVersion: "1.0",
Provides: []string{"editor.text"},
Permissions: []string{"workbench.open"},
Contributes: &Contributions{
OpenProviders: []ContributionOpenProvider{{
ID: "editor.text",
Title: "Text Editor",
Component: "TextEditor",
Supports: []OpenProviderSupport{{
Kind: "vault-file",
Extensions: []string{".txt"},
Contexts: []string{"generic-text"},
}},
}},
},
}
if errs := ValidateManifest(valid); len(errs) != 0 {
t.Fatalf("valid manifest errors = %v", errs)
}
invalid := *valid
invalid.Contributes = &Contributions{
OpenProviders: []ContributionOpenProvider{{
ID: "broken",
Title: "Broken",
Component: "",
Supports: []OpenProviderSupport{{}},
}},
}
errs := ValidateManifest(&invalid)
combined := strings.Join(errs, "\n")
if !strings.Contains(combined, "component is required") || !strings.Contains(combined, "kind is required") {
t.Fatalf("expected open provider validation errors, got %v", errs)
}
}
// TestDiscoverPlugins_MultipleDirs ensures discovery scans multiple directories.
func TestDiscoverPlugins_MultipleDirs(t *testing.T) {
dir1 := t.TempDir()
+377
View File
@@ -0,0 +1,377 @@
package sync
import (
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
// DeviceTokenPath returns the path to the device_token file inside the vault.
func DeviceTokenPath(vaultRoot string) string {
return filepath.Join(vaultRoot, ".verstak", "device_token.json")
}
// SaveDeviceToken writes the device token to a file with 0600 perms.
func SaveDeviceToken(vaultRoot, token string) error {
path := DeviceTokenPath(vaultRoot)
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o750); err != nil {
return err
}
data := fmt.Sprintf(`{"device_token":%q}`, token)
return os.WriteFile(path, []byte(data), 0o600)
}
// LoadDeviceToken reads the device token from the vault.
func LoadDeviceToken(vaultRoot string) string {
path := DeviceTokenPath(vaultRoot)
data, err := os.ReadFile(path)
if err != nil {
return ""
}
var v struct {
DeviceToken string `json:"device_token"`
}
if err := json.Unmarshal(data, &v); err != nil {
return ""
}
return v.DeviceToken
}
// RemoveDeviceToken deletes the device token file.
func RemoveDeviceToken(vaultRoot string) error {
path := DeviceTokenPath(vaultRoot)
return os.Remove(path)
}
// Client communicates with the Verstak Sync Server.
type Client struct {
ServerURL string
APIKey string
DeviceToken string
DeviceID string
VaultRoot string
HTTP *http.Client
}
// NewClient creates a sync client.
func NewClient(serverURL, apiKey, deviceID, vaultRoot string) *Client {
return &Client{
ServerURL: serverURL,
APIKey: apiKey,
DeviceID: deviceID,
VaultRoot: vaultRoot,
HTTP: &http.Client{Timeout: 30 * time.Second},
}
}
// PairDevice calls POST /api/client/pair and returns device_id and device_token.
func (c *Client) PairDevice(serverURL, username, password, deviceName, clientVersion string) (deviceID, deviceToken string, err error) {
body := map[string]string{
"login": username,
"password": password,
"device_name": deviceName,
"client_version": clientVersion,
}
var resp struct {
DeviceID string `json:"device_id"`
DeviceToken string `json:"device_token"`
}
savedURL := c.ServerURL
c.ServerURL = serverURL
err = c.post("/api/client/pair", body, &resp)
c.ServerURL = savedURL
if err != nil {
return "", "", err
}
return resp.DeviceID, resp.DeviceToken, nil
}
// DeviceInfo holds device information from the server.
type DeviceInfo struct {
DeviceID string `json:"device_id"`
UserID string `json:"user_id"`
Username string `json:"username"`
DeviceName string `json:"device_name"`
ClientVersion string `json:"client_version"`
LastSeen string `json:"last_seen"`
RevokedAt string `json:"revoked_at"`
CreatedAt string `json:"created_at"`
}
// GetMe calls GET /api/client/me and returns device info.
func (c *Client) GetMe() (*DeviceInfo, error) {
var resp DeviceInfo
if err := c.get("/api/client/me", &resp); err != nil {
return nil, err
}
return &resp, nil
}
// RevokeCurrent calls POST /api/client/revoke-current.
func (c *Client) RevokeCurrent() error {
var resp struct {
Status string `json:"status"`
}
return c.post("/api/client/revoke-current", nil, &resp)
}
// TestAuth checks credentials without creating a device.
func (c *Client) TestAuth(serverURL, username, password string) error {
// First, check if this is a Verstak Sync server
healthURL := strings.TrimSuffix(serverURL, "/") + "/api/v1/health"
req, err := http.NewRequest("GET", healthURL, nil)
if err != nil {
return fmt.Errorf("invalid URL: %w", err)
}
resp, err := c.HTTP.Do(req)
if err != nil {
return fmt.Errorf("connection failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("not a Verstak Sync server (HTTP %d)", resp.StatusCode)
}
data, _ := io.ReadAll(resp.Body)
body := string(data)
if !strings.Contains(body, "status") && !strings.Contains(body, "ok") {
return fmt.Errorf("not a Verstak Sync server (unexpected response)")
}
// Now test actual auth
authBody := map[string]string{"username": username, "password": password}
savedURL := c.ServerURL
savedKey := c.APIKey
c.ServerURL = serverURL
c.APIKey = ""
err = c.post("/api/auth/test", authBody, nil)
c.ServerURL = savedURL
c.APIKey = savedKey
return err
}
// PushRequest is the payload for POST /sync/push.
type PushRequest struct {
DeviceID string `json:"device_id"`
IdempotencyKey string `json:"idempotency_key,omitempty"`
Ops []PushOp `json:"ops"`
}
// PushOp is a single operation in a push request.
type PushOp struct {
OpID string `json:"op_id"`
EntityType string `json:"entity_type"`
EntityID string `json:"entity_id"`
OpType string `json:"op_type"`
PayloadJSON string `json:"payload_json"`
ClientSequence int `json:"client_sequence"`
LastSeenServerSeq int `json:"last_seen_server_seq"`
CreatedAt string `json:"created_at"`
}
// PushResponse is the response from POST /sync/push.
type PushResponse struct {
Accepted []string `json:"accepted"`
Count int `json:"count"`
Conflicts []map[string]interface{} `json:"conflicts"`
}
// Push sends local operations to the server.
func (c *Client) Push(ops []Op) (*PushResponse, error) {
pushOps := make([]PushOp, len(ops))
for i, op := range ops {
pushOps[i] = PushOp{
OpID: op.OpID,
EntityType: op.EntityType,
EntityID: op.EntityID,
OpType: op.OpType,
PayloadJSON: op.PayloadJSON,
ClientSequence: op.ClientSequence,
LastSeenServerSeq: op.LastSeenServerSeq,
CreatedAt: op.CreatedAt,
}
}
req := PushRequest{DeviceID: c.DeviceID, Ops: pushOps}
var resp PushResponse
if err := c.post("/api/v1/sync/push", req, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// PullRequest is the payload for POST /sync/pull.
type PullRequest struct {
SinceSequence int `json:"since_sequence"`
}
// PullResponse is the response from POST /sync/pull.
type PullResponse struct {
ServerSequence int `json:"server_sequence"`
Ops []Op `json:"ops"`
}
// Pull fetches remote operations since a given sequence.
func (c *Client) Pull(sinceSequence int) (*PullResponse, error) {
req := PullRequest{SinceSequence: sinceSequence}
var resp PullResponse
if err := c.post("/api/v1/sync/pull", req, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// UploadBlob uploads a file to the server and returns its SHA-256.
func (c *Client) UploadBlob(localPath string) (sha256 string, err error) {
var b bytes.Buffer
w := multipart.NewWriter(&b)
fw, err := w.CreateFormFile("file", filepath.Base(localPath))
if err != nil {
return "", err
}
f, err := os.Open(localPath)
if err != nil {
return "", err
}
defer f.Close()
if _, err := io.Copy(fw, f); err != nil {
return "", err
}
w.Close()
req, err := http.NewRequest("POST", c.ServerURL+"/api/v1/blobs/", &b)
if err != nil {
return "", err
}
req.Header.Set("Content-Type", w.FormDataContentType())
req.Header.Set("Authorization", "Bearer "+c.bearerToken())
resp, err := c.HTTP.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var result struct {
SHA256 string `json:"sha256"`
Size int `json:"size"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
return result.SHA256, nil
}
// DownloadBlob downloads a blob by SHA-256 hash.
func (c *Client) DownloadBlob(sha256, destPath string) error {
req, err := http.NewRequest("GET", c.ServerURL+"/api/v1/blobs/"+sha256, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.bearerToken())
resp, err := c.HTTP.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("download blob: HTTP %d", resp.StatusCode)
}
out, err := os.Create(destPath)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
return err
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
func (c *Client) bearerToken() string {
if c.DeviceToken != "" {
return c.DeviceToken
}
return c.APIKey
}
func (c *Client) post(path string, body, result interface{}) error {
var b bytes.Buffer
if body != nil {
if err := json.NewEncoder(&b).Encode(body); err != nil {
return err
}
}
req, err := http.NewRequest("POST", c.ServerURL+path, &b)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.bearerToken())
resp, err := c.HTTP.Do(req)
if err != nil {
return fmt.Errorf("http: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return c.readErrorBody(resp, resp.StatusCode)
}
if result != nil {
return json.NewDecoder(resp.Body).Decode(result)
}
return nil
}
func (c *Client) get(path string, result interface{}) error {
req, err := http.NewRequest("GET", c.ServerURL+path, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.bearerToken())
resp, err := c.HTTP.Do(req)
if err != nil {
return fmt.Errorf("http: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return c.readErrorBody(resp, resp.StatusCode)
}
if result != nil {
return json.NewDecoder(resp.Body).Decode(result)
}
return nil
}
func (c *Client) readErrorBody(resp *http.Response, statusCode int) error {
buf := make([]byte, 4096)
n, _ := io.ReadFull(resp.Body, buf)
body := string(buf[:minInt(n, 500)])
lower := strings.ToLower(body)
if strings.Contains(lower, "<html") || strings.Contains(lower, "<!doctype") {
return fmt.Errorf("not a Verstak Sync server (HTTP %d)", statusCode)
}
return fmt.Errorf("server error (HTTP %d)", statusCode)
}
+289
View File
@@ -0,0 +1,289 @@
package sync
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
"github.com/google/uuid"
)
const (
EntityNode = "node"
EntityNote = "note"
EntityFile = "file"
EntityFolder = "folder"
EntityAction = "action"
EntityWorklog = "worklog"
)
const (
OpCreate = "create"
OpUpdate = "update"
OpDelete = "delete"
OpMove = "move"
)
// Op represents a sync operation.
type Op struct {
ID string `json:"id"`
OpID string `json:"op_id"`
ServerSequence int `json:"server_sequence,omitempty"`
DeviceID string `json:"device_id,omitempty"`
EntityType string `json:"entity_type"`
EntityID string `json:"entity_id"`
OpType string `json:"op_type"`
PayloadJSON string `json:"payload_json"`
CreatedAt string `json:"created_at"`
PushedAt *string `json:"pushed_at,omitempty"`
AppliedAt *string `json:"applied_at,omitempty"`
ClientSequence int `json:"client_sequence,omitempty"`
LastSeenServerSeq int `json:"last_seen_server_seq,omitempty"`
}
// syncState persists connection state to JSON file.
type syncState struct {
ServerURL string `json:"server_url"`
APIKey string `json:"api_key"`
DeviceID string `json:"device_id"`
LastPullSeq int `json:"last_pull_seq"`
LastSyncAt string `json:"last_sync_at"`
}
// Service records and manages sync operations using JSON file storage.
type Service struct {
vaultRoot string
deviceID string
}
// NewService creates a sync service.
func NewService(vaultRoot, deviceID string) *Service {
return &Service{vaultRoot: vaultRoot, deviceID: deviceID}
}
func (s *Service) syncDir() string {
return filepath.Join(s.vaultRoot, ".verstak", "sync")
}
func (s *Service) opsPath() string {
return filepath.Join(s.syncDir(), "ops.json")
}
func (s *Service) statePath() string {
return filepath.Join(s.syncDir(), "state.json")
}
func (s *Service) ensureDir() error {
return os.MkdirAll(s.syncDir(), 0o755)
}
// RecordOp writes a sync operation to the local ops file.
func (s *Service) RecordOp(entityType, entityID, opType string, payload interface{}) error {
if err := s.ensureDir(); err != nil {
return err
}
id := uuid.New().String()
now := time.Now().UTC().Format(time.RFC3339)
var payloadStr string
if payload != nil {
b, err := json.Marshal(payload)
if err != nil {
return err
}
payloadStr = string(b)
}
op := Op{
ID: id,
OpID: id,
DeviceID: s.deviceID,
EntityType: entityType,
EntityID: entityID,
OpType: opType,
PayloadJSON: payloadStr,
CreatedAt: now,
}
ops, err := s.loadOps()
if err != nil {
return err
}
ops = append(ops, op)
return s.saveOps(ops)
}
// RecordRemoteOp writes a remote op to the local ops file.
func (s *Service) RecordRemoteOp(op Op) error {
if err := s.ensureDir(); err != nil {
return err
}
now := time.Now().UTC().Format(time.RFC3339)
ops, err := s.loadOps()
if err != nil {
return err
}
remoteID := op.OpID + "-remote"
for _, existing := range ops {
if existing.ID == remoteID {
return nil
}
}
op.ID = remoteID
op.PushedAt = &now
op.AppliedAt = &now
ops = append(ops, op)
return s.saveOps(ops)
}
// GetUnpushedOps returns ops that have not been pushed yet.
func (s *Service) GetUnpushedOps() ([]Op, error) {
ops, err := s.loadOps()
if err != nil {
return nil, err
}
var unpushed []Op
for _, op := range ops {
if op.PushedAt == nil {
unpushed = append(unpushed, op)
}
}
return unpushed, nil
}
// MarkPushed marks ops as pushed to server.
func (s *Service) MarkPushed(opIDs []string) error {
ops, err := s.loadOps()
if err != nil {
return err
}
now := time.Now().UTC().Format(time.RFC3339)
idSet := make(map[string]bool, len(opIDs))
for _, id := range opIDs {
idSet[id] = true
}
for i := range ops {
if idSet[ops[i].OpID] {
ops[i].PushedAt = &now
}
}
return s.saveOps(ops)
}
// MarkApplied marks remote ops as applied locally.
func (s *Service) MarkApplied(opIDs []string) error {
ops, err := s.loadOps()
if err != nil {
return err
}
now := time.Now().UTC().Format(time.RFC3339)
idSet := make(map[string]bool, len(opIDs))
for _, id := range opIDs {
idSet[id] = true
}
for i := range ops {
if idSet[ops[i].OpID] {
ops[i].AppliedAt = &now
}
}
return s.saveOps(ops)
}
// GetState returns the current sync state.
func (s *Service) GetState() (serverURL, apiKey string, lastPullSeq int, lastSyncAt string, err error) {
st, err := s.loadState()
if err != nil {
return "", "", 0, "", err
}
return st.ServerURL, st.APIKey, st.LastPullSeq, st.LastSyncAt, nil
}
// SetState saves sync connection state.
func (s *Service) SetState(serverURL, apiKey string) error {
if err := s.ensureDir(); err != nil {
return err
}
st, err := s.loadState()
if err != nil {
st = &syncState{}
}
st.ServerURL = serverURL
st.APIKey = apiKey
return s.saveState(st)
}
// SetLastPullSeq updates the last pulled server sequence.
func (s *Service) SetLastPullSeq(seq int) error {
st, err := s.loadState()
if err != nil {
return err
}
st.LastPullSeq = seq
return s.saveState(st)
}
// SetLastSyncAt updates the last sync timestamp.
func (s *Service) SetLastSyncAt(t string) error {
st, err := s.loadState()
if err != nil {
return err
}
st.LastSyncAt = t
return s.saveState(st)
}
// GetDeviceID returns the device ID used by this service.
func (s *Service) GetDeviceID() string {
return s.deviceID
}
// --- file helpers ---
func (s *Service) loadOps() ([]Op, error) {
data, err := os.ReadFile(s.opsPath())
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("read ops: %w", err)
}
var ops []Op
if err := json.Unmarshal(data, &ops); err != nil {
return nil, fmt.Errorf("parse ops: %w", err)
}
return ops, nil
}
func (s *Service) saveOps(ops []Op) error {
data, err := json.MarshalIndent(ops, "", " ")
if err != nil {
return fmt.Errorf("marshal ops: %w", err)
}
return os.WriteFile(s.opsPath(), data, 0o644)
}
func (s *Service) loadState() (*syncState, error) {
data, err := os.ReadFile(s.statePath())
if err != nil {
if os.IsNotExist(err) {
return &syncState{}, nil
}
return nil, fmt.Errorf("read state: %w", err)
}
var st syncState
if err := json.Unmarshal(data, &st); err != nil {
return nil, fmt.Errorf("parse state: %w", err)
}
return &st, nil
}
func (s *Service) saveState(st *syncState) error {
data, err := json.MarshalIndent(st, "", " ")
if err != nil {
return fmt.Errorf("marshal state: %w", err)
}
return os.WriteFile(s.statePath(), data, 0o644)
}
+19 -1
View File
@@ -15,6 +15,7 @@ import (
"github.com/google/uuid"
"github.com/verstak/verstak-desktop/internal/core/events"
"github.com/verstak/verstak-desktop/internal/core/workspace"
)
// VaultStatus represents the current state of a vault.
@@ -114,6 +115,13 @@ func (v *Vault) CreateVault(path string) error {
return fmt.Errorf("failed to write vault.json: %w", err)
}
// Create the initial physical workspace folder. Workspace listing is still
// sourced from top-level vault folders, not from .verstak metadata.
wsMgr := workspace.NewManager(vaultDir)
if _, err := wsMgr.CreateWorkspace("Workspace", "default"); err != nil {
return fmt.Errorf("failed to create workspace: %w", err)
}
v.mu.Lock()
v.status = StatusOpen
v.path = vaultDir
@@ -132,16 +140,26 @@ func (v *Vault) CreateVault(path string) error {
}
// OpenVault opens an existing vault at the given path.
// The path can be either the vault root (containing .verstak/vault.json)
// or the parent directory (containing VerstakVault/.verstak/vault.json).
func (v *Vault) OpenVault(path string) error {
absPath, err := filepath.Abs(path)
if err != nil {
return fmt.Errorf("failed to resolve path: %w", err)
}
// Try direct path first: <path>/.verstak/vault.json
metaPath := filepath.Join(absPath, ".verstak", "vault.json")
data, err := os.ReadFile(metaPath)
if err != nil {
return fmt.Errorf("failed to read vault.json: %w", err)
// Try VerstakVault subdirectory: <path>/VerstakVault/.verstak/vault.json
vaultDir := filepath.Join(absPath, "VerstakVault")
metaPath = filepath.Join(vaultDir, ".verstak", "vault.json")
data, err = os.ReadFile(metaPath)
if err != nil {
return fmt.Errorf("failed to read vault.json: %w (looked in %s and %s)", err, filepath.Join(absPath, ".verstak"), filepath.Join(vaultDir, ".verstak"))
}
absPath = vaultDir
}
var meta VaultMeta
+64
View File
@@ -247,3 +247,67 @@ func TestVaultEvents_Published(t *testing.T) {
}
}
}
func TestCreateVault_CreatesWorkspace(t *testing.T) {
dir := t.TempDir()
vaultPath := filepath.Join(dir, "testvault")
bus := events.NewBus()
v := NewVault(bus)
if err := v.CreateVault(vaultPath); err != nil {
t.Fatalf("CreateVault: %v", err)
}
if _, err := os.Stat(filepath.Join(v.GetVaultPath(), "Workspace")); err != nil {
t.Fatalf("Workspace folder not found: %v", err)
}
if _, err := os.Stat(filepath.Join(v.GetVaultPath(), "Workspace", "Notes", "Overview.md")); err != nil {
t.Fatalf("default workspace overview not found: %v", err)
}
if _, err := os.Stat(filepath.Join(v.GetVaultPath(), ".verstak", "workspace.json")); !os.IsNotExist(err) {
t.Fatalf("workspace.json should not be created as workspace source of truth, stat err=%v", err)
}
}
func TestOpenVault_WorkspaceLoads(t *testing.T) {
dir := t.TempDir()
vaultPath := filepath.Join(dir, "testvault")
bus := events.NewBus()
v := NewVault(bus)
if err := v.CreateVault(vaultPath); err != nil {
t.Fatalf("CreateVault: %v", err)
}
v.CloseVault()
if err := v.OpenVault(vaultPath); err != nil {
t.Fatalf("OpenVault: %v", err)
}
if _, err := os.Stat(filepath.Join(v.GetVaultPath(), "Workspace")); err != nil {
t.Fatalf("Workspace folder should still exist after reopen: %v", err)
}
if _, err := os.Stat(filepath.Join(v.GetVaultPath(), ".verstak", "workspace.json")); !os.IsNotExist(err) {
t.Fatalf("OpenVault should not create workspace.json, stat err=%v", err)
}
}
func TestCreateVault_VaultPathNormalized(t *testing.T) {
dir := t.TempDir()
vaultPath := filepath.Join(dir, "testvault")
bus := events.NewBus()
v := NewVault(bus)
if err := v.CreateVault(vaultPath); err != nil {
t.Fatalf("CreateVault: %v", err)
}
expectedPath := filepath.Join(vaultPath, "VerstakVault")
if v.GetVaultPath() != expectedPath {
t.Errorf("GetVaultPath: got %q, want %q", v.GetVaultPath(), expectedPath)
}
}
+257
View File
@@ -0,0 +1,257 @@
// Package workbench routes open/edit resource requests to contributed providers.
package workbench
import (
"fmt"
"path"
"sort"
"strings"
"time"
"github.com/verstak/verstak-desktop/internal/core/contribution"
"github.com/verstak/verstak-desktop/internal/core/plugin"
)
type Preferences struct {
DefaultTextEditorProvider string `json:"defaultTextEditorProvider,omitempty"`
DefaultMarkdownEditorProvider string `json:"defaultMarkdownEditorProvider,omitempty"`
DefaultNotesMarkdownEditorProvider string `json:"defaultNotesMarkdownEditorProvider,omitempty"`
}
const (
ContextGenericText = "generic-text"
ContextGenericMarkdown = "generic-markdown"
ContextNotesMarkdown = "notes-markdown"
)
type OpenResourceContext struct {
SourcePluginID string `json:"sourcePluginId,omitempty"`
SourceView string `json:"sourceView,omitempty"`
IsInsideNotesFolder bool `json:"isInsideNotesFolder,omitempty"`
NotesScopePath string `json:"notesScopePath,omitempty"`
NotesMode bool `json:"notesMode,omitempty"`
}
type OpenResourceRequest struct {
Kind string `json:"kind"`
Path string `json:"path"`
Mode string `json:"mode,omitempty"`
Mime string `json:"mime,omitempty"`
Extension string `json:"extension,omitempty"`
Context OpenResourceContext `json:"context,omitempty"`
}
type OpenResourceResult struct {
Status string `json:"status"`
ProviderID string `json:"providerId,omitempty"`
ProviderPluginID string `json:"providerPluginId,omitempty"`
ProviderComponent string `json:"providerComponent,omitempty"`
Request OpenResourceRequest `json:"request"`
Message string `json:"message,omitempty"`
}
type OpenedResource struct {
ID string `json:"id"`
ProviderID string `json:"providerId"`
ProviderPluginID string `json:"providerPluginId"`
ProviderComponent string `json:"providerComponent"`
Request OpenResourceRequest `json:"request"`
OpenedAt string `json:"openedAt"`
}
type Router struct {
preferences Preferences
opened []OpenedResource
}
func NewRouter(preferences Preferences) *Router {
return &Router{preferences: preferences}
}
func (r *Router) Preferences() Preferences {
return r.preferences
}
func (r *Router) SetPreferences(preferences Preferences) {
r.preferences = preferences
}
func (r *Router) SelectProvider(request OpenResourceRequest, providers []contribution.ContributionOpenProvider) (contribution.ContributionOpenProvider, error) {
request = normalizeRequest(request)
var matches []contribution.ContributionOpenProvider
for _, provider := range providers {
if providerMatches(request, provider.Item) {
matches = append(matches, provider)
}
}
if len(matches) == 0 {
return contribution.ContributionOpenProvider{}, fmt.Errorf("no open provider supports %s %q", request.Kind, request.Path)
}
preferred := r.preferenceFor(request)
if preferred != "" {
for _, provider := range matches {
if provider.Item.ID == preferred {
return provider, nil
}
}
}
sort.Slice(matches, func(i, j int) bool {
if matches[i].Item.Priority != matches[j].Item.Priority {
return matches[i].Item.Priority > matches[j].Item.Priority
}
if matches[i].PluginID != matches[j].PluginID {
return matches[i].PluginID < matches[j].PluginID
}
return matches[i].Item.ID < matches[j].Item.ID
})
return matches[0], nil
}
func (r *Router) OpenResource(request OpenResourceRequest, providers []contribution.ContributionOpenProvider) (OpenResourceResult, error) {
request = normalizeRequest(request)
provider, err := r.SelectProvider(request, providers)
if err != nil {
return OpenResourceResult{
Status: "no-provider",
Request: request,
Message: err.Error(),
}, nil
}
result := OpenResourceResult{
Status: "opened",
ProviderID: provider.Item.ID,
ProviderPluginID: provider.PluginID,
ProviderComponent: provider.Item.Component,
Request: request,
}
r.opened = append(r.opened, OpenedResource{
ID: fmt.Sprintf("%s:%d", provider.Item.ID, len(r.opened)+1),
ProviderID: result.ProviderID,
ProviderPluginID: result.ProviderPluginID,
ProviderComponent: result.ProviderComponent,
Request: result.Request,
OpenedAt: time.Now().UTC().Format(time.RFC3339Nano),
})
return result, nil
}
func (r *Router) OpenedResources() []OpenedResource {
result := make([]OpenedResource, len(r.opened))
copy(result, r.opened)
return result
}
func normalizeRequest(request OpenResourceRequest) OpenResourceRequest {
if request.Mode == "" {
request.Mode = "view"
}
if request.Extension == "" {
request.Extension = path.Ext(request.Path)
}
request.Extension = strings.ToLower(request.Extension)
request.Mime = strings.ToLower(request.Mime)
return request
}
// DetermineContextName derives the current routing context from a request.
// Future Files/Notes callers can move canonical Notes folder auto-detection here.
func DetermineContextName(request OpenResourceRequest) string {
request = normalizeRequest(request)
return resourceContextName(request)
}
func providerMatches(request OpenResourceRequest, provider plugin.ContributionOpenProvider) bool {
for _, support := range provider.Supports {
if support.Kind != request.Kind {
continue
}
if !supportMatchesExtensionOrMime(request, support) {
continue
}
if !supportMatchesContext(request, support) {
continue
}
return true
}
return false
}
func supportMatchesExtensionOrMime(request OpenResourceRequest, support plugin.OpenProviderSupport) bool {
hasExtensionRules := len(support.Extensions) > 0
hasMimeRules := len(support.Mime) > 0
if !hasExtensionRules && !hasMimeRules {
return true
}
if hasExtensionRules {
for _, ext := range support.Extensions {
if strings.ToLower(ext) == request.Extension {
return true
}
}
}
if hasMimeRules && request.Mime != "" {
for _, mime := range support.Mime {
if strings.ToLower(mime) == request.Mime {
return true
}
}
}
return false
}
func supportMatchesContext(request OpenResourceRequest, support plugin.OpenProviderSupport) bool {
if len(support.Contexts) == 0 {
return true
}
context := resourceContextName(request)
for _, supported := range support.Contexts {
if supported == context {
return true
}
}
return false
}
func (r *Router) preferenceFor(request OpenResourceRequest) string {
context := resourceContextName(request)
switch {
case context == ContextNotesMarkdown:
return r.preferences.DefaultNotesMarkdownEditorProvider
case context == ContextGenericMarkdown:
return r.preferences.DefaultMarkdownEditorProvider
case context == ContextGenericText:
return r.preferences.DefaultTextEditorProvider
default:
return ""
}
}
func resourceContextName(request OpenResourceRequest) string {
ext := strings.ToLower(request.Extension)
if ext == ".md" || ext == ".markdown" {
if request.Context.NotesMode || request.Context.IsInsideNotesFolder {
return ContextNotesMarkdown
}
return ContextGenericMarkdown
}
if isTextResource(request) {
return ContextGenericText
}
return ""
}
func isTextResource(request OpenResourceRequest) bool {
if strings.HasPrefix(request.Mime, "text/") {
return true
}
switch strings.ToLower(request.Extension) {
case ".txt", ".log", ".json", ".yaml", ".yml", ".toml", ".ini", ".conf":
return true
default:
return false
}
}
+425
View File
@@ -0,0 +1,425 @@
package workbench
import (
"testing"
"github.com/verstak/verstak-desktop/internal/core/contribution"
"github.com/verstak/verstak-desktop/internal/core/plugin"
)
func provider(pluginID, id string, priority int, component string, supports ...plugin.OpenProviderSupport) contribution.ContributionOpenProvider {
return contribution.ContributionOpenProvider{
PluginID: pluginID,
Item: plugin.ContributionOpenProvider{
ID: id,
Title: id,
Priority: priority,
Component: component,
Supports: supports,
},
}
}
func TestSelectProviderUsesNotesMarkdownPreference(t *testing.T) {
r := NewRouter(Preferences{
DefaultNotesMarkdownEditorProvider: "community.notes-editor",
})
providers := []contribution.ContributionOpenProvider{
provider("official.editor", "official.markdown", 100, "OfficialMarkdown", plugin.OpenProviderSupport{
Kind: "vault-file",
Extensions: []string{".md", ".markdown"},
Contexts: []string{"generic-markdown", "notes-markdown"},
}),
provider("community.editor", "community.notes-editor", 10, "CommunityNotes", plugin.OpenProviderSupport{
Kind: "vault-file",
Extensions: []string{".md"},
Contexts: []string{"notes-markdown"},
}),
}
selected, err := r.SelectProvider(OpenResourceRequest{
Kind: "vault-file",
Path: "Clients/Acme/Notes/Overview.md",
Extension: ".md",
Mode: "edit",
Context: OpenResourceContext{
SourceView: "notes",
IsInsideNotesFolder: true,
NotesMode: true,
},
}, providers)
if err != nil {
t.Fatalf("SelectProvider: %v", err)
}
if selected.Item.ID != "community.notes-editor" {
t.Fatalf("provider = %q, want community.notes-editor", selected.Item.ID)
}
}
func TestSelectProviderFallsBackByPriorityThenID(t *testing.T) {
r := NewRouter(Preferences{
DefaultMarkdownEditorProvider: "disabled.or.missing",
})
providers := []contribution.ContributionOpenProvider{
provider("b.plugin", "b.provider", 100, "B", plugin.OpenProviderSupport{
Kind: "vault-file",
Extensions: []string{".md"},
Contexts: []string{"generic-markdown"},
}),
provider("a.plugin", "a.provider", 100, "A", plugin.OpenProviderSupport{
Kind: "vault-file",
Extensions: []string{".md"},
Contexts: []string{"generic-markdown"},
}),
provider("high.plugin", "high.provider", 200, "High", plugin.OpenProviderSupport{
Kind: "vault-file",
Extensions: []string{".txt"},
}),
}
selected, err := r.SelectProvider(OpenResourceRequest{
Kind: "vault-file",
Path: "Docs/readme.md",
Extension: ".md",
Mode: "view",
}, providers)
if err != nil {
t.Fatalf("SelectProvider: %v", err)
}
if selected.Item.ID != "a.provider" {
t.Fatalf("provider = %q, want deterministic tie winner a.provider", selected.Item.ID)
}
}
func TestSelectProviderTieBreaksByPluginIDThenProviderID(t *testing.T) {
r := NewRouter(Preferences{})
providers := []contribution.ContributionOpenProvider{
provider("b.plugin", "a.provider", 100, "B", plugin.OpenProviderSupport{
Kind: "vault-file",
Extensions: []string{".md"},
Contexts: []string{ContextGenericMarkdown},
}),
provider("a.plugin", "z.provider", 100, "A", plugin.OpenProviderSupport{
Kind: "vault-file",
Extensions: []string{".md"},
Contexts: []string{ContextGenericMarkdown},
}),
}
selected, err := r.SelectProvider(OpenResourceRequest{
Kind: "vault-file",
Path: "Docs/readme.md",
Mode: "view",
}, providers)
if err != nil {
t.Fatalf("SelectProvider: %v", err)
}
if selected.PluginID != "a.plugin" || selected.Item.ID != "z.provider" {
t.Fatalf("provider = %+v, want a.plugin/z.provider", selected)
}
}
func TestSelectProviderMatchesGenericTextContext(t *testing.T) {
r := NewRouter(Preferences{})
selected, err := r.SelectProvider(OpenResourceRequest{
Kind: "vault-file",
Path: "Docs/readme.txt",
Mode: "view",
}, []contribution.ContributionOpenProvider{
provider("text.plugin", "text.provider", 10, "Text", plugin.OpenProviderSupport{
Kind: "vault-file",
Extensions: []string{".txt"},
Contexts: []string{ContextGenericText},
}),
})
if err != nil {
t.Fatalf("SelectProvider: %v", err)
}
if selected.Item.ID != "text.provider" {
t.Fatalf("provider = %q, want text.provider", selected.Item.ID)
}
}
func TestGenericMarkdownDoesNotSelectNotesOnlyProvider(t *testing.T) {
r := NewRouter(Preferences{})
_, err := r.SelectProvider(OpenResourceRequest{
Kind: "vault-file",
Path: "Docs/readme.md",
Mode: "view",
}, []contribution.ContributionOpenProvider{
provider("notes.plugin", "notes.provider", 10, "Notes", plugin.OpenProviderSupport{
Kind: "vault-file",
Extensions: []string{".md"},
Contexts: []string{ContextNotesMarkdown},
}),
})
if err == nil {
t.Fatal("expected no provider for generic markdown with notes-only provider")
}
}
func TestOpenResourceStoresSelectedProviderAndRequest(t *testing.T) {
r := NewRouter(Preferences{})
result, err := r.OpenResource(OpenResourceRequest{
Kind: "vault-file",
Path: "Notes/Overview.md",
Extension: ".md",
Mode: "edit",
Context: OpenResourceContext{
IsInsideNotesFolder: true,
NotesMode: true,
},
}, []contribution.ContributionOpenProvider{
provider("official.editor", "official.markdown", 100, "MarkdownEditor", plugin.OpenProviderSupport{
Kind: "vault-file",
Extensions: []string{".md"},
Contexts: []string{"notes-markdown"},
}),
})
if err != nil {
t.Fatalf("OpenResource: %v", err)
}
if result.Status != "opened" || result.ProviderID != "official.markdown" || result.ProviderComponent != "MarkdownEditor" {
t.Fatalf("result = %+v", result)
}
opened := r.OpenedResources()
if len(opened) != 1 || opened[0].Request.Path != "Notes/Overview.md" {
t.Fatalf("opened = %+v", opened)
}
}
func TestOpenResourceReturnsNoProviderFallback(t *testing.T) {
r := NewRouter(Preferences{})
result, err := r.OpenResource(OpenResourceRequest{
Kind: "vault-file",
Path: "Docs/unknown.bin",
}, nil)
if err != nil {
t.Fatalf("OpenResource: %v", err)
}
if result.Status != "no-provider" || result.Request.Path != "Docs/unknown.bin" || result.Message == "" {
t.Fatalf("result = %+v", result)
}
if len(r.OpenedResources()) != 0 {
t.Fatalf("no-provider result should not store opened resource: %+v", r.OpenedResources())
}
}
func TestSelectProviderUsesTextPreference(t *testing.T) {
r := NewRouter(Preferences{
DefaultTextEditorProvider: "custom.text-editor",
})
providers := []contribution.ContributionOpenProvider{
provider("official.editor", "builtin.text", 100, "BuiltinText", plugin.OpenProviderSupport{
Kind: "vault-file",
Extensions: []string{".txt"},
Contexts: []string{ContextGenericText},
}),
provider("custom.editor", "custom.text-editor", 10, "CustomText", plugin.OpenProviderSupport{
Kind: "vault-file",
Extensions: []string{".txt"},
Contexts: []string{ContextGenericText},
}),
}
selected, err := r.SelectProvider(OpenResourceRequest{
Kind: "vault-file",
Path: "Docs/todo.txt",
Mode: "view",
}, providers)
if err != nil {
t.Fatalf("SelectProvider: %v", err)
}
if selected.Item.ID != "custom.text-editor" {
t.Fatalf("provider = %q, want custom.text-editor", selected.Item.ID)
}
}
func TestSelectProviderUsesMarkdownPreference(t *testing.T) {
r := NewRouter(Preferences{
DefaultMarkdownEditorProvider: "community.markdown-editor",
})
providers := []contribution.ContributionOpenProvider{
provider("official.editor", "builtin.markdown", 100, "BuiltinMarkdown", plugin.OpenProviderSupport{
Kind: "vault-file",
Extensions: []string{".md"},
Contexts: []string{ContextGenericMarkdown},
}),
provider("community.editor", "community.markdown-editor", 10, "CommunityMarkdown", plugin.OpenProviderSupport{
Kind: "vault-file",
Extensions: []string{".md"},
Contexts: []string{ContextGenericMarkdown},
}),
}
selected, err := r.SelectProvider(OpenResourceRequest{
Kind: "vault-file",
Path: "Docs/readme.md",
Mode: "view",
}, providers)
if err != nil {
t.Fatalf("SelectProvider: %v", err)
}
if selected.Item.ID != "community.markdown-editor" {
t.Fatalf("provider = %q, want community.markdown-editor", selected.Item.ID)
}
}
func TestSelectProviderMatchesMime(t *testing.T) {
r := NewRouter(Preferences{})
providers := []contribution.ContributionOpenProvider{
provider("image.plugin", "image.viewer", 10, "ImageViewer", plugin.OpenProviderSupport{
Kind: "vault-file",
Mime: []string{"image/png", "image/jpeg"},
}),
}
selected, err := r.SelectProvider(OpenResourceRequest{
Kind: "vault-file",
Path: "Photos/screenshot.png",
Extension: ".png",
Mime: "image/png",
Mode: "view",
}, providers)
if err != nil {
t.Fatalf("SelectProvider: %v", err)
}
if selected.Item.ID != "image.viewer" {
t.Fatalf("provider = %q, want image.viewer", selected.Item.ID)
}
}
func TestSelectProviderExtensionCaseInsensitive(t *testing.T) {
r := NewRouter(Preferences{})
providers := []contribution.ContributionOpenProvider{
provider("editor.plugin", "md.editor", 10, "MDEditor", plugin.OpenProviderSupport{
Kind: "vault-file",
Extensions: []string{".md"},
}),
}
tests := []struct {
name string
ext string
path string
}{
{"uppercase .MD", ".MD", "Docs/README.MD"},
{"mixed case .Md", ".Md", "Docs/Notes.Md"},
{"lowercase .md", ".md", "Docs/readme.md"},
{"markdown extension uppercase", ".MD", "Docs/guide.MD"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
selected, err := r.SelectProvider(OpenResourceRequest{
Kind: "vault-file",
Path: tt.path,
Extension: tt.ext,
Mode: "view",
}, providers)
if err != nil {
t.Fatalf("SelectProvider: %v", err)
}
if selected.Item.ID != "md.editor" {
t.Fatalf("provider = %q, want md.editor for ext %s", selected.Item.ID, tt.ext)
}
})
}
}
func TestSelectProviderMultipleSupportsEntries(t *testing.T) {
r := NewRouter(Preferences{})
providers := []contribution.ContributionOpenProvider{
provider("editor.plugin", "multi.editor", 10, "MultiEditor", plugin.OpenProviderSupport{
Kind: "vault-file",
Extensions: []string{".md"},
Contexts: []string{ContextGenericMarkdown},
}, plugin.OpenProviderSupport{
Kind: "vault-file",
Extensions: []string{".txt"},
Contexts: []string{ContextGenericText},
}),
}
t.Run("matches markdown entry", func(t *testing.T) {
selected, err := r.SelectProvider(OpenResourceRequest{
Kind: "vault-file",
Path: "Docs/readme.md",
Mode: "view",
}, providers)
if err != nil {
t.Fatalf("SelectProvider: %v", err)
}
if selected.Item.ID != "multi.editor" {
t.Fatalf("provider = %q, want multi.editor", selected.Item.ID)
}
})
t.Run("matches text entry", func(t *testing.T) {
selected, err := r.SelectProvider(OpenResourceRequest{
Kind: "vault-file",
Path: "Docs/todo.txt",
Mode: "view",
}, providers)
if err != nil {
t.Fatalf("SelectProvider: %v", err)
}
if selected.Item.ID != "multi.editor" {
t.Fatalf("provider = %q, want multi.editor", selected.Item.ID)
}
})
}
func TestSelectProviderKindMismatch(t *testing.T) {
r := NewRouter(Preferences{})
_, err := r.SelectProvider(OpenResourceRequest{
Kind: "http-url",
Path: "https://example.com/file.md",
Mode: "view",
}, []contribution.ContributionOpenProvider{
provider("editor.plugin", "vault.editor", 10, "VaultEditor", plugin.OpenProviderSupport{
Kind: "vault-file",
Extensions: []string{".md"},
}),
})
if err == nil {
t.Fatal("expected no provider for http-url kind with vault-file provider")
}
}
func TestDetermineContextName(t *testing.T) {
tests := []struct {
name string
request OpenResourceRequest
want string
}{
{
name: "text",
request: OpenResourceRequest{Kind: "vault-file", Path: "Docs/readme.txt"},
want: ContextGenericText,
},
{
name: "markdown",
request: OpenResourceRequest{Kind: "vault-file", Path: "Docs/readme.md"},
want: ContextGenericMarkdown,
},
{
name: "notes markdown",
request: OpenResourceRequest{
Kind: "vault-file",
Path: "Notes/Overview.md",
Context: OpenResourceContext{
IsInsideNotesFolder: true,
NotesMode: true,
},
},
want: ContextNotesMarkdown,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := DetermineContextName(tt.request); got != tt.want {
t.Fatalf("DetermineContextName = %q, want %q", got, tt.want)
}
})
}
}
+794
View File
@@ -0,0 +1,794 @@
// Package workspace provides the semantic workspace lifecycle service.
//
// A workspace is a top-level physical folder directly under the vault root.
// The filesystem is the source of truth for workspace existence and listing.
// Metadata under .verstak stores UI state and creation snapshots only.
package workspace
import (
"encoding/base64"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"unicode"
"github.com/google/uuid"
)
// NodeType is retained for compatibility with the current shell API.
type NodeType string
const (
TypeSpace NodeType = "space"
TypeCase NodeType = "case"
TypeFolder NodeType = "folder"
)
// NodeStatus is retained for compatibility with the current shell API.
type NodeStatus string
const (
StatusActive NodeStatus = "active"
StatusSleeping NodeStatus = "sleeping"
StatusArchived NodeStatus = "archived"
)
// Workspace is a physical top-level workspace folder.
type Workspace struct {
Name string `json:"name"`
RootPath string `json:"rootPath"`
}
// TemplateSnapshot is copied into workspace metadata when a template is applied.
type TemplateSnapshot struct {
TemplateID string `json:"templateId"`
TemplateName string `json:"templateName"`
TemplateVersion int `json:"templateVersion"`
AppliedAt string `json:"appliedAt"`
}
// Metadata stores semantic workspace metadata that is not the source of truth
// for whether the workspace exists.
type Metadata struct {
WorkspaceName string `json:"workspaceName"`
CreatedFromTemplate *TemplateSnapshot `json:"createdFromTemplate,omitempty"`
Features map[string]bool `json:"features,omitempty"`
Folders map[string]string `json:"folders,omitempty"`
UpdatedAt string `json:"updatedAt,omitempty"`
}
// MetadataPatch updates metadata fields without replacing unspecified fields.
type MetadataPatch struct {
Features map[string]bool `json:"features,omitempty"`
Folders map[string]string `json:"folders,omitempty"`
}
// TrashResult describes a workspace moved into the internal trash area.
type TrashResult struct {
OriginalPath string `json:"originalPath"`
TrashPath string `json:"trashPath"`
TrashID string `json:"trashId"`
DeletedAt string `json:"deletedAt"`
}
// WorkspaceNode is a compatibility shell view of a top-level workspace.
// Path is deliberately not serialized; workspaceRootPath is derived from Name/ID.
type WorkspaceNode struct {
ID string `json:"id"`
ParentID string `json:"parentId,omitempty"`
Type NodeType `json:"type"`
Title string `json:"title"`
Name string `json:"name,omitempty"`
RootPath string `json:"rootPath,omitempty"`
Path string `json:"-"`
Status NodeStatus `json:"status"`
Tags []string `json:"tags,omitempty"`
Order int `json:"order"`
CreatedAt string `json:"createdAt,omitempty"`
UpdatedAt string `json:"updatedAt,omitempty"`
}
// WorkspaceTree is a compatibility flat list, derived from top-level folders.
type WorkspaceTree struct {
SchemaVersion int `json:"schemaVersion"`
Nodes []WorkspaceNode `json:"nodes"`
CurrentNodeID string `json:"currentNodeId,omitempty"`
UpdatedAt string `json:"updatedAt"`
}
type templateDefinition struct {
ID string
Name string
Version int
Features map[string]bool
Folders map[string]string
Files map[string]string
}
var builtInTemplates = map[string]templateDefinition{
"default": {
ID: "default",
Name: "Default Workspace",
Version: 1,
Features: map[string]bool{
"files": true,
"notes": true,
"secrets": false,
"activity": false,
},
Folders: map[string]string{
"notes": "Notes",
"files": "Files",
},
Files: map[string]string{
"Notes/Overview.md": "# Overview\n",
},
},
"client-project": {
ID: "client-project",
Name: "Client Project",
Version: 1,
Features: map[string]bool{
"files": true,
"notes": true,
"secrets": true,
"activity": false,
},
Folders: map[string]string{
"notes": "Notes",
"files": "Files",
"secrets": "Secrets",
},
Files: map[string]string{
"Notes/Overview.md": "# Overview\n",
},
},
}
// Manager provides workspace operations for one vault.
type Manager struct {
mu sync.RWMutex
vaultDir string
initialized bool
currentWorkspaceName string
}
// NewManager creates a workspace manager for the given vault directory.
func NewManager(vaultDir string) *Manager {
return &Manager{vaultDir: vaultDir}
}
// Load initializes the manager without creating or migrating workspace folders.
func (m *Manager) Load() error {
m.mu.Lock()
defer m.mu.Unlock()
m.initialized = true
m.currentWorkspaceName = m.readSelectedWorkspaceLocked()
return nil
}
// IsInitialized returns true after Load has been called.
func (m *Manager) IsInitialized() bool {
m.mu.RLock()
defer m.mu.RUnlock()
return m.initialized
}
// ListWorkspaces returns top-level physical workspace folders from the vault.
func (m *Manager) ListWorkspaces() ([]Workspace, error) {
entries, err := os.ReadDir(m.vaultDir)
if err != nil {
return nil, err
}
workspaces := make([]Workspace, 0, len(entries))
for _, entry := range entries {
name := entry.Name()
if isReservedWorkspaceName(name) {
continue
}
if entry.Type()&os.ModeSymlink != 0 {
continue
}
if !entry.IsDir() {
continue
}
workspaces = append(workspaces, Workspace{Name: name, RootPath: name})
}
sort.Slice(workspaces, func(i, j int) bool {
return strings.ToLower(workspaces[i].Name) < strings.ToLower(workspaces[j].Name)
})
return workspaces, nil
}
// CreateWorkspace creates a top-level workspace folder and applies a template once.
func (m *Manager) CreateWorkspace(name, templateID string) (Workspace, error) {
name = strings.TrimSpace(name)
if err := validateWorkspaceName(name); err != nil {
return Workspace{}, err
}
if templateID == "" {
templateID = "default"
}
template, ok := builtInTemplates[templateID]
if !ok {
return Workspace{}, fmt.Errorf("template-not-found: %s", templateID)
}
full := filepath.Join(m.vaultDir, name)
if _, err := os.Lstat(full); err == nil {
return Workspace{}, fmt.Errorf("conflict: %s", name)
} else if !os.IsNotExist(err) {
return Workspace{}, err
}
if err := os.Mkdir(full, 0o755); err != nil {
return Workspace{}, err
}
created := true
defer func() {
if created {
_ = os.RemoveAll(full)
}
}()
if err := applyTemplate(full, template); err != nil {
return Workspace{}, err
}
now := time.Now().UTC().Format(time.RFC3339Nano)
meta := Metadata{
WorkspaceName: name,
CreatedFromTemplate: &TemplateSnapshot{
TemplateID: template.ID,
TemplateName: template.Name,
TemplateVersion: template.Version,
AppliedAt: now,
},
Features: cloneBoolMap(template.Features),
Folders: cloneStringMap(template.Folders),
UpdatedAt: now,
}
if err := m.writeMetadata(name, meta); err != nil {
return Workspace{}, err
}
created = false
return Workspace{Name: name, RootPath: name}, nil
}
// RenameWorkspace physically renames a top-level workspace folder and metadata key.
func (m *Manager) RenameWorkspace(oldName, newName string) error {
oldName = strings.TrimSpace(oldName)
newName = strings.TrimSpace(newName)
if err := validateWorkspaceName(oldName); err != nil {
return err
}
if err := validateWorkspaceName(newName); err != nil {
return err
}
oldFull := filepath.Join(m.vaultDir, oldName)
newFull := filepath.Join(m.vaultDir, newName)
if err := ensureExistingWorkspaceDir(oldFull, oldName); err != nil {
return err
}
if _, err := os.Lstat(newFull); err == nil {
return fmt.Errorf("conflict: %s", newName)
} else if !os.IsNotExist(err) {
return err
}
if err := os.Rename(oldFull, newFull); err != nil {
return err
}
renamedFolder := true
defer func() {
if renamedFolder {
_ = os.Rename(newFull, oldFull)
}
}()
oldMetaPath := m.metadataPath(oldName)
if data, err := os.ReadFile(oldMetaPath); err == nil {
var meta Metadata
if err := json.Unmarshal(data, &meta); err != nil {
return err
}
meta.WorkspaceName = newName
meta.UpdatedAt = time.Now().UTC().Format(time.RFC3339Nano)
if err := m.writeMetadata(newName, meta); err != nil {
return err
}
if err := os.Remove(oldMetaPath); err != nil && !os.IsNotExist(err) {
return err
}
} else if !os.IsNotExist(err) {
return err
}
m.mu.Lock()
if m.currentWorkspaceName == oldName {
m.currentWorkspaceName = newName
_ = m.writeUIStateLocked()
}
m.mu.Unlock()
renamedFolder = false
return nil
}
// TrashWorkspace moves the whole top-level workspace folder to internal trash.
func (m *Manager) TrashWorkspace(name string) (TrashResult, error) {
name = strings.TrimSpace(name)
if err := validateWorkspaceName(name); err != nil {
return TrashResult{}, err
}
full := filepath.Join(m.vaultDir, name)
if err := ensureExistingWorkspaceDir(full, name); err != nil {
return TrashResult{}, err
}
deletedAt := time.Now().UTC().Format(time.RFC3339Nano)
trashID := time.Now().UTC().Format("20060102T150405.000000000Z") + "-" + uuid.NewString()
trashRel := filepath.ToSlash(filepath.Join(".verstak", "trash", "workspaces", trashID, name))
trashFull := filepath.Join(m.vaultDir, filepath.FromSlash(trashRel))
if err := os.MkdirAll(filepath.Dir(trashFull), 0o755); err != nil {
return TrashResult{}, err
}
if err := os.Rename(full, trashFull); err != nil {
return TrashResult{}, err
}
result := TrashResult{OriginalPath: name, TrashPath: trashRel, TrashID: trashID, DeletedAt: deletedAt}
trashMeta := map[string]string{
"originalPath": name,
"trashPath": trashRel,
"trashId": trashID,
"deletedAt": deletedAt,
"originalType": "folder",
"basename": name,
"type": "workspace",
}
data, err := json.MarshalIndent(trashMeta, "", " ")
if err != nil {
return TrashResult{}, err
}
trashDir := filepath.Join(m.vaultDir, ".verstak", "trash", "workspaces", trashID)
if err := os.WriteFile(filepath.Join(trashDir, "metadata.json"), data, 0o644); err != nil {
return TrashResult{}, err
}
if err := moveIfExists(m.metadataPath(name), filepath.Join(trashDir, "workspace.metadata.json")); err != nil {
return TrashResult{}, err
}
m.mu.Lock()
if m.currentWorkspaceName == name {
m.currentWorkspaceName = ""
_ = m.writeUIStateLocked()
}
m.mu.Unlock()
return result, nil
}
// GetWorkspaceMetadata returns stored metadata or safe generic metadata.
func (m *Manager) GetWorkspaceMetadata(name string) (Metadata, error) {
name = strings.TrimSpace(name)
if err := validateWorkspaceName(name); err != nil {
return Metadata{}, err
}
full := filepath.Join(m.vaultDir, name)
if err := ensureExistingWorkspaceDir(full, name); err != nil {
return Metadata{}, err
}
data, err := os.ReadFile(m.metadataPath(name))
if err != nil {
if os.IsNotExist(err) {
return genericMetadata(name), nil
}
return Metadata{}, err
}
var meta Metadata
if err := json.Unmarshal(data, &meta); err != nil {
return Metadata{}, err
}
// Workspace identity is the top-level folder name. Stored metadata may be
// stale after manual edits or old dev snapshots, so normalize the returned
// presentation name without writing back to disk.
meta.WorkspaceName = name
if meta.Features == nil {
meta.Features = map[string]bool{"files": true}
}
if !hasAnyTrueFeature(meta.Features) {
meta.Features["files"] = true
}
if meta.Folders == nil {
meta.Folders = defaultFolders()
}
return meta, nil
}
// UpdateWorkspaceMetadata merges UI/semantic metadata fields for an existing workspace.
func (m *Manager) UpdateWorkspaceMetadata(name string, patch MetadataPatch) (Metadata, error) {
meta, err := m.GetWorkspaceMetadata(name)
if err != nil {
return Metadata{}, err
}
if meta.Features == nil {
meta.Features = map[string]bool{}
}
for k, v := range patch.Features {
meta.Features[k] = v
}
if meta.Folders == nil {
meta.Folders = map[string]string{}
}
for k, v := range patch.Folders {
meta.Folders[k] = v
}
meta.UpdatedAt = time.Now().UTC().Format(time.RFC3339Nano)
if err := m.writeMetadata(name, meta); err != nil {
return Metadata{}, err
}
return meta, nil
}
// GetTree returns a compatibility flat tree derived from top-level folders.
func (m *Manager) GetTree() WorkspaceTree {
workspaces, err := m.ListWorkspaces()
if err != nil {
return WorkspaceTree{SchemaVersion: 1, Nodes: []WorkspaceNode{}}
}
m.mu.RLock()
current := m.currentWorkspaceName
m.mu.RUnlock()
if current == "" || !workspaceExists(workspaces, current) {
if len(workspaces) > 0 {
current = workspaces[0].Name
}
}
nodes := make([]WorkspaceNode, 0, len(workspaces))
now := time.Now().UTC().Format(time.RFC3339Nano)
for i, ws := range workspaces {
nodes = append(nodes, WorkspaceNode{
ID: ws.Name,
Type: TypeSpace,
Title: ws.Name,
Name: ws.Name,
RootPath: ws.RootPath,
Status: StatusActive,
Order: i,
CreatedAt: now,
UpdatedAt: now,
})
}
return WorkspaceTree{SchemaVersion: 1, Nodes: nodes, CurrentNodeID: current, UpdatedAt: now}
}
// GetNode returns a compatibility node by workspace name.
func (m *Manager) GetNode(id string) (WorkspaceNode, error) {
for _, node := range m.GetTree().Nodes {
if node.ID == id {
return node, nil
}
}
return WorkspaceNode{}, fmt.Errorf("workspace not found: %s", id)
}
// ListChildren returns no children because workspaces are only top-level folders.
func (m *Manager) ListChildren(parentID string) []WorkspaceNode {
if parentID != "" {
return nil
}
return m.GetTree().Nodes
}
// CreateNode is a compatibility wrapper for creating top-level workspaces only.
func (m *Manager) CreateNode(parentID string, nodeType NodeType, title string) (WorkspaceNode, error) {
if parentID != "" {
return WorkspaceNode{}, fmt.Errorf("workspace folders are top-level only")
}
if nodeType != "" && nodeType != TypeSpace {
return WorkspaceNode{}, fmt.Errorf("workspace folders are top-level only")
}
ws, err := m.CreateWorkspace(title, "")
if err != nil {
return WorkspaceNode{}, err
}
return WorkspaceNode{ID: ws.Name, Type: TypeSpace, Title: ws.Name, Name: ws.Name, RootPath: ws.RootPath, Status: StatusActive}, nil
}
// RenameNode is a compatibility wrapper for physical workspace rename.
func (m *Manager) RenameNode(id, title string) error {
return m.RenameWorkspace(id, title)
}
// MoveNode is unsupported in the corrected workspace model.
func (m *Manager) MoveNode(id, newParentID string) error {
return fmt.Errorf("workspace folders are top-level only")
}
// ArchiveNode is a compatibility wrapper for trashing a workspace.
func (m *Manager) ArchiveNode(id string) error {
_, err := m.TrashWorkspace(id)
return err
}
// SetCurrentNode stores UI selection only.
func (m *Manager) SetCurrentNode(id string) error {
if err := validateWorkspaceName(id); err != nil {
return err
}
workspaces, err := m.ListWorkspaces()
if err != nil {
return err
}
if !workspaceExists(workspaces, id) {
return fmt.Errorf("workspace not found: %s", id)
}
m.mu.Lock()
defer m.mu.Unlock()
m.currentWorkspaceName = id
return m.writeUIStateLocked()
}
// GetCurrentNode returns the currently selected compatibility node.
func (m *Manager) GetCurrentNode() (WorkspaceNode, error) {
tree := m.GetTree()
if tree.CurrentNodeID == "" {
return WorkspaceNode{}, fmt.Errorf("no current workspace")
}
for _, node := range tree.Nodes {
if node.ID == tree.CurrentNodeID {
return node, nil
}
}
return WorkspaceNode{}, fmt.Errorf("current workspace not found: %s", tree.CurrentNodeID)
}
// Save persists UI state only; workspace existence is never persisted here.
func (m *Manager) Save() error {
m.mu.Lock()
defer m.mu.Unlock()
return m.writeUIStateLocked()
}
func applyTemplate(workspaceDir string, template templateDefinition) error {
for rel, content := range template.Files {
if strings.Contains(rel, "\x00") || strings.Contains(rel, "\\") || strings.HasPrefix(rel, "/") {
return fmt.Errorf("invalid-template-path: %s", rel)
}
parts := strings.Split(rel, "/")
for _, part := range parts {
if part == "" || part == "." || part == ".." {
return fmt.Errorf("invalid-template-path: %s", rel)
}
}
full := filepath.Join(workspaceDir, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
return err
}
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
return err
}
}
for _, folder := range template.Folders {
if folder == "" {
continue
}
if err := os.MkdirAll(filepath.Join(workspaceDir, folder), 0o755); err != nil {
return err
}
}
return nil
}
func validateWorkspaceName(name string) error {
if strings.TrimSpace(name) == "" {
return fmt.Errorf("invalid-workspace-name: empty")
}
if strings.Contains(name, "\x00") {
return fmt.Errorf("invalid-workspace-name: null-byte")
}
if strings.ContainsAny(name, `/\`) {
return fmt.Errorf("invalid-workspace-name: path separators are not allowed")
}
if looksAbsoluteName(name) {
return fmt.Errorf("invalid-workspace-name: absolute path rejected")
}
if name == "." || name == ".." || strings.Contains(name, "..") {
return fmt.Errorf("invalid-workspace-name: path traversal")
}
for _, r := range name {
if unicode.IsControl(r) {
return fmt.Errorf("invalid-workspace-name: control character")
}
}
if isReservedWorkspaceName(name) {
return fmt.Errorf("reserved-workspace-name: %s", name)
}
return nil
}
func looksAbsoluteName(name string) bool {
if filepath.IsAbs(name) || strings.HasPrefix(name, "/") || strings.HasPrefix(name, "\\") {
return true
}
return len(name) >= 2 && name[1] == ':' && unicode.IsLetter(rune(name[0]))
}
func isReservedWorkspaceName(name string) bool {
reserved := []string{".verstak", ".git"}
for _, item := range reserved {
if strings.EqualFold(name, item) {
return true
}
}
return false
}
func ensureExistingWorkspaceDir(full, name string) error {
info, err := os.Lstat(full)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("not-found: %s", name)
}
return err
}
if info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("symlink-not-allowed: %s", name)
}
if !info.IsDir() {
return fmt.Errorf("not-directory: %s", name)
}
return nil
}
func (m *Manager) metadataPath(name string) string {
encoded := base64.RawURLEncoding.EncodeToString([]byte(name))
return filepath.Join(m.vaultDir, ".verstak", "workspaces", encoded+".json")
}
func (m *Manager) writeMetadata(name string, meta Metadata) error {
if err := os.MkdirAll(filepath.Join(m.vaultDir, ".verstak", "workspaces"), 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(meta, "", " ")
if err != nil {
return err
}
path := m.metadataPath(name)
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return err
}
if err := os.Rename(tmp, path); err != nil {
_ = os.Remove(tmp)
return err
}
return nil
}
func (m *Manager) uiStatePath() string {
return filepath.Join(m.vaultDir, ".verstak", "workspace-ui.json")
}
func (m *Manager) readSelectedWorkspaceLocked() string {
data, err := os.ReadFile(m.uiStatePath())
if err != nil {
return ""
}
var state struct {
SelectedWorkspace string `json:"selectedWorkspace"`
}
if err := json.Unmarshal(data, &state); err != nil {
return ""
}
return state.SelectedWorkspace
}
func (m *Manager) writeUIStateLocked() error {
if err := os.MkdirAll(filepath.Join(m.vaultDir, ".verstak"), 0o755); err != nil {
return err
}
state := struct {
SelectedWorkspace string `json:"selectedWorkspace,omitempty"`
UpdatedAt string `json:"updatedAt"`
}{
SelectedWorkspace: m.currentWorkspaceName,
UpdatedAt: time.Now().UTC().Format(time.RFC3339Nano),
}
data, err := json.MarshalIndent(state, "", " ")
if err != nil {
return err
}
path := m.uiStatePath()
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return err
}
if err := os.Rename(tmp, path); err != nil {
_ = os.Remove(tmp)
return err
}
return nil
}
func genericMetadata(name string) Metadata {
return Metadata{
WorkspaceName: name,
Features: map[string]bool{"files": true},
Folders: defaultFolders(),
}
}
func defaultFolders() map[string]string {
return map[string]string{
"notes": "Notes",
"files": "Files",
}
}
func cloneBoolMap(src map[string]bool) map[string]bool {
dst := make(map[string]bool, len(src))
for k, v := range src {
dst[k] = v
}
return dst
}
func cloneStringMap(src map[string]string) map[string]string {
dst := make(map[string]string, len(src))
for k, v := range src {
dst[k] = v
}
return dst
}
func hasAnyTrueFeature(features map[string]bool) bool {
for _, enabled := range features {
if enabled {
return true
}
}
return false
}
func workspaceExists(workspaces []Workspace, name string) bool {
for _, ws := range workspaces {
if ws.Name == name {
return true
}
}
return false
}
func moveIfExists(from, to string) error {
if _, err := os.Stat(from); err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
if err := os.MkdirAll(filepath.Dir(to), 0o755); err != nil {
return err
}
return os.Rename(from, to)
}
// ClearTemplateRegistryForTest simulates templates disappearing after a workspace
// has already stored its creation snapshot.
func ClearTemplateRegistryForTest(t interface{ Cleanup(func()) }) {
original := builtInTemplates
builtInTemplates = map[string]templateDefinition{}
t.Cleanup(func() {
builtInTemplates = original
})
}
+375
View File
@@ -0,0 +1,375 @@
package workspace
import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestListWorkspacesReadsTopLevelPhysicalFolders(t *testing.T) {
vaultDir := newVaultDir(t)
mustMkdir(t, filepath.Join(vaultDir, "Project"))
mustMkdir(t, filepath.Join(vaultDir, "Test"))
mustMkdir(t, filepath.Join(vaultDir, ".verstak"))
mustMkdir(t, filepath.Join(vaultDir, ".git"))
mustWrite(t, filepath.Join(vaultDir, "readme.md"), "not a workspace")
m := NewManager(vaultDir)
if err := m.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
workspaces, err := m.ListWorkspaces()
if err != nil {
t.Fatalf("ListWorkspaces: %v", err)
}
got := workspaceNames(workspaces)
want := []string{"Project", "Test"}
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("workspaces = %v, want %v", got, want)
}
}
func TestListWorkspacesExcludesTopLevelSymlink(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink creation needs extra privileges on Windows")
}
vaultDir := newVaultDir(t)
target := filepath.Join(t.TempDir(), "outside")
mustMkdir(t, target)
if err := os.Symlink(target, filepath.Join(vaultDir, "Linked")); err != nil {
t.Fatalf("Symlink: %v", err)
}
m := NewManager(vaultDir)
workspaces, err := m.ListWorkspaces()
if err != nil {
t.Fatalf("ListWorkspaces: %v", err)
}
if len(workspaces) != 0 {
t.Fatalf("expected symlink workspace to be excluded, got %+v", workspaces)
}
}
func TestLoadDoesNotCreateOrMigrateFoldersFromOldWorkspaceJSON(t *testing.T) {
vaultDir := newVaultDir(t)
mustMkdir(t, filepath.Join(vaultDir, ".verstak"))
oldTree := `{"schemaVersion":1,"nodes":[{"id":"old","type":"space","title":"Old Tree Workspace","path":"Old Tree Workspace"}],"currentNodeId":"old"}`
mustWrite(t, filepath.Join(vaultDir, ".verstak", "workspace.json"), oldTree)
m := NewManager(vaultDir)
if err := m.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
if _, err := os.Stat(filepath.Join(vaultDir, "Old Tree Workspace")); !os.IsNotExist(err) {
t.Fatalf("Load created folder from old workspace.json, stat err=%v", err)
}
workspaces, err := m.ListWorkspaces()
if err != nil {
t.Fatalf("ListWorkspaces: %v", err)
}
if len(workspaces) != 0 {
t.Fatalf("workspace.json tree should not be source of truth, got %+v", workspaces)
}
}
func TestCreateWorkspaceCreatesFolderDefaultTemplateAndMetadataSnapshot(t *testing.T) {
vaultDir := newVaultDir(t)
m := NewManager(vaultDir)
ws, err := m.CreateWorkspace("Project", "")
if err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
if ws.Name != "Project" || ws.RootPath != "Project" {
t.Fatalf("workspace = %+v, want Project root", ws)
}
if _, err := os.Stat(filepath.Join(vaultDir, "Project")); err != nil {
t.Fatalf("workspace folder missing: %v", err)
}
if _, err := os.Stat(filepath.Join(vaultDir, "Project", "Notes", "Overview.md")); err != nil {
t.Fatalf("default template overview missing: %v", err)
}
meta, err := m.GetWorkspaceMetadata("Project")
if err != nil {
t.Fatalf("GetWorkspaceMetadata: %v", err)
}
if meta.WorkspaceName != "Project" {
t.Fatalf("metadata workspaceName = %q", meta.WorkspaceName)
}
if meta.CreatedFromTemplate == nil {
t.Fatal("metadata missing createdFromTemplate snapshot")
}
if meta.CreatedFromTemplate.TemplateID != "default" || meta.CreatedFromTemplate.TemplateName == "" || meta.CreatedFromTemplate.TemplateVersion == 0 || meta.CreatedFromTemplate.AppliedAt == "" {
t.Fatalf("bad template snapshot: %+v", meta.CreatedFromTemplate)
}
if !meta.Features["files"] || !meta.Features["notes"] {
t.Fatalf("features = %+v, want files and notes enabled", meta.Features)
}
if meta.Folders["notes"] != "Notes" {
t.Fatalf("folders = %+v, want notes folder", meta.Folders)
}
}
func TestWorkspaceMetadataDoesNotRequireLiveTemplate(t *testing.T) {
vaultDir := newVaultDir(t)
m := NewManager(vaultDir)
if _, err := m.CreateWorkspace("ClientA", "client-project"); err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
ClearTemplateRegistryForTest(t)
meta, err := m.GetWorkspaceMetadata("ClientA")
if err != nil {
t.Fatalf("GetWorkspaceMetadata after registry clear: %v", err)
}
if meta.CreatedFromTemplate == nil || meta.CreatedFromTemplate.TemplateID != "client-project" {
t.Fatalf("snapshot not preserved after registry clear: %+v", meta.CreatedFromTemplate)
}
}
func TestMissingMetadataReturnsGenericWorkspaceMetadata(t *testing.T) {
vaultDir := newVaultDir(t)
mustMkdir(t, filepath.Join(vaultDir, "Loose"))
m := NewManager(vaultDir)
meta, err := m.GetWorkspaceMetadata("Loose")
if err != nil {
t.Fatalf("GetWorkspaceMetadata: %v", err)
}
if meta.WorkspaceName != "Loose" {
t.Fatalf("workspaceName = %q", meta.WorkspaceName)
}
if meta.CreatedFromTemplate != nil {
t.Fatalf("generic metadata should not invent a template snapshot: %+v", meta.CreatedFromTemplate)
}
if !meta.Features["files"] {
t.Fatalf("generic metadata should enable files at minimum: %+v", meta.Features)
}
}
func TestGetWorkspaceMetadataReturnsCanonicalFolderNameWhenStoredNameIsStale(t *testing.T) {
vaultDir := newVaultDir(t)
m := NewManager(vaultDir)
if _, err := m.CreateWorkspace("Project", "default"); err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
data, err := os.ReadFile(m.metadataPath("Project"))
if err != nil {
t.Fatalf("read metadata: %v", err)
}
var meta Metadata
if err := json.Unmarshal(data, &meta); err != nil {
t.Fatalf("unmarshal metadata: %v", err)
}
meta.WorkspaceName = "OldName"
staleData, err := json.MarshalIndent(meta, "", " ")
if err != nil {
t.Fatalf("marshal metadata: %v", err)
}
if err := os.WriteFile(m.metadataPath("Project"), staleData, 0o600); err != nil {
t.Fatalf("write stale metadata: %v", err)
}
got, err := m.GetWorkspaceMetadata("Project")
if err != nil {
t.Fatalf("GetWorkspaceMetadata: %v", err)
}
if got.WorkspaceName != "Project" {
t.Fatalf("workspaceName = %q, want canonical folder name Project", got.WorkspaceName)
}
}
func TestRenameWorkspacePhysicallyRenamesFolderAndMetadata(t *testing.T) {
vaultDir := newVaultDir(t)
m := NewManager(vaultDir)
if _, err := m.CreateWorkspace("Project", "default"); err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
if err := m.RenameWorkspace("Project", "Renamed"); err != nil {
t.Fatalf("RenameWorkspace: %v", err)
}
if _, err := os.Stat(filepath.Join(vaultDir, "Project")); !os.IsNotExist(err) {
t.Fatalf("old folder still exists or stat failed unexpectedly: %v", err)
}
if _, err := os.Stat(filepath.Join(vaultDir, "Renamed")); err != nil {
t.Fatalf("renamed folder missing: %v", err)
}
meta, err := m.GetWorkspaceMetadata("Renamed")
if err != nil {
t.Fatalf("metadata after rename: %v", err)
}
if meta.WorkspaceName != "Renamed" {
t.Fatalf("metadata workspaceName = %q, want Renamed", meta.WorkspaceName)
}
if _, err := os.Stat(m.metadataPath("Project")); !os.IsNotExist(err) {
t.Fatalf("old metadata key still exists or stat failed unexpectedly: %v", err)
}
}
func TestTrashWorkspaceMovesFolderToTrashAndRemovesFromList(t *testing.T) {
vaultDir := newVaultDir(t)
m := NewManager(vaultDir)
if _, err := m.CreateWorkspace("Project", "default"); err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
result, err := m.TrashWorkspace("Project")
if err != nil {
t.Fatalf("TrashWorkspace: %v", err)
}
if result.OriginalPath != "Project" || result.TrashID == "" || result.TrashPath == "" {
t.Fatalf("bad trash result: %+v", result)
}
if _, err := os.Stat(filepath.Join(vaultDir, "Project")); !os.IsNotExist(err) {
t.Fatalf("workspace still exists after trash, stat err=%v", err)
}
if _, err := os.Stat(filepath.Join(vaultDir, filepath.FromSlash(result.TrashPath))); err != nil {
t.Fatalf("trashed workspace missing: %v", err)
}
workspaces, err := m.ListWorkspaces()
if err != nil {
t.Fatalf("ListWorkspaces: %v", err)
}
if len(workspaces) != 0 {
t.Fatalf("workspace should be removed from list after trash, got %+v", workspaces)
}
}
func TestCreateAndRenameConflictsAreExplicit(t *testing.T) {
vaultDir := newVaultDir(t)
mustMkdir(t, filepath.Join(vaultDir, "Existing"))
mustMkdir(t, filepath.Join(vaultDir, "Other"))
m := NewManager(vaultDir)
if _, err := m.CreateWorkspace("Existing", ""); err == nil || !strings.Contains(err.Error(), "conflict") {
t.Fatalf("create conflict error = %v, want conflict", err)
}
if err := m.RenameWorkspace("Existing", "Other"); err == nil || !strings.Contains(err.Error(), "conflict") {
t.Fatalf("rename conflict error = %v, want conflict", err)
}
}
func TestInvalidWorkspaceNamesRejected(t *testing.T) {
vaultDir := newVaultDir(t)
m := NewManager(vaultDir)
names := []string{"", " ", "A/B", `A\B`, "/abs", `C:\abs`, "..", "a..b", "bad\x00name", ".verstak", ".Verstak", ".git"}
for _, name := range names {
if _, err := m.CreateWorkspace(name, ""); err == nil {
t.Fatalf("CreateWorkspace(%q) succeeded, want invalid name error", name)
}
}
}
func TestCompatibilityTreeIsDerivedFromTopLevelFolders(t *testing.T) {
vaultDir := newVaultDir(t)
mustMkdir(t, filepath.Join(vaultDir, "Project"))
mustMkdir(t, filepath.Join(vaultDir, "Project", "Nested"))
mustMkdir(t, filepath.Join(vaultDir, "Test"))
m := NewManager(vaultDir)
if err := m.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
tree := m.GetTree()
if len(tree.Nodes) != 2 {
t.Fatalf("nodes = %+v, want 2 top-level workspaces", tree.Nodes)
}
if tree.Nodes[0].ID != "Project" || tree.Nodes[0].Title != "Project" || tree.Nodes[0].Path != "" {
t.Fatalf("first compatibility node = %+v, want derived workspace without persisted path mapping", tree.Nodes[0])
}
for _, node := range tree.Nodes {
if node.ParentID != "" {
t.Fatalf("compatibility tree should be flat, got child node %+v", node)
}
if node.ID == "Nested" || node.Title == "Nested" {
t.Fatalf("nested folders must not become workspace nodes: %+v", tree.Nodes)
}
}
}
func TestMoveNodeCompatibilityDoesNotCreateNestedWorkspaceModel(t *testing.T) {
vaultDir := newVaultDir(t)
mustMkdir(t, filepath.Join(vaultDir, "Project"))
mustMkdir(t, filepath.Join(vaultDir, "Test"))
m := NewManager(vaultDir)
if err := m.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
err := m.MoveNode("Project", "Test")
if err == nil || !strings.Contains(err.Error(), "top-level only") {
t.Fatalf("MoveNode error = %v, want top-level only", err)
}
if _, statErr := os.Stat(filepath.Join(vaultDir, "Test", "Project")); !os.IsNotExist(statErr) {
t.Fatalf("MoveNode created nested mapped workspace, stat err=%v", statErr)
}
}
func TestMetadataFileShape(t *testing.T) {
vaultDir := newVaultDir(t)
m := NewManager(vaultDir)
if _, err := m.CreateWorkspace("Project", "default"); err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
data, err := os.ReadFile(m.metadataPath("Project"))
if err != nil {
t.Fatalf("read metadata: %v", err)
}
var raw map[string]interface{}
if err := json.Unmarshal(data, &raw); err != nil {
t.Fatalf("metadata JSON: %v", err)
}
if raw["workspaceName"] != "Project" {
t.Fatalf("workspaceName = %v", raw["workspaceName"])
}
if _, ok := raw["createdFromTemplate"].(map[string]interface{}); !ok {
t.Fatalf("createdFromTemplate missing in raw metadata: %s", data)
}
}
func newVaultDir(t *testing.T) string {
t.Helper()
vaultDir := filepath.Join(t.TempDir(), "vault")
mustMkdir(t, vaultDir)
mustMkdir(t, filepath.Join(vaultDir, ".verstak", "trash"))
return vaultDir
}
func mustMkdir(t *testing.T, path string) {
t.Helper()
if err := os.MkdirAll(path, 0o755); err != nil {
t.Fatalf("MkdirAll(%s): %v", path, err)
}
}
func mustWrite(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("MkdirAll(%s): %v", filepath.Dir(path), err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("WriteFile(%s): %v", path, err)
}
}
func workspaceNames(workspaces []Workspace) []string {
names := make([]string, len(workspaces))
for i, ws := range workspaces {
names[i] = ws.Name
}
return names
}
+95
View File
@@ -0,0 +1,95 @@
// Package debug provides a debug logger that writes to a file.
// Enabled with --debug CLI flag.
package debug
import (
"fmt"
"io"
"log"
"os"
"path/filepath"
"sync"
"time"
)
var (
logger *log.Logger
mu sync.Mutex
enabled bool
)
// Init initializes the debug logger. If --debug is present in args,
// it writes to ~/.local/share/verstak/debug/verstak-YYYY-MM-DD-HHMMSS.log.
// Returns true if debug mode is enabled.
func Init(args []string) bool {
mu.Lock()
defer mu.Unlock()
for _, a := range args {
if a == "--debug" {
enabled = true
break
}
}
if !enabled {
return false
}
// Create log directory
logDir := filepath.Join(os.Getenv("HOME"), ".local", "share", "verstak", "debug")
if err := os.MkdirAll(logDir, 0755); err != nil {
log.Printf("[debug] failed to create log dir %s: %v", logDir, err)
// Fallback to /tmp
logDir = filepath.Join(os.TempDir(), "verstak-debug")
os.MkdirAll(logDir, 0755)
}
// Create log file with timestamp
timestamp := time.Now().Format("2006-01-02-150405")
logFile := filepath.Join(logDir, fmt.Sprintf("verstak-%s.log", timestamp))
f, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
log.Printf("[debug] failed to open log file %s: %v", logFile, err)
return true // Still enabled, but logging to stderr
}
// Write to both file and stderr
mw := io.MultiWriter(f, os.Stderr)
logger = log.New(mw, "", log.LstdFlags|log.Lmicroseconds)
log.Printf("[debug] logger initialized: %s", logFile)
return true
}
// IsEnabled returns whether debug mode is active.
func IsEnabled() bool {
mu.Lock()
defer mu.Unlock()
return enabled
}
// Logf writes a formatted debug message.
func Logf(format string, v ...interface{}) {
mu.Lock()
defer mu.Unlock()
if !enabled {
return
}
if logger != nil {
logger.Printf(format, v...)
}
}
// Log writes a debug message.
func Log(v ...interface{}) {
mu.Lock()
defer mu.Unlock()
if !enabled {
return
}
if logger != nil {
logger.Println(v...)
}
}
+113 -54
View File
@@ -5,7 +5,6 @@ import (
"fmt"
"log"
"os"
"path/filepath"
"strings"
"github.com/wailsapp/wails/v2"
@@ -17,30 +16,27 @@ import (
"github.com/verstak/verstak-desktop/internal/core/capability"
"github.com/verstak/verstak-desktop/internal/core/contribution"
"github.com/verstak/verstak-desktop/internal/core/events"
corefiles "github.com/verstak/verstak-desktop/internal/core/files"
"github.com/verstak/verstak-desktop/internal/core/permissions"
"github.com/verstak/verstak-desktop/internal/core/plugin"
"github.com/verstak/verstak-desktop/internal/core/pluginstate"
"github.com/verstak/verstak-desktop/internal/core/storage"
syncsvc "github.com/verstak/verstak-desktop/internal/core/sync"
"github.com/verstak/verstak-desktop/internal/core/vault"
"github.com/verstak/verstak-desktop/internal/core/workspace"
"github.com/verstak/verstak-desktop/internal/shell/debug"
)
//go:embed frontend/dist
var assets embed.FS
// expandPath resolves "~" to the user's home directory.
func expandPath(path string) string {
if strings.HasPrefix(path, "~/") {
home, err := os.UserHomeDir()
if err != nil {
log.Printf("[main] expandPath: cannot get home dir: %v", err)
return path
}
return filepath.Join(home, path[2:])
}
return path
}
func main() {
// ─── Debug Logging ───────────────────────────────────────
debugEnabled := debug.Init(os.Args)
if debugEnabled {
log.Printf("[main] debug mode enabled — logging to file")
}
// ─── Initialize Core Registries ──────────────────────────
capRegistry := capability.NewRegistry()
contribRegistry := contribution.NewRegistry()
@@ -50,29 +46,6 @@ func main() {
// ─── Initialize Vault ────────────────────────────────────
vaultService := vault.NewVault(eventBus)
// ─── Register Core Capabilities ─────────────────────────
// These are provided by the desktop core itself, not by plugins.
// Registered before plugin discovery so that plugins can resolve
// required capabilities (e.g. verstak/core/plugin-manager/v1) at load time.
corePluginID := "verstak-desktop"
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",
}
if err := capRegistry.Register(corePluginID, coreCaps); err != nil {
log.Fatalf("[main] failed to register core capabilities: %v", err)
}
log.Printf("[main] registered %d core capabilities", len(coreCaps))
// Register vault capability (vault is available as a core service)
if err := capRegistry.Register(corePluginID, []string{"verstak/core/vault/v1"}); err != nil {
log.Fatalf("[main] failed to register vault capability: %v", err)
}
log.Printf("[main] registered vault capability")
// ─── Initialize App Settings ─────────────────────────────
appSettingsMgr := appsettings.NewDefaultManager()
if err := appSettingsMgr.Load(); err != nil {
@@ -80,7 +53,6 @@ func main() {
}
// ─── Vault Auto-Open ─────────────────────────────────────
// If currentVaultPath is set in app settings, try to open it.
cfg := appSettingsMgr.Get()
if cfg.CurrentVaultPath != "" {
if err := vaultService.OpenVault(cfg.CurrentVaultPath); err != nil {
@@ -98,38 +70,88 @@ func main() {
}
}
// ─── Initialize Workspace ────────────────────────────────
var workspaceMgr *workspace.Manager
if vaultService.GetVaultStatus() == vault.StatusOpen {
workspaceMgr = workspace.NewManager(vaultService.GetVaultPath())
if err := workspaceMgr.Load(); err != nil {
log.Printf("[main] workspace: %v", err)
workspaceMgr = nil
} else {
log.Printf("[main] workspace loaded: %d nodes", len(workspaceMgr.GetTree().Nodes))
}
}
// ─── Register Core Capabilities ─────────────────────────
corePluginID := "verstak-desktop"
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",
"verstak/core/files/v1",
"verstak/core/workbench/v1",
}
if err := capRegistry.Register(corePluginID, coreCaps); err != nil {
log.Fatalf("[main] failed to register core capabilities: %v", err)
}
log.Printf("[main] registered %d core capabilities", len(coreCaps))
// Register vault capability
if err := capRegistry.Register(corePluginID, []string{"verstak/core/vault/v1"}); err != nil {
log.Fatalf("[main] failed to register vault capability: %v", err)
}
log.Printf("[main] registered vault capability")
// Register workspace capability (only when vault is open and workspace initialized)
if workspaceMgr != nil && workspaceMgr.IsInitialized() {
if err := capRegistry.Register(corePluginID, []string{"verstak/core/workspace/v1"}); err != nil {
log.Fatalf("[main] failed to register workspace capability: %v", err)
}
log.Printf("[main] registered workspace capability")
}
// ─── Plugin Discovery ───────────────────────────────────
// Resolve plugin directories relative to the binary location,
// not CWD (Wails may launch from a different directory).
binDir := filepath.Dir(os.Args[0])
pluginDir := filepath.Join(binDir, "plugins")
discoveryDirs := []string{
"~/.config/verstak/plugins",
pluginDir,
}
// Expand tilde in all paths
for i, d := range discoveryDirs {
discoveryDirs[i] = expandPath(d)
}
discoveryDirs := plugin.DefaultDiscoveryDirs()
log.Printf("[main] plugin dirs: %v", discoveryDirs)
if debugEnabled {
debug.Logf("[main] plugin dirs: %v", discoveryDirs)
}
plugins, discErrors := plugin.DiscoverPlugins(discoveryDirs)
for _, err := range discErrors {
log.Printf("[plugin] discovery warning: %v", err)
if debugEnabled {
debug.Logf("[plugin] discovery warning: %v", err)
}
}
log.Printf("[plugin] discovered %d plugins", len(plugins))
if debugEnabled {
for i, p := range plugins {
debug.Logf("[plugin] discovered[%d]: id=%s name=%s version=%s source=%s root=%s",
i, p.Manifest.ID, p.Manifest.Name, p.Manifest.Version, p.Manifest.Source, p.RootPath)
}
}
// ─── Plugin Lifecycle: Register Capabilities + Contributions ──
if debugEnabled {
debug.Logf("[main] starting plugin lifecycle for %d plugins", len(plugins))
}
for i := range plugins {
p := &plugins[i]
if debugEnabled {
debug.Logf("[main] lifecycle[%d]: id=%s status=%s enabled=%v", i, p.Manifest.ID, p.Status, p.Enabled)
}
// Check if plugin is disabled in vault plugin state
if pluginStateMgr != nil && pluginStateMgr.IsDisabled(p.Manifest.ID) {
log.Printf("[plugin] %s: disabled in vault plugin state — skipping", p.Manifest.ID)
if debugEnabled {
debug.Logf("[main] lifecycle: %s disabled in vault state, skipping", p.Manifest.ID)
}
p.Status = plugin.StatusDisabled
p.Enabled = false
continue
@@ -139,6 +161,9 @@ func main() {
if len(p.Manifest.Provides) > 0 {
if err := capRegistry.Register(p.Manifest.ID, p.Manifest.Provides); err != nil {
log.Printf("[plugin] %s: capability registration failed: %v", p.Manifest.ID, err)
if debugEnabled {
debug.Logf("[main] lifecycle: %s capability registration failed: %v", p.Manifest.ID, err)
}
p.Status = plugin.StatusFailed
p.Error = err.Error()
continue
@@ -150,6 +175,9 @@ func main() {
missingRequired := capRegistry.CheckRequired(p.Manifest.Requires)
if len(missingRequired) > 0 {
log.Printf("[plugin] %s: missing required capabilities: %v", p.Manifest.ID, missingRequired)
if debugEnabled {
debug.Logf("[main] lifecycle: %s missing required: %v", p.Manifest.ID, missingRequired)
}
p.Status = plugin.StatusMissingRequiredCapability
p.Error = fmt.Sprintf("missing required: %s", strings.Join(missingRequired, ", "))
continue
@@ -159,6 +187,9 @@ func main() {
missingOptional := capRegistry.CheckRequired(p.Manifest.OptionalRequires)
if len(missingOptional) > 0 {
log.Printf("[plugin] %s: missing optional capabilities (degraded): %v", p.Manifest.ID, missingOptional)
if debugEnabled {
debug.Logf("[main] lifecycle: %s missing optional (degraded): %v", p.Manifest.ID, missingOptional)
}
p.Status = plugin.StatusDegraded
} else {
p.Status = plugin.StatusLoaded
@@ -168,9 +199,31 @@ func main() {
if p.Manifest.Contributes != nil {
contribRegistry.Register(p.Manifest.ID, p.Manifest.Contributes)
log.Printf("[plugin] %s: contributions registered", p.Manifest.ID)
if debugEnabled {
c := p.Manifest.Contributes
debug.Logf("[main] lifecycle: %s contributions: views=%d commands=%d sidebar=%d settings=%d statusbar=%d",
p.Manifest.ID, len(c.Views), len(c.Commands), len(c.SidebarItems), len(c.SettingsPanels), len(c.StatusBarItems))
}
}
// 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)
if debugEnabled {
debug.Logf("[main] lifecycle: %s failed to record desired: %v", p.Manifest.ID, err)
}
}
}
log.Printf("[plugin] %s: status=%s", p.Manifest.ID, p.Status)
if debugEnabled {
debug.Logf("[main] lifecycle: %s final status=%s enabled=%v", p.Manifest.ID, p.Status, p.Enabled)
}
}
// ─── Log Summary ───────────────────────────────────────
@@ -192,7 +245,12 @@ func main() {
// Create the App struct
storageService := storage.New(vaultService)
app := api.NewApp(capRegistry, contribRegistry, permRegistry, eventBus, plugins, vaultService, storageService, appSettingsMgr, pluginStateMgr)
filesService := corefiles.NewService(vaultService)
var syncService *syncsvc.Service
if vaultService.GetVaultStatus() == vault.StatusOpen {
syncService = syncsvc.NewService(vaultService.GetVaultPath(), "")
}
app := api.NewApp(capRegistry, contribRegistry, permRegistry, eventBus, plugins, vaultService, storageService, filesService, appSettingsMgr, pluginStateMgr, workspaceMgr, syncService, debugEnabled)
// ─── Wails App ───────────────────────────────────────────
err := wails.Run(&options.App{
@@ -202,6 +260,7 @@ func main() {
MinWidth: 800,
MinHeight: 600,
WindowStartState: options.Normal,
OnStartup: app.Startup,
AssetServer: &assetserver.Options{
Assets: assets,
},
+27
View File
@@ -0,0 +1,27 @@
--- vendor/github.com/wailsapp/wails/v2/internal/frontend/desktop/linux/window.c
+++ vendor/github.com/wailsapp/wails/v2/internal/frontend/desktop/linux/window.c
@@ -329,6 +329,24 @@
if (event->button == 3)
{
return FALSE;
+ }
+
+ // Handle mouse back/forward buttons (GDK button 8 = Back, button 9 = Forward)
+ // WebKitGTK doesn't propagate these correctly to DOM, so we dispatch custom events
+ // that the frontend can listen to for history navigation.
+ if (event->button == 8)
+ {
+ webkit_web_view_run_javascript(WEBKIT_WEB_VIEW(widget),
+ "window.dispatchEvent(new CustomEvent('verstak:navigate-back'));",
+ NULL, NULL, NULL);
+ return TRUE;
+ }
+ if (event->button == 9)
+ {
+ webkit_web_view_run_javascript(WEBKIT_WEB_VIEW(widget),
+ "window.dispatchEvent(new CustomEvent('verstak:navigate-forward'));",
+ NULL, NULL, NULL);
+ return TRUE;
}
if (event->type == GDK_BUTTON_PRESS && event->button == 1)
+79 -82
View File
@@ -2,95 +2,94 @@
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
FAILED=0
report() {
if [ "$2" -eq 0 ]; then
echo " ✅ $1"
else
echo " ❌ $1"
FAILED=1
fi
}
ensure_npm_deps() {
local dir="$1"
if [ ! -f "$dir/package.json" ]; then
return 1
fi
if [ ! -d "$dir/node_modules" ]; then
echo " 📦 node_modules missing — installing..."
if [ -f "$dir/package-lock.json" ]; then
(cd "$dir" && npm ci --no-audit --no-fund)
else
(cd "$dir" && npm install --no-audit --no-fund)
fi
report "npm install in $(basename "$dir")" $?
fi
return 0
}
echo "=== verstak-desktop build ==="
echo ""
# ── Dependency checks ──
echo "[deps]"
if ! command -v go &>/dev/null; then
echo " ❌ go: not found. Install Go 1.24+ from https://go.dev/dl/"
FAILED=1
else
echo " ✅ go $(go version | grep -oP 'go\S+')"
fi
if ! command -v node &>/dev/null; then
echo " ❌ node: not found. Install Node.js 20+"
FAILED=1
else
echo " ✅ node $(node --version)"
fi
if ! command -v npm &>/dev/null; then
echo " ❌ npm: not found"
FAILED=1
fi
if [ "$FAILED" -ne 0 ]; then
echo ""
echo "❌ build failed — missing core dependencies"
exit 1
fi
echo " ✅ go $(go version | grep -oP 'go\S+')"
if ! command -v node &>/dev/null; then
echo " ❌ node: not found. Install Node.js 20+"
exit 1
fi
echo " ✅ node $(node --version)"
if ! command -v npm &>/dev/null; then
echo " ❌ npm: not found"
exit 1
fi
echo " ✅ npm $(npm --version)"
# ── Frontend (build first — Go //go:embed needs frontend/dist/) ──
echo ""
echo "[frontend]"
if [ -f "$ROOT/frontend/package.json" ]; then
ensure_npm_deps "$ROOT/frontend"
if [ ! -d "$ROOT/frontend/node_modules" ]; then
echo " 📦 node_modules missing — installing..."
if [ -f "$ROOT/frontend/package-lock.json" ]; then
(cd "$ROOT/frontend" && npm ci --no-audit --no-fund)
else
(cd "$ROOT/frontend" && npm install --no-audit --no-fund)
fi
fi
(cd "$ROOT/frontend" && npm run build)
report "frontend build" $?
echo " ✅ frontend build"
else
echo " ℹ️ frontend/package.json not found — skipping"
fi
# ── Go backend ──
echo ""
echo "[backend]"
# Ensure Go module deps are downloaded
echo " 📦 go mod download..."
(cd "$ROOT" && go mod download)
report "go mod download" $?
echo " ✅ go mod download"
echo " 🔍 go vet..."
(cd "$ROOT" && go vet ./...)
report "go vet" $?
echo " ✅ go vet"
echo " 🔨 go build..."
(cd "$ROOT" && go build ./...)
report "go build" $?
echo " ✅ go build"
# Go test (best-effort — some packages may have no tests)
(cd "$ROOT" && go test -count=1 ./... 2>&1 || true)
report "go test" $?
echo " 🧪 go test..."
(cd "$ROOT" && go test -count=1 ./...)
echo " ✅ go test"
# ── Mouse button patch ──
# WebKitGTK does not propagate buttons 8/9 (XButton1/XButton2) into DOM events.
# We patch Wails' window.c on Linux to intercept these GTK signals and dispatch
# CustomEvent('verstak:navigate-back'/'verstak:navigate-forward') into the page.
echo ""
echo "[mouse-buttons]"
PATCH_FILE="$ROOT/patches/window.c.button-press.patch"
if [ -f "$PATCH_FILE" ]; then
echo " 📦 go mod vendor..."
(cd "$ROOT" && go mod vendor)
echo " ✅ go mod vendor"
echo " 📝 applying window.c.button-press.patch..."
(cd "$ROOT" && patch -p0 --forward < "$PATCH_FILE" 2>/dev/null || true)
rm -f "$ROOT/vendor/github.com/wailsapp/wails/v2/internal/frontend/desktop/linux/window.c.rej"
echo " ✅ window.c patched"
else
echo " ℹ️ patch file not found at $PATCH_FILE — skipping"
fi
# ── Wails ──
echo ""
echo "[wails]"
WAILS=""
if command -v wails &>/dev/null; then
WAILS="wails"
else
# Check GO bin paths
GOBIN="$(go env GOBIN 2>/dev/null)"
GOPATH="$(go env GOPATH 2>/dev/null)"
if [ -n "$GOBIN" ] && [ -f "$GOBIN/wails" ]; then
@@ -101,7 +100,6 @@ else
if [ -z "$WAILS" ]; then
echo " 📦 wails not found — installing..."
go install github.com/wailsapp/wails/v2/cmd/wails@latest
report "wails install" $?
if [ -n "$GOBIN" ] && [ -f "$GOBIN/wails" ]; then
WAILS="$GOBIN/wails"
elif [ -f "$GOPATH/bin/wails" ]; then
@@ -109,6 +107,7 @@ else
fi
fi
fi
WAILS_BINARY="verstak-desktop"
WAILS_TAGS=""
if command -v pkg-config &>/dev/null; then
@@ -123,32 +122,30 @@ if command -v pkg-config &>/dev/null; then
else
echo " ⚠️ pkg-config not found"
fi
if [ -n "$WAILS" ]; then
echo " 🔨 wails build..."
(cd "$ROOT" && "$WAILS" build -clean $WAILS_TAGS)
report "wails build" $?
# Copy plugins/ to build/bin/ so the binary can find them at runtime
if [ -d "$ROOT/plugins" ]; then
mkdir -p "$ROOT/build/bin/plugins"
cp -r "$ROOT/plugins/"* "$ROOT/build/bin/plugins/" 2>/dev/null || true
echo " 📦 plugins copied to build/bin/plugins/"
fi
# Show where the binary ended up
if [ -f "$ROOT/build/bin/$WAILS_BINARY" ]; then
echo " 📦 binary: $ROOT/build/bin/$WAILS_BINARY"
fi
if [ -f "$ROOT/$WAILS_BINARY" ]; then
echo " 📦 binary: $ROOT/$WAILS_BINARY"
fi
else
echo " ❌ wails: could not install"
FAILED=1
if [ -z "$WAILS" ]; then
echo " ❌ wails: could not find or install"
exit 1
fi
echo " 🔨 wails build..."
(cd "$ROOT" && "$WAILS" build -clean $WAILS_TAGS)
echo " ✅ wails build"
# Copy plugins/ to build/bin/ so the binary can find them at runtime
if [ -d "$ROOT/plugins" ]; then
mkdir -p "$ROOT/build/bin/plugins"
cp -r "$ROOT/plugins/"* "$ROOT/build/bin/plugins/" 2>/dev/null || true
echo " 📦 plugins copied to build/bin/plugins/"
fi
# Show where the binary ended up
if [ -f "$ROOT/build/bin/$WAILS_BINARY" ]; then
echo " 📦 binary: $ROOT/build/bin/$WAILS_BINARY"
fi
if [ -f "$ROOT/$WAILS_BINARY" ]; then
echo " 📦 binary: $ROOT/$WAILS_BINARY"
fi
echo ""
if [ "$FAILED" -eq 0 ]; then
echo "✅ build passed"
else
echo "❌ build failed"
fi
exit "$FAILED"
echo "✅ build passed"
+15 -17
View File
@@ -15,34 +15,34 @@ if [ ! -d "$OFFICIAL_PLUGINS" ]; then
exit 1
fi
# ── ensure dist package exists ──
DIST_PACKAGE="$OFFICIAL_PLUGINS/dist/platform-test"
if [ ! -d "$DIST_PACKAGE" ]; then
echo " ℹ️ dist package not found at $DIST_PACKAGE"
# ── ensure dist packages exist ──
DIST_ROOT="$OFFICIAL_PLUGINS/dist"
if [ ! -d "$DIST_ROOT" ] || [ -z "$(find "$DIST_ROOT" -mindepth 1 -maxdepth 1 -type d 2>/dev/null)" ]; then
echo " ℹ️ dist packages not found at $DIST_ROOT"
echo " → Running build.sh in verstak-official-plugins..."
(cd "$OFFICIAL_PLUGINS" && ./scripts/build.sh)
echo ""
if [ ! -d "$DIST_PACKAGE" ]; then
echo "❌ dist package still missing after build"
if [ ! -d "$DIST_ROOT" ] || [ -z "$(find "$DIST_ROOT" -mindepth 1 -maxdepth 1 -type d 2>/dev/null)" ]; then
echo "❌ dist packages still missing after build"
exit 1
fi
fi
# ── create ./plugins/platform-test ──
PLUGIN_DIR="$ROOT/plugins/platform-test"
echo " → installing platform-test to $PLUGIN_DIR"
# ── create ./plugins/* from official dist packages ──
PLUGIN_DIR="$ROOT/plugins"
echo " → installing official plugins to $PLUGIN_DIR"
mkdir -p "$ROOT/plugins"
# Clean up any leftover temp directories
for tmp in "$ROOT/plugins"/.platform-test-tmp.*; do
for tmp in "$ROOT"/.official-plugins-tmp.*; do
[ -d "$tmp" ] && rm -rf "$tmp"
done
# Atomic replace: install to temp then rename
TMP_DIR=$(mktemp -d "$ROOT/plugins/.platform-test-tmp.XXXXXX")
cp -r "$DIST_PACKAGE/." "$TMP_DIR/"
# Remove old directory (fix permissions first if needed)
TMP_DIR=$(mktemp -d "$ROOT/.official-plugins-tmp.XXXXXX")
cp -r "$DIST_ROOT/"* "$TMP_DIR/"
# Remove old plugin directories (fix permissions first if needed)
if [ -d "$PLUGIN_DIR" ]; then
chmod -R u+rwx "$PLUGIN_DIR" 2>/dev/null || true
rm -rf "$PLUGIN_DIR"
@@ -50,14 +50,12 @@ fi
mv "$TMP_DIR" "$PLUGIN_DIR"
# ── verify ──
if [ -f "$PLUGIN_DIR/plugin.json" ]; then
PLUGIN_ID=$(python3 -c "import json; print(json.load(open('$PLUGIN_DIR/plugin.json')).get('id','unknown'))" 2>/dev/null || echo "unknown")
if [ -d "$PLUGIN_DIR" ]; then
FILE_COUNT=$(find "$PLUGIN_DIR" -type f | wc -l)
echo " ✅ installed: $PLUGIN_DIR"
echo " plugin id: $PLUGIN_ID"
echo " files: $FILE_COUNT"
else
echo "❌ install failed: plugin.json missing in $PLUGIN_DIR"
echo "❌ install failed: $PLUGIN_DIR missing"
exit 1
fi
+45 -1
View File
@@ -63,5 +63,49 @@ if [ "$SMOKE_EXIT" -ne 0 ]; then
exit 1
fi
# ── test enable/disable via Go smoke ──
echo ""
echo "✅ smoke-platform done"
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
# ── test workspace via Go smoke ──
echo ""
echo "[go smoke: workspace]"
(cd "$ROOT" && go run -mod=mod ./cmd/smoke-platform/ -test-workspace 2>&1)
SMOKE_WS_EXIT=$?
if [ "$SMOKE_WS_EXIT" -ne 0 ]; then
echo " ❌ smoke-platform: workspace test failed"
exit 1
fi
# ── test contributions via Go smoke ──
echo ""
echo "[go smoke: contributions]"
(cd "$ROOT" && go run -mod=mod ./cmd/smoke-platform/ -test-contributions 2>&1)
SMOKE_CONT_EXIT=$?
if [ "$SMOKE_CONT_EXIT" -ne 0 ]; then
echo " ❌ smoke-platform: contributions test failed"
exit 1
fi
# ── bundle host test (JS smoke for error boundary + real mount proof) ──
echo ""
echo "[bundle host test]"
if ! command -v node &>/dev/null; then
echo " ⚠️ node not found — skipping bundle-host-test"
else
(node "$ROOT/frontend/tests/bundle-host-test.cjs" 2>&1)
BUNDLE_HOST_EXIT=$?
if [ "$BUNDLE_HOST_EXIT" -ne 0 ]; then
echo " ❌ bundle-host-test failed"
exit 1
fi
fi
echo ""
echo "✅ smoke-platform all tests done"
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# update-and-build-all.sh — dev helper: pull all repos, build official plugins, build desktop
# This is NOT part of CI. For local deterministic build, use scripts/build.sh in each repo.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
VERSTAK_ROOT="$(cd "$ROOT/.." && pwd)"
echo "=== update-and-build-all ==="
echo ""
# ── 1. Pull all repos ──
echo "=== pull all repos ==="
repos=("verstak-desktop" "verstak-sdk" "verstak-official-plugins" "verstak-sync-server" "verstak-browser-extension" "verstak-docs")
for repo in "${repos[@]}"; do
repo_path="$VERSTAK_ROOT/$repo"
if [ ! -d "$repo_path" ]; then
echo " ⚠️ $repo: directory not found at $repo_path — skipping"
continue
fi
echo "[$repo]"
(cd "$repo_path" && git pull --ff-only 2>&1) && echo " ✅ pulled" || echo " ❌ git pull failed"
done
# ── 2. Build official plugins ──
echo ""
echo "=== build official plugins ==="
OFFICIAL="$VERSTAK_ROOT/verstak-official-plugins"
if [ ! -d "$OFFICIAL" ]; then
echo " ⚠️ verstak-official-plugins not found — skipping"
else
(cd "$OFFICIAL" && ./scripts/build.sh)
echo " ✅ official plugins built"
fi
# ── 3. Copy plugin packages to desktop ──
echo ""
echo "=== install plugins to desktop ==="
DEST="$ROOT/plugins"
rm -rf "$DEST"
mkdir -p "$DEST"
if [ -d "$OFFICIAL/dist" ] && [ -n "$(find "$OFFICIAL/dist" -mindepth 1 -maxdepth 1 -type d 2>/dev/null)" ]; then
cp -r "$OFFICIAL/dist/"* "$DEST/" 2>/dev/null
echo " ✅ plugins copied to $DEST"
else
echo " ℹ️ no plugin packages to copy"
fi
# ── 4. Build desktop ──
echo ""
echo "=== build desktop ==="
exec "$ROOT/scripts/build.sh"