Author SHA1 Message Date
mirivlad 6eeebd39ad fix: restore Windows tray lifecycle 2026-07-15 18:56:22 +08:00
mirivlad 3c1f2c7177 docs: add historical release notes 2026-07-15 07:50:17 +08:00
mirivlad a9576ac141 docs: require human release notes 2026-07-15 07:42:02 +08:00
mirivlad fb4bb2a0c3 fix: localize native tray menu 2026-07-15 03:31:00 +08:00
mirivlad 76bc312bb9 fix: localize deal rename confirmation 2026-07-15 03:17:03 +08:00
mirivlad d353b5b335 docs: refresh README screenshots 2026-07-15 02:32:21 +08:00
mirivlad 3d78e0deaf fix: hide technical workbench metadata 2026-07-15 02:32:08 +08:00
mirivlad 41aeefc182 docs: describe Deals as the user-facing unit 2026-07-14 23:57:50 +08:00
mirivlad f9b20d51ff fix: style plugin manager filter selects 2026-07-14 23:47:51 +08:00
mirivlad 8be107013d feat: add plugin manager filters 2026-07-14 23:46:06 +08:00
mirivlad ddcd799f16 test: expect Deal labels in activity review 2026-07-14 23:32:08 +08:00
mirivlad 7b7b892332 test: align plugin mock terminology 2026-07-14 23:27:40 +08:00
mirivlad be3755e50e chore: remove unused plugin manager style 2026-07-14 23:25:43 +08:00
mirivlad dbfc4e8d0a fix: localize plugin capability labels 2026-07-14 23:23:24 +08:00
mirivlad cd44ee3866 test: expect Deal label in journal review 2026-07-14 23:13:59 +08:00
mirivlad a0262b97b3 fix: localize overview activity labels 2026-07-14 23:03:14 +08:00
mirivlad 2da414fb3b fix: rename Browser workspace tool 2026-07-14 22:33:43 +08:00
mirivlad 5cad11f3f9 fix: use Deal terminology in desktop shell 2026-07-14 22:18:52 +08:00
mirivlad dbef71f830 fix: refresh desktop integration on updates 2026-07-14 22:04:25 +08:00
mirivlad 963049add3 fix: localize shell operation errors 2026-07-14 22:00:24 +08:00
mirivlad 6179ad73e7 test: cover typed Todo reminder times 2026-07-14 20:50:14 +08:00
mirivlad 1e397d0211 test: cover global secrets workflow 2026-07-14 20:41:40 +08:00
mirivlad 47d38bcd4d fix: scope overview to available tools 2026-07-14 20:21:40 +08:00
mirivlad 9d06642ca6 feat: explain unavailable template tools 2026-07-14 20:10:29 +08:00
mirivlad b6290aa8ee test: cover global Journal entry assignment 2026-07-14 19:58:39 +08:00
mirivlad c3836ef1a6 test: exercise Todo reminder controls from source 2026-07-14 19:39:11 +08:00
mirivlad e223ccccc6 test: cover activity deletion confirmation 2026-07-14 19:19:11 +08:00
mirivlad fe64e1f822 test: cover secret workbench navigation 2026-07-14 19:14:01 +08:00
mirivlad fc82b3e6b1 fix: validate bundled plugin requirements 2026-07-14 19:04:03 +08:00
mirivlad 680846da72 fix: style workspace template selector 2026-07-14 08:00:38 +08:00
86 changed files with 2668 additions and 626 deletions
+17 -18
View File
@@ -7,7 +7,7 @@
### Keep the context of your work together — locally.
Files, notes, links, browser captures, activity and work history
in one extensible local-first workspace.
in one extensible local-first workbench.
**English** · [Русский](README.ru.md)
@@ -29,7 +29,7 @@ in one extensible local-first workspace.
Verstak is a local-first workbench that keeps the context around your work in one place.
A **workspace** can represent almost anything:
A **Deal** can represent almost anything:
* a software project;
* a client or customer;
@@ -47,29 +47,29 @@ No account, cloud service or sync server is required for local use.
## Verstak in use
| Overview: return to a workspace and see its recent work, captures and items that need attention. | Files: manage ordinary folders and documents inside a workspace. |
| Overview: return to a Deal and see its recent work, captures and items that need attention. | Files: manage ordinary folders and documents inside a Deal. |
| --- | --- |
| ![Verstak Overview in a test vault](docs/screenshots/overview.png) | ![Verstak workspace files in a test vault](docs/screenshots/workspace-files.png) |
| ![Current Verstak Overview in a test vault](docs/screenshots/overview-en.png) | ![Current Verstak Deal files in a test vault](docs/screenshots/files-en.png) |
| Notes: keep Markdown notes next to the workspace they describe. | Journal: record a completed session and retain its context. |
| Notes: keep Markdown notes next to the Deal they describe. | Journal: record a completed session and retain its context. |
| --- | --- |
| ![Verstak notes in a test vault](docs/screenshots/notes.png) | ![Verstak journal in a test vault](docs/screenshots/journal.png) |
| ![Current Verstak notes in a test vault](docs/screenshots/notes-en.png) | ![Current Verstak journal in a test vault](docs/screenshots/journal-en.png) |
## Main features
| Feature | Description |
| ------------------------ | -------------------------------------------------------------------------------------------- |
| **Workspaces** | Organize files, notes and activity around durable projects, clients and other areas of work. |
| **Deals** | Organize files, notes and activity around durable projects, clients and other areas of work. |
| **Files** | Browse and manage ordinary files stored inside your vault. |
| **Notes** | Create Markdown notes, overview pages and links between related information. |
| **Overview** | Quickly return to recent work and see what may need attention. |
| **Browser Inbox** | Send pages, links, selections and files from a browser into Verstak. |
| **Activity and Journal** | Reconstruct work sessions and turn selected activity into journal entries. |
| **Todo** | Keep optional task lists inside individual workspaces and across the whole vault. |
| **Todo** | Keep optional task lists inside individual Deals and across the whole vault. |
| **Search** | Search across notes, files and supported plugin data. |
| **Trash** | Restore deleted items or remove them permanently from one central location. |
| **Secrets** | Keep credentials and access information connected to the relevant workspace. |
| **Templates** | Create repeatable workspace structures for common types of work. |
| **Secrets** | Keep credentials and access information connected to the relevant Deal. |
| **Templates** | Create repeatable Deal structures for common types of work. |
| **Plugins** | Add, replace or disable tools without turning Verstak into one rigid monolithic application. |
| **Optional sync** | Synchronize vaults between devices using a self-hosted Verstak Sync Server. |
@@ -124,7 +124,7 @@ Verstak uses the Microsoft WebView2 Runtime. It is already included with Windows
## Background and tray mode
Closing the main window keeps Verstak running in the system tray. Use **Show Verstak** to bring it back and **Quit** to exit the application completely. This lets scheduled Todo reminders continue while the window is hidden.
After the tray icon is ready, closing the main window keeps Verstak running in the system tray. One left click restores and focuses the window; a right click opens the native **Show Verstak** and **Quit** menu. **Quit** exits the application completely. If the tray cannot start, closing the window exits normally rather than leaving an unreachable process. Scheduled Todo reminders continue while the window is hidden.
### Verify a download
@@ -151,9 +151,9 @@ Documents/
Your files and notes remain ordinary files that can be accessed without Verstak.
### 2. Create your first workspace
### 2. Create your first Deal
Inside the vault, create a workspace for a project, client, server, device or another area of work.
Inside the vault, create a Deal for a project, client, server, device or another area of work.
For example:
@@ -167,7 +167,7 @@ Training course
### 3. Add context
Open the workspace and add the information needed to return to it later:
Open the Deal and add the information needed to return to it later:
* notes and decisions;
* documents and source files;
@@ -234,7 +234,6 @@ Each vault is connected separately. The local vault remains the primary copy of
* Git;
* Wails v2 build dependencies;
* WebKitGTK development packages on Linux.
* Ayatana AppIndicator development files on Linux (`sudo apt install libayatana-appindicator3-dev`).
See the
[Wails installation documentation](https://wails.io/docs/gettingstarted/installation/)
@@ -245,15 +244,15 @@ for distribution-specific dependencies.
The desktop, SDK and official plugin repositories must be cloned as sibling directories:
```text
verstak-workspace/
verstak-source/
├── verstak/
├── verstak-sdk/
└── verstak-official-plugins/
```
```bash
mkdir verstak-workspace
cd verstak-workspace
mkdir verstak-source
cd verstak-source
git clone https://github.com/mirivlad/verstak.git
git clone https://github.com/mirivlad/verstak-sdk.git
+7 -8
View File
@@ -7,7 +7,7 @@
### Рабочий контекст остаётся рядом — и хранится локально.
Файлы, заметки, ссылки, материалы из браузера, активность и история работы
в одном расширяемом рабочем пространстве.
в одной расширяемой локальной среде.
[English](README.md) · **Русский**
@@ -49,11 +49,11 @@
| Обзор: возвращает к делу и показывает недавнюю работу, входящие и то, что требует внимания. | Файлы: обычные папки и документы прямо внутри дела. |
| --- | --- |
| ![Экран «Обзор» Верстака в тестовом vault](docs/screenshots/overview.png) | ![Файлы дела в тестовом vault Верстака](docs/screenshots/workspace-files.png) |
| ![Актуальный экран «Обзор» Верстака в тестовом vault](docs/screenshots/overview-ru.png) | ![Актуальные файлы дела в тестовом vault Верстака](docs/screenshots/files-ru.png) |
| Заметки: Markdown-заметки остаются рядом с делом, которое они описывают. | Журнал: фиксирует завершённую работу и её контекст. |
| --- | --- |
| ![Заметки Верстака в тестовом vault](docs/screenshots/notes.png) | ![Журнал Верстака в тестовом vault](docs/screenshots/journal.png) |
| ![Актуальные заметки Верстака в тестовом vault](docs/screenshots/notes-ru.png) | ![Актуальный журнал Верстака в тестовом vault](docs/screenshots/journal-ru.png) |
## Основные возможности
@@ -124,7 +124,7 @@ APPIMAGE_EXTRACT_AND_RUN=1 ./verstak-linux-x86_64-*.AppImage
## Фоновая работа и трей
Закрытие главного окна не завершает Верстак: приложение остаётся в системном трее. Выберите **Show Verstak**, чтобы вернуть окно, или **Quit**, чтобы полностью завершить приложение. Благодаря этому напоминания Todo продолжают работать, пока окно скрыто.
После готовности значка в трее закрытие главного окна не завершает Верстак. Один левый клик возвращает и фокусирует окно, правый клик открывает нативное меню **«Показать Верстак»** и **«Выйти»**. **«Выйти»** полностью завершает приложение. Если трей не удалось запустить, закрытие окна завершает приложение обычным образом и не оставляет недоступный процесс. Пока окно скрыто, напоминания Todo продолжают работать.
### Проверка скачанного файла
@@ -234,7 +234,6 @@ sha256sum -c SHA256SUMS --ignore-missing
* Git;
* зависимости для сборки Wails v2;
* пакеты разработки WebKitGTK в Linux.
* файлы разработки Ayatana AppIndicator в Linux (`sudo apt install libayatana-appindicator3-dev`).
Зависимости для конкретного дистрибутива перечислены в
[документации Wails](https://wails.io/docs/gettingstarted/installation/).
@@ -244,15 +243,15 @@ sha256sum -c SHA256SUMS --ignore-missing
Desktop, SDK и официальные плагины должны находиться в соседних каталогах:
```text
verstak-workspace/
verstak-source/
├── verstak/
├── verstak-sdk/
└── verstak-official-plugins/
```
```bash
mkdir verstak-workspace
cd verstak-workspace
mkdir verstak-source
cd verstak-source
git clone https://github.com/mirivlad/verstak.git
git clone https://github.com/mirivlad/verstak-sdk.git
+45 -43
View File
@@ -464,7 +464,7 @@ contributions summary.
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.
- UI policy: the Deal `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
@@ -635,7 +635,7 @@ bundled runtime. Это реальный runtime contract для cooperative bun
1. Unregister contributions всех plugins предыдущего discovery.
2. Unregister all non-core capabilities.
3. Re-register core capabilities + vault + workspace (если открыт).
3. Re-register core capabilities + vault + Deal manager (если открыт).
4. Re-scan discovery directories и повторить capability resolution.
5. Register contributions для loaded/degraded plugins (disabled/failed — не регистрируются).
6. Update plugins list.
@@ -838,51 +838,52 @@ Vault plugin state хранится **внутри vault** в `.verstak/plugins.
- `./scripts/smoke-platform.sh` — ✅ (enable/disable/plugins.json)
- `./scripts/build.sh` — ✅
## Workspace Core Capability
## Дело: core capability
Workspace — это физическая папка верхнего уровня внутри vault root. Filesystem
является source of truth для списка workspaces.
Дело — это физическая папка верхнего уровня внутри vault root. Filesystem
является source of truth для списка Дел.
Пример:
```
<vault>/
Workspace/
Deal/
Notes/
Project/
ClientA/
.verstak/
```
Нет единого `<vault>/Workspace/` контейнера для всех workspaces. Папка
`Workspace/` может быть обычным workspace, но `Project/` и `ClientA/` являются
такими же workspace на том же уровне.
Нет единого `<vault>/Deal/` контейнера для всех Дел. Папка `Deal/` может быть
обычным Делом, но `Project/` и `ClientA/` являются такими же Делами на том же
уровне.
### Хранение
Workspace existence/list хранится только в filesystem:
Существование и список Дел хранятся только в filesystem:
- `ListWorkspaces()` читает top-level directories из vault root.
- `.verstak`, reserved/internal directories, top-level files и symlinks не
считаются workspaces.
- `.verstak/workspace*.json` не является source of truth для списка workspaces.
- Нет persisted workspace path mapping и нет virtual workspace tree, которое
считаются Делами.
- `.verstak/workspace*.json` не является source of truth для списка Дел.
- Нет virtual workspace tree, которое
мапится на произвольные папки.
`.verstak` может хранить только metadata, которая не заменяет filesystem:
- UI state: selected workspace, expanded folders, sort/pin state, preferences.
- UI state: выбранное Дело, expanded folders, sort/pin state, preferences.
- Semantic snapshot: applied template snapshot, enabled feature areas, exact
`workspaceTools`, and folder conventions.
Template snapshot копируется в metadata при создании workspace. Workspace
identity при этом остаётся именем top-level folder; `metadata.workspaceName`
является presentation/snapshot field, not canonical identity. Если сохранённое
значение расходится с именем папки, runtime возвращает canonical `workspaceName`
равным имени папки без filesystem side effects.
При создании Дела runtime генерирует UUID и сохраняет его в
`.verstak/workspace.json`. `workspaceId` — постоянная личность Дела;
`workspaceRootPath` и `workspaceName` — изменяемый адрес и presentation fields.
Переименование обновляет путь, но не ID. Новая папка с прежним именем получает
другой ID и не может перехватить старые привязки.
```json
{
"workspaceId": "1eb7cc69-52e8-4a18-ae2a-50d5229c5b60",
"workspaceName": "Project",
"createdFromTemplate": {
"templateId": "project",
@@ -921,25 +922,25 @@ identity при этом остаётся именем top-level folder; `metada
}
```
Если original template удалён или изменён позже, существующий workspace
Если original template удалён или изменён позже, существующее Дело
открывается по сохранённому snapshot и не мутирует автоматически. Template
update/migration может быть только явной future feature. Если metadata
отсутствует, workspace открывается как generic workspace минимум с `files: true`.
отсутствует, Дело открывается как generic Deal минимум с `files: true`.
### API
- `ListWorkspaces()` — список top-level physical folders.
- `ListWorkspaces()` — список top-level папок Дел с их `workspaceId`.
- `ListWorkspaceTemplates()` — selectable built-in templates с presentation
metadata и `workspaceTools`.
- `CreateWorkspace(name, templateId?)` — создать `<vault>/<name>/`, применить
template один раз, сохранить snapshot metadata.
- `RenameWorkspace(oldName, newName)` — физически переименовать top-level folder
и обновить metadata key/name.
- `TrashWorkspace(name)` — перенести весь top-level workspace folder в internal
- `RenameWorkspace(oldName, newName)` — физически переименовать top-level папку
Дела и обновить metadata name, не меняя `workspaceId`.
- `TrashWorkspace(name)` — перенести весь top-level folder Дела в internal
trash policy.
- `GetWorkspaceMetadata(name)` — прочитать metadata или вернуть generic fallback.
- `UpdateWorkspaceMetadata(name, patch)` — обновить metadata без влияния на
существование workspace.
существование Дела.
Deprecated compatibility APIs:
@@ -949,7 +950,7 @@ Deprecated compatibility APIs:
- `ArchiveWorkspaceNode(...)` — wrapper over `TrashWorkspace`.
- `MoveWorkspaceNode(...)` — unsupported; old nested/mapped moves are rejected.
- `GetCurrentWorkspaceNode()` / `SetCurrentWorkspaceNode(...)` — wrappers over
selected top-level workspace UI state.
selected top-level Deal UI state.
Эти методы существуют только для постепенного frontend/Wails cleanup. Они не
должны создавать или сохранять nested workspace tree и не должны восстанавливать
@@ -957,32 +958,33 @@ Deprecated compatibility APIs:
### Capability
`verstak/core/workspace/v1` — регистрируется только когда vault открыт и workspace инициализирован.
`verstak/core/workspace/v1` — техническое имя capability; оно регистрируется
только когда vault открыт и Deal manager инициализирован.
### Правила
- Workspace name — один safe folder name, не path.
- Имя Дела — один safe folder name, не path.
- Reject: empty, slash, backslash, absolute-looking paths, `..`, null byte,
`.verstak`, reserved/internal names, symlink workspaces, conflicts.
- WorkspaceItems получают `workspaceRootPath`, равный имени top-level папки
`.verstak`, reserved/internal names, symlink Дел, conflicts.
- WorkspaceItems получают техническое поле `workspaceRootPath`, равное имени top-level папки
(`Project`, `ClientA`, etc). Files plugin показывает именно эту папку.
- 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/`; нет
- Notes are ordinary Markdown files under `<Deal>/Notes/`; нет
`.verstak/notes`, UUID note storage или второго source of truth для note
content.
### Lifecycle Events
Runtime publishes workspace lifecycle events after successful operations:
Runtime publishes lifecycle events Дел после успешных операций:
- `workspace.created` — payload includes `operation: "create"`,
- `workspace.created` — payload includes `operation: "create"`, `workspaceId`,
`workspaceRootPath`, `workspaceName`, and optional `templateId`.
- `workspace.renamed` — payload includes `operation: "rename"`, new
`workspaceRootPath` / `workspaceName`, and previous workspace fields.
- `workspace.trashed` — payload includes `operation: "trash"`,
- `workspace.renamed` — payload includes `operation: "rename"`, `workspaceId`,
new `workspaceRootPath` / `workspaceName`, and previous path fields.
- `workspace.trashed` — payload includes `operation: "trash"`, `workspaceId`,
`workspaceRootPath`, `workspaceName`, `trashId`, `trashPath`, and `deletedAt`.
- `workspace.selected` — payload includes `operation: "select"`,
- `workspace.selected` — payload includes `operation: "select"`, `workspaceId`,
`workspaceRootPath`, and `workspaceName`.
Official Activity subscribes to these events and stores them through the normal
@@ -990,11 +992,11 @@ activity provider path.
### UI
Workspace list в sidebar:
- Flat list of top-level workspace folders.
- Create workspace, rename workspace, trash workspace.
- Selection is stored as selected workspace name.
- No expand/collapse workspace tree and no case/folder node creation in core.
Список Дел в sidebar:
- Flat list of top-level папок Дел.
- Создать Дело, переименовать Дело, переместить Дело в корзину.
- Selection is stored as selected Deal name.
- No expand/collapse tree and no case/folder node creation in core.
---
+11 -11
View File
@@ -1,12 +1,12 @@
# Workspace Templates
# Deal 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
Deal templates choose which dynamic plugin Deal tabs are visible when a new
top-level Deal 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 |
| Template | Deal tabs |
| --- | --- |
| General | Notes, Files, Journal, Activity, Browser Inbox |
| Project | Notes, Files, Todos, Journal, Activity, Browser Inbox |
@@ -14,27 +14,27 @@ whole vault and do not affect global sidebar tools.
| Admin | Notes, Files, Secrets, Todos, Journal |
| Minimal | Notes, Files |
The create-workspace modal displays the selected template description and its
The create-Deal 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.
`workspaceTools` list of plugin IDs. Existing Deal metadata without
`workspaceTools` remains compatible: its Deal continues to show all globally
enabled Deal 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.
Deal with a different template never changes an existing Deal 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.
Template visibility applies only to `workspaceItems` in the selected Deal.
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
The Deal 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: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

@@ -6,12 +6,13 @@
**Architecture:** Desktop core stores and delivers plugin-owned notification schedules and controls the tray lifecycle. Todo uses the public plugin API to replace its desired reminders. A small core tray adapter keeps the Wails process alive after window close. Documentation uses the supplied English and Russian README sources plus screenshots from an actual test vault.
**Tech Stack:** Go 1.24, Wails v2.12 runtime notifications, `getlantern/systray`, plain JavaScript plugin API, Node smoke tests, Playwright, bash packaging.
**Tech Stack:** Go 1.24, Wails v2.12 runtime notifications, `fyne.io/systray`, plain JavaScript plugin API, Node smoke tests, Playwright, bash packaging.
## Global constraints
- Support Windows and Linux only; no background daemon after an explicit Quit.
- Closing a window hides it; only tray **Quit** ends the process.
- Closing a window hides it only after the tray reports ready; otherwise normal
window close ends the process.
- `verstak.todo` requires `verstak/core/notifications/v1` and `notifications.schedule`.
- Plugins cannot receive Wails runtime access.
- Keep Wails generated bindings out of commits.
@@ -231,7 +232,12 @@ Expected: absent packages/methods.
- [ ] **Step 3: Implement adapter and Wails wiring**
Add `github.com/getlantern/systray@v1.2.2`. The production adapter calls nonblocking `systray.Register`, sets compact source-controlled PNG bytes derived from the existing tracked logo, and starts goroutines for the two click channels. `main.go` registers `OnBeforeClose` and uses `options.SingleInstanceLock` whose second launch calls `app.ShowWindow`. Do not embed ignored Wails-generated files from `build/`.
Use `fyne.io/systray`. The production adapter calls `RunWithExternalLoop` so the
Windows native message loop runs alongside Wails, embeds a multi-resolution
ICO on Windows and PNG on Linux, routes one left click to `app.ShowWindow`, and
leaves the platform-native right-click menu active. `main.go` registers
`OnBeforeClose` and uses `options.SingleInstanceLock` whose second launch calls
`app.ShowWindow`. Do not embed ignored Wails-generated files from `build/`.
- [ ] **Step 4: Verify and commit**
@@ -239,7 +245,7 @@ Add `github.com/getlantern/systray@v1.2.2`. The production adapter calls nonbloc
gofmt -w main.go internal/api/app.go internal/api/app_test.go internal/shell/tray/*.go
GOCACHE=/tmp/verstak-go-cache go test ./internal/shell/tray ./internal/api -count=1
./scripts/build.sh
ldd build/bin/verstak-desktop | grep -E 'ayatana-appindicator|appindicator'
! rg -n 'getlantern|appindicator' go.mod go.sum packaging scripts/build.sh
git diff --check
```
@@ -265,7 +271,8 @@ git push mirror main
- [ ] **Step 1: Add failing package-contract checks**
Add assertions for `libayatana-appindicator3-dev` in the Linux build guidance, `libayatana-appindicator3-1` in Debian dependencies, and `ayatana-appindicator` in the AppImage packing verification.
Add assertions that the Linux build guidance, Debian metadata, and AppImage
packager no longer require the removed AppIndicator backend.
- [ ] **Step 2: Confirm red**
@@ -275,7 +282,9 @@ Expected: the first tray dependency assertion fails.
- [ ] **Step 3: Implement portable package support**
Declare the Debian runtime dependency. Make the Linux build error name the appindicator development header. Require the dynamically discovered appindicator library to appear in AppDir after the existing `ldd` traversal. Keep the Windows system-WebView2 policy unchanged.
Keep Debian and AppImage focused on the Wails/WebKitGTK runtime. Do not add a
tray-specific AppIndicator dependency. Keep the Windows system-WebView2 policy
unchanged.
- [ ] **Step 4: Install the supplied public README sources**
@@ -1,6 +1,6 @@
# Native Notifications and System Tray Design
**Status:** approved for implementation on 2026-07-14
**Status:** implemented; tray reliability update recorded on 2026-07-15
## Goal
@@ -19,27 +19,33 @@ must work in the portable Windows archive, Debian package, and AppImage.
## Tray behavior
On Windows and Linux, a tray icon is registered before the Wails event loop
starts. Its menu contains exactly two actions:
On Windows and Linux, a tray icon is initialized after Wails reaches
`OnDomReady`. Its menu contains exactly two actions:
1. **Show Verstak** — shows and focuses the existing main window.
2. **Quit** — exits the process deliberately.
Closing the main window with its window-manager close control hides the window
and keeps the process, plugins, local browser receiver, and reminder scheduler
alive. It does not terminate the application. The quit action temporarily
allows the close lifecycle to finish and then exits normally.
One left click restores and focuses the existing main window. A native right
click opens the menu. Closing the main window with its window-manager close
control hides the window only after the tray has successfully initialized; it
then keeps the process, plugins, local browser receiver, and reminder scheduler
alive. If tray initialization fails or the native message loop exits, the
ordinary close path exits normally rather than leaving an unreachable process.
The quit action allows the close lifecycle to finish and exits normally.
The app has a single-instance lock. If a user launches the executable while an
instance is hidden in the tray, the existing instance shows its window instead
of creating a second process.
The implementation uses `github.com/getlantern/systray` through a small
`internal/shell/tray` adapter. It uses `Register`, rather than its blocking
`Run`, so Wails remains the owner of the GUI event loop. A compact PNG derived
from the tracked project logo is encoded in the tray package, so a clean build
does not depend on ignored Wails-generated icon files. The library accepts PNG
icon bytes on both target platforms.
The implementation uses `fyne.io/systray` through a small
`internal/shell/tray` adapter. `RunWithExternalLoop` starts the Windows native
message loop without making Wails relinquish ownership of its GUI lifecycle.
The icon is a source-controlled multi-resolution ICO on Windows (16, 20, 24,
32, 48, and 256 pixels with transparency) and a PNG on Linux. Both are embedded
in the binary, so a clean build does not depend on ignored Wails-generated
files. Tray readiness is published only after icon, tooltip, and menu creation
all succeed; lifecycle diagnostics are logged for startup, readiness, clicks,
failure fallback, and shutdown.
## Notification capability and permission
@@ -116,16 +122,14 @@ is not removed.
## Packaging
`getlantern/systray` requires CGO. The Windows release build already uses
`x86_64-w64-mingw32-gcc`; the Windows packaging tests must compile it with the
tray dependency included. Linux build instructions add
`libayatana-appindicator3-dev`. The Debian package declares the corresponding
runtime dependency `libayatana-appindicator3-1`.
`fyne.io/systray` supplies the Windows message loop and uses the session D-Bus
on Linux. It does not require the removed AppIndicator development or runtime
package. The Windows release build still uses `x86_64-w64-mingw32-gcc` for the
Wails application itself.
The existing AppImage packager traverses `ldd` for the desktop executable and
copies non-glibc runtime libraries. Its verification is extended to prove that
the appindicator library is present in the AppDir when the tray implementation
is compiled in.
copies non-glibc runtime libraries; it does not require a tray-specific shared
library.
## Public README and product screenshots
@@ -156,15 +160,19 @@ Automated tests cover:
- schedule replacement, cancellation, rescheduling, persistence, one-time
overdue delivery, failed-send retry, and permission/capability rejection;
- Todo desired-list derivation and calls after create/edit/status/delete;
- close policy: ordinary close hides, explicit quit permits shutdown;
- tray controller action wiring and second-instance window reveal;
- Linux/Windows build scripts and package dependency expectations.
- close policy: ordinary close hides only after tray readiness, while explicit
quit permits shutdown;
- tray controller action wiring, readiness/failure fallback, idempotent stop,
left-click reveal, and second-instance window reveal;
- true multi-resolution Windows ICO data, Linux PNG data, and Linux/Windows
build and package dependency expectations.
Manual smoke tests are required because neither unit tests nor Playwright can
assert a real desktop notification area or OS toast:
1. On Linux and Windows, start Verstak, close its window, use the tray menu to
reveal it, and use **Quit** to terminate it.
1. On Linux and Windows, start Verstak, verify the tray icon and tooltip, use
one left click to reveal the window, use the right-click menu to reveal it,
close the window, and use **Quit** to terminate it.
2. Set a Todo reminder for a near future time, hide the window in the tray, and
observe one native notification.
3. Quit before a future reminder, relaunch after it expires, and observe one
+38 -6
View File
@@ -27,6 +27,38 @@ test.describe('Activity workflow', () => {
await expect(activity.locator('[data-activity-action="clear"]')).toBeDisabled();
});
test('clearing activity requires destructive confirmation for the current case', async ({ page }) => {
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.activity', {
'events:workspace:Project': [{
activityId: 'activity-to-clear',
occurredAt: '2026-06-30T08:00:00.000Z',
type: 'note.saved',
title: 'Keep until confirmed',
workspaceRootPath: 'Project',
}],
});
});
await page.getByRole('tab', { name: 'Activity' }).click();
const activity = page.locator('.activity-root');
await expect(activity.locator('.activity-count')).toHaveText('1 event');
await activity.locator('[data-activity-action="clear"]').click();
const confirmation = activity.locator('[data-activity-clear-confirmation]');
await expect(confirmation).toBeVisible();
await expect(confirmation).toContainText(/Project|Дело/);
await expect(activity.locator('.activity-count')).toHaveText('1 event');
await confirmation.locator('[data-activity-clear-cancel]').click();
await expect(confirmation).toHaveCount(0);
await expect(activity.locator('.activity-count')).toHaveText('1 event');
await activity.locator('[data-activity-action="clear"]').click();
await activity.locator('[data-activity-clear-confirm]').click();
await expect(activity.locator('.activity-count')).toHaveText('0 events');
});
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', {
@@ -74,8 +106,8 @@ test.describe('Activity workflow', () => {
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('Deal: Project');
await expect(candidate).toContainText('Estimated duration: 10 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();
@@ -110,11 +142,11 @@ test.describe('Activity workflow', () => {
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-candidate]')).toContainText('Deal: Project');
await expect(journal.locator('[data-journal-candidate]')).toContainText('Estimated duration: 10 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');
await expect(journal.locator('[data-journal-input="minutes"]')).toHaveValue('10');
const activityInputs = journal.locator('[data-journal-candidate-activity]');
await expect(activityInputs).toHaveCount(2);
@@ -126,7 +158,7 @@ test.describe('Activity workflow', () => {
await journal.locator('[data-journal-action="save-entry"]').click();
await expect(journal).toContainText('Review research capture');
await expect(journal).toContainText('20 min');
await expect(journal).toContainText('10 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'];
+7 -7
View File
@@ -1,7 +1,7 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
test.describe('Browser Inbox workflow', () => {
test.describe('Browser workflow', () => {
let consoleCollector;
test.beforeEach(async ({ page }) => {
@@ -16,14 +16,14 @@ test.describe('Browser Inbox workflow', () => {
});
test('global inbox explains empty capture flow and exposes assignment filters', async ({ page }) => {
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser Inbox' }).click();
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser' }).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-title')).toHaveText('Browser');
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('.browser-inbox-empty')).toContainText('No browser materials yet');
await expect(inbox.locator('.browser-inbox-empty')).toContainText('Send a page, selection, or link from the 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();
@@ -47,7 +47,7 @@ test.describe('Browser Inbox workflow', () => {
}],
});
});
await page.getByRole('tab', { name: 'Browser Inbox' }).click();
await page.getByRole('tab', { name: 'Browser' }).click();
const inbox = page.locator('.browser-inbox-root');
await expect(inbox.locator('.browser-inbox-count')).toHaveText('1 item');
@@ -94,7 +94,7 @@ test.describe('Browser Inbox workflow', () => {
],
});
});
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser Inbox' }).click();
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser' }).click();
const inbox = page.locator('.browser-inbox-root');
await expect(inbox.locator('.browser-inbox-count')).toHaveText('3 items');
+16
View File
@@ -124,4 +124,20 @@ test.describe('Command Palette', () => {
await expect(page.locator('.modal[aria-label="Plugin Settings"]')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.modal-header h3')).toContainText('Sync');
});
test('keeps technical command failures out of the palette status', async ({ page }) => {
await page.evaluate(() => {
window.go.api.App.PluginSyncNow = () => Promise.resolve('[plugin:verstak.sync] sync.now failed: internal service code 42');
});
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+K' : 'Control+K');
const palette = page.locator('.command-palette');
await palette.locator('[data-command-palette-input]').fill('sync now');
await page.keyboard.press('Enter');
const status = page.locator('[data-command-palette-status="error"]');
await expect(status).toContainText('Could not run Sync Now. Please try again.');
await expect(status).not.toContainText('[plugin:');
await expect(status).not.toContainText('sync.now');
});
});
+66 -3
View File
@@ -36,6 +36,69 @@ test.describe('F: Default Editor Plugin', () => {
await expect(textarea).toHaveValue('Buy groceries\nWrite tests');
});
test('secret link opens its exact secret and closing it restores the note preview', async ({ page }) => {
const notePath = 'Notes/Secret Link.md';
const noteContent = '# Secret link\n\n[Target secret](verstak-secret://target.secret)';
await page.evaluate(async ({ notePath, noteContent }) => {
const writeError = await window.go.api.App.WriteVaultTextFile(
'verstak.platform-test',
notePath,
noteContent,
{ createIfMissing: true, overwrite: true },
);
if (writeError) throw new Error(writeError);
const [result, openError] = await window.go.api.App.OpenWorkbenchResource('verstak.platform-test', {
kind: 'vault-file',
path: notePath,
extension: '.md',
context: { sourceView: 'notes', isInsideNotesFolder: true, notesMode: true },
});
if (openError) throw new Error(openError);
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
}, { notePath, noteContent });
const note = page.locator('[data-editor-mode="notes-markdown"]');
await expect(note).toBeVisible({ timeout: 10000 });
await expect(note.locator('[data-preview]')).toContainText('Target secret');
await note.locator('.secret-link').click();
const secrets = page.locator('.secrets-root');
await expect(secrets).toBeVisible({ timeout: 10000 });
await expect(secrets.locator('.secrets-item.active .secrets-item-title')).toHaveText('Target secret');
await expect(secrets.locator('.secrets-card h2')).toHaveText('Target secret');
await page.locator('.workbench-host .close-btn').click();
await expect(note).toBeVisible({ timeout: 10000 });
await expect(note.locator('[data-preview]')).toContainText('Target secret');
await expect(note.locator('[data-editor-textarea]')).toHaveCount(0);
await expect(note.locator('[data-save-state]')).toHaveText('');
const storedContent = await page.evaluate(async ({ notePath }) => {
const [content, readError] = await window.go.api.App.ReadVaultTextFile('verstak.platform-test', notePath);
if (readError) throw new Error(readError);
return content;
}, { notePath });
expect(storedContent).toBe(noteContent);
});
test('unavailable secret does not fall back to the first secret', async ({ page }) => {
await page.evaluate(async () => {
const [result, openError] = await window.go.api.App.OpenWorkbenchResource('verstak.default-editor', {
kind: 'secret',
path: 'missing.secret',
mode: 'view',
});
if (openError) throw new Error(openError);
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
});
const secrets = page.locator('.secrets-root');
await expect(secrets).toBeVisible({ timeout: 10000 });
await expect(secrets.locator('.secrets-item.active')).toHaveCount(0);
await expect(secrets.locator('.secrets-status.error')).toContainText(/unavailable|недоступен/i);
});
test('editor supports markdown toolbar split save reopen and revert', async ({ page }) => {
await page.evaluate(async () => {
const err = await window.go.api.App.WriteVaultTextFile(
@@ -112,10 +175,10 @@ test.describe('F: Default Editor Plugin', () => {
const workbench = page.locator('.workbench-host');
await expect(workbench).toBeVisible({ timeout: 10000 });
const title = workbench.locator('.workbench-title');
await expect(title).toHaveText('Docs/readme.md');
await expect(title).toHaveText('readme.md');
});
test('open .md with notes context routes to highest-priority provider', async ({ page }) => {
test('opening a note uses its title rather than its technical path', async ({ page }) => {
await page.evaluate(async () => {
const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.platform-test', {
kind: 'vault-file',
@@ -130,7 +193,7 @@ test.describe('F: Default Editor Plugin', () => {
const workbench = page.locator('.workbench-host');
await expect(workbench).toBeVisible({ timeout: 10000 });
const title = workbench.locator('.workbench-title');
await expect(title).toHaveText('Notes/Overview.md');
await expect(title).toHaveText('Overview');
});
test('default-editor plugin is listed as loaded in plugin manager', async ({ page }) => {
+2 -2
View File
@@ -513,7 +513,7 @@ test.describe('G: Files Plugin', () => {
const workbench = page.locator('.workbench-host');
await expect(workbench).toBeVisible({ timeout: 10000 });
await expect(workbench.locator('.workbench-title')).toHaveText('Docs/readme.md');
await expect(workbench.locator('.workbench-title')).toHaveText('readme.md');
const preview = page.locator('.de-preview');
await expect(preview).toBeVisible({ timeout: 10000 });
@@ -535,7 +535,7 @@ test.describe('G: Files Plugin', () => {
const workbench = page.locator('.workbench-host');
await expect(workbench).toBeVisible({ timeout: 10000 });
await expect(workbench.locator('.workbench-title')).toHaveText('Notes/Overview.md');
await expect(workbench.locator('.workbench-title')).toHaveText('Overview');
});
test('files plugin card shows openProviders in contributions', async ({ page }) => {
+35
View File
@@ -0,0 +1,35 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
test('global Journal creates an entry in the selected Deal', async ({ page }) => {
const consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.journal', {
'worklog:workspace:Project': [{ entryId: 'existing-project-entry', workspaceRootPath: 'Project', date: '2026-07-14', title: 'Existing entry', minutes: 5 }],
});
});
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Journal' }).click();
const journal = page.locator('.journal-root');
await expect(journal.locator('.journal-title')).toHaveText('Journal');
await journal.locator('[data-journal-action="add"]').click();
await journal.locator('[data-journal-input="workspaceRootPath"]').selectOption('Project');
await journal.locator('[data-journal-input="title"]').fill('Prepare project handoff');
await journal.locator('[data-journal-input="minutes"]').fill('30');
await journal.locator('[data-journal-action="save-entry"]').click();
await expect.poll(async () => page.evaluate(async () => {
const result = await window.go.api.App.ReadPluginSettings('verstak.journal');
const settings = Array.isArray(result) ? result[0] : result;
return settings['worklog:workspace:Project']?.[0]?.title;
})).toBe('Prepare project handoff');
await expect(journal).toContainText('Prepare project handoff');
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await page.getByRole('tab', { name: 'Journal' }).click();
await expect(page.locator('.journal-root')).toContainText('Prepare project handoff');
consoleCollector.assertNoErrors();
});
+14
View File
@@ -25,9 +25,23 @@ test.describe('Desktop localization', () => {
await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Тест платформы' })).toBeVisible();
await expect(page.locator('[data-status-item-id="verstak.platform-test.status"]')).toContainText('Все тесты пройдены');
const project = page.locator('.wt-node').filter({ hasText: 'Project' });
await project.locator('button[title="Переименовать Дело"]').click();
await expect(page.locator('button[title="Сохранить новое название"]')).toHaveText('Сохранить');
await page.locator('.wt-rename').press('Escape');
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.locator('[data-settings-action="plugin-manager"]').click();
const pluginManager = page.locator('.plugin-manager');
await expect(pluginManager).toContainText('Зарегистрировано возможностей:');
await expect(pluginManager.locator('.registry-section')).toContainText('Реестр возможностей');
await expect(pluginManager.locator('.registry-section')).toContainText('Возможность');
await expect(pluginManager.locator('.plugin-filters')).toContainText('Фильтры');
await expect(pluginManager.locator('[data-plugin-filter-results]')).toContainText('Показано плагинов:');
await expect(pluginManager.locator('[data-plugin-filter="status"]')).toContainText('Выключенные');
await expect(pluginManager).not.toContainText('Workspace-scoped');
await page.reload();
await waitForAppReady(page);
+6 -4
View File
@@ -58,7 +58,7 @@ test.describe('D: Plugin API bridge', () => {
const workbench = page.locator('.workbench-host');
await expect(workbench).toBeVisible({ timeout: 10000 });
await expect(workbench.locator('.workbench-title')).toHaveText('Notes/Overview.md');
await expect(workbench.locator('.workbench-title')).toHaveText('Overview');
});
test('workbench routes markdown files to default-editor provider', async ({ page }) => {
@@ -76,7 +76,7 @@ test.describe('D: Plugin API bridge', () => {
const workbench = page.locator('.workbench-host');
await expect(workbench).toBeVisible({ timeout: 10000 });
const title = workbench.locator('.workbench-title');
await expect(title).toHaveText('Docs/readme.md');
await expect(title).toHaveText('readme.md');
});
test('workbench shows no-provider fallback when no provider matches', async ({ page }) => {
@@ -91,8 +91,10 @@ test.describe('D: Plugin API bridge', () => {
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
});
await expect(page.locator('[data-workbench-status="no-provider"]')).toBeVisible();
await expect(page.locator('[data-workbench-status="no-provider"]')).toContainText('No viewer/editor available');
const fallback = page.locator('[data-workbench-status="no-provider"]');
await expect(fallback).toBeVisible();
await expect(fallback).toContainText('No viewer/editor available');
await expect(fallback).not.toContainText('generic-text');
});
test('sync plugin API routes through mocked Wails bridge', async ({ page }) => {
+55 -5
View File
@@ -1,5 +1,5 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState, openPluginManager } from './helpers.js';
import { waitForAppReady, setupConsoleCollector, resetMockState, openPluginManager, setPluginStatus } from './helpers.js';
test.describe('E: Plugin Manager layout', () => {
let consoleCollector;
@@ -88,6 +88,56 @@ test.describe('E: Plugin Manager layout', () => {
await expect(risk).toContainText('elevated permissions');
});
test('plugin manager filters by state, declared permissions, capabilities, settings and source', async ({ page }) => {
await setPluginStatus(page, 'verstak.todo', 'disabled', false);
await page.evaluate(() => window.__wailsMock.addSyntheticPlugins(1, 'third-party'));
await openPluginManager(page);
const results = page.locator('[data-plugin-filter-results]');
await expect(results).toContainText('Showing');
const filterSelectStyle = await page.locator('[data-plugin-filter="status"]').evaluate((node) => {
const style = getComputedStyle(node);
return { appearance: style.appearance, backgroundImage: style.backgroundImage };
});
expect(filterSelectStyle.appearance).toBe('none');
expect(filterSelectStyle.backgroundImage).not.toBe('none');
await page.locator('[data-plugin-filter="status"]').selectOption('disabled');
await expect(page.locator('.plugin-card')).toHaveCount(1);
await expect(page.locator('.plugin-card')).toContainText('Todos');
await page.locator('button.reload-btn').click();
await expect(page.locator('[data-plugin-filter="status"]')).toHaveValue('disabled');
await expect(page.locator('.plugin-card')).toHaveCount(1);
await page.locator('[data-plugin-filter-reset]').click();
await page.locator('[data-plugin-filter-permission="notifications.schedule"]').check();
await expect(page.locator('.plugin-card')).toHaveCount(1);
await expect(page.locator('.plugin-card')).toContainText('Todos');
await page.locator('[data-plugin-filter-permission="secrets.read"]').check();
await expect(page.locator('.plugin-card')).toHaveCount(2);
await page.locator('[data-plugin-filter-permission="secrets.read"]').uncheck();
await page.locator('[data-plugin-filter-reset]').click();
await page.locator('[data-plugin-filter-capability="verstak/core/notifications/v1"]').check();
await expect(page.locator('.plugin-card')).toHaveCount(1);
await expect(page.locator('.plugin-card')).toContainText('Todos');
await page.locator('[data-plugin-filter-capability="secret-store"]').check();
await expect(page.locator('.plugin-card')).toHaveCount(2);
await page.locator('[data-plugin-filter-capability="secret-store"]').uncheck();
await page.locator('[data-plugin-filter-reset]').click();
await page.locator('[data-plugin-filter="settings"]').selectOption('with');
await expect(page.locator('.plugin-card').filter({ hasText: 'Platform Test' })).toBeVisible();
await expect(page.locator('.plugin-card').filter({ hasText: 'Default Editor' })).toHaveCount(0);
await page.locator('[data-plugin-filter="source"]').selectOption('third-party');
await expect(page.locator('.plugin-card')).toHaveCount(0);
await expect(page.locator('[data-plugin-filter-empty]')).toBeVisible();
await page.locator('[data-plugin-filter-reset]').click();
await expect(page.locator('.plugin-card')).toHaveCount(13);
});
test('workspace selection keeps exactly one active node', async ({ page }) => {
const selected = page.locator('.wt-node.selected .wt-label');
await expect(selected).toHaveCount(1);
@@ -118,15 +168,15 @@ test.describe('E: Plugin Manager layout', () => {
});
test('workspace sidebar creates renames and trashes top-level workspaces', async ({ page }) => {
await page.locator('button[title="New workspace"]').click();
await page.locator('button[title="New Deal"]').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 modal.getByRole('button', { name: 'Create Deal' }).click();
await expect(page.locator('.wt-label').filter({ hasText: 'ClientA' })).toBeVisible();
const client = page.locator('.wt-node').filter({ hasText: 'ClientA' });
await client.locator('button[title="Rename workspace"]').click();
await client.locator('button[title="Rename Deal"]').click();
await page.locator('.wt-rename').fill('ClientB');
await page.locator('button[title="Save rename"]').click();
@@ -134,7 +184,7 @@ test.describe('E: Plugin Manager layout', () => {
await expect(page.locator('.wt-label').filter({ hasText: 'ClientA' })).toHaveCount(0);
const renamed = page.locator('.wt-node').filter({ hasText: 'ClientB' });
await renamed.locator('button[title="Trash workspace"]').click();
await renamed.locator('button[title="Move Deal to trash"]').click();
await expect(page.locator('.wt-label').filter({ hasText: 'ClientB' })).toHaveCount(0);
});
+50
View File
@@ -0,0 +1,50 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
test('global Secrets lists, filters, and creates Deal-scoped secrets', async ({ page }) => {
const consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
await page.evaluate(async () => {
const [record, error] = await window.go.api.App.PluginSecretsWrite('verstak.secrets', {
id: 'project.global-secret',
title: 'Project API',
username: 'project-user',
value: 'project-value',
scope: { kind: 'workspace', workspaceRootPath: 'Project' },
});
if (error || !record?.id) throw new Error(error || 'could not create a project secret');
});
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Secrets' }).click();
const secrets = page.locator('.secrets-root');
await expect(secrets).toBeVisible({ timeout: 10000 });
await expect(secrets).toContainText('First secret');
await expect(secrets).toContainText('Project API');
await secrets.locator('[data-secret-scope-filter]').selectOption('workspace:Project');
await expect(secrets).toContainText('Project API');
await expect(secrets).not.toContainText('First secret');
await secrets.locator('[data-secret-search]').fill('no matching secret');
await expect(secrets).toContainText('No secrets');
await secrets.locator('[data-secret-search]').fill('');
await secrets.locator('[data-secret-scope-filter]').selectOption('all');
await secrets.getByRole('button', { name: 'New' }).click();
await secrets.locator('[data-secret-title]').fill('Project deployment token');
await secrets.locator('[data-secret-value]').fill('deployment-token-value');
await secrets.locator('[data-secret-scope]').selectOption('workspace');
await secrets.locator('[data-secret-workspace]').selectOption('Project');
await secrets.locator('[data-secret-save]').click();
await expect.poll(async () => page.evaluate(async () => {
const [records, error] = await window.go.api.App.PluginSecretsList('verstak.secrets');
if (error) throw new Error(error);
const record = records.find((item) => item.title === 'Project deployment token');
return record && [record.scope?.kind, record.scope?.workspaceRootPath].join('|');
})).toBe('workspace|Project');
consoleCollector.assertNoErrors();
});
+31 -4
View File
@@ -30,18 +30,45 @@ 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();
await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser' })).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 }) => {
test('Global Activity and Browser 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 });
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser' }).click();
await expect(page.locator('.view-container .view-header h2')).toHaveText('Browser', { timeout: 10000 });
});
test('selected global tool remains visibly active through navigation and sidebar reloads', async ({ page }) => {
const activity = page.locator('.sidebar .plugin-item').filter({ hasText: 'Activity' });
const browserInbox = page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser' });
await activity.click();
await expect(activity).toHaveAttribute('aria-current', 'page');
await expect(activity).toHaveClass(/is-active/);
await page.evaluate(() => {
window.dispatchEvent(new CustomEvent('verstak:plugins-changed'));
});
await expect(activity).toHaveAttribute('aria-current', 'page');
await page.evaluate(() => {
window.dispatchEvent(new CustomEvent('verstak:open-view', {
detail: { viewId: 'verstak.browser-inbox.view', pluginId: 'verstak.browser-inbox' },
}));
});
await expect(page.locator('.view-container .view-header h2')).toHaveText('Browser', { timeout: 10000 });
await expect(browserInbox).toHaveAttribute('aria-current', 'page');
await expect(browserInbox).toHaveClass(/is-active/);
await expect(activity).not.toHaveAttribute('aria-current', 'page');
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await expect(browserInbox).not.toHaveAttribute('aria-current', 'page');
});
test('Click sidebar item opens diagnostics view by view ID, not sidebar ID', async ({ page }) => {
+16 -3
View File
@@ -27,7 +27,10 @@ test.describe('Todo plugin workflow', () => {
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-input="reminderDate"]').fill('2000-01-01');
const reminderTime = todos.locator('[data-todo-input="reminderTime"]');
await expect(reminderTime).toHaveAttribute('type', 'text');
await reminderTime.fill('09:30');
await todos.locator('[data-todo-action="save"]').click();
await expect(todos).toContainText('Overdue');
@@ -36,13 +39,23 @@ test.describe('Todo plugin workflow', () => {
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');
return todo && [todo.workspaceRootPath, todo.priority, todo.dueAt, todo.reminderDate, todo.reminderAt].join('|');
})).toBe('Project|high|2000-01-01|2000-01-01|2000-01-01T09:30');
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-input="reminderTime"]').fill('not-a-time');
await todos.locator('[data-todo-action="save"]').click();
await expect(todos).toContainText('Enter a valid reminder time');
await todos.locator('[data-todo-input="reminderTime"]').fill('');
await todos.locator('[data-todo-action="save"]').click();
await expect(todos).toContainText('Prepare project review updated');
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 updated');
return todo && [todo.reminderDate, todo.reminderAt].join('|');
})).toBe('2000-01-01|');
await todos.locator('[data-todo-action="mark-done"]').click();
await expect(todos.locator('[data-todo-action="create-journal-entry"]')).toBeVisible();
+2 -2
View File
@@ -18,7 +18,7 @@ test.describe('UX follow-up fixes', () => {
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 page.locator('.sidebar .nav-item').filter({ hasText: 'Browser' }).click();
await expect(page.locator('.browser-inbox-root')).toBeVisible({ timeout: 10000 });
await expect(search).toBeVisible();
});
@@ -67,7 +67,7 @@ test.describe('UX follow-up fixes', () => {
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' });
const result = page.locator('[data-global-search-result-type="Browser"]').filter({ hasText: 'Research Search Result' });
await expect(result).toBeVisible({ timeout: 10000 });
await result.click();
+5
View File
@@ -38,6 +38,11 @@ test.describe('UX P0 shell flow', () => {
await expect(page.locator('.plugin-manager')).toHaveCount(0);
});
test('Deal header does not expose the internal workspace type badge', async ({ page }) => {
await expect(page.locator('.workspace-host')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.workspace-type')).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);
+77 -5
View File
@@ -56,7 +56,7 @@ test.describe('UX Overview workspace flow', () => {
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.getByRole('tab', { name: 'Browser' })).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.browser-inbox-root')).toBeVisible({ timeout: 10000 });
await page.getByRole('tab', { name: 'Overview' }).click();
@@ -70,7 +70,7 @@ test.describe('UX Overview workspace flow', () => {
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');
await expect(page.getByRole('tab', { name: 'Browser' })).toHaveAttribute('aria-selected', 'true');
});
test('Overview keeps the Notes total factual when the Files plugin is unavailable', async ({ page }) => {
@@ -80,6 +80,54 @@ test.describe('UX Overview workspace flow', () => {
await expect(page.locator('[data-overview-summary="notes"]')).toContainText('1 total');
});
test('Overview hides Browser cards and actions when the current Deal does not include it', async ({ page }) => {
await page.locator('button[title="New Deal"]').click();
const modal = page.locator('[data-workspace-create-modal]');
await modal.locator('[data-workspace-name]').fill('MinimalOverview');
await modal.locator('[data-workspace-template]').selectOption('minimal');
await modal.getByRole('button', { name: 'Create Deal' }).click();
await expect(page.getByRole('tab', { name: 'Browser' })).toHaveCount(0);
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.browser-inbox', {
'captures:global': [{
captureId: 'hidden-browser-capture',
capturedAt: '2026-07-14T08:00:00.000Z',
kind: 'page',
title: 'Inbox material must stay hidden',
workspaceRootPath: 'MinimalOverview',
}],
});
});
const overview = page.locator('[data-overview-root]');
await overview.locator('[data-overview-action="refresh"]').click();
await expect(overview.locator('[data-overview-summary="captures"]')).toHaveCount(0);
await expect(overview.locator('[data-overview-action="browser-inbox"]')).toHaveCount(0);
await expect(overview).not.toContainText('Inbox material must stay hidden');
});
test('Overview refreshes when Browser is disabled through plugin state changes', async ({ page }) => {
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.browser-inbox', {
'captures:global': [{
captureId: 'disabled-browser-capture',
capturedAt: '2026-07-14T08:00:00.000Z',
kind: 'page',
title: 'Disabled inbox material',
workspaceRootPath: 'Project',
}],
});
window.__wailsMock.setPluginStatus('verstak.browser-inbox', 'disabled', false);
window.dispatchEvent(new CustomEvent('verstak:plugins-changed'));
});
const overview = page.locator('[data-overview-root]');
await expect(page.getByRole('tab', { name: 'Browser' })).toHaveCount(0);
await expect(overview.locator('[data-overview-summary="captures"]')).toHaveCount(0);
await expect(overview.locator('[data-overview-action="browser-inbox"]')).toHaveCount(0);
});
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', {
@@ -200,10 +248,10 @@ test.describe('UX Overview workspace flow', () => {
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 expect(attention).toContainText('Deal: 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 expect(page.locator('.journal-root [data-journal-candidate]')).toContainText('Deal: Project');
await page.locator('.journal-modal-actions').getByRole('button', { name: 'Cancel' }).click();
await page.getByRole('tab', { name: 'Overview' }).click();
@@ -216,7 +264,7 @@ test.describe('UX Overview workspace flow', () => {
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.getByRole('tab', { name: 'Browser' })).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.browser-inbox-root')).toBeVisible({ timeout: 10000 });
await page.getByRole('tab', { name: 'Overview' }).click();
@@ -247,4 +295,28 @@ test.describe('UX Overview workspace flow', () => {
await expect(recent).toContainText('Added journal entry "Write project summary"');
await expect(recent).not.toContainText('Changed file "draft.md"');
});
test('Overview localizes activity labels without exposing internal event names', async ({ page }) => {
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.activity', {
'events:workspace:Project': [{
activityId: 'overview-russian-note',
occurredAt: '2026-06-30T08:25:00.000Z',
type: 'note.saved',
title: 'Локализация',
summary: 'Project/Notes/Localization.md',
workspaceRootPath: 'Project',
}],
});
});
await page.locator('[data-settings-menu-button]').click();
await page.locator('[data-settings-language="ru"]').click();
const overview = page.locator('[data-overview-root]');
await overview.locator('[data-overview-action="refresh"]').click();
const recent = overview.locator('[data-overview-section="recent"]');
await expect(recent).toContainText('Изменена заметка «Локализация»');
await expect(recent).not.toContainText('note.saved');
await expect(recent).not.toContainText('Edited note');
});
});
+32 -11
View File
@@ -16,7 +16,7 @@ test.describe('Workspace templates', () => {
});
async function openCreateModal(page) {
await page.locator('button[title="New workspace"]').click();
await page.locator('button[title="New Deal"]').click();
const modal = page.locator('[data-workspace-create-modal]');
await expect(modal).toBeVisible();
return modal;
@@ -24,22 +24,24 @@ test.describe('Workspace templates', () => {
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');
const templateSelect = modal.locator('[data-workspace-template]');
await expect(templateSelect).toHaveValue('default');
await expect(templateSelect).toHaveCSS('appearance', 'none');
await expect(modal.locator('[data-workspace-template-tools]')).toContainText('Notes');
await expect(modal.locator('[data-workspace-template-tools]')).toContainText('Browser Inbox');
await expect(modal.locator('[data-workspace-template-tools]')).toContainText('Browser');
await modal.getByRole('button', { name: 'Create workspace' }).click();
await modal.getByRole('button', { name: 'Create Deal' }).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.getByRole('button', { name: 'Create Deal' }).click();
await expect(modal.locator('[data-workspace-create-error]')).toContainText('Could not create the Deal. Please try again.');
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 modal.getByRole('button', { name: 'Create Deal' }).click();
await expect(page.locator('.wt-label').filter({ hasText: 'ProjectPlan' })).toBeVisible();
await expect.poll(async () => page.evaluate(async () => {
@@ -63,7 +65,7 @@ test.describe('Workspace templates', () => {
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 modal.getByRole('button', { name: 'Create Deal' }).click();
await expect(page.getByRole('tab', { name: 'Overview' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Notes' })).toBeVisible();
@@ -72,21 +74,40 @@ test.describe('Workspace templates', () => {
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();
await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser' })).toBeVisible();
});
test('template explains an unavailable plugin and warns after incomplete creation', async ({ page }) => {
await page.evaluate(() => window.__wailsMock.setPluginStatus('verstak.todo', 'disabled', false));
const modal = await openCreateModal(page);
await modal.locator('[data-workspace-template]').selectOption('project');
const todo = modal.locator('[data-workspace-template-tool="verstak.todo"]');
await expect(todo).toContainText('Todos');
await expect(todo).toContainText('Plugin is disabled');
await expect(todo).toHaveAttribute('data-template-tool-status', 'unavailable');
await modal.locator('[data-workspace-name]').fill('ProjectWithWarning');
await modal.getByRole('button', { name: 'Create Deal' }).click();
const warning = page.locator('[data-workspace-template-warning]');
await expect(warning).toContainText('ProjectWithWarning');
await expect(warning).toContainText('Todos');
await expect(warning).toContainText('Plugin is disabled');
});
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 modal.getByRole('button', { name: 'Create Deal' }).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 modal.getByRole('button', { name: 'Create Deal' }).click();
await expect(page.getByRole('tab', { name: 'Notes' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Files' })).toBeVisible();
+5 -1
View File
@@ -395,7 +395,11 @@
<VaultSelection />
{:else}
<main>
<Sidebar showGlobalSearch={currentView !== 'workspace' && currentView !== 'workspace-empty'} />
<Sidebar
showGlobalSearch={currentView !== 'workspace' && currentView !== 'workspace-empty'}
{activeView}
{activeViewPluginId}
/>
<CommandPalette />
<section class="content-shell">
+115 -32
View File
@@ -16,13 +16,13 @@ export default {
'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.',
'workspace.search': 'Search in this Deal',
'workspace.tools': 'Deal tools',
'workspace.tool': 'Deal tool',
'workspace.emptyTools': 'No Deal tools available',
'workspace.emptyToolsHint': 'Enable plugins with Deal tools or open Plugin Manager from settings.',
'workspace.select': 'Select a Deal',
'workspace.selectHint': 'Use the + button in Deals to add your first project.',
'common.details': 'Details',
'common.plugin': 'Plugin',
'common.component': 'Component',
@@ -35,6 +35,7 @@ export default {
'pluginView.select': 'Select a plugin view from the sidebar',
'pluginView.selectHint': 'Plugin views will appear here',
'common.retry': '⟳ Retry',
'common.save': 'Save',
'common.source': 'Source',
'common.status': 'Status',
'common.capability': 'Capability',
@@ -54,6 +55,24 @@ export default {
'pluginManager.health': 'Plugin Health',
'pluginManager.permissionRisk': 'Permission Risk',
'pluginManager.elevatedPermissions': '{count} plugins request elevated permissions',
'pluginManager.filters': 'Filters',
'pluginManager.filterResults': 'Showing {visible} of {total} plugins',
'pluginManager.filterReset': 'Reset filters',
'pluginManager.filterState': 'State',
'pluginManager.filterAll': 'All',
'pluginManager.filterEnabled': 'Enabled',
'pluginManager.filterDisabled': 'Disabled',
'pluginManager.filterFailed': 'Problem',
'pluginManager.filterDegraded': 'Degraded',
'pluginManager.filterPermissions': 'Permissions',
'pluginManager.filterCapabilities': 'Capabilities',
'pluginManager.filterSettings': 'Settings',
'pluginManager.filterWithSettings': 'With settings',
'pluginManager.filterWithoutSettings': 'Without settings',
'pluginManager.filterSource': 'Source',
'pluginManager.filterOfficial': 'Official',
'pluginManager.filterThirdParty': 'Third-party or local',
'pluginManager.filterEmpty': 'No plugins match the selected filters.',
'pluginManager.none': 'No plugins found',
'pluginManager.scannedDirs': 'Plugin directories scanned:',
'pluginManager.userPlugins': 'user plugins',
@@ -74,7 +93,7 @@ export default {
'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.count.workspace': '{count} Deal tool(s)',
'pluginCard.degraded': 'Plugin is usable, but some optional capabilities are unavailable.',
'pluginCard.name': 'Name',
'pluginCard.contributions': 'Contributions',
@@ -96,12 +115,12 @@ export default {
'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.createError': 'Could not create the vault. Please try again.',
'vaultSelection.openError': 'Could not open the vault. Please try again.',
'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.createHint': 'Create a local vault folder for Deals and projects.',
'vaultSelection.pathPlaceholder': 'Choose or enter a path...',
'vaultSelection.creating': 'Creating...',
'vaultSelection.create': 'Create vault',
@@ -112,21 +131,45 @@ export default {
'vaultSelection.recent': 'Recent vaults',
'common.cancel': 'Cancel',
'common.close': 'Close',
'workspaceTree.title': 'Workspaces',
'workspaceTree.new': 'New workspace',
'workspaceTree.title': 'Deals',
'workspaceTree.new': 'New Deal',
'workspaceTree.nameRequired': 'Name is required',
'workspaceTree.chooseTemplate': 'Choose a workspace template',
'workspaceTree.chooseTemplate': 'Choose a Deal template',
'workspaceTree.saveRename': 'Save rename',
'workspaceTree.rename': 'Rename workspace',
'workspaceTree.trash': 'Trash workspace',
'workspaceTree.create': 'Create workspace',
'workspaceTree.namePlaceholder': 'Workspace name',
'workspaceTree.rename': 'Rename Deal',
'workspaceTree.trash': 'Move Deal to trash',
'workspaceTree.create': 'Create Deal',
'workspaceTree.namePlaceholder': 'Deal name',
'workspaceTree.template': 'Template',
'workspaceTree.creating': 'Creating...',
'workspaceTree.templateAvailable': 'Available',
'workspaceTree.templateLimited': 'Available with limitations',
'workspaceTree.templateMissingPlugin': 'Plugin is not installed',
'workspaceTree.templatePluginDisabled': 'Plugin is disabled',
'workspaceTree.templateCapabilityUnavailable': 'A required capability is unavailable',
'workspaceTree.templateIncompatible': 'Plugin is incompatible with this version of Verstak',
'workspaceTree.templateLoadFailed': 'Plugin failed to load',
'workspaceTree.templateNotReady': 'Plugin is not ready',
'workspaceTree.templateToolTabs': 'Creates: {tabs}',
'workspaceTree.templateToolNoTabs': 'No Deal tab provided',
'workspaceTree.templateWillBeIncomplete': 'This Deal will be created without the unavailable components.',
'workspaceTree.templateIncompleteCreated': '«{name}» was created with unavailable components.',
'workspaceTree.templatesError': 'Could not load Deal templates. Please try again.',
'workspaceTree.loadError': 'Could not load Deals. Please try again.',
'workspaceTree.selectError': 'Could not select this Deal. Please try again.',
'workspaceTree.createError': 'Could not create the Deal. Please try again.',
'workspaceTree.renameError': 'Could not rename the Deal. Please try again.',
'workspaceTree.trashError': 'Could not move the Deal to trash. Please try again.',
'pluginManager.loadError': 'Could not load plugins. Please try again.',
'pluginManager.reloadError': 'Could not reload plugins. Please try again.',
'pluginManager.enableError': 'Could not enable the plugin. Please try again.',
'pluginManager.disableError': 'Could not disable the plugin. Please try again.',
'pluginManager.settingsLoadError': 'Could not load plugin settings. Please try again.',
'pluginManager.settingsUnavailable': 'Plugin settings are unavailable.',
'command.openOverview': 'Open Overview',
'command.openFiles': 'Open Files',
'command.openActivity': 'Open Activity',
'command.openBrowserInbox': 'Open Browser Inbox',
'command.openBrowserInbox': 'Open Browser',
'command.createMarkdown': 'Create Markdown File',
'command.createText': 'Create Text File',
'command.syncNow': 'Sync Now',
@@ -134,6 +177,7 @@ export default {
'command.openPluginManager': 'Open Plugin Manager',
'command.handled': '{title} handled',
'command.result': '{title} {status}',
'command.failed': 'Could not run {title}. Please try again.',
'command.statusHandled': 'handled',
'command.palette': 'Command Palette',
'command.run': 'Run command',
@@ -142,26 +186,26 @@ export default {
'search.placeholder': 'Search',
'search.global': 'Global search',
'search.noResults': 'No results',
'search.type.workspace': 'Workspace',
'search.type.workspace': 'Deal',
'search.type.tool': 'Tool',
'search.type.folder': 'Folder',
'search.type.file': 'File',
'search.type.journal': 'Journal',
'search.type.browserInbox': 'Browser Inbox',
'search.type.browserInbox': 'Browser',
'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.loadFailed': 'Could not load the plugin interface. Please try again.',
'bundle.emptyContent': 'empty content',
'bundle.executionError': 'Bundle execution error: {error}',
'bundle.executionError': 'Could not start the plugin interface. Please try again.',
'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.componentMissing': 'The requested plugin interface is unavailable.',
'bundle.mountError': 'Could not open the plugin interface. Please try again.',
'bundle.mountUnavailable': 'Mount container not available',
'bundle.unexpectedError': 'Unexpected error: {error}',
'bundle.unexpectedError': 'Could not open the plugin interface. Please try again.',
'bundle.unknownError': 'Unknown error',
'bundle.frontendEntry': 'Frontend entry',
'bundle.availableComponents': 'Available components',
@@ -197,18 +241,57 @@ export default {
'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.loadingContext': 'Loading Deal context...',
'overview.lastActive': 'Last active {time}',
'overview.noRecentActivity': 'No recent workspace activity',
'overview.noRecentActivity': 'No recent Deal activity',
'overview.refresh': 'Refresh',
'overview.summary': 'Workspace overview summary',
'overview.summary': 'Deal overview summary',
'overview.continue': 'Continue working',
'overview.continueHint': 'Pick up the next useful item in this workspace.',
'overview.loadingSignals': 'Loading workspace signals...',
'overview.continueHint': 'Pick up the next useful item in this Deal.',
'overview.loadingSignals': 'Loading Deal 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.recentChangesHint': 'Latest meaningful activity in this Deal.',
'overview.event.workspaceOpened': 'Opened Deal',
'overview.event.activity': 'Deal activity',
'overview.event.noteEdited': 'Edited note "{name}"',
'overview.event.noteOpened': 'Opened note "{name}"',
'overview.event.noteCreated': 'Created note "{name}"',
'overview.event.fileOpened': 'Opened file "{name}"',
'overview.event.fileChanged': 'Changed file "{name}"',
'overview.event.fileCreated': 'Created file "{name}"',
'overview.event.fileRemoved': 'Removed file "{name}"',
'overview.event.capturePage': 'Captured page "{name}"',
'overview.event.captureSelection': 'Captured selection "{name}"',
'overview.event.captureLink': 'Captured link "{name}"',
'overview.event.captureFile': 'Captured file "{name}"',
'overview.event.captureConverted': 'Converted capture "{name}"',
'overview.event.capture': 'Captured {kind} "{title}"',
'overview.event.journalAdded': 'Added journal entry "{name}"',
'overview.event.workSessionDetected': 'Work session detected',
'overview.event.todo': 'Todo "{title}"',
'overview.event.reviewCapture': 'Review capture "{title}"',
'overview.event.continueJournal': 'Continue journal entry "{title}"',
'overview.event.possibleJournalEntry': 'Possible journal entry',
'overview.captureKind.page': 'page',
'overview.captureKind.selection': 'selection',
'overview.captureKind.link': 'link',
'overview.captureKind.file': 'file',
'overview.captureKind.item': 'item',
'overview.todo.reminderDue': 'Reminder due',
'overview.todo.overdue': 'Overdue',
'overview.todo.dueSoon': 'Due soon',
'overview.todo.due': 'Due {date}',
'overview.candidateMeta': 'Deal: {deal} · {minutes} min · {activities} activities',
'overview.minutes': '{count} min',
'overview.browserCapture': 'Browser capture',
'overview.untitledTodo': 'Untitled todo',
'overview.untitledCapture': 'Untitled capture',
'overview.untitledJournal': 'Journal entry',
'overview.unknownDeal': 'Unknown Deal',
'overview.item': 'item',
'overview.overviewNote': 'Deal overview note',
'overview.recentFilter': 'Recent changes filter',
'overview.loadingRecent': 'Loading recent changes...',
'overview.noChanges': 'No meaningful changes for this filter yet.',
+120 -37
View File
@@ -16,13 +16,13 @@ export default {
'sidebar.error.load': 'Не удалось загрузить боковую панель',
'sidebar.error.ui': 'Ошибка интерфейса плагина',
'workspace.overview': 'Обзор',
'workspace.search': 'Поиск в рабочем пространстве',
'workspace.tools': 'Инструменты рабочего пространства',
'workspace.tool': 'Инструмент рабочего пространства',
'workspace.search': 'Поиск в Деле',
'workspace.tools': 'Инструменты Дела',
'workspace.tool': 'Инструмент Дела',
'workspace.emptyTools': 'Нет доступных инструментов',
'workspace.emptyToolsHint': 'Включите плагины с инструментами рабочего пространства или откройте Менеджер плагинов в настройках.',
'workspace.select': 'Выберите рабочее пространство',
'workspace.selectHint': 'Используйте кнопку + в разделе рабочих пространств, чтобы добавить первый проект.',
'workspace.emptyToolsHint': 'Включите плагины с инструментами Дела или откройте Менеджер плагинов в настройках.',
'workspace.select': 'Выберите Дело',
'workspace.selectHint': 'Используйте кнопку + в разделе Дел, чтобы добавить первый проект.',
'common.details': 'Подробнее',
'common.plugin': 'Плагин',
'common.component': 'Компонент',
@@ -35,9 +35,10 @@ export default {
'pluginView.select': 'Выберите представление плагина на боковой панели',
'pluginView.selectHint': 'Представления плагинов появятся здесь',
'common.retry': '⟳ Повторить',
'common.save': 'Сохранить',
'common.source': 'Источник',
'common.status': 'Состояние',
'common.capability': 'Capability',
'common.capability': 'Возможность',
'common.provider': 'Поставщик',
'status.loaded': 'загружено',
'status.degraded': 'ограничено',
@@ -48,12 +49,30 @@ export default {
'pluginManager.reload': '⟳ Обновить',
'pluginManager.scanning': 'Сканирование каталогов плагинов...',
'pluginManager.summary.plugins': 'Обнаружено плагинов: {count}',
'pluginManager.summary.capabilities': 'Зарегистрировано capabilities: {count}',
'pluginManager.summary.capabilities': 'Зарегистрировано возможностей: {count}',
'pluginManager.summary.permissions': 'Известно разрешений: {count}',
'pluginManager.scanSummary': 'Сводка проверки плагинов',
'pluginManager.health': 'Состояние плагинов',
'pluginManager.permissionRisk': 'Риск разрешений',
'pluginManager.elevatedPermissions': 'Плагинов с повышенными разрешениями: {count}',
'pluginManager.filters': 'Фильтры',
'pluginManager.filterResults': 'Показано плагинов: {visible} из {total}',
'pluginManager.filterReset': 'Сбросить фильтры',
'pluginManager.filterState': 'Состояние',
'pluginManager.filterAll': 'Все',
'pluginManager.filterEnabled': 'Включённые',
'pluginManager.filterDisabled': 'Выключенные',
'pluginManager.filterFailed': 'С проблемами',
'pluginManager.filterDegraded': 'Ограниченные',
'pluginManager.filterPermissions': 'Разрешения',
'pluginManager.filterCapabilities': 'Возможности',
'pluginManager.filterSettings': 'Настройки',
'pluginManager.filterWithSettings': 'С настройками',
'pluginManager.filterWithoutSettings': 'Без настроек',
'pluginManager.filterSource': 'Источник',
'pluginManager.filterOfficial': 'Официальные',
'pluginManager.filterThirdParty': 'Сторонние или локальные',
'pluginManager.filterEmpty': 'Нет плагинов, соответствующих выбранным фильтрам.',
'pluginManager.none': 'Плагины не найдены',
'pluginManager.scannedDirs': 'Проверенные каталоги плагинов:',
'pluginManager.userPlugins': 'пользовательские плагины',
@@ -62,7 +81,7 @@ export default {
'pluginManager.missingTitle': 'Отсутствующие установленные плагины',
'pluginManager.missingHint': 'Эти плагины требуются хранилищу, но их пакеты не установлены на этом устройстве.',
'pluginManager.missingPackage': 'Плагин указан среди желаемых плагинов хранилища, но его пакет не установлен.',
'pluginManager.capabilityRegistry': 'Реестр capabilities ({count})',
'pluginManager.capabilityRegistry': 'Реестр возможностей ({count})',
'pluginManager.settingsError': 'Ошибка настроек',
'pluginManager.pluginSettings': 'Настройки плагина',
'pluginManager.settingsBundleUnavailable': 'Frontend bundle панели настроек недоступен',
@@ -74,8 +93,8 @@ export default {
'pluginCard.count.sidebar': 'элементов боковой панели: {count}',
'pluginCard.count.statusbar': 'элементов строки состояния: {count}',
'pluginCard.count.openProviders': 'обработчиков открытия: {count}',
'pluginCard.count.workspace': 'инструментов рабочего пространства: {count}',
'pluginCard.degraded': 'Плагин работает, но некоторые необязательные capabilities недоступны.',
'pluginCard.count.workspace': 'инструментов Дела: {count}',
'pluginCard.degraded': 'Плагин работает, но некоторые необязательные возможности недоступны.',
'pluginCard.name': 'Название',
'pluginCard.contributions': 'Элементы интерфейса',
'pluginCard.technicalDetails': 'Технические сведения',
@@ -83,9 +102,9 @@ export default {
'pluginCard.root': 'Корневой каталог',
'pluginCard.provides': 'Предоставляет',
'pluginCard.requires': 'Требует',
'pluginCard.missingRequired': 'Отсутствуют обязательные capabilities',
'pluginCard.missingRequired': 'Отсутствуют обязательные возможности',
'pluginCard.optionalRequires': 'Необязательные зависимости',
'pluginCard.optionalUnavailable': 'Необязательные capabilities недоступны — плагин работает в ограниченном режиме',
'pluginCard.optionalUnavailable': 'Необязательные возможности недоступны — плагин работает в ограниченном режиме',
'pluginCard.permissions': 'Разрешения',
'pluginCard.enabling': '⟳ Включение...',
'pluginCard.enable': '▶ Включить',
@@ -96,12 +115,12 @@ export default {
'common.loading': 'Загрузка...',
'common.browse': 'Обзор...',
'vaultSelection.chooseNew': 'Выберите или введите папку для нового хранилища.',
'vaultSelection.createError': 'Не удалось создать хранилище: {error}',
'vaultSelection.openError': 'Не удалось открыть хранилище: {error}',
'vaultSelection.createError': 'Не удалось создать хранилище. Повторите попытку.',
'vaultSelection.openError': 'Не удалось открыть хранилище. Повторите попытку.',
'vaultSelection.chooseExisting': 'Выберите или введите существующее хранилище.',
'vaultSelection.subtitle': 'Выберите хранилище, чтобы начать работу',
'vaultSelection.createTitle': 'Создать новое хранилище',
'vaultSelection.createHint': 'Создайте локальную папку хранилища для рабочих пространств и проектов.',
'vaultSelection.createHint': 'Создайте локальную папку хранилища для Дел и проектов.',
'vaultSelection.pathPlaceholder': 'Выберите или введите путь...',
'vaultSelection.creating': 'Создание...',
'vaultSelection.create': 'Создать хранилище',
@@ -112,21 +131,45 @@ export default {
'vaultSelection.recent': 'Недавние хранилища',
'common.cancel': 'Отмена',
'common.close': 'Закрыть',
'workspaceTree.title': 'Рабочие пространства',
'workspaceTree.new': 'Новое рабочее пространство',
'workspaceTree.title': 'Дела',
'workspaceTree.new': 'Новое Дело',
'workspaceTree.nameRequired': 'Введите название',
'workspaceTree.chooseTemplate': 'Выберите шаблон рабочего пространства',
'workspaceTree.chooseTemplate': 'Выберите шаблон Дела',
'workspaceTree.saveRename': 'Сохранить новое название',
'workspaceTree.rename': 'Переименовать рабочее пространство',
'workspaceTree.trash': 'Переместить рабочее пространство в корзину',
'workspaceTree.create': 'Создать рабочее пространство',
'workspaceTree.namePlaceholder': 'Название рабочего пространства',
'workspaceTree.rename': 'Переименовать Дело',
'workspaceTree.trash': 'Переместить Дело в корзину',
'workspaceTree.create': 'Создать Дело',
'workspaceTree.namePlaceholder': 'Название Дела',
'workspaceTree.template': 'Шаблон',
'workspaceTree.creating': 'Создание...',
'workspaceTree.templateAvailable': 'Доступен',
'workspaceTree.templateLimited': 'Доступен с ограничениями',
'workspaceTree.templateMissingPlugin': 'Плагин не установлен',
'workspaceTree.templatePluginDisabled': 'Плагин выключен',
'workspaceTree.templateCapabilityUnavailable': 'Не хватает обязательной возможности',
'workspaceTree.templateIncompatible': 'Плагин несовместим с этой версией Верстака',
'workspaceTree.templateLoadFailed': 'Плагин не удалось загрузить',
'workspaceTree.templateNotReady': 'Плагин ещё не готов к работе',
'workspaceTree.templateToolTabs': 'Создаёт вкладки: {tabs}',
'workspaceTree.templateToolNoTabs': 'Не предоставляет вкладку Дела',
'workspaceTree.templateWillBeIncomplete': 'Дело будет создано без недоступных компонентов.',
'workspaceTree.templateIncompleteCreated': 'Дело «{name}» создано с недоступными компонентами.',
'workspaceTree.templatesError': 'Не удалось загрузить шаблоны Дел. Повторите попытку.',
'workspaceTree.loadError': 'Не удалось загрузить Дела. Повторите попытку.',
'workspaceTree.selectError': 'Не удалось выбрать это Дело. Повторите попытку.',
'workspaceTree.createError': 'Не удалось создать Дело. Повторите попытку.',
'workspaceTree.renameError': 'Не удалось переименовать Дело. Повторите попытку.',
'workspaceTree.trashError': 'Не удалось переместить Дело в корзину. Повторите попытку.',
'pluginManager.loadError': 'Не удалось загрузить плагины. Повторите попытку.',
'pluginManager.reloadError': 'Не удалось перезагрузить плагины. Повторите попытку.',
'pluginManager.enableError': 'Не удалось включить плагин. Повторите попытку.',
'pluginManager.disableError': 'Не удалось выключить плагин. Повторите попытку.',
'pluginManager.settingsLoadError': 'Не удалось загрузить настройки плагина. Повторите попытку.',
'pluginManager.settingsUnavailable': 'Настройки плагина недоступны.',
'command.openOverview': 'Открыть обзор',
'command.openFiles': 'Открыть файлы',
'command.openActivity': 'Открыть активность',
'command.openBrowserInbox': 'Открыть входящие из браузера',
'command.openBrowserInbox': 'Открыть Браузер',
'command.createMarkdown': 'Создать файл Markdown',
'command.createText': 'Создать текстовый файл',
'command.syncNow': 'Синхронизировать сейчас',
@@ -134,6 +177,7 @@ export default {
'command.openPluginManager': 'Открыть менеджер плагинов',
'command.handled': 'Выполнено: {title}',
'command.result': '{title}: {status}',
'command.failed': 'Не удалось выполнить команду «{title}». Повторите попытку.',
'command.statusHandled': 'выполнено',
'command.palette': 'Палитра команд',
'command.run': 'Выполнить команду',
@@ -142,26 +186,26 @@ export default {
'search.placeholder': 'Поиск',
'search.global': 'Глобальный поиск',
'search.noResults': 'Ничего не найдено',
'search.type.workspace': 'Рабочее пространство',
'search.type.workspace': 'Дело',
'search.type.tool': 'Инструмент',
'search.type.folder': 'Папка',
'search.type.file': 'Файл',
'search.type.journal': 'Журнал',
'search.type.browserInbox': 'Входящие из браузера',
'search.type.browserInbox': 'Браузер',
'search.type.activity': 'Активность',
'workbench.noProvider': 'Нет обработчика',
'workbench.noViewer': 'Нет доступного средства просмотра или редактора',
'workbench.noResource': 'Ресурс не открыт',
'bundle.noFrontend': 'У плагина нет frontend bundle',
'bundle.notFound': 'Плагин не найден',
'bundle.loadFailed': 'Не удалось загрузить bundle: {error}',
'bundle.loadFailed': 'Не удалось загрузить интерфейс плагина. Повторите попытку.',
'bundle.emptyContent': 'пустое содержимое',
'bundle.executionError': 'Ошибка выполнения bundle: {error}',
'bundle.executionError': 'Не удалось запустить интерфейс плагина. Повторите попытку.',
'bundle.registrationMissing': 'Bundle загружен, но вызов VerstakPluginRegister не обнаружен',
'bundle.componentMissing': 'Компонент «{component}» не найден в bundle. Доступны: {available}',
'bundle.mountError': 'Ошибка подключения компонента: {error}',
'bundle.componentMissing': 'Запрошенный интерфейс плагина недоступен.',
'bundle.mountError': 'Не удалось открыть интерфейс плагина. Повторите попытку.',
'bundle.mountUnavailable': 'Контейнер для подключения недоступен',
'bundle.unexpectedError': 'Непредвиденная ошибка: {error}',
'bundle.unexpectedError': 'Не удалось открыть интерфейс плагина. Повторите попытку.',
'bundle.unknownError': 'Неизвестная ошибка',
'bundle.frontendEntry': 'Точка входа frontend',
'bundle.availableComponents': 'Доступные компоненты',
@@ -197,18 +241,57 @@ export default {
'overview.count.journal.many': 'Записей журнала: {count}',
'overview.count.pending.one': 'Ожидают: {count}',
'overview.count.pending.many': 'Ожидают: {count}',
'overview.loadingContext': 'Загрузка данных рабочего пространства...',
'overview.loadingContext': 'Загрузка данных Дела...',
'overview.lastActive': 'Последняя активность: {time}',
'overview.noRecentActivity': 'В рабочем пространстве пока нет недавней активности',
'overview.noRecentActivity': 'В Деле пока нет недавней активности',
'overview.refresh': 'Обновить',
'overview.summary': 'Сводка рабочего пространства',
'overview.summary': 'Сводка Дела',
'overview.continue': 'Продолжить работу',
'overview.continueHint': 'Вернитесь к следующему полезному делу в этом рабочем пространстве.',
'overview.loadingSignals': 'Загрузка рабочих данных...',
'overview.continueHint': 'Вернитесь к следующему полезному делу в этом Деле.',
'overview.loadingSignals': 'Загрузка данных Дела...',
'overview.noResume': 'Пока неясно, с чего продолжить',
'overview.noResumeHint': 'Здесь появятся недавние заметки, файлы, сохранённое и записи журнала.',
'overview.recentChanges': 'Недавние изменения',
'overview.recentChangesHint': 'Последняя важная активность в этом рабочем пространстве.',
'overview.recentChangesHint': 'Последняя важная активность в этом Деле.',
'overview.event.workspaceOpened': 'Открыто Дело',
'overview.event.activity': 'Активность Дела',
'overview.event.noteEdited': 'Изменена заметка «{name}»',
'overview.event.noteOpened': 'Открыта заметка «{name}»',
'overview.event.noteCreated': 'Создана заметка «{name}»',
'overview.event.fileOpened': 'Открыт файл «{name}»',
'overview.event.fileChanged': 'Изменён файл «{name}»',
'overview.event.fileCreated': 'Создан файл «{name}»',
'overview.event.fileRemoved': 'Удалён файл «{name}»',
'overview.event.capturePage': 'Сохранена страница «{name}»',
'overview.event.captureSelection': 'Сохранено выделение «{name}»',
'overview.event.captureLink': 'Сохранена ссылка «{name}»',
'overview.event.captureFile': 'Сохранён файл «{name}»',
'overview.event.captureConverted': 'Сохранённое преобразовано: «{name}»',
'overview.event.capture': 'Сохранено: {kind} «{title}»',
'overview.event.journalAdded': 'Добавлена запись в журнал «{name}»',
'overview.event.workSessionDetected': 'Обнаружена рабочая сессия',
'overview.event.todo': 'Задача «{title}»',
'overview.event.reviewCapture': 'Разобрать сохранённое «{title}»',
'overview.event.continueJournal': 'Продолжить запись журнала «{title}»',
'overview.event.possibleJournalEntry': 'Возможная запись в журнал',
'overview.captureKind.page': 'страница',
'overview.captureKind.selection': 'выделение',
'overview.captureKind.link': 'ссылка',
'overview.captureKind.file': 'файл',
'overview.captureKind.item': 'элемент',
'overview.todo.reminderDue': 'Пора напомнить',
'overview.todo.overdue': 'Просрочено',
'overview.todo.dueSoon': 'Скоро срок',
'overview.todo.due': 'Срок: {date}',
'overview.candidateMeta': 'Дело: {deal} · {minutes} мин. · активностей: {activities}',
'overview.minutes': '{count} мин.',
'overview.browserCapture': 'Сохранённое из браузера',
'overview.untitledTodo': 'Задача без названия',
'overview.untitledCapture': 'Сохранённое без названия',
'overview.untitledJournal': 'Запись журнала',
'overview.unknownDeal': 'Неизвестное Дело',
'overview.item': 'элемент',
'overview.overviewNote': 'Обзорная заметка Дела',
'overview.recentFilter': 'Фильтр недавних изменений',
'overview.loadingRecent': 'Загрузка недавних изменений...',
'overview.noChanges': 'Для этого фильтра пока нет важных изменений.',
@@ -83,6 +83,11 @@
return { value: result, error: '' };
}
function reportError(key, fallback, details) {
console.warn('[PluginBundleHost] ' + key + ':', details);
return tr(key, undefined, fallback);
}
async function loadAndMount(pId, compId, nextPropsKey) {
// If same plugin+component and already mounted, skip
if (currentPluginId === pId && currentComponent === compId && currentPropsKey === nextPropsKey && loadState === 'loaded') {
@@ -125,7 +130,7 @@
const content = assetResult.value;
if (assetResult.error || !content) {
loadState = 'error';
errorText = tr('bundle.loadFailed', { error: assetResult.error || tr('bundle.emptyContent') });
errorText = reportError('bundle.loadFailed', 'Could not load the plugin interface. Please try again.', assetResult.error || tr('bundle.emptyContent'));
return;
}
@@ -136,8 +141,7 @@
fn();
} catch (e) {
loadState = 'error';
errorText = tr('bundle.executionError', { error: e.message });
console.error('[PluginBundleHost] bundle exec error:', e);
errorText = reportError('bundle.executionError', 'Could not start the plugin interface. Please try again.', e);
return;
}
@@ -154,10 +158,7 @@
const comp = components[compId];
if (!comp || !comp.mount) {
loadState = 'error';
errorText = tr('bundle.componentMissing', {
component: compId,
available: Object.keys(components).join(', ') || tr('common.none'),
});
errorText = tr('bundle.componentMissing', undefined, 'The requested plugin interface is unavailable.');
return;
}
@@ -177,8 +178,7 @@
errorText = '';
} catch (e) {
loadState = 'error';
errorText = tr('bundle.mountError', { error: e.message });
console.error('[PluginBundleHost] mount error:', e);
errorText = reportError('bundle.mountError', 'Could not open the plugin interface. Please try again.', e);
}
} else {
loadState = 'error';
@@ -186,8 +186,7 @@
}
} catch (e) {
loadState = 'error';
errorText = tr('bundle.unexpectedError', { error: e.message || e });
console.error('[PluginBundleHost] error:', e);
errorText = reportError('bundle.unexpectedError', 'Could not open the plugin interface. Please try again.', e);
}
}
@@ -208,17 +207,18 @@
<div class="host-state error">
<Icon name="warning" size={24} class="error-icon" />
<p class="error-title">{tr('pluginView.error')}</p>
<div class="error-details">
<p class="error-message">{errorText || tr('bundle.unknownError')}</p>
<details class="error-details">
<summary>{tr('common.details')}</summary>
<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">{tr('bundle.frontendEntry')}: {pluginInfo.entry || tr('common.none')}</p>
{/if}
{#if getComponentList().length > 0}
<p class="error-meta">{tr('bundle.availableComponents')}: {getComponentList().join(', ')}</p>
{/if}
</div>
</details>
</div>
{:else}
@@ -303,11 +303,21 @@
max-width: 400px;
text-align: left;
background: #16213e;
padding: 1rem;
padding: 0.5rem 0.75rem;
border-radius: 6px;
border: 1px solid #0f3460;
}
.error-details[open] {
padding: 0.75rem 1rem;
}
.error-details summary {
cursor: pointer;
color: #e0e0f0;
font-weight: 600;
}
.error-details p {
margin: 0.3rem 0;
}
@@ -318,12 +328,8 @@
.error-message {
color: #e94560;
font-family: monospace;
font-size: 0.8rem;
margin-top: 0.5rem !important;
padding: 0.5rem;
background: rgba(233, 69, 96, 0.1);
border-radius: 4px;
max-width: 400px;
margin: 0 0 0.75rem;
}
.error-meta {
@@ -33,6 +33,11 @@
let reloading = false;
let toastMessage = '';
let toastType = 'success'; // 'success' | 'error' | 'info'
let statusFilter = 'all';
let permissionFilters = [];
let capabilityFilters = [];
let settingsFilter = 'all';
let sourceFilter = 'all';
export let activeSettingsPluginId = '';
export let activeSettingsPanelId = '';
@@ -53,6 +58,12 @@
}, 4000);
}
function reportError(key, fallback, details) {
debug.log('[PluginManager] ' + key + ':', String(details));
WriteFrontendLog('PluginManager', key + ': ' + String(details)).catch(() => {});
return tr(key, undefined, fallback);
}
function notifyPluginsChanged() {
window.dispatchEvent(new CustomEvent('verstak:plugins-changed'));
}
@@ -89,14 +100,14 @@
ReadPluginSettings(pluginId).then(result => {
const unpacked = unpackBackendResult(result);
if (unpacked.error) {
settingsError = unpacked.error;
settingsError = reportError('pluginManager.settingsLoadError', 'Could not load plugin settings. Please try again.', unpacked.error);
settingsData = {};
return;
}
settingsData = unpacked.value || {};
}).catch(() => { settingsData = {}; });
} else {
settingsError = `Settings panel not found for plugin "${pluginId}". Check that the plugin is enabled and has settingsPanels in its manifest.`;
settingsError = tr('pluginManager.settingsUnavailable', undefined, 'Plugin settings are unavailable.');
}
}
@@ -127,7 +138,7 @@
} catch (e) {
debug.log('[PluginManager] loadAll: GetPlugins ERROR:', String(e));
WriteFrontendLog('PluginManager', 'loadAll: GetPlugins ERROR: ' + String(e));
error = 'GetPlugins: ' + String(e);
error = reportError('pluginManager.loadError', 'Could not load plugins. Please try again.', e);
loading = false;
return;
}
@@ -191,7 +202,7 @@
resultMsg = `Reloaded ${count} plugin(s). ${summary}`;
} catch (e) {
debug.log('[PluginManager] reload: ReloadPlugins ERROR:', String(e));
error = 'Reload: ' + String(e);
error = reportError('pluginManager.reloadError', 'Could not reload plugins. Please try again.', e);
reloading = false;
return;
}
@@ -211,7 +222,7 @@
if (err) {
debug.log('[PluginManager] enablePlugin: ERROR:', err);
actionFeedback = { ...actionFeedback, [pluginId]: null };
error = 'Enable: ' + err;
error = reportError('pluginManager.enableError', 'Could not enable the plugin. Please try again.', err);
return;
}
debug.log('[PluginManager] enablePlugin: success, reloading...');
@@ -232,7 +243,7 @@
if (err) {
debug.log('[PluginManager] disablePlugin: ERROR:', err);
actionFeedback = { ...actionFeedback, [pluginId]: null };
error = 'Disable: ' + err;
error = reportError('pluginManager.disableError', 'Could not disable the plugin. Please try again.', err);
return;
}
debug.log('[PluginManager] disablePlugin: success, reloading...');
@@ -250,6 +261,11 @@
$: totalPerms = permissions.length;
$: statusSummary = computeStatusSummary(plugins);
$: elevatedPermissionPluginCount = computeElevatedPermissionPluginCount(plugins, permissions);
$: filterPermissions = collectManifestValues(plugins, 'permissions');
$: filterCapabilities = collectCapabilities(plugins);
$: hasReliableSourceMetadata = plugins.some((plugin) => sourceGroup(plugin) !== '');
$: visiblePlugins = filterPlugins(plugins, statusFilter, permissionFilters, capabilityFilters, settingsFilter, sourceFilter, contributions);
$: filtersActive = statusFilter !== 'all' || permissionFilters.length > 0 || capabilityFilters.length > 0 || settingsFilter !== 'all' || sourceFilter !== 'all';
function computeStatusSummary(pluginRows) {
return pluginRows.reduce((summary, plugin) => {
@@ -268,6 +284,69 @@
return (pluginRows || []).filter(plugin => (plugin?.manifest?.permissions || []).some(permission => dangerous.has(permission))).length;
}
function collectManifestValues(pluginRows, key) {
return Array.from(new Set((pluginRows || []).flatMap((plugin) => plugin?.manifest?.[key] || [])))
.sort((left, right) => left.localeCompare(right));
}
function collectCapabilities(pluginRows) {
return Array.from(new Set((pluginRows || []).flatMap((plugin) => [
...(plugin?.manifest?.provides || []),
...(plugin?.manifest?.requires || []),
...(plugin?.manifest?.optionalRequires || []),
]))).sort((left, right) => left.localeCompare(right));
}
function pluginStatusGroup(plugin) {
const status = plugin?.status || '';
if (status === 'failed' || status === 'incompatible' || status === 'missing-required-capability') return 'failed';
if (status === 'degraded') return 'degraded';
if (status === 'disabled' || plugin?.enabled === false) return 'disabled';
return 'enabled';
}
function sourceGroup(plugin) {
const source = plugin?.manifest?.source;
if (source === 'official') return 'official';
if (source === 'local' || source === 'third-party') return 'third-party';
return '';
}
function hasSettings(plugin, activeContributions) {
const pluginId = plugin?.manifest?.id;
return Boolean(pluginId && (activeContributions.settingsPanels || []).some((panel) => panel.pluginId === pluginId));
}
function includesAny(values, expected) {
return expected.length === 0 || expected.some((value) => values.includes(value));
}
function filterPlugins(pluginRows, activeStatusFilter, activePermissionFilters, activeCapabilityFilters, activeSettingsFilter, activeSourceFilter, activeContributions) {
return pluginRows.filter((plugin) => matchesFilters(plugin, activeStatusFilter, activePermissionFilters, activeCapabilityFilters, activeSettingsFilter, activeSourceFilter, activeContributions));
}
function matchesFilters(plugin, activeStatusFilter, activePermissionFilters, activeCapabilityFilters, activeSettingsFilter, activeSourceFilter, activeContributions) {
if (activeStatusFilter !== 'all' && pluginStatusGroup(plugin) !== activeStatusFilter) return false;
if (!includesAny(plugin?.manifest?.permissions || [], activePermissionFilters)) return false;
const declaredCapabilities = [
...(plugin?.manifest?.provides || []),
...(plugin?.manifest?.requires || []),
...(plugin?.manifest?.optionalRequires || []),
];
if (!includesAny(declaredCapabilities, activeCapabilityFilters)) return false;
if (activeSettingsFilter === 'with' && !hasSettings(plugin, activeContributions)) return false;
if (activeSettingsFilter === 'without' && hasSettings(plugin, activeContributions)) return false;
return activeSourceFilter === 'all' || sourceGroup(plugin) === activeSourceFilter;
}
function resetFilters() {
statusFilter = 'all';
permissionFilters = [];
capabilityFilters = [];
settingsFilter = 'all';
sourceFilter = 'all';
}
function closeSettings() {
settingsPanel = null;
settingsPluginId = '';
@@ -339,6 +418,70 @@
</section>
</div>
<section class="plugin-filters" aria-label={tr('pluginManager.filters')}>
<div class="filter-header">
<div>
<h3>{tr('pluginManager.filters')}</h3>
<p data-plugin-filter-results>{tr('pluginManager.filterResults', { visible: visiblePlugins.length, total: totalPlugins })}</p>
</div>
<button class="filter-reset" data-plugin-filter-reset type="button" on:click={resetFilters} disabled={!filtersActive}>{tr('pluginManager.filterReset')}</button>
</div>
<div class="filter-grid">
<label class="filter-select-label">
<span>{tr('pluginManager.filterState')}</span>
<select class="filter-select" data-plugin-filter="status" bind:value={statusFilter}>
<option value="all">{tr('pluginManager.filterAll')}</option>
<option value="enabled">{tr('pluginManager.filterEnabled')}</option>
<option value="disabled">{tr('pluginManager.filterDisabled')}</option>
<option value="failed">{tr('pluginManager.filterFailed')}</option>
<option value="degraded">{tr('pluginManager.filterDegraded')}</option>
</select>
</label>
<label class="filter-select-label">
<span>{tr('pluginManager.filterSettings')}</span>
<select class="filter-select" data-plugin-filter="settings" bind:value={settingsFilter}>
<option value="all">{tr('pluginManager.filterAll')}</option>
<option value="with">{tr('pluginManager.filterWithSettings')}</option>
<option value="without">{tr('pluginManager.filterWithoutSettings')}</option>
</select>
</label>
{#if hasReliableSourceMetadata}
<label class="filter-select-label">
<span>{tr('pluginManager.filterSource')}</span>
<select class="filter-select" data-plugin-filter="source" bind:value={sourceFilter}>
<option value="all">{tr('pluginManager.filterAll')}</option>
<option value="official">{tr('pluginManager.filterOfficial')}</option>
<option value="third-party">{tr('pluginManager.filterThirdParty')}</option>
</select>
</label>
{/if}
</div>
{#if filterPermissions.length > 0}
<fieldset class="filter-options">
<legend>{tr('pluginManager.filterPermissions')}</legend>
<div class="filter-option-list">
{#each filterPermissions as permission}
<label><input data-plugin-filter-permission={permission} type="checkbox" bind:group={permissionFilters} value={permission} /> <code>{permission}</code></label>
{/each}
</div>
</fieldset>
{/if}
{#if filterCapabilities.length > 0}
<fieldset class="filter-options">
<legend>{tr('pluginManager.filterCapabilities')}</legend>
<div class="filter-option-list">
{#each filterCapabilities as capability}
<label><input data-plugin-filter-capability={capability} type="checkbox" bind:group={capabilityFilters} value={capability} /> <code>{capability}</code></label>
{/each}
</div>
</fieldset>
{/if}
</section>
{#if plugins.length === 0 && missingInstalled.length === 0}
<div class="empty">
<div class="empty-icon">
@@ -354,9 +497,13 @@
</ul>
<p class="hint">{tr('pluginManager.installHint')}</p>
</div>
{:else if visiblePlugins.length === 0}
<div class="empty filter-empty" data-plugin-filter-empty>
<p>{tr('pluginManager.filterEmpty')}</p>
</div>
{:else}
<div class="plugin-list">
{#each plugins as p}
{#each visiblePlugins as p}
<PluginCard {p} {capabilities} {permissions} {contributions} {vaultOpen} {actionFeedback} settingsPanels={(contributions.settingsPanels || []).filter(sp => sp.pluginId === p.manifest?.id)} onEnable={enablePlugin} onDisable={disablePlugin} />
{/each}
</div>
@@ -571,6 +718,105 @@
color: #f4f7fb;
font-size: 0.88rem;
}
.plugin-filters {
margin-bottom: 1rem;
padding: 0.75rem;
border: 1px solid #0f3460;
border-radius: 8px;
background: #121a2c;
}
.filter-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.filter-header h3 {
margin: 0;
color: #e0e0f0;
font-size: 0.9rem;
}
.filter-header p {
margin: 0.2rem 0 0;
color: #a0a0b8;
font-size: 0.78rem;
}
.filter-reset {
flex: 0 0 auto;
padding: 0.35rem 0.65rem;
border: 1px solid #533483;
border-radius: 5px;
background: #16213e;
color: #e0e0e0;
cursor: pointer;
font-size: 0.78rem;
}
.filter-reset:hover:not(:disabled) { background: #0f3460; }
.filter-reset:disabled { cursor: not-allowed; opacity: 0.5; }
.filter-grid {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
}
.filter-select-label {
display: grid;
gap: 0.28rem;
min-width: 10rem;
color: #a0a0b8;
font-size: 0.76rem;
font-weight: 600;
}
.filter-select {
min-height: 2rem;
padding: 0.3rem 1.9rem 0.3rem 0.55rem;
border: 1px solid #0f3460;
border-radius: 5px;
background: #16213e;
background-image: linear-gradient(45deg, transparent 50%, #a0a0b8 50%), linear-gradient(135deg, #a0a0b8 50%, transparent 50%);
background-position: calc(100% - 14px) 50%, calc(100% - 9px) 50%;
background-size: 5px 5px, 5px 5px;
background-repeat: no-repeat;
color: #e0e0e0;
font: inherit;
appearance: none;
}
.filter-select option { background: #16213e; color: #e0e0e0; }
.filter-select:focus { outline: 2px solid #533483; outline-offset: 1px; }
.filter-options {
min-width: 0;
margin: 0.75rem 0 0;
padding: 0.55rem 0.65rem 0.65rem;
border: 1px solid rgba(15, 52, 96, 0.85);
border-radius: 6px;
}
.filter-options legend {
padding: 0 0.25rem;
color: #a0a0b8;
font-size: 0.76rem;
font-weight: 600;
}
.filter-option-list {
display: flex;
flex-wrap: wrap;
gap: 0.4rem 0.75rem;
}
.filter-option-list label {
display: inline-flex;
align-items: center;
gap: 0.28rem;
min-width: 0;
color: #c6c6d8;
font-size: 0.75rem;
}
.filter-option-list input { accent-color: #6c4fa3; }
.filter-option-list code {
max-width: min(31rem, 72vw);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.filter-empty { margin-bottom: 1.5rem; }
.empty {
padding: 2rem; text-align: center; color: #a0a0b8;
background: #16213e; border-radius: 8px; border: 1px dashed #0f3460;
@@ -579,7 +825,6 @@
.hint { font-size: 0.85rem; margin-top: 0.5rem; opacity: 0.7; }
.hint-list { list-style: none; padding: 0; margin: 0.5rem 0; font-size: 0.8rem; opacity: 0.7; }
.hint-list li { margin: 0.25rem 0; }
.hint code { background: #0f3460; padding: 0.1rem 0.3rem; border-radius: 3px; }
.plugin-list { display: flex; flex-direction: column; gap: 0.75rem; margin-bottom: 1.5rem; min-width: 0; }
.missing-section { margin-bottom: 1.5rem; }
@@ -646,6 +891,19 @@
grid-template-columns: 1fr;
}
.filter-header {
align-items: stretch;
flex-direction: column;
}
.filter-reset {
width: 100%;
}
.filter-select-label {
flex: 1 1 10rem;
}
.modal {
width: min(880px, calc(100vw - 2rem));
height: min(680px, calc(100vh - 2rem));
+7 -1
View File
@@ -3,6 +3,7 @@
import * as App from '../../../wailsjs/go/api/App';
import { executePluginCommand } from '../plugin-host/VerstakPluginAPI.js';
import { i18n } from '../i18n/index.js';
import { debug } from '../log/debug.js';
let open = false;
let query = '';
@@ -194,7 +195,12 @@
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)}`);
const details = err?.message || String(err);
debug.log('[CommandPalette] command failed:', details);
App.WriteFrontendLog('CommandPalette', `command failed: ${details}`).catch(() => {});
setStatus('error', tr('command.failed', {
title: command.title || command.id,
}, `Could not run ${command.title || command.id}. Please try again.`));
}
}
+15
View File
@@ -8,6 +8,8 @@
import { i18n } from '../i18n/index.js';
export let showGlobalSearch = true;
export let activeView = null;
export let activeViewPluginId = '';
function flog(msg) {
App.WriteFrontendLog('Sidebar', msg);
@@ -26,6 +28,7 @@
})(locale);
$: vaultOpen = vaultStatus.status === 'open';
$: activeSidebarKey = activeView ? `${activeViewPluginId}:${activeView}` : '';
async function loadSidebar() {
debug.log('[Sidebar] onMount: START');
@@ -107,6 +110,8 @@
{#each sidebarItems as item}
<button
class="nav-item plugin-item vt-list-row"
class:is-active={activeSidebarKey === `${item.pluginId || ''}:${item.view || item.id}`}
aria-current={activeSidebarKey === `${item.pluginId || ''}:${item.view || item.id}` ? 'page' : undefined}
on:click={() => handleSidebarItem(item)}
type="button"
>
@@ -210,6 +215,16 @@
color: var(--vt-color-text-primary);
}
.nav-item.is-active {
background: var(--vt-color-surface-selected);
color: var(--vt-color-accent);
}
.nav-item.is-active :global(.nav-icon.icon-plugin) {
color: currentColor;
opacity: 1;
}
:global(.nav-icon) {
width: 0.9rem;
height: 0.9rem;
+131 -85
View File
@@ -30,6 +30,7 @@
let keyResources = [];
let totalNotes = 0;
let loadedWorkspaceRoot = '';
let loadedToolKey = '';
let toolProbe = 0;
let locale = i18n.getLocale();
let unsubscribeLocale = null;
@@ -38,17 +39,25 @@
void activeLocale;
return i18n.t(key, params, fallback);
})(locale);
$: FILTERS = ['all', 'notes', 'files', 'captures', 'journal'].map((key) => ({
key,
label: tr(`overview.filter.${key}`),
}));
function countText(key, count, params = {}) {
return tr(`${key}.${count === 1 ? 'one' : 'many'}`, { count, ...params });
}
$: hasNotes = hasTool('notes', availableTools);
$: hasFiles = hasTool('files', availableTools);
$: hasBrowserInbox = hasTool('browser-inbox', availableTools);
$: hasActivity = hasTool('activity', availableTools);
$: hasJournal = hasTool('journal', availableTools);
$: hasTodos = hasTool('todo', availableTools);
$: recentChanges = buildRecentChanges(activityEvents, captures, journalEntries);
$: overviewToolKey = [hasNotes, hasFiles, hasBrowserInbox, hasActivity, hasJournal, hasTodos].join('|');
$: FILTERS = ['all']
.concat(hasNotes ? ['notes'] : [])
.concat(hasFiles ? ['files'] : [])
.concat(hasBrowserInbox ? ['captures'] : [])
.concat(hasJournal ? ['journal'] : [])
.map((key) => ({ key, label: tr(`overview.filter.${key}`) }));
$: if (!FILTERS.some(filter => filter.key === activeFilter)) activeFilter = 'all';
$: recentChanges = filterAvailableItems(buildRecentChanges(activityEvents, captures, journalEntries), overviewToolKey);
$: filteredRecentChanges = activeFilter === 'all'
? recentChanges
: recentChanges.filter(item => item.category === activeFilter);
@@ -58,18 +67,19 @@
$: linkedCandidateIds = new Set(journalEntries.map(item => String(item?.sourceCandidateId || '')).filter(Boolean));
$: pendingWorkSessionCandidates = workSessionCandidates.filter(item => !linkedCandidateIds.has(String(item?.candidateId || '')));
$: urgentTodos = hasTodos ? todos.filter(item => todoAttentionState(item)) : [];
$: needsAttention = buildNeedsAttention(unprocessedCaptures, pendingWorkSessionCandidates, urgentTodos);
$: continueItems = buildContinueItems(activityEvents, unprocessedCaptures, journalEntries, urgentTodos);
$: attentionActionKind = needsAttention[0]?.actionKind || 'browser-inbox';
$: needsAttention = filterAvailableItems(buildNeedsAttention(unprocessedCaptures, pendingWorkSessionCandidates, urgentTodos), overviewToolKey);
$: continueItems = filterAvailableItems(buildContinueItems(activityEvents, unprocessedCaptures, journalEntries, urgentTodos), overviewToolKey);
$: hasAttentionTools = hasBrowserInbox || hasTodos || (hasActivity && hasJournal);
$: attentionActionKind = needsAttention[0]?.actionKind || fallbackAttentionAction();
$: lastActive = lastActiveDate([...recentChanges, ...continueItems], captures, journalEntries, todos);
$: summaryItems = [
{ key: 'notes', label: tr('overview.notes'), count: totalNotes, detail: countText('overview.count.totalRecent', noteRecentChanges, { total: totalNotes, recent: noteRecentChanges }), actionKind: 'notes', actionLabel: tr('overview.openNotes') },
{ key: 'files', label: tr('overview.files'), count: fileRecentChanges, detail: countText('overview.count.recentChanges', fileRecentChanges), actionKind: 'files', actionLabel: tr('overview.openFiles') },
{ key: 'captures', label: tr('overview.captures'), count: unprocessedCaptures.length, detail: countText('overview.count.captures', unprocessedCaptures.length), actionKind: 'browser-inbox', actionLabel: tr('overview.reviewInbox') },
{ key: 'activity', label: tr('overview.activity'), count: activityEvents.length, detail: countText('overview.count.events', activityEvents.length), actionKind: 'activity', actionLabel: tr('overview.viewActivity') },
{ key: 'journal', label: tr('overview.journal'), count: journalEntries.length, detail: countText('overview.count.journal', journalEntries.length), actionKind: 'journal', actionLabel: tr('overview.openJournal') },
{ key: 'attention', label: tr('overview.attention'), count: needsAttention.length, detail: countText('overview.count.pending', needsAttention.length), actionKind: attentionActionKind, actionLabel: tr('overview.reviewPending') },
];
hasNotes ? { key: 'notes', label: tr('overview.notes'), count: totalNotes, detail: countText('overview.count.totalRecent', noteRecentChanges, { total: totalNotes, recent: noteRecentChanges }), actionKind: 'notes', actionLabel: tr('overview.openNotes') } : null,
hasFiles ? { key: 'files', label: tr('overview.files'), count: fileRecentChanges, detail: countText('overview.count.recentChanges', fileRecentChanges), actionKind: 'files', actionLabel: tr('overview.openFiles') } : null,
hasBrowserInbox ? { key: 'captures', label: tr('overview.captures'), count: unprocessedCaptures.length, detail: countText('overview.count.captures', unprocessedCaptures.length), actionKind: 'browser-inbox', actionLabel: tr('overview.reviewInbox') } : null,
hasActivity ? { key: 'activity', label: tr('overview.activity'), count: activityEvents.length, detail: countText('overview.count.events', activityEvents.length), actionKind: 'activity', actionLabel: tr('overview.viewActivity') } : null,
hasJournal ? { key: 'journal', label: tr('overview.journal'), count: journalEntries.length, detail: countText('overview.count.journal', journalEntries.length), actionKind: 'journal', actionLabel: tr('overview.openJournal') } : null,
hasAttentionTools ? { key: 'attention', label: tr('overview.attention'), count: needsAttention.length, detail: countText('overview.count.pending', needsAttention.length), actionKind: attentionActionKind, actionLabel: tr('overview.reviewPending') } : null,
].filter(Boolean);
onMount(() => {
unsubscribeLocale = i18n.subscribe((nextLocale) => locale = nextLocale);
@@ -78,7 +88,7 @@
onDestroy(() => unsubscribeLocale?.());
$: if (workspaceRootPath && workspaceRootPath !== loadedWorkspaceRoot) {
$: if (workspaceRootPath && (workspaceRootPath !== loadedWorkspaceRoot || overviewToolKey !== loadedToolKey)) {
loadOverview();
}
@@ -89,11 +99,29 @@
const label = `${tool?.title || ''} ${tool?.id || ''} ${tool?.pluginId || ''}`.toLowerCase();
return label.includes(name);
});
if (fromProps) return true;
if (typeof document === 'undefined') return false;
return Array.from(document.querySelectorAll('.workspace-tabs [role="tab"]')).some(tab => {
return String(tab.textContent || '').trim().toLowerCase().includes(name);
});
return fromProps;
}
function actionIsAvailable(kind) {
if (kind === 'notes') return hasNotes;
if (kind === 'files') return hasFiles;
if (kind === 'browser-inbox') return hasBrowserInbox;
if (kind === 'activity') return hasActivity;
if (kind === 'journal') return hasJournal;
if (kind === 'todo') return hasTodos;
return false;
}
function filterAvailableItems(items, _toolKey) {
void _toolKey;
return (items || []).filter(item => actionIsAvailable(item?.actionKind));
}
function fallbackAttentionAction() {
if (hasBrowserInbox) return 'browser-inbox';
if (hasTodos) return 'todo';
if (hasActivity && hasJournal) return 'journal';
return '';
}
function decodeTuple(response, fallback) {
@@ -167,7 +195,7 @@
}
function todoTitle(item) {
return String(item?.title || item?.name || 'Untitled todo').trim() || 'Untitled todo';
return String(item?.title || item?.name || tr('overview.untitledTodo')).trim() || tr('overview.untitledTodo');
}
function todoAttentionState(item) {
@@ -175,9 +203,9 @@
const now = Date.now();
const dueAt = todoDateMs(item?.dueAt);
const reminderAt = todoDateMs(item?.reminderAt);
if (reminderAt && reminderAt <= now) return 'Reminder due';
if (dueAt && dueAt < now) return 'Overdue';
if (dueAt && dueAt <= now + 3 * 24 * 60 * 60 * 1000) return 'Due soon';
if (reminderAt && reminderAt <= now) return tr('overview.todo.reminderDue');
if (dueAt && dueAt < now) return tr('overview.todo.overdue');
if (dueAt && dueAt <= now + 3 * 24 * 60 * 60 * 1000) return tr('overview.todo.dueSoon');
return '';
}
@@ -210,17 +238,12 @@
return fileName(path).replace(/\.(md|markdown|txt)$/i, '').replace(/_/g, ' ') || 'Untitled';
}
function quoted(value) {
const clean = String(value || '').trim();
return clean ? `"${clean}"` : '';
}
function entityName(item) {
const payload = item?.payload && typeof item.payload === 'object' ? item.payload : {};
const path = payload.path || payload.notePath || item?.path || item?.relativePath || looksLikePath(item?.summary);
const title = String(item?.title || payload.title || '').trim();
if (path && (!title || /^(saved note|file opened|file changed|activity event)$/i.test(title))) return titleFromPath(path);
return title || titleFromPath(path) || String(item?.summary || item?.activityId || 'item');
return title || titleFromPath(path) || String(item?.summary || item?.activityId || tr('overview.item'));
}
function looksLikePath(value) {
@@ -230,11 +253,11 @@
}
function captureTitle(capture) {
return capture?.title || capture?.fileName || capture?.url || capture?.captureId || 'Untitled capture';
return capture?.title || capture?.fileName || capture?.url || capture?.captureId || tr('overview.untitledCapture');
}
function journalTitle(entry) {
return entry?.title || entry?.summary || entry?.date || entry?.entryId || 'Journal entry';
return entry?.title || entry?.summary || entry?.date || entry?.entryId || tr('overview.untitledJournal');
}
function itemTimeLabel(item) {
@@ -284,22 +307,31 @@
function activityTitle(item) {
const type = String(item?.type || '').toLowerCase();
const name = entityName(item);
if (type === 'note.saved' || type === 'note.edited') return `Edited note ${quoted(name)}`;
if (type === 'note.opened') return `Opened note ${quoted(name)}`;
if (type === 'note.created') return `Created note ${quoted(name)}`;
if (type === 'file.opened') return `Opened file ${quoted(name)}`;
if (type === 'file.changed') return `Changed file ${quoted(name)}`;
if (type === 'file.created') return `Created file ${quoted(name)}`;
if (type === 'file.deleted' || type === 'file.trashed') return `Removed file ${quoted(name)}`;
if (type === 'browser.capture.page') return `Captured page ${quoted(name)}`;
if (type === 'browser.capture.selection') return `Captured selection ${quoted(name)}`;
if (type === 'browser.capture.link') return `Captured link ${quoted(name)}`;
if (type === 'browser.capture.file') return `Captured file ${quoted(name)}`;
if (type === 'browser.capture.converted') return `Converted capture ${quoted(name)}`;
if (type === 'journal.entry.added' || type === 'worklog.entry.added') return `Added journal entry ${quoted(name)}`;
if (type === 'action.started') return 'Work session detected';
if (type === 'workspace.opened') return 'Workspace opened';
return item?.title || item?.summary || 'Workspace activity';
if (type === 'note.saved' || type === 'note.edited') return tr('overview.event.noteEdited', { name });
if (type === 'note.opened') return tr('overview.event.noteOpened', { name });
if (type === 'note.created') return tr('overview.event.noteCreated', { name });
if (type === 'file.opened') return tr('overview.event.fileOpened', { name });
if (type === 'file.changed') return tr('overview.event.fileChanged', { name });
if (type === 'file.created') return tr('overview.event.fileCreated', { name });
if (type === 'file.deleted' || type === 'file.trashed') return tr('overview.event.fileRemoved', { name });
if (type === 'browser.capture.page') return tr('overview.event.capturePage', { name });
if (type === 'browser.capture.selection') return tr('overview.event.captureSelection', { name });
if (type === 'browser.capture.link') return tr('overview.event.captureLink', { name });
if (type === 'browser.capture.file') return tr('overview.event.captureFile', { name });
if (type === 'browser.capture.converted') return tr('overview.event.captureConverted', { name });
if (type === 'journal.entry.added' || type === 'worklog.entry.added') return tr('overview.event.journalAdded', { name });
if (type === 'action.started') return tr('overview.event.workSessionDetected');
if (type === 'workspace.opened') return tr('overview.event.workspaceOpened');
return item?.title || item?.summary || tr('overview.event.activity');
}
function captureKindLabel(capture) {
const kind = String(capture?.kind || '').toLowerCase();
if (kind === 'page') return tr('overview.captureKind.page');
if (kind === 'selection') return tr('overview.captureKind.selection');
if (kind === 'link') return tr('overview.captureKind.link');
if (kind === 'file') return tr('overview.captureKind.file');
return tr('overview.captureKind.item');
}
function actionForCategory(category) {
@@ -320,7 +352,7 @@
id: item.activityId || `${item.type}:${timeValue(item)}`,
category,
title: activityTitle(item),
meta: `${itemTimeLabel(item)}${item.sourcePluginId ? ' · ' + item.sourcePluginId.replace('verstak.', '') : ''}`,
meta: itemTimeLabel(item),
time: timeValue(item),
absolute: absoluteTime(timeValue(item)),
actionKind: action.kind,
@@ -330,8 +362,8 @@
const captureItems = captureRows.map(item => ({
id: item.captureId || `capture:${timeValue(item)}`,
category: 'captures',
title: `Captured ${item.kind || 'item'} ${quoted(captureTitle(item))}`,
meta: `${itemTimeLabel(item)} · ${item.domain || item.url || 'Browser capture'}`,
title: tr('overview.event.capture', { kind: captureKindLabel(item), title: captureTitle(item) }),
meta: `${itemTimeLabel(item)} · ${item.domain || item.url || tr('overview.browserCapture')}`,
time: timeValue(item),
absolute: absoluteTime(timeValue(item)),
actionKind: 'browser-inbox',
@@ -340,8 +372,8 @@
const journalItems = journalRows.map(item => ({
id: item.entryId || `journal:${timeValue(item)}`,
category: 'journal',
title: `Added journal entry ${quoted(journalTitle(item))}`,
meta: `${itemTimeLabel(item)}${item.minutes ? ' · ' + item.minutes + ' min' : ''}`,
title: tr('overview.event.journalAdded', { name: journalTitle(item) }),
meta: `${itemTimeLabel(item)}${item.minutes ? ' · ' + tr('overview.minutes', { count: item.minutes }) : ''}`,
time: timeValue(item),
absolute: absoluteTime(timeValue(item)),
actionKind: 'journal',
@@ -381,8 +413,8 @@
const todoCandidates = [...todoRows].sort((a, b) => todoDateMs(todoTimeValue(a)) - todoDateMs(todoTimeValue(b))).map(item => ({
id: item.id || `todo:${todoTitle(item)}`,
category: 'todo',
title: `Todo ${quoted(todoTitle(item))}`,
meta: `${todoAttentionState(item)}${item.dueAt ? ` · Due ${item.dueAt}` : ''}`,
title: tr('overview.event.todo', { title: todoTitle(item) }),
meta: `${todoAttentionState(item)}${item.dueAt ? ` · ${tr('overview.todo.due', { date: item.dueAt })}` : ''}`,
time: todoTimeValue(item),
absolute: absoluteTime(todoTimeValue(item)),
actionKind: 'todo',
@@ -391,8 +423,8 @@
const captureCandidates = sortByTime(captureRows).map(item => ({
id: item.captureId || `capture:${timeValue(item)}`,
category: 'captures',
title: `Review capture ${quoted(captureTitle(item))}`,
meta: `${itemTimeLabel(item)} · ${item.domain || item.kind || 'Browser capture'}`,
title: tr('overview.event.reviewCapture', { title: captureTitle(item) }),
meta: `${itemTimeLabel(item)} · ${item.domain || captureKindLabel(item) || tr('overview.browserCapture')}`,
time: timeValue(item),
absolute: absoluteTime(timeValue(item)),
actionKind: 'browser-inbox',
@@ -407,7 +439,7 @@
const journalCandidates = sortByTime(journalRows).map(item => ({
id: item.entryId || `journal:${timeValue(item)}`,
category: 'journal',
title: `Continue journal entry ${quoted(journalTitle(item))}`,
title: tr('overview.event.continueJournal', { title: journalTitle(item) }),
meta: itemTimeLabel(item),
time: timeValue(item),
absolute: absoluteTime(timeValue(item)),
@@ -421,14 +453,18 @@
const captureItems = sortByTime(captureRows).slice(0, 4).map(item => ({
id: item.captureId || `capture:${timeValue(item)}`,
title: captureTitle(item),
meta: `${item.kind || 'capture'} · ${itemTimeLabel(item)}`,
meta: `${captureKindLabel(item)} · ${itemTimeLabel(item)}`,
actionKind: 'browser-inbox',
actionLabel: tr('overview.reviewInbox'),
}));
const candidateItems = sortByTime(candidates).slice(0, 4).map(item => ({
id: item.candidateId || `work-session:${timeValue(item)}`,
title: 'Possible journal entry',
meta: `Workspace: ${item.workspaceRootPath || workspaceRootPath || 'Unknown'} · ${item.estimatedMinutes || 0} min · ${item.activityCount || (item.activityIds || []).length || 0} activities`,
title: tr('overview.event.possibleJournalEntry'),
meta: tr('overview.candidateMeta', {
deal: item.workspaceRootPath || workspaceRootPath || tr('overview.unknownDeal'),
minutes: item.estimatedMinutes || 0,
activities: item.activityCount || (item.activityIds || []).length || 0,
}),
actionKind: 'journal',
actionLabel: tr('overview.reviewCandidate'),
toolRequest: { type: 'work-session-candidate', candidate: item },
@@ -436,7 +472,7 @@
const todoItems = [...todoRows].sort((a, b) => todoDateMs(todoTimeValue(a)) - todoDateMs(todoTimeValue(b))).slice(0, 4).map(item => ({
id: item.id || `todo:${todoTitle(item)}`,
title: todoTitle(item),
meta: `${todoAttentionState(item)}${item.dueAt ? ` · Due ${item.dueAt}` : ''}`,
meta: `${todoAttentionState(item)}${item.dueAt ? ` · ${tr('overview.todo.due', { date: item.dueAt })}` : ''}`,
actionKind: 'todo',
actionLabel: tr('overview.openTodos'),
}));
@@ -477,12 +513,12 @@
}
}
async function loadWorkspaceResources() {
async function loadWorkspaceResources(toolState) {
const workspace = String(workspaceRootPath || '').trim();
if (!workspace) return { keyResources: [], totalNotes: 0 };
const [rootEntries, notesEntries] = await Promise.all([
listFiles('verstak.files', workspace),
listFiles('verstak.notes', `${workspace}/Notes`),
toolState.files ? listFiles('verstak.files', workspace) : Promise.resolve([]),
toolState.notes ? listFiles('verstak.notes', `${workspace}/Notes`) : Promise.resolve([]),
]);
const noteFiles = notesEntries.filter(item => item?.type === 'file');
const overview = [...notesEntries, ...rootEntries].find(item => /(^|\/)overview\.md$/i.test(String(item.relativePath || item.name || '')));
@@ -491,44 +527,54 @@
keyResources: overview ? [{
id: overview.relativePath || overview.name,
title: overview.name || fileName(overview.relativePath) || 'Overview.md',
meta: overview.relativePath || 'Workspace overview note',
actionKind: hasNotes ? 'notes' : 'files',
actionLabel: hasNotes ? tr('overview.openNotes') : tr('overview.openFiles'),
meta: overview.relativePath || tr('overview.overviewNote'),
actionKind: toolState.notes ? 'notes' : 'files',
actionLabel: toolState.notes ? tr('overview.openNotes') : tr('overview.openFiles'),
}] : [],
};
}
async function loadOverview() {
const workspaceAtStart = String(workspaceRootPath || '').trim();
const toolKeyAtStart = overviewToolKey;
const toolState = {
notes: hasNotes,
files: hasFiles,
browserInbox: hasBrowserInbox,
activity: hasActivity,
journal: hasJournal,
todos: hasTodos,
};
loadedWorkspaceRoot = workspaceAtStart;
loadedToolKey = toolKeyAtStart;
loading = true;
const [browserSettings, activitySettings, activityRecords, journalSettings, todoSettings, resources] = await Promise.all([
readPluginSettings('verstak.browser-inbox'),
readPluginSettings('verstak.activity'),
App.ReadPluginDataNDJSON
toolState.browserInbox ? readPluginSettings('verstak.browser-inbox') : Promise.resolve({}),
toolState.activity ? readPluginSettings('verstak.activity') : Promise.resolve({}),
toolState.activity && App.ReadPluginDataNDJSON
? App.ReadPluginDataNDJSON('verstak.activity', 'activity-events')
.then(value => decodeTuple(value, []))
.catch(() => [])
: Promise.resolve([]),
readPluginSettings('verstak.journal'),
readPluginSettings('verstak.todo'),
loadWorkspaceResources(),
toolState.journal ? readPluginSettings('verstak.journal') : Promise.resolve({}),
toolState.todos ? readPluginSettings('verstak.todo') : Promise.resolve({}),
loadWorkspaceResources(toolState),
]);
if (workspaceAtStart !== String(workspaceRootPath || '').trim()) return;
if (workspaceAtStart !== String(workspaceRootPath || '').trim() || toolKeyAtStart !== overviewToolKey) return;
captures = browserCaptureRowsForWorkspace(browserSettings);
activityEvents = normalizeRows(activityRecords).filter(item => {
captures = toolState.browserInbox ? browserCaptureRowsForWorkspace(browserSettings) : [];
activityEvents = toolState.activity ? normalizeRows(activityRecords).filter(item => {
const tagged = String(item.workspaceRootPath || item.workspaceName || item.workspaceNodeId || '').trim();
return !tagged || tagged === workspaceAtStart;
});
journalEntries = rowsFor(journalSettings, [
}) : [];
journalEntries = toolState.journal ? rowsFor(journalSettings, [
workspaceKey('worklog:workspace:'),
'worklog',
]);
todos = todoRowsForWorkspace(todoSettings);
workSessionCandidates = rowsFor(activitySettings, [
]) : [];
todos = toolState.todos ? todoRowsForWorkspace(todoSettings) : [];
workSessionCandidates = toolState.activity && toolState.journal ? rowsFor(activitySettings, [
workspaceKey('work-session-candidates:workspace:'),
]);
]) : [];
keyResources = resources.keyResources;
totalNotes = resources.totalNotes;
loading = false;
@@ -657,7 +703,7 @@
</main>
<aside class="overview-side">
{#if needsAttention.length || !loading}
{#if hasAttentionTools && (needsAttention.length || !loading)}
<section class="today-panel overview-panel" data-overview-section="attention">
<div class="today-panel-head overview-panel-head">
<div>
+13 -7
View File
@@ -18,6 +18,12 @@
void activeLocale;
return i18n.t(key, params, fallback);
})(locale);
function reportError(key, fallback, details) {
console.warn('[VaultSelection] operation failed:', details);
return tr(key, undefined, fallback);
}
onDestroy(unsubscribeLocale);
onMount(async () => {
@@ -54,13 +60,13 @@
try {
const createErr = await App.CreateVault(newVaultPath.trim());
if (createErr) {
error = tr('vaultSelection.createError', { error: createErr });
error = reportError('vaultSelection.createError', 'Could not create the vault. Please try again.', createErr);
creating = false;
return;
}
const openErr = await App.OpenVault(newVaultPath.trim());
if (openErr) {
error = tr('vaultSelection.openError', { error: openErr });
error = reportError('vaultSelection.openError', 'Could not open the vault. Please try again.', openErr);
creating = false;
return;
}
@@ -70,7 +76,7 @@
}
window.dispatchEvent(new CustomEvent('verstak:vault-opened'));
} catch (e) {
error = String(e);
error = reportError('vaultSelection.createError', 'Could not create the vault. Please try again.', e);
creating = false;
}
}
@@ -85,7 +91,7 @@
try {
const openErr = await App.OpenVault(openVaultPath.trim());
if (openErr) {
error = tr('vaultSelection.openError', { error: openErr });
error = reportError('vaultSelection.openError', 'Could not open the vault. Please try again.', openErr);
opening = false;
return;
}
@@ -95,7 +101,7 @@
}
window.dispatchEvent(new CustomEvent('verstak:vault-opened'));
} catch (e) {
error = String(e);
error = reportError('vaultSelection.openError', 'Could not open the vault. Please try again.', e);
opening = false;
}
}
@@ -106,7 +112,7 @@
try {
const openErr = await App.OpenVault(path);
if (openErr) {
error = tr('vaultSelection.openError', { error: openErr });
error = reportError('vaultSelection.openError', 'Could not open the vault. Please try again.', openErr);
opening = false;
return;
}
@@ -116,7 +122,7 @@
}
window.dispatchEvent(new CustomEvent('verstak:vault-opened'));
} catch (e) {
error = String(e);
error = reportError('vaultSelection.openError', 'Could not open the vault. Please try again.', e);
opening = false;
}
}
+11 -3
View File
@@ -22,6 +22,7 @@
$: requestContext = openedResource?.request?.context?.notesMode || openedResource?.request?.context?.isInsideNotesFolder
? 'notes-markdown'
: ((openedResource?.request?.extension === '.md' || openedResource?.request?.extension === '.markdown') ? 'generic-markdown' : 'generic-text');
$: resourceTitle = resourceDisplayTitle(resourcePath, requestContext);
$: componentProps = openedResource || {};
$: mountKey = [
providerPluginId,
@@ -31,6 +32,11 @@
requestContext,
].join(':');
function resourceDisplayTitle(path, context) {
const fileName = String(path || '').split('/').filter(Boolean).pop() || String(path || '');
return context === 'notes-markdown' ? fileName.replace(/\.(md|markdown)$/i, '') : fileName;
}
function closeWorkbench() {
window.dispatchEvent(new CustomEvent('verstak:close-workbench', { cancelable: true }));
}
@@ -41,7 +47,7 @@
<div class="workbench-host vt-page">
{#if openedResource?.status === 'no-provider'}
<div class="workbench-header vt-page-header">
<span class="workbench-title vt-page-title">{resourcePath}</span>
<span class="workbench-title vt-page-title">{resourceTitle}</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} />
@@ -49,11 +55,13 @@
</div>
<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>
{#if debug.isEnabled()}
<p class="workbench-meta">{requestMode} · {requestContext}</p>
{/if}
</div>
{:else if openedResource}
<div class="workbench-header vt-page-header">
<span class="workbench-title vt-page-title">{resourcePath}</span>
<span class="workbench-title vt-page-title">{resourceTitle}</span>
{#if debug.isEnabled() && providerId}
<span class="workbench-provider vt-badge accent" data-debug-provider>{providerId}</span>
{/if}
+6 -10
View File
@@ -45,7 +45,6 @@
$: workspaceRootPath = selectedWorkspace?.rootPath || selectedWorkspace?.name || selectedWorkspace?.id || '';
$: workspaceId = selectedWorkspace?.workspaceId || '';
$: workspaceTitle = selectedWorkspace?.title || selectedWorkspace?.name || selectedWorkspace?.id || selectedWorkspaceName;
$: workspaceType = selectedWorkspace?.type || 'workspace';
$: if (workspaceRootPath !== metadataWorkspaceRoot) {
metadataWorkspaceRoot = workspaceRootPath;
workspaceMetadata = null;
@@ -80,11 +79,13 @@
if (changed && selectedWorkspaceName) loadTools();
});
window.addEventListener('verstak:workspace-open-tool', handleWorkspaceOpenTool);
window.addEventListener('verstak:plugins-changed', handlePluginsChanged);
});
onDestroy(() => {
if (unsubscribeLocale) unsubscribeLocale();
window.removeEventListener('verstak:workspace-open-tool', handleWorkspaceOpenTool);
window.removeEventListener('verstak:plugins-changed', handlePluginsChanged);
});
function toolKey(tool) {
@@ -170,6 +171,10 @@
requestWorkspaceTool(event?.detail?.kind, event?.detail?.toolRequest || null);
}
function handlePluginsChanged() {
if (selectedWorkspaceName) loadTools();
}
async function loadTools() {
try {
toolsLoaded = false;
@@ -201,7 +206,6 @@
<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 />
@@ -288,14 +292,6 @@
font-weight: 600;
}
.workspace-type {
color: var(--vt-color-accent);
font-size: 0.75rem;
padding: 0.1rem 0.4rem;
border-radius: var(--vt-radius-sm);
background: var(--vt-color-accent-muted);
}
.workspace-search {
width: min(27rem, 46vw);
min-width: 16rem;
+134 -19
View File
@@ -18,8 +18,11 @@
let newWorkspaceName = '';
let workspaceTemplates = [];
let templatePluginNames = {};
let templatePlugins = {};
let templateCapabilities = new Set();
let selectedTemplateId = 'default';
let createError = '';
let templateWarning = null;
let templatesLoading = false;
let creating = false;
let renamingId = '';
@@ -32,6 +35,11 @@
return i18n.t(key, params, fallback);
})(locale);
function reportError(key, fallback, details) {
console.warn('[WorkspaceTree] operation failed:', details);
return tr(key, undefined, fallback);
}
onMount(() => {
unsubscribeLocale = i18n.subscribe((nextLocale) => {
const changed = locale !== nextLocale;
@@ -61,21 +69,71 @@
}
$: selectedTemplate = workspaceTemplates.find(template => template.id === selectedTemplateId) || workspaceTemplates[0] || null;
$: selectedTemplateTools = (selectedTemplate?.workspaceTools || []).map((pluginId) => (
templateToolState(pluginId, templatePlugins, templateCapabilities, templatePluginNames, tr)
));
$: selectedTemplateIssues = selectedTemplateTools.filter(tool => tool.status !== 'available');
function toolLabel(pluginId) {
return templatePluginNames[pluginId] || String(pluginId || '').replace(/^verstak\./, '');
function toolLabel(pluginId, names = templatePluginNames) {
return names[pluginId] || String(pluginId || '').replace(/^verstak\./, '');
}
function templateToolState(pluginId, plugins, capabilities, names, translate) {
const plugin = plugins[pluginId];
if (!plugin) {
return {
pluginId,
name: toolLabel(pluginId, names),
tabs: [],
status: 'unavailable',
reason: translate('workspaceTree.templateMissingPlugin'),
};
}
const manifest = plugin.manifest || {};
const tabs = Array.isArray(manifest.contributes?.workspaceItems)
? manifest.contributes.workspaceItems.map(item => item?.title || item?.id).filter(Boolean)
: [];
const pluginStatus = String(plugin.status || '').toLowerCase();
const missingCapability = Array.isArray(manifest.requires)
&& manifest.requires.some(capabilityId => !capabilities.has(capabilityId));
let status = 'available';
let reason = translate('workspaceTree.templateAvailable');
if (!plugin.enabled || pluginStatus === 'disabled') {
status = 'unavailable';
reason = translate('workspaceTree.templatePluginDisabled');
} else if (pluginStatus === 'missing-required-capability' || missingCapability) {
status = 'unavailable';
reason = translate('workspaceTree.templateCapabilityUnavailable');
} else if (pluginStatus === 'incompatible') {
status = 'unavailable';
reason = translate('workspaceTree.templateIncompatible');
} else if (pluginStatus === 'failed') {
status = 'unavailable';
reason = translate('workspaceTree.templateLoadFailed');
} else if (pluginStatus === 'degraded') {
status = 'limited';
reason = translate('workspaceTree.templateLimited');
} else if (pluginStatus !== 'loaded') {
status = 'unavailable';
reason = translate('workspaceTree.templateNotReady');
}
return { pluginId, name: manifest.name || toolLabel(pluginId, names), tabs, status, reason };
}
async function loadWorkspaceTemplates() {
templatesLoading = true;
try {
const [templates, plugins] = await Promise.all([
const [templates, plugins, capabilities] = await Promise.all([
App.ListWorkspaceTemplates ? App.ListWorkspaceTemplates() : [],
App.GetPlugins ? App.GetPlugins() : [],
App.GetCapabilities ? App.GetCapabilities() : [],
]);
const [list, err] = resultOrError(templates, []);
if (err) {
createError = err;
createError = reportError('workspaceTree.templatesError', 'Could not load Deal templates. Please try again.', err);
workspaceTemplates = [];
return;
}
@@ -83,17 +141,25 @@
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 localizedPlugins = (Array.isArray(plugins) ? plugins : []).map((plugin) => i18n.localizePlugin(plugin));
templatePluginNames = localizedPlugins.reduce((names, plugin) => {
const id = plugin?.manifest?.id;
const name = plugin?.manifest?.name;
if (id && name) names[id] = name;
return names;
}, {});
templatePlugins = localizedPlugins.reduce((result, plugin) => {
const id = plugin?.manifest?.id;
if (id) result[id] = plugin;
return result;
}, {});
const [capabilityList] = resultOrError(capabilities, []);
templateCapabilities = new Set((Array.isArray(capabilityList) ? capabilityList : []).map(capability => capability?.name).filter(Boolean));
if (!workspaceTemplates.some(template => template.id === selectedTemplateId)) {
selectedTemplateId = workspaceTemplates[0]?.id || '';
}
} catch (error) {
createError = String(error);
createError = reportError('workspaceTree.templatesError', 'Could not load Deal templates. Please try again.', error);
workspaceTemplates = [];
} finally {
templatesLoading = false;
@@ -127,7 +193,7 @@
try {
const [list, err] = resultOrError(await App.ListWorkspaces(), []);
if (err) {
localError = err;
localError = reportError('workspaceTree.loadError', 'Could not load Deals. Please try again.', err);
workspaces = [];
} else {
workspaces = list || [];
@@ -148,7 +214,7 @@
activeWorkspaceId.set(currentWorkspaceId);
}
} catch (e) {
localError = String(e);
localError = reportError('workspaceTree.loadError', 'Could not load Deals. Please try again.', e);
}
loading = false;
}
@@ -157,7 +223,7 @@
const id = wsName(workspace);
const err = await App.SetCurrentWorkspace(id);
if (err) {
localError = err;
localError = reportError('workspaceTree.selectError', 'Could not select this Deal. Please try again.', err);
return;
}
currentWorkspaceId = id;
@@ -177,11 +243,16 @@
createError = tr('workspaceTree.chooseTemplate');
return;
}
const creationIssues = selectedTemplateIssues.map(tool => ({
pluginId: tool.pluginId,
name: tool.name,
reason: tool.reason,
}));
creating = true;
createError = '';
const [, err] = resultOrError(await App.CreateWorkspace(name, selectedTemplate.id), null);
if (err) {
createError = err;
createError = reportError('workspaceTree.createError', 'Could not create the Deal. Please try again.', err);
creating = false;
return;
}
@@ -191,13 +262,14 @@
await loadWorkspaces();
const created = workspaces.find((ws) => wsName(ws) === name);
if (created) await selectWorkspace(created);
templateWarning = creationIssues.length > 0 ? { workspaceName: name, issues: creationIssues } : null;
}
function openCreateDialog() {
async function openCreateDialog() {
showCreate = true;
newWorkspaceName = '';
createError = '';
if (!workspaceTemplates.length && !templatesLoading) loadWorkspaceTemplates();
await loadWorkspaceTemplates();
}
function closeCreateDialog() {
@@ -207,6 +279,10 @@
createError = '';
}
function dismissTemplateWarning() {
templateWarning = null;
}
function startRename(workspace) {
renamingId = wsName(workspace);
renameValue = renamingId;
@@ -228,7 +304,7 @@
busyId = oldName;
const err = await App.RenameWorkspace(oldName, newName);
if (err) {
localError = err;
localError = reportError('workspaceTree.renameError', 'Could not rename the Deal. Please try again.', err);
busyId = '';
return;
}
@@ -246,7 +322,7 @@
busyId = name;
const [, err] = resultOrError(await App.TrashWorkspace(name), null);
if (err) {
localError = err;
localError = reportError('workspaceTree.trashError', 'Could not move the Deal to trash. Please try again.', err);
busyId = '';
return;
}
@@ -288,7 +364,7 @@
if (e.key === 'Escape') cancelRename();
}}
/>
<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={() => commitRename(workspace)} title={tr('workspaceTree.saveRename')} type="button" disabled={busyId === id}>{tr('common.save')}</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>
@@ -304,6 +380,18 @@
{/each}
</div>
{#if templateWarning}
<section class="workspace-template-warning" data-workspace-template-warning role="alert">
<div>
<strong>{tr('workspaceTree.templateIncompleteCreated', { name: templateWarning.workspaceName })}</strong>
{#each templateWarning.issues as issue (issue.pluginId)}
<div class="workspace-template-warning-item">{issue.name}: {issue.reason}</div>
{/each}
</div>
<button class="wt-btn" type="button" on:click={dismissTemplateWarning} title={tr('common.close')}>{tr('common.close')}</button>
</section>
{/if}
{#if showCreate}
<div class="workspace-create-overlay" data-workspace-create-modal role="dialog" aria-modal="true" aria-label={tr('workspaceTree.create')}>
<div class="workspace-create-modal">
@@ -329,10 +417,23 @@
<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 selectedTemplateTools as tool (tool.pluginId)}
<div
class="workspace-template-tool"
class:limited={tool.status === 'limited'}
class:unavailable={tool.status === 'unavailable'}
data-workspace-template-tool={tool.pluginId}
data-template-tool-status={tool.status}
>
<span class="workspace-template-tool-name">{tool.name}</span>
<span class="workspace-template-tool-tabs">{tool.tabs.length ? tr('workspaceTree.templateToolTabs', { tabs: tool.tabs.join(', ') }) : tr('workspaceTree.templateToolNoTabs')}</span>
<span class="workspace-template-tool-reason">{tool.reason}</span>
</div>
{/each}
</div>
{#if selectedTemplateIssues.length > 0}
<p class="workspace-template-incomplete" data-workspace-template-incomplete>{tr('workspaceTree.templateWillBeIncomplete')}</p>
{/if}
</div>
{/if}
{#if createError}
@@ -378,11 +479,25 @@
.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 select { appearance: none; background-color: #0f1424; background-image: linear-gradient(45deg, transparent 50%, var(--vt-color-text-muted) 50%), linear-gradient(135deg, var(--vt-color-text-muted) 50%, transparent 50%); background-position: calc(100% - 14px) 50%, calc(100% - 9px) 50%; background-size: 5px 5px, 5px 5px; background-repeat: no-repeat; padding-right: 1.7rem; }
.workspace-create-field select option { background: #0f1424; color: var(--vt-color-text-primary); }
.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-template-tools { display: grid; gap: 0.4rem; }
.workspace-template-tool { display: grid; grid-template-columns: minmax(7rem, 1fr) minmax(0, 1.4fr) minmax(0, 1.2fr); gap: 0.5rem; align-items: center; padding: 0.45rem 0.55rem; 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-template-tool-name { color: var(--vt-color-text-primary); font-weight: 600; }
.workspace-template-tool-tabs { color: var(--vt-color-text-muted); }
.workspace-template-tool-reason { color: var(--vt-color-accent); }
.workspace-template-tool.limited { border-color: rgba(220, 164, 57, 0.58); background: rgba(220, 164, 57, 0.08); }
.workspace-template-tool.limited .workspace-template-tool-reason { color: #e8bc63; }
.workspace-template-tool.unavailable { border-color: rgba(233, 69, 96, 0.58); background: var(--vt-color-danger-muted); }
.workspace-template-tool.unavailable .workspace-template-tool-reason { color: var(--vt-color-danger); }
.workspace-template-incomplete { color: var(--vt-color-danger) !important; }
.workspace-template-warning { display: flex; gap: 0.5rem; align-items: flex-start; justify-content: space-between; margin: 0.25rem 0.6rem 0.5rem; padding: 0.6rem; border: 1px solid rgba(233, 69, 96, 0.58); border-radius: var(--vt-radius-md); background: var(--vt-color-danger-muted); color: var(--vt-color-text-secondary); font-size: 0.73rem; line-height: 1.4; }
.workspace-template-warning strong { color: var(--vt-color-danger); }
.workspace-template-warning-item { margin-top: 0.2rem; }
@media (max-width: 700px) { .workspace-template-tool { grid-template-columns: 1fr; gap: 0.18rem; } }
.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; }
+143 -49
View File
@@ -4,6 +4,12 @@
* Каждый метод возвращает Promise с данными, совместимыми с Wails-контрактом.
* Состояние мутабельно — тесты могут менять его между сценариями.
*/
import defaultEditorSource from '../../../../../verstak-official-plugins/plugins/default-editor/frontend/src/index.js?raw';
import secretsSource from '../../../../../verstak-official-plugins/plugins/secrets/frontend/src/index.js?raw';
import activitySource from '../../../../../verstak-official-plugins/plugins/activity/frontend/src/index.js?raw';
import todoSource from '../../../../../verstak-official-plugins/plugins/todo/frontend/src/index.js?raw';
import journalSource from '../../../../../verstak-official-plugins/plugins/journal/frontend/src/index.js?raw';
(function () {
if (window.__wailsMockReady) return;
@@ -144,7 +150,7 @@
name: 'Notes',
version: '0.1.0',
apiVersion: '0.1.0',
description: 'Workspace-scoped notes manager.',
description: 'Deal-scoped notes manager.',
source: 'official',
icon: 'edit',
provides: ['verstak/notes/v1'],
@@ -191,7 +197,7 @@
name: 'Activity',
version: '0.1.0',
apiVersion: '0.1.0',
description: 'Workspace-scoped activity log for public plugin events.',
description: 'Deal-scoped activity log for public plugin events.',
source: 'official',
icon: 'activity',
provides: ['activity.log', 'activity.provider', 'activity.reconstruction'],
@@ -215,11 +221,11 @@
name: 'Journal',
version: '0.1.0',
apiVersion: '0.1.0',
description: 'Workspace-scoped journal with user-authored entries and optional Activity links.',
description: 'Deal-scoped journal with user-authored entries and optional Activity links.',
source: 'official',
icon: 'book-open',
provides: ['worklog', 'journal', 'report.worklog'],
permissions: ['storage.namespace', 'ui.register'],
permissions: ['events.publish', 'files.read', 'storage.namespace', 'ui.register'],
frontend: { entry: 'frontend/dist/index.js' },
contributes: {
views: [{ id: 'verstak.journal.view', title: 'Journal', icon: 'book-open', component: 'JournalView' }],
@@ -236,19 +242,19 @@
manifest: {
schemaVersion: 1,
id: 'verstak.browser-inbox',
name: 'Browser Inbox',
name: 'Browser',
version: '0.1.0',
apiVersion: '0.1.0',
description: 'Global browser capture queue with explicit workspace assignment.',
description: 'Global browser materials with explicit Deal assignment.',
source: 'official',
icon: 'inbox',
provides: ['browser.inbox'],
permissions: ['events.subscribe', 'files.read', 'storage.namespace', 'ui.register'],
frontend: { entry: 'frontend/dist/index.js' },
contributes: {
views: [{ id: 'verstak.browser-inbox.view', title: 'Browser Inbox', icon: 'inbox', component: 'BrowserInboxView' }],
sidebarItems: [{ id: 'verstak.browser-inbox.sidebar', title: 'Browser Inbox', icon: 'inbox', view: 'verstak.browser-inbox.view', position: 30 }],
workspaceItems: [{ id: 'verstak.browser-inbox.workspace', title: 'Browser Inbox', icon: 'inbox', component: 'BrowserInboxView' }]
views: [{ id: 'verstak.browser-inbox.view', title: 'Browser', icon: 'inbox', component: 'BrowserInboxView' }],
sidebarItems: [{ id: 'verstak.browser-inbox.sidebar', title: 'Browser', icon: 'inbox', view: 'verstak.browser-inbox.view', position: 30 }],
workspaceItems: [{ id: 'verstak.browser-inbox.workspace', title: 'Browser', icon: 'inbox', component: 'BrowserInboxView' }]
}
},
rootPath: '/tmp/verstak-test/plugins/browser-inbox',
@@ -265,7 +271,7 @@
name: 'Search',
version: '0.1.0',
apiVersion: '0.1.0',
description: 'Workspace-scoped vault text search provider.',
description: 'Deal-scoped vault text search provider.',
source: 'official',
icon: 'search',
provides: ['verstak/search/v1', 'search.provider'],
@@ -291,7 +297,7 @@
'verstak.sync': 'Синхронизация',
'verstak.activity': 'Активность',
'verstak.journal': 'Журнал',
'verstak.browser-inbox': 'Входящие из браузера',
'verstak.browser-inbox': 'Браузер',
'verstak.search': 'Поиск',
'verstak.trash': 'Корзина',
'verstak.todo': 'Задачи',
@@ -359,7 +365,9 @@
var pluginSettings = {
'verstak.platform-test': { savedText: 'initial value' }
};
var pluginNotifications = {};
var pluginData = {};
var secretRecords = makeDefaultSecretRecords();
var vaultFiles = makeDefaultVaultFiles();
var externalOpens = [];
var trashEntries = [];
@@ -404,12 +412,33 @@
return JSON.parse(JSON.stringify(value));
}
function makeDefaultSecretRecords() {
return [
{
id: 'first.secret',
title: 'First secret',
username: 'first-user',
value: 'first-value',
scope: { kind: 'global' },
updatedAt: '2026-07-14T00:00:00Z'
},
{
id: 'target.secret',
title: 'Target secret',
username: 'target-user',
value: 'target-value',
scope: { kind: 'global' },
updatedAt: '2026-07-14T00:00:00Z'
}
];
}
function builtInWorkspaceTemplates() {
return [
{
id: 'default',
name: 'General',
description: 'Everyday workspace with notes, files, journal, activity, and browser captures.',
description: 'Everyday Deal with notes, files, journal, activity, and browser captures.',
version: 2,
workspaceTools: ['verstak.notes', 'verstak.files', 'verstak.journal', 'verstak.activity', 'verstak.browser-inbox'],
folders: ['Notes', 'Files'],
@@ -427,7 +456,7 @@
{
id: 'writing',
name: 'Writing',
description: 'Focused notes, files, and journal workspace for documentation and writing.',
description: 'Focused notes, files, and journal Deal for documentation and writing.',
version: 1,
workspaceTools: ['verstak.notes', 'verstak.files', 'verstak.journal'],
folders: ['Notes', 'Files'],
@@ -436,7 +465,7 @@
{
id: 'admin',
name: 'Admin',
description: 'Infrastructure workspace with secrets, todos, and journal.',
description: 'Infrastructure Deal with secrets, todos, and journal.',
version: 1,
workspaceTools: ['verstak.notes', 'verstak.files', 'verstak.secrets', 'verstak.todo', 'verstak.journal'],
folders: ['Notes', 'Files', 'Secrets'],
@@ -445,7 +474,7 @@
{
id: 'minimal',
name: 'Minimal',
description: 'Only notes and files for a lightweight workspace.',
description: 'Only notes and files for a lightweight Deal.',
version: 1,
workspaceTools: ['verstak.notes', 'verstak.files'],
folders: ['Notes', 'Files'],
@@ -541,11 +570,12 @@
name: 'Todos',
version: '0.1.0',
apiVersion: '0.1.0',
description: 'Global and workspace todo tracking with due and reminder metadata.',
description: 'Global and Deal-scoped todo tracking with due and reminder metadata.',
source: 'official',
icon: 'list-todo',
provides: ['todo.list', 'todo.workspace'],
permissions: ['files.read', 'storage.namespace', 'ui.register'],
requires: ['verstak/core/notifications/v1'],
permissions: ['files.read', 'storage.namespace', 'ui.register', 'notifications.schedule'],
frontend: { entry: 'frontend/dist/index.js' },
contributes: {
views: [{ id: 'verstak.todo.view', title: 'Todos', icon: 'list-todo', component: 'TodoView' }],
@@ -568,13 +598,22 @@
name: 'Secrets',
version: '0.1.0',
apiVersion: '0.1.0',
description: 'Encrypted global and workspace-scoped secret manager.',
description: 'Encrypted global and Deal-scoped secret manager.',
source: 'official',
icon: 'key-round',
provides: ['secret-store', 'secrets.read-ui', 'secrets.write-ui'],
permissions: ['secrets.read', 'secrets.write', 'ui.register'],
frontend: { entry: 'frontend/dist/index.js' },
permissions: ['files.read', 'secrets.read', 'secrets.write', 'ui.register'],
frontend: { entry: 'frontend/src/index.js' },
contributes: {
views: [{ id: 'verstak.secrets.view', title: 'Secrets', icon: 'key-round', component: 'SecretsView' }],
sidebarItems: [{ id: 'verstak.secrets.sidebar', title: 'Secrets', icon: 'key-round', view: 'verstak.secrets.view', position: 45 }],
openProviders: [{
id: 'verstak.secrets.secret',
title: 'Secrets',
priority: 100,
component: 'SecretsView',
supports: [{ kind: 'secret', modes: ['view'] }]
}],
workspaceItems: [{ id: 'verstak.secrets.workspace', title: 'Secrets', icon: 'key-round', component: 'SecretsView' }]
}
},
@@ -678,6 +717,7 @@
caps.push({ name: 'verstak/core/files/v1', description: 'Files API', pluginId: 'verstak-desktop', status: 'stable' });
caps.push({ name: 'verstak/core/workbench/v1', description: 'Workbench routing', pluginId: 'verstak-desktop', status: 'stable' });
caps.push({ name: 'verstak/core/sync/v1', description: 'Sync API', pluginId: 'verstak-desktop', status: 'stable' });
caps.push({ name: 'verstak/core/notifications/v1', description: 'Native notifications', pluginId: 'verstak-desktop', status: 'stable' });
for (var id in pluginStates) {
var s = pluginStates[id];
if (s.status === 'loaded' && s.enabled && s.manifest && s.manifest.provides) {
@@ -2416,7 +2456,7 @@
}
function title(capture) {
return capture.title || capture.fileName || capture.url || capture.captureId || 'Untitled capture';
return capture.title || capture.fileName || capture.url || capture.captureId || 'Untitled material';
}
function renderBrowserInbox(containerEl, props, api) {
@@ -2433,7 +2473,7 @@
containerEl.innerHTML = '';
var root = el('div', { className: 'browser-inbox-root', 'data-plugin-id': PLUGIN_ID });
var toolbar = el('div', { className: 'browser-inbox-toolbar' });
var titleEl = el('span', { className: 'browser-inbox-title', textContent: rootPath ? 'Browser Inbox · ' + rootPath : 'Browser Inbox' });
var titleEl = el('span', { className: 'browser-inbox-title', textContent: rootPath ? 'Browser · ' + rootPath : 'Browser' });
var countEl = el('span', { className: 'browser-inbox-count' });
var statusEl = el('span', { className: 'browser-inbox-status' });
var filtersEl = el('div', { className: 'browser-inbox-filters' });
@@ -2517,7 +2557,7 @@
function renderWorkspaceFilterOptions() {
if (rootPath) return;
workspaceFilterEl.innerHTML = '';
workspaceFilterEl.appendChild(option('', 'All workspaces'));
workspaceFilterEl.appendChild(option('', 'All Deals'));
workspaceRoots().forEach(function (workspace) {
workspaceFilterEl.appendChild(option(workspace, workspace));
});
@@ -2590,7 +2630,7 @@
}) : captures).map(function (capture) { return capture.captureId; });
captures = captures.filter(function (capture) { return ids.indexOf(capture.captureId) === -1; });
selectedId = '';
statusText = rootPath ? 'Workspace captures cleared' : 'Inbox cleared';
statusText = rootPath ? 'Deal materials cleared' : 'Inbox cleared';
return persist();
}
@@ -2632,7 +2672,7 @@
listEl.appendChild(el('div', {
className: 'browser-inbox-empty',
textContent: captures.length === 0
? 'No browser captures yet. Keep this view open, then send a page, selection, link, or file from the browser extension.'
? 'No browser materials yet. Send a page, selection, or link from the extension.'
: 'No captures match the current filters.'
}));
return;
@@ -2677,7 +2717,7 @@
el('div', { className: 'browser-inbox-meta-value', textContent: capture.domain || '-' }),
el('div', { className: 'browser-inbox-meta-label', textContent: 'Browser' }),
el('div', { className: 'browser-inbox-meta-value', textContent: capture.browserName || capture.source || '-' }),
el('div', { className: 'browser-inbox-meta-label', textContent: 'Workspace' }),
el('div', { className: 'browser-inbox-meta-label', textContent: 'Deal' }),
el('div', { className: 'browser-inbox-meta-value', textContent: capture.workspaceRootPath || 'Unassigned' }),
el('div', { className: 'browser-inbox-meta-label', textContent: 'Status' }),
el('div', { className: 'browser-inbox-meta-value', textContent: capture.processed ? 'Processed' : 'Unprocessed' })
@@ -3328,6 +3368,43 @@
GetPluginLocalization: function (pluginId, locale) {
return Promise.resolve([mockPluginCatalog(pluginId, locale), '']);
},
PluginSecretsStatus: function () {
return Promise.resolve([{ initialized: true, unlocked: true }, '']);
},
PluginSecretsUnlock: function () {
return Promise.resolve('');
},
PluginSecretsList: function () {
return Promise.resolve([secretRecords.map(function (record) {
var listed = cloneJson(record);
delete listed.value;
return listed;
}), '']);
},
PluginSecretsRead: function (_pluginId, secretID) {
var record = secretRecords.find(function (item) { return item.id === secretID; });
if (!record) return Promise.resolve([{}, 'not-found: secret ' + secretID]);
return Promise.resolve([cloneJson(record), '']);
},
PluginSecretsWrite: function (_pluginId, nextRecord) {
var record = Object.assign({}, nextRecord || {});
if (!record.id) return Promise.resolve([{}, 'secret id is required']);
record.scope = record.scope || { kind: 'global' };
record.updatedAt = new Date().toISOString();
var index = secretRecords.findIndex(function (item) { return item.id === record.id; });
if (index === -1) secretRecords.push(record);
else secretRecords[index] = record;
return Promise.resolve([cloneJson(record), '']);
},
PluginSecretsDelete: function (_pluginId, secretID) {
secretRecords = secretRecords.filter(function (record) { return record.id !== secretID; });
return Promise.resolve('');
},
PluginSecretsCopyLink: function (_pluginId, secretID) {
var record = secretRecords.find(function (item) { return item.id === secretID; });
if (!record) return Promise.resolve(['', 'not-found: secret ' + secretID]);
return Promise.resolve(['[' + (record.title || record.id) + '](verstak-secret://' + encodeURIComponent(record.id) + ')', '']);
},
ReadPluginSettings: function (pluginId) {
return Promise.resolve([Object.assign({}, pluginSettings[pluginId] || {}), '']);
},
@@ -3335,8 +3412,22 @@
pluginSettings[pluginId] = Object.assign({}, settings || {});
return Promise.resolve('');
},
ReadPluginSetting: function () { return Promise.resolve(null); },
WritePluginSetting: function () { return Promise.resolve(null); },
ReadPluginSetting: function (pluginId, key) {
return Promise.resolve([pluginSettings[pluginId] && pluginSettings[pluginId][key], '']);
},
WritePluginSetting: function (pluginId, key, value) {
pluginSettings[pluginId] = pluginSettings[pluginId] || {};
pluginSettings[pluginId][key] = value;
return Promise.resolve('');
},
ReplacePluginNotifications: function (pluginId, items) {
pluginNotifications[pluginId] = Array.isArray(items) ? items.slice() : [];
return Promise.resolve('');
},
ClearPluginNotifications: function (pluginId) {
delete pluginNotifications[pluginId];
return Promise.resolve('');
},
ReadPluginDataJSON: function (pluginId, name) {
var data = (pluginData[pluginId] && pluginData[pluginId][name]) || {};
return Promise.resolve([Object.assign({}, data), '']);
@@ -3455,8 +3546,8 @@
if (pluginId === 'verstak.platform-test' && assetPath === 'frontend/dist/index.js') {
return Promise.resolve(platformTestBundle());
}
if (pluginId === 'verstak.default-editor' && assetPath === 'frontend/dist/index.js') {
return Promise.resolve(defaultEditorBundle());
if (pluginId === 'verstak.default-editor') {
return Promise.resolve(defaultEditorSource);
}
if (pluginId === 'verstak.files' && assetPath === 'frontend/dist/index.js') {
return Promise.resolve(filesPluginBundle());
@@ -3467,20 +3558,20 @@
if (pluginId === 'verstak.notes' && assetPath === 'frontend/dist/index.js') {
return Promise.resolve(simplePluginBundle('verstak.notes', 'NotesView', 'notes-root', 'Notes'));
}
if (pluginId === 'verstak.activity' && assetPath === 'frontend/dist/index.js') {
return Promise.resolve(activityBundle());
if (pluginId === 'verstak.activity') {
return Promise.resolve(activitySource);
}
if (pluginId === 'verstak.journal' && assetPath === 'frontend/dist/index.js') {
return Promise.resolve(journalBundle());
return Promise.resolve(journalSource);
}
if (pluginId === 'verstak.browser-inbox' && assetPath === 'frontend/dist/index.js') {
return Promise.resolve(browserInboxBundle());
}
if (pluginId === 'verstak.todo' && assetPath === 'frontend/dist/index.js') {
return Promise.resolve(todoBundle());
return Promise.resolve(todoSource);
}
if (pluginId === 'verstak.secrets' && assetPath === 'frontend/dist/index.js') {
return Promise.resolve(simplePluginBundle('verstak.secrets', 'SecretsView', 'secrets-root', 'Secrets'));
if (pluginId === 'verstak.secrets') {
return Promise.resolve(secretsSource);
}
if (pluginId === 'verstak.search' && assetPath === 'frontend/dist/index.js') {
return Promise.resolve(searchPluginBundle());
@@ -4013,7 +4104,7 @@
name: 'Notes',
version: '0.1.0',
apiVersion: '0.1.0',
description: 'Workspace-scoped notes manager.',
description: 'Deal-scoped notes manager.',
source: 'official',
icon: 'edit',
provides: ['verstak/notes/v1'],
@@ -4060,7 +4151,7 @@
name: 'Activity',
version: '0.1.0',
apiVersion: '0.1.0',
description: 'Workspace-scoped activity log for public plugin events.',
description: 'Deal-scoped activity log for public plugin events.',
source: 'official',
icon: 'activity',
provides: ['activity.log', 'activity.provider', 'activity.reconstruction'],
@@ -4084,11 +4175,11 @@
name: 'Journal',
version: '0.1.0',
apiVersion: '0.1.0',
description: 'Workspace-scoped journal with user-authored entries and optional Activity links.',
description: 'Deal-scoped journal with user-authored entries and optional Activity links.',
source: 'official',
icon: 'book-open',
provides: ['worklog', 'journal', 'report.worklog'],
permissions: ['storage.namespace', 'ui.register'],
permissions: ['events.publish', 'files.read', 'storage.namespace', 'ui.register'],
frontend: { entry: 'frontend/dist/index.js' },
contributes: {
views: [{ id: 'verstak.journal.view', title: 'Journal', icon: 'book-open', component: 'JournalView' }],
@@ -4105,19 +4196,19 @@
manifest: {
schemaVersion: 1,
id: 'verstak.browser-inbox',
name: 'Browser Inbox',
name: 'Browser',
version: '0.1.0',
apiVersion: '0.1.0',
description: 'Global browser capture queue with explicit workspace assignment.',
description: 'Global browser materials with explicit Deal assignment.',
source: 'official',
icon: 'inbox',
provides: ['browser.inbox'],
permissions: ['events.subscribe', 'files.read', 'storage.namespace', 'ui.register'],
frontend: { entry: 'frontend/dist/index.js' },
contributes: {
views: [{ id: 'verstak.browser-inbox.view', title: 'Browser Inbox', icon: 'inbox', component: 'BrowserInboxView' }],
sidebarItems: [{ id: 'verstak.browser-inbox.sidebar', title: 'Browser Inbox', icon: 'inbox', view: 'verstak.browser-inbox.view', position: 30 }],
workspaceItems: [{ id: 'verstak.browser-inbox.workspace', title: 'Browser Inbox', icon: 'inbox', component: 'BrowserInboxView' }]
views: [{ id: 'verstak.browser-inbox.view', title: 'Browser', icon: 'inbox', component: 'BrowserInboxView' }],
sidebarItems: [{ id: 'verstak.browser-inbox.sidebar', title: 'Browser', icon: 'inbox', view: 'verstak.browser-inbox.view', position: 30 }],
workspaceItems: [{ id: 'verstak.browser-inbox.workspace', title: 'Browser', icon: 'inbox', component: 'BrowserInboxView' }]
}
},
rootPath: '/tmp/verstak-test/plugins/browser-inbox',
@@ -4134,7 +4225,7 @@
name: 'Search',
version: '0.1.0',
apiVersion: '0.1.0',
description: 'Workspace-scoped vault text search provider.',
description: 'Deal-scoped vault text search provider.',
source: 'official',
icon: 'search',
provides: ['verstak/search/v1', 'search.provider'],
@@ -4164,6 +4255,8 @@
workbenchPreferences = {};
openedResources = [];
pluginSettings = { 'verstak.platform-test': { savedText: 'initial value' } };
pluginNotifications = {};
secretRecords = makeDefaultSecretRecords();
vaultFiles = makeDefaultVaultFiles();
externalOpens = [];
trashEntries = [];
@@ -4183,8 +4276,9 @@
getPluginState: function (pluginId) {
return pluginStates[pluginId] ? Object.assign({}, pluginStates[pluginId]) : null;
},
addSyntheticPlugins: function (count) {
addSyntheticPlugins: function (count, source) {
var total = Number(count || 0);
var pluginSource = source === 'official' || source === 'local' || source === 'third-party' ? source : 'third-party';
for (var i = 1; i <= total; i++) {
var id = 'verstak.synthetic-layout-' + String(i).padStart(2, '0');
pluginStates[id] = {
@@ -4197,7 +4291,7 @@
version: '0.0.' + i,
apiVersion: '0.1.0',
description: 'Synthetic plugin used by frontend layout tests.',
source: 'test',
source: pluginSource,
provides: ['verstak/synthetic-layout-' + i + '/v1'],
requires: [],
optionalRequires: [],
@@ -4217,7 +4311,7 @@
vaultPluginState.enabledPlugins.push(id);
}
if (!vaultPluginState.desiredPlugins.some(function (p) { return p.id === id; })) {
vaultPluginState.desiredPlugins.push({ id: id, version: '0.0.' + i, source: 'test' });
vaultPluginState.desiredPlugins.push({ id: id, version: '0.0.' + i, source: pluginSource });
}
}
},
@@ -0,0 +1,36 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const files = [
'src/lib/shell/CommandPalette.svelte',
'src/lib/plugin-manager/PluginManager.svelte',
'src/lib/shell/VaultSelection.svelte',
'src/lib/shell/WorkspaceTree.svelte',
'src/lib/plugin-host/PluginBundleHost.svelte',
];
const patterns = [
/(?:^|\n)\s*(?:error|localError|createError)\s*=\s*(?:err|String\()/,
/(?:^|\n)\s*error\s*=\s*['"].*['"]\s*\+\s*(?:err|String\()/,
/tr\([^\n]*\{\s*error\s*:/,
];
const violations = [];
for (const relativePath of files) {
const source = fs.readFileSync(path.join(root, relativePath), 'utf8');
for (const pattern of patterns) {
if (pattern.test(source)) violations.push(relativePath);
}
}
if (violations.length) {
throw new Error(`Technical backend errors reach desktop UI in: ${[...new Set(violations)].join(', ')}`);
}
const bundleHost = fs.readFileSync(path.join(root, 'src/lib/plugin-host/PluginBundleHost.svelte'), 'utf8');
if (!/<p class="error-message">\{errorText \|\| tr\('bundle\.unknownError'\)\}<\/p>\s*<details class="error-details">/.test(bundleHost)) {
throw new Error('Plugin bundle errors must keep the user-facing message outside expandable technical details');
}
console.log('desktop UI does not interpolate raw backend errors');
+9
View File
@@ -0,0 +1,9 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
const source = await readFile(new URL('../src/lib/shell/WorkspaceTree.svelte', import.meta.url), 'utf8');
assert.match(source, /\.workspace-create-field select\s*\{[^}]*appearance:\s*none/, 'workspace template select must hide the native arrow');
assert.match(source, /\.workspace-create-field select option\s*\{[^}]*background/, 'workspace template options must use the application surface');
console.log('workspace select style contract passed');
+28
View File
@@ -0,0 +1,28 @@
import en from '../src/lib/i18n/catalogs/en.js';
import ru from '../src/lib/i18n/catalogs/ru.js';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const forbidden = [
['English catalog', /\bworkspace(?:s)?\b/i],
['Russian catalog', /рабоч(?:ее|ие|ем|его|их)\s+пространств(?:о|а|е)?/i],
];
for (const [name, pattern] of forbidden) {
const violations = Object.entries(name === 'English catalog' ? en : ru)
.filter(([, value]) => pattern.test(value));
if (violations.length) {
throw new Error(`${name} still exposes workspace terminology: ${violations.map(([key]) => key).join(', ')}`);
}
}
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const overview = fs.readFileSync(path.join(root, 'src/lib/shell/TodaySurface.svelte'), 'utf8');
for (const phrase of ['Workspace opened', 'Workspace activity', 'Workspace overview note']) {
if (overview.includes(phrase)) {
throw new Error(`Overview exposes workspace terminology: ${phrase}`);
}
}
console.log('desktop user terminology uses Deal/Дело');
+4
View File
@@ -1,3 +1,4 @@
import { resolve } from 'node:path';
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
@@ -13,6 +14,9 @@ export default defineConfig(({ mode }) => {
server: {
port: isTest ? 5174 : 5173,
strictPort: true,
fs: {
allow: [resolve(__dirname, '..', '..')],
},
},
optimizeDeps: {
include: [
+1 -9
View File
@@ -3,7 +3,7 @@ module github.com/verstak/verstak-desktop
go 1.24.4
require (
github.com/getlantern/systray v1.2.2
fyne.io/systray v1.12.2
github.com/google/uuid v1.6.0
github.com/wailsapp/wails/v2 v2.12.0
golang.org/x/net v0.35.0
@@ -12,14 +12,7 @@ require (
require (
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 // indirect
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 // indirect
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 // indirect
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 // indirect
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 // indirect
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-stack/stack v1.8.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
@@ -31,7 +24,6 @@ require (
github.com/leaanthony/u v1.1.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
+2 -26
View File
@@ -1,28 +1,13 @@
fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA=
fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 h1:NRUJuo3v3WGC/g5YiyF790gut6oQr5f3FBI88Wv0dx4=
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY=
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 h1:6uJ+sZ/e03gkbqZ0kUG6mfKoqDb4XMAzMIwlajq19So=
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A=
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 h1:guBYzEaLz0Vfc/jv0czrr2z7qyzTOGC9hiQ0VC+hKjk=
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7/go.mod h1:zx/1xUUeYPy3Pcmet8OSXLbF47l+3y6hIPpyLWoR9oc=
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 h1:micT5vkcr9tOVk1FiH8SWKID8ultN44Z+yzd2y/Vyb0=
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7/go.mod h1:dD3CgOrwlzca8ed61CsZouQS5h5jIzkK9ZWrTcf0s+o=
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 h1:XYzSdCbkzOC0FDNrgJqGRo8PCMFOBFL9py72DRs7bmc=
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55/go.mod h1:6mmzY2kW1TOOrVy+r41Za2MxXM+hhqTtY3oBKd2AgFA=
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f h1:wrYrQttPS8FHIRSlsrcuKazukx/xqO/PpLZzZXsF+EA=
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA=
github.com/getlantern/systray v1.2.2 h1:dCEHtfmvkJG7HZ8lS/sLklTH4RKUcIsKrAD9sThoEBE=
github.com/getlantern/systray v1.2.2/go.mod h1:pXFOI1wwqwYXEhLPm9ZGjS2u/vVELeIgNMY5HvhHhcE=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -45,8 +30,6 @@ github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ=
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
@@ -55,8 +38,6 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw=
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@@ -68,9 +49,6 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
@@ -91,7 +69,6 @@ golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qx
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -104,6 +81,5 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+28 -18
View File
@@ -11,6 +11,7 @@ import (
"path/filepath"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
@@ -100,6 +101,8 @@ type App struct {
browserInboxEvents map[string]bool
browserInboxEnabled atomic.Bool
allowQuit atomic.Bool
trayReady atomic.Bool
quitOnce sync.Once
}
// SetNotificationService attaches the core-owned plugin notification scheduler.
@@ -221,11 +224,23 @@ func (a *App) Shutdown(ctx context.Context) {
cleanupNativeNotifications(ctx)
}
// BeforeClose hides the primary window until the user explicitly quits from the tray.
// SetTrayReady reports whether the native tray can safely return a hidden window.
func (a *App) SetTrayReady(ready bool) {
if a != nil {
a.trayReady.Store(ready)
}
}
// BeforeClose hides the primary window only after the native tray has confirmed
// that it can return the window. Otherwise the normal Wails close path exits.
func (a *App) BeforeClose(ctx context.Context) bool {
if a.allowQuit.Load() {
return false
}
if !a.trayReady.Load() {
log.Printf("[app] tray is unavailable; allowing normal window close")
return false
}
hideNativeWindow(ctx)
return true
}
@@ -243,10 +258,12 @@ func (a *App) Quit() {
if a == nil {
return
}
a.allowQuit.Store(true)
if a.ctx != nil {
quitNativeApplication(a.ctx)
}
a.quitOnce.Do(func() {
a.allowQuit.Store(true)
if a.ctx != nil {
quitNativeApplication(a.ctx)
}
})
}
// NativeNotificationSender delivers scheduler items through the Wails runtime.
@@ -1064,30 +1081,23 @@ func (a *App) ReloadPlugins() (int, string) {
// Unregister all non-core capabilities
a.capRegistry.UnregisterAll()
// Re-register core capabilities
coreCaps := []string{
"verstak/core/plugin-manager/v1",
"verstak/core/capability-registry/v1",
"verstak/core/contribution-registry/v1",
"verstak/core/permissions/v1",
"verstak/core/events/v1",
"verstak/core/files/v1",
"verstak/core/workbench/v1",
}
if err := a.capRegistry.Register("verstak-desktop", coreCaps); err != nil {
// Re-register the same core capabilities as initial startup. Keeping this
// list in the capability package prevents a plugin reload from dropping a
// required capability such as native notifications.
if err := a.capRegistry.Register(capability.CorePluginID, capability.CorePlatformCapabilities()); err != nil {
log.Printf("[api] ReloadPlugins: failed to re-register core capabilities: %v", err)
}
// Re-register vault capability if vault is open
if a.vault != nil && a.vault.GetVaultStatus() == vault.StatusOpen {
if err := a.capRegistry.Register("verstak-desktop", []string{"verstak/core/vault/v1"}); err != nil {
if err := a.capRegistry.Register(capability.CorePluginID, []string{"verstak/core/vault/v1"}); err != nil {
log.Printf("[api] ReloadPlugins: failed to re-register vault capability: %v", err)
}
}
// Re-register workspace capability if workspace is initialized
if a.workspace != nil && a.workspace.IsInitialized() {
if err := a.capRegistry.Register("verstak-desktop", []string{"verstak/core/workspace/v1"}); err != nil {
if err := a.capRegistry.Register(capability.CorePluginID, []string{"verstak/core/workspace/v1"}); err != nil {
log.Printf("[api] ReloadPlugins: failed to re-register workspace capability: %v", err)
}
}
+48 -2
View File
@@ -225,6 +225,19 @@ func TestReplaceAndClearPluginNotificationsStayWithinPluginNamespace(t *testing.
}
}
func TestReloadPluginsRestoresNotificationCoreCapability(t *testing.T) {
app := &App{
capRegistry: capability.NewRegistry(),
contribRegistry: contribution.NewRegistry(),
}
app.ReloadPlugins()
if !app.capRegistry.Has("verstak/core/notifications/v1") {
t.Fatal("ReloadPlugins did not restore verstak/core/notifications/v1")
}
}
func TestDomReadyInitializesNotificationsBeforeScheduler(t *testing.T) {
oldInitialize := initializeNativeNotifications
defer func() { initializeNativeNotifications = oldInitialize }()
@@ -306,7 +319,7 @@ func TestNativeNotificationSenderUsesStablePluginScopedID(t *testing.T) {
}
}
func TestBeforeCloseHidesWindowUntilUserChoosesQuit(t *testing.T) {
func TestBeforeCloseAllowsExitUntilTrayIsReady(t *testing.T) {
oldHide := hideNativeWindow
defer func() { hideNativeWindow = oldHide }()
@@ -314,8 +327,25 @@ func TestBeforeCloseHidesWindowUntilUserChoosesQuit(t *testing.T) {
hideNativeWindow = func(context.Context) { hideCalls++ }
app := &App{}
if prevent := app.BeforeClose(context.Background()); prevent {
t.Fatal("BeforeClose() = true, want false while tray is unavailable")
}
if hideCalls != 0 {
t.Fatalf("hide calls = %d, want 0", hideCalls)
}
}
func TestBeforeCloseHidesWindowAfterTrayIsReady(t *testing.T) {
oldHide := hideNativeWindow
defer func() { hideNativeWindow = oldHide }()
hideCalls := 0
hideNativeWindow = func(context.Context) { hideCalls++ }
app := &App{}
app.SetTrayReady(true)
if prevent := app.BeforeClose(context.Background()); !prevent {
t.Fatal("BeforeClose() = false, want true while tray mode is active")
t.Fatal("BeforeClose() = false, want true while tray is ready")
}
if hideCalls != 1 {
t.Fatalf("hide calls = %d, want 1", hideCalls)
@@ -340,6 +370,22 @@ func TestTrayQuitAllowsWindowCloseAndQuitsApplication(t *testing.T) {
}
}
func TestTrayQuitRequestsNativeShutdownOnlyOnce(t *testing.T) {
oldQuit := quitNativeApplication
defer func() { quitNativeApplication = oldQuit }()
quitCalls := 0
quitNativeApplication = func(context.Context) { quitCalls++ }
app := &App{ctx: context.Background()}
app.Quit()
app.Quit()
if quitCalls != 1 {
t.Fatalf("native quit calls = %d, want 1", quitCalls)
}
}
func TestShowWindowUsesWailsContext(t *testing.T) {
oldShow := showNativeWindow
defer func() { showNativeWindow = oldShow }()
+25 -5
View File
@@ -78,9 +78,10 @@ type WindowState struct {
// Manager provides thread-safe access to app settings.
type Manager struct {
mu sync.RWMutex
config *Config
configPath string
mu sync.RWMutex
config *Config
configPath string
onLanguageChanged func(string)
}
// DefaultConfigPath returns the default path for app settings.
@@ -230,13 +231,32 @@ func (m *Manager) UpdateLanguage(language string) error {
}
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()
err := m.saveLocked()
handler := m.onLanguageChanged
m.mu.Unlock()
if err != nil {
return err
}
if handler != nil {
handler(language)
}
return nil
}
// SetLanguageChangedHandler registers the shell callback invoked after a
// language preference has been durably updated.
func (m *Manager) SetLanguageChangedHandler(handler func(string)) {
if m == nil {
return
}
m.mu.Lock()
m.onLanguageChanged = handler
m.mu.Unlock()
}
// UpdateSync replaces sync settings without changing unrelated app settings.
+29
View File
@@ -3,6 +3,7 @@ package appsettings
import (
"os"
"path/filepath"
"reflect"
"testing"
)
@@ -74,6 +75,34 @@ func TestUpdateLanguagePersistsAndPreservesUnrelatedSettings(t *testing.T) {
}
}
func TestUpdateLanguageNotifiesTheDesktopShellAfterPersisting(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
manager := NewManager(path)
if err := manager.Load(); err != nil {
t.Fatal(err)
}
var received []string
manager.SetLanguageChangedHandler(func(language string) {
received = append(received, language)
})
if err := manager.UpdateLanguage(LanguageRussian); err != nil {
t.Fatalf("UpdateLanguage: %v", err)
}
if got := manager.Get().Language; got != LanguageRussian {
t.Fatalf("persisted language = %q, want %q", got, LanguageRussian)
}
if !reflect.DeepEqual(received, []string{LanguageRussian}) {
t.Fatalf("language notifications = %#v, want [%q]", received, LanguageRussian)
}
if err := manager.UpdateLanguage("de"); err == nil {
t.Fatal("UpdateLanguage(de) succeeded, want validation failure")
}
if !reflect.DeepEqual(received, []string{LanguageRussian}) {
t.Fatalf("language notification fired after rejected update: %#v", received)
}
}
func TestLoad_CorruptConfig(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
+20
View File
@@ -0,0 +1,20 @@
package capability
const CorePluginID = "verstak-desktop"
var platformCapabilities = []string{
"verstak/core/plugin-manager/v1",
"verstak/core/capability-registry/v1",
"verstak/core/contribution-registry/v1",
"verstak/core/permissions/v1",
"verstak/core/events/v1",
"verstak/core/files/v1",
"verstak/core/workbench/v1",
"verstak/core/notifications/v1",
}
// CorePlatformCapabilities returns a copy of the capabilities registered by
// the desktop before dynamic plugins are resolved.
func CorePlatformCapabilities() []string {
return append([]string(nil), platformCapabilities...)
}
+40 -12
View File
@@ -3,25 +3,16 @@ package plugin
import (
"os"
"path/filepath"
"runtime"
"testing"
"github.com/verstak/verstak-desktop/internal/core/capability"
)
// coreCapabilities lists the 5 core capabilities that the platform registers
// before any plugins are loaded.
var coreCapabilities = []string{
"verstak/core/plugin-manager/v1",
"verstak/core/capability-registry/v1",
"verstak/core/contribution-registry/v1",
"verstak/core/permissions/v1",
"verstak/core/events/v1",
}
// registerCoreCapabilities registers the 5 core capabilities on a registry.
// registerCoreCapabilities registers the desktop's core capabilities on a registry.
func registerCoreCapabilities(t *testing.T, reg *capability.Registry) {
t.Helper()
if err := reg.Register("verstak-core", coreCapabilities); err != nil {
if err := reg.Register(capability.CorePluginID, capability.CorePlatformCapabilities()); err != nil {
t.Fatalf("failed to register core capabilities: %v", err)
}
}
@@ -76,6 +67,43 @@ func TestLifecycle_CoreCapabilitiesRegisteredBeforePlugins(t *testing.T) {
}
}
func TestBundledOfficialPluginRequirementsResolve(t *testing.T) {
plugins, errs := DiscoverPlugins([]string{bundledPluginDir(t)})
if len(errs) > 0 {
t.Fatalf("discover bundled plugins: %v", errs)
}
reg := capability.NewRegistry()
registerCoreCapabilities(t, reg)
ResolveLifecycle(plugins, reg, nil)
foundTodo := false
for _, loaded := range plugins {
if loaded.Manifest.ID == "verstak.todo" {
foundTodo = true
}
if loaded.Status == StatusMissingRequiredCapability {
t.Fatalf("bundled plugin %q is missing required capabilities: %s", loaded.Manifest.ID, loaded.Error)
}
}
if !foundTodo {
t.Fatal("bundled Todo manifest was not discovered")
}
}
func bundledPluginDir(t *testing.T) string {
t.Helper()
if configured := os.Getenv("VERSTAK_RELEASE_PLUGIN_DIR"); configured != "" {
return configured
}
_, sourceFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("could not resolve lifecycle test path")
}
return filepath.Join(filepath.Dir(sourceFile), "../../../plugins")
}
// TestLifecycle_MissingRequiredCapability verifies that when the required
// capability is NOT registered, CheckRequired reports it as missing and the
// plugin status should be set to StatusMissingRequiredCapability.
+265 -25
View File
@@ -1,20 +1,39 @@
// Package tray owns the native tray menu wiring for the desktop shell.
package tray
import "sync"
import (
"errors"
"log"
"strings"
"sync"
"sync/atomic"
)
var errBackendUnavailable = errors.New("tray backend is unavailable")
// MenuItem exposes click events from a native tray menu item.
type MenuItem interface {
Clicked() <-chan struct{}
SetTitle(title string)
SetTooltip(tooltip string)
}
// BackendCallbacks are invoked by the native tray implementation.
// A backend keeps the platform-native secondary-click handler enabled so the
// operating system opens the menu on a right click.
type BackendCallbacks struct {
Ready func()
Exit func()
LeftClick func()
}
// Backend is the platform tray implementation.
type Backend interface {
Register(onReady func(), onExit func())
SetIcon(icon []byte)
SetTooltip(tooltip string)
AddMenuItem(title, tooltip string) MenuItem
Quit()
Start(callbacks BackendCallbacks) error
SetIcon(icon []byte) error
SetTooltip(tooltip string) error
AddMenuItem(title, tooltip string) (MenuItem, error)
Stop()
}
// Actions are executed from the native tray menu.
@@ -23,11 +42,72 @@ type Actions struct {
Quit func()
}
// Labels contains the user-visible text for one tray menu locale.
type Labels struct {
ShowTitle string
ShowTooltip string
QuitTitle string
QuitTooltip string
}
func englishLabels() Labels {
return Labels{
ShowTitle: "Show Verstak",
ShowTooltip: "Show the Verstak window",
QuitTitle: "Quit",
QuitTooltip: "Quit Verstak",
}
}
func russianLabels() Labels {
return Labels{
ShowTitle: "Показать Верстак",
ShowTooltip: "Показать окно Верстака",
QuitTitle: "Выйти",
QuitTooltip: "Завершить Верстак",
}
}
// LabelsForPreference resolves the saved language preference for native tray UI.
// System locale values are intentionally passed in by the shell so this package
// stays independent of process environment and is deterministic in tests.
func LabelsForPreference(preference string, systemLocales ...string) Labels {
if strings.EqualFold(strings.TrimSpace(preference), "ru") {
return russianLabels()
}
if strings.EqualFold(strings.TrimSpace(preference), "system") {
for _, locale := range systemLocales {
locale = strings.ToLower(strings.TrimSpace(locale))
if locale == "" {
continue
}
if strings.HasPrefix(locale, "ru") {
return russianLabels()
}
return englishLabels()
}
}
return englishLabels()
}
// Controller initializes one native tray and routes its menu actions.
type Controller struct {
backend Backend
icon []byte
start sync.Once
startOnce sync.Once
readyOnce sync.Once
stopOnce sync.Once
started atomic.Bool
ready atomic.Bool
mu sync.RWMutex
labels Labels
actions Actions
show MenuItem
quit MenuItem
startErr error
readyChanged func(bool)
}
// New creates a tray controller for one application process.
@@ -35,36 +115,196 @@ func New(backend Backend, icon []byte) *Controller {
return &Controller{
backend: backend,
icon: append([]byte(nil), icon...),
labels: englishLabels(),
}
}
// Start registers the native tray without taking over the Wails event loop.
func (c *Controller) Start(actions Actions) {
if c == nil || c.backend == nil {
// SetReadyChangedHandler receives readiness changes after native initialization
// has either completed or ended. It lets the Wails close policy avoid hiding a
// window before the tray can bring it back.
func (c *Controller) SetReadyChangedHandler(handler func(bool)) {
if c == nil {
return
}
c.start.Do(func() {
c.backend.Register(func() {
c.backend.SetIcon(c.icon)
c.backend.SetTooltip("Verstak")
show := c.backend.AddMenuItem("Show Verstak", "Show the Verstak window")
quit := c.backend.AddMenuItem("Quit", "Quit Verstak")
if actions.Show != nil && show != nil {
go routeClicks(show.Clicked(), actions.Show)
}
if actions.Quit != nil && quit != nil {
go routeClicks(quit.Clicked(), actions.Quit)
}
}, nil)
c.mu.Lock()
c.readyChanged = handler
c.mu.Unlock()
}
// Ready reports whether the tray has completed icon and menu initialization.
func (c *Controller) Ready() bool {
return c != nil && c.ready.Load()
}
// SetLabels updates the current and future native tray menu labels.
func (c *Controller) SetLabels(labels Labels) {
if c == nil {
return
}
c.mu.Lock()
c.labels = labels
show, quit := c.show, c.quit
c.mu.Unlock()
applyLabels(show, quit, labels)
}
// Start connects the tray to Wails without taking over Wails' GUI event loop.
// Ready remains false until the backend callback and all required menu setup
// finish successfully.
func (c *Controller) Start(actions Actions) error {
if c == nil || c.backend == nil {
return errBackendUnavailable
}
c.startOnce.Do(func() {
c.mu.Lock()
c.actions = actions
c.mu.Unlock()
c.started.Store(true)
log.Printf("[tray] starting native backend")
err := c.backend.Start(BackendCallbacks{
Ready: c.onReady,
Exit: c.onExit,
LeftClick: c.onLeftClick,
})
if err != nil {
c.mu.Lock()
c.startErr = err
c.mu.Unlock()
c.started.Store(false)
c.setReady(false)
log.Printf("[tray] backend start failed: %v; falling back to normal window close", err)
}
})
c.mu.RLock()
err := c.startErr
c.mu.RUnlock()
return err
}
func (c *Controller) onReady() {
if c == nil {
return
}
if !c.started.Load() {
log.Printf("[tray] ready callback ignored after backend startup failed")
return
}
c.readyOnce.Do(func() {
log.Printf("[tray] native backend reported ready")
if err := c.backend.SetIcon(c.icon); err != nil {
c.fail("icon setup", err)
return
}
if err := c.backend.SetTooltip("Verstak"); err != nil {
c.fail("tooltip setup", err)
return
}
c.mu.RLock()
labels := c.labels
actions := c.actions
c.mu.RUnlock()
show, err := c.backend.AddMenuItem(labels.ShowTitle, labels.ShowTooltip)
if err != nil || show == nil {
if err == nil {
err = errors.New("show menu item is nil")
}
c.fail("show menu creation", err)
return
}
quit, err := c.backend.AddMenuItem(labels.QuitTitle, labels.QuitTooltip)
if err != nil || quit == nil {
if err == nil {
err = errors.New("quit menu item is nil")
}
c.fail("quit menu creation", err)
return
}
c.mu.Lock()
c.show, c.quit = show, quit
labels = c.labels
c.mu.Unlock()
applyLabels(show, quit, labels)
if actions.Show != nil {
go routeClicks(show.Clicked(), func() {
log.Printf("[tray] Show command")
actions.Show()
})
}
if actions.Quit != nil {
go routeClicks(quit.Clicked(), func() {
log.Printf("[tray] Quit command")
actions.Quit()
})
}
c.setReady(true)
log.Printf("[tray] native tray is ready")
})
}
func (c *Controller) onLeftClick() {
if c == nil {
return
}
log.Printf("[tray] left click")
if !c.Ready() {
log.Printf("[tray] left click ignored while tray is not ready")
return
}
c.mu.RLock()
show := c.actions.Show
c.mu.RUnlock()
if show != nil {
show()
}
}
func (c *Controller) onExit() {
if c == nil {
return
}
c.setReady(false)
log.Printf("[tray] native message loop ended; falling back to normal window close")
}
func (c *Controller) fail(stage string, err error) {
c.setReady(false)
log.Printf("[tray] %s failed: %v; falling back to normal window close", stage, err)
c.Stop()
}
func (c *Controller) setReady(ready bool) {
if c == nil || c.ready.Swap(ready) == ready {
return
}
c.mu.RLock()
handler := c.readyChanged
c.mu.RUnlock()
if handler != nil {
handler(ready)
}
}
func applyLabels(show, quit MenuItem, labels Labels) {
if show != nil {
show.SetTitle(labels.ShowTitle)
show.SetTooltip(labels.ShowTooltip)
}
if quit != nil {
quit.SetTitle(labels.QuitTitle)
quit.SetTooltip(labels.QuitTooltip)
}
}
// Stop releases the native tray after Wails has begun application shutdown.
func (c *Controller) Stop() {
if c == nil || c.backend == nil {
if c == nil || c.backend == nil || !c.started.Load() {
return
}
c.backend.Quit()
c.stopOnce.Do(func() {
c.setReady(false)
log.Printf("[tray] stopping native backend")
c.backend.Stop()
})
}
func routeClicks(clicked <-chan struct{}, action func()) {
+166 -30
View File
@@ -1,47 +1,77 @@
package tray
import (
"errors"
"sync"
"testing"
"time"
)
type fakeMenuItem struct {
clicked chan struct{}
title string
tooltip string
}
func (i *fakeMenuItem) Clicked() <-chan struct{} {
return i.clicked
}
func (i *fakeMenuItem) Clicked() <-chan struct{} { return i.clicked }
func (i *fakeMenuItem) SetTitle(title string) { i.title = title }
func (i *fakeMenuItem) SetTooltip(tooltip string) { i.tooltip = tooltip }
type fakeBackend struct {
icon []byte
tooltip string
items map[string]*fakeMenuItem
quitCalls int
registering bool
callbacks BackendCallbacks
icon []byte
tooltip string
items map[string]*fakeMenuItem
startErr error
iconErr error
menuErr error
starts int
stops int
}
func (b *fakeBackend) Register(onReady func(), _ func()) {
b.registering = true
onReady()
func (b *fakeBackend) Start(callbacks BackendCallbacks) error {
b.starts++
b.callbacks = callbacks
return b.startErr
}
func (b *fakeBackend) SetIcon(icon []byte) {
func (b *fakeBackend) SetIcon(icon []byte) error {
b.icon = append([]byte(nil), icon...)
return b.iconErr
}
func (b *fakeBackend) SetTooltip(tooltip string) {
func (b *fakeBackend) SetTooltip(tooltip string) error {
b.tooltip = tooltip
return nil
}
func (b *fakeBackend) AddMenuItem(title, _ string) MenuItem {
item := &fakeMenuItem{clicked: make(chan struct{}, 1)}
func (b *fakeBackend) AddMenuItem(title, tooltip string) (MenuItem, error) {
if b.menuErr != nil {
return nil, b.menuErr
}
item := &fakeMenuItem{clicked: make(chan struct{}, 1), title: title, tooltip: tooltip}
b.items[title] = item
return item
return item, nil
}
func (b *fakeBackend) Quit() {
b.quitCalls++
func (b *fakeBackend) Stop() { b.stops++ }
func (b *fakeBackend) ready() {
if b.callbacks.Ready != nil {
b.callbacks.Ready()
}
}
func (b *fakeBackend) exited() {
if b.callbacks.Exit != nil {
b.callbacks.Exit()
}
}
func (b *fakeBackend) leftClick() {
if b.callbacks.LeftClick != nil {
b.callbacks.LeftClick()
}
}
func waitFor(t *testing.T, signal <-chan struct{}) {
@@ -53,39 +83,145 @@ func waitFor(t *testing.T, signal <-chan struct{}) {
}
}
func TestControllerInitializesTrayAndRoutesMenuActions(t *testing.T) {
func TestControllerBecomesReadyOnlyAfterBackendCallback(t *testing.T) {
backend := &fakeBackend{items: make(map[string]*fakeMenuItem)}
showCalls := make(chan struct{}, 1)
quitCalls := make(chan struct{}, 1)
controller := New(backend, []byte{1, 2, 3})
controller.Start(Actions{
if err := controller.Start(Actions{}); err != nil {
t.Fatalf("Start() error = %v", err)
}
if controller.Ready() {
t.Fatal("tray became ready before backend callback")
}
backend.ready()
if !controller.Ready() {
t.Fatal("tray did not become ready after backend callback")
}
if string(backend.icon) != string([]byte{1, 2, 3}) || backend.tooltip != "Verstak" {
t.Fatalf("tray initialization = icon:%v tooltip:%q", backend.icon, backend.tooltip)
}
}
func TestControllerRoutesLeftClickAndMenuActionsToShowAndQuit(t *testing.T) {
backend := &fakeBackend{items: make(map[string]*fakeMenuItem)}
showCalls := make(chan struct{}, 2)
quitCalls := make(chan struct{}, 1)
controller := New(backend, []byte{1})
if err := controller.Start(Actions{
Show: func() { showCalls <- struct{}{} },
Quit: func() { quitCalls <- struct{}{} },
})
if !backend.registering || string(backend.icon) != string([]byte{1, 2, 3}) || backend.tooltip != "Verstak" {
t.Fatalf("tray initialization = registering:%t icon:%v tooltip:%q", backend.registering, backend.icon, backend.tooltip)
}); err != nil {
t.Fatalf("Start() error = %v", err)
}
backend.ready()
backend.leftClick()
waitFor(t, showCalls)
showItem := backend.items["Show Verstak"]
quitItem := backend.items["Quit"]
if showItem == nil || quitItem == nil {
t.Fatalf("tray menu = %#v, want Show Verstak and Quit", backend.items)
}
showItem.clicked <- struct{}{}
waitFor(t, showCalls)
quitItem.clicked <- struct{}{}
waitFor(t, quitCalls)
}
func TestControllerStopsNativeTrayBackend(t *testing.T) {
func TestControllerStopCallsBackendOnce(t *testing.T) {
backend := &fakeBackend{items: make(map[string]*fakeMenuItem)}
controller := New(backend, []byte{1})
if err := controller.Start(Actions{}); err != nil {
t.Fatalf("Start() error = %v", err)
}
controller.Stop()
controller.Stop()
if backend.quitCalls != 1 {
t.Fatalf("backend quit calls = %d, want 1", backend.quitCalls)
if backend.stops != 1 {
t.Fatalf("backend stop calls = %d, want 1", backend.stops)
}
}
func TestControllerStartupAndSetupFailuresNeverBecomeReady(t *testing.T) {
for name, backend := range map[string]*fakeBackend{
"start": {items: make(map[string]*fakeMenuItem), startErr: errors.New("start failed")},
"icon": {items: make(map[string]*fakeMenuItem), iconErr: errors.New("icon failed")},
"menu": {items: make(map[string]*fakeMenuItem), menuErr: errors.New("menu failed")},
} {
t.Run(name, func(t *testing.T) {
controller := New(backend, []byte{1})
err := controller.Start(Actions{})
if name == "start" && err == nil {
t.Fatal("Start() error = nil, want startup failure")
}
if name != "start" && err != nil {
t.Fatalf("Start() error = %v, want nil before ready callback", err)
}
backend.ready()
if controller.Ready() {
t.Fatal("failed tray became ready")
}
})
}
}
func TestControllerExitRevokesReadinessAndKeepsLocalizedMenuItems(t *testing.T) {
backend := &fakeBackend{items: make(map[string]*fakeMenuItem)}
controller := New(backend, []byte{1})
controller.SetLabels(LabelsForPreference("ru"))
if err := controller.Start(Actions{}); err != nil {
t.Fatalf("Start() error = %v", err)
}
backend.ready()
show := backend.items["Показать Верстак"]
quit := backend.items["Выйти"]
if show == nil || quit == nil {
t.Fatalf("Russian tray menu = %#v", backend.items)
}
controller.SetLabels(LabelsForPreference("en"))
if show.title != "Show Verstak" || quit.title != "Quit" {
t.Fatalf("English tray menu after update = show:%q quit:%q", show.title, quit.title)
}
backend.exited()
if controller.Ready() {
t.Fatal("tray stayed ready after backend exit")
}
}
func TestLabelsForSystemRussianLocale(t *testing.T) {
labels := LabelsForPreference("system", "", "ru_RU.UTF-8", "en_US.UTF-8")
if labels.ShowTitle != "Показать Верстак" || labels.QuitTitle != "Выйти" {
t.Fatalf("system Russian labels = %#v", labels)
}
labels = LabelsForPreference("system", "C.UTF-8", "", "ru_RU.UTF-8")
if labels.ShowTitle != "Show Verstak" || labels.QuitTitle != "Quit" {
t.Fatalf("higher-priority system locale must win, got %#v", labels)
}
}
func TestControllerReadyNotificationIsSafeAcrossCallbacks(t *testing.T) {
backend := &fakeBackend{items: make(map[string]*fakeMenuItem)}
controller := New(backend, []byte{1})
var changes []bool
var mu sync.Mutex
controller.SetReadyChangedHandler(func(ready bool) {
mu.Lock()
changes = append(changes, ready)
mu.Unlock()
})
if err := controller.Start(Actions{}); err != nil {
t.Fatalf("Start() error = %v", err)
}
backend.ready()
backend.exited()
mu.Lock()
defer mu.Unlock()
if len(changes) != 2 || !changes[0] || changes[1] {
t.Fatalf("ready changes = %#v, want [true false]", changes)
}
}
-14
View File
@@ -1,14 +0,0 @@
package tray
import "encoding/base64"
const iconPNGBase64 = "iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAACT1BMVEUAAACOjo7///8RERHY2NghISEDAwOjo6MODg7GxsYQEBDIyMgPDw/Hx8cKCgq9vb12dnaHh4fFxcV1dXWjo6OoqKinp6eNjY25ubnr6+v29vb29vb29vbx8fHV1dXm5ub9/f3z8/OUlJQAAADu7u74+Pi5ubkAAADu7u74+Pi6urq6urrs7Oz39/ezs7PZ2dn5+fn8/Pzq6uobGxtdXV3U1NTl5eXl5eXl5eXm5ubd3d2urq7////+/v7//v7//v/s7OzLy8vLy8ru7u708/POzc3Kysrd3d39/f3Z2dnU1NT7+/u0tLQpKSkoKCcnJycsLCy+vr63t7crKyskJCRzc3NQUFAlJSUoKChvb28gICAdHR0cHBzDwsJ6enoaGhkZGRltbW3MzMwqKioeHh6pqam+vb0iIiJGRkYbGxseHR0aGhpubm6cnJw6Ojrg4ODExMQmJiYeHh36+vppaWhxcXH8/PwvLy/a2tmKiorl5eU+Pj6zs7PQ0NA1NTXR0dFSUlIbGhq/v79BQUHn5+fW1tYuLi48PDyysrItLS02NjaNjY17e3v///7b29syMjJ/f38hISEqKSlqampaWlq9vb3f398cGxs9PT1KSko3NzeioqJRUVFJSUnk5OQaGRljY2OGhobp6ek/Pz+dnZ3IyMgoKSjHx8ZDQ0PHx8fx8fHw8PBVVVX39/cdHh2RkZHz8/NPT0+Ojo7Dw8PPz8/29vZUVFTCwsL19fX4+PhYWFgWFhYXFxfAwMCqqqqFhYWHhoaEhITe3t6MjIzi4uIwLXXJAAAAPHRSTlMAAAAAAAAAAAAAAAAAAAAAAAAABRIWFQoUis3R0K82b/22DQGZ2x0Cm9wfHo/UGULi+IIEAzZudHNzUA0lLC8OAAAAAWJLR0QCZgt8ZAAAAAd0SU1FB+oHDRIyN3EuEOkAAAHzSURBVDjLvdPXXxNBEAdwPHvvFewde8WyG3NGT41KlFxMwoyKguLeCCZRUCzRgIXYUexEjR17713/MPfCB+Vy8ZXfw+3tzvdlbueyslolbfr26z8gQwYqbVP1doMGD8nOsSU7Z+gwpb2sd1CGj2CZM3JURwk6jR4j37l5wDlvejrkssjBxo7rLEGX8RPk1qmqi1UXW7JU05bx5Ss0VXOvZBNzu/4Fq1bne9asZQVej+7jrnX+gB6UYNI/UFAIqK9nGzZC0Sa2uRihZAuzgK2lAB7VtU0YtJ2VIWD5DisI+QG87lAYIhTcuYsiVFFpBVo+4O49VQKAKvbuE1C6XzbWEhwoBowePBSDEvJV+0HUHE4DR44iHquN43G9yHdCROgkP2UFp8+gqDkLdcFzdL6ewH9Bfi8LuBjFS5ev0NVrAdEQRZFwpoPr5QiB8I2b1WHw1oFIMkcauHVbGAB4565sF+De/dS9tATOB8IwMO5uNEGs4aENPEpIQI+fhHR4CvjMrFvB8xcxA8RL9kqHCMZf28Gbt2RAoJG9k4Dq39vBh4+f6HPCxb4UElFZqm4FlV+/ff9Ry9nPqmTy128L6GYCR/OwNY9eE8jtLkEPZTL/z9BOmdrTHHtl2nSeMTNmzuplgt6z58zNm2dL3vwFC/u0yo/7B9C02RGfGBOeAAAAJXRFWHRkYXRlOmNyZWF0ZQAyMDI2LTA3LTEzVDE0OjIyOjMzKzAwOjAwJHU36gAAACV0RVh0ZGF0ZTptb2RpZnkAMjAyNi0wNy0xM1QxNDoyMjozMyswMDowMFUoj1YAAAAASUVORK5CYII="
// DefaultIcon returns the compact source-controlled Verstak tray icon.
func DefaultIcon() []byte {
icon, err := base64.StdEncoding.DecodeString(iconPNGBase64)
if err != nil {
return nil
}
return icon
}
+16
View File
@@ -0,0 +1,16 @@
//go:build linux
package tray
import _ "embed"
//go:embed verstak.png
var defaultIcon []byte
// DefaultIcon returns the Linux PNG tray icon embedded in the binary.
func DefaultIcon() []byte {
return append([]byte(nil), defaultIcon...)
}
// IconFileExtension is used when the backend materializes the embedded icon.
func IconFileExtension() string { return ".png" }
+18
View File
@@ -0,0 +1,18 @@
//go:build linux
package tray
import "testing"
func TestDefaultIconUsesPlatformResource(t *testing.T) {
icon := DefaultIcon()
if len(icon) == 0 {
t.Fatal("DefaultIcon() returned no data")
}
if IconFileExtension() != ".png" {
t.Fatalf("IconFileExtension() = %q, want .png on Linux", IconFileExtension())
}
if string(icon[:8]) != "\x89PNG\r\n\x1a\n" {
t.Fatal("DefaultIcon() is not PNG data on Linux")
}
}
+16
View File
@@ -0,0 +1,16 @@
//go:build windows
package tray
import _ "embed"
//go:embed verstak.ico
var defaultIcon []byte
// DefaultIcon returns the multi-resolution Windows ICO embedded in the binary.
func DefaultIcon() []byte {
return append([]byte(nil), defaultIcon...)
}
// IconFileExtension is used when the backend materializes the embedded icon.
func IconFileExtension() string { return ".ico" }
+37
View File
@@ -0,0 +1,37 @@
//go:build windows
package tray
import (
"encoding/binary"
"testing"
)
func TestDefaultIconIsMultiResolutionICO(t *testing.T) {
icon := DefaultIcon()
if IconFileExtension() != ".ico" || len(icon) < 6 {
t.Fatalf("Windows tray icon is not ICO data")
}
if binary.LittleEndian.Uint16(icon[0:2]) != 0 || binary.LittleEndian.Uint16(icon[2:4]) != 1 {
t.Fatal("Windows tray icon has an invalid ICO header")
}
count := int(binary.LittleEndian.Uint16(icon[4:6]))
if count < 6 {
t.Fatalf("ICO image count = %d, want at least 6", count)
}
want := map[int]bool{16: false, 20: false, 24: false, 32: false, 48: false, 256: false}
for offset := 6; offset+16 <= len(icon) && offset < 6+count*16; offset += 16 {
size := int(icon[offset])
if size == 0 {
size = 256
}
if _, ok := want[size]; ok {
want[size] = true
}
}
for size, found := range want {
if !found {
t.Errorf("ICO is missing %dx%d image", size, size)
}
}
}
+130 -13
View File
@@ -1,36 +1,141 @@
package tray
import "github.com/getlantern/systray"
import (
"errors"
"fmt"
"os"
"strings"
"sync"
type systrayBackend struct{}
"fyne.io/systray"
)
var (
runWithExternalLoop = systray.RunWithExternalLoop
setOnTapped = systray.SetOnTapped
)
type systrayBackend struct {
mu sync.Mutex
end func()
iconPath string
started bool
stopRequested bool
}
type systrayMenuItem struct {
item *systray.MenuItem
}
// NewNativeBackend creates the cross-platform native tray backend.
// NewNativeBackend creates the cross-platform tray backend. It uses
// RunWithExternalLoop so the native tray message loop runs alongside Wails.
func NewNativeBackend() Backend {
return systrayBackend{}
return &systrayBackend{}
}
func (systrayBackend) Register(onReady func(), onExit func()) {
systray.Register(onReady, onExit)
func (b *systrayBackend) Start(callbacks BackendCallbacks) (err error) {
if b == nil {
return errBackendUnavailable
}
b.mu.Lock()
if b.started {
b.mu.Unlock()
return errors.New("native tray backend was already started")
}
b.started = true
b.mu.Unlock()
defer func() {
if recovered := recover(); recovered != nil {
err = fmt.Errorf("native tray startup panic: %v", recovered)
}
}()
start, end := runWithExternalLoop(callbacks.Ready, callbacks.Exit)
if callbacks.LeftClick != nil {
setOnTapped(callbacks.LeftClick)
}
// The Ready callback is asynchronous on Windows and can synchronously
// decide that startup failed. Start the native loop before publishing the
// end function so a concurrent Stop is never left with a half-started loop.
start()
b.mu.Lock()
stopRequested := b.stopRequested
if !stopRequested {
b.end = end
}
b.mu.Unlock()
if stopRequested {
end()
}
return nil
}
func (systrayBackend) SetIcon(icon []byte) {
systray.SetIcon(icon)
func (b *systrayBackend) SetIcon(icon []byte) error {
if b == nil || len(icon) == 0 {
return errors.New("tray icon is empty")
}
file, err := os.CreateTemp("", "verstak-tray-*"+IconFileExtension())
if err != nil {
return fmt.Errorf("create tray icon file: %w", err)
}
path := file.Name()
if _, err := file.Write(icon); err != nil {
file.Close()
os.Remove(path)
return fmt.Errorf("write tray icon file: %w", err)
}
if err := file.Close(); err != nil {
os.Remove(path)
return fmt.Errorf("close tray icon file: %w", err)
}
if err := systray.SetIconFromFilePath(path); err != nil {
os.Remove(path)
return fmt.Errorf("set native tray icon: %w", err)
}
b.mu.Lock()
previous := b.iconPath
b.iconPath = path
b.mu.Unlock()
if previous != "" && previous != path {
_ = os.Remove(previous)
}
return nil
}
func (systrayBackend) SetTooltip(tooltip string) {
func (b *systrayBackend) SetTooltip(tooltip string) error {
if strings.TrimSpace(tooltip) == "" {
return errors.New("tray tooltip is empty")
}
systray.SetTooltip(tooltip)
return nil
}
func (systrayBackend) AddMenuItem(title, tooltip string) MenuItem {
return systrayMenuItem{item: systray.AddMenuItem(title, tooltip)}
func (b *systrayBackend) AddMenuItem(title, tooltip string) (MenuItem, error) {
if strings.TrimSpace(title) == "" {
return nil, errors.New("tray menu title is empty")
}
item := systray.AddMenuItem(title, tooltip)
if item == nil {
return nil, errors.New("native tray menu item is nil")
}
return systrayMenuItem{item: item}, nil
}
func (systrayBackend) Quit() {
systray.Quit()
func (b *systrayBackend) Stop() {
if b == nil {
return
}
b.mu.Lock()
end, iconPath := b.end, b.iconPath
b.end = nil
b.iconPath = ""
b.stopRequested = true
b.mu.Unlock()
if end != nil {
end()
}
if iconPath != "" {
_ = os.Remove(iconPath)
}
}
func (item systrayMenuItem) Clicked() <-chan struct{} {
@@ -39,3 +144,15 @@ func (item systrayMenuItem) Clicked() <-chan struct{} {
}
return item.item.ClickedCh
}
func (item systrayMenuItem) SetTitle(title string) {
if item.item != nil {
item.item.SetTitle(title)
}
}
func (item systrayMenuItem) SetTooltip(tooltip string) {
if item.item != nil {
item.item.SetTooltip(tooltip)
}
}
@@ -0,0 +1,30 @@
package tray
import "testing"
func TestBackendStopsWhenReadyCallbackStopsDuringStartup(t *testing.T) {
originalRun := runWithExternalLoop
originalSetOnTapped := setOnTapped
defer func() {
runWithExternalLoop = originalRun
setOnTapped = originalSetOnTapped
}()
var startCalls, endCalls int
runWithExternalLoop = func(onReady, _ func()) (func(), func()) {
onReady()
return func() { startCalls++ }, func() { endCalls++ }
}
setOnTapped = func(func()) {}
backend := &systrayBackend{}
if err := backend.Start(BackendCallbacks{Ready: backend.Stop}); err != nil {
t.Fatalf("Start: %v", err)
}
if startCalls != 1 {
t.Fatalf("start calls = %d, want 1", startCalls)
}
if endCalls != 1 {
t.Fatalf("end calls = %d, want 1", endCalls)
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

+13 -12
View File
@@ -86,17 +86,8 @@ func main() {
}
// ─── Register Core Capabilities ─────────────────────────
corePluginID := "verstak-desktop"
coreCaps := []string{
"verstak/core/plugin-manager/v1",
"verstak/core/capability-registry/v1",
"verstak/core/contribution-registry/v1",
"verstak/core/permissions/v1",
"verstak/core/events/v1",
"verstak/core/files/v1",
"verstak/core/workbench/v1",
"verstak/core/notifications/v1",
}
corePluginID := capability.CorePluginID
coreCaps := capability.CorePlatformCapabilities()
if err := capRegistry.Register(corePluginID, coreCaps); err != nil {
log.Fatalf("[main] failed to register core capabilities: %v", err)
}
@@ -239,6 +230,14 @@ func main() {
app = api.NewApp(capRegistry, contribRegistry, permRegistry, eventBus, plugins, vaultService, storageService, filesService, appSettingsMgr, pluginStateMgr, workspaceMgr, syncService, browserReceiver, debugEnabled)
app.SetNotificationService(notifications.New(vaultService, api.NewNativeNotificationSender(), time.Now))
trayController := tray.New(tray.NewNativeBackend(), tray.DefaultIcon())
trayController.SetReadyChangedHandler(app.SetTrayReady)
trayLabels := func(language string) tray.Labels {
return tray.LabelsForPreference(language, os.Getenv("LC_ALL"), os.Getenv("LC_MESSAGES"), os.Getenv("LANG"))
}
trayController.SetLabels(trayLabels(cfg.Language))
appSettingsMgr.SetLanguageChangedHandler(func(language string) {
trayController.SetLabels(trayLabels(language))
})
if browserReceiver != nil {
browserReceiverServer, err := browserreceiver.Start(browserreceiver.DefaultAddr, browserReceiver)
if err != nil {
@@ -260,7 +259,9 @@ func main() {
OnStartup: app.Startup,
OnDomReady: func(ctx context.Context) {
app.DomReady(ctx)
trayController.Start(tray.Actions{Show: app.ShowWindow, Quit: app.Quit})
if err := trayController.Start(tray.Actions{Show: app.ShowWindow, Quit: app.Quit}); err != nil {
log.Printf("[tray] disabled: %v; normal window close will exit", err)
}
},
OnShutdown: func(ctx context.Context) {
trayController.Stop()
+1 -1
View File
@@ -3,7 +3,7 @@ Version: @VERSION@
Section: utils
Priority: optional
Architecture: amd64
Depends: libwebkit2gtk-4.1-0, libgtk-3-0t64 | libgtk-3-0, libayatana-appindicator3-1
Depends: libwebkit2gtk-4.1-0, libgtk-3-0t64 | libgtk-3-0
Maintainer: Verstak contributors <dev@verstak.app>
Homepage: https://github.com/mirivlad/verstak
Description: Local-first workspace
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
set -eu
if command -v update-desktop-database >/dev/null 2>&1; then
update-desktop-database -q /usr/share/applications || true
fi
if command -v gtk-update-icon-cache >/dev/null 2>&1; then
gtk-update-icon-cache -q -t -f /usr/share/icons/hicolor || true
fi
exit 0
+1 -1
View File
@@ -10,4 +10,4 @@ export GST_PLUGIN_SYSTEM_PATH_1_0="$HERE/usr/lib/gstreamer-1.0"
export WEBKIT_EXEC_PATH="$HERE/usr/lib/webkit2gtk-4.1"
cd "$HERE/usr/bin"
exec "$HERE/usr/bin/verstak-desktop" "$@"
exec "$HERE/usr/bin/verstak" "$@"
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env sh
set -eu
HERE="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
exec "$HERE/verstak-desktop" "$@"
+4 -2
View File
@@ -2,8 +2,10 @@
Type=Application
Name=Verstak
Comment=Local-first workspace
Exec=verstak-desktop %U
Exec=verstak %U
Icon=verstak
Terminal=false
Categories=Office;Utility;
Categories=Office;
StartupNotify=true
X-Verstak-Desktop-Entry=true
X-AppImage-Name=Verstak
+7
View File
@@ -0,0 +1,7 @@
## Highlights
- First public alpha of the local Verstak workbench.
- Introduces Deals, workspace templates, the Overview, Files, Notes, Journal, activity tracking, browser intake, and the official plugin system.
- Includes portable Windows, Debian, and AppImage packages for early testing.
**Commit history**: https://github.com/mirivlad/verstak/commits/v0.1.0-alpha.1
+7
View File
@@ -0,0 +1,7 @@
## Highlights
- Adds scheduled native notifications and Todo reminders.
- Keeps Verstak available in the system tray, including the required Linux tray runtime in packages.
- Improves the public alpha installation and troubleshooting documentation.
**Full Changelog**: https://github.com/mirivlad/verstak/compare/v0.1.0-alpha.1...v0.1.0-alpha.2
+7
View File
@@ -0,0 +1,7 @@
## Highlights
- Replaces technical workspace terminology in the main interface with the user-facing term “Deal”.
- Improves Russian localization, operation errors, template guidance, and the plugin manager filters.
- Makes global Journal and Secrets flows usable and improves Todo reminder time selection.
**Full Changelog**: https://github.com/mirivlad/verstak/compare/v0.1.0-alpha.2...v0.1.0-alpha.3
+9
View File
@@ -0,0 +1,9 @@
## Highlights
- The Deal rename dialog now uses the selected application language.
- Native tray actions use the selected language and refresh when it changes.
- The bundled official plugins include clearer, localized file and folder labels.
## Packages
This prerelease provides a portable Windows archive, a Debian package, and an AppImage for Linux.
+31
View File
@@ -0,0 +1,31 @@
## Highlights
- Fixed the Windows 10 system-tray integration: the release now embeds a real
multi-resolution Windows icon and runs the native tray message loop.
- One left click on the tray icon restores and focuses Verstak. A right click
opens the native, localized **Show Verstak** and **Quit** menu.
- Closing the window now hides it only after the tray is confirmed ready. If
the tray cannot start, the app exits normally instead of becoming unreachable.
- Replaced the obsolete tray dependency with `fyne.io/systray`, removing the
AppIndicator requirement from Linux builds, Debian packages, and AppImages.
## Главное
- Исправлена интеграция с системным треем в Windows 10: релиз содержит
настоящий многомасштабный значок Windows и запускает нативный цикл сообщений
трея.
- Один левый клик по значку возвращает и фокусирует Верстак. Правый клик
открывает нативное локализованное меню **«Показать Верстак»** и **«Выйти»**.
- Окно скрывается только после подтверждённой готовности трея. Если трей не
запускается, приложение завершается обычным образом и не остаётся
недоступным.
- Устаревшая зависимость трея заменена на `fyne.io/systray`; AppIndicator
больше не нужен для Linux-сборки, Debian-пакета и AppImage.
## Packages / Пакеты
This prerelease provides a portable Windows archive, a Debian package, and an
AppImage for Linux.
В этот prerelease входят переносимый архив для Windows, Debian-пакет и AppImage
для Linux.
+1
View File
@@ -13,6 +13,7 @@ fi
echo "=== verstak desktop Linux amd64 bundle ==="
(cd "$OFFICIAL_PLUGINS" && ./scripts/build.sh)
"$ROOT/scripts/install-dev-plugins.sh"
(cd "$ROOT" && go test ./internal/core/plugin -run TestBundledOfficialPluginRequirementsResolve -count=1)
"$ROOT/scripts/build.sh"
BINARY="$ROOT/build/bin/verstak-desktop"
+1
View File
@@ -52,6 +52,7 @@ if [[ ! -d "$WINDOWS_PLUGIN_DIST" ]]; then
echo "Windows plugin packages were not produced: $WINDOWS_PLUGIN_DIST" >&2
exit 1
fi
VERSTAK_RELEASE_PLUGIN_DIR="$WINDOWS_PLUGIN_DIST" go test ./internal/core/plugin -run TestBundledOfficialPluginRequirementsResolve -count=1
# Wails' -compiler option selects a Go binary, not a C compiler. Cross-CGO
# therefore has to be supplied through the standard Go environment instead.
-9
View File
@@ -26,15 +26,6 @@ if ! command -v npm &>/dev/null; then
fi
echo " ✅ npm $(npm --version)"
if [[ "$(go env GOOS)" == "linux" ]]; then
if ! command -v pkg-config &>/dev/null || ! pkg-config --exists ayatana-appindicator3-0.1; then
echo " ❌ native tray development files are missing"
echo " Debian/Ubuntu: sudo apt install libayatana-appindicator3-dev"
exit 1
fi
echo " ✅ ayatana-appindicator3 $(pkg-config --modversion ayatana-appindicator3-0.1)"
fi
# ── Frontend (build first — Go //go:embed needs frontend/dist/) ──
echo ""
echo "[frontend]"
+1 -5
View File
@@ -45,6 +45,7 @@ mkdir -p "$APPDIR/usr/bin" "$APPDIR/usr/lib" "$APPDIR/usr/share/applications" \
"$APPDIR/usr/share/icons/hicolor/scalable/apps"
install -m 755 "$ROOT/packaging/linux/AppRun" "$APPDIR/AppRun"
install -m 755 "$BUNDLE/verstak-desktop" "$APPDIR/usr/bin/verstak-desktop"
install -m 755 "$ROOT/packaging/linux/verstak" "$APPDIR/usr/bin/verstak"
install -m 644 "$ROOT/packaging/linux/verstak.desktop" "$APPDIR/verstak.desktop"
install -m 644 "$ROOT/packaging/linux/verstak.desktop" "$APPDIR/usr/share/applications/verstak.desktop"
install -m 644 "$ROOT/packaging/linux/verstak.svg" "$APPDIR/verstak.svg"
@@ -105,11 +106,6 @@ while IFS= read -r candidate; do
done < "$APPDIR/.elf-queue"
rm -f "$APPDIR/.elf-queue"
if ! compgen -G "$APPDIR/usr/lib/libayatana-appindicator3.so.*" >/dev/null; then
echo "native tray runtime library was not bundled" >&2
exit 1
fi
if command -v glib-compile-schemas >/dev/null; then
glib-compile-schemas "$APPDIR/usr/share/glib-2.0/schemas"
fi
+1
View File
@@ -28,6 +28,7 @@ mkdir -p "$STAGING/DEBIAN" "$STAGING/opt/verstak" "$STAGING/usr/bin" \
"$STAGING/usr/share/doc/verstak"
sed "s/@VERSION@/$PACKAGE_VERSION/g" "$ROOT/packaging/deb/control" > "$STAGING/DEBIAN/control"
install -m 755 "$ROOT/packaging/deb/postinst" "$STAGING/DEBIAN/postinst"
install -m 755 "$ROOT/packaging/deb/verstak" "$STAGING/usr/bin/verstak"
install -m 755 "$BUNDLE/verstak-desktop" "$STAGING/opt/verstak/verstak-desktop"
install -m 644 "$BUNDLE/README.md" "$BUNDLE/LICENSE" "$STAGING/usr/share/doc/verstak/"
+12 -1
View File
@@ -6,6 +6,7 @@ VERSION="${1:-}"
REPOSITORY="mirivlad/verstak"
RELEASE_SCRIPT="${VERSTAK_RELEASE_SCRIPT:-$ROOT/scripts/release.sh}"
RELEASE_DIR="${VERSTAK_RELEASE_DIR:-$ROOT/release}"
RELEASE_NOTES_DIR="${VERSTAK_RELEASE_NOTES_DIR:-$ROOT/release-notes}"
GIT_BIN="${GIT_BIN:-git}"
GH_BIN="${GH_BIN:-gh}"
@@ -65,7 +66,17 @@ fi
if "$GH_BIN" release view "$VERSION" --repo "$REPOSITORY" >/dev/null 2>&1; then
"$GH_BIN" release upload "$VERSION" "${ASSETS[@]}" --repo "$REPOSITORY" --clobber
else
RELEASE_OPTIONS=(--generate-notes --verify-tag)
NOTES_FILE="$RELEASE_NOTES_DIR/$VERSION.md"
if [[ ! -s "$NOTES_FILE" ]]; then
echo "human-readable release notes are required: $NOTES_FILE" >&2
exit 1
fi
RELEASE_OPTIONS=(--notes-file "$NOTES_FILE" --generate-notes --verify-tag)
PREVIOUS_TAG="$("$GIT_BIN" describe --tags --abbrev=0 "${HEAD}^" 2>/dev/null || true)"
if [[ -n "$PREVIOUS_TAG" ]]; then
RELEASE_OPTIONS+=(--notes-start-tag "$PREVIOUS_TAG")
fi
if [[ "$VERSION" == *-* ]]; then
RELEASE_OPTIONS+=(--prerelease)
else
+20 -3
View File
@@ -15,12 +15,27 @@ done
grep -Fq 'dpkg-deb' "$ROOT/scripts/package-deb.sh"
grep -Fq -- '--build' "$ROOT/scripts/package-deb.sh"
grep -Fq 'packaging/deb/control' "$ROOT/scripts/package-deb.sh"
grep -Fq 'packaging/deb/postinst' "$ROOT/scripts/package-deb.sh"
test -x "$ROOT/packaging/deb/postinst"
sh -n "$ROOT/packaging/deb/postinst"
grep -Fxq 'Exec=verstak %U' "$ROOT/packaging/linux/verstak.desktop"
grep -Fxq 'X-Verstak-Desktop-Entry=true' "$ROOT/packaging/linux/verstak.desktop"
grep -Fxq 'Categories=Office;' "$ROOT/packaging/linux/verstak.desktop"
grep -Fq 'update-desktop-database' "$ROOT/packaging/deb/postinst"
grep -Fq 'gtk-update-icon-cache' "$ROOT/packaging/deb/postinst"
grep -Fq 'packaging/linux/verstak' "$ROOT/scripts/package-appimage.sh"
grep -Fq 'usr/bin/verstak' "$ROOT/packaging/linux/AppRun"
grep -Fq 'libwebkit2gtk-4.1-0' "$ROOT/packaging/deb/control"
grep -Fq 'libayatana-appindicator3-1' "$ROOT/packaging/deb/control"
if grep -Fq 'appindicator' "$ROOT/packaging/deb/control"; then
echo "Debian package must not require the removed AppIndicator tray backend" >&2
exit 1
fi
grep -Fq 'appimagetool' "$ROOT/scripts/package-appimage.sh"
grep -Fq 'WebKitWebProcess' "$ROOT/scripts/package-appimage.sh"
grep -Fq 'libayatana-appindicator3.so.' "$ROOT/scripts/package-appimage.sh"
grep -Fq 'ayatana-appindicator3-0.1' "$ROOT/scripts/build.sh"
if grep -Fq 'appindicator' "$ROOT/scripts/package-appimage.sh" "$ROOT/scripts/build.sh"; then
echo "Linux build and AppImage scripts must not require AppIndicator" >&2
exit 1
fi
if grep -Fq 'FixedVersionRuntime' "$ROOT/scripts/package-windows-portable.sh"; then
echo "Windows portable archive must not bundle Fixed Version WebView2" >&2
exit 1
@@ -41,5 +56,7 @@ grep -Fq 'package-appimage.sh' "$ROOT/scripts/release.sh"
grep -Fq 'package-windows-portable.sh' "$ROOT/scripts/release.sh"
git -C "$ROOT" check-ignore -q verstak-desktop-res.syso
grep -Fq 'chmod -R a+rX' "$ROOT/scripts/build-linux-bundle.sh"
grep -Fq 'TestBundledOfficialPluginRequirementsResolve' "$ROOT/scripts/build-linux-bundle.sh"
grep -Fq 'TestBundledOfficialPluginRequirementsResolve' "$ROOT/scripts/build-windows.sh"
echo "desktop package script contracts passed"
+7 -1
View File
@@ -18,9 +18,10 @@ if [[ ! -x "$PUBLISHER" ]]; then
exit 1
fi
mkdir -p "$WORK/bin" "$WORK/release" "$WORK/state"
mkdir -p "$WORK/bin" "$WORK/release" "$WORK/release-notes" "$WORK/state"
LOG="$WORK/log"
export LOG
printf '## Highlights\n\nHuman-readable release notes.\n' > "$WORK/release-notes/$VERSION.md"
cat > "$WORK/release.sh" <<'SCRIPT'
#!/usr/bin/env bash
@@ -51,6 +52,7 @@ case "${1:-}" in
fi
echo test-commit
;;
describe) echo v0.0.0-previous ;;
tag) touch "$TEST_STATE/tag"; printf 'tag:%s\n' "${3:-}" >> "$LOG" ;;
push) printf 'push:%s:%s\n' "${2:-}" "${3:-}" >> "$LOG" ;;
*) echo "unexpected git invocation: $*" >&2; exit 1 ;;
@@ -76,6 +78,7 @@ chmod +x "$WORK/bin/gh"
run_publisher() {
VERSTAK_RELEASE_SCRIPT="$WORK/release.sh" \
VERSTAK_RELEASE_DIR="$WORK/release" \
VERSTAK_RELEASE_NOTES_DIR="$WORK/release-notes" \
GIT_BIN="$WORK/bin/git" \
GH_BIN="$WORK/bin/gh" \
EXPECTED_ROOT="$ROOT" \
@@ -92,6 +95,9 @@ for asset in "${ASSET_NAMES[@]}"; do
grep -F "$asset" "$LOG" >/dev/null
done
grep -F "SHA256SUMS" "$LOG" >/dev/null
grep -F -- "--notes-file $WORK/release-notes/$VERSION.md" "$LOG" >/dev/null
grep -F -- "--generate-notes" "$LOG" >/dev/null
grep -F -- "--notes-start-tag v0.0.0-previous" "$LOG" >/dev/null
grep -F -- "--prerelease" "$LOG" >/dev/null
run_publisher
+4
View File
@@ -47,6 +47,10 @@ DEBUG_MODE_STATUS=0
(cd "$ROOT" && node --experimental-vm-modules frontend/tests/debug-mode-test.mjs) || DEBUG_MODE_STATUS=$?
report "session-only debug mode" "$DEBUG_MODE_STATUS"
SELECT_STYLE_STATUS=0
(cd "$ROOT" && node frontend/tests/select-styles-test.mjs) || SELECT_STYLE_STATUS=$?
report "workspace select styles" "$SELECT_STYLE_STATUS"
# ── Frontend tests ──
echo "[frontend]"
if ensure_npm_deps "$ROOT/frontend"; then