Author SHA1 Message Date
mirivlad 477b372110 fix: persist browser captures atomically 2026-07-11 23:30:38 +08:00
mirivlad c129c1a2ac fix: preserve plugin contribution label contract 2026-07-11 12:56:17 +08:00
mirivlad 7f0cc308c2 feat: add Russian desktop localization 2026-07-11 12:50:00 +08:00
mirivlad 8b1151e03a feat: load declared plugin locale catalogs 2026-07-11 02:02:47 +08:00
mirivlad 15f1f413d0 feat: persist desktop language preference 2026-07-11 02:00:10 +08:00
mirivlad e412cfc45a feat: add workspace templates and tab visibility 2026-07-10 21:32:48 +08:00
mirivlad 2007b909d1 feat: integrate todo plugin into desktop workspace 2026-07-10 21:09:02 +08:00
mirivlad 8dafde4519 feat: scope overview browser captures by workspace 2026-07-10 20:27:58 +08:00
mirivlad 1d1eaa6bc7 feat: cover global trash workflow in desktop 2026-07-10 09:00:14 +08:00
mirivlad 3f27ba79a4 feat: bridge permanent trash deletion to plugins 2026-07-10 08:35:46 +08:00
mirivlad 8445ea11db feat: support permanent vault trash deletion 2026-07-10 08:31:20 +08:00
mirivlad e5f5d4d33f feat: route work session candidates to journal 2026-07-10 08:21:11 +08:00
mirivlad b056016929 feat: refine workspace overview 2026-07-10 07:46:18 +08:00
mirivlad a323710a6f fix: resolve plugin lifecycle dependencies deterministically 2026-07-10 04:03:08 +08:00
mirivlad 0f293640f9 feat: require paired browser receiver 2026-07-10 03:37:12 +08:00
mirivlad 89c261824f fix: bound browser capture ingress 2026-07-10 03:29:51 +08:00
mirivlad 18da09e06b fix: bind sync state to active vault 2026-07-10 03:13:04 +08:00
mirivlad b845e8b5e2 feat: replace Today workspace surface with Overview 2026-07-10 02:36:00 +08:00
mirivlad 5b4c792de5 Polish desktop workbench UI 2026-07-02 00:49:29 +08:00
mirivlad 12b1e5a496 Cover Files action icon states 2026-07-01 19:20:41 +08:00
mirivlad a540da707d Cover Files no-match filter feedback 2026-07-01 19:07:06 +08:00
mirivlad 74c627f706 Cover Files rename conflict feedback 2026-07-01 18:58:31 +08:00
mirivlad 029bb7a1a8 Cover Files empty folder actions 2026-07-01 18:49:51 +08:00
mirivlad d2a07baf82 Cover Files rich file icons 2026-07-01 18:34:51 +08:00
mirivlad 3c54ef55c9 Cover Files inline create validation 2026-07-01 18:24:55 +08:00
mirivlad 3b0cd2e429 Cover Files trash restore flow 2026-07-01 08:47:19 +08:00
mirivlad 501d7a76d0 Use in-app Files trash confirmation in mock 2026-07-01 08:25:57 +08:00
mirivlad 05bfe1d993 Cover Files breadcrumb segment navigation 2026-07-01 08:17:45 +08:00
mirivlad 03dbede414 Cover Files duplicate context action 2026-07-01 08:09:28 +08:00
mirivlad 5c47ca2480 Cover Files inline rename validation 2026-07-01 07:59:36 +08:00
mirivlad 167d593245 Revert "fix: P1 shell tooltip fixes + Today routing + workspaces API"
This reverts commit 9d06b164d3.
2026-07-01 07:44:28 +08:00
mirivlad 9d06b164d3 fix: P1 shell tooltip fixes + Today routing + workspaces API
Shell:
- WorkspaceHost: change tooltip from pluginId to human-readable title
- StatusBar: replace pluginId tooltips with label or id (3 occurrences)
- App: startup routing to Today view, sidebar Today nav item
- Sidebar: add Today button in Overview section
- TodaySurface: separate global vs workspace data (no fallthrough)

Plugin API:
- Add workspaces.list() method wrapping App.ListWorkspaces()
  (needed by browser-inbox workspace picker)
2026-07-01 04:15:35 +08:00
mirivlad b0d80e230f Cover Files keyboard row selection 2026-07-01 03:18:27 +08:00
mirivlad d3dfa2d652 Add plugin manager scan summary 2026-07-01 03:08:18 +08:00
mirivlad 67468e92f7 Improve Today workspace resume surface 2026-07-01 02:56:50 +08:00
mirivlad 80e53ff252 Improve workspace search and responsive shell 2026-07-01 02:46:40 +08:00
mirivlad 578ce2f2a5 Fix global search result navigation 2026-06-30 22:43:09 +08:00
mirivlad b28039cf0d Fix global search folder navigation 2026-06-30 20:57:58 +08:00
mirv 268e79d2f0 Improve workspace UX flows 2026-06-30 17:38:18 +08:00
mirivlad 46f754cc2d feat: workspace routing, GlobalSearch, and shell refinements
- Add workspace-aware startup routing (openDefaultWorkspaceRoute)
- Add GlobalSearch component and workspace-empty state
- Refactor Sidebar, StatusBar, WorkspaceTree, VaultSelection
- Update PluginCard/PluginManager UI
- Extend e2e coverage (ux-p0, ux-followup, helpers)
- Add ListWorkspaces/SetCurrentWorkspace backend bindings
2026-06-30 12:32:04 +08:00
mirivlad 4bb9e84c35 feat: harden secret unlock and deletion 2026-06-29 21:42:10 +08:00
mirivlad 63c2c1314f test: cover secret workbench resources 2026-06-29 20:45:03 +08:00
mirivlad 56c685212b feat: expose scoped secret store API 2026-06-29 20:36:45 +08:00
mirivlad 7a7b3c7a3e feat: add encrypted local secret store 2026-06-29 20:12:59 +08:00
mirv fe91784a8e feat: add bounded binary file writes 2026-06-29 10:12:26 +08:00
mirv df4538532f feat: accept browser file captures 2026-06-29 09:57:21 +08:00
mirivlad 2cbf542b13 feat: require token for paired browser receiver 2026-06-29 04:52:19 +08:00
mirivlad 7363313f1e feat: expose plugin data storage bridge 2026-06-29 04:27:22 +08:00
mirivlad 874b7c7ffb feat: add workspace header search slot 2026-06-29 04:24:15 +08:00
mirivlad 8f6559cdb6 docs: define sync conflict contract 2026-06-29 03:38:03 +08:00
mirivlad 9f3448c1ea feat: retry transient sync requests 2026-06-29 03:30:10 +08:00
mirivlad 04ce8c5bf3 fix: preserve sync error status 2026-06-29 03:23:45 +08:00
mirivlad 57677d1b1b feat: add bounded file byte reads 2026-06-29 03:16:16 +08:00
mirivlad aa4905a097 docs: document live file watcher 2026-06-28 23:00:32 +08:00
mirivlad 1d521251a2 feat: watch vault file changes 2026-06-28 22:53:27 +08:00
mirivlad 5a74809ab7 docs: document file trash restore API 2026-06-28 22:23:42 +08:00
mirivlad cc0c050985 feat: restore files from trash 2026-06-28 22:18:44 +08:00
mirivlad e5bdaec0aa feat: expose file trash metadata 2026-06-28 17:03:12 +08:00
mirivlad ed4b117a94 docs: update runtime contribution APIs 2026-06-28 16:42:02 +08:00
mirivlad d46bc9436d feat: publish workspace lifecycle events 2026-06-28 16:34:03 +08:00
mirivlad 1f1001108b feat: expose action contributions to plugins 2026-06-28 16:28:33 +08:00
mirivlad 9729b432d6 feat: host activity providers 2026-06-28 16:14:59 +08:00
mirivlad 93597a2c45 Tag browser captures with current workspace 2026-06-28 04:00:50 +08:00
mirivlad 6d30015a1d Move global plugin navigation into shell 2026-06-28 03:22:54 +08:00
mirivlad 0c788bed80 Expose search provider contributions 2026-06-28 01:03:36 +08:00
mirivlad 35012025b6 Add sync reset key bridge 2026-06-27 18:46:05 +08:00
mirivlad a2791c494f Add browser inbox receiver 2026-06-27 18:39:01 +08:00
mirivlad fb68c54409 Route open providers by request mode 2026-06-27 13:49:09 +08:00
mirivlad f87f8235bd Expand real sync smoke for folders 2026-06-27 13:39:48 +08:00
mirivlad 173cc93258 Add public external file open API 2026-06-27 13:30:31 +08:00
mirivlad 6cc37972d1 Add status bar contribution host 2026-06-27 13:21:22 +08:00
mirivlad 7630a31286 Add command palette host 2026-06-27 13:13:15 +08:00
mirivlad 1fb9db73ec Update completed files milestone docs 2026-06-27 12:51:08 +08:00
mirivlad 28a4e10e79 Remove core notes service 2026-06-27 12:44:02 +08:00
mirivlad 24444a8588 Refine v2 plugin API and sync flow 2026-06-27 12:36:31 +08:00
mirivlad 03175aa46d feat: Notes core service + Notes API + router auto-detect notes context
- Add internal/core/notes/ service (Service, Layout, Normalize, tests)
- Register verstak/core/notes/v1 capability
- Inject NotesService into App, expose 8 Notes API endpoints
  (CreateNote, RenameNote, ReadNote, SaveNote, EnsureOverview,
   ListNotes, SearchNotes, NormalizeNoteTitle)
- Router: auto-detect Notes context via path (IsInsideNotes)
- PluginCard: show workspaceItems contribution count
- Regenerate Wails bindings (App.d.ts, App.js, models.ts with notes.NoteInfo)
- Fix .gitignore pattern for e2e-results/
2026-06-21 23:22:36 +08:00
105 changed files with 18271 additions and 1108 deletions
+1 -1
View File
@@ -5,4 +5,4 @@ build/bin/verstak-desktop
smoke-platform
plugins/
vendor/
\nfrontend/e2e-results/
frontend/e2e-results/
+9 -7
View File
@@ -85,19 +85,21 @@ Frontend bundles are mounted with a plugin-scoped API created by
- `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.
- `files.list/metadata/readText/readBytes/writeText/createFolder/move/trash/listTrash/restoreTrash/deleteTrash/openExternal/showInFolder`
for canonical vault-relative slash paths guarded by `files.read`,
`files.write`, `files.delete`, and `files.openExternal`. Backslashes,
Windows absolute paths, UNC paths, traversal, `.verstak` variants, and
symlink read/write/move/trash/external-open operations are rejected. Text
read/write is UTF-8 only and limited to 2 MB for text reads; `readBytes`
returns base64 for regular files up to 8 MB.
- `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
Workbench selects by resource kind, request mode, 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.
+11 -20
View File
@@ -25,37 +25,28 @@ These tests:
| File | Suite | Tests | Status |
|------|-------|-------|--------|
| `plugin-manager-disable-enable.spec.js` | A: Disable/Enable refresh | 4 | 3 pass, 1 fail* |
| `plugin-manager-disable-enable.spec.js` | A: Disable/Enable refresh | 4 | pass |
| `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* |
| `reload-updates-state.spec.js` | C: Reload updates UI | 4 | pass |
\* Failing tests document **known bugs** (see below).
## Resolved bugs covered by tests
## Known bugs detected by tests
### Bug M5-1: Sidebar updates when plugin state changes
### Bug M5-1: Sidebar does not update when plugin state changes
**Previous symptom:** After disabling a plugin in Plugin Manager, the sidebar
item for that plugin remained visible. Reloading after external mock state
changes also left stale sidebar items.
**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).
**Current behavior:** `PluginManager.svelte` dispatches
`verstak:plugins-changed` after reload/enable/disable flows. `Sidebar.svelte`
listens for that event and re-fetches plugins, vault status, and contributions.
**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.
**Regression coverage:**
**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)
+58 -58
View File
@@ -181,18 +181,18 @@ type Entry struct {
- 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`
- [x] Add `NormalizeVaultRelativePath(relative string) (string, error)`.
- [x] Reject absolute paths, null bytes, `..`, and empty file paths.
- [x] Preserve path case, including canonical `Notes`.
- [x] Add `IsReservedPath(relative string) bool` returning true for `.verstak`
and `.verstak/...`.
- [ ] Add tests:
- [x] Add tests:
`TestNormalizeRejectsAbsolutePath`,
`TestNormalizeRejectsTraversal`,
`TestNormalizeRejectsNullByte`,
`TestNormalizePreservesCase`,
`TestReservedPathPolicy`.
- [ ] Run:
- [x] Run:
```bash
go test ./internal/core/files
@@ -207,21 +207,21 @@ Expected: all `internal/core/files` tests pass.
- 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
- [x] 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,
- [x] Implement `List(relativeDir string) ([]Entry, error)`.
- [x] Implement `Metadata(relativePath string) (Entry, error)`.
- [x] Implement `ReadText(relativePath string) (string, error)`.
- [x] Implement `WriteText(relativePath, content string, overwrite bool) (Entry, error)`.
- [x] Implement `Mkdir(relativePath string) (Entry, error)`.
- [x] Implement `Move(fromRelativePath, toRelativePath string, overwrite bool) (Entry, error)`.
- [x] Implement `Trash(relativePath string) (Entry, error)`.
- [x] Use the shared path policy for every public method.
- [x] Block `.verstak` paths in every public method.
- [x] Add tests for closed vault, list, metadata, text read/write, mkdir, move,
trash, overwrite false conflict, overwrite true replace, and reserved path
rejection.
- [ ] Run:
- [x] Run:
```bash
go test ./internal/core/files
@@ -236,13 +236,13 @@ Expected: all `internal/core/files` tests pass.
- 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
- [x] Write text content to a temp file in the target directory.
- [x] Rename the temp file into the final path only after successful write.
- [x] Remove temp file on write failure.
- [x] Add test `TestWriteTextIsAtomicOnFailure` using a controlled failing path
or permission-denied directory.
- [ ] Add test `TestWriteTextDoesNotLeaveTempFile`.
- [ ] Run:
- [x] Add test `TestWriteTextDoesNotLeaveTempFile`.
- [x] Run:
```bash
go test ./internal/core/files
@@ -258,12 +258,12 @@ Expected: all `internal/core/files` tests pass.
- 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
- [x] Register permissions: `files.read`, `files.write`, `files.delete`.
- [x] Register core capability `verstak/core/files/v1` when vault services are
initialized.
- [ ] Add API guard tests proving each Files bridge method rejects plugins that
- [x] Add API guard tests proving each Files bridge method rejects plugins that
are missing the required permission.
- [ ] Run:
- [x] Run:
```bash
go test ./internal/core/permissions ./internal/api
@@ -282,13 +282,13 @@ Expected: permission registry and API tests pass.
- 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,
- [x] Add `files.Service` to `api.App`.
- [x] Add plugin-scoped methods listed in "Public Backend Shape".
- [x] Use `requirePluginAccess(pluginID, permission)` for every method.
- [x] 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:
- [x] Add tests for successful read/write/list/mkdir/move/trash through `App`.
- [x] Run:
```bash
go test ./internal/api
@@ -305,16 +305,16 @@ Expected: API tests pass.
- 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:
- [x] Add `api.files.list(relativeDir)`.
- [x] Add `api.files.metadata(relativePath)`.
- [x] Add `api.files.readText(relativePath)`.
- [x] Add `api.files.writeText(relativePath, content, options)`.
- [x] Add `api.files.mkdir(relativePath)`.
- [x] Add `api.files.move(fromRelativePath, toRelativePath, options)`.
- [x] Add `api.files.trash(relativePath)`.
- [x] Keep all calls plugin-scoped; plugin code must not pass `pluginId`.
- [x] Mock readable errors for reserved path and missing permission.
- [x] Run:
```bash
cd frontend
@@ -331,11 +331,11 @@ Expected: frontend build passes.
- 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
- [x] Add `files` API TypeScript interfaces matching the frontend API names.
- [x] Add mock Files API methods in `createMockPluginAPI`.
- [x] Add contract tests for API shape, text write/read, reserved path error, and
trash result shape.
- [ ] Run:
- [x] Run:
```bash
cd ../verstak-sdk
@@ -353,36 +353,36 @@ Expected: SDK check, build, and tests pass.
- 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.
- [x] Document Files Core API as functional for Milestone 6a.
- [x] Keep Notes API documented as planned until Milestone 6b or later.
- [x] Document `.verstak` reserved path policy.
- [x] Document slash-only path policy, Windows/UNC rejection, and symlink policy.
- [x] Document text-only write support and deferred binary streaming.
## Task 9: Final Verification
- [ ] Run desktop backend tests:
- [x] Run desktop backend tests:
```bash
cd verstak-desktop
go test ./...
```
- [ ] Run desktop frontend build:
- [x] Run desktop frontend build:
```bash
cd verstak-desktop/frontend
npm run build
```
- [ ] Run desktop e2e:
- [x] Run desktop e2e:
```bash
cd verstak-desktop/frontend
npm run test:e2e -- --reporter=list
```
- [ ] Run official plugins checks:
- [x] Run official plugins checks:
```bash
cd verstak-official-plugins
@@ -390,7 +390,7 @@ cd verstak-official-plugins
./scripts/build.sh
```
- [ ] Run SDK checks:
- [x] Run SDK checks:
```bash
cd verstak-sdk
+29 -27
View File
@@ -54,8 +54,9 @@ Canonical rules:
Canonical scoped paths:
- Workspace overview notes live under `<workspace>/Notes/`.
- The default overview note is `<workspace>/Notes/Overview.md`.
- Notes live under `<workspace>/Notes/`.
- `Overview.md` is allowed as an ordinary Markdown filename, but the platform
must not create it automatically or treat it as a dedicated UI entity.
- `<workspace>` is the top-level physical folder name under the vault root.
- Files plugin workspace views are scoped with `workspaceRootPath`, which is the
selected top-level workspace folder name.
@@ -68,8 +69,9 @@ Visibility requirements:
- Outside Verstak, the files must remain useful as normal Markdown.
There is no canonical metadata workspace tree. Adding `note` as a workspace node
type is not part of the next milestone. The Notes service can index and manage
Markdown files inside canonical `Notes/` folders under each top-level workspace.
type is not part of the next milestone. The official Notes plugin can index and
manage Markdown files inside canonical `Notes/` folders under each top-level
workspace.
## Title To Filename Contract
@@ -99,9 +101,9 @@ Examples:
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.
Creating or renaming a note 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:
@@ -159,16 +161,17 @@ Later Files methods:
- `OpenExternal(relativePath)` with explicit permission and UX confirmation.
- `RevealInFileManager(relativePath)`.
## Notes Service Model
## Official Notes Plugin Model
Notes API is a semantic layer over Markdown files managed by the Files/path
policy.
The official Notes plugin is a semantic layer over Markdown files managed through
the public Files plugin API. There is no core desktop Notes service or
`verstak/core/notes/v1` capability in v2.
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.
- Notes plugin and Files API must not create two sources of truth.
- Notes plugin 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.
@@ -178,27 +181,27 @@ Rules:
watcher/indexer must observe it.
- Until watcher/indexer exists, external changes require reload/rescan.
Minimum Notes methods:
Minimum Notes plugin behavior:
- `ListNotes(scope)` where `scope` resolves to a canonical `Notes/` folder.
- `GetNote(notePath)`.
- `CreateNote(scope, title, initialBody)`.
- `RenameNote(notePath, newTitle)`.
- `UpdateNoteBody(notePath, body)`.
- `TrashNote(notePath)`.
- list Markdown files in a scoped canonical `Notes/` folder;
- create a Markdown note by writing the normalized filename atomically with
`overwrite: false`;
- rename a note by moving the file to the normalized filename;
- open and edit notes through Workbench providers;
- never special-case `Overview.md` in the UI.
Later Notes methods:
Later Notes plugin behavior:
- `SearchNotes(query, filters)`.
- `ListBacklinks(notePath)`.
- `ResolveNoteLinks(notePath)`.
- `ExportNote(notePath, format)`.
- search notes;
- list backlinks;
- resolve note links;
- export notes.
## 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:
The same physical note must be visible through both surfaces:
- Files sees `Project/Notes/Overview.md` as a file.
- Notes sees `Project/Notes/Overview.md` as a note with title `Overview`.
@@ -238,7 +241,6 @@ 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.
@@ -389,7 +391,7 @@ Backend Go tests:
- 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.
- note create/rename conflict errors without silent suffixes.
- Notes and Files read the same physical `.md` file.
- Permission checks for `files.*`, `notes.*`, `vault.read`, and `vault.write`.
+160 -48
View File
@@ -112,17 +112,25 @@ capRegistry.Register("verstak-desktop", []string{"verstak/core/vault/v1"})
### Plugin capability resolution
```
foreach plugin:
1. зарегистрировать plugin.provides в capability registry
2. проверить plugin.requires — если есть missing → missing-required-capability
3. проверить plugin.optionalRequires — если есть missing → degraded
4. иначе → loaded
5. зарегистрировать plugin.contributes в contribution registry
1. Исключить disabled plugins.
2. Пока есть неразрешённые plugins:
- plugin с уже доступными `requires` атомарно регистрирует `provides`;
- при конфликте `provides` plugin получает `failed`;
- если за проход ничего не разрешилось, остальные получают
`missing-required-capability`.
3. После разрешения всех обязательных зависимостей проверить
`optionalRequires`: отсутствующие переводят plugin в `degraded`.
4. Зарегистрировать `contributes` только для `loaded` и `degraded` plugins.
```
Таким образом, порядок discovery не влияет на обычные зависимости между
plugins. При конкурирующих providers одного capability порядок discovery служит
tie-breaker. `provides` плагина с неразрешённым `requires` или ошибкой
регистрации не попадают в registry.
### Capability конфликт
Два plugin не могут предоставлять один и тот же capability. При попытке повторной регистрации — ошибка и статус `failed` для второго плагина.
Два plugin не могут предоставлять один и тот же capability. При попытке повторной регистрации второй plugin получает `failed`; регистрация атомарна, поэтому ни один capability такого plugin не остаётся в registry.
## Plugin Manifest Format
@@ -189,19 +197,22 @@ Icon fields use shell icon names rendered through the bundled Lucide SVG wrapper
| Боковая панель | `sidebarItems` | Элементы в sidebar слева | ✅ Sidebar.svelte (из ContributionRegistry) |
| Основные панели | `views` | Полноценные страницы/панели | ✅ ViewContainer.svelte (PluginBundleHost — real frontend bundle) |
| Панели настроек | `settingsPanels` | Панели в Plugin Manager | ✅ PluginManager.svelte (кнопка Settings, открывает modal) |
| Команды | `commands` | Команды для command palette | ✅ ContributionRegistry (UI command palette не реализован) |
| Команды | `commands` | Команды для command palette | ✅ ContributionRegistry + CommandPalette UI |
| Open/edit providers | `openProviders` | Провайдеры viewer/editor для Workbench routing | ✅ ContributionRegistry + минимальный Workbench host |
| Действия над файлами | `fileActions` | Provider actions for Files surface | ✅ Files plugin context menu |
| Действия над заметками | `noteActions` | Provider actions for Notes surface | ✅ Notes plugin row actions |
| Контекстное меню | `contextMenuEntries` | Provider context menu entries | ✅ Files plugin context menu |
| Провайдеры поиска | `searchProviders` | Search provider discovery | ✅ Contribution summary + Search plugin |
| Провайдеры активности | `activityProviders` | Activity event subscriptions | ✅ Backend activity recorder |
| Элементы status bar | `statusBarItems` | Status bar labels/actions | ✅ StatusBar.svelte host |
### Планируемые contribution points
| Тип | Поле manifest | Статус |
|---|---|---|
| Действия над файлами | `fileActions` | Registry готов, UI не реализован |
| Действия над заметками | `noteActions` | Registry готов, UI не реализован |
| Контекстное меню | `contextMenuEntries` | Registry готов, UI не реализован |
| Провайдеры поиска | `searchProviders` | Registry готов, UI не реализован |
| Провайдеры активности | `activityProviders` | Registry готов, UI не реализован |
| Элементы status bar | `statusBarItems` | Registry готов, UI не реализован |
| Sidecar-backed command handlers | `commands` | Planned; bundled frontend handlers are current runtime |
| Import/export providers | `importers` / `exporters` | Planned |
| Protocol receivers | `protocol.receivers` | Planned |
### Структура contribution points в manifest
@@ -248,7 +259,7 @@ Icon fields use shell icon names rendered through the bundled Lucide SVG wrapper
1. Plugin `Register(pluginID, contributions)` — все contributions регистрируются
2. `Unregister(pluginID)` — удаляет все contributions указанного plugin
3. Reload: `Unregister → Register` (предотвращает дублирование)
3. Reload сначала удаляет contributions всех plugins предыдущего discovery, затем регистрирует только `loaded`/`degraded` plugins
4. Disable plugin → `Unregister` (contributions исчезают из UI)
5. Enable plugin → `Register` при следующем Reload
6. Registry idempotent: Register удаляет старые записи перед добавлением новых
@@ -317,7 +328,8 @@ the Workbench helper.
{
"kind": "vault-file",
"extensions": [".md", ".markdown"],
"contexts": ["generic-markdown", "notes-markdown"]
"contexts": ["generic-markdown", "notes-markdown"],
"modes": ["view"]
},
{
"kind": "vault-file",
@@ -333,7 +345,7 @@ the Workbench helper.
```
Selection uses enabled loaded/degraded provider plugins, resource kind,
extension/mime, context, user preference, priority, then deterministic
request mode, 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.
@@ -384,10 +396,35 @@ contributions summary.
- `commands.execute(commandId, args)` — backend сначала проверяет plugin status,
permission `commands.register` и что command объявлен в `contributes.commands`
именно этим plugin. Затем frontend registry вызывает зарегистрированный handler.
- `commands.executeFor(pluginId, commandId, args)` — то же выполнение, но для
command другого plugin-provider. Используется host surfaces вроде Files/Notes,
которые читают contribution action и вызывают объявленный provider handler.
- Если command объявлен в manifest, но handler не зарегистрирован, API возвращает
понятную ошибку `declared-but-unhandled`.
- Handler registry очищается при component unmount, reload/disable flow и
`api.dispose()`.
- Shell command palette открывается через `Ctrl+K` / `Cmd+K` или
`Ctrl+Shift+P` / `Cmd+Shift+P`, показывает commands enabled plugins,
фильтрует по title/id/plugin и вызывает зарегистрированные bundled frontend
handlers.
`statusBarItems`
- Shell status bar renders enabled plugin `statusBarItems` contributions.
- Items support `left`, `center`, and `right` positions. Missing position
defaults to `left`.
- The host refreshes on plugin reload/enable/disable through
`verstak:plugins-changed`.
- `handler` is preserved in the contribution summary for future action routing;
current host only renders status labels.
`contributions`
- `contributions.list()` — возвращает весь flattened contribution summary.
- `contributions.list(point)` — возвращает массив contribution records для
конкретного поля (`fileActions`, `noteActions`, `contextMenuEntries`, etc).
- Runtime records include `pluginId`, so consumer surfaces can call
`commands.executeFor(record.pluginId, record.handler, args)`.
`events`
@@ -398,22 +435,45 @@ contributions summary.
- Handler получает envelope `{ name, pluginId, payload, timestamp }`.
- Subscriptions очищаются при component unmount, reload/disable flow и
`api.dispose()`.
- Core publishes `file.changed` for Files API mutations and live vault watcher
changes. Watcher payloads use `operation: "external.create"`,
`"external.update"`, or `"external.delete"`, include vault-relative `path`,
`type`, `workspaceRootPath`, and `external: true`.
`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.readBytes(relativePath)` — reads a regular file up to 8 MB and returns
`{ relativePath, size, mimeHint, dataBase64 }` for bounded binary preview use.
- `files.writeText(relativePath, content, options)` — atomically writes text via
temp-file-and-rename. `options.createIfMissing` and `options.overwrite`
control conflicts.
- `files.writeBytes(relativePath, dataBase64, options)` — decodes a base64
payload up to 8 MB and atomically writes bytes 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.
- `files.listTrash()` — returns internal file trash metadata, newest first.
- `files.restoreTrash(trashId, options)` — restores a file/folder from internal
trash to its original vault-relative path, or to `options.targetPath`.
Conflicts are rejected unless `options.overwrite` is true.
- `files.deleteTrash(trashId)` — permanently removes a file/folder already in
internal trash. This operation cannot be undone.
- UI policy: the workspace `Files` plugin displays only live files and folders.
The global official `verstak.trash` plugin owns listing, restoring, filtering,
and permanently deleting deleted items across the vault.
- `files.openExternal(relativePath)` — opens a vault-relative file/folder in
the OS default application.
- `files.showInFolder(relativePath)` — reveals a vault-relative file/folder in
the OS file manager where the platform supports it.
- Backend requires plugin exists, enabled, status `loaded`/`degraded`, open
vault, and `files.read`, `files.write`, or `files.delete`.
vault, and `files.read`, `files.write`, `files.delete`, or
`files.openExternal`.
- 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.
@@ -422,9 +482,26 @@ contributions summary.
- `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.
- `readText` is limited to UTF-8 regular files up to 2 MB. `readBytes` and
`writeBytes` are bounded byte contracts up to 8 MB; chunked streaming is
deferred.
- Live watcher refresh is active while Verstak is running and a vault is open.
It performs an initial no-event snapshot, then publishes `file.changed` for
external creates, updates, and deletes outside `.verstak/`. It does not keep a
persistent snapshot or report what changed while Verstak was closed.
`sync`
- `sync.now()` pushes local operations, pulls remote operations, and returns
`{ pushed, pulled, serverSequence, conflicts?, applyErrors? }`.
- `conflicts` is an array of server-reported sync conflicts. Conflict objects
may include `op_id`, `entity_type`, `entity_id`, `reason`, and additional
server fields. The Sync plugin must show conflict details instead of only a
count, and it must not silently resolve or overwrite local data.
- `applyErrors` lists local apply failures for pulled operations. These are
user-visible warnings and do not imply that sync was fully successful.
- Transport push/pull uses bounded retry/backoff for transient HTTP/network
failures. Client/auth errors are not retried.
`dispose`
@@ -487,17 +564,29 @@ bundled runtime. Это реальный runtime contract для cooperative bun
| `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.commands.executeFor(pluginId, id, args)` | ✅ Работает | Выполняет handler другого plugin-provider после backend validation |
| `api.contributions.list(point?)` | ✅ Работает | Возвращает flattened contribution summary или массив по point |
| Command Palette UI | ✅ Работает | `Ctrl/Cmd+K`, фильтр enabled plugin commands, вызов registered frontend handlers |
| `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.readBytes(relativePath)` | ✅ Работает | Читает regular file до 8 MB как base64 payload |
| `api.files.writeText(relativePath, content, options)` | ✅ Работает | Atomic text write с явным create/overwrite policy |
| `api.files.writeBytes(relativePath, dataBase64, options)` | ✅ Работает | Atomic bounded byte write до 8 MB с явным 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.files.trash(relativePath)` | ✅ Работает | Перемещает в internal trash и сохраняет metadata исходного объекта |
| `api.files.listTrash()` | ✅ Работает | Возвращает metadata internal file trash |
| `api.files.restoreTrash(trashId, options)` | ✅ Работает | Восстанавливает из internal trash, conflict-safe по умолчанию |
| `api.files.deleteTrash(trashId)` | ✅ Работает | Необратимо удаляет запись и payload из internal trash |
| `api.files.openExternal(relativePath)` | ✅ Работает | Открывает vault file/folder во внешнем приложении, требует `files.openExternal` |
| `api.files.showInFolder(relativePath)` | ✅ Работает | Показывает vault file/folder в системном файловом менеджере, требует `files.openExternal` |
| `api.workbench.openResource(request)` | ✅ Работает | Routes vault resources to `openProviders` |
| `api.workbench.editResource(request)` | ✅ Работает | Same routing, forcing `mode: "edit"` |
| `api.sync.now()` | ✅ Работает | Push/pull с bounded retry/backoff для transient HTTP/network failures |
| `api.sync.status()` | ✅ Работает | Возвращает configured/connected/error/revoked state, lastError, unpushed count |
| `api.dispose()` | ✅ Работает | Очищает command handlers и event subscriptions текущего API instance |
Ограничения:
@@ -544,13 +633,12 @@ bundled runtime. Это реальный runtime contract для cooperative bun
`ReloadPlugins()` в `internal/api/app.go` позволяет перезагрузить plugins без перезапуска приложения:
1. Unregister all non-core capabilities.
2. Re-register core capabilities + vault + workspace (если открыт).
3. Re-scan discovery directories.
4. For each plugin: re-run capability resolution.
5. **Unregister contributions** before re-registering (предотвращает дубли).
6. Register contributions for loaded/degraded plugins (disabled/failed — не регистрируются).
7. Update plugins list.
1. Unregister contributions всех plugins предыдущего discovery.
2. Unregister all non-core capabilities.
3. Re-register core capabilities + vault + workspace (если открыт).
4. Re-scan discovery directories и повторить capability resolution.
5. Register contributions для loaded/degraded plugins (disabled/failed — не регистрируются).
6. Update plugins list.
Frontend вызывает это при нажатии "Reload" в Plugin Manager.
@@ -761,7 +849,6 @@ Workspace — это физическая папка верхнего уровн
<vault>/
Workspace/
Notes/
Overview.md
Project/
ClientA/
.verstak/
@@ -785,8 +872,8 @@ Workspace existence/list хранится только в filesystem:
`.verstak` может хранить только metadata, которая не заменяет filesystem:
- UI state: selected workspace, expanded folders, sort/pin state, preferences.
- Semantic snapshot: applied template snapshot, enabled feature areas, folder
conventions.
- Semantic snapshot: applied template snapshot, enabled feature areas, exact
`workspaceTools`, and folder conventions.
Template snapshot копируется в metadata при создании workspace. Workspace
identity при этом остаётся именем top-level folder; `metadata.workspaceName`
@@ -798,22 +885,39 @@ identity при этом остаётся именем top-level folder; `metada
{
"workspaceName": "Project",
"createdFromTemplate": {
"templateId": "client-project",
"templateName": "Client Project",
"templateId": "project",
"templateName": "Project",
"templateVersion": 1,
"appliedAt": "2026-06-19T12:00:00Z"
"appliedAt": "2026-06-19T12:00:00Z",
"workspaceTools": [
"verstak.notes",
"verstak.files",
"verstak.todo",
"verstak.journal",
"verstak.activity",
"verstak.browser-inbox"
]
},
"features": {
"notes": true,
"files": true,
"secrets": true,
"activity": false
"todo": true,
"journal": true,
"activity": true,
"browser-inbox": true
},
"folders": {
"notes": "Notes",
"files": "Files",
"secrets": "Secrets"
}
"files": "Files"
},
"workspaceTools": [
"verstak.notes",
"verstak.files",
"verstak.todo",
"verstak.journal",
"verstak.activity",
"verstak.browser-inbox"
]
}
```
@@ -825,6 +929,8 @@ update/migration может быть только явной future feature. Е
### API
- `ListWorkspaces()` — список top-level physical folders.
- `ListWorkspaceTemplates()` — selectable built-in templates с presentation
metadata и `workspaceTools`.
- `CreateWorkspace(name, templateId?)` — создать `<vault>/<name>/`, применить
template один раз, сохранить snapshot metadata.
- `RenameWorkspace(oldName, newName)` — физически переименовать top-level folder
@@ -860,7 +966,7 @@ Deprecated compatibility APIs:
`.verstak`, reserved/internal names, symlink workspaces, conflicts.
- WorkspaceItems получают `workspaceRootPath`, равный имени top-level папки
(`Project`, `ClientA`, etc). Files plugin показывает именно эту папку.
- Files API остаётся raw vault-relative API: `Project/Notes/Overview.md`,
- Files API остаётся raw vault-relative API: `Project/Notes/example.md`,
`Project/docs/file.md`, `Test/readme.md`.
- Notes are ordinary Markdown files under `<workspace>/Notes/`; нет
`.verstak/notes`, UUID note storage или второго source of truth для note
@@ -868,13 +974,19 @@ Deprecated compatibility APIs:
### Lifecycle Events
**Planned (not yet implemented in runtime):**
- `workspace.node.created`
- `workspace.node.renamed`
- `workspace.node.moved`
- `workspace.node.archived`
- `workspace.node.selected`
- `workspace.error`
Runtime publishes workspace lifecycle events after successful operations:
- `workspace.created` — payload includes `operation: "create"`,
`workspaceRootPath`, `workspaceName`, and optional `templateId`.
- `workspace.renamed` — payload includes `operation: "rename"`, new
`workspaceRootPath` / `workspaceName`, and previous workspace fields.
- `workspace.trashed` — payload includes `operation: "trash"`,
`workspaceRootPath`, `workspaceName`, `trashId`, `trashPath`, and `deletedAt`.
- `workspace.selected` — payload includes `operation: "select"`,
`workspaceRootPath`, and `workspaceName`.
Official Activity subscribes to these events and stores them through the normal
activity provider path.
### UI
+87
View File
@@ -0,0 +1,87 @@
# UX/UI Refactor Plan
## Assumptions
- `verstak-desktop` is the core platform and UI shell. Notes, files, editor, activity, journal, browser inbox, and search behavior stay plugin-owned.
- The old `~/git/verstak` UI is a visual and interaction reference, not an architecture source.
- The v2 shell should improve orientation, density, keyboard/mouse ergonomics, and responsive behavior without reintroducing the v1 monolith.
## Reference Rules
Keep from v1:
- compact dark workbench rhythm;
- clear title/header zones;
- dense rows and tabs for repeated work;
- action controls that appear where the user is working;
- custom, in-app interaction surfaces instead of browser-default dialogs where the flow is important.
Avoid from v1:
- putting user business workflows into `App.svelte`;
- direct coupling between shell and notes/files/editor internals;
- global mutable UI state that plugins must know about;
- moving plugin behavior into core for visual convenience.
## Mimo Delegation Model
Use `~/bin/mimo.sh run --dir <repo> "<task>"` only for bounded junior tasks.
Good tasks:
- compare two small components and write a short report to `/tmp`;
- draft CSS for a named component within existing tokens;
- inspect one test file and suggest missing assertions;
- make a one-component mechanical change after the target behavior is already specified.
Do not delegate:
- architecture decisions;
- plugin/core boundary decisions;
- final diff review;
- verification claims;
- commits or pushes.
Every mimo result must be reviewed with `git diff` and verified independently.
## Work Plan
1. Shell orientation
- Move persistent search and workspace context into the workspace header.
- Keep exactly one global search entry visible at a time.
- Preserve search availability in global plugin views.
2. Responsive shell
- Make narrow viewports usable by stacking sidebar above workspace content.
- Ensure tabs and workspace cards do not force horizontal page overflow.
- Add Playwright coverage for mobile geometry.
3. Today surface
- Make Today feel like a work-resume surface, not a static card grid.
- Keep data loading from plugin settings/contributions; do not add business logic to core.
- Improve empty states and quick actions based on available workspace tools.
4. Plugin manager polish
- Improve scanning, status density, and permission readability.
- Keep enable/disable/status behavior unchanged.
- Verify degraded/failed/disabled plugin paths.
5. Files/workbench ergonomics
- Use the existing files plugin comparison report as input.
- Prefer plugin-local improvements: context menu, keyboard navigation, selection, and custom confirmation.
- Do not add file-manager logic to the shell.
## Verification Gates
Before commit:
- `npm run build`
- focused Playwright suite for the touched flow
- full `npm run test:e2e` when App, Sidebar, WorkspaceHost, PluginManager, or shared shell layout changes
- desktop and mobile screenshots inspected manually
Before push:
- re-run the relevant verification from a clean current worktree state;
- inspect `git status` and `git diff --stat`;
- push only after tests and visual smoke have current evidence.
+40
View File
@@ -0,0 +1,40 @@
# Workspace Templates
Workspace templates choose which dynamic plugin workspace tabs are visible when a
new top-level workspace is created. They do not enable or disable plugins for the
whole vault and do not affect global sidebar tools.
## Built-in templates
| Template | Workspace tabs |
| --- | --- |
| General | Notes, Files, Journal, Activity, Browser Inbox |
| Project | Notes, Files, Todos, Journal, Activity, Browser Inbox |
| Writing | Notes, Files, Journal |
| Admin | Notes, Files, Secrets, Todos, Journal |
| Minimal | Notes, Files |
The create-workspace modal displays the selected template description and its
included plugin tabs before the folder is created.
## Metadata and compatibility
Creation stores a template snapshot in `.verstak/workspaces/` metadata. The
snapshot contains the template id, name, version, applied time, and an ordered
`workspaceTools` list of plugin IDs. Existing workspace metadata without
`workspaceTools` remains compatible: its workspace continues to show all globally
enabled workspace plugins rather than unexpectedly hiding tabs.
Templates are applied once. Editing the built-in catalog or creating another
workspace with a different template never changes an existing workspace snapshot.
There is no template editor or post-creation template switcher yet.
## Global tools and unavailable plugins
Template visibility applies only to `workspaceItems` in the selected workspace.
Global views and sidebar items, such as global Todos, Browser Inbox, and Trash,
remain available according to the normal plugin enablement state.
The workspace host intersects the snapshot with dynamically discovered, globally
enabled plugin contributions. If a template references a plugin that is missing or
disabled, that tab is simply absent; the other template tabs remain usable.
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

+96
View File
@@ -0,0 +1,96 @@
# UI Polish Milestone
## Assumptions
- This milestone is visual polish only: no backend API changes, no plugin contract changes, and no feature removal.
- English remains the baseline UI language for this pass. Hardcoded Russian strings that are visible in core UI should be converted to English where touched.
- Official plugins are plain JS bundles with injected CSS. Introducing a new shared UI package would be larger than this milestone, so shared primitives will be expressed as global design tokens/classes plus consistent local CSS in each bundle.
- Desktop workbench density should stay compact. The goal is consistency, not a marketing-style redesign.
## Audit Findings
### Existing Tokens
- `frontend/src/App.svelte` already defines partial global button classes: `btn-primary`, `btn-secondary`, `btn-danger`, `btn-ghost`, `btn-icon`, plus global button focus/disabled behavior.
- Most shell and plugin surfaces hardcode the same dark palette directly: `#1a1a2e`, `#16213e`, `#0f3460`, `#4ecca3`, `#e94560`, `#8b8ba8`.
- There are no named CSS variables for semantic surface, selected row, border, muted text, warning, or focus ring.
- Plugin bundles repeat their own toolbar/button/list/empty/error styles instead of reusing shell-level primitives.
### Repeated Patterns
- Page/workspace headers: `WorkspaceHost.svelte`, `WorkbenchHost.svelte`, `ViewContainer.svelte`, plugin toolbars.
- Tab bars: workspace tabs use a custom active style separate from selected rows and sidebar active states.
- Toolbars: Files, Notes, Search, Activity, Journal, Browser Inbox, and default editor use similar but inconsistent dark toolbar rules.
- List rows: Files rows, Notes rows, Activity rows, Journal rows, Secrets list items, Browser Inbox rows, Global Search results, WorkspaceTree rows.
- Cards/panels: Today panels, worklog suggestions, Secrets detail/form, Browser Inbox detail text, plugin manager cards.
- Empty/error states: many are plain centered text or show technical details as primary text.
### Candidate Primitives
Implement as global classes/tokens first, then apply locally:
- PageHeader / WorkspaceHeader: compact title, metadata, right actions/search.
- TabBar / TabButton: consistent active tab, hover, focus.
- Toolbar / ToolbarGroup: grouped controls with separators and predictable height.
- Button variants: primary, secondary, ghost, danger, icon.
- ListRow: hover, selected, disabled, metadata line, actions zone.
- Card: flat desktop panel with 6px radius and subtle border.
- SplitPane: list/detail grid with shared border and selected item states.
- EmptyState: centered title, hint, optional action.
- InlineAlert: readable message plus optional details/debug text.
- StatusBadge: compact semantic label for type, status, warning/error.
- Menu: context menu with same surface, hover, danger item, separator.
## Implementation Plan
1. Add semantic CSS variables and global primitive classes in `frontend/src/App.svelte`.
- Colors: background, surface, surface-muted, surface-hover, surface-selected, border, border-strong, text-primary, text-secondary, text-muted, accent, accent-muted, danger, warning, success.
- Spacing: 4/8/12/16/24/32 via variables.
- Radius, font sizes, focus ring, and modest elevation tokens.
2. Normalize shell surfaces.
- Update `WorkspaceHost.svelte`, `WorkbenchHost.svelte`, `ViewContainer.svelte`, `Sidebar.svelte`, `WorkspaceTree.svelte`, `GlobalSearch.svelte`, and `TodaySurface.svelte` to use tokens and shared state rules.
- Convert visible Russian shell search/empty strings to English.
- Keep plugin manager architecture untouched; only global tokens should make it less visually divergent.
3. Normalize Files.
- Group toolbar into Navigation, Create, Selection, Clipboard/More, and filter/sort areas.
- Keep existing context menu and operations, restyle menu with shared surface/hover/danger states.
- Make single row click open when no modifier is used; keep ctrl/meta/shift selection behavior and keyboard selection.
- Reduce actions column visual noise by showing secondary row actions on hover/selection and using a compact More/action group.
4. Normalize Notes.
- Make New Note the primary page action.
- Keep filter/sort inside the toolbar group.
- Make entire note row readable and clickable; row actions remain visible enough and use icon-button states.
- Use shared empty/status/error styling.
5. Normalize Search.
- Render results as compact rows/cards with type badge/icon, title/path, secondary match reason, and compact Open button.
- Show provider errors as inline alert-style secondary information, not as raw main status text.
6. Normalize Activity and Journal.
- Add human-readable event labels such as "Workspace selected", "File opened", "Note edited", and "Work session detected".
- Move raw event names/source IDs into a details block or secondary metadata.
- Style plugin errors through inline alert patterns.
- Present worklog suggestions as cards with consistent title/summary/minutes actions.
7. Normalize Secrets and Browser Inbox.
- Keep split-pane list/detail layout.
- Use shared list rows, detail card/form surfaces, and empty states.
- Render "Value hidden" as password-like hidden field state.
- Use button variants: primary for save/create, secondary for copy/edit/remove where appropriate, danger for delete/clear/remove destructive actions.
8. Verify.
- Run desktop frontend build and relevant Playwright checks if available.
- Run official plugin smoke/check script.
- Capture before/after screenshots or save available after screenshots under `docs/ui-polish-assets/`.
## Out of Scope
- Backend API or Wails method changes.
- Plugin manifest/contribution contract changes.
- New heavy UI dependencies.
- Full i18n extraction.
- Redesigning the product information architecture.
- Rebuilding official plugins into Svelte packages.
+141
View File
@@ -0,0 +1,141 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
test.describe('Activity workflow', () => {
let consoleCollector;
test.beforeEach(async ({ page }) => {
consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
});
test.afterEach(async () => {
consoleCollector.assertNoErrors();
});
test('workspace activity explains empty event flow', async ({ page }) => {
await page.getByRole('tab', { name: 'Activity' }).click();
const activity = page.locator('.activity-root');
await expect(activity).toBeVisible({ timeout: 10000 });
await expect(activity.locator('.activity-title')).toContainText('Activity');
await expect(activity.locator('.activity-count')).toHaveText('0 events');
await expect(activity.locator('.activity-empty')).toContainText('No activity events yet');
await expect(activity.locator('.activity-empty')).toContainText('File changes, browser captures, and conversions will appear here');
await expect(activity.locator('[data-activity-action="clear"]')).toBeDisabled();
});
test('workspace activity keeps raw events and renders factual work session candidates', async ({ page }) => {
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.activity', {
'events:workspace:Project': [
{
activityId: 'activity-e2e-capture',
occurredAt: '2026-06-30T08:00:00.000Z',
type: 'browser.capture.selection',
title: 'Research Capture',
summary: 'Selected text from the article',
sourcePluginId: 'verstak.browser-inbox',
workspaceRootPath: 'Project',
},
{
activityId: 'activity-e2e-note',
occurredAt: '2026-06-30T08:20:00.000Z',
type: 'note.saved',
title: 'Saved note',
summary: 'Project/Notes/Research Capture.md',
sourcePluginId: 'verstak.files',
workspaceRootPath: 'Project',
},
{
activityId: 'activity-e2e-open',
occurredAt: '2026-06-30T08:30:00.000Z',
type: 'file.opened',
title: 'Selected file',
summary: 'Project/Notes/Research Capture.md',
sourcePluginId: 'verstak.files',
workspaceRootPath: 'Project',
},
],
});
});
await page.getByRole('tab', { name: 'Activity' }).click();
const activity = page.locator('.activity-root');
await expect(activity.locator('.activity-count')).toHaveText('3 events');
await expect(activity.locator('[data-activity-id="activity-e2e-capture"]')).toContainText('Research Capture');
await expect(activity.locator('[data-activity-id="activity-e2e-note"]')).toContainText('Saved note');
await expect(activity.locator('[data-activity-id="activity-e2e-open"]')).toContainText('Selected file');
const candidateSection = activity.locator('[data-activity-section="work-session-candidates"]');
await expect(candidateSection).toContainText('Possible journal entries');
const candidate = candidateSection.locator('[data-work-session-candidate]');
await expect(candidate).toHaveCount(1);
await expect(candidate).toContainText('Workspace: Project');
await expect(candidate).toContainText('Estimated duration: 20 min');
await expect(candidate).toContainText('Activities: 2');
await expect(candidate).not.toContainText('Project work on');
await expect(candidate.locator('[data-work-session-action="review"]')).toBeVisible();
await expect(candidate.locator('[data-work-session-action="dismiss"]')).toBeVisible();
});
test('Review opens an empty Journal form with selectable candidate activities', async ({ page }) => {
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.activity', {
'events:workspace:Project': [
{
activityId: 'review-capture',
occurredAt: '2026-06-30T08:00:00.000Z',
type: 'browser.capture.selection',
title: 'Research Capture',
workspaceRootPath: 'Project',
},
{
activityId: 'review-note',
occurredAt: '2026-06-30T08:20:00.000Z',
type: 'note.saved',
title: 'Saved note',
workspaceRootPath: 'Project',
},
],
});
});
await page.getByRole('tab', { name: 'Activity' }).click();
await page.locator('[data-work-session-candidate] [data-work-session-action="review"]').click();
await expect(page.getByRole('tab', { name: 'Journal' })).toHaveAttribute('aria-selected', 'true');
const journal = page.locator('.journal-root');
await expect(journal).toBeVisible({ timeout: 10000 });
await expect(journal.locator('[data-journal-candidate]')).toContainText('Workspace: Project');
await expect(journal.locator('[data-journal-candidate]')).toContainText('Estimated duration: 20 min');
await expect(journal.locator('[data-journal-input="title"]')).toHaveValue('');
await expect(journal.locator('[data-journal-input="summary"]')).toHaveValue('');
await expect(journal.locator('[data-journal-input="minutes"]')).toHaveValue('20');
const activityInputs = journal.locator('[data-journal-candidate-activity]');
await expect(activityInputs).toHaveCount(2);
await expect(activityInputs.nth(0)).toBeChecked();
await expect(activityInputs.nth(1)).toBeChecked();
await journal.locator('[data-journal-input="title"]').fill('Review research capture');
await journal.locator('[data-journal-input="summary"]').fill('Read the capture and updated the project note.');
await activityInputs.nth(1).uncheck();
await journal.locator('[data-journal-action="save-entry"]').click();
await expect(journal).toContainText('Review research capture');
await expect(journal).toContainText('20 min');
const stored = await page.evaluate(async () => {
const result = await window.go.api.App.ReadPluginSettings('verstak.journal');
return Array.isArray(result) ? result[0]['worklog:workspace:Project'] : result['worklog:workspace:Project'];
});
await expect(stored).toEqual([expect.objectContaining({
title: 'Review research capture',
summary: 'Read the capture and updated the project note.',
sourceCandidateId: expect.any(String),
activityIds: ['review-capture'],
})]);
});
});
+157
View File
@@ -0,0 +1,157 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
test.describe('Browser Inbox workflow', () => {
let consoleCollector;
test.beforeEach(async ({ page }) => {
consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
});
test.afterEach(async () => {
consoleCollector.assertNoErrors();
});
test('global inbox explains empty capture flow and exposes assignment filters', async ({ page }) => {
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser Inbox' }).click();
const inbox = page.locator('.browser-inbox-root');
await expect(inbox).toBeVisible({ timeout: 10000 });
await expect(inbox.locator('.browser-inbox-title')).toContainText('Browser Inbox');
await expect(inbox.locator('.browser-inbox-count')).toHaveText('0 items');
await expect(inbox.locator('.browser-inbox-empty')).toContainText('No browser captures yet');
await expect(inbox.locator('.browser-inbox-empty')).toContainText('send a page, selection, link, or file from the browser extension');
await expect(inbox.locator('[data-browser-inbox-filter="status"]')).toBeVisible();
await expect(inbox.locator('[data-browser-inbox-filter="workspace"]')).toBeVisible();
await expect(inbox.locator('[data-browser-inbox-action="clear"]')).toBeDisabled();
});
test('workspace inbox renders stored captures with conversion actions', async ({ page }) => {
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.browser-inbox', {
'captures:global': [{
captureId: 'capture-e2e-1',
capturedAt: '2026-06-30T08:00:00.000Z',
kind: 'file',
url: 'https://example.com/report',
title: 'Research Report',
domain: 'example.com',
text: 'Selected page text',
fileName: 'report.txt',
fileText: 'Report file contents',
workspaceRootPath: 'Project',
browserName: 'Firefox',
}],
});
});
await page.getByRole('tab', { name: 'Browser Inbox' }).click();
const inbox = page.locator('.browser-inbox-root');
await expect(inbox.locator('.browser-inbox-count')).toHaveText('1 item');
await expect(inbox.locator('[data-browser-capture-id="capture-e2e-1"]')).toContainText('Research Report');
await expect(inbox.locator('.browser-inbox-detail-title')).toHaveText('Research Report');
await expect(inbox.locator('.browser-inbox-meta')).toContainText('example.com');
await expect(inbox.locator('.browser-inbox-text').first()).toContainText('Selected page text');
await expect(inbox.locator('[data-browser-inbox-action="create-note"]')).toBeVisible();
await expect(inbox.locator('[data-browser-inbox-action="create-link"]')).toBeVisible();
await expect(inbox.locator('[data-browser-inbox-action="create-file"]')).toBeVisible();
await expect(inbox.locator('[data-browser-inbox-action="remove"]')).toBeVisible();
});
test('global inbox assigns, reassigns, filters, marks, and deletes captures', async ({ page }) => {
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.browser-inbox', {
'captures:global': [
{
captureId: 'inbox-unassigned',
capturedAt: '2026-06-30T08:20:00.000Z',
kind: 'page',
url: 'https://example.com/unassigned',
title: 'Unassigned research',
domain: 'example.com',
},
{
captureId: 'inbox-client-processed',
capturedAt: '2026-06-30T08:10:00.000Z',
kind: 'link',
url: 'https://client.example.com/processed',
title: 'Processed client link',
domain: 'client.example.com',
workspaceRootPath: 'ClientA',
processed: true,
},
{
captureId: 'inbox-project-open',
capturedAt: '2026-06-30T08:00:00.000Z',
kind: 'selection',
title: 'Project quote',
text: 'A project quote',
workspaceRootPath: 'Project',
},
],
});
});
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser Inbox' }).click();
const inbox = page.locator('.browser-inbox-root');
await expect(inbox.locator('.browser-inbox-count')).toHaveText('3 items');
const statusFilter = inbox.locator('[data-browser-inbox-filter="status"]');
const workspaceFilter = inbox.locator('[data-browser-inbox-filter="workspace"]');
await statusFilter.selectOption('unassigned');
await expect(inbox.locator('[data-browser-capture-id="inbox-unassigned"]')).toBeVisible();
await expect(inbox.locator('[data-browser-capture-id="inbox-client-processed"]')).toHaveCount(0);
await statusFilter.selectOption('all');
await workspaceFilter.selectOption('ClientA');
await expect(inbox.locator('[data-browser-capture-id="inbox-client-processed"]')).toBeVisible();
await expect(inbox.locator('[data-browser-capture-id="inbox-project-open"]')).toHaveCount(0);
await workspaceFilter.selectOption('');
await statusFilter.selectOption('unprocessed');
await expect(inbox.locator('[data-browser-capture-id="inbox-unassigned"]')).toBeVisible();
await expect(inbox.locator('[data-browser-capture-id="inbox-project-open"]')).toBeVisible();
await expect(inbox.locator('[data-browser-capture-id="inbox-client-processed"]')).toHaveCount(0);
await statusFilter.selectOption('all');
const assignment = inbox.locator('[data-browser-inbox-assignment="inbox-unassigned"]');
await assignment.selectOption('ClientA');
await expect.poll(async () => page.evaluate(async () => {
const result = await window.go.api.App.ReadPluginSettings('verstak.browser-inbox');
const settings = Array.isArray(result) ? result[0] : result;
return settings['captures:global'].find((capture) => capture.captureId === 'inbox-unassigned').workspaceRootPath;
})).toBe('ClientA');
await inbox.locator('[data-browser-inbox-assignment="inbox-unassigned"]').selectOption('Project');
await expect.poll(async () => page.evaluate(async () => {
const result = await window.go.api.App.ReadPluginSettings('verstak.browser-inbox');
const settings = Array.isArray(result) ? result[0] : result;
return settings['captures:global'].find((capture) => capture.captureId === 'inbox-unassigned').workspaceRootPath;
})).toBe('Project');
await inbox.locator('[data-browser-inbox-action="clear-assignment"]').click();
await expect.poll(async () => page.evaluate(async () => {
const result = await window.go.api.App.ReadPluginSettings('verstak.browser-inbox');
const settings = Array.isArray(result) ? result[0] : result;
return settings['captures:global'].find((capture) => capture.captureId === 'inbox-unassigned').workspaceRootPath || '';
})).toBe('');
await inbox.locator('[data-browser-inbox-action="toggle-processed"]').click();
await expect.poll(async () => page.evaluate(async () => {
const result = await window.go.api.App.ReadPluginSettings('verstak.browser-inbox');
const settings = Array.isArray(result) ? result[0] : result;
return settings['captures:global'].find((capture) => capture.captureId === 'inbox-unassigned').processed;
})).toBe(true);
await inbox.locator('[data-browser-inbox-action="toggle-processed"]').click();
await inbox.locator('[data-browser-inbox-action="remove"]').click();
await expect.poll(async () => page.evaluate(async () => {
const result = await window.go.api.App.ReadPluginSettings('verstak.browser-inbox');
const settings = Array.isArray(result) ? result[0] : result;
return settings['captures:global'].some((capture) => capture.captureId === 'inbox-unassigned');
})).toBe(false);
});
});
+127
View File
@@ -0,0 +1,127 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
test.describe('Command Palette', () => {
let consoleCollector;
test.beforeEach(async ({ page }) => {
consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
});
test.afterEach(async () => {
consoleCollector.assertNoErrors();
});
test('opens with keyboard, filters commands, and executes registered frontend handler', async ({ page }) => {
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' }).click();
await expect(page.locator('.pt-root')).toBeVisible({ timeout: 10000 });
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+K' : 'Control+K');
const palette = page.locator('.command-palette');
await expect(palette).toBeVisible();
await expect(palette.locator('[data-command-id="verstak.platform-test.show-version"]')).toBeVisible();
await palette.locator('[data-command-palette-input]').fill('version');
await expect(palette.locator('[data-command-id="verstak.platform-test.show-version"]')).toBeVisible();
await expect(palette.locator('[data-command-id="verstak.platform-test.run-tests"]')).not.toBeVisible();
await page.keyboard.press('Enter');
await expect(palette).not.toBeVisible();
await expect(page.locator('[data-command-palette-status="success"]')).toContainText('Show Version Info');
await expect(page.locator('[data-command-palette-status="success"]')).toContainText('handled');
});
test('Escape closes the palette without changing current view', async ({ page }) => {
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+K' : 'Control+K');
await expect(page.locator('.command-palette')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.locator('.command-palette')).not.toBeVisible();
await expect(page.locator('.workspace-host')).toBeVisible();
});
test('shows user workflow commands before diagnostics and opens workspace tools', async ({ page }) => {
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+K' : 'Control+K');
const palette = page.locator('.command-palette');
await expect(palette).toBeVisible();
const items = palette.locator('.command-palette-item');
await expect(items.nth(0)).toHaveAttribute('data-command-id', 'verstak.shell.open-overview');
await expect(items.nth(1)).toHaveAttribute('data-command-id', 'verstak.shell.open-files');
await expect(items.nth(2)).toHaveAttribute('data-command-id', 'verstak.shell.open-activity');
await expect(items.nth(3)).toHaveAttribute('data-command-id', 'verstak.shell.open-browser-inbox');
await palette.locator('[data-command-palette-input]').fill('activity');
await expect(palette.locator('[data-command-id="verstak.shell.open-activity"]')).toBeVisible();
await expect(palette.locator('[data-command-id="verstak.platform-test.run-tests"]')).not.toBeVisible();
await page.keyboard.press('Enter');
await expect(palette).not.toBeVisible();
await expect(page.getByRole('tab', { name: 'Activity' })).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.activity-root')).toBeVisible({ timeout: 10000 });
});
test('starts file creation workflows from user commands', async ({ page }) => {
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+K' : 'Control+K');
let palette = page.locator('.command-palette');
await palette.locator('[data-command-palette-input]').fill('markdown');
await expect(palette.locator('[data-command-id="verstak.shell.create-markdown"]')).toBeVisible();
await page.keyboard.press('Enter');
await expect(palette).not.toBeVisible();
await expect(page.getByRole('tab', { name: 'Files' })).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.files-root')).toBeVisible({ timeout: 10000 });
await expect(page.locator('[data-files-create-input]')).toBeVisible();
await page.locator('[data-files-create-input]').fill('Palette Note.md');
await page.locator('[data-files-create-confirm]').click();
await expect(page.locator('[data-file-name="Palette Note.md"]')).toBeVisible();
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+K' : 'Control+K');
palette = page.locator('.command-palette');
await palette.locator('[data-command-palette-input]').fill('text file');
await expect(palette.locator('[data-command-id="verstak.shell.create-text"]')).toBeVisible();
await page.keyboard.press('Enter');
await expect(page.locator('[data-files-create-input]')).toBeVisible();
await page.locator('[data-files-create-input]').fill('Palette Text.txt');
await page.locator('[data-files-create-confirm]').click();
await expect(page.locator('[data-file-name="Palette Text.txt"]')).toBeVisible();
});
test('runs sync workflow commands', async ({ page }) => {
await page.evaluate(async () => {
const err = await window.go.api.App.PluginSyncConfigure('verstak.sync', 'https://sync.example.test', 'alice', 'secret');
if (err) throw new Error(err);
});
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+K' : 'Control+K');
let palette = page.locator('.command-palette');
await palette.locator('[data-command-palette-input]').fill('sync now');
await expect(palette.locator('[data-command-id="verstak.shell.sync-now"]')).toBeVisible();
await page.keyboard.press('Enter');
await expect(palette).not.toBeVisible();
await expect(page.locator('[data-command-palette-status="success"]')).toContainText('Sync Now');
await expect(page.locator('[data-command-palette-status="success"]')).toContainText('handled');
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+K' : 'Control+K');
palette = page.locator('.command-palette');
await palette.locator('[data-command-palette-input]').fill('sync settings');
await expect(palette.locator('[data-command-id="verstak.shell.open-sync-settings"]')).toBeVisible();
await page.keyboard.press('Enter');
await expect(page.locator('.plugin-manager')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.modal[aria-label="Plugin Settings"]')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.modal-header h3')).toContainText('Sync');
});
});
+4 -4
View File
@@ -1,5 +1,5 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
import { waitForAppReady, setupConsoleCollector, resetMockState, openPluginManager } from './helpers.js';
test.describe('F: Default Editor Plugin', () => {
let consoleCollector;
@@ -134,14 +134,14 @@ test.describe('F: Default Editor Plugin', () => {
});
test('default-editor plugin is listed as loaded in plugin manager', async ({ page }) => {
await page.locator('.sidebar .nav-item').filter({ hasText: 'Plugin Manager' }).click();
await openPluginManager(page);
const card = page.locator('.plugin-card').filter({ hasText: 'verstak.default-editor' });
await expect(card).toBeVisible({ timeout: 10000 });
await expect(card.locator('.status-badge')).toHaveText('loaded');
});
test('disable default-editor plugin removes its providers', async ({ page }) => {
await page.locator('.sidebar .nav-item').filter({ hasText: 'Plugin Manager' }).click();
await openPluginManager(page);
const card = page.locator('.plugin-card').filter({ hasText: 'verstak.default-editor' });
await card.locator('button.btn-disable').click();
await expect(card.locator('button.btn-enable')).toBeVisible({ timeout: 10000 });
@@ -161,7 +161,7 @@ test.describe('F: Default Editor Plugin', () => {
});
test('default-editor plugin card shows openProviders contribution count', async ({ page }) => {
await page.locator('.sidebar .nav-item').filter({ hasText: 'Plugin Manager' }).click();
await openPluginManager(page);
const card = page.locator('.plugin-card').filter({ hasText: 'verstak.default-editor' });
await expect(card).toBeVisible({ timeout: 10000 });
await expect(card.locator('.meta-row').filter({ hasText: 'Contributions:' })).toContainText('3 openProviders');
+262 -13
View File
@@ -1,5 +1,10 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
import { waitForAppReady, setupConsoleCollector, resetMockState, openPluginManager } from './helpers.js';
async function openFilesTool(page) {
await page.getByRole('tab', { name: 'Files' }).click();
await expect(page.locator('.files-root')).toBeVisible({ timeout: 10000 });
}
test.describe('G: Files Plugin', () => {
let consoleCollector;
@@ -16,7 +21,7 @@ test.describe('G: Files Plugin', () => {
});
test('files plugin appears in Plugin Manager as loaded', async ({ page }) => {
await page.locator('.sidebar .nav-item').filter({ hasText: 'Plugin Manager' }).click();
await openPluginManager(page);
const card = page.locator('.plugin-card').filter({ hasText: 'verstak.files' });
await expect(card).toBeVisible({ timeout: 10000 });
await expect(card.locator('.status-badge')).toHaveText('loaded');
@@ -29,6 +34,7 @@ test.describe('G: Files Plugin', () => {
test('workspace Files view is scoped to selected workspace folder', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await openFilesTool(page);
await expect(page.locator('.workspace-host')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.files-item-name').filter({ hasText: 'project-only.txt' })).toBeVisible({ timeout: 10000 });
@@ -42,6 +48,7 @@ test.describe('G: Files Plugin', () => {
test('files explorer supports create navigate rename filter sort open and trash', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await openFilesTool(page);
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
await page.locator('[data-files-action="new-folder"]').click();
@@ -80,34 +87,211 @@ test.describe('G: Files Plugin', () => {
await expect(page.locator('.files-breadcrumb')).toContainText('Daily', { timeout: 10000 });
await expect(page.locator('[data-file-name="Journal.md"]')).toBeVisible({ timeout: 10000 });
await page.locator('[data-file-name="Journal.md"]').click();
page.once('dialog', (dialog) => dialog.accept());
await page.locator('[data-files-action="trash"]').click();
await expect(page.locator('.files-modal-overlay')).toBeVisible();
await expect(page.locator('.files-modal-title')).toContainText('Journal.md');
await page.locator('.files-modal-btn.confirm').click();
await expect(page.locator('[data-file-name="Journal.md"]')).toHaveCount(0);
await page.locator('[data-files-action="up"]').click();
await expect(page.locator('.files-breadcrumb')).not.toContainText('Daily');
});
test('files explorer uses icon controls and no row New Here action', async ({ page }) => {
test('files explorer shows inline rename validation errors', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await openFilesTool(page);
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
for (const action of ['back', 'forward', 'up', 'refresh', 'new-folder', 'new-markdown', 'new-text', 'open', 'rename', 'trash', 'cut', 'copy', 'paste']) {
const button = page.locator(`[data-files-action="${action}"]`);
await expect(button).toHaveAttribute('title', /.+/);
await expect(button.locator('svg')).toBeVisible();
await expect(button).not.toHaveText(/\S/);
await page.locator('[data-file-name="project-only.txt"]').click();
await page.locator('[data-files-action="rename"]').click();
await page.locator('[data-files-rename-input]').fill('bad/name.txt');
await page.locator('[data-files-rename-confirm]').click();
await expect(page.locator('[data-files-rename-error]')).toBeVisible();
await expect(page.locator('[data-files-rename-error]')).toContainText('Invalid characters');
await expect(page.locator('[data-files-rename-input]')).toBeVisible();
await expect(page.locator('[data-file-name="project-only.txt"]')).toBeVisible();
await expect(page.locator('[data-file-name="bad"]')).toHaveCount(0);
});
test('files explorer shows inline rename conflict errors', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await openFilesTool(page);
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
await page.locator('[data-file-name="project-only.txt"]').click();
await page.locator('[data-files-action="rename"]').click();
await page.locator('[data-files-rename-input]').fill('Notes');
await page.locator('[data-files-rename-confirm]').click();
await expect(page.locator('[data-files-rename-error]')).toBeVisible();
await expect(page.locator('[data-files-rename-error]')).toContainText('already exists');
await expect(page.locator('[data-files-rename-input]')).toBeVisible();
await expect(page.locator('[data-files-rename-input]')).toHaveValue('Notes');
await expect(page.locator('[data-file-name="project-only.txt"]')).toBeVisible();
await expect(page.locator('[data-file-name="Notes"]')).toBeVisible();
});
test('files explorer shows inline create validation errors', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await openFilesTool(page);
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
await page.locator('[data-files-action="new-folder"]').click();
await page.locator('[data-files-create-input]').fill('bad/name');
await page.locator('[data-files-create-confirm]').click();
await expect(page.locator('[data-files-create-error]')).toBeVisible();
await expect(page.locator('[data-files-create-error]')).toContainText('Invalid characters');
await expect(page.locator('[data-files-create-input]')).toBeVisible();
await expect(page.locator('[data-file-name="bad"]')).toHaveCount(0);
});
test('files explorer uses rich file icons by file type', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await openFilesTool(page);
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
async function createFile(action, name) {
await page.locator(`[data-files-action="${action}"]`).click();
await page.locator('[data-files-create-input]').fill(name);
await page.locator('[data-files-create-confirm]').click();
await expect(page.locator(`[data-file-name="${name}"]`)).toBeVisible();
}
await createFile('new-markdown', 'IconNote.md');
await createFile('new-text', 'Photo.png');
await createFile('new-text', 'Manual.pdf');
await createFile('new-text', 'Script.js');
const iconFor = (name) => page.locator(`[data-file-name="${name}"] .files-item-icon`);
await expect(iconFor('Notes')).toHaveAttribute('data-file-icon', 'folder');
await expect(iconFor('project-only.txt')).toHaveAttribute('data-file-icon', 'text');
await expect(iconFor('IconNote.md')).toHaveAttribute('data-file-icon', 'markdown');
await expect(iconFor('Photo.png')).toHaveAttribute('data-file-icon', 'image');
await expect(iconFor('Manual.pdf')).toHaveAttribute('data-file-icon', 'pdf');
await expect(iconFor('Script.js')).toHaveAttribute('data-file-icon', 'code');
await expect(iconFor('Manual.pdf')).toHaveAttribute('aria-label', 'PDF file');
await expect(iconFor('Photo.png')).toHaveAttribute('title', 'Image file');
});
test('files explorer offers create actions in empty folders', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await openFilesTool(page);
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
await page.locator('[data-files-action="new-folder"]').click();
await page.locator('[data-files-create-input]').fill('EmptyQuickActions');
await page.locator('[data-files-create-confirm]').click();
await page.locator('[data-file-name="EmptyQuickActions"]').dblclick();
const emptyState = page.locator('.files-empty');
await expect(emptyState).toContainText('Empty folder');
await expect(emptyState.locator('[data-files-empty-action="new-folder"]')).toBeVisible();
await expect(emptyState.locator('[data-files-empty-action="new-markdown"]')).toBeVisible();
await expect(emptyState.locator('[data-files-empty-action="new-text"]')).toBeVisible();
await emptyState.locator('[data-files-empty-action="new-markdown"]').click();
await page.locator('[data-files-create-input]').fill('FromEmpty.md');
await page.locator('[data-files-create-confirm]').click();
await expect(page.locator('[data-file-name="FromEmpty.md"]')).toBeVisible();
await expect(page.locator('.files-empty')).toHaveCount(0);
});
test('files explorer shows no-match feedback while filtering', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await openFilesTool(page);
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
await page.locator('[data-files-filter]').fill('zzzz-no-matches');
const emptyState = page.locator('.files-empty');
await expect(emptyState).toBeVisible();
await expect(emptyState).toContainText('No matches');
await expect(emptyState.locator('[data-files-empty-action="clear-filter"]')).toBeVisible();
await expect(page.locator('[data-file-name="project-only.txt"]')).toHaveCount(0);
await emptyState.locator('[data-files-empty-action="clear-filter"]').click();
await expect(page.locator('[data-files-filter]')).toHaveValue('');
await expect(page.locator('.files-empty')).toHaveCount(0);
await expect(page.locator('[data-file-name="project-only.txt"]')).toBeVisible();
});
test('files explorer keeps removed items out of live files without Trash metadata', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await openFilesTool(page);
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
await page.locator('[data-files-action="new-text"]').click();
await page.locator('[data-files-create-input]').fill('RestoreMe.txt');
await page.locator('[data-files-create-confirm]').click();
await expect(page.locator('[data-file-name="RestoreMe.txt"]')).toBeVisible();
await page.locator('[data-file-name="RestoreMe.txt"]').click();
await page.locator('[data-files-action="trash"]').click();
await page.locator('.files-modal-btn.confirm').click();
await expect(page.locator('[data-file-name="RestoreMe.txt"]')).toHaveCount(0);
await expect(page.locator('[data-files-action="trash-view"]')).toHaveCount(0);
await expect(page.locator('[data-files-restore-trash]')).toHaveCount(0);
});
test('files explorer uses labeled controls and no row New Here action', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await openFilesTool(page);
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
const actionIcons = {
back: 'back',
forward: 'forward',
up: 'up',
refresh: 'refresh',
'new-folder': 'folderAdd',
'new-markdown': 'markdownAdd',
'new-text': 'textAdd',
open: 'open',
rename: 'rename',
trash: 'trash',
cut: 'cut',
copy: 'copy',
paste: 'paste',
};
const pathData = [];
for (const [action, icon] of Object.entries(actionIcons)) {
const button = page.locator(`[data-files-action="${action}"]`);
await expect(button).toHaveAttribute('title', /.+/);
await expect(button).toHaveAttribute('aria-label', /.+/);
await expect(button).toHaveAttribute('data-files-icon', icon);
await expect(button.locator('svg')).toBeVisible();
await expect(button).toHaveText('');
pathData.push(await button.locator('svg path').getAttribute('d'));
}
expect(new Set(pathData).size).toBe(pathData.length);
await expect(page.locator('.files-row-btn').filter({ hasText: 'New here' })).toHaveCount(0);
const firstRowButton = page.locator('[data-file-name="Notes"] .files-row-btn').first();
await expect(firstRowButton).toBeVisible();
await expect(firstRowButton).not.toHaveText(/\S/);
expect(await firstRowButton.evaluate((node) => node.innerHTML)).toContain('<svg');
const rowIcons = {
'row-open': 'open',
'row-rename': 'rename',
'row-trash': 'trash',
};
const rowPathData = [];
for (const [action, icon] of Object.entries(rowIcons)) {
const rowButton = page.locator(`[data-file-name="Notes"] [data-files-action="${action}"]`);
await expect(rowButton).toBeVisible();
await expect(rowButton).toHaveAttribute('title', /.+/);
await expect(rowButton).toHaveAttribute('aria-label', /.+/);
await expect(rowButton).toHaveAttribute('data-files-icon', icon);
await expect(rowButton).toHaveText('');
expect(await rowButton.evaluate((node) => node.innerHTML)).toContain('<svg');
rowPathData.push(await rowButton.locator('svg path').getAttribute('d'));
}
expect(new Set(rowPathData).size).toBe(rowPathData.length);
});
test('files explorer supports empty-space context paste after cutting a folder', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await openFilesTool(page);
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
await page.locator('[data-files-action="new-folder"]').click();
@@ -130,8 +314,48 @@ test.describe('G: Files Plugin', () => {
await expect(page.locator('[data-file-name="CutMe"]')).toHaveCount(0);
});
test('files explorer navigates by clicking breadcrumb segments', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await openFilesTool(page);
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
await page.locator('[data-files-action="new-folder"]').click();
await page.locator('[data-files-create-input]').fill('BreadcrumbParent');
await page.locator('[data-files-create-confirm]').click();
await page.locator('[data-file-name="BreadcrumbParent"]').dblclick();
await expect(page.locator('.files-breadcrumb')).toContainText('BreadcrumbParent');
await page.locator('[data-files-action="new-folder"]').click();
await page.locator('[data-files-create-input]').fill('BreadcrumbChild');
await page.locator('[data-files-create-confirm]').click();
await page.locator('[data-file-name="BreadcrumbChild"]').dblclick();
await expect(page.locator('.files-breadcrumb')).toContainText('BreadcrumbChild');
await page.locator('.files-breadcrumb-item').filter({ hasText: 'BreadcrumbParent' }).click();
await expect(page.locator('.files-breadcrumb')).toContainText('BreadcrumbParent');
await expect(page.locator('.files-breadcrumb')).not.toContainText('BreadcrumbChild');
await expect(page.locator('[data-file-name="BreadcrumbChild"]')).toBeVisible();
});
test('files explorer duplicates a file from the context menu', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await openFilesTool(page);
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
await page.locator('[data-file-name="project-only.txt"]').click({ button: 'right' });
await page.locator('[data-files-menu-action="duplicate"]').click();
await expect(page.locator('[data-file-name="project-only.txt"]')).toBeVisible();
await expect(page.locator('[data-file-name="project-only (copy).txt"]')).toBeVisible();
await page.locator('[data-file-name="project-only.txt"]').click({ button: 'right' });
await page.locator('[data-files-menu-action="duplicate"]').click();
await expect(page.locator('[data-file-name="project-only (copy 2).txt"]')).toBeVisible();
});
test('files explorer supports multiselect and internal drag/drop move', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await openFilesTool(page);
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
await page.locator('[data-files-action="new-folder"]').click();
@@ -164,8 +388,32 @@ test.describe('G: Files Plugin', () => {
await expect(page.locator('[data-file-name="DragTwo.txt"]')).toBeVisible();
});
test('files explorer supports keyboard row selection and clearing selection', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await openFilesTool(page);
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
const rows = page.locator('.files-item');
await expect(rows.nth(1)).toBeVisible();
await rows.nth(0).click();
await expect(rows.nth(0)).toHaveClass(/selected/);
await page.locator('.files-root').focus();
await page.keyboard.press('ArrowDown');
await expect(rows.nth(1)).toHaveClass(/selected/);
await expect(rows.nth(0)).not.toHaveClass(/selected/);
await page.keyboard.press('ArrowUp');
await expect(rows.nth(0)).toHaveClass(/selected/);
await page.keyboard.press('Escape');
await expect(page.locator('.files-item.selected')).toHaveCount(0);
});
test('files history persists in workspace context and handles mouse back forward buttons', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await openFilesTool(page);
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
await page.locator('[data-file-name="Notes"]').dblclick();
@@ -191,6 +439,7 @@ test.describe('G: Files Plugin', () => {
test('workbench close and mouse back return from editor to the previous Files folder', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await openFilesTool(page);
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
await page.locator('[data-file-name="Notes"]').dblclick();
+7
View File
@@ -11,6 +11,13 @@ export async function waitForAppReady(page) {
await page.waitForTimeout(1000);
}
/** Open the secondary Plugin Manager route from the status-bar settings menu. */
export async function openPluginManager(page) {
await page.locator('[data-settings-menu-button]').click();
await page.locator('[data-settings-action="plugin-manager"]').click();
await page.waitForSelector('.plugin-manager', { state: 'visible', timeout: 10000 });
}
/** Collect all console errors since last reset */
export function setupConsoleCollector(page) {
const errors = [];
+48
View File
@@ -0,0 +1,48 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
test.describe('Desktop localization', () => {
let consoleCollector;
test.beforeEach(async ({ page }) => {
consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
});
test.afterEach(async () => {
consoleCollector.assertNoErrors();
});
test('switches shell language live and persists the choice', async ({ page }) => {
await page.locator('[data-settings-menu-button]').click();
await expect(page.locator('[data-settings-language="system"]')).toHaveAttribute('aria-checked', 'true');
await page.locator('[data-settings-language="ru"]').click();
await expect(page.locator('[data-settings-menu-button]')).toHaveAttribute('title', 'Настройки');
await expect(page.locator('.vault-status')).toContainText('Хранилище: открыто');
await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Тест платформы' })).toBeVisible();
await expect(page.locator('[data-status-item-id="verstak.platform-test.status"]')).toContainText('Все тесты пройдены');
await page.locator('[data-settings-menu-button]').click();
await expect(page.locator('[data-settings-action="plugin-manager"]')).toContainText('Менеджер плагинов');
await expect(page.locator('[data-settings-language="ru"]')).toHaveAttribute('aria-checked', 'true');
await page.reload();
await waitForAppReady(page);
await expect(page.locator('[data-settings-menu-button]')).toHaveAttribute('title', 'Настройки');
await page.locator('[data-settings-menu-button]').click();
await expect(page.locator('[data-settings-language="ru"]')).toHaveAttribute('aria-checked', 'true');
});
test('switches back to English without reloading', async ({ page }) => {
await page.locator('[data-settings-menu-button]').click();
await page.locator('[data-settings-language="ru"]').click();
await page.locator('[data-settings-menu-button]').click();
await page.locator('[data-settings-language="en"]').click();
await expect(page.locator('[data-settings-menu-button]')).toHaveAttribute('title', 'Settings');
await expect(page.locator('.vault-status')).toContainText('Vault: open');
});
});
+80 -5
View File
@@ -1,5 +1,5 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
import { waitForAppReady, setupConsoleCollector, resetMockState, openPluginManager } from './helpers.js';
test.describe('D: Plugin API bridge', () => {
let consoleCollector;
@@ -26,7 +26,7 @@ test.describe('D: Plugin API 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 openPluginManager(page);
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();
@@ -95,6 +95,81 @@ test.describe('D: Plugin API bridge', () => {
await expect(page.locator('[data-workbench-status="no-provider"]')).toContainText('No viewer/editor available');
});
test('sync plugin API routes through mocked Wails bridge', async ({ page }) => {
const result = await page.evaluate(async () => {
const api = window.createPluginAPI('verstak.sync');
const initial = await api.sync.status();
await api.sync.testConnection('https://sync.example.test', 'alice', 'secret');
await api.sync.configure('https://sync.example.test', 'alice', 'secret');
await api.sync.setInterval(15);
const configured = await api.sync.status();
const syncNow = await api.sync.now();
await api.sync.resetKey();
const reset = await api.sync.status();
await api.sync.disconnect();
const disconnected = await api.sync.status();
api.dispose();
return { initial, configured, syncNow, reset, disconnected };
});
expect(result.initial.statusLabel).toBe('disabled');
expect(result.configured.configured).toBe(true);
expect(result.configured.serverUrl).toBe('https://sync.example.test');
expect(result.configured.syncInterval).toBe(15);
expect(result.syncNow).toEqual({ pushed: 0, pulled: 0, serverSequence: 0 });
expect(result.reset.configured).toBe(false);
expect(result.reset.serverUrl).toBe('https://sync.example.test');
expect(result.reset.tokenStored).toBe(false);
expect(result.reset.statusLabel).toBe('disconnected');
expect(result.disconnected.configured).toBe(false);
expect(result.disconnected.statusLabel).toBe('disabled');
});
test('files API permanently deletes an existing trash entry through the bridge', async ({ page }) => {
const result = await page.evaluate(async () => {
const api = window.createPluginAPI('verstak.files');
await api.files.writeText('Project/bridge-trash.txt', 'remove me', { createIfMissing: true });
const trash = await api.files.trash('Project/bridge-trash.txt');
const before = await api.files.listTrash();
await api.files.deleteTrash(trash.trashId);
const after = await api.files.listTrash();
let readError = '';
try {
await api.files.readText('Project/bridge-trash.txt');
} catch (error) {
readError = String(error && error.message || error);
}
api.dispose();
return { before, after, readError };
});
expect(result.before).toHaveLength(1);
expect(result.after).toEqual([]);
expect(result.readError).toContain('not-found: Project/bridge-trash.txt');
});
test('backend plugin events are dispatched to subscribed frontend handlers', async ({ page }) => {
const result = await page.evaluate(async () => {
const api = window.createPluginAPI('verstak.platform-test');
let received = null;
const unsubscribe = await api.events.subscribe('browser.capture.page', (event) => {
received = event;
});
window.__VERSTAK_DISPATCH_BACKEND_EVENT__({
name: 'browser.capture.page',
timestamp: '2026-06-27T00:00:00.000Z',
payload: { url: 'https://example.com/article' }
});
unsubscribe();
api.dispose();
return received;
});
expect(result.name).toBe('browser.capture.page');
expect(result.payload.url).toBe('https://example.com/article');
expect(result.timestamp).toBe('2026-06-27T00:00:00.000Z');
});
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();
@@ -103,7 +178,7 @@ test.describe('D: Plugin API bridge', () => {
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 openPluginManager(page);
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);
@@ -113,7 +188,7 @@ test.describe('D: Plugin API bridge', () => {
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();
await openPluginManager(page);
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 });
@@ -123,7 +198,7 @@ test.describe('D: Plugin API bridge', () => {
});
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();
await openPluginManager(page);
const pluginCard = page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' });
await pluginCard.locator('button.btn-settings').click();
@@ -12,7 +12,7 @@
* 8. Verify plugin sidebar item returns
*/
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
import { waitForAppReady, setupConsoleCollector, resetMockState, openPluginManager } from './helpers.js';
test.describe('A: Plugin Manager Disable/Enable refresh', () => {
let consoleCollector;
@@ -22,6 +22,7 @@ test.describe('A: Plugin Manager Disable/Enable refresh', () => {
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
await openPluginManager(page);
});
test.afterEach(async () => {
+30 -6
View File
@@ -1,5 +1,5 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
import { waitForAppReady, setupConsoleCollector, resetMockState, openPluginManager } from './helpers.js';
test.describe('E: Plugin Manager layout', () => {
let consoleCollector;
@@ -16,9 +16,11 @@ test.describe('E: Plugin Manager layout', () => {
});
test('plugin list scrolls through the global main scroll surface and stays responsive', async ({ page }) => {
await openPluginManager(page);
const basePluginCount = await page.locator('.plugin-card').count();
await page.evaluate(() => window.__wailsMock.addSyntheticPlugins(18));
await page.locator('button.reload-btn').click();
await expect(page.locator('.plugin-card')).toHaveCount(21, { timeout: 10000 });
await expect(page.locator('.plugin-card')).toHaveCount(basePluginCount + 18, { timeout: 10000 });
const manager = page.locator('.plugin-manager');
const scrollSurface = page.locator('.content.scroll-surface');
@@ -71,6 +73,21 @@ test.describe('E: Plugin Manager layout', () => {
expect(buttonStyle.borderRadius).toBe('6px');
});
test('plugin manager summarizes plugin health and elevated permissions before the list', async ({ page }) => {
await openPluginManager(page);
const health = page.locator('[data-plugin-manager-summary="health"]');
await expect(health).toBeVisible();
await expect(health.locator('[data-plugin-status-summary="loaded"]')).toContainText('12');
await expect(health.locator('[data-plugin-status-summary="failed"]')).toContainText('0');
await expect(health.locator('[data-plugin-status-summary="disabled"]')).toContainText('0');
const risk = page.locator('[data-plugin-manager-summary="risk"]');
await expect(risk).toBeVisible();
await expect(risk.locator('[data-plugin-risk-summary="elevated-permissions"]')).toContainText('6');
await expect(risk).toContainText('elevated permissions');
});
test('workspace selection keeps exactly one active node', async ({ page }) => {
const selected = page.locator('.wt-node.selected .wt-label');
await expect(selected).toHaveCount(1);
@@ -82,22 +99,29 @@ test.describe('E: Plugin Manager layout', () => {
await expect(selected).toHaveText('Test');
});
test('workspace tools render as tabs with Files as one tab', async ({ page }) => {
test('workspace tools render Overview first with Files as one tab', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
const tabs = page.locator('.workspace-tabs');
await expect(tabs).toBeVisible({ timeout: 10000 });
const overviewTab = tabs.locator('[role="tab"]').filter({ hasText: 'Overview' });
const filesTab = tabs.locator('[role="tab"]').filter({ hasText: 'Files' });
await expect(overviewTab).toBeVisible();
await expect(overviewTab).toHaveAttribute('aria-selected', 'true');
await expect(filesTab).toBeVisible();
await expect(filesTab).toHaveAttribute('aria-selected', 'true');
await expect(filesTab).toHaveAttribute('aria-selected', 'false');
await expect(page.locator('.workspace-tool')).toHaveCount(0);
await expect(page.locator('[data-overview-root]')).toBeVisible();
await filesTab.click();
await expect(page.locator('.files-root')).toBeVisible();
});
test('workspace sidebar creates renames and trashes top-level workspaces', async ({ page }) => {
await page.locator('button[title="New workspace"]').click();
await page.locator('.wt-create input').fill('ClientA');
await page.locator('.wt-btn-primary', { hasText: 'Create' }).click();
const modal = page.locator('[data-workspace-create-modal]');
await modal.locator('[data-workspace-name]').fill('ClientA');
await modal.getByRole('button', { name: 'Create workspace' }).click();
await expect(page.locator('.wt-label').filter({ hasText: 'ClientA' })).toBeVisible();
+2 -1
View File
@@ -7,7 +7,7 @@
* 3. Verify UI reflects the updated state
*/
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState, setPluginStatus } from './helpers.js';
import { waitForAppReady, setupConsoleCollector, resetMockState, setPluginStatus, openPluginManager } from './helpers.js';
test.describe('C: Reload updates UI state', () => {
let consoleCollector;
@@ -17,6 +17,7 @@ test.describe('C: Reload updates UI state', () => {
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
await openPluginManager(page);
});
test.afterEach(async () => {
+12
View File
@@ -28,10 +28,22 @@ test.describe('B: Sidebar opens plugin view by item.view', () => {
});
test('Sidebar item exists with correct label', async ({ page }) => {
await expect(page.locator('.sidebar .nav-item').filter({ hasText: 'Plugin Manager' })).not.toBeVisible();
await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Activity' })).toBeVisible();
await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser Inbox' })).toBeVisible();
const sidebarItem = page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' });
await expect(sidebarItem).toBeVisible();
});
test('Global Activity and Browser Inbox sidebar items open plugin views', async ({ page }) => {
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Activity' }).click();
await expect(page.locator('.view-container .view-header h2')).toHaveText('Activity', { timeout: 10000 });
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser Inbox' }).click();
await expect(page.locator('.view-container .view-header h2')).toHaveText('Browser Inbox', { timeout: 10000 });
});
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();
+49
View File
@@ -0,0 +1,49 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState, openPluginManager } from './helpers.js';
test.describe('Status Bar host', () => {
let consoleCollector;
test.beforeEach(async ({ page }) => {
consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
});
test.afterEach(async () => {
consoleCollector.assertNoErrors();
});
test('renders enabled plugin statusBarItems', async ({ page }) => {
const statusBar = page.locator('.status-bar');
await expect(statusBar).toBeVisible();
await expect(statusBar.locator('.vault-status')).toContainText('Vault: open');
await expect(statusBar.locator('[data-status-item-id="verstak.platform-test.status"]')).toContainText('All Tests Pass');
});
test('opens settings menu with plugin manager and plugin settings', async ({ page }) => {
await page.locator('[data-settings-menu-button]').click();
await expect(page.locator('[data-settings-action="plugin-manager"]')).toBeVisible();
await expect(page.locator('[data-settings-panel-id="verstak.sync.settings"]')).toBeVisible();
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' }).click();
await expect(page.locator('.view-container .view-header h2')).toHaveText('Platform Diagnostics');
await page.locator('[data-settings-menu-button]').click();
await page.locator('[data-settings-action="plugin-manager"]').click();
await expect(page.locator('.plugin-manager')).toBeVisible();
});
test('refreshes statusBarItems after disabling plugin', async ({ page }) => {
const pluginCard = page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' });
await expect(page.locator('[data-status-item-id="verstak.platform-test.status"]')).toBeVisible();
await openPluginManager(page);
await pluginCard.locator('button.btn-disable').click();
await expect(pluginCard.locator('button.btn-enable')).toBeVisible({ timeout: 10000 });
await expect(page.locator('[data-status-item-id="verstak.platform-test.status"]')).not.toBeVisible();
});
});
+148
View File
@@ -0,0 +1,148 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
test.describe('Todo plugin workflow', () => {
let consoleCollector;
test.beforeEach(async ({ page }) => {
consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
});
test.afterEach(async () => {
consoleCollector.assertNoErrors();
});
test('workspace Todo supports CRUD, reminders, and manual Journal conversion', async ({ page }) => {
await page.getByRole('tab', { name: 'Todos' }).click();
const todos = page.locator('.todo-root');
await expect(todos).toBeVisible({ timeout: 10000 });
await expect(todos).toContainText('Todos · Project');
await todos.locator('[data-todo-action="add"]').click();
await todos.locator('[data-todo-input="title"]').fill('Prepare project review');
await todos.locator('[data-todo-input="description"]').fill('Collect factual review notes.');
await todos.locator('[data-todo-input="priority"]').selectOption('high');
await todos.locator('[data-todo-input="dueAt"]').fill('2000-01-01');
await todos.locator('[data-todo-input="reminderAt"]').fill('2000-01-01T09:00');
await todos.locator('[data-todo-action="save"]').click();
await expect(todos).toContainText('Overdue');
await expect(todos).toContainText('Reminder due');
await expect.poll(async () => page.evaluate(async () => {
const result = await window.go.api.App.ReadPluginSettings('verstak.todo');
const settings = Array.isArray(result) ? result[0] : result;
const todo = settings['todos:global'].find((item) => item.title === 'Prepare project review');
return todo && [todo.workspaceRootPath, todo.priority, todo.dueAt, todo.reminderAt].join('|');
})).toBe('Project|high|2000-01-01|2000-01-01T09:00');
await todos.locator('[data-todo-action="edit"]').click();
await todos.locator('[data-todo-input="title"]').fill('Prepare project review updated');
await todos.locator('[data-todo-action="save"]').click();
await expect(todos).toContainText('Prepare project review updated');
await todos.locator('[data-todo-action="mark-done"]').click();
await expect(todos.locator('[data-todo-action="create-journal-entry"]')).toBeVisible();
await todos.locator('[data-todo-action="create-journal-entry"]').click();
const journal = page.locator('.journal-root');
await expect(page.getByRole('tab', { name: 'Journal' })).toHaveAttribute('aria-selected', 'true');
await expect(journal).toBeVisible({ timeout: 10000 });
await expect(journal).toContainText('Create journal entry from completed todo');
await expect(journal.locator('[data-journal-input="title"]')).toHaveValue('Prepare project review updated');
await expect(journal.locator('[data-journal-input="summary"]')).toHaveValue('Collect factual review notes.');
await expect(journal.locator('[data-journal-input="minutes"]')).toHaveValue('0');
await journal.locator('[data-journal-input="title"]').fill('Prepare project review handoff');
await journal.locator('[data-journal-input="summary"]').fill('Reviewed factual project notes before handoff.');
await journal.locator('[data-journal-action="save-entry"]').click();
await expect.poll(async () => page.evaluate(async () => {
const todoResult = await window.go.api.App.ReadPluginSettings('verstak.todo');
const todoSettings = Array.isArray(todoResult) ? todoResult[0] : todoResult;
const todo = todoSettings['todos:global'].find((item) => item.title === 'Prepare project review updated');
const journalResult = await window.go.api.App.ReadPluginSettings('verstak.journal');
const journalSettings = Array.isArray(journalResult) ? journalResult[0] : journalResult;
const entry = journalSettings['worklog:workspace:Project'].find((item) => item.sourceTodoId === todo.id);
return entry && [entry.title, entry.summary, entry.minutes].join('|');
})).toBe('Prepare project review handoff|Reviewed factual project notes before handoff.|0');
await page.getByRole('tab', { name: 'Todos' }).click();
await page.locator('.todo-root [data-todo-action="delete"]').click();
await expect.poll(async () => page.evaluate(async () => {
const result = await window.go.api.App.ReadPluginSettings('verstak.todo');
const settings = Array.isArray(result) ? result[0] : result;
return settings['todos:global'].some((item) => item.title === 'Prepare project review updated');
})).toBe(false);
});
test('global Todos filter by workspace and Overview only exposes current workspace attention', async ({ page }) => {
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.todo', {
'todos:global': [
{
id: 'todo-project-open',
title: 'Project deadline',
workspaceRootPath: 'Project',
status: 'open',
priority: 'high',
dueAt: '2000-01-01',
reminderAt: '2000-01-01T09:00',
createdAt: '2026-06-30T08:00:00.000Z',
updatedAt: '2026-06-30T08:00:00.000Z',
},
{
id: 'todo-project-done',
title: 'Project completed item',
workspaceRootPath: 'Project',
status: 'done',
priority: 'normal',
createdAt: '2026-06-30T08:01:00.000Z',
updatedAt: '2026-06-30T08:01:00.000Z',
completedAt: '2026-06-30T08:02:00.000Z',
},
{
id: 'todo-test-open',
title: 'Test workspace item',
workspaceRootPath: 'Test',
status: 'open',
priority: 'normal',
dueAt: '2000-01-01',
createdAt: '2026-06-30T08:03:00.000Z',
updatedAt: '2026-06-30T08:03:00.000Z',
},
],
});
});
const overview = page.locator('[data-overview-root]');
await overview.locator('[data-overview-action="refresh"]').click();
const attention = overview.locator('[data-overview-section="attention"]');
await expect(attention).toContainText('Project deadline');
await expect(attention).not.toContainText('Test workspace item');
await attention.locator('.overview-attention-row', { hasText: 'Project deadline' }).getByRole('button', { name: 'Open Todos' }).click();
await expect(page.getByRole('tab', { name: 'Todos' })).toHaveAttribute('aria-selected', 'true');
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Todos' }).click();
const globalTodos = page.locator('.todo-root');
await expect(globalTodos).toContainText('Project deadline');
await expect(globalTodos).toContainText('Test workspace item');
await globalTodos.locator('[data-todo-filter="workspace"]').selectOption('Project');
await expect(globalTodos).toContainText('Project deadline');
await expect(globalTodos).toContainText('Project completed item');
await expect(globalTodos).not.toContainText('Test workspace item');
await globalTodos.locator('[data-todo-filter="status"]').selectOption('done');
await expect(globalTodos).toContainText('Project completed item');
await expect(globalTodos).not.toContainText('Project deadline');
await page.locator('.wt-label').filter({ hasText: 'Test' }).click();
await page.getByRole('tab', { name: 'Todos' }).click();
const workspaceTodos = page.locator('.todo-root');
await expect(workspaceTodos).toContainText('Test workspace item');
await expect(workspaceTodos).not.toContainText('Project deadline');
});
});
+121
View File
@@ -0,0 +1,121 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
async function openTrash(page) {
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Trash' }).click();
await expect(page.locator('.trash-root')).toBeVisible({ timeout: 10000 });
}
async function createTrashEntry(page, path, content, deletedAt) {
return page.evaluate(async ({ path, content, deletedAt }) => {
const api = window.createPluginAPI('verstak.files');
await api.files.writeText(path, content, { createIfMissing: true });
const entry = await api.files.trash(path);
window.__wailsMock.setTrashDeletedAt(entry.trashId, deletedAt);
api.dispose();
return entry;
}, { path, content, deletedAt });
}
test.describe('L: Global Trash Plugin', () => {
let consoleCollector;
test.beforeEach(async ({ page }) => {
consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
});
test.afterEach(async () => {
consoleCollector.assertNoErrors();
});
test('moves a Files item into global Trash without exposing Trash metadata in Files', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await page.getByRole('tab', { name: 'Files' }).click();
await expect(page.locator('.files-root')).toBeVisible({ timeout: 10000 });
await page.locator('[data-files-action="new-text"]').click();
await page.locator('[data-files-create-input]').fill('GlobalTrash.txt');
await page.locator('[data-files-create-confirm]').click();
await expect(page.locator('[data-file-name="GlobalTrash.txt"]')).toBeVisible();
await page.locator('[data-file-name="GlobalTrash.txt"]').click();
await page.locator('[data-files-action="trash"]').click();
await page.locator('.files-modal-btn.confirm').click();
await expect(page.locator('[data-file-name="GlobalTrash.txt"]')).toHaveCount(0);
await expect(page.locator('[data-files-action="trash-view"]')).toHaveCount(0);
await openTrash(page);
const row = page.locator('[data-trash-row]').filter({ hasText: 'GlobalTrash.txt' });
await expect(row).toBeVisible();
await expect(row).toContainText('Project');
await expect(row).toContainText('Project/GlobalTrash.txt');
});
test('filters and sorts global Trash and restores without overwriting a conflict', async ({ page }) => {
const older = await createTrashEntry(page, 'Project/Older.txt', 'older original', '2026-06-20T08:00:00.000Z');
const newest = await createTrashEntry(page, 'Test/Newest.txt', 'newest original', '2026-06-29T08:00:00.000Z');
const restore = await createTrashEntry(page, 'Project/Restore.txt', 'restore original', '2026-06-25T08:00:00.000Z');
const conflict = await page.evaluate(async () => {
const api = window.createPluginAPI('verstak.files');
await api.files.writeText('Project/Conflict.txt', 'trashed content', { createIfMissing: true });
const entry = await api.files.trash('Project/Conflict.txt');
await api.files.writeText('Project/Conflict.txt', 'existing content', { createIfMissing: true });
window.__wailsMock.setTrashDeletedAt(entry.trashId, '2026-06-24T08:00:00.000Z');
api.dispose();
return entry;
});
await openTrash(page);
await page.locator('[data-trash-filter-workspace]').selectOption('Project');
await expect(page.locator(`[data-trash-row="${older.trashId}"]`)).toBeVisible();
await expect(page.locator(`[data-trash-row="${newest.trashId}"]`)).toHaveCount(0);
await page.locator('[data-trash-filter-workspace]').selectOption('');
await page.locator('[data-trash-filter-search]').fill('Newest');
await expect(page.locator(`[data-trash-row="${newest.trashId}"]`)).toBeVisible();
await expect(page.locator(`[data-trash-row="${older.trashId}"]`)).toHaveCount(0);
await page.locator('[data-trash-filter-search]').fill('');
await page.locator('[data-trash-sort]').selectOption('date-asc');
await expect(page.locator('[data-trash-row]').first()).toHaveAttribute('data-trash-row', older.trashId);
await page.locator(`[data-trash-restore="${restore.trashId}"]`).click();
await expect(page.locator(`[data-trash-row="${restore.trashId}"]`)).toHaveCount(0);
await expect.poll(() => page.evaluate(async () => {
const api = window.createPluginAPI('verstak.files');
const content = await api.files.readText('Project/Restore.txt');
api.dispose();
return content;
})).toBe('restore original');
await page.locator(`[data-trash-restore="${conflict.trashId}"]`).click();
await expect(page.locator('[data-trash-status]')).toContainText('Restore blocked');
await expect(page.locator(`[data-trash-row="${conflict.trashId}"]`)).toBeVisible();
await expect.poll(() => page.evaluate(async () => {
const api = window.createPluginAPI('verstak.files');
const content = await api.files.readText('Project/Conflict.txt');
api.dispose();
return content;
})).toBe('existing content');
});
test('requires confirmation before permanently deleting a Trash item', async ({ page }) => {
const entry = await createTrashEntry(page, 'Test/Permanent.txt', 'remove forever', '2026-06-26T08:00:00.000Z');
await openTrash(page);
await page.locator(`[data-trash-delete="${entry.trashId}"]`).click();
await expect(page.locator(`[data-trash-confirm="${entry.trashId}"]`)).toBeVisible();
await expect(page.locator(`[data-trash-row="${entry.trashId}"]`)).toBeVisible();
await page.locator(`[data-trash-confirm-delete="${entry.trashId}"]`).click();
await expect(page.locator(`[data-trash-row="${entry.trashId}"]`)).toHaveCount(0);
await expect.poll(() => page.evaluate(async (trashId) => {
const api = window.createPluginAPI('verstak.files');
const entries = await api.files.listTrash();
api.dispose();
return entries.some((item) => item.trashId === trashId);
}, entry.trashId)).toBe(false);
});
});
+118
View File
@@ -0,0 +1,118 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, resetMockState, openPluginManager } from './helpers.js';
test.describe('UX follow-up fixes', () => {
test.beforeEach(async ({ page }) => {
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
});
test('global search stays available after opening tool sidebar views', async ({ page }) => {
const search = page.locator('[data-global-search-input]');
await expect(search).toBeVisible();
await expect(page.locator('.workspace-header [data-global-search-input]')).toBeVisible();
await page.locator('.sidebar .nav-item').filter({ hasText: 'Activity' }).click();
await expect(page.locator('.activity-root')).toBeVisible({ timeout: 10000 });
await expect(search).toBeVisible();
await expect(page.locator('.sidebar [data-global-search-input]')).toBeVisible();
await page.locator('.sidebar .nav-item').filter({ hasText: 'Browser Inbox' }).click();
await expect(page.locator('.browser-inbox-root')).toBeVisible({ timeout: 10000 });
await expect(search).toBeVisible();
});
test('global search types ahead across workspaces and file contents with keyboard layout fallback', async ({ page }) => {
const search = page.locator('[data-global-search-input]');
await search.fill('Зкщоусе');
await expect(page.locator('[data-global-search-results]')).toContainText('Project', { timeout: 10000 });
await search.fill('project file');
await expect(page.locator('[data-global-search-results]')).toContainText('project-only.txt', { timeout: 10000 });
});
test('global search folder results open the workspace Files context', async ({ page }) => {
const search = page.locator('[data-global-search-input]');
await search.fill('Project/Notes');
const folderResult = page.locator('[data-global-search-result-type="Folder"][data-global-search-result-path="Project/Notes"]');
await expect(folderResult).toBeVisible({ timeout: 10000 });
await folderResult.click();
await expect(page.locator('.workspace-title')).toHaveText('Project', { timeout: 10000 });
await expect(page.getByRole('tab', { name: 'Files' })).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.files-root')).toBeVisible();
await expect(page.locator('.files-breadcrumb')).toContainText('Notes');
await expect(page.locator('.files-item')).toContainText('Overview.md');
});
test('global search opens indexed browser inbox results', async ({ page }) => {
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.browser-inbox', {
'captures:workspace:Project': [{
captureId: 'capture-search-1',
capturedAt: '2026-06-30T08:00:00.000Z',
kind: 'page',
url: 'https://example.com/research',
title: 'Research Search Result',
domain: 'example.com',
text: 'Searchable captured browser text',
workspaceRootPath: 'Project',
browserName: 'Firefox',
}],
});
});
const search = page.locator('[data-global-search-input]');
await search.fill('Research Search Result');
const result = page.locator('[data-global-search-result-type="Browser Inbox"]').filter({ hasText: 'Research Search Result' });
await expect(result).toBeVisible({ timeout: 10000 });
await result.click();
await expect(page.locator('.browser-inbox-root')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.browser-inbox-detail-title')).toHaveText('Research Search Result');
});
test('workspace Search input keeps focus while typing', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await page.getByRole('tab', { name: 'Search' }).click();
const searchInput = page.locator('[data-search-input="query"]');
await expect(searchInput).toBeVisible({ timeout: 10000 });
await searchInput.click();
await page.keyboard.press('p');
await expect(searchInput).toBeFocused();
await page.keyboard.press('r');
await expect(searchInput).toBeFocused();
await expect(searchInput).toHaveValue('pr');
});
test('mobile workspace layout gives content full width below the sidebar', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.reload();
await waitForAppReady(page);
const workspaceBox = await page.locator('.workspace-host').boundingBox();
const sidebarBox = await page.locator('.sidebar').boundingBox();
expect(workspaceBox.width).toBeGreaterThan(340);
expect(workspaceBox.y).toBeGreaterThan(sidebarBox.y + sidebarBox.height - 1);
await expect(page.locator('.workspace-header [data-global-search-input]')).toBeVisible();
await expect(page.getByRole('tab', { name: 'Overview' })).toBeVisible();
const hasHorizontalOverflow = await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth);
expect(hasHorizontalOverflow).toBe(false);
});
test('plugin settings modal gives complex panels enough space', async ({ page }) => {
await openPluginManager(page);
await page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' }).getByRole('button', { name: 'Settings' }).click();
const modal = page.locator('.modal[aria-label="Plugin Settings"]');
await expect(modal).toBeVisible({ timeout: 10000 });
const box = await modal.boundingBox();
expect(box.width).toBeGreaterThanOrEqual(760);
expect(box.height).toBeGreaterThanOrEqual(560);
});
});
+105
View File
@@ -0,0 +1,105 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState, openPluginManager } from './helpers.js';
test.describe('UX P0 shell flow', () => {
let consoleCollector;
test.beforeEach(async ({ page }) => {
consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
});
test.afterEach(async () => {
consoleCollector.assertNoErrors();
});
test('starts in the first workspace instead of Plugin Manager', async ({ page }) => {
await expect(page.locator('.workspace-host')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.plugin-manager')).toHaveCount(0);
await expect(page.locator('.wt-node.selected .wt-label')).toHaveText('Project');
await expect(page.locator('.workspace-title')).toHaveText('Project');
});
test('workspace selection and main content stay in sync across plugin manager round trip', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Test' }).click();
await expect(page.locator('.workspace-title')).toHaveText('Test', { timeout: 10000 });
await expect(page.locator('.wt-node.selected .wt-label')).toHaveText('Test');
await expect(page.locator('.plugin-manager')).toHaveCount(0);
await openPluginManager(page);
await expect(page.locator('.plugin-manager')).toBeVisible();
await expect(page.locator('.wt-node.selected .wt-label')).toHaveCount(0);
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await expect(page.locator('.workspace-title')).toHaveText('Project', { timeout: 10000 });
await expect(page.locator('.plugin-manager')).toHaveCount(0);
});
test('status bar plugin contribution failures do not render large error panels', async ({ page }) => {
await expect(page.locator('.workspace-host')).toBeVisible({ timeout: 10000 });
await expect(page.getByText('Plugin View Error')).toHaveCount(0);
await expect(page.locator('.status-bar [data-status-item-id]')).toHaveCount(2);
});
test('Plugin Manager remains reachable from the settings menu', async ({ page }) => {
await openPluginManager(page);
await expect(page.locator('.plugin-manager')).toBeVisible();
await expect(page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' })).toBeVisible();
});
});
test.describe('UX quick wins', () => {
test('Files screen uses readable dates and understandable action controls', async ({ page }) => {
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await page.getByRole('tab', { name: 'Files' }).click();
const files = page.locator('.files-root');
await expect(files).toBeVisible({ timeout: 10000 });
await expect(files.getByText(/T\d{2}:\d{2}:\d{2}/)).toHaveCount(0);
const actions = [
['new-folder', 'New folder'],
['new-markdown', 'New markdown file'],
['new-text', 'New text file'],
];
for (const [action, label] of actions) {
const button = page.locator(`[data-files-action="${action}"]`).first();
await expect(button).toBeVisible();
await expect(button).toHaveAttribute('title', label);
await expect(button).toHaveAttribute('aria-label', label);
await expect(button).toHaveAttribute('data-files-icon', /.+/);
await expect(button.locator('svg')).toBeVisible();
}
});
test('Vault Selection is localized and has a clear primary action', async ({ browser }) => {
const page = await browser.newPage();
await page.addInitScript(() => {
window.go = { api: { App: {
GetAppSettings: async () => ({ currentVaultPath: '', recentVaults: ['/tmp/verstak-recent-vault'] }),
GetVaultStatus: async () => ({ status: 'closed', path: '', vaultId: '' }),
SelectDirectory: async () => '',
SelectVaultForOpen: async () => '',
CreateVault: async () => null,
OpenVault: async () => null,
SetCurrentVault: async () => '',
WriteFrontendLog: async () => {},
} } };
});
await page.goto('/');
await page.waitForSelector('.vault-selection', { timeout: 10000 });
await expect(page.getByText('Choose a vault to start working')).toBeVisible();
await expect(page.getByRole('button', { name: 'Create vault' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Open existing' })).toBeVisible();
await page.close();
});
});
+250
View File
@@ -0,0 +1,250 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
test.describe('UX Overview workspace flow', () => {
let consoleCollector;
test.beforeEach(async ({ page }) => {
consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
});
test.afterEach(async () => {
consoleCollector.assertNoErrors();
});
test('workspace opens with Overview before plugin tools', async ({ page }) => {
await expect(page.locator('.workspace-host')).toBeVisible({ timeout: 10000 });
const tabs = page.getByRole('tab');
await expect(tabs.nth(0)).toHaveText('Overview');
await expect(tabs.nth(1)).toHaveText('Notes');
await expect(page.getByRole('tab', { name: 'Overview' })).toHaveAttribute('aria-selected', 'true');
await expect(page.getByRole('tab', { name: 'Today' })).toHaveCount(0);
const overview = page.locator('[data-overview-root]');
await expect(overview).toBeVisible();
await expect(overview.locator('[data-overview-section="continue"]')).toContainText('Continue working');
await expect(overview.locator('[data-overview-section="recent"]')).toContainText('Recent changes');
await expect(overview.locator('[data-overview-section="attention"]')).toContainText('Needs attention');
await expect(overview.locator('[data-overview-section="quick-actions"]')).toHaveCount(0);
const summaryCards = overview.locator('button[data-overview-summary]');
await expect(summaryCards).toHaveCount(6);
await expect(overview.locator('[data-overview-summary="notes"]')).toContainText('1 total');
await expect(overview.locator('[data-overview-summary="notes"]')).toContainText('0 recent changes');
await expect(overview.locator('[data-overview-summary="captures"]')).toContainText('0 captures to review');
await expect(overview.locator('[data-overview-summary="activity"]')).toContainText('0 recorded events');
await expect(overview.locator('[data-overview-summary="journal"]')).toContainText('0 journal entries');
await expect(overview).toContainText('No clear resume point yet');
await expect(overview).toContainText('No meaningful changes for this filter yet');
});
test('Overview summary cards navigate to their corresponding workspace tools', async ({ page }) => {
const overview = page.locator('[data-overview-root]');
await overview.locator('[data-overview-summary="notes"]').click();
await expect(page.getByRole('tab', { name: 'Notes' })).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.notes-root')).toBeVisible({ timeout: 10000 });
await page.getByRole('tab', { name: 'Overview' }).click();
await overview.locator('[data-overview-summary="files"]').click();
await expect(page.getByRole('tab', { name: 'Files' })).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.files-root')).toBeVisible({ timeout: 10000 });
await page.getByRole('tab', { name: 'Overview' }).click();
await overview.locator('[data-overview-summary="captures"]').click();
await expect(page.getByRole('tab', { name: 'Browser Inbox' })).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.browser-inbox-root')).toBeVisible({ timeout: 10000 });
await page.getByRole('tab', { name: 'Overview' }).click();
await overview.locator('[data-overview-summary="activity"]').click();
await expect(page.getByRole('tab', { name: 'Activity' })).toHaveAttribute('aria-selected', 'true');
await page.getByRole('tab', { name: 'Overview' }).click();
await overview.locator('[data-overview-summary="journal"]').click();
await expect(page.getByRole('tab', { name: 'Journal' })).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.journal-root')).toBeVisible({ timeout: 10000 });
await page.getByRole('tab', { name: 'Overview' }).click();
await overview.locator('[data-overview-summary="attention"]').click();
await expect(page.getByRole('tab', { name: 'Browser Inbox' })).toHaveAttribute('aria-selected', 'true');
});
test('Overview keeps the Notes total factual when the Files plugin is unavailable', async ({ page }) => {
await page.evaluate(() => window.__wailsMock.setPluginStatus('verstak.files', 'disabled', false));
await page.locator('[data-overview-action="refresh"]').click();
await expect(page.locator('[data-overview-summary="notes"]')).toContainText('1 total');
});
test('Overview prioritizes resume work and filters meaningful recent changes', async ({ page }) => {
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.browser-inbox', {
'captures:global': [
{
captureId: 'overview-capture-1',
capturedAt: '2026-06-30T08:00:00.000Z',
kind: 'page',
url: 'https://example.com/research',
title: 'Research Report',
domain: 'example.com',
workspaceRootPath: 'Project',
},
{
captureId: 'overview-capture-2',
capturedAt: '2026-06-30T08:15:00.000Z',
kind: 'selection',
title: 'Quote to process',
domain: 'example.com',
workspaceRootPath: 'Project',
},
{
captureId: 'overview-client-capture',
capturedAt: '2026-06-30T08:30:00.000Z',
kind: 'page',
title: 'Client capture must stay global',
workspaceRootPath: 'ClientA',
},
{
captureId: 'overview-unassigned-capture',
capturedAt: '2026-06-30T08:35:00.000Z',
kind: 'page',
title: 'Unassigned capture must stay global',
},
],
});
await window.go.api.App.WritePluginSettings('verstak.activity', {
'events:workspace:Project': [
{
activityId: 'overview-selected-file',
occurredAt: '2026-06-30T08:50:00.000Z',
type: 'file.selected',
title: 'Selected file',
summary: 'Project/draft.md',
workspaceRootPath: 'Project',
},
{
activityId: 'overview-opened-file',
occurredAt: '2026-06-30T08:40:00.000Z',
type: 'file.opened',
title: 'draft.md',
summary: 'Project/draft.md',
workspaceRootPath: 'Project',
},
{
activityId: 'overview-note',
occurredAt: '2026-06-30T08:25:00.000Z',
type: 'note.saved',
title: 'Overview',
summary: 'Project/Notes/Overview.md',
workspaceRootPath: 'Project',
},
{
activityId: 'overview-file',
occurredAt: '2026-06-30T08:20:00.000Z',
type: 'file.changed',
title: 'draft.md',
summary: 'Project/draft.md',
workspaceRootPath: 'Project',
},
{
activityId: 'overview-workspace-selected',
occurredAt: '2026-06-30T08:10:00.000Z',
type: 'case.selected',
title: 'Workspace selected',
workspaceRootPath: 'Project',
},
],
'work-session-candidates:workspace:Project': [
{
candidateId: 'work-session:Project:overview-note:overview-file',
workspaceRootPath: 'Project',
startedAt: '2026-06-30T08:15:00.000Z',
endedAt: '2026-06-30T08:25:00.000Z',
estimatedMinutes: 10,
activityCount: 2,
activityIds: ['overview-file', 'overview-note'],
activities: [
{ activityId: 'overview-file', type: 'file.changed', occurredAt: '2026-06-30T08:20:00.000Z' },
{ activityId: 'overview-note', type: 'note.saved', occurredAt: '2026-06-30T08:25:00.000Z' },
],
},
],
});
await window.go.api.App.WritePluginSettings('verstak.journal', {
'worklog:workspace:Project': [
{
entryId: 'overview-journal-1',
date: '2026-06-30',
title: 'Write project summary',
summary: 'Turn recent captures into a worklog entry',
minutes: 35,
workspaceRootPath: 'Project',
},
],
});
});
await page.locator('[data-overview-action="refresh"]').click();
const overview = page.locator('[data-overview-root]');
await expect(overview.locator('[data-overview-summary="notes"]')).toContainText('1 total');
await expect(overview.locator('[data-overview-summary="notes"]')).toContainText('1 recent change');
await expect(overview.locator('[data-overview-summary="files"]')).toContainText('1 recent change');
await expect(overview.locator('[data-overview-summary="captures"]')).toContainText('2');
await expect(overview.locator('[data-overview-summary="activity"]')).toContainText('5 recorded events');
await expect(overview.locator('[data-overview-summary="journal"]')).toContainText('1');
await expect(overview.locator('[data-overview-summary="attention"]')).toContainText('3');
const attention = overview.locator('[data-overview-section="attention"]');
await expect(attention).toContainText('Possible journal entry');
await expect(attention).toContainText('Workspace: Project · 10 min · 2 activities');
await attention.locator('.overview-attention-row', { hasText: 'Possible journal entry' }).getByRole('button', { name: 'Review candidate' }).click();
await expect(page.getByRole('tab', { name: 'Journal' })).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.journal-root [data-journal-candidate]')).toContainText('Workspace: Project');
await page.locator('.journal-modal-actions').getByRole('button', { name: 'Cancel' }).click();
await page.getByRole('tab', { name: 'Overview' }).click();
const resume = overview.locator('[data-overview-section="continue"]');
const candidates = resume.locator('[data-overview-continue-item]');
await expect(candidates).toHaveCount(4);
await expect(candidates.nth(0)).toContainText('Quote to process');
await expect(candidates.nth(1)).toContainText('Research Report');
await expect(candidates.nth(2)).toContainText('Edited note "Overview"');
await expect(candidates.nth(3)).toContainText('Changed file "draft.md"');
await candidates.nth(0).click();
await expect(page.getByRole('tab', { name: 'Browser Inbox' })).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.browser-inbox-root')).toBeVisible({ timeout: 10000 });
await page.getByRole('tab', { name: 'Overview' }).click();
const recent = overview.locator('[data-overview-section="recent"]');
await expect(recent).toContainText('Edited note "Overview"');
await expect(recent).toContainText('Changed file "draft.md"');
await expect(recent).toContainText('Captured page "Research Report"');
await expect(recent).toContainText('Added journal entry "Write project summary"');
await expect(recent).not.toContainText('Selected file');
await expect(recent).not.toContainText('Workspace selected');
await expect(recent).not.toContainText('file.opened');
await expect(recent).not.toContainText('Client capture must stay global');
await expect(recent).not.toContainText('Unassigned capture must stay global');
await expect(recent.locator('[data-overview-recent-item]')).toHaveCount(5);
await expect(recent.locator('[data-overview-recent-item] button')).toHaveCount(0);
await overview.locator('[data-overview-filter="notes"]').click();
await expect(recent).toContainText('Edited note "Overview"');
await expect(recent).not.toContainText('Changed file "draft.md"');
await expect(recent).not.toContainText('Research Report');
await overview.locator('[data-overview-filter="captures"]').click();
await expect(recent).toContainText('Captured page "Research Report"');
await expect(recent).toContainText('Captured selection "Quote to process"');
await expect(recent).not.toContainText('Edited note "Overview"');
await overview.locator('[data-overview-filter="journal"]').click();
await expect(recent).toContainText('Added journal entry "Write project summary"');
await expect(recent).not.toContainText('Changed file "draft.md"');
});
});
+98
View File
@@ -0,0 +1,98 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
test.describe('Workspace templates', () => {
let consoleCollector;
test.beforeEach(async ({ page }) => {
consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
});
test.afterEach(async () => {
consoleCollector.assertNoErrors();
});
async function openCreateModal(page) {
await page.locator('button[title="New workspace"]').click();
const modal = page.locator('[data-workspace-create-modal]');
await expect(modal).toBeVisible();
return modal;
}
test('creation modal validates names, shows template tools, and persists the selected snapshot', async ({ page }) => {
const modal = await openCreateModal(page);
await expect(modal.locator('[data-workspace-template]')).toHaveValue('default');
await expect(modal.locator('[data-workspace-template-tools]')).toContainText('Notes');
await expect(modal.locator('[data-workspace-template-tools]')).toContainText('Browser Inbox');
await modal.getByRole('button', { name: 'Create workspace' }).click();
await expect(modal.locator('[data-workspace-create-error]')).toContainText('Name is required');
await modal.locator('[data-workspace-name]').fill('bad/name');
await modal.getByRole('button', { name: 'Create workspace' }).click();
await expect(modal.locator('[data-workspace-create-error]')).toContainText('invalid-workspace-name');
await modal.locator('[data-workspace-name]').fill('ProjectPlan');
await modal.locator('[data-workspace-template]').selectOption('project');
await expect(modal.locator('[data-workspace-template-description]')).toContainText('Project planning');
await expect(modal.locator('[data-workspace-template-tools]')).toContainText('Todos');
await modal.getByRole('button', { name: 'Create workspace' }).click();
await expect(page.locator('.wt-label').filter({ hasText: 'ProjectPlan' })).toBeVisible();
await expect.poll(async () => page.evaluate(async () => {
const result = await window.go.api.App.GetWorkspaceMetadata('ProjectPlan');
const metadata = Array.isArray(result) ? result[0] : result;
return {
templateId: metadata.createdFromTemplate?.templateId,
tools: metadata.workspaceTools,
};
})).toEqual({
templateId: 'project',
tools: ['verstak.notes', 'verstak.files', 'verstak.todo', 'verstak.journal', 'verstak.activity', 'verstak.browser-inbox'],
});
await expect(page.getByRole('tab', { name: 'Todos' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Journal' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Secrets' })).toHaveCount(0);
});
test('Minimal keeps global tools available while limiting workspace tabs', async ({ page }) => {
const modal = await openCreateModal(page);
await modal.locator('[data-workspace-name]').fill('MinimalSpace');
await modal.locator('[data-workspace-template]').selectOption('minimal');
await modal.getByRole('button', { name: 'Create workspace' }).click();
await expect(page.getByRole('tab', { name: 'Overview' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Notes' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Files' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Todos' })).toHaveCount(0);
await expect(page.getByRole('tab', { name: 'Journal' })).toHaveCount(0);
await expect(page.getByRole('tab', { name: 'Secrets' })).toHaveCount(0);
await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Todos' })).toBeVisible();
await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser Inbox' })).toBeVisible();
});
test('Admin shows Secrets when available and missing workspace plugins degrade without breaking tabs', async ({ page }) => {
let modal = await openCreateModal(page);
await modal.locator('[data-workspace-name]').fill('AdminSpace');
await modal.locator('[data-workspace-template]').selectOption('admin');
await modal.getByRole('button', { name: 'Create workspace' }).click();
await expect(page.getByRole('tab', { name: 'Secrets' })).toBeVisible();
await page.evaluate(() => window.__wailsMock.setPluginStatus('verstak.todo', 'disabled', false));
modal = await openCreateModal(page);
await modal.locator('[data-workspace-name]').fill('ProjectWithoutTodo');
await modal.locator('[data-workspace-template]').selectOption('project');
await modal.getByRole('button', { name: 'Create workspace' }).click();
await expect(page.getByRole('tab', { name: 'Notes' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Files' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Todos' })).toHaveCount(0);
await page.locator('.wt-label').filter({ hasText: 'AdminSpace' }).click();
await expect(page.getByRole('tab', { name: 'Secrets' })).toBeVisible();
});
});
+440 -47
View File
@@ -1,19 +1,27 @@
<script>
import PluginManager from './lib/plugin-manager/PluginManager.svelte';
import Sidebar from './lib/shell/Sidebar.svelte';
import CommandPalette from './lib/shell/CommandPalette.svelte';
import StatusBar from './lib/shell/StatusBar.svelte';
import ViewContainer from './lib/shell/ViewContainer.svelte';
import VaultSelection from './lib/shell/VaultSelection.svelte';
import WorkbenchHost from './lib/shell/WorkbenchHost.svelte';
import WorkspaceHost from './lib/shell/WorkspaceHost.svelte';
import * as App from '../wailsjs/go/api/App';
import { debug } from './lib/log/debug.js';
import { onMount } from 'svelte';
import { tick } from 'svelte';
import { onDestroy, onMount, tick } from 'svelte';
import { i18n } from './lib/i18n/index.js';
let currentView = 'plugin-manager';
let currentView = 'workspace';
let vaultStatus = { status: 'unknown', path: '', vaultId: '' };
let needsVaultSelection = false;
let loading = true;
let locale = i18n.getLocale();
const unsubscribeLocale = i18n.subscribe((nextLocale) => { locale = nextLocale; });
$: tr = ((activeLocale) => (key, params, fallback) => {
void activeLocale;
return i18n.t(key, params, fallback);
})(locale);
let activeView = null;
let activeViewPluginId = '';
@@ -23,6 +31,7 @@
let workspaceNodes = [];
let selectedWorkspaceName = '';
let activeWorkspaceToolKey = '';
let navigationStack = [];
let navigationIndex = -1;
let applyingNavigation = false;
@@ -33,6 +42,75 @@
App.WriteFrontendLog('App', msg);
}
function resultOrError(response, fallbackValue) {
return typeof response === 'string' ? [fallbackValue, response] : [response, ''];
}
function workspaceName(workspace) {
return String(workspace?.name || workspace?.rootPath || workspace?.id || '');
}
function workspaceAsNode(workspace, order) {
const name = workspaceName(workspace);
return {
id: name,
type: workspace?.type || 'space',
title: workspace?.title || name,
name,
rootPath: workspace?.rootPath || name,
status: workspace?.status || 'active',
order,
};
}
function emitWorkspaceActive(name) {
window.dispatchEvent(new CustomEvent('verstak:workspace-active-changed', {
detail: { workspaceName: name || '' }
}));
}
function clearWorkspaceSelection() {
selectedWorkspaceName = '';
emitWorkspaceActive('');
}
async function openDefaultWorkspaceRoute() {
try {
const [workspaces, err] = resultOrError(await App.ListWorkspaces(), []);
if (err || !workspaces || workspaces.length === 0) {
workspaceNodes = [];
selectedWorkspaceName = '';
currentView = 'workspace-empty';
emitWorkspaceActive('');
return;
}
workspaceNodes = workspaces.map(workspaceAsNode);
let currentWorkspace = null;
try {
currentWorkspace = await App.GetCurrentWorkspace();
} catch {
currentWorkspace = null;
}
const currentName = workspaceName(currentWorkspace);
const selected = workspaces.find((workspace) => workspaceName(workspace) === currentName) || workspaces[0];
selectedWorkspaceName = workspaceName(selected);
if (selectedWorkspaceName) {
try { await App.SetCurrentWorkspace(selectedWorkspaceName); } catch {}
currentView = 'workspace';
} else {
currentView = 'workspace-empty';
}
emitWorkspaceActive(selectedWorkspaceName);
} catch (e) {
debug.log('[App] openDefaultWorkspaceRoute ERROR', String(e));
workspaceNodes = [];
selectedWorkspaceName = '';
currentView = 'workspace-empty';
emitWorkspaceActive('');
}
}
function currentSnapshot() {
return {
currentView,
@@ -42,6 +120,7 @@
activeSettingsPanelId,
openedResource,
selectedWorkspaceName,
activeWorkspaceToolKey,
};
}
@@ -68,6 +147,8 @@
activeSettingsPanelId = snapshot.activeSettingsPanelId;
openedResource = snapshot.openedResource;
selectedWorkspaceName = snapshot.selectedWorkspaceName;
activeWorkspaceToolKey = snapshot.activeWorkspaceToolKey || '';
emitWorkspaceActive(currentView === 'workspace' ? selectedWorkspaceName : '');
applyingNavigation = false;
}
@@ -86,7 +167,7 @@
}
function mouseHistoryDirection(event) {
if (currentView === 'workspace' || currentView === 'workbench') return '';
if (currentView === 'workspace') return '';
if (event.button === 3 || event.button === 8 || event.buttons === 8 || event.buttons === 128 || event.which === 8) return 'back';
if (event.button === 4 || event.button === 9 || event.buttons === 16 || event.buttons === 256 || event.which === 9) return 'forward';
return '';
@@ -151,6 +232,7 @@
debug.log('[App] checkVault: vault open, needsVaultSelection=false');
flog('checkVault: needsVaultSelection=false');
needsVaultSelection = false;
await openDefaultWorkspaceRoute();
}
} catch (e) {
debug.log('[App] checkVault: ERROR', String(e));
@@ -164,15 +246,18 @@
flog('checkVault: END, loading=false');
}
function onVaultOpened() {
async function onVaultOpened() {
debug.log('[App] onVaultOpened');
needsVaultSelection = false;
vaultStatus = { status: 'open', path: '', vaultId: '' };
await openDefaultWorkspaceRoute();
pushNavigation();
}
function onNav(e) {
debug.log('[App] onNav:', e.detail.viewId);
currentView = e.detail.viewId;
if (currentView !== 'workspace') clearWorkspaceSelection();
pushNavigation();
}
@@ -181,6 +266,7 @@
activeView = e.detail.viewId;
activeViewPluginId = e.detail.pluginId || '';
currentView = 'plugin-view';
clearWorkspaceSelection();
pushNavigation();
}
@@ -189,22 +275,35 @@
activeSettingsPluginId = e.detail.pluginId;
activeSettingsPanelId = e.detail.panelId || '';
currentView = 'plugin-manager';
clearWorkspaceSelection();
pushNavigation();
}
function onWorkbenchOpened(e) {
debug.log('[App] onWorkbenchOpened:', e.detail?.request?.path, e.detail?.providerId);
if (currentView === 'workspace') pushNavigation();
openedResource = e.detail;
currentView = 'workbench';
pushNavigation();
}
function onWorkspaceToolSelected(e) {
activeWorkspaceToolKey = e.detail?.toolKey || '';
if (currentView === 'workspace') pushNavigation();
}
function onWorkspaceSelected(e) {
debug.log('[App] onWorkspaceSelected:', e.detail?.workspaceName);
selectedWorkspaceName = e.detail?.workspaceName || '';
workspaceNodes = e.detail?.nodes || workspaceNodes;
if (selectedWorkspaceName) {
activeView = null;
activeViewPluginId = '';
activeSettingsPluginId = '';
activeSettingsPanelId = '';
openedResource = null;
currentView = 'workspace';
emitWorkspaceActive(selectedWorkspaceName);
pushNavigation();
}
}
@@ -267,6 +366,7 @@
window.addEventListener('verstak:close-settings', onCloseSettings);
window.addEventListener('verstak:workbench-opened', onWorkbenchOpened);
window.addEventListener('verstak:workspace-selected', onWorkspaceSelected);
window.addEventListener('verstak:workspace-tool-selected', onWorkspaceToolSelected);
window.addEventListener('verstak:navigate-back', onNavigateBack);
window.addEventListener('verstak:navigate-forward', onNavigateForward);
window.addEventListener('verstak:close-workbench', onCloseWorkbench);
@@ -281,28 +381,38 @@
await checkVault();
pushNavigation();
});
onDestroy(unsubscribeLocale);
</script>
{#if loading}
<div class="app-loading">
<p>Loading Verstak...</p>
<p>{tr('app.loading')}</p>
</div>
{:else if needsVaultSelection}
<VaultSelection />
{:else}
<main>
<Sidebar />
<Sidebar showGlobalSearch={currentView !== 'workspace' && currentView !== 'workspace-empty'} />
<CommandPalette />
<section class="content scroll-surface">
{#if currentView === 'plugin-manager'}
<PluginManager {activeSettingsPluginId} {activeSettingsPanelId} />
{:else if currentView === 'workbench'}
<WorkbenchHost {openedResource} />
{:else if currentView === 'workspace'}
<WorkspaceHost selectedWorkspaceName={selectedWorkspaceName} nodes={workspaceNodes} />
{:else}
<ViewContainer {activeView} {activeViewPluginId} />
{/if}
<section class="content-shell">
<section class="content scroll-surface">
{#if currentView === 'plugin-manager'}
<PluginManager {activeSettingsPluginId} {activeSettingsPanelId} />
{:else if currentView === 'workbench'}
<WorkbenchHost {openedResource} />
{:else if currentView === 'workspace' || currentView === 'workspace-empty'}
<WorkspaceHost
selectedWorkspaceName={selectedWorkspaceName}
nodes={workspaceNodes}
bind:activeToolKey={activeWorkspaceToolKey}
/>
{:else}
<ViewContainer {activeView} {activeViewPluginId} />
{/if}
</section>
<StatusBar />
</section>
</main>
{/if}
@@ -314,6 +424,41 @@
box-sizing: border-box;
}
:global(:root) {
--vt-color-background: #101020;
--vt-color-surface: #15152c;
--vt-color-surface-muted: #111629;
--vt-color-surface-hover: #1b2440;
--vt-color-surface-selected: rgba(78, 204, 163, 0.14);
--vt-color-border: #202b46;
--vt-color-border-strong: #2c456a;
--vt-color-text-primary: #f4f7fb;
--vt-color-text-secondary: #b7c0d4;
--vt-color-text-muted: #7f8aa3;
--vt-color-accent: #4ecca3;
--vt-color-accent-muted: rgba(78, 204, 163, 0.14);
--vt-color-danger: #e94560;
--vt-color-danger-muted: rgba(233, 69, 96, 0.14);
--vt-color-warning: #ffc857;
--vt-color-warning-muted: rgba(255, 200, 87, 0.14);
--vt-color-success: #4ecca3;
--vt-space-1: 0.25rem;
--vt-space-2: 0.5rem;
--vt-space-3: 0.75rem;
--vt-space-4: 1rem;
--vt-space-6: 1.5rem;
--vt-space-8: 2rem;
--vt-radius-sm: 4px;
--vt-radius-md: 6px;
--vt-radius-lg: 8px;
--vt-font-xs: 0.72rem;
--vt-font-sm: 0.8rem;
--vt-font-md: 0.88rem;
--vt-font-lg: 1rem;
--vt-focus-ring: 0 0 0 2px rgba(78, 204, 163, 0.34);
--vt-elevation-menu: 0 14px 32px rgba(0, 0, 0, 0.42);
}
:global(html),
:global(body),
:global(#app) {
@@ -323,8 +468,8 @@
:global(body) {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #1a1a2e;
color: #e0e0f0;
background: var(--vt-color-background);
color: var(--vt-color-text-primary);
overflow: hidden;
}
@@ -335,12 +480,12 @@
gap: 0.35rem;
min-height: 2rem;
padding: 0.4rem 0.85rem;
border: 1px solid #1a3a5c;
border-radius: 6px;
background: #0f3460;
color: #e0e0f0;
border: 1px solid var(--vt-color-border-strong);
border-radius: var(--vt-radius-md);
background: var(--vt-color-surface-hover);
color: var(--vt-color-text-primary);
font: inherit;
font-size: 0.85rem;
font-size: var(--vt-font-sm);
font-weight: 600;
line-height: 1.2;
cursor: pointer;
@@ -348,14 +493,14 @@
}
:global(button:hover:not(:disabled)) {
background: #1a3a5c;
border-color: #4ecca3;
background: #203050;
border-color: var(--vt-color-accent);
color: #ffffff;
}
:global(button:focus-visible) {
outline: 2px solid #4ecca3;
outline-offset: 2px;
outline: 0;
box-shadow: var(--vt-focus-ring);
}
:global(button:disabled) {
@@ -364,8 +509,8 @@
}
:global(.btn-primary) {
background: #4ecca3;
border-color: #4ecca3;
background: var(--vt-color-accent);
border-color: var(--vt-color-accent);
color: #101827;
}
@@ -376,14 +521,14 @@
}
:global(.btn-secondary) {
background: #0f3460;
border-color: #533483;
color: #e0e0f0;
background: var(--vt-color-surface-hover);
border-color: var(--vt-color-border-strong);
color: var(--vt-color-text-primary);
}
:global(.btn-danger) {
background: #e94560;
border-color: #e94560;
background: var(--vt-color-danger);
border-color: var(--vt-color-danger);
color: #ffffff;
}
@@ -395,13 +540,13 @@
:global(.btn-ghost) {
background: transparent;
border-color: transparent;
color: #a0a0b8;
color: var(--vt-color-text-secondary);
}
:global(.btn-ghost:hover:not(:disabled)) {
background: rgba(15, 52, 96, 0.55);
border-color: #0f3460;
color: #e0e0f0;
background: var(--vt-color-surface-hover);
border-color: var(--vt-color-border);
color: var(--vt-color-text-primary);
}
:global(.btn-icon) {
@@ -410,6 +555,236 @@
padding: 0;
}
:global(.vt-page) {
min-width: 0;
min-height: 0;
height: 100%;
display: flex;
flex-direction: column;
background: var(--vt-color-background);
color: var(--vt-color-text-primary);
}
:global(.vt-page-header),
:global(.vt-toolbar) {
min-height: 2.75rem;
display: flex;
align-items: center;
gap: var(--vt-space-2);
padding: var(--vt-space-2) var(--vt-space-3);
border-bottom: 1px solid var(--vt-color-border);
background: var(--vt-color-surface-muted);
flex-shrink: 0;
}
:global(.vt-page-title) {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--vt-color-text-primary);
font-size: var(--vt-font-lg);
font-weight: 650;
}
:global(.vt-page-subtitle) {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--vt-color-text-muted);
font-size: var(--vt-font-sm);
}
:global(.vt-toolbar-group) {
display: inline-flex;
align-items: center;
gap: var(--vt-space-1);
padding: 0 var(--vt-space-2) 0 0;
border-right: 1px solid var(--vt-color-border);
}
:global(.vt-toolbar-group:last-child) {
border-right: 0;
padding-right: 0;
}
:global(.vt-button) {
min-height: 2rem;
border-radius: var(--vt-radius-md);
}
:global(.vt-icon-button) {
width: 2rem;
min-width: 2rem;
height: 2rem;
min-height: 2rem;
padding: 0;
}
:global(.vt-tabbar) {
display: flex;
align-items: center;
gap: var(--vt-space-1);
padding: var(--vt-space-1) var(--vt-space-3) 0;
border-bottom: 1px solid var(--vt-color-border);
background: #12162a;
overflow-x: auto;
flex-shrink: 0;
}
:global(.vt-tab) {
flex-shrink: 0;
min-height: 2.1rem;
padding: 0.38rem 0.78rem;
border: 1px solid transparent;
border-bottom: 0;
border-radius: var(--vt-radius-md) var(--vt-radius-md) 0 0;
background: transparent;
color: var(--vt-color-text-muted);
font-size: var(--vt-font-sm);
}
:global(.vt-tab:hover:not(:disabled)) {
background: var(--vt-color-surface-hover);
border-color: transparent;
color: var(--vt-color-text-primary);
}
:global(.vt-tab.is-active),
:global(.vt-tab.active) {
background: var(--vt-color-background);
border-color: var(--vt-color-border);
color: var(--vt-color-accent);
}
:global(.vt-list-row) {
border-bottom: 1px solid rgba(32, 43, 70, 0.72);
color: var(--vt-color-text-primary);
}
:global(.vt-list-row:hover) {
background: var(--vt-color-surface-hover);
}
:global(.vt-list-row.selected),
:global(.vt-list-row.active),
:global(.vt-list-row.is-selected) {
background: var(--vt-color-surface-selected);
box-shadow: inset 2px 0 0 var(--vt-color-accent);
}
:global(.vt-card) {
border: 1px solid var(--vt-color-border);
border-radius: var(--vt-radius-lg);
background: var(--vt-color-surface);
}
:global(.vt-split-pane) {
display: grid;
grid-template-columns: minmax(16rem, 22rem) minmax(0, 1fr);
min-height: 0;
height: 100%;
background: var(--vt-color-background);
}
:global(.vt-split-list) {
min-height: 0;
overflow: auto;
border-right: 1px solid var(--vt-color-border);
background: var(--vt-color-surface-muted);
}
:global(.vt-split-detail) {
min-width: 0;
min-height: 0;
overflow: auto;
padding: var(--vt-space-4);
}
:global(.vt-empty-state) {
min-height: 9rem;
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--vt-space-2);
padding: var(--vt-space-8);
color: var(--vt-color-text-muted);
font-size: var(--vt-font-sm);
line-height: 1.45;
text-align: center;
}
:global(.vt-empty-title) {
color: var(--vt-color-text-secondary);
font-size: var(--vt-font-md);
font-weight: 650;
}
:global(.vt-inline-alert) {
border: 1px solid var(--vt-color-border-strong);
border-radius: var(--vt-radius-md);
background: var(--vt-color-surface-muted);
color: var(--vt-color-text-secondary);
padding: var(--vt-space-3);
font-size: var(--vt-font-sm);
line-height: 1.45;
}
:global(.vt-inline-alert.error) {
border-color: rgba(233, 69, 96, 0.55);
background: var(--vt-color-danger-muted);
color: #ffc6ce;
}
:global(.vt-badge) {
display: inline-flex;
align-items: center;
min-height: 1.25rem;
padding: 0.1rem 0.4rem;
border: 1px solid var(--vt-color-border);
border-radius: var(--vt-radius-sm);
background: var(--vt-color-surface-muted);
color: var(--vt-color-text-muted);
font-size: var(--vt-font-xs);
font-weight: 650;
line-height: 1;
}
:global(.vt-badge.accent) {
border-color: rgba(78, 204, 163, 0.4);
background: var(--vt-color-accent-muted);
color: var(--vt-color-accent);
}
:global(.vt-menu) {
border: 1px solid var(--vt-color-border-strong);
border-radius: var(--vt-radius-md);
background: var(--vt-color-surface);
color: var(--vt-color-text-primary);
box-shadow: var(--vt-elevation-menu);
}
:global(.vt-menu-item) {
display: flex;
align-items: center;
gap: var(--vt-space-2);
padding: 0.42rem 0.7rem;
color: var(--vt-color-text-secondary);
cursor: pointer;
}
:global(.vt-menu-item:hover) {
background: var(--vt-color-surface-hover);
color: var(--vt-color-text-primary);
}
:global(.vt-menu-item.danger) {
color: #ff9aaa;
}
:global(.scroll-surface) {
min-width: 0;
min-height: 0;
@@ -419,7 +794,7 @@
:global(*) {
scrollbar-width: thin;
scrollbar-color: #0f3460 #1a1a2e;
scrollbar-color: var(--vt-color-border-strong) var(--vt-color-background);
}
:global(*::-webkit-scrollbar) {
@@ -428,12 +803,12 @@
}
:global(*::-webkit-scrollbar-track) {
background: #1a1a2e;
background: var(--vt-color-background);
}
:global(*::-webkit-scrollbar-thumb) {
background: #0f3460;
border-radius: 4px;
background: var(--vt-color-border-strong);
border-radius: var(--vt-radius-sm);
}
:global(*::-webkit-scrollbar-thumb:hover) {
@@ -445,8 +820,8 @@
align-items: center;
justify-content: center;
height: 100vh;
background: #1a1a2e;
color: #a0a0b8;
background: var(--vt-color-background);
color: var(--vt-color-text-muted);
font-size: 1rem;
}
@@ -454,16 +829,34 @@
display: flex;
height: 100vh;
width: 100%;
background: #1a1a2e;
background: var(--vt-color-background);
overflow: hidden;
}
.content-shell {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
}
.content {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
padding: clamp(1rem, 2vw, 1.5rem);
padding: 0;
}
@media (max-width: 720px) {
main {
flex-direction: column;
}
.content {
padding: 0.75rem;
}
}
</style>
+225
View File
@@ -0,0 +1,225 @@
export default {
'app.loading': 'Loading Verstak...',
'settings.title': 'Settings',
'settings.language': 'Language',
'settings.language.system': 'System / Системный',
'settings.language.en': 'English',
'settings.language.ru': 'Русский',
'settings.pluginManager': 'Plugin Manager',
'statusBar.label': 'Status bar',
'vault.label': 'Vault: {status}',
'vault.status.open': 'open',
'vault.status.closed': 'closed',
'vault.status.unknown': 'unknown',
'sidebar.tools': 'Tools',
'sidebar.error.contributions': 'Failed to load plugin contributions',
'sidebar.error.load': 'Failed to load sidebar',
'sidebar.error.ui': 'Plugin UI error',
'workspace.overview': 'Overview',
'workspace.search': 'Workspace search',
'workspace.tools': 'Workspace tools',
'workspace.tool': 'Workspace tool',
'workspace.emptyTools': 'No workspace tools available',
'workspace.emptyToolsHint': 'Enable plugins with workspace tools or open Plugin Manager from settings.',
'workspace.select': 'Select a workspace',
'workspace.selectHint': 'Use the + button in Workspaces to add your first project.',
'common.details': 'Details',
'common.plugin': 'Plugin',
'common.component': 'Component',
'pluginView.error': 'Plugin view error',
'pluginView.failed': 'Plugin UI failed',
'pluginView.noVisual': 'This plugin does not provide a visual view yet.',
'pluginView.viewId': 'View ID',
'pluginView.bundleUnavailable': 'Frontend bundle unavailable',
'pluginView.unavailable': 'View is unavailable.',
'pluginView.select': 'Select a plugin view from the sidebar',
'pluginView.selectHint': 'Plugin views will appear here',
'common.retry': '⟳ Retry',
'common.source': 'Source',
'common.status': 'Status',
'common.capability': 'Capability',
'common.provider': 'Provider',
'status.loaded': 'loaded',
'status.degraded': 'degraded',
'status.failed': 'failed',
'status.disabled': 'disabled',
'status.missing': 'missing',
'pluginManager.reloading': '⟳ Reloading...',
'pluginManager.reload': '⟳ Reload',
'pluginManager.scanning': 'Scanning plugin directories...',
'pluginManager.summary.plugins': '{count} plugin(s) discovered',
'pluginManager.summary.capabilities': '{count} capabilities registered',
'pluginManager.summary.permissions': '{count} permissions known',
'pluginManager.scanSummary': 'Plugin scan summary',
'pluginManager.health': 'Plugin Health',
'pluginManager.permissionRisk': 'Permission Risk',
'pluginManager.elevatedPermissions': '{count} plugins request elevated permissions',
'pluginManager.none': 'No plugins found',
'pluginManager.scannedDirs': 'Plugin directories scanned:',
'pluginManager.userPlugins': 'user plugins',
'pluginManager.bundledPlugins': 'bundled plugins (app directory)',
'pluginManager.installHint': 'Place a plugin folder with plugin.json in one of these directories and click Reload.',
'pluginManager.missingTitle': 'Missing Installed Plugins',
'pluginManager.missingHint': 'These plugins are required by this vault but their packages are not installed locally.',
'pluginManager.missingPackage': "This plugin is listed in the vault's desired plugins but the package is not installed.",
'pluginManager.capabilityRegistry': 'Capability Registry ({count})',
'pluginManager.settingsError': 'Settings Error',
'pluginManager.pluginSettings': 'Plugin Settings',
'pluginManager.settingsBundleUnavailable': 'Settings panel frontend bundle not available',
'common.none': 'none',
'common.unknown': 'unknown',
'pluginCard.count.views': '{count} view(s)',
'pluginCard.count.commands': '{count} command(s)',
'pluginCard.count.searchProviders': '{count} search provider(s)',
'pluginCard.count.sidebar': '{count} sidebar item(s)',
'pluginCard.count.statusbar': '{count} status bar item(s)',
'pluginCard.count.openProviders': '{count} openProviders',
'pluginCard.count.workspace': '{count} workspace item(s)',
'pluginCard.degraded': 'Plugin is usable, but some optional capabilities are unavailable.',
'pluginCard.name': 'Name',
'pluginCard.contributions': 'Contributions',
'pluginCard.technicalDetails': 'Technical details',
'pluginCard.apiVersion': 'API Version',
'pluginCard.root': 'Root',
'pluginCard.provides': 'Provides',
'pluginCard.requires': 'Requires',
'pluginCard.missingRequired': 'Missing required capabilities',
'pluginCard.optionalRequires': 'Optional Requires',
'pluginCard.optionalUnavailable': 'Optional capabilities not available — plugin running in degraded mode',
'pluginCard.permissions': 'Permissions',
'pluginCard.enabling': '⟳ Enabling...',
'pluginCard.enable': '▶ Enable',
'pluginCard.disabling': '⟳ Disabling...',
'pluginCard.disable': '⏸ Disable',
'pluginCard.openVault': 'Open a vault to manage plugin state',
'pluginCard.missingUiPermission': 'Plugin has UI contributions but lacks ui.register permission',
'common.loading': 'Loading...',
'common.browse': 'Browse...',
'vaultSelection.chooseNew': 'Choose or enter a folder for the new vault.',
'vaultSelection.createError': 'Could not create vault: {error}',
'vaultSelection.openError': 'Could not open vault: {error}',
'vaultSelection.chooseExisting': 'Choose or enter an existing vault.',
'vaultSelection.subtitle': 'Choose a vault to start working',
'vaultSelection.createTitle': 'Create a new vault',
'vaultSelection.createHint': 'Create a local vault folder for workspaces and projects.',
'vaultSelection.pathPlaceholder': 'Choose or enter a path...',
'vaultSelection.creating': 'Creating...',
'vaultSelection.create': 'Create vault',
'vaultSelection.openTitle': 'Open an existing vault',
'vaultSelection.openHint': 'Use a vault that is already on this computer.',
'vaultSelection.opening': 'Opening...',
'vaultSelection.open': 'Open existing',
'vaultSelection.recent': 'Recent vaults',
'common.cancel': 'Cancel',
'common.close': 'Close',
'workspaceTree.title': 'Workspaces',
'workspaceTree.new': 'New workspace',
'workspaceTree.nameRequired': 'Name is required',
'workspaceTree.chooseTemplate': 'Choose a workspace template',
'workspaceTree.saveRename': 'Save rename',
'workspaceTree.rename': 'Rename workspace',
'workspaceTree.trash': 'Trash workspace',
'workspaceTree.create': 'Create workspace',
'workspaceTree.namePlaceholder': 'Workspace name',
'workspaceTree.template': 'Template',
'workspaceTree.creating': 'Creating...',
'command.openOverview': 'Open Overview',
'command.openFiles': 'Open Files',
'command.openActivity': 'Open Activity',
'command.openBrowserInbox': 'Open Browser Inbox',
'command.createMarkdown': 'Create Markdown File',
'command.createText': 'Create Text File',
'command.syncNow': 'Sync Now',
'command.openSyncSettings': 'Open Sync Settings',
'command.openPluginManager': 'Open Plugin Manager',
'command.handled': '{title} handled',
'command.result': '{title} {status}',
'command.statusHandled': 'handled',
'command.palette': 'Command Palette',
'command.run': 'Run command',
'command.none': 'No commands',
'search.indexing': 'Indexing...',
'search.placeholder': 'Search',
'search.global': 'Global search',
'search.noResults': 'No results',
'search.type.workspace': 'Workspace',
'search.type.tool': 'Tool',
'search.type.folder': 'Folder',
'search.type.file': 'File',
'search.type.journal': 'Journal',
'search.type.browserInbox': 'Browser Inbox',
'search.type.activity': 'Activity',
'workbench.noProvider': 'No provider',
'workbench.noViewer': 'No viewer/editor available',
'workbench.noResource': 'No resource opened',
'bundle.noFrontend': 'Plugin has no frontend bundle',
'bundle.notFound': 'Plugin not found',
'bundle.loadFailed': 'Failed to load bundle: {error}',
'bundle.emptyContent': 'empty content',
'bundle.executionError': 'Bundle execution error: {error}',
'bundle.registrationMissing': 'Bundle loaded but no VerstakPluginRegister call detected',
'bundle.componentMissing': 'Component "{component}" not found in bundle. Available: {available}',
'bundle.mountError': 'Component mount error: {error}',
'bundle.mountUnavailable': 'Mount container not available',
'bundle.unexpectedError': 'Unexpected error: {error}',
'bundle.unknownError': 'Unknown error',
'bundle.frontendEntry': 'Frontend entry',
'bundle.availableComponents': 'Available components',
'bundle.loading': 'Loading plugin bundle...',
'overview.filter.all': 'All',
'overview.filter.notes': 'Notes',
'overview.filter.files': 'Files',
'overview.filter.captures': 'Captures',
'overview.filter.journal': 'Journal',
'overview.notes': 'Notes',
'overview.files': 'Files',
'overview.captures': 'Captures',
'overview.activity': 'Activity',
'overview.journal': 'Journal',
'overview.attention': 'Needs attention',
'overview.openNotes': 'Open Notes',
'overview.openFiles': 'Open Files',
'overview.reviewInbox': 'Review Inbox',
'overview.viewActivity': 'View Activity',
'overview.openJournal': 'Open Journal',
'overview.openTodos': 'Open Todos',
'overview.reviewCandidate': 'Review candidate',
'overview.reviewPending': 'Review pending items',
'overview.count.totalRecent.one': '{total} total · {recent} recent change',
'overview.count.totalRecent.many': '{total} total · {recent} recent changes',
'overview.count.recentChanges.one': '{count} recent change',
'overview.count.recentChanges.many': '{count} recent changes',
'overview.count.captures.one': '{count} capture to review',
'overview.count.captures.many': '{count} captures to review',
'overview.count.events.one': '{count} recorded event',
'overview.count.events.many': '{count} recorded events',
'overview.count.journal.one': '{count} journal entry',
'overview.count.journal.many': '{count} journal entries',
'overview.count.pending.one': '{count} pending item',
'overview.count.pending.many': '{count} pending items',
'overview.loadingContext': 'Loading workspace context...',
'overview.lastActive': 'Last active {time}',
'overview.noRecentActivity': 'No recent workspace activity',
'overview.refresh': 'Refresh',
'overview.summary': 'Workspace overview summary',
'overview.continue': 'Continue working',
'overview.continueHint': 'Pick up the next useful item in this workspace.',
'overview.loadingSignals': 'Loading workspace signals...',
'overview.noResume': 'No clear resume point yet',
'overview.noResumeHint': 'Recent notes, files, captures, and journal entries will appear here.',
'overview.recentChanges': 'Recent changes',
'overview.recentChangesHint': 'Latest meaningful activity in this workspace.',
'overview.recentFilter': 'Recent changes filter',
'overview.loadingRecent': 'Loading recent changes...',
'overview.noChanges': 'No meaningful changes for this filter yet.',
'overview.attentionHint': 'Pending captures, urgent todos, and possible journal entries.',
'overview.loadingPending': 'Loading pending items...',
'overview.noPending': 'No pending captures, urgent todos, or possible journal entries.',
'overview.keyResources': 'Key resources',
'overview.time.none': 'No timestamp',
'overview.time.now': 'Just now',
'overview.time.minutes': '{count} min ago',
'overview.time.hours': '{count}h ago',
'overview.time.yesterday': 'Yesterday',
'overview.time.days': '{count} days ago',
};
+225
View File
@@ -0,0 +1,225 @@
export default {
'app.loading': 'Загрузка Верстака...',
'settings.title': 'Настройки',
'settings.language': 'Язык',
'settings.language.system': 'System / Системный',
'settings.language.en': 'English',
'settings.language.ru': 'Русский',
'settings.pluginManager': 'Менеджер плагинов',
'statusBar.label': 'Строка состояния',
'vault.label': 'Хранилище: {status}',
'vault.status.open': 'открыто',
'vault.status.closed': 'закрыто',
'vault.status.unknown': 'неизвестно',
'sidebar.tools': 'Инструменты',
'sidebar.error.contributions': 'Не удалось загрузить элементы плагинов',
'sidebar.error.load': 'Не удалось загрузить боковую панель',
'sidebar.error.ui': 'Ошибка интерфейса плагина',
'workspace.overview': 'Обзор',
'workspace.search': 'Поиск в рабочем пространстве',
'workspace.tools': 'Инструменты рабочего пространства',
'workspace.tool': 'Инструмент рабочего пространства',
'workspace.emptyTools': 'Нет доступных инструментов',
'workspace.emptyToolsHint': 'Включите плагины с инструментами рабочего пространства или откройте Менеджер плагинов в настройках.',
'workspace.select': 'Выберите рабочее пространство',
'workspace.selectHint': 'Используйте кнопку + в разделе рабочих пространств, чтобы добавить первый проект.',
'common.details': 'Подробнее',
'common.plugin': 'Плагин',
'common.component': 'Компонент',
'pluginView.error': 'Ошибка представления плагина',
'pluginView.failed': 'Ошибка интерфейса плагина',
'pluginView.noVisual': 'Этот плагин пока не предоставляет визуальное представление.',
'pluginView.viewId': 'ID представления',
'pluginView.bundleUnavailable': 'Frontend bundle недоступен',
'pluginView.unavailable': 'Представление недоступно.',
'pluginView.select': 'Выберите представление плагина на боковой панели',
'pluginView.selectHint': 'Представления плагинов появятся здесь',
'common.retry': '⟳ Повторить',
'common.source': 'Источник',
'common.status': 'Состояние',
'common.capability': 'Capability',
'common.provider': 'Поставщик',
'status.loaded': 'загружено',
'status.degraded': 'ограничено',
'status.failed': 'ошибка',
'status.disabled': 'выключено',
'status.missing': 'отсутствует',
'pluginManager.reloading': '⟳ Обновление...',
'pluginManager.reload': '⟳ Обновить',
'pluginManager.scanning': 'Сканирование каталогов плагинов...',
'pluginManager.summary.plugins': 'Обнаружено плагинов: {count}',
'pluginManager.summary.capabilities': 'Зарегистрировано capabilities: {count}',
'pluginManager.summary.permissions': 'Известно разрешений: {count}',
'pluginManager.scanSummary': 'Сводка проверки плагинов',
'pluginManager.health': 'Состояние плагинов',
'pluginManager.permissionRisk': 'Риск разрешений',
'pluginManager.elevatedPermissions': 'Плагинов с повышенными разрешениями: {count}',
'pluginManager.none': 'Плагины не найдены',
'pluginManager.scannedDirs': 'Проверенные каталоги плагинов:',
'pluginManager.userPlugins': 'пользовательские плагины',
'pluginManager.bundledPlugins': 'встроенные плагины (каталог приложения)',
'pluginManager.installHint': 'Поместите папку плагина с plugin.json в один из этих каталогов и нажмите «Обновить».',
'pluginManager.missingTitle': 'Отсутствующие установленные плагины',
'pluginManager.missingHint': 'Эти плагины требуются хранилищу, но их пакеты не установлены на этом устройстве.',
'pluginManager.missingPackage': 'Плагин указан среди желаемых плагинов хранилища, но его пакет не установлен.',
'pluginManager.capabilityRegistry': 'Реестр capabilities ({count})',
'pluginManager.settingsError': 'Ошибка настроек',
'pluginManager.pluginSettings': 'Настройки плагина',
'pluginManager.settingsBundleUnavailable': 'Frontend bundle панели настроек недоступен',
'common.none': 'нет',
'common.unknown': 'неизвестно',
'pluginCard.count.views': 'представлений: {count}',
'pluginCard.count.commands': 'команд: {count}',
'pluginCard.count.searchProviders': 'поставщиков поиска: {count}',
'pluginCard.count.sidebar': 'элементов боковой панели: {count}',
'pluginCard.count.statusbar': 'элементов строки состояния: {count}',
'pluginCard.count.openProviders': 'обработчиков открытия: {count}',
'pluginCard.count.workspace': 'инструментов рабочего пространства: {count}',
'pluginCard.degraded': 'Плагин работает, но некоторые необязательные capabilities недоступны.',
'pluginCard.name': 'Название',
'pluginCard.contributions': 'Элементы интерфейса',
'pluginCard.technicalDetails': 'Технические сведения',
'pluginCard.apiVersion': 'Версия API',
'pluginCard.root': 'Корневой каталог',
'pluginCard.provides': 'Предоставляет',
'pluginCard.requires': 'Требует',
'pluginCard.missingRequired': 'Отсутствуют обязательные capabilities',
'pluginCard.optionalRequires': 'Необязательные зависимости',
'pluginCard.optionalUnavailable': 'Необязательные capabilities недоступны — плагин работает в ограниченном режиме',
'pluginCard.permissions': 'Разрешения',
'pluginCard.enabling': '⟳ Включение...',
'pluginCard.enable': '▶ Включить',
'pluginCard.disabling': '⟳ Выключение...',
'pluginCard.disable': '⏸ Выключить',
'pluginCard.openVault': 'Откройте хранилище для управления состоянием плагина',
'pluginCard.missingUiPermission': 'Плагин добавляет элементы интерфейса, но не имеет разрешения ui.register',
'common.loading': 'Загрузка...',
'common.browse': 'Обзор...',
'vaultSelection.chooseNew': 'Выберите или введите папку для нового хранилища.',
'vaultSelection.createError': 'Не удалось создать хранилище: {error}',
'vaultSelection.openError': 'Не удалось открыть хранилище: {error}',
'vaultSelection.chooseExisting': 'Выберите или введите существующее хранилище.',
'vaultSelection.subtitle': 'Выберите хранилище, чтобы начать работу',
'vaultSelection.createTitle': 'Создать новое хранилище',
'vaultSelection.createHint': 'Создайте локальную папку хранилища для рабочих пространств и проектов.',
'vaultSelection.pathPlaceholder': 'Выберите или введите путь...',
'vaultSelection.creating': 'Создание...',
'vaultSelection.create': 'Создать хранилище',
'vaultSelection.openTitle': 'Открыть существующее хранилище',
'vaultSelection.openHint': 'Используйте хранилище, которое уже находится на этом компьютере.',
'vaultSelection.opening': 'Открытие...',
'vaultSelection.open': 'Открыть',
'vaultSelection.recent': 'Недавние хранилища',
'common.cancel': 'Отмена',
'common.close': 'Закрыть',
'workspaceTree.title': 'Рабочие пространства',
'workspaceTree.new': 'Новое рабочее пространство',
'workspaceTree.nameRequired': 'Введите название',
'workspaceTree.chooseTemplate': 'Выберите шаблон рабочего пространства',
'workspaceTree.saveRename': 'Сохранить новое название',
'workspaceTree.rename': 'Переименовать рабочее пространство',
'workspaceTree.trash': 'Переместить рабочее пространство в корзину',
'workspaceTree.create': 'Создать рабочее пространство',
'workspaceTree.namePlaceholder': 'Название рабочего пространства',
'workspaceTree.template': 'Шаблон',
'workspaceTree.creating': 'Создание...',
'command.openOverview': 'Открыть обзор',
'command.openFiles': 'Открыть файлы',
'command.openActivity': 'Открыть активность',
'command.openBrowserInbox': 'Открыть входящие из браузера',
'command.createMarkdown': 'Создать файл Markdown',
'command.createText': 'Создать текстовый файл',
'command.syncNow': 'Синхронизировать сейчас',
'command.openSyncSettings': 'Открыть настройки синхронизации',
'command.openPluginManager': 'Открыть менеджер плагинов',
'command.handled': 'Выполнено: {title}',
'command.result': '{title}: {status}',
'command.statusHandled': 'выполнено',
'command.palette': 'Палитра команд',
'command.run': 'Выполнить команду',
'command.none': 'Команды не найдены',
'search.indexing': 'Индексирование...',
'search.placeholder': 'Поиск',
'search.global': 'Глобальный поиск',
'search.noResults': 'Ничего не найдено',
'search.type.workspace': 'Рабочее пространство',
'search.type.tool': 'Инструмент',
'search.type.folder': 'Папка',
'search.type.file': 'Файл',
'search.type.journal': 'Журнал',
'search.type.browserInbox': 'Входящие из браузера',
'search.type.activity': 'Активность',
'workbench.noProvider': 'Нет обработчика',
'workbench.noViewer': 'Нет доступного средства просмотра или редактора',
'workbench.noResource': 'Ресурс не открыт',
'bundle.noFrontend': 'У плагина нет frontend bundle',
'bundle.notFound': 'Плагин не найден',
'bundle.loadFailed': 'Не удалось загрузить bundle: {error}',
'bundle.emptyContent': 'пустое содержимое',
'bundle.executionError': 'Ошибка выполнения bundle: {error}',
'bundle.registrationMissing': 'Bundle загружен, но вызов VerstakPluginRegister не обнаружен',
'bundle.componentMissing': 'Компонент «{component}» не найден в bundle. Доступны: {available}',
'bundle.mountError': 'Ошибка подключения компонента: {error}',
'bundle.mountUnavailable': 'Контейнер для подключения недоступен',
'bundle.unexpectedError': 'Непредвиденная ошибка: {error}',
'bundle.unknownError': 'Неизвестная ошибка',
'bundle.frontendEntry': 'Точка входа frontend',
'bundle.availableComponents': 'Доступные компоненты',
'bundle.loading': 'Загрузка bundle плагина...',
'overview.filter.all': 'Все',
'overview.filter.notes': 'Заметки',
'overview.filter.files': 'Файлы',
'overview.filter.captures': 'Сохранённое',
'overview.filter.journal': 'Журнал',
'overview.notes': 'Заметки',
'overview.files': 'Файлы',
'overview.captures': 'Сохранённое',
'overview.activity': 'Активность',
'overview.journal': 'Журнал',
'overview.attention': 'Требует внимания',
'overview.openNotes': 'Открыть заметки',
'overview.openFiles': 'Открыть файлы',
'overview.reviewInbox': 'Разобрать входящие',
'overview.viewActivity': 'Открыть активность',
'overview.openJournal': 'Открыть журнал',
'overview.openTodos': 'Открыть задачи',
'overview.reviewCandidate': 'Просмотреть запись',
'overview.reviewPending': 'Разобрать ожидающее',
'overview.count.totalRecent.one': 'Всего: {total} · недавних изменений: {recent}',
'overview.count.totalRecent.many': 'Всего: {total} · недавних изменений: {recent}',
'overview.count.recentChanges.one': 'Недавних изменений: {count}',
'overview.count.recentChanges.many': 'Недавних изменений: {count}',
'overview.count.captures.one': 'На разбор: {count}',
'overview.count.captures.many': 'На разбор: {count}',
'overview.count.events.one': 'Событий: {count}',
'overview.count.events.many': 'Событий: {count}',
'overview.count.journal.one': 'Записей журнала: {count}',
'overview.count.journal.many': 'Записей журнала: {count}',
'overview.count.pending.one': 'Ожидают: {count}',
'overview.count.pending.many': 'Ожидают: {count}',
'overview.loadingContext': 'Загрузка данных рабочего пространства...',
'overview.lastActive': 'Последняя активность: {time}',
'overview.noRecentActivity': 'В рабочем пространстве пока нет недавней активности',
'overview.refresh': 'Обновить',
'overview.summary': 'Сводка рабочего пространства',
'overview.continue': 'Продолжить работу',
'overview.continueHint': 'Вернитесь к следующему полезному делу в этом рабочем пространстве.',
'overview.loadingSignals': 'Загрузка рабочих данных...',
'overview.noResume': 'Пока неясно, с чего продолжить',
'overview.noResumeHint': 'Здесь появятся недавние заметки, файлы, сохранённое и записи журнала.',
'overview.recentChanges': 'Недавние изменения',
'overview.recentChangesHint': 'Последняя важная активность в этом рабочем пространстве.',
'overview.recentFilter': 'Фильтр недавних изменений',
'overview.loadingRecent': 'Загрузка недавних изменений...',
'overview.noChanges': 'Для этого фильтра пока нет важных изменений.',
'overview.attentionHint': 'Неразобранное сохранённое, срочные задачи и возможные записи журнала.',
'overview.loadingPending': 'Загрузка ожидающих элементов...',
'overview.noPending': 'Нет неразобранного сохранённого, срочных задач или возможных записей журнала.',
'overview.keyResources': 'Ключевые ресурсы',
'overview.time.none': 'Время не указано',
'overview.time.now': 'Только что',
'overview.time.minutes': '{count} мин. назад',
'overview.time.hours': '{count} ч. назад',
'overview.time.yesterday': 'Вчера',
'overview.time.days': '{count} дн. назад',
};
+200
View File
@@ -0,0 +1,200 @@
import englishShellCatalog from './catalogs/en.js';
import russianShellCatalog from './catalogs/ru.js';
const LANGUAGE_PREFERENCES = new Set(['system', 'en', 'ru']);
const CONTRIBUTION_TEXT_FIELDS = {
views: 'title',
commands: 'title',
settingsPanels: 'title',
sidebarItems: 'title',
fileActions: 'label',
noteActions: 'label',
contextMenuEntries: 'label',
searchProviders: 'label',
statusBarItems: 'label',
openProviders: 'title',
workspaceItems: 'title',
};
function normalizeSystemLanguages(languages) {
const values = Array.isArray(languages) ? languages : [languages];
return values.map((value) => String(value || '').trim().toLowerCase()).filter(Boolean);
}
export function resolveLocale(preference, systemLanguages = []) {
if (!LANGUAGE_PREFERENCES.has(preference)) {
throw new Error(`unsupported language preference: ${preference}`);
}
if (preference === 'en' || preference === 'ru') return preference;
return normalizeSystemLanguages(systemLanguages).some((locale) => locale === 'ru' || locale.startsWith('ru-'))
? 'ru'
: 'en';
}
function interpolate(message, params) {
if (!params) return message;
return message.replace(/\{([A-Za-z0-9_.-]+)\}/g, (placeholder, name) => (
Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : placeholder
));
}
function browserLanguages() {
if (typeof navigator === 'undefined') return ['en'];
if (Array.isArray(navigator.languages) && navigator.languages.length > 0) return navigator.languages;
return [navigator.language || 'en'];
}
export function createI18n(options = {}) {
const shellCatalogs = options.shellCatalogs || { en: {}, ru: {} };
let systemLanguages = options.systemLanguages || browserLanguages;
let catalogLoader = options.loadPluginCatalog || (async () => ({}));
let preference = 'system';
let locale = 'en';
const listeners = new Set();
const pluginConfigs = new Map();
const pluginCatalogs = new Map();
function configure(next = {}) {
if (next.systemLanguages) systemLanguages = next.systemLanguages;
if (next.loadPluginCatalog) catalogLoader = next.loadPluginCatalog;
}
function notify() {
listeners.forEach((listener) => listener(locale));
}
async function initialize(initialPreference = 'system') {
preference = LANGUAGE_PREFERENCES.has(initialPreference) ? initialPreference : 'system';
locale = resolveLocale(preference, systemLanguages());
}
async function loadCatalog(pluginId, catalogLocale) {
if (!catalogLocale) return;
let catalogs = pluginCatalogs.get(pluginId);
if (!catalogs) {
catalogs = new Map();
pluginCatalogs.set(pluginId, catalogs);
}
if (catalogs.has(catalogLocale)) return;
const messages = await catalogLoader(pluginId, catalogLocale);
catalogs.set(catalogLocale, { ...(messages || {}) });
}
async function loadPlugin(pluginId, localization) {
if (!pluginId || !localization || !localization.defaultLocale || !localization.locales) return;
pluginConfigs.set(pluginId, localization);
const requested = localization.locales[locale] ? locale : '';
const defaultLocale = localization.defaultLocale;
await Promise.all(Array.from(new Set([requested, defaultLocale].filter(Boolean))).map((item) => loadCatalog(pluginId, item)));
}
async function setLanguagePreference(nextPreference) {
const nextLocale = resolveLocale(nextPreference, systemLanguages());
preference = nextPreference;
locale = nextLocale;
await Promise.all(Array.from(pluginConfigs.entries()).map(([pluginId, config]) => (
loadPlugin(pluginId, config).catch((error) => {
console.warn(`[i18n] failed to load ${pluginId} catalog for ${nextLocale}:`, error);
})
)));
notify();
}
function t(key, params, fallback) {
const message = shellCatalogs[locale]?.[key]
?? shellCatalogs.en?.[key]
?? fallback
?? key;
return interpolate(message, params);
}
function translatePlugin(pluginId, key, params, fallback) {
const config = pluginConfigs.get(pluginId);
const catalogs = pluginCatalogs.get(pluginId);
const message = catalogs?.get(locale)?.[key]
?? catalogs?.get(config?.defaultLocale)?.[key]
?? fallback
?? key;
return interpolate(message, params);
}
function localizeContributions(pluginId, contributions) {
if (!contributions) return contributions;
const localized = { ...contributions };
Object.entries(CONTRIBUTION_TEXT_FIELDS).forEach(([point, field]) => {
if (!Array.isArray(contributions[point])) return;
localized[point] = contributions[point].map((item) => ({
...item,
[field]: translatePlugin(
pluginId,
`contributions.${point}.${item.id}.${field}`,
undefined,
item[field],
),
}));
});
return localized;
}
function localizePlugin(plugin) {
if (!plugin) return plugin;
const hasStateWrapper = !!plugin.manifest;
const manifest = hasStateWrapper ? plugin.manifest : plugin;
const pluginId = manifest.id;
const localizedManifest = {
...manifest,
name: translatePlugin(pluginId, 'manifest.name', undefined, manifest.name),
description: translatePlugin(pluginId, 'manifest.description', undefined, manifest.description),
contributes: localizeContributions(pluginId, manifest.contributes),
};
return hasStateWrapper ? { ...plugin, manifest: localizedManifest } : localizedManifest;
}
function localizeContributionSummary(summary) {
if (!summary) return summary;
const localized = { ...summary };
Object.entries(CONTRIBUTION_TEXT_FIELDS).forEach(([point, field]) => {
if (!Array.isArray(summary[point])) return;
localized[point] = summary[point].map((item) => ({
...item,
[field]: translatePlugin(
item.pluginId,
`contributions.${point}.${item.id}.${field}`,
undefined,
item[field],
),
}));
});
return localized;
}
function subscribe(listener) {
listeners.add(listener);
listener(locale);
return () => listeners.delete(listener);
}
return {
configure,
initialize,
getLanguagePreference: () => preference,
getLocale: () => locale,
setLanguagePreference,
t,
subscribe,
loadPlugin,
translatePlugin,
localizeContributions,
localizePlugin,
localizeContributionSummary,
};
}
export const i18n = createI18n({
shellCatalogs: {
en: englishShellCatalog,
ru: russianShellCatalog,
},
});
export const t = (...args) => i18n.t(...args);
@@ -2,6 +2,7 @@
import { onMount, onDestroy } from 'svelte';
import * as App from '../../../wailsjs/go/api/App';
import Icon from '../ui/Icon.svelte';
import { i18n } from '../i18n/index.js';
import { createPluginAPI } from './VerstakPluginAPI.js';
@@ -18,6 +19,13 @@
let currentComponent = null;
let currentAPI = null;
let currentPropsKey = '';
let locale = i18n.getLocale();
let unsubscribeLocale = null;
$: tr = ((activeLocale) => (key, params, fallback) => {
void activeLocale;
return i18n.t(key, params, fallback);
})(locale);
$: activePluginId = pluginId || viewPluginId;
$: activeComponent = componentId;
@@ -32,9 +40,14 @@
}
onDestroy(() => {
unsubscribeLocale?.();
cleanup();
});
onMount(() => {
unsubscribeLocale = i18n.subscribe((nextLocale) => locale = nextLocale);
});
function cleanup() {
if (currentAPI && typeof currentAPI.dispose === 'function') {
try {
@@ -93,11 +106,17 @@
if (!info || info.status === 'no-frontend' || info.status === 'not-found') {
loadState = 'error';
errorText = info.status === 'no-frontend'
? 'Plugin has no frontend bundle'
: 'Plugin not found';
? tr('bundle.noFrontend')
: tr('bundle.notFound');
return;
}
try {
await i18n.loadPlugin(pId, info.localization);
} catch (catalogError) {
console.warn(`[PluginBundleHost] localization unavailable for ${pId}:`, catalogError);
}
// Check if bundle already loaded for this plugin
const reg = window.__VERSTAK_PLUGIN_REGISTRY__ || {};
if (!reg[pId]) {
@@ -106,7 +125,7 @@
const content = assetResult.value;
if (assetResult.error || !content) {
loadState = 'error';
errorText = 'Failed to load bundle: ' + (assetResult.error || 'empty content');
errorText = tr('bundle.loadFailed', { error: assetResult.error || tr('bundle.emptyContent') });
return;
}
@@ -117,7 +136,7 @@
fn();
} catch (e) {
loadState = 'error';
errorText = 'Bundle execution error: ' + e.message;
errorText = tr('bundle.executionError', { error: e.message });
console.error('[PluginBundleHost] bundle exec error:', e);
return;
}
@@ -125,7 +144,7 @@
// Verify registration happened
if (!window.__VERSTAK_PLUGIN_REGISTRY__[pId]) {
loadState = 'error';
errorText = 'Bundle loaded but no VerstakPluginRegister call detected';
errorText = tr('bundle.registrationMissing');
return;
}
}
@@ -135,8 +154,10 @@
const comp = components[compId];
if (!comp || !comp.mount) {
loadState = 'error';
errorText = 'Component "' + compId + '" not found in bundle. Available: '
+ (Object.keys(components).join(', ') || 'none');
errorText = tr('bundle.componentMissing', {
component: compId,
available: Object.keys(components).join(', ') || tr('common.none'),
});
return;
}
@@ -156,16 +177,16 @@
errorText = '';
} catch (e) {
loadState = 'error';
errorText = 'Component mount error: ' + e.message;
errorText = tr('bundle.mountError', { error: e.message });
console.error('[PluginBundleHost] mount error:', e);
}
} else {
loadState = 'error';
errorText = 'Mount container not available';
errorText = tr('bundle.mountUnavailable');
}
} catch (e) {
loadState = 'error';
errorText = 'Unexpected error: ' + (e.message || e);
errorText = tr('bundle.unexpectedError', { error: e.message || e });
console.error('[PluginBundleHost] error:', e);
}
}
@@ -180,22 +201,22 @@
<div class="plugin-bundle-host">
{#if loadState === 'idle'}
<div class="host-state idle">
<p>Select a plugin view from the sidebar</p>
<p>{tr('pluginView.select')}</p>
</div>
{:else if loadState === 'error'}
<div class="host-state error">
<Icon name="warning" size={24} class="error-icon" />
<p class="error-title">Plugin View Error</p>
<p class="error-title">{tr('pluginView.error')}</p>
<div class="error-details">
<p><strong>Plugin:</strong> {currentPluginId || 'unknown'}</p>
<p><strong>Component:</strong> {currentComponent || 'unknown'}</p>
<p class="error-message">{errorText || 'Unknown error'}</p>
<p><strong>{tr('common.plugin')}:</strong> {currentPluginId || tr('common.unknown')}</p>
<p><strong>{tr('common.component')}:</strong> {currentComponent || tr('common.unknown')}</p>
<p class="error-message">{errorText || tr('bundle.unknownError')}</p>
{#if pluginInfo}
<p class="error-meta">Frontend entry: {pluginInfo.entry || 'none'}</p>
<p class="error-meta">{tr('bundle.frontendEntry')}: {pluginInfo.entry || tr('common.none')}</p>
{/if}
{#if getComponentList().length > 0}
<p class="error-meta">Available components: {getComponentList().join(', ')}</p>
<p class="error-meta">{tr('bundle.availableComponents')}: {getComponentList().join(', ')}</p>
{/if}
</div>
</div>
@@ -204,7 +225,7 @@
{#if loadState === 'loading'}
<div class="host-state loading">
<div class="spinner"></div>
<p>Loading plugin bundle...</p>
<p>{tr('bundle.loading')}</p>
</div>
{/if}
<div
+272 -33
View File
@@ -1,4 +1,5 @@
import * as App from '../../../wailsjs/go/api/App';
import { i18n } from '../i18n/index.js';
window.__VERSTAK_PLUGIN_REGISTRY__ = window.__VERSTAK_PLUGIN_REGISTRY__ || {};
window.__VERSTAK_EVENT_HANDLERS__ = window.__VERSTAK_EVENT_HANDLERS__ || {};
@@ -71,10 +72,51 @@ function dispatchLocalEvent(pluginId, eventName, payload) {
});
}
function dispatchBackendEvent(event) {
if (!event || !event.name) return;
const handlers = getEventHandlers(event.name).slice();
handlers.forEach(function(handler) {
try {
handler(event);
} catch (e) {
console.error('[VerstakPluginAPI] backend event handler error:', e);
}
});
}
window.__VERSTAK_DISPATCH_BACKEND_EVENT__ = dispatchBackendEvent;
if (!window.__VERSTAK_BACKEND_EVENT_BRIDGE__ && window.runtime && typeof window.runtime.EventsOnMultiple === 'function') {
window.__VERSTAK_BACKEND_EVENT_BRIDGE__ = window.runtime.EventsOnMultiple('verstak:plugin-event', dispatchBackendEvent, -1);
}
function commandKey(pluginId, commandId) {
return pluginId + ':' + commandId;
}
export async function executePluginCommand(pluginId, cmdId, args) {
if (!pluginId) {
throw new Error('executePluginCommand requires pluginId');
}
if (!cmdId) {
throw new Error('executePluginCommand requires command id');
}
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
};
}
export function createPluginAPI(pluginId) {
if (!pluginId) {
throw new Error('createPluginAPI requires pluginId');
@@ -103,6 +145,33 @@ export function createPluginAPI(pluginId) {
return {
pluginId: pluginId,
i18n: {
getLocale: function() {
assertActive('i18n.getLocale');
return i18n.getLocale();
},
t: function(key, params, fallback) {
assertActive('i18n.t(' + key + ')');
return i18n.translatePlugin(pluginId, key, params, fallback);
},
onDidChangeLocale: function(listener) {
assertActive('i18n.onDidChangeLocale');
if (typeof listener !== 'function') {
throw new Error('i18n.onDidChangeLocale requires a listener function');
}
return trackCleanup(i18n.subscribe(listener));
}
},
ui: {
openSettings: function(panelId) {
assertActive('ui.openSettings');
window.dispatchEvent(new CustomEvent('verstak:open-settings', {
detail: { pluginId: pluginId, panelId: panelId || '' }
}));
}
},
capabilities: {
has: async function(capId) {
const info = await callBackend(pluginId, 'capabilities.has(' + capId + ')', function() {
@@ -127,7 +196,9 @@ export function createPluginAPI(pluginId) {
await callBackendErrorString(pluginId, 'events.publish(' + type + ')', function() {
return App.PublishPluginEvent(pluginId, type, payload || {});
});
dispatchLocalEvent(pluginId, type, payload || {});
if (!window.__VERSTAK_BACKEND_EVENT_BRIDGE__) {
dispatchLocalEvent(pluginId, type, payload || {});
}
},
subscribe: function(type, handler) {
assertActive('events.subscribe(' + type + ')');
@@ -165,12 +236,10 @@ export function createPluginAPI(pluginId) {
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 App.WritePluginSetting(pluginId, key, value);
});
return settings;
return this.read();
},
writeAll: function(settings) {
assertActive('settings.writeAll');
@@ -180,6 +249,87 @@ export function createPluginAPI(pluginId) {
}
},
storage: {
data: {
read: function(name) {
assertActive('storage.data.read(' + name + ')');
if (!name) {
throw new Error('storage.data.read requires a name');
}
return callBackend(pluginId, 'storage.data.read(' + name + ')', function() {
return App.ReadPluginDataJSON(pluginId, name);
}).then(function(data) {
return data || {};
});
},
write: function(name, data) {
assertActive('storage.data.write(' + name + ')');
if (!name) {
throw new Error('storage.data.write requires a name');
}
return callBackendErrorString(pluginId, 'storage.data.write(' + name + ')', function() {
return App.WritePluginDataJSON(pluginId, name, data || {});
});
}
}
},
secrets: {
status: function() {
assertActive('secrets.status');
return callBackend(pluginId, 'secrets.status', function() {
return App.PluginSecretsStatus(pluginId);
});
},
unlock: function(masterPassword) {
assertActive('secrets.unlock');
return callBackendErrorString(pluginId, 'secrets.unlock', function() {
return App.PluginSecretsUnlock(pluginId, String(masterPassword == null ? '' : masterPassword));
});
},
list: function() {
assertActive('secrets.list');
return callBackend(pluginId, 'secrets.list', function() {
return App.PluginSecretsList(pluginId);
}).then(function(records) {
return Array.isArray(records) ? records : [];
});
},
read: function(secretId) {
assertActive('secrets.read(' + secretId + ')');
if (!secretId) {
throw new Error('secrets.read requires a secret id');
}
return callBackend(pluginId, 'secrets.read(' + secretId + ')', function() {
return App.PluginSecretsRead(pluginId, secretId);
});
},
write: function(record) {
assertActive('secrets.write');
return callBackend(pluginId, 'secrets.write', function() {
return App.PluginSecretsWrite(pluginId, record || {});
});
},
delete: function(secretId) {
assertActive('secrets.delete(' + secretId + ')');
if (!secretId) {
throw new Error('secrets.delete requires a secret id');
}
return callBackendErrorString(pluginId, 'secrets.delete(' + secretId + ')', function() {
return App.PluginSecretsDelete(pluginId, secretId);
});
},
copyLink: function(secretId) {
assertActive('secrets.copyLink(' + secretId + ')');
if (!secretId) {
throw new Error('secrets.copyLink requires a secret id');
}
return callBackend(pluginId, 'secrets.copyLink(' + secretId + ')', function() {
return App.PluginSecretsCopyLink(pluginId, secretId);
});
}
},
files: {
list: function(relativeDir) {
assertActive('files.list');
@@ -199,12 +349,24 @@ export function createPluginAPI(pluginId) {
return App.ReadVaultTextFile(pluginId, relativePath);
});
},
readBytes: function(relativePath) {
assertActive('files.readBytes(' + relativePath + ')');
return callBackend(pluginId, 'files.readBytes(' + relativePath + ')', function() {
return App.ReadVaultFileBytes(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 || {});
});
},
writeBytes: function(relativePath, dataBase64, options) {
assertActive('files.writeBytes(' + relativePath + ')');
return callBackendErrorString(pluginId, 'files.writeBytes(' + relativePath + ')', function() {
return App.WriteVaultFileBytes(pluginId, relativePath, String(dataBase64 == null ? '' : dataBase64), options || {});
});
},
createFolder: function(relativePath) {
assertActive('files.createFolder(' + relativePath + ')');
return callBackendErrorString(pluginId, 'files.createFolder(' + relativePath + ')', function() {
@@ -222,23 +384,96 @@ export function createPluginAPI(pluginId) {
return callBackend(pluginId, 'files.trash(' + relativePath + ')', function() {
return App.TrashVaultPath(pluginId, relativePath);
});
},
listTrash: function() {
assertActive('files.listTrash');
return callBackend(pluginId, 'files.listTrash', function() {
return App.ListVaultTrash(pluginId);
});
},
restoreTrash: function(trashId, options) {
assertActive('files.restoreTrash(' + trashId + ')');
return callBackend(pluginId, 'files.restoreTrash(' + trashId + ')', function() {
return App.RestoreVaultTrash(pluginId, trashId, options || {});
});
},
deleteTrash: function(trashId) {
assertActive('files.deleteTrash(' + trashId + ')');
return callBackendErrorString(pluginId, 'files.deleteTrash(' + trashId + ')', function() {
return App.DeleteVaultTrash(pluginId, trashId);
});
},
openExternal: function(relativePath) {
assertActive('files.openExternal(' + relativePath + ')');
return callBackendErrorString(pluginId, 'files.openExternal(' + relativePath + ')', function() {
return App.OpenVaultPathExternal(pluginId, relativePath);
});
},
showInFolder: function(relativePath) {
assertActive('files.showInFolder(' + relativePath + ')');
return callBackendErrorString(pluginId, 'files.showInFolder(' + relativePath + ')', function() {
return App.ShowVaultPathInFolder(pluginId, relativePath);
});
}
},
backend: {
call: async function(method, ...args) {
assertActive('backend.call(' + method + ')');
try {
const App = window['go']?.['api']?.['App'];
if (!App || typeof App[method] !== 'function') {
throw new Error('Backend method not found: ' + method);
}
const result = await App[method](...args);
return result;
} catch (e) {
const message = e && e.message ? e.message : String(e);
throw new Error('[plugin:' + pluginId + '] backend.call(' + method + ') failed: ' + message);
}
sync: {
status: function() {
assertActive('sync.status');
return callBackend(pluginId, 'sync.status', function() {
return App.PluginSyncStatus(pluginId);
});
},
configure: function(serverURL, username, password) {
assertActive('sync.configure');
return callBackendErrorString(pluginId, 'sync.configure', function() {
return App.PluginSyncConfigure(pluginId, serverURL || '', username || '', password || '');
});
},
disconnect: function() {
assertActive('sync.disconnect');
return callBackendErrorString(pluginId, 'sync.disconnect', function() {
return App.PluginSyncDisconnect(pluginId);
});
},
testConnection: function(serverURL, username, password) {
assertActive('sync.testConnection');
return callBackendErrorString(pluginId, 'sync.testConnection', function() {
return App.PluginSyncTestConnection(pluginId, serverURL || '', username || '', password || '');
});
},
setInterval: function(minutes) {
assertActive('sync.setInterval');
return callBackendErrorString(pluginId, 'sync.setInterval', function() {
return App.PluginSyncSetInterval(pluginId, Number(minutes) || 0);
});
},
resetKey: function() {
assertActive('sync.resetKey');
return callBackendErrorString(pluginId, 'sync.resetKey', function() {
return App.PluginSyncResetKey(pluginId);
});
},
now: function() {
assertActive('sync.now');
return callBackend(pluginId, 'sync.now', function() {
return App.PluginSyncNow(pluginId);
});
}
},
browserReceiver: {
pairing: function() {
assertActive('browserReceiver.pairing');
return callBackend(pluginId, 'browserReceiver.pairing', function() {
return App.PluginBrowserReceiverPairing(pluginId);
});
},
rotateToken: function() {
assertActive('browserReceiver.rotateToken');
return callBackend(pluginId, 'browserReceiver.rotateToken', function() {
return App.PluginRotateBrowserReceiverToken(pluginId);
});
}
},
@@ -261,6 +496,19 @@ export function createPluginAPI(pluginId) {
}
},
contributions: {
list: async function(point) {
assertActive('contributions.list');
const summary = await callBackend(pluginId, 'contributions.list', function() {
return App.GetContributions();
});
if (!point) {
return summary || {};
}
return Array.isArray((summary || {})[point]) ? summary[point] : [];
}
},
commands: {
register: function(cmdId, handler) {
assertActive('commands.register(' + cmdId + ')');
@@ -284,20 +532,11 @@ export function createPluginAPI(pluginId) {
},
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
};
return executePluginCommand(pluginId, cmdId, args || {});
},
executeFor: async function(targetPluginId, cmdId, args) {
assertActive('commands.executeFor(' + targetPluginId + ':' + cmdId + ')');
return executePluginCommand(targetPluginId, cmdId, args || {});
}
},
@@ -1,5 +1,7 @@
<script>
import Icon from '../ui/Icon.svelte';
import { onDestroy } from 'svelte';
import { i18n } from '../i18n/index.js';
export let p = {};
export let capabilities = [];
export let permissions = [];
@@ -8,6 +10,13 @@
export let settingsPanels = [];
export let onEnable = () => {};
export let onDisable = () => {};
let locale = i18n.getLocale();
const unsubscribeLocale = i18n.subscribe((nextLocale) => { locale = nextLocale; });
$: tr = ((activeLocale) => (key, params, fallback) => {
void activeLocale;
return i18n.t(key, params, fallback);
})(locale);
onDestroy(unsubscribeLocale);
$: m = p.manifest || {};
$: pluginId = m.id || 'unknown';
@@ -30,19 +39,23 @@
$: contribCounts = {
views: (contributions.views || []).filter(v => v.pluginId === pluginId).length,
commands: (contributions.commands || []).filter(c => c.pluginId === pluginId).length,
searchProviders: (contributions.searchProviders || []).filter(s => s.pluginId === pluginId).length,
sidebar: (contributions.sidebarItems || []).filter(s => s.pluginId === pluginId).length,
statusbar: (contributions.statusBarItems || []).filter(s => s.pluginId === pluginId).length,
openProviders: (contributions.openProviders || []).filter(o => o.pluginId === pluginId).length,
workspaceItems: (contributions.workspaceItems || []).filter(w => w.pluginId === pluginId).length,
};
$: contribSummary = (() => {
const parts = [];
if (contribCounts.views > 0) parts.push(contribCounts.views + ' view' + (contribCounts.views !== 1 ? 's' : ''));
if (contribCounts.commands > 0) parts.push(contribCounts.commands + ' command' + (contribCounts.commands !== 1 ? 's' : ''));
if (contribCounts.sidebar > 0) parts.push(contribCounts.sidebar + ' sidebar' + (contribCounts.sidebar !== 1 ? 's' : ''));
if (contribCounts.statusbar > 0) parts.push(contribCounts.statusbar + ' statusbar' + (contribCounts.statusbar !== 1 ? 's' : ''));
if (contribCounts.openProviders > 0) parts.push(contribCounts.openProviders + ' openProvider' + (contribCounts.openProviders !== 1 ? 's' : ''));
return parts.length > 0 ? parts.join(', ') : 'none';
if (contribCounts.views > 0) parts.push(tr('pluginCard.count.views', { count: contribCounts.views }));
if (contribCounts.commands > 0) parts.push(tr('pluginCard.count.commands', { count: contribCounts.commands }));
if (contribCounts.searchProviders > 0) parts.push(tr('pluginCard.count.searchProviders', { count: contribCounts.searchProviders }));
if (contribCounts.sidebar > 0) parts.push(tr('pluginCard.count.sidebar', { count: contribCounts.sidebar }));
if (contribCounts.statusbar > 0) parts.push(tr('pluginCard.count.statusbar', { count: contribCounts.statusbar }));
if (contribCounts.openProviders > 0) parts.push(tr('pluginCard.count.openProviders', { count: contribCounts.openProviders }));
if (contribCounts.workspaceItems > 0) parts.push(tr('pluginCard.count.workspace', { count: contribCounts.workspaceItems }));
return parts.length > 0 ? parts.join(', ') : tr('common.none');
})();
$: dangerousPermissions = (m.permissions || []).filter(name => {
@@ -77,11 +90,11 @@
<strong>{pluginId}</strong>
<span class="version">v{m.version || '?'}</span>
</div>
<span class="status-badge" style="color: {statusColor}">{p.status}</span>
<span class="status-badge" style="color: {statusColor}">{tr(`status.${p.status}`, undefined, p.status)}</span>
</div>
{#if p.status === 'degraded'}
<p class="degraded-text">Plugin is usable, but some optional capabilities are unavailable.</p>
<p class="degraded-text">{tr('pluginCard.degraded')}</p>
{/if}
{#if m.description}
@@ -90,40 +103,44 @@
<div class="card-meta">
<div class="meta-row">
<span class="label">Name:</span>
<span class="label">{tr('pluginCard.name')}:</span>
<span>{m.name || '-'}</span>
</div>
<div class="meta-row">
<span class="label">API Version:</span>
<span>{m.apiVersion || '-'}</span>
<span class="label">{tr('common.source')}:</span>
<span>{m.source || tr('common.unknown')}</span>
</div>
<div class="meta-row">
<span class="label">Source:</span>
<span>{m.source || 'unknown'}</span>
</div>
<div class="meta-row">
<span class="label">Root:</span>
<span class="path">{p.rootPath || '-'}</span>
</div>
<div class="meta-row">
<span class="label">Contributions:</span>
<span class="label">{tr('pluginCard.contributions')}:</span>
<span>{contribSummary}</span>
</div>
</div>
<!-- Capabilities -->
<div class="section">
<span class="section-title">Provides</span>
<div class="tags">
{#each m.provides || [] as cap}
<span class="tag provides">{cap}</span>
{/each}
<details class="plugin-details">
<summary>{tr('pluginCard.technicalDetails')}</summary>
<div class="card-meta technical-meta">
<div class="meta-row">
<span class="label">{tr('pluginCard.apiVersion')}:</span>
<span>{m.apiVersion || '-'}</span>
</div>
<div class="meta-row">
<span class="label">{tr('pluginCard.root')}:</span>
<span class="path">{p.rootPath || '-'}</span>
</div>
</div>
</div>
{#if m.requires && m.requires.length > 0}
<div class="section">
<span class="section-title">Requires</span>
<span class="section-title">{tr('pluginCard.provides')}</span>
<div class="tags">
{#each m.provides || [] as cap}
<span class="tag provides">{cap}</span>
{/each}
</div>
</div>
{#if m.requires && m.requires.length > 0}
<div class="section">
<span class="section-title">{tr('pluginCard.requires')}</span>
<div class="tags">
{#each m.requires as req}
{@const found = capabilities.some(c => c.name === req)}
@@ -134,14 +151,14 @@
{/each}
</div>
{#if missingRequired.length > 0}
<p class="warning"><Icon name="warning" size={12} /> Missing required capabilities: {missingRequired.join(', ')}</p>
<p class="warning"><Icon name="warning" size={12} /> {tr('pluginCard.missingRequired')}: {missingRequired.join(', ')}</p>
{/if}
</div>
{/if}
{/if}
{#if m.optionalRequires && m.optionalRequires.length > 0}
{#if m.optionalRequires && m.optionalRequires.length > 0}
<div class="section">
<span class="section-title">Optional Requires</span>
<span class="section-title">{tr('pluginCard.optionalRequires')}</span>
<div class="tags">
{#each m.optionalRequires as opt}
{@const found = capabilities.some(c => c.name === opt)}
@@ -152,15 +169,14 @@
{/each}
</div>
{#if missingOptional.length > 0}
<p class="info"><Icon name="warning" size={12} /> Optional capabilities not available — plugin running in degraded mode</p>
<p class="info"><Icon name="warning" size={12} /> {tr('pluginCard.optionalUnavailable')}</p>
{/if}
</div>
{/if}
{/if}
<!-- Permissions -->
{#if m.permissions && m.permissions.length > 0}
{#if m.permissions && m.permissions.length > 0}
<div class="section">
<span class="section-title">Permissions</span>
<span class="section-title">{tr('pluginCard.permissions')}</span>
<div class="tags">
{#each m.permissions as perm}
{@const isDangerous = dangerousPermissions.includes(perm)}
@@ -171,7 +187,8 @@
{/each}
</div>
</div>
{/if}
{/if}
</details>
<!-- Error -->
{#if p.error}
@@ -182,28 +199,28 @@
<div class="card-actions">
{#if hasSettingsPanel}
<button class="btn-settings" on:click={() => window.dispatchEvent(new CustomEvent('verstak:open-settings', { detail: { pluginId: m.id, panelId: settingsPanels[0]?.id } }))} type="button" disabled={isDisabled || p.status === 'failed'}>
<Icon name="gear" size={14} /> Settings
<Icon name="gear" size={14} /> {tr('settings.title')}
</button>
{/if}
{#if vaultOpen && canToggle}
{#if isDisabled}
<button class="btn-enable" on:click={() => onEnable(m.id)} type="button" disabled={isBusy}>
{#if busyAction === 'enabling'}⟳ Enabling...{:else}▶ Enable{/if}
{#if busyAction === 'enabling'}{tr('pluginCard.enabling')}{:else}{tr('pluginCard.enable')}{/if}
</button>
{:else}
<button class="btn-disable" on:click={() => onDisable(m.id)} type="button" disabled={isBusy}>
{#if busyAction === 'disabling'}⟳ Disabling...{:else}⏸ Disable{/if}
{#if busyAction === 'disabling'}{tr('pluginCard.disabling')}{:else}{tr('pluginCard.disable')}{/if}
</button>
{/if}
{/if}
{#if !vaultOpen && canToggle}
<span class="vault-hint">Open a vault to manage plugin state</span>
<span class="vault-hint">{tr('pluginCard.openVault')}</span>
{/if}
</div>
<!-- Permission warnings -->
{#if !hasUIPermission && m.contributes && ((m.contributes.views || []).length > 0 || (m.contributes.sidebarItems || []).length > 0 || (m.contributes.settingsPanels || []).length > 0)}
<p class="warning"><Icon name="warning" size={12} /> Plugin has UI contributions but lacks ui.register permission</p>
<p class="warning"><Icon name="warning" size={12} /> {tr('pluginCard.missingUiPermission')}</p>
{/if}
</div>
@@ -285,6 +302,24 @@
font-size: 0.8rem;
}
.technical-meta {
margin-top: 0.55rem;
}
.plugin-details {
margin: 0.6rem 0;
padding: 0.45rem 0;
border-top: 1px solid rgba(15, 52, 96, 0.75);
border-bottom: 1px solid rgba(15, 52, 96, 0.75);
}
.plugin-details summary {
cursor: pointer;
color: #8b8ba8;
font-size: 0.78rem;
font-weight: 600;
}
.meta-row {
display: flex;
gap: 0.5rem;
@@ -2,9 +2,10 @@
import Icon from '../ui/Icon.svelte';
import PluginCard from './PluginCard.svelte';
import PluginBundleHost from '../plugin-host/PluginBundleHost.svelte';
import { onMount, tick } from 'svelte';
import { onDestroy, onMount, tick } from 'svelte';
import { GetPlugins, GetCapabilities, GetPermissions, GetContributions, ReloadPlugins, GetVaultStatus, GetVaultPluginState, EnablePlugin, DisablePlugin, ReadPluginSettings, WritePluginSettings, GetPluginFrontendInfo, WriteFrontendLog } from '../../../wailsjs/go/api/App';
import { debug } from '../log/debug.js';
import { i18n } from '../i18n/index.js';
let plugins = [];
let capabilities = [];
@@ -20,6 +21,12 @@
let settingsError = null;
let settingsPluginInfo = null;
let lastOpenedKey = '';
let locale = i18n.getLocale();
let unsubscribeLocale = null;
$: tr = ((activeLocale) => (key, params, fallback) => {
void activeLocale;
return i18n.t(key, params, fallback);
})(locale);
// Per-action loading state — shows feedback on specific buttons without hiding the whole list
let actionFeedback = {}; // { [pluginId]: 'enabling' | 'disabling' | null }
@@ -30,10 +37,10 @@
export let activeSettingsPluginId = '';
export let activeSettingsPanelId = '';
$: if (activeSettingsPluginId && activeSettingsPanelId) {
const key = `${activeSettingsPluginId}:${activeSettingsPanelId}`;
if (key !== lastOpenedKey) {
lastOpenedKey = key;
$: if (activeSettingsPluginId) {
const settingsPanelCount = (contributions.settingsPanels || []).length;
const key = `${activeSettingsPluginId}:${activeSettingsPanelId || '*'}`;
if (key !== lastOpenedKey || settingsPanelCount === 0) {
openSettingsFromProps(activeSettingsPluginId, activeSettingsPanelId);
}
}
@@ -71,6 +78,7 @@
async function openSettingsFromProps(pluginId, panelId) {
const panel = (contributions.settingsPanels || []).find(sp => sp.pluginId === pluginId && (!panelId || sp.id === panelId));
if (panel) {
lastOpenedKey = `${pluginId}:${panelId || '*'}`;
settingsPanel = panel;
settingsPluginId = pluginId;
settingsError = null;
@@ -108,7 +116,10 @@
try {
debug.log('[PluginManager] loadAll: calling GetPlugins...');
const p = await GetPlugins();
plugins = p || [];
await Promise.all((p || []).map((plugin) => (
i18n.loadPlugin(plugin.manifest?.id, plugin.manifest?.localization).catch(() => {})
)));
plugins = (p || []).map((plugin) => i18n.localizePlugin(plugin));
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);
@@ -132,7 +143,7 @@
vaultStatus = v || { status: 'unknown', path: '', vaultId: '' };
capabilities = caps || [];
permissions = perms || [];
contributions = contribs || {};
contributions = i18n.localizeContributionSummary(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) {
@@ -155,7 +166,18 @@
WriteFrontendLog('PluginManager', 'loadAll: END, loading=false');
}
onMount(() => { loadAll(); });
onMount(() => {
unsubscribeLocale = i18n.subscribe((nextLocale) => {
const changed = locale !== nextLocale;
locale = nextLocale;
if (changed) loadAll();
});
loadAll();
});
onDestroy(() => {
if (unsubscribeLocale) unsubscribeLocale();
});
async function reload() {
debug.log('[PluginManager] reload: START');
@@ -226,6 +248,25 @@
$: totalPlugins = plugins.length;
$: totalCaps = capabilities.length;
$: totalPerms = permissions.length;
$: statusSummary = computeStatusSummary(plugins);
$: elevatedPermissionPluginCount = computeElevatedPermissionPluginCount(plugins, permissions);
function computeStatusSummary(pluginRows) {
return pluginRows.reduce((summary, plugin) => {
const status = plugin?.status || 'unknown';
if (status === 'loaded') summary.loaded += 1;
else if (status === 'degraded') summary.degraded += 1;
else if (status === 'failed' || status === 'incompatible' || status === 'missing-required-capability') summary.failed += 1;
else if (status === 'disabled' || plugin?.enabled === false) summary.disabled += 1;
else summary.other += 1;
return summary;
}, { loaded: 0, degraded: 0, failed: 0, disabled: 0, other: 0 });
}
function computeElevatedPermissionPluginCount(pluginRows, permissionRows) {
const dangerous = new Set((permissionRows || []).filter(permission => permission.dangerous).map(permission => permission.name));
return (pluginRows || []).filter(plugin => (plugin?.manifest?.permissions || []).some(permission => dangerous.has(permission))).length;
}
function closeSettings() {
settingsPanel = null;
@@ -252,31 +293,50 @@
<header>
<div class="header-left">
<h2>Plugin Manager</h2>
<h2>{tr('settings.pluginManager')}</h2>
{#if vaultStatus.status !== 'unknown'}
<span class="vault-badge" class:vault-open={vaultStatus.status === 'open'} class:vault-not-created={vaultStatus.status === 'not-created'} class:vault-closed={vaultStatus.status === 'closed'} class:vault-error={vaultStatus.status === 'error'}>
Vault: {vaultStatus.status}{#if vaultStatus.path} ({vaultStatus.path}){/if}
{tr('vault.label', { status: tr(`vault.status.${vaultStatus.status}`, undefined, vaultStatus.status) })}{#if vaultStatus.path} ({vaultStatus.path}){/if}
</span>
{/if}
</div>
<button class="reload-btn" on:click={reload} type="button" disabled={loading || reloading}>
{reloading ? '⟳ Reloading...' : '⟳ Reload'}
{reloading ? tr('pluginManager.reloading') : tr('pluginManager.reload')}
</button>
</header>
{#if loading}
<div class="loading">Scanning plugin directories...</div>
<div class="loading">{tr('pluginManager.scanning')}</div>
{:else if error}
<div class="error">
<Icon name="warning" size={24} class="error-icon" />
<div class="error-message">{error}</div>
<button class="retry-btn" on:click={loadAll} type="button">⟳ Retry</button>
<button class="retry-btn" on:click={loadAll} type="button">{tr('common.retry')}</button>
</div>
{:else}
<div class="summary">
<span class="badge">{totalPlugins} plugin(s) discovered</span>
<span class="badge">{totalCaps} capabilities registered</span>
<span class="badge">{totalPerms} permissions known</span>
<span class="badge">{tr('pluginManager.summary.plugins', { count: totalPlugins })}</span>
<span class="badge">{tr('pluginManager.summary.capabilities', { count: totalCaps })}</span>
<span class="badge">{tr('pluginManager.summary.permissions', { count: totalPerms })}</span>
</div>
<div class="scan-summary" aria-label={tr('pluginManager.scanSummary')}>
<section class="scan-card" data-plugin-manager-summary="health">
<div class="scan-card-title">{tr('pluginManager.health')}</div>
<div class="scan-metrics">
<span data-plugin-status-summary="loaded"><strong>{statusSummary.loaded}</strong> {tr('status.loaded')}</span>
<span data-plugin-status-summary="degraded"><strong>{statusSummary.degraded}</strong> {tr('status.degraded')}</span>
<span data-plugin-status-summary="failed"><strong>{statusSummary.failed}</strong> {tr('status.failed')}</span>
<span data-plugin-status-summary="disabled"><strong>{statusSummary.disabled}</strong> {tr('status.disabled')}</span>
</div>
</section>
<section class="scan-card" data-plugin-manager-summary="risk">
<div class="scan-card-title">{tr('pluginManager.permissionRisk')}</div>
<div class="scan-metrics">
<span data-plugin-risk-summary="elevated-permissions">{tr('pluginManager.elevatedPermissions', { count: elevatedPermissionPluginCount })}</span>
</div>
</section>
</div>
{#if plugins.length === 0 && missingInstalled.length === 0}
@@ -286,13 +346,13 @@
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
</div>
<p>No plugins found</p>
<p class="hint">Plugin directories scanned:</p>
<p>{tr('pluginManager.none')}</p>
<p class="hint">{tr('pluginManager.scannedDirs')}</p>
<ul class="hint-list">
<li><code>~/.config/verstak/plugins/</code> — user plugins</li>
<li><code>./plugins/</code> — bundled plugins (app directory)</li>
<li><code>~/.config/verstak/plugins/</code> — {tr('pluginManager.userPlugins')}</li>
<li><code>./plugins/</code> — {tr('pluginManager.bundledPlugins')}</li>
</ul>
<p class="hint">Place a plugin folder with <code>plugin.json</code> in one of these directories and click Reload.</p>
<p class="hint">{tr('pluginManager.installHint')}</p>
</div>
{:else}
<div class="plugin-list">
@@ -304,8 +364,8 @@
{#if missingInstalled.length > 0}
<div class="missing-section">
<h3>Missing Installed Plugins</h3>
<p class="missing-hint">These plugins are required by this vault but their packages are not installed locally.</p>
<h3>{tr('pluginManager.missingTitle')}</h3>
<p class="missing-hint">{tr('pluginManager.missingHint')}</p>
<div class="plugin-list">
{#each missingInstalled as mp}
<div class="plugin-card missing-card">
@@ -315,12 +375,12 @@
<strong>{mp.id}</strong>
{#if mp.version}<span class="version">v{mp.version}</span>{/if}
</div>
<span class="status-badge" style="color: #e94560">missing</span>
<span class="status-badge" style="color: #e94560">{tr('status.missing')}</span>
</div>
<p class="missing-text">
This plugin is listed in the vault's desired plugins but the package is not installed.
{tr('pluginManager.missingPackage')}
{#if mp.source && mp.source !== 'unknown'}
<span class="source-hint">Source: {mp.source}</span>
<span class="source-hint">{tr('common.source')}: {mp.source}</span>
{/if}
</p>
</div>
@@ -331,10 +391,10 @@
{#if capabilities.length > 0}
<details class="registry-section">
<summary>Capability Registry ({totalCaps})</summary>
<summary>{tr('pluginManager.capabilityRegistry', { count: totalCaps })}</summary>
<table>
<thead>
<tr><th>Capability</th><th>Provider</th><th>Source</th><th>Status</th></tr>
<tr><th>{tr('common.capability')}</th><th>{tr('common.provider')}</th><th>{tr('common.source')}</th><th>{tr('common.status')}</th></tr>
</thead>
<tbody>
{#each capabilities as cap}
@@ -355,9 +415,9 @@
{#key `settings-${settingsPluginId}`}
{#if settingsError}
<div class="modal-overlay" on:click|self={closeSettings} on:keydown|self={(e) => e.key === 'Escape' && closeSettings()} role="presentation">
<div class="modal" role="dialog" aria-modal="true" aria-label="Settings Error">
<div class="modal" role="dialog" aria-modal="true" aria-label={tr('pluginManager.settingsError')}>
<div class="modal-header">
<h3>Settings Error</h3>
<h3>{tr('pluginManager.settingsError')}</h3>
<button class="modal-close" on:click={closeSettings} type="button">✕</button>
</div>
<div class="modal-body">
@@ -367,21 +427,21 @@
</div>
{:else if settingsPanel}
<div class="modal-overlay" on:click|self={closeSettings} on:keydown|self={(e) => e.key === 'Escape' && closeSettings()} role="presentation">
<div class="modal" role="dialog" aria-modal="true" aria-label="Plugin Settings">
<div class="modal" role="dialog" aria-modal="true" aria-label={tr('pluginManager.pluginSettings')}>
<div class="modal-header">
<h3>{settingsPanel.title}</h3>
<button class="modal-close" on:click={closeSettings} type="button">✕</button>
</div>
<div class="modal-body">
<p class="settings-hint">Plugin: <code>{settingsPluginId}</code></p>
<p class="settings-hint">{tr('common.plugin')}: <code>{settingsPluginId}</code></p>
{#if settingsPluginInfo && settingsPluginInfo.entry}
<PluginBundleHost
pluginId={settingsPluginId}
componentId={settingsPanel.component || settingsPanel.id}
/>
{:else}
<p class="settings-hint">Component: <code>{settingsPanel.component}</code></p>
<p class="placeholder">Settings panel frontend bundle not available</p>
<p class="settings-hint">{tr('common.component')}: <code>{settingsPanel.component}</code></p>
<p class="placeholder">{tr('pluginManager.settingsBundleUnavailable')}</p>
{/if}
</div>
</div>
@@ -470,6 +530,47 @@
background: #16213e; padding: 0.25rem 0.75rem; border-radius: 12px;
font-size: 0.8rem; color: #a0a0b8; border: 1px solid #0f3460;
}
.scan-summary {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr);
gap: 0.75rem;
margin-bottom: 1rem;
}
.scan-card {
min-width: 0;
padding: 0.75rem;
border: 1px solid #0f3460;
border-radius: 8px;
background: #121a2c;
}
.scan-card-title {
color: #e0e0f0;
font-size: 0.82rem;
font-weight: 700;
margin-bottom: 0.5rem;
}
.scan-metrics {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.45rem;
}
.scan-metrics span {
min-height: 1.55rem;
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.18rem 0.5rem;
border: 1px solid rgba(78, 204, 163, 0.16);
border-radius: 5px;
color: #a0a0b8;
background: #101626;
font-size: 0.76rem;
}
.scan-metrics strong {
color: #f4f7fb;
font-size: 0.88rem;
}
.empty {
padding: 2rem; text-align: center; color: #a0a0b8;
background: #16213e; border-radius: 8px; border: 1px dashed #0f3460;
@@ -513,7 +614,7 @@
}
.modal {
background: #16213e; border: 1px solid #0f3460; border-radius: 8px;
width: 480px; max-width: 90vw; max-height: 80vh; display: flex; flex-direction: column;
width: min(880px, calc(100vw - 4rem)); max-width: calc(100vw - 4rem); height: min(680px, calc(100vh - 4rem)); max-height: calc(100vh - 4rem); display: flex; flex-direction: column;
}
.modal-header {
display: flex; align-items: center; justify-content: space-between;
@@ -522,7 +623,8 @@
.modal-header h3 { margin: 0; color: #e0e0f0; font-size: 1.1rem; }
.modal-close { background: none; border: none; color: #a0a0b8; font-size: 1.2rem; cursor: pointer; padding: 0.2rem 0.5rem; }
.modal-close:hover { color: #e94560; }
.modal-body { padding: 1rem; overflow-y: auto; }
.modal-body { padding: 1rem; overflow: auto; min-height: 0; flex: 1; display: flex; flex-direction: column; }
.modal-body :global(.plugin-bundle-host) { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.settings-hint { color: #666; font-size: 0.8rem; margin: 0.25rem 0; }
.settings-hint code { color: #4ecca3; }
@@ -540,8 +642,13 @@
width: 100%;
}
.scan-summary {
grid-template-columns: 1fr;
}
.modal {
width: min(480px, calc(100vw - 2rem));
width: min(880px, calc(100vw - 2rem));
height: min(680px, calc(100vh - 2rem));
max-height: calc(100vh - 2rem);
}
}
@@ -0,0 +1,409 @@
<script>
import { onDestroy, onMount, tick } from 'svelte';
import * as App from '../../../wailsjs/go/api/App';
import { executePluginCommand } from '../plugin-host/VerstakPluginAPI.js';
import { i18n } from '../i18n/index.js';
let open = false;
let query = '';
let commands = [];
let selectedIndex = 0;
let inputEl = null;
let statusMessage = '';
let statusType = '';
let locale = i18n.getLocale();
let unsubscribeLocale = null;
$: tr = ((activeLocale) => (key, params, fallback) => {
void activeLocale;
return i18n.t(key, params, fallback);
})(locale);
const inactiveStatuses = new Set(['disabled', 'failed', 'incompatible', 'missing-required-capability']);
$: shellCommands = [
{ id: 'verstak.shell.open-overview', title: tr('command.openOverview'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 10, shellAction: 'overview' },
{ id: 'verstak.shell.open-files', title: tr('command.openFiles'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 20, shellAction: 'files' },
{ id: 'verstak.shell.open-activity', title: tr('command.openActivity'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 30, shellAction: 'activity' },
{ id: 'verstak.shell.open-browser-inbox', title: tr('command.openBrowserInbox'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 40, shellAction: 'browser-inbox' },
{ id: 'verstak.shell.create-markdown', title: tr('command.createMarkdown'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 50, shellAction: 'create-markdown' },
{ id: 'verstak.shell.create-text', title: tr('command.createText'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 60, shellAction: 'create-text' },
{ id: 'verstak.shell.sync-now', title: tr('command.syncNow'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 70, shellAction: 'sync-now' },
{ id: 'verstak.shell.open-sync-settings', title: tr('command.openSyncSettings'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 80, shellAction: 'sync-settings' },
{ id: 'verstak.shell.open-plugin-manager', title: tr('command.openPluginManager'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 90, shellAction: 'plugin-manager' },
];
$: normalizedQuery = query.trim().toLowerCase();
$: filteredCommands = commands.filter((command) => {
if (!normalizedQuery) return true;
return [
command.title,
command.id,
command.pluginId,
command.pluginName,
].filter(Boolean).join(' ').toLowerCase().includes(normalizedQuery);
});
$: if (selectedIndex >= filteredCommands.length) {
selectedIndex = Math.max(0, filteredCommands.length - 1);
}
async function loadCommands() {
const [plugins, contributions] = await Promise.all([
App.GetPlugins().catch(() => []),
App.GetContributions().catch(() => ({})),
]);
const pluginById = new Map((plugins || []).map((plugin) => [plugin.manifest?.id, plugin]));
await Promise.all((plugins || []).map((plugin) => i18n.loadPlugin(
plugin.manifest?.id,
plugin.manifest?.localization,
).catch(() => {})));
const localizedPlugins = (plugins || []).map((plugin) => i18n.localizePlugin(plugin));
const localizedPluginById = new Map(localizedPlugins.map((plugin) => [plugin.manifest?.id, plugin]));
const localizedContributions = i18n.localizeContributionSummary(contributions || {});
const pluginCommands = (localizedContributions.commands || [])
.filter((command) => {
const plugin = pluginById.get(command.pluginId);
if (!plugin) return false;
return !inactiveStatuses.has(plugin.status);
})
.map((command) => {
const plugin = localizedPluginById.get(command.pluginId);
return {
...command,
pluginName: plugin?.manifest?.name || command.pluginId,
priority: 1000,
};
});
commands = [...shellCommands, ...pluginCommands].sort((a, b) => {
const priority = (a.priority || 1000) - (b.priority || 1000);
if (priority) return priority;
const title = String(a.title || a.id).localeCompare(String(b.title || b.id));
if (title) return title;
return String(a.pluginId).localeCompare(String(b.pluginId));
});
}
async function openPalette() {
await loadCommands();
query = '';
selectedIndex = 0;
open = true;
await tick();
inputEl?.focus();
}
function closePalette() {
open = false;
query = '';
selectedIndex = 0;
}
function setStatus(type, message) {
statusType = type;
statusMessage = message;
window.clearTimeout(setStatus.timer);
setStatus.timer = window.setTimeout(() => {
statusType = '';
statusMessage = '';
}, 4000);
}
function clickWorkspaceTool(label) {
const tabs = Array.from(document.querySelectorAll('.workspace-tabs [role="tab"]'));
const tab = tabs.find((node) => String(node.textContent || '').trim().toLowerCase() === label);
if (tab) {
tab.click();
return true;
}
return false;
}
async function openWorkspaceTool(label) {
if (clickWorkspaceTool(label)) return;
const selectedWorkspace = document.querySelector('.wt-node.selected .wt-label');
const firstWorkspace = document.querySelector('.wt-label');
const workspaceButton = selectedWorkspace || firstWorkspace;
if (workspaceButton) {
workspaceButton.click();
await tick();
await new Promise((resolve) => requestAnimationFrame(resolve));
}
clickWorkspaceTool(label);
}
async function startFilesCreate(action) {
await openWorkspaceTool('files');
await tick();
await new Promise((resolve) => requestAnimationFrame(resolve));
const button = document.querySelector(`[data-files-action="${action}"]`);
if (!button) {
throw new Error(`Files action not available: ${action}`);
}
button.click();
}
async function runShellCommand(command) {
if (command.shellAction === 'plugin-manager') {
window.dispatchEvent(new CustomEvent('verstak:open-settings', { detail: {} }));
return;
}
if (command.shellAction === 'sync-settings') {
window.dispatchEvent(new CustomEvent('verstak:open-settings', {
detail: { pluginId: 'verstak.sync', panelId: 'verstak.sync.settings' }
}));
return;
}
if (command.shellAction === 'sync-now') {
const result = await App.PluginSyncNow('verstak.sync');
if (typeof result === 'string' && result) {
throw new Error(result);
}
if (Array.isArray(result) && result[1]) {
throw new Error(result[1]);
}
return;
}
if (command.shellAction === 'create-markdown') {
await startFilesCreate('new-markdown');
return;
}
if (command.shellAction === 'create-text') {
await startFilesCreate('new-text');
return;
}
const actionToTab = {
overview: 'overview',
files: 'files',
activity: 'activity',
'browser-inbox': 'browser inbox',
};
await openWorkspaceTool(actionToTab[command.shellAction] || command.shellAction);
}
async function runCommand(command) {
if (!command) return;
try {
if (command.shellAction) {
await runShellCommand(command);
closePalette();
setStatus('success', tr('command.handled', { title: command.title || command.id }));
return;
}
const result = await executePluginCommand(command.pluginId, command.id, {
source: 'command-palette',
});
closePalette();
setStatus('success', tr('command.result', { title: command.title || command.id, status: result.status || tr('command.statusHandled') }));
} catch (err) {
setStatus('error', `${command.title || command.id}: ${err?.message || String(err)}`);
}
}
function moveSelection(delta) {
if (filteredCommands.length === 0) return;
selectedIndex = (selectedIndex + delta + filteredCommands.length) % filteredCommands.length;
}
function onWindowKeydown(event) {
const key = event.key || '';
const comboOpen = (event.ctrlKey || event.metaKey) && (key.toLowerCase() === 'k' || (event.shiftKey && key.toLowerCase() === 'p'));
if (!open && comboOpen) {
event.preventDefault();
openPalette();
return;
}
if (!open) return;
if (key === 'Escape') {
event.preventDefault();
closePalette();
} else if (key === 'ArrowDown') {
event.preventDefault();
moveSelection(1);
} else if (key === 'ArrowUp') {
event.preventDefault();
moveSelection(-1);
} else if (key === 'Enter') {
event.preventDefault();
runCommand(filteredCommands[selectedIndex]);
}
}
function onOverlayMouseDown(event) {
if (event.target === event.currentTarget) closePalette();
}
if (typeof window !== 'undefined') {
window.addEventListener('keydown', onWindowKeydown);
}
onMount(() => {
unsubscribeLocale = i18n.subscribe((nextLocale) => {
const changed = locale !== nextLocale;
locale = nextLocale;
if (changed && open) loadCommands();
});
});
onDestroy(() => {
unsubscribeLocale?.();
if (typeof window !== 'undefined') {
window.removeEventListener('keydown', onWindowKeydown);
window.clearTimeout(setStatus.timer);
}
});
</script>
{#if statusMessage}
<div
class="command-palette-toast"
data-command-palette-status={statusType}
>
{statusMessage}
</div>
{/if}
{#if open}
<div class="command-palette-overlay" role="presentation" on:mousedown={onOverlayMouseDown}>
<section class="command-palette" role="dialog" aria-modal="true" aria-label={tr('command.palette')}>
<input
bind:this={inputEl}
bind:value={query}
class="command-palette-input"
data-command-palette-input
placeholder={tr('command.run')}
aria-label={tr('command.run')}
/>
<div class="command-palette-list" role="listbox">
{#if filteredCommands.length === 0}
<div class="command-palette-empty">{tr('command.none')}</div>
{:else}
{#each filteredCommands as command, index}
<button
type="button"
class:selected={index === selectedIndex}
class="command-palette-item"
data-command-id={command.id}
on:mouseenter={() => selectedIndex = index}
on:click={() => runCommand(command)}
>
<span class="command-palette-title">{command.title || command.id}</span>
<span class="command-palette-meta">{command.pluginName} · {command.id}</span>
</button>
{/each}
{/if}
</div>
</section>
</div>
{/if}
<style>
.command-palette-overlay {
position: fixed;
inset: 0;
z-index: 10000;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 10vh 1rem 1rem;
background: rgba(0, 0, 0, 0.48);
}
.command-palette {
width: min(640px, 100%);
max-height: min(560px, 80vh);
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid #254466;
border-radius: 8px;
background: #101526;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.45);
}
.command-palette-input {
width: 100%;
min-height: 3rem;
padding: 0.75rem 1rem;
border: 0;
border-bottom: 1px solid #213650;
background: #0b1020;
color: #f4f7fb;
font: inherit;
font-size: 1rem;
outline: none;
}
.command-palette-list {
min-height: 0;
overflow: auto;
padding: 0.35rem;
}
.command-palette-item {
width: 100%;
min-height: 3rem;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.18rem;
padding: 0.55rem 0.75rem;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: #e0e0f0;
text-align: left;
}
.command-palette-item:hover,
.command-palette-item.selected {
border-color: #4ecca3;
background: #17243a;
}
.command-palette-title {
font-size: 0.9rem;
font-weight: 650;
}
.command-palette-meta {
max-width: 100%;
overflow: hidden;
color: #8da2bd;
font-size: 0.74rem;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.command-palette-empty {
padding: 1.25rem;
color: #8da2bd;
font-size: 0.9rem;
text-align: center;
}
.command-palette-toast {
position: fixed;
right: 1rem;
bottom: 1rem;
z-index: 10001;
max-width: min(420px, calc(100vw - 2rem));
padding: 0.7rem 0.9rem;
border: 1px solid #254466;
border-radius: 8px;
background: #101526;
color: #e0e0f0;
font-size: 0.85rem;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.35);
}
.command-palette-toast[data-command-palette-status="success"] {
border-color: #4ecca3;
}
.command-palette-toast[data-command-palette-status="error"] {
border-color: #e74c3c;
color: #ffd6d1;
}
</style>
+443
View File
@@ -0,0 +1,443 @@
<script>
import { onMount } from 'svelte';
import * as App from '../../../wailsjs/go/api/App';
import Icon from '../ui/Icon.svelte';
import { i18n } from '../i18n/index.js';
const TEXT_EXTENSIONS = new Set(['txt', 'md', 'markdown', 'log', 'json', 'csv', 'yaml', 'yml', 'toml']);
const FILE_INDEX_LIMIT = 220;
const RESULT_LIMIT = 8;
const RU = 'ёйцукенгшщзхъфывапролджэячсмитьбю';
const EN = '`qwertyuiop[]asdfghjkl;\\zxcvbnm,.';
let query = '';
let index = [];
let results = [];
let focused = false;
let loading = true;
let searchTimer = null;
let buildSeq = 0;
let locale = i18n.getLocale();
$: tr = ((activeLocale) => (key, params, fallback) => {
void activeLocale;
return i18n.t(key, params, fallback);
})(locale);
$: scheduleSearch(query);
onMount(() => {
const unsubscribeLocale = i18n.subscribe((nextLocale) => {
const changed = locale !== nextLocale;
locale = nextLocale;
if (changed) buildIndex();
});
buildIndex();
return () => {
unsubscribeLocale();
clearTimeout(searchTimer);
};
});
function normalize(value) {
return String(value == null ? '' : value).trim().toLowerCase();
}
function swapLayout(value, from, to) {
return String(value || '').split('').map(ch => {
const lower = ch.toLowerCase();
const idx = from.indexOf(lower);
if (idx === -1) return ch;
const mapped = to[idx] || ch;
return ch === lower ? mapped : mapped.toUpperCase();
}).join('');
}
function queryVariants(value) {
const base = normalize(value);
return [...new Set([
base,
normalize(swapLayout(base, RU, EN)),
normalize(swapLayout(base, EN, RU)),
].filter(Boolean))];
}
function matchScore(item, variants) {
const haystack = normalize(`${item.title} ${item.subtitle || ''} ${item.keywords || ''}`);
for (const variant of variants) {
if (!variant) continue;
if (normalize(item.title) === variant) return 100;
if (normalize(item.title).startsWith(variant)) return 80;
if (haystack.includes(variant)) return 50;
}
return 0;
}
function scheduleSearch(value) {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => runSearch(value), 80);
}
function runSearch(value) {
const variants = queryVariants(value);
if (!variants.length) {
results = [];
return;
}
results = index
.map(item => ({ item, score: matchScore(item, variants) }))
.filter(row => row.score > 0)
.sort((a, b) => b.score - a.score || a.item.rank - b.item.rank || a.item.title.localeCompare(b.item.title))
.slice(0, RESULT_LIMIT)
.map(row => row.item);
}
function workspaceTitle(node) {
return node?.title || node?.name || node?.id || node?.rootPath || '';
}
function workspaceName(node) {
return node?.name || node?.id || node?.rootPath || '';
}
async function resultOrEmpty(promise, fallback) {
try {
const response = await promise;
if (Array.isArray(response) && response.length === 2) return response[1] ? fallback : response[0];
return response || fallback;
} catch (_) {
return fallback;
}
}
async function listFilesRecursive(dir = '', depth = 0, acc = []) {
if (acc.length >= FILE_INDEX_LIMIT || depth > 5) return acc;
const entries = await resultOrEmpty(App.ListVaultFiles('verstak.search', dir), []);
for (const entry of entries || []) {
if (acc.length >= FILE_INDEX_LIMIT) break;
const path = entry.relativePath || entry.path || entry.name || '';
if (!path) continue;
acc.push(entry);
if (entry.type === 'folder') await listFilesRecursive(path, depth + 1, acc);
}
return acc;
}
async function readFileSnippet(path) {
const ext = String(path).split('.').pop().toLowerCase();
if (!TEXT_EXTENSIONS.has(ext)) return '';
const text = await resultOrEmpty(App.ReadVaultTextFile('verstak.search', path), '');
return String(text || '').slice(0, 900);
}
function pluginToolKind(pluginId, label) {
if (pluginId === 'verstak.browser-inbox') return 'browser-inbox';
if (pluginId === 'verstak.activity') return 'activity';
if (pluginId === 'verstak.journal') return 'journal';
return String(label || pluginId || '').toLowerCase();
}
async function indexPluginSettings(pluginId, label, rank, view, nodes) {
const settings = await resultOrEmpty(App.ReadPluginSettings(pluginId), {});
const items = [];
Object.keys(settings || {}).forEach(key => {
const value = settings[key];
const rows = Array.isArray(value) ? value : [];
rows.forEach(row => {
if (!row || typeof row !== 'object') return;
const title = row.title || row.summary || row.url || row.captureId || row.activityId || row.entryId || label;
const workspaceName = row.workspaceRootPath || row.workspaceName || '';
items.push({
type: label,
title,
subtitle: row.url || row.summary || row.workspaceRootPath || key,
keywords: JSON.stringify(row),
rank,
action: workspaceName ? 'workspace-tool' : (view ? 'view' : ''),
viewId: view?.id || '',
pluginId,
workspaceName,
toolKind: pluginToolKind(pluginId, label),
nodes,
});
});
});
return items;
}
async function buildIndex() {
const seq = ++buildSeq;
loading = true;
const next = [];
const tree = await resultOrEmpty(App.GetWorkspaceTree(), { nodes: [] });
const nodes = Array.isArray(tree.nodes) ? tree.nodes : [];
nodes.forEach(node => {
next.push({
type: 'Workspace',
typeLabel: tr('search.type.workspace'),
title: workspaceTitle(node),
subtitle: tr('search.type.workspace'),
keywords: `${node.id || ''} ${node.rootPath || ''}`,
rank: 10,
action: 'workspace',
workspaceName: workspaceName(node),
nodes,
});
});
const [rawPlugins, rawContributions] = await Promise.all([
resultOrEmpty(App.GetPlugins(), []),
resultOrEmpty(App.GetContributions(), {}),
]);
await Promise.all((rawPlugins || []).map((plugin) => i18n.loadPlugin(
plugin.manifest?.id,
plugin.manifest?.localization,
).catch(() => {})));
const contributions = i18n.localizeContributionSummary(rawContributions || {});
const viewByPluginId = new Map();
(contributions.views || []).forEach(view => {
if (view.pluginId && !viewByPluginId.has(view.pluginId)) viewByPluginId.set(view.pluginId, view);
});
(contributions.sidebarItems || []).forEach(item => {
next.push({
type: 'Tool',
typeLabel: tr('search.type.tool'),
title: item.title || item.id,
subtitle: item.pluginId || '',
keywords: `${item.id || ''} ${item.view || ''}`,
rank: 20,
action: 'view',
viewId: item.view || item.id,
pluginId: item.pluginId,
});
});
const files = await listFilesRecursive();
for (const entry of files) {
const path = entry.relativePath || entry.path || entry.name || '';
const snippet = await readFileSnippet(path);
next.push({
type: entry.type === 'folder' ? 'Folder' : 'File',
typeLabel: tr(entry.type === 'folder' ? 'search.type.folder' : 'search.type.file'),
title: path.split('/').pop() || path,
subtitle: path,
keywords: snippet,
rank: entry.type === 'folder' ? 30 : 40,
action: entry.type === 'folder' ? 'file-folder' : 'file',
path,
nodes,
});
}
const pluginItems = await Promise.all([
indexPluginSettings('verstak.journal', tr('search.type.journal'), 50, viewByPluginId.get('verstak.journal'), nodes),
indexPluginSettings('verstak.browser-inbox', tr('search.type.browserInbox'), 55, viewByPluginId.get('verstak.browser-inbox'), nodes),
indexPluginSettings('verstak.activity', tr('search.type.activity'), 60, viewByPluginId.get('verstak.activity'), nodes),
]);
if (seq !== buildSeq) return;
index = next.concat(pluginItems.flat());
loading = false;
runSearch(query);
}
function handleFocus() {
focused = true;
buildIndex();
}
async function openResult(item) {
query = '';
results = [];
if (item.action === 'workspace') {
window.dispatchEvent(new CustomEvent('verstak:workspace-selected', {
detail: { workspaceName: item.workspaceName, nodes: item.nodes || [] }
}));
return;
}
if (item.action === 'view') {
window.dispatchEvent(new CustomEvent('verstak:open-view', {
detail: { viewId: item.viewId, pluginId: item.pluginId }
}));
return;
}
if (item.action === 'workspace-tool') {
const workspaceName = item.workspaceName || '';
if (workspaceName) {
window.dispatchEvent(new CustomEvent('verstak:workspace-selected', {
detail: { workspaceName, nodes: item.nodes || [] }
}));
window.dispatchEvent(new CustomEvent('verstak:workspace-open-tool', {
detail: { kind: item.toolKind || item.type || '' }
}));
}
return;
}
if (item.action === 'file-folder') {
const parts = String(item.path || '').split('/').filter(Boolean);
const workspaceName = parts[0] || '';
const localPath = parts.slice(1).join('/');
if (workspaceName) {
window.__filesHistoryByWorkspace = window.__filesHistoryByWorkspace || {};
window.__filesHistoryByWorkspace[workspaceName] = {
stack: [localPath],
index: 0,
currentPath: localPath,
};
const detail = { workspaceName };
if (Array.isArray(item.nodes) && item.nodes.length > 0) detail.nodes = item.nodes;
window.dispatchEvent(new CustomEvent('verstak:workspace-selected', {
detail
}));
window.dispatchEvent(new CustomEvent('verstak:workspace-open-tool', {
detail: { kind: 'files' }
}));
}
return;
}
if (item.action === 'file') {
const response = await App.OpenWorkbenchResource('verstak.search', {
kind: 'vault-file',
path: item.path,
mode: 'view',
context: { sourceView: 'global-search' }
});
const [result, err] = Array.isArray(response) ? response : [response, ''];
if (!err && result) {
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
}
}
}
</script>
<div class="global-search" class:open={focused && (query || results.length)}>
<div class="global-search-box">
<Icon name="search" size={14} class="global-search-icon" />
<input
bind:value={query}
on:focus={handleFocus}
on:blur={() => setTimeout(() => focused = false, 120)}
type="search"
placeholder={loading ? tr('search.indexing') : tr('search.placeholder')}
aria-label={tr('search.global')}
data-global-search-input
/>
</div>
{#if focused && query}
<div class="global-search-results" data-global-search-results>
{#if results.length}
{#each results as item}
<button
type="button"
class="global-search-result"
data-global-search-result-type={item.type}
data-global-search-result-path={item.path || ''}
on:mousedown|preventDefault={() => openResult(item)}
>
<span class="global-search-result-title">{item.title}</span>
<span class="global-search-result-meta">{item.typeLabel || item.type} · {item.subtitle}</span>
</button>
{/each}
{:else}
<div class="global-search-empty vt-empty-title">{tr('search.noResults')}</div>
{/if}
</div>
{/if}
</div>
<style>
.global-search {
position: relative;
padding: 0.55rem 0.75rem;
border-bottom: 1px solid var(--vt-color-border);
flex-shrink: 0;
}
.global-search-box {
display: flex;
align-items: center;
gap: 0.35rem;
height: 2rem;
padding: 0 0.55rem;
border: 1px solid var(--vt-color-border-strong);
border-radius: var(--vt-radius-md);
background: #0f1424;
color: var(--vt-color-text-muted);
}
:global(.global-search-icon) {
color: var(--vt-color-text-muted);
flex-shrink: 0;
}
.global-search input {
width: 100%;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
color: var(--vt-color-text-primary);
font: inherit;
font-size: 0.78rem;
}
.global-search input::placeholder {
color: var(--vt-color-text-muted);
}
.global-search-box:focus-within {
border-color: var(--vt-color-accent);
box-shadow: var(--vt-focus-ring);
}
.global-search-results {
position: absolute;
left: 0.75rem;
right: 0.75rem;
top: calc(100% - 0.25rem);
z-index: 400;
max-height: 20rem;
overflow: auto;
border: 1px solid var(--vt-color-border-strong);
border-radius: var(--vt-radius-md);
background: var(--vt-color-surface);
box-shadow: var(--vt-elevation-menu);
}
.global-search-result {
display: flex;
flex-direction: column;
gap: 0.12rem;
width: 100%;
padding: 0.55rem 0.65rem;
border: 0;
border-bottom: 1px solid rgba(40, 70, 111, 0.55);
background: transparent;
color: var(--vt-color-text-primary);
text-align: left;
cursor: pointer;
}
.global-search-result:hover {
background: var(--vt-color-surface-hover);
}
.global-search-result-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.8rem;
}
.global-search-result-meta,
.global-search-empty {
color: var(--vt-color-text-muted);
font-size: 0.7rem;
}
.global-search-empty {
padding: 0.7rem;
}
</style>
+78 -75
View File
@@ -2,8 +2,12 @@
import { onDestroy, onMount } from 'svelte';
import * as App from '../../../wailsjs/go/api/App';
import WorkspaceTree from './WorkspaceTree.svelte';
import GlobalSearch from './GlobalSearch.svelte';
import Icon from '../ui/Icon.svelte';
import { debug } from '../log/debug.js';
import { i18n } from '../i18n/index.js';
export let showGlobalSearch = true;
function flog(msg) {
App.WriteFrontendLog('Sidebar', msg);
@@ -13,10 +17,13 @@
let vaultStatus = { status: 'unknown', path: '', vaultId: '' };
let sidebarItems = [];
let errorMessage = '';
let locale = i18n.getLocale();
let unsubscribeLocale = null;
let navItems = [
{ id: 'plugin-manager', label: 'Plugin Manager', icon: 'puzzle' },
];
$: tr = ((activeLocale) => (key, params, fallback) => {
void activeLocale;
return i18n.t(key, params, fallback);
})(locale);
$: vaultOpen = vaultStatus.status === 'open';
@@ -32,14 +39,18 @@
App.GetVaultStatus().catch(() => ({ status: 'unknown', path: '', vaultId: '' })),
App.GetContributions().catch(() => { contribErr = true; return {}; }),
]);
plugins = p || [];
await Promise.all((p || []).map((plugin) => (
i18n.loadPlugin(plugin.manifest?.id, plugin.manifest?.localization).catch(() => {})
)));
plugins = (p || []).map((plugin) => i18n.localizePlugin(plugin));
const localizedContributions = i18n.localizeContributionSummary(contribs || {});
vaultStatus = v;
debug.log('[Sidebar] onMount: plugins=' + plugins.length + ' vault=' + vaultStatus.status);
flog('onMount: plugins=' + plugins.length + ' vault=' + vaultStatus.status);
if (contribErr) {
errorMessage = 'Failed to load plugin contributions';
errorMessage = tr('sidebar.error.contributions');
}
sidebarItems = (contribs.sidebarItems || []).filter(item => {
sidebarItems = (localizedContributions.sidebarItems || []).filter(item => {
const plugin = plugins.find(p => p.manifest?.id === item.pluginId);
if (!plugin) return false;
return plugin.status !== 'disabled' && plugin.status !== 'failed' && plugin.status !== 'incompatible' && plugin.status !== 'missing-required-capability';
@@ -51,26 +62,27 @@
debug.log('[Sidebar] onMount: ERROR:', String(e));
flog('onMount: ERROR: ' + String(e));
console.error('[Sidebar] load error:', e);
errorMessage = 'Failed to load sidebar';
errorMessage = tr('sidebar.error.load');
}
debug.log('[Sidebar] onMount: END');
flog('onMount: END');
}
onMount(() => {
unsubscribeLocale = i18n.subscribe((nextLocale) => {
const changed = locale !== nextLocale;
locale = nextLocale;
if (changed) loadSidebar();
});
loadSidebar();
window.addEventListener('verstak:plugins-changed', loadSidebar);
});
onDestroy(() => {
if (unsubscribeLocale) unsubscribeLocale();
window.removeEventListener('verstak:plugins-changed', loadSidebar);
});
function handleNav(id) {
debug.log('[Sidebar] handleNav:', id);
window.dispatchEvent(new CustomEvent('verstak:nav', { detail: { viewId: id } }));
}
function handleSidebarItem(item) {
debug.log('[Sidebar] handleSidebarItem:', item.id, '-> view:', item.view);
// Use item.view (the view contribution ID) if available, fall back to item.id
@@ -85,25 +97,16 @@
<span class="sidebar-title">Verstak</span>
</div>
<nav class="sidebar-nav">
{#each navItems as item}
<button
class="nav-item"
on:click={() => handleNav(item.id)}
type="button"
>
<Icon name={item.icon} size={16} class="nav-icon" />
<span class="nav-label">{item.label}</span>
</button>
{/each}
</nav>
{#if vaultOpen && showGlobalSearch}
<GlobalSearch />
{/if}
{#if sidebarItems.length > 0}
<div class="sidebar-section">
<span class="section-label">Plugins</span>
<span class="section-label">{tr('sidebar.tools')}</span>
{#each sidebarItems as item}
<button
class="nav-item plugin-item"
class="nav-item plugin-item vt-list-row"
on:click={() => handleSidebarItem(item)}
type="button"
>
@@ -122,12 +125,7 @@
{#if errorMessage}
<span class="sidebar-error">
<Icon name="warning" size={10} class="sidebar-error-icon" />
Plugin UI error
</span>
{/if}
{#if vaultStatus.status !== 'unknown'}
<span class="vault-indicator" class:vault-open={vaultStatus.status === 'open'} class:vault-closed={vaultStatus.status !== 'open'}>
● Vault: {vaultStatus.status}
{tr('sidebar.error.ui')}
</span>
{/if}
</div>
@@ -137,10 +135,10 @@
.sidebar {
width: 220px;
min-width: 220px;
background: #16213e;
background: var(--vt-color-surface-muted);
display: flex;
flex-direction: column;
border-right: 1px solid #0f3460;
border-right: 1px solid var(--vt-color-border);
overflow: hidden;
}
@@ -149,36 +147,28 @@
align-items: center;
gap: 0.5rem;
padding: 1rem 1.25rem;
border-bottom: 1px solid #0f3460;
border-bottom: 1px solid var(--vt-color-border);
}
:global(.sidebar-logo) {
width: 1.2rem;
height: 1.2rem;
color: #4ecca3;
color: var(--vt-color-accent);
flex-shrink: 0;
}
.sidebar-title {
color: #e0e0f0;
color: var(--vt-color-text-primary);
font-size: 1rem;
font-weight: 600;
}
.sidebar-nav {
display: flex;
flex-direction: column;
padding: 0.5rem 0.75rem;
gap: 0.15rem;
}
.sidebar-section {
display: flex;
flex-direction: column;
padding: 0.5rem 0.75rem;
padding: 0.45rem 0.6rem 0.55rem;
gap: 0.15rem;
border-top: 1px solid #0f3460;
margin-top: 0.25rem;
border-bottom: 1px solid var(--vt-color-border);
}
:global(workspace-tree) {
@@ -189,43 +179,46 @@
}
.section-label {
color: #666;
color: var(--vt-color-text-muted);
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.25rem 0.5rem;
padding: 0.25rem 0.45rem 0.35rem;
font-weight: 600;
}
.nav-item {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0.6rem;
padding: 0.45rem 0.75rem;
gap: 0.45rem;
min-height: 1.85rem;
padding: 0.15rem 0.45rem;
background: none;
border: none;
color: #a0a0b8;
font-size: 0.85rem;
color: var(--vt-color-text-secondary);
font-size: 0.78rem;
cursor: pointer;
border-radius: 6px;
border-radius: var(--vt-radius-sm);
text-align: left;
width: 100%;
transition: background 0.15s, color 0.15s;
}
.nav-item:hover {
background: #0f3460;
color: #e0e0f0;
background: var(--vt-color-surface-hover);
color: var(--vt-color-text-primary);
}
:global(.nav-icon) {
width: 1.2rem;
height: 1.2rem;
width: 0.9rem;
height: 0.9rem;
flex-shrink: 0;
color: currentColor;
}
:global(.nav-icon.icon-plugin) {
color: #a78bfa;
color: var(--vt-color-text-muted);
opacity: 0.9;
}
.nav-label {
@@ -237,20 +230,7 @@
.sidebar-footer {
margin-top: auto;
padding: 0.75rem 1.25rem;
border-top: 1px solid #0f3460;
}
.vault-indicator {
font-size: 0.7rem;
color: #666;
}
.vault-indicator.vault-open {
color: #4ecca3;
}
.vault-indicator.vault-closed {
color: #a0a0b8;
border-top: 1px solid var(--vt-color-border);
}
.sidebar-error {
@@ -258,10 +238,33 @@
align-items: center;
gap: 0.25rem;
font-size: 0.7rem;
color: #e94560;
color: var(--vt-color-danger);
margin-bottom: 0.25rem;
}
:global(.sidebar-error-icon) {
color: #e94560;
color: var(--vt-color-danger);
}
@media (max-width: 720px) {
.sidebar {
width: 100%;
min-width: 0;
max-height: 14rem;
border-right: 0;
border-bottom: 1px solid var(--vt-color-border);
}
.sidebar-header {
padding: 0.75rem 1rem;
}
.sidebar-section {
max-height: 5.5rem;
overflow: auto;
}
.sidebar-footer {
display: none;
}
}
</style>
+395
View File
@@ -0,0 +1,395 @@
<script>
import { onDestroy, onMount } from 'svelte';
import * as App from '../../../wailsjs/go/api/App';
import Icon from '../ui/Icon.svelte';
import { i18n } from '../i18n/index.js';
let items = [];
let settingsPanels = [];
let vaultStatus = { status: 'unknown', path: '', vaultId: '' };
let settingsOpen = false;
let locale = i18n.getLocale();
let selectedLanguage = i18n.getLanguagePreference();
let unsubscribeLocale = null;
$: tr = ((activeLocale) => (key, params, fallback) => {
void activeLocale;
return i18n.t(key, params, fallback);
})(locale);
$: leftItems = items.filter((item) => item.position === 'left');
$: centerItems = items.filter((item) => item.position === 'center');
$: rightItems = items.filter((item) => item.position === 'right');
$: vaultOpen = vaultStatus.status === 'open';
$: vaultStatusLabel = tr(`vault.status.${vaultStatus.status || 'unknown'}`, undefined, vaultStatus.status || 'unknown');
$: vaultLabel = tr('vault.label', { status: vaultStatusLabel });
const inactiveStatuses = new Set(['disabled', 'failed', 'incompatible', 'missing-required-capability']);
async function loadStatusBar() {
const [rawPlugins, rawContributions, vault] = await Promise.all([
App.GetPlugins().catch(() => []),
App.GetContributions().catch(() => ({})),
App.GetVaultStatus().catch(() => ({ status: 'unknown', path: '', vaultId: '' })),
]);
await Promise.all((rawPlugins || []).map((plugin) => (
i18n.loadPlugin(plugin.manifest?.id, plugin.manifest?.localization).catch(() => {})
)));
const plugins = (rawPlugins || []).map((plugin) => i18n.localizePlugin(plugin));
const contributions = i18n.localizeContributionSummary(rawContributions || {});
const pluginById = new Map((plugins || []).map((plugin) => [plugin.manifest?.id, plugin]));
vaultStatus = vault || { status: 'unknown', path: '', vaultId: '' };
items = (contributions.statusBarItems || [])
.filter((item) => {
const plugin = pluginById.get(item.pluginId);
if (!plugin) return false;
return !inactiveStatuses.has(plugin.status);
})
.map((item) => ({
...item,
position: item.position || 'left',
}));
settingsPanels = (contributions.settingsPanels || [])
.filter((panel) => {
const plugin = pluginById.get(panel.pluginId);
if (!plugin) return false;
return !inactiveStatuses.has(plugin.status);
})
.sort((a, b) => String(a.title || a.id).localeCompare(String(b.title || b.id)));
}
function openPluginManager() {
settingsOpen = false;
window.dispatchEvent(new CustomEvent('verstak:close-settings'));
window.dispatchEvent(new CustomEvent('verstak:nav', { detail: { viewId: 'plugin-manager' } }));
}
function openSettingsPanel(panel) {
settingsOpen = false;
window.dispatchEvent(new CustomEvent('verstak:open-settings', {
detail: { pluginId: panel.pluginId, panelId: panel.id }
}));
}
async function selectLanguage(language) {
const err = await App.UpdateAppSettings({ language });
if (err) {
throw new Error(err);
}
await i18n.setLanguagePreference(language);
selectedLanguage = language;
settingsOpen = false;
}
function toggleSettings(event) {
event.stopPropagation();
settingsOpen = !settingsOpen;
}
function closeSettings() {
settingsOpen = false;
}
onMount(() => {
unsubscribeLocale = i18n.subscribe((nextLocale) => {
const changed = locale !== nextLocale;
locale = nextLocale;
selectedLanguage = i18n.getLanguagePreference();
if (changed) loadStatusBar();
});
loadStatusBar();
window.addEventListener('verstak:plugins-changed', loadStatusBar);
window.addEventListener('verstak:vault-opened', loadStatusBar);
window.addEventListener('click', closeSettings);
});
onDestroy(() => {
if (unsubscribeLocale) unsubscribeLocale();
window.removeEventListener('verstak:plugins-changed', loadStatusBar);
window.removeEventListener('verstak:vault-opened', loadStatusBar);
window.removeEventListener('click', closeSettings);
});
</script>
<footer class="status-bar" aria-label={tr('statusBar.label')}>
<div class="status-bar-group status-left">
<span
class="vault-status"
class:vault-open={vaultOpen}
class:vault-closed={!vaultOpen}
title={vaultStatus.path || vaultStatus.vaultId || vaultLabel}
>
<Icon name="vault" size={13} class="status-icon" />
{vaultLabel}
</span>
{#each leftItems as item}
<span
class:status-bar-warning={item.handler}
class="status-bar-item"
data-status-item-id={item.id}
title={item.handler ? `${item.pluginId}: compact status only` : item.pluginId}
>
{#if item.handler}<Icon name="warning" size={11} class="status-warning-icon" />{/if}
{item.label || item.id}
</span>
{/each}
</div>
<div class="status-bar-group status-center">
{#each centerItems as item}
<span
class:status-bar-warning={item.handler}
class="status-bar-item"
data-status-item-id={item.id}
title={item.handler ? `${item.pluginId}: compact status only` : item.pluginId}
>
{#if item.handler}<Icon name="warning" size={11} class="status-warning-icon" />{/if}
{item.label || item.id}
</span>
{/each}
</div>
<div class="status-bar-group status-right">
{#each rightItems as item}
<span
class:status-bar-warning={item.handler}
class="status-bar-item"
data-status-item-id={item.id}
title={item.handler ? `${item.pluginId}: compact status only` : item.pluginId}
>
{#if item.handler}<Icon name="warning" size={11} class="status-warning-icon" />{/if}
{item.label || item.id}
</span>
{/each}
<div class="settings-menu-wrap">
<button
class="settings-button"
class:active={settingsOpen}
type="button"
title={tr('settings.title')}
aria-haspopup="menu"
aria-expanded={settingsOpen}
data-settings-menu-button
on:click={toggleSettings}
>
<Icon name="settings" size={14} class="settings-icon" />
<Icon name="chevronDown" size={12} class="settings-chevron" />
</button>
{#if settingsOpen}
<div class="settings-menu" role="menu">
<div class="settings-menu-heading">{tr('settings.language')}</div>
{#each ['system', 'en', 'ru'] as language}
<button
class="settings-menu-item language-item"
class:active-language={selectedLanguage === language}
type="button"
role="menuitemradio"
aria-checked={selectedLanguage === language}
data-settings-language={language}
on:click={() => selectLanguage(language)}
>
<span class="language-check" aria-hidden="true">{selectedLanguage === language ? '✓' : ''}</span>
<span>{tr(`settings.language.${language}`)}</span>
</button>
{/each}
<div class="settings-menu-separator"></div>
<button
class="settings-menu-item"
type="button"
role="menuitem"
data-settings-action="plugin-manager"
on:click={openPluginManager}
>
<Icon name="puzzle" size={14} class="settings-menu-icon" />
<span>{tr('settings.pluginManager')}</span>
</button>
{#if settingsPanels.length > 0}
<div class="settings-menu-separator"></div>
{#each settingsPanels as panel}
<button
class="settings-menu-item"
type="button"
role="menuitem"
data-settings-panel-id={panel.id}
title={panel.pluginId}
on:click={() => openSettingsPanel(panel)}
>
<Icon name={panel.icon || 'settings'} size={14} class="settings-menu-icon" />
<span>{panel.title || panel.id}</span>
</button>
{/each}
{/if}
</div>
{/if}
</div>
</div>
</footer>
<style>
.status-bar {
position: relative;
z-index: 100;
min-height: 1.7rem;
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center;
gap: 0.5rem;
padding: 0.2rem 0.65rem;
border-top: 1px solid #16213e;
background: #111629;
color: #9fb2ca;
font-size: 0.74rem;
}
.status-bar-group {
min-width: 0;
display: flex;
align-items: center;
gap: 0.4rem;
overflow: hidden;
}
.status-center {
justify-content: center;
}
.status-right {
justify-content: flex-end;
}
.status-bar-item {
display: inline-flex;
align-items: center;
gap: 0.25rem;
max-width: 18rem;
overflow: hidden;
padding: 0.12rem 0.35rem;
border-radius: 4px;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-bar-warning {
color: #ffc857;
}
:global(.status-warning-icon) {
flex-shrink: 0;
color: currentColor;
}
.vault-status {
display: inline-flex;
align-items: center;
gap: 0.3rem;
max-width: 24rem;
min-width: 0;
overflow: hidden;
padding: 0.12rem 0.35rem;
border-radius: 4px;
color: #a0a0b8;
white-space: nowrap;
text-overflow: ellipsis;
}
.vault-status.vault-open {
color: #4ecca3;
}
.vault-status.vault-closed {
color: #9fb2ca;
}
:global(.status-icon) {
flex-shrink: 0;
color: currentColor;
}
.settings-menu-wrap {
position: relative;
display: inline-flex;
align-items: center;
flex-shrink: 0;
}
.settings-button {
min-height: 1.35rem;
height: 1.35rem;
padding: 0 0.35rem;
gap: 0.15rem;
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
color: #9fb2ca;
}
.settings-button:hover,
.settings-button.active {
border-color: #1a3a5c;
background: #16213e;
color: #e0e0f0;
}
:global(.settings-icon),
:global(.settings-chevron),
:global(.settings-menu-icon) {
flex-shrink: 0;
color: currentColor;
}
.settings-menu {
position: fixed;
right: 0.65rem;
bottom: 2rem;
z-index: 10000;
min-width: 13rem;
padding: 0.3rem;
border: 1px solid #1a3a5c;
border-radius: 6px;
background: #12122a;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
}
.settings-menu-item {
width: 100%;
min-height: 1.8rem;
justify-content: flex-start;
gap: 0.45rem;
padding: 0.3rem 0.45rem;
border: none;
border-radius: 4px;
background: transparent;
color: #cfd8e3;
font-size: 0.78rem;
font-weight: 500;
text-align: left;
}
.settings-menu-item:hover {
background: #0f3460;
color: #ffffff;
}
.settings-menu-heading {
padding: 0.25rem 0.45rem 0.2rem;
color: #7f8aa3;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.language-check {
width: 0.9rem;
color: #4ecca3;
text-align: center;
}
.active-language {
background: rgba(78, 204, 163, 0.1);
}
.settings-menu-separator {
height: 1px;
margin: 0.25rem 0.2rem;
background: #1a3a5c;
}
</style>
File diff suppressed because it is too large Load Diff
+61 -52
View File
@@ -1,7 +1,8 @@
<script>
import { onMount } from 'svelte';
import { onDestroy, onMount } from 'svelte';
import * as App from '../../../wailsjs/go/api/App';
import Icon from '../ui/Icon.svelte';
import { i18n } from '../i18n/index.js';
let appSettings = {};
let recentVaults = [];
@@ -11,6 +12,13 @@
let opening = false;
let newVaultPath = '';
let openVaultPath = '';
let locale = i18n.getLocale();
const unsubscribeLocale = i18n.subscribe((nextLocale) => { locale = nextLocale; });
$: tr = ((activeLocale) => (key, params, fallback) => {
void activeLocale;
return i18n.t(key, params, fallback);
})(locale);
onDestroy(unsubscribeLocale);
onMount(async () => {
try {
@@ -39,20 +47,20 @@
async function createVault() {
error = '';
if (!newVaultPath.trim()) {
error = 'Please enter or select a path for the new vault';
error = tr('vaultSelection.chooseNew');
return;
}
creating = true;
try {
const createErr = await App.CreateVault(newVaultPath.trim());
if (createErr) {
error = 'Create vault: ' + createErr;
error = tr('vaultSelection.createError', { error: createErr });
creating = false;
return;
}
const openErr = await App.OpenVault(newVaultPath.trim());
if (openErr) {
error = 'Open vault: ' + openErr;
error = tr('vaultSelection.openError', { error: openErr });
creating = false;
return;
}
@@ -70,14 +78,14 @@
async function openExistingVault() {
error = '';
if (!openVaultPath.trim()) {
error = 'Please enter or select a path to an existing vault';
error = tr('vaultSelection.chooseExisting');
return;
}
opening = true;
try {
const openErr = await App.OpenVault(openVaultPath.trim());
if (openErr) {
error = 'Open vault: ' + openErr;
error = tr('vaultSelection.openError', { error: openErr });
opening = false;
return;
}
@@ -98,7 +106,7 @@
try {
const openErr = await App.OpenVault(path);
if (openErr) {
error = 'Open vault: ' + openErr;
error = tr('vaultSelection.openError', { error: openErr });
opening = false;
return;
}
@@ -117,7 +125,7 @@
{#if loading}
<div class="vault-selection">
<div class="vault-selection-inner">
<p class="loading-text">Loading...</p>
<p class="loading-text">{tr('common.loading')}</p>
</div>
</div>
{:else}
@@ -130,7 +138,7 @@
<line x1="9" y1="14" x2="15" y2="14"/>
</svg>
<h1>Verstak</h1>
<p class="subtitle">Choose a vault to get started</p>
<p class="subtitle">{tr('vaultSelection.subtitle')}</p>
</div>
{#if error}
@@ -142,43 +150,43 @@
<div class="actions">
<div class="action-card">
<h3>Create New Vault</h3>
<p class="hint">Create a new vault folder. This will be your workspace.</p>
<h3>{tr('vaultSelection.createTitle')}</h3>
<p class="hint">{tr('vaultSelection.createHint')}</p>
<div class="input-row">
<input
type="text"
bind:value={newVaultPath}
placeholder="Select or type a path..."
placeholder={tr('vaultSelection.pathPlaceholder')}
disabled={creating}
/>
<button class="btn-secondary" on:click={browseNewVault} type="button" disabled={creating}>
Browse…
{tr('common.browse')}
</button>
</div>
<div class="button-row">
<button class="btn-primary" on:click={createVault} type="button" disabled={creating}>
{creating ? 'Creating...' : 'Create & Open'}
{creating ? tr('vaultSelection.creating') : tr('vaultSelection.create')}
</button>
</div>
</div>
<div class="action-card">
<h3>Open Existing Vault</h3>
<p class="hint">Open a vault that already exists on this computer.</p>
<h3>{tr('vaultSelection.openTitle')}</h3>
<p class="hint">{tr('vaultSelection.openHint')}</p>
<div class="input-row">
<input
type="text"
bind:value={openVaultPath}
placeholder="Select or type a path..."
placeholder={tr('vaultSelection.pathPlaceholder')}
disabled={opening}
/>
<button class="btn-secondary" on:click={browseOpenVault} type="button" disabled={opening}>
Browse…
{tr('common.browse')}
</button>
</div>
<div class="button-row">
<button class="btn-primary" on:click={openExistingVault} type="button" disabled={opening}>
{opening ? 'Opening...' : 'Open'}
<button class="btn-secondary open-existing-btn" on:click={openExistingVault} type="button" disabled={opening}>
{opening ? tr('vaultSelection.opening') : tr('vaultSelection.open')}
</button>
</div>
</div>
@@ -186,7 +194,7 @@
{#if recentVaults.length > 0}
<div class="recent-section">
<h3>Recent Vaults</h3>
<h3>{tr('vaultSelection.recent')}</h3>
<ul class="recent-list">
{#each recentVaults as path}
<li>
@@ -209,7 +217,7 @@
align-items: center;
justify-content: center;
height: 100vh;
background: #1a1a2e;
background: var(--vt-color-background);
padding: 2rem;
}
.vault-selection-inner {
@@ -217,7 +225,7 @@
width: 100%;
}
.loading-text {
color: #a0a0b8;
color: var(--vt-color-text-muted);
text-align: center;
font-size: 1rem;
}
@@ -226,26 +234,26 @@
margin-bottom: 2rem;
}
.logo h1 {
color: #e0e0f0;
color: var(--vt-color-text-primary);
font-size: 1.8rem;
margin: 0.5rem 0 0.25rem;
}
.subtitle {
color: #a0a0b8;
color: var(--vt-color-text-muted);
font-size: 0.95rem;
margin: 0;
}
.error-box {
background: rgba(233, 69, 96, 0.1);
border: 1px solid #e94560;
border-radius: 8px;
background: var(--vt-color-danger-muted);
border: 1px solid rgba(233, 69, 96, 0.5);
border-radius: var(--vt-radius-lg);
padding: 0.75rem 1rem;
margin-bottom: 1.5rem;
display: flex;
align-items: flex-start;
gap: 0.5rem;
font-size: 0.85rem;
color: #e94560;
color: #ffc6ce;
}
:global(.error-icon) { flex-shrink: 0; }
.error-text { word-break: break-word; }
@@ -256,18 +264,18 @@
margin-bottom: 1.5rem;
}
.action-card {
background: #16213e;
border: 1px solid #0f3460;
border-radius: 8px;
background: var(--vt-color-surface);
border: 1px solid var(--vt-color-border);
border-radius: var(--vt-radius-lg);
padding: 1.25rem;
}
.action-card h3 {
color: #e0e0f0;
color: var(--vt-color-text-primary);
font-size: 1rem;
margin: 0 0 0.25rem;
}
.hint {
color: #a0a0b8;
color: var(--vt-color-text-muted);
font-size: 0.8rem;
margin: 0 0 0.75rem;
}
@@ -278,27 +286,28 @@
}
.input-row input {
flex: 1;
background: #0f3460;
border: 1px solid #1a3a5c;
color: #e0e0f0;
background: #0f1424;
border: 1px solid var(--vt-color-border-strong);
color: var(--vt-color-text-primary);
padding: 0.5rem 0.75rem;
border-radius: 6px;
font-size: 0.9rem;
}
.input-row input:focus {
outline: none;
border-color: #4ecca3;
border-color: var(--vt-color-accent);
box-shadow: var(--vt-focus-ring);
}
.input-row input::placeholder {
color: #666;
color: var(--vt-color-text-muted);
}
.button-row {
display: flex;
justify-content: flex-end;
}
.btn-primary {
background: #4ecca3;
color: #1a1a2e;
background: var(--vt-color-accent);
color: #101827;
border: none;
padding: 0.5rem 1.25rem;
border-radius: 6px;
@@ -314,9 +323,9 @@
cursor: not-allowed;
}
.btn-secondary {
background: #0f3460;
color: #a0a0b8;
border: 1px solid #1a3a5c;
background: var(--vt-color-surface-hover);
color: var(--vt-color-text-secondary);
border: 1px solid var(--vt-color-border-strong);
padding: 0.5rem 0.75rem;
border-radius: 6px;
cursor: pointer;
@@ -324,21 +333,21 @@
white-space: nowrap;
}
.btn-secondary:hover:not(:disabled) {
background: #1a3a5c;
color: #e0e0f0;
background: var(--vt-color-surface-hover);
color: var(--vt-color-text-primary);
}
.btn-secondary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.recent-section {
background: #16213e;
border: 1px solid #0f3460;
border-radius: 8px;
background: var(--vt-color-surface);
border: 1px solid var(--vt-color-border);
border-radius: var(--vt-radius-lg);
padding: 1rem 1.25rem;
}
.recent-section h3 {
color: #a0a0b8;
color: var(--vt-color-text-muted);
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
@@ -356,14 +365,14 @@
width: 100%;
background: none;
border: none;
color: #e0e0f0;
color: var(--vt-color-text-primary);
padding: 0.4rem 0;
cursor: pointer;
text-align: left;
font-size: 0.85rem;
}
.recent-item:hover {
color: #4ecca3;
color: var(--vt-color-accent);
}
.recent-item:disabled {
opacity: 0.5;
+71 -62
View File
@@ -1,8 +1,9 @@
<script>
import PluginBundleHost from '../plugin-host/PluginBundleHost.svelte';
import { onMount } from 'svelte';
import { onDestroy, onMount } from 'svelte';
import * as App from '../../../wailsjs/go/api/App';
import Icon from '../ui/Icon.svelte';
import { i18n } from '../i18n/index.js';
export let activeView = null;
export let activeViewPluginId = null;
@@ -10,18 +11,40 @@
let views = [];
let plugins = [];
let renderError = null;
let locale = i18n.getLocale();
let unsubscribeLocale = null;
$: tr = ((activeLocale) => (key, params, fallback) => {
void activeLocale;
return i18n.t(key, params, fallback);
})(locale);
onMount(async () => {
async function loadViews() {
try {
const [contribs, pluginList] = await Promise.all([
App.GetContributions().catch(() => ({ views: [] })),
App.GetPlugins().catch(() => []),
]);
views = contribs.views || [];
plugins = pluginList;
await Promise.all((pluginList || []).map((plugin) => (
i18n.loadPlugin(plugin.manifest?.id, plugin.manifest?.localization).catch(() => {})
)));
views = i18n.localizeContributionSummary(contribs || {}).views || [];
plugins = (pluginList || []).map((plugin) => i18n.localizePlugin(plugin));
} catch (e) {
console.error('[ViewContainer] load error:', e);
}
}
onMount(() => {
unsubscribeLocale = i18n.subscribe((nextLocale) => {
const changed = locale !== nextLocale;
locale = nextLocale;
if (changed) loadViews();
});
loadViews();
});
onDestroy(() => {
if (unsubscribeLocale) unsubscribeLocale();
});
$: currentView = views.find(v => v.id === activeView && v.pluginId === activeViewPluginId);
@@ -39,7 +62,7 @@
}
function onHostError(e) {
renderError = e.detail?.message || 'Plugin view error';
renderError = e.detail?.message || tr('pluginView.error');
}
</script>
@@ -47,10 +70,13 @@
{#if renderError}
<div class="view-container">
<div class="error-boundary">
<div class="error-fallback">
<div class="error-fallback vt-inline-alert error">
<Icon name="warning" size={24} class="error-icon" />
<p class="error-title">Plugin UI failed</p>
<p class="error-text">{renderError}</p>
<p class="error-title">{tr('pluginView.failed')}</p>
<details class="error-details">
<summary>{tr('common.details')}</summary>
<p class="error-text">{renderError}</p>
</details>
</div>
</div>
</div>
@@ -60,11 +86,6 @@
<div class="view-header">
<Icon name={currentView.icon || 'logo'} size={20} class="view-icon" />
<h2>{currentView.title}</h2>
{#if hasFrontend}
<span class="frontend-badge">frontend bundle</span>
{:else}
<span class="no-frontend-badge">no frontend bundle</span>
{/if}
</div>
<div class="view-content">
{#if hasFrontend}
@@ -74,11 +95,14 @@
/>
{:else}
<div class="placeholder">
<p class="placeholder-label">Plugin View Host</p>
<p class="placeholder-info"><span class="placeholder-key">Plugin:</span> <strong>{currentView.pluginId}</strong></p>
<p class="placeholder-info"><span class="placeholder-key">View ID:</span> <code>{currentView.id}</code></p>
<p class="placeholder-info"><span class="placeholder-key">Component:</span> <code>{currentView.component}</code></p>
<p class="placeholder-badge">frontend bundle not available</p>
<p class="placeholder-label">{tr('pluginView.noVisual')}</p>
<details class="placeholder-details">
<summary>{tr('common.details')}</summary>
<p class="placeholder-info"><span class="placeholder-key">{tr('common.plugin')}:</span> <strong>{currentView.pluginId}</strong></p>
<p class="placeholder-info"><span class="placeholder-key">{tr('pluginView.viewId')}:</span> <code>{currentView.id}</code></p>
<p class="placeholder-info"><span class="placeholder-key">{tr('common.component')}:</span> <code>{currentView.component}</code></p>
</details>
<p class="placeholder-badge vt-badge">{tr('pluginView.bundleUnavailable')}</p>
</div>
{/if}
</div>
@@ -86,12 +110,12 @@
</div>
{:else if activeView}
<div class="view-container empty">
<p>View "{activeView}" not found in contributions</p>
<p>{tr('pluginView.unavailable')}</p>
</div>
{:else}
<div class="view-container empty">
<p>Select a plugin view from the sidebar</p>
<p class="sub">Plugin views will appear here</p>
<p>{tr('pluginView.select')}</p>
<p class="sub">{tr('pluginView.selectHint')}</p>
</div>
{/if}
{/key}
@@ -102,7 +126,7 @@
display: flex;
flex-direction: column;
min-width: 0;
background: #1a1a2e;
background: var(--vt-color-background);
}
.view {
flex: 1;
@@ -112,7 +136,7 @@
padding: 1.5rem;
}
.view.degraded {
border-left: 3px solid #ffc857;
border-left: 3px solid var(--vt-color-warning);
}
.view-header {
display: flex;
@@ -120,72 +144,52 @@
gap: 0.5rem;
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid #16213e;
border-bottom: 1px solid var(--vt-color-border);
}
.view-header h2 {
margin: 0;
font-size: 1.2rem;
color: #e0e0f0;
color: var(--vt-color-text-primary);
flex: 1;
}
:global(.view-icon) {
width: 1.3rem;
height: 1.3rem;
color: #a78bfa;
color: var(--vt-color-text-muted);
flex-shrink: 0;
}
.frontend-badge {
font-size: 0.7rem;
padding: 0.15rem 0.5rem;
background: rgba(78, 204, 163, 0.15);
color: #4ecca3;
border-radius: 8px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.no-frontend-badge {
font-size: 0.7rem;
padding: 0.15rem 0.5rem;
background: rgba(233, 69, 96, 0.1);
color: #e94560;
border-radius: 8px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.view-content {
flex: 1;
min-width: 0;
}
.placeholder {
color: #666;
color: var(--vt-color-text-muted);
font-style: italic;
padding: 2rem;
text-align: center;
border: 1px dashed #333;
border-radius: 8px;
border: 1px dashed var(--vt-color-border-strong);
border-radius: var(--vt-radius-lg);
}
.placeholder-label {
font-size: 1rem;
color: #a0a0b8;
color: var(--vt-color-text-secondary);
font-weight: 600;
margin-bottom: 1rem;
font-style: normal;
}
.placeholder-info {
font-size: 0.85rem;
color: #666;
color: var(--vt-color-text-muted);
margin: 0.3rem 0;
font-style: normal;
}
.placeholder-key {
color: #a0a0b8;
color: var(--vt-color-text-muted);
}
.placeholder-info strong { color: #4ecca3; }
.placeholder-info strong { color: var(--vt-color-accent); }
.placeholder-info code {
color: #e0e0f0;
background: #16213e;
color: var(--vt-color-text-primary);
background: var(--vt-color-surface-muted);
padding: 0.1rem 0.3rem;
border-radius: 3px;
font-size: 0.8rem;
@@ -194,8 +198,8 @@
display: inline-block;
margin-top: 1rem;
padding: 0.25rem 0.75rem;
background: #533483;
color: #e0e0f0;
background: var(--vt-color-surface-muted);
color: var(--vt-color-text-secondary);
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
@@ -213,16 +217,16 @@
padding: 2rem;
}
:global(.error-icon) {
color: #e94560;
color: var(--vt-color-danger);
}
.error-title {
color: #e94560;
color: var(--vt-color-danger);
font-size: 1.1rem;
font-weight: 600;
margin: 0.5rem 0;
}
.error-text {
color: #a0a0b8;
color: var(--vt-color-text-secondary);
font-size: 0.85rem;
font-family: monospace;
margin-top: 0.5rem;
@@ -233,8 +237,13 @@
align-items: center;
justify-content: center;
height: 100%;
color: #555;
color: var(--vt-color-text-muted);
font-size: 1rem;
text-align: center;
}
.empty .sub { font-size: 0.85rem; color: #444; margin-top: 0.5rem; }
.empty .sub { font-size: 0.85rem; color: var(--vt-color-text-muted); margin-top: 0.5rem; }
.placeholder-details,
.error-details { margin-top: 0.75rem; color: var(--vt-color-text-muted); }
.placeholder-details summary,
.error-details summary { cursor: pointer; color: var(--vt-color-text-secondary); }
</style>
+35 -26
View File
@@ -1,8 +1,17 @@
<script>
import { onDestroy } from 'svelte';
import PluginBundleHost from '../plugin-host/PluginBundleHost.svelte';
import Icon from '../ui/Icon.svelte';
import { i18n } from '../i18n/index.js';
export let openedResource = null;
let locale = i18n.getLocale();
const unsubscribeLocale = i18n.subscribe((nextLocale) => locale = nextLocale);
$: tr = ((activeLocale) => (key, params, fallback) => {
void activeLocale;
return i18n.t(key, params, fallback);
})(locale);
$: providerPluginId = openedResource?.providerPluginId || '';
$: providerComponent = openedResource?.providerComponent || '';
@@ -24,26 +33,28 @@
function closeWorkbench() {
window.dispatchEvent(new CustomEvent('verstak:close-workbench', { cancelable: true }));
}
onDestroy(unsubscribeLocale);
</script>
<div class="workbench-host">
<div class="workbench-host vt-page">
{#if openedResource?.status === 'no-provider'}
<div class="workbench-header">
<span class="workbench-title">{resourcePath}</span>
<span class="workbench-provider">no-provider</span>
<button class="close-btn" type="button" title="Close" aria-label="Close" on:click={closeWorkbench}>
<div class="workbench-header vt-page-header">
<span class="workbench-title vt-page-title">{resourcePath}</span>
<span class="workbench-provider vt-badge">{tr('workbench.noProvider')}</span>
<button class="close-btn btn-ghost btn-icon" type="button" title={tr('common.close')} aria-label={tr('common.close')} on:click={closeWorkbench}>
<Icon name="x" size={18} />
</button>
</div>
<div class="workbench-empty no-provider" data-workbench-status="no-provider">
<p>No viewer/editor available</p>
<div class="workbench-empty no-provider vt-empty-state" data-workbench-status="no-provider">
<p class="vt-empty-title">{tr('workbench.noViewer')}</p>
<p class="workbench-meta">{requestMode} · {requestContext}</p>
</div>
{:else if openedResource}
<div class="workbench-header">
<span class="workbench-title">{resourcePath}</span>
<span class="workbench-provider">{providerId}</span>
<button class="close-btn" type="button" title="Close" aria-label="Close" on:click={closeWorkbench}>
<div class="workbench-header vt-page-header">
<span class="workbench-title vt-page-title">{resourcePath}</span>
<span class="workbench-provider vt-badge accent">{providerId}</span>
<button class="close-btn btn-ghost btn-icon" type="button" title={tr('common.close')} aria-label={tr('common.close')} on:click={closeWorkbench}>
<Icon name="x" size={18} />
</button>
</div>
@@ -57,8 +68,8 @@
{/key}
</div>
{:else}
<div class="workbench-empty">
<p>No resource opened</p>
<div class="workbench-empty vt-empty-state">
<p class="vt-empty-title">{tr('workbench.noResource')}</p>
</div>
{/if}
</div>
@@ -71,20 +82,20 @@
height: 100%;
display: flex;
flex-direction: column;
background: #1a1a2e;
background: var(--vt-color-background);
}
.workbench-header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid #16213e;
padding: var(--vt-space-2) var(--vt-space-4);
border-bottom: 1px solid var(--vt-color-border);
flex-shrink: 0;
}
.workbench-title {
color: #e0e0f0;
color: var(--vt-color-text-primary);
font-size: 0.95rem;
font-weight: 600;
min-width: 0;
@@ -98,21 +109,19 @@
height: 2rem;
min-height: 0;
padding: 0;
border: 1px solid #1a3a5c;
border-radius: 4px;
background: #0f3460;
color: #a0a0b8;
border-radius: var(--vt-radius-md);
color: var(--vt-color-text-secondary);
flex-shrink: 0;
cursor: pointer;
}
.close-btn:hover {
color: #e0e0f0;
background: #1a4a7a;
color: var(--vt-color-text-primary);
background: var(--vt-color-surface-hover);
}
.workbench-provider {
color: #4ecca3;
color: var(--vt-color-accent);
font-size: 0.75rem;
margin-left: auto;
}
@@ -132,7 +141,7 @@
display: flex;
align-items: center;
justify-content: center;
color: #666;
color: var(--vt-color-text-muted);
}
.workbench-empty.no-provider {
@@ -142,7 +151,7 @@
.workbench-meta {
margin: 0;
color: #8b8ba8;
color: var(--vt-color-text-muted);
font-size: 0.8rem;
}
</style>
+261 -48
View File
@@ -1,89 +1,255 @@
<script>
import PluginBundleHost from '../plugin-host/PluginBundleHost.svelte';
import GlobalSearch from './GlobalSearch.svelte';
import TodaySurface from './TodaySurface.svelte';
import * as App from '../../../wailsjs/go/api/App';
import { onDestroy, onMount } from 'svelte';
import { i18n } from '../i18n/index.js';
export let selectedWorkspaceName = '';
export let nodes = [];
export let activeToolKey = '';
let contributions = {};
let plugins = [];
let discoveredWorkspaceTools = [];
let workspaceTools = [];
let activeToolKey = '';
let workspaceMetadata = null;
let metadataWorkspaceRoot = '';
let toolsLoaded = false;
let requestedToolKind = '';
let requestedToolRequest = null;
let activeToolRequest = null;
let requestedWorkspaceRoot = '';
let locale = i18n.getLocale();
let unsubscribeLocale = null;
$: tr = ((activeLocale) => (key, params, fallback) => {
void activeLocale;
return i18n.t(key, params, fallback);
})(locale);
// TODO: Rename TodaySurface.svelte to OverviewSurface.svelte in a refactor-only follow-up.
$: overviewTool = { id: '__overview', title: tr('workspace.overview'), pluginId: 'verstak.shell', component: 'TodaySurface', shell: true };
const toolOrder = new Map([
['notes', 10],
['files', 20],
['todo', 30],
['activity', 40],
['browser', 50],
['inbox', 50],
['secrets', 60],
['search', 90],
]);
$: selectedWorkspace = nodes.find(n => n.id === selectedWorkspaceName || n.name === selectedWorkspaceName || n.rootPath === selectedWorkspaceName) || null;
$: workspaceRootPath = selectedWorkspace?.rootPath || selectedWorkspace?.name || selectedWorkspace?.id || '';
$: workspaceTitle = selectedWorkspace?.title || selectedWorkspace?.name || selectedWorkspace?.id || selectedWorkspaceName;
$: workspaceType = selectedWorkspace?.type || 'workspace';
$: activeTool = workspaceTools.find(tool => toolKey(tool) === activeToolKey) || workspaceTools[0] || null;
$: if (workspaceTools.length > 0 && (!activeToolKey || !workspaceTools.some(tool => toolKey(tool) === activeToolKey))) {
activeToolKey = toolKey(workspaceTools[0]);
$: if (workspaceRootPath !== metadataWorkspaceRoot) {
metadataWorkspaceRoot = workspaceRootPath;
workspaceMetadata = null;
if (workspaceRootPath) loadWorkspaceMetadata(workspaceRootPath);
}
$: workspaceTools = sortWorkspaceTools(filterWorkspaceTools(discoveredWorkspaceTools, workspaceMetadata));
$: if (workspaceRootPath !== requestedWorkspaceRoot) {
requestedWorkspaceRoot = workspaceRootPath;
requestedToolRequest = null;
activeToolRequest = null;
}
$: displayedTools = selectedWorkspace ? [overviewTool, ...workspaceTools] : [];
$: activeTool = displayedTools.find(tool => toolKey(tool) === activeToolKey) || displayedTools[0] || null;
$: if (displayedTools.length > 0 && (!activeToolKey || (toolsLoaded && !displayedTools.some(tool => toolKey(tool) === activeToolKey)))) {
activeToolKey = toolKey(overviewTool);
}
$: if (requestedToolKind && workspaceTools.length > 0) {
const match = findWorkspaceTool(requestedToolKind);
if (match) {
const toolRequest = requestedToolRequest;
requestedToolKind = '';
requestedToolRequest = null;
selectTool(match, toolRequest);
}
}
$: if (selectedWorkspaceName) loadTools();
onMount(() => {
unsubscribeLocale = i18n.subscribe((nextLocale) => {
const changed = locale !== nextLocale;
locale = nextLocale;
if (changed && selectedWorkspaceName) loadTools();
});
window.addEventListener('verstak:workspace-open-tool', handleWorkspaceOpenTool);
});
onDestroy(() => {
if (unsubscribeLocale) unsubscribeLocale();
window.removeEventListener('verstak:workspace-open-tool', handleWorkspaceOpenTool);
});
function toolKey(tool) {
return `${tool?.pluginId || ''}:${tool?.id || ''}`;
}
function toolRank(tool) {
const text = `${tool?.title || ''} ${tool?.id || ''} ${tool?.pluginId || ''}`.toLowerCase();
for (const [needle, rank] of toolOrder.entries()) {
if (text.includes(needle)) return rank;
}
return 1000;
}
function sortWorkspaceTools(tools) {
return [...tools].sort((a, b) => {
const rankDiff = toolRank(a) - toolRank(b);
if (rankDiff !== 0) return rankDiff;
return String(a.title || a.id).localeCompare(String(b.title || b.id));
});
}
function filterWorkspaceTools(tools, metadata) {
if (!Array.isArray(metadata?.workspaceTools)) return tools;
const allowedPluginIds = new Set(metadata.workspaceTools);
return tools.filter(tool => allowedPluginIds.has(tool.pluginId));
}
function resultOrError(response, fallbackValue) {
if (Array.isArray(response) && typeof response[1] === 'string') {
return [response[0] || fallbackValue, response[1] || ''];
}
return typeof response === 'string' ? [fallbackValue, response] : [response || fallbackValue, ''];
}
async function loadWorkspaceMetadata(rootPath) {
try {
const [metadata, err] = resultOrError(await App.GetWorkspaceMetadata(rootPath), null);
if (rootPath !== workspaceRootPath) return;
workspaceMetadata = err ? null : metadata;
} catch (_) {
if (rootPath === workspaceRootPath) workspaceMetadata = null;
}
}
function selectTool(tool, toolRequest = null) {
activeToolKey = toolKey(tool);
activeToolRequest = toolRequest;
window.dispatchEvent(new CustomEvent('verstak:workspace-tool-selected', {
detail: {
toolKey: activeToolKey,
toolId: tool?.id || '',
pluginId: tool?.pluginId || '',
},
}));
}
function findWorkspaceTool(kind) {
kind = String(kind || '').toLowerCase();
return workspaceTools.find(tool => {
const text = `${tool?.title || ''} ${tool?.id || ''} ${tool?.pluginId || ''}`.toLowerCase();
if (kind === 'browser-inbox') return text.includes('browser') || text.includes('inbox');
return text.includes(kind);
});
}
function requestWorkspaceTool(kind, toolRequest = null) {
requestedToolKind = String(kind || '').toLowerCase();
requestedToolRequest = toolRequest;
const match = findWorkspaceTool(requestedToolKind);
if (match) {
requestedToolKind = '';
requestedToolRequest = null;
selectTool(match, toolRequest);
}
}
function openWorkspaceTool(event) {
requestWorkspaceTool(event?.detail?.kind, event?.detail?.toolRequest || null);
}
function handleWorkspaceOpenTool(event) {
requestWorkspaceTool(event?.detail?.kind, event?.detail?.toolRequest || null);
}
async function loadTools() {
try {
toolsLoaded = false;
const [c, p] = await Promise.all([
App.GetContributions().catch(() => ({})),
App.GetPlugins().catch(() => []),
]);
contributions = c || {};
plugins = p || [];
await Promise.all((p || []).map((plugin) => (
i18n.loadPlugin(plugin.manifest?.id, plugin.manifest?.localization).catch(() => {})
)));
contributions = i18n.localizeContributionSummary(c || {});
plugins = (p || []).map((plugin) => i18n.localizePlugin(plugin));
const enabledIds = new Set(
plugins.filter(pl => pl.enabled && (pl.status === 'loaded' || pl.status === 'degraded')).map(pl => pl.manifest?.id)
);
workspaceTools = (contributions.workspaceItems || []).filter(tool => enabledIds.has(tool.pluginId));
discoveredWorkspaceTools = (contributions.workspaceItems || []).filter(tool => enabledIds.has(tool.pluginId));
} catch (e) {
console.error('[WorkspaceHost] loadTools error:', e);
} finally {
toolsLoaded = true;
}
}
</script>
<div class="workspace-host">
<div class="workspace-host vt-page">
{#if selectedWorkspace}
<div class="workspace-header">
<span class="workspace-title">{workspaceTitle}</span>
<span class="workspace-type">{workspaceType}</span>
<div class="workspace-header vt-page-header">
<div class="workspace-title-group">
<span class="workspace-title vt-page-title">{workspaceTitle}</span>
<span class="workspace-type vt-badge accent">{workspaceType}</span>
</div>
<div class="workspace-search" aria-label={tr('workspace.search')}>
<GlobalSearch />
</div>
</div>
{#if workspaceTools.length > 0}
<div class="workspace-tabs" role="tablist" aria-label="Workspace tools">
{#each workspaceTools as tool (tool.id + tool.pluginId)}
{#if displayedTools.length > 0}
<div class="workspace-tabs vt-tabbar" role="tablist" aria-label={tr('workspace.tools')}>
{#each displayedTools as tool (tool.id + tool.pluginId)}
<button
class:active={toolKey(tool) === toolKey(activeTool)}
class="vt-tab"
class:is-active={toolKey(tool) === toolKey(activeTool)}
role="tab"
aria-selected={toolKey(tool) === toolKey(activeTool)}
type="button"
title={tool.pluginId}
on:click={() => activeToolKey = toolKey(tool)}
on:click={() => selectTool(tool)}
>
{tool.title || tool.id}
</button>
{/each}
</div>
<div class="workspace-tool-content" role="tabpanel" aria-label={activeTool?.title || activeTool?.id || 'Workspace tool'}>
<div class="workspace-tool-content" role="tabpanel" aria-label={activeTool?.title || activeTool?.id || tr('workspace.tool')}>
{#if activeTool}
<PluginBundleHost
pluginId={activeTool.pluginId}
componentId={activeTool.component}
componentProps={{ workspaceName: selectedWorkspaceName, workspaceNodeId: selectedWorkspaceName, workspaceNode: selectedWorkspace, workspaceRootPath }}
/>
{#if activeTool.shell}
<TodaySurface
{workspaceRootPath}
availableTools={displayedTools}
on:openTool={openWorkspaceTool}
/>
{:else}
<PluginBundleHost
pluginId={activeTool.pluginId}
componentId={activeTool.component}
componentProps={{ workspaceName: selectedWorkspaceName, workspaceNodeId: selectedWorkspaceName, workspaceNode: selectedWorkspace, workspaceRootPath, toolRequest: activeToolRequest }}
/>
{/if}
{/if}
</div>
{:else}
<div class="workspace-empty">
<p>No workspace tools available</p>
<p class="workspace-hint">Install plugins that contribute workspaceItems to see tools here.</p>
<div class="workspace-empty vt-empty-state">
<p class="vt-empty-title">{tr('workspace.emptyTools')}</p>
<p class="workspace-hint">{tr('workspace.emptyToolsHint')}</p>
</div>
{/if}
{:else}
<div class="workspace-empty">
<p>Select a workspace node from the sidebar</p>
<div class="workspace-empty vt-empty-state">
<p class="vt-empty-title">{tr('workspace.select')}</p>
<p class="workspace-hint">{tr('workspace.selectHint')}</p>
</div>
{/if}
</div>
@@ -95,64 +261,109 @@
display: flex;
flex-direction: column;
height: 100%;
background: #1a1a2e;
background: var(--vt-color-background);
}
.workspace-header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid #16213e;
justify-content: space-between;
gap: 1rem;
padding: var(--vt-space-2) var(--vt-space-4);
border-bottom: 1px solid var(--vt-color-border);
flex-shrink: 0;
}
.workspace-title-group {
min-width: 0;
display: flex;
align-items: center;
gap: 0.75rem;
}
.workspace-title {
color: #e0e0f0;
color: var(--vt-color-text-primary);
font-size: 0.95rem;
font-weight: 600;
}
.workspace-type {
color: #4ecca3;
color: var(--vt-color-accent);
font-size: 0.75rem;
padding: 0.1rem 0.4rem;
border-radius: 3px;
background: #1a2a3a;
border-radius: var(--vt-radius-sm);
background: var(--vt-color-accent-muted);
}
.workspace-search {
width: min(27rem, 46vw);
min-width: 16rem;
flex-shrink: 1;
}
.workspace-search :global(.global-search) {
padding: 0;
border-bottom: 0;
}
.workspace-search :global(.global-search-box) {
background: #0f1424;
border-color: var(--vt-color-border-strong);
}
.workspace-search :global(.global-search-results) {
left: 0;
right: 0;
top: calc(100% + 0.35rem);
}
@media (max-width: 720px) {
.workspace-header {
align-items: stretch;
flex-direction: column;
}
.workspace-search {
width: 100%;
min-width: 0;
}
}
.workspace-tabs {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.35rem 0.75rem 0;
background: #12122a;
border-bottom: 1px solid #16213e;
padding: var(--vt-space-1) var(--vt-space-3) 0;
background: #12162a;
border-bottom: 1px solid var(--vt-color-border);
flex-shrink: 0;
overflow-x: auto;
scrollbar-gutter: auto;
}
.workspace-tabs button {
flex-shrink: 0;
min-height: 2rem;
padding: 0.35rem 0.8rem;
border: 1px solid transparent;
border-bottom: none;
border-radius: 6px 6px 0 0;
border-radius: var(--vt-radius-md) var(--vt-radius-md) 0 0;
background: transparent;
color: #8b8ba8;
color: var(--vt-color-text-muted);
cursor: pointer;
font: inherit;
font-size: 0.8rem;
}
.workspace-tabs button:hover {
color: #e0e0f0;
background: rgba(15, 52, 96, 0.4);
color: var(--vt-color-text-primary);
background: var(--vt-color-surface-hover);
}
.workspace-tabs button.active {
color: #4ecca3;
background: #1a1a2e;
border-color: #16213e;
.workspace-tabs button.is-active {
color: var(--vt-color-accent);
background: var(--vt-color-background);
border-color: var(--vt-color-border);
}
.workspace-tool-content {
@@ -167,13 +378,15 @@
flex-direction: column;
align-items: center;
justify-content: center;
color: #666;
color: var(--vt-color-text-muted);
gap: 0.5rem;
padding: 2rem;
text-align: center;
}
.workspace-hint {
font-size: 0.8rem;
color: #555;
color: var(--vt-color-text-muted);
max-width: 300px;
text-align: center;
}
+192 -50
View File
@@ -5,9 +5,10 @@
</script>
<script>
import { onMount } from 'svelte';
import { onDestroy, onMount } from 'svelte';
import * as App from '../../../wailsjs/go/api/App';
import Icon from '../ui/Icon.svelte';
import { i18n } from '../i18n/index.js';
let loading = true;
let localError = '';
@@ -15,17 +16,90 @@
let currentWorkspaceId = '';
let showCreate = false;
let newWorkspaceName = '';
let workspaceTemplates = [];
let templatePluginNames = {};
let selectedTemplateId = 'default';
let createError = '';
let templatesLoading = false;
let creating = false;
let renamingId = '';
let renameValue = '';
let busyId = '';
let locale = i18n.getLocale();
let unsubscribeLocale = null;
$: tr = ((activeLocale) => (key, params, fallback) => {
void activeLocale;
return i18n.t(key, params, fallback);
})(locale);
onMount(loadWorkspaces);
onMount(() => {
unsubscribeLocale = i18n.subscribe((nextLocale) => {
const changed = locale !== nextLocale;
locale = nextLocale;
if (changed) loadWorkspaceTemplates();
});
loadWorkspaces();
loadWorkspaceTemplates();
window.addEventListener('verstak:workspace-active-changed', onActiveWorkspaceChanged);
});
onDestroy(() => {
if (unsubscribeLocale) unsubscribeLocale();
window.removeEventListener('verstak:workspace-active-changed', onActiveWorkspaceChanged);
});
function onActiveWorkspaceChanged(event) {
currentWorkspaceId = event.detail?.workspaceName || '';
activeWorkspaceId.set(currentWorkspaceId);
}
function resultOrError(response, fallbackValue) {
if (Array.isArray(response) && typeof response[1] === 'string') {
return [response[0] || fallbackValue, response[1] || ''];
}
return typeof response === 'string' ? [fallbackValue, response] : [response, ''];
}
$: selectedTemplate = workspaceTemplates.find(template => template.id === selectedTemplateId) || workspaceTemplates[0] || null;
function toolLabel(pluginId) {
return templatePluginNames[pluginId] || String(pluginId || '').replace(/^verstak\./, '');
}
async function loadWorkspaceTemplates() {
templatesLoading = true;
try {
const [templates, plugins] = await Promise.all([
App.ListWorkspaceTemplates ? App.ListWorkspaceTemplates() : [],
App.GetPlugins ? App.GetPlugins() : [],
]);
const [list, err] = resultOrError(templates, []);
if (err) {
createError = err;
workspaceTemplates = [];
return;
}
workspaceTemplates = Array.isArray(list) ? list : [];
await Promise.all((Array.isArray(plugins) ? plugins : []).map((plugin) => (
i18n.loadPlugin(plugin.manifest?.id, plugin.manifest?.localization).catch(() => {})
)));
templatePluginNames = (Array.isArray(plugins) ? plugins : []).map((plugin) => i18n.localizePlugin(plugin)).reduce((names, plugin) => {
const id = plugin?.manifest?.id;
const name = plugin?.manifest?.name;
if (id && name) names[id] = name;
return names;
}, {});
if (!workspaceTemplates.some(template => template.id === selectedTemplateId)) {
selectedTemplateId = workspaceTemplates[0]?.id || '';
}
} catch (error) {
createError = String(error);
workspaceTemplates = [];
} finally {
templatesLoading = false;
}
}
function wsName(workspace) {
return String(workspace?.name || workspace?.rootPath || '');
}
@@ -57,8 +131,19 @@
workspaces = [];
} else {
workspaces = list || [];
if (!currentWorkspaceId || !workspaces.some((ws) => wsName(ws) === currentWorkspaceId)) {
currentWorkspaceId = wsName(workspaces[0] || {});
if (!currentWorkspaceId) {
let currentWorkspace = null;
try {
currentWorkspace = await App.GetCurrentWorkspace();
} catch {
currentWorkspace = null;
}
const currentName = wsName(currentWorkspace);
if (workspaces.some((ws) => wsName(ws) === currentName)) {
currentWorkspaceId = currentName;
}
} else if (!workspaces.some((ws) => wsName(ws) === currentWorkspaceId)) {
currentWorkspaceId = '';
}
activeWorkspaceId.set(currentWorkspaceId);
}
@@ -84,12 +169,19 @@
async function doCreate() {
const name = newWorkspaceName.trim();
if (!name) return;
if (!name) {
createError = tr('workspaceTree.nameRequired');
return;
}
if (!selectedTemplate) {
createError = tr('workspaceTree.chooseTemplate');
return;
}
creating = true;
localError = '';
const [, err] = resultOrError(await App.CreateWorkspace(name, 'default'), null);
createError = '';
const [, err] = resultOrError(await App.CreateWorkspace(name, selectedTemplate.id), null);
if (err) {
localError = err;
createError = err;
creating = false;
return;
}
@@ -101,6 +193,20 @@
if (created) await selectWorkspace(created);
}
function openCreateDialog() {
showCreate = true;
newWorkspaceName = '';
createError = '';
if (!workspaceTemplates.length && !templatesLoading) loadWorkspaceTemplates();
}
function closeCreateDialog() {
if (creating) return;
showCreate = false;
newWorkspaceName = '';
createError = '';
}
function startRename(workspace) {
renamingId = wsName(workspace);
renameValue = renamingId;
@@ -156,12 +262,12 @@
<div class="wt">
<div class="wt-header">
<span class="wt-title">Workspaces</span>
<button class="wt-btn" on:click={() => { showCreate = true; newWorkspaceName = ''; }} title="New workspace" type="button">+</button>
<span class="wt-title">{tr('workspaceTree.title')}</span>
<button class="wt-btn" on:click={openCreateDialog} title={tr('workspaceTree.new')} type="button">+</button>
</div>
{#if loading}
<div class="wt-loading">Loading...</div>
<div class="wt-loading">{tr('common.loading')}</div>
{:else if localError}
<div class="wt-error">{localError}</div>
{/if}
@@ -169,7 +275,7 @@
<div class="wt-list">
{#each workspaces as workspace (wsName(workspace))}
{@const id = wsName(workspace)}
<div class="wt-node" class:selected={id === $activeWorkspaceId}>
<div class="wt-node vt-list-row" class:selected={id === $activeWorkspaceId}>
<div class="wt-row">
<span class="wt-icon"><Icon name="space" size={13} class="wt-node-icon" /></span>
{#if renamingId === id}
@@ -182,14 +288,14 @@
if (e.key === 'Escape') cancelRename();
}}
/>
<button class="wt-btn wt-btn-small wt-always" on:click={() => commitRename(workspace)} title="Save rename" type="button" disabled={busyId === id}>OK</button>
<button class="wt-btn wt-btn-small wt-always" on:click={cancelRename} title="Cancel rename" type="button" disabled={busyId === id}>x</button>
<button class="wt-btn wt-btn-small wt-always" on:click={() => commitRename(workspace)} title={tr('workspaceTree.saveRename')} type="button" disabled={busyId === id}>OK</button>
<button class="wt-btn wt-btn-small wt-always" on:click={cancelRename} title={tr('common.cancel')} type="button" disabled={busyId === id}>{tr('common.cancel')}</button>
{:else}
<button class="wt-label" on:click={() => selectWorkspace(workspace)} type="button">{id}</button>
<button class="wt-icon-btn" on:click={() => startRename(workspace)} title="Rename workspace" type="button" disabled={busyId === id}>
<button class="wt-icon-btn" on:click={() => startRename(workspace)} title={tr('workspaceTree.rename')} type="button" disabled={busyId === id}>
<Icon name="edit" size={12} />
</button>
<button class="wt-icon-btn danger" on:click={() => trashWorkspace(workspace)} title="Trash workspace" type="button" disabled={busyId === id}>
<button class="wt-icon-btn danger" on:click={() => trashWorkspace(workspace)} title={tr('workspaceTree.trash')} type="button" disabled={busyId === id}>
<Icon name="trash" size={12} />
</button>
{/if}
@@ -199,15 +305,43 @@
</div>
{#if showCreate}
<div class="wt-create">
<div class="wt-create-header">
<span>New workspace</span>
<button class="wt-btn" on:click={() => { showCreate = false; newWorkspaceName = ''; }} type="button">x</button>
</div>
<input type="text" bind:value={newWorkspaceName} placeholder="Name..." disabled={creating} />
<div class="wt-create-actions">
<button class="wt-btn-primary" on:click={doCreate} type="button" disabled={creating || !newWorkspaceName.trim()}>{creating ? '...' : 'Create'}</button>
<button class="wt-btn" on:click={() => { showCreate = false; newWorkspaceName = ''; }} type="button" disabled={creating}>Cancel</button>
<div class="workspace-create-overlay" data-workspace-create-modal role="dialog" aria-modal="true" aria-label={tr('workspaceTree.create')}>
<div class="workspace-create-modal">
<div class="workspace-create-header">
<div>
<h2>{tr('workspaceTree.new')}</h2>
</div>
<button class="wt-btn" on:click={closeCreateDialog} type="button" disabled={creating}>{tr('common.close')}</button>
</div>
<label class="workspace-create-field">
<span>{tr('pluginCard.name')}</span>
<input data-workspace-name type="text" bind:value={newWorkspaceName} placeholder={tr('workspaceTree.namePlaceholder')} disabled={creating} on:keydown={(event) => event.key === 'Enter' && doCreate()} />
</label>
<label class="workspace-create-field">
<span>{tr('workspaceTree.template')}</span>
<select data-workspace-template bind:value={selectedTemplateId} disabled={creating || templatesLoading || !workspaceTemplates.length}>
{#each workspaceTemplates as template (template.id)}
<option value={template.id}>{template.name}</option>
{/each}
</select>
</label>
{#if selectedTemplate}
<div class="workspace-template-summary">
<p data-workspace-template-description>{selectedTemplate.description}</p>
<div class="workspace-template-tools" data-workspace-template-tools>
{#each selectedTemplate.workspaceTools || [] as pluginId (pluginId)}
<span>{toolLabel(pluginId)}</span>
{/each}
</div>
</div>
{/if}
{#if createError}
<p class="workspace-create-error" data-workspace-create-error role="alert">{createError}</p>
{/if}
<div class="workspace-create-actions">
<button class="wt-btn-primary" on:click={doCreate} type="button" disabled={creating || templatesLoading || !selectedTemplate}>{creating ? tr('workspaceTree.creating') : tr('workspaceTree.create')}</button>
<button class="wt-btn" on:click={closeCreateDialog} type="button" disabled={creating}>{tr('common.cancel')}</button>
</div>
</div>
</div>
{/if}
@@ -215,35 +349,43 @@
<style>
.wt { display: flex; flex-direction: column; flex: 1; overflow: hidden; position: relative; }
.wt-header { display: flex; align-items: center; justify-content: space-between; padding: 0.4rem 0.6rem; border-bottom: 1px solid #0f3460; flex-shrink: 0; }
.wt-title { color: #a0a0b8; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600; }
.wt-list { min-height: 0; overflow-y: auto; padding: 0.2rem 0; }
.wt-btn { min-height: 0; background: none; border: none; color: #666; cursor: pointer; font-size: 0.85rem; padding: 0.1rem 0.3rem; border-radius: 3px; }
.wt-btn:hover:not(:disabled) { color: #4ecca3; background: rgba(78,204,163,0.1); }
.wt-btn-small { font-size: 0.68rem; opacity: 0; }
.wt-header { display: flex; align-items: center; justify-content: space-between; padding: 0.7rem 0.6rem 0.35rem; border-bottom: 1px solid var(--vt-color-border); flex-shrink: 0; }
.wt-title { color: var(--vt-color-text-muted); font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600; }
.wt-list { min-height: 0; overflow-y: auto; padding: 0.2rem 0.6rem; }
.wt-btn { min-height: 1.55rem; background: transparent; border: 1px solid transparent; color: var(--vt-color-text-muted); cursor: pointer; font-size: 0.78rem; padding: 0.12rem 0.38rem; border-radius: var(--vt-radius-sm); }
.wt-btn:hover:not(:disabled) { color: var(--vt-color-accent); background: var(--vt-color-accent-muted); border-color: rgba(78,204,163,0.25); }
.wt-btn-small { font-size: 0.7rem; opacity: 0; }
.wt-always { opacity: 1; }
.wt-row:hover .wt-btn-small { opacity: 1; }
.wt-loading, .wt-error { padding: 0.5rem; font-size: 0.75rem; color: #666; }
.wt-error { color: #e94560; }
.wt-row { display: flex; align-items: center; gap: 0.25rem; padding: 0.15rem 0.45rem; min-height: 1.7rem; }
.wt-row:hover { background: rgba(15,52,96,0.4); }
.wt-node.selected > .wt-row { background: rgba(78,204,163,0.1); }
.wt-icon { width: 0.9rem; height: 0.9rem; display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; color: #a0a0b8; }
.wt-loading, .wt-error { padding: 0.5rem; font-size: 0.75rem; color: var(--vt-color-text-muted); }
.wt-error { color: var(--vt-color-danger); }
.wt-row { display: flex; align-items: center; gap: 0.45rem; padding: 0.18rem 0.45rem; min-height: 1.85rem; border-radius: var(--vt-radius-sm); }
.wt-row:hover { background: var(--vt-color-surface-hover); }
.wt-node.selected > .wt-row { background: var(--vt-color-surface-selected); box-shadow: inset 2px 0 0 var(--vt-color-accent); }
.wt-icon { width: 0.95rem; height: 0.95rem; display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; color: var(--vt-color-text-muted); }
:global(.wt-node-icon) { display: block; }
.wt-label { flex: 1; min-width: 0; min-height: 0; justify-content: flex-start; background: none; border: none; color: #e0e0f0; font-size: 0.78rem; text-align: left; cursor: pointer; padding: 0.1rem 0.2rem; border-radius: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wt-label:hover { color: #4ecca3; }
.wt-icon-btn { width: 1.25rem; height: 1.25rem; min-height: 0; padding: 0; border: none; background: transparent; color: #666; opacity: 0; flex-shrink: 0; cursor: pointer; border-radius: 3px; }
.wt-label { flex: 1; min-width: 0; min-height: 0; justify-content: flex-start; background: none; border: none; color: var(--vt-color-text-secondary); font-size: 0.78rem; text-align: left; cursor: pointer; padding: 0.1rem 0; border-radius: var(--vt-radius-sm); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wt-label:hover { color: var(--vt-color-accent); }
.wt-icon-btn { width: 1.45rem; height: 1.45rem; min-height: 0; padding: 0; border: 1px solid transparent; background: transparent; color: var(--vt-color-text-muted); opacity: 0.75; flex-shrink: 0; cursor: pointer; border-radius: var(--vt-radius-sm); }
.wt-row:hover .wt-icon-btn { opacity: 1; }
.wt-icon-btn:hover:not(:disabled) { color: #4ecca3; background: rgba(78,204,163,0.1); }
.wt-icon-btn.danger:hover:not(:disabled) { color: #e94560; background: rgba(233,69,96,0.12); }
.wt-rename { flex: 1; min-width: 0; background: #0f3460; border: 1px solid #1a3a5c; color: #e0e0f0; padding: 0.2rem 0.35rem; border-radius: 4px; font-size: 0.78rem; }
.wt-rename:focus { outline: none; border-color: #4ecca3; }
.wt-create { position: absolute; bottom: 0; left: 0; right: 0; background: #16213e; border-top: 1px solid #0f3460; padding: 0.6rem; z-index: 10; }
.wt-create-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.4rem; color: #a0a0b8; font-size: 0.7rem; text-transform: uppercase; }
.wt-create input { width: 100%; background: #0f3460; border: 1px solid #1a3a5c; color: #e0e0f0; padding: 0.35rem 0.5rem; border-radius: 4px; font-size: 0.8rem; margin-bottom: 0.4rem; box-sizing: border-box; }
.wt-create input:focus { outline: none; border-color: #4ecca3; }
.wt-create-actions { display: flex; gap: 0.4rem; justify-content: flex-end; }
.wt-btn-primary { background: #4ecca3; color: #1a1a2e; border: none; padding: 0.3rem 0.6rem; border-radius: 4px; cursor: pointer; font-size: 0.75rem; font-weight: 600; }
.wt-icon-btn:hover:not(:disabled) { color: var(--vt-color-accent); background: var(--vt-color-accent-muted); border-color: rgba(78,204,163,0.25); }
.wt-icon-btn.danger:hover:not(:disabled) { color: var(--vt-color-danger); background: var(--vt-color-danger-muted); border-color: rgba(233,69,96,0.35); }
.wt-rename { flex: 1; min-width: 0; background: #0f1424; border: 1px solid var(--vt-color-border-strong); color: var(--vt-color-text-primary); padding: 0.2rem 0.35rem; border-radius: var(--vt-radius-sm); font-size: 0.78rem; }
.wt-rename:focus { outline: none; border-color: var(--vt-color-accent); box-shadow: var(--vt-focus-ring); }
.workspace-create-overlay { position: fixed; inset: 0; z-index: 10000; display: flex; align-items: center; justify-content: center; padding: 1rem; background: rgba(4, 8, 18, 0.7); }
.workspace-create-modal { width: min(34rem, 100%); display: grid; gap: 0.85rem; padding: 1rem; border: 1px solid var(--vt-color-border-strong); border-radius: var(--vt-radius-lg); background: var(--vt-color-surface); box-shadow: 0 18px 44px rgba(0, 0, 0, 0.38); }
.workspace-create-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; }
.workspace-create-header h2 { margin: 0; font-size: 1rem; }
.workspace-create-field { display: grid; gap: 0.35rem; color: var(--vt-color-text-muted); font-size: 0.75rem; }
.workspace-create-field input, .workspace-create-field select { width: 100%; min-height: 2rem; box-sizing: border-box; border: 1px solid var(--vt-color-border-strong); border-radius: var(--vt-radius-sm); background: #0f1424; color: var(--vt-color-text-primary); padding: 0.35rem 0.5rem; font: inherit; font-size: 0.84rem; }
.workspace-create-field input:focus, .workspace-create-field select:focus { outline: none; border-color: var(--vt-color-accent); box-shadow: var(--vt-focus-ring); }
.workspace-template-summary { display: grid; gap: 0.55rem; padding: 0.75rem; border: 1px solid var(--vt-color-border); border-radius: var(--vt-radius-md); background: var(--vt-color-surface-muted); }
.workspace-template-summary p { margin: 0; color: var(--vt-color-text-secondary); font-size: 0.8rem; line-height: 1.45; }
.workspace-template-tools { display: flex; flex-wrap: wrap; gap: 0.35rem; }
.workspace-template-tools span { min-height: 1.35rem; display: inline-flex; align-items: center; padding: 0 0.35rem; border: 1px solid var(--vt-color-border-strong); border-radius: var(--vt-radius-sm); color: var(--vt-color-text-secondary); font-size: 0.72rem; }
.workspace-create-error { margin: 0; color: var(--vt-color-danger); font-size: 0.78rem; line-height: 1.4; }
.workspace-create-actions { display: flex; gap: 0.4rem; justify-content: flex-end; }
.wt-btn-primary { background: var(--vt-color-accent); color: #101827; border: none; padding: 0.3rem 0.6rem; border-radius: var(--vt-radius-sm); cursor: pointer; font-size: 0.75rem; font-weight: 600; }
.wt-btn-primary:hover:not(:disabled) { background: #3dbb92; }
.wt-btn-primary:disabled, .wt-btn:disabled, .wt-icon-btn:disabled { opacity: 0.5; cursor: not-allowed; }
</style>
File diff suppressed because it is too large Load Diff
+7
View File
@@ -11,6 +11,7 @@
* - NO dependency on system icon fonts
* - If name is not found, renders a default circle icon
*/
import Activity from 'lucide-svelte/icons/activity';
import Briefcase from 'lucide-svelte/icons/briefcase';
import ChevronDown from 'lucide-svelte/icons/chevron-down';
import ChevronLeft from 'lucide-svelte/icons/chevron-left';
@@ -18,7 +19,9 @@
import Circle from 'lucide-svelte/icons/circle';
import FlaskConical from 'lucide-svelte/icons/flask-conical';
import Folder from 'lucide-svelte/icons/folder';
import Inbox from 'lucide-svelte/icons/inbox';
import LayoutGrid from 'lucide-svelte/icons/layout-grid';
import ListTodo from 'lucide-svelte/icons/list-todo';
import PanelsTopLeft from 'lucide-svelte/icons/panels-top-left';
import Pencil from 'lucide-svelte/icons/pencil';
import Plug from 'lucide-svelte/icons/plug';
@@ -34,6 +37,7 @@
export let className = '';
const icons = {
activity: Activity,
case: Briefcase,
chevronDown: ChevronDown,
chevronLeft: ChevronLeft,
@@ -43,6 +47,8 @@
flask: FlaskConical,
folder: Folder,
gear: Settings,
inbox: Inbox,
'list-todo': ListTodo,
logo: PanelsTopLeft,
plugin: Plug,
puzzle: Puzzle,
@@ -57,6 +63,7 @@
'🧪': 'flask',
danger: 'warning',
settings: 'gear',
todo: 'list-todo',
};
$: iconKey = aliases[name] || name || 'dot';
+28 -2
View File
@@ -1,7 +1,33 @@
import App from './App.svelte';
import * as Backend from '../wailsjs/go/api/App';
import { i18n } from './lib/i18n/index.js';
const app = new App({
target: document.getElementById('app'),
function unpack(result) {
if (Array.isArray(result) && result.length === 2 && (typeof result[1] === 'string' || result[1] == null)) {
if (result[1]) throw new Error(result[1]);
return result[0];
}
return result;
}
i18n.configure({
loadPluginCatalog: async (pluginId, locale) => unpack(await Backend.GetPluginLocalization(pluginId, locale)),
});
async function start() {
try {
const settings = await Backend.GetAppSettings();
await i18n.initialize(settings?.language || 'system');
} catch (error) {
console.error('[i18n] initialization failed:', error);
await i18n.initialize('system');
}
return new App({
target: document.getElementById('app'),
});
}
const app = start();
export default app;
+112
View File
@@ -0,0 +1,112 @@
import assert from 'node:assert/strict';
import {
createI18n,
resolveLocale,
} from '../src/lib/i18n/index.js';
import englishShellCatalog from '../src/lib/i18n/catalogs/en.js';
import russianShellCatalog from '../src/lib/i18n/catalogs/ru.js';
assert.deepEqual(
Object.keys(russianShellCatalog).sort(),
Object.keys(englishShellCatalog).sort(),
'English and Russian shell catalogs must have identical keys',
);
assert.equal(resolveLocale('system', ['ru-RU']), 'ru');
assert.equal(resolveLocale('system', ['uk-UA', 'en-US']), 'en');
assert.equal(resolveLocale('ru', ['en-US']), 'ru');
assert.equal(resolveLocale('en', ['ru-RU']), 'en');
assert.throws(() => resolveLocale('de', ['de-DE']), /unsupported language/);
const catalogs = {
'localized.plugin': {
en: { 'manifest.name': 'Localized Plugin', greeting: 'Hello, {name}!' },
ru: {
'manifest.name': 'Локализованный плагин',
'contributions.views.localized.view.title': 'Локализованный экран',
greeting: 'Привет, {name}!',
},
},
};
const loads = [];
const service = createI18n({
shellCatalogs: {
en: { loading: 'Loading {name}...', fallbackOnly: 'English fallback' },
ru: { loading: 'Загрузка {name}...' },
},
systemLanguages: () => ['ru-RU'],
loadPluginCatalog: async (pluginId, locale) => {
loads.push(`${pluginId}:${locale}`);
return catalogs[pluginId]?.[locale] || {};
},
});
await service.initialize('system');
assert.equal(service.getLanguagePreference(), 'system');
assert.equal(service.getLocale(), 'ru');
assert.equal(service.t('loading', { name: 'Верстак' }), 'Загрузка Верстак...');
assert.equal(service.t('fallbackOnly'), 'English fallback');
assert.equal(service.t('missing', undefined, 'Explicit fallback'), 'Explicit fallback');
assert.equal(service.t('unknown'), 'unknown');
await service.loadPlugin('localized.plugin', {
defaultLocale: 'en',
locales: { en: 'locales/en.json', ru: 'locales/ru.json' },
});
assert.deepEqual(loads.sort(), ['localized.plugin:en', 'localized.plugin:ru']);
assert.equal(service.translatePlugin('localized.plugin', 'greeting', { name: 'Мир' }), 'Привет, Мир!');
const plugin = {
manifest: {
id: 'localized.plugin',
name: 'Localized Plugin',
description: 'Literal description',
contributes: {
views: [{ id: 'localized.view', title: 'Literal View', component: 'View' }],
},
},
};
const localized = service.localizePlugin(plugin);
assert.notEqual(localized, plugin);
assert.equal(localized.manifest.name, 'Локализованный плагин');
assert.equal(localized.manifest.description, 'Literal description');
assert.equal(localized.manifest.contributes.views[0].title, 'Локализованный экран');
assert.equal(plugin.manifest.name, 'Localized Plugin');
const summary = service.localizeContributionSummary({
views: [{ pluginId: 'localized.plugin', id: 'localized.view', title: 'Literal View', component: 'View' }],
});
assert.equal(summary.views[0].title, 'Локализованный экран');
let notifications = 0;
const unsubscribe = service.subscribe(() => { notifications += 1; });
assert.equal(notifications, 1);
await service.setLanguagePreference('en');
assert.equal(service.getLocale(), 'en');
assert.equal(service.translatePlugin('localized.plugin', 'greeting', { name: 'World' }), 'Hello, World!');
assert.equal(notifications, 2);
unsubscribe();
await service.setLanguagePreference('ru');
assert.equal(notifications, 2);
const resilientService = createI18n({
shellCatalogs: { en: { title: 'Title' }, ru: { title: 'Заголовок' } },
systemLanguages: () => ['en-US'],
loadPluginCatalog: async () => { throw new Error('broken catalog'); },
});
await resilientService.initialize('en');
await assert.rejects(
resilientService.loadPlugin('broken.plugin', {
defaultLocale: 'en',
locales: { en: 'locales/en.json', ru: 'locales/ru.json' },
}),
/broken catalog/,
);
let resilientNotification = '';
resilientService.subscribe((nextLocale) => { resilientNotification = nextLocale; });
await resilientService.setLanguagePreference('ru');
assert.equal(resilientService.getLanguagePreference(), 'ru');
assert.equal(resilientService.getLocale(), 'ru');
assert.equal(resilientNotification, 'ru');
assert.equal(resilientService.t('title'), 'Заголовок');
console.log('i18n service tests passed');
@@ -0,0 +1,124 @@
import fs from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
const pluginData = {};
globalThis.window = {
__VERSTAK_PLUGIN_REGISTRY__: {},
__VERSTAK_EVENT_HANDLERS__: {},
__VERSTAK_COMMAND_HANDLERS__: {},
go: {
api: {
App: {
GetContributions: () => Promise.resolve({
fileActions: [
{
pluginId: 'provider.plugin',
id: 'provider.file.action',
label: 'Provider File Action',
handler: 'provider.command',
},
],
noteActions: [],
contextMenuEntries: [],
}),
ExecutePluginCommand: (pluginId, commandId, args) => Promise.resolve([{
status: 'declared',
pluginId,
commandId,
args,
}, '']),
ReadPluginDataJSON: (pluginId, name) => Promise.resolve([
Object.assign({}, (pluginData[pluginId] && pluginData[pluginId][name]) || {}),
'',
]),
WritePluginDataJSON: (pluginId, name, data) => {
pluginData[pluginId] = pluginData[pluginId] || {};
pluginData[pluginId][name] = Object.assign({}, data || {});
return Promise.resolve('');
},
},
},
},
};
globalThis.__mockApp = window.go.api.App;
const localeListeners = new Set();
globalThis.__mockI18n = {
getLocale: () => 'ru',
translatePlugin: (_pluginId, key, params, fallback) => {
const messages = { greeting: 'Привет, {name}!' };
const message = messages[key] || fallback || key;
return message.replace(/\{([^}]+)\}/g, (placeholder, name) => (
Object.prototype.hasOwnProperty.call(params || {}, name) ? String(params[name]) : placeholder
));
},
subscribe: (listener) => {
localeListeners.add(listener);
listener('ru');
return () => localeListeners.delete(listener);
},
};
const sourcePath = path.resolve('frontend/src/lib/plugin-host/VerstakPluginAPI.js');
const source = fs.readFileSync(sourcePath, 'utf8')
.replace("import * as App from '../../../wailsjs/go/api/App';", 'const App = globalThis.__mockApp;')
.replace("import { i18n } from '../i18n/index.js';", 'const i18n = globalThis.__mockI18n;');
const tempPath = path.resolve('/tmp/verstak-plugin-api-contributions-test.mjs');
fs.writeFileSync(tempPath, source);
const apiModule = await import(pathToFileURL(tempPath).href + '?t=' + Date.now());
const api = apiModule.createPluginAPI('verstak.files');
if (!api.contributions || typeof api.contributions.list !== 'function') {
throw new Error('api.contributions.list is missing');
}
if (!api.commands || typeof api.commands.executeFor !== 'function') {
throw new Error('api.commands.executeFor is missing');
}
if (!api.i18n || typeof api.i18n.getLocale !== 'function' || typeof api.i18n.t !== 'function' || typeof api.i18n.onDidChangeLocale !== 'function') {
throw new Error('api.i18n contract is missing');
}
if (api.i18n.getLocale() !== 'ru' || api.i18n.t('greeting', { name: 'Мир' }) !== 'Привет, Мир!') {
throw new Error('api.i18n locale or translation is incorrect');
}
let localeNotifications = 0;
api.i18n.onDidChangeLocale(() => { localeNotifications += 1; });
if (localeNotifications !== 1 || localeListeners.size !== 1) {
throw new Error('api.i18n locale subscription was not registered');
}
const fileActions = await api.contributions.list('fileActions');
if (fileActions.length !== 1 || fileActions[0].id !== 'provider.file.action') {
throw new Error(`unexpected file actions: ${JSON.stringify(fileActions)}`);
}
window.__VERSTAK_COMMAND_HANDLERS__['provider.plugin:provider.command'] = (args, declared) => ({
handledPath: args.path,
declaredPlugin: declared.pluginId,
});
const result = await api.commands.executeFor('provider.plugin', 'provider.command', {
source: 'files',
path: 'Project/Docs/readme.md',
});
if (result.status !== 'handled' || result.result.handledPath !== 'Project/Docs/readme.md') {
throw new Error(`unexpected executeFor result: ${JSON.stringify(result)}`);
}
if (!api.storage || !api.storage.data || typeof api.storage.data.read !== 'function' || typeof api.storage.data.write !== 'function') {
throw new Error('api.storage.data read/write is missing');
}
await api.storage.data.write('search-index', { version: 1, workspaceRootPath: 'Project' });
const stored = await api.storage.data.read('search-index');
if (stored.version !== 1 || stored.workspaceRootPath !== 'Project') {
throw new Error(`unexpected storage data: ${JSON.stringify(stored)}`);
}
api.dispose();
if (localeListeners.size !== 0) {
throw new Error('api.i18n locale subscription was not disposed');
}
console.log('plugin api contributions smoke passed');
+72
View File
@@ -0,0 +1,72 @@
import fs from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
const calls = [];
globalThis.window = {
__VERSTAK_PLUGIN_REGISTRY__: {},
__VERSTAK_EVENT_HANDLERS__: {},
__VERSTAK_COMMAND_HANDLERS__: {},
go: {
api: {
App: {
ReadVaultFileBytes: (pluginId, relativePath) => {
calls.push({ method: 'ReadVaultFileBytes', pluginId, relativePath });
return Promise.resolve([{ relativePath, size: 4, mimeHint: 'image/png', dataBase64: 'iVBORw==' }, '']);
},
RestoreVaultTrash: (pluginId, trashId, options) => {
calls.push({ method: 'RestoreVaultTrash', pluginId, trashId, options });
return Promise.resolve(['Docs/restored.txt', '']);
},
WriteVaultFileBytes: (pluginId, relativePath, dataBase64, options) => {
calls.push({ method: 'WriteVaultFileBytes', pluginId, relativePath, dataBase64, options });
return Promise.resolve('');
},
},
},
},
};
globalThis.__mockApp = window.go.api.App;
const sourcePath = path.resolve('frontend/src/lib/plugin-host/VerstakPluginAPI.js');
const source = fs.readFileSync(sourcePath, 'utf8')
.replace("import * as App from '../../../wailsjs/go/api/App';", 'const App = globalThis.__mockApp;');
const tempPath = path.resolve('/tmp/verstak-plugin-api-files-test.mjs');
fs.writeFileSync(tempPath, source);
const apiModule = await import(pathToFileURL(tempPath).href + '?t=' + Date.now());
const api = apiModule.createPluginAPI('verstak.files');
if (!api.files || typeof api.files.restoreTrash !== 'function') {
throw new Error('api.files.restoreTrash is missing');
}
if (typeof api.files.readBytes !== 'function') {
throw new Error('api.files.readBytes is missing');
}
if (typeof api.files.writeBytes !== 'function') {
throw new Error('api.files.writeBytes is missing');
}
const bytes = await api.files.readBytes('Docs/image.png');
if (bytes.dataBase64 !== 'iVBORw==' || bytes.mimeHint !== 'image/png') {
throw new Error(`unexpected readBytes result: ${JSON.stringify(bytes)}`);
}
await api.files.writeBytes('Docs/copy.png', 'iVBORw==', { createIfMissing: true });
const restored = await api.files.restoreTrash('trash-1', { overwrite: true });
if (restored !== 'Docs/restored.txt') {
throw new Error(`unexpected restore result: ${JSON.stringify(restored)}`);
}
if (calls.length !== 3 || calls[0].method !== 'ReadVaultFileBytes' || calls[0].pluginId !== 'verstak.files' || calls[0].relativePath !== 'Docs/image.png') {
throw new Error(`unexpected ReadVaultFileBytes call: ${JSON.stringify(calls)}`);
}
if (calls[1].method !== 'WriteVaultFileBytes' || calls[1].pluginId !== 'verstak.files' || calls[1].relativePath !== 'Docs/copy.png' || calls[1].dataBase64 !== 'iVBORw==' || calls[1].options.createIfMissing !== true) {
throw new Error(`unexpected WriteVaultFileBytes call: ${JSON.stringify(calls)}`);
}
if (calls[2].method !== 'RestoreVaultTrash' || calls[2].pluginId !== 'verstak.files' || calls[2].trashId !== 'trash-1' || calls[2].options.overwrite !== true) {
throw new Error(`unexpected RestoreVaultTrash call: ${JSON.stringify(calls)}`);
}
console.log('plugin api files smoke passed');
@@ -0,0 +1,57 @@
import fs from 'node:fs';
import path from 'node:path';
const root = path.resolve('.');
function read(relativePath) {
return fs.readFileSync(path.join(root, relativePath), 'utf8');
}
function assertIncludes(source, needle, message) {
if (!source.includes(needle)) {
throw new Error(message);
}
}
const workspaceHost = read('frontend/src/lib/shell/WorkspaceHost.svelte');
const statusBar = read('frontend/src/lib/shell/StatusBar.svelte');
const pluginManager = read('frontend/src/lib/plugin-manager/PluginManager.svelte');
const syncManifest = JSON.parse(read('../verstak-official-plugins/plugins/sync/plugin.json'));
assertIncludes(
workspaceHost,
'data-workspace-search',
'WorkspaceHost should expose a stable workspace header search slot'
);
assertIncludes(
workspaceHost,
'toolOrder',
'WorkspaceHost should define usage-based workspace tool ordering'
);
assertIncludes(
workspaceHost,
'sortWorkspaceTools',
'WorkspaceHost should sort workspace tools by expected usage'
);
assertIncludes(
statusBar,
"import PluginBundleHost from '../plugin-host/PluginBundleHost.svelte';",
'StatusBar should mount plugin-provided status bar components'
);
assertIncludes(
statusBar,
'componentId={item.handler}',
'StatusBar should use statusBarItems.handler as the component id'
);
const syncStatus = syncManifest.contributes.statusBarItems.find((item) => item.id === 'verstak.sync.status');
if (!syncStatus || syncStatus.handler !== 'SyncStatusBar') {
throw new Error('Sync statusBarItem should declare handler "SyncStatusBar"');
}
if (/lastOpenedKey\s*=\s*key;\s*openSettingsFromProps\(activeSettingsPluginId,\s*activeSettingsPanelId\)/s.test(pluginManager)) {
throw new Error('PluginManager should not mark settings panel as opened before resolving contributions');
}
console.log('shell source contract smoke passed');
+49 -13
View File
@@ -20,6 +20,8 @@ export function CreateWorkspace(arg1:string,arg2:string):Promise<workspace.Works
export function CreateWorkspaceNode(arg1:string,arg2:string,arg3:string):Promise<Record<string, any>>;
export function DeleteVaultTrash(arg1:string,arg2:string):Promise<string>;
export function DisablePlugin(arg1:string):Promise<string>;
export function EditWorkbenchResource(arg1:string,arg2:Record<string, any>):Promise<workbench.OpenResourceResult|string>;
@@ -46,6 +48,8 @@ export function GetPluginCapability(arg1:string,arg2:string):Promise<Record<stri
export function GetPluginFrontendInfo(arg1:string):Promise<Record<string, any>>;
export function GetPluginLocalization(arg1:string,arg2:string):Promise<Record<string, string>|string>;
export function GetPlugins():Promise<Array<plugin.Plugin>>;
export function GetVaultFileMetadata(arg1:string,arg2:string):Promise<files.FileMetadata|string>;
@@ -66,16 +70,54 @@ export function ListPluginCapabilities(arg1:string):Promise<Array<capability.Ent
export function ListVaultFiles(arg1:string,arg2:string):Promise<Array<files.FileEntry>|string>;
export function ListVaultTrash(arg1:string):Promise<Array<files.TrashEntry>|string>;
export function ListWorkspaces():Promise<Array<workspace.Workspace>|string>;
export function ListWorkspaceTemplates():Promise<Array<workspace.WorkspaceTemplate>|string>;
export function MoveVaultPath(arg1:string,arg2:string,arg3:string,arg4:files.MoveOptions):Promise<string>;
export function MoveWorkspaceNode(arg1:string,arg2:string):Promise<string>;
export function OpenVault(arg1:string):Promise<void>;
export function OpenVaultPathExternal(arg1:string,arg2:string):Promise<string>;
export function OpenWorkbenchResource(arg1:string,arg2:Record<string, any>):Promise<workbench.OpenResourceResult|string>;
export function PluginBrowserReceiverPairing(arg1:string):Promise<Record<string, string>|string>;
export function PluginRotateBrowserReceiverToken(arg1:string):Promise<Record<string, string>|string>;
export function PluginSecretsCopyLink(arg1:string,arg2:string):Promise<string|string>;
export function PluginSecretsDelete(arg1:string,arg2:string):Promise<string>;
export function PluginSecretsList(arg1:string):Promise<Array<Record<string, any>>|string>;
export function PluginSecretsRead(arg1:string,arg2:string):Promise<Record<string, any>|string>;
export function PluginSecretsStatus(arg1:string):Promise<Record<string, any>|string>;
export function PluginSecretsUnlock(arg1:string,arg2:string):Promise<string>;
export function PluginSecretsWrite(arg1:string,arg2:Record<string, any>):Promise<Record<string, any>|string>;
export function PluginSyncConfigure(arg1:string,arg2:string,arg3:string,arg4:string):Promise<string>;
export function PluginSyncDisconnect(arg1:string):Promise<string>;
export function PluginSyncNow(arg1:string):Promise<Record<string, any>|string>;
export function PluginSyncResetKey(arg1:string):Promise<string>;
export function PluginSyncSetInterval(arg1:string,arg2:number):Promise<string>;
export function PluginSyncStatus(arg1:string):Promise<api.SyncStatusDTO|string>;
export function PluginSyncTestConnection(arg1:string,arg2:string,arg3:string,arg4:string):Promise<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>>;
@@ -84,6 +126,8 @@ export function ReadPluginSetting(arg1:string,arg2:string):Promise<any>;
export function ReadPluginSettings(arg1:string):Promise<Record<string, any>|string>;
export function ReadVaultFileBytes(arg1:string,arg2:string):Promise<files.FileBytes|string>;
export function ReadVaultTextFile(arg1:string,arg2:string):Promise<string|string>;
export function RecordDesiredPlugin(arg1:string,arg2:string,arg3:string):Promise<string>;
@@ -94,7 +138,7 @@ export function RenameWorkspace(arg1:string,arg2:string):Promise<string>;
export function RenameWorkspaceNode(arg1:string,arg2:string):Promise<string>;
export function ResetSyncKey():Promise<void>;
export function RestoreVaultTrash(arg1:string,arg2:string,arg3:files.RestoreOptions):Promise<string|string>;
export function SelectDirectory():Promise<string>;
@@ -106,20 +150,10 @@ export function SetCurrentWorkspace(arg1:string):Promise<string>;
export function SetCurrentWorkspaceNode(arg1:string):Promise<string>;
export function ShowVaultPathInFolder(arg1:string,arg2:string):Promise<string>;
export function SubscribePluginEvent(arg1:string,arg2:string):Promise<string>;
export function SyncConfigure(arg1:string,arg2:string,arg3:string):Promise<void>;
export function SyncDisconnect():Promise<void>;
export function SyncNow():Promise<Record<string, any>>;
export function SyncSetInterval(arg1:number):Promise<void>;
export function SyncStatus():Promise<api.SyncStatusDTO>;
export function SyncTestConnection(arg1:string,arg2:string,arg3:string):Promise<void>;
export function TrashVaultPath(arg1:string,arg2:string):Promise<files.TrashResult|string>;
export function TrashWorkspace(arg1:string):Promise<workspace.TrashResult|string>;
@@ -138,4 +172,6 @@ export function WritePluginSetting(arg1:string,arg2:string,arg3:any):Promise<str
export function WritePluginSettings(arg1:string,arg2:Record<string, any>):Promise<string>;
export function WriteVaultFileBytes(arg1:string,arg2:string,arg3:string,arg4:files.WriteOptions):Promise<string>;
export function WriteVaultTextFile(arg1:string,arg2:string,arg3:string,arg4:files.WriteOptions):Promise<string>;
+98 -26
View File
@@ -26,6 +26,10 @@ export function CreateWorkspaceNode(arg1, arg2, arg3) {
return window['go']['api']['App']['CreateWorkspaceNode'](arg1, arg2, arg3);
}
export function DeleteVaultTrash(arg1, arg2) {
return window['go']['api']['App']['DeleteVaultTrash'](arg1, arg2);
}
export function DisablePlugin(arg1) {
return window['go']['api']['App']['DisablePlugin'](arg1);
}
@@ -78,6 +82,10 @@ export function GetPluginFrontendInfo(arg1) {
return window['go']['api']['App']['GetPluginFrontendInfo'](arg1);
}
export function GetPluginLocalization(arg1, arg2) {
return window['go']['api']['App']['GetPluginLocalization'](arg1, arg2);
}
export function GetPlugins() {
return window['go']['api']['App']['GetPlugins']();
}
@@ -118,10 +126,18 @@ export function ListVaultFiles(arg1, arg2) {
return window['go']['api']['App']['ListVaultFiles'](arg1, arg2);
}
export function ListVaultTrash(arg1) {
return window['go']['api']['App']['ListVaultTrash'](arg1);
}
export function ListWorkspaces() {
return window['go']['api']['App']['ListWorkspaces']();
}
export function ListWorkspaceTemplates() {
return window['go']['api']['App']['ListWorkspaceTemplates']();
}
export function MoveVaultPath(arg1, arg2, arg3, arg4) {
return window['go']['api']['App']['MoveVaultPath'](arg1, arg2, arg3, arg4);
}
@@ -134,10 +150,78 @@ export function OpenVault(arg1) {
return window['go']['api']['App']['OpenVault'](arg1);
}
export function OpenVaultPathExternal(arg1, arg2) {
return window['go']['api']['App']['OpenVaultPathExternal'](arg1, arg2);
}
export function OpenWorkbenchResource(arg1, arg2) {
return window['go']['api']['App']['OpenWorkbenchResource'](arg1, arg2);
}
export function PluginBrowserReceiverPairing(arg1) {
return window['go']['api']['App']['PluginBrowserReceiverPairing'](arg1);
}
export function PluginRotateBrowserReceiverToken(arg1) {
return window['go']['api']['App']['PluginRotateBrowserReceiverToken'](arg1);
}
export function PluginSecretsCopyLink(arg1, arg2) {
return window['go']['api']['App']['PluginSecretsCopyLink'](arg1, arg2);
}
export function PluginSecretsDelete(arg1, arg2) {
return window['go']['api']['App']['PluginSecretsDelete'](arg1, arg2);
}
export function PluginSecretsList(arg1) {
return window['go']['api']['App']['PluginSecretsList'](arg1);
}
export function PluginSecretsRead(arg1, arg2) {
return window['go']['api']['App']['PluginSecretsRead'](arg1, arg2);
}
export function PluginSecretsStatus(arg1) {
return window['go']['api']['App']['PluginSecretsStatus'](arg1);
}
export function PluginSecretsUnlock(arg1, arg2) {
return window['go']['api']['App']['PluginSecretsUnlock'](arg1, arg2);
}
export function PluginSecretsWrite(arg1, arg2) {
return window['go']['api']['App']['PluginSecretsWrite'](arg1, arg2);
}
export function PluginSyncConfigure(arg1, arg2, arg3, arg4) {
return window['go']['api']['App']['PluginSyncConfigure'](arg1, arg2, arg3, arg4);
}
export function PluginSyncDisconnect(arg1) {
return window['go']['api']['App']['PluginSyncDisconnect'](arg1);
}
export function PluginSyncNow(arg1) {
return window['go']['api']['App']['PluginSyncNow'](arg1);
}
export function PluginSyncResetKey(arg1) {
return window['go']['api']['App']['PluginSyncResetKey'](arg1);
}
export function PluginSyncSetInterval(arg1, arg2) {
return window['go']['api']['App']['PluginSyncSetInterval'](arg1, arg2);
}
export function PluginSyncStatus(arg1) {
return window['go']['api']['App']['PluginSyncStatus'](arg1);
}
export function PluginSyncTestConnection(arg1, arg2, arg3, arg4) {
return window['go']['api']['App']['PluginSyncTestConnection'](arg1, arg2, arg3, arg4);
}
export function PublishPluginEvent(arg1, arg2, arg3) {
return window['go']['api']['App']['PublishPluginEvent'](arg1, arg2, arg3);
}
@@ -154,6 +238,10 @@ export function ReadPluginSettings(arg1) {
return window['go']['api']['App']['ReadPluginSettings'](arg1);
}
export function ReadVaultFileBytes(arg1, arg2) {
return window['go']['api']['App']['ReadVaultFileBytes'](arg1, arg2);
}
export function ReadVaultTextFile(arg1, arg2) {
return window['go']['api']['App']['ReadVaultTextFile'](arg1, arg2);
}
@@ -174,8 +262,8 @@ export function RenameWorkspaceNode(arg1, arg2) {
return window['go']['api']['App']['RenameWorkspaceNode'](arg1, arg2);
}
export function ResetSyncKey() {
return window['go']['api']['App']['ResetSyncKey']();
export function RestoreVaultTrash(arg1, arg2, arg3) {
return window['go']['api']['App']['RestoreVaultTrash'](arg1, arg2, arg3);
}
export function SelectDirectory() {
@@ -198,34 +286,14 @@ export function SetCurrentWorkspaceNode(arg1) {
return window['go']['api']['App']['SetCurrentWorkspaceNode'](arg1);
}
export function ShowVaultPathInFolder(arg1, arg2) {
return window['go']['api']['App']['ShowVaultPathInFolder'](arg1, arg2);
}
export function SubscribePluginEvent(arg1, arg2) {
return window['go']['api']['App']['SubscribePluginEvent'](arg1, arg2);
}
export function SyncConfigure(arg1, arg2, arg3) {
return window['go']['api']['App']['SyncConfigure'](arg1, arg2, arg3);
}
export function SyncDisconnect() {
return window['go']['api']['App']['SyncDisconnect']();
}
export function SyncNow() {
return window['go']['api']['App']['SyncNow']();
}
export function SyncSetInterval(arg1) {
return window['go']['api']['App']['SyncSetInterval'](arg1);
}
export function SyncStatus() {
return window['go']['api']['App']['SyncStatus']();
}
export function SyncTestConnection(arg1, arg2, arg3) {
return window['go']['api']['App']['SyncTestConnection'](arg1, arg2, arg3);
}
export function TrashVaultPath(arg1, arg2) {
return window['go']['api']['App']['TrashVaultPath'](arg1, arg2);
}
@@ -262,6 +330,10 @@ export function WritePluginSettings(arg1, arg2) {
return window['go']['api']['App']['WritePluginSettings'](arg1, arg2);
}
export function WriteVaultFileBytes(arg1, arg2, arg3, arg4) {
return window['go']['api']['App']['WriteVaultFileBytes'](arg1, arg2, arg3, arg4);
}
export function WriteVaultTextFile(arg1, arg2, arg3, arg4) {
return window['go']['api']['App']['WriteVaultTextFile'](arg1, arg2, arg3, arg4);
}
+201 -1
View File
@@ -1,5 +1,51 @@
export namespace api {
export class FlatContextMenuEntry {
pluginId: string;
id: string;
label: string;
context: string;
group?: string;
capability?: string;
handler?: string;
static createFrom(source: any = {}) {
return new FlatContextMenuEntry(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.pluginId = source["pluginId"];
this.id = source["id"];
this.label = source["label"];
this.context = source["context"];
this.group = source["group"];
this.capability = source["capability"];
this.handler = source["handler"];
}
}
export class FlatAction {
pluginId: string;
id: string;
label: string;
icon?: string;
capability?: string;
handler?: string;
static createFrom(source: any = {}) {
return new FlatAction(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.pluginId = source["pluginId"];
this.id = source["id"];
this.label = source["label"];
this.icon = source["icon"];
this.capability = source["capability"];
this.handler = source["handler"];
}
}
export class FlatWorkspaceItem {
pluginId: string;
id: string;
@@ -25,6 +71,7 @@ export namespace api {
mime?: string[];
extensions?: string[];
contexts?: string[];
modes?: string[];
static createFrom(source: any = {}) {
return new FlatOpenProviderSupport(source);
@@ -36,6 +83,7 @@ export namespace api {
this.mime = source["mime"];
this.extensions = source["extensions"];
this.contexts = source["contexts"];
this.modes = source["modes"];
}
}
export class FlatOpenProvider {
@@ -78,6 +126,26 @@ export namespace api {
return a;
}
}
export class FlatStatusBarItem {
pluginId: string;
id: string;
label: string;
position?: string;
handler?: string;
static createFrom(source: any = {}) {
return new FlatStatusBarItem(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.pluginId = source["pluginId"];
this.id = source["id"];
this.label = source["label"];
this.position = source["position"];
this.handler = source["handler"];
}
}
export class FlatSidebarItem {
pluginId: string;
id: string;
@@ -120,6 +188,24 @@ export namespace api {
this.component = source["component"];
}
}
export class FlatSearchProvider {
pluginId: string;
id: string;
label: string;
handler: string;
static createFrom(source: any = {}) {
return new FlatSearchProvider(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.pluginId = source["pluginId"];
this.id = source["id"];
this.label = source["label"];
this.handler = source["handler"];
}
}
export class FlatCommand {
pluginId: string;
id: string;
@@ -163,10 +249,15 @@ export namespace api {
export class ContributionSummary {
views: FlatView[];
commands: FlatCommand[];
searchProviders: FlatSearchProvider[];
settingsPanels: FlatSettingsPanel[];
sidebarItems: FlatSidebarItem[];
statusBarItems: FlatStatusBarItem[];
openProviders: FlatOpenProvider[];
workspaceItems: FlatWorkspaceItem[];
fileActions: FlatAction[];
noteActions: FlatAction[];
contextMenuEntries: FlatContextMenuEntry[];
static createFrom(source: any = {}) {
return new ContributionSummary(source);
@@ -176,10 +267,15 @@ export namespace api {
if ('string' === typeof source) source = JSON.parse(source);
this.views = this.convertValues(source["views"], FlatView);
this.commands = this.convertValues(source["commands"], FlatCommand);
this.searchProviders = this.convertValues(source["searchProviders"], FlatSearchProvider);
this.settingsPanels = this.convertValues(source["settingsPanels"], FlatSettingsPanel);
this.sidebarItems = this.convertValues(source["sidebarItems"], FlatSidebarItem);
this.statusBarItems = this.convertValues(source["statusBarItems"], FlatStatusBarItem);
this.openProviders = this.convertValues(source["openProviders"], FlatOpenProvider);
this.workspaceItems = this.convertValues(source["workspaceItems"], FlatWorkspaceItem);
this.fileActions = this.convertValues(source["fileActions"], FlatAction);
this.noteActions = this.convertValues(source["noteActions"], FlatAction);
this.contextMenuEntries = this.convertValues(source["contextMenuEntries"], FlatContextMenuEntry);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
@@ -207,6 +303,10 @@ export namespace api {
export class SyncStatusDTO {
configured: boolean;
serverUrl: string;
@@ -269,6 +369,24 @@ export namespace capability {
export namespace files {
export class FileBytes {
relativePath: string;
size: number;
mimeHint: string;
dataBase64: string;
static createFrom(source: any = {}) {
return new FileBytes(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.relativePath = source["relativePath"];
this.size = source["size"];
this.mimeHint = source["mimeHint"];
this.dataBase64 = source["dataBase64"];
}
}
export class FileEntry {
name: string;
relativePath: string;
@@ -345,11 +463,50 @@ export namespace files {
this.overwrite = source["overwrite"];
}
}
export class RestoreOptions {
targetPath?: string;
overwrite: boolean;
static createFrom(source: any = {}) {
return new RestoreOptions(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.targetPath = source["targetPath"];
this.overwrite = source["overwrite"];
}
}
export class TrashEntry {
originalPath: string;
trashPath: string;
trashId: string;
deletedAt: string;
originalType: string;
basename: string;
size: number;
static createFrom(source: any = {}) {
return new TrashEntry(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"];
this.originalType = source["originalType"];
this.basename = source["basename"];
this.size = source["size"];
}
}
export class TrashResult {
originalPath: string;
trashPath: string;
trashId: string;
deletedAt: string;
size: number;
static createFrom(source: any = {}) {
return new TrashResult(source);
@@ -361,6 +518,7 @@ export namespace files {
this.trashPath = source["trashPath"];
this.trashId = source["trashId"];
this.deletedAt = source["deletedAt"];
this.size = source["size"];
}
}
export class WriteOptions {
@@ -534,6 +692,7 @@ export namespace plugin {
mime?: string[];
extensions?: string[];
contexts?: string[];
modes?: string[];
static createFrom(source: any = {}) {
return new OpenProviderSupport(source);
@@ -545,6 +704,7 @@ export namespace plugin {
this.mime = source["mime"];
this.extensions = source["extensions"];
this.contexts = source["contexts"];
this.modes = source["modes"];
}
}
export class ContributionOpenProvider {
@@ -760,6 +920,20 @@ export namespace plugin {
}
}
export class LocalizationConfig {
defaultLocale: string;
locales: Record<string, string>;
static createFrom(source: any = {}) {
return new LocalizationConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.defaultLocale = source["defaultLocale"];
this.locales = source["locales"];
}
}
export class SyncConfig {
namespaces?: string[];
participate?: boolean;
@@ -795,6 +969,7 @@ export namespace plugin {
description?: string;
source?: string;
icon?: string;
localization?: LocalizationConfig;
provides: string[];
requires?: string[];
optionalRequires?: string[];
@@ -819,6 +994,7 @@ export namespace plugin {
this.description = source["description"];
this.source = source["source"];
this.icon = source["icon"];
this.localization = this.convertValues(source["localization"], LocalizationConfig);
this.provides = source["provides"];
this.requires = source["requires"];
this.optionalRequires = source["optionalRequires"];
@@ -1054,11 +1230,33 @@ export namespace workbench {
export namespace workspace {
export class WorkspaceTemplate {
id: string;
name: string;
description: string;
version: number;
workspaceTools: string[];
static createFrom(source: any = {}) {
return new WorkspaceTemplate(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.description = source["description"];
this.version = source["version"];
this.workspaceTools = source["workspaceTools"];
}
}
export class TemplateSnapshot {
templateId: string;
templateName: string;
templateVersion: number;
appliedAt: string;
workspaceTools?: string[];
static createFrom(source: any = {}) {
return new TemplateSnapshot(source);
@@ -1070,6 +1268,7 @@ export namespace workspace {
this.templateName = source["templateName"];
this.templateVersion = source["templateVersion"];
this.appliedAt = source["appliedAt"];
this.workspaceTools = source["workspaceTools"];
}
}
export class Metadata {
@@ -1077,6 +1276,7 @@ export namespace workspace {
createdFromTemplate?: TemplateSnapshot;
features?: Record<string, boolean>;
folders?: Record<string, string>;
workspaceTools?: string[];
updatedAt?: string;
static createFrom(source: any = {}) {
@@ -1089,6 +1289,7 @@ export namespace workspace {
this.createdFromTemplate = this.convertValues(source["createdFromTemplate"], TemplateSnapshot);
this.features = source["features"];
this.folders = source["folders"];
this.workspaceTools = source["workspaceTools"];
this.updatedAt = source["updatedAt"];
}
@@ -1159,4 +1360,3 @@ export namespace workspace {
}
}
+1557 -123
View File
File diff suppressed because it is too large Load Diff
+1744 -3
View File
File diff suppressed because it is too large Load Diff
+110
View File
@@ -0,0 +1,110 @@
package api
import (
"os"
"strings"
"testing"
corefiles "github.com/verstak/verstak-desktop/internal/core/files"
)
func TestSyncNowAgainstRealServerTwoVaults(t *testing.T) {
serverURL := os.Getenv("VERSTAK_SYNC_SMOKE_SERVER_URL")
deviceA := os.Getenv("VERSTAK_SYNC_SMOKE_DEVICE_A")
deviceB := os.Getenv("VERSTAK_SYNC_SMOKE_DEVICE_B")
apiKeyA := os.Getenv("VERSTAK_SYNC_SMOKE_KEY_A")
apiKeyB := os.Getenv("VERSTAK_SYNC_SMOKE_KEY_B")
if serverURL == "" || deviceA == "" || deviceB == "" || apiKeyA == "" || apiKeyB == "" {
t.Skip("set VERSTAK_SYNC_SMOKE_* env vars to run the real sync-server smoke test")
}
appA, _ := newSyncFilesTestApp(t, []string{"files.read", "files.write", "files.delete"}, deviceA)
appB, _ := newSyncFilesTestApp(t, []string{"files.read", "files.write", "files.delete"}, deviceB)
if err := appA.syncSvc.SetState(serverURL, apiKeyA); err != nil {
t.Fatalf("appA SetState: %v", err)
}
if err := appB.syncSvc.SetState(serverURL, apiKeyB); err != nil {
t.Fatalf("appB SetState: %v", err)
}
if errStr := appA.CreateVaultFolder("files.plugin", "Shared"); errStr != "" {
t.Fatalf("appA CreateVaultFolder: %s", errStr)
}
if errStr := appA.WriteVaultTextFile("files.plugin", "Shared/one.txt", "from A", corefiles.WriteOptions{CreateIfMissing: true}); errStr != "" {
t.Fatalf("appA WriteVaultTextFile: %s", errStr)
}
expectSyncCounts(t, appA, 2, 2)
expectSyncCounts(t, appB, 0, 2)
expectText(t, appB, "Shared/one.txt", "from A")
if errStr := appB.WriteVaultTextFile("files.plugin", "Shared/one.txt", "from B", corefiles.WriteOptions{Overwrite: true}); errStr != "" {
t.Fatalf("appB update: %s", errStr)
}
if errStr := appB.MoveVaultPath("files.plugin", "Shared/one.txt", "Shared/two.txt", corefiles.MoveOptions{}); errStr != "" {
t.Fatalf("appB move: %s", errStr)
}
expectSyncCounts(t, appB, 2, 2)
expectSyncCounts(t, appA, 0, 2)
expectText(t, appA, "Shared/two.txt", "from B")
if _, errStr := appA.TrashVaultPath("files.plugin", "Shared/two.txt"); errStr != "" {
t.Fatalf("appA trash: %s", errStr)
}
expectSyncCounts(t, appA, 1, 1)
expectSyncCounts(t, appB, 0, 1)
if _, errStr := appB.GetVaultFileMetadata("files.plugin", "Shared/two.txt"); !strings.Contains(errStr, "not-found") {
t.Fatalf("appB deleted file metadata err = %q, want not-found", errStr)
}
if errStr := appB.CreateVaultFolder("files.plugin", "Shared/Folder"); errStr != "" {
t.Fatalf("appB create folder: %s", errStr)
}
expectSyncCounts(t, appB, 1, 1)
expectSyncCounts(t, appA, 0, 1)
if meta, errStr := appA.GetVaultFileMetadata("files.plugin", "Shared/Folder"); errStr != "" || meta.Type != corefiles.FileTypeFolder {
t.Fatalf("appA folder metadata = %+v err=%q, want folder", meta, errStr)
}
if errStr := appA.MoveVaultPath("files.plugin", "Shared/Folder", "Shared/Archive", corefiles.MoveOptions{}); errStr != "" {
t.Fatalf("appA move folder: %s", errStr)
}
expectSyncCounts(t, appA, 1, 1)
expectSyncCounts(t, appB, 0, 1)
if _, errStr := appB.GetVaultFileMetadata("files.plugin", "Shared/Folder"); !strings.Contains(errStr, "not-found") {
t.Fatalf("appB moved folder old metadata err = %q, want not-found", errStr)
}
if meta, errStr := appB.GetVaultFileMetadata("files.plugin", "Shared/Archive"); errStr != "" || meta.Type != corefiles.FileTypeFolder {
t.Fatalf("appB moved folder metadata = %+v err=%q, want folder", meta, errStr)
}
if _, errStr := appB.TrashVaultPath("files.plugin", "Shared/Archive"); errStr != "" {
t.Fatalf("appB trash folder: %s", errStr)
}
expectSyncCounts(t, appB, 1, 1)
expectSyncCounts(t, appA, 0, 1)
if _, errStr := appA.GetVaultFileMetadata("files.plugin", "Shared/Archive"); !strings.Contains(errStr, "not-found") {
t.Fatalf("appA deleted folder metadata err = %q, want not-found", errStr)
}
}
func expectSyncCounts(t *testing.T, app *App, pushed, pulled int) {
t.Helper()
result, err := app.syncNow()
if err != nil {
t.Fatalf("syncNow: %v", err)
}
if result["pushed"] != pushed || result["pulled"] != pulled {
t.Fatalf("sync result = %#v, want pushed=%d pulled=%d", result, pushed, pulled)
}
}
func expectText(t *testing.T, app *App, path, want string) {
t.Helper()
text, errStr := app.ReadVaultTextFile("files.plugin", path)
if errStr != "" {
t.Fatalf("ReadVaultTextFile(%s): %s", path, errStr)
}
if text != want {
t.Fatalf("ReadVaultTextFile(%s) = %q, want %q", path, text, want)
}
}
+111 -10
View File
@@ -4,26 +4,46 @@
package appsettings
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// Config represents the application settings stored in ~/.config/verstak/config.json.
type Config struct {
SchemaVersion int `json:"schemaVersion"`
CurrentVaultPath string `json:"currentVaultPath"`
RecentVaults []string `json:"recentVaults"`
Theme string `json:"theme"`
DevMode bool `json:"devMode"`
UserPluginsDir string `json:"userPluginsDir"`
Workbench WorkbenchPreferences `json:"workbench,omitempty"`
Sync SyncSettings `json:"sync,omitempty"`
WindowState *WindowState `json:"windowState,omitempty"`
LastOpenedAt string `json:"lastOpenedAt"`
SchemaVersion int `json:"schemaVersion"`
CurrentVaultPath string `json:"currentVaultPath"`
RecentVaults []string `json:"recentVaults"`
Theme string `json:"theme"`
Language string `json:"language"`
DevMode bool `json:"devMode"`
UserPluginsDir string `json:"userPluginsDir"`
Workbench WorkbenchPreferences `json:"workbench,omitempty"`
Sync SyncSettings `json:"sync,omitempty"`
BrowserReceiver BrowserReceiverSettings `json:"browserReceiver,omitempty"`
WindowState *WindowState `json:"windowState,omitempty"`
LastOpenedAt string `json:"lastOpenedAt"`
}
const (
LanguageSystem = "system"
LanguageEnglish = "en"
LanguageRussian = "ru"
)
func validLanguage(language string) bool {
switch language {
case LanguageSystem, LanguageEnglish, LanguageRussian:
return true
default:
return false
}
}
type WorkbenchPreferences struct {
@@ -44,6 +64,11 @@ type SyncSettings struct {
LastError string `json:"lastError,omitempty"`
}
// BrowserReceiverSettings holds the installation-local browser capture pairing secret.
type BrowserReceiverSettings struct {
Token string `json:"token,omitempty"`
}
// WindowState stores the last window position and size.
type WindowState struct {
Width int `json:"width"`
@@ -112,6 +137,9 @@ func (m *Manager) Load() error {
if cfg.Theme == "" {
cfg.Theme = "dark"
}
if !validLanguage(cfg.Language) {
cfg.Language = LanguageSystem
}
if cfg.RecentVaults == nil {
cfg.RecentVaults = []string{}
}
@@ -170,6 +198,9 @@ func (m *Manager) Update(patch *Config) error {
if patch.Theme != "" {
m.config.Theme = patch.Theme
}
if patch.Language != "" && validLanguage(patch.Language) {
m.config.Language = patch.Language
}
if patch.UserPluginsDir != "" {
m.config.UserPluginsDir = patch.UserPluginsDir
}
@@ -191,6 +222,73 @@ func (m *Manager) Update(patch *Config) error {
return m.saveLocked()
}
// UpdateLanguage validates and persists the installation-local UI language.
func (m *Manager) UpdateLanguage(language string) error {
language = strings.TrimSpace(language)
if !validLanguage(language) {
return fmt.Errorf("unsupported language %q", language)
}
m.mu.Lock()
defer m.mu.Unlock()
if m.config == nil {
m.config = defaultConfig()
}
m.config.Language = language
m.config.LastOpenedAt = time.Now().UTC().Format(time.RFC3339)
return m.saveLocked()
}
// UpdateSync replaces sync settings without changing unrelated app settings.
func (m *Manager) UpdateSync(syncSettings SyncSettings) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.config == nil {
m.config = defaultConfig()
}
m.config.Sync = syncSettings
m.config.LastOpenedAt = time.Now().UTC().Format(time.RFC3339)
return m.saveLocked()
}
// EnsureBrowserReceiverToken returns the persisted pairing token, creating it when absent.
func (m *Manager) EnsureBrowserReceiverToken() (string, error) {
m.mu.Lock()
defer m.mu.Unlock()
return m.updateBrowserReceiverTokenLocked(false)
}
// RotateBrowserReceiverToken replaces the persisted pairing token.
func (m *Manager) RotateBrowserReceiverToken() (string, error) {
m.mu.Lock()
defer m.mu.Unlock()
return m.updateBrowserReceiverTokenLocked(true)
}
func (m *Manager) updateBrowserReceiverTokenLocked(force bool) (string, error) {
if m.config == nil {
m.config = defaultConfig()
}
current := strings.TrimSpace(m.config.BrowserReceiver.Token)
if current != "" && !force {
return current, nil
}
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return "", fmt.Errorf("generate browser receiver token: %w", err)
}
token := base64.RawURLEncoding.EncodeToString(bytes)
m.config.BrowserReceiver.Token = token
if err := m.saveLocked(); err != nil {
m.config.BrowserReceiver.Token = current
return "", err
}
return token, nil
}
// SetCurrentVault updates the current vault path and adds to recents.
func (m *Manager) SetCurrentVault(path string) error {
m.mu.Lock()
@@ -228,6 +326,7 @@ func defaultConfig() *Config {
CurrentVaultPath: "",
RecentVaults: []string{},
Theme: "dark",
Language: LanguageSystem,
DevMode: false,
UserPluginsDir: filepath.Join(os.Getenv("HOME"), ".config", "verstak", "plugins"),
Workbench: WorkbenchPreferences{},
@@ -244,10 +343,12 @@ func copyConfig(c *Config) *Config {
CurrentVaultPath: c.CurrentVaultPath,
RecentVaults: recent,
Theme: c.Theme,
Language: c.Language,
DevMode: c.DevMode,
UserPluginsDir: c.UserPluginsDir,
Workbench: c.Workbench,
Sync: c.Sync,
BrowserReceiver: c.BrowserReceiver,
LastOpenedAt: c.LastOpenedAt,
}
if c.WindowState != nil {
+129
View File
@@ -25,6 +25,53 @@ func TestLoad_DefaultCreation(t *testing.T) {
if cfg.CurrentVaultPath != "" {
t.Errorf("CurrentVaultPath: got %q, want empty", cfg.CurrentVaultPath)
}
if cfg.Language != "system" {
t.Errorf("Language: got %q, want %q", cfg.Language, "system")
}
}
func TestLoad_MissingLanguageDefaultsToSystem(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"schemaVersion":1,"theme":"dark","devMode":true}`), 0o600); err != nil {
t.Fatal(err)
}
m := NewManager(path)
if err := m.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
if got := m.Get().Language; got != "system" {
t.Fatalf("Language = %q, want system", got)
}
}
func TestUpdateLanguagePersistsAndPreservesUnrelatedSettings(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
m := NewManager(path)
if err := m.Load(); err != nil {
t.Fatal(err)
}
if err := m.Update(&Config{Theme: "light", DevMode: true}); err != nil {
t.Fatal(err)
}
if err := m.UpdateLanguage("ru"); err != nil {
t.Fatalf("UpdateLanguage: %v", err)
}
reloaded := NewManager(path)
if err := reloaded.Load(); err != nil {
t.Fatal(err)
}
cfg := reloaded.Get()
if cfg.Language != "ru" || cfg.Theme != "light" || !cfg.DevMode {
t.Fatalf("settings after language update = %+v", cfg)
}
if err := reloaded.UpdateLanguage("de"); err == nil {
t.Fatal("UpdateLanguage(de) succeeded, want validation error")
}
if got := reloaded.Get().Language; got != "ru" {
t.Fatalf("Language after rejected update = %q, want ru", got)
}
}
func TestLoad_CorruptConfig(t *testing.T) {
@@ -52,6 +99,43 @@ func TestLoad_CorruptConfig(t *testing.T) {
// Just verify no panic
}
func TestBrowserReceiverTokenPersistsAndRotates(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
manager := NewManager(path)
if err := manager.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
firstToken, err := manager.EnsureBrowserReceiverToken()
if err != nil {
t.Fatalf("EnsureBrowserReceiverToken: %v", err)
}
if firstToken == "" {
t.Fatal("EnsureBrowserReceiverToken returned an empty token")
}
reloaded := NewManager(path)
if err := reloaded.Load(); err != nil {
t.Fatalf("reload settings: %v", err)
}
persistedToken, err := reloaded.EnsureBrowserReceiverToken()
if err != nil {
t.Fatalf("EnsureBrowserReceiverToken after reload: %v", err)
}
if persistedToken != firstToken {
t.Fatalf("persisted token = %q, want %q", persistedToken, firstToken)
}
rotatedToken, err := reloaded.RotateBrowserReceiverToken()
if err != nil {
t.Fatalf("RotateBrowserReceiverToken: %v", err)
}
if rotatedToken == "" || rotatedToken == firstToken {
t.Fatalf("rotated token = %q, want new non-empty token", rotatedToken)
}
}
func TestSetCurrentVault(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
@@ -155,6 +239,51 @@ func TestUpdate_WorkbenchPreferences(t *testing.T) {
}
}
func TestUpdateSync(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{DevMode: true}); err != nil {
t.Fatalf("Update dev mode: %v", err)
}
if err := m.UpdateSync(SyncSettings{
Enabled: true,
ServerURL: "https://sync.example",
DeviceID: "device-1",
DeviceName: "Desktop",
SyncInterval: 15,
LastStatus: "connected",
LastSyncAt: "2026-06-27T00:00:00Z",
LastError: "previous",
}); err != nil {
t.Fatalf("UpdateSync: %v", err)
}
reloaded := NewManager(path)
if err := reloaded.Load(); err != nil {
t.Fatal(err)
}
cfg := reloaded.Get()
if !cfg.Sync.Enabled ||
cfg.Sync.ServerURL != "https://sync.example" ||
cfg.Sync.DeviceID != "device-1" ||
cfg.Sync.DeviceName != "Desktop" ||
cfg.Sync.SyncInterval != 15 ||
cfg.Sync.LastStatus != "connected" ||
cfg.Sync.LastSyncAt != "2026-06-27T00:00:00Z" ||
cfg.Sync.LastError != "previous" {
t.Fatalf("sync settings = %+v", cfg.Sync)
}
if !cfg.DevMode {
t.Fatal("UpdateSync changed DevMode")
}
}
func TestAppSettings_NotInsideVault(t *testing.T) {
// App settings path should be under ~/.config/verstak/, not inside vault
path := DefaultConfigPath()
+462
View File
@@ -0,0 +1,462 @@
// Package browserreceiver hosts the local HTTP protocol used by the browser extension.
package browserreceiver
import (
"context"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/verstak/verstak-desktop/internal/core/events"
)
const (
capturePath = "/api/browser-inbox/v1/captures"
DefaultAddr = "127.0.0.1:47731"
DefaultCaptureURL = "http://" + DefaultAddr + capturePath
receiverTokenHeader = "X-Verstak-Receiver-Token"
)
const (
maxCaptureBodyBytes = 12 * 1024 * 1024
maxCaptureIDBytes = 256
maxCapturedAtBytes = 64
maxCaptureSourceBytes = 128
maxPageURLBytes = 4096
maxPageTitleBytes = 512
maxPageDomainBytes = 255
maxSelectionTextBytes = 20 * 1024
maxLinkTextBytes = 512
maxBrowserNameBytes = 64
maxFileNameBytes = 255
maxFileMimeBytes = 128
maxFileTextBytes = 2 * 1024 * 1024
maxFileBytes = 8 * 1024 * 1024
maxFileDataBase64Bytes = 4 * ((maxFileBytes + 2) / 3)
)
type Receiver struct {
bus *events.Bus
workspaceProvider WorkspaceProvider
optionsMu sync.RWMutex
options Options
}
type WorkspaceProvider func() string
type Options struct {
RequireToken bool
ReceiverToken string
Available func() bool
Persist func(events.Event) error
}
type Server struct {
listener net.Listener
server *http.Server
}
type CapturePayload struct {
SchemaVersion int `json:"schemaVersion"`
CaptureID string `json:"captureId"`
CapturedAt string `json:"capturedAt"`
Source string `json:"source"`
Kind string `json:"kind"`
Page CapturePage `json:"page"`
Selection *CaptureSelection `json:"selection,omitempty"`
Link *CaptureLink `json:"link,omitempty"`
File *CaptureFile `json:"file,omitempty"`
Browser *CaptureBrowser `json:"browser,omitempty"`
Context interface{} `json:"context,omitempty"`
}
type CapturePage struct {
URL string `json:"url"`
Title string `json:"title"`
Domain string `json:"domain"`
}
type CaptureSelection struct {
Text string `json:"text"`
}
type CaptureLink struct {
URL string `json:"url"`
Text string `json:"text"`
}
type CaptureFile struct {
Name string `json:"name"`
Mime string `json:"mime"`
Size int64 `json:"size"`
Text string `json:"text"`
DataBase64 string `json:"dataBase64"`
}
type CaptureBrowser struct {
Name string `json:"name"`
}
func New(bus *events.Bus, providers ...WorkspaceProvider) *Receiver {
return NewWithOptions(bus, Options{}, providers...)
}
func NewWithOptions(bus *events.Bus, options Options, providers ...WorkspaceProvider) *Receiver {
var provider WorkspaceProvider
if len(providers) > 0 {
provider = providers[0]
}
return &Receiver{bus: bus, workspaceProvider: provider, options: options}
}
// SetReceiverToken updates the active token without restarting the local server.
func (r *Receiver) SetReceiverToken(token string) {
if r == nil {
return
}
r.optionsMu.Lock()
defer r.optionsMu.Unlock()
r.options.RequireToken = true
r.options.ReceiverToken = strings.TrimSpace(token)
}
// SetPersistence configures durable capture storage and its availability gate.
func (r *Receiver) SetPersistence(available func() bool, persist func(events.Event) error) {
if r == nil {
return
}
r.optionsMu.Lock()
defer r.optionsMu.Unlock()
r.options.Available = available
r.options.Persist = persist
}
func Start(addr string, receiver *Receiver) (*Server, error) {
if receiver == nil {
return nil, fmt.Errorf("receiver is required")
}
listener, err := net.Listen("tcp", addr)
if err != nil {
return nil, err
}
s := &Server{
listener: listener,
server: &http.Server{
Handler: receiver,
},
}
go func() {
if err := s.server.Serve(listener); err != nil && err != http.ErrServerClosed {
log.Printf("[browserreceiver] serve: %v", err)
}
}()
return s, nil
}
func (s *Server) URL() string {
if s == nil || s.listener == nil {
return ""
}
return "http://" + s.listener.Addr().String()
}
func (s *Server) Close() error {
if s == nil || s.server == nil {
return nil
}
return s.server.Shutdown(context.Background())
}
func (r *Receiver) ServeHTTP(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "application/json")
if req.URL.Path != capturePath {
http.NotFound(w, req)
return
}
if req.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "method not allowed"})
return
}
if err := r.validateReceiverToken(req); err != nil {
writeError(w, http.StatusUnauthorized, err.Error())
return
}
options := r.currentOptions()
if options.Available != nil && !options.Available() {
writeError(w, http.StatusServiceUnavailable, "browser inbox unavailable")
return
}
defer req.Body.Close()
decoder := json.NewDecoder(http.MaxBytesReader(w, req.Body, maxCaptureBodyBytes))
var payload CapturePayload
if err := decoder.Decode(&payload); err != nil {
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
writeError(w, http.StatusRequestEntityTooLarge, "capture payload exceeds limit")
return
}
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
writeError(w, http.StatusRequestEntityTooLarge, "capture payload exceeds limit")
return
}
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
if err := payload.Validate(); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
eventName := "browser.capture." + payload.Kind
if options.Persist == nil && (r.bus == nil || !r.bus.HasSubscribers(eventName)) {
writeError(w, http.StatusServiceUnavailable, "browser inbox unavailable")
return
}
eventPayload := payload.EventPayload()
r.annotateWorkspace(eventPayload)
event := events.Event{
Name: eventName,
Timestamp: time.Now().UTC().Format(time.RFC3339Nano),
Payload: eventPayload,
}
if options.Persist != nil {
if err := options.Persist(event); err != nil {
log.Printf("[browserreceiver] persist %s: %v", payload.CaptureID, err)
writeError(w, http.StatusServiceUnavailable, "browser inbox unavailable")
return
}
}
if r.bus != nil {
r.bus.Publish(event)
}
w.WriteHeader(http.StatusAccepted)
_ = json.NewEncoder(w).Encode(map[string]string{
"status": "accepted",
"captureId": payload.CaptureID,
})
}
func (r *Receiver) currentOptions() Options {
if r == nil {
return Options{}
}
r.optionsMu.RLock()
defer r.optionsMu.RUnlock()
return r.options
}
func (r *Receiver) validateReceiverToken(req *http.Request) error {
if r == nil {
return nil
}
r.optionsMu.RLock()
requireToken := r.options.RequireToken
expected := strings.TrimSpace(r.options.ReceiverToken)
r.optionsMu.RUnlock()
if !requireToken {
return nil
}
if expected == "" {
return fmt.Errorf("receiver token required")
}
supplied := strings.TrimSpace(req.Header.Get(receiverTokenHeader))
if supplied == "" {
return fmt.Errorf("receiver token required")
}
if subtle.ConstantTimeCompare([]byte(supplied), []byte(expected)) != 1 {
return fmt.Errorf("receiver token invalid")
}
return nil
}
func (r *Receiver) annotateWorkspace(payload map[string]interface{}) {
if r == nil || r.workspaceProvider == nil || payload == nil {
return
}
if _, ok := payload["workspaceRootPath"]; ok {
return
}
workspaceRoot := strings.TrimSpace(r.workspaceProvider())
if workspaceRoot == "" {
return
}
payload["workspaceRootPath"] = workspaceRoot
payload["workspaceName"] = workspaceRoot
}
func (p CapturePayload) Validate() error {
if p.SchemaVersion != 1 {
return fmt.Errorf("unsupported schemaVersion")
}
if strings.TrimSpace(p.CaptureID) == "" {
return fmt.Errorf("captureId is required")
}
if err := validateCaptureText(p.CaptureID, "captureId", maxCaptureIDBytes); err != nil {
return err
}
if strings.TrimSpace(p.CapturedAt) == "" {
return fmt.Errorf("capturedAt is required")
}
if err := validateCaptureText(p.CapturedAt, "capturedAt", maxCapturedAtBytes); err != nil {
return err
}
if p.Kind != "page" && p.Kind != "selection" && p.Kind != "link" && p.Kind != "file" {
return fmt.Errorf("unsupported kind")
}
if strings.TrimSpace(p.Page.URL) == "" {
return fmt.Errorf("page.url is required")
}
if err := validateCaptureText(p.Source, "source", maxCaptureSourceBytes); err != nil {
return err
}
if err := validateCaptureText(p.Page.URL, "page.url", maxPageURLBytes); err != nil {
return err
}
if err := validateCaptureText(p.Page.Title, "page.title", maxPageTitleBytes); err != nil {
return err
}
if err := validateCaptureText(p.Page.Domain, "page.domain", maxPageDomainBytes); err != nil {
return err
}
if p.Browser != nil {
if err := validateCaptureText(p.Browser.Name, "browser.name", maxBrowserNameBytes); err != nil {
return err
}
}
if p.Kind == "selection" && (p.Selection == nil || strings.TrimSpace(p.Selection.Text) == "") {
return fmt.Errorf("selection.text is required")
}
if p.Kind == "selection" {
if err := validateCaptureText(p.Selection.Text, "selection.text", maxSelectionTextBytes); err != nil {
return err
}
}
if p.Kind == "link" {
if p.Link == nil || strings.TrimSpace(p.Link.URL) == "" {
return fmt.Errorf("link.url is required")
}
if err := validateCaptureText(p.Link.URL, "link.url", maxPageURLBytes); err != nil {
return err
}
if err := validateCaptureText(p.Link.Text, "link.text", maxLinkTextBytes); err != nil {
return err
}
}
if p.Kind == "file" {
if err := p.validateFile(); err != nil {
return err
}
}
return nil
}
func (p CapturePayload) validateFile() error {
if p.File == nil || strings.TrimSpace(p.File.Name) == "" {
return fmt.Errorf("file.name is required")
}
if p.File.Text == "" && strings.TrimSpace(p.File.DataBase64) == "" {
return fmt.Errorf("file.text or file.dataBase64 is required")
}
if p.File.Size < 0 || p.File.Size > maxFileBytes {
return fmt.Errorf("file.size exceeds limit")
}
if err := validateCaptureText(p.File.Name, "file.name", maxFileNameBytes); err != nil {
return err
}
if err := validateCaptureText(p.File.Mime, "file.mime", maxFileMimeBytes); err != nil {
return err
}
if err := validateCaptureText(p.File.Text, "file.text", maxFileTextBytes); err != nil {
return err
}
dataBase64 := strings.TrimSpace(p.File.DataBase64)
if dataBase64 == "" {
return nil
}
if len(dataBase64) > maxFileDataBase64Bytes {
return fmt.Errorf("file.dataBase64 exceeds limit")
}
data, err := base64.StdEncoding.DecodeString(dataBase64)
if err != nil {
return fmt.Errorf("file.dataBase64 is invalid")
}
if len(data) > maxFileBytes {
return fmt.Errorf("file.dataBase64 exceeds limit")
}
return nil
}
func validateCaptureText(value, field string, maxBytes int) error {
if len(value) > maxBytes {
return fmt.Errorf("%s exceeds limit", field)
}
return nil
}
func (p CapturePayload) EventPayload() map[string]interface{} {
pageURL := strings.TrimSpace(p.Page.URL)
result := map[string]interface{}{
"captureId": strings.TrimSpace(p.CaptureID),
"capturedAt": strings.TrimSpace(p.CapturedAt),
"source": strings.TrimSpace(p.Source),
"kind": p.Kind,
"url": pageURL,
"title": strings.TrimSpace(p.Page.Title),
"domain": captureDomain(pageURL, p.Page.Domain),
}
if p.Browser != nil {
result["browserName"] = strings.TrimSpace(p.Browser.Name)
}
if p.Context != nil {
result["context"] = p.Context
}
switch p.Kind {
case "selection":
result["text"] = strings.TrimSpace(p.Selection.Text)
case "link":
linkURL := strings.TrimSpace(p.Link.URL)
result["url"] = linkURL
result["title"] = strings.TrimSpace(p.Link.Text)
result["domain"] = captureDomain(linkURL, "")
case "file":
result["fileName"] = strings.TrimSpace(p.File.Name)
result["fileMime"] = strings.TrimSpace(p.File.Mime)
result["fileSize"] = p.File.Size
result["fileText"] = p.File.Text
result["fileDataBase64"] = strings.TrimSpace(p.File.DataBase64)
}
return result
}
func captureDomain(rawURL, fallback string) string {
if u, err := url.Parse(strings.TrimSpace(rawURL)); err == nil && u.Hostname() != "" {
return u.Hostname()
}
return strings.TrimSpace(fallback)
}
func writeError(w http.ResponseWriter, status int, message string) {
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]string{"error": message})
}
@@ -0,0 +1,589 @@
package browserreceiver
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/verstak/verstak-desktop/internal/core/events"
)
func TestReceiverAcceptsSelectionCaptureAndPublishesEvent(t *testing.T) {
bus := events.NewBus()
received := make(chan events.Event, 1)
bus.Subscribe("browser.capture.selection", func(event events.Event) {
received <- event
})
receiver := New(bus)
body := `{
"schemaVersion": 1,
"captureId": "capture-123",
"capturedAt": "2026-06-27T00:00:00.000Z",
"source": "verstak-browser-extension",
"kind": "selection",
"page": {
"url": "https://example.com/article",
"title": "Example Article",
"domain": "example.com"
},
"selection": {
"text": "selected text"
},
"browser": {
"name": "Chromium"
}
}`
req := httptest.NewRequest(http.MethodPost, "/api/browser-inbox/v1/captures", bytes.NewBufferString(body))
rec := httptest.NewRecorder()
receiver.ServeHTTP(rec, req)
if rec.Code != http.StatusAccepted {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusAccepted, rec.Body.String())
}
var response map[string]string
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
t.Fatalf("response json: %v", err)
}
if response["status"] != "accepted" {
t.Fatalf("response status = %q, want accepted", response["status"])
}
if response["captureId"] != "capture-123" {
t.Fatalf("response captureId = %q, want capture-123", response["captureId"])
}
event := <-received
if event.Name != "browser.capture.selection" {
t.Fatalf("event name = %q, want browser.capture.selection", event.Name)
}
payload, ok := event.Payload.(map[string]interface{})
if !ok {
t.Fatalf("event payload type = %T, want map[string]interface{}", event.Payload)
}
if payload["captureId"] != "capture-123" {
t.Fatalf("payload captureId = %v, want capture-123", payload["captureId"])
}
if payload["url"] != "https://example.com/article" {
t.Fatalf("payload url = %v, want https://example.com/article", payload["url"])
}
if payload["title"] != "Example Article" {
t.Fatalf("payload title = %v, want Example Article", payload["title"])
}
if payload["text"] != "selected text" {
t.Fatalf("payload text = %v, want selected text", payload["text"])
}
if payload["capturedAt"] != "2026-06-27T00:00:00.000Z" {
t.Fatalf("payload capturedAt = %v, want documented timestamp", payload["capturedAt"])
}
if payload["domain"] != "example.com" {
t.Fatalf("payload domain = %v, want example.com", payload["domain"])
}
}
func TestReceiverAcceptsFileCaptureAndPublishesEvent(t *testing.T) {
bus := events.NewBus()
received := make(chan events.Event, 1)
bus.Subscribe("browser.capture.file", func(event events.Event) {
received <- event
})
receiver := New(bus)
body := `{
"schemaVersion": 1,
"captureId": "capture-file",
"capturedAt": "2026-06-29T02:00:00.000Z",
"source": "verstak-browser-extension",
"kind": "file",
"page": {
"url": "https://example.com/files",
"title": "Example Files",
"domain": "example.com"
},
"file": {
"name": "notes.txt",
"mime": "text/plain",
"size": 11,
"text": "hello file",
"dataBase64": "aGVsbG8gZmlsZQ=="
},
"browser": {
"name": "Firefox"
}
}`
req := httptest.NewRequest(http.MethodPost, "/api/browser-inbox/v1/captures", bytes.NewBufferString(body))
rec := httptest.NewRecorder()
receiver.ServeHTTP(rec, req)
if rec.Code != http.StatusAccepted {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusAccepted, rec.Body.String())
}
event := <-received
if event.Name != "browser.capture.file" {
t.Fatalf("event name = %q, want browser.capture.file", event.Name)
}
payload, ok := event.Payload.(map[string]interface{})
if !ok {
t.Fatalf("event payload type = %T, want map[string]interface{}", event.Payload)
}
if payload["kind"] != "file" {
t.Fatalf("payload kind = %v, want file", payload["kind"])
}
if payload["fileName"] != "notes.txt" {
t.Fatalf("payload fileName = %v, want notes.txt", payload["fileName"])
}
if payload["fileMime"] != "text/plain" {
t.Fatalf("payload fileMime = %v, want text/plain", payload["fileMime"])
}
if payload["fileSize"] != int64(11) {
t.Fatalf("payload fileSize = %v, want 11", payload["fileSize"])
}
if payload["fileText"] != "hello file" {
t.Fatalf("payload fileText = %v, want hello file", payload["fileText"])
}
if payload["fileDataBase64"] != "aGVsbG8gZmlsZQ==" {
t.Fatalf("payload fileDataBase64 = %v, want aGVsbG8gZmlsZQ==", payload["fileDataBase64"])
}
}
func TestReceiverAnnotatesCaptureWithCurrentWorkspace(t *testing.T) {
bus := events.NewBus()
received := make(chan events.Event, 1)
bus.Subscribe("browser.capture.page", func(event events.Event) {
received <- event
})
receiver := New(bus, func() string { return "Project" })
body := `{
"schemaVersion": 1,
"captureId": "capture-workspace",
"capturedAt": "2026-06-27T00:00:00.000Z",
"source": "verstak-browser-extension",
"kind": "page",
"page": {
"url": "https://example.com/article",
"title": "Example Article"
}
}`
req := httptest.NewRequest(http.MethodPost, "/api/browser-inbox/v1/captures", bytes.NewBufferString(body))
rec := httptest.NewRecorder()
receiver.ServeHTTP(rec, req)
if rec.Code != http.StatusAccepted {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusAccepted, rec.Body.String())
}
event := <-received
payload, ok := event.Payload.(map[string]interface{})
if !ok {
t.Fatalf("event payload type = %T, want map[string]interface{}", event.Payload)
}
if payload["workspaceRootPath"] != "Project" {
t.Fatalf("payload workspaceRootPath = %v, want Project", payload["workspaceRootPath"])
}
if payload["workspaceName"] != "Project" {
t.Fatalf("payload workspaceName = %v, want Project", payload["workspaceName"])
}
}
func TestReceiverLeavesCaptureUnassignedWithoutCurrentWorkspace(t *testing.T) {
bus := events.NewBus()
received := make(chan events.Event, 1)
bus.Subscribe("browser.capture.page", func(event events.Event) {
received <- event
})
receiver := New(bus, func() string { return "" })
body := `{
"schemaVersion": 1,
"captureId": "capture-unassigned",
"capturedAt": "2026-06-27T00:00:00.000Z",
"source": "verstak-browser-extension",
"kind": "page",
"page": {
"url": "https://example.com/article",
"title": "Example Article"
}
}`
req := httptest.NewRequest(http.MethodPost, "/api/browser-inbox/v1/captures", bytes.NewBufferString(body))
rec := httptest.NewRecorder()
receiver.ServeHTTP(rec, req)
if rec.Code != http.StatusAccepted {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusAccepted, rec.Body.String())
}
event := <-received
payload, ok := event.Payload.(map[string]interface{})
if !ok {
t.Fatalf("event payload type = %T, want map[string]interface{}", event.Payload)
}
if _, ok := payload["workspaceRootPath"]; ok {
t.Fatalf("workspaceRootPath = %v, want absent for an unassigned capture", payload["workspaceRootPath"])
}
if _, ok := payload["workspaceName"]; ok {
t.Fatalf("workspaceName = %v, want absent for an unassigned capture", payload["workspaceName"])
}
}
func TestReceiverRequiresTokenWhenPaired(t *testing.T) {
bus := events.NewBus()
received := make(chan events.Event, 1)
bus.Subscribe("browser.capture.page", func(event events.Event) {
received <- event
})
receiver := NewWithOptions(bus, Options{RequireToken: true, ReceiverToken: "pair-token"})
body := `{
"schemaVersion": 1,
"captureId": "capture-paired",
"capturedAt": "2026-06-27T00:00:00.000Z",
"source": "verstak-browser-extension",
"kind": "page",
"page": {
"url": "https://example.com/article",
"title": "Example Article"
}
}`
for _, tc := range []struct {
name string
token string
wantError string
}{
{name: "missing", token: "", wantError: "receiver token required"},
{name: "wrong", token: "wrong-token", wantError: "receiver token invalid"},
} {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/browser-inbox/v1/captures", bytes.NewBufferString(body))
if tc.token != "" {
req.Header.Set("X-Verstak-Receiver-Token", tc.token)
}
rec := httptest.NewRecorder()
receiver.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusUnauthorized, rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte(tc.wantError)) {
t.Fatalf("response body = %q, want %q", rec.Body.String(), tc.wantError)
}
select {
case event := <-received:
t.Fatalf("unexpected event published for rejected capture: %#v", event)
default:
}
})
}
}
func TestReceiverAcceptsPairedToken(t *testing.T) {
bus := events.NewBus()
received := make(chan events.Event, 1)
bus.Subscribe("browser.capture.page", func(event events.Event) {
received <- event
})
receiver := NewWithOptions(bus, Options{RequireToken: true, ReceiverToken: "pair-token"})
body := `{
"schemaVersion": 1,
"captureId": "capture-paired",
"capturedAt": "2026-06-27T00:00:00.000Z",
"source": "verstak-browser-extension",
"kind": "page",
"page": {
"url": "https://example.com/article",
"title": "Example Article"
}
}`
req := httptest.NewRequest(http.MethodPost, "/api/browser-inbox/v1/captures", bytes.NewBufferString(body))
req.Header.Set("X-Verstak-Receiver-Token", "pair-token")
rec := httptest.NewRecorder()
receiver.ServeHTTP(rec, req)
if rec.Code != http.StatusAccepted {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusAccepted, rec.Body.String())
}
event := <-received
if event.Name != "browser.capture.page" {
t.Fatalf("event name = %q, want browser.capture.page", event.Name)
}
}
func TestReceiverRejectsCaptureWhenInboxIsUnavailable(t *testing.T) {
bus := events.NewBus()
bus.Subscribe("browser.capture.page", func(event events.Event) {
t.Fatalf("unexpected event while inbox is unavailable: %#v", event)
})
receiver := NewWithOptions(bus, Options{
Available: func() bool { return false },
})
req := httptest.NewRequest(http.MethodPost, capturePath, strings.NewReader(`{
"schemaVersion": 1,
"captureId": "capture-no-vault",
"capturedAt": "2026-07-11T12:00:00Z",
"kind": "page",
"page": {"url": "https://example.com"}
}`))
res := httptest.NewRecorder()
receiver.ServeHTTP(res, req)
if res.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want %d; body=%s", res.Code, http.StatusServiceUnavailable, res.Body.String())
}
}
func TestReceiverDoesNotAcknowledgeFailedPersistence(t *testing.T) {
bus := events.NewBus()
published := false
bus.Subscribe("browser.capture.page", func(event events.Event) {
published = true
})
receiver := NewWithOptions(bus, Options{
Available: func() bool { return true },
Persist: func(event events.Event) error {
return errors.New("disk full")
},
})
req := httptest.NewRequest(http.MethodPost, capturePath, strings.NewReader(`{
"schemaVersion": 1,
"captureId": "capture-disk-full",
"capturedAt": "2026-07-11T12:00:00Z",
"kind": "page",
"page": {"url": "https://example.com"}
}`))
res := httptest.NewRecorder()
receiver.ServeHTTP(res, req)
if res.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want %d; body=%s", res.Code, http.StatusServiceUnavailable, res.Body.String())
}
if published {
t.Fatal("capture event was published after persistence failed")
}
}
func TestReceiverRotatesPairedToken(t *testing.T) {
bus := events.NewBus()
bus.Subscribe("browser.capture.page", func(event events.Event) {})
receiver := NewWithOptions(bus, Options{RequireToken: true, ReceiverToken: "old-token"})
body := `{
"schemaVersion": 1,
"captureId": "capture-rotated-token",
"capturedAt": "2026-06-27T00:00:00.000Z",
"kind": "page",
"page": {"url": "https://example.com"}
}`
request := func(token string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, capturePath, bytes.NewBufferString(body))
req.Header.Set(receiverTokenHeader, token)
res := httptest.NewRecorder()
receiver.ServeHTTP(res, req)
return res
}
if res := request("old-token"); res.Code != http.StatusAccepted {
t.Fatalf("old token before rotation status = %d, want %d", res.Code, http.StatusAccepted)
}
receiver.SetReceiverToken("new-token")
if res := request("old-token"); res.Code != http.StatusUnauthorized {
t.Fatalf("old token after rotation status = %d, want %d", res.Code, http.StatusUnauthorized)
}
if res := request("new-token"); res.Code != http.StatusAccepted {
t.Fatalf("new token after rotation status = %d, want %d", res.Code, http.StatusAccepted)
}
}
func TestServerStartsOnLocalAddressAndAcceptsCapture(t *testing.T) {
bus := events.NewBus()
bus.Subscribe("browser.capture.page", func(event events.Event) {})
receiver := New(bus)
server, err := Start("127.0.0.1:0", receiver)
if err != nil {
t.Fatalf("Start: %v", err)
}
defer server.Close()
response, err := http.Post(server.URL()+capturePath, "application/json", bytes.NewBufferString(`{
"schemaVersion": 1,
"captureId": "capture-server",
"capturedAt": "2026-06-27T00:00:00.000Z",
"source": "verstak-browser-extension",
"kind": "page",
"page": {
"url": "https://example.com/article",
"title": "Example Article"
}
}`))
if err != nil {
t.Fatalf("post capture: %v", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusAccepted {
body, _ := io.ReadAll(response.Body)
t.Fatalf("status = %d, want %d; body=%s", response.StatusCode, http.StatusAccepted, string(body))
}
}
func TestReceiverRejectsCaptureWhenNoConsumerIsRegistered(t *testing.T) {
receiver := New(events.NewBus())
body := `{
"schemaVersion": 1,
"captureId": "capture-queued",
"capturedAt": "2026-06-27T00:00:00.000Z",
"source": "verstak-browser-extension",
"kind": "page",
"page": {
"url": "https://example.com/article",
"title": "Example Article"
}
}`
req := httptest.NewRequest(http.MethodPost, "/api/browser-inbox/v1/captures", bytes.NewBufferString(body))
rec := httptest.NewRecorder()
receiver.ServeHTTP(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusServiceUnavailable, rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte("browser inbox unavailable")) {
t.Fatalf("response body = %q, want unavailable error", rec.Body.String())
}
}
func TestReceiverRejectsInvalidCapturePayload(t *testing.T) {
receiver := New(events.NewBus())
body := `{
"schemaVersion": 1,
"captureId": "capture-123",
"capturedAt": "2026-06-27T00:00:00.000Z",
"source": "verstak-browser-extension",
"kind": "link",
"page": {
"url": "https://example.com/article",
"title": "Example Article"
}
}`
req := httptest.NewRequest(http.MethodPost, "/api/browser-inbox/v1/captures", bytes.NewBufferString(body))
rec := httptest.NewRecorder()
receiver.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte("link.url is required")) {
t.Fatalf("response body = %q, want validation error", rec.Body.String())
}
}
func TestReceiverRejectsOversizedCaptureBody(t *testing.T) {
bus := events.NewBus()
received := make(chan events.Event, 1)
bus.Subscribe("browser.capture.page", func(event events.Event) {
received <- event
})
receiver := New(bus)
const maxCaptureBodyBytes = 12 * 1024 * 1024
body := fmt.Sprintf(`{
"schemaVersion": 1,
"captureId": "capture-oversized",
"capturedAt": "2026-06-27T00:00:00.000Z",
"kind": "page",
"page": {"url": "https://example.com", "title": %q}
}`, strings.Repeat("x", maxCaptureBodyBytes))
req := httptest.NewRequest(http.MethodPost, "/api/browser-inbox/v1/captures", strings.NewReader(body))
rec := httptest.NewRecorder()
receiver.ServeHTTP(rec, req)
if rec.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusRequestEntityTooLarge, rec.Body.String())
}
select {
case event := <-received:
t.Fatalf("unexpected event published for oversized capture: %#v", event)
default:
}
}
func TestCapturePayloadRejectsUnsafeFileContent(t *testing.T) {
const maxFileBytes = 8 * 1024 * 1024
const maxFileTextBytes = 2 * 1024 * 1024
newFilePayload := func() CapturePayload {
return CapturePayload{
SchemaVersion: 1,
CaptureID: "capture-file-validation",
CapturedAt: "2026-06-27T00:00:00.000Z",
Kind: "file",
Page: CapturePage{URL: "https://example.com"},
File: &CaptureFile{Name: "attachment.bin", Size: 1, DataBase64: "AQ=="},
}
}
tests := []struct {
name string
edit func(*CapturePayload)
want string
}{
{
name: "oversized declared size",
edit: func(payload *CapturePayload) {
payload.File.Size = maxFileBytes + 1
},
want: "file.size exceeds limit",
},
{
name: "invalid base64",
edit: func(payload *CapturePayload) {
payload.File.DataBase64 = "not base64"
},
want: "file.dataBase64 is invalid",
},
{
name: "oversized decoded data",
edit: func(payload *CapturePayload) {
payload.File.DataBase64 = base64.StdEncoding.EncodeToString(make([]byte, maxFileBytes+1))
},
want: "file.dataBase64 exceeds limit",
},
{
name: "oversized text",
edit: func(payload *CapturePayload) {
payload.File.DataBase64 = ""
payload.File.Text = strings.Repeat("x", maxFileTextBytes+1)
},
want: "file.text exceeds limit",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
payload := newFilePayload()
tt.edit(&payload)
if err := payload.Validate(); err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("Validate() error = %v, want %q", err, tt.want)
}
})
}
}
+8
View File
@@ -33,10 +33,18 @@ func (r *Registry) Register(pluginID string, capabilities []string) error {
r.mu.Lock()
defer r.mu.Unlock()
pending := make(map[string]struct{}, len(capabilities))
for _, name := range capabilities {
if existing, ok := r.capabilities[name]; ok {
return fmt.Errorf("capability %q already registered by plugin %q", name, existing.PluginID)
}
if _, ok := pending[name]; ok {
return fmt.Errorf("capability %q is registered more than once by plugin %q", name, pluginID)
}
pending[name] = struct{}{}
}
for _, name := range capabilities {
r.capabilities[name] = &Entry{
Name: name,
PluginID: pluginID,
+21
View File
@@ -0,0 +1,21 @@
package capability
import "testing"
func TestRegisterDoesNotPartiallyRegisterCapabilitiesOnConflict(t *testing.T) {
registry := NewRegistry()
if err := registry.Register("existing.plugin", []string{"shared.capability"}); err != nil {
t.Fatalf("register existing capability: %v", err)
}
if err := registry.Register("failed.plugin", []string{"new.capability", "shared.capability"}); err == nil {
t.Fatal("Register returned nil for a duplicate capability")
}
if registry.Has("new.capability") {
t.Fatal("Register leaked a capability from the failed registration")
}
entry := registry.Get("shared.capability")
if entry == nil || entry.PluginID != "existing.plugin" {
t.Fatalf("shared capability entry = %#v, want existing.plugin", entry)
}
}
+32
View File
@@ -280,6 +280,15 @@ func (r *Registry) SidebarItems() []ContributionSidebarItem {
return result
}
func (r *Registry) StatusBarItems() []ContributionStatusBarItem {
r.mu.RLock()
defer r.mu.RUnlock()
result := make([]ContributionStatusBarItem, len(r.statusBarItems))
copy(result, r.statusBarItems)
sort.Slice(result, func(i, j int) bool { return result[i].Item.ID < result[j].Item.ID })
return result
}
func (r *Registry) FileActions() []ContributionAction {
r.mu.RLock()
defer r.mu.RUnlock()
@@ -298,6 +307,15 @@ func (r *Registry) NoteActions() []ContributionAction {
return result
}
func (r *Registry) ContextMenus() []ContributionContextMenuEntry {
r.mu.RLock()
defer r.mu.RUnlock()
result := make([]ContributionContextMenuEntry, len(r.contextMenus))
copy(result, r.contextMenus)
sort.Slice(result, func(i, j int) bool { return result[i].Item.ID < result[j].Item.ID })
return result
}
func (r *Registry) SearchProviders() []ContributionSearchProvider {
r.mu.RLock()
defer r.mu.RUnlock()
@@ -307,6 +325,20 @@ func (r *Registry) SearchProviders() []ContributionSearchProvider {
return result
}
func (r *Registry) ActivityProviders() []ContributionActivityProvider {
r.mu.RLock()
defer r.mu.RUnlock()
result := make([]ContributionActivityProvider, len(r.activityProviders))
copy(result, r.activityProviders)
sort.Slice(result, func(i, j int) bool {
if result[i].PluginID != result[j].PluginID {
return result[i].PluginID < result[j].PluginID
}
return result[i].Item.ID < result[j].Item.ID
})
return result
}
func (r *Registry) OpenProviders() []ContributionOpenProvider {
r.mu.RLock()
defer r.mu.RUnlock()
+7
View File
@@ -35,6 +35,13 @@ func (b *Bus) Subscribe(event string, handler Handler) {
b.handlers[event] = append(b.handlers[event], handler)
}
// HasSubscribers reports whether an event has at least one registered handler.
func (b *Bus) HasSubscribers(event string) bool {
b.mu.RLock()
defer b.mu.RUnlock()
return len(b.handlers[event]) > 0
}
// Unsubscribe removes all handlers for a plugin (matched by prefix or exact).
// For now, a simple version: clear all handlers for a given event name.
func (b *Bus) Unsubscribe(event string) {
+62
View File
@@ -0,0 +1,62 @@
package externalopen
import (
"os/exec"
"path/filepath"
"runtime"
)
type Runner func(name string, args ...string) error
type Service struct {
goos string
runner Runner
}
func NewService() *Service {
return NewServiceFor(runtime.GOOS, func(name string, args ...string) error {
return exec.Command(name, args...).Start()
})
}
func NewServiceFor(goos string, runner Runner) *Service {
return &Service{goos: goos, runner: runner}
}
func (s *Service) OpenPath(path string) error {
name, args := s.openCommand(path)
return s.runner(name, args...)
}
func (s *Service) ShowInFolder(path string, isDir bool) error {
name, args := s.showCommand(path, isDir)
return s.runner(name, args...)
}
func (s *Service) openCommand(path string) (string, []string) {
switch s.goos {
case "darwin":
return "open", []string{path}
case "windows":
return "rundll32", []string{"url.dll,FileProtocolHandler", path}
default:
return "xdg-open", []string{path}
}
}
func (s *Service) showCommand(path string, isDir bool) (string, []string) {
switch s.goos {
case "darwin":
return "open", []string{"-R", path}
case "windows":
if isDir {
return "explorer", []string{path}
}
return "explorer", []string{"/select," + path}
default:
if isDir {
return "xdg-open", []string{path}
}
return "xdg-open", []string{filepath.Dir(path)}
}
}
@@ -0,0 +1,71 @@
package externalopen
import (
"reflect"
"testing"
)
func TestOpenPathCommands(t *testing.T) {
cases := []struct {
goos string
path string
want commandCall
}{
{goos: "linux", path: "/vault/file.txt", want: commandCall{name: "xdg-open", args: []string{"/vault/file.txt"}}},
{goos: "darwin", path: "/vault/file.txt", want: commandCall{name: "open", args: []string{"/vault/file.txt"}}},
{goos: "windows", path: `C:\Vault\file.txt`, want: commandCall{name: "rundll32", args: []string{"url.dll,FileProtocolHandler", `C:\Vault\file.txt`}}},
}
for _, tc := range cases {
t.Run(tc.goos, func(t *testing.T) {
var got commandCall
svc := NewServiceFor(tc.goos, func(name string, args ...string) error {
got = commandCall{name: name, args: args}
return nil
})
if err := svc.OpenPath(tc.path); err != nil {
t.Fatalf("OpenPath: %v", err)
}
if !reflect.DeepEqual(got, tc.want) {
t.Fatalf("command = %#v, want %#v", got, tc.want)
}
})
}
}
func TestShowInFolderCommands(t *testing.T) {
cases := []struct {
name string
goos string
path string
isDir bool
want commandCall
}{
{name: "linux file opens parent", goos: "linux", path: "/vault/Docs/file.txt", want: commandCall{name: "xdg-open", args: []string{"/vault/Docs"}}},
{name: "linux dir opens dir", goos: "linux", path: "/vault/Docs", isDir: true, want: commandCall{name: "xdg-open", args: []string{"/vault/Docs"}}},
{name: "darwin reveal", goos: "darwin", path: "/vault/file.txt", want: commandCall{name: "open", args: []string{"-R", "/vault/file.txt"}}},
{name: "windows select file", goos: "windows", path: `C:\Vault\file.txt`, want: commandCall{name: "explorer", args: []string{`/select,C:\Vault\file.txt`}}},
{name: "windows open dir", goos: "windows", path: `C:\Vault\Docs`, isDir: true, want: commandCall{name: "explorer", args: []string{`C:\Vault\Docs`}}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var got commandCall
svc := NewServiceFor(tc.goos, func(name string, args ...string) error {
got = commandCall{name: name, args: args}
return nil
})
if err := svc.ShowInFolder(tc.path, tc.isDir); err != nil {
t.Fatalf("ShowInFolder: %v", err)
}
if !reflect.DeepEqual(got, tc.want) {
t.Fatalf("command = %#v, want %#v", got, tc.want)
}
})
}
}
type commandCall struct {
name string
args []string
}
+311 -3
View File
@@ -1,12 +1,15 @@
package files
import (
"encoding/base64"
"encoding/json"
"fmt"
"io/fs"
"mime"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"unicode/utf8"
@@ -88,6 +91,31 @@ func (s *Service) GetVaultFileMetadata(relativePath string) (FileMetadata, error
return makeMetadata(rel, info), nil
}
func (s *Service) ResolveExternalOpenTarget(relativePath string) (ExternalOpenTarget, error) {
root, rel, full, err := s.resolveFile(relativePath)
if err != nil {
return ExternalOpenTarget{}, err
}
if err := rejectSymlinkPath(root, rel, true); err != nil {
return ExternalOpenTarget{}, err
}
info, err := os.Lstat(full)
if err != nil {
if os.IsNotExist(err) {
return ExternalOpenTarget{}, fmt.Errorf("not-found: %s", rel)
}
return ExternalOpenTarget{}, err
}
if info.Mode()&os.ModeSymlink != 0 {
return ExternalOpenTarget{}, fmt.Errorf("symlink-not-allowed: %s", rel)
}
return ExternalOpenTarget{
RelativePath: rel,
AbsolutePath: full,
Metadata: makeMetadata(rel, info),
}, nil
}
func (s *Service) ReadVaultTextFile(relativePath string) (string, error) {
root, rel, full, err := s.resolveFile(relativePath)
if err != nil {
@@ -122,7 +150,58 @@ func (s *Service) ReadVaultTextFile(relativePath string) (string, error) {
return string(data), nil
}
func (s *Service) ReadVaultFileBytes(relativePath string) (FileBytes, error) {
root, rel, full, err := s.resolveFile(relativePath)
if err != nil {
return FileBytes{}, err
}
if err := rejectSymlinkPath(root, rel, true); err != nil {
return FileBytes{}, err
}
info, err := os.Lstat(full)
if err != nil {
if os.IsNotExist(err) {
return FileBytes{}, fmt.Errorf("not-found: %s", rel)
}
return FileBytes{}, err
}
if info.Mode()&os.ModeSymlink != 0 {
return FileBytes{}, fmt.Errorf("symlink-not-allowed: %s", rel)
}
if !info.Mode().IsRegular() {
return FileBytes{}, fmt.Errorf("not-regular-file: %s", rel)
}
if info.Size() > MaxBinaryReadBytes {
return FileBytes{}, fmt.Errorf("file-too-large: %s", rel)
}
data, err := os.ReadFile(full)
if err != nil {
return FileBytes{}, err
}
return FileBytes{
RelativePath: rel,
Size: int64(len(data)),
MimeHint: mime.TypeByExtension(filepath.Ext(info.Name())),
DataBase64: base64.StdEncoding.EncodeToString(data),
}, nil
}
func (s *Service) WriteVaultTextFile(relativePath string, content string, options WriteOptions) error {
return s.writeVaultFileData(relativePath, []byte(content), options)
}
func (s *Service) WriteVaultFileBytes(relativePath string, dataBase64 string, options WriteOptions) error {
data, err := base64.StdEncoding.DecodeString(dataBase64)
if err != nil {
return fmt.Errorf("invalid-base64: %w", err)
}
if int64(len(data)) > MaxBinaryReadBytes {
return fmt.Errorf("file-too-large: %s", relativePath)
}
return s.writeVaultFileData(relativePath, data, options)
}
func (s *Service) writeVaultFileData(relativePath string, data []byte, options WriteOptions) error {
root, rel, full, err := s.resolveFile(relativePath)
if err != nil {
return err
@@ -171,7 +250,7 @@ func (s *Service) WriteVaultTextFile(relativePath string, content string, option
_ = os.Remove(tmpName)
}
}()
if _, err := tmp.WriteString(content); err != nil {
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return err
}
@@ -287,20 +366,24 @@ func (s *Service) TrashVaultPath(relativePath string) (TrashResult, error) {
if err := os.Rename(full, trashFull); err != nil {
return TrashResult{}, err
}
originalType := fileTypeFromInfo(info)
size := sizeForType(originalType, info)
result := TrashResult{
OriginalPath: rel,
TrashPath: trashRel,
TrashID: trashID,
DeletedAt: deletedAt,
Size: size,
}
meta := map[string]string{
"originalPath": rel,
"trashPath": trashRel,
"trashId": trashID,
"deletedAt": deletedAt,
"originalType": string(fileTypeFromInfo(info)),
"originalType": string(originalType),
"basename": filepath.Base(rel),
"type": string(fileTypeFromInfo(info)),
"type": string(originalType),
"size": strconv.FormatInt(size, 10),
}
data, err := json.MarshalIndent(meta, "", " ")
if err != nil {
@@ -312,6 +395,231 @@ func (s *Service) TrashVaultPath(relativePath string) (TrashResult, error) {
return result, nil
}
func (s *Service) ListTrashEntries() ([]TrashEntry, error) {
root, err := s.vaultRoot()
if err != nil {
return nil, err
}
trashRoot := filepath.Join(root, ".verstak", "trash", "files")
dirs, err := os.ReadDir(trashRoot)
if err != nil {
if os.IsNotExist(err) {
return []TrashEntry{}, nil
}
return nil, err
}
entries := make([]TrashEntry, 0, len(dirs))
for _, dir := range dirs {
if !dir.IsDir() {
continue
}
data, err := os.ReadFile(filepath.Join(trashRoot, dir.Name(), "metadata.json"))
if err != nil {
if os.IsNotExist(err) {
continue
}
return nil, err
}
var raw struct {
OriginalPath string `json:"originalPath"`
TrashPath string `json:"trashPath"`
TrashID string `json:"trashId"`
DeletedAt string `json:"deletedAt"`
OriginalType string `json:"originalType"`
Basename string `json:"basename"`
Size string `json:"size"`
}
if err := json.Unmarshal(data, &raw); err != nil {
continue
}
if raw.OriginalPath == "" || raw.TrashPath == "" || raw.TrashID == "" || raw.DeletedAt == "" {
continue
}
entries = append(entries, TrashEntry{
OriginalPath: raw.OriginalPath,
TrashPath: raw.TrashPath,
TrashID: raw.TrashID,
DeletedAt: raw.DeletedAt,
OriginalType: FileType(raw.OriginalType),
Basename: raw.Basename,
Size: parseTrashSize(raw.Size),
})
}
sort.Slice(entries, func(i, j int) bool {
if entries[i].DeletedAt != entries[j].DeletedAt {
return entries[i].DeletedAt > entries[j].DeletedAt
}
return entries[i].TrashID > entries[j].TrashID
})
return entries, nil
}
func (s *Service) RestoreTrashEntry(trashID string, options RestoreOptions) (string, error) {
root, err := s.vaultRoot()
if err != nil {
return "", err
}
if err := validateTrashID(trashID); err != nil {
return "", err
}
entry, err := readTrashEntry(root, trashID)
if err != nil {
return "", err
}
targetRel := entry.OriginalPath
if options.TargetPath != "" {
targetRel = options.TargetPath
}
targetRel, err = NormalizeRelativeFile(targetRel)
if err != nil {
return "", err
}
targetFull, err := s.resolve(root, targetRel)
if err != nil {
return "", err
}
if err := rejectSymlinkPath(root, targetRel, false); err != nil {
return "", err
}
trashDir := filepath.Join(root, ".verstak", "trash", "files", trashID)
payloadFull := filepath.Join(root, filepath.FromSlash(entry.TrashPath))
if !isInsideDir(trashDir, payloadFull) {
return "", fmt.Errorf("invalid-trash-entry: payload outside trash")
}
if info, err := os.Lstat(payloadFull); err != nil {
if os.IsNotExist(err) {
return "", fmt.Errorf("not-found: trash payload %s", trashID)
}
return "", err
} else if info.Mode()&os.ModeSymlink != 0 {
return "", fmt.Errorf("symlink-not-allowed: %s", entry.TrashPath)
}
parent := filepath.Dir(targetFull)
if info, err := os.Stat(parent); err != nil {
if os.IsNotExist(err) {
return "", fmt.Errorf("parent-not-found: %s", pathDir(targetRel))
}
return "", err
} else if !info.IsDir() {
return "", fmt.Errorf("parent-not-directory: %s", pathDir(targetRel))
}
if existing, err := os.Lstat(targetFull); err == nil {
if existing.Mode()&os.ModeSymlink != 0 {
return "", fmt.Errorf("symlink-not-allowed: %s", targetRel)
}
if !options.Overwrite {
return "", fmt.Errorf("conflict: %s", targetRel)
}
if err := os.RemoveAll(targetFull); err != nil {
return "", err
}
} else if err != nil && !os.IsNotExist(err) {
return "", err
}
if err := os.Rename(payloadFull, targetFull); err != nil {
return "", err
}
if err := os.RemoveAll(trashDir); err != nil {
return "", err
}
return targetRel, nil
}
// DeleteTrashEntry permanently removes an internal trash entry.
func (s *Service) DeleteTrashEntry(trashID string) error {
root, err := s.vaultRoot()
if err != nil {
return err
}
if err := validateTrashID(trashID); err != nil {
return err
}
if _, err := readTrashEntry(root, trashID); err != nil {
return err
}
return os.RemoveAll(filepath.Join(root, ".verstak", "trash", "files", trashID))
}
func readTrashEntry(root, trashID string) (TrashEntry, error) {
if err := validateTrashID(trashID); err != nil {
return TrashEntry{}, err
}
data, err := os.ReadFile(filepath.Join(root, ".verstak", "trash", "files", trashID, "metadata.json"))
if err != nil {
if os.IsNotExist(err) {
return TrashEntry{}, fmt.Errorf("not-found: trash entry %s", trashID)
}
return TrashEntry{}, err
}
var raw struct {
OriginalPath string `json:"originalPath"`
TrashPath string `json:"trashPath"`
TrashID string `json:"trashId"`
DeletedAt string `json:"deletedAt"`
OriginalType string `json:"originalType"`
Basename string `json:"basename"`
Size string `json:"size"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return TrashEntry{}, err
}
if raw.OriginalPath == "" || raw.TrashPath == "" || raw.TrashID == "" || raw.DeletedAt == "" {
return TrashEntry{}, fmt.Errorf("invalid-trash-entry: missing metadata")
}
if raw.TrashID != trashID {
return TrashEntry{}, fmt.Errorf("invalid-trash-entry: mismatched trash id")
}
return TrashEntry{
OriginalPath: raw.OriginalPath,
TrashPath: raw.TrashPath,
TrashID: raw.TrashID,
DeletedAt: raw.DeletedAt,
OriginalType: FileType(raw.OriginalType),
Basename: raw.Basename,
Size: parseTrashSize(raw.Size),
}, nil
}
func parseTrashSize(value string) int64 {
if value == "" {
return 0
}
size, err := strconv.ParseInt(value, 10, 64)
if err != nil || size < 0 {
return 0
}
return size
}
func validateTrashID(trashID string) error {
if trashID == "" || trashID == "." || trashID == ".." || strings.ContainsAny(trashID, "/\\\x00") {
return fmt.Errorf("invalid-trash-id")
}
return nil
}
func isInsideDir(parent, child string) bool {
absParent, err := filepath.Abs(parent)
if err != nil {
return false
}
absChild, err := filepath.Abs(child)
if err != nil {
return false
}
rel, err := filepath.Rel(absParent, absChild)
if err != nil {
return false
}
return rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator)) && !filepath.IsAbs(rel)
}
func (s *Service) vaultRoot() (string, error) {
if s == nil || s.vault == nil {
return "", fmt.Errorf("vault-not-initialized")
+245
View File
@@ -1,6 +1,7 @@
package files
import (
"encoding/base64"
"encoding/json"
"os"
"path/filepath"
@@ -87,6 +88,9 @@ func TestPathPolicyRejectsUnsafeOperations(t *testing.T) {
if _, err := s.ReadVaultTextFile(input); err == nil {
t.Fatal("read: expected error")
}
if _, err := s.ReadVaultFileBytes(input); err == nil {
t.Fatal("read bytes: expected error")
}
if err := s.WriteVaultTextFile(input, "x", WriteOptions{CreateIfMissing: true}); err == nil {
t.Fatal("write: expected error")
}
@@ -137,6 +141,47 @@ func TestReadVaultTextFileRules(t *testing.T) {
}
}
func TestReadVaultFileBytesRules(t *testing.T) {
s, root := newTestService(t)
imageBytes := []byte{0x89, 0x50, 0x4e, 0x47}
if err := os.WriteFile(filepath.Join(root, "image.png"), imageBytes, 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, "huge.bin"), []byte(strings.Repeat("a", int(MaxBinaryReadBytes)+1)), 0o644); err != nil {
t.Fatal(err)
}
result, err := s.ReadVaultFileBytes("image.png")
if err != nil {
t.Fatalf("ReadVaultFileBytes image: %v", err)
}
if result.RelativePath != "image.png" {
t.Fatalf("relative path = %q, want image.png", result.RelativePath)
}
if result.Size != int64(len(imageBytes)) {
t.Fatalf("size = %d, want %d", result.Size, len(imageBytes))
}
if result.MimeHint != "image/png" {
t.Fatalf("mime hint = %q, want image/png", result.MimeHint)
}
if result.DataBase64 != "iVBORw==" {
t.Fatalf("dataBase64 = %q, want iVBORw==", result.DataBase64)
}
if _, err := s.ReadVaultFileBytes("Folder"); err == nil || !strings.Contains(err.Error(), "not-regular-file") {
t.Fatalf("read folder error = %v, want not-regular-file", err)
}
if _, err := s.ReadVaultFileBytes("missing.png"); err == nil || !strings.Contains(err.Error(), "not-found") {
t.Fatalf("read missing error = %v, want not-found", err)
}
if _, err := s.ReadVaultFileBytes("huge.bin"); err == nil || !strings.Contains(err.Error(), "file-too-large") {
t.Fatalf("read huge error = %v, want file-too-large", err)
}
}
func TestWriteVaultTextFileAtomicAndConflictBehavior(t *testing.T) {
s, root := newTestService(t)
@@ -177,6 +222,55 @@ func TestWriteVaultTextFileAtomicAndConflictBehavior(t *testing.T) {
}
}
func TestWriteVaultFileBytesAtomicAndConflictBehavior(t *testing.T) {
s, root := newTestService(t)
if err := s.WriteVaultFileBytes("Images/logo.png", "iVBORw==", WriteOptions{CreateIfMissing: true}); err == nil {
t.Fatal("write bytes should fail when parent folder is missing")
}
if err := s.CreateVaultFolder("Images"); err != nil {
t.Fatalf("CreateVaultFolder: %v", err)
}
if err := s.WriteVaultFileBytes("Images/logo.png", "iVBORw==", WriteOptions{CreateIfMissing: true}); err != nil {
t.Fatalf("write bytes create: %v", err)
}
data, err := os.ReadFile(filepath.Join(root, "Images", "logo.png"))
if err != nil {
t.Fatal(err)
}
if string(data) != string([]byte{0x89, 0x50, 0x4e, 0x47}) {
t.Fatalf("file bytes = %v", data)
}
if err := s.WriteVaultFileBytes("Images/logo.png", "AQID", WriteOptions{CreateIfMissing: true}); err == nil || !strings.Contains(err.Error(), "conflict") {
t.Fatalf("write bytes conflict error = %v, want conflict", err)
}
if err := s.WriteVaultFileBytes("Images/logo.png", "AQID", WriteOptions{Overwrite: true}); err != nil {
t.Fatalf("write bytes overwrite: %v", err)
}
data, err = os.ReadFile(filepath.Join(root, "Images", "logo.png"))
if err != nil {
t.Fatal(err)
}
if string(data) != string([]byte{0x01, 0x02, 0x03}) {
t.Fatalf("overwritten bytes = %v", data)
}
matches, err := filepath.Glob(filepath.Join(root, "Images", ".verstak-write-*"))
if err != nil {
t.Fatal(err)
}
if len(matches) != 0 {
t.Fatalf("atomic byte write left temp files: %v", matches)
}
if err := s.WriteVaultFileBytes("Images/bad.bin", "not-base64!", WriteOptions{CreateIfMissing: true}); err == nil || !strings.Contains(err.Error(), "invalid-base64") {
t.Fatalf("invalid base64 error = %v, want invalid-base64", err)
}
tooLarge := base64.StdEncoding.EncodeToString([]byte(strings.Repeat("a", int(MaxBinaryReadBytes)+1)))
if err := s.WriteVaultFileBytes("Images/huge.bin", tooLarge, WriteOptions{CreateIfMissing: true}); err == nil || !strings.Contains(err.Error(), "file-too-large") {
t.Fatalf("oversized bytes error = %v, want file-too-large", err)
}
}
func TestCreateVaultFolderConflict(t *testing.T) {
s, _ := newTestService(t)
if err := s.CreateVaultFolder("Folder"); err != nil {
@@ -301,6 +395,157 @@ func TestTrashVaultPathMovesToReservedTrashAndHidesFromList(t *testing.T) {
}
}
func TestListTrashEntriesReturnsMetadata(t *testing.T) {
s, root := newTestService(t)
if err := os.WriteFile(filepath.Join(root, "old.txt"), []byte("old"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "new.txt"), []byte("new"), 0o644); err != nil {
t.Fatal(err)
}
oldResult, err := s.TrashVaultPath("old.txt")
if err != nil {
t.Fatalf("trash old: %v", err)
}
newResult, err := s.TrashVaultPath("new.txt")
if err != nil {
t.Fatalf("trash new: %v", err)
}
entries, err := s.ListTrashEntries()
if err != nil {
t.Fatalf("ListTrashEntries: %v", err)
}
if len(entries) != 2 {
t.Fatalf("trash entries count = %d, want 2: %+v", len(entries), entries)
}
if entries[0].TrashID != newResult.TrashID || entries[1].TrashID != oldResult.TrashID {
t.Fatalf("trash entries order = %+v, want newest first", entries)
}
if entries[0].OriginalPath != "new.txt" || entries[0].TrashPath != newResult.TrashPath || entries[0].OriginalType != FileTypeFile || entries[0].Basename != "new.txt" || entries[0].Size != 3 {
t.Fatalf("new trash entry = %+v", entries[0])
}
}
func TestRestoreTrashEntryRestoresOriginalPathAndRemovesTrashMetadata(t *testing.T) {
s, root := newTestService(t)
if err := os.Mkdir(filepath.Join(root, "Docs"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "Docs", "restore.txt"), []byte("restore me"), 0o644); err != nil {
t.Fatal(err)
}
trash, err := s.TrashVaultPath("Docs/restore.txt")
if err != nil {
t.Fatalf("TrashVaultPath: %v", err)
}
restored, err := s.RestoreTrashEntry(trash.TrashID, RestoreOptions{})
if err != nil {
t.Fatalf("RestoreTrashEntry: %v", err)
}
if restored != "Docs/restore.txt" {
t.Fatalf("restored path = %q, want Docs/restore.txt", restored)
}
data, err := os.ReadFile(filepath.Join(root, "Docs", "restore.txt"))
if err != nil {
t.Fatalf("restored file missing: %v", err)
}
if string(data) != "restore me" {
t.Fatalf("restored content = %q", string(data))
}
if _, err := os.Stat(filepath.Join(root, trash.TrashPath)); !os.IsNotExist(err) {
t.Fatalf("trash payload should be removed, stat err = %v", err)
}
entries, err := s.ListTrashEntries()
if err != nil {
t.Fatalf("ListTrashEntries: %v", err)
}
if len(entries) != 0 {
t.Fatalf("trash entries after restore = %+v, want none", entries)
}
}
func TestDeleteTrashEntryRemovesPayloadAndMetadata(t *testing.T) {
s, root := newTestService(t)
if err := os.WriteFile(filepath.Join(root, "delete-permanently.txt"), []byte("remove me"), 0o644); err != nil {
t.Fatal(err)
}
trash, err := s.TrashVaultPath("delete-permanently.txt")
if err != nil {
t.Fatalf("TrashVaultPath: %v", err)
}
if err := s.DeleteTrashEntry(trash.TrashID); err != nil {
t.Fatalf("DeleteTrashEntry: %v", err)
}
if _, err := os.Stat(filepath.Join(root, ".verstak", "trash", "files", trash.TrashID)); !os.IsNotExist(err) {
t.Fatalf("trash directory should be removed, stat err = %v", err)
}
entries, err := s.ListTrashEntries()
if err != nil {
t.Fatalf("ListTrashEntries: %v", err)
}
if len(entries) != 0 {
t.Fatalf("trash entries after permanent delete = %+v, want none", entries)
}
if err := s.DeleteTrashEntry(trash.TrashID); err == nil || !strings.Contains(err.Error(), "not-found: trash entry") {
t.Fatalf("second DeleteTrashEntry error = %v, want not found", err)
}
}
func TestRestoreTrashEntryConflictAndOverwrite(t *testing.T) {
s, root := newTestService(t)
if err := os.WriteFile(filepath.Join(root, "conflict.txt"), []byte("trashed"), 0o644); err != nil {
t.Fatal(err)
}
trash, err := s.TrashVaultPath("conflict.txt")
if err != nil {
t.Fatalf("TrashVaultPath: %v", err)
}
if err := os.WriteFile(filepath.Join(root, "conflict.txt"), []byte("existing"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := s.RestoreTrashEntry(trash.TrashID, RestoreOptions{}); err == nil || !strings.Contains(err.Error(), "conflict: conflict.txt") {
t.Fatalf("restore conflict error = %v, want conflict", err)
}
data, err := os.ReadFile(filepath.Join(root, "conflict.txt"))
if err != nil {
t.Fatal(err)
}
if string(data) != "existing" {
t.Fatalf("conflicting file content = %q, want existing", string(data))
}
restored, err := s.RestoreTrashEntry(trash.TrashID, RestoreOptions{Overwrite: true})
if err != nil {
t.Fatalf("RestoreTrashEntry overwrite: %v", err)
}
if restored != "conflict.txt" {
t.Fatalf("restored path = %q, want conflict.txt", restored)
}
data, err = os.ReadFile(filepath.Join(root, "conflict.txt"))
if err != nil {
t.Fatal(err)
}
if string(data) != "trashed" {
t.Fatalf("overwritten content = %q, want trashed", string(data))
}
}
func TestRestoreTrashEntryRejectsInvalidTrashID(t *testing.T) {
s, _ := newTestService(t)
for _, trashID := range []string{"", "../escape", "bad/slash"} {
t.Run(trashID, func(t *testing.T) {
if _, err := s.RestoreTrashEntry(trashID, RestoreOptions{}); err == nil {
t.Fatal("expected invalid trash id error")
}
})
}
}
func TestSymlinkEscapeRejected(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink creation requires privileges on many Windows test environments")
+30
View File
@@ -1,6 +1,7 @@
package files
const MaxTextFileBytes int64 = 2 * 1024 * 1024
const MaxBinaryReadBytes int64 = 8 * 1024 * 1024
type FileType string
@@ -39,6 +40,19 @@ type FileMetadata struct {
CanWrite bool `json:"canWrite"`
}
type FileBytes struct {
RelativePath string `json:"relativePath"`
Size int64 `json:"size"`
MimeHint string `json:"mimeHint"`
DataBase64 string `json:"dataBase64"`
}
type ExternalOpenTarget struct {
RelativePath string `json:"relativePath"`
AbsolutePath string `json:"absolutePath"`
Metadata FileMetadata `json:"metadata"`
}
type WriteOptions struct {
CreateIfMissing bool `json:"createIfMissing"`
Overwrite bool `json:"overwrite"`
@@ -48,9 +62,25 @@ type MoveOptions struct {
Overwrite bool `json:"overwrite"`
}
type RestoreOptions struct {
TargetPath string `json:"targetPath,omitempty"`
Overwrite bool `json:"overwrite"`
}
type TrashResult struct {
OriginalPath string `json:"originalPath"`
TrashPath string `json:"trashPath"`
TrashID string `json:"trashId"`
DeletedAt string `json:"deletedAt"`
Size int64 `json:"size"`
}
type TrashEntry struct {
OriginalPath string `json:"originalPath"`
TrashPath string `json:"trashPath"`
TrashID string `json:"trashId"`
DeletedAt string `json:"deletedAt"`
OriginalType FileType `json:"originalType"`
Basename string `json:"basename"`
Size int64 `json:"size"`
}
+233
View File
@@ -0,0 +1,233 @@
// Package filewatcher provides a lightweight live vault change watcher.
package filewatcher
import (
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/verstak/verstak-desktop/internal/core/events"
)
const defaultInterval = 750 * time.Millisecond
type entryKind string
const (
entryFile entryKind = "file"
entryFolder entryKind = "folder"
entrySymlink entryKind = "symlink"
entryUnknown entryKind = "unknown"
)
type snapshotEntry struct {
kind entryKind
size int64
modTime time.Time
}
// Service polls an open vault and publishes file.changed events for external changes.
type Service struct {
bus *events.Bus
interval time.Duration
mu sync.Mutex
root string
cancel chan struct{}
done chan struct{}
current map[string]snapshotEntry
}
// NewService creates a watcher. The interval parameter is mainly for tests.
func NewService(bus *events.Bus, interval time.Duration) *Service {
if interval <= 0 {
interval = defaultInterval
}
return &Service{bus: bus, interval: interval}
}
// Start begins watching root. Any previous watch is stopped first.
func (s *Service) Start(root string) error {
if s == nil {
return fmt.Errorf("file watcher is nil")
}
if root == "" {
return fmt.Errorf("file watcher root is empty")
}
root, err := filepath.Abs(root)
if err != nil {
return err
}
info, err := os.Stat(root)
if err != nil {
return err
}
if !info.IsDir() {
return fmt.Errorf("file watcher root is not a directory: %s", root)
}
initial, err := scan(root)
if err != nil {
return err
}
s.Stop()
s.mu.Lock()
s.root = root
s.current = initial
s.cancel = make(chan struct{})
s.done = make(chan struct{})
cancel := s.cancel
done := s.done
s.mu.Unlock()
go s.loop(root, cancel, done)
return nil
}
// Stop stops the active watcher.
func (s *Service) Stop() {
if s == nil {
return
}
s.mu.Lock()
cancel := s.cancel
done := s.done
if cancel == nil {
s.mu.Unlock()
return
}
s.cancel = nil
s.done = nil
s.root = ""
s.current = nil
close(cancel)
s.mu.Unlock()
<-done
}
func (s *Service) loop(root string, cancel <-chan struct{}, done chan<- struct{}) {
defer close(done)
ticker := time.NewTicker(s.interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
s.poll(root)
case <-cancel:
return
}
}
}
func (s *Service) poll(root string) {
next, err := scan(root)
if err != nil {
return
}
s.mu.Lock()
prev := s.current
s.current = next
s.mu.Unlock()
for path, entry := range next {
old, ok := prev[path]
if !ok {
s.publish(path, "external.create", entry.kind)
continue
}
if entry.kind == entryFile && (entry.size != old.size || !entry.modTime.Equal(old.modTime)) {
s.publish(path, "external.update", entry.kind)
}
}
for path, entry := range prev {
if _, ok := next[path]; !ok {
s.publish(path, "external.delete", entry.kind)
}
}
}
func (s *Service) publish(path, operation string, kind entryKind) {
if s.bus == nil {
return
}
s.bus.Publish(events.Event{
Name: "file.changed",
Timestamp: time.Now().UTC().Format(time.RFC3339Nano),
Payload: map[string]interface{}{
"path": path,
"title": path,
"operation": operation,
"type": string(kind),
"workspaceRootPath": workspaceRoot(path),
"external": true,
},
})
}
func scan(root string) (map[string]snapshotEntry, error) {
out := make(map[string]snapshotEntry)
err := filepath.WalkDir(root, func(path string, dirEntry fs.DirEntry, err error) error {
if err != nil {
return nil
}
if path == root {
return nil
}
rel, err := filepath.Rel(root, path)
if err != nil {
return nil
}
rel = filepath.ToSlash(rel)
if isReserved(rel) {
if dirEntry.IsDir() {
return filepath.SkipDir
}
return nil
}
info, err := dirEntry.Info()
if err != nil {
return nil
}
out[rel] = snapshotEntry{
kind: kindFromInfo(info),
size: info.Size(),
modTime: info.ModTime(),
}
return nil
})
return out, err
}
func kindFromInfo(info fs.FileInfo) entryKind {
if info.Mode()&os.ModeSymlink != 0 {
return entrySymlink
}
if info.IsDir() {
return entryFolder
}
if info.Mode().IsRegular() {
return entryFile
}
return entryUnknown
}
func isReserved(rel string) bool {
first := strings.Split(filepath.ToSlash(rel), "/")[0]
return strings.EqualFold(first, ".verstak")
}
func workspaceRoot(path string) string {
path = strings.Trim(strings.TrimSpace(filepath.ToSlash(path)), "/")
if path == "" {
return ""
}
if idx := strings.Index(path, "/"); idx >= 0 {
return path[:idx]
}
return path
}
+86
View File
@@ -0,0 +1,86 @@
package filewatcher
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/verstak/verstak-desktop/internal/core/events"
)
func TestServicePublishesExternalFileChanges(t *testing.T) {
root := t.TempDir()
bus := events.NewBus()
received := make(chan events.Event, 4)
bus.Subscribe("file.changed", func(event events.Event) {
received <- event
})
service := NewService(bus, 10*time.Millisecond)
if err := service.Start(root); err != nil {
t.Fatalf("Start: %v", err)
}
t.Cleanup(service.Stop)
if err := os.WriteFile(filepath.Join(root, "note.md"), []byte("hello"), 0o644); err != nil {
t.Fatal(err)
}
event := waitForEvent(t, received)
payload, ok := event.Payload.(map[string]interface{})
if !ok {
t.Fatalf("payload type = %T, want map", event.Payload)
}
if event.Name != "file.changed" {
t.Fatalf("event name = %q, want file.changed", event.Name)
}
if payload["path"] != "note.md" {
t.Fatalf("path = %v, want note.md", payload["path"])
}
if payload["operation"] != "external.create" {
t.Fatalf("operation = %v, want external.create", payload["operation"])
}
if payload["type"] != "file" {
t.Fatalf("type = %v, want file", payload["type"])
}
}
func TestServiceIgnoresReservedVerstakPaths(t *testing.T) {
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, ".verstak"), 0o755); err != nil {
t.Fatal(err)
}
bus := events.NewBus()
received := make(chan events.Event, 4)
bus.Subscribe("file.changed", func(event events.Event) {
received <- event
})
service := NewService(bus, 10*time.Millisecond)
if err := service.Start(root); err != nil {
t.Fatalf("Start: %v", err)
}
t.Cleanup(service.Stop)
if err := os.WriteFile(filepath.Join(root, ".verstak", "internal.json"), []byte("{}"), 0o644); err != nil {
t.Fatal(err)
}
select {
case event := <-received:
t.Fatalf("unexpected reserved-path event: %+v", event)
case <-time.After(80 * time.Millisecond):
}
}
func waitForEvent(t *testing.T, eventCh <-chan events.Event) events.Event {
t.Helper()
select {
case event := <-eventCh:
return event
case <-time.After(500 * time.Millisecond):
t.Fatal("timed out waiting for file.changed")
return events.Event{}
}
}
+2
View File
@@ -36,6 +36,7 @@ func (r *Registry) registerDefaults() {
{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: "files.openExternal", Description: "Open vault files and folders in external OS applications", Dangerous: true},
{Name: "storage.namespace", Description: "Read/write plugin's own storage namespace", Dangerous: false},
{Name: "storage.migrations", Description: "Run database migrations in plugin namespace", Dangerous: false},
{Name: "events.publish", Description: "Publish events to the event bus", Dangerous: false},
@@ -49,6 +50,7 @@ func (r *Registry) registerDefaults() {
{Name: "secrets.read", Description: "Read secrets from the secret store", Dangerous: true},
{Name: "secrets.write", Description: "Write secrets to the secret store", Dangerous: true},
{Name: "sync.participate", Description: "Participate in vault sync", Dangerous: true},
{Name: "browser.receiver.manage", Description: "View and rotate the local browser receiver pairing token", Dangerous: true},
}
for _, e := range defaults {
r.permissions[e.Name] = e
+75
View File
@@ -0,0 +1,75 @@
package plugin
import (
"fmt"
"strings"
"github.com/verstak/verstak-desktop/internal/core/capability"
)
// ResolveLifecycle registers only viable plugin capabilities and assigns final
// lifecycle statuses after all required dependencies have had a chance to load.
func ResolveLifecycle(plugins []Plugin, registry *capability.Registry, isDisabled func(string) bool) {
if registry == nil {
for i := range plugins {
plugins[i].Status = StatusFailed
plugins[i].Error = "capability registry is unavailable"
}
return
}
pending := make(map[int]struct{}, len(plugins))
for i := range plugins {
p := &plugins[i]
p.Error = ""
if !p.Enabled || (isDisabled != nil && isDisabled(p.Manifest.ID)) {
p.Enabled = false
p.Status = StatusDisabled
continue
}
pending[i] = struct{}{}
}
for len(pending) > 0 {
progressed := false
for i := range plugins {
if _, ok := pending[i]; !ok {
continue
}
p := &plugins[i]
if len(registry.CheckRequired(p.Manifest.Requires)) > 0 {
continue
}
if err := registry.Register(p.Manifest.ID, p.Manifest.Provides); err != nil {
p.Status = StatusFailed
p.Error = err.Error()
} else {
p.Status = StatusLoaded
}
delete(pending, i)
progressed = true
}
if progressed {
continue
}
for i := range pending {
p := &plugins[i]
missing := registry.CheckRequired(p.Manifest.Requires)
p.Status = StatusMissingRequiredCapability
p.Error = fmt.Sprintf("missing required: %s", strings.Join(missing, ", "))
}
break
}
for i := range plugins {
p := &plugins[i]
if p.Status != StatusLoaded {
continue
}
if missing := registry.CheckRequired(p.Manifest.OptionalRequires); len(missing) > 0 {
p.Status = StatusDegraded
p.Error = fmt.Sprintf("missing optional: %s", strings.Join(missing, ", "))
}
}
}
+99
View File
@@ -336,3 +336,102 @@ func TestLifecycle_DisabledPlugin(t *testing.T) {
t.Errorf("expected plugin ID 'test.lifecycle.disabled', got %q", plugins[0].Manifest.ID)
}
}
func TestResolveLifecycleResolvesRequiredCapabilitiesRegardlessOfDiscoveryOrder(t *testing.T) {
reg := capability.NewRegistry()
plugins := []Plugin{
{
Manifest: Manifest{
ID: "consumer.plugin",
Provides: []string{"consumer.capability"},
Requires: []string{"provider.capability"},
Permissions: []string{"vault.read"},
},
Enabled: true,
},
{
Manifest: Manifest{
ID: "provider.plugin",
Provides: []string{"provider.capability"},
Permissions: []string{"vault.read"},
},
Enabled: true,
},
}
ResolveLifecycle(plugins, reg, nil)
if plugins[0].Status != StatusLoaded {
t.Fatalf("consumer status = %q, want %q; error=%q", plugins[0].Status, StatusLoaded, plugins[0].Error)
}
if plugins[1].Status != StatusLoaded {
t.Fatalf("provider status = %q, want %q; error=%q", plugins[1].Status, StatusLoaded, plugins[1].Error)
}
if !reg.Has("consumer.capability") || !reg.Has("provider.capability") {
t.Fatalf("resolved capabilities = %#v, want provider and consumer", reg.Available())
}
}
func TestResolveLifecycleDoesNotExposeCapabilitiesFromUnresolvedPlugin(t *testing.T) {
reg := capability.NewRegistry()
plugins := []Plugin{
{
Manifest: Manifest{
ID: "dependent.plugin",
Provides: []string{"dependent.capability"},
Requires: []string{"unresolved.capability"},
Permissions: []string{"vault.read"},
},
Enabled: true,
},
{
Manifest: Manifest{
ID: "unresolved.plugin",
Provides: []string{"unresolved.capability"},
Requires: []string{"missing.capability"},
Permissions: []string{"vault.read"},
},
Enabled: true,
},
}
ResolveLifecycle(plugins, reg, nil)
for _, p := range plugins {
if p.Status != StatusMissingRequiredCapability {
t.Fatalf("%s status = %q, want %q; error=%q", p.Manifest.ID, p.Status, StatusMissingRequiredCapability, p.Error)
}
}
if reg.Has("unresolved.capability") || reg.Has("dependent.capability") {
t.Fatalf("unresolved capabilities leaked into registry: %#v", reg.Available())
}
}
func TestResolveLifecycleEvaluatesOptionalCapabilitiesAfterProvidersLoad(t *testing.T) {
reg := capability.NewRegistry()
plugins := []Plugin{
{
Manifest: Manifest{
ID: "optional.consumer",
Provides: []string{"consumer.capability"},
OptionalRequires: []string{"optional.capability"},
Permissions: []string{"vault.read"},
},
Enabled: true,
},
{
Manifest: Manifest{
ID: "optional.provider",
Provides: []string{"optional.capability"},
Permissions: []string{"vault.read"},
},
Enabled: true,
},
}
ResolveLifecycle(plugins, reg, nil)
if plugins[0].Status != StatusLoaded {
t.Fatalf("optional consumer status = %q, want %q; error=%q", plugins[0].Status, StatusLoaded, plugins[0].Error)
}
}
+70 -17
View File
@@ -7,28 +7,36 @@ import (
"log"
"os"
"path/filepath"
"regexp"
"strings"
)
// Manifest represents a Verstak plugin.json manifest.
type Manifest struct {
SchemaVersion int `json:"schemaVersion"`
ID string `json:"id"`
Name string `json:"name"`
Version string `json:"version"`
APIVersion string `json:"apiVersion"`
Description string `json:"description,omitempty"`
Source string `json:"source,omitempty"`
Icon string `json:"icon,omitempty"`
Provides []string `json:"provides"`
Requires []string `json:"requires,omitempty"`
OptionalRequires []string `json:"optionalRequires,omitempty"`
Permissions []string `json:"permissions"`
Frontend *FrontendConfig `json:"frontend,omitempty"`
Backend *BackendConfig `json:"backend,omitempty"`
Migrations *MigrationConfig `json:"migrations,omitempty"`
Contributes *Contributions `json:"contributes,omitempty"`
Sync *SyncConfig `json:"sync,omitempty"`
SchemaVersion int `json:"schemaVersion"`
ID string `json:"id"`
Name string `json:"name"`
Version string `json:"version"`
APIVersion string `json:"apiVersion"`
Description string `json:"description,omitempty"`
Source string `json:"source,omitempty"`
Icon string `json:"icon,omitempty"`
Localization *LocalizationConfig `json:"localization,omitempty"`
Provides []string `json:"provides"`
Requires []string `json:"requires,omitempty"`
OptionalRequires []string `json:"optionalRequires,omitempty"`
Permissions []string `json:"permissions"`
Frontend *FrontendConfig `json:"frontend,omitempty"`
Backend *BackendConfig `json:"backend,omitempty"`
Migrations *MigrationConfig `json:"migrations,omitempty"`
Contributes *Contributions `json:"contributes,omitempty"`
Sync *SyncConfig `json:"sync,omitempty"`
}
// LocalizationConfig declares plugin-owned UI message catalogs.
type LocalizationConfig struct {
DefaultLocale string `json:"defaultLocale"`
Locales map[string]string `json:"locales"`
}
// FrontendConfig describes the plugin's frontend bundle.
@@ -152,6 +160,7 @@ type OpenProviderSupport struct {
Mime []string `json:"mime,omitempty"`
Extensions []string `json:"extensions,omitempty"`
Contexts []string `json:"contexts,omitempty"`
Modes []string `json:"modes,omitempty"`
}
// ContributionOpenProvider represents an editor/viewer provider contribution.
@@ -236,6 +245,25 @@ func ValidateManifest(m *Manifest) []string {
if len(m.Permissions) == 0 {
errs.add("permissions must have at least one permission")
}
if m.Localization != nil {
if !isValidLocaleTag(m.Localization.DefaultLocale) {
errs.add("localization.defaultLocale %q is not a valid lower-case locale", m.Localization.DefaultLocale)
}
if len(m.Localization.Locales) == 0 {
errs.add("localization.locales must contain at least one catalog")
}
if _, ok := m.Localization.Locales[m.Localization.DefaultLocale]; !ok {
errs.add("localization.defaultLocale %q must have a declared catalog", m.Localization.DefaultLocale)
}
for locale, catalogPath := range m.Localization.Locales {
if !isValidLocaleTag(locale) {
errs.add("localization locale %q is not a valid lower-case locale", locale)
}
if err := ValidateLocalizationPath(catalogPath); err != nil {
errs.add("localization locale %q: %v", locale, err)
}
}
}
if m.Contributes != nil {
for i, provider := range m.Contributes.OpenProviders {
if provider.ID == "" {
@@ -261,6 +289,31 @@ func ValidateManifest(m *Manifest) []string {
return errs.errors
}
var localeTagPattern = regexp.MustCompile(`^[a-z]{2}(?:-[a-z0-9]+)*$`)
func isValidLocaleTag(locale string) bool {
return localeTagPattern.MatchString(locale)
}
// ValidateLocalizationPath accepts only slash-separated plugin-relative paths.
func ValidateLocalizationPath(catalogPath string) error {
if strings.TrimSpace(catalogPath) == "" {
return fmt.Errorf("catalog path is required")
}
if filepath.IsAbs(catalogPath) || filepath.VolumeName(catalogPath) != "" || strings.HasPrefix(catalogPath, "/") {
return fmt.Errorf("catalog path must be relative")
}
if strings.Contains(catalogPath, `\`) {
return fmt.Errorf("catalog path must not contain backslash")
}
for _, part := range strings.Split(catalogPath, "/") {
if part == ".." {
return fmt.Errorf("catalog path traversal is not allowed")
}
}
return nil
}
func isValidPluginID(id string) bool {
if id == "" {
return false
+44
View File
@@ -233,6 +233,50 @@ func TestValidateManifest_OpenProviders(t *testing.T) {
}
}
func TestValidateManifest_Localization(t *testing.T) {
base := &Manifest{
SchemaVersion: 1,
ID: "localized.plugin",
Name: "Localized",
Version: "1.0.0",
APIVersion: "1.0",
Provides: []string{"localized.capability"},
Permissions: []string{"ui.register"},
Localization: &LocalizationConfig{
DefaultLocale: "en",
Locales: map[string]string{
"en": "locales/en.json",
"ru": "locales/ru.json",
},
},
}
if errs := ValidateManifest(base); len(errs) != 0 {
t.Fatalf("valid localization errors = %v", errs)
}
tests := []struct {
name string
localization *LocalizationConfig
want string
}{
{"missing default catalog", &LocalizationConfig{DefaultLocale: "en", Locales: map[string]string{"ru": "locales/ru.json"}}, "defaultLocale"},
{"invalid locale", &LocalizationConfig{DefaultLocale: "EN", Locales: map[string]string{"EN": "locales/en.json"}}, "locale"},
{"absolute path", &LocalizationConfig{DefaultLocale: "en", Locales: map[string]string{"en": "/tmp/en.json"}}, "relative"},
{"traversal", &LocalizationConfig{DefaultLocale: "en", Locales: map[string]string{"en": "../en.json"}}, "traversal"},
{"backslash", &LocalizationConfig{DefaultLocale: "en", Locales: map[string]string{"en": `locales\en.json`}}, "backslash"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
manifest := *base
manifest.Localization = tc.localization
combined := strings.Join(ValidateManifest(&manifest), "\n")
if !strings.Contains(combined, tc.want) {
t.Fatalf("errors = %q, want %q", combined, tc.want)
}
})
}
}
// TestDiscoverPlugins_MultipleDirs ensures discovery scans multiple directories.
func TestDiscoverPlugins_MultipleDirs(t *testing.T) {
dir1 := t.TempDir()
+532
View File
@@ -0,0 +1,532 @@
// Package secrets provides encrypted local storage for secret values.
package secrets
import (
"crypto/aes"
"crypto/cipher"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
const (
keySize = 32
nonceSize = 12
masterSaltSize = 16
masterPBKDF2Iterations = 200000
masterVerifierPlaintext = "verstak-secret-store:v1"
masterMetadataVersion = 1
minMasterPasswordLength = 8
recordsDirName = "records"
masterMetadataFileName = "metadata.json"
ScopeGlobal = "global"
ScopeWorkspace = "workspace"
)
// Store encrypts secret records before writing them to disk.
type Store struct {
mu sync.RWMutex
root string
key []byte
}
type SecretScope struct {
Kind string `json:"kind"`
WorkspaceRootPath string `json:"workspaceRootPath,omitempty"`
}
type SecretRecord struct {
ID string `json:"id"`
Title string `json:"title"`
Value string `json:"value,omitempty"`
Scope SecretScope `json:"scope"`
Username string `json:"username,omitempty"`
UpdatedAt string `json:"updatedAt"`
}
type encryptedRecord struct {
Version int `json:"version"`
Nonce []byte `json:"nonce"`
Ciphertext []byte `json:"ciphertext"`
UpdatedAt string `json:"updatedAt"`
}
type plaintextRecord struct {
ID string `json:"id"`
Title string `json:"title,omitempty"`
Value string `json:"value"`
Scope SecretScope `json:"scope"`
Username string `json:"username,omitempty"`
UpdatedAt string `json:"updatedAt,omitempty"`
}
type masterMetadata struct {
Version int `json:"version"`
Salt []byte `json:"salt"`
Nonce []byte `json:"nonce"`
Ciphertext []byte `json:"ciphertext"`
CreatedAt string `json:"createdAt"`
}
type VaultSession struct {
mu sync.RWMutex
root string
store *Store
}
// NewStore creates an encrypted secret store rooted at root.
func NewStore(root string, key []byte) (*Store, error) {
if root == "" {
return nil, fmt.Errorf("secret store root is empty")
}
if len(key) != keySize {
return nil, fmt.Errorf("secret store key must be %d bytes", keySize)
}
copiedKey := make([]byte, keySize)
copy(copiedKey, key)
return &Store{
root: root,
key: copiedKey,
}, nil
}
// Write encrypts and stores a secret value by ID.
func (s *Store) Write(id, value string) error {
return s.WriteRecord(SecretRecord{
ID: id,
Title: id,
Value: value,
Scope: SecretScope{Kind: ScopeGlobal},
})
}
func (s *Store) WriteRecord(record SecretRecord) error {
record.ID = strings.TrimSpace(record.ID)
record.Title = strings.TrimSpace(record.Title)
record.Scope.Kind = strings.TrimSpace(record.Scope.Kind)
record.Scope.WorkspaceRootPath = cleanWorkspaceRoot(record.Scope.WorkspaceRootPath)
if err := validateRecord(record); err != nil {
return err
}
record.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
plaintext, err := json.Marshal(plaintextRecord{
ID: record.ID,
Title: record.Title,
Value: record.Value,
Scope: record.Scope,
Username: record.Username,
UpdatedAt: record.UpdatedAt,
})
if err != nil {
return fmt.Errorf("marshal secret: %w", err)
}
nonce := make([]byte, nonceSize)
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return fmt.Errorf("generate nonce: %w", err)
}
aead, err := s.aead()
if err != nil {
return err
}
encrypted := encryptedRecord{
Version: 1,
Nonce: nonce,
Ciphertext: aead.Seal(nil, nonce, plaintext, nil),
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
}
data, err := json.MarshalIndent(encrypted, "", " ")
if err != nil {
return fmt.Errorf("marshal encrypted secret: %w", err)
}
s.mu.Lock()
defer s.mu.Unlock()
return atomicWrite0600(s.pathForID(record.ID), data)
}
// Read decrypts and returns a secret value by ID.
func (s *Store) Read(id string) (string, error) {
record, err := s.ReadRecord(id)
if err != nil {
return "", err
}
return record.Value, nil
}
func (s *Store) ReadRecord(id string) (SecretRecord, error) {
if err := validateID(id); err != nil {
return SecretRecord{}, err
}
s.mu.RLock()
data, err := os.ReadFile(s.pathForID(id))
s.mu.RUnlock()
if err != nil {
return SecretRecord{}, fmt.Errorf("read secret %q: %w", id, err)
}
decoded, err := s.decryptRecord(id, data)
if err != nil {
return SecretRecord{}, err
}
return decoded, nil
}
func (s *Store) Delete(id string) error {
if err := validateID(id); err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
if err := os.Remove(s.pathForID(id)); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("delete secret %q: %w", id, err)
}
return nil
}
func (s *Store) ListRecords() ([]SecretRecord, error) {
s.mu.RLock()
entries, err := os.ReadDir(s.root)
s.mu.RUnlock()
if err != nil {
if os.IsNotExist(err) {
return []SecretRecord{}, nil
}
return nil, fmt.Errorf("list secrets: %w", err)
}
records := make([]SecretRecord, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
continue
}
path := filepath.Join(s.root, entry.Name())
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read secret list item %s: %w", entry.Name(), err)
}
record, err := s.decryptRecord("", data)
if err != nil {
return nil, fmt.Errorf("decrypt secret list item %s: %w", entry.Name(), err)
}
record.Value = ""
records = append(records, record)
}
return records, nil
}
func (s *Store) decryptRecord(expectedID string, data []byte) (SecretRecord, error) {
var record encryptedRecord
if err := json.Unmarshal(data, &record); err != nil {
return SecretRecord{}, fmt.Errorf("decode encrypted secret %q: %w", expectedID, err)
}
if record.Version != 1 {
return SecretRecord{}, fmt.Errorf("unsupported secret version %d", record.Version)
}
aead, err := s.aead()
if err != nil {
return SecretRecord{}, err
}
plaintext, err := aead.Open(nil, record.Nonce, record.Ciphertext, nil)
if err != nil {
return SecretRecord{}, fmt.Errorf("decrypt secret %q: %w", expectedID, err)
}
var decoded plaintextRecord
if err := json.Unmarshal(plaintext, &decoded); err != nil {
return SecretRecord{}, fmt.Errorf("decode secret %q: %w", expectedID, err)
}
if expectedID != "" && decoded.ID != expectedID {
return SecretRecord{}, fmt.Errorf("secret %q contains mismatched id", expectedID)
}
return SecretRecord{
ID: decoded.ID,
Title: decoded.Title,
Value: decoded.Value,
Scope: decoded.Scope,
Username: decoded.Username,
UpdatedAt: decoded.UpdatedAt,
}, nil
}
func (s *Store) aead() (cipher.AEAD, error) {
block, err := aes.NewCipher(s.key)
if err != nil {
return nil, fmt.Errorf("create secret cipher: %w", err)
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("create secret gcm: %w", err)
}
return aead, nil
}
func (s *Store) pathForID(id string) string {
sum := sha256.Sum256([]byte(id))
return filepath.Join(s.root, hex.EncodeToString(sum[:])+".json")
}
func validateRecord(record SecretRecord) error {
if err := validateID(record.ID); err != nil {
return err
}
if record.Title == "" {
return fmt.Errorf("secret title is empty")
}
switch record.Scope.Kind {
case ScopeGlobal:
if record.Scope.WorkspaceRootPath != "" {
return fmt.Errorf("global secret must not have workspace root path")
}
case ScopeWorkspace:
if record.Scope.WorkspaceRootPath == "" {
return fmt.Errorf("workspace secret requires workspace root path")
}
if strings.ContainsAny(record.Scope.WorkspaceRootPath, `\`) {
return fmt.Errorf("workspace root path contains path separators")
}
default:
return fmt.Errorf("unsupported secret scope %q", record.Scope.Kind)
}
return nil
}
func cleanWorkspaceRoot(value string) string {
return strings.Trim(strings.TrimSpace(value), "/")
}
func validateID(id string) error {
if id == "" {
return fmt.Errorf("secret id is empty")
}
if len(id) > 256 {
return fmt.Errorf("secret id is too long")
}
if id == "." || id == ".." {
return fmt.Errorf("secret id %q is a path traversal reference", id)
}
if strings.ContainsAny(id, `/\`) {
return fmt.Errorf("secret id %q contains path separators", id)
}
if filepath.Clean(id) != id {
return fmt.Errorf("secret id %q contains path traversal", id)
}
return nil
}
func atomicWrite0600(path string, data []byte) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o700); err != nil {
return fmt.Errorf("create secret store dir: %w", err)
}
tmpFile := filepath.Join(dir, fmt.Sprintf(".tmp.%d", time.Now().UnixNano()))
if err := os.WriteFile(tmpFile, data, 0o600); err != nil {
return fmt.Errorf("write secret temp file: %w", err)
}
if err := os.Rename(tmpFile, path); err != nil {
os.Remove(tmpFile)
return fmt.Errorf("commit secret file: %w", err)
}
return nil
}
func NewVaultSession(root string) *VaultSession {
return &VaultSession{root: root}
}
func (s *VaultSession) Unlocked() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.store != nil
}
func (s *VaultSession) Store() (*Store, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if s.store == nil {
return nil, fmt.Errorf("secret store locked")
}
return s.store, nil
}
func (s *VaultSession) Initialized() (bool, error) {
metadata, err := readMasterMetadata(s.root)
if err != nil {
return false, err
}
return metadata != nil, nil
}
func (s *VaultSession) Unlock(masterPassword string) (*Store, error) {
if strings.TrimSpace(masterPassword) == "" {
return nil, fmt.Errorf("master password is empty")
}
s.mu.Lock()
defer s.mu.Unlock()
if s.store != nil {
return s.store, nil
}
metadata, err := readMasterMetadata(s.root)
if err != nil {
return nil, err
}
if metadata == nil {
if err := validateInitialMasterPassword(masterPassword); err != nil {
return nil, err
}
created, key, err := createMasterMetadata(masterPassword)
if err != nil {
return nil, err
}
if err := writeMasterMetadata(s.root, *created); err != nil {
return nil, err
}
store, err := NewStore(filepath.Join(s.root, recordsDirName), key)
if err != nil {
return nil, err
}
s.store = store
return store, nil
}
key := deriveMasterKey(masterPassword, metadata.Salt)
if err := verifyMasterMetadata(*metadata, key); err != nil {
return nil, err
}
store, err := NewStore(filepath.Join(s.root, recordsDirName), key)
if err != nil {
return nil, err
}
s.store = store
return store, nil
}
func validateInitialMasterPassword(masterPassword string) error {
if len([]rune(masterPassword)) < minMasterPasswordLength {
return fmt.Errorf("master password must be at least %d characters", minMasterPasswordLength)
}
return nil
}
func readMasterMetadata(root string) (*masterMetadata, error) {
data, err := os.ReadFile(filepath.Join(root, masterMetadataFileName))
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("read secret metadata: %w", err)
}
var metadata masterMetadata
if err := json.Unmarshal(data, &metadata); err != nil {
return nil, fmt.Errorf("decode secret metadata: %w", err)
}
if metadata.Version != masterMetadataVersion {
return nil, fmt.Errorf("unsupported secret metadata version %d", metadata.Version)
}
return &metadata, nil
}
func createMasterMetadata(masterPassword string) (*masterMetadata, []byte, error) {
salt := make([]byte, masterSaltSize)
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
return nil, nil, fmt.Errorf("generate secret salt: %w", err)
}
key := deriveMasterKey(masterPassword, salt)
nonce := make([]byte, nonceSize)
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, nil, fmt.Errorf("generate verifier nonce: %w", err)
}
aead, err := newAEAD(key)
if err != nil {
return nil, nil, err
}
return &masterMetadata{
Version: masterMetadataVersion,
Salt: salt,
Nonce: nonce,
Ciphertext: aead.Seal(nil, nonce, []byte(masterVerifierPlaintext), nil),
CreatedAt: time.Now().UTC().Format(time.RFC3339),
}, key, nil
}
func verifyMasterMetadata(metadata masterMetadata, key []byte) error {
aead, err := newAEAD(key)
if err != nil {
return err
}
plaintext, err := aead.Open(nil, metadata.Nonce, metadata.Ciphertext, nil)
if err != nil {
return fmt.Errorf("invalid master password")
}
if !hmac.Equal(plaintext, []byte(masterVerifierPlaintext)) {
return fmt.Errorf("invalid master password")
}
return nil
}
func writeMasterMetadata(root string, metadata masterMetadata) error {
data, err := json.MarshalIndent(metadata, "", " ")
if err != nil {
return fmt.Errorf("marshal secret metadata: %w", err)
}
return atomicWrite0600(filepath.Join(root, masterMetadataFileName), data)
}
func deriveMasterKey(masterPassword string, salt []byte) []byte {
return pbkdf2SHA256([]byte(masterPassword), salt, masterPBKDF2Iterations, keySize)
}
func newAEAD(key []byte) (cipher.AEAD, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("create secret cipher: %w", err)
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("create secret gcm: %w", err)
}
return aead, nil
}
func pbkdf2SHA256(password, salt []byte, iterations, size int) []byte {
if iterations <= 0 || size <= 0 {
return nil
}
hashLen := sha256.Size
blocks := (size + hashLen - 1) / hashLen
output := make([]byte, 0, blocks*hashLen)
for block := 1; block <= blocks; block++ {
mac := hmac.New(sha256.New, password)
mac.Write(salt)
mac.Write([]byte{byte(block >> 24), byte(block >> 16), byte(block >> 8), byte(block)})
u := mac.Sum(nil)
t := make([]byte, len(u))
copy(t, u)
for i := 1; i < iterations; i++ {
mac = hmac.New(sha256.New, password)
mac.Write(u)
u = mac.Sum(nil)
for j := range t {
t[j] ^= u[j]
}
}
output = append(output, t...)
}
return output[:size]
}
+277
View File
@@ -0,0 +1,277 @@
package secrets
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
func testKey(seed byte) []byte {
key := make([]byte, 32)
for i := range key {
key[i] = seed
}
return key
}
func readAllSecretStoreBytes(t *testing.T, root string) []byte {
t.Helper()
var data []byte
if err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
fileData, err := os.ReadFile(path)
if err != nil {
return err
}
data = append(data, fileData...)
return nil
}); err != nil {
t.Fatalf("read secret store bytes: %v", err)
}
return data
}
func TestStoreRoundTripsSecretWithoutPlaintextOnDisk(t *testing.T) {
root := t.TempDir()
store, err := NewStore(root, testKey(0x11))
if err != nil {
t.Fatalf("NewStore: %v", err)
}
const id = "server.password"
const value = "s3cr3t-value"
if err := store.Write(id, value); err != nil {
t.Fatalf("Write: %v", err)
}
got, err := store.Read(id)
if err != nil {
t.Fatalf("Read: %v", err)
}
if got != value {
t.Fatalf("Read = %q, want %q", got, value)
}
raw := readAllSecretStoreBytes(t, root)
if bytes.Contains(raw, []byte(value)) {
t.Fatal("secret value is stored as plaintext")
}
if bytes.Contains(raw, []byte(id)) {
t.Fatal("secret id is stored as plaintext")
}
}
func TestStoreRejectsWrongKey(t *testing.T) {
root := t.TempDir()
store, err := NewStore(root, testKey(0x11))
if err != nil {
t.Fatalf("NewStore: %v", err)
}
if err := store.Write("server.password", "s3cr3t-value"); err != nil {
t.Fatalf("Write: %v", err)
}
wrongKeyStore, err := NewStore(root, testKey(0x22))
if err != nil {
t.Fatalf("NewStore wrong key: %v", err)
}
if _, err := wrongKeyStore.Read("server.password"); err == nil {
t.Fatal("Read with wrong key succeeded")
}
}
func TestStoreRejectsUnsafeIDs(t *testing.T) {
store, err := NewStore(t.TempDir(), testKey(0x11))
if err != nil {
t.Fatalf("NewStore: %v", err)
}
for _, id := range []string{"", ".", "..", "../secret", `folder\secret`, strings.Repeat("a", 257)} {
t.Run(id, func(t *testing.T) {
if err := store.Write(id, "value"); err == nil {
t.Fatalf("Write(%q): expected error", id)
}
if _, err := store.Read(id); err == nil {
t.Fatalf("Read(%q): expected error", id)
}
})
}
}
func TestStoreDeletesSecretRecord(t *testing.T) {
store, err := NewStore(t.TempDir(), testKey(0x11))
if err != nil {
t.Fatalf("NewStore: %v", err)
}
if err := store.Write("server.password", "s3cr3t-value"); err != nil {
t.Fatalf("Write: %v", err)
}
if err := store.Delete("server.password"); err != nil {
t.Fatalf("Delete: %v", err)
}
if _, err := store.Read("server.password"); err == nil {
t.Fatal("Read after Delete succeeded")
}
list, err := store.ListRecords()
if err != nil {
t.Fatalf("ListRecords: %v", err)
}
if len(list) != 0 {
t.Fatalf("ListRecords after Delete = %+v, want empty", list)
}
}
func TestStoreListsScopedRecordsWithoutPlaintextOnDisk(t *testing.T) {
root := t.TempDir()
store, err := NewStore(root, testKey(0x11))
if err != nil {
t.Fatalf("NewStore: %v", err)
}
records := []SecretRecord{
{
ID: "global.server-token",
Title: "Server Token",
Value: "global-secret-value",
Scope: SecretScope{Kind: ScopeGlobal},
Username: "root",
UpdatedAt: "caller value must be replaced",
},
{
ID: "client-a.database",
Title: "Client A Database",
Value: "workspace-secret-value",
Scope: SecretScope{
Kind: ScopeWorkspace,
WorkspaceRootPath: "ClientA",
},
Username: "app",
},
}
for _, record := range records {
if err := store.WriteRecord(record); err != nil {
t.Fatalf("WriteRecord(%s): %v", record.ID, err)
}
}
list, err := store.ListRecords()
if err != nil {
t.Fatalf("ListRecords: %v", err)
}
if len(list) != 2 {
t.Fatalf("ListRecords len = %d, want 2: %+v", len(list), list)
}
if list[0].Value != "" || list[1].Value != "" {
t.Fatalf("ListRecords leaked secret values: %+v", list)
}
workspaceRecord, err := store.ReadRecord("client-a.database")
if err != nil {
t.Fatalf("ReadRecord: %v", err)
}
if workspaceRecord.Value != "workspace-secret-value" {
t.Fatalf("workspace secret value = %q", workspaceRecord.Value)
}
if workspaceRecord.Scope.Kind != ScopeWorkspace || workspaceRecord.Scope.WorkspaceRootPath != "ClientA" {
t.Fatalf("workspace scope = %+v", workspaceRecord.Scope)
}
if workspaceRecord.UpdatedAt == "" || workspaceRecord.UpdatedAt == "caller value must be replaced" {
t.Fatalf("UpdatedAt was not set by store: %+v", workspaceRecord)
}
raw := readAllSecretStoreBytes(t, root)
for _, plaintext := range []string{
"global-secret-value",
"workspace-secret-value",
"global.server-token",
"client-a.database",
"Client A Database",
"ClientA",
} {
if bytes.Contains(raw, []byte(plaintext)) {
t.Fatalf("secret store contains plaintext %q", plaintext)
}
}
}
func TestVaultSessionUnlocksWithMasterPasswordOnce(t *testing.T) {
root := t.TempDir()
session := NewVaultSession(root)
if session.Unlocked() {
t.Fatal("new session is unlocked")
}
if _, err := session.Store(); err == nil {
t.Fatal("Store before unlock succeeded")
}
store, err := session.Unlock("correct horse battery staple")
if err != nil {
t.Fatalf("Unlock first time: %v", err)
}
if !session.Unlocked() {
t.Fatal("session did not stay unlocked")
}
if err := store.Write("server.password", "s3cr3t-value"); err != nil {
t.Fatalf("Write: %v", err)
}
sameStore, err := session.Store()
if err != nil {
t.Fatalf("Store after unlock: %v", err)
}
if got, err := sameStore.Read("server.password"); err != nil || got != "s3cr3t-value" {
t.Fatalf("Read after unlock = %q, %v", got, err)
}
nextSession := NewVaultSession(root)
if _, err := nextSession.Unlock("wrong password"); err == nil {
t.Fatal("Unlock with wrong password succeeded")
}
if _, err := nextSession.Unlock("correct horse battery staple"); err != nil {
t.Fatalf("Unlock with correct password: %v", err)
}
raw := readAllSecretStoreBytes(t, root)
for _, plaintext := range []string{"correct horse battery staple", "s3cr3t-value"} {
if bytes.Contains(raw, []byte(plaintext)) {
t.Fatalf("vault session storage contains plaintext %q", plaintext)
}
}
}
func TestVaultSessionReportsInitializationAndRejectsWeakInitialPassword(t *testing.T) {
root := t.TempDir()
session := NewVaultSession(root)
initialized, err := session.Initialized()
if err != nil {
t.Fatalf("Initialized: %v", err)
}
if initialized {
t.Fatal("new secret session is initialized")
}
if _, err := session.Unlock("123123"); err == nil || !strings.Contains(err.Error(), "at least 8 characters") {
t.Fatalf("weak initial Unlock err = %v, want minimum length error", err)
}
if _, err := session.Unlock("strong password"); err != nil {
t.Fatalf("Unlock strong initial password: %v", err)
}
initialized, err = session.Initialized()
if err != nil {
t.Fatalf("Initialized after unlock: %v", err)
}
if !initialized {
t.Fatal("secret session was not initialized")
}
}
+76 -18
View File
@@ -44,6 +44,23 @@ func validatePluginID(pluginID string) error {
return nil
}
func validateStorageName(kind, name string) error {
if name == "" {
return fmt.Errorf("%s name is empty", kind)
}
if strings.ContainsAny(name, `/\`) {
return fmt.Errorf("%s name %q contains path separators", kind, name)
}
if name == "." || name == ".." {
return fmt.Errorf("%s name %q is a path traversal reference", kind, name)
}
cleaned := filepath.Clean(name)
if cleaned != name {
return fmt.Errorf("%s name %q contains path traversal", kind, name)
}
return nil
}
// ─── Atomic write helper ──────────────────────────────────
func atomicWrite(path string, data []byte) error {
@@ -67,13 +84,22 @@ func atomicWrite(path string, data []byte) error {
// ReadPluginSettings reads all settings for a plugin.
// Returns empty map if settings.json does not exist.
func (s *Storage) ReadPluginSettings(pluginID string) (map[string]interface{}, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if err := validatePluginID(pluginID); err != nil {
return nil, err
}
var result map[string]interface{}
err := s.withOpenVault(func(vaultPath string) error {
var err error
result, err = readPluginSettingsAt(vaultPath, pluginID)
return err
})
return result, err
}
dir := s.vault.GetPluginSettingsPath(pluginID)
path := filepath.Join(dir, "settings.json")
func readPluginSettingsAt(vaultPath, pluginID string) (map[string]interface{}, error) {
path := filepath.Join(vaultPath, ".verstak", "plugin-settings", pluginID, "settings.json")
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
@@ -91,13 +117,18 @@ func (s *Storage) ReadPluginSettings(pluginID string) (map[string]interface{}, e
// WritePluginSettings writes all settings for a plugin atomically.
func (s *Storage) WritePluginSettings(pluginID string, data map[string]interface{}) error {
s.mu.Lock()
defer s.mu.Unlock()
if err := validatePluginID(pluginID); err != nil {
return err
}
return s.withOpenVault(func(vaultPath string) error {
return writePluginSettingsAt(vaultPath, pluginID, data)
})
}
dir := s.vault.GetPluginSettingsPath(pluginID)
path := filepath.Join(dir, "settings.json")
func writePluginSettingsAt(vaultPath, pluginID string, data map[string]interface{}) error {
path := filepath.Join(vaultPath, ".verstak", "plugin-settings", pluginID, "settings.json")
encoded, err := json.MarshalIndent(data, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal settings for plugin %s: %w", pluginID, err)
@@ -120,12 +151,39 @@ func (s *Storage) ReadPluginSetting(pluginID, key string) (interface{}, error) {
// WritePluginSetting writes a single setting key.
func (s *Storage) WritePluginSetting(pluginID, key string, value interface{}) error {
settings, err := s.ReadPluginSettings(pluginID)
if err != nil {
return s.UpdatePluginSettings(pluginID, func(settings map[string]interface{}) error {
settings[key] = value
return nil
})
}
// UpdatePluginSettings atomically reads, mutates, and writes a plugin's settings.
func (s *Storage) UpdatePluginSettings(pluginID string, update func(map[string]interface{}) error) error {
if update == nil {
return fmt.Errorf("settings update is nil")
}
s.mu.Lock()
defer s.mu.Unlock()
if err := validatePluginID(pluginID); err != nil {
return err
}
settings[key] = value
return s.WritePluginSettings(pluginID, settings)
return s.withOpenVault(func(vaultPath string) error {
settings, err := readPluginSettingsAt(vaultPath, pluginID)
if err != nil {
return err
}
if err := update(settings); err != nil {
return err
}
return writePluginSettingsAt(vaultPath, pluginID, settings)
})
}
func (s *Storage) withOpenVault(operation func(string) error) error {
if s == nil || s.vault == nil {
return fmt.Errorf("vault is not initialized")
}
return s.vault.WithOpenPath(operation)
}
// ─── Data JSON API ────────────────────────────────────────
@@ -135,8 +193,8 @@ func (s *Storage) ReadPluginDataJSON(pluginID, name string) (map[string]interfac
if err := validatePluginID(pluginID); err != nil {
return nil, err
}
if name == "" {
return nil, fmt.Errorf("data name is empty")
if err := validateStorageName("data", name); err != nil {
return nil, err
}
dir := s.vault.GetPluginDataPath(pluginID)
@@ -162,8 +220,8 @@ func (s *Storage) WritePluginDataJSON(pluginID, name string, data map[string]int
if err := validatePluginID(pluginID); err != nil {
return err
}
if name == "" {
return fmt.Errorf("data name is empty")
if err := validateStorageName("data", name); err != nil {
return err
}
dir := s.vault.GetPluginDataPath(pluginID)
@@ -183,8 +241,8 @@ func (s *Storage) ReadPluginCacheJSON(pluginID, name string) (map[string]interfa
if err := validatePluginID(pluginID); err != nil {
return nil, err
}
if name == "" {
return nil, fmt.Errorf("cache name is empty")
if err := validateStorageName("cache", name); err != nil {
return nil, err
}
dir := s.vault.GetPluginCachePath(pluginID)
@@ -210,8 +268,8 @@ func (s *Storage) WritePluginCacheJSON(pluginID, name string, data map[string]in
if err := validatePluginID(pluginID); err != nil {
return err
}
if name == "" {
return fmt.Errorf("cache name is empty")
if err := validateStorageName("cache", name); err != nil {
return err
}
dir := s.vault.GetPluginCachePath(pluginID)
+54
View File
@@ -229,6 +229,60 @@ func TestPathTraversal_Blocked(t *testing.T) {
}
}
func TestPluginDataJSONNameTraversal_Blocked(t *testing.T) {
s, _ := newTestStorage(t)
traversalNames := []string{
"..",
"../evil",
"foo/../../bar",
"/absolute",
`backslash\traverse`,
"nested/name",
}
for _, name := range traversalNames {
t.Run(name, func(t *testing.T) {
err := s.WritePluginDataJSON("data-plugin", name, map[string]interface{}{"x": 1})
if err == nil {
t.Errorf("WritePluginDataJSON(%q): expected error, got nil", name)
}
_, err = s.ReadPluginDataJSON("data-plugin", name)
if err == nil {
t.Errorf("ReadPluginDataJSON(%q): expected error, got nil", name)
}
})
}
}
func TestPluginCacheJSONNameTraversal_Blocked(t *testing.T) {
s, _ := newTestStorage(t)
traversalNames := []string{
"..",
"../evil",
"foo/../../bar",
"/absolute",
`backslash\traverse`,
"nested/name",
}
for _, name := range traversalNames {
t.Run(name, func(t *testing.T) {
err := s.WritePluginCacheJSON("cache-plugin", name, map[string]interface{}{"x": 1})
if err == nil {
t.Errorf("WritePluginCacheJSON(%q): expected error, got nil", name)
}
_, err = s.ReadPluginCacheJSON("cache-plugin", name)
if err == nil {
t.Errorf("ReadPluginCacheJSON(%q): expected error, got nil", name)
}
})
}
}
// ─── Atomic write tests ──────────────────────────────────────
func TestAtomicWrite(t *testing.T) {
+90 -33
View File
@@ -61,6 +61,10 @@ type Client struct {
HTTP *http.Client
}
var syncRetrySleep = time.Sleep
const syncHTTPAttempts = 3
// NewClient creates a sync client.
func NewClient(serverURL, apiKey, deviceID, vaultRoot string) *Client {
return &Client{
@@ -73,12 +77,13 @@ func NewClient(serverURL, apiKey, deviceID, vaultRoot string) *Client {
}
// PairDevice calls POST /api/client/pair and returns device_id and device_token.
func (c *Client) PairDevice(serverURL, username, password, deviceName, clientVersion string) (deviceID, deviceToken string, err error) {
func (c *Client) PairDevice(serverURL, username, password, deviceName, clientVersion, vaultID string) (deviceID, deviceToken string, err error) {
body := map[string]string{
"login": username,
"password": password,
"device_name": deviceName,
"client_version": clientVersion,
"vault_id": vaultID,
}
var resp struct {
DeviceID string `json:"device_id"`
@@ -318,50 +323,102 @@ func (c *Client) post(path string, body, result interface{}) error {
return err
}
}
req, err := http.NewRequest("POST", c.ServerURL+path, &b)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.bearerToken())
payload := b.Bytes()
resp, err := c.HTTP.Do(req)
if err != nil {
return fmt.Errorf("http: %w", err)
}
defer resp.Body.Close()
var lastErr error
for attempt := 1; attempt <= syncHTTPAttempts; attempt++ {
req, err := http.NewRequest("POST", c.ServerURL+path, bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.bearerToken())
if resp.StatusCode >= 400 {
return c.readErrorBody(resp, resp.StatusCode)
}
resp, err := c.HTTP.Do(req)
if err != nil {
lastErr = fmt.Errorf("http: %w", err)
if attempt < syncHTTPAttempts {
syncRetrySleep(syncBackoffDelay(attempt))
continue
}
return lastErr
}
if resp.StatusCode >= 400 {
if isTransientHTTPStatus(resp.StatusCode) && attempt < syncHTTPAttempts {
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
syncRetrySleep(syncBackoffDelay(attempt))
continue
}
err := c.readErrorBody(resp, resp.StatusCode)
_ = resp.Body.Close()
return err
}
if result != nil {
return json.NewDecoder(resp.Body).Decode(result)
if result != nil {
err := json.NewDecoder(resp.Body).Decode(result)
_ = resp.Body.Close()
return err
}
_ = resp.Body.Close()
return nil
}
return nil
return lastErr
}
func (c *Client) get(path string, result interface{}) error {
req, err := http.NewRequest("GET", c.ServerURL+path, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.bearerToken())
var lastErr error
for attempt := 1; attempt <= syncHTTPAttempts; attempt++ {
req, err := http.NewRequest("GET", c.ServerURL+path, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.bearerToken())
resp, err := c.HTTP.Do(req)
if err != nil {
return fmt.Errorf("http: %w", err)
}
defer resp.Body.Close()
resp, err := c.HTTP.Do(req)
if err != nil {
lastErr = fmt.Errorf("http: %w", err)
if attempt < syncHTTPAttempts {
syncRetrySleep(syncBackoffDelay(attempt))
continue
}
return lastErr
}
if resp.StatusCode >= 400 {
if isTransientHTTPStatus(resp.StatusCode) && attempt < syncHTTPAttempts {
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
syncRetrySleep(syncBackoffDelay(attempt))
continue
}
err := c.readErrorBody(resp, resp.StatusCode)
_ = resp.Body.Close()
return err
}
if resp.StatusCode >= 400 {
return c.readErrorBody(resp, resp.StatusCode)
if result != nil {
err := json.NewDecoder(resp.Body).Decode(result)
_ = resp.Body.Close()
return err
}
_ = resp.Body.Close()
return nil
}
return lastErr
}
if result != nil {
return json.NewDecoder(resp.Body).Decode(result)
func syncBackoffDelay(attempt int) time.Duration {
return time.Duration(attempt) * 250 * time.Millisecond
}
func isTransientHTTPStatus(statusCode int) bool {
switch statusCode {
case http.StatusRequestTimeout, http.StatusTooManyRequests, http.StatusInternalServerError,
http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout:
return true
default:
return false
}
return nil
}
func (c *Client) readErrorBody(resp *http.Response, statusCode int) error {
+121
View File
@@ -0,0 +1,121 @@
package sync
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func withoutSyncRetrySleep(t *testing.T) {
t.Helper()
original := syncRetrySleep
syncRetrySleep = func(time.Duration) {}
t.Cleanup(func() {
syncRetrySleep = original
})
}
func TestPushRetriesTransientServerErrors(t *testing.T) {
withoutSyncRetrySleep(t)
attempts := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/sync/push" {
http.NotFound(w, r)
return
}
attempts++
if attempts < 3 {
http.Error(w, "try again", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"accepted": []string{"op-1"},
"count": 1,
"conflicts": []map[string]interface{}{},
})
}))
defer server.Close()
client := NewClient(server.URL, "", "device-1", t.TempDir())
client.DeviceToken = "token"
result, err := client.Push([]Op{{
OpID: "op-1",
EntityType: EntityFile,
EntityID: "Docs/one.txt",
OpType: OpCreate,
CreatedAt: time.Now().UTC().Format(time.RFC3339),
}})
if err != nil {
t.Fatalf("Push: %v", err)
}
if attempts != 3 {
t.Fatalf("attempts = %d, want 3", attempts)
}
if len(result.Accepted) != 1 || result.Accepted[0] != "op-1" {
t.Fatalf("push result = %#v", result)
}
}
func TestPushDoesNotRetryClientErrors(t *testing.T) {
withoutSyncRetrySleep(t)
attempts := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts++
http.Error(w, "unauthorized", http.StatusUnauthorized)
}))
defer server.Close()
client := NewClient(server.URL, "", "device-1", t.TempDir())
client.DeviceToken = "token"
_, err := client.Push([]Op{{
OpID: "op-1",
EntityType: EntityFile,
EntityID: "Docs/one.txt",
OpType: OpCreate,
CreatedAt: time.Now().UTC().Format(time.RFC3339),
}})
if err == nil {
t.Fatal("Push should fail on unauthorized response")
}
if attempts != 1 {
t.Fatalf("attempts = %d, want 1", attempts)
}
}
func TestPairDeviceSendsVaultID(t *testing.T) {
var pairedVaultID string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/client/pair" {
http.NotFound(w, r)
return
}
var request struct {
VaultID string `json:"vault_id"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
pairedVaultID = request.VaultID
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{
"device_id": "device-1",
"device_token": "token-1",
})
}))
defer server.Close()
client := NewClient(server.URL, "", "", t.TempDir())
_, _, err := client.PairDevice(server.URL, "alice", "secret", "Desktop", "verstak-desktop/v2", "vault-123")
if err != nil {
t.Fatalf("PairDevice: %v", err)
}
if pairedVaultID != "vault-123" {
t.Fatalf("paired vault ID = %q, want vault-123", pairedVaultID)
}
}
+34 -8
View File
@@ -60,7 +60,13 @@ type Service struct {
// NewService creates a sync service.
func NewService(vaultRoot, deviceID string) *Service {
return &Service{vaultRoot: vaultRoot, deviceID: deviceID}
service := &Service{vaultRoot: vaultRoot, deviceID: deviceID}
if deviceID == "" {
if state, err := service.loadState(); err == nil {
service.deviceID = state.DeviceID
}
}
return service
}
func (s *Service) syncDir() string {
@@ -97,14 +103,14 @@ func (s *Service) RecordOp(entityType, entityID, opType string, payload interfac
}
op := Op{
ID: id,
OpID: id,
DeviceID: s.deviceID,
EntityType: entityType,
EntityID: entityID,
OpType: opType,
ID: id,
OpID: id,
DeviceID: s.deviceID,
EntityType: entityType,
EntityID: entityID,
OpType: opType,
PayloadJSON: payloadStr,
CreatedAt: now,
CreatedAt: now,
}
ops, err := s.loadOps()
@@ -212,6 +218,9 @@ func (s *Service) SetState(serverURL, apiKey string) error {
}
st.ServerURL = serverURL
st.APIKey = apiKey
if s.deviceID != "" {
st.DeviceID = s.deviceID
}
return s.saveState(st)
}
@@ -240,6 +249,23 @@ func (s *Service) GetDeviceID() string {
return s.deviceID
}
// SetDeviceID persists the device ID for this vault's sync state.
func (s *Service) SetDeviceID(deviceID string) error {
if err := s.ensureDir(); err != nil {
return err
}
st, err := s.loadState()
if err != nil {
st = &syncState{}
}
st.DeviceID = deviceID
if err := s.saveState(st); err != nil {
return err
}
s.deviceID = deviceID
return nil
}
// --- file helpers ---
func (s *Service) loadOps() ([]Op, error) {
+17
View File
@@ -70,6 +70,23 @@ func (v *Vault) GetVaultPath() string {
return v.path
}
// WithOpenPath runs fn while holding a read lease on the currently open vault.
// CloseVault and OpenVault wait until the operation completes.
func (v *Vault) WithOpenPath(fn func(string) error) error {
if v == nil {
return fmt.Errorf("vault is not initialized")
}
if fn == nil {
return fmt.Errorf("vault path operation is nil")
}
v.mu.RLock()
defer v.mu.RUnlock()
if v.status != StatusOpen || strings.TrimSpace(v.path) == "" {
return fmt.Errorf("vault is not open")
}
return fn(v.path)
}
// GetVaultMeta returns the current vault metadata.
func (v *Vault) GetVaultMeta() *VaultMeta {
v.mu.RLock()
+5 -2
View File
@@ -262,8 +262,11 @@ func TestCreateVault_CreatesWorkspace(t *testing.T) {
if _, err := os.Stat(filepath.Join(v.GetVaultPath(), "Workspace")); err != nil {
t.Fatalf("Workspace folder not found: %v", err)
}
if _, err := os.Stat(filepath.Join(v.GetVaultPath(), "Workspace", "Notes", "Overview.md")); err != nil {
t.Fatalf("default workspace overview not found: %v", err)
if _, err := os.Stat(filepath.Join(v.GetVaultPath(), "Workspace", "Notes")); err != nil {
t.Fatalf("default workspace notes folder not found: %v", err)
}
if _, err := os.Stat(filepath.Join(v.GetVaultPath(), "Workspace", "Notes", "Overview.md")); !os.IsNotExist(err) {
t.Fatalf("default workspace should not create overview file, stat err=%v", err)
}
if _, err := os.Stat(filepath.Join(v.GetVaultPath(), ".verstak", "workspace.json")); !os.IsNotExist(err) {
t.Fatalf("workspace.json should not be created as workspace source of truth, stat err=%v", err)
+34 -1
View File
@@ -168,6 +168,9 @@ func providerMatches(request OpenResourceRequest, provider plugin.ContributionOp
if support.Kind != request.Kind {
continue
}
if !supportMatchesMode(request, support) {
continue
}
if !supportMatchesExtensionOrMime(request, support) {
continue
}
@@ -179,6 +182,19 @@ func providerMatches(request OpenResourceRequest, provider plugin.ContributionOp
return false
}
func supportMatchesMode(request OpenResourceRequest, support plugin.OpenProviderSupport) bool {
if len(support.Modes) == 0 {
return true
}
mode := strings.ToLower(request.Mode)
for _, supported := range support.Modes {
if strings.ToLower(supported) == mode {
return true
}
}
return false
}
func supportMatchesExtensionOrMime(request OpenResourceRequest, support plugin.OpenProviderSupport) bool {
hasExtensionRules := len(support.Extensions) > 0
hasMimeRules := len(support.Mime) > 0
@@ -233,7 +249,9 @@ func (r *Router) preferenceFor(request OpenResourceRequest) string {
func resourceContextName(request OpenResourceRequest) string {
ext := strings.ToLower(request.Extension)
if ext == ".md" || ext == ".markdown" {
if request.Context.NotesMode || request.Context.IsInsideNotesFolder {
// Auto-detect Notes context: either explicitly set in request context
// or path-based detection using the canonical Notes/ folder layout.
if request.Context.NotesMode || request.Context.IsInsideNotesFolder || isInsideNotesPath(request.Path) {
return ContextNotesMarkdown
}
return ContextGenericMarkdown
@@ -244,6 +262,21 @@ func resourceContextName(request OpenResourceRequest) string {
return ""
}
func isInsideNotesPath(relativePath string) bool {
if relativePath == "" {
return false
}
cleaned := strings.TrimSpace(relativePath)
cleaned = strings.TrimPrefix(cleaned, "./")
cleaned = strings.TrimPrefix(cleaned, "/")
for _, part := range strings.Split(cleaned, "/") {
if part == "Notes" {
return true
}
}
return false
}
func isTextResource(request OpenResourceRequest) bool {
if strings.HasPrefix(request.Mime, "text/") {
return true
+86 -1
View File
@@ -91,6 +91,47 @@ func TestSelectProviderFallsBackByPriorityThenID(t *testing.T) {
}
}
func TestSelectProviderHonorsSupportModes(t *testing.T) {
r := NewRouter(Preferences{})
providers := []contribution.ContributionOpenProvider{
provider("preview.plugin", "markdown.preview", 100, "MarkdownPreview", plugin.OpenProviderSupport{
Kind: "vault-file",
Extensions: []string{".md"},
Contexts: []string{ContextGenericMarkdown},
Modes: []string{"view"},
}),
provider("editor.plugin", "markdown.editor", 50, "MarkdownEditor", plugin.OpenProviderSupport{
Kind: "vault-file",
Extensions: []string{".md"},
Contexts: []string{ContextGenericMarkdown},
}),
}
viewProvider, err := r.SelectProvider(OpenResourceRequest{
Kind: "vault-file",
Path: "Docs/readme.md",
Mode: "view",
}, providers)
if err != nil {
t.Fatalf("SelectProvider(view): %v", err)
}
if viewProvider.Item.ID != "markdown.preview" {
t.Fatalf("view provider = %q, want markdown.preview", viewProvider.Item.ID)
}
editProvider, err := r.SelectProvider(OpenResourceRequest{
Kind: "vault-file",
Path: "Docs/readme.md",
Mode: "edit",
}, providers)
if err != nil {
t.Fatalf("SelectProvider(edit): %v", err)
}
if editProvider.Item.ID != "markdown.editor" {
t.Fatalf("edit provider = %q, want markdown.editor", editProvider.Item.ID)
}
}
func TestSelectProviderTieBreaksByPluginIDThenProviderID(t *testing.T) {
r := NewRouter(Preferences{})
providers := []contribution.ContributionOpenProvider{
@@ -386,6 +427,26 @@ func TestSelectProviderKindMismatch(t *testing.T) {
}
}
func TestOpenResourceRoutesSecretResources(t *testing.T) {
r := NewRouter(Preferences{})
result, err := r.OpenResource(OpenResourceRequest{
Kind: "secret",
Path: "client-a.db",
Mode: "view",
}, []contribution.ContributionOpenProvider{
provider("verstak.secrets", "verstak.secrets.secret", 100, "SecretsView", plugin.OpenProviderSupport{
Kind: "secret",
Modes: []string{"view"},
}),
})
if err != nil {
t.Fatalf("OpenResource: %v", err)
}
if result.Status != "opened" || result.ProviderPluginID != "verstak.secrets" || result.ProviderComponent != "SecretsView" {
t.Fatalf("secret open result = %+v", result)
}
}
func TestDetermineContextName(t *testing.T) {
tests := []struct {
name string
@@ -403,7 +464,7 @@ func TestDetermineContextName(t *testing.T) {
want: ContextGenericMarkdown,
},
{
name: "notes markdown",
name: "notes markdown with explicit context",
request: OpenResourceRequest{
Kind: "vault-file",
Path: "Notes/Overview.md",
@@ -414,6 +475,30 @@ func TestDetermineContextName(t *testing.T) {
},
want: ContextNotesMarkdown,
},
{
name: "notes markdown auto-detected from path",
request: OpenResourceRequest{
Kind: "vault-file",
Path: "Workspace/Notes/MyNote.md",
},
want: ContextNotesMarkdown,
},
{
name: "notes overview auto-detected",
request: OpenResourceRequest{
Kind: "vault-file",
Path: "Notes/Overview.md",
},
want: ContextNotesMarkdown,
},
{
name: "non-notes markdown stays generic",
request: OpenResourceRequest{
Kind: "vault-file",
Path: "Workspace/Files/readme.md",
},
want: ContextGenericMarkdown,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
+158 -28
View File
@@ -46,10 +46,21 @@ type Workspace struct {
// TemplateSnapshot is copied into workspace metadata when a template is applied.
type TemplateSnapshot struct {
TemplateID string `json:"templateId"`
TemplateName string `json:"templateName"`
TemplateVersion int `json:"templateVersion"`
AppliedAt string `json:"appliedAt"`
TemplateID string `json:"templateId"`
TemplateName string `json:"templateName"`
TemplateVersion int `json:"templateVersion"`
AppliedAt string `json:"appliedAt"`
WorkspaceTools []string `json:"workspaceTools,omitempty"`
}
// WorkspaceTemplate describes a selectable built-in template without exposing
// its filesystem implementation details.
type WorkspaceTemplate struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Version int `json:"version"`
WorkspaceTools []string `json:"workspaceTools"`
}
// Metadata stores semantic workspace metadata that is not the source of truth
@@ -59,6 +70,7 @@ type Metadata struct {
CreatedFromTemplate *TemplateSnapshot `json:"createdFromTemplate,omitempty"`
Features map[string]bool `json:"features,omitempty"`
Folders map[string]string `json:"folders,omitempty"`
WorkspaceTools []string `json:"workspaceTools,omitempty"`
UpdatedAt string `json:"updatedAt,omitempty"`
}
@@ -102,37 +114,127 @@ type WorkspaceTree struct {
}
type templateDefinition struct {
ID string
Name string
Version int
Features map[string]bool
Folders map[string]string
Files map[string]string
ID string
Name string
Description string
Version int
Features map[string]bool
Folders map[string]string
Files map[string]string
WorkspaceTools []string
Selectable bool
Order int
}
var builtInTemplates = map[string]templateDefinition{
"default": {
ID: "default",
Name: "Default Workspace",
Version: 1,
ID: "default",
Name: "General",
Description: "Everyday workspace with notes, files, journal, activity, and browser captures.",
Version: 2,
Features: map[string]bool{
"files": true,
"notes": true,
"secrets": false,
"activity": false,
"files": true,
"notes": true,
"secrets": false,
"activity": true,
"journal": true,
"browser-inbox": true,
},
Folders: map[string]string{
"notes": "Notes",
"files": "Files",
},
Files: map[string]string{
"Notes/Overview.md": "# Overview\n",
Files: map[string]string{},
WorkspaceTools: []string{"verstak.notes", "verstak.files", "verstak.journal", "verstak.activity", "verstak.browser-inbox"},
Selectable: true,
Order: 10,
},
"project": {
ID: "project",
Name: "Project",
Description: "Project planning with todos, journal, activity, and browser captures.",
Version: 1,
Features: map[string]bool{
"files": true,
"notes": true,
"todo": true,
"journal": true,
"activity": true,
"browser-inbox": true,
},
Folders: map[string]string{
"notes": "Notes",
"files": "Files",
},
Files: map[string]string{},
WorkspaceTools: []string{"verstak.notes", "verstak.files", "verstak.todo", "verstak.journal", "verstak.activity", "verstak.browser-inbox"},
Selectable: true,
Order: 20,
},
"writing": {
ID: "writing",
Name: "Writing",
Description: "Focused notes, files, and journal workspace for documentation and writing.",
Version: 1,
Features: map[string]bool{
"files": true,
"notes": true,
"journal": true,
},
Folders: map[string]string{
"notes": "Notes",
"files": "Files",
},
Files: map[string]string{},
WorkspaceTools: []string{"verstak.notes", "verstak.files", "verstak.journal"},
Selectable: true,
Order: 30,
},
"admin": {
ID: "admin",
Name: "Admin",
Description: "Infrastructure workspace with secrets, todos, and journal.",
Version: 1,
Features: map[string]bool{
"files": true,
"notes": true,
"secrets": true,
"todo": true,
"journal": true,
},
Folders: map[string]string{
"notes": "Notes",
"files": "Files",
"secrets": "Secrets",
},
Files: map[string]string{},
WorkspaceTools: []string{"verstak.notes", "verstak.files", "verstak.secrets", "verstak.todo", "verstak.journal"},
Selectable: true,
Order: 40,
},
"minimal": {
ID: "minimal",
Name: "Minimal",
Description: "Only notes and files for a lightweight workspace.",
Version: 1,
Features: map[string]bool{
"files": true,
"notes": true,
},
Folders: map[string]string{
"notes": "Notes",
"files": "Files",
},
Files: map[string]string{},
WorkspaceTools: []string{"verstak.notes", "verstak.files"},
Selectable: true,
Order: 50,
},
"client-project": {
ID: "client-project",
Name: "Client Project",
Version: 1,
ID: "client-project",
Name: "Client Project",
Description: "Legacy client project template retained for existing integrations.",
Version: 1,
Features: map[string]bool{
"files": true,
"notes": true,
@@ -144,9 +246,10 @@ var builtInTemplates = map[string]templateDefinition{
"files": "Files",
"secrets": "Secrets",
},
Files: map[string]string{
"Notes/Overview.md": "# Overview\n",
},
Files: map[string]string{},
WorkspaceTools: []string{"verstak.notes", "verstak.files", "verstak.secrets"},
Selectable: false,
Order: 0,
},
}
@@ -247,10 +350,12 @@ func (m *Manager) CreateWorkspace(name, templateID string) (Workspace, error) {
TemplateName: template.Name,
TemplateVersion: template.Version,
AppliedAt: now,
WorkspaceTools: cloneStringSlice(template.WorkspaceTools),
},
Features: cloneBoolMap(template.Features),
Folders: cloneStringMap(template.Folders),
UpdatedAt: now,
Features: cloneBoolMap(template.Features),
Folders: cloneStringMap(template.Folders),
WorkspaceTools: cloneStringSlice(template.WorkspaceTools),
UpdatedAt: now,
}
if err := m.writeMetadata(name, meta); err != nil {
return Workspace{}, err
@@ -260,6 +365,27 @@ func (m *Manager) CreateWorkspace(name, templateID string) (Workspace, error) {
return Workspace{Name: name, RootPath: name}, nil
}
// ListWorkspaceTemplates returns selectable built-ins in their presentation order.
func (m *Manager) ListWorkspaceTemplates() []WorkspaceTemplate {
templates := make([]WorkspaceTemplate, 0, len(builtInTemplates))
for _, template := range builtInTemplates {
if !template.Selectable {
continue
}
templates = append(templates, WorkspaceTemplate{
ID: template.ID,
Name: template.Name,
Description: template.Description,
Version: template.Version,
WorkspaceTools: cloneStringSlice(template.WorkspaceTools),
})
}
sort.SliceStable(templates, func(i, j int) bool {
return builtInTemplates[templates[i].ID].Order < builtInTemplates[templates[j].ID].Order
})
return templates
}
// RenameWorkspace physically renames a top-level workspace folder and metadata key.
func (m *Manager) RenameWorkspace(oldName, newName string) error {
oldName = strings.TrimSpace(oldName)
@@ -752,6 +878,10 @@ func cloneStringMap(src map[string]string) map[string]string {
return dst
}
func cloneStringSlice(src []string) []string {
return append([]string(nil), src...)
}
func hasAnyTrueFeature(features map[string]bool) bool {
for _, enabled := range features {
if enabled {
+47 -2
View File
@@ -92,8 +92,11 @@ func TestCreateWorkspaceCreatesFolderDefaultTemplateAndMetadataSnapshot(t *testi
if _, err := os.Stat(filepath.Join(vaultDir, "Project")); err != nil {
t.Fatalf("workspace folder missing: %v", err)
}
if _, err := os.Stat(filepath.Join(vaultDir, "Project", "Notes", "Overview.md")); err != nil {
t.Fatalf("default template overview missing: %v", err)
if _, err := os.Stat(filepath.Join(vaultDir, "Project", "Notes")); err != nil {
t.Fatalf("default template notes folder missing: %v", err)
}
if _, err := os.Stat(filepath.Join(vaultDir, "Project", "Notes", "Overview.md")); !os.IsNotExist(err) {
t.Fatalf("default template should not create overview file, stat err=%v", err)
}
meta, err := m.GetWorkspaceMetadata("Project")
@@ -117,6 +120,45 @@ func TestCreateWorkspaceCreatesFolderDefaultTemplateAndMetadataSnapshot(t *testi
}
}
func TestListWorkspaceTemplatesExposesSelectableBuiltIns(t *testing.T) {
m := NewManager(newVaultDir(t))
templates := m.ListWorkspaceTemplates()
wantIDs := []string{"default", "project", "writing", "admin", "minimal"}
if len(templates) != len(wantIDs) {
t.Fatalf("templates = %+v, want %d selectable built-ins", templates, len(wantIDs))
}
gotIDs := make([]string, 0, len(templates))
for _, template := range templates {
gotIDs = append(gotIDs, template.ID)
if template.Name == "" || template.Description == "" || template.Version == 0 || len(template.WorkspaceTools) == 0 {
t.Fatalf("template is missing presentation or workspace tool data: %+v", template)
}
}
if strings.Join(gotIDs, ",") != strings.Join(wantIDs, ",") {
t.Fatalf("template ids = %v, want %v", gotIDs, wantIDs)
}
}
func TestCreateWorkspaceStoresWorkspaceToolSnapshot(t *testing.T) {
m := NewManager(newVaultDir(t))
if _, err := m.CreateWorkspace("Minimal", "minimal"); err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
meta, err := m.GetWorkspaceMetadata("Minimal")
if err != nil {
t.Fatalf("GetWorkspaceMetadata: %v", err)
}
wantTools := []string{"verstak.notes", "verstak.files"}
if strings.Join(meta.WorkspaceTools, ",") != strings.Join(wantTools, ",") {
t.Fatalf("metadata workspace tools = %v, want %v", meta.WorkspaceTools, wantTools)
}
if meta.CreatedFromTemplate == nil || strings.Join(meta.CreatedFromTemplate.WorkspaceTools, ",") != strings.Join(wantTools, ",") {
t.Fatalf("template snapshot workspace tools = %+v, want %v", meta.CreatedFromTemplate, wantTools)
}
}
func TestWorkspaceMetadataDoesNotRequireLiveTemplate(t *testing.T) {
vaultDir := newVaultDir(t)
m := NewManager(vaultDir)
@@ -133,6 +175,9 @@ func TestWorkspaceMetadataDoesNotRequireLiveTemplate(t *testing.T) {
if meta.CreatedFromTemplate == nil || meta.CreatedFromTemplate.TemplateID != "client-project" {
t.Fatalf("snapshot not preserved after registry clear: %+v", meta.CreatedFromTemplate)
}
if len(meta.WorkspaceTools) == 0 || len(meta.CreatedFromTemplate.WorkspaceTools) == 0 {
t.Fatalf("workspace tool snapshot not preserved after registry clear: %+v", meta)
}
}
func TestMissingMetadataReturnsGenericWorkspaceMetadata(t *testing.T) {

Some files were not shown because too many files have changed in this diff Show More