Implement milestone 6b workbench routing skeleton
This commit is contained in:
parent
a100f5a441
commit
6ed6df311a
86
AGENTS.md
86
AGENTS.md
|
|
@ -91,3 +91,89 @@ verstak-desktop/
|
||||||
wails.json
|
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.**
|
||||||
|
|
|
||||||
|
|
@ -59,12 +59,51 @@ This validates:
|
||||||
|
|
||||||
## Desktop Runtime Scanning Paths
|
## Desktop Runtime Scanning Paths
|
||||||
|
|
||||||
The desktop scans two directories for plugins:
|
The desktop resolves plugin directories in one shared backend resolver. Priority:
|
||||||
|
|
||||||
| Path | Purpose |
|
| 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 |
|
| `~/.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
|
## Important Rules
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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`
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -6,19 +6,34 @@
|
||||||
|
|
||||||
### Discovery Directories
|
### 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) |
|
| `~/.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/ как Dev/Install Target
|
||||||
|
|
||||||
Директория `./plugins/` в корне `verstak-desktop` используется как:
|
Директория `./plugins/` от текущей рабочей директории используется как:
|
||||||
|
|
||||||
- **Dev target** — `install-dev-plugins.sh` коприрует сюда собранные пакеты из `verstak-official-plugins/dist/`.
|
- **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`.
|
Директория **не коммитится**. Каждый разработчик устанавливает плагины через `install-dev-plugins.sh`.
|
||||||
|
|
||||||
|
|
@ -83,6 +98,8 @@ coreCaps := []string{
|
||||||
"verstak/core/contribution-registry/v1",
|
"verstak/core/contribution-registry/v1",
|
||||||
"verstak/core/permissions/v1",
|
"verstak/core/permissions/v1",
|
||||||
"verstak/core/events/v1",
|
"verstak/core/events/v1",
|
||||||
|
"verstak/core/files/v1",
|
||||||
|
"verstak/core/workbench/v1",
|
||||||
}
|
}
|
||||||
capRegistry.Register("verstak-desktop", coreCaps)
|
capRegistry.Register("verstak-desktop", coreCaps)
|
||||||
|
|
||||||
|
|
@ -150,7 +167,7 @@ foreach plugin:
|
||||||
"provides": ["verstak/platform-test/v1"],
|
"provides": ["verstak/platform-test/v1"],
|
||||||
"requires": ["verstak/core/plugin-manager/v1"],
|
"requires": ["verstak/core/plugin-manager/v1"],
|
||||||
"optionalRequires": ["verstak/core/vault/v1", "verstak/core/sync/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" },
|
"frontend": { "entry": "frontend/dist/index.js" },
|
||||||
"contributes": {
|
"contributes": {
|
||||||
"views": [{ "id": "my.view", "title": "My View", "component": "MyPanel" }],
|
"views": [{ "id": "my.view", "title": "My View", "component": "MyPanel" }],
|
||||||
|
|
@ -171,6 +188,7 @@ foreach plugin:
|
||||||
| Основные панели | `views` | Полноценные страницы/панели | ✅ ViewContainer.svelte (PluginBundleHost — real frontend bundle) |
|
| Основные панели | `views` | Полноценные страницы/панели | ✅ ViewContainer.svelte (PluginBundleHost — real frontend bundle) |
|
||||||
| Панели настроек | `settingsPanels` | Панели в Plugin Manager | ✅ PluginManager.svelte (кнопка Settings, открывает modal) |
|
| Панели настроек | `settingsPanels` | Панели в Plugin Manager | ✅ PluginManager.svelte (кнопка Settings, открывает modal) |
|
||||||
| Команды | `commands` | Команды для command palette | ✅ ContributionRegistry (UI command palette не реализован) |
|
| Команды | `commands` | Команды для command palette | ✅ ContributionRegistry (UI command palette не реализован) |
|
||||||
|
| Open/edit providers | `openProviders` | Провайдеры viewer/editor для Workbench routing | ✅ ContributionRegistry + минимальный Workbench host |
|
||||||
|
|
||||||
### Планируемые contribution points
|
### Планируемые contribution points
|
||||||
|
|
||||||
|
|
@ -233,6 +251,176 @@ foreach plugin:
|
||||||
5. Enable plugin → `Register` при следующем Reload
|
5. Enable plugin → `Register` при следующем Reload
|
||||||
6. Registry idempotent: Register удаляет старые записи перед добавлением новых
|
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
|
### Error boundary
|
||||||
|
|
||||||
- Ошибка в plugin view/settings placeholder не роняет shell
|
- Ошибка в plugin view/settings placeholder не роняет shell
|
||||||
|
|
@ -265,17 +453,44 @@ window.VerstakPluginRegister('plugin.id', {
|
||||||
|
|
||||||
### VerstakPluginAPI
|
### VerstakPluginAPI
|
||||||
|
|
||||||
API объект передаётся в `mount()` и содержит только ограниченный набор методов:
|
API объект передаётся в `mount()` и содержит plugin-scoped методы текущего
|
||||||
|
bundled runtime. Это реальный runtime contract для cooperative bundled plugins,
|
||||||
|
но не sandbox/security boundary.
|
||||||
|
|
||||||
| Свойство | Статус | Описание |
|
| Свойство | Статус | Описание |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `api.pluginId` | ✅ Работает | ID плагина |
|
| `api.pluginId` | ✅ Работает | ID плагина |
|
||||||
| `api.capabilities.has(id)` | 🔧 Stub | Запрос capability registry (planned) |
|
| `api.settings.read(key?)` | ✅ Работает | Читает plugin-scoped settings через backend bridge |
|
||||||
| `api.events.publish(type, payload)` | 🔧 Stub | Публикация события (planned) |
|
| `api.settings.write(key, value)` | ✅ Работает | Пишет один settings key через backend bridge |
|
||||||
| `api.events.subscribe(type, handler)` | 🔧 Stub | Подписка на события (planned) |
|
| `api.settings.writeAll(settings)` | ✅ Работает | Заменяет settings namespace плагина |
|
||||||
| `api.settings.read(key)` | 🔧 Stub | Чтение настроек плагина (planned) |
|
| `api.capabilities.list()` | ✅ Работает | Возвращает capability registry |
|
||||||
| `api.settings.write(key, value)` | 🔧 Stub | Запись настроек плагина (planned) |
|
| `api.capabilities.get(id)` | ✅ Работает | Возвращает capability entry/status |
|
||||||
| `api.commands.execute(id, args)` | 🔧 Stub | Выполнение команды (planned) |
|
| `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.
|
||||||
|
|
||||||
### Загрузка бандла
|
### Загрузка бандла
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -16,6 +16,15 @@
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
"name": "verstak-desktop-frontend",
|
"name": "verstak-desktop-frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.61.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^3.1.0",
|
"@sveltejs/vite-plugin-svelte": "^3.1.0",
|
||||||
"svelte": "^4.2.0",
|
"svelte": "^4.2.0",
|
||||||
"vite": "^5.4.0"
|
"vite": "^5.4.0"
|
||||||
|
|
@ -429,6 +430,21 @@
|
||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
"version": "4.62.0",
|
"version": "4.62.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.0.tgz",
|
"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==",
|
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.15",
|
"version": "8.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,13 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"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": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.61.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^3.1.0",
|
"@sveltejs/vite-plugin-svelte": "^3.1.0",
|
||||||
"svelte": "^4.2.0",
|
"svelte": "^4.2.0",
|
||||||
"vite": "^5.4.0"
|
"vite": "^5.4.0"
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
ef6849d398e9d32c4ae5afdea904eb49
|
43be2fbdf6ba6ca9504a7c4b0ac32ae0
|
||||||
|
|
@ -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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -3,7 +3,11 @@
|
||||||
import Sidebar from './lib/shell/Sidebar.svelte';
|
import Sidebar from './lib/shell/Sidebar.svelte';
|
||||||
import ViewContainer from './lib/shell/ViewContainer.svelte';
|
import ViewContainer from './lib/shell/ViewContainer.svelte';
|
||||||
import VaultSelection from './lib/shell/VaultSelection.svelte';
|
import VaultSelection from './lib/shell/VaultSelection.svelte';
|
||||||
|
import WorkbenchHost from './lib/shell/WorkbenchHost.svelte';
|
||||||
import * as App from '../wailsjs/go/api/App';
|
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 currentView = 'plugin-manager';
|
||||||
let vaultStatus = { status: 'unknown', path: '', vaultId: '' };
|
let vaultStatus = { status: 'unknown', path: '', vaultId: '' };
|
||||||
|
|
@ -14,47 +18,81 @@
|
||||||
let activeViewPluginId = '';
|
let activeViewPluginId = '';
|
||||||
let activeSettingsPluginId = '';
|
let activeSettingsPluginId = '';
|
||||||
let activeSettingsPanelId = '';
|
let activeSettingsPanelId = '';
|
||||||
|
let openedResource = null;
|
||||||
|
|
||||||
|
function flog(msg) {
|
||||||
|
App.WriteFrontendLog('App', msg);
|
||||||
|
}
|
||||||
|
|
||||||
async function checkVault() {
|
async function checkVault() {
|
||||||
|
debug.log('[App] checkVault: START');
|
||||||
|
flog('checkVault: START');
|
||||||
loading = true;
|
loading = true;
|
||||||
try {
|
try {
|
||||||
|
debug.log('[App] checkVault: calling GetAppSettings...');
|
||||||
const settings = await App.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: '' };
|
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') {
|
if (!settings.currentVaultPath || vaultStatus.status !== 'open') {
|
||||||
|
debug.log('[App] checkVault: vault not open, needsVaultSelection=true');
|
||||||
|
flog('checkVault: needsVaultSelection=true');
|
||||||
needsVaultSelection = true;
|
needsVaultSelection = true;
|
||||||
} else {
|
} else {
|
||||||
|
debug.log('[App] checkVault: vault open, needsVaultSelection=false');
|
||||||
|
flog('checkVault: needsVaultSelection=false');
|
||||||
needsVaultSelection = false;
|
needsVaultSelection = false;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
debug.log('[App] checkVault: ERROR', String(e));
|
||||||
|
flog('checkVault: ERROR: ' + String(e));
|
||||||
console.error('[App] startup check failed:', e);
|
console.error('[App] startup check failed:', e);
|
||||||
needsVaultSelection = true;
|
needsVaultSelection = true;
|
||||||
}
|
}
|
||||||
loading = false;
|
loading = false;
|
||||||
|
await tick();
|
||||||
|
debug.log('[App] checkVault: END, loading=false');
|
||||||
|
flog('checkVault: END, loading=false');
|
||||||
}
|
}
|
||||||
|
|
||||||
function onVaultOpened() {
|
function onVaultOpened() {
|
||||||
|
debug.log('[App] onVaultOpened');
|
||||||
needsVaultSelection = false;
|
needsVaultSelection = false;
|
||||||
vaultStatus = { status: 'open', path: '', vaultId: '' };
|
vaultStatus = { status: 'open', path: '', vaultId: '' };
|
||||||
}
|
}
|
||||||
|
|
||||||
function onNav(e) {
|
function onNav(e) {
|
||||||
|
debug.log('[App] onNav:', e.detail.viewId);
|
||||||
currentView = e.detail.viewId;
|
currentView = e.detail.viewId;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onOpenView(e) {
|
function onOpenView(e) {
|
||||||
|
debug.log('[App] onOpenView:', e.detail.viewId, 'plugin:', e.detail.pluginId);
|
||||||
activeView = e.detail.viewId;
|
activeView = e.detail.viewId;
|
||||||
activeViewPluginId = e.detail.pluginId || '';
|
activeViewPluginId = e.detail.pluginId || '';
|
||||||
currentView = 'plugin-view';
|
currentView = 'plugin-view';
|
||||||
}
|
}
|
||||||
|
|
||||||
function onOpenSettings(e) {
|
function onOpenSettings(e) {
|
||||||
|
debug.log('[App] onOpenSettings:', e.detail.pluginId, e.detail.panelId);
|
||||||
activeSettingsPluginId = e.detail.pluginId;
|
activeSettingsPluginId = e.detail.pluginId;
|
||||||
activeSettingsPanelId = e.detail.panelId || '';
|
activeSettingsPanelId = e.detail.panelId || '';
|
||||||
currentView = 'plugin-manager';
|
currentView = 'plugin-manager';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onWorkbenchOpened(e) {
|
||||||
|
debug.log('[App] onWorkbenchOpened:', e.detail?.request?.path, e.detail?.providerId);
|
||||||
|
openedResource = e.detail;
|
||||||
|
currentView = 'workbench';
|
||||||
|
}
|
||||||
|
|
||||||
function onCloseSettings() {
|
function onCloseSettings() {
|
||||||
|
debug.log('[App] onCloseSettings');
|
||||||
activeSettingsPluginId = '';
|
activeSettingsPluginId = '';
|
||||||
activeSettingsPanelId = '';
|
activeSettingsPanelId = '';
|
||||||
}
|
}
|
||||||
|
|
@ -66,9 +104,10 @@
|
||||||
window.addEventListener('verstak:open-view', onOpenView);
|
window.addEventListener('verstak:open-view', onOpenView);
|
||||||
window.addEventListener('verstak:open-settings', onOpenSettings);
|
window.addEventListener('verstak:open-settings', onOpenSettings);
|
||||||
window.addEventListener('verstak:close-settings', onCloseSettings);
|
window.addEventListener('verstak:close-settings', onCloseSettings);
|
||||||
|
window.addEventListener('verstak:workbench-opened', onWorkbenchOpened);
|
||||||
}
|
}
|
||||||
|
|
||||||
checkVault();
|
onMount(() => { checkVault(); });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
|
|
@ -81,9 +120,11 @@
|
||||||
<main>
|
<main>
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
|
|
||||||
<section class="content">
|
<section class="content scroll-surface">
|
||||||
{#if currentView === 'plugin-manager'}
|
{#if currentView === 'plugin-manager'}
|
||||||
<PluginManager {activeSettingsPluginId} {activeSettingsPanelId} />
|
<PluginManager {activeSettingsPluginId} {activeSettingsPanelId} />
|
||||||
|
{:else if currentView === 'workbench'}
|
||||||
|
<WorkbenchHost {openedResource} />
|
||||||
{:else}
|
{:else}
|
||||||
<ViewContainer {activeView} {activeViewPluginId} />
|
<ViewContainer {activeView} {activeViewPluginId} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -98,6 +139,13 @@
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:global(html),
|
||||||
|
:global(body),
|
||||||
|
:global(#app) {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
:global(body) {
|
:global(body) {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
background: #1a1a2e;
|
background: #1a1a2e;
|
||||||
|
|
@ -105,6 +153,118 @@
|
||||||
overflow: hidden;
|
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 {
|
.app-loading {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -118,14 +278,17 @@
|
||||||
main {
|
main {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
width: 100%;
|
||||||
background: #1a1a2e;
|
background: #1a1a2e;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
padding: clamp(1rem, 2vw, 1.5rem);
|
||||||
padding: 1.5rem;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
|
@ -3,12 +3,12 @@
|
||||||
import * as App from '../../../wailsjs/go/api/App';
|
import * as App from '../../../wailsjs/go/api/App';
|
||||||
import Icon from '../ui/Icon.svelte';
|
import Icon from '../ui/Icon.svelte';
|
||||||
|
|
||||||
// Import the VerstakPluginAPI contract
|
import { createPluginAPI } from './VerstakPluginAPI.js';
|
||||||
import './VerstakPluginAPI.js';
|
|
||||||
|
|
||||||
export let pluginId = null;
|
export let pluginId = null;
|
||||||
export let componentId = null;
|
export let componentId = null;
|
||||||
export let viewPluginId = null;
|
export let viewPluginId = null;
|
||||||
|
export let componentProps = {};
|
||||||
|
|
||||||
let loadState = 'idle'; // idle | loading | loaded | error
|
let loadState = 'idle'; // idle | loading | loaded | error
|
||||||
let pluginInfo = null;
|
let pluginInfo = null;
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
let mountContainer = null;
|
let mountContainer = null;
|
||||||
let currentPluginId = null;
|
let currentPluginId = null;
|
||||||
let currentComponent = null;
|
let currentComponent = null;
|
||||||
|
let currentAPI = null;
|
||||||
|
|
||||||
$: activePluginId = pluginId || viewPluginId;
|
$: activePluginId = pluginId || viewPluginId;
|
||||||
$: activeComponent = componentId;
|
$: activeComponent = componentId;
|
||||||
|
|
@ -33,6 +34,13 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
function cleanup() {
|
function cleanup() {
|
||||||
|
if (currentAPI && typeof currentAPI.dispose === 'function') {
|
||||||
|
try {
|
||||||
|
currentAPI.dispose();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[PluginBundleHost] API dispose error:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
const reg = window.__VERSTAK_PLUGIN_REGISTRY__;
|
const reg = window.__VERSTAK_PLUGIN_REGISTRY__;
|
||||||
if (currentPluginId && currentComponent && reg && reg[currentPluginId]) {
|
if (currentPluginId && currentComponent && reg && reg[currentPluginId]) {
|
||||||
const comp = reg[currentPluginId][currentComponent];
|
const comp = reg[currentPluginId][currentComponent];
|
||||||
|
|
@ -49,6 +57,14 @@
|
||||||
}
|
}
|
||||||
currentPluginId = null;
|
currentPluginId = null;
|
||||||
currentComponent = 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) {
|
async function loadAndMount(pId, compId) {
|
||||||
|
|
@ -82,10 +98,11 @@
|
||||||
const reg = window.__VERSTAK_PLUGIN_REGISTRY__ || {};
|
const reg = window.__VERSTAK_PLUGIN_REGISTRY__ || {};
|
||||||
if (!reg[pId]) {
|
if (!reg[pId]) {
|
||||||
// Load the bundle JS content via backend API
|
// Load the bundle JS content via backend API
|
||||||
const [content, err] = await App.GetPluginAssetContent(pId, info.entry);
|
const assetResult = unpackBackendResult(await App.GetPluginAssetContent(pId, info.entry));
|
||||||
if (err || !content) {
|
const content = assetResult.value;
|
||||||
|
if (assetResult.error || !content) {
|
||||||
loadState = 'error';
|
loadState = 'error';
|
||||||
errorText = 'Failed to load bundle: ' + (err || 'empty content');
|
errorText = 'Failed to load bundle: ' + (assetResult.error || 'empty content');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -120,7 +137,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create API
|
// Create API
|
||||||
const api = window.VerstakPluginAPI(pId);
|
const api = createPluginAPI(pId);
|
||||||
|
currentAPI = api;
|
||||||
|
|
||||||
// Mount component
|
// Mount component
|
||||||
if (!mountContainer) {
|
if (!mountContainer) {
|
||||||
|
|
@ -129,7 +147,7 @@
|
||||||
}
|
}
|
||||||
if (mountContainer) {
|
if (mountContainer) {
|
||||||
try {
|
try {
|
||||||
comp.mount(mountContainer, { componentId: compId }, api);
|
comp.mount(mountContainer, Object.assign({ componentId: compId }, componentProps || {}), api);
|
||||||
loadState = 'loaded';
|
loadState = 'loaded';
|
||||||
errorText = '';
|
errorText = '';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -161,12 +179,6 @@
|
||||||
<p>Select a plugin view from the sidebar</p>
|
<p>Select a plugin view from the sidebar</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{:else if loadState === 'loading'}
|
|
||||||
<div class="host-state loading">
|
|
||||||
<div class="spinner"></div>
|
|
||||||
<p>Loading plugin bundle...</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{:else if loadState === 'error'}
|
{:else if loadState === 'error'}
|
||||||
<div class="host-state error">
|
<div class="host-state error">
|
||||||
<Icon name="warning" size={24} className="error-icon" />
|
<Icon name="warning" size={24} className="error-icon" />
|
||||||
|
|
@ -184,9 +196,16 @@
|
||||||
</div>
|
</div>
|
||||||
</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
|
<div
|
||||||
class="plugin-mount-container"
|
class="plugin-mount-container"
|
||||||
|
class:mount-hidden={loadState !== 'loaded'}
|
||||||
bind:this={mountContainer}
|
bind:this={mountContainer}
|
||||||
data-plugin-id={currentPluginId}
|
data-plugin-id={currentPluginId}
|
||||||
data-component={currentComponent}
|
data-component={currentComponent}
|
||||||
|
|
@ -197,10 +216,10 @@
|
||||||
<style>
|
<style>
|
||||||
.plugin-bundle-host {
|
.plugin-bundle-host {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
|
||||||
min-height: 200px;
|
min-height: 200px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.host-state {
|
.host-state {
|
||||||
|
|
@ -286,8 +305,14 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.plugin-mount-container {
|
.plugin-mount-container {
|
||||||
flex: 1;
|
min-width: 0;
|
||||||
overflow: auto;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.plugin-mount-container.mount-hidden {
|
||||||
|
height: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,10 @@
|
||||||
// VerstakPluginAPI is the restricted API passed to plugin frontend bundles.
|
import * as App from '../../../wailsjs/go/api/App';
|
||||||
// Plugins do NOT get direct access to Wails bridge — only what's exposed here.
|
|
||||||
// All methods are stubs or limited implementations.
|
|
||||||
|
|
||||||
(function() {
|
window.__VERSTAK_PLUGIN_REGISTRY__ = window.__VERSTAK_PLUGIN_REGISTRY__ || {};
|
||||||
// Store registered components per plugin
|
window.__VERSTAK_EVENT_HANDLERS__ = window.__VERSTAK_EVENT_HANDLERS__ || {};
|
||||||
window.__VERSTAK_PLUGIN_REGISTRY__ = window.__VERSTAK_PLUGIN_REGISTRY__ || {};
|
window.__VERSTAK_COMMAND_HANDLERS__ = window.__VERSTAK_COMMAND_HANDLERS__ || {};
|
||||||
|
|
||||||
// Original register function
|
|
||||||
const origRegister = window.VerstakPluginRegister;
|
|
||||||
if (origRegister) {
|
|
||||||
// Already defined — don't override
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (!window.VerstakPluginRegister) {
|
||||||
window.VerstakPluginRegister = function(pluginId, bundle) {
|
window.VerstakPluginRegister = function(pluginId, bundle) {
|
||||||
if (!pluginId || !bundle || !bundle.components) {
|
if (!pluginId || !bundle || !bundle.components) {
|
||||||
console.error('[VerstakPluginRegister] invalid registration:', pluginId);
|
console.error('[VerstakPluginRegister] invalid registration:', pluginId);
|
||||||
|
|
@ -21,48 +13,291 @@
|
||||||
console.log('[VerstakPluginRegister] registered:', pluginId, Object.keys(bundle.components));
|
console.log('[VerstakPluginRegister] registered:', pluginId, Object.keys(bundle.components));
|
||||||
window.__VERSTAK_PLUGIN_REGISTRY__[pluginId] = bundle.components;
|
window.__VERSTAK_PLUGIN_REGISTRY__[pluginId] = bundle.components;
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function unpack(result) {
|
||||||
|
if (Array.isArray(result) && result.length === 2 && (typeof result[1] === 'string' || result[1] == null)) {
|
||||||
|
return [result[0], result[1] || ''];
|
||||||
|
}
|
||||||
|
return [result, ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callBackend(pluginId, label, fn) {
|
||||||
|
try {
|
||||||
|
const [value, err] = unpack(await fn());
|
||||||
|
if (err) {
|
||||||
|
throw new Error(err);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
} catch (e) {
|
||||||
|
const message = e && e.message ? e.message : String(e);
|
||||||
|
throw new Error('[plugin:' + pluginId + '] ' + label + ' failed: ' + message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callBackendErrorString(pluginId, label, fn) {
|
||||||
|
try {
|
||||||
|
const err = await fn();
|
||||||
|
if (err) {
|
||||||
|
throw new Error(err);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const message = e && e.message ? e.message : String(e);
|
||||||
|
throw new Error('[plugin:' + pluginId + '] ' + label + ' failed: ' + message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEventHandlers(eventName) {
|
||||||
|
if (!window.__VERSTAK_EVENT_HANDLERS__[eventName]) {
|
||||||
|
window.__VERSTAK_EVENT_HANDLERS__[eventName] = [];
|
||||||
|
}
|
||||||
|
return window.__VERSTAK_EVENT_HANDLERS__[eventName];
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchLocalEvent(pluginId, eventName, payload) {
|
||||||
|
const event = {
|
||||||
|
name: eventName,
|
||||||
|
pluginId: pluginId,
|
||||||
|
payload: payload || {},
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
const handlers = getEventHandlers(eventName).slice();
|
||||||
|
handlers.forEach(function(handler) {
|
||||||
|
try {
|
||||||
|
handler(event);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[VerstakPluginAPI] event handler error:', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandKey(pluginId, commandId) {
|
||||||
|
return pluginId + ':' + commandId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPluginAPI(pluginId) {
|
||||||
|
if (!pluginId) {
|
||||||
|
throw new Error('createPluginAPI requires pluginId');
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanups = [];
|
||||||
|
let disposed = false;
|
||||||
|
|
||||||
|
function assertActive(label) {
|
||||||
|
if (disposed) {
|
||||||
|
throw new Error('[plugin:' + pluginId + '] ' + label + ' failed: API disposed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function trackCleanup(fn) {
|
||||||
|
cleanups.push(fn);
|
||||||
|
return function untrackAndRun() {
|
||||||
|
const idx = cleanups.indexOf(fn);
|
||||||
|
if (idx !== -1) {
|
||||||
|
cleanups.splice(idx, 1);
|
||||||
|
}
|
||||||
|
fn();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Create the restricted API object for a plugin host context
|
|
||||||
window.VerstakPluginAPI = function(pluginId) {
|
|
||||||
return {
|
return {
|
||||||
pluginId: pluginId,
|
pluginId: pluginId,
|
||||||
|
|
||||||
capabilities: {
|
capabilities: {
|
||||||
has: function(capId) {
|
has: async function(capId) {
|
||||||
// planned: query backend cap registry
|
const info = await callBackend(pluginId, 'capabilities.has(' + capId + ')', function() {
|
||||||
console.log('[plugin:' + pluginId + '] capabilities.has(' + capId + ') — stub');
|
return App.GetPluginCapability(pluginId, capId);
|
||||||
return false;
|
});
|
||||||
|
return !!(info && info.available);
|
||||||
|
},
|
||||||
|
get: function(capId) {
|
||||||
|
return callBackend(pluginId, 'capabilities.get(' + capId + ')', function() {
|
||||||
|
return App.GetPluginCapability(pluginId, capId);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
list: function() {
|
||||||
|
return callBackend(pluginId, 'capabilities.list', function() {
|
||||||
|
return App.ListPluginCapabilities(pluginId);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
events: {
|
events: {
|
||||||
publish: function(type, payload) {
|
publish: async function(type, payload) {
|
||||||
console.log('[plugin:' + pluginId + '] event publish:', type, payload);
|
await callBackendErrorString(pluginId, 'events.publish(' + type + ')', function() {
|
||||||
// planned: actual event bus bridge
|
return App.PublishPluginEvent(pluginId, type, payload || {});
|
||||||
|
});
|
||||||
|
dispatchLocalEvent(pluginId, type, payload || {});
|
||||||
},
|
},
|
||||||
subscribe: function(type, handler) {
|
subscribe: function(type, handler) {
|
||||||
console.log('[plugin:' + pluginId + '] event subscribe:', type, '(stub)');
|
assertActive('events.subscribe(' + type + ')');
|
||||||
// planned: actual event bus bridge
|
if (typeof handler !== 'function') {
|
||||||
|
throw new Error('events.subscribe requires a handler function');
|
||||||
|
}
|
||||||
|
return callBackendErrorString(pluginId, 'events.subscribe(' + type + ')', function() {
|
||||||
|
return App.SubscribePluginEvent(pluginId, type);
|
||||||
|
}).then(function() {
|
||||||
|
const handlers = getEventHandlers(type);
|
||||||
|
handlers.push(handler);
|
||||||
|
return trackCleanup(function unsubscribe() {
|
||||||
|
const current = getEventHandlers(type);
|
||||||
|
window.__VERSTAK_EVENT_HANDLERS__[type] = current.filter(function(item) {
|
||||||
|
return item !== handler;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
settings: {
|
settings: {
|
||||||
read: function(key) {
|
read: async function(key) {
|
||||||
console.log('[plugin:' + pluginId + '] settings.read(' + key + ') — stub');
|
assertActive('settings.read');
|
||||||
return null;
|
const settings = await callBackend(pluginId, 'settings.read', function() {
|
||||||
|
return App.ReadPluginSettings(pluginId);
|
||||||
|
});
|
||||||
|
if (!key) {
|
||||||
|
return settings || {};
|
||||||
|
}
|
||||||
|
return settings ? settings[key] : undefined;
|
||||||
},
|
},
|
||||||
write: function(key, value) {
|
write: async function(key, value) {
|
||||||
console.log('[plugin:' + pluginId + '] settings.write(' + key + ',', value, ') — stub');
|
assertActive('settings.write(' + key + ')');
|
||||||
// planned: backend storage namespace
|
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: {
|
commands: {
|
||||||
execute: function(cmdId, args) {
|
register: function(cmdId, handler) {
|
||||||
console.log('[plugin:' + pluginId + '] commands.execute(' + cmdId + ') — stub');
|
assertActive('commands.register(' + cmdId + ')');
|
||||||
// planned: command execution
|
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;
|
||||||
|
|
|
||||||
|
|
@ -211,6 +211,7 @@
|
||||||
border: 1px solid #0f3460;
|
border: 1px solid #0f3460;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.plugin-card.disabled {
|
.plugin-card.disabled {
|
||||||
|
|
@ -225,6 +226,8 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -232,6 +235,12 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
min-width: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-id strong {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-dot {
|
.status-dot {
|
||||||
|
|
@ -268,7 +277,7 @@
|
||||||
|
|
||||||
.card-meta {
|
.card-meta {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 0.3rem;
|
gap: 0.3rem;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
|
|
@ -277,6 +286,7 @@
|
||||||
.meta-row {
|
.meta-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
|
|
@ -288,6 +298,8 @@
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: #a0a0b8;
|
color: #a0a0b8;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section {
|
.section {
|
||||||
|
|
@ -316,6 +328,8 @@
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
color: #e0e0e0;
|
color: #e0e0e0;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag.provides {
|
.tag.provides {
|
||||||
|
|
@ -375,6 +389,7 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
padding-top: 0.5rem;
|
padding-top: 0.5rem;
|
||||||
border-top: 1px solid #0f3460;
|
border-top: 1px solid #0f3460;
|
||||||
|
|
@ -432,4 +447,19 @@
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-style: italic;
|
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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,9 @@
|
||||||
import Icon from '../ui/Icon.svelte';
|
import Icon from '../ui/Icon.svelte';
|
||||||
import PluginCard from './PluginCard.svelte';
|
import PluginCard from './PluginCard.svelte';
|
||||||
import PluginBundleHost from '../plugin-host/PluginBundleHost.svelte';
|
import PluginBundleHost from '../plugin-host/PluginBundleHost.svelte';
|
||||||
import { onMount } from 'svelte';
|
import { onMount, tick } from 'svelte';
|
||||||
import { GetPlugins, GetCapabilities, GetPermissions, GetContributions, ReloadPlugins, GetVaultStatus, GetVaultPluginState, EnablePlugin, DisablePlugin, ReadPluginSettings, WritePluginSettings, GetPluginFrontendInfo } from '../../../wailsjs/go/api/App';
|
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 plugins = [];
|
||||||
let capabilities = [];
|
let capabilities = [];
|
||||||
|
|
@ -45,6 +46,28 @@
|
||||||
}, 4000);
|
}, 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) {
|
async function openSettingsFromProps(pluginId, panelId) {
|
||||||
const panel = (contributions.settingsPanels || []).find(sp => sp.pluginId === pluginId && (!panelId || sp.id === panelId));
|
const panel = (contributions.settingsPanels || []).find(sp => sp.pluginId === pluginId && (!panelId || sp.id === panelId));
|
||||||
if (panel) {
|
if (panel) {
|
||||||
|
|
@ -55,8 +78,14 @@
|
||||||
const info = await GetPluginFrontendInfo(pluginId);
|
const info = await GetPluginFrontendInfo(pluginId);
|
||||||
settingsPluginInfo = info;
|
settingsPluginInfo = info;
|
||||||
} catch { settingsPluginInfo = null; }
|
} catch { settingsPluginInfo = null; }
|
||||||
ReadPluginSettings(pluginId).then(data => {
|
ReadPluginSettings(pluginId).then(result => {
|
||||||
settingsData = data || {};
|
const unpacked = unpackBackendResult(result);
|
||||||
|
if (unpacked.error) {
|
||||||
|
settingsError = unpacked.error;
|
||||||
|
settingsData = {};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settingsData = unpacked.value || {};
|
||||||
}).catch(() => { settingsData = {}; });
|
}).catch(() => { settingsData = {}; });
|
||||||
} else {
|
} else {
|
||||||
settingsError = `Settings panel not found for plugin "${pluginId}". Check that the plugin is enabled and has settingsPanels in its manifest.`;
|
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() {
|
async function loadAll() {
|
||||||
|
debug.log('[PluginManager] loadAll: START');
|
||||||
error = '';
|
error = '';
|
||||||
loading = true;
|
loading = true;
|
||||||
try {
|
try {
|
||||||
|
debug.log('[PluginManager] loadAll: calling GetPlugins...');
|
||||||
const p = await GetPlugins();
|
const p = await GetPlugins();
|
||||||
plugins = p || [];
|
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) {
|
} catch (e) {
|
||||||
|
debug.log('[PluginManager] loadAll: GetPlugins ERROR:', String(e));
|
||||||
|
WriteFrontendLog('PluginManager', 'loadAll: GetPlugins ERROR: ' + String(e));
|
||||||
error = 'GetPlugins: ' + String(e);
|
error = 'GetPlugins: ' + String(e);
|
||||||
loading = false;
|
loading = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Collect all async loads but await them so loading stays true until all are done
|
// Collect all async loads but await them so loading stays true until all are done
|
||||||
try {
|
try {
|
||||||
|
debug.log('[PluginManager] loadAll: loading vault/capabilities/permissions/contributions...');
|
||||||
const [v, caps, perms, contribs] = await Promise.all([
|
const [v, caps, perms, contribs] = await Promise.all([
|
||||||
GetVaultStatus().catch(() => ({ status: 'unknown', path: '', vaultId: '' })),
|
GetVaultStatus().catch(() => ({ status: 'unknown', path: '', vaultId: '' })),
|
||||||
GetCapabilities().catch(() => []),
|
GetCapabilities().catch(() => []),
|
||||||
|
|
@ -95,66 +133,93 @@
|
||||||
capabilities = caps || [];
|
capabilities = caps || [];
|
||||||
permissions = perms || [];
|
permissions = perms || [];
|
||||||
contributions = contribs || {};
|
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) {
|
} 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);
|
console.error('[PluginManager] non-critical load error:', e);
|
||||||
}
|
}
|
||||||
if (vaultStatus.status === 'open') {
|
if (vaultStatus.status === 'open') {
|
||||||
try {
|
try {
|
||||||
|
debug.log('[PluginManager] loadAll: calling GetVaultPluginState...');
|
||||||
vaultPluginState = await GetVaultPluginState() || { enabledPlugins: [], disabledPlugins: [], desiredPlugins: [] };
|
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;
|
loading = false;
|
||||||
|
await tick();
|
||||||
|
debug.log('[PluginManager] loadAll: END, loading=false');
|
||||||
|
WriteFrontendLog('PluginManager', 'loadAll: END, loading=false');
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => { loadAll(); });
|
onMount(() => { loadAll(); });
|
||||||
|
|
||||||
async function reload() {
|
async function reload() {
|
||||||
|
debug.log('[PluginManager] reload: START');
|
||||||
reloading = true;
|
reloading = true;
|
||||||
error = '';
|
error = '';
|
||||||
let resultMsg = '';
|
let resultMsg = '';
|
||||||
try {
|
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}`;
|
resultMsg = `Reloaded ${count} plugin(s). ${summary}`;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
debug.log('[PluginManager] reload: ReloadPlugins ERROR:', String(e));
|
||||||
error = 'Reload: ' + String(e);
|
error = 'Reload: ' + String(e);
|
||||||
reloading = false;
|
reloading = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
debug.log('[PluginManager] reload: calling loadAll after reload...');
|
||||||
await loadAll();
|
await loadAll();
|
||||||
|
notifyPluginsChanged();
|
||||||
reloading = false;
|
reloading = false;
|
||||||
|
debug.log('[PluginManager] reload: END');
|
||||||
showToast(resultMsg, 'success');
|
showToast(resultMsg, 'success');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function enablePlugin(pluginId) {
|
async function enablePlugin(pluginId) {
|
||||||
|
debug.log('[PluginManager] enablePlugin:', pluginId);
|
||||||
actionFeedback = { ...actionFeedback, [pluginId]: 'enabling' };
|
actionFeedback = { ...actionFeedback, [pluginId]: 'enabling' };
|
||||||
error = '';
|
error = '';
|
||||||
const err = await EnablePlugin(pluginId);
|
const err = await EnablePlugin(pluginId);
|
||||||
if (err) {
|
if (err) {
|
||||||
|
debug.log('[PluginManager] enablePlugin: ERROR:', err);
|
||||||
actionFeedback = { ...actionFeedback, [pluginId]: null };
|
actionFeedback = { ...actionFeedback, [pluginId]: null };
|
||||||
error = 'Enable: ' + err;
|
error = 'Enable: ' + err;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
debug.log('[PluginManager] enablePlugin: success, reloading...');
|
||||||
// Reload to get updated state
|
// Reload to get updated state
|
||||||
try { await ReloadPlugins(); } catch (e) { /* ignore */ }
|
try { await ReloadPlugins(); } catch (e) { /* ignore */ }
|
||||||
await loadAll();
|
await loadAll();
|
||||||
|
notifyPluginsChanged();
|
||||||
actionFeedback = { ...actionFeedback, [pluginId]: null };
|
actionFeedback = { ...actionFeedback, [pluginId]: null };
|
||||||
|
debug.log('[PluginManager] enablePlugin: done');
|
||||||
showToast(`Plugin "${pluginId}" enabled`, 'success');
|
showToast(`Plugin "${pluginId}" enabled`, 'success');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function disablePlugin(pluginId) {
|
async function disablePlugin(pluginId) {
|
||||||
|
debug.log('[PluginManager] disablePlugin:', pluginId);
|
||||||
actionFeedback = { ...actionFeedback, [pluginId]: 'disabling' };
|
actionFeedback = { ...actionFeedback, [pluginId]: 'disabling' };
|
||||||
error = '';
|
error = '';
|
||||||
const err = await DisablePlugin(pluginId);
|
const err = await DisablePlugin(pluginId);
|
||||||
if (err) {
|
if (err) {
|
||||||
|
debug.log('[PluginManager] disablePlugin: ERROR:', err);
|
||||||
actionFeedback = { ...actionFeedback, [pluginId]: null };
|
actionFeedback = { ...actionFeedback, [pluginId]: null };
|
||||||
error = 'Disable: ' + err;
|
error = 'Disable: ' + err;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
debug.log('[PluginManager] disablePlugin: success, reloading...');
|
||||||
// Reload to get updated state
|
// Reload to get updated state
|
||||||
try { await ReloadPlugins(); } catch (e) { /* ignore */ }
|
try { await ReloadPlugins(); } catch (e) { /* ignore */ }
|
||||||
await loadAll();
|
await loadAll();
|
||||||
|
notifyPluginsChanged();
|
||||||
actionFeedback = { ...actionFeedback, [pluginId]: null };
|
actionFeedback = { ...actionFeedback, [pluginId]: null };
|
||||||
|
debug.log('[PluginManager] disablePlugin: done');
|
||||||
showToast(`Plugin "${pluginId}" disabled`, 'info');
|
showToast(`Plugin "${pluginId}" disabled`, 'info');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -327,14 +392,18 @@
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.plugin-manager {
|
.plugin-manager {
|
||||||
max-width: 900px;
|
flex: 1;
|
||||||
padding-top: 0.5rem;
|
width: min(100%, 1100px);
|
||||||
|
min-height: 0;
|
||||||
|
padding: 0.5rem 0.5rem 1.5rem 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
header {
|
header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
margin-bottom: 1.25rem;
|
margin-bottom: 1.25rem;
|
||||||
padding-bottom: 0.75rem;
|
padding-bottom: 0.75rem;
|
||||||
border-bottom: 1px solid #0f3460;
|
border-bottom: 1px solid #0f3460;
|
||||||
|
|
@ -343,14 +412,20 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
min-width: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
h2 { color: #e0e0e0; font-size: 1.3rem; margin: 0; }
|
h2 { color: #e0e0e0; font-size: 1.3rem; margin: 0; }
|
||||||
.vault-badge {
|
.vault-badge {
|
||||||
|
max-width: 100%;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
padding: 0.2rem 0.6rem;
|
padding: 0.2rem 0.6rem;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
border: 1px solid;
|
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-open { background: rgba(78, 204, 163, 0.15); color: #4ecca3; border-color: #4ecca3; }
|
||||||
.vault-not-created { background: rgba(255, 200, 87, 0.15); color: #ffc857; border-color: #ffc857; }
|
.vault-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 { list-style: none; padding: 0; margin: 0.5rem 0; font-size: 0.8rem; opacity: 0.7; }
|
||||||
.hint-list li { margin: 0.25rem 0; }
|
.hint-list li { margin: 0.25rem 0; }
|
||||||
.hint code { background: #0f3460; padding: 0.1rem 0.3rem; border-radius: 3px; }
|
.hint code { background: #0f3460; padding: 0.1rem 0.3rem; border-radius: 3px; }
|
||||||
.plugin-list { display: flex; flex-direction: column; gap: 0.75rem; margin-bottom: 1.5rem; }
|
.plugin-list { display: flex; flex-direction: column; gap: 0.75rem; margin-bottom: 1.5rem; min-width: 0; }
|
||||||
|
|
||||||
.missing-section { margin-bottom: 1.5rem; }
|
.missing-section { margin-bottom: 1.5rem; }
|
||||||
.missing-section h3 { color: #e94560; font-size: 1rem; margin: 0 0 0.25rem; }
|
.missing-section h3 { color: #e94560; font-size: 1rem; margin: 0 0 0.25rem; }
|
||||||
|
|
@ -450,4 +525,24 @@
|
||||||
.modal-body { padding: 1rem; overflow-y: auto; }
|
.modal-body { padding: 1rem; overflow-y: auto; }
|
||||||
.settings-hint { color: #666; font-size: 0.8rem; margin: 0.25rem 0; }
|
.settings-hint { color: #666; font-size: 0.8rem; margin: 0.25rem 0; }
|
||||||
.settings-hint code { color: #4ecca3; }
|
.settings-hint code { color: #4ecca3; }
|
||||||
|
|
||||||
|
@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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,13 @@
|
||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import * as App from '../../../wailsjs/go/api/App';
|
import * as App from '../../../wailsjs/go/api/App';
|
||||||
import WorkspaceTree from './WorkspaceTree.svelte';
|
import WorkspaceTree from './WorkspaceTree.svelte';
|
||||||
import Icon from '../ui/Icon.svelte';
|
import Icon from '../ui/Icon.svelte';
|
||||||
|
import { debug } from '../log/debug.js';
|
||||||
|
|
||||||
|
function flog(msg) {
|
||||||
|
App.WriteFrontendLog('Sidebar', msg);
|
||||||
|
}
|
||||||
|
|
||||||
let plugins = [];
|
let plugins = [];
|
||||||
let vaultStatus = { status: 'unknown', path: '', vaultId: '' };
|
let vaultStatus = { status: 'unknown', path: '', vaultId: '' };
|
||||||
|
|
@ -15,9 +20,13 @@
|
||||||
|
|
||||||
$: vaultOpen = vaultStatus.status === 'open';
|
$: vaultOpen = vaultStatus.status === 'open';
|
||||||
|
|
||||||
onMount(async () => {
|
async function loadSidebar() {
|
||||||
|
debug.log('[Sidebar] onMount: START');
|
||||||
|
flog('onMount: START');
|
||||||
let contribErr = false;
|
let contribErr = false;
|
||||||
try {
|
try {
|
||||||
|
debug.log('[Sidebar] onMount: loading plugins/vault/contributions...');
|
||||||
|
flog('onMount: loading plugins/vault/contributions...');
|
||||||
const [p, v, contribs] = await Promise.all([
|
const [p, v, contribs] = await Promise.all([
|
||||||
App.GetPlugins().catch(() => []),
|
App.GetPlugins().catch(() => []),
|
||||||
App.GetVaultStatus().catch(() => ({ status: 'unknown', path: '', vaultId: '' })),
|
App.GetVaultStatus().catch(() => ({ status: 'unknown', path: '', vaultId: '' })),
|
||||||
|
|
@ -25,6 +34,8 @@
|
||||||
]);
|
]);
|
||||||
plugins = p || [];
|
plugins = p || [];
|
||||||
vaultStatus = v;
|
vaultStatus = v;
|
||||||
|
debug.log('[Sidebar] onMount: plugins=' + plugins.length + ' vault=' + vaultStatus.status);
|
||||||
|
flog('onMount: plugins=' + plugins.length + ' vault=' + vaultStatus.status);
|
||||||
if (contribErr) {
|
if (contribErr) {
|
||||||
errorMessage = 'Failed to load plugin contributions';
|
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';
|
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));
|
sidebarItems.sort((a, b) => (a.position || 100) - (b.position || 100));
|
||||||
|
debug.log('[Sidebar] onMount: sidebarItems=' + sidebarItems.length);
|
||||||
|
flog('onMount: sidebarItems=' + sidebarItems.length);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
debug.log('[Sidebar] onMount: ERROR:', String(e));
|
||||||
|
flog('onMount: ERROR: ' + String(e));
|
||||||
console.error('[Sidebar] load error:', e);
|
console.error('[Sidebar] load error:', e);
|
||||||
errorMessage = 'Failed to load sidebar';
|
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) {
|
function handleNav(id) {
|
||||||
|
debug.log('[Sidebar] handleNav:', id);
|
||||||
window.dispatchEvent(new CustomEvent('verstak:nav', { detail: { viewId: id } }));
|
window.dispatchEvent(new CustomEvent('verstak:nav', { detail: { viewId: id } }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSidebarItem(item) {
|
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
|
// Use item.view (the view contribution ID) if available, fall back to item.id
|
||||||
const viewId = item.view || item.id;
|
const viewId = item.view || item.id;
|
||||||
window.dispatchEvent(new CustomEvent('verstak:open-view', { detail: { viewId, pluginId: item.pluginId } }));
|
window.dispatchEvent(new CustomEvent('verstak:open-view', { detail: { viewId, pluginId: item.pluginId } }));
|
||||||
|
|
@ -171,6 +199,7 @@
|
||||||
.nav-item {
|
.nav-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
padding: 0.45rem 0.75rem;
|
padding: 0.45rem 0.75rem;
|
||||||
background: none;
|
background: none;
|
||||||
|
|
|
||||||
|
|
@ -101,13 +101,14 @@
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
min-width: 0;
|
||||||
background: #1a1a2e;
|
background: #1a1a2e;
|
||||||
}
|
}
|
||||||
.view {
|
.view {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
}
|
}
|
||||||
.view.degraded {
|
.view.degraded {
|
||||||
|
|
@ -155,7 +156,7 @@
|
||||||
}
|
}
|
||||||
.view-content {
|
.view-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: auto;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.placeholder {
|
.placeholder {
|
||||||
color: #666;
|
color: #666;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -1,8 +1,15 @@
|
||||||
|
<script context="module">
|
||||||
|
import { writable } from 'svelte/store';
|
||||||
|
|
||||||
|
const activeWorkspaceNodeId = writable('');
|
||||||
|
</script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import * as App from '../../../wailsjs/go/api/App';
|
import * as App from '../../../wailsjs/go/api/App';
|
||||||
|
|
||||||
export let nodes = [];
|
export let nodes = [];
|
||||||
|
export let node = null;
|
||||||
export let currentNodeId = '';
|
export let currentNodeId = '';
|
||||||
export let expandedNodes = {};
|
export let expandedNodes = {};
|
||||||
export let depth = 0;
|
export let depth = 0;
|
||||||
|
|
@ -32,6 +39,7 @@
|
||||||
} else {
|
} else {
|
||||||
nodes = result.nodes || [];
|
nodes = result.nodes || [];
|
||||||
currentNodeId = result.currentNodeId || '';
|
currentNodeId = result.currentNodeId || '';
|
||||||
|
activeWorkspaceNodeId.set(currentNodeId);
|
||||||
const root = nodes.find(n => !n.parentId);
|
const root = nodes.find(n => !n.parentId);
|
||||||
if (root) expandedNodes[root.id] = true;
|
if (root) expandedNodes[root.id] = true;
|
||||||
}
|
}
|
||||||
|
|
@ -69,6 +77,7 @@
|
||||||
const err = await App.SetCurrentWorkspaceNode(id);
|
const err = await App.SetCurrentWorkspaceNode(id);
|
||||||
if (err) { localError = err; return; }
|
if (err) { localError = err; return; }
|
||||||
currentNodeId = id;
|
currentNodeId = id;
|
||||||
|
activeWorkspaceNodeId.set(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCreate(parentId, type) {
|
function openCreate(parentId, type) {
|
||||||
|
|
@ -128,7 +137,7 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{: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;">
|
<div class="wt-row" style="padding-left: {depth * 1.0 + 0.4}rem;">
|
||||||
{#if hasKids(node.id)}
|
{#if hasKids(node.id)}
|
||||||
<button class="wt-expand" on:click={() => toggle(node.id)} type="button">{expandedNodes[node.id] ? '\u25BE' : '\u25B8'}</button>
|
<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 { 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-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-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:hover { color: #4ecca3; background: rgba(78,204,163,0.1); }
|
||||||
.wt-btn-small { font-size: 0.7rem; opacity: 0; }
|
.wt-btn-small { font-size: 0.7rem; opacity: 0; }
|
||||||
.wt-row:hover .wt-btn-small { opacity: 1; }
|
.wt-row:hover .wt-btn-small { opacity: 1; }
|
||||||
|
|
@ -162,12 +171,12 @@
|
||||||
.wt-node { }
|
.wt-node { }
|
||||||
.wt-row { display: flex; align-items: center; gap: 0.2rem; padding: 0.15rem 0; }
|
.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:hover { background: rgba(15,52,96,0.4); }
|
||||||
.wt-row.selected { background: rgba(78,204,163,0.1); }
|
.wt-node.selected > .wt-row { 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-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:hover { color: #e0e0f0; }
|
||||||
.wt-expand-spacer { width: 1rem; flex-shrink: 0; }
|
.wt-expand-spacer { width: 1rem; flex-shrink: 0; }
|
||||||
.wt-icon { font-size: 0.8rem; 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-label:hover { color: #4ecca3; }
|
||||||
.wt-node.archived .wt-label { text-decoration: line-through; opacity: 0.5; }
|
.wt-node.archived .wt-label { text-decoration: line-through; opacity: 0.5; }
|
||||||
.wt-node.sleeping .wt-label { opacity: 0.6; }
|
.wt-node.sleeping .wt-label { opacity: 0.6; }
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
})();
|
||||||
|
|
@ -1,14 +1,18 @@
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => {
|
||||||
|
const isTest = mode === 'test';
|
||||||
|
|
||||||
|
return {
|
||||||
plugins: [svelte()],
|
plugins: [svelte()],
|
||||||
build: {
|
build: {
|
||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: isTest ? 5174 : 5173,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
},
|
},
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import {capability} from '../models';
|
||||||
import {api} from '../models';
|
import {api} from '../models';
|
||||||
import {permissions} from '../models';
|
import {permissions} from '../models';
|
||||||
import {plugin} from '../models';
|
import {plugin} from '../models';
|
||||||
|
import {files} from '../models';
|
||||||
|
import {workbench} from '../models';
|
||||||
|
|
||||||
export function ArchiveWorkspaceNode(arg1:string):Promise<string>;
|
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 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 CreateWorkspaceNode(arg1:string,arg2:string,arg3:string):Promise<Record<string, any>>;
|
||||||
|
|
||||||
export function DisablePlugin(arg1:string):Promise<string>;
|
export function DisablePlugin(arg1:string):Promise<string>;
|
||||||
|
|
||||||
export function EnablePlugin(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 GetAppSettings():Promise<Record<string, any>>;
|
||||||
|
|
||||||
export function GetCapabilities():Promise<Array<capability.Entry>>;
|
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 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 GetPluginFrontendInfo(arg1:string):Promise<Record<string, any>>;
|
||||||
|
|
||||||
export function GetPlugins():Promise<Array<plugin.Plugin>>;
|
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 GetVaultPluginState():Promise<Record<string, any>>;
|
||||||
|
|
||||||
export function GetVaultStatus():Promise<Record<string, string>>;
|
export function GetVaultStatus():Promise<Record<string, string>>;
|
||||||
|
|
||||||
export function GetWorkspaceTree():Promise<Record<string, any>>;
|
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 MoveWorkspaceNode(arg1:string,arg2:string):Promise<string>;
|
||||||
|
|
||||||
export function OpenVault(arg1:string):Promise<void>;
|
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 ReadPluginDataJSON(arg1:string,arg2:string):Promise<Record<string, any>>;
|
||||||
|
|
||||||
export function ReadPluginSetting(arg1:string,arg2:string):Promise<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>;
|
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 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 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 WritePluginDataJSON(arg1:string,arg2:string,arg3:Record<string, any>):Promise<string>;
|
||||||
|
|
||||||
export function WritePluginSetting(arg1:string,arg2:string,arg3: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 WritePluginSettings(arg1:string,arg2:Record<string, any>):Promise<string>;
|
||||||
|
|
||||||
|
export function WriteVaultTextFile(arg1:string,arg2:string,arg3:string,arg4:files.WriteOptions):Promise<string>;
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,10 @@ export function CreateVault(arg1) {
|
||||||
return window['go']['api']['App']['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) {
|
export function CreateWorkspaceNode(arg1, arg2, arg3) {
|
||||||
return window['go']['api']['App']['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);
|
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() {
|
export function GetAppSettings() {
|
||||||
return window['go']['api']['App']['GetAppSettings']();
|
return window['go']['api']['App']['GetAppSettings']();
|
||||||
}
|
}
|
||||||
|
|
@ -50,6 +62,10 @@ export function GetPluginAssetContent(arg1, arg2) {
|
||||||
return window['go']['api']['App']['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) {
|
export function GetPluginFrontendInfo(arg1) {
|
||||||
return window['go']['api']['App']['GetPluginFrontendInfo'](arg1);
|
return window['go']['api']['App']['GetPluginFrontendInfo'](arg1);
|
||||||
}
|
}
|
||||||
|
|
@ -58,6 +74,10 @@ export function GetPlugins() {
|
||||||
return window['go']['api']['App']['GetPlugins']();
|
return window['go']['api']['App']['GetPlugins']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetVaultFileMetadata(arg1, arg2) {
|
||||||
|
return window['go']['api']['App']['GetVaultFileMetadata'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
export function GetVaultPluginState() {
|
export function GetVaultPluginState() {
|
||||||
return window['go']['api']['App']['GetVaultPluginState']();
|
return window['go']['api']['App']['GetVaultPluginState']();
|
||||||
}
|
}
|
||||||
|
|
@ -70,6 +90,26 @@ export function GetWorkspaceTree() {
|
||||||
return window['go']['api']['App']['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) {
|
export function MoveWorkspaceNode(arg1, arg2) {
|
||||||
return window['go']['api']['App']['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);
|
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) {
|
export function ReadPluginDataJSON(arg1, arg2) {
|
||||||
return window['go']['api']['App']['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);
|
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) {
|
export function RecordDesiredPlugin(arg1, arg2, arg3) {
|
||||||
return window['go']['api']['App']['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);
|
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) {
|
export function UpdateAppSettings(arg1) {
|
||||||
return window['go']['api']['App']['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) {
|
export function WritePluginDataJSON(arg1, arg2, arg3) {
|
||||||
return window['go']['api']['App']['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) {
|
export function WritePluginSettings(arg1, arg2) {
|
||||||
return window['go']['api']['App']['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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,63 @@
|
||||||
export namespace api {
|
export namespace api {
|
||||||
|
|
||||||
|
export class FlatOpenProviderSupport {
|
||||||
|
kind: string;
|
||||||
|
mime?: string[];
|
||||||
|
extensions?: string[];
|
||||||
|
contexts?: string[];
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new FlatOpenProviderSupport(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.kind = source["kind"];
|
||||||
|
this.mime = source["mime"];
|
||||||
|
this.extensions = source["extensions"];
|
||||||
|
this.contexts = source["contexts"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class FlatOpenProvider {
|
||||||
|
pluginId: string;
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
priority?: number;
|
||||||
|
component: string;
|
||||||
|
supports: FlatOpenProviderSupport[];
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new FlatOpenProvider(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.pluginId = source["pluginId"];
|
||||||
|
this.id = source["id"];
|
||||||
|
this.title = source["title"];
|
||||||
|
this.priority = source["priority"];
|
||||||
|
this.component = source["component"];
|
||||||
|
this.supports = this.convertValues(source["supports"], FlatOpenProviderSupport);
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
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 FlatSidebarItem {
|
export class FlatSidebarItem {
|
||||||
pluginId: string;
|
pluginId: string;
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -87,6 +145,7 @@ export namespace api {
|
||||||
commands: FlatCommand[];
|
commands: FlatCommand[];
|
||||||
settingsPanels: FlatSettingsPanel[];
|
settingsPanels: FlatSettingsPanel[];
|
||||||
sidebarItems: FlatSidebarItem[];
|
sidebarItems: FlatSidebarItem[];
|
||||||
|
openProviders: FlatOpenProvider[];
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new ContributionSummary(source);
|
return new ContributionSummary(source);
|
||||||
|
|
@ -98,6 +157,7 @@ export namespace api {
|
||||||
this.commands = this.convertValues(source["commands"], FlatCommand);
|
this.commands = this.convertValues(source["commands"], FlatCommand);
|
||||||
this.settingsPanels = this.convertValues(source["settingsPanels"], FlatSettingsPanel);
|
this.settingsPanels = this.convertValues(source["settingsPanels"], FlatSettingsPanel);
|
||||||
this.sidebarItems = this.convertValues(source["sidebarItems"], FlatSidebarItem);
|
this.sidebarItems = this.convertValues(source["sidebarItems"], FlatSidebarItem);
|
||||||
|
this.openProviders = this.convertValues(source["openProviders"], FlatOpenProvider);
|
||||||
}
|
}
|
||||||
|
|
||||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
|
@ -122,6 +182,167 @@ export namespace api {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
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 namespace capability {
|
||||||
|
|
@ -147,6 +368,119 @@ 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 namespace permissions {
|
||||||
|
|
||||||
export class Entry {
|
export class Entry {
|
||||||
|
|
@ -578,4 +912,3 @@ export namespace plugin {
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,13 @@ package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
"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/capability"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/contribution"
|
"github.com/verstak/verstak-desktop/internal/core/contribution"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/events"
|
"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/permissions"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/plugin"
|
"github.com/verstak/verstak-desktop/internal/core/plugin"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/pluginstate"
|
"github.com/verstak/verstak-desktop/internal/core/pluginstate"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/storage"
|
"github.com/verstak/verstak-desktop/internal/core/storage"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/vault"
|
"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/core/workspace"
|
||||||
|
"github.com/verstak/verstak-desktop/internal/shell/debug"
|
||||||
)
|
)
|
||||||
|
|
||||||
// App is the main application struct exposed to the Wails frontend.
|
// App is the main application struct exposed to the Wails frontend.
|
||||||
|
|
@ -33,9 +38,12 @@ type App struct {
|
||||||
plugins []plugin.Plugin
|
plugins []plugin.Plugin
|
||||||
vault *vault.Vault
|
vault *vault.Vault
|
||||||
storage *storage.Storage
|
storage *storage.Storage
|
||||||
|
files *corefiles.Service
|
||||||
appSettings *appsettings.Manager
|
appSettings *appsettings.Manager
|
||||||
pluginState *pluginstate.Manager
|
pluginState *pluginstate.Manager
|
||||||
|
workbench *coreworkbench.Router
|
||||||
workspace *workspace.Manager
|
workspace *workspace.Manager
|
||||||
|
debug bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewApp creates a new App instance.
|
// NewApp creates a new App instance.
|
||||||
|
|
@ -47,9 +55,11 @@ func NewApp(
|
||||||
plugins []plugin.Plugin,
|
plugins []plugin.Plugin,
|
||||||
vaultService *vault.Vault,
|
vaultService *vault.Vault,
|
||||||
storageService *storage.Storage,
|
storageService *storage.Storage,
|
||||||
|
filesService *corefiles.Service,
|
||||||
appSettingsMgr *appsettings.Manager,
|
appSettingsMgr *appsettings.Manager,
|
||||||
pluginStateMgr *pluginstate.Manager,
|
pluginStateMgr *pluginstate.Manager,
|
||||||
workspaceMgr *workspace.Manager,
|
workspaceMgr *workspace.Manager,
|
||||||
|
debugEnabled bool,
|
||||||
) *App {
|
) *App {
|
||||||
return &App{
|
return &App{
|
||||||
capRegistry: capReg,
|
capRegistry: capReg,
|
||||||
|
|
@ -59,37 +69,119 @@ func NewApp(
|
||||||
plugins: plugins,
|
plugins: plugins,
|
||||||
vault: vaultService,
|
vault: vaultService,
|
||||||
storage: storageService,
|
storage: storageService,
|
||||||
|
files: filesService,
|
||||||
appSettings: appSettingsMgr,
|
appSettings: appSettingsMgr,
|
||||||
pluginState: pluginStateMgr,
|
pluginState: pluginStateMgr,
|
||||||
|
workbench: coreworkbench.NewRouter(workbenchPrefsFromSettings(appSettingsMgr)),
|
||||||
workspace: workspaceMgr,
|
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.
|
// Startup is called when the app starts. Sets the Wails context for dialogs.
|
||||||
func (a *App) Startup(ctx context.Context) {
|
func (a *App) Startup(ctx context.Context) {
|
||||||
a.ctx = ctx
|
a.ctx = ctx
|
||||||
log.Printf("[api] App.Startup: initialized with %d plugins", len(a.plugins))
|
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 ─────────────────────────────────────
|
// ─── Plugin Manager API ─────────────────────────────────────
|
||||||
|
|
||||||
// GetPlugins returns all discovered plugins.
|
// GetPlugins returns all discovered plugins.
|
||||||
func (a *App) GetPlugins() []plugin.Plugin {
|
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
|
return a.plugins
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCapabilities returns all registered capabilities.
|
// GetCapabilities returns all registered capabilities.
|
||||||
func (a *App) GetCapabilities() []capability.Entry {
|
func (a *App) GetCapabilities() []capability.Entry {
|
||||||
entries := a.capRegistry.List()
|
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
|
return entries
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPermissions returns all known permissions.
|
// GetPermissions returns all known permissions.
|
||||||
func (a *App) GetPermissions() []permissions.Entry {
|
func (a *App) GetPermissions() []permissions.Entry {
|
||||||
entries := a.permRegistry.List()
|
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
|
return entries
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -132,12 +224,29 @@ type FlatCommand struct {
|
||||||
Handler string `json:"handler,omitempty"`
|
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.
|
// ContributionSummary aggregates all contribution types for the frontend.
|
||||||
type ContributionSummary struct {
|
type ContributionSummary struct {
|
||||||
Views []FlatView `json:"views"`
|
Views []FlatView `json:"views"`
|
||||||
Commands []FlatCommand `json:"commands"`
|
Commands []FlatCommand `json:"commands"`
|
||||||
SettingsPanels []FlatSettingsPanel `json:"settingsPanels"`
|
SettingsPanels []FlatSettingsPanel `json:"settingsPanels"`
|
||||||
SidebarItems []FlatSidebarItem `json:"sidebarItems"`
|
SidebarItems []FlatSidebarItem `json:"sidebarItems"`
|
||||||
|
OpenProviders []FlatOpenProvider `json:"openProviders"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildContributionSummary creates a ContributionSummary from the registry.
|
// buildContributionSummary creates a ContributionSummary from the registry.
|
||||||
|
|
@ -149,6 +258,7 @@ func buildContributionSummary(r *contribution.Registry) ContributionSummary {
|
||||||
regCmds := r.Commands()
|
regCmds := r.Commands()
|
||||||
regPanels := r.SettingsPanels()
|
regPanels := r.SettingsPanels()
|
||||||
regSidebar := r.SidebarItems()
|
regSidebar := r.SidebarItems()
|
||||||
|
regOpenProviders := r.OpenProviders()
|
||||||
|
|
||||||
views := make([]FlatView, len(regViews))
|
views := make([]FlatView, len(regViews))
|
||||||
for i, v := range regViews {
|
for i, v := range regViews {
|
||||||
|
|
@ -166,46 +276,43 @@ func buildContributionSummary(r *contribution.Registry) ContributionSummary {
|
||||||
for i, v := range regSidebar {
|
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}
|
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.
|
// GetContributions returns all registered contributions flattened for the frontend.
|
||||||
func (a *App) GetContributions() ContributionSummary {
|
func (a *App) GetContributions() ContributionSummary {
|
||||||
if a.contribRegistry == nil {
|
if a.contribRegistry == nil {
|
||||||
|
if a.debug {
|
||||||
|
debug.Logf("[api] GetContributions: contribRegistry is nil")
|
||||||
|
}
|
||||||
return ContributionSummary{}
|
return ContributionSummary{}
|
||||||
}
|
}
|
||||||
return buildContributionSummary(a.contribRegistry)
|
summary := buildContributionSummary(a.contribRegistry)
|
||||||
}
|
if a.debug {
|
||||||
|
debug.Logf("[api] GetContributions: returning views=%d commands=%d sidebar=%d settings=%d openProviders=%d",
|
||||||
// expandPath resolves "~" to the user's home directory.
|
len(summary.Views), len(summary.Commands), len(summary.SidebarItems), len(summary.SettingsPanels), len(summary.OpenProviders))
|
||||||
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:])
|
return summary
|
||||||
}
|
|
||||||
return path
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReloadPlugins re-discovers plugins from disk and returns a summary.
|
// ReloadPlugins re-discovers plugins from disk and returns a summary.
|
||||||
func (a *App) ReloadPlugins() (int, string) {
|
func (a *App) ReloadPlugins() (int, string) {
|
||||||
// Resolve plugin directories relative to the binary location
|
discoveryDirs := plugin.DefaultDiscoveryDirs()
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("[api] ReloadPlugins: scanning dirs: %v", discoveryDirs)
|
log.Printf("[api] ReloadPlugins: scanning dirs: %v", discoveryDirs)
|
||||||
|
|
||||||
// Unregister all non-core capabilities
|
// Unregister all non-core capabilities
|
||||||
|
|
@ -218,6 +325,8 @@ func (a *App) ReloadPlugins() (int, string) {
|
||||||
"verstak/core/contribution-registry/v1",
|
"verstak/core/contribution-registry/v1",
|
||||||
"verstak/core/permissions/v1",
|
"verstak/core/permissions/v1",
|
||||||
"verstak/core/events/v1",
|
"verstak/core/events/v1",
|
||||||
|
"verstak/core/files/v1",
|
||||||
|
"verstak/core/workbench/v1",
|
||||||
}
|
}
|
||||||
if err := a.capRegistry.Register("verstak-desktop", coreCaps); err != nil {
|
if err := a.capRegistry.Register("verstak-desktop", coreCaps); err != nil {
|
||||||
log.Printf("[api] ReloadPlugins: failed to re-register core capabilities: %v", err)
|
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{
|
return map[string]string{
|
||||||
"status": status,
|
"status": status,
|
||||||
"path": path,
|
"path": path,
|
||||||
|
|
@ -370,20 +483,26 @@ func (a *App) CloseVault() error {
|
||||||
// ─── Storage API ────────────────────────────────────────────
|
// ─── Storage API ────────────────────────────────────────────
|
||||||
|
|
||||||
// ReadPluginSettings returns all settings for a plugin.
|
// 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 {
|
if a.storage == nil {
|
||||||
return make(map[string]interface{})
|
return make(map[string]interface{}), "storage not initialized"
|
||||||
}
|
}
|
||||||
data, err := a.storage.ReadPluginSettings(pluginID)
|
data, err := a.storage.ReadPluginSettings(pluginID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[api] ReadPluginSettings(%s): %v", pluginID, err)
|
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.
|
// WritePluginSettings writes all settings for a plugin.
|
||||||
func (a *App) WritePluginSettings(pluginID string, data map[string]interface{}) string {
|
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 {
|
if a.storage == nil {
|
||||||
return "storage not initialized"
|
return "storage not initialized"
|
||||||
}
|
}
|
||||||
|
|
@ -396,6 +515,10 @@ func (a *App) WritePluginSettings(pluginID string, data map[string]interface{})
|
||||||
|
|
||||||
// ReadPluginSetting returns a single setting value.
|
// ReadPluginSetting returns a single setting value.
|
||||||
func (a *App) ReadPluginSetting(pluginID, key string) interface{} {
|
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 {
|
if a.storage == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -409,6 +532,9 @@ func (a *App) ReadPluginSetting(pluginID, key string) interface{} {
|
||||||
|
|
||||||
// WritePluginSetting writes a single setting value.
|
// WritePluginSetting writes a single setting value.
|
||||||
func (a *App) WritePluginSetting(pluginID, key string, value interface{}) string {
|
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 {
|
if a.storage == nil {
|
||||||
return "storage not initialized"
|
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.
|
// ReadPluginDataJSON reads a named JSON data file for a plugin.
|
||||||
func (a *App) ReadPluginDataJSON(pluginID, name string) map[string]interface{} {
|
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 {
|
if a.storage == nil {
|
||||||
return make(map[string]interface{})
|
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.
|
// WritePluginDataJSON writes a named JSON data file for a plugin.
|
||||||
func (a *App) WritePluginDataJSON(pluginID, name string, data map[string]interface{}) string {
|
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 {
|
if a.storage == nil {
|
||||||
return "storage not initialized"
|
return "storage not initialized"
|
||||||
}
|
}
|
||||||
|
|
@ -444,6 +577,278 @@ func (a *App) WritePluginDataJSON(pluginID, name string, data map[string]interfa
|
||||||
return ""
|
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 ──────────────────────────────────────
|
// ─── App Settings API ──────────────────────────────────────
|
||||||
|
|
||||||
// GetAppSettings returns the current app settings.
|
// GetAppSettings returns the current app settings.
|
||||||
|
|
@ -510,14 +915,12 @@ func (a *App) SetCurrentVault(path string) string {
|
||||||
log.Printf("[api] SetCurrentVault: warning loading plugin state: %v", err)
|
log.Printf("[api] SetCurrentVault: warning loading plugin state: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Load workspace for the vault
|
// Load workspace for the vault. This also handles first-run startup,
|
||||||
if a.workspace != nil {
|
// where no workspace manager exists until a vault is selected.
|
||||||
// Replace workspace manager with one pointing to the new vault
|
|
||||||
a.workspace = workspace.NewManager(vaultPath)
|
a.workspace = workspace.NewManager(vaultPath)
|
||||||
if err := a.workspace.Load(); err != nil {
|
if err := a.workspace.Load(); err != nil {
|
||||||
log.Printf("[api] SetCurrentVault: warning loading workspace: %v", err)
|
log.Printf("[api] SetCurrentVault: warning loading workspace: %v", err)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// Register vault capability
|
// Register vault capability
|
||||||
if err := a.capRegistry.Register("verstak-desktop", []string{"verstak/core/vault/v1"}); err != nil {
|
if err := a.capRegistry.Register("verstak-desktop", []string{"verstak/core/vault/v1"}); err != nil {
|
||||||
log.Printf("[api] SetCurrentVault: failed to register vault capability: %v", err)
|
log.Printf("[api] SetCurrentVault: failed to register vault capability: %v", err)
|
||||||
|
|
@ -679,6 +1082,13 @@ func (a *App) RecordDesiredPlugin(pluginID, version, source string) string {
|
||||||
return ""
|
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 ─────────────────────────────────────────────
|
// ─── Dialog API ─────────────────────────────────────────────
|
||||||
|
|
||||||
// SelectDirectory opens a native directory picker dialog.
|
// SelectDirectory opens a native directory picker dialog.
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,15 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"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/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.
|
// 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
|
// TestGetPluginFrontendInfo_KnownPluginWithFrontend verifies that
|
||||||
// GetPluginFrontendInfo returns correct metadata for a plugin with a frontend.
|
// GetPluginFrontendInfo returns correct metadata for a plugin with a frontend.
|
||||||
func TestGetPluginFrontendInfo_KnownPluginWithFrontend(t *testing.T) {
|
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)
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,10 +20,17 @@ type Config struct {
|
||||||
Theme string `json:"theme"`
|
Theme string `json:"theme"`
|
||||||
DevMode bool `json:"devMode"`
|
DevMode bool `json:"devMode"`
|
||||||
UserPluginsDir string `json:"userPluginsDir"`
|
UserPluginsDir string `json:"userPluginsDir"`
|
||||||
|
Workbench WorkbenchPreferences `json:"workbench,omitempty"`
|
||||||
WindowState *WindowState `json:"windowState,omitempty"`
|
WindowState *WindowState `json:"windowState,omitempty"`
|
||||||
LastOpenedAt string `json:"lastOpenedAt"`
|
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.
|
// WindowState stores the last window position and size.
|
||||||
type WindowState struct {
|
type WindowState struct {
|
||||||
Width int `json:"width"`
|
Width int `json:"width"`
|
||||||
|
|
@ -156,6 +163,15 @@ func (m *Manager) Update(patch *Config) error {
|
||||||
if patch.WindowState != nil {
|
if patch.WindowState != nil {
|
||||||
m.config.WindowState = patch.WindowState
|
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.DevMode = patch.DevMode
|
||||||
|
|
||||||
m.config.LastOpenedAt = time.Now().UTC().Format(time.RFC3339)
|
m.config.LastOpenedAt = time.Now().UTC().Format(time.RFC3339)
|
||||||
|
|
@ -201,6 +217,7 @@ func defaultConfig() *Config {
|
||||||
Theme: "dark",
|
Theme: "dark",
|
||||||
DevMode: false,
|
DevMode: false,
|
||||||
UserPluginsDir: filepath.Join(os.Getenv("HOME"), ".config", "verstak", "plugins"),
|
UserPluginsDir: filepath.Join(os.Getenv("HOME"), ".config", "verstak", "plugins"),
|
||||||
|
Workbench: WorkbenchPreferences{},
|
||||||
WindowState: &WindowState{Width: 1200, Height: 800},
|
WindowState: &WindowState{Width: 1200, Height: 800},
|
||||||
LastOpenedAt: time.Now().UTC().Format(time.RFC3339),
|
LastOpenedAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
}
|
}
|
||||||
|
|
@ -216,6 +233,7 @@ func copyConfig(c *Config) *Config {
|
||||||
Theme: c.Theme,
|
Theme: c.Theme,
|
||||||
DevMode: c.DevMode,
|
DevMode: c.DevMode,
|
||||||
UserPluginsDir: c.UserPluginsDir,
|
UserPluginsDir: c.UserPluginsDir,
|
||||||
|
Workbench: c.Workbench,
|
||||||
LastOpenedAt: c.LastOpenedAt,
|
LastOpenedAt: c.LastOpenedAt,
|
||||||
}
|
}
|
||||||
if c.WindowState != nil {
|
if c.WindowState != nil {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestAppSettings_NotInsideVault(t *testing.T) {
|
||||||
// App settings path should be under ~/.config/verstak/, not inside vault
|
// App settings path should be under ~/.config/verstak/, not inside vault
|
||||||
path := DefaultConfigPath()
|
path := DefaultConfigPath()
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ type Registry struct {
|
||||||
searchProviders []ContributionSearchProvider
|
searchProviders []ContributionSearchProvider
|
||||||
activityProviders []ContributionActivityProvider
|
activityProviders []ContributionActivityProvider
|
||||||
statusBarItems []ContributionStatusBarItem
|
statusBarItems []ContributionStatusBarItem
|
||||||
|
openProviders []ContributionOpenProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
// ContributionPointType defines the type of contribution point.
|
// ContributionPointType defines the type of contribution point.
|
||||||
|
|
@ -38,6 +39,7 @@ const (
|
||||||
PointSearchProviders ContributionPointType = "searchProviders"
|
PointSearchProviders ContributionPointType = "searchProviders"
|
||||||
PointActivity ContributionPointType = "activityProviders"
|
PointActivity ContributionPointType = "activityProviders"
|
||||||
PointStatusBar ContributionPointType = "statusBarItems"
|
PointStatusBar ContributionPointType = "statusBarItems"
|
||||||
|
PointOpenProviders ContributionPointType = "openProviders"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ListByPoint returns all contributions for a given point type.
|
// 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 {
|
for _, v := range r.statusBarItems {
|
||||||
result = append(result, v)
|
result = append(result, v)
|
||||||
}
|
}
|
||||||
|
case PointOpenProviders:
|
||||||
|
for _, v := range r.openProviders {
|
||||||
|
result = append(result, v)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
@ -136,6 +142,11 @@ type ContributionStatusBarItem struct {
|
||||||
Item plugin.ContributionStatusBarItem `json:"item"`
|
Item plugin.ContributionStatusBarItem `json:"item"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ContributionOpenProvider struct {
|
||||||
|
PluginID string `json:"pluginId"`
|
||||||
|
Item plugin.ContributionOpenProvider `json:"item"`
|
||||||
|
}
|
||||||
|
|
||||||
// NewRegistry creates a new contribution registry.
|
// NewRegistry creates a new contribution registry.
|
||||||
func NewRegistry() *Registry {
|
func NewRegistry() *Registry {
|
||||||
return &Registry{}
|
return &Registry{}
|
||||||
|
|
@ -159,6 +170,7 @@ func (r *Registry) Register(pluginID string, c *plugin.Contributions) {
|
||||||
r.searchProviders = removeSearchProviders(r.searchProviders, pluginID)
|
r.searchProviders = removeSearchProviders(r.searchProviders, pluginID)
|
||||||
r.activityProviders = removeActivityProviders(r.activityProviders, pluginID)
|
r.activityProviders = removeActivityProviders(r.activityProviders, pluginID)
|
||||||
r.statusBarItems = removeStatusBarItems(r.statusBarItems, pluginID)
|
r.statusBarItems = removeStatusBarItems(r.statusBarItems, pluginID)
|
||||||
|
r.openProviders = removeOpenProviders(r.openProviders, pluginID)
|
||||||
|
|
||||||
for _, item := range c.Views {
|
for _, item := range c.Views {
|
||||||
r.views = append(r.views, ContributionView{PluginID: pluginID, Item: item})
|
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 {
|
for _, item := range c.StatusBarItems {
|
||||||
r.statusBarItems = append(r.statusBarItems, ContributionStatusBarItem{PluginID: pluginID, Item: item})
|
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.
|
// Unregister removes all contributions from a plugin.
|
||||||
|
|
@ -207,6 +222,7 @@ func (r *Registry) Unregister(pluginID string) {
|
||||||
r.searchProviders = removeSearchProviders(r.searchProviders, pluginID)
|
r.searchProviders = removeSearchProviders(r.searchProviders, pluginID)
|
||||||
r.activityProviders = removeActivityProviders(r.activityProviders, pluginID)
|
r.activityProviders = removeActivityProviders(r.activityProviders, pluginID)
|
||||||
r.statusBarItems = removeStatusBarItems(r.statusBarItems, pluginID)
|
r.statusBarItems = removeStatusBarItems(r.statusBarItems, pluginID)
|
||||||
|
r.openProviders = removeOpenProviders(r.openProviders, pluginID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Getters — sorted for deterministic display.
|
// Getters — sorted for deterministic display.
|
||||||
|
|
@ -274,6 +290,20 @@ func (r *Registry) SearchProviders() []ContributionSearchProvider {
|
||||||
return result
|
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 ─────────────────────────────────────────
|
// ─── Remove helpers ─────────────────────────────────────────
|
||||||
|
|
||||||
func removeViews(items []ContributionView, pluginID string) []ContributionView {
|
func removeViews(items []ContributionView, pluginID string) []ContributionView {
|
||||||
|
|
@ -365,3 +395,13 @@ func removeStatusBarItems(items []ContributionStatusBarItem, pluginID string) []
|
||||||
}
|
}
|
||||||
return result
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -184,6 +184,17 @@ func TestListByPoint(t *testing.T) {
|
||||||
SearchProviders: []plugin.ContributionSearchProvider{{ID: "sp1", Label: "SP1", Handler: "h"}},
|
SearchProviders: []plugin.ContributionSearchProvider{{ID: "sp1", Label: "SP1", Handler: "h"}},
|
||||||
ActivityProviders: []plugin.ContributionActivityProvider{{ID: "ap1", Events: []string{"test"}, Handler: "h"}},
|
ActivityProviders: []plugin.ContributionActivityProvider{{ID: "ap1", Events: []string{"test"}, Handler: "h"}},
|
||||||
StatusBarItems: []plugin.ContributionStatusBarItem{{ID: "sb1", Label: "SB1"}},
|
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)
|
r.Register("test.plugin", contrib)
|
||||||
|
|
@ -202,6 +213,7 @@ func TestListByPoint(t *testing.T) {
|
||||||
{PointSearchProviders, 1},
|
{PointSearchProviders, 1},
|
||||||
{PointActivity, 1},
|
{PointActivity, 1},
|
||||||
{PointStatusBar, 1},
|
{PointStatusBar, 1},
|
||||||
|
{PointOpenProviders, 1},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
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
|
// TestRegister_DuplicatePrevention calls Register twice for the same plugin
|
||||||
// (simulating reload) and checks contributions appear only once (no duplicates).
|
// (simulating reload) and checks contributions appear only once (no duplicates).
|
||||||
// This is the KEY TEST for idempotent re-registration.
|
// This is the KEY TEST for idempotent re-registration.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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]
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"`
|
||||||
|
}
|
||||||
|
|
@ -33,12 +33,16 @@ func (r *Registry) registerDefaults() {
|
||||||
{Name: "vault.read", Description: "Read vault files and metadata", Dangerous: false},
|
{Name: "vault.read", Description: "Read vault files and metadata", Dangerous: false},
|
||||||
{Name: "vault.write", Description: "Write vault files and metadata", Dangerous: true},
|
{Name: "vault.write", Description: "Write vault files and metadata", Dangerous: true},
|
||||||
{Name: "vault.watch", Description: "Watch vault file changes", Dangerous: false},
|
{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.namespace", Description: "Read/write plugin's own storage namespace", Dangerous: false},
|
||||||
{Name: "storage.migrations", Description: "Run database migrations in plugin 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.publish", Description: "Publish events to the event bus", Dangerous: false},
|
||||||
{Name: "events.subscribe", Description: "Subscribe to events on 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: "ui.register", Description: "Register UI components and contributions", Dangerous: false},
|
||||||
{Name: "commands.register", Description: "Register command palette commands", 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.local", Description: "Connect to localhost network services", Dangerous: false},
|
||||||
{Name: "network.remote", Description: "Connect to remote network services", Dangerous: true},
|
{Name: "network.remote", Description: "Connect to remote network services", Dangerous: true},
|
||||||
{Name: "process.spawn", Description: "Spawn external processes", Dangerous: true},
|
{Name: "process.spawn", Description: "Spawn external processes", Dangerous: true},
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -67,6 +67,7 @@ type Contributions struct {
|
||||||
SearchProviders []ContributionSearchProvider `json:"searchProviders,omitempty"`
|
SearchProviders []ContributionSearchProvider `json:"searchProviders,omitempty"`
|
||||||
ActivityProviders []ContributionActivityProvider `json:"activityProviders,omitempty"`
|
ActivityProviders []ContributionActivityProvider `json:"activityProviders,omitempty"`
|
||||||
StatusBarItems []ContributionStatusBarItem `json:"statusBarItems,omitempty"`
|
StatusBarItems []ContributionStatusBarItem `json:"statusBarItems,omitempty"`
|
||||||
|
OpenProviders []ContributionOpenProvider `json:"openProviders,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ContributionView represents a view contribution.
|
// ContributionView represents a view contribution.
|
||||||
|
|
@ -144,6 +145,23 @@ type ContributionStatusBarItem struct {
|
||||||
Handler string `json:"handler,omitempty"`
|
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.
|
// SyncConfig describes plugin sync configuration.
|
||||||
type SyncConfig struct {
|
type SyncConfig struct {
|
||||||
Namespaces []string `json:"namespaces,omitempty"`
|
Namespaces []string `json:"namespaces,omitempty"`
|
||||||
|
|
@ -209,6 +227,27 @@ func ValidateManifest(m *Manifest) []string {
|
||||||
if len(m.Permissions) == 0 {
|
if len(m.Permissions) == 0 {
|
||||||
errs.add("permissions must have at least one permission")
|
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
|
return errs.errors
|
||||||
}
|
}
|
||||||
|
|
@ -249,7 +288,7 @@ func DiscoverPlugins(dirs []string) ([]Plugin, []error) {
|
||||||
var plugins []Plugin
|
var plugins []Plugin
|
||||||
var errs []error
|
var errs []error
|
||||||
|
|
||||||
seen := make(map[string]bool)
|
seen := make(map[string]string)
|
||||||
|
|
||||||
log.Printf("[discovery] start: %d dir(s): %v", len(dirs), dirs)
|
log.Printf("[discovery] start: %d dir(s): %v", len(dirs), dirs)
|
||||||
|
|
||||||
|
|
@ -287,12 +326,12 @@ func DiscoverPlugins(dirs []string) ([]Plugin, []error) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if seen[plugin.Manifest.ID] {
|
if existingPath, ok := seen[plugin.Manifest.ID]; ok {
|
||||||
errs = append(errs, fmt.Errorf("duplicate plugin ID %q in %s", plugin.Manifest.ID, pluginDir))
|
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 (skip)", entry.Name(), plugin.Manifest.ID)
|
log.Printf("[discovery] %s: duplicate ID %q in %s (already loaded from %s; skip)", entry.Name(), plugin.Manifest.ID, pluginDir, existingPath)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seen[plugin.Manifest.ID] = true
|
seen[plugin.Manifest.ID] = pluginDir
|
||||||
plugins = append(plugins, plugin)
|
plugins = append(plugins, plugin)
|
||||||
log.Printf("[discovery] %s: ✅ %s@%s", entry.Name(), plugin.Manifest.ID, plugin.Manifest.Version)
|
log.Printf("[discovery] %s: ✅ %s@%s", entry.Name(), plugin.Manifest.ID, plugin.Manifest.Version)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
// TestDiscoverPlugins_MultipleDirs ensures discovery scans multiple directories.
|
||||||
func TestDiscoverPlugins_MultipleDirs(t *testing.T) {
|
func TestDiscoverPlugins_MultipleDirs(t *testing.T) {
|
||||||
dir1 := t.TempDir()
|
dir1 := t.TempDir()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
85
main.go
|
|
@ -5,7 +5,6 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/wailsapp/wails/v2"
|
"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/capability"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/contribution"
|
"github.com/verstak/verstak-desktop/internal/core/contribution"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/events"
|
"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/permissions"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/plugin"
|
"github.com/verstak/verstak-desktop/internal/core/plugin"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/pluginstate"
|
"github.com/verstak/verstak-desktop/internal/core/pluginstate"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/storage"
|
"github.com/verstak/verstak-desktop/internal/core/storage"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/vault"
|
"github.com/verstak/verstak-desktop/internal/core/vault"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/workspace"
|
"github.com/verstak/verstak-desktop/internal/core/workspace"
|
||||||
|
"github.com/verstak/verstak-desktop/internal/shell/debug"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed frontend/dist
|
//go:embed frontend/dist
|
||||||
var assets embed.FS
|
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() {
|
func main() {
|
||||||
|
// ─── Debug Logging ───────────────────────────────────────
|
||||||
|
debugEnabled := debug.Init(os.Args)
|
||||||
|
if debugEnabled {
|
||||||
|
log.Printf("[main] debug mode enabled — logging to file")
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Initialize Core Registries ──────────────────────────
|
// ─── Initialize Core Registries ──────────────────────────
|
||||||
capRegistry := capability.NewRegistry()
|
capRegistry := capability.NewRegistry()
|
||||||
contribRegistry := contribution.NewRegistry()
|
contribRegistry := contribution.NewRegistry()
|
||||||
|
|
@ -95,6 +89,8 @@ func main() {
|
||||||
"verstak/core/contribution-registry/v1",
|
"verstak/core/contribution-registry/v1",
|
||||||
"verstak/core/permissions/v1",
|
"verstak/core/permissions/v1",
|
||||||
"verstak/core/events/v1",
|
"verstak/core/events/v1",
|
||||||
|
"verstak/core/files/v1",
|
||||||
|
"verstak/core/workbench/v1",
|
||||||
}
|
}
|
||||||
if err := capRegistry.Register(corePluginID, coreCaps); err != nil {
|
if err := capRegistry.Register(corePluginID, coreCaps); err != nil {
|
||||||
log.Fatalf("[main] failed to register core capabilities: %v", err)
|
log.Fatalf("[main] failed to register core capabilities: %v", err)
|
||||||
|
|
@ -116,37 +112,45 @@ func main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Plugin Discovery ───────────────────────────────────
|
// ─── Plugin Discovery ───────────────────────────────────
|
||||||
// Resolve plugin directories relative to the binary location,
|
discoveryDirs := plugin.DefaultDiscoveryDirs()
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("[main] plugin dirs: %v", discoveryDirs)
|
log.Printf("[main] plugin dirs: %v", discoveryDirs)
|
||||||
|
if debugEnabled {
|
||||||
|
debug.Logf("[main] plugin dirs: %v", discoveryDirs)
|
||||||
|
}
|
||||||
|
|
||||||
plugins, discErrors := plugin.DiscoverPlugins(discoveryDirs)
|
plugins, discErrors := plugin.DiscoverPlugins(discoveryDirs)
|
||||||
for _, err := range discErrors {
|
for _, err := range discErrors {
|
||||||
log.Printf("[plugin] discovery warning: %v", err)
|
log.Printf("[plugin] discovery warning: %v", err)
|
||||||
|
if debugEnabled {
|
||||||
|
debug.Logf("[plugin] discovery warning: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("[plugin] discovered %d plugins", len(plugins))
|
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 ──
|
// ─── Plugin Lifecycle: Register Capabilities + Contributions ──
|
||||||
|
if debugEnabled {
|
||||||
|
debug.Logf("[main] starting plugin lifecycle for %d plugins", len(plugins))
|
||||||
|
}
|
||||||
for i := range plugins {
|
for i := range plugins {
|
||||||
p := &plugins[i]
|
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
|
// Check if plugin is disabled in vault plugin state
|
||||||
if pluginStateMgr != nil && pluginStateMgr.IsDisabled(p.Manifest.ID) {
|
if pluginStateMgr != nil && pluginStateMgr.IsDisabled(p.Manifest.ID) {
|
||||||
log.Printf("[plugin] %s: disabled in vault plugin state — skipping", 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.Status = plugin.StatusDisabled
|
||||||
p.Enabled = false
|
p.Enabled = false
|
||||||
continue
|
continue
|
||||||
|
|
@ -156,6 +160,9 @@ func main() {
|
||||||
if len(p.Manifest.Provides) > 0 {
|
if len(p.Manifest.Provides) > 0 {
|
||||||
if err := capRegistry.Register(p.Manifest.ID, p.Manifest.Provides); err != nil {
|
if err := capRegistry.Register(p.Manifest.ID, p.Manifest.Provides); err != nil {
|
||||||
log.Printf("[plugin] %s: capability registration failed: %v", p.Manifest.ID, err)
|
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.Status = plugin.StatusFailed
|
||||||
p.Error = err.Error()
|
p.Error = err.Error()
|
||||||
continue
|
continue
|
||||||
|
|
@ -167,6 +174,9 @@ func main() {
|
||||||
missingRequired := capRegistry.CheckRequired(p.Manifest.Requires)
|
missingRequired := capRegistry.CheckRequired(p.Manifest.Requires)
|
||||||
if len(missingRequired) > 0 {
|
if len(missingRequired) > 0 {
|
||||||
log.Printf("[plugin] %s: missing required capabilities: %v", p.Manifest.ID, missingRequired)
|
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.Status = plugin.StatusMissingRequiredCapability
|
||||||
p.Error = fmt.Sprintf("missing required: %s", strings.Join(missingRequired, ", "))
|
p.Error = fmt.Sprintf("missing required: %s", strings.Join(missingRequired, ", "))
|
||||||
continue
|
continue
|
||||||
|
|
@ -176,6 +186,9 @@ func main() {
|
||||||
missingOptional := capRegistry.CheckRequired(p.Manifest.OptionalRequires)
|
missingOptional := capRegistry.CheckRequired(p.Manifest.OptionalRequires)
|
||||||
if len(missingOptional) > 0 {
|
if len(missingOptional) > 0 {
|
||||||
log.Printf("[plugin] %s: missing optional capabilities (degraded): %v", p.Manifest.ID, missingOptional)
|
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
|
p.Status = plugin.StatusDegraded
|
||||||
} else {
|
} else {
|
||||||
p.Status = plugin.StatusLoaded
|
p.Status = plugin.StatusLoaded
|
||||||
|
|
@ -185,6 +198,11 @@ func main() {
|
||||||
if p.Manifest.Contributes != nil {
|
if p.Manifest.Contributes != nil {
|
||||||
contribRegistry.Register(p.Manifest.ID, p.Manifest.Contributes)
|
contribRegistry.Register(p.Manifest.ID, p.Manifest.Contributes)
|
||||||
log.Printf("[plugin] %s: contributions registered", p.Manifest.ID)
|
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)
|
// 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 {
|
if err := pluginStateMgr.RecordDesiredPlugin(p.Manifest.ID, p.Manifest.Version, source); err != nil {
|
||||||
log.Printf("[plugin] %s: failed to record desired: %v", p.Manifest.ID, err)
|
log.Printf("[plugin] %s: 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)
|
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 ───────────────────────────────────────
|
// ─── Log Summary ───────────────────────────────────────
|
||||||
|
|
@ -220,7 +244,8 @@ func main() {
|
||||||
|
|
||||||
// Create the App struct
|
// Create the App struct
|
||||||
storageService := storage.New(vaultService)
|
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 ───────────────────────────────────────────
|
// ─── Wails App ───────────────────────────────────────────
|
||||||
err := wails.Run(&options.App{
|
err := wails.Run(&options.App{
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue