From 6ed6df311a6b32fdca9fcdde29d0101d26fbb40b Mon Sep 17 00:00:00 2001 From: mirivlad Date: Fri, 19 Jun 2026 07:51:57 +0800 Subject: [PATCH] Implement milestone 6b workbench routing skeleton --- AGENTS.md | 86 ++ docs/DEV_PLUGINS.md | 43 +- docs/GUI_TESTING.md | 143 ++++ docs/MILESTONE_6A_FILES_CORE_PLAN.md | 403 ++++++++++ docs/MILESTONE_6B_PLAN.md | 135 ++++ docs/NOTES_FILES_PLUGIN_PLAN.md | 425 ++++++++++ docs/PLUGIN_RUNTIME.md | 239 +++++- frontend/e2e/helpers.js | 54 ++ frontend/e2e/plugin-api-bridge.spec.js | 125 +++ .../e2e/plugin-manager-disable-enable.spec.js | 121 +++ frontend/e2e/plugin-manager-layout.spec.js | 84 ++ frontend/e2e/reload-updates-state.spec.js | 101 +++ frontend/e2e/sidebar-opens-view.spec.js | 75 ++ frontend/index.html | 11 +- frontend/package-lock.json | 60 ++ frontend/package.json | 6 +- frontend/package.json.md5 | 2 +- frontend/playwright.config.js | 59 ++ frontend/src/App.svelte | 171 +++- frontend/src/lib/log/debug.js | 150 ++++ .../lib/plugin-host/PluginBundleHost.svelte | 59 +- .../src/lib/plugin-host/VerstakPluginAPI.js | 331 ++++++-- .../src/lib/plugin-manager/PluginCard.svelte | 32 +- .../lib/plugin-manager/PluginManager.svelte | 115 ++- frontend/src/lib/shell/Sidebar.svelte | 33 +- frontend/src/lib/shell/ViewContainer.svelte | 5 +- frontend/src/lib/shell/WorkbenchHost.svelte | 114 +++ frontend/src/lib/shell/WorkspaceTree.svelte | 19 +- frontend/src/lib/test/wails-mock.js | 756 ++++++++++++++++++ frontend/vite.config.js | 24 +- frontend/wailsjs/go/api/App.d.ts | 40 +- frontend/wailsjs/go/api/App.js | 72 ++ frontend/wailsjs/go/models.ts | 599 +++++++++++--- internal/api/app.go | 494 +++++++++++- internal/api/app_test.go | 473 +++++++++++ internal/core/appsettings/manager.go | 34 +- internal/core/appsettings/manager_test.go | 31 + internal/core/contribution/registry.go | 40 + internal/core/contribution/registry_test.go | 61 ++ internal/core/files/path_policy.go | 85 ++ internal/core/files/path_policy_test.go | 93 +++ internal/core/files/service.go | 472 +++++++++++ internal/core/files/service_test.go | 436 ++++++++++ internal/core/files/types.go | 56 ++ internal/core/permissions/registry.go | 4 + internal/core/plugin/dirs.go | 71 ++ internal/core/plugin/dirs_test.go | 71 ++ internal/core/plugin/plugin.go | 49 +- internal/core/plugin/plugin_test.go | 84 ++ internal/core/workbench/router.go | 257 ++++++ internal/core/workbench/routing_test.go | 244 ++++++ internal/shell/debug/logger.go | 95 +++ main.go | 85 +- 53 files changed, 7592 insertions(+), 335 deletions(-) create mode 100644 docs/GUI_TESTING.md create mode 100644 docs/MILESTONE_6A_FILES_CORE_PLAN.md create mode 100644 docs/MILESTONE_6B_PLAN.md create mode 100644 docs/NOTES_FILES_PLUGIN_PLAN.md create mode 100644 frontend/e2e/helpers.js create mode 100644 frontend/e2e/plugin-api-bridge.spec.js create mode 100644 frontend/e2e/plugin-manager-disable-enable.spec.js create mode 100644 frontend/e2e/plugin-manager-layout.spec.js create mode 100644 frontend/e2e/reload-updates-state.spec.js create mode 100644 frontend/e2e/sidebar-opens-view.spec.js create mode 100644 frontend/playwright.config.js create mode 100644 frontend/src/lib/log/debug.js create mode 100644 frontend/src/lib/shell/WorkbenchHost.svelte create mode 100644 frontend/src/lib/test/wails-mock.js create mode 100644 internal/core/files/path_policy.go create mode 100644 internal/core/files/path_policy_test.go create mode 100644 internal/core/files/service.go create mode 100644 internal/core/files/service_test.go create mode 100644 internal/core/files/types.go create mode 100644 internal/core/plugin/dirs.go create mode 100644 internal/core/plugin/dirs_test.go create mode 100644 internal/core/workbench/router.go create mode 100644 internal/core/workbench/routing_test.go create mode 100644 internal/shell/debug/logger.go diff --git a/AGENTS.md b/AGENTS.md index b47d1a3..82db629 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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.** diff --git a/docs/DEV_PLUGINS.md b/docs/DEV_PLUGINS.md index 5f8ec9e..55b320a 100644 --- a/docs/DEV_PLUGINS.md +++ b/docs/DEV_PLUGINS.md @@ -59,12 +59,51 @@ 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 | +| `/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. + +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 diff --git a/docs/GUI_TESTING.md b/docs/GUI_TESTING.md new file mode 100644 index 0000000..aac5276 --- /dev/null +++ b/docs/GUI_TESTING.md @@ -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` diff --git a/docs/MILESTONE_6A_FILES_CORE_PLAN.md b/docs/MILESTONE_6A_FILES_CORE_PLAN.md new file mode 100644 index 0000000..dbb82f2 --- /dev/null +++ b/docs/MILESTONE_6A_FILES_CORE_PLAN.md @@ -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//...` 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. diff --git a/docs/MILESTONE_6B_PLAN.md b/docs/MILESTONE_6B_PLAN.md new file mode 100644 index 0000000..ed3b21b --- /dev/null +++ b/docs/MILESTONE_6B_PLAN.md @@ -0,0 +1,135 @@ +# 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. diff --git a/docs/NOTES_FILES_PLUGIN_PLAN.md b/docs/NOTES_FILES_PLUGIN_PLAN.md new file mode 100644 index 0000000..d9d5e15 --- /dev/null +++ b/docs/NOTES_FILES_PLUGIN_PLAN.md @@ -0,0 +1,425 @@ +# 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 tree APIs for `space`, `case`, and `folder`. +- Plugin-owned internal storage directories: + `.verstak/plugin-data/`, `.verstak/plugin-settings/`, and + `.verstak/plugin-cache/`. +- 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/root overview notes live under `Notes/`. +- Case/project/folder scoped notes live under `/Notes/`. +- The default overview note is `/Notes/Overview.md`. + +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. + +The workspace tree can remain `space`/`case`/`folder`. Adding `note` as a +workspace node type is not part of the next milestone because it would require a +schema migration. The Notes service can index and manage Markdown files inside +canonical `Notes/` folders without changing workspace node types. + +## 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 `SomeCase/Notes/Overview.md` as a file. +- Notes sees `SomeCase/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. diff --git a/docs/PLUGIN_RUNTIME.md b/docs/PLUGIN_RUNTIME.md index e272a2c..ff0e5d2 100644 --- a/docs/PLUGIN_RUNTIME.md +++ b/docs/PLUGIN_RUNTIME.md @@ -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`) | +| `/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) @@ -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" }], @@ -171,6 +188,7 @@ foreach plugin: | Основные панели | `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 @@ -233,6 +251,176 @@ foreach plugin: 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. + +Draft app-global preferences are `defaultTextEditorProvider`, +`defaultMarkdownEditorProvider`, and `defaultNotesMarkdownEditorProvider`. +Vault-scoped and per-extension overrides are deferred. + +### 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`. +- `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`. +- `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//...` 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 | +|---|---| +| 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 @@ -265,17 +453,44 @@ window.VerstakPluginRegister('plugin.id', { ### VerstakPluginAPI -API объект передаётся в `mount()` и содержит только ограниченный набор методов: +API объект передаётся в `mount()` и содержит plugin-scoped методы текущего +bundled runtime. Это реальный runtime contract для cooperative bundled plugins, +но не sandbox/security boundary. | Свойство | Статус | Описание | |---|---|---| | `api.pluginId` | ✅ Работает | ID плагина | -| `api.capabilities.has(id)` | 🔧 Stub | Запрос capability registry (planned) | -| `api.events.publish(type, payload)` | 🔧 Stub | Публикация события (planned) | -| `api.events.subscribe(type, handler)` | 🔧 Stub | Подписка на события (planned) | -| `api.settings.read(key)` | 🔧 Stub | Чтение настроек плагина (planned) | -| `api.settings.write(key, value)` | 🔧 Stub | Запись настроек плагина (planned) | -| `api.commands.execute(id, args)` | 🔧 Stub | Выполнение команды (planned) | +| `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. ### Загрузка бандла diff --git a/frontend/e2e/helpers.js b/frontend/e2e/helpers.js new file mode 100644 index 0000000..a60ec6f --- /dev/null +++ b/frontend/e2e/helpers.js @@ -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 } + ); +} diff --git a/frontend/e2e/plugin-api-bridge.spec.js b/frontend/e2e/plugin-api-bridge.spec.js new file mode 100644 index 0000000..5e72ea3 --- /dev/null +++ b/frontend/e2e/plugin-api-bridge.spec.js @@ -0,0 +1,125 @@ +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.locator('.pt-open-workbench-notes').click(); + await expect(page.locator('.pt-workbench-result')).toHaveAttribute('data-workbench-status', 'ok'); + await expect(page.locator('.pt-workbench-result')).toContainText('Workbench: opened Notes/Overview.md with verstak.platform-test.markdown-diagnostic'); + await expect(page.locator('.pt-workbench-result')).toHaveAttribute('data-resource-context', 'notes-markdown'); + }); + + test('platform-test diagnostic provider routes text, markdown, and notes markdown contexts', async ({ page }) => { + async function openFromDiagnostics(buttonClass, path, context) { + 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(buttonClass).click(); + const result = page.locator('.pt-workbench-result'); + await expect(result).toHaveAttribute('data-workbench-status', 'ok', { timeout: 10000 }); + await expect(result).toHaveAttribute('data-resource-path', path); + await expect(result).toHaveAttribute('data-resource-mode', 'edit'); + await expect(result).toHaveAttribute('data-resource-context', context); + await expect(result).toContainText('verstak.platform-test.markdown-diagnostic'); + } + + await openFromDiagnostics('.pt-open-workbench-text', 'Docs/todo.txt', 'generic-text'); + await openFromDiagnostics('.pt-open-workbench-markdown', 'Docs/readme.md', 'generic-markdown'); + await openFromDiagnostics('.pt-open-workbench-notes', 'Notes/Overview.md', 'notes-markdown'); + }); + + 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); + }); +}); diff --git a/frontend/e2e/plugin-manager-disable-enable.spec.js b/frontend/e2e/plugin-manager-disable-enable.spec.js new file mode 100644 index 0000000..ffa77b2 --- /dev/null +++ b/frontend/e2e/plugin-manager-disable-enable.spec.js @@ -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(); + }); +}); diff --git a/frontend/e2e/plugin-manager-layout.spec.js b/frontend/e2e/plugin-manager-layout.spec.js new file mode 100644 index 0000000..443b795 --- /dev/null +++ b/frontend/e2e/plugin-manager-layout.spec.js @@ -0,0 +1,84 @@ +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(19, { 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('Alpha Case'); + + await page.locator('.wt-label').filter({ hasText: 'Beta Case' }).click(); + + await expect(selected).toHaveCount(1); + await expect(selected).toHaveText('Beta Case'); + }); +}); diff --git a/frontend/e2e/reload-updates-state.spec.js b/frontend/e2e/reload-updates-state.spec.js new file mode 100644 index 0000000..eef9f7f --- /dev/null +++ b/frontend/e2e/reload-updates-state.spec.js @@ -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 }); + }); +}); diff --git a/frontend/e2e/sidebar-opens-view.spec.js b/frontend/e2e/sidebar-opens-view.spec.js new file mode 100644 index 0000000..c3b0949 --- /dev/null +++ b/frontend/e2e/sidebar-opens-view.spec.js @@ -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'); + }); +}); diff --git a/frontend/index.html b/frontend/index.html index 6665cba..4888224 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -16,6 +16,15 @@
- + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e24fa57..c2c709e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "verstak-desktop-frontend", "version": "0.1.0", "devDependencies": { + "@playwright/test": "^1.61.0", "@sveltejs/vite-plugin-svelte": "^3.1.0", "svelte": "^4.2.0", "vite": "^5.4.0" @@ -429,6 +430,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", @@ -1022,6 +1038,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", diff --git a/frontend/package.json b/frontend/package.json index a116845..664699a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,9 +6,13 @@ "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", "svelte": "^4.2.0", "vite": "^5.4.0" diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index e4e87e9..f7b5fea 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -ef6849d398e9d32c4ae5afdea904eb49 \ No newline at end of file +43be2fbdf6ba6ca9504a7c4b0ac32ae0 \ No newline at end of file diff --git a/frontend/playwright.config.js b/frontend/playwright.config.js new file mode 100644 index 0000000..ef59c4f --- /dev/null +++ b/frontend/playwright.config.js @@ -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', + }, +}); diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 5af2804..1f27b05 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -3,7 +3,11 @@ 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 * 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: '' }; @@ -14,47 +18,81 @@ let activeViewPluginId = ''; let activeSettingsPluginId = ''; let activeSettingsPanelId = ''; + let openedResource = null; + + function flog(msg) { + App.WriteFrontendLog('App', msg); + } 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; } 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'; } function onOpenSettings(e) { + debug.log('[App] onOpenSettings:', e.detail.pluginId, e.detail.panelId); activeSettingsPluginId = e.detail.pluginId; activeSettingsPanelId = e.detail.panelId || ''; currentView = 'plugin-manager'; } + function onWorkbenchOpened(e) { + debug.log('[App] onWorkbenchOpened:', e.detail?.request?.path, e.detail?.providerId); + openedResource = e.detail; + currentView = 'workbench'; + } + function onCloseSettings() { + debug.log('[App] onCloseSettings'); activeSettingsPluginId = ''; activeSettingsPanelId = ''; } @@ -66,9 +104,10 @@ window.addEventListener('verstak:open-view', onOpenView); window.addEventListener('verstak:open-settings', onOpenSettings); window.addEventListener('verstak:close-settings', onCloseSettings); + window.addEventListener('verstak:workbench-opened', onWorkbenchOpened); } - checkVault(); + onMount(() => { checkVault(); }); {#if loading} @@ -81,9 +120,11 @@
-
+
{#if currentView === 'plugin-manager'} + {:else if currentView === 'workbench'} + {:else} {/if} @@ -98,6 +139,13 @@ 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; @@ -105,6 +153,118 @@ 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; @@ -118,14 +278,17 @@ main { display: flex; height: 100vh; + width: 100%; background: #1a1a2e; + overflow: hidden; } .content { flex: 1; + min-width: 0; + min-height: 0; display: flex; flex-direction: column; - overflow: hidden; - padding: 1.5rem; + padding: clamp(1rem, 2vw, 1.5rem); } diff --git a/frontend/src/lib/log/debug.js b/frontend/src/lib/log/debug.js new file mode 100644 index 0000000..de32608 --- /dev/null +++ b/frontend/src/lib/log/debug.js @@ -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'); +} diff --git a/frontend/src/lib/plugin-host/PluginBundleHost.svelte b/frontend/src/lib/plugin-host/PluginBundleHost.svelte index 834c30c..7289b3d 100644 --- a/frontend/src/lib/plugin-host/PluginBundleHost.svelte +++ b/frontend/src/lib/plugin-host/PluginBundleHost.svelte @@ -3,12 +3,12 @@ import * as App from '../../../wailsjs/go/api/App'; import Icon from '../ui/Icon.svelte'; - // Import the VerstakPluginAPI contract - import './VerstakPluginAPI.js'; + 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; @@ -16,6 +16,7 @@ let mountContainer = null; let currentPluginId = null; let currentComponent = null; + let currentAPI = null; $: activePluginId = pluginId || viewPluginId; $: activeComponent = componentId; @@ -33,6 +34,13 @@ }); 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]; @@ -49,6 +57,14 @@ } currentPluginId = null; currentComponent = null; + currentAPI = null; + } + + 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) { @@ -82,10 +98,11 @@ const reg = window.__VERSTAK_PLUGIN_REGISTRY__ || {}; if (!reg[pId]) { // Load the bundle JS content via backend API - const [content, err] = await App.GetPluginAssetContent(pId, info.entry); - if (err || !content) { + const assetResult = unpackBackendResult(await App.GetPluginAssetContent(pId, info.entry)); + const content = assetResult.value; + if (assetResult.error || !content) { loadState = 'error'; - errorText = 'Failed to load bundle: ' + (err || 'empty content'); + errorText = 'Failed to load bundle: ' + (assetResult.error || 'empty content'); return; } @@ -120,7 +137,8 @@ } // Create API - const api = window.VerstakPluginAPI(pId); + const api = createPluginAPI(pId); + currentAPI = api; // Mount component if (!mountContainer) { @@ -129,7 +147,7 @@ } if (mountContainer) { try { - comp.mount(mountContainer, { componentId: compId }, api); + comp.mount(mountContainer, Object.assign({ componentId: compId }, componentProps || {}), api); loadState = 'loaded'; errorText = ''; } catch (e) { @@ -161,12 +179,6 @@

Select a plugin view from the sidebar

- {:else if loadState === 'loading'} -
-
-

Loading plugin bundle...

-
- {:else if loadState === 'error'}
@@ -184,9 +196,16 @@
- {:else if loadState === 'loaded'} + {:else} + {#if loadState === 'loading'} +
+
+

Loading plugin bundle...

+
+ {/if}
.plugin-bundle-host { width: 100%; - height: 100%; min-height: 200px; display: flex; flex-direction: column; + min-width: 0; } .host-state { @@ -286,8 +305,14 @@ } .plugin-mount-container { - flex: 1; - overflow: auto; + min-width: 0; position: relative; } + + .plugin-mount-container.mount-hidden { + height: 0; + min-height: 0; + overflow: hidden; + visibility: hidden; + } diff --git a/frontend/src/lib/plugin-host/VerstakPluginAPI.js b/frontend/src/lib/plugin-host/VerstakPluginAPI.js index 43f8f10..4ba0d31 100644 --- a/frontend/src/lib/plugin-host/VerstakPluginAPI.js +++ b/frontend/src/lib/plugin-host/VerstakPluginAPI.js @@ -1,18 +1,10 @@ -// VerstakPluginAPI is the restricted API passed to plugin frontend bundles. -// Plugins do NOT get direct access to Wails bridge — only what's exposed here. -// All methods are stubs or limited implementations. +import * as App from '../../../wailsjs/go/api/App'; -(function() { - // Store registered components per plugin - window.__VERSTAK_PLUGIN_REGISTRY__ = window.__VERSTAK_PLUGIN_REGISTRY__ || {}; - - // Original register function - const origRegister = window.VerstakPluginRegister; - if (origRegister) { - // Already defined — don't override - return; - } +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); @@ -21,48 +13,291 @@ console.log('[VerstakPluginRegister] registered:', pluginId, Object.keys(bundle.components)); window.__VERSTAK_PLUGIN_REGISTRY__[pluginId] = bundle.components; }; +} - // Create the restricted API object for a plugin host context - window.VerstakPluginAPI = function(pluginId) { - return { - pluginId: pluginId, +function unpack(result) { + if (Array.isArray(result) && result.length === 2 && (typeof result[1] === 'string' || result[1] == null)) { + return [result[0], result[1] || '']; + } + return [result, '']; +} - capabilities: { - has: function(capId) { - // planned: query backend cap registry - console.log('[plugin:' + pluginId + '] capabilities.has(' + capId + ') — stub'); - return false; - } +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); }, - - events: { - publish: function(type, payload) { - console.log('[plugin:' + pluginId + '] event publish:', type, payload); - // planned: actual event bus bridge - }, - subscribe: function(type, handler) { - console.log('[plugin:' + pluginId + '] event subscribe:', type, '(stub)'); - // planned: actual event bus bridge - } + 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); + }); + } + }, - settings: { - read: function(key) { - console.log('[plugin:' + pluginId + '] settings.read(' + key + ') — stub'); - return null; - }, - write: function(key, value) { - console.log('[plugin:' + pluginId + '] settings.write(' + key + ',', value, ') — stub'); - // planned: backend storage namespace - } + 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; + }); + }); + }); + } + }, - commands: { - execute: function(cmdId, args) { - console.log('[plugin:' + pluginId + '] commands.execute(' + cmdId + ') — stub'); - // planned: command execution + 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); + }); + } + }, + + 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; diff --git a/frontend/src/lib/plugin-manager/PluginCard.svelte b/frontend/src/lib/plugin-manager/PluginCard.svelte index 2a5ba95..4943787 100644 --- a/frontend/src/lib/plugin-manager/PluginCard.svelte +++ b/frontend/src/lib/plugin-manager/PluginCard.svelte @@ -211,6 +211,7 @@ border: 1px solid #0f3460; border-radius: 8px; padding: 1rem; + min-width: 0; } .plugin-card.disabled { @@ -225,6 +226,8 @@ display: flex; align-items: center; justify-content: space-between; + gap: 0.75rem; + flex-wrap: wrap; margin-bottom: 0.5rem; } @@ -232,6 +235,12 @@ display: flex; align-items: center; gap: 0.5rem; + min-width: 0; + flex-wrap: wrap; + } + + .plugin-id strong { + overflow-wrap: anywhere; } .status-dot { @@ -268,7 +277,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; @@ -277,6 +286,7 @@ .meta-row { display: flex; gap: 0.5rem; + min-width: 0; } .label { @@ -288,6 +298,8 @@ font-family: monospace; font-size: 0.75rem; color: #a0a0b8; + min-width: 0; + overflow-wrap: anywhere; } .section { @@ -316,6 +328,8 @@ font-size: 0.75rem; font-family: monospace; color: #e0e0e0; + max-width: 100%; + overflow-wrap: anywhere; } .tag.provides { @@ -375,6 +389,7 @@ display: flex; align-items: center; gap: 0.5rem; + flex-wrap: wrap; margin-top: 0.75rem; padding-top: 0.5rem; border-top: 1px solid #0f3460; @@ -432,4 +447,19 @@ 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; + } + } diff --git a/frontend/src/lib/plugin-manager/PluginManager.svelte b/frontend/src/lib/plugin-manager/PluginManager.svelte index 3d0b337..d54ca31 100644 --- a/frontend/src/lib/plugin-manager/PluginManager.svelte +++ b/frontend/src/lib/plugin-manager/PluginManager.svelte @@ -2,8 +2,9 @@ import Icon from '../ui/Icon.svelte'; import PluginCard from './PluginCard.svelte'; import PluginBundleHost from '../plugin-host/PluginBundleHost.svelte'; - import { onMount } from 'svelte'; - import { GetPlugins, GetCapabilities, GetPermissions, GetContributions, ReloadPlugins, GetVaultStatus, GetVaultPluginState, EnablePlugin, DisablePlugin, ReadPluginSettings, WritePluginSettings, GetPluginFrontendInfo } from '../../../wailsjs/go/api/App'; + 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 = []; @@ -45,6 +46,28 @@ }, 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) { @@ -55,8 +78,14 @@ const info = await GetPluginFrontendInfo(pluginId); settingsPluginInfo = info; } catch { settingsPluginInfo = null; } - ReadPluginSettings(pluginId).then(data => { - settingsData = data || {}; + 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.`; @@ -73,18 +102,27 @@ } 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; } // 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(() => []), @@ -95,66 +133,93 @@ 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) { - // Non-critical — log but don't fail + 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: [] }; - } catch { /* non-critical */ } + 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() { + debug.log('[PluginManager] reload: START'); reloading = true; error = ''; let resultMsg = ''; try { - const [count, summary] = 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); 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'); } @@ -327,14 +392,18 @@ diff --git a/frontend/src/lib/shell/Sidebar.svelte b/frontend/src/lib/shell/Sidebar.svelte index 521d63b..0edcff9 100644 --- a/frontend/src/lib/shell/Sidebar.svelte +++ b/frontend/src/lib/shell/Sidebar.svelte @@ -1,8 +1,13 @@ + +
+ {#if openedResource?.status === 'no-provider'} +
+ {resourcePath} + no-provider +
+
+

No viewer/editor available

+

{requestMode} · {requestContext}

+
+ {:else if openedResource} +
+ {resourcePath} + {providerId} +
+
+ {#key mountKey} + + {/key} +
+ {:else} +
+

No resource opened

+
+ {/if} +
+ + diff --git a/frontend/src/lib/shell/WorkspaceTree.svelte b/frontend/src/lib/shell/WorkspaceTree.svelte index b530556..7989d7b 100644 --- a/frontend/src/lib/shell/WorkspaceTree.svelte +++ b/frontend/src/lib/shell/WorkspaceTree.svelte @@ -1,8 +1,15 @@ + +