Implement milestone 6b workbench routing skeleton

This commit is contained in:
mirivlad 2026-06-19 07:51:57 +08:00
parent a100f5a441
commit 6ed6df311a
53 changed files with 7592 additions and 335 deletions

View File

@ -91,3 +91,89 @@ verstak-desktop/
wails.json
...
```
## Verification language policy
**Do not say "checked", "verified", "works", or "all good" unless a concrete
verification command or user scenario was executed.**
Use exact labels:
- Build checked
- Unit tests checked
- Backend behavior checked
- Frontend E2E checked
- Real desktop GUI checked
- Not checked / requires manual verification
If GUI behavior was not clicked and asserted, report:
"GUI behavior was not verified."
### GUI testing
Frontend E2E tests are in `frontend/e2e/`. Run with `npm run test:e2e`.
These tests use Playwright + mocked Wails bindings. They test Svelte component
logic and user interactions in a real Chromium browser.
**Limitations:** Playwright tests do NOT test the real WebKitGTK/Wails native
shell. For real desktop GUI verification, a separate AT-SPI/xdotool layer is
needed (not yet implemented).
See `docs/GUI_TESTING.md` for details.
## Debug logging
**Always use debug logging when investigating issues. Never rely on "it should work" — look at the logs.**
### Backend debug
Enable with `--debug` flag:
```bash
./verstak-desktop --debug
```
Logs go to: `~/.local/share/verstak/debug/verstak-YYYY-MM-DD-HHMMSS.log`
View in real-time:
```bash
tail -f ~/.local/share/verstak/debug/verstak-*.log
```
What's logged (when `--debug` is active):
- Plugin discovery: dirs scanned, each plugin found (id, name, version, source, root)
- Plugin lifecycle: capability registration, contribution registration, status transitions
- API calls: GetPlugins, GetContributions, GetCapabilities, ReloadPlugins, EnablePlugin, DisablePlugin
- Vault operations: open/close/status
### Frontend debug
Enable via **either**:
1. URL query param: `?debug` (not practical in Wails, use #2)
2. In browser console: `localStorage.setItem('verstak-debug', 'true')` then reload
Logs go to:
- Browser console (with `[debug]` prefix)
- localStorage buffer: `localStorage.getItem('verstak-debug-log')` (last 1000 entries)
Export frontend log from console:
```javascript
copy(JSON.parse(localStorage.getItem('verstak-debug-log')))
// or
window.__verstakDebug.exportLog()
```
What's logged:
- App startup: checkVault, GetAppSettings, GetVaultStatus
- Navigation: onNav, onOpenView, onOpenSettings, onCloseSettings
- PluginManager: loadAll (start/end, plugin count, each plugin status), reload, enable/disable
- Sidebar: onMount (plugins loaded, sidebar items count), handleNav, handleSidebarItem
### Debug workflow
1. User reports issue
2. Restart app with `--debug` and reproduce
3. Run `tail -f ~/.local/share/verstak/debug/verstak-*.log` and share output
4. For frontend issues: enable frontend debug view `exportLog()` output
5. Analyze logs, identify root cause, fix
6. Verify fix by asking user to reproduce again with debug on
**Never skip step 2-4. Always look at real logs before proposing fixes.**

View File

@ -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 |
| `<binary-dir>/plugins/` | Packaged plugins shipped next to the desktop executable |
| `~/.config/verstak/plugins/` | User-installed plugins |
| `./plugins/` | Bundled/dev plugins (project-local) |
The resolver normalizes paths and removes duplicates before scanning. Missing
directories are ignored by discovery.
Discovery scans all resolved directories in order. If two plugin packages declare
the same `id`, the first package wins and later duplicates are skipped. The
warning includes both package paths, so during development check the log if an
updated plugin appears to be ignored.
## Bundled Plugin API During Development
Frontend bundles are mounted with a plugin-scoped API created by
`createPluginAPI(pluginId)`. The current API supports:
- `settings.read/write/writeAll`
- `capabilities.list/get/has`
- `commands.register/execute` for handlers declared in `contributes.commands`
- `events.publish/subscribe` using the bundled frontend event bus
- `files.list/metadata/readText/writeText/createFolder/move/trash` for
canonical vault-relative slash paths guarded by `files.read`, `files.write`,
and `files.delete`. Backslashes, Windows absolute paths, UNC paths,
traversal, `.verstak` variants, and symlink read/write/move/trash operations
are rejected. Text read/write is UTF-8 only and limited to 2 MB for reads.
- `workbench.openResource/editResource` for routing vault resources to
contributed `openProviders`. Plugins must declare `workbench.open`; this is a
policy/contract check. Files and Notes plugins call this API and do not import
a concrete editor plugin.
Editor/viewer plugins contribute providers with `contributes.openProviders`.
Workbench selects by resource kind, extension/mime, context (`generic-text`,
`generic-markdown`, `notes-markdown`), user preference, priority, then
deterministic `pluginId/providerId` tie-break. If nothing matches, Workbench
shows `no-provider` fallback instead of a core editor.
This is a cooperative contract, not a sandbox. Bundled plugins run in the same JS
context as the desktop frontend; real isolation is deferred to the sidecar/sandbox
milestone.
## Important Rules

143
docs/GUI_TESTING.md Normal file
View File

@ -0,0 +1,143 @@
# GUI Testing
## Overview
Verstak Desktop uses **Playwright** for frontend E2E tests that run in a real
Chromium browser with mocked Wails bindings. This tests the Svelte component
logic, user interactions, and UI state transitions — without needing the actual
Wails desktop shell.
## What is tested
### Frontend E2E (Playwright)
Located in `frontend/e2e/`, run via `npm run test:e2e`.
These tests:
- Launch a Vite dev server with mock Wails bindings
- Open the app in a real Chromium browser via Playwright
- Simulate user clicks, wait for UI transitions, assert DOM state
- Collect console errors and page errors on failure
- Capture screenshots on failure
### Test suites
| File | Suite | Tests | Status |
|------|-------|-------|--------|
| `plugin-manager-disable-enable.spec.js` | A: Disable/Enable refresh | 4 | 3 pass, 1 fail* |
| `sidebar-opens-view.spec.js` | B: Sidebar → view routing | 3 | 3 pass |
| `reload-updates-state.spec.js` | C: Reload updates UI | 3 | 2 pass, 1 fail* |
\* Failing tests document **known bugs** (see below).
## Known bugs detected by tests
### Bug M5-1: Sidebar does not update when plugin state changes
**Symptom:** After disabling a plugin in Plugin Manager, the sidebar item for
that plugin remains visible. After re-enabling, it stays visible (doesn't
disappear then reappear — it was never gone).
**Root cause:** `Sidebar.svelte` loads plugin/contribution data once in
`onMount` and stores it in local `sidebarItems`. When `PluginManager`
disables/enables a plugin and calls `ReloadPlugins`, the `PluginManager`
component re-fetches data, but `Sidebar` does not react to the change — it
still holds the stale list.
**Affected tests:**
- `A: Disable plugin: button changes to Enable, sidebar item disappears`
- `A: Disable → Enable full flow in sequence`
- `C: Reload after mock state change reflects new plugin status`
**Fix needed:** Sidebar must either:
1. Re-fetch contributions when it receives a custom event (e.g.
`verstak:plugins-reloaded`), or
2. Read plugin state reactively from a shared store that both
PluginManager and Sidebar subscribe to.
## What is NOT tested
### Real desktop GUI (WebKitGTK + Wails native shell)
The Playwright tests run the frontend in a **standard Chromium browser** with
mocked Wails bindings. They do **not** test:
- Actual WebKitGTK rendering (Wails uses WebKitGTK, not Chromium)
- Native window management (minimize, maximize, resize)
- Native file dialogs (SelectDirectory, SelectVaultForOpen)
- Clipboard integration
- System tray / menu bar
- Plugin frontend bundle loading from real filesystem
- Wails event system (window.runtime.EventsOn/Emit)
For real Wails smoke tests, a separate layer is needed using:
- **AT-SPI2** (Linux accessibility tree inspection)
- **xdotool** / **ydotool** (input simulation)
- **scrot** / **import** (screenshot capture)
## Running tests
```bash
cd frontend
# Run all E2E tests (headless)
npm run test:e2e
# Run with Playwright UI (interactive)
npm run test:e2e:ui
# Run in headed browser (visible)
npm run test:e2e:headed
```
## Test infrastructure
### Mock bridge (`src/lib/test/wails-mock.js`)
Replaces `window['go']['api']['App']` with in-memory mock implementations of
all Wails backend methods. Provides:
- Mutable plugin state (enable/disable/status)
- Mutable vault state
- Mutable contributions (views, commands, sidebar items, settings panels)
- Test helpers via `window.__wailsMock`:
- `reset()` — reset all state to defaults
- `setPluginStatus(id, status, enabled)` — change plugin state
- `getPluginState(id)` — read current state
- `setVaultStatus(status)` — change vault state
### Test harness (`index.html`)
The same `index.html` is used for both production and test. It detects whether
the Wails runtime (`window['go']`) is present. If not (i.e. running in a plain
browser), it loads the mock bridge before the Svelte app.
### Playwright config (`playwright.config.js`)
- Dev server: `vite --mode test --port 5174`
- Browser: Chromium headless
- Timeouts: 30s test, 10s expect
- Workers: 1 (sequential)
- Screenshots: on failure
- Traces: on first retry
- Results: `e2e-results/test-results.json`
## Adding new tests
1. Create `e2e/your-test.spec.js`
2. Import helpers from `./helpers.js`
3. Use `test.beforeEach` to reset mock state and navigate to `/`
4. Use `test.afterEach` to assert no console errors
5. Write scenarios as user actions + assertions
6. Run with `npm run test:e2e`
### Selector conventions
- Plugin cards: `.plugin-card` filtered by text
- Buttons: `.btn-disable`, `.btn-enable`, `.btn-settings`, `.reload-btn`
- Sidebar items: `.sidebar .plugin-item`
- View container: `.view-container`
- View header: `.view-header h2`
- Status badges: `.status-badge`
- Toast: `.toast`

View File

@ -0,0 +1,403 @@
# Milestone 6a Files Core Service Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use
> `superpowers:subagent-driven-development` or `superpowers:executing-plans` to
> implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for
> tracking.
**Goal:** Add a safe backend Files core service and plugin bridge for
vault-relative text file operations.
**Architecture:** Files Core is a backend service under `internal/core/files`
that accepts vault-relative paths, enforces reserved path policy, and performs
atomic text writes. The Wails API exposes plugin-scoped methods guarded by plugin
state and `files.*` permissions; SDK types describe the bridge shape.
**Tech Stack:** Go backend services/tests, Wails-bound API methods, TypeScript
SDK type definitions, existing Playwright/Vitest checks.
---
## Implementation Status
Milestone 6a is implemented.
Actual backend package:
- `internal/core/files/types.go`
- `internal/core/files/path_policy.go`
- `internal/core/files/service.go`
- `internal/core/files/*_test.go`
Actual plugin-scoped Wails methods:
```go
func (a *App) ListVaultFiles(pluginID string, relativeDir string) ([]files.FileEntry, string)
func (a *App) GetVaultFileMetadata(pluginID string, relativePath string) (files.FileMetadata, string)
func (a *App) ReadVaultTextFile(pluginID string, relativePath string) (string, string)
func (a *App) WriteVaultTextFile(pluginID string, relativePath string, content string, options files.WriteOptions) string
func (a *App) CreateVaultFolder(pluginID string, relativePath string) string
func (a *App) MoveVaultPath(pluginID string, fromRelativePath string, toRelativePath string, options files.MoveOptions) string
func (a *App) TrashVaultPath(pluginID string, relativePath string) (files.TrashResult, string)
```
Actual bundled frontend API:
- `api.files.list(relativeDir)`
- `api.files.metadata(relativePath)`
- `api.files.readText(relativePath)`
- `api.files.writeText(relativePath, content, options)`
- `api.files.createFolder(relativePath)`
- `api.files.move(fromRelativePath, toRelativePath, options)`
- `api.files.trash(relativePath)`
Implemented limits:
- canonical vault-relative slash paths only;
- backslashes, POSIX absolute paths, Windows drive paths, UNC paths, traversal,
null bytes, and empty file paths are rejected;
- `.verstak/` is reserved case-insensitively and hidden from public Files API;
- metadata may report symlinks, but list-through-symlink and
read/write/move/trash through symlink are forbidden;
- text read/write only, with `readText` limited to UTF-8 files up to 2 MB;
- trash uses `.verstak/trash/files/<trashId>/...` with restore metadata, but
restore itself is deferred;
- binary streaming, watcher, external editor, Files UI, Notes service, sidecar,
sandbox/security isolation deferred.
---
## Scope
Implement:
- Backend Files service.
- Safe vault-relative path handling.
- Reserved `.verstak/` policy.
- List files.
- Read/write text files.
- Create folder.
- Move path.
- Trash path.
- Atomic writes.
- Backend tests.
- SDK bridge shape draft.
Do not implement:
- Full Notes plugin.
- Notes UI.
- Sync.
- Watcher.
- Binary streaming.
- External editor integration.
- Sidecar/security isolation.
## Canonical Policy
All public Files API methods use canonical vault-relative slash paths.
Rejected inputs:
- absolute paths;
- backslashes;
- Windows drive paths and UNC/network paths;
- paths containing `..` after normalization;
- null bytes;
- empty paths where a file path is required;
- access to `.verstak/` through the public plugin Files API, including
`.Verstak` case variants.
Delete behavior:
- `TrashVaultPath` moves files/folders into `.verstak/trash`.
- Trash metadata includes `originalPath`, `deletedAt`, `originalType`,
`trashId`, and `basename`.
- Permanent delete is out of scope.
- Restore is out of scope.
Write behavior:
- Text writes use a temporary file in the target directory and rename into place.
- Existing files are overwritten only when the method explicitly allows overwrite.
- Parent directory must exist unless the method explicitly creates it.
Binary behavior:
- Binary files can appear in list/metadata results.
- Binary read/write streaming is out of scope.
## Public Backend Shape
Implemented plugin-scoped Wails methods:
```go
func (a *App) ListVaultFiles(pluginID string, relativeDir string) ([]files.FileEntry, string)
func (a *App) GetVaultFileMetadata(pluginID string, relativePath string) (files.FileMetadata, string)
func (a *App) ReadVaultTextFile(pluginID string, relativePath string) (string, string)
func (a *App) WriteVaultTextFile(pluginID string, relativePath string, content string, options files.WriteOptions) string
func (a *App) CreateVaultFolder(pluginID string, relativePath string) string
func (a *App) MoveVaultPath(pluginID string, fromRelativePath string, toRelativePath string, options files.MoveOptions) string
func (a *App) TrashVaultPath(pluginID string, relativePath string) (files.TrashResult, string)
```
Permission mapping:
- `ListVaultFiles`, `GetVaultFileMetadata`, `ReadVaultTextFile`: `files.read`.
- `WriteVaultTextFile`, `CreateVaultFolder`, `MoveVaultPath`: `files.write`.
- `TrashVaultPath`: `files.delete`.
## Data Types
Create `internal/core/files/types.go`:
```go
package files
type EntryKind string
const (
KindFile EntryKind = "file"
KindDirectory EntryKind = "directory"
)
type Entry struct {
Name string `json:"name"`
Path string `json:"path"`
Kind EntryKind `json:"kind"`
Size int64 `json:"size"`
ModifiedAt string `json:"modifiedAt"`
IsText bool `json:"isText"`
IsBinary bool `json:"isBinary"`
IsHidden bool `json:"isHidden"`
IsReserved bool `json:"isReserved"`
}
```
## Task 1: Path Policy
**Files:**
- Create: `internal/core/files/path.go`
- Create: `internal/core/files/path_test.go`
- [ ] Add `NormalizeVaultRelativePath(relative string) (string, error)`.
- [ ] Reject absolute paths, null bytes, `..`, and empty file paths.
- [ ] Preserve path case, including canonical `Notes`.
- [ ] Add `IsReservedPath(relative string) bool` returning true for `.verstak`
and `.verstak/...`.
- [ ] Add tests:
`TestNormalizeRejectsAbsolutePath`,
`TestNormalizeRejectsTraversal`,
`TestNormalizeRejectsNullByte`,
`TestNormalizePreservesCase`,
`TestReservedPathPolicy`.
- [ ] Run:
```bash
go test ./internal/core/files
```
Expected: all `internal/core/files` tests pass.
## Task 2: Files Service
**Files:**
- Create: `internal/core/files/service.go`
- Create/modify: `internal/core/files/service_test.go`
- [ ] Define `Service` with a vault dependency that can return the current vault
root and status.
- [ ] Implement `List(relativeDir string) ([]Entry, error)`.
- [ ] Implement `Metadata(relativePath string) (Entry, error)`.
- [ ] Implement `ReadText(relativePath string) (string, error)`.
- [ ] Implement `WriteText(relativePath, content string, overwrite bool) (Entry, error)`.
- [ ] Implement `Mkdir(relativePath string) (Entry, error)`.
- [ ] Implement `Move(fromRelativePath, toRelativePath string, overwrite bool) (Entry, error)`.
- [ ] Implement `Trash(relativePath string) (Entry, error)`.
- [ ] Use the shared path policy for every public method.
- [ ] Block `.verstak` paths in every public method.
- [ ] Add tests for closed vault, list, metadata, text read/write, mkdir, move,
trash, overwrite false conflict, overwrite true replace, and reserved path
rejection.
- [ ] Run:
```bash
go test ./internal/core/files
```
Expected: all `internal/core/files` tests pass.
## Task 3: Atomic Writes
**Files:**
- Modify: `internal/core/files/service.go`
- Modify: `internal/core/files/service_test.go`
- [ ] Write text content to a temp file in the target directory.
- [ ] Rename the temp file into the final path only after successful write.
- [ ] Remove temp file on write failure.
- [ ] Add test `TestWriteTextIsAtomicOnFailure` using a controlled failing path
or permission-denied directory.
- [ ] Add test `TestWriteTextDoesNotLeaveTempFile`.
- [ ] Run:
```bash
go test ./internal/core/files
```
Expected: all `internal/core/files` tests pass.
## Task 4: Permissions And Capabilities
**Files:**
- Modify: `internal/core/permissions/registry.go`
- Modify: `main.go`
- Modify: `internal/api/app_test.go`
- [ ] Register permissions: `files.read`, `files.write`, `files.delete`.
- [ ] Register core capability `verstak/core/files/v1` when vault services are
initialized.
- [ ] Add API guard tests proving each Files bridge method rejects plugins that
are missing the required permission.
- [ ] Run:
```bash
go test ./internal/core/permissions ./internal/api
```
Expected: permission registry and API tests pass.
## Task 5: Wails API Bridge
**Files:**
- Modify: `internal/api/app.go`
- Modify: `internal/api/app_test.go`
- Modify after Wails generation or by hand if generation is unavailable:
`frontend/wailsjs/go/api/App.d.ts`
- Modify after Wails generation or by hand if generation is unavailable:
`frontend/wailsjs/go/api/App.js`
- [ ] Add `files.Service` to `api.App`.
- [ ] Add plugin-scoped methods listed in "Public Backend Shape".
- [ ] Use `requirePluginAccess(pluginID, permission)` for every method.
- [ ] Return readable errors for closed vault, missing file, reserved path,
conflict, and missing permission.
- [ ] Add tests for successful read/write/list/mkdir/move/trash through `App`.
- [ ] Run:
```bash
go test ./internal/api
```
Expected: API tests pass.
## Task 6: Frontend Plugin API Draft
**Files:**
- Modify: `frontend/src/lib/plugin-host/VerstakPluginAPI.js`
- Modify: `frontend/src/lib/test/wails-mock.js`
- Add/modify focused frontend tests under `frontend/e2e/` only if existing test
coverage cannot validate the shape outside Playwright.
- [ ] Add `api.files.list(relativeDir)`.
- [ ] Add `api.files.metadata(relativePath)`.
- [ ] Add `api.files.readText(relativePath)`.
- [ ] Add `api.files.writeText(relativePath, content, options)`.
- [ ] Add `api.files.mkdir(relativePath)`.
- [ ] Add `api.files.move(fromRelativePath, toRelativePath, options)`.
- [ ] Add `api.files.trash(relativePath)`.
- [ ] Keep all calls plugin-scoped; plugin code must not pass `pluginId`.
- [ ] Mock readable errors for reserved path and missing permission.
- [ ] Run:
```bash
cd frontend
npm run build
```
Expected: frontend build passes.
## Task 7: SDK Bridge Shape Draft
**Files:**
- Modify: `../verstak-sdk/src/plugin-api.ts`
- Modify: `../verstak-sdk/src/test-utils.ts`
- Modify: `../verstak-sdk/src/plugin-api.test.ts`
- [ ] Add `files` API TypeScript interfaces matching the frontend API names.
- [ ] Add mock Files API methods in `createMockPluginAPI`.
- [ ] Add contract tests for API shape, text write/read, reserved path error, and
trash result shape.
- [ ] Run:
```bash
cd ../verstak-sdk
./scripts/check.sh
./scripts/build.sh
./scripts/test.sh
```
Expected: SDK check, build, and tests pass.
## Task 8: Documentation
**Files:**
- Modify: `docs/PLUGIN_RUNTIME.md`
- Modify: `docs/NOTES_FILES_PLUGIN_PLAN.md`
- [ ] Document Files Core API as functional for Milestone 6a.
- [ ] Keep Notes API documented as planned until Milestone 6b or later.
- [ ] Document `.verstak` reserved path policy.
- [ ] Document slash-only path policy, Windows/UNC rejection, and symlink policy.
- [ ] Document text-only write support and deferred binary streaming.
## Task 9: Final Verification
- [ ] Run desktop backend tests:
```bash
cd verstak-desktop
go test ./...
```
- [ ] Run desktop frontend build:
```bash
cd verstak-desktop/frontend
npm run build
```
- [ ] Run desktop e2e:
```bash
cd verstak-desktop/frontend
npm run test:e2e -- --reporter=list
```
- [ ] Run official plugins checks:
```bash
cd verstak-official-plugins
./scripts/check.sh
./scripts/build.sh
```
- [ ] Run SDK checks:
```bash
cd verstak-sdk
./scripts/check.sh
./scripts/build.sh
./scripts/test.sh
```
Expected: all commands exit 0. Existing Svelte unused CSS warnings are acceptable
only if they remain warnings and do not fail the build.

135
docs/MILESTONE_6B_PLAN.md Normal file
View File

@ -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.

View File

@ -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/<pluginId>`, `.verstak/plugin-settings/<pluginId>`, and
`.verstak/plugin-cache/<pluginId>`.
- Contribution registry entries for `fileActions`, `noteActions`,
`contextMenuEntries`, `searchProviders`, `activityProviders`, and
`statusBarItems`.
Not available yet:
- Notes plugin/API as a semantic view over Markdown files.
- Files UI plugin.
- Editor/viewer plugin.
- Open/edit resource routing and provider selection.
- UI hosts for file actions, note actions, context menus, search providers,
activity providers, or status bar items.
- Watcher/indexer for external filesystem changes.
- Real plugin isolation. Current permission checks are contract/policy checks,
not a security boundary for bundled frontend JavaScript.
## Canonical Notes Model
Notes are ordinary human-readable Markdown files inside the vault. They must be
visible and editable outside Verstak.
Canonical rules:
- Notes are `.md` files, not opaque records.
- Canonical folder name is exactly `Notes`.
- Do not create lowercase `notes`.
- Do not store user notes in `.verstak/notes/`.
- Do not store user notes in `.verstak/plugin-data/verstak.notes/`.
- Do not use UUID-only filenames for notes.
- The note title is the source of truth.
- The filename is a normalized projection from the title.
- `RenameNote` must update both the title and the `.md` filename.
Canonical scoped paths:
- Workspace/root overview notes live under `Notes/`.
- Case/project/folder scoped notes live under `<case-or-parent>/Notes/`.
- The default overview note is `<case-or-parent>/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.

View File

@ -6,19 +6,34 @@
### Discovery Directories
Plugins ищутся в двух директориях (порядок приоритета):
Plugins ищутся через единый resolver `internal/core/plugin.ResolveDiscoveryDirs`.
Порядок приоритета:
| Путь | Назначение | Коммитится |
|---|---|---|
| `VERSTAK_PLUGIN_DIR` | Override для тестов/dev; можно передать несколько путей через OS path separator | Нет |
| `./plugins/` | Dev plugins относительно текущей рабочей директории/repo | Нет (`.gitignore`) |
| `<binary-dir>/plugins/` | Packaged plugins рядом с desktop binary | Зависит от дистрибутива |
| `~/.config/verstak/plugins/` | User-installed plugins | Нет (user home) |
| `./plugins/` | Bundled / dev plugins | Нет (`.gitignore`) |
Resolver нормализует пути, удаляет дубликаты и передает discovery только канонический
список директорий. Отсутствующие директории просто пропускаются на этапе scanning.
Discovery сканирует **все** resolved директории в указанном порядке. Если один и тот
же `plugin.id` найден несколько раз, применяется правило **first plugin wins**:
первый найденный plugin загружается, последующие plugins с тем же id пропускаются.
Конфликт логируется и возвращается как discovery warning с двумя путями: путь
пропущенного duplicate и путь уже загруженного winner.
### ./plugins/ как Dev/Install Target
Директория `./plugins/` в корне `verstak-desktop` используется как:
Директория `./plugins/` от текущей рабочей директории используется как:
- **Dev target**`install-dev-plugins.sh` коприрует сюда собранные пакеты из `verstak-official-plugins/dist/`.
- **Bundled plugins** — при дистрибутиве core может поставлять плагины здесь.
- **Local override** — при запуске desktop из repo позволяет быстро проверять packaged bundles.
В packaged-сборке bundled plugins должны лежать в `plugins/` рядом с executable.
Для тестов и локальных сценариев можно задать `VERSTAK_PLUGIN_DIR=/path/to/plugins`.
Директория **не коммитится**. Каждый разработчик устанавливает плагины через `install-dev-plugins.sh`.
@ -83,6 +98,8 @@ coreCaps := []string{
"verstak/core/contribution-registry/v1",
"verstak/core/permissions/v1",
"verstak/core/events/v1",
"verstak/core/files/v1",
"verstak/core/workbench/v1",
}
capRegistry.Register("verstak-desktop", coreCaps)
@ -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<unsubscribe>`.
- `commands.execute(commandId, args)` — backend сначала проверяет plugin status,
permission `commands.register` и что command объявлен в `contributes.commands`
именно этим plugin. Затем frontend registry вызывает зарегистрированный handler.
- Если command объявлен в manifest, но handler не зарегистрирован, API возвращает
понятную ошибку `declared-but-unhandled`.
- Handler registry очищается при component unmount, reload/disable flow и
`api.dispose()`.
`events`
- `events.subscribe(eventName, handler)` — frontend-local subscription с backend
validation permission `events.subscribe`. Возвращает `Promise<unsubscribe>`.
- `events.publish(eventName, payload)` — backend проверяет `events.publish`, затем
событие dispatch'ится в bundled frontend event bus.
- Handler получает envelope `{ name, pluginId, payload, timestamp }`.
- Subscriptions очищаются при component unmount, reload/disable flow и
`api.dispose()`.
`files`
- `files.list(relativeDir)` — list directory using a vault-relative path.
- `files.metadata(relativePath)` — returns file/folder/symlink metadata.
- `files.readText(relativePath)` — reads a UTF-8 regular file, with a size limit.
- `files.writeText(relativePath, content, options)` — atomically writes text via
temp-file-and-rename. `options.createIfMissing` and `options.overwrite`
control conflicts.
- `files.createFolder(relativePath)` — creates one folder when the parent exists.
- `files.move(from, to, options)` — moves a file or folder; rejects moving a
folder into itself and conflicts unless `options.overwrite` is true.
- `files.trash(relativePath)` — moves a file/folder into internal
`.verstak/trash/files/<trashId>/...` and returns trash metadata.
- Backend requires plugin exists, enabled, status `loaded`/`degraded`, open
vault, and `files.read`, `files.write`, or `files.delete`.
- All paths are canonical vault-relative slash paths. Backslashes, POSIX
absolute paths, Windows drive paths, UNC/network paths, `..`, null bytes,
symlink traversal, and public access to `.verstak/` are rejected.
- `.verstak` is reserved case-insensitively: `.verstak`, `.Verstak`, and any
first path segment with that spelling are internal-only.
- `files.metadata` may report a final symlink as `type: "symlink"`, but
`files.list` through a symlink directory and all read/write/move/trash
operations through symlinks are forbidden in Milestone 6a.
- Files API is text-only for read/write in Milestone 6a. `readText` is limited
to UTF-8 regular files up to 2 MB. Binary streaming, watcher, restore,
external editor integration, and Files UI plugin are deferred.
`dispose`
- `dispose()` вызывается host'ом при cleanup. Plugin code обычно не вызывает его
напрямую. Он удаляет зарегистрированные command handlers и event subscriptions.
### Runtime boundaries
| Layer | Current status |
|---|---|
| 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.
### Загрузка бандла

54
frontend/e2e/helpers.js Normal file
View File

@ -0,0 +1,54 @@
/**
* Shared helpers for Verstak E2E tests.
*/
import { expect } from '@playwright/test';
/** Wait for the app to finish loading (loading screen disappears) */
export async function waitForAppReady(page) {
// App shows "Loading Verstak..." initially, then renders the main layout
await page.waitForSelector('main', { state: 'visible', timeout: 15000 });
// Wait a bit for all async data to load
await page.waitForTimeout(1000);
}
/** Collect all console errors since last reset */
export function setupConsoleCollector(page) {
const errors = [];
page.on('console', (msg) => {
if (msg.type() === 'error') {
errors.push(msg.text());
}
});
page.on('pageerror', (err) => {
errors.push(err.message);
});
return {
getErrors: () => errors,
assertNoErrors: () => {
if (errors.length > 0) {
throw new Error(`Console errors detected:\n${errors.join('\n')}`);
}
},
};
}
/** Reset mock state before each test */
export async function resetMockState(page) {
await page.evaluate(() => {
if (window.__wailsMock) {
window.__wailsMock.reset();
}
});
}
/** Set plugin status in mock */
export async function setPluginStatus(page, pluginId, status, enabled) {
await page.evaluate(
({ id, st, en }) => {
if (window.__wailsMock) {
window.__wailsMock.setPluginStatus(id, st, en);
}
},
{ id: pluginId, st: status, en: enabled }
);
}

View File

@ -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);
});
});

View File

@ -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();
});
});

View File

@ -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');
});
});

View File

@ -0,0 +1,101 @@
/**
* Acceptance Test C: Reload updates UI state
*
* Scenario:
* 1. Change mocked plugin state (e.g. disable a plugin in mock)
* 2. Click Reload button
* 3. Verify UI reflects the updated state
*/
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState, setPluginStatus } from './helpers.js';
test.describe('C: Reload updates UI state', () => {
let consoleCollector;
test.beforeEach(async ({ page }) => {
consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
});
test.afterEach(async () => {
consoleCollector.assertNoErrors();
});
test('Reload after mock state change reflects new plugin status', async ({ page }) => {
const pluginCard = page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' });
// Initial state: loaded/enabled
await expect(pluginCard.locator('.status-badge')).toHaveText('loaded');
await expect(pluginCard.locator('button.btn-disable')).toBeVisible();
// Change mock state to disabled (simulating external state change)
await setPluginStatus(page, 'verstak.platform-test', 'disabled', false);
await page.waitForTimeout(200);
// Click Reload button in Plugin Manager header
const reloadBtn = page.locator('button.reload-btn');
await expect(reloadBtn).toBeVisible();
await reloadBtn.click();
// Wait for reload to complete and UI to update
await page.waitForTimeout(1000);
// After reload: status should reflect the disabled state
await expect(pluginCard.locator('.status-badge')).toHaveText('disabled', { timeout: 10000 });
// Enable button should appear (since plugin is now disabled)
await expect(pluginCard.locator('button.btn-enable')).toBeVisible({ timeout: 10000 });
// Sidebar item should be gone (disabled plugins are filtered from sidebar)
const sidebarItem = page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' });
await expect(sidebarItem).not.toBeVisible();
});
test('Reload restores plugin after re-enabling in mock', async ({ page }) => {
const pluginCard = page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' });
// Disable in mock, reload
await setPluginStatus(page, 'verstak.platform-test', 'disabled', false);
await page.waitForTimeout(200);
await page.locator('button.reload-btn').click();
await page.waitForTimeout(1000);
// Verify disabled
await expect(pluginCard.locator('.status-badge')).toHaveText('disabled', { timeout: 10000 });
await expect(pluginCard.locator('button.btn-enable')).toBeVisible();
// Re-enable in mock
await setPluginStatus(page, 'verstak.platform-test', 'loaded', true);
await page.waitForTimeout(200);
// Reload again
await page.locator('button.reload-btn').click();
await page.waitForTimeout(1000);
// After reload: should be loaded again
await expect(pluginCard.locator('.status-badge')).toHaveText('loaded', { timeout: 10000 });
await expect(pluginCard.locator('button.btn-disable')).toBeVisible();
// Sidebar item should return
const sidebarItem = page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' });
await expect(sidebarItem).toBeVisible();
});
test('Reload button is not disabled during normal operation', async ({ page }) => {
const reloadBtn = page.locator('button.reload-btn');
await expect(reloadBtn).toBeVisible();
await expect(reloadBtn).not.toBeDisabled();
});
test('Reload handles raw Wails count result without falling into error state', async ({ page }) => {
await page.evaluate(() => window.__wailsMock.setReloadResponseMode('raw-count'));
const reloadBtn = page.locator('button.reload-btn');
await reloadBtn.click();
await expect(page.locator('.error-state')).toHaveCount(0);
await expect(page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' })).toBeVisible({ timeout: 10000 });
});
});

View File

@ -0,0 +1,75 @@
/**
* Acceptance Test B: Sidebar opens plugin view by item.view, not item.id
*
* Data:
* - sidebar item id = verstak.platform-test.sidebar
* - sidebar item view = verstak.platform-test.diagnostics
*
* Scenario:
* 1. Click sidebar item "Platform Test"
* 2. Verify diagnostics view is opened (verstak.platform-test.diagnostics)
* 3. Verify NOT opened empty container by sidebar id
*/
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
test.describe('B: Sidebar opens plugin view by item.view', () => {
let consoleCollector;
test.beforeEach(async ({ page }) => {
consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
});
test.afterEach(async () => {
consoleCollector.assertNoErrors();
});
test('Sidebar item exists with correct label', async ({ page }) => {
const sidebarItem = page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' });
await expect(sidebarItem).toBeVisible();
});
test('Click sidebar item opens diagnostics view by view ID, not sidebar ID', async ({ page }) => {
const sidebarItem = page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' });
await expect(sidebarItem).toBeVisible();
// Click the sidebar item
await sidebarItem.click();
await page.waitForTimeout(500);
// View container should be visible
const viewContainer = page.locator('.view-container');
await expect(viewContainer).toBeVisible();
// The view header should show "Platform Diagnostics" (from view contribution title)
// This proves the view was opened by item.view = "verstak.platform-test.diagnostics"
// NOT by item.id = "verstak.platform-test.sidebar"
const viewHeader = viewContainer.locator('.view-header h2');
await expect(viewHeader).toHaveText('Platform Diagnostics', { timeout: 10000 });
// The view should NOT show "View ... not found" error
// (which would happen if it tried to open by sidebar item id)
await expect(viewContainer).not.toHaveText(/not found/);
// The view should NOT show an empty container message
const emptyView = viewContainer.locator('.empty');
await expect(emptyView).not.toBeVisible();
});
test('View header shows correct title from view contribution', async ({ page }) => {
const sidebarItem = page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' });
await sidebarItem.click();
await page.waitForTimeout(500);
// Verify the view title comes from the view contribution (item.view)
// NOT from the sidebar item (item.id)
const viewHeader = page.locator('.view-container .view-header h2');
await expect(viewHeader).toHaveText('Platform Diagnostics', { timeout: 10000 });
// Should NOT show sidebar item id as view title
await expect(viewHeader).not.toHaveText('verstak.platform-test.sidebar');
});
});

View File

@ -16,6 +16,15 @@
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
<script type="module">
// In test mode (no Wails runtime), load mock bridge first
if (!window['go'] || !window['go']['api']) {
import('/src/lib/test/wails-mock.js').then(function() {
import('/src/main.js');
});
} else {
import('/src/main.js');
}
</script>
</body>
</html>

View File

@ -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",

View File

@ -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"

View File

@ -1 +1 @@
ef6849d398e9d32c4ae5afdea904eb49
43be2fbdf6ba6ca9504a7c4b0ac32ae0

View File

@ -0,0 +1,59 @@
import { defineConfig, devices } from '@playwright/test';
import { fileURLToPath } from 'url';
import { dirname, resolve } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const FRONTEND_PORT = 5174;
const FRONTEND_URL = `http://localhost:${FRONTEND_PORT}`;
const LOOPBACK_NO_PROXY = 'localhost,127.0.0.1,::1';
process.env.NO_PROXY = process.env.NO_PROXY
? `${process.env.NO_PROXY},${LOOPBACK_NO_PROXY}`
: LOOPBACK_NO_PROXY;
process.env.no_proxy = process.env.no_proxy
? `${process.env.no_proxy},${LOOPBACK_NO_PROXY}`
: LOOPBACK_NO_PROXY;
export default defineConfig({
testDir: resolve(__dirname, 'e2e'),
testMatch: '**/*.spec.js',
timeout: 30000,
expect: { timeout: 10000 },
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: 1,
reporter: [
['list'],
['json', { outputFile: resolve(__dirname, 'e2e-results/test-results.json') }],
],
outputDir: resolve(__dirname, 'e2e-results'),
use: {
baseURL: FRONTEND_URL,
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'on-first-retry',
headless: true,
viewport: { width: 1280, height: 720 },
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
webServer: {
command: `npx vite --mode test --port ${FRONTEND_PORT}`,
url: FRONTEND_URL,
reuseExistingServer: !process.env.CI,
timeout: 60000,
env: {
NO_PROXY: process.env.NO_PROXY,
no_proxy: process.env.no_proxy,
},
stdout: 'pipe',
stderr: 'pipe',
},
});

View File

@ -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(); });
</script>
{#if loading}
@ -81,9 +120,11 @@
<main>
<Sidebar />
<section class="content">
<section class="content scroll-surface">
{#if currentView === 'plugin-manager'}
<PluginManager {activeSettingsPluginId} {activeSettingsPanelId} />
{:else if currentView === 'workbench'}
<WorkbenchHost {openedResource} />
{:else}
<ViewContainer {activeView} {activeViewPluginId} />
{/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);
}
</style>

View File

@ -0,0 +1,150 @@
/**
* Frontend debug logger.
*
* Enabled when:
* 1. URL has ?debug query param, OR
* 2. localStorage has verstak-debug = "true"
*
* Writes to:
* - console (always, with [debug] prefix)
* - localStorage buffer (last 1000 entries, key: verstak-debug-log)
*
* Usage:
* import { debug } from '../log/debug.js';
* debug.log('[ComponentName]', 'message', data);
* debug.logf('[ComponentName]', 'format %s', arg);
*
* To enable: open app with ?debug or run in console:
* localStorage.setItem('verstak-debug', 'true')
*
* To export log: run in console:
* copy(JSON.parse(localStorage.getItem('verstak-debug-log')))
*/
var ENABLED = false;
var BUFFER_KEY = 'verstak-debug-log';
var MAX_ENTRIES = 1000;
// Check enable conditions
function checkEnabled() {
try {
if (window.location && window.location.search && window.location.search.indexOf('debug') !== -1) {
return true;
}
if (typeof localStorage !== 'undefined' && localStorage.getItem('verstak-debug') === 'true') {
return true;
}
} catch (e) {
// localStorage not available
}
return false;
}
ENABLED = checkEnabled();
function getTimestamp() {
return new Date().toISOString();
}
function formatMessage(args) {
var parts = [];
for (var i = 0; i < args.length; i++) {
var a = args[i];
if (typeof a === 'object') {
try {
parts.push(JSON.stringify(a));
} catch (e) {
parts.push(String(a));
}
} else {
parts.push(String(a));
}
}
return parts.join(' ');
}
function writeToBuffer(entry) {
try {
if (typeof localStorage === 'undefined') return;
var raw = localStorage.getItem(BUFFER_KEY);
var log = raw ? JSON.parse(raw) : [];
log.push(entry);
if (log.length > MAX_ENTRIES) {
log = log.slice(log.length - MAX_ENTRIES);
}
localStorage.setItem(BUFFER_KEY, JSON.stringify(log));
} catch (e) {
// Ignore quota errors
}
}
function log() {
if (!ENABLED) return;
var msg = formatMessage(Array.prototype.slice.call(arguments));
var entry = { ts: getTimestamp(), msg: msg };
writeToBuffer(entry);
console.log('[debug]', msg);
}
function logf() {
if (!ENABLED) return;
var args = Array.prototype.slice.call(arguments);
var format = args.shift();
var i = 0;
var msg = format.replace(/%[sdfo]/g, function () {
return i < args.length ? String(args[i++]) : '';
});
var entry = { ts: getTimestamp(), msg: msg };
writeToBuffer(entry);
console.log('[debug]', msg);
}
function getLog() {
try {
if (typeof localStorage === 'undefined') return [];
var raw = localStorage.getItem(BUFFER_KEY);
return raw ? JSON.parse(raw) : [];
} catch (e) {
return [];
}
}
function clearLog() {
try {
if (typeof localStorage !== 'undefined') {
localStorage.removeItem(BUFFER_KEY);
}
} catch (e) {}
}
function exportLog() {
var entries = getLog();
return entries.map(function (e) { return e.ts + ' ' + e.msg; }).join('\n');
}
// Named export for Svelte/Vite
export var debug = {
log: log,
logf: logf,
isEnabled: function () { return ENABLED; },
enable: function () {
ENABLED = true;
try { localStorage.setItem('verstak-debug', 'true'); } catch (e) {}
},
disable: function () {
ENABLED = false;
try { localStorage.removeItem('verstak-debug'); } catch (e) {}
},
getLog: getLog,
clearLog: clearLog,
exportLog: exportLog
};
// Also expose globally for console access
if (typeof window !== 'undefined') {
window.__verstakDebug = debug;
}
if (ENABLED) {
console.log('[debug] frontend debug logger enabled');
}

View File

@ -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 @@
<p>Select a plugin view from the sidebar</p>
</div>
{:else if loadState === 'loading'}
<div class="host-state loading">
<div class="spinner"></div>
<p>Loading plugin bundle...</p>
</div>
{:else if loadState === 'error'}
<div class="host-state error">
<Icon name="warning" size={24} className="error-icon" />
@ -184,9 +196,16 @@
</div>
</div>
{:else if loadState === 'loaded'}
{:else}
{#if loadState === 'loading'}
<div class="host-state loading">
<div class="spinner"></div>
<p>Loading plugin bundle...</p>
</div>
{/if}
<div
class="plugin-mount-container"
class:mount-hidden={loadState !== 'loaded'}
bind:this={mountContainer}
data-plugin-id={currentPluginId}
data-component={currentComponent}
@ -197,10 +216,10 @@
<style>
.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;
}
</style>

View File

@ -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;

View File

@ -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;
}
}
</style>

View File

@ -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 @@
<style>
.plugin-manager {
max-width: 900px;
padding-top: 0.5rem;
flex: 1;
width: min(100%, 1100px);
min-height: 0;
padding: 0.5rem 0.5rem 1.5rem 0;
position: relative;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
margin-bottom: 1.25rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid #0f3460;
@ -343,14 +412,20 @@
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 0;
flex-wrap: wrap;
}
h2 { color: #e0e0e0; font-size: 1.3rem; margin: 0; }
.vault-badge {
max-width: 100%;
font-size: 0.75rem;
padding: 0.2rem 0.6rem;
border-radius: 12px;
font-weight: 600;
border: 1px solid;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vault-open { background: rgba(78, 204, 163, 0.15); color: #4ecca3; border-color: #4ecca3; }
.vault-not-created { background: rgba(255, 200, 87, 0.15); color: #ffc857; border-color: #ffc857; }
@ -404,7 +479,7 @@
.hint-list { list-style: none; padding: 0; margin: 0.5rem 0; font-size: 0.8rem; opacity: 0.7; }
.hint-list li { margin: 0.25rem 0; }
.hint code { background: #0f3460; padding: 0.1rem 0.3rem; border-radius: 3px; }
.plugin-list { display: flex; flex-direction: column; gap: 0.75rem; margin-bottom: 1.5rem; }
.plugin-list { display: flex; flex-direction: column; gap: 0.75rem; margin-bottom: 1.5rem; min-width: 0; }
.missing-section { margin-bottom: 1.5rem; }
.missing-section h3 { color: #e94560; font-size: 1rem; margin: 0 0 0.25rem; }
@ -450,4 +525,24 @@
.modal-body { padding: 1rem; overflow-y: auto; }
.settings-hint { color: #666; font-size: 0.8rem; margin: 0.25rem 0; }
.settings-hint code { color: #4ecca3; }
@media (max-width: 760px) {
.plugin-manager {
width: 100%;
padding-right: 0;
}
header {
align-items: flex-start;
}
.reload-btn {
width: 100%;
}
.modal {
width: min(480px, calc(100vw - 2rem));
max-height: calc(100vh - 2rem);
}
}
</style>

View File

@ -1,8 +1,13 @@
<script>
import { onMount } from 'svelte';
import { onDestroy, onMount } from 'svelte';
import * as App from '../../../wailsjs/go/api/App';
import WorkspaceTree from './WorkspaceTree.svelte';
import Icon from '../ui/Icon.svelte';
import { debug } from '../log/debug.js';
function flog(msg) {
App.WriteFrontendLog('Sidebar', msg);
}
let plugins = [];
let vaultStatus = { status: 'unknown', path: '', vaultId: '' };
@ -15,9 +20,13 @@
$: vaultOpen = vaultStatus.status === 'open';
onMount(async () => {
async function loadSidebar() {
debug.log('[Sidebar] onMount: START');
flog('onMount: START');
let contribErr = false;
try {
debug.log('[Sidebar] onMount: loading plugins/vault/contributions...');
flog('onMount: loading plugins/vault/contributions...');
const [p, v, contribs] = await Promise.all([
App.GetPlugins().catch(() => []),
App.GetVaultStatus().catch(() => ({ status: 'unknown', path: '', vaultId: '' })),
@ -25,6 +34,8 @@
]);
plugins = p || [];
vaultStatus = v;
debug.log('[Sidebar] onMount: plugins=' + plugins.length + ' vault=' + vaultStatus.status);
flog('onMount: plugins=' + plugins.length + ' vault=' + vaultStatus.status);
if (contribErr) {
errorMessage = 'Failed to load plugin contributions';
}
@ -34,17 +45,34 @@
return plugin.status !== 'disabled' && plugin.status !== 'failed' && plugin.status !== 'incompatible' && plugin.status !== 'missing-required-capability';
});
sidebarItems.sort((a, b) => (a.position || 100) - (b.position || 100));
debug.log('[Sidebar] onMount: sidebarItems=' + sidebarItems.length);
flog('onMount: sidebarItems=' + sidebarItems.length);
} catch (e) {
debug.log('[Sidebar] onMount: ERROR:', String(e));
flog('onMount: ERROR: ' + String(e));
console.error('[Sidebar] load error:', e);
errorMessage = 'Failed to load sidebar';
}
debug.log('[Sidebar] onMount: END');
flog('onMount: END');
}
onMount(() => {
loadSidebar();
window.addEventListener('verstak:plugins-changed', loadSidebar);
});
onDestroy(() => {
window.removeEventListener('verstak:plugins-changed', loadSidebar);
});
function handleNav(id) {
debug.log('[Sidebar] handleNav:', id);
window.dispatchEvent(new CustomEvent('verstak:nav', { detail: { viewId: id } }));
}
function handleSidebarItem(item) {
debug.log('[Sidebar] handleSidebarItem:', item.id, '-> view:', item.view);
// Use item.view (the view contribution ID) if available, fall back to item.id
const viewId = item.view || item.id;
window.dispatchEvent(new CustomEvent('verstak:open-view', { detail: { viewId, pluginId: item.pluginId } }));
@ -171,6 +199,7 @@
.nav-item {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0.6rem;
padding: 0.45rem 0.75rem;
background: none;

View File

@ -101,13 +101,14 @@
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
background: #1a1a2e;
}
.view {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
padding: 1.5rem;
}
.view.degraded {
@ -155,7 +156,7 @@
}
.view-content {
flex: 1;
overflow: auto;
min-width: 0;
}
.placeholder {
color: #666;

View File

@ -0,0 +1,114 @@
<script>
import PluginBundleHost from '../plugin-host/PluginBundleHost.svelte';
export let openedResource = null;
$: providerPluginId = openedResource?.providerPluginId || '';
$: providerComponent = openedResource?.providerComponent || '';
$: resourcePath = openedResource?.request?.path || '';
$: providerId = openedResource?.providerId || '';
$: requestMode = openedResource?.request?.mode || 'view';
$: requestContext = openedResource?.request?.context?.notesMode || openedResource?.request?.context?.isInsideNotesFolder
? 'notes-markdown'
: ((openedResource?.request?.extension === '.md' || openedResource?.request?.extension === '.markdown') ? 'generic-markdown' : 'generic-text');
$: componentProps = openedResource || {};
$: mountKey = [
providerPluginId,
providerComponent,
resourcePath,
requestMode,
requestContext,
].join(':');
</script>
<div class="workbench-host">
{#if openedResource?.status === 'no-provider'}
<div class="workbench-header">
<span class="workbench-title">{resourcePath}</span>
<span class="workbench-provider">no-provider</span>
</div>
<div class="workbench-empty no-provider" data-workbench-status="no-provider">
<p>No viewer/editor available</p>
<p class="workbench-meta">{requestMode} · {requestContext}</p>
</div>
{:else if openedResource}
<div class="workbench-header">
<span class="workbench-title">{resourcePath}</span>
<span class="workbench-provider">{providerId}</span>
</div>
<div class="workbench-content">
{#key mountKey}
<PluginBundleHost
pluginId={providerPluginId}
componentId={providerComponent}
{componentProps}
/>
{/key}
</div>
{:else}
<div class="workbench-empty">
<p>No resource opened</p>
</div>
{/if}
</div>
<style>
.workbench-host {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
background: #1a1a2e;
}
.workbench-header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid #16213e;
flex-shrink: 0;
}
.workbench-title {
color: #e0e0f0;
font-size: 0.95rem;
font-weight: 600;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.workbench-provider {
color: #4ecca3;
font-size: 0.75rem;
margin-left: auto;
}
.workbench-content {
min-width: 0;
min-height: 0;
flex: 1;
padding: 1rem;
}
.workbench-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: #666;
}
.workbench-empty.no-provider {
flex-direction: column;
gap: 0.35rem;
}
.workbench-meta {
margin: 0;
color: #8b8ba8;
font-size: 0.8rem;
}
</style>

View File

@ -1,8 +1,15 @@
<script context="module">
import { writable } from 'svelte/store';
const activeWorkspaceNodeId = writable('');
</script>
<script>
import { onMount } from 'svelte';
import * as App from '../../../wailsjs/go/api/App';
export let nodes = [];
export let node = null;
export let currentNodeId = '';
export let expandedNodes = {};
export let depth = 0;
@ -32,6 +39,7 @@
} else {
nodes = result.nodes || [];
currentNodeId = result.currentNodeId || '';
activeWorkspaceNodeId.set(currentNodeId);
const root = nodes.find(n => !n.parentId);
if (root) expandedNodes[root.id] = true;
}
@ -69,6 +77,7 @@
const err = await App.SetCurrentWorkspaceNode(id);
if (err) { localError = err; return; }
currentNodeId = id;
activeWorkspaceNodeId.set(id);
}
function openCreate(parentId, type) {
@ -128,7 +137,7 @@
{/if}
</div>
{:else}
<div class="wt-node" class:selected={node.id === currentNodeId} class:archived={node.status === 'archived'} class:sleeping={node.status === 'sleeping'}>
<div class="wt-node" class:selected={node.id === $activeWorkspaceNodeId} class:archived={node.status === 'archived'} class:sleeping={node.status === 'sleeping'}>
<div class="wt-row" style="padding-left: {depth * 1.0 + 0.4}rem;">
{#if hasKids(node.id)}
<button class="wt-expand" on:click={() => toggle(node.id)} type="button">{expandedNodes[node.id] ? '\u25BE' : '\u25B8'}</button>
@ -153,7 +162,7 @@
.wt { display: flex; flex-direction: column; flex: 1; overflow: hidden; position: relative; }
.wt-header { display: flex; align-items: center; justify-content: space-between; padding: 0.4rem 0.6rem; border-bottom: 1px solid #0f3460; flex-shrink: 0; }
.wt-title { color: #a0a0b8; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600; }
.wt-btn { background: none; border: none; color: #666; cursor: pointer; font-size: 0.85rem; padding: 0.1rem 0.3rem; border-radius: 3px; }
.wt-btn { min-height: 0; background: none; border: none; color: #666; cursor: pointer; font-size: 0.85rem; padding: 0.1rem 0.3rem; border-radius: 3px; }
.wt-btn:hover { color: #4ecca3; background: rgba(78,204,163,0.1); }
.wt-btn-small { font-size: 0.7rem; opacity: 0; }
.wt-row:hover .wt-btn-small { opacity: 1; }
@ -162,12 +171,12 @@
.wt-node { }
.wt-row { display: flex; align-items: center; gap: 0.2rem; padding: 0.15rem 0; }
.wt-row:hover { background: rgba(15,52,96,0.4); }
.wt-row.selected { background: rgba(78,204,163,0.1); }
.wt-expand { width: 1rem; height: 1rem; display: flex; align-items: center; justify-content: center; font-size: 0.65rem; color: #666; background: none; border: none; cursor: pointer; padding: 0; flex-shrink: 0; }
.wt-node.selected > .wt-row { background: rgba(78,204,163,0.1); }
.wt-expand { width: 1rem; height: 1rem; min-height: 0; display: flex; align-items: center; justify-content: center; font-size: 0.65rem; color: #666; background: none; border: none; cursor: pointer; padding: 0; flex-shrink: 0; }
.wt-expand:hover { color: #e0e0f0; }
.wt-expand-spacer { width: 1rem; flex-shrink: 0; }
.wt-icon { font-size: 0.8rem; flex-shrink: 0; }
.wt-label { flex: 1; background: none; border: none; color: #e0e0f0; font-size: 0.78rem; text-align: left; cursor: pointer; padding: 0.1rem 0.2rem; border-radius: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wt-label { flex: 1; min-height: 0; justify-content: flex-start; background: none; border: none; color: #e0e0f0; font-size: 0.78rem; text-align: left; cursor: pointer; padding: 0.1rem 0.2rem; border-radius: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wt-label:hover { color: #4ecca3; }
.wt-node.archived .wt-label { text-decoration: line-through; opacity: 0.5; }
.wt-node.sleeping .wt-label { opacity: 0.6; }

View File

@ -0,0 +1,756 @@
/**
* Wails Mock Bridge эмулирует window['go']['api']['App'] для тестового окружения.
*
* Каждый метод возвращает Promise с данными, совместимыми с Wails-контрактом.
* Состояние мутабельно тесты могут менять его между сценариями.
*/
(function () {
if (window.__wailsMockReady) return;
// ── Mutable state ──────────────────────────────────────────────────
var pluginStates = {
'verstak.platform-test': {
status: 'loaded',
enabled: true,
manifest: {
schemaVersion: 1,
id: 'verstak.platform-test',
name: 'Platform Test',
version: '0.1.0',
apiVersion: '0.1.0',
description: 'Runtime test plugin for verifying the Verstak platform.',
source: 'official',
icon: '🧪',
provides: ['verstak/platform-test/v1', 'verstak/diagnostics/v1'],
requires: ['verstak/core/plugin-manager/v1', 'verstak/core/capability-registry/v1'],
optionalRequires: ['verstak/core/vault/v1', 'verstak/core/sync/v1', 'verstak/core/files/v1', 'verstak/core/workbench/v1'],
permissions: ['vault.read', 'events.publish', 'events.subscribe', 'ui.register', 'commands.register', 'storage.namespace', 'files.read', 'files.write', 'files.delete', 'workbench.open'],
frontend: { entry: 'frontend/dist/index.js' },
contributes: {
views: [
{ id: 'verstak.platform-test.diagnostics', title: 'Platform Diagnostics', icon: '🧪', component: 'DiagnosticsPanel' }
],
commands: [
{ id: 'verstak.platform-test.run-tests', title: 'Run Platform Tests', handler: 'runAllTests' },
{ id: 'verstak.platform-test.show-version', title: 'Show Version Info', handler: 'showVersion' }
],
sidebarItems: [
{ id: 'verstak.platform-test.sidebar', title: 'Platform Test', icon: '🧪', view: 'verstak.platform-test.diagnostics', position: 100 }
],
statusBarItems: [
{ id: 'verstak.platform-test.status', label: '🧪 All Tests Pass', position: 'right', handler: 'openDiagnostics' }
],
settingsPanels: [
{ id: 'verstak.platform-test.settings', title: 'Platform Test Settings', icon: '🧪', component: 'PlatformTestSettings' }
],
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', extensions: ['.txt', '.log'], mime: ['text/plain'], contexts: ['generic-text'] }
]
}
]
}
},
rootPath: '/tmp/verstak-test/plugins/platform-test',
error: ''
}
};
var vaultStatus = { status: 'open', path: '/tmp/verstak-test/vault', vaultId: 'test-vault-001' };
var vaultPluginState = { enabledPlugins: ['verstak.platform-test'], disabledPlugins: [], desiredPlugins: [{ id: 'verstak.platform-test', version: '0.1.0', source: 'official' }] };
var appSettings = { currentVaultPath: '/tmp/verstak-test/vault', recentVaults: [] };
var workbenchPreferences = {};
var openedResources = [];
var pluginSettings = {
'verstak.platform-test': { savedText: 'initial value' }
};
var vaultFiles = makeDefaultVaultFiles();
var workspaceTree = makeDefaultWorkspaceTree();
var reloadResponseMode = 'tuple';
// ── Helpers ────────────────────────────────────────────────────────
function makeDefaultWorkspaceTree() {
return {
status: 'initialized',
currentNodeId: 'case-alpha',
nodes: [
{ id: 'space-main', parentId: '', type: 'space', title: 'Main Space', status: 'active', order: 1 },
{ id: 'case-alpha', parentId: 'space-main', type: 'case', title: 'Alpha Case', status: 'active', order: 1 },
{ id: 'case-beta', parentId: 'space-main', type: 'case', title: 'Beta Case', status: 'active', order: 2 }
]
};
}
function cloneWorkspaceTree() {
return {
status: workspaceTree.status,
currentNodeId: workspaceTree.currentNodeId,
nodes: workspaceTree.nodes.map(function (n) { return Object.assign({}, n); })
};
}
function makeDefaultVaultFiles() {
return {
'': { type: 'folder', modifiedAt: new Date().toISOString() }
};
}
function normalizeVaultPath(relativePath, allowRoot) {
var p = String(relativePath || '');
if (p.indexOf('\x00') !== -1) return { error: 'invalid-path: null-byte' };
if (p.indexOf('\\') !== -1) return { error: 'invalid-path: backslash not allowed' };
if (p.indexOf('./') === 0) p = p.slice(2);
if (!allowRoot && !p) return { error: 'invalid-path: empty path' };
if (p.charAt(0) === '/' || /^[A-Za-z]:/.test(p)) return { error: 'invalid-path: absolute path rejected' };
var parts = p.split('/').filter(Boolean);
if (parts.indexOf('..') !== -1) return { error: 'invalid-path: path-traversal' };
if (parts[0] && parts[0].toLowerCase() === '.verstak') return { error: 'reserved-path: .verstak is internal' };
return { path: parts.join('/') };
}
function parentPath(path) {
var idx = path.lastIndexOf('/');
return idx === -1 ? '' : path.slice(0, idx);
}
function baseName(path) {
var idx = path.lastIndexOf('/');
return idx === -1 ? path : path.slice(idx + 1);
}
function fileEntry(path, node) {
var name = path ? baseName(path) : '';
var ext = '';
var dot = name.lastIndexOf('.');
if (dot > 0) ext = name.slice(dot + 1);
return {
name: name,
relativePath: path,
type: node.type,
size: node.type === 'file' ? (node.content || '').length : 0,
modifiedAt: node.modifiedAt || new Date().toISOString(),
extension: ext,
isHidden: name.charAt(0) === '.',
isReserved: false,
canRead: node.type === 'file' || node.type === 'folder',
canWrite: node.type === 'file' || node.type === 'folder'
};
}
function requirePluginPermission(pluginId, permission) {
var s = pluginStates[pluginId];
if (!s || !s.enabled || (s.status !== 'loaded' && s.status !== 'degraded')) {
return 'plugin not enabled and loaded';
}
if (!s.manifest.permissions || s.manifest.permissions.indexOf(permission) === -1) {
return 'plugin lacks required permission ' + permission;
}
if (vaultStatus.status !== 'open') return 'vault-not-open';
return '';
}
function makePlugin(id) {
var s = pluginStates[id];
if (!s) return null;
return {
manifest: s.manifest,
status: s.status,
enabled: s.enabled,
rootPath: s.rootPath,
error: s.error
};
}
function allPlugins() {
return Object.keys(pluginStates).map(makePlugin).filter(Boolean);
}
function allCapabilities() {
var caps = [];
caps.push({ name: 'verstak/core/plugin-manager/v1', description: 'Plugin management', pluginId: 'verstak-desktop', status: 'stable' });
caps.push({ name: 'verstak/core/capability-registry/v1', description: 'Capability registry', pluginId: 'verstak-desktop', status: 'stable' });
caps.push({ name: 'verstak/core/files/v1', description: 'Files API', pluginId: 'verstak-desktop', status: 'stable' });
caps.push({ name: 'verstak/core/workbench/v1', description: 'Workbench routing', pluginId: 'verstak-desktop', status: 'stable' });
for (var id in pluginStates) {
var s = pluginStates[id];
if (s.status === 'loaded' && s.enabled && s.manifest && s.manifest.provides) {
s.manifest.provides.forEach(function (p) {
caps.push({ name: p, description: '', pluginId: id, status: 'stable' });
});
}
}
return caps;
}
function allPermissions() {
return [
{ name: 'vault.read', description: 'Read vault data', dangerous: false },
{ name: 'events.publish', description: 'Publish events', dangerous: false },
{ name: 'events.subscribe', description: 'Subscribe to events', dangerous: false },
{ name: 'ui.register', description: 'Register UI contributions', dangerous: false },
{ name: 'commands.register', description: 'Register commands', dangerous: false },
{ name: 'storage.namespace', description: 'Access plugin storage', dangerous: false },
{ name: 'files.read', description: 'Read vault files', dangerous: false },
{ name: 'files.write', description: 'Write vault files', dangerous: true },
{ name: 'files.delete', description: 'Trash vault files', dangerous: true },
{ name: 'workbench.open', description: 'Request Workbench open/edit routing', dangerous: false }
];
}
function allContributions() {
var views = [], commands = [], sidebarItems = [], statusBarItems = [], settingsPanels = [], openProviders = [];
for (var id in pluginStates) {
var s = pluginStates[id];
var c = (s.manifest && s.manifest.contributes) || {};
if (c.views) c.views.forEach(function (v) { views.push(Object.assign({}, v, { pluginId: id })); });
if (c.commands) c.commands.forEach(function (cmd) { commands.push(Object.assign({}, cmd, { pluginId: id })); });
if (c.sidebarItems) c.sidebarItems.forEach(function (sb) { sidebarItems.push(Object.assign({}, sb, { pluginId: id })); });
if (c.statusBarItems) c.statusBarItems.forEach(function (st) { statusBarItems.push(Object.assign({}, st, { pluginId: id })); });
if (c.settingsPanels) c.settingsPanels.forEach(function (sp) { settingsPanels.push(Object.assign({}, sp, { pluginId: id })); });
if (c.openProviders) c.openProviders.forEach(function (op) { openProviders.push(Object.assign({}, op, { pluginId: id })); });
}
return { views: views, commands: commands, sidebarItems: sidebarItems, statusBarItems: statusBarItems, settingsPanels: settingsPanels, openProviders: openProviders };
}
function requestExtension(request) {
if (request && request.extension) {
var explicit = String(request.extension).toLowerCase();
return explicit.charAt(0) === '.' ? explicit : '.' + explicit;
}
var p = String((request && request.path) || '').toLowerCase();
var slash = p.lastIndexOf('/');
var name = slash === -1 ? p : p.slice(slash + 1);
var dot = name.lastIndexOf('.');
return dot > 0 ? name.slice(dot) : '';
}
function requestContextName(request) {
var ctx = (request && request.context) || {};
if (ctx.notesMode || ctx.isInsideNotesFolder || ctx.sourceView === 'notes') return 'notes-markdown';
var ext = requestExtension(request);
if (ext === '.md' || ext === '.markdown') return 'generic-markdown';
return 'generic-text';
}
function providerSupports(provider, request) {
var ext = requestExtension(request);
var contextName = requestContextName(request);
return (provider.supports || []).some(function (support) {
if (support.kind && support.kind !== request.kind) return false;
if (support.extensions && support.extensions.length && support.extensions.map(function (e) { return String(e).toLowerCase(); }).indexOf(ext) === -1) return false;
if (support.contexts && support.contexts.length && support.contexts.indexOf(contextName) === -1) return false;
return true;
});
}
function selectOpenProvider(request) {
var providers = allContributions().openProviders.filter(function (provider) {
var s = pluginStates[provider.pluginId];
return s && s.enabled && (s.status === 'loaded' || s.status === 'degraded') && providerSupports(provider, request);
});
providers.sort(function (a, b) {
var byPriority = (b.priority || 0) - (a.priority || 0);
if (byPriority) return byPriority;
return String(a.id).localeCompare(String(b.id));
});
return providers[0] || null;
}
function openWorkbenchResource(pluginId, request, forcedMode) {
var s = pluginStates[pluginId];
if (!s || !s.enabled || (s.status !== 'loaded' && s.status !== 'degraded')) {
return Promise.resolve([{}, 'plugin not enabled and loaded']);
}
if (!s.manifest.permissions || s.manifest.permissions.indexOf('workbench.open') === -1) {
return Promise.resolve([{}, 'plugin lacks required permission workbench.open']);
}
var normalized = Object.assign({}, request || {});
normalized.kind = normalized.kind || 'vault-file';
normalized.mode = forcedMode || normalized.mode || 'view';
normalized.extension = requestExtension(normalized);
normalized.context = Object.assign({}, normalized.context || {}, { sourcePluginId: pluginId });
var provider = selectOpenProvider(normalized);
if (!provider) {
return Promise.resolve([{
status: 'no-provider',
request: normalized,
message: 'no open provider for resource'
}, '']);
}
var result = {
status: 'opened',
providerId: provider.id,
providerPluginId: provider.pluginId,
providerComponent: provider.component,
request: normalized
};
openedResources.push(Object.assign({ id: provider.id + ':' + openedResources.length, openedAt: new Date().toISOString() }, result));
return Promise.resolve([result, '']);
}
function platformTestBundle() {
return [
"(function(){",
"var DiagnosticsPanel={",
"mount:function(containerEl,props,api){",
"containerEl.innerHTML='';",
"containerEl.__ptCleanup=[];",
"function track(fn){if(typeof fn==='function')containerEl.__ptCleanup.push(fn);}",
"var root=document.createElement('div');",
"root.className='pt-root';",
"var title=document.createElement('h2');",
"title.className='pt-plugin-name';",
"title.textContent='Platform Diagnostics';",
"var pluginId=document.createElement('p');",
"pluginId.className='pt-plugin-id';",
"pluginId.textContent=api.pluginId;",
"var status=document.createElement('div');",
"status.className='pt-badge pt-badge-success';",
"status.textContent='Frontend Bundle Loaded';",
"var saved=document.createElement('div');",
"saved.className='pt-card pt-saved-setting';",
"saved.textContent='Saved setting: loading...';",
"var cap=document.createElement('div');",
"cap.className='pt-capability-result';",
"cap.textContent='Capabilities: loading...';",
"api.capabilities.list().then(function(caps){cap.textContent='Capabilities: '+caps.length+' available';});",
"api.settings.read('savedText').then(function(value){saved.textContent='Saved setting: '+(value||'');});",
"var input=document.createElement('input');",
"input.className='pt-setting-input';",
"input.setAttribute('aria-label','Saved setting');",
"input.value='changed value';",
"var button=document.createElement('button');",
"button.className='btn btn-primary pt-save-setting';",
"button.textContent='Save Setting';",
"button.addEventListener('click',function(){api.settings.write('savedText',input.value).then(function(){saved.textContent='Saved setting: '+input.value;});});",
"api.capabilities.has('verstak/platform-test/v1').then(function(ok){status.textContent='Frontend Bundle Loaded | capability '+(ok?'available':'missing');});",
"var command=document.createElement('div');",
"command.className='pt-command-result';",
"command.textContent='Command: registering...';",
"api.commands.register('verstak.platform-test.show-version',function(){return {version:'0.1.0',source:'bundled-frontend'};}).then(function(unregister){track(unregister);return api.commands.execute('verstak.platform-test.show-version',{});}).then(function(result){status.setAttribute('data-command-status',result.status||'');command.textContent='Command: '+result.status+' '+result.result.version+' from '+result.result.source;});",
"var eventResult=document.createElement('div');",
"eventResult.className='pt-event-result';",
"eventResult.textContent='Event: subscribing...';",
"api.events.subscribe('verstak.platform-test.echo',function(event){eventResult.textContent='Event: received '+event.payload.message;eventResult.setAttribute('data-event-status','received');}).then(function(unsubscribe){track(unsubscribe);return api.events.publish('verstak.platform-test.echo',{message:'hello-event'});});",
"var filesResult=document.createElement('div');",
"filesResult.className='pt-files-result';",
"filesResult.textContent='Files: running...';",
"var filesError=document.createElement('div');",
"filesError.className='pt-files-error-result';",
"filesError.textContent='Files error path: checking...';",
"var workbenchResult=document.createElement('div');",
"workbenchResult.className='pt-workbench-result';",
"workbenchResult.textContent='Workbench: ready';",
"function makeWorkbenchButton(cls,label,request){var b=document.createElement('button');b.className='btn btn-primary '+cls;b.textContent=label;b.addEventListener('click',function(){workbenchResult.textContent='Workbench: opening...';api.workbench.editResource(request).then(function(result){workbenchResult.textContent='Workbench: opened '+result.request.path+' with '+(result.providerId||'no-provider');workbenchResult.setAttribute('data-workbench-status',result.status==='opened'?'ok':result.status);}).catch(function(err){workbenchResult.textContent='Workbench error: '+(err&&err.message?err.message:String(err));workbenchResult.setAttribute('data-workbench-status','error');});});return b;}",
"var textWorkbenchButton=makeWorkbenchButton('pt-open-workbench-text','Open Text Diagnostic',{kind:'vault-file',path:'Docs/todo.txt',extension:'.txt',mime:'text/plain',context:{sourceView:'files'}});",
"var markdownWorkbenchButton=makeWorkbenchButton('pt-open-workbench-markdown','Open Markdown Diagnostic',{kind:'vault-file',path:'Docs/readme.md',extension:'.md',context:{sourceView:'files'}});",
"var notesWorkbenchButton=makeWorkbenchButton('pt-open-workbench-notes','Open Notes Diagnostic',{kind:'vault-file',path:'Notes/Overview.md',extension:'.md',context:{sourceView:'notes',isInsideNotesFolder:true,notesMode:true}});",
"api.files.createFolder('PlatformTest').catch(function(e){if(String(e).indexOf('conflict')===-1)throw e;}).then(function(){return api.files.writeText('PlatformTest/files-api.txt','hello files',{createIfMissing:true,overwrite:true});}).then(function(){return api.files.readText('PlatformTest/files-api.txt');}).then(function(text){if(text!=='hello files')throw new Error('read mismatch');return api.files.list('PlatformTest');}).then(function(entries){if(!entries.some(function(e){return e.relativePath==='PlatformTest/files-api.txt';}))throw new Error('list missing file');return api.files.move('PlatformTest/files-api.txt','PlatformTest/files-api-moved.txt',{overwrite:true});}).then(function(){return api.files.trash('PlatformTest/files-api-moved.txt');}).then(function(){filesResult.textContent='Files: wrote/read/listed/moved/trashed';filesResult.setAttribute('data-files-status','ok');}).catch(function(err){filesResult.textContent='Files error: '+(err&&err.message?err.message:String(err));filesResult.setAttribute('data-files-status','error');});",
"api.files.readText('.verstak/vault.json').then(function(){filesError.textContent='Files error path: unexpectedly allowed';filesError.setAttribute('data-files-error-status','error');}).catch(function(err){var message=err&&err.message?err.message:String(err);if(message.indexOf('reserved-path')===-1&&message.indexOf('.verstak')===-1){filesError.textContent='Files error path: wrong error '+message;filesError.setAttribute('data-files-error-status','error');return;}filesError.textContent='Files error path: rejected reserved-path';filesError.setAttribute('data-files-error-status','expected');});",
"root.appendChild(title);",
"root.appendChild(pluginId);",
"root.appendChild(status);",
"root.appendChild(saved);",
"root.appendChild(input);",
"root.appendChild(button);",
"root.appendChild(cap);",
"root.appendChild(command);",
"root.appendChild(eventResult);",
"root.appendChild(filesResult);",
"root.appendChild(filesError);",
"root.appendChild(textWorkbenchButton);",
"root.appendChild(markdownWorkbenchButton);",
"root.appendChild(notesWorkbenchButton);",
"root.appendChild(workbenchResult);",
"containerEl.appendChild(root);",
"},",
"unmount:function(containerEl){while(containerEl.__ptCleanup&&containerEl.__ptCleanup.length){containerEl.__ptCleanup.pop()();}containerEl.innerHTML='';}",
"};",
"var MarkdownDiagnosticProvider={",
"mount:function(containerEl,props,api){",
"containerEl.innerHTML='';",
"var root=document.createElement('div');",
"root.className='pt-root pt-workbench-result';",
"root.setAttribute('data-workbench-status','ok');",
"var req=(props&&props.request)||{};",
"var ctx=(req.context&&req.context.notesMode)||false?'notes-markdown':((req.extension==='.md'||req.extension==='.markdown')?'generic-markdown':'generic-text');",
"root.setAttribute('data-resource-path',req.path||'');",
"root.setAttribute('data-resource-mode',req.mode||'');",
"root.setAttribute('data-resource-context',ctx);",
"root.textContent='Workbench: opened '+(req.path||'')+' with '+((props&&props.providerId)||'')+' mode='+(req.mode||'')+' context='+ctx;",
"containerEl.appendChild(root);",
"},",
"unmount:function(containerEl){containerEl.innerHTML='';}",
"};",
"var PlatformTestSettings={",
"mount:function(containerEl,props,api){",
"containerEl.innerHTML='<div class=\"pt-root\"><h2>Platform Test Settings</h2><p>'+api.pluginId+'</p></div>';",
"},",
"unmount:function(containerEl){containerEl.innerHTML='';}",
"};",
"window.VerstakPluginRegister('verstak.platform-test',{components:{DiagnosticsPanel:DiagnosticsPanel,PlatformTestSettings:PlatformTestSettings,MarkdownDiagnosticProvider:MarkdownDiagnosticProvider}});",
"})();"
].join('');
}
// ── Mock API ───────────────────────────────────────────────────────
var mock = {
GetPlugins: function () { return Promise.resolve(allPlugins()); },
GetCapabilities: function () { return Promise.resolve(allCapabilities()); },
GetPermissions: function () { return Promise.resolve(allPermissions()); },
GetContributions: function () { return Promise.resolve(allContributions()); },
GetVaultStatus: function () { return Promise.resolve(vaultStatus); },
GetVaultPluginState: function () { return Promise.resolve(vaultPluginState); },
GetAppSettings: function () { return Promise.resolve(appSettings); },
GetPluginFrontendInfo: function (pluginId) {
var s = pluginStates[pluginId];
if (s && s.manifest && s.manifest.frontend) {
return Promise.resolve({ entry: s.manifest.frontend.entry });
}
return Promise.resolve({});
},
ReadPluginSettings: function (pluginId) {
return Promise.resolve([Object.assign({}, pluginSettings[pluginId] || {}), '']);
},
WritePluginSettings: function (pluginId, settings) {
pluginSettings[pluginId] = Object.assign({}, settings || {});
return Promise.resolve('');
},
ReadPluginSetting: function () { return Promise.resolve(null); },
WritePluginSetting: function () { return Promise.resolve(null); },
ReadPluginDataJSON: function () { return Promise.resolve({}); },
WritePluginDataJSON: function () { return Promise.resolve(null); },
OpenWorkbenchResource: function (pluginId, request) {
return openWorkbenchResource(pluginId, request || {}, '');
},
EditWorkbenchResource: function (pluginId, request) {
return openWorkbenchResource(pluginId, request || {}, 'edit');
},
GetWorkbenchOpenedResources: function () {
return Promise.resolve(openedResources.map(function (resource) {
return Object.assign({}, resource, { request: Object.assign({}, resource.request || {}) });
}));
},
GetWorkbenchPreferences: function () {
return Promise.resolve(Object.assign({}, workbenchPreferences));
},
UpdateWorkbenchPreferences: function (preferences) {
workbenchPreferences = Object.assign({}, workbenchPreferences, preferences || {});
return Promise.resolve('');
},
GetPluginAssetContent: function (pluginId, assetPath) {
if (pluginId === 'verstak.platform-test' && assetPath === 'frontend/dist/index.js') {
return Promise.resolve(platformTestBundle());
}
return Promise.resolve('');
},
GetPluginCapability: function (pluginId, capId) {
var caps = allCapabilities();
var found = caps.find(function (cap) { return cap.name === capId; });
return Promise.resolve([found ? Object.assign({ available: true }, found) : { available: false, name: capId }, '']);
},
ListPluginCapabilities: function () { return Promise.resolve([allCapabilities(), '']); },
ExecutePluginCommand: function (pluginId, commandId, args) {
var s = pluginStates[pluginId];
var commands = ((s && s.manifest && s.manifest.contributes && s.manifest.contributes.commands) || []);
var found = commands.find(function (cmd) { return cmd.id === commandId; });
if (!found) return Promise.resolve([{}, 'command not declared']);
return Promise.resolve([{ status: 'declared', pluginId: pluginId, commandId: commandId, handler: found.handler, args: args || {} }, '']);
},
PublishPluginEvent: function () { return Promise.resolve(''); },
SubscribePluginEvent: function (pluginId, eventName) {
var s = pluginStates[pluginId];
if (!s || !s.enabled || s.status !== 'loaded') return Promise.resolve('plugin not enabled and loaded');
if (!eventName) return Promise.resolve('event name is empty');
if (!s.manifest.permissions || s.manifest.permissions.indexOf('events.subscribe') === -1) {
return Promise.resolve('plugin lacks required permission events.subscribe');
}
return Promise.resolve('');
},
ListVaultFiles: function (pluginId, relativeDir) {
var err = requirePluginPermission(pluginId, 'files.read');
if (err) return Promise.resolve([[], err]);
var norm = normalizeVaultPath(relativeDir, true);
if (norm.error) return Promise.resolve([[], norm.error]);
var dir = norm.path;
if (!vaultFiles[dir] || vaultFiles[dir].type !== 'folder') return Promise.resolve([[], 'not-found: ' + dir]);
var prefix = dir ? dir + '/' : '';
var entries = [];
Object.keys(vaultFiles).forEach(function (path) {
if (path === dir || path.indexOf(prefix) !== 0) return;
var rest = path.slice(prefix.length);
if (!rest || rest.indexOf('/') !== -1) return;
entries.push(fileEntry(path, vaultFiles[path]));
});
return Promise.resolve([entries, '']);
},
GetVaultFileMetadata: function (pluginId, relativePath) {
var err = requirePluginPermission(pluginId, 'files.read');
if (err) return Promise.resolve([{}, err]);
var norm = normalizeVaultPath(relativePath, false);
if (norm.error) return Promise.resolve([{}, norm.error]);
var node = vaultFiles[norm.path];
if (!node) return Promise.resolve([{}, 'not-found: ' + norm.path]);
return Promise.resolve([fileEntry(norm.path, node), '']);
},
ReadVaultTextFile: function (pluginId, relativePath) {
var err = requirePluginPermission(pluginId, 'files.read');
if (err) return Promise.resolve(['', err]);
var norm = normalizeVaultPath(relativePath, false);
if (norm.error) return Promise.resolve(['', norm.error]);
var node = vaultFiles[norm.path];
if (!node) return Promise.resolve(['', 'not-found: ' + norm.path]);
if (node.type !== 'file') return Promise.resolve(['', 'not-regular-file: ' + norm.path]);
return Promise.resolve([node.content || '', '']);
},
WriteVaultTextFile: function (pluginId, relativePath, content, options) {
var err = requirePluginPermission(pluginId, 'files.write');
if (err) return Promise.resolve(err);
var norm = normalizeVaultPath(relativePath, false);
if (norm.error) return Promise.resolve(norm.error);
options = options || {};
var existing = vaultFiles[norm.path];
if (existing && existing.type !== 'file') return Promise.resolve('not-regular-file: ' + norm.path);
if (existing && !options.overwrite) return Promise.resolve('conflict: ' + norm.path);
if (!existing && !options.createIfMissing) return Promise.resolve('not-found: ' + norm.path);
var parent = parentPath(norm.path);
if (!vaultFiles[parent] || vaultFiles[parent].type !== 'folder') return Promise.resolve('parent-not-found: ' + parent);
vaultFiles[norm.path] = { type: 'file', content: String(content == null ? '' : content), modifiedAt: new Date().toISOString() };
return Promise.resolve('');
},
CreateVaultFolder: function (pluginId, relativePath) {
var err = requirePluginPermission(pluginId, 'files.write');
if (err) return Promise.resolve(err);
var norm = normalizeVaultPath(relativePath, false);
if (norm.error) return Promise.resolve(norm.error);
if (vaultFiles[norm.path]) return Promise.resolve('conflict: ' + norm.path);
var parent = parentPath(norm.path);
if (!vaultFiles[parent] || vaultFiles[parent].type !== 'folder') return Promise.resolve('parent-not-found: ' + parent);
vaultFiles[norm.path] = { type: 'folder', modifiedAt: new Date().toISOString() };
return Promise.resolve('');
},
MoveVaultPath: function (pluginId, fromRelativePath, toRelativePath, options) {
var err = requirePluginPermission(pluginId, 'files.write');
if (err) return Promise.resolve(err);
var from = normalizeVaultPath(fromRelativePath, false);
var to = normalizeVaultPath(toRelativePath, false);
if (from.error) return Promise.resolve(from.error);
if (to.error) return Promise.resolve(to.error);
options = options || {};
if (!vaultFiles[from.path]) return Promise.resolve('not-found: ' + from.path);
if (vaultFiles[from.path].type === 'folder' && (to.path === from.path || to.path.indexOf(from.path + '/') === 0)) {
return Promise.resolve('move-into-self: ' + from.path + ' -> ' + to.path);
}
if (vaultFiles[to.path] && !options.overwrite) return Promise.resolve('conflict: ' + to.path);
var parent = parentPath(to.path);
if (!vaultFiles[parent] || vaultFiles[parent].type !== 'folder') return Promise.resolve('parent-not-found: ' + parent);
var moving = Object.keys(vaultFiles).filter(function (path) { return path === from.path || path.indexOf(from.path + '/') === 0; });
moving.forEach(function (path) {
var suffix = path.slice(from.path.length);
vaultFiles[to.path + suffix] = vaultFiles[path];
delete vaultFiles[path];
});
return Promise.resolve('');
},
TrashVaultPath: function (pluginId, relativePath) {
var err = requirePluginPermission(pluginId, 'files.delete');
if (err) return Promise.resolve([{}, err]);
var norm = normalizeVaultPath(relativePath, false);
if (norm.error) return Promise.resolve([{}, norm.error]);
if (!vaultFiles[norm.path]) return Promise.resolve([{}, 'not-found: ' + norm.path]);
var trashId = 'mock-' + Date.now() + '-' + Math.random().toString(16).slice(2);
var trashPath = '.verstak/trash/files/' + trashId + '/' + baseName(norm.path);
var moving = Object.keys(vaultFiles).filter(function (path) { return path === norm.path || path.indexOf(norm.path + '/') === 0; });
moving.forEach(function (path) { delete vaultFiles[path]; });
return Promise.resolve([{ originalPath: norm.path, trashPath: trashPath, trashId: trashId, deletedAt: new Date().toISOString() }, '']);
},
GetCurrentWorkspaceNode: function () { return Promise.resolve(null); },
GetWorkspaceTree: function () { return Promise.resolve(cloneWorkspaceTree()); },
ArchiveWorkspaceNode: function () { return Promise.resolve(''); },
CreateWorkspaceNode: function () { return Promise.resolve({}); },
MoveWorkspaceNode: function () { return Promise.resolve(''); },
RenameWorkspaceNode: function () { return Promise.resolve(''); },
SetCurrentWorkspaceNode: function (id) {
var found = workspaceTree.nodes.some(function (n) { return n.id === id; });
if (!found) return Promise.resolve('workspace node not found: ' + id);
workspaceTree.currentNodeId = id;
return Promise.resolve('');
},
SelectDirectory: function () { return Promise.resolve(''); },
SelectVaultForOpen: function () { return Promise.resolve(''); },
CreateVault: function () { return Promise.resolve(null); },
OpenVault: function () { return Promise.resolve(null); },
CloseVault: function () { return Promise.resolve(null); },
SetCurrentVault: function () { return Promise.resolve(''); },
UpdateAppSettings: function () { return Promise.resolve(''); },
RecordDesiredPlugin: function () { return Promise.resolve(''); },
WriteFrontendLog: function () { return Promise.resolve(); },
EnablePlugin: function (pluginId) {
if (pluginStates[pluginId]) {
pluginStates[pluginId].status = 'loaded';
pluginStates[pluginId].enabled = true;
if (vaultPluginState.disabledPlugins.indexOf(pluginId) !== -1) {
vaultPluginState.disabledPlugins = vaultPluginState.disabledPlugins.filter(function (id) { return id !== pluginId; });
}
if (vaultPluginState.enabledPlugins.indexOf(pluginId) === -1) {
vaultPluginState.enabledPlugins.push(pluginId);
}
}
return Promise.resolve(null);
},
DisablePlugin: function (pluginId) {
if (pluginStates[pluginId]) {
pluginStates[pluginId].status = 'disabled';
pluginStates[pluginId].enabled = false;
if (vaultPluginState.enabledPlugins.indexOf(pluginId) !== -1) {
vaultPluginState.enabledPlugins = vaultPluginState.enabledPlugins.filter(function (id) { return id !== pluginId; });
}
if (vaultPluginState.disabledPlugins.indexOf(pluginId) === -1) {
vaultPluginState.disabledPlugins.push(pluginId);
}
}
return Promise.resolve(null);
},
ReloadPlugins: function () {
if (reloadResponseMode === 'raw-count') {
return Promise.resolve(Object.keys(pluginStates).length);
}
return Promise.resolve([Object.keys(pluginStates).length, 'Reloaded ' + Object.keys(pluginStates).length + ' plugin(s).']);
}
};
// ── Install bridge ─────────────────────────────────────────────────
if (!window['go']) window['go'] = {};
if (!window['go']['api']) window['go']['api'] = {};
window['go']['api']['App'] = mock;
// ── Test helpers (exposed for Playwright) ──────────────────────────
window.__wailsMock = {
reset: function () {
pluginStates = {
'verstak.platform-test': {
status: 'loaded',
enabled: true,
manifest: {
schemaVersion: 1,
id: 'verstak.platform-test',
name: 'Platform Test',
version: '0.1.0',
apiVersion: '0.1.0',
description: 'Runtime test plugin for verifying the Verstak platform.',
source: 'official',
icon: '🧪',
provides: ['verstak/platform-test/v1', 'verstak/diagnostics/v1'],
requires: ['verstak/core/plugin-manager/v1', 'verstak/core/capability-registry/v1'],
optionalRequires: ['verstak/core/vault/v1', 'verstak/core/sync/v1', 'verstak/core/files/v1', 'verstak/core/workbench/v1'],
permissions: ['vault.read', 'events.publish', 'events.subscribe', 'ui.register', 'commands.register', 'storage.namespace', 'files.read', 'files.write', 'files.delete', 'workbench.open'],
frontend: { entry: 'frontend/dist/index.js' },
contributes: {
views: [
{ id: 'verstak.platform-test.diagnostics', title: 'Platform Diagnostics', icon: '🧪', component: 'DiagnosticsPanel' }
],
commands: [
{ id: 'verstak.platform-test.run-tests', title: 'Run Platform Tests', handler: 'runAllTests' },
{ id: 'verstak.platform-test.show-version', title: 'Show Version Info', handler: 'showVersion' }
],
sidebarItems: [
{ id: 'verstak.platform-test.sidebar', title: 'Platform Test', icon: '🧪', view: 'verstak.platform-test.diagnostics', position: 100 }
],
statusBarItems: [
{ id: 'verstak.platform-test.status', label: '🧪 All Tests Pass', position: 'right', handler: 'openDiagnostics' }
],
settingsPanels: [
{ id: 'verstak.platform-test.settings', title: 'Platform Test Settings', icon: '🧪', component: 'PlatformTestSettings' }
],
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', extensions: ['.txt', '.log'], mime: ['text/plain'], contexts: ['generic-text'] }
]
}
]
}
},
rootPath: '/tmp/verstak-test/plugins/platform-test',
error: ''
}
};
vaultStatus = { status: 'open', path: '/tmp/verstak-test/vault', vaultId: 'test-vault-001' };
vaultPluginState = { enabledPlugins: ['verstak.platform-test'], disabledPlugins: [], desiredPlugins: [{ id: 'verstak.platform-test', version: '0.1.0', source: 'official' }] };
appSettings = { currentVaultPath: '/tmp/verstak-test/vault', recentVaults: [] };
workbenchPreferences = {};
openedResources = [];
pluginSettings = { 'verstak.platform-test': { savedText: 'initial value' } };
vaultFiles = makeDefaultVaultFiles();
workspaceTree = makeDefaultWorkspaceTree();
reloadResponseMode = 'tuple';
},
setPluginStatus: function (pluginId, status, enabled) {
if (pluginStates[pluginId]) {
pluginStates[pluginId].status = status;
pluginStates[pluginId].enabled = enabled;
}
},
getPluginState: function (pluginId) {
return pluginStates[pluginId] ? Object.assign({}, pluginStates[pluginId]) : null;
},
addSyntheticPlugins: function (count) {
var total = Number(count || 0);
for (var i = 1; i <= total; i++) {
var id = 'verstak.synthetic-layout-' + String(i).padStart(2, '0');
pluginStates[id] = {
status: 'loaded',
enabled: true,
manifest: {
schemaVersion: 1,
id: id,
name: 'Synthetic Layout Plugin ' + i,
version: '0.0.' + i,
apiVersion: '0.1.0',
description: 'Synthetic plugin used by frontend layout tests.',
source: 'test',
provides: ['verstak/synthetic-layout-' + i + '/v1'],
requires: [],
optionalRequires: [],
permissions: [],
contributes: {
views: [],
commands: [],
sidebarItems: [],
statusBarItems: [],
settingsPanels: []
}
},
rootPath: '/tmp/verstak-test/plugins/synthetic-layout-' + i + '/with/a/long/path/for/responsive-checks',
error: ''
};
if (vaultPluginState.enabledPlugins.indexOf(id) === -1) {
vaultPluginState.enabledPlugins.push(id);
}
if (!vaultPluginState.desiredPlugins.some(function (p) { return p.id === id; })) {
vaultPluginState.desiredPlugins.push({ id: id, version: '0.0.' + i, source: 'test' });
}
}
},
setVaultStatus: function (status) { vaultStatus = status; },
setVaultPluginState: function (state) { vaultPluginState = state; },
setReloadResponseMode: function (mode) { reloadResponseMode = mode || 'tuple'; }
};
window.__wailsMockReady = true;
console.log('[wails-mock] bridge installed');
})();

View File

@ -1,14 +1,18 @@
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
export default defineConfig({
plugins: [svelte()],
build: {
outDir: 'dist',
emptyOutDir: true,
},
server: {
port: 5173,
strictPort: true,
},
export default defineConfig(({ mode }) => {
const isTest = mode === 'test';
return {
plugins: [svelte()],
build: {
outDir: 'dist',
emptyOutDir: true,
},
server: {
port: isTest ? 5174 : 5173,
strictPort: true,
},
};
});

View File

@ -4,6 +4,8 @@ import {capability} from '../models';
import {api} from '../models';
import {permissions} from '../models';
import {plugin} from '../models';
import {files} from '../models';
import {workbench} from '../models';
export function ArchiveWorkspaceNode(arg1:string):Promise<string>;
@ -11,12 +13,18 @@ export function CloseVault():Promise<void>;
export function CreateVault(arg1:string):Promise<void>;
export function CreateVaultFolder(arg1:string,arg2:string):Promise<string>;
export function CreateWorkspaceNode(arg1:string,arg2:string,arg3:string):Promise<Record<string, any>>;
export function DisablePlugin(arg1:string):Promise<string>;
export function EnablePlugin(arg1:string):Promise<string>;
export function ExecutePluginCommand(arg1:string,arg2:string,arg3:Record<string, any>):Promise<Record<string, any>|string>;
export function EditWorkbenchResource(arg1:string,arg2:Record<string, any>):Promise<workbench.OpenResourceResult|string>;
export function GetAppSettings():Promise<Record<string, any>>;
export function GetCapabilities():Promise<Array<capability.Entry>>;
@ -29,25 +37,45 @@ export function GetPermissions():Promise<Array<permissions.Entry>>;
export function GetPluginAssetContent(arg1:string,arg2:string):Promise<string|string>;
export function GetPluginCapability(arg1:string,arg2:string):Promise<Record<string, any>|string>;
export function GetPluginFrontendInfo(arg1:string):Promise<Record<string, any>>;
export function GetPlugins():Promise<Array<plugin.Plugin>>;
export function GetVaultFileMetadata(arg1:string,arg2:string):Promise<files.FileMetadata|string>;
export function GetVaultPluginState():Promise<Record<string, any>>;
export function GetVaultStatus():Promise<Record<string, string>>;
export function GetWorkspaceTree():Promise<Record<string, any>>;
export function GetWorkbenchOpenedResources():Promise<Array<workbench.OpenedResource>>;
export function GetWorkbenchPreferences():Promise<workbench.Preferences>;
export function ListPluginCapabilities(arg1:string):Promise<Array<capability.Entry>|string>;
export function ListVaultFiles(arg1:string,arg2:string):Promise<Array<files.FileEntry>|string>;
export function MoveVaultPath(arg1:string,arg2:string,arg3:string,arg4:files.MoveOptions):Promise<string>;
export function MoveWorkspaceNode(arg1:string,arg2:string):Promise<string>;
export function OpenVault(arg1:string):Promise<void>;
export function OpenWorkbenchResource(arg1:string,arg2:Record<string, any>):Promise<workbench.OpenResourceResult|string>;
export function PublishPluginEvent(arg1:string,arg2:string,arg3:Record<string, any>):Promise<string>;
export function ReadPluginDataJSON(arg1:string,arg2:string):Promise<Record<string, any>>;
export function ReadPluginSetting(arg1:string,arg2:string):Promise<any>;
export function ReadPluginSettings(arg1:string):Promise<Record<string, any>>;
export function ReadPluginSettings(arg1:string):Promise<Record<string, any>|string>;
export function ReadVaultTextFile(arg1:string,arg2:string):Promise<string|string>;
export function RecordDesiredPlugin(arg1:string,arg2:string,arg3:string):Promise<string>;
@ -63,10 +91,20 @@ export function SetCurrentVault(arg1:string):Promise<string>;
export function SetCurrentWorkspaceNode(arg1:string):Promise<string>;
export function SubscribePluginEvent(arg1:string,arg2:string):Promise<string>;
export function TrashVaultPath(arg1:string,arg2:string):Promise<files.TrashResult|string>;
export function UpdateAppSettings(arg1:Record<string, any>):Promise<string>;
export function UpdateWorkbenchPreferences(arg1:workbench.Preferences):Promise<string>;
export function WriteFrontendLog(arg1:string,arg2:string):Promise<void>;
export function WritePluginDataJSON(arg1:string,arg2:string,arg3:Record<string, any>):Promise<string>;
export function WritePluginSetting(arg1:string,arg2:string,arg3:any):Promise<string>;
export function WritePluginSettings(arg1:string,arg2:Record<string, any>):Promise<string>;
export function WriteVaultTextFile(arg1:string,arg2:string,arg3:string,arg4:files.WriteOptions):Promise<string>;

View File

@ -14,6 +14,10 @@ export function CreateVault(arg1) {
return window['go']['api']['App']['CreateVault'](arg1);
}
export function CreateVaultFolder(arg1, arg2) {
return window['go']['api']['App']['CreateVaultFolder'](arg1, arg2);
}
export function CreateWorkspaceNode(arg1, arg2, arg3) {
return window['go']['api']['App']['CreateWorkspaceNode'](arg1, arg2, arg3);
}
@ -26,6 +30,14 @@ export function EnablePlugin(arg1) {
return window['go']['api']['App']['EnablePlugin'](arg1);
}
export function ExecutePluginCommand(arg1, arg2, arg3) {
return window['go']['api']['App']['ExecutePluginCommand'](arg1, arg2, arg3);
}
export function EditWorkbenchResource(arg1, arg2) {
return window['go']['api']['App']['EditWorkbenchResource'](arg1, arg2);
}
export function GetAppSettings() {
return window['go']['api']['App']['GetAppSettings']();
}
@ -50,6 +62,10 @@ export function GetPluginAssetContent(arg1, arg2) {
return window['go']['api']['App']['GetPluginAssetContent'](arg1, arg2);
}
export function GetPluginCapability(arg1, arg2) {
return window['go']['api']['App']['GetPluginCapability'](arg1, arg2);
}
export function GetPluginFrontendInfo(arg1) {
return window['go']['api']['App']['GetPluginFrontendInfo'](arg1);
}
@ -58,6 +74,10 @@ export function GetPlugins() {
return window['go']['api']['App']['GetPlugins']();
}
export function GetVaultFileMetadata(arg1, arg2) {
return window['go']['api']['App']['GetVaultFileMetadata'](arg1, arg2);
}
export function GetVaultPluginState() {
return window['go']['api']['App']['GetVaultPluginState']();
}
@ -70,6 +90,26 @@ export function GetWorkspaceTree() {
return window['go']['api']['App']['GetWorkspaceTree']();
}
export function GetWorkbenchOpenedResources() {
return window['go']['api']['App']['GetWorkbenchOpenedResources']();
}
export function GetWorkbenchPreferences() {
return window['go']['api']['App']['GetWorkbenchPreferences']();
}
export function ListPluginCapabilities(arg1) {
return window['go']['api']['App']['ListPluginCapabilities'](arg1);
}
export function ListVaultFiles(arg1, arg2) {
return window['go']['api']['App']['ListVaultFiles'](arg1, arg2);
}
export function MoveVaultPath(arg1, arg2, arg3, arg4) {
return window['go']['api']['App']['MoveVaultPath'](arg1, arg2, arg3, arg4);
}
export function MoveWorkspaceNode(arg1, arg2) {
return window['go']['api']['App']['MoveWorkspaceNode'](arg1, arg2);
}
@ -78,6 +118,14 @@ export function OpenVault(arg1) {
return window['go']['api']['App']['OpenVault'](arg1);
}
export function OpenWorkbenchResource(arg1, arg2) {
return window['go']['api']['App']['OpenWorkbenchResource'](arg1, arg2);
}
export function PublishPluginEvent(arg1, arg2, arg3) {
return window['go']['api']['App']['PublishPluginEvent'](arg1, arg2, arg3);
}
export function ReadPluginDataJSON(arg1, arg2) {
return window['go']['api']['App']['ReadPluginDataJSON'](arg1, arg2);
}
@ -90,6 +138,10 @@ export function ReadPluginSettings(arg1) {
return window['go']['api']['App']['ReadPluginSettings'](arg1);
}
export function ReadVaultTextFile(arg1, arg2) {
return window['go']['api']['App']['ReadVaultTextFile'](arg1, arg2);
}
export function RecordDesiredPlugin(arg1, arg2, arg3) {
return window['go']['api']['App']['RecordDesiredPlugin'](arg1, arg2, arg3);
}
@ -118,10 +170,26 @@ export function SetCurrentWorkspaceNode(arg1) {
return window['go']['api']['App']['SetCurrentWorkspaceNode'](arg1);
}
export function SubscribePluginEvent(arg1, arg2) {
return window['go']['api']['App']['SubscribePluginEvent'](arg1, arg2);
}
export function TrashVaultPath(arg1, arg2) {
return window['go']['api']['App']['TrashVaultPath'](arg1, arg2);
}
export function UpdateAppSettings(arg1) {
return window['go']['api']['App']['UpdateAppSettings'](arg1);
}
export function UpdateWorkbenchPreferences(arg1) {
return window['go']['api']['App']['UpdateWorkbenchPreferences'](arg1);
}
export function WriteFrontendLog(arg1, arg2) {
return window['go']['api']['App']['WriteFrontendLog'](arg1, arg2);
}
export function WritePluginDataJSON(arg1, arg2, arg3) {
return window['go']['api']['App']['WritePluginDataJSON'](arg1, arg2, arg3);
}
@ -133,3 +201,7 @@ export function WritePluginSetting(arg1, arg2, arg3) {
export function WritePluginSettings(arg1, arg2) {
return window['go']['api']['App']['WritePluginSettings'](arg1, arg2);
}
export function WriteVaultTextFile(arg1, arg2, arg3, arg4) {
return window['go']['api']['App']['WriteVaultTextFile'](arg1, arg2, arg3, arg4);
}

View File

@ -1,105 +1,45 @@
export namespace api {
export class FlatSidebarItem {
pluginId: string;
id: string;
title: string;
icon?: string;
view: string;
position?: number;
export class FlatOpenProviderSupport {
kind: string;
mime?: string[];
extensions?: string[];
contexts?: string[];
static createFrom(source: any = {}) {
return new FlatSidebarItem(source);
return new FlatOpenProviderSupport(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.pluginId = source["pluginId"];
this.id = source["id"];
this.title = source["title"];
this.icon = source["icon"];
this.view = source["view"];
this.position = source["position"];
this.kind = source["kind"];
this.mime = source["mime"];
this.extensions = source["extensions"];
this.contexts = source["contexts"];
}
}
export class FlatSettingsPanel {
export class FlatOpenProvider {
pluginId: string;
id: string;
title: string;
icon?: string;
priority?: number;
component: string;
supports: FlatOpenProviderSupport[];
static createFrom(source: any = {}) {
return new FlatSettingsPanel(source);
return new FlatOpenProvider(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.pluginId = source["pluginId"];
this.id = source["id"];
this.title = source["title"];
this.icon = source["icon"];
this.priority = source["priority"];
this.component = source["component"];
this.supports = this.convertValues(source["supports"], FlatOpenProviderSupport);
}
}
export class FlatCommand {
pluginId: string;
id: string;
title: string;
icon?: string;
handler?: string;
static createFrom(source: any = {}) {
return new FlatCommand(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.pluginId = source["pluginId"];
this.id = source["id"];
this.title = source["title"];
this.icon = source["icon"];
this.handler = source["handler"];
}
}
export class FlatView {
pluginId: string;
id: string;
title: string;
icon?: string;
component: string;
static createFrom(source: any = {}) {
return new FlatView(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.pluginId = source["pluginId"];
this.id = source["id"];
this.title = source["title"];
this.icon = source["icon"];
this.component = source["component"];
}
}
export class ContributionSummary {
views: FlatView[];
commands: FlatCommand[];
settingsPanels: FlatSettingsPanel[];
sidebarItems: FlatSidebarItem[];
static createFrom(source: any = {}) {
return new ContributionSummary(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.views = this.convertValues(source["views"], FlatView);
this.commands = this.convertValues(source["commands"], FlatCommand);
this.settingsPanels = this.convertValues(source["settingsPanels"], FlatSettingsPanel);
this.sidebarItems = this.convertValues(source["sidebarItems"], FlatSidebarItem);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
@ -118,24 +58,305 @@ export namespace api {
return a;
}
}
export class FlatSidebarItem {
pluginId: string;
id: string;
title: string;
icon?: string;
view: string;
position?: number;
static createFrom(source: any = {}) {
return new FlatSidebarItem(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.pluginId = source["pluginId"];
this.id = source["id"];
this.title = source["title"];
this.icon = source["icon"];
this.view = source["view"];
this.position = source["position"];
}
}
export class FlatSettingsPanel {
pluginId: string;
id: string;
title: string;
icon?: string;
component: string;
static createFrom(source: any = {}) {
return new FlatSettingsPanel(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.pluginId = source["pluginId"];
this.id = source["id"];
this.title = source["title"];
this.icon = source["icon"];
this.component = source["component"];
}
}
export class FlatCommand {
pluginId: string;
id: string;
title: string;
icon?: string;
handler?: string;
static createFrom(source: any = {}) {
return new FlatCommand(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.pluginId = source["pluginId"];
this.id = source["id"];
this.title = source["title"];
this.icon = source["icon"];
this.handler = source["handler"];
}
}
export class FlatView {
pluginId: string;
id: string;
title: string;
icon?: string;
component: string;
static createFrom(source: any = {}) {
return new FlatView(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.pluginId = source["pluginId"];
this.id = source["id"];
this.title = source["title"];
this.icon = source["icon"];
this.component = source["component"];
}
}
export class ContributionSummary {
views: FlatView[];
commands: FlatCommand[];
settingsPanels: FlatSettingsPanel[];
sidebarItems: FlatSidebarItem[];
openProviders: FlatOpenProvider[];
static createFrom(source: any = {}) {
return new ContributionSummary(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.views = this.convertValues(source["views"], FlatView);
this.commands = this.convertValues(source["commands"], FlatCommand);
this.settingsPanels = this.convertValues(source["settingsPanels"], FlatSettingsPanel);
this.sidebarItems = this.convertValues(source["sidebarItems"], FlatSidebarItem);
this.openProviders = this.convertValues(source["openProviders"], FlatOpenProvider);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
}
export namespace workbench {
export class OpenResourceContext {
sourcePluginId?: string;
sourceView?: string;
isInsideNotesFolder?: boolean;
notesScopePath?: string;
notesMode?: boolean;
static createFrom(source: any = {}) {
return new OpenResourceContext(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.sourcePluginId = source["sourcePluginId"];
this.sourceView = source["sourceView"];
this.isInsideNotesFolder = source["isInsideNotesFolder"];
this.notesScopePath = source["notesScopePath"];
this.notesMode = source["notesMode"];
}
}
export class OpenResourceRequest {
kind: string;
path: string;
mode?: string;
mime?: string;
extension?: string;
context?: OpenResourceContext;
static createFrom(source: any = {}) {
return new OpenResourceRequest(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.kind = source["kind"];
this.path = source["path"];
this.mode = source["mode"];
this.mime = source["mime"];
this.extension = source["extension"];
this.context = this.convertValues(source["context"], OpenResourceContext);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class OpenResourceResult {
status: string;
providerId?: string;
providerPluginId?: string;
providerComponent?: string;
request: OpenResourceRequest;
message?: string;
static createFrom(source: any = {}) {
return new OpenResourceResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.status = source["status"];
this.providerId = source["providerId"];
this.providerPluginId = source["providerPluginId"];
this.providerComponent = source["providerComponent"];
this.request = this.convertValues(source["request"], OpenResourceRequest);
this.message = source["message"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class OpenedResource {
id: string;
providerId: string;
providerPluginId: string;
providerComponent: string;
request: OpenResourceRequest;
openedAt: string;
static createFrom(source: any = {}) {
return new OpenedResource(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.providerId = source["providerId"];
this.providerPluginId = source["providerPluginId"];
this.providerComponent = source["providerComponent"];
this.request = this.convertValues(source["request"], OpenResourceRequest);
this.openedAt = source["openedAt"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class Preferences {
defaultTextEditorProvider?: string;
defaultMarkdownEditorProvider?: string;
defaultNotesMarkdownEditorProvider?: string;
static createFrom(source: any = {}) {
return new Preferences(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.defaultTextEditorProvider = source["defaultTextEditorProvider"];
this.defaultMarkdownEditorProvider = source["defaultMarkdownEditorProvider"];
this.defaultNotesMarkdownEditorProvider = source["defaultNotesMarkdownEditorProvider"];
}
}
}
export namespace capability {
export class Entry {
name: string;
description?: string;
pluginId: string;
status: string;
static createFrom(source: any = {}) {
return new Entry(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
@ -147,17 +368,130 @@ export namespace capability {
}
export namespace files {
export class FileEntry {
name: string;
relativePath: string;
type: string;
size: number;
modifiedAt: string;
extension: string;
isHidden: boolean;
isReserved: boolean;
canRead: boolean;
canWrite: boolean;
static createFrom(source: any = {}) {
return new FileEntry(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
this.relativePath = source["relativePath"];
this.type = source["type"];
this.size = source["size"];
this.modifiedAt = source["modifiedAt"];
this.extension = source["extension"];
this.isHidden = source["isHidden"];
this.isReserved = source["isReserved"];
this.canRead = source["canRead"];
this.canWrite = source["canWrite"];
}
}
export class FileMetadata {
relativePath: string;
type: string;
size: number;
modifiedAt: string;
createdAt?: string;
extension: string;
mimeHint: string;
isText: boolean;
isHidden: boolean;
isReserved: boolean;
canRead: boolean;
canWrite: boolean;
static createFrom(source: any = {}) {
return new FileMetadata(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.relativePath = source["relativePath"];
this.type = source["type"];
this.size = source["size"];
this.modifiedAt = source["modifiedAt"];
this.createdAt = source["createdAt"];
this.extension = source["extension"];
this.mimeHint = source["mimeHint"];
this.isText = source["isText"];
this.isHidden = source["isHidden"];
this.isReserved = source["isReserved"];
this.canRead = source["canRead"];
this.canWrite = source["canWrite"];
}
}
export class MoveOptions {
overwrite: boolean;
static createFrom(source: any = {}) {
return new MoveOptions(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.overwrite = source["overwrite"];
}
}
export class TrashResult {
originalPath: string;
trashPath: string;
trashId: string;
deletedAt: string;
static createFrom(source: any = {}) {
return new TrashResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.originalPath = source["originalPath"];
this.trashPath = source["trashPath"];
this.trashId = source["trashId"];
this.deletedAt = source["deletedAt"];
}
}
export class WriteOptions {
createIfMissing: boolean;
overwrite: boolean;
static createFrom(source: any = {}) {
return new WriteOptions(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.createIfMissing = source["createIfMissing"];
this.overwrite = source["overwrite"];
}
}
}
export namespace permissions {
export class Entry {
name: string;
description: string;
dangerous: boolean;
static createFrom(source: any = {}) {
return new Entry(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
@ -169,15 +503,15 @@ export namespace permissions {
}
export namespace plugin {
export class HealthCheckConfig {
type?: string;
timeout?: number;
static createFrom(source: any = {}) {
return new HealthCheckConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.type = source["type"];
@ -188,18 +522,18 @@ export namespace plugin {
type: string;
entry: Record<string, string>;
healthCheck?: HealthCheckConfig;
static createFrom(source: any = {}) {
return new BackendConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.type = source["type"];
this.entry = source["entry"];
this.healthCheck = this.convertValues(source["healthCheck"], HealthCheckConfig);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
@ -224,11 +558,11 @@ export namespace plugin {
icon?: string;
capability?: string;
handler?: string;
static createFrom(source: any = {}) {
return new ContributionAction(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
@ -242,11 +576,11 @@ export namespace plugin {
id: string;
events?: string[];
handler: string;
static createFrom(source: any = {}) {
return new ContributionActivityProvider(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
@ -260,11 +594,11 @@ export namespace plugin {
keybinding?: string;
icon?: string;
handler?: string;
static createFrom(source: any = {}) {
return new ContributionCommand(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
@ -281,11 +615,11 @@ export namespace plugin {
group?: string;
capability?: string;
handler?: string;
static createFrom(source: any = {}) {
return new ContributionContextMenuEntry(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
@ -300,11 +634,11 @@ export namespace plugin {
id: string;
label: string;
handler: string;
static createFrom(source: any = {}) {
return new ContributionSearchProvider(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
@ -317,11 +651,11 @@ export namespace plugin {
title: string;
component: string;
icon?: string;
static createFrom(source: any = {}) {
return new ContributionSettingsPanel(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
@ -336,11 +670,11 @@ export namespace plugin {
icon?: string;
view: string;
position?: number;
static createFrom(source: any = {}) {
return new ContributionSidebarItem(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
@ -355,11 +689,11 @@ export namespace plugin {
label: string;
position?: string;
handler?: string;
static createFrom(source: any = {}) {
return new ContributionStatusBarItem(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
@ -373,11 +707,11 @@ export namespace plugin {
title: string;
icon?: string;
component: string;
static createFrom(source: any = {}) {
return new ContributionView(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
@ -397,11 +731,11 @@ export namespace plugin {
searchProviders?: ContributionSearchProvider[];
activityProviders?: ContributionActivityProvider[];
statusBarItems?: ContributionStatusBarItem[];
static createFrom(source: any = {}) {
return new Contributions(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.views = this.convertValues(source["views"], ContributionView);
@ -415,7 +749,7 @@ export namespace plugin {
this.activityProviders = this.convertValues(source["activityProviders"], ContributionActivityProvider);
this.statusBarItems = this.convertValues(source["statusBarItems"], ContributionStatusBarItem);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
@ -437,26 +771,26 @@ export namespace plugin {
export class FrontendConfig {
entry: string;
style?: string;
static createFrom(source: any = {}) {
return new FrontendConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.entry = source["entry"];
this.style = source["style"];
}
}
export class SyncConfig {
namespaces?: string[];
participate?: boolean;
static createFrom(source: any = {}) {
return new SyncConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.namespaces = source["namespaces"];
@ -465,11 +799,11 @@ export namespace plugin {
}
export class MigrationConfig {
path?: string;
static createFrom(source: any = {}) {
return new MigrationConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.path = source["path"];
@ -493,11 +827,11 @@ export namespace plugin {
migrations?: MigrationConfig;
contributes?: Contributions;
sync?: SyncConfig;
static createFrom(source: any = {}) {
return new Manifest(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.schemaVersion = source["schemaVersion"];
@ -518,7 +852,7 @@ export namespace plugin {
this.contributes = this.convertValues(source["contributes"], Contributions);
this.sync = this.convertValues(source["sync"], SyncConfig);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
@ -537,18 +871,18 @@ export namespace plugin {
return a;
}
}
export class Plugin {
manifest: Manifest;
status: string;
error?: string;
enabled: boolean;
rootPath: string;
static createFrom(source: any = {}) {
return new Plugin(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.manifest = this.convertValues(source["manifest"], Manifest);
@ -557,7 +891,7 @@ export namespace plugin {
this.enabled = source["enabled"];
this.rootPath = source["rootPath"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
@ -578,4 +912,3 @@ export namespace plugin {
}
}

View File

@ -3,11 +3,13 @@ package api
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"time"
"github.com/wailsapp/wails/v2/pkg/runtime"
@ -15,12 +17,15 @@ import (
"github.com/verstak/verstak-desktop/internal/core/capability"
"github.com/verstak/verstak-desktop/internal/core/contribution"
"github.com/verstak/verstak-desktop/internal/core/events"
corefiles "github.com/verstak/verstak-desktop/internal/core/files"
"github.com/verstak/verstak-desktop/internal/core/permissions"
"github.com/verstak/verstak-desktop/internal/core/plugin"
"github.com/verstak/verstak-desktop/internal/core/pluginstate"
"github.com/verstak/verstak-desktop/internal/core/storage"
"github.com/verstak/verstak-desktop/internal/core/vault"
coreworkbench "github.com/verstak/verstak-desktop/internal/core/workbench"
"github.com/verstak/verstak-desktop/internal/core/workspace"
"github.com/verstak/verstak-desktop/internal/shell/debug"
)
// App is the main application struct exposed to the Wails frontend.
@ -33,9 +38,12 @@ type App struct {
plugins []plugin.Plugin
vault *vault.Vault
storage *storage.Storage
files *corefiles.Service
appSettings *appsettings.Manager
pluginState *pluginstate.Manager
workbench *coreworkbench.Router
workspace *workspace.Manager
debug bool
}
// NewApp creates a new App instance.
@ -47,9 +55,11 @@ func NewApp(
plugins []plugin.Plugin,
vaultService *vault.Vault,
storageService *storage.Storage,
filesService *corefiles.Service,
appSettingsMgr *appsettings.Manager,
pluginStateMgr *pluginstate.Manager,
workspaceMgr *workspace.Manager,
debugEnabled bool,
) *App {
return &App{
capRegistry: capReg,
@ -59,37 +69,119 @@ func NewApp(
plugins: plugins,
vault: vaultService,
storage: storageService,
files: filesService,
appSettings: appSettingsMgr,
pluginState: pluginStateMgr,
workbench: coreworkbench.NewRouter(workbenchPrefsFromSettings(appSettingsMgr)),
workspace: workspaceMgr,
debug: debugEnabled,
}
}
func workbenchPrefsFromSettings(m *appsettings.Manager) coreworkbench.Preferences {
if m == nil {
return coreworkbench.Preferences{}
}
cfg := m.Get()
return coreworkbench.Preferences{
DefaultTextEditorProvider: cfg.Workbench.DefaultTextEditorProvider,
DefaultMarkdownEditorProvider: cfg.Workbench.DefaultMarkdownEditorProvider,
DefaultNotesMarkdownEditorProvider: cfg.Workbench.DefaultNotesMarkdownEditorProvider,
}
}
func appSettingsWorkbenchPrefs(p coreworkbench.Preferences) appsettings.WorkbenchPreferences {
return appsettings.WorkbenchPreferences{
DefaultTextEditorProvider: p.DefaultTextEditorProvider,
DefaultMarkdownEditorProvider: p.DefaultMarkdownEditorProvider,
DefaultNotesMarkdownEditorProvider: p.DefaultNotesMarkdownEditorProvider,
}
}
func (a *App) ensureWorkbench() *coreworkbench.Router {
if a.workbench == nil {
a.workbench = coreworkbench.NewRouter(workbenchPrefsFromSettings(a.appSettings))
}
return a.workbench
}
// Startup is called when the app starts. Sets the Wails context for dialogs.
func (a *App) Startup(ctx context.Context) {
a.ctx = ctx
log.Printf("[api] App.Startup: initialized with %d plugins", len(a.plugins))
}
func (a *App) findPlugin(pluginID string) (*plugin.Plugin, error) {
for i := range a.plugins {
if a.plugins[i].Manifest.ID == pluginID {
return &a.plugins[i], nil
}
}
return nil, fmt.Errorf("plugin %q not found", pluginID)
}
func (a *App) requirePluginAccess(pluginID, permission string) (*plugin.Plugin, error) {
p, err := a.findPlugin(pluginID)
if err != nil {
return nil, err
}
if !p.Enabled || (p.Status != plugin.StatusLoaded && p.Status != plugin.StatusDegraded) {
return nil, fmt.Errorf("plugin %q is not enabled and loaded: status=%s enabled=%v", pluginID, p.Status, p.Enabled)
}
if permission != "" && !hasString(p.Manifest.Permissions, permission) {
return nil, fmt.Errorf("plugin %q lacks required permission %q", pluginID, permission)
}
return p, nil
}
func (a *App) requirePluginCapabilityAccess(pluginID, capabilityName string) (*plugin.Plugin, error) {
p, err := a.requirePluginAccess(pluginID, "")
if err != nil {
return nil, err
}
if !hasString(p.Manifest.Requires, capabilityName) && !hasString(p.Manifest.OptionalRequires, capabilityName) {
return nil, fmt.Errorf("plugin %q does not declare capability dependency %q", pluginID, capabilityName)
}
return p, nil
}
func hasString(items []string, want string) bool {
for _, item := range items {
if item == want {
return true
}
}
return false
}
// ─── Plugin Manager API ─────────────────────────────────────
// GetPlugins returns all discovered plugins.
func (a *App) GetPlugins() []plugin.Plugin {
log.Printf("[api] GetPlugins: returning %d plugins", len(a.plugins))
if a.debug {
debug.Logf("[api] GetPlugins: returning %d plugins", len(a.plugins))
for i, p := range a.plugins {
debug.Logf("[api] plugin[%d]: id=%s status=%s enabled=%v root=%s", i, p.Manifest.ID, p.Status, p.Enabled, p.RootPath)
}
}
return a.plugins
}
// GetCapabilities returns all registered capabilities.
func (a *App) GetCapabilities() []capability.Entry {
entries := a.capRegistry.List()
log.Printf("[api] GetCapabilities: returning %d entries", len(entries))
if a.debug {
debug.Logf("[api] GetCapabilities: returning %d entries", len(entries))
}
return entries
}
// GetPermissions returns all known permissions.
func (a *App) GetPermissions() []permissions.Entry {
entries := a.permRegistry.List()
log.Printf("[api] GetPermissions: returning %d entries", len(entries))
if a.debug {
debug.Logf("[api] GetPermissions: returning %d entries", len(entries))
}
return entries
}
@ -132,12 +224,29 @@ type FlatCommand struct {
Handler string `json:"handler,omitempty"`
}
type FlatOpenProviderSupport struct {
Kind string `json:"kind"`
Mime []string `json:"mime,omitempty"`
Extensions []string `json:"extensions,omitempty"`
Contexts []string `json:"contexts,omitempty"`
}
type FlatOpenProvider struct {
PluginID string `json:"pluginId"`
ID string `json:"id"`
Title string `json:"title"`
Priority int `json:"priority,omitempty"`
Component string `json:"component"`
Supports []FlatOpenProviderSupport `json:"supports"`
}
// ContributionSummary aggregates all contribution types for the frontend.
type ContributionSummary struct {
Views []FlatView `json:"views"`
Commands []FlatCommand `json:"commands"`
SettingsPanels []FlatSettingsPanel `json:"settingsPanels"`
SidebarItems []FlatSidebarItem `json:"sidebarItems"`
OpenProviders []FlatOpenProvider `json:"openProviders"`
}
// buildContributionSummary creates a ContributionSummary from the registry.
@ -149,6 +258,7 @@ func buildContributionSummary(r *contribution.Registry) ContributionSummary {
regCmds := r.Commands()
regPanels := r.SettingsPanels()
regSidebar := r.SidebarItems()
regOpenProviders := r.OpenProviders()
views := make([]FlatView, len(regViews))
for i, v := range regViews {
@ -166,46 +276,43 @@ func buildContributionSummary(r *contribution.Registry) ContributionSummary {
for i, v := range regSidebar {
sidebar[i] = FlatSidebarItem{PluginID: v.PluginID, ID: v.Item.ID, Title: v.Item.Title, Icon: v.Item.Icon, View: v.Item.View, Position: v.Item.Position}
}
return ContributionSummary{Views: views, Commands: cmds, SettingsPanels: panels, SidebarItems: sidebar}
openProviders := make([]FlatOpenProvider, len(regOpenProviders))
for i, v := range regOpenProviders {
supports := make([]FlatOpenProviderSupport, len(v.Item.Supports))
for j, s := range v.Item.Supports {
supports[j] = FlatOpenProviderSupport{Kind: s.Kind, Mime: s.Mime, Extensions: s.Extensions, Contexts: s.Contexts}
}
openProviders[i] = FlatOpenProvider{
PluginID: v.PluginID,
ID: v.Item.ID,
Title: v.Item.Title,
Priority: v.Item.Priority,
Component: v.Item.Component,
Supports: supports,
}
}
return ContributionSummary{Views: views, Commands: cmds, SettingsPanels: panels, SidebarItems: sidebar, OpenProviders: openProviders}
}
// GetContributions returns all registered contributions flattened for the frontend.
func (a *App) GetContributions() ContributionSummary {
if a.contribRegistry == nil {
if a.debug {
debug.Logf("[api] GetContributions: contribRegistry is nil")
}
return ContributionSummary{}
}
return buildContributionSummary(a.contribRegistry)
}
// expandPath resolves "~" to the user's home directory.
func expandPath(path string) string {
if strings.HasPrefix(path, "~/") {
home, err := os.UserHomeDir()
if err != nil {
log.Printf("[api] expandPath: cannot get home dir: %v", err)
return path
}
return filepath.Join(home, path[2:])
summary := buildContributionSummary(a.contribRegistry)
if a.debug {
debug.Logf("[api] GetContributions: returning views=%d commands=%d sidebar=%d settings=%d openProviders=%d",
len(summary.Views), len(summary.Commands), len(summary.SidebarItems), len(summary.SettingsPanels), len(summary.OpenProviders))
}
return path
return summary
}
// ReloadPlugins re-discovers plugins from disk and returns a summary.
func (a *App) ReloadPlugins() (int, string) {
// Resolve plugin directories relative to the binary location
binDir := filepath.Dir(os.Args[0])
pluginDir := filepath.Join(binDir, "plugins")
discoveryDirs := []string{
"~/.config/verstak/plugins",
pluginDir,
}
// Expand tilde in all paths
for i, d := range discoveryDirs {
discoveryDirs[i] = expandPath(d)
}
discoveryDirs := plugin.DefaultDiscoveryDirs()
log.Printf("[api] ReloadPlugins: scanning dirs: %v", discoveryDirs)
// Unregister all non-core capabilities
@ -218,6 +325,8 @@ func (a *App) ReloadPlugins() (int, string) {
"verstak/core/contribution-registry/v1",
"verstak/core/permissions/v1",
"verstak/core/events/v1",
"verstak/core/files/v1",
"verstak/core/workbench/v1",
}
if err := a.capRegistry.Register("verstak-desktop", coreCaps); err != nil {
log.Printf("[api] ReloadPlugins: failed to re-register core capabilities: %v", err)
@ -335,6 +444,10 @@ func (a *App) GetVaultStatus() map[string]string {
}
}
if a.debug {
debug.Logf("[api] GetVaultStatus: status=%s path=%s vaultId=%s", status, path, vaultID)
}
return map[string]string{
"status": status,
"path": path,
@ -370,20 +483,26 @@ func (a *App) CloseVault() error {
// ─── Storage API ────────────────────────────────────────────
// ReadPluginSettings returns all settings for a plugin.
func (a *App) ReadPluginSettings(pluginID string) map[string]interface{} {
func (a *App) ReadPluginSettings(pluginID string) (map[string]interface{}, string) {
if _, err := a.requirePluginAccess(pluginID, "storage.namespace"); err != nil {
return make(map[string]interface{}), err.Error()
}
if a.storage == nil {
return make(map[string]interface{})
return make(map[string]interface{}), "storage not initialized"
}
data, err := a.storage.ReadPluginSettings(pluginID)
if err != nil {
log.Printf("[api] ReadPluginSettings(%s): %v", pluginID, err)
return make(map[string]interface{})
return make(map[string]interface{}), err.Error()
}
return data
return data, ""
}
// WritePluginSettings writes all settings for a plugin.
func (a *App) WritePluginSettings(pluginID string, data map[string]interface{}) string {
if _, err := a.requirePluginAccess(pluginID, "storage.namespace"); err != nil {
return err.Error()
}
if a.storage == nil {
return "storage not initialized"
}
@ -396,6 +515,10 @@ func (a *App) WritePluginSettings(pluginID string, data map[string]interface{})
// ReadPluginSetting returns a single setting value.
func (a *App) ReadPluginSetting(pluginID, key string) interface{} {
if _, err := a.requirePluginAccess(pluginID, "storage.namespace"); err != nil {
log.Printf("[api] ReadPluginSetting(%s, %s): %v", pluginID, key, err)
return nil
}
if a.storage == nil {
return nil
}
@ -409,6 +532,9 @@ func (a *App) ReadPluginSetting(pluginID, key string) interface{} {
// WritePluginSetting writes a single setting value.
func (a *App) WritePluginSetting(pluginID, key string, value interface{}) string {
if _, err := a.requirePluginAccess(pluginID, "storage.namespace"); err != nil {
return err.Error()
}
if a.storage == nil {
return "storage not initialized"
}
@ -421,6 +547,10 @@ func (a *App) WritePluginSetting(pluginID, key string, value interface{}) string
// ReadPluginDataJSON reads a named JSON data file for a plugin.
func (a *App) ReadPluginDataJSON(pluginID, name string) map[string]interface{} {
if _, err := a.requirePluginAccess(pluginID, "storage.namespace"); err != nil {
log.Printf("[api] ReadPluginDataJSON(%s, %s): %v", pluginID, name, err)
return make(map[string]interface{})
}
if a.storage == nil {
return make(map[string]interface{})
}
@ -434,6 +564,9 @@ func (a *App) ReadPluginDataJSON(pluginID, name string) map[string]interface{} {
// WritePluginDataJSON writes a named JSON data file for a plugin.
func (a *App) WritePluginDataJSON(pluginID, name string, data map[string]interface{}) string {
if _, err := a.requirePluginAccess(pluginID, "storage.namespace"); err != nil {
return err.Error()
}
if a.storage == nil {
return "storage not initialized"
}
@ -444,6 +577,278 @@ func (a *App) WritePluginDataJSON(pluginID, name string, data map[string]interfa
return ""
}
// ListVaultFiles lists a vault-relative directory for a plugin with files.read.
func (a *App) ListVaultFiles(pluginID, relativeDir string) ([]corefiles.FileEntry, string) {
if _, err := a.requirePluginAccess(pluginID, "files.read"); err != nil {
return nil, err.Error()
}
if a.files == nil {
return nil, "files service not initialized"
}
entries, err := a.files.ListVaultFiles(relativeDir)
if err != nil {
return nil, err.Error()
}
return entries, ""
}
// GetVaultFileMetadata returns metadata for a vault-relative path for a plugin with files.read.
func (a *App) GetVaultFileMetadata(pluginID, relativePath string) (corefiles.FileMetadata, string) {
if _, err := a.requirePluginAccess(pluginID, "files.read"); err != nil {
return corefiles.FileMetadata{}, err.Error()
}
if a.files == nil {
return corefiles.FileMetadata{}, "files service not initialized"
}
meta, err := a.files.GetVaultFileMetadata(relativePath)
if err != nil {
return corefiles.FileMetadata{}, err.Error()
}
return meta, ""
}
// ReadVaultTextFile reads a UTF-8 text file for a plugin with files.read.
func (a *App) ReadVaultTextFile(pluginID, relativePath string) (string, string) {
if _, err := a.requirePluginAccess(pluginID, "files.read"); err != nil {
return "", err.Error()
}
if a.files == nil {
return "", "files service not initialized"
}
text, err := a.files.ReadVaultTextFile(relativePath)
if err != nil {
return "", err.Error()
}
return text, ""
}
// WriteVaultTextFile atomically writes a UTF-8 text file for a plugin with files.write.
func (a *App) WriteVaultTextFile(pluginID, relativePath string, content string, options corefiles.WriteOptions) string {
if _, err := a.requirePluginAccess(pluginID, "files.write"); err != nil {
return err.Error()
}
if a.files == nil {
return "files service not initialized"
}
if err := a.files.WriteVaultTextFile(relativePath, content, options); err != nil {
return err.Error()
}
return ""
}
// CreateVaultFolder creates a vault-relative folder for a plugin with files.write.
func (a *App) CreateVaultFolder(pluginID, relativePath string) string {
if _, err := a.requirePluginAccess(pluginID, "files.write"); err != nil {
return err.Error()
}
if a.files == nil {
return "files service not initialized"
}
if err := a.files.CreateVaultFolder(relativePath); err != nil {
return err.Error()
}
return ""
}
// MoveVaultPath moves a vault-relative file or folder for a plugin with files.write.
func (a *App) MoveVaultPath(pluginID, fromRelativePath string, toRelativePath string, options corefiles.MoveOptions) string {
if _, err := a.requirePluginAccess(pluginID, "files.write"); err != nil {
return err.Error()
}
if a.files == nil {
return "files service not initialized"
}
if err := a.files.MoveVaultPath(fromRelativePath, toRelativePath, options); err != nil {
return err.Error()
}
return ""
}
// TrashVaultPath moves a vault-relative file or folder to internal trash for a plugin with files.delete.
func (a *App) TrashVaultPath(pluginID, relativePath string) (corefiles.TrashResult, string) {
if _, err := a.requirePluginAccess(pluginID, "files.delete"); err != nil {
return corefiles.TrashResult{}, err.Error()
}
if a.files == nil {
return corefiles.TrashResult{}, "files service not initialized"
}
result, err := a.files.TrashVaultPath(relativePath)
if err != nil {
return corefiles.TrashResult{}, err.Error()
}
return result, ""
}
func (a *App) activeOpenProviders() []contribution.ContributionOpenProvider {
if a.contribRegistry == nil {
return nil
}
providers := a.contribRegistry.OpenProviders()
active := make([]contribution.ContributionOpenProvider, 0, len(providers))
for _, provider := range providers {
p, err := a.findPlugin(provider.PluginID)
if err != nil {
continue
}
if !p.Enabled || (p.Status != plugin.StatusLoaded && p.Status != plugin.StatusDegraded) {
continue
}
active = append(active, provider)
}
return active
}
func decodeOpenResourceRequest(raw map[string]interface{}) (coreworkbench.OpenResourceRequest, error) {
data, err := json.Marshal(raw)
if err != nil {
return coreworkbench.OpenResourceRequest{}, err
}
var request coreworkbench.OpenResourceRequest
if err := json.Unmarshal(data, &request); err != nil {
return coreworkbench.OpenResourceRequest{}, err
}
if request.Kind == "" {
return request, fmt.Errorf("resource kind is empty")
}
if request.Path == "" {
return request, fmt.Errorf("resource path is empty")
}
return request, nil
}
func (a *App) OpenWorkbenchResource(pluginID string, rawRequest map[string]interface{}) (coreworkbench.OpenResourceResult, string) {
if _, err := a.requirePluginAccess(pluginID, "workbench.open"); err != nil {
return coreworkbench.OpenResourceResult{}, err.Error()
}
request, err := decodeOpenResourceRequest(rawRequest)
if err != nil {
return coreworkbench.OpenResourceResult{}, err.Error()
}
if request.Context.SourcePluginID == "" {
request.Context.SourcePluginID = pluginID
}
result, err := a.ensureWorkbench().OpenResource(request, a.activeOpenProviders())
if err != nil {
return coreworkbench.OpenResourceResult{}, err.Error()
}
return result, ""
}
func (a *App) EditWorkbenchResource(pluginID string, rawRequest map[string]interface{}) (coreworkbench.OpenResourceResult, string) {
if rawRequest == nil {
rawRequest = map[string]interface{}{}
}
rawRequest["mode"] = "edit"
return a.OpenWorkbenchResource(pluginID, rawRequest)
}
func (a *App) GetWorkbenchOpenedResources() []coreworkbench.OpenedResource {
return a.ensureWorkbench().OpenedResources()
}
func (a *App) GetWorkbenchPreferences() coreworkbench.Preferences {
return a.ensureWorkbench().Preferences()
}
func (a *App) UpdateWorkbenchPreferences(preferences coreworkbench.Preferences) string {
a.ensureWorkbench().SetPreferences(preferences)
if a.appSettings == nil {
return ""
}
if err := a.appSettings.Update(&appsettings.Config{Workbench: appSettingsWorkbenchPrefs(preferences)}); err != nil {
return err.Error()
}
return ""
}
// ListPluginCapabilities returns the current capability registry for an enabled plugin.
func (a *App) ListPluginCapabilities(pluginID string) ([]capability.Entry, string) {
if _, err := a.requirePluginCapabilityAccess(pluginID, "verstak/core/capability-registry/v1"); err != nil {
return nil, err.Error()
}
if a.capRegistry == nil {
return nil, "capability registry not initialized"
}
return a.capRegistry.List(), ""
}
// GetPluginCapability returns a single capability lookup for an enabled plugin.
func (a *App) GetPluginCapability(pluginID, capabilityName string) (map[string]interface{}, string) {
if _, err := a.requirePluginCapabilityAccess(pluginID, "verstak/core/capability-registry/v1"); err != nil {
return map[string]interface{}{"available": false}, err.Error()
}
if a.capRegistry == nil {
return map[string]interface{}{"available": false}, "capability registry not initialized"
}
entry := a.capRegistry.Get(capabilityName)
if entry == nil {
return map[string]interface{}{"available": false, "name": capabilityName}, ""
}
return map[string]interface{}{
"available": true,
"name": entry.Name,
"pluginId": entry.PluginID,
"status": entry.Status,
}, ""
}
// ExecutePluginCommand validates that a command is declared by the plugin.
// Actual handler execution is intentionally deferred until sidecar/RPC exists.
func (a *App) ExecutePluginCommand(pluginID, commandID string, args map[string]interface{}) (map[string]interface{}, string) {
if _, err := a.requirePluginAccess(pluginID, "commands.register"); err != nil {
return nil, err.Error()
}
if a.contribRegistry == nil {
return nil, "contribution registry not initialized"
}
for _, command := range a.contribRegistry.Commands() {
if command.PluginID == pluginID && command.Item.ID == commandID {
return map[string]interface{}{
"status": "declared",
"pluginId": pluginID,
"commandId": commandID,
"handler": command.Item.Handler,
"args": args,
}, ""
}
}
return nil, fmt.Sprintf("command %q is not declared by plugin %q", commandID, pluginID)
}
// PublishPluginEvent validates publish permission and emits to the in-process bus.
func (a *App) PublishPluginEvent(pluginID, eventName string, payload map[string]interface{}) string {
if _, err := a.requirePluginAccess(pluginID, "events.publish"); err != nil {
return err.Error()
}
if eventName == "" {
return "event name is empty"
}
if payload == nil {
payload = make(map[string]interface{})
}
payload["pluginId"] = pluginID
if a.eventBus != nil {
a.eventBus.Publish(events.Event{
Name: eventName,
Timestamp: time.Now().UTC().Format(time.RFC3339Nano),
Payload: payload,
})
}
return ""
}
// SubscribePluginEvent validates subscribe permission for a bundled frontend plugin.
// Actual bundled event dispatch is handled by the frontend plugin host event bus.
func (a *App) SubscribePluginEvent(pluginID, eventName string) string {
if _, err := a.requirePluginAccess(pluginID, "events.subscribe"); err != nil {
return err.Error()
}
if eventName == "" {
return "event name is empty"
}
return ""
}
// ─── App Settings API ──────────────────────────────────────
// GetAppSettings returns the current app settings.
@ -510,13 +915,11 @@ func (a *App) SetCurrentVault(path string) string {
log.Printf("[api] SetCurrentVault: warning loading plugin state: %v", err)
}
}
// Load workspace for the vault
if a.workspace != nil {
// Replace workspace manager with one pointing to the new vault
a.workspace = workspace.NewManager(vaultPath)
if err := a.workspace.Load(); err != nil {
log.Printf("[api] SetCurrentVault: warning loading workspace: %v", err)
}
// Load workspace for the vault. This also handles first-run startup,
// where no workspace manager exists until a vault is selected.
a.workspace = workspace.NewManager(vaultPath)
if err := a.workspace.Load(); err != nil {
log.Printf("[api] SetCurrentVault: warning loading workspace: %v", err)
}
// Register vault capability
if err := a.capRegistry.Register("verstak-desktop", []string{"verstak/core/vault/v1"}); err != nil {
@ -679,6 +1082,13 @@ func (a *App) RecordDesiredPlugin(pluginID, version, source string) string {
return ""
}
// WriteFrontendLog writes a frontend debug message to the backend debug log.
func (a *App) WriteFrontendLog(component, message string) {
if a.debug {
debug.Logf("[frontend][%s] %s", component, message)
}
}
// ─── Dialog API ─────────────────────────────────────────────
// SelectDirectory opens a native directory picker dialog.

View File

@ -6,7 +6,15 @@ import (
"strings"
"testing"
"github.com/verstak/verstak-desktop/internal/core/appsettings"
"github.com/verstak/verstak-desktop/internal/core/capability"
"github.com/verstak/verstak-desktop/internal/core/contribution"
"github.com/verstak/verstak-desktop/internal/core/events"
corefiles "github.com/verstak/verstak-desktop/internal/core/files"
"github.com/verstak/verstak-desktop/internal/core/plugin"
"github.com/verstak/verstak-desktop/internal/core/storage"
"github.com/verstak/verstak-desktop/internal/core/vault"
"github.com/verstak/verstak-desktop/internal/core/workspace"
)
// newTestApp creates an App with a mocked plugin list for testing.
@ -45,6 +53,31 @@ func newTestApp(tmpRoot string) *App {
}
}
func newFilesTestApp(t *testing.T, perms []string) (*App, string) {
t.Helper()
v := vault.NewVault(nil)
if err := v.CreateVault(t.TempDir()); err != nil {
t.Fatalf("CreateVault: %v", err)
}
return &App{
files: corefiles.NewService(v),
vault: v,
plugins: []plugin.Plugin{
{
Manifest: plugin.Manifest{
ID: "files.plugin",
Name: "Files Plugin",
Version: "1.0.0",
Provides: []string{"files/plugin/v1"},
Permissions: perms,
},
Status: plugin.StatusLoaded,
Enabled: true,
},
},
}, v.GetVaultPath()
}
// TestGetPluginFrontendInfo_KnownPluginWithFrontend verifies that
// GetPluginFrontendInfo returns correct metadata for a plugin with a frontend.
func TestGetPluginFrontendInfo_KnownPluginWithFrontend(t *testing.T) {
@ -255,3 +288,443 @@ func TestGetPluginAssetContent_NonexistentFile(t *testing.T) {
t.Errorf("error should mention 'failed to read', got: %s", errStr)
}
}
func TestFilesBridgeReadWriteListMoveTrash(t *testing.T) {
app, root := newFilesTestApp(t, []string{"files.read", "files.write", "files.delete"})
if errStr := app.CreateVaultFolder("files.plugin", "Docs"); errStr != "" {
t.Fatalf("CreateVaultFolder: %s", errStr)
}
if errStr := app.WriteVaultTextFile("files.plugin", "Docs/one.txt", "hello", corefiles.WriteOptions{CreateIfMissing: true}); errStr != "" {
t.Fatalf("WriteVaultTextFile: %s", errStr)
}
text, errStr := app.ReadVaultTextFile("files.plugin", "Docs/one.txt")
if errStr != "" {
t.Fatalf("ReadVaultTextFile: %s", errStr)
}
if text != "hello" {
t.Fatalf("text = %q", text)
}
entries, errStr := app.ListVaultFiles("files.plugin", "Docs")
if errStr != "" {
t.Fatalf("ListVaultFiles: %s", errStr)
}
if len(entries) != 1 || entries[0].RelativePath != "Docs/one.txt" {
t.Fatalf("entries = %+v", entries)
}
meta, errStr := app.GetVaultFileMetadata("files.plugin", "Docs/one.txt")
if errStr != "" {
t.Fatalf("GetVaultFileMetadata: %s", errStr)
}
if meta.Type != corefiles.FileTypeFile || !meta.IsText {
t.Fatalf("metadata = %+v", meta)
}
if errStr := app.MoveVaultPath("files.plugin", "Docs/one.txt", "Docs/two.txt", corefiles.MoveOptions{}); errStr != "" {
t.Fatalf("MoveVaultPath: %s", errStr)
}
trash, errStr := app.TrashVaultPath("files.plugin", "Docs/two.txt")
if errStr != "" {
t.Fatalf("TrashVaultPath: %s", errStr)
}
if trash.OriginalPath != "Docs/two.txt" || trash.TrashID == "" {
t.Fatalf("trash result = %+v", trash)
}
if _, err := os.Stat(filepath.Join(root, trash.TrashPath)); err != nil {
t.Fatalf("trash path missing: %v", err)
}
}
func TestFilesBridgePermissions(t *testing.T) {
cases := []struct {
name string
perms []string
call func(*App) string
wantPhrase string
}{
{
name: "list requires read",
perms: []string{"files.write", "files.delete"},
call: func(app *App) string { _, errStr := app.ListVaultFiles("files.plugin", ""); return errStr },
wantPhrase: "files.read",
},
{
name: "metadata requires read",
perms: []string{"files.write", "files.delete"},
call: func(app *App) string { _, errStr := app.GetVaultFileMetadata("files.plugin", "one.txt"); return errStr },
wantPhrase: "files.read",
},
{
name: "read requires read",
perms: []string{"files.write", "files.delete"},
call: func(app *App) string { _, errStr := app.ReadVaultTextFile("files.plugin", "one.txt"); return errStr },
wantPhrase: "files.read",
},
{
name: "write requires write",
perms: []string{"files.read", "files.delete"},
call: func(app *App) string {
return app.WriteVaultTextFile("files.plugin", "one.txt", "x", corefiles.WriteOptions{CreateIfMissing: true})
},
wantPhrase: "files.write",
},
{
name: "create folder requires write",
perms: []string{"files.read", "files.delete"},
call: func(app *App) string { return app.CreateVaultFolder("files.plugin", "Folder") },
wantPhrase: "files.write",
},
{
name: "move requires write",
perms: []string{"files.read", "files.delete"},
call: func(app *App) string {
return app.MoveVaultPath("files.plugin", "one.txt", "two.txt", corefiles.MoveOptions{})
},
wantPhrase: "files.write",
},
{
name: "trash requires delete",
perms: []string{"files.read", "files.write"},
call: func(app *App) string { _, errStr := app.TrashVaultPath("files.plugin", "one.txt"); return errStr },
wantPhrase: "files.delete",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
app, _ := newFilesTestApp(t, tc.perms)
errStr := tc.call(app)
if errStr == "" {
t.Fatal("expected permission error")
}
if !strings.Contains(errStr, tc.wantPhrase) {
t.Fatalf("error = %q, want %q", errStr, tc.wantPhrase)
}
})
}
}
func TestFilesBridgeRequiresLoadedPluginAndOpenVault(t *testing.T) {
app, _ := newFilesTestApp(t, []string{"files.read"})
app.plugins[0].Enabled = false
if _, errStr := app.ListVaultFiles("files.plugin", ""); errStr == "" || !strings.Contains(errStr, "not enabled") {
t.Fatalf("disabled plugin error = %q", errStr)
}
app, _ = newFilesTestApp(t, []string{"files.read"})
app.plugins[0].Status = plugin.StatusFailed
if _, errStr := app.ListVaultFiles("files.plugin", ""); errStr == "" || !strings.Contains(errStr, "not enabled") {
t.Fatalf("failed plugin error = %q", errStr)
}
app, _ = newFilesTestApp(t, []string{"files.read"})
app.plugins[0].Status = plugin.StatusDegraded
if _, errStr := app.ListVaultFiles("files.plugin", ""); errStr != "" {
t.Fatalf("degraded plugin should be allowed, got %q", errStr)
}
app, _ = newFilesTestApp(t, []string{"files.read"})
if _, errStr := app.ListVaultFiles("missing.plugin", ""); errStr == "" || !strings.Contains(errStr, "not found") {
t.Fatalf("missing plugin error = %q", errStr)
}
app, _ = newFilesTestApp(t, []string{"files.read"})
app.vault.CloseVault()
if _, errStr := app.ListVaultFiles("files.plugin", ""); errStr == "" || !strings.Contains(errStr, "vault-not-open") {
t.Fatalf("closed vault error = %q", errStr)
}
}
func TestSetCurrentVaultInitializesWorkspaceWhenMissingAtStartup(t *testing.T) {
tmpDir := t.TempDir()
vaultParent := filepath.Join(tmpDir, "vault-parent")
if err := os.MkdirAll(vaultParent, 0o755); err != nil {
t.Fatal(err)
}
bus := events.NewBus()
vaultService := vault.NewVault(bus)
if err := vaultService.CreateVault(vaultParent); err != nil {
t.Fatalf("CreateVault: %v", err)
}
vaultService.CloseVault()
settings := appsettings.NewManager(filepath.Join(tmpDir, "config.json"))
if err := settings.Load(); err != nil {
t.Fatalf("settings Load: %v", err)
}
app := &App{
capRegistry: capability.NewRegistry(),
vault: vaultService,
appSettings: settings,
workspace: nil,
}
if errStr := app.SetCurrentVault(vaultParent); errStr != "" {
t.Fatalf("SetCurrentVault: %s", errStr)
}
tree := app.GetWorkspaceTree()
if tree["status"] == "not initialized" {
t.Fatal("workspace should be initialized after SetCurrentVault")
}
nodes, ok := tree["nodes"].([]workspace.WorkspaceNode)
if !ok {
t.Fatalf("workspace nodes type: got %T", tree["nodes"])
}
if len(nodes) == 0 {
t.Fatal("workspace nodes should not be empty")
}
if !app.capRegistry.Has("verstak/core/workspace/v1") {
t.Fatal("workspace capability should be registered after SetCurrentVault")
}
}
func newBridgeTestApp(t *testing.T) *App {
t.Helper()
tmpDir := t.TempDir()
vaultParent := filepath.Join(tmpDir, "vault-parent")
if err := os.MkdirAll(vaultParent, 0o755); err != nil {
t.Fatal(err)
}
bus := events.NewBus()
vaultService := vault.NewVault(bus)
if err := vaultService.CreateVault(vaultParent); err != nil {
t.Fatalf("CreateVault: %v", err)
}
capReg := capability.NewRegistry()
if err := capReg.Register("verstak-desktop", []string{"verstak/core/vault/v1"}); err != nil {
t.Fatal(err)
}
if err := capReg.Register("bridge.plugin", []string{"bridge/cap/v1"}); err != nil {
t.Fatal(err)
}
contribReg := contribution.NewRegistry()
contribReg.Register("bridge.plugin", &plugin.Contributions{
Commands: []plugin.ContributionCommand{
{ID: "bridge.command", Title: "Bridge Command", Handler: "runBridgeCommand"},
},
OpenProviders: []plugin.ContributionOpenProvider{
{
ID: "bridge.markdown",
Title: "Bridge Markdown",
Priority: 100,
Component: "BridgeMarkdown",
Supports: []plugin.OpenProviderSupport{
{Kind: "vault-file", Extensions: []string{".md"}, Contexts: []string{"generic-markdown", "notes-markdown"}},
},
},
},
})
return &App{
capRegistry: capReg,
contribRegistry: contribReg,
eventBus: bus,
vault: vaultService,
storage: storage.New(vaultService),
plugins: []plugin.Plugin{
{
Manifest: plugin.Manifest{
ID: "bridge.plugin",
Name: "Bridge Plugin",
Version: "1.0.0",
Provides: []string{"bridge/cap/v1"},
Requires: []string{"verstak/core/capability-registry/v1"},
Permissions: []string{"storage.namespace", "commands.register", "events.publish", "events.subscribe", "workbench.open"},
},
Status: plugin.StatusLoaded,
Enabled: true,
},
{
Manifest: plugin.Manifest{
ID: "no.storage",
Name: "No Storage",
Version: "1.0.0",
Provides: []string{"no/storage/v1"},
Permissions: []string{"events.publish"},
},
Status: plugin.StatusLoaded,
Enabled: true,
},
{
Manifest: plugin.Manifest{
ID: "disabled.plugin",
Name: "Disabled",
Version: "1.0.0",
Provides: []string{"disabled/cap/v1"},
Permissions: []string{"storage.namespace"},
},
Status: plugin.StatusDisabled,
Enabled: false,
},
},
}
}
func TestContributionSummaryIncludesOpenProviders(t *testing.T) {
app := newBridgeTestApp(t)
summary := app.GetContributions()
if len(summary.OpenProviders) != 1 {
t.Fatalf("OpenProviders count = %d, want 1", len(summary.OpenProviders))
}
provider := summary.OpenProviders[0]
if provider.PluginID != "bridge.plugin" || provider.ID != "bridge.markdown" || provider.Component != "BridgeMarkdown" {
t.Fatalf("provider = %+v", provider)
}
if len(provider.Supports) != 1 || provider.Supports[0].Contexts[1] != "notes-markdown" {
t.Fatalf("supports = %+v", provider.Supports)
}
}
func TestWorkbenchOpenAndEditResourceRouteToProvider(t *testing.T) {
app := newBridgeTestApp(t)
app.contribRegistry.Register("disabled.plugin", &plugin.Contributions{
OpenProviders: []plugin.ContributionOpenProvider{
{
ID: "disabled.markdown",
Title: "Disabled Markdown",
Priority: 1000,
Component: "DisabledMarkdown",
Supports: []plugin.OpenProviderSupport{
{Kind: "vault-file", Extensions: []string{".md"}, Contexts: []string{"generic-markdown", "notes-markdown"}},
},
},
},
})
result, errStr := app.OpenWorkbenchResource("bridge.plugin", map[string]interface{}{
"kind": "vault-file",
"path": "Notes/Overview.md",
"extension": ".md",
"context": map[string]interface{}{
"sourceView": "notes",
"isInsideNotesFolder": true,
"notesMode": true,
},
})
if errStr != "" {
t.Fatalf("OpenWorkbenchResource: %s", errStr)
}
if result.ProviderID != "bridge.markdown" || result.ProviderComponent != "BridgeMarkdown" || result.Request.Mode != "view" {
t.Fatalf("open result = %+v", result)
}
editResult, errStr := app.EditWorkbenchResource("bridge.plugin", map[string]interface{}{
"kind": "vault-file",
"path": "Notes/Overview.md",
"extension": ".md",
"context": map[string]interface{}{
"sourceView": "notes",
"isInsideNotesFolder": true,
"notesMode": true,
},
})
if errStr != "" {
t.Fatalf("EditWorkbenchResource: %s", errStr)
}
if editResult.Request.Mode != "edit" {
t.Fatalf("edit mode = %q", editResult.Request.Mode)
}
opened := app.GetWorkbenchOpenedResources()
if len(opened) != 2 {
t.Fatalf("opened resources = %+v", opened)
}
}
func TestWorkbenchOpenResourceReturnsNoProviderFallback(t *testing.T) {
app := newBridgeTestApp(t)
result, errStr := app.OpenWorkbenchResource("bridge.plugin", map[string]interface{}{
"kind": "vault-file",
"path": "Images/logo.png",
})
if errStr != "" {
t.Fatalf("OpenWorkbenchResource: %s", errStr)
}
if result.Status != "no-provider" || result.Request.Path != "Images/logo.png" {
t.Fatalf("result = %+v", result)
}
}
func TestWorkbenchOpenResourceRequiresPermission(t *testing.T) {
app := newBridgeTestApp(t)
_, errStr := app.OpenWorkbenchResource("no.storage", map[string]interface{}{
"kind": "vault-file",
"path": "Docs/readme.md",
})
if !strings.Contains(errStr, "workbench.open") {
t.Fatalf("err = %q, want workbench.open permission error", errStr)
}
}
func TestPluginBridgeSettingsRequireLoadedPluginAndStoragePermission(t *testing.T) {
app := newBridgeTestApp(t)
if errStr := app.WritePluginSettings("bridge.plugin", map[string]interface{}{"savedText": "hello"}); errStr != "" {
t.Fatalf("WritePluginSettings: %s", errStr)
}
settings, errStr := app.ReadPluginSettings("bridge.plugin")
if errStr != "" {
t.Fatalf("ReadPluginSettings: %s", errStr)
}
if settings["savedText"] != "hello" {
t.Fatalf("savedText = %v, want hello", settings["savedText"])
}
if _, errStr := app.ReadPluginSettings("missing.plugin"); errStr == "" {
t.Fatal("expected error for missing plugin")
}
if _, errStr := app.ReadPluginSettings("disabled.plugin"); errStr == "" {
t.Fatal("expected error for disabled plugin")
}
if _, errStr := app.ReadPluginSettings("no.storage"); errStr == "" {
t.Fatal("expected error for plugin without storage.namespace")
}
}
func TestPluginBridgeCapabilitiesCommandsAndEventsAreChecked(t *testing.T) {
app := newBridgeTestApp(t)
capInfo, errStr := app.GetPluginCapability("bridge.plugin", "bridge/cap/v1")
if errStr != "" {
t.Fatalf("GetPluginCapability: %s", errStr)
}
if capInfo["available"] != true {
t.Fatalf("capability should be available: %#v", capInfo)
}
if _, errStr := app.GetPluginCapability("no.storage", "bridge/cap/v1"); errStr == "" {
t.Fatal("expected capability dependency error")
}
commandResult, errStr := app.ExecutePluginCommand("bridge.plugin", "bridge.command", map[string]interface{}{"value": "x"})
if errStr != "" {
t.Fatalf("ExecutePluginCommand: %s", errStr)
}
if commandResult["status"] != "declared" {
t.Fatalf("command status = %v, want declared", commandResult["status"])
}
if errStr := app.PublishPluginEvent("bridge.plugin", "bridge.event", map[string]interface{}{"ok": true}); errStr != "" {
t.Fatalf("PublishPluginEvent: %s", errStr)
}
if errStr := app.SubscribePluginEvent("bridge.plugin", "bridge.event"); errStr != "" {
t.Fatalf("SubscribePluginEvent: %s", errStr)
}
if errStr := app.SubscribePluginEvent("no.storage", "bridge.event"); errStr == "" {
t.Fatal("expected subscribe permission error")
}
if _, errStr := app.ExecutePluginCommand("no.storage", "bridge.command", nil); errStr == "" {
t.Fatal("expected command permission/ownership error")
}
}

View File

@ -14,14 +14,21 @@ import (
// Config represents the application settings stored in ~/.config/verstak/config.json.
type Config struct {
SchemaVersion int `json:"schemaVersion"`
CurrentVaultPath string `json:"currentVaultPath"`
RecentVaults []string `json:"recentVaults"`
Theme string `json:"theme"`
DevMode bool `json:"devMode"`
UserPluginsDir string `json:"userPluginsDir"`
WindowState *WindowState `json:"windowState,omitempty"`
LastOpenedAt string `json:"lastOpenedAt"`
SchemaVersion int `json:"schemaVersion"`
CurrentVaultPath string `json:"currentVaultPath"`
RecentVaults []string `json:"recentVaults"`
Theme string `json:"theme"`
DevMode bool `json:"devMode"`
UserPluginsDir string `json:"userPluginsDir"`
Workbench WorkbenchPreferences `json:"workbench,omitempty"`
WindowState *WindowState `json:"windowState,omitempty"`
LastOpenedAt string `json:"lastOpenedAt"`
}
type WorkbenchPreferences struct {
DefaultTextEditorProvider string `json:"defaultTextEditorProvider,omitempty"`
DefaultMarkdownEditorProvider string `json:"defaultMarkdownEditorProvider,omitempty"`
DefaultNotesMarkdownEditorProvider string `json:"defaultNotesMarkdownEditorProvider,omitempty"`
}
// WindowState stores the last window position and size.
@ -156,6 +163,15 @@ func (m *Manager) Update(patch *Config) error {
if patch.WindowState != nil {
m.config.WindowState = patch.WindowState
}
if patch.Workbench.DefaultTextEditorProvider != "" {
m.config.Workbench.DefaultTextEditorProvider = patch.Workbench.DefaultTextEditorProvider
}
if patch.Workbench.DefaultMarkdownEditorProvider != "" {
m.config.Workbench.DefaultMarkdownEditorProvider = patch.Workbench.DefaultMarkdownEditorProvider
}
if patch.Workbench.DefaultNotesMarkdownEditorProvider != "" {
m.config.Workbench.DefaultNotesMarkdownEditorProvider = patch.Workbench.DefaultNotesMarkdownEditorProvider
}
m.config.DevMode = patch.DevMode
m.config.LastOpenedAt = time.Now().UTC().Format(time.RFC3339)
@ -201,6 +217,7 @@ func defaultConfig() *Config {
Theme: "dark",
DevMode: false,
UserPluginsDir: filepath.Join(os.Getenv("HOME"), ".config", "verstak", "plugins"),
Workbench: WorkbenchPreferences{},
WindowState: &WindowState{Width: 1200, Height: 800},
LastOpenedAt: time.Now().UTC().Format(time.RFC3339),
}
@ -216,6 +233,7 @@ func copyConfig(c *Config) *Config {
Theme: c.Theme,
DevMode: c.DevMode,
UserPluginsDir: c.UserPluginsDir,
Workbench: c.Workbench,
LastOpenedAt: c.LastOpenedAt,
}
if c.WindowState != nil {

View File

@ -124,6 +124,37 @@ func TestUpdate_Patch(t *testing.T) {
}
}
func TestUpdate_WorkbenchPreferences(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
m := NewManager(path)
if err := m.Load(); err != nil {
t.Fatal(err)
}
if err := m.Update(&Config{
Workbench: WorkbenchPreferences{
DefaultTextEditorProvider: "editor.text",
DefaultMarkdownEditorProvider: "editor.markdown",
DefaultNotesMarkdownEditorProvider: "editor.notes",
},
}); err != nil {
t.Fatalf("Update: %v", err)
}
reloaded := NewManager(path)
if err := reloaded.Load(); err != nil {
t.Fatal(err)
}
cfg := reloaded.Get()
if cfg.Workbench.DefaultTextEditorProvider != "editor.text" ||
cfg.Workbench.DefaultMarkdownEditorProvider != "editor.markdown" ||
cfg.Workbench.DefaultNotesMarkdownEditorProvider != "editor.notes" {
t.Fatalf("workbench preferences = %+v", cfg.Workbench)
}
}
func TestAppSettings_NotInsideVault(t *testing.T) {
// App settings path should be under ~/.config/verstak/, not inside vault
path := DefaultConfigPath()

View File

@ -22,6 +22,7 @@ type Registry struct {
searchProviders []ContributionSearchProvider
activityProviders []ContributionActivityProvider
statusBarItems []ContributionStatusBarItem
openProviders []ContributionOpenProvider
}
// ContributionPointType defines the type of contribution point.
@ -38,6 +39,7 @@ const (
PointSearchProviders ContributionPointType = "searchProviders"
PointActivity ContributionPointType = "activityProviders"
PointStatusBar ContributionPointType = "statusBarItems"
PointOpenProviders ContributionPointType = "openProviders"
)
// ListByPoint returns all contributions for a given point type.
@ -87,6 +89,10 @@ func (r *Registry) ListByPoint(point ContributionPointType) []interface{} {
for _, v := range r.statusBarItems {
result = append(result, v)
}
case PointOpenProviders:
for _, v := range r.openProviders {
result = append(result, v)
}
}
return result
}
@ -136,6 +142,11 @@ type ContributionStatusBarItem struct {
Item plugin.ContributionStatusBarItem `json:"item"`
}
type ContributionOpenProvider struct {
PluginID string `json:"pluginId"`
Item plugin.ContributionOpenProvider `json:"item"`
}
// NewRegistry creates a new contribution registry.
func NewRegistry() *Registry {
return &Registry{}
@ -159,6 +170,7 @@ func (r *Registry) Register(pluginID string, c *plugin.Contributions) {
r.searchProviders = removeSearchProviders(r.searchProviders, pluginID)
r.activityProviders = removeActivityProviders(r.activityProviders, pluginID)
r.statusBarItems = removeStatusBarItems(r.statusBarItems, pluginID)
r.openProviders = removeOpenProviders(r.openProviders, pluginID)
for _, item := range c.Views {
r.views = append(r.views, ContributionView{PluginID: pluginID, Item: item})
@ -190,6 +202,9 @@ func (r *Registry) Register(pluginID string, c *plugin.Contributions) {
for _, item := range c.StatusBarItems {
r.statusBarItems = append(r.statusBarItems, ContributionStatusBarItem{PluginID: pluginID, Item: item})
}
for _, item := range c.OpenProviders {
r.openProviders = append(r.openProviders, ContributionOpenProvider{PluginID: pluginID, Item: item})
}
}
// Unregister removes all contributions from a plugin.
@ -207,6 +222,7 @@ func (r *Registry) Unregister(pluginID string) {
r.searchProviders = removeSearchProviders(r.searchProviders, pluginID)
r.activityProviders = removeActivityProviders(r.activityProviders, pluginID)
r.statusBarItems = removeStatusBarItems(r.statusBarItems, pluginID)
r.openProviders = removeOpenProviders(r.openProviders, pluginID)
}
// Getters — sorted for deterministic display.
@ -274,6 +290,20 @@ func (r *Registry) SearchProviders() []ContributionSearchProvider {
return result
}
func (r *Registry) OpenProviders() []ContributionOpenProvider {
r.mu.RLock()
defer r.mu.RUnlock()
result := make([]ContributionOpenProvider, len(r.openProviders))
copy(result, r.openProviders)
sort.Slice(result, func(i, j int) bool {
if result[i].PluginID != result[j].PluginID {
return result[i].PluginID < result[j].PluginID
}
return result[i].Item.ID < result[j].Item.ID
})
return result
}
// ─── Remove helpers ─────────────────────────────────────────
func removeViews(items []ContributionView, pluginID string) []ContributionView {
@ -365,3 +395,13 @@ func removeStatusBarItems(items []ContributionStatusBarItem, pluginID string) []
}
return result
}
func removeOpenProviders(items []ContributionOpenProvider, pluginID string) []ContributionOpenProvider {
var result []ContributionOpenProvider
for _, item := range items {
if item.PluginID != pluginID {
result = append(result, item)
}
}
return result
}

View File

@ -184,6 +184,17 @@ func TestListByPoint(t *testing.T) {
SearchProviders: []plugin.ContributionSearchProvider{{ID: "sp1", Label: "SP1", Handler: "h"}},
ActivityProviders: []plugin.ContributionActivityProvider{{ID: "ap1", Events: []string{"test"}, Handler: "h"}},
StatusBarItems: []plugin.ContributionStatusBarItem{{ID: "sb1", Label: "SB1"}},
OpenProviders: []plugin.ContributionOpenProvider{{
ID: "op1",
Title: "Open Provider 1",
Priority: 100,
Component: "OpenProvider",
Supports: []plugin.OpenProviderSupport{{
Kind: "vault-file",
Extensions: []string{".md"},
Contexts: []string{"generic-markdown", "notes-markdown"},
}},
}},
}
r.Register("test.plugin", contrib)
@ -202,6 +213,7 @@ func TestListByPoint(t *testing.T) {
{PointSearchProviders, 1},
{PointActivity, 1},
{PointStatusBar, 1},
{PointOpenProviders, 1},
}
for _, tt := range tests {
@ -212,6 +224,55 @@ func TestListByPoint(t *testing.T) {
}
}
func TestOpenProviders_RegisterReplaceUnregister(t *testing.T) {
r := NewRegistry()
r.Register("editor.plugin", &plugin.Contributions{
OpenProviders: []plugin.ContributionOpenProvider{{
ID: "editor.markdown",
Title: "Markdown",
Priority: 50,
Component: "MarkdownEditor",
Supports: []plugin.OpenProviderSupport{{
Kind: "vault-file",
Extensions: []string{".md", ".markdown"},
Contexts: []string{"generic-markdown", "notes-markdown"},
}},
}},
})
providers := r.OpenProviders()
if len(providers) != 1 {
t.Fatalf("OpenProviders count = %d, want 1", len(providers))
}
if providers[0].PluginID != "editor.plugin" || providers[0].Item.Component != "MarkdownEditor" {
t.Fatalf("provider = %+v", providers[0])
}
r.Register("editor.plugin", &plugin.Contributions{
OpenProviders: []plugin.ContributionOpenProvider{{
ID: "editor.text",
Title: "Text",
Priority: 10,
Component: "TextEditor",
Supports: []plugin.OpenProviderSupport{{
Kind: "vault-file",
Extensions: []string{".txt"},
}},
}},
})
providers = r.OpenProviders()
if len(providers) != 1 || providers[0].Item.ID != "editor.text" {
t.Fatalf("providers after replace = %+v", providers)
}
r.Unregister("editor.plugin")
if got := len(r.OpenProviders()); got != 0 {
t.Fatalf("OpenProviders after unregister = %d, want 0", got)
}
}
// TestRegister_DuplicatePrevention calls Register twice for the same plugin
// (simulating reload) and checks contributions appear only once (no duplicates).
// This is the KEY TEST for idempotent re-registration.

View File

@ -0,0 +1,85 @@
package files
import (
"fmt"
"path"
"path/filepath"
"strings"
"unicode"
)
func NormalizeRelativeDir(relativeDir string) (string, error) {
return normalizeRelativePath(relativeDir, true)
}
func NormalizeRelativeFile(relativePath string) (string, error) {
return normalizeRelativePath(relativePath, false)
}
func IsReservedPath(relativePath string) bool {
normalized := strings.ReplaceAll(relativePath, "\\", "/")
cleaned := path.Clean(normalized)
if cleaned == "." {
cleaned = ""
}
if cleaned == "" {
return false
}
first := strings.Split(cleaned, "/")[0]
return strings.EqualFold(first, ".verstak")
}
func normalizeRelativePath(input string, allowRoot bool) (string, error) {
if strings.Contains(input, "\x00") {
return "", fmt.Errorf("invalid-path: null-byte")
}
if strings.Contains(input, "\\") {
return "", fmt.Errorf("invalid-path: backslash not allowed")
}
if looksAbsolute(input) {
return "", fmt.Errorf("invalid-path: absolute path rejected")
}
normalized := input
for _, part := range strings.Split(normalized, "/") {
if part == ".." {
return "", fmt.Errorf("invalid-path: path-traversal")
}
}
cleaned := path.Clean(normalized)
if cleaned == "." {
cleaned = ""
}
if cleaned == "" && !allowRoot {
return "", fmt.Errorf("invalid-path: empty path")
}
if cleaned == ".." || strings.HasPrefix(cleaned, "../") {
return "", fmt.Errorf("invalid-path: path-traversal")
}
if IsReservedPathNoNormalize(cleaned) {
return "", fmt.Errorf("reserved-path: .verstak is internal")
}
return cleaned, nil
}
func IsReservedPathNoNormalize(cleaned string) bool {
if cleaned == "" {
return false
}
first := strings.Split(cleaned, "/")[0]
return strings.EqualFold(first, ".verstak")
}
func looksAbsolute(input string) bool {
if input == "" {
return false
}
if filepath.IsAbs(input) || strings.HasPrefix(input, "/") || strings.HasPrefix(input, "\\\\") || strings.HasPrefix(input, "\\") {
return true
}
if len(input) >= 2 && input[1] == ':' && unicode.IsLetter(rune(input[0])) {
return true
}
return false
}

View File

@ -0,0 +1,93 @@
package files
import (
"strings"
"testing"
)
func TestNormalizeRelativeDirAllowsRootAndPreservesCase(t *testing.T) {
got, err := NormalizeRelativeDir("")
if err != nil {
t.Fatalf("NormalizeRelativeDir root: %v", err)
}
if got != "" {
t.Fatalf("root dir = %q, want empty", got)
}
got, err = NormalizeRelativeDir("Notes/Overview.md")
if err != nil {
t.Fatalf("NormalizeRelativeDir preserves case: %v", err)
}
if got != "Notes/Overview.md" {
t.Fatalf("path = %q, want Notes/Overview.md", got)
}
}
func TestNormalizeRelativeFileRejectsUnsafePaths(t *testing.T) {
cases := []string{
"",
"/etc/passwd",
"C:\\Users\\file.txt",
"C:/Windows/system.ini",
`\\server\share`,
"//server/share",
`..\secret`,
`folder\..\secret`,
"../outside.txt",
"folder/../../outside.txt",
`folder\sub/../../secret`,
`folder\sub`,
"bad\x00name.txt",
".verstak",
".verstak/",
".verstak/vault.json",
"./.verstak",
".verstak/trash",
"folder/../.verstak",
".Verstak",
}
for _, input := range cases {
t.Run(input, func(t *testing.T) {
_, err := NormalizeRelativeFile(input)
if err == nil {
t.Fatalf("NormalizeRelativeFile(%q): expected error", input)
}
})
}
}
func TestReservedPathPolicy(t *testing.T) {
if !IsReservedPath(".verstak") {
t.Fatal(".verstak should be reserved")
}
if !IsReservedPath(".verstak/trash/file.txt") {
t.Fatal(".verstak/trash/file.txt should be reserved")
}
if !IsReservedPath(".Verstak/trash/file.txt") {
t.Fatal(".Verstak/trash/file.txt should be reserved by case-insensitive policy")
}
if IsReservedPath("Notes/.verstak.md") {
t.Fatal("Notes/.verstak.md should not be reserved")
}
}
func TestNormalizeRelativeFileAcceptsOnlySlashSeparatedRelativePaths(t *testing.T) {
got, err := NormalizeRelativeFile("Notes/Overview.md")
if err != nil {
t.Fatalf("NormalizeRelativeFile valid slash path: %v", err)
}
if got != "Notes/Overview.md" {
t.Fatalf("path = %q, want Notes/Overview.md", got)
}
}
func TestPathPolicyErrorsAreReadable(t *testing.T) {
_, err := NormalizeRelativeFile("../outside.txt")
if err == nil {
t.Fatal("expected traversal error")
}
if !strings.Contains(err.Error(), "path-traversal") {
t.Fatalf("error = %q, want path-traversal", err.Error())
}
}

View File

@ -0,0 +1,472 @@
package files
import (
"encoding/json"
"fmt"
"io/fs"
"mime"
"os"
"path/filepath"
"strings"
"time"
"unicode/utf8"
"github.com/google/uuid"
"github.com/verstak/verstak-desktop/internal/core/vault"
)
type Service struct {
vault *vault.Vault
}
func NewService(v *vault.Vault) *Service {
return &Service{vault: v}
}
func (s *Service) ListVaultFiles(relativeDir string) ([]FileEntry, error) {
root, err := s.vaultRoot()
if err != nil {
return nil, err
}
rel, err := NormalizeRelativeDir(relativeDir)
if err != nil {
return nil, err
}
full, err := s.resolve(root, rel)
if err != nil {
return nil, err
}
if err := rejectSymlinkPath(root, rel, true); err != nil {
return nil, err
}
info, err := os.Stat(full)
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("not-found: %s", rel)
}
return nil, err
}
if !info.IsDir() {
return nil, fmt.Errorf("not-directory: %s", rel)
}
dirEntries, err := os.ReadDir(full)
if err != nil {
return nil, err
}
entries := make([]FileEntry, 0, len(dirEntries))
for _, dirEntry := range dirEntries {
childRel := joinRel(rel, dirEntry.Name())
if IsReservedPathNoNormalize(childRel) {
continue
}
info, err := dirEntry.Info()
if err != nil {
continue
}
entries = append(entries, makeEntry(childRel, info))
}
return entries, nil
}
func (s *Service) GetVaultFileMetadata(relativePath string) (FileMetadata, error) {
root, rel, full, err := s.resolveFile(relativePath)
if err != nil {
return FileMetadata{}, err
}
if err := rejectSymlinkPath(root, rel, false); err != nil {
return FileMetadata{}, err
}
info, err := os.Lstat(full)
if err != nil {
if os.IsNotExist(err) {
return FileMetadata{}, fmt.Errorf("not-found: %s", rel)
}
return FileMetadata{}, err
}
return makeMetadata(rel, info), nil
}
func (s *Service) ReadVaultTextFile(relativePath string) (string, error) {
root, rel, full, err := s.resolveFile(relativePath)
if err != nil {
return "", err
}
if err := rejectSymlinkPath(root, rel, true); err != nil {
return "", err
}
info, err := os.Lstat(full)
if err != nil {
if os.IsNotExist(err) {
return "", fmt.Errorf("not-found: %s", rel)
}
return "", err
}
if info.Mode()&os.ModeSymlink != 0 {
return "", fmt.Errorf("symlink-not-allowed: %s", rel)
}
if !info.Mode().IsRegular() {
return "", fmt.Errorf("not-regular-file: %s", rel)
}
if info.Size() > MaxTextFileBytes {
return "", fmt.Errorf("file-too-large: %s", rel)
}
data, err := os.ReadFile(full)
if err != nil {
return "", err
}
if !utf8.Valid(data) {
return "", fmt.Errorf("not-text-file: %s", rel)
}
return string(data), nil
}
func (s *Service) WriteVaultTextFile(relativePath string, content string, options WriteOptions) error {
root, rel, full, err := s.resolveFile(relativePath)
if err != nil {
return err
}
if err := rejectSymlinkPath(root, rel, true); err != nil {
return err
}
parent := filepath.Dir(full)
if info, err := os.Stat(parent); err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("parent-not-found: %s", pathDir(rel))
}
return err
} else if !info.IsDir() {
return fmt.Errorf("parent-not-directory: %s", pathDir(rel))
}
existing, err := os.Lstat(full)
if err == nil {
if existing.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("symlink-not-allowed: %s", rel)
}
if !existing.Mode().IsRegular() {
return fmt.Errorf("not-regular-file: %s", rel)
}
if !options.Overwrite {
return fmt.Errorf("conflict: %s", rel)
}
} else if os.IsNotExist(err) {
if !options.CreateIfMissing {
return fmt.Errorf("not-found: %s", rel)
}
} else {
return err
}
tmp, err := os.CreateTemp(parent, ".verstak-write-*")
if err != nil {
return err
}
tmpName := tmp.Name()
cleanup := true
defer func() {
if cleanup {
_ = os.Remove(tmpName)
}
}()
if _, err := tmp.WriteString(content); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Rename(tmpName, full); err != nil {
return err
}
cleanup = false
return nil
}
func (s *Service) CreateVaultFolder(relativePath string) error {
root, rel, full, err := s.resolveFile(relativePath)
if err != nil {
return err
}
if _, err := os.Lstat(full); err == nil {
return fmt.Errorf("conflict: %s", rel)
} else if !os.IsNotExist(err) {
return err
}
if err := rejectSymlinkPath(root, rel, true); err != nil {
return err
}
parent := filepath.Dir(full)
if info, err := os.Stat(parent); err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("parent-not-found: %s", pathDir(rel))
}
return err
} else if !info.IsDir() {
return fmt.Errorf("parent-not-directory: %s", pathDir(rel))
}
return os.Mkdir(full, 0o755)
}
func (s *Service) MoveVaultPath(fromRelativePath string, toRelativePath string, options MoveOptions) error {
root, fromRel, fromFull, err := s.resolveFile(fromRelativePath)
if err != nil {
return err
}
_, toRel, toFull, err := s.resolveFile(toRelativePath)
if err != nil {
return err
}
if fromRel == "" || toRel == "" {
return fmt.Errorf("invalid-path: cannot move root")
}
if err := rejectSymlinkPath(root, fromRel, true); err != nil {
return err
}
if err := rejectSymlinkPath(root, toRel, false); err != nil {
return err
}
fromInfo, err := os.Lstat(fromFull)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("not-found: %s", fromRel)
}
return err
}
if fromInfo.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("symlink-not-allowed: %s", fromRel)
}
if fromInfo.IsDir() && (toRel == fromRel || strings.HasPrefix(toRel, fromRel+"/")) {
return fmt.Errorf("move-into-self: %s -> %s", fromRel, toRel)
}
parent := filepath.Dir(toFull)
if info, err := os.Stat(parent); err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("parent-not-found: %s", pathDir(toRel))
}
return err
} else if !info.IsDir() {
return fmt.Errorf("parent-not-directory: %s", pathDir(toRel))
}
if _, err := os.Lstat(toFull); err == nil && !options.Overwrite {
return fmt.Errorf("conflict: %s", toRel)
} else if err != nil && !os.IsNotExist(err) {
return err
}
return os.Rename(fromFull, toFull)
}
func (s *Service) TrashVaultPath(relativePath string) (TrashResult, error) {
root, rel, full, err := s.resolveFile(relativePath)
if err != nil {
return TrashResult{}, err
}
if err := rejectSymlinkPath(root, rel, true); err != nil {
return TrashResult{}, err
}
info, err := os.Lstat(full)
if err != nil {
if os.IsNotExist(err) {
return TrashResult{}, fmt.Errorf("not-found: %s", rel)
}
return TrashResult{}, err
}
if info.Mode()&os.ModeSymlink != 0 {
return TrashResult{}, fmt.Errorf("symlink-not-allowed: %s", rel)
}
deletedAt := time.Now().UTC().Format(time.RFC3339Nano)
trashID := time.Now().UTC().Format("20060102T150405.000000000Z") + "-" + uuid.NewString()
trashRel := filepath.ToSlash(filepath.Join(".verstak", "trash", "files", trashID, filepath.Base(rel)))
trashFull := filepath.Join(root, filepath.FromSlash(trashRel))
if err := os.MkdirAll(filepath.Dir(trashFull), 0o755); err != nil {
return TrashResult{}, err
}
if err := os.Rename(full, trashFull); err != nil {
return TrashResult{}, err
}
result := TrashResult{
OriginalPath: rel,
TrashPath: trashRel,
TrashID: trashID,
DeletedAt: deletedAt,
}
meta := map[string]string{
"originalPath": rel,
"trashPath": trashRel,
"trashId": trashID,
"deletedAt": deletedAt,
"originalType": string(fileTypeFromInfo(info)),
"basename": filepath.Base(rel),
"type": string(fileTypeFromInfo(info)),
}
data, err := json.MarshalIndent(meta, "", " ")
if err != nil {
return TrashResult{}, err
}
if err := os.WriteFile(filepath.Join(root, ".verstak", "trash", "files", trashID, "metadata.json"), data, 0o644); err != nil {
return TrashResult{}, err
}
return result, nil
}
func (s *Service) vaultRoot() (string, error) {
if s == nil || s.vault == nil {
return "", fmt.Errorf("vault-not-initialized")
}
if s.vault.GetVaultStatus() != vault.StatusOpen {
return "", fmt.Errorf("vault-not-open")
}
root := s.vault.GetVaultPath()
if root == "" {
return "", fmt.Errorf("vault-not-open")
}
return root, nil
}
func (s *Service) resolveFile(relativePath string) (string, string, string, error) {
root, err := s.vaultRoot()
if err != nil {
return "", "", "", err
}
rel, err := NormalizeRelativeFile(relativePath)
if err != nil {
return "", "", "", err
}
full, err := s.resolve(root, rel)
return root, rel, full, err
}
func (s *Service) resolve(root, rel string) (string, error) {
full := filepath.Join(root, filepath.FromSlash(rel))
absRoot, err := filepath.Abs(root)
if err != nil {
return "", err
}
absFull, err := filepath.Abs(full)
if err != nil {
return "", err
}
relToRoot, err := filepath.Rel(absRoot, absFull)
if err != nil {
return "", err
}
if relToRoot == ".." || strings.HasPrefix(relToRoot, ".."+string(os.PathSeparator)) || filepath.IsAbs(relToRoot) {
return "", fmt.Errorf("invalid-path: path-traversal")
}
return absFull, nil
}
func rejectSymlinkPath(root, rel string, includeFinal bool) error {
if rel == "" {
return nil
}
parts := strings.Split(rel, "/")
limit := len(parts)
if !includeFinal {
limit--
}
current := root
for i := 0; i < limit; i++ {
current = filepath.Join(current, filepath.FromSlash(parts[i]))
info, err := os.Lstat(current)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
if info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("symlink-not-allowed: %s", strings.Join(parts[:i+1], "/"))
}
}
return nil
}
func makeEntry(rel string, info fs.FileInfo) FileEntry {
t := fileTypeFromInfo(info)
return FileEntry{
Name: info.Name(),
RelativePath: rel,
Type: t,
Size: sizeForType(t, info),
ModifiedAt: info.ModTime().UTC().Format(time.RFC3339Nano),
Extension: strings.TrimPrefix(filepath.Ext(info.Name()), "."),
IsHidden: strings.HasPrefix(info.Name(), "."),
IsReserved: IsReservedPathNoNormalize(rel),
CanRead: t == FileTypeFile || t == FileTypeFolder,
CanWrite: t == FileTypeFile || t == FileTypeFolder,
}
}
func makeMetadata(rel string, info fs.FileInfo) FileMetadata {
t := fileTypeFromInfo(info)
ext := strings.TrimPrefix(filepath.Ext(info.Name()), ".")
return FileMetadata{
RelativePath: rel,
Type: t,
Size: sizeForType(t, info),
ModifiedAt: info.ModTime().UTC().Format(time.RFC3339Nano),
Extension: ext,
MimeHint: mime.TypeByExtension(filepath.Ext(info.Name())),
IsText: isTextExtension(ext),
IsHidden: strings.HasPrefix(info.Name(), "."),
IsReserved: IsReservedPathNoNormalize(rel),
CanRead: t == FileTypeFile || t == FileTypeFolder,
CanWrite: t == FileTypeFile || t == FileTypeFolder,
}
}
func fileTypeFromInfo(info fs.FileInfo) FileType {
if info.Mode()&os.ModeSymlink != 0 {
return FileTypeSymlink
}
if info.IsDir() {
return FileTypeFolder
}
if info.Mode().IsRegular() {
return FileTypeFile
}
return FileTypeUnknown
}
func sizeForType(t FileType, info fs.FileInfo) int64 {
if t == FileTypeFolder {
return 0
}
return info.Size()
}
func isTextExtension(ext string) bool {
switch strings.ToLower(ext) {
case "txt", "md", "markdown", "json", "yaml", "yml", "toml", "csv", "log", "xml", "html", "css", "js", "ts", "svelte", "go":
return true
default:
return false
}
}
func joinRel(parent, name string) string {
if parent == "" {
return name
}
return parent + "/" + name
}
func pathDir(rel string) string {
dir := pathDirSlash(rel)
if dir == "." {
return ""
}
return dir
}
func pathDirSlash(rel string) string {
idx := strings.LastIndex(rel, "/")
if idx < 0 {
return "."
}
return rel[:idx]
}

View File

@ -0,0 +1,436 @@
package files
import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/verstak/verstak-desktop/internal/core/vault"
)
func newTestService(t *testing.T) (*Service, string) {
t.Helper()
v := vault.NewVault(nil)
if err := v.CreateVault(t.TempDir()); err != nil {
t.Fatalf("CreateVault: %v", err)
}
return NewService(v), v.GetVaultPath()
}
func TestServiceRequiresOpenVault(t *testing.T) {
v := vault.NewVault(nil)
s := NewService(v)
if _, err := s.ListVaultFiles(""); err == nil {
t.Fatal("ListVaultFiles with closed vault: expected error")
}
}
func TestListVaultFilesExcludesReservedAndReturnsEntries(t *testing.T) {
s, root := newTestService(t)
if err := os.WriteFile(filepath.Join(root, "readme.md"), []byte("hello"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(root, "Docs"), 0o755); err != nil {
t.Fatal(err)
}
entries, err := s.ListVaultFiles("")
if err != nil {
t.Fatalf("ListVaultFiles: %v", err)
}
names := map[string]FileEntry{}
for _, entry := range entries {
names[entry.Name] = entry
if strings.HasPrefix(entry.RelativePath, ".verstak") {
t.Fatalf("reserved entry leaked into list: %+v", entry)
}
}
if names["readme.md"].Type != FileTypeFile {
t.Fatalf("readme.md type = %q", names["readme.md"].Type)
}
if names["Docs"].Type != FileTypeFolder {
t.Fatalf("Docs type = %q", names["Docs"].Type)
}
}
func TestPathPolicyRejectsUnsafeOperations(t *testing.T) {
s, _ := newTestService(t)
cases := []string{
"/etc/passwd",
"C:\\Windows\\system.ini",
"C:/Windows/system.ini",
`\\server\share`,
"//server/share",
`..\outside`,
`folder\..\outside`,
"../outside",
"folder/../../outside",
`folder\sub/../../outside`,
"bad\x00path",
".verstak",
".verstak/",
".verstak/vault.json",
"./.verstak",
".Verstak/trash",
}
for _, input := range cases {
t.Run(input, func(t *testing.T) {
if _, err := s.GetVaultFileMetadata(input); err == nil {
t.Fatal("metadata: expected error")
}
if _, err := s.ReadVaultTextFile(input); err == nil {
t.Fatal("read: expected error")
}
if err := s.WriteVaultTextFile(input, "x", WriteOptions{CreateIfMissing: true}); err == nil {
t.Fatal("write: expected error")
}
if err := s.MoveVaultPath(input, "safe.txt", MoveOptions{}); err == nil {
t.Fatal("move: expected error")
}
if _, err := s.TrashVaultPath(input); err == nil {
t.Fatal("trash: expected error")
}
})
}
}
func TestReadVaultTextFileRules(t *testing.T) {
s, root := newTestService(t)
if err := os.WriteFile(filepath.Join(root, "note.md"), []byte("hello\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(root, "Folder"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "binary.bin"), []byte{0xff, 0xfe, 0xfd}, 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "huge.txt"), []byte(strings.Repeat("a", int(MaxTextFileBytes)+1)), 0o644); err != nil {
t.Fatal(err)
}
text, err := s.ReadVaultTextFile("note.md")
if err != nil {
t.Fatalf("ReadVaultTextFile note: %v", err)
}
if text != "hello\n" {
t.Fatalf("text = %q", text)
}
if _, err := s.ReadVaultTextFile("Folder"); err == nil || !strings.Contains(err.Error(), "not-regular-file") {
t.Fatalf("read folder error = %v, want not-regular-file", err)
}
if _, err := s.ReadVaultTextFile("missing.md"); err == nil || !strings.Contains(err.Error(), "not-found") {
t.Fatalf("read missing error = %v, want not-found", err)
}
if _, err := s.ReadVaultTextFile("huge.txt"); err == nil || !strings.Contains(err.Error(), "file-too-large") {
t.Fatalf("read huge error = %v, want file-too-large", err)
}
if _, err := s.ReadVaultTextFile("binary.bin"); err == nil || !strings.Contains(err.Error(), "not-text-file") {
t.Fatalf("read binary error = %v, want not-text-file", err)
}
}
func TestWriteVaultTextFileAtomicAndConflictBehavior(t *testing.T) {
s, root := newTestService(t)
if err := s.WriteVaultTextFile("Notes/one.md", "first", WriteOptions{CreateIfMissing: true}); err == nil {
t.Fatal("write should fail when parent folder is missing")
}
if err := s.CreateVaultFolder("Notes"); err != nil {
t.Fatalf("CreateVaultFolder: %v", err)
}
if err := s.WriteVaultTextFile("Notes/one.md", "first", WriteOptions{CreateIfMissing: true}); err != nil {
t.Fatalf("write create: %v", err)
}
if err := s.WriteVaultTextFile("Notes/one.md", "second", WriteOptions{CreateIfMissing: true}); err == nil || !strings.Contains(err.Error(), "conflict") {
t.Fatalf("write conflict error = %v, want conflict", err)
}
if err := s.WriteVaultTextFile("Notes/one.md", "second", WriteOptions{CreateIfMissing: true, Overwrite: true}); err != nil {
t.Fatalf("write overwrite: %v", err)
}
data, err := os.ReadFile(filepath.Join(root, "Notes", "one.md"))
if err != nil {
t.Fatal(err)
}
if string(data) != "second" {
t.Fatalf("file content = %q", string(data))
}
matches, err := filepath.Glob(filepath.Join(root, "Notes", ".verstak-write-*"))
if err != nil {
t.Fatal(err)
}
if len(matches) != 0 {
t.Fatalf("atomic write left temp files: %v", matches)
}
if err := s.WriteVaultTextFile("", "root", WriteOptions{CreateIfMissing: true}); err == nil || !strings.Contains(err.Error(), "empty path") {
t.Fatalf("write root error = %v, want empty path", err)
}
}
func TestCreateVaultFolderConflict(t *testing.T) {
s, _ := newTestService(t)
if err := s.CreateVaultFolder("Folder"); err != nil {
t.Fatalf("CreateVaultFolder first: %v", err)
}
if err := s.CreateVaultFolder("Folder"); err == nil || !strings.Contains(err.Error(), "conflict") {
t.Fatalf("CreateVaultFolder conflict error = %v, want conflict", err)
}
}
func TestMoveVaultPathRules(t *testing.T) {
s, root := newTestService(t)
if err := os.Mkdir(filepath.Join(root, "A"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "A", "one.txt"), []byte("one"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "target.txt"), []byte("target"), 0o644); err != nil {
t.Fatal(err)
}
if err := s.MoveVaultPath("A/one.txt", "moved.txt", MoveOptions{}); err != nil {
t.Fatalf("move file: %v", err)
}
if _, err := os.Stat(filepath.Join(root, "moved.txt")); err != nil {
t.Fatalf("moved file missing: %v", err)
}
if err := s.MoveVaultPath("A", "B", MoveOptions{}); err != nil {
t.Fatalf("move folder: %v", err)
}
if err := os.Mkdir(filepath.Join(root, "C"), 0o755); err != nil {
t.Fatal(err)
}
if err := s.MoveVaultPath("C", "C/Child", MoveOptions{}); err == nil || !strings.Contains(err.Error(), "move-into-self") {
t.Fatalf("move into self error = %v, want move-into-self", err)
}
if err := s.MoveVaultPath("moved.txt", "target.txt", MoveOptions{}); err == nil || !strings.Contains(err.Error(), "conflict") {
t.Fatalf("move conflict error = %v, want conflict", err)
}
if err := s.MoveVaultPath("", "root-move", MoveOptions{}); err == nil {
t.Fatal("move root should fail")
}
}
func TestTrashVaultPathMovesToReservedTrashAndHidesFromList(t *testing.T) {
s, root := newTestService(t)
if err := os.WriteFile(filepath.Join(root, "delete-me.txt"), []byte("bye"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(root, "delete-folder"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "same.txt"), []byte("one"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(root, "Other"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "Other", "same.txt"), []byte("two"), 0o644); err != nil {
t.Fatal(err)
}
fileResult, err := s.TrashVaultPath("delete-me.txt")
if err != nil {
t.Fatalf("trash file: %v", err)
}
if fileResult.OriginalPath != "delete-me.txt" || fileResult.TrashID == "" || fileResult.DeletedAt == "" {
t.Fatalf("unexpected trash result: %+v", fileResult)
}
if _, err := os.Stat(filepath.Join(root, fileResult.TrashPath)); err != nil {
t.Fatalf("trashed file missing: %v", err)
}
metaPath := filepath.Join(root, ".verstak", "trash", "files", fileResult.TrashID, "metadata.json")
metaData, err := os.ReadFile(metaPath)
if err != nil {
t.Fatalf("trash metadata missing: %v", err)
}
var meta map[string]string
if err := json.Unmarshal(metaData, &meta); err != nil {
t.Fatalf("trash metadata invalid JSON: %v", err)
}
for _, key := range []string{"originalPath", "deletedAt", "originalType", "trashId", "basename"} {
if meta[key] == "" {
t.Fatalf("trash metadata missing %s: %s", key, string(metaData))
}
}
if meta["basename"] != "delete-me.txt" || meta["originalType"] != string(FileTypeFile) {
t.Fatalf("trash metadata = %+v", meta)
}
if _, err := s.TrashVaultPath("delete-folder"); err != nil {
t.Fatalf("trash folder: %v", err)
}
firstSame, err := s.TrashVaultPath("same.txt")
if err != nil {
t.Fatalf("trash same root: %v", err)
}
secondSame, err := s.TrashVaultPath("Other/same.txt")
if err != nil {
t.Fatalf("trash same nested: %v", err)
}
if firstSame.TrashID == secondSame.TrashID || firstSame.TrashPath == secondSame.TrashPath {
t.Fatalf("repeated trash basename collided: first=%+v second=%+v", firstSame, secondSame)
}
if _, err := s.TrashVaultPath(""); err == nil {
t.Fatal("trash root should fail")
}
if _, err := s.TrashVaultPath("missing.txt"); err == nil || !strings.Contains(err.Error(), "not-found") {
t.Fatalf("trash missing error = %v, want not-found", err)
}
entries, err := s.ListVaultFiles("")
if err != nil {
t.Fatalf("ListVaultFiles: %v", err)
}
for _, entry := range entries {
if entry.Name == "delete-me.txt" || entry.Name == "delete-folder" || entry.Name == ".verstak" {
t.Fatalf("unexpected entry after trash: %+v", entry)
}
}
}
func TestSymlinkEscapeRejected(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink creation requires privileges on many Windows test environments")
}
s, root := newTestService(t)
outside := t.TempDir()
outsideFile := filepath.Join(outside, "outside.txt")
if err := os.WriteFile(outsideFile, []byte("secret"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outsideFile, filepath.Join(root, "escape.txt")); err != nil {
t.Skipf("symlink not supported in this environment: %v", err)
}
meta, err := s.GetVaultFileMetadata("escape.txt")
if err != nil {
t.Fatalf("metadata symlink: %v", err)
}
if meta.Type != FileTypeSymlink {
t.Fatalf("symlink type = %q", meta.Type)
}
if _, err := s.ReadVaultTextFile("escape.txt"); err == nil || !strings.Contains(err.Error(), "symlink-not-allowed") {
t.Fatalf("read symlink error = %v, want symlink-not-allowed", err)
}
if err := s.WriteVaultTextFile("escape.txt", "x", WriteOptions{Overwrite: true}); err == nil || !strings.Contains(err.Error(), "symlink-not-allowed") {
t.Fatalf("write symlink error = %v, want symlink-not-allowed", err)
}
if err := s.MoveVaultPath("escape.txt", "moved-link.txt", MoveOptions{}); err == nil || !strings.Contains(err.Error(), "symlink-not-allowed") {
t.Fatalf("move symlink error = %v, want symlink-not-allowed", err)
}
if _, err := s.TrashVaultPath("escape.txt"); err == nil || !strings.Contains(err.Error(), "symlink-not-allowed") {
t.Fatalf("trash symlink error = %v, want symlink-not-allowed", err)
}
}
func TestSymlinkInsideVaultRejectedForMutatingAndReadOperations(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink creation requires privileges on many Windows test environments")
}
s, root := newTestService(t)
if err := os.WriteFile(filepath.Join(root, "target.txt"), []byte("inside"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(filepath.Join(root, "target.txt"), filepath.Join(root, "inside-link.txt")); err != nil {
t.Skipf("symlink not supported in this environment: %v", err)
}
meta, err := s.GetVaultFileMetadata("inside-link.txt")
if err != nil {
t.Fatalf("metadata inside symlink: %v", err)
}
if meta.Type != FileTypeSymlink {
t.Fatalf("symlink type = %q", meta.Type)
}
if _, err := s.ReadVaultTextFile("inside-link.txt"); err == nil || !strings.Contains(err.Error(), "symlink-not-allowed") {
t.Fatalf("read inside symlink error = %v, want symlink-not-allowed", err)
}
if err := s.WriteVaultTextFile("inside-link.txt", "x", WriteOptions{Overwrite: true}); err == nil || !strings.Contains(err.Error(), "symlink-not-allowed") {
t.Fatalf("write inside symlink error = %v, want symlink-not-allowed", err)
}
if err := s.MoveVaultPath("inside-link.txt", "moved-link.txt", MoveOptions{}); err == nil || !strings.Contains(err.Error(), "symlink-not-allowed") {
t.Fatalf("move inside symlink error = %v, want symlink-not-allowed", err)
}
if _, err := s.TrashVaultPath("inside-link.txt"); err == nil || !strings.Contains(err.Error(), "symlink-not-allowed") {
t.Fatalf("trash inside symlink error = %v, want symlink-not-allowed", err)
}
matches, err := filepath.Glob(filepath.Join(root, ".verstak-write-*"))
if err != nil {
t.Fatal(err)
}
if len(matches) != 0 {
t.Fatalf("write symlink left root temp files: %v", matches)
}
}
func TestListVaultFilesRejectsSymlinkDirectoryEscape(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink creation requires privileges on many Windows test environments")
}
s, root := newTestService(t)
outside := t.TempDir()
if err := os.WriteFile(filepath.Join(outside, "outside.txt"), []byte("secret"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, filepath.Join(root, "outside-dir")); err != nil {
t.Skipf("symlink not supported in this environment: %v", err)
}
if _, err := s.ListVaultFiles("outside-dir"); err == nil || !strings.Contains(err.Error(), "symlink-not-allowed") {
t.Fatalf("list symlink dir error = %v, want symlink-not-allowed", err)
}
entries, err := s.ListVaultFiles("")
if err != nil {
t.Fatalf("list root: %v", err)
}
var foundSymlink bool
for _, entry := range entries {
if entry.RelativePath == "outside-dir" {
foundSymlink = true
if entry.Type != FileTypeSymlink {
t.Fatalf("root symlink entry type = %q, want symlink", entry.Type)
}
}
}
if !foundSymlink {
t.Fatal("root list should expose the symlink as metadata without following it")
}
}
func TestCreateVaultFolderRejectsSymlinkParentEscape(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink creation requires privileges on many Windows test environments")
}
s, root := newTestService(t)
outside := t.TempDir()
if err := os.Symlink(outside, filepath.Join(root, "outside-dir")); err != nil {
t.Skipf("symlink not supported in this environment: %v", err)
}
if err := s.CreateVaultFolder("outside-dir/new-folder"); err == nil || !strings.Contains(err.Error(), "symlink-not-allowed") {
t.Fatalf("create folder through symlink parent error = %v, want symlink-not-allowed", err)
}
if _, err := os.Stat(filepath.Join(outside, "new-folder")); !os.IsNotExist(err) {
t.Fatalf("folder should not be created outside vault, stat err=%v", err)
}
}

View File

@ -0,0 +1,56 @@
package files
const MaxTextFileBytes int64 = 2 * 1024 * 1024
type FileType string
const (
FileTypeFile FileType = "file"
FileTypeFolder FileType = "folder"
FileTypeSymlink FileType = "symlink"
FileTypeUnknown FileType = "unknown"
)
type FileEntry struct {
Name string `json:"name"`
RelativePath string `json:"relativePath"`
Type FileType `json:"type"`
Size int64 `json:"size"`
ModifiedAt string `json:"modifiedAt"`
Extension string `json:"extension"`
IsHidden bool `json:"isHidden"`
IsReserved bool `json:"isReserved"`
CanRead bool `json:"canRead"`
CanWrite bool `json:"canWrite"`
}
type FileMetadata struct {
RelativePath string `json:"relativePath"`
Type FileType `json:"type"`
Size int64 `json:"size"`
ModifiedAt string `json:"modifiedAt"`
CreatedAt string `json:"createdAt,omitempty"`
Extension string `json:"extension"`
MimeHint string `json:"mimeHint"`
IsText bool `json:"isText"`
IsHidden bool `json:"isHidden"`
IsReserved bool `json:"isReserved"`
CanRead bool `json:"canRead"`
CanWrite bool `json:"canWrite"`
}
type WriteOptions struct {
CreateIfMissing bool `json:"createIfMissing"`
Overwrite bool `json:"overwrite"`
}
type MoveOptions struct {
Overwrite bool `json:"overwrite"`
}
type TrashResult struct {
OriginalPath string `json:"originalPath"`
TrashPath string `json:"trashPath"`
TrashID string `json:"trashId"`
DeletedAt string `json:"deletedAt"`
}

View File

@ -33,12 +33,16 @@ func (r *Registry) registerDefaults() {
{Name: "vault.read", Description: "Read vault files and metadata", Dangerous: false},
{Name: "vault.write", Description: "Write vault files and metadata", Dangerous: true},
{Name: "vault.watch", Description: "Watch vault file changes", Dangerous: false},
{Name: "files.read", Description: "List files and read text files through the vault Files API", Dangerous: false},
{Name: "files.write", Description: "Create folders, write text files, and move paths through the vault Files API", Dangerous: true},
{Name: "files.delete", Description: "Trash vault files and folders through the vault Files API", Dangerous: true},
{Name: "storage.namespace", Description: "Read/write plugin's own storage namespace", Dangerous: false},
{Name: "storage.migrations", Description: "Run database migrations in plugin namespace", Dangerous: false},
{Name: "events.publish", Description: "Publish events to the event bus", Dangerous: false},
{Name: "events.subscribe", Description: "Subscribe to events on the event bus", Dangerous: false},
{Name: "ui.register", Description: "Register UI components and contributions", Dangerous: false},
{Name: "commands.register", Description: "Register command palette commands", Dangerous: false},
{Name: "workbench.open", Description: "Request Workbench open/edit routing for vault resources", Dangerous: false},
{Name: "network.local", Description: "Connect to localhost network services", Dangerous: false},
{Name: "network.remote", Description: "Connect to remote network services", Dangerous: true},
{Name: "process.spawn", Description: "Spawn external processes", Dangerous: true},

View File

@ -0,0 +1,71 @@
package plugin
import (
"os"
"path/filepath"
"strings"
)
// DirResolveOptions makes plugin directory resolution testable.
type DirResolveOptions struct {
EnvPluginDir string
CWD string
ExecutablePath string
UserConfigDir string
HomeDir string
}
// ResolveDiscoveryDirs returns plugin discovery directories in priority order:
// explicit env override, dev ./plugins, packaged binary-adjacent plugins, user plugins.
func ResolveDiscoveryDirs(opts DirResolveOptions) []string {
var dirs []string
add := func(path string) {
if path == "" {
return
}
cleaned := filepath.Clean(path)
for _, existing := range dirs {
if existing == cleaned {
return
}
}
dirs = append(dirs, cleaned)
}
if opts.EnvPluginDir != "" {
for _, path := range filepath.SplitList(opts.EnvPluginDir) {
add(path)
}
}
if opts.CWD != "" {
add(filepath.Join(opts.CWD, "plugins"))
}
if opts.ExecutablePath != "" {
add(filepath.Join(filepath.Dir(opts.ExecutablePath), "plugins"))
}
if opts.UserConfigDir != "" {
add(filepath.Join(opts.UserConfigDir, "verstak", "plugins"))
} else if opts.HomeDir != "" {
add(filepath.Join(opts.HomeDir, ".config", "verstak", "plugins"))
}
return dirs
}
// DefaultDiscoveryDirs resolves discovery directories from the current process.
func DefaultDiscoveryDirs() []string {
cwd, _ := os.Getwd()
exe, _ := os.Executable()
userConfig, _ := os.UserConfigDir()
home, _ := os.UserHomeDir()
return ResolveDiscoveryDirs(DirResolveOptions{
EnvPluginDir: strings.TrimSpace(os.Getenv("VERSTAK_PLUGIN_DIR")),
CWD: cwd,
ExecutablePath: exe,
UserConfigDir: userConfig,
HomeDir: home,
})
}

View File

@ -0,0 +1,71 @@
package plugin
import (
"path/filepath"
"reflect"
"testing"
)
func TestResolveDiscoveryDirs_EnvCwdBinaryUserDedup(t *testing.T) {
root := t.TempDir()
envDir := filepath.Join(root, "env-plugins")
cwdDir := filepath.Join(root, "repo", "plugins")
binaryDir := filepath.Join(root, "app", "plugins")
userConfigDir := filepath.Join(root, "config")
got := ResolveDiscoveryDirs(DirResolveOptions{
EnvPluginDir: envDir + string(filepath.ListSeparator) + cwdDir,
CWD: filepath.Join(root, "repo"),
ExecutablePath: filepath.Join(root, "app", "verstak-desktop"),
UserConfigDir: userConfigDir,
})
want := []string{
envDir,
cwdDir,
binaryDir,
filepath.Join(userConfigDir, "verstak", "plugins"),
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ResolveDiscoveryDirs() = %#v, want %#v", got, want)
}
}
func TestResolveDiscoveryDirs_UsesCwdWhenExecutablePathMissing(t *testing.T) {
root := t.TempDir()
got := ResolveDiscoveryDirs(DirResolveOptions{
CWD: root,
HomeDir: filepath.Join(root, "home"),
})
wantFirst := filepath.Join(root, "plugins")
if got[0] != wantFirst {
t.Fatalf("first plugin dir = %q, want cwd plugins %q", got[0], wantFirst)
}
}
func TestResolveDiscoveryDirs_FallsBackToHomeConfigDir(t *testing.T) {
root := t.TempDir()
got := ResolveDiscoveryDirs(DirResolveOptions{
HomeDir: filepath.Join(root, "home"),
})
want := []string{filepath.Join(root, "home", ".config", "verstak", "plugins")}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ResolveDiscoveryDirs() = %#v, want %#v", got, want)
}
}
func TestResolveDiscoveryDirs_NormalizesAndDeduplicatesPaths(t *testing.T) {
root := t.TempDir()
cwd := filepath.Join(root, "repo")
got := ResolveDiscoveryDirs(DirResolveOptions{
EnvPluginDir: filepath.Join(cwd, ".", "plugins") + string(filepath.ListSeparator) + filepath.Join(cwd, "plugins"),
CWD: cwd,
})
want := []string{filepath.Join(cwd, "plugins")}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ResolveDiscoveryDirs() = %#v, want %#v", got, want)
}
}

View File

@ -67,6 +67,7 @@ type Contributions struct {
SearchProviders []ContributionSearchProvider `json:"searchProviders,omitempty"`
ActivityProviders []ContributionActivityProvider `json:"activityProviders,omitempty"`
StatusBarItems []ContributionStatusBarItem `json:"statusBarItems,omitempty"`
OpenProviders []ContributionOpenProvider `json:"openProviders,omitempty"`
}
// ContributionView represents a view contribution.
@ -144,6 +145,23 @@ type ContributionStatusBarItem struct {
Handler string `json:"handler,omitempty"`
}
// OpenProviderSupport describes a resource shape an open provider can handle.
type OpenProviderSupport struct {
Kind string `json:"kind"`
Mime []string `json:"mime,omitempty"`
Extensions []string `json:"extensions,omitempty"`
Contexts []string `json:"contexts,omitempty"`
}
// ContributionOpenProvider represents an editor/viewer provider contribution.
type ContributionOpenProvider struct {
ID string `json:"id"`
Title string `json:"title"`
Priority int `json:"priority,omitempty"`
Component string `json:"component"`
Supports []OpenProviderSupport `json:"supports"`
}
// SyncConfig describes plugin sync configuration.
type SyncConfig struct {
Namespaces []string `json:"namespaces,omitempty"`
@ -209,6 +227,27 @@ func ValidateManifest(m *Manifest) []string {
if len(m.Permissions) == 0 {
errs.add("permissions must have at least one permission")
}
if m.Contributes != nil {
for i, provider := range m.Contributes.OpenProviders {
if provider.ID == "" {
errs.add("contributes.openProviders[%d].id is required", i)
}
if provider.Title == "" {
errs.add("contributes.openProviders[%d].title is required", i)
}
if provider.Component == "" {
errs.add("contributes.openProviders[%d].component is required", i)
}
if len(provider.Supports) == 0 {
errs.add("contributes.openProviders[%d].supports must have at least one entry", i)
}
for j, support := range provider.Supports {
if support.Kind == "" {
errs.add("contributes.openProviders[%d].supports[%d].kind is required", i, j)
}
}
}
}
return errs.errors
}
@ -249,7 +288,7 @@ func DiscoverPlugins(dirs []string) ([]Plugin, []error) {
var plugins []Plugin
var errs []error
seen := make(map[string]bool)
seen := make(map[string]string)
log.Printf("[discovery] start: %d dir(s): %v", len(dirs), dirs)
@ -287,12 +326,12 @@ func DiscoverPlugins(dirs []string) ([]Plugin, []error) {
continue
}
if seen[plugin.Manifest.ID] {
errs = append(errs, fmt.Errorf("duplicate plugin ID %q in %s", plugin.Manifest.ID, pluginDir))
log.Printf("[discovery] %s: duplicate ID %q (skip)", entry.Name(), plugin.Manifest.ID)
if existingPath, ok := seen[plugin.Manifest.ID]; ok {
errs = append(errs, fmt.Errorf("duplicate plugin ID %q in %s (already loaded from %s); first plugin wins", plugin.Manifest.ID, pluginDir, existingPath))
log.Printf("[discovery] %s: duplicate ID %q in %s (already loaded from %s; skip)", entry.Name(), plugin.Manifest.ID, pluginDir, existingPath)
continue
}
seen[plugin.Manifest.ID] = true
seen[plugin.Manifest.ID] = pluginDir
plugins = append(plugins, plugin)
log.Printf("[discovery] %s: ✅ %s@%s", entry.Name(), plugin.Manifest.ID, plugin.Manifest.Version)
}

View File

@ -149,6 +149,90 @@ func TestDiscoverPlugins_DuplicateID(t *testing.T) {
}
}
func TestDiscoverPlugins_DuplicateIDAcrossDirs_FirstWinsAndReportsBothPaths(t *testing.T) {
dir1 := t.TempDir()
dir2 := t.TempDir()
firstPath := createTempPlugin(t, dir1, "shared.plugin", "First")
secondPath := filepath.Join(dir2, "other-name")
if err := os.MkdirAll(secondPath, 0755); err != nil {
t.Fatal(err)
}
manifest := `{
"schemaVersion": 1,
"id": "shared.plugin",
"name": "Second",
"version": "2.0.0",
"apiVersion": "1.0",
"provides": ["shared.plugin.second.cap"],
"permissions": ["vault.read"]
}`
if err := os.WriteFile(filepath.Join(secondPath, "plugin.json"), []byte(manifest), 0644); err != nil {
t.Fatal(err)
}
plugins, errs := DiscoverPlugins([]string{dir1, dir2})
if len(plugins) != 1 {
t.Fatalf("expected first plugin only, got %d", len(plugins))
}
if plugins[0].RootPath != firstPath {
t.Fatalf("winner path = %q, want %q", plugins[0].RootPath, firstPath)
}
combined := ""
for _, err := range errs {
combined += err.Error()
}
if !strings.Contains(combined, "duplicate plugin ID") {
t.Fatalf("expected duplicate error, got %v", errs)
}
if !strings.Contains(combined, firstPath) || !strings.Contains(combined, secondPath) {
t.Fatalf("duplicate error should include both paths; got %q", combined)
}
}
func TestValidateManifest_OpenProviders(t *testing.T) {
valid := &Manifest{
SchemaVersion: 1,
ID: "editor.plugin",
Name: "Editor",
Version: "1.0.0",
APIVersion: "1.0",
Provides: []string{"editor.text"},
Permissions: []string{"workbench.open"},
Contributes: &Contributions{
OpenProviders: []ContributionOpenProvider{{
ID: "editor.text",
Title: "Text Editor",
Component: "TextEditor",
Supports: []OpenProviderSupport{{
Kind: "vault-file",
Extensions: []string{".txt"},
Contexts: []string{"generic-text"},
}},
}},
},
}
if errs := ValidateManifest(valid); len(errs) != 0 {
t.Fatalf("valid manifest errors = %v", errs)
}
invalid := *valid
invalid.Contributes = &Contributions{
OpenProviders: []ContributionOpenProvider{{
ID: "broken",
Title: "Broken",
Component: "",
Supports: []OpenProviderSupport{{}},
}},
}
errs := ValidateManifest(&invalid)
combined := strings.Join(errs, "\n")
if !strings.Contains(combined, "component is required") || !strings.Contains(combined, "kind is required") {
t.Fatalf("expected open provider validation errors, got %v", errs)
}
}
// TestDiscoverPlugins_MultipleDirs ensures discovery scans multiple directories.
func TestDiscoverPlugins_MultipleDirs(t *testing.T) {
dir1 := t.TempDir()

View File

@ -0,0 +1,257 @@
// Package workbench routes open/edit resource requests to contributed providers.
package workbench
import (
"fmt"
"path"
"sort"
"strings"
"time"
"github.com/verstak/verstak-desktop/internal/core/contribution"
"github.com/verstak/verstak-desktop/internal/core/plugin"
)
type Preferences struct {
DefaultTextEditorProvider string `json:"defaultTextEditorProvider,omitempty"`
DefaultMarkdownEditorProvider string `json:"defaultMarkdownEditorProvider,omitempty"`
DefaultNotesMarkdownEditorProvider string `json:"defaultNotesMarkdownEditorProvider,omitempty"`
}
const (
ContextGenericText = "generic-text"
ContextGenericMarkdown = "generic-markdown"
ContextNotesMarkdown = "notes-markdown"
)
type OpenResourceContext struct {
SourcePluginID string `json:"sourcePluginId,omitempty"`
SourceView string `json:"sourceView,omitempty"`
IsInsideNotesFolder bool `json:"isInsideNotesFolder,omitempty"`
NotesScopePath string `json:"notesScopePath,omitempty"`
NotesMode bool `json:"notesMode,omitempty"`
}
type OpenResourceRequest struct {
Kind string `json:"kind"`
Path string `json:"path"`
Mode string `json:"mode,omitempty"`
Mime string `json:"mime,omitempty"`
Extension string `json:"extension,omitempty"`
Context OpenResourceContext `json:"context,omitempty"`
}
type OpenResourceResult struct {
Status string `json:"status"`
ProviderID string `json:"providerId,omitempty"`
ProviderPluginID string `json:"providerPluginId,omitempty"`
ProviderComponent string `json:"providerComponent,omitempty"`
Request OpenResourceRequest `json:"request"`
Message string `json:"message,omitempty"`
}
type OpenedResource struct {
ID string `json:"id"`
ProviderID string `json:"providerId"`
ProviderPluginID string `json:"providerPluginId"`
ProviderComponent string `json:"providerComponent"`
Request OpenResourceRequest `json:"request"`
OpenedAt string `json:"openedAt"`
}
type Router struct {
preferences Preferences
opened []OpenedResource
}
func NewRouter(preferences Preferences) *Router {
return &Router{preferences: preferences}
}
func (r *Router) Preferences() Preferences {
return r.preferences
}
func (r *Router) SetPreferences(preferences Preferences) {
r.preferences = preferences
}
func (r *Router) SelectProvider(request OpenResourceRequest, providers []contribution.ContributionOpenProvider) (contribution.ContributionOpenProvider, error) {
request = normalizeRequest(request)
var matches []contribution.ContributionOpenProvider
for _, provider := range providers {
if providerMatches(request, provider.Item) {
matches = append(matches, provider)
}
}
if len(matches) == 0 {
return contribution.ContributionOpenProvider{}, fmt.Errorf("no open provider supports %s %q", request.Kind, request.Path)
}
preferred := r.preferenceFor(request)
if preferred != "" {
for _, provider := range matches {
if provider.Item.ID == preferred {
return provider, nil
}
}
}
sort.Slice(matches, func(i, j int) bool {
if matches[i].Item.Priority != matches[j].Item.Priority {
return matches[i].Item.Priority > matches[j].Item.Priority
}
if matches[i].PluginID != matches[j].PluginID {
return matches[i].PluginID < matches[j].PluginID
}
return matches[i].Item.ID < matches[j].Item.ID
})
return matches[0], nil
}
func (r *Router) OpenResource(request OpenResourceRequest, providers []contribution.ContributionOpenProvider) (OpenResourceResult, error) {
request = normalizeRequest(request)
provider, err := r.SelectProvider(request, providers)
if err != nil {
return OpenResourceResult{
Status: "no-provider",
Request: request,
Message: err.Error(),
}, nil
}
result := OpenResourceResult{
Status: "opened",
ProviderID: provider.Item.ID,
ProviderPluginID: provider.PluginID,
ProviderComponent: provider.Item.Component,
Request: request,
}
r.opened = append(r.opened, OpenedResource{
ID: fmt.Sprintf("%s:%d", provider.Item.ID, len(r.opened)+1),
ProviderID: result.ProviderID,
ProviderPluginID: result.ProviderPluginID,
ProviderComponent: result.ProviderComponent,
Request: result.Request,
OpenedAt: time.Now().UTC().Format(time.RFC3339Nano),
})
return result, nil
}
func (r *Router) OpenedResources() []OpenedResource {
result := make([]OpenedResource, len(r.opened))
copy(result, r.opened)
return result
}
func normalizeRequest(request OpenResourceRequest) OpenResourceRequest {
if request.Mode == "" {
request.Mode = "view"
}
if request.Extension == "" {
request.Extension = path.Ext(request.Path)
}
request.Extension = strings.ToLower(request.Extension)
request.Mime = strings.ToLower(request.Mime)
return request
}
// DetermineContextName derives the current routing context from a request.
// Future Files/Notes callers can move canonical Notes folder auto-detection here.
func DetermineContextName(request OpenResourceRequest) string {
request = normalizeRequest(request)
return resourceContextName(request)
}
func providerMatches(request OpenResourceRequest, provider plugin.ContributionOpenProvider) bool {
for _, support := range provider.Supports {
if support.Kind != request.Kind {
continue
}
if !supportMatchesExtensionOrMime(request, support) {
continue
}
if !supportMatchesContext(request, support) {
continue
}
return true
}
return false
}
func supportMatchesExtensionOrMime(request OpenResourceRequest, support plugin.OpenProviderSupport) bool {
hasExtensionRules := len(support.Extensions) > 0
hasMimeRules := len(support.Mime) > 0
if !hasExtensionRules && !hasMimeRules {
return true
}
if hasExtensionRules {
for _, ext := range support.Extensions {
if strings.ToLower(ext) == request.Extension {
return true
}
}
}
if hasMimeRules && request.Mime != "" {
for _, mime := range support.Mime {
if strings.ToLower(mime) == request.Mime {
return true
}
}
}
return false
}
func supportMatchesContext(request OpenResourceRequest, support plugin.OpenProviderSupport) bool {
if len(support.Contexts) == 0 {
return true
}
context := resourceContextName(request)
for _, supported := range support.Contexts {
if supported == context {
return true
}
}
return false
}
func (r *Router) preferenceFor(request OpenResourceRequest) string {
context := resourceContextName(request)
switch {
case context == ContextNotesMarkdown:
return r.preferences.DefaultNotesMarkdownEditorProvider
case context == ContextGenericMarkdown:
return r.preferences.DefaultMarkdownEditorProvider
case context == ContextGenericText:
return r.preferences.DefaultTextEditorProvider
default:
return ""
}
}
func resourceContextName(request OpenResourceRequest) string {
ext := strings.ToLower(request.Extension)
if ext == ".md" || ext == ".markdown" {
if request.Context.NotesMode || request.Context.IsInsideNotesFolder {
return ContextNotesMarkdown
}
return ContextGenericMarkdown
}
if isTextResource(request) {
return ContextGenericText
}
return ""
}
func isTextResource(request OpenResourceRequest) bool {
if strings.HasPrefix(request.Mime, "text/") {
return true
}
switch strings.ToLower(request.Extension) {
case ".txt", ".log", ".json", ".yaml", ".yml", ".toml", ".ini", ".conf":
return true
default:
return false
}
}

View File

@ -0,0 +1,244 @@
package workbench
import (
"testing"
"github.com/verstak/verstak-desktop/internal/core/contribution"
"github.com/verstak/verstak-desktop/internal/core/plugin"
)
func provider(pluginID, id string, priority int, component string, supports ...plugin.OpenProviderSupport) contribution.ContributionOpenProvider {
return contribution.ContributionOpenProvider{
PluginID: pluginID,
Item: plugin.ContributionOpenProvider{
ID: id,
Title: id,
Priority: priority,
Component: component,
Supports: supports,
},
}
}
func TestSelectProviderUsesNotesMarkdownPreference(t *testing.T) {
r := NewRouter(Preferences{
DefaultNotesMarkdownEditorProvider: "community.notes-editor",
})
providers := []contribution.ContributionOpenProvider{
provider("official.editor", "official.markdown", 100, "OfficialMarkdown", plugin.OpenProviderSupport{
Kind: "vault-file",
Extensions: []string{".md", ".markdown"},
Contexts: []string{"generic-markdown", "notes-markdown"},
}),
provider("community.editor", "community.notes-editor", 10, "CommunityNotes", plugin.OpenProviderSupport{
Kind: "vault-file",
Extensions: []string{".md"},
Contexts: []string{"notes-markdown"},
}),
}
selected, err := r.SelectProvider(OpenResourceRequest{
Kind: "vault-file",
Path: "Clients/Acme/Notes/Overview.md",
Extension: ".md",
Mode: "edit",
Context: OpenResourceContext{
SourceView: "notes",
IsInsideNotesFolder: true,
NotesMode: true,
},
}, providers)
if err != nil {
t.Fatalf("SelectProvider: %v", err)
}
if selected.Item.ID != "community.notes-editor" {
t.Fatalf("provider = %q, want community.notes-editor", selected.Item.ID)
}
}
func TestSelectProviderFallsBackByPriorityThenID(t *testing.T) {
r := NewRouter(Preferences{
DefaultMarkdownEditorProvider: "disabled.or.missing",
})
providers := []contribution.ContributionOpenProvider{
provider("b.plugin", "b.provider", 100, "B", plugin.OpenProviderSupport{
Kind: "vault-file",
Extensions: []string{".md"},
Contexts: []string{"generic-markdown"},
}),
provider("a.plugin", "a.provider", 100, "A", plugin.OpenProviderSupport{
Kind: "vault-file",
Extensions: []string{".md"},
Contexts: []string{"generic-markdown"},
}),
provider("high.plugin", "high.provider", 200, "High", plugin.OpenProviderSupport{
Kind: "vault-file",
Extensions: []string{".txt"},
}),
}
selected, err := r.SelectProvider(OpenResourceRequest{
Kind: "vault-file",
Path: "Docs/readme.md",
Extension: ".md",
Mode: "view",
}, providers)
if err != nil {
t.Fatalf("SelectProvider: %v", err)
}
if selected.Item.ID != "a.provider" {
t.Fatalf("provider = %q, want deterministic tie winner a.provider", selected.Item.ID)
}
}
func TestSelectProviderTieBreaksByPluginIDThenProviderID(t *testing.T) {
r := NewRouter(Preferences{})
providers := []contribution.ContributionOpenProvider{
provider("b.plugin", "a.provider", 100, "B", plugin.OpenProviderSupport{
Kind: "vault-file",
Extensions: []string{".md"},
Contexts: []string{ContextGenericMarkdown},
}),
provider("a.plugin", "z.provider", 100, "A", plugin.OpenProviderSupport{
Kind: "vault-file",
Extensions: []string{".md"},
Contexts: []string{ContextGenericMarkdown},
}),
}
selected, err := r.SelectProvider(OpenResourceRequest{
Kind: "vault-file",
Path: "Docs/readme.md",
Mode: "view",
}, providers)
if err != nil {
t.Fatalf("SelectProvider: %v", err)
}
if selected.PluginID != "a.plugin" || selected.Item.ID != "z.provider" {
t.Fatalf("provider = %+v, want a.plugin/z.provider", selected)
}
}
func TestSelectProviderMatchesGenericTextContext(t *testing.T) {
r := NewRouter(Preferences{})
selected, err := r.SelectProvider(OpenResourceRequest{
Kind: "vault-file",
Path: "Docs/readme.txt",
Mode: "view",
}, []contribution.ContributionOpenProvider{
provider("text.plugin", "text.provider", 10, "Text", plugin.OpenProviderSupport{
Kind: "vault-file",
Extensions: []string{".txt"},
Contexts: []string{ContextGenericText},
}),
})
if err != nil {
t.Fatalf("SelectProvider: %v", err)
}
if selected.Item.ID != "text.provider" {
t.Fatalf("provider = %q, want text.provider", selected.Item.ID)
}
}
func TestGenericMarkdownDoesNotSelectNotesOnlyProvider(t *testing.T) {
r := NewRouter(Preferences{})
_, err := r.SelectProvider(OpenResourceRequest{
Kind: "vault-file",
Path: "Docs/readme.md",
Mode: "view",
}, []contribution.ContributionOpenProvider{
provider("notes.plugin", "notes.provider", 10, "Notes", plugin.OpenProviderSupport{
Kind: "vault-file",
Extensions: []string{".md"},
Contexts: []string{ContextNotesMarkdown},
}),
})
if err == nil {
t.Fatal("expected no provider for generic markdown with notes-only provider")
}
}
func TestOpenResourceStoresSelectedProviderAndRequest(t *testing.T) {
r := NewRouter(Preferences{})
result, err := r.OpenResource(OpenResourceRequest{
Kind: "vault-file",
Path: "Notes/Overview.md",
Extension: ".md",
Mode: "edit",
Context: OpenResourceContext{
IsInsideNotesFolder: true,
NotesMode: true,
},
}, []contribution.ContributionOpenProvider{
provider("official.editor", "official.markdown", 100, "MarkdownEditor", plugin.OpenProviderSupport{
Kind: "vault-file",
Extensions: []string{".md"},
Contexts: []string{"notes-markdown"},
}),
})
if err != nil {
t.Fatalf("OpenResource: %v", err)
}
if result.Status != "opened" || result.ProviderID != "official.markdown" || result.ProviderComponent != "MarkdownEditor" {
t.Fatalf("result = %+v", result)
}
opened := r.OpenedResources()
if len(opened) != 1 || opened[0].Request.Path != "Notes/Overview.md" {
t.Fatalf("opened = %+v", opened)
}
}
func TestOpenResourceReturnsNoProviderFallback(t *testing.T) {
r := NewRouter(Preferences{})
result, err := r.OpenResource(OpenResourceRequest{
Kind: "vault-file",
Path: "Docs/unknown.bin",
}, nil)
if err != nil {
t.Fatalf("OpenResource: %v", err)
}
if result.Status != "no-provider" || result.Request.Path != "Docs/unknown.bin" || result.Message == "" {
t.Fatalf("result = %+v", result)
}
if len(r.OpenedResources()) != 0 {
t.Fatalf("no-provider result should not store opened resource: %+v", r.OpenedResources())
}
}
func TestDetermineContextName(t *testing.T) {
tests := []struct {
name string
request OpenResourceRequest
want string
}{
{
name: "text",
request: OpenResourceRequest{Kind: "vault-file", Path: "Docs/readme.txt"},
want: ContextGenericText,
},
{
name: "markdown",
request: OpenResourceRequest{Kind: "vault-file", Path: "Docs/readme.md"},
want: ContextGenericMarkdown,
},
{
name: "notes markdown",
request: OpenResourceRequest{
Kind: "vault-file",
Path: "Notes/Overview.md",
Context: OpenResourceContext{
IsInsideNotesFolder: true,
NotesMode: true,
},
},
want: ContextNotesMarkdown,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := DetermineContextName(tt.request); got != tt.want {
t.Fatalf("DetermineContextName = %q, want %q", got, tt.want)
}
})
}
}

View File

@ -0,0 +1,95 @@
// Package debug provides a debug logger that writes to a file.
// Enabled with --debug CLI flag.
package debug
import (
"fmt"
"io"
"log"
"os"
"path/filepath"
"sync"
"time"
)
var (
logger *log.Logger
mu sync.Mutex
enabled bool
)
// Init initializes the debug logger. If --debug is present in args,
// it writes to ~/.local/share/verstak/debug/verstak-YYYY-MM-DD-HHMMSS.log.
// Returns true if debug mode is enabled.
func Init(args []string) bool {
mu.Lock()
defer mu.Unlock()
for _, a := range args {
if a == "--debug" {
enabled = true
break
}
}
if !enabled {
return false
}
// Create log directory
logDir := filepath.Join(os.Getenv("HOME"), ".local", "share", "verstak", "debug")
if err := os.MkdirAll(logDir, 0755); err != nil {
log.Printf("[debug] failed to create log dir %s: %v", logDir, err)
// Fallback to /tmp
logDir = filepath.Join(os.TempDir(), "verstak-debug")
os.MkdirAll(logDir, 0755)
}
// Create log file with timestamp
timestamp := time.Now().Format("2006-01-02-150405")
logFile := filepath.Join(logDir, fmt.Sprintf("verstak-%s.log", timestamp))
f, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
log.Printf("[debug] failed to open log file %s: %v", logFile, err)
return true // Still enabled, but logging to stderr
}
// Write to both file and stderr
mw := io.MultiWriter(f, os.Stderr)
logger = log.New(mw, "", log.LstdFlags|log.Lmicroseconds)
log.Printf("[debug] logger initialized: %s", logFile)
return true
}
// IsEnabled returns whether debug mode is active.
func IsEnabled() bool {
mu.Lock()
defer mu.Unlock()
return enabled
}
// Logf writes a formatted debug message.
func Logf(format string, v ...interface{}) {
mu.Lock()
defer mu.Unlock()
if !enabled {
return
}
if logger != nil {
logger.Printf(format, v...)
}
}
// Log writes a debug message.
func Log(v ...interface{}) {
mu.Lock()
defer mu.Unlock()
if !enabled {
return
}
if logger != nil {
logger.Println(v...)
}
}

85
main.go
View File

@ -5,7 +5,6 @@ import (
"fmt"
"log"
"os"
"path/filepath"
"strings"
"github.com/wailsapp/wails/v2"
@ -17,31 +16,26 @@ import (
"github.com/verstak/verstak-desktop/internal/core/capability"
"github.com/verstak/verstak-desktop/internal/core/contribution"
"github.com/verstak/verstak-desktop/internal/core/events"
corefiles "github.com/verstak/verstak-desktop/internal/core/files"
"github.com/verstak/verstak-desktop/internal/core/permissions"
"github.com/verstak/verstak-desktop/internal/core/plugin"
"github.com/verstak/verstak-desktop/internal/core/pluginstate"
"github.com/verstak/verstak-desktop/internal/core/storage"
"github.com/verstak/verstak-desktop/internal/core/vault"
"github.com/verstak/verstak-desktop/internal/core/workspace"
"github.com/verstak/verstak-desktop/internal/shell/debug"
)
//go:embed frontend/dist
var assets embed.FS
// expandPath resolves "~" to the user's home directory.
func expandPath(path string) string {
if strings.HasPrefix(path, "~/") {
home, err := os.UserHomeDir()
if err != nil {
log.Printf("[main] expandPath: cannot get home dir: %v", err)
return path
}
return filepath.Join(home, path[2:])
}
return path
}
func main() {
// ─── Debug Logging ───────────────────────────────────────
debugEnabled := debug.Init(os.Args)
if debugEnabled {
log.Printf("[main] debug mode enabled — logging to file")
}
// ─── Initialize Core Registries ──────────────────────────
capRegistry := capability.NewRegistry()
contribRegistry := contribution.NewRegistry()
@ -95,6 +89,8 @@ func main() {
"verstak/core/contribution-registry/v1",
"verstak/core/permissions/v1",
"verstak/core/events/v1",
"verstak/core/files/v1",
"verstak/core/workbench/v1",
}
if err := capRegistry.Register(corePluginID, coreCaps); err != nil {
log.Fatalf("[main] failed to register core capabilities: %v", err)
@ -116,37 +112,45 @@ func main() {
}
// ─── Plugin Discovery ───────────────────────────────────
// Resolve plugin directories relative to the binary location,
// not CWD (Wails may launch from a different directory).
binDir := filepath.Dir(os.Args[0])
pluginDir := filepath.Join(binDir, "plugins")
discoveryDirs := []string{
"~/.config/verstak/plugins",
pluginDir,
}
// Expand tilde in all paths
for i, d := range discoveryDirs {
discoveryDirs[i] = expandPath(d)
}
discoveryDirs := plugin.DefaultDiscoveryDirs()
log.Printf("[main] plugin dirs: %v", discoveryDirs)
if debugEnabled {
debug.Logf("[main] plugin dirs: %v", discoveryDirs)
}
plugins, discErrors := plugin.DiscoverPlugins(discoveryDirs)
for _, err := range discErrors {
log.Printf("[plugin] discovery warning: %v", err)
if debugEnabled {
debug.Logf("[plugin] discovery warning: %v", err)
}
}
log.Printf("[plugin] discovered %d plugins", len(plugins))
if debugEnabled {
for i, p := range plugins {
debug.Logf("[plugin] discovered[%d]: id=%s name=%s version=%s source=%s root=%s",
i, p.Manifest.ID, p.Manifest.Name, p.Manifest.Version, p.Manifest.Source, p.RootPath)
}
}
// ─── Plugin Lifecycle: Register Capabilities + Contributions ──
if debugEnabled {
debug.Logf("[main] starting plugin lifecycle for %d plugins", len(plugins))
}
for i := range plugins {
p := &plugins[i]
if debugEnabled {
debug.Logf("[main] lifecycle[%d]: id=%s status=%s enabled=%v", i, p.Manifest.ID, p.Status, p.Enabled)
}
// Check if plugin is disabled in vault plugin state
if pluginStateMgr != nil && pluginStateMgr.IsDisabled(p.Manifest.ID) {
log.Printf("[plugin] %s: disabled in vault plugin state — skipping", p.Manifest.ID)
if debugEnabled {
debug.Logf("[main] lifecycle: %s disabled in vault state, skipping", p.Manifest.ID)
}
p.Status = plugin.StatusDisabled
p.Enabled = false
continue
@ -156,6 +160,9 @@ func main() {
if len(p.Manifest.Provides) > 0 {
if err := capRegistry.Register(p.Manifest.ID, p.Manifest.Provides); err != nil {
log.Printf("[plugin] %s: capability registration failed: %v", p.Manifest.ID, err)
if debugEnabled {
debug.Logf("[main] lifecycle: %s capability registration failed: %v", p.Manifest.ID, err)
}
p.Status = plugin.StatusFailed
p.Error = err.Error()
continue
@ -167,6 +174,9 @@ func main() {
missingRequired := capRegistry.CheckRequired(p.Manifest.Requires)
if len(missingRequired) > 0 {
log.Printf("[plugin] %s: missing required capabilities: %v", p.Manifest.ID, missingRequired)
if debugEnabled {
debug.Logf("[main] lifecycle: %s missing required: %v", p.Manifest.ID, missingRequired)
}
p.Status = plugin.StatusMissingRequiredCapability
p.Error = fmt.Sprintf("missing required: %s", strings.Join(missingRequired, ", "))
continue
@ -176,6 +186,9 @@ func main() {
missingOptional := capRegistry.CheckRequired(p.Manifest.OptionalRequires)
if len(missingOptional) > 0 {
log.Printf("[plugin] %s: missing optional capabilities (degraded): %v", p.Manifest.ID, missingOptional)
if debugEnabled {
debug.Logf("[main] lifecycle: %s missing optional (degraded): %v", p.Manifest.ID, missingOptional)
}
p.Status = plugin.StatusDegraded
} else {
p.Status = plugin.StatusLoaded
@ -185,6 +198,11 @@ func main() {
if p.Manifest.Contributes != nil {
contribRegistry.Register(p.Manifest.ID, p.Manifest.Contributes)
log.Printf("[plugin] %s: contributions registered", p.Manifest.ID)
if debugEnabled {
c := p.Manifest.Contributes
debug.Logf("[main] lifecycle: %s contributions: views=%d commands=%d sidebar=%d settings=%d statusbar=%d",
p.Manifest.ID, len(c.Views), len(c.Commands), len(c.SidebarItems), len(c.SettingsPanels), len(c.StatusBarItems))
}
}
// Record as desired plugin in vault state (only if vault is open)
@ -195,10 +213,16 @@ func main() {
}
if err := pluginStateMgr.RecordDesiredPlugin(p.Manifest.ID, p.Manifest.Version, source); err != nil {
log.Printf("[plugin] %s: failed to record desired: %v", p.Manifest.ID, err)
if debugEnabled {
debug.Logf("[main] lifecycle: %s failed to record desired: %v", p.Manifest.ID, err)
}
}
}
log.Printf("[plugin] %s: status=%s", p.Manifest.ID, p.Status)
if debugEnabled {
debug.Logf("[main] lifecycle: %s final status=%s enabled=%v", p.Manifest.ID, p.Status, p.Enabled)
}
}
// ─── Log Summary ───────────────────────────────────────
@ -220,7 +244,8 @@ func main() {
// Create the App struct
storageService := storage.New(vaultService)
app := api.NewApp(capRegistry, contribRegistry, permRegistry, eventBus, plugins, vaultService, storageService, appSettingsMgr, pluginStateMgr, workspaceMgr)
filesService := corefiles.NewService(vaultService)
app := api.NewApp(capRegistry, contribRegistry, permRegistry, eventBus, plugins, vaultService, storageService, filesService, appSettingsMgr, pluginStateMgr, workspaceMgr, debugEnabled)
// ─── Wails App ───────────────────────────────────────────
err := wails.Run(&options.App{