16 Commits
57 changed files with 1577 additions and 349 deletions
+17 -18
View File
@@ -7,7 +7,7 @@
### Keep the context of your work together — locally. ### Keep the context of your work together — locally.
Files, notes, links, browser captures, activity and work history 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) **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. 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 software project;
* a client or customer; * a client or customer;
@@ -47,29 +47,29 @@ No account, cloud service or sync server is required for local use.
## Verstak in 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 ## Main features
| Feature | Description | | 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. | | **Files** | Browse and manage ordinary files stored inside your vault. |
| **Notes** | Create Markdown notes, overview pages and links between related information. | | **Notes** | Create Markdown notes, overview pages and links between related information. |
| **Overview** | Quickly return to recent work and see what may need attention. | | **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. | | **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. | | **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. | | **Search** | Search across notes, files and supported plugin data. |
| **Trash** | Restore deleted items or remove them permanently from one central location. | | **Trash** | Restore deleted items or remove them permanently from one central location. |
| **Secrets** | Keep credentials and access information connected to the relevant workspace. | | **Secrets** | Keep credentials and access information connected to the relevant Deal. |
| **Templates** | Create repeatable workspace structures for common types of work. | | **Templates** | Create repeatable Deal structures for common types of work. |
| **Plugins** | Add, replace or disable tools without turning Verstak into one rigid monolithic application. | | **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. | | **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 ## 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 ### Verify a download
@@ -151,9 +151,9 @@ Documents/
Your files and notes remain ordinary files that can be accessed without Verstak. 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: For example:
@@ -167,7 +167,7 @@ Training course
### 3. Add context ### 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; * notes and decisions;
* documents and source files; * documents and source files;
@@ -234,7 +234,6 @@ Each vault is connected separately. The local vault remains the primary copy of
* Git; * Git;
* Wails v2 build dependencies; * Wails v2 build dependencies;
* WebKitGTK development packages on Linux. * WebKitGTK development packages on Linux.
* Ayatana AppIndicator development files on Linux (`sudo apt install libayatana-appindicator3-dev`).
See the See the
[Wails installation documentation](https://wails.io/docs/gettingstarted/installation/) [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: The desktop, SDK and official plugin repositories must be cloned as sibling directories:
```text ```text
verstak-workspace/ verstak-source/
├── verstak/ ├── verstak/
├── verstak-sdk/ ├── verstak-sdk/
└── verstak-official-plugins/ └── verstak-official-plugins/
``` ```
```bash ```bash
mkdir verstak-workspace mkdir verstak-source
cd verstak-workspace cd verstak-source
git clone https://github.com/mirivlad/verstak.git git clone https://github.com/mirivlad/verstak.git
git clone https://github.com/mirivlad/verstak-sdk.git git clone https://github.com/mirivlad/verstak-sdk.git
+7 -8
View File
@@ -7,7 +7,7 @@
### Рабочий контекст остаётся рядом — и хранится локально. ### Рабочий контекст остаётся рядом — и хранится локально.
Файлы, заметки, ссылки, материалы из браузера, активность и история работы Файлы, заметки, ссылки, материалы из браузера, активность и история работы
в одном расширяемом рабочем пространстве. в одной расширяемой локальной среде.
[English](README.md) · **Русский** [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-заметки остаются рядом с делом, которое они описывают. | Журнал: фиксирует завершённую работу и её контекст. | | Заметки: 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; * Git;
* зависимости для сборки Wails v2; * зависимости для сборки Wails v2;
* пакеты разработки WebKitGTK в Linux. * пакеты разработки WebKitGTK в Linux.
* файлы разработки Ayatana AppIndicator в Linux (`sudo apt install libayatana-appindicator3-dev`).
Зависимости для конкретного дистрибутива перечислены в Зависимости для конкретного дистрибутива перечислены в
[документации Wails](https://wails.io/docs/gettingstarted/installation/). [документации Wails](https://wails.io/docs/gettingstarted/installation/).
@@ -244,15 +243,15 @@ sha256sum -c SHA256SUMS --ignore-missing
Desktop, SDK и официальные плагины должны находиться в соседних каталогах: Desktop, SDK и официальные плагины должны находиться в соседних каталогах:
```text ```text
verstak-workspace/ verstak-source/
├── verstak/ ├── verstak/
├── verstak-sdk/ ├── verstak-sdk/
└── verstak-official-plugins/ └── verstak-official-plugins/
``` ```
```bash ```bash
mkdir verstak-workspace mkdir verstak-source
cd verstak-workspace cd verstak-source
git clone https://github.com/mirivlad/verstak.git git clone https://github.com/mirivlad/verstak.git
git clone https://github.com/mirivlad/verstak-sdk.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. Conflicts are rejected unless `options.overwrite` is true.
- `files.deleteTrash(trashId)` — permanently removes a file/folder already in - `files.deleteTrash(trashId)` — permanently removes a file/folder already in
internal trash. This operation cannot be undone. 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, The global official `verstak.trash` plugin owns listing, restoring, filtering,
and permanently deleting deleted items across the vault. and permanently deleting deleted items across the vault.
- `files.openExternal(relativePath)` — opens a vault-relative file/folder in - `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. 1. Unregister contributions всех plugins предыдущего discovery.
2. Unregister all non-core capabilities. 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. 4. Re-scan discovery directories и повторить capability resolution.
5. Register contributions для loaded/degraded plugins (disabled/failed — не регистрируются). 5. Register contributions для loaded/degraded plugins (disabled/failed — не регистрируются).
6. Update plugins list. 6. Update plugins list.
@@ -838,51 +838,52 @@ Vault plugin state хранится **внутри vault** в `.verstak/plugins.
- `./scripts/smoke-platform.sh` — ✅ (enable/disable/plugins.json) - `./scripts/smoke-platform.sh` — ✅ (enable/disable/plugins.json)
- `./scripts/build.sh` — ✅ - `./scripts/build.sh` — ✅
## Workspace Core Capability ## Дело: core capability
Workspace — это физическая папка верхнего уровня внутри vault root. Filesystem Дело — это физическая папка верхнего уровня внутри vault root. Filesystem
является source of truth для списка workspaces. является source of truth для списка Дел.
Пример: Пример:
``` ```
<vault>/ <vault>/
Workspace/ Deal/
Notes/ Notes/
Project/ Project/
ClientA/ ClientA/
.verstak/ .verstak/
``` ```
Нет единого `<vault>/Workspace/` контейнера для всех workspaces. Папка Нет единого `<vault>/Deal/` контейнера для всех Дел. Папка `Deal/` может быть
`Workspace/` может быть обычным workspace, но `Project/` и `ClientA/` являются обычным Делом, но `Project/` и `ClientA/` являются такими же Делами на том же
такими же workspace на том же уровне. уровне.
### Хранение ### Хранение
Workspace existence/list хранится только в filesystem: Существование и список Дел хранятся только в filesystem:
- `ListWorkspaces()` читает top-level directories из vault root. - `ListWorkspaces()` читает top-level directories из vault root.
- `.verstak`, reserved/internal directories, top-level files и symlinks не - `.verstak`, reserved/internal directories, top-level files и symlinks не
считаются workspaces. считаются Делами.
- `.verstak/workspace*.json` не является source of truth для списка workspaces. - `.verstak/workspace*.json` не является source of truth для списка Дел.
- Нет persisted workspace path mapping и нет virtual workspace tree, которое - Нет virtual workspace tree, которое
мапится на произвольные папки. мапится на произвольные папки.
`.verstak` может хранить только metadata, которая не заменяет filesystem: `.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 - Semantic snapshot: applied template snapshot, enabled feature areas, exact
`workspaceTools`, and folder conventions. `workspaceTools`, and folder conventions.
Template snapshot копируется в metadata при создании workspace. Workspace При создании Дела runtime генерирует UUID и сохраняет его в
identity при этом остаётся именем top-level folder; `metadata.workspaceName` `.verstak/workspace.json`. `workspaceId` — постоянная личность Дела;
является presentation/snapshot field, not canonical identity. Если сохранённое `workspaceRootPath` и `workspaceName` — изменяемый адрес и presentation fields.
значение расходится с именем папки, runtime возвращает canonical `workspaceName` Переименование обновляет путь, но не ID. Новая папка с прежним именем получает
равным имени папки без filesystem side effects. другой ID и не может перехватить старые привязки.
```json ```json
{ {
"workspaceId": "1eb7cc69-52e8-4a18-ae2a-50d5229c5b60",
"workspaceName": "Project", "workspaceName": "Project",
"createdFromTemplate": { "createdFromTemplate": {
"templateId": "project", "templateId": "project",
@@ -921,25 +922,25 @@ identity при этом остаётся именем top-level folder; `metada
} }
``` ```
Если original template удалён или изменён позже, существующий workspace Если original template удалён или изменён позже, существующее Дело
открывается по сохранённому snapshot и не мутирует автоматически. Template открывается по сохранённому snapshot и не мутирует автоматически. Template
update/migration может быть только явной future feature. Если metadata update/migration может быть только явной future feature. Если metadata
отсутствует, workspace открывается как generic workspace минимум с `files: true`. отсутствует, Дело открывается как generic Deal минимум с `files: true`.
### API ### API
- `ListWorkspaces()` — список top-level physical folders. - `ListWorkspaces()` — список top-level папок Дел с их `workspaceId`.
- `ListWorkspaceTemplates()` — selectable built-in templates с presentation - `ListWorkspaceTemplates()` — selectable built-in templates с presentation
metadata и `workspaceTools`. metadata и `workspaceTools`.
- `CreateWorkspace(name, templateId?)` — создать `<vault>/<name>/`, применить - `CreateWorkspace(name, templateId?)` — создать `<vault>/<name>/`, применить
template один раз, сохранить snapshot metadata. template один раз, сохранить snapshot metadata.
- `RenameWorkspace(oldName, newName)` — физически переименовать top-level folder - `RenameWorkspace(oldName, newName)` — физически переименовать top-level папку
и обновить metadata key/name. Дела и обновить metadata name, не меняя `workspaceId`.
- `TrashWorkspace(name)` — перенести весь top-level workspace folder в internal - `TrashWorkspace(name)` — перенести весь top-level folder Дела в internal
trash policy. trash policy.
- `GetWorkspaceMetadata(name)` — прочитать metadata или вернуть generic fallback. - `GetWorkspaceMetadata(name)` — прочитать metadata или вернуть generic fallback.
- `UpdateWorkspaceMetadata(name, patch)` — обновить metadata без влияния на - `UpdateWorkspaceMetadata(name, patch)` — обновить metadata без влияния на
существование workspace. существование Дела.
Deprecated compatibility APIs: Deprecated compatibility APIs:
@@ -949,7 +950,7 @@ Deprecated compatibility APIs:
- `ArchiveWorkspaceNode(...)` — wrapper over `TrashWorkspace`. - `ArchiveWorkspaceNode(...)` — wrapper over `TrashWorkspace`.
- `MoveWorkspaceNode(...)` — unsupported; old nested/mapped moves are rejected. - `MoveWorkspaceNode(...)` — unsupported; old nested/mapped moves are rejected.
- `GetCurrentWorkspaceNode()` / `SetCurrentWorkspaceNode(...)` — wrappers over - `GetCurrentWorkspaceNode()` / `SetCurrentWorkspaceNode(...)` — wrappers over
selected top-level workspace UI state. selected top-level Deal UI state.
Эти методы существуют только для постепенного frontend/Wails cleanup. Они не Эти методы существуют только для постепенного frontend/Wails cleanup. Они не
должны создавать или сохранять nested workspace tree и не должны восстанавливать должны создавать или сохранять nested workspace tree и не должны восстанавливать
@@ -957,32 +958,33 @@ Deprecated compatibility APIs:
### Capability ### 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, - Reject: empty, slash, backslash, absolute-looking paths, `..`, null byte,
`.verstak`, reserved/internal names, symlink workspaces, conflicts. `.verstak`, reserved/internal names, symlink Дел, conflicts.
- WorkspaceItems получают `workspaceRootPath`, равный имени top-level папки - WorkspaceItems получают техническое поле `workspaceRootPath`, равное имени top-level папки
(`Project`, `ClientA`, etc). Files plugin показывает именно эту папку. (`Project`, `ClientA`, etc). Files plugin показывает именно эту папку.
- Files API остаётся raw vault-relative API: `Project/Notes/example.md`, - Files API остаётся raw vault-relative API: `Project/Notes/example.md`,
`Project/docs/file.md`, `Test/readme.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 `.verstak/notes`, UUID note storage или второго source of truth для note
content. content.
### Lifecycle Events ### 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`. `workspaceRootPath`, `workspaceName`, and optional `templateId`.
- `workspace.renamed` — payload includes `operation: "rename"`, new - `workspace.renamed` — payload includes `operation: "rename"`, `workspaceId`,
`workspaceRootPath` / `workspaceName`, and previous workspace fields. new `workspaceRootPath` / `workspaceName`, and previous path fields.
- `workspace.trashed` — payload includes `operation: "trash"`, - `workspace.trashed` — payload includes `operation: "trash"`, `workspaceId`,
`workspaceRootPath`, `workspaceName`, `trashId`, `trashPath`, and `deletedAt`. `workspaceRootPath`, `workspaceName`, `trashId`, `trashPath`, and `deletedAt`.
- `workspace.selected` — payload includes `operation: "select"`, - `workspace.selected` — payload includes `operation: "select"`, `workspaceId`,
`workspaceRootPath`, and `workspaceName`. `workspaceRootPath`, and `workspaceName`.
Official Activity subscribes to these events and stores them through the normal Official Activity subscribes to these events and stores them through the normal
@@ -990,11 +992,11 @@ activity provider path.
### UI ### UI
Workspace list в sidebar: Список Дел в sidebar:
- Flat list of top-level workspace folders. - Flat list of top-level папок Дел.
- Create workspace, rename workspace, trash workspace. - Создать Дело, переименовать Дело, переместить Дело в корзину.
- Selection is stored as selected workspace name. - Selection is stored as selected Deal name.
- No expand/collapse workspace tree and no case/folder node creation in core. - 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 Deal templates choose which dynamic plugin Deal tabs are visible when a new
new top-level workspace is created. They do not enable or disable plugins for the top-level Deal is created. They do not enable or disable plugins for the
whole vault and do not affect global sidebar tools. whole vault and do not affect global sidebar tools.
## Built-in templates ## Built-in templates
| Template | Workspace tabs | | Template | Deal tabs |
| --- | --- | | --- | --- |
| General | Notes, Files, Journal, Activity, Browser Inbox | | General | Notes, Files, Journal, Activity, Browser Inbox |
| Project | Notes, Files, Todos, 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 | | Admin | Notes, Files, Secrets, Todos, Journal |
| Minimal | Notes, Files | | 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. included plugin tabs before the folder is created.
## Metadata and compatibility ## Metadata and compatibility
Creation stores a template snapshot in `.verstak/workspaces/` metadata. The Creation stores a template snapshot in `.verstak/workspaces/` metadata. The
snapshot contains the template id, name, version, applied time, and an ordered snapshot contains the template id, name, version, applied time, and an ordered
`workspaceTools` list of plugin IDs. Existing workspace metadata without `workspaceTools` list of plugin IDs. Existing Deal metadata without
`workspaceTools` remains compatible: its workspace continues to show all globally `workspaceTools` remains compatible: its Deal continues to show all globally
enabled workspace plugins rather than unexpectedly hiding tabs. enabled Deal plugins rather than unexpectedly hiding tabs.
Templates are applied once. Editing the built-in catalog or creating another 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. There is no template editor or post-creation template switcher yet.
## Global tools and unavailable plugins ## 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, Global views and sidebar items, such as global Todos, Browser Inbox, and Trash,
remain available according to the normal plugin enablement state. 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 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. 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. **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 ## Global constraints
- Support Windows and Linux only; no background daemon after an explicit Quit. - 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`. - `verstak.todo` requires `verstak/core/notifications/v1` and `notifications.schedule`.
- Plugins cannot receive Wails runtime access. - Plugins cannot receive Wails runtime access.
- Keep Wails generated bindings out of commits. - Keep Wails generated bindings out of commits.
@@ -231,7 +232,12 @@ Expected: absent packages/methods.
- [ ] **Step 3: Implement adapter and Wails wiring** - [ ] **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** - [ ] **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 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 GOCACHE=/tmp/verstak-go-cache go test ./internal/shell/tray ./internal/api -count=1
./scripts/build.sh ./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 git diff --check
``` ```
@@ -265,7 +271,8 @@ git push mirror main
- [ ] **Step 1: Add failing package-contract checks** - [ ] **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** - [ ] **Step 2: Confirm red**
@@ -275,7 +282,9 @@ Expected: the first tray dependency assertion fails.
- [ ] **Step 3: Implement portable package support** - [ ] **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** - [ ] **Step 4: Install the supplied public README sources**
@@ -1,6 +1,6 @@
# Native Notifications and System Tray Design # 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 ## Goal
@@ -19,27 +19,33 @@ must work in the portable Windows archive, Debian package, and AppImage.
## Tray behavior ## Tray behavior
On Windows and Linux, a tray icon is registered before the Wails event loop On Windows and Linux, a tray icon is initialized after Wails reaches
starts. Its menu contains exactly two actions: `OnDomReady`. Its menu contains exactly two actions:
1. **Show Verstak** — shows and focuses the existing main window. 1. **Show Verstak** — shows and focuses the existing main window.
2. **Quit** — exits the process deliberately. 2. **Quit** — exits the process deliberately.
Closing the main window with its window-manager close control hides the window One left click restores and focuses the existing main window. A native right
and keeps the process, plugins, local browser receiver, and reminder scheduler click opens the menu. Closing the main window with its window-manager close
alive. It does not terminate the application. The quit action temporarily control hides the window only after the tray has successfully initialized; it
allows the close lifecycle to finish and then exits normally. 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 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 instance is hidden in the tray, the existing instance shows its window instead
of creating a second process. of creating a second process.
The implementation uses `github.com/getlantern/systray` through a small The implementation uses `fyne.io/systray` through a small
`internal/shell/tray` adapter. It uses `Register`, rather than its blocking `internal/shell/tray` adapter. `RunWithExternalLoop` starts the Windows native
`Run`, so Wails remains the owner of the GUI event loop. A compact PNG derived message loop without making Wails relinquish ownership of its GUI lifecycle.
from the tracked project logo is encoded in the tray package, so a clean build The icon is a source-controlled multi-resolution ICO on Windows (16, 20, 24,
does not depend on ignored Wails-generated icon files. The library accepts PNG 32, 48, and 256 pixels with transparency) and a PNG on Linux. Both are embedded
icon bytes on both target platforms. 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 ## Notification capability and permission
@@ -116,16 +122,14 @@ is not removed.
## Packaging ## Packaging
`getlantern/systray` requires CGO. The Windows release build already uses `fyne.io/systray` supplies the Windows message loop and uses the session D-Bus
`x86_64-w64-mingw32-gcc`; the Windows packaging tests must compile it with the on Linux. It does not require the removed AppIndicator development or runtime
tray dependency included. Linux build instructions add package. The Windows release build still uses `x86_64-w64-mingw32-gcc` for the
`libayatana-appindicator3-dev`. The Debian package declares the corresponding Wails application itself.
runtime dependency `libayatana-appindicator3-1`.
The existing AppImage packager traverses `ldd` for the desktop executable and The existing AppImage packager traverses `ldd` for the desktop executable and
copies non-glibc runtime libraries. Its verification is extended to prove that copies non-glibc runtime libraries; it does not require a tray-specific shared
the appindicator library is present in the AppDir when the tray implementation library.
is compiled in.
## Public README and product screenshots ## Public README and product screenshots
@@ -156,15 +160,19 @@ Automated tests cover:
- schedule replacement, cancellation, rescheduling, persistence, one-time - schedule replacement, cancellation, rescheduling, persistence, one-time
overdue delivery, failed-send retry, and permission/capability rejection; overdue delivery, failed-send retry, and permission/capability rejection;
- Todo desired-list derivation and calls after create/edit/status/delete; - Todo desired-list derivation and calls after create/edit/status/delete;
- close policy: ordinary close hides, explicit quit permits shutdown; - close policy: ordinary close hides only after tray readiness, while explicit
- tray controller action wiring and second-instance window reveal; quit permits shutdown;
- Linux/Windows build scripts and package dependency expectations. - 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 Manual smoke tests are required because neither unit tests nor Playwright can
assert a real desktop notification area or OS toast: assert a real desktop notification area or OS toast:
1. On Linux and Windows, start Verstak, close its window, use the tray menu to 1. On Linux and Windows, start Verstak, verify the tray icon and tooltip, use
reveal it, and use **Quit** to terminate it. 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 2. Set a Todo reminder for a near future time, hide the window in the tray, and
observe one native notification. observe one native notification.
3. Quit before a future reminder, relaunch after it expires, and observe one 3. Quit before a future reminder, relaunch after it expires, and observe one
+2 -2
View File
@@ -106,7 +106,7 @@ test.describe('Activity workflow', () => {
await expect(candidateSection).toContainText('Possible journal entries'); await expect(candidateSection).toContainText('Possible journal entries');
const candidate = candidateSection.locator('[data-work-session-candidate]'); const candidate = candidateSection.locator('[data-work-session-candidate]');
await expect(candidate).toHaveCount(1); await expect(candidate).toHaveCount(1);
await expect(candidate).toContainText('Workspace: Project'); await expect(candidate).toContainText('Deal: Project');
await expect(candidate).toContainText('Estimated duration: 10 min'); await expect(candidate).toContainText('Estimated duration: 10 min');
await expect(candidate).toContainText('Activities: 2'); await expect(candidate).toContainText('Activities: 2');
await expect(candidate).not.toContainText('Project work on'); await expect(candidate).not.toContainText('Project work on');
@@ -142,7 +142,7 @@ test.describe('Activity workflow', () => {
await expect(page.getByRole('tab', { name: 'Journal' })).toHaveAttribute('aria-selected', 'true'); await expect(page.getByRole('tab', { name: 'Journal' })).toHaveAttribute('aria-selected', 'true');
const journal = page.locator('.journal-root'); const journal = page.locator('.journal-root');
await expect(journal).toBeVisible({ timeout: 10000 }); await expect(journal).toBeVisible({ timeout: 10000 });
await expect(journal.locator('[data-journal-candidate]')).toContainText('Workspace: Project'); 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-candidate]')).toContainText('Estimated duration: 10 min');
await expect(journal.locator('[data-journal-input="title"]')).toHaveValue(''); 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="summary"]')).toHaveValue('');
+3 -3
View File
@@ -175,10 +175,10 @@ test.describe('F: Default Editor Plugin', () => {
const workbench = page.locator('.workbench-host'); const workbench = page.locator('.workbench-host');
await expect(workbench).toBeVisible({ timeout: 10000 }); await expect(workbench).toBeVisible({ timeout: 10000 });
const title = workbench.locator('.workbench-title'); 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 () => { await page.evaluate(async () => {
const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.platform-test', { const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.platform-test', {
kind: 'vault-file', kind: 'vault-file',
@@ -193,7 +193,7 @@ test.describe('F: Default Editor Plugin', () => {
const workbench = page.locator('.workbench-host'); const workbench = page.locator('.workbench-host');
await expect(workbench).toBeVisible({ timeout: 10000 }); await expect(workbench).toBeVisible({ timeout: 10000 });
const title = workbench.locator('.workbench-title'); 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 }) => { 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'); const workbench = page.locator('.workbench-host');
await expect(workbench).toBeVisible({ timeout: 10000 }); 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'); const preview = page.locator('.de-preview');
await expect(preview).toBeVisible({ timeout: 10000 }); await expect(preview).toBeVisible({ timeout: 10000 });
@@ -535,7 +535,7 @@ test.describe('G: Files Plugin', () => {
const workbench = page.locator('.workbench-host'); const workbench = page.locator('.workbench-host');
await expect(workbench).toBeVisible({ timeout: 10000 }); 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 }) => { test('files plugin card shows openProviders in contributions', async ({ page }) => {
+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('.sidebar .plugin-item').filter({ hasText: 'Тест платформы' })).toBeVisible();
await expect(page.locator('[data-status-item-id="verstak.platform-test.status"]')).toContainText('Все тесты пройдены'); 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 page.locator('[data-settings-menu-button]').click();
await expect(page.locator('[data-settings-action="plugin-manager"]')).toContainText('Менеджер плагинов'); await expect(page.locator('[data-settings-action="plugin-manager"]')).toContainText('Менеджер плагинов');
await expect(page.locator('[data-settings-language="ru"]')).toHaveAttribute('aria-checked', 'true'); 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 page.reload();
await waitForAppReady(page); await waitForAppReady(page);
+6 -4
View File
@@ -58,7 +58,7 @@ test.describe('D: Plugin API bridge', () => {
const workbench = page.locator('.workbench-host'); const workbench = page.locator('.workbench-host');
await expect(workbench).toBeVisible({ timeout: 10000 }); 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 }) => { 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'); const workbench = page.locator('.workbench-host');
await expect(workbench).toBeVisible({ timeout: 10000 }); await expect(workbench).toBeVisible({ timeout: 10000 });
const title = workbench.locator('.workbench-title'); 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 }) => { 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 })); window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
}); });
await expect(page.locator('[data-workbench-status="no-provider"]')).toBeVisible(); const fallback = page.locator('[data-workbench-status="no-provider"]');
await expect(page.locator('[data-workbench-status="no-provider"]')).toContainText('No viewer/editor available'); 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 }) => { 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 { 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', () => { test.describe('E: Plugin Manager layout', () => {
let consoleCollector; let consoleCollector;
@@ -88,6 +88,56 @@ test.describe('E: Plugin Manager layout', () => {
await expect(risk).toContainText('elevated permissions'); 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 }) => { test('workspace selection keeps exactly one active node', async ({ page }) => {
const selected = page.locator('.wt-node.selected .wt-label'); const selected = page.locator('.wt-node.selected .wt-label');
await expect(selected).toHaveCount(1); 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 }) => { 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]'); const modal = page.locator('[data-workspace-create-modal]');
await modal.locator('[data-workspace-name]').fill('ClientA'); 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(); await expect(page.locator('.wt-label').filter({ hasText: 'ClientA' })).toBeVisible();
const client = page.locator('.wt-node').filter({ hasText: 'ClientA' }); 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('.wt-rename').fill('ClientB');
await page.locator('button[title="Save rename"]').click(); 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); await expect(page.locator('.wt-label').filter({ hasText: 'ClientA' })).toHaveCount(0);
const renamed = page.locator('.wt-node').filter({ hasText: 'ClientB' }); 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); await expect(page.locator('.wt-label').filter({ hasText: 'ClientB' })).toHaveCount(0);
}); });
+26 -2
View File
@@ -248,10 +248,10 @@ test.describe('UX Overview workspace flow', () => {
await expect(overview.locator('[data-overview-summary="attention"]')).toContainText('3'); await expect(overview.locator('[data-overview-summary="attention"]')).toContainText('3');
const attention = overview.locator('[data-overview-section="attention"]'); const attention = overview.locator('[data-overview-section="attention"]');
await expect(attention).toContainText('Possible journal entry'); 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 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.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.locator('.journal-modal-actions').getByRole('button', { name: 'Cancel' }).click();
await page.getByRole('tab', { name: 'Overview' }).click(); await page.getByRole('tab', { name: 'Overview' }).click();
@@ -295,4 +295,28 @@ test.describe('UX Overview workspace flow', () => {
await expect(recent).toContainText('Added journal entry "Write project summary"'); await expect(recent).toContainText('Added journal entry "Write project summary"');
await expect(recent).not.toContainText('Changed file "draft.md"'); 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');
});
}); });
+55
View File
@@ -35,6 +35,7 @@ export default {
'pluginView.select': 'Select a plugin view from the sidebar', 'pluginView.select': 'Select a plugin view from the sidebar',
'pluginView.selectHint': 'Plugin views will appear here', 'pluginView.selectHint': 'Plugin views will appear here',
'common.retry': '⟳ Retry', 'common.retry': '⟳ Retry',
'common.save': 'Save',
'common.source': 'Source', 'common.source': 'Source',
'common.status': 'Status', 'common.status': 'Status',
'common.capability': 'Capability', 'common.capability': 'Capability',
@@ -54,6 +55,24 @@ export default {
'pluginManager.health': 'Plugin Health', 'pluginManager.health': 'Plugin Health',
'pluginManager.permissionRisk': 'Permission Risk', 'pluginManager.permissionRisk': 'Permission Risk',
'pluginManager.elevatedPermissions': '{count} plugins request elevated permissions', '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.none': 'No plugins found',
'pluginManager.scannedDirs': 'Plugin directories scanned:', 'pluginManager.scannedDirs': 'Plugin directories scanned:',
'pluginManager.userPlugins': 'user plugins', 'pluginManager.userPlugins': 'user plugins',
@@ -236,6 +255,42 @@ export default {
'overview.recentChangesHint': 'Latest meaningful activity in this Deal.', 'overview.recentChangesHint': 'Latest meaningful activity in this Deal.',
'overview.event.workspaceOpened': 'Opened Deal', 'overview.event.workspaceOpened': 'Opened Deal',
'overview.event.activity': 'Deal activity', '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.overviewNote': 'Deal overview note',
'overview.recentFilter': 'Recent changes filter', 'overview.recentFilter': 'Recent changes filter',
'overview.loadingRecent': 'Loading recent changes...', 'overview.loadingRecent': 'Loading recent changes...',
+61 -6
View File
@@ -35,9 +35,10 @@ export default {
'pluginView.select': 'Выберите представление плагина на боковой панели', 'pluginView.select': 'Выберите представление плагина на боковой панели',
'pluginView.selectHint': 'Представления плагинов появятся здесь', 'pluginView.selectHint': 'Представления плагинов появятся здесь',
'common.retry': '⟳ Повторить', 'common.retry': '⟳ Повторить',
'common.save': 'Сохранить',
'common.source': 'Источник', 'common.source': 'Источник',
'common.status': 'Состояние', 'common.status': 'Состояние',
'common.capability': 'Capability', 'common.capability': 'Возможность',
'common.provider': 'Поставщик', 'common.provider': 'Поставщик',
'status.loaded': 'загружено', 'status.loaded': 'загружено',
'status.degraded': 'ограничено', 'status.degraded': 'ограничено',
@@ -48,12 +49,30 @@ export default {
'pluginManager.reload': '⟳ Обновить', 'pluginManager.reload': '⟳ Обновить',
'pluginManager.scanning': 'Сканирование каталогов плагинов...', 'pluginManager.scanning': 'Сканирование каталогов плагинов...',
'pluginManager.summary.plugins': 'Обнаружено плагинов: {count}', 'pluginManager.summary.plugins': 'Обнаружено плагинов: {count}',
'pluginManager.summary.capabilities': 'Зарегистрировано capabilities: {count}', 'pluginManager.summary.capabilities': 'Зарегистрировано возможностей: {count}',
'pluginManager.summary.permissions': 'Известно разрешений: {count}', 'pluginManager.summary.permissions': 'Известно разрешений: {count}',
'pluginManager.scanSummary': 'Сводка проверки плагинов', 'pluginManager.scanSummary': 'Сводка проверки плагинов',
'pluginManager.health': 'Состояние плагинов', 'pluginManager.health': 'Состояние плагинов',
'pluginManager.permissionRisk': 'Риск разрешений', 'pluginManager.permissionRisk': 'Риск разрешений',
'pluginManager.elevatedPermissions': 'Плагинов с повышенными разрешениями: {count}', '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.none': 'Плагины не найдены',
'pluginManager.scannedDirs': 'Проверенные каталоги плагинов:', 'pluginManager.scannedDirs': 'Проверенные каталоги плагинов:',
'pluginManager.userPlugins': 'пользовательские плагины', 'pluginManager.userPlugins': 'пользовательские плагины',
@@ -62,7 +81,7 @@ export default {
'pluginManager.missingTitle': 'Отсутствующие установленные плагины', 'pluginManager.missingTitle': 'Отсутствующие установленные плагины',
'pluginManager.missingHint': 'Эти плагины требуются хранилищу, но их пакеты не установлены на этом устройстве.', 'pluginManager.missingHint': 'Эти плагины требуются хранилищу, но их пакеты не установлены на этом устройстве.',
'pluginManager.missingPackage': 'Плагин указан среди желаемых плагинов хранилища, но его пакет не установлен.', 'pluginManager.missingPackage': 'Плагин указан среди желаемых плагинов хранилища, но его пакет не установлен.',
'pluginManager.capabilityRegistry': 'Реестр capabilities ({count})', 'pluginManager.capabilityRegistry': 'Реестр возможностей ({count})',
'pluginManager.settingsError': 'Ошибка настроек', 'pluginManager.settingsError': 'Ошибка настроек',
'pluginManager.pluginSettings': 'Настройки плагина', 'pluginManager.pluginSettings': 'Настройки плагина',
'pluginManager.settingsBundleUnavailable': 'Frontend bundle панели настроек недоступен', 'pluginManager.settingsBundleUnavailable': 'Frontend bundle панели настроек недоступен',
@@ -75,7 +94,7 @@ export default {
'pluginCard.count.statusbar': 'элементов строки состояния: {count}', 'pluginCard.count.statusbar': 'элементов строки состояния: {count}',
'pluginCard.count.openProviders': 'обработчиков открытия: {count}', 'pluginCard.count.openProviders': 'обработчиков открытия: {count}',
'pluginCard.count.workspace': 'инструментов Дела: {count}', 'pluginCard.count.workspace': 'инструментов Дела: {count}',
'pluginCard.degraded': 'Плагин работает, но некоторые необязательные capabilities недоступны.', 'pluginCard.degraded': 'Плагин работает, но некоторые необязательные возможности недоступны.',
'pluginCard.name': 'Название', 'pluginCard.name': 'Название',
'pluginCard.contributions': 'Элементы интерфейса', 'pluginCard.contributions': 'Элементы интерфейса',
'pluginCard.technicalDetails': 'Технические сведения', 'pluginCard.technicalDetails': 'Технические сведения',
@@ -83,9 +102,9 @@ export default {
'pluginCard.root': 'Корневой каталог', 'pluginCard.root': 'Корневой каталог',
'pluginCard.provides': 'Предоставляет', 'pluginCard.provides': 'Предоставляет',
'pluginCard.requires': 'Требует', 'pluginCard.requires': 'Требует',
'pluginCard.missingRequired': 'Отсутствуют обязательные capabilities', 'pluginCard.missingRequired': 'Отсутствуют обязательные возможности',
'pluginCard.optionalRequires': 'Необязательные зависимости', 'pluginCard.optionalRequires': 'Необязательные зависимости',
'pluginCard.optionalUnavailable': 'Необязательные capabilities недоступны — плагин работает в ограниченном режиме', 'pluginCard.optionalUnavailable': 'Необязательные возможности недоступны — плагин работает в ограниченном режиме',
'pluginCard.permissions': 'Разрешения', 'pluginCard.permissions': 'Разрешения',
'pluginCard.enabling': '⟳ Включение...', 'pluginCard.enabling': '⟳ Включение...',
'pluginCard.enable': '▶ Включить', 'pluginCard.enable': '▶ Включить',
@@ -236,6 +255,42 @@ export default {
'overview.recentChangesHint': 'Последняя важная активность в этом Деле.', 'overview.recentChangesHint': 'Последняя важная активность в этом Деле.',
'overview.event.workspaceOpened': 'Открыто Дело', 'overview.event.workspaceOpened': 'Открыто Дело',
'overview.event.activity': 'Активность Дела', '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.overviewNote': 'Обзорная заметка Дела',
'overview.recentFilter': 'Фильтр недавних изменений', 'overview.recentFilter': 'Фильтр недавних изменений',
'overview.loadingRecent': 'Загрузка недавних изменений...', 'overview.loadingRecent': 'Загрузка недавних изменений...',
@@ -33,6 +33,11 @@
let reloading = false; let reloading = false;
let toastMessage = ''; let toastMessage = '';
let toastType = 'success'; // 'success' | 'error' | 'info' let toastType = 'success'; // 'success' | 'error' | 'info'
let statusFilter = 'all';
let permissionFilters = [];
let capabilityFilters = [];
let settingsFilter = 'all';
let sourceFilter = 'all';
export let activeSettingsPluginId = ''; export let activeSettingsPluginId = '';
export let activeSettingsPanelId = ''; export let activeSettingsPanelId = '';
@@ -256,6 +261,11 @@
$: totalPerms = permissions.length; $: totalPerms = permissions.length;
$: statusSummary = computeStatusSummary(plugins); $: statusSummary = computeStatusSummary(plugins);
$: elevatedPermissionPluginCount = computeElevatedPermissionPluginCount(plugins, permissions); $: 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) { function computeStatusSummary(pluginRows) {
return pluginRows.reduce((summary, plugin) => { return pluginRows.reduce((summary, plugin) => {
@@ -274,6 +284,69 @@
return (pluginRows || []).filter(plugin => (plugin?.manifest?.permissions || []).some(permission => dangerous.has(permission))).length; 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() { function closeSettings() {
settingsPanel = null; settingsPanel = null;
settingsPluginId = ''; settingsPluginId = '';
@@ -345,6 +418,70 @@
</section> </section>
</div> </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} {#if plugins.length === 0 && missingInstalled.length === 0}
<div class="empty"> <div class="empty">
<div class="empty-icon"> <div class="empty-icon">
@@ -360,9 +497,13 @@
</ul> </ul>
<p class="hint">{tr('pluginManager.installHint')}</p> <p class="hint">{tr('pluginManager.installHint')}</p>
</div> </div>
{:else if visiblePlugins.length === 0}
<div class="empty filter-empty" data-plugin-filter-empty>
<p>{tr('pluginManager.filterEmpty')}</p>
</div>
{:else} {:else}
<div class="plugin-list"> <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} /> <PluginCard {p} {capabilities} {permissions} {contributions} {vaultOpen} {actionFeedback} settingsPanels={(contributions.settingsPanels || []).filter(sp => sp.pluginId === p.manifest?.id)} onEnable={enablePlugin} onDisable={disablePlugin} />
{/each} {/each}
</div> </div>
@@ -577,6 +718,105 @@
color: #f4f7fb; color: #f4f7fb;
font-size: 0.88rem; 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 { .empty {
padding: 2rem; text-align: center; color: #a0a0b8; padding: 2rem; text-align: center; color: #a0a0b8;
background: #16213e; border-radius: 8px; border: 1px dashed #0f3460; background: #16213e; border-radius: 8px; border: 1px dashed #0f3460;
@@ -585,7 +825,6 @@
.hint { font-size: 0.85rem; margin-top: 0.5rem; opacity: 0.7; } .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 { list-style: none; padding: 0; margin: 0.5rem 0; font-size: 0.8rem; opacity: 0.7; }
.hint-list li { margin: 0.25rem 0; } .hint-list li { margin: 0.25rem 0; }
.hint code { background: #0f3460; padding: 0.1rem 0.3rem; border-radius: 3px; }
.plugin-list { display: flex; flex-direction: column; gap: 0.75rem; margin-bottom: 1.5rem; min-width: 0; } .plugin-list { display: flex; flex-direction: column; gap: 0.75rem; margin-bottom: 1.5rem; min-width: 0; }
.missing-section { margin-bottom: 1.5rem; } .missing-section { margin-bottom: 1.5rem; }
@@ -652,6 +891,19 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.filter-header {
align-items: stretch;
flex-direction: column;
}
.filter-reset {
width: 100%;
}
.filter-select-label {
flex: 1 1 10rem;
}
.modal { .modal {
width: min(880px, calc(100vw - 2rem)); width: min(880px, calc(100vw - 2rem));
height: min(680px, calc(100vh - 2rem)); height: min(680px, calc(100vh - 2rem));
+48 -40
View File
@@ -195,7 +195,7 @@
} }
function todoTitle(item) { 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) { function todoAttentionState(item) {
@@ -203,9 +203,9 @@
const now = Date.now(); const now = Date.now();
const dueAt = todoDateMs(item?.dueAt); const dueAt = todoDateMs(item?.dueAt);
const reminderAt = todoDateMs(item?.reminderAt); const reminderAt = todoDateMs(item?.reminderAt);
if (reminderAt && reminderAt <= now) return 'Reminder due'; if (reminderAt && reminderAt <= now) return tr('overview.todo.reminderDue');
if (dueAt && dueAt < now) return 'Overdue'; if (dueAt && dueAt < now) return tr('overview.todo.overdue');
if (dueAt && dueAt <= now + 3 * 24 * 60 * 60 * 1000) return 'Due soon'; if (dueAt && dueAt <= now + 3 * 24 * 60 * 60 * 1000) return tr('overview.todo.dueSoon');
return ''; return '';
} }
@@ -238,17 +238,12 @@
return fileName(path).replace(/\.(md|markdown|txt)$/i, '').replace(/_/g, ' ') || 'Untitled'; 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) { function entityName(item) {
const payload = item?.payload && typeof item.payload === 'object' ? item.payload : {}; const payload = item?.payload && typeof item.payload === 'object' ? item.payload : {};
const path = payload.path || payload.notePath || item?.path || item?.relativePath || looksLikePath(item?.summary); const path = payload.path || payload.notePath || item?.path || item?.relativePath || looksLikePath(item?.summary);
const title = String(item?.title || payload.title || '').trim(); 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); 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) { function looksLikePath(value) {
@@ -258,11 +253,11 @@
} }
function captureTitle(capture) { 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) { 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) { function itemTimeLabel(item) {
@@ -312,24 +307,33 @@
function activityTitle(item) { function activityTitle(item) {
const type = String(item?.type || '').toLowerCase(); const type = String(item?.type || '').toLowerCase();
const name = entityName(item); const name = entityName(item);
if (type === 'note.saved' || type === 'note.edited') return `Edited note ${quoted(name)}`; if (type === 'note.saved' || type === 'note.edited') return tr('overview.event.noteEdited', { name });
if (type === 'note.opened') return `Opened note ${quoted(name)}`; if (type === 'note.opened') return tr('overview.event.noteOpened', { name });
if (type === 'note.created') return `Created note ${quoted(name)}`; if (type === 'note.created') return tr('overview.event.noteCreated', { name });
if (type === 'file.opened') return `Opened file ${quoted(name)}`; if (type === 'file.opened') return tr('overview.event.fileOpened', { name });
if (type === 'file.changed') return `Changed file ${quoted(name)}`; if (type === 'file.changed') return tr('overview.event.fileChanged', { name });
if (type === 'file.created') return `Created file ${quoted(name)}`; if (type === 'file.created') return tr('overview.event.fileCreated', { name });
if (type === 'file.deleted' || type === 'file.trashed') return `Removed file ${quoted(name)}`; if (type === 'file.deleted' || type === 'file.trashed') return tr('overview.event.fileRemoved', { name });
if (type === 'browser.capture.page') return `Captured page ${quoted(name)}`; if (type === 'browser.capture.page') return tr('overview.event.capturePage', { name });
if (type === 'browser.capture.selection') return `Captured selection ${quoted(name)}`; if (type === 'browser.capture.selection') return tr('overview.event.captureSelection', { name });
if (type === 'browser.capture.link') return `Captured link ${quoted(name)}`; if (type === 'browser.capture.link') return tr('overview.event.captureLink', { name });
if (type === 'browser.capture.file') return `Captured file ${quoted(name)}`; if (type === 'browser.capture.file') return tr('overview.event.captureFile', { name });
if (type === 'browser.capture.converted') return `Converted capture ${quoted(name)}`; if (type === 'browser.capture.converted') return tr('overview.event.captureConverted', { name });
if (type === 'journal.entry.added' || type === 'worklog.entry.added') return `Added journal entry ${quoted(name)}`; if (type === 'journal.entry.added' || type === 'worklog.entry.added') return tr('overview.event.journalAdded', { name });
if (type === 'action.started') return 'Work session detected'; if (type === 'action.started') return tr('overview.event.workSessionDetected');
if (type === 'workspace.opened') return tr('overview.event.workspaceOpened'); if (type === 'workspace.opened') return tr('overview.event.workspaceOpened');
return item?.title || item?.summary || tr('overview.event.activity'); 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) { function actionForCategory(category) {
if (category === 'notes') return { kind: 'notes', label: tr('overview.openNotes') }; if (category === 'notes') return { kind: 'notes', label: tr('overview.openNotes') };
if (category === 'files') return { kind: 'files', label: tr('overview.openFiles') }; if (category === 'files') return { kind: 'files', label: tr('overview.openFiles') };
@@ -348,7 +352,7 @@
id: item.activityId || `${item.type}:${timeValue(item)}`, id: item.activityId || `${item.type}:${timeValue(item)}`,
category, category,
title: activityTitle(item), title: activityTitle(item),
meta: `${itemTimeLabel(item)}${item.sourcePluginId ? ' · ' + item.sourcePluginId.replace('verstak.', '') : ''}`, meta: itemTimeLabel(item),
time: timeValue(item), time: timeValue(item),
absolute: absoluteTime(timeValue(item)), absolute: absoluteTime(timeValue(item)),
actionKind: action.kind, actionKind: action.kind,
@@ -358,8 +362,8 @@
const captureItems = captureRows.map(item => ({ const captureItems = captureRows.map(item => ({
id: item.captureId || `capture:${timeValue(item)}`, id: item.captureId || `capture:${timeValue(item)}`,
category: 'captures', category: 'captures',
title: `Captured ${item.kind || 'item'} ${quoted(captureTitle(item))}`, title: tr('overview.event.capture', { kind: captureKindLabel(item), title: captureTitle(item) }),
meta: `${itemTimeLabel(item)} · ${item.domain || item.url || 'Browser capture'}`, meta: `${itemTimeLabel(item)} · ${item.domain || item.url || tr('overview.browserCapture')}`,
time: timeValue(item), time: timeValue(item),
absolute: absoluteTime(timeValue(item)), absolute: absoluteTime(timeValue(item)),
actionKind: 'browser-inbox', actionKind: 'browser-inbox',
@@ -368,8 +372,8 @@
const journalItems = journalRows.map(item => ({ const journalItems = journalRows.map(item => ({
id: item.entryId || `journal:${timeValue(item)}`, id: item.entryId || `journal:${timeValue(item)}`,
category: 'journal', category: 'journal',
title: `Added journal entry ${quoted(journalTitle(item))}`, title: tr('overview.event.journalAdded', { name: journalTitle(item) }),
meta: `${itemTimeLabel(item)}${item.minutes ? ' · ' + item.minutes + ' min' : ''}`, meta: `${itemTimeLabel(item)}${item.minutes ? ' · ' + tr('overview.minutes', { count: item.minutes }) : ''}`,
time: timeValue(item), time: timeValue(item),
absolute: absoluteTime(timeValue(item)), absolute: absoluteTime(timeValue(item)),
actionKind: 'journal', actionKind: 'journal',
@@ -409,8 +413,8 @@
const todoCandidates = [...todoRows].sort((a, b) => todoDateMs(todoTimeValue(a)) - todoDateMs(todoTimeValue(b))).map(item => ({ const todoCandidates = [...todoRows].sort((a, b) => todoDateMs(todoTimeValue(a)) - todoDateMs(todoTimeValue(b))).map(item => ({
id: item.id || `todo:${todoTitle(item)}`, id: item.id || `todo:${todoTitle(item)}`,
category: 'todo', category: 'todo',
title: `Todo ${quoted(todoTitle(item))}`, title: tr('overview.event.todo', { title: todoTitle(item) }),
meta: `${todoAttentionState(item)}${item.dueAt ? ` · Due ${item.dueAt}` : ''}`, meta: `${todoAttentionState(item)}${item.dueAt ? ` · ${tr('overview.todo.due', { date: item.dueAt })}` : ''}`,
time: todoTimeValue(item), time: todoTimeValue(item),
absolute: absoluteTime(todoTimeValue(item)), absolute: absoluteTime(todoTimeValue(item)),
actionKind: 'todo', actionKind: 'todo',
@@ -419,8 +423,8 @@
const captureCandidates = sortByTime(captureRows).map(item => ({ const captureCandidates = sortByTime(captureRows).map(item => ({
id: item.captureId || `capture:${timeValue(item)}`, id: item.captureId || `capture:${timeValue(item)}`,
category: 'captures', category: 'captures',
title: `Review capture ${quoted(captureTitle(item))}`, title: tr('overview.event.reviewCapture', { title: captureTitle(item) }),
meta: `${itemTimeLabel(item)} · ${item.domain || item.kind || 'Browser capture'}`, meta: `${itemTimeLabel(item)} · ${item.domain || captureKindLabel(item) || tr('overview.browserCapture')}`,
time: timeValue(item), time: timeValue(item),
absolute: absoluteTime(timeValue(item)), absolute: absoluteTime(timeValue(item)),
actionKind: 'browser-inbox', actionKind: 'browser-inbox',
@@ -435,7 +439,7 @@
const journalCandidates = sortByTime(journalRows).map(item => ({ const journalCandidates = sortByTime(journalRows).map(item => ({
id: item.entryId || `journal:${timeValue(item)}`, id: item.entryId || `journal:${timeValue(item)}`,
category: 'journal', category: 'journal',
title: `Continue journal entry ${quoted(journalTitle(item))}`, title: tr('overview.event.continueJournal', { title: journalTitle(item) }),
meta: itemTimeLabel(item), meta: itemTimeLabel(item),
time: timeValue(item), time: timeValue(item),
absolute: absoluteTime(timeValue(item)), absolute: absoluteTime(timeValue(item)),
@@ -449,14 +453,18 @@
const captureItems = sortByTime(captureRows).slice(0, 4).map(item => ({ const captureItems = sortByTime(captureRows).slice(0, 4).map(item => ({
id: item.captureId || `capture:${timeValue(item)}`, id: item.captureId || `capture:${timeValue(item)}`,
title: captureTitle(item), title: captureTitle(item),
meta: `${item.kind || 'capture'} · ${itemTimeLabel(item)}`, meta: `${captureKindLabel(item)} · ${itemTimeLabel(item)}`,
actionKind: 'browser-inbox', actionKind: 'browser-inbox',
actionLabel: tr('overview.reviewInbox'), actionLabel: tr('overview.reviewInbox'),
})); }));
const candidateItems = sortByTime(candidates).slice(0, 4).map(item => ({ const candidateItems = sortByTime(candidates).slice(0, 4).map(item => ({
id: item.candidateId || `work-session:${timeValue(item)}`, id: item.candidateId || `work-session:${timeValue(item)}`,
title: 'Possible journal entry', title: tr('overview.event.possibleJournalEntry'),
meta: `Workspace: ${item.workspaceRootPath || workspaceRootPath || 'Unknown'} · ${item.estimatedMinutes || 0} min · ${item.activityCount || (item.activityIds || []).length || 0} activities`, meta: tr('overview.candidateMeta', {
deal: item.workspaceRootPath || workspaceRootPath || tr('overview.unknownDeal'),
minutes: item.estimatedMinutes || 0,
activities: item.activityCount || (item.activityIds || []).length || 0,
}),
actionKind: 'journal', actionKind: 'journal',
actionLabel: tr('overview.reviewCandidate'), actionLabel: tr('overview.reviewCandidate'),
toolRequest: { type: 'work-session-candidate', candidate: item }, toolRequest: { type: 'work-session-candidate', candidate: item },
@@ -464,7 +472,7 @@
const todoItems = [...todoRows].sort((a, b) => todoDateMs(todoTimeValue(a)) - todoDateMs(todoTimeValue(b))).slice(0, 4).map(item => ({ const todoItems = [...todoRows].sort((a, b) => todoDateMs(todoTimeValue(a)) - todoDateMs(todoTimeValue(b))).slice(0, 4).map(item => ({
id: item.id || `todo:${todoTitle(item)}`, id: item.id || `todo:${todoTitle(item)}`,
title: 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', actionKind: 'todo',
actionLabel: tr('overview.openTodos'), actionLabel: tr('overview.openTodos'),
})); }));
+11 -3
View File
@@ -22,6 +22,7 @@
$: requestContext = openedResource?.request?.context?.notesMode || openedResource?.request?.context?.isInsideNotesFolder $: requestContext = openedResource?.request?.context?.notesMode || openedResource?.request?.context?.isInsideNotesFolder
? 'notes-markdown' ? 'notes-markdown'
: ((openedResource?.request?.extension === '.md' || openedResource?.request?.extension === '.markdown') ? 'generic-markdown' : 'generic-text'); : ((openedResource?.request?.extension === '.md' || openedResource?.request?.extension === '.markdown') ? 'generic-markdown' : 'generic-text');
$: resourceTitle = resourceDisplayTitle(resourcePath, requestContext);
$: componentProps = openedResource || {}; $: componentProps = openedResource || {};
$: mountKey = [ $: mountKey = [
providerPluginId, providerPluginId,
@@ -31,6 +32,11 @@
requestContext, requestContext,
].join(':'); ].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() { function closeWorkbench() {
window.dispatchEvent(new CustomEvent('verstak:close-workbench', { cancelable: true })); window.dispatchEvent(new CustomEvent('verstak:close-workbench', { cancelable: true }));
} }
@@ -41,7 +47,7 @@
<div class="workbench-host vt-page"> <div class="workbench-host vt-page">
{#if openedResource?.status === 'no-provider'} {#if openedResource?.status === 'no-provider'}
<div class="workbench-header vt-page-header"> <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> <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}> <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} /> <Icon name="x" size={18} />
@@ -49,11 +55,13 @@
</div> </div>
<div class="workbench-empty no-provider vt-empty-state" data-workbench-status="no-provider"> <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="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> </div>
{:else if openedResource} {:else if openedResource}
<div class="workbench-header vt-page-header"> <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} {#if debug.isEnabled() && providerId}
<span class="workbench-provider vt-badge accent" data-debug-provider>{providerId}</span> <span class="workbench-provider vt-badge accent" data-debug-provider>{providerId}</span>
{/if} {/if}
+1 -1
View File
@@ -364,7 +364,7 @@
if (e.key === 'Escape') cancelRename(); 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> <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} {:else}
<button class="wt-label" on:click={() => selectWorkspace(workspace)} type="button">{id}</button> <button class="wt-label" on:click={() => selectWorkspace(workspace)} type="button">{id}</button>
+18 -17
View File
@@ -150,7 +150,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
name: 'Notes', name: 'Notes',
version: '0.1.0', version: '0.1.0',
apiVersion: '0.1.0', apiVersion: '0.1.0',
description: 'Workspace-scoped notes manager.', description: 'Deal-scoped notes manager.',
source: 'official', source: 'official',
icon: 'edit', icon: 'edit',
provides: ['verstak/notes/v1'], provides: ['verstak/notes/v1'],
@@ -197,7 +197,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
name: 'Activity', name: 'Activity',
version: '0.1.0', version: '0.1.0',
apiVersion: '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', source: 'official',
icon: 'activity', icon: 'activity',
provides: ['activity.log', 'activity.provider', 'activity.reconstruction'], provides: ['activity.log', 'activity.provider', 'activity.reconstruction'],
@@ -221,7 +221,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
name: 'Journal', name: 'Journal',
version: '0.1.0', version: '0.1.0',
apiVersion: '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', source: 'official',
icon: 'book-open', icon: 'book-open',
provides: ['worklog', 'journal', 'report.worklog'], provides: ['worklog', 'journal', 'report.worklog'],
@@ -271,7 +271,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
name: 'Search', name: 'Search',
version: '0.1.0', version: '0.1.0',
apiVersion: '0.1.0', apiVersion: '0.1.0',
description: 'Workspace-scoped vault text search provider.', description: 'Deal-scoped vault text search provider.',
source: 'official', source: 'official',
icon: 'search', icon: 'search',
provides: ['verstak/search/v1', 'search.provider'], provides: ['verstak/search/v1', 'search.provider'],
@@ -438,7 +438,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
{ {
id: 'default', id: 'default',
name: 'General', 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, version: 2,
workspaceTools: ['verstak.notes', 'verstak.files', 'verstak.journal', 'verstak.activity', 'verstak.browser-inbox'], workspaceTools: ['verstak.notes', 'verstak.files', 'verstak.journal', 'verstak.activity', 'verstak.browser-inbox'],
folders: ['Notes', 'Files'], folders: ['Notes', 'Files'],
@@ -456,7 +456,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
{ {
id: 'writing', id: 'writing',
name: '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, version: 1,
workspaceTools: ['verstak.notes', 'verstak.files', 'verstak.journal'], workspaceTools: ['verstak.notes', 'verstak.files', 'verstak.journal'],
folders: ['Notes', 'Files'], folders: ['Notes', 'Files'],
@@ -465,7 +465,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
{ {
id: 'admin', id: 'admin',
name: 'Admin', name: 'Admin',
description: 'Infrastructure workspace with secrets, todos, and journal.', description: 'Infrastructure Deal with secrets, todos, and journal.',
version: 1, version: 1,
workspaceTools: ['verstak.notes', 'verstak.files', 'verstak.secrets', 'verstak.todo', 'verstak.journal'], workspaceTools: ['verstak.notes', 'verstak.files', 'verstak.secrets', 'verstak.todo', 'verstak.journal'],
folders: ['Notes', 'Files', 'Secrets'], folders: ['Notes', 'Files', 'Secrets'],
@@ -474,7 +474,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
{ {
id: 'minimal', id: 'minimal',
name: 'Minimal', name: 'Minimal',
description: 'Only notes and files for a lightweight workspace.', description: 'Only notes and files for a lightweight Deal.',
version: 1, version: 1,
workspaceTools: ['verstak.notes', 'verstak.files'], workspaceTools: ['verstak.notes', 'verstak.files'],
folders: ['Notes', 'Files'], folders: ['Notes', 'Files'],
@@ -570,7 +570,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
name: 'Todos', name: 'Todos',
version: '0.1.0', version: '0.1.0',
apiVersion: '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', source: 'official',
icon: 'list-todo', icon: 'list-todo',
provides: ['todo.list', 'todo.workspace'], provides: ['todo.list', 'todo.workspace'],
@@ -598,7 +598,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
name: 'Secrets', name: 'Secrets',
version: '0.1.0', version: '0.1.0',
apiVersion: '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', source: 'official',
icon: 'key-round', icon: 'key-round',
provides: ['secret-store', 'secrets.read-ui', 'secrets.write-ui'], provides: ['secret-store', 'secrets.read-ui', 'secrets.write-ui'],
@@ -4104,7 +4104,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
name: 'Notes', name: 'Notes',
version: '0.1.0', version: '0.1.0',
apiVersion: '0.1.0', apiVersion: '0.1.0',
description: 'Workspace-scoped notes manager.', description: 'Deal-scoped notes manager.',
source: 'official', source: 'official',
icon: 'edit', icon: 'edit',
provides: ['verstak/notes/v1'], provides: ['verstak/notes/v1'],
@@ -4151,7 +4151,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
name: 'Activity', name: 'Activity',
version: '0.1.0', version: '0.1.0',
apiVersion: '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', source: 'official',
icon: 'activity', icon: 'activity',
provides: ['activity.log', 'activity.provider', 'activity.reconstruction'], provides: ['activity.log', 'activity.provider', 'activity.reconstruction'],
@@ -4175,7 +4175,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
name: 'Journal', name: 'Journal',
version: '0.1.0', version: '0.1.0',
apiVersion: '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', source: 'official',
icon: 'book-open', icon: 'book-open',
provides: ['worklog', 'journal', 'report.worklog'], provides: ['worklog', 'journal', 'report.worklog'],
@@ -4225,7 +4225,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
name: 'Search', name: 'Search',
version: '0.1.0', version: '0.1.0',
apiVersion: '0.1.0', apiVersion: '0.1.0',
description: 'Workspace-scoped vault text search provider.', description: 'Deal-scoped vault text search provider.',
source: 'official', source: 'official',
icon: 'search', icon: 'search',
provides: ['verstak/search/v1', 'search.provider'], provides: ['verstak/search/v1', 'search.provider'],
@@ -4276,8 +4276,9 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
getPluginState: function (pluginId) { getPluginState: function (pluginId) {
return pluginStates[pluginId] ? Object.assign({}, pluginStates[pluginId]) : null; return pluginStates[pluginId] ? Object.assign({}, pluginStates[pluginId]) : null;
}, },
addSyntheticPlugins: function (count) { addSyntheticPlugins: function (count, source) {
var total = Number(count || 0); var total = Number(count || 0);
var pluginSource = source === 'official' || source === 'local' || source === 'third-party' ? source : 'third-party';
for (var i = 1; i <= total; i++) { for (var i = 1; i <= total; i++) {
var id = 'verstak.synthetic-layout-' + String(i).padStart(2, '0'); var id = 'verstak.synthetic-layout-' + String(i).padStart(2, '0');
pluginStates[id] = { pluginStates[id] = {
@@ -4290,7 +4291,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
version: '0.0.' + i, version: '0.0.' + i,
apiVersion: '0.1.0', apiVersion: '0.1.0',
description: 'Synthetic plugin used by frontend layout tests.', description: 'Synthetic plugin used by frontend layout tests.',
source: 'test', source: pluginSource,
provides: ['verstak/synthetic-layout-' + i + '/v1'], provides: ['verstak/synthetic-layout-' + i + '/v1'],
requires: [], requires: [],
optionalRequires: [], optionalRequires: [],
@@ -4310,7 +4311,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
vaultPluginState.enabledPlugins.push(id); vaultPluginState.enabledPlugins.push(id);
} }
if (!vaultPluginState.desiredPlugins.some(function (p) { return p.id === 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 });
} }
} }
}, },
+1 -9
View File
@@ -3,7 +3,7 @@ module github.com/verstak/verstak-desktop
go 1.24.4 go 1.24.4
require ( require (
github.com/getlantern/systray v1.2.2 fyne.io/systray v1.12.2
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/wailsapp/wails/v2 v2.12.0 github.com/wailsapp/wails/v2 v2.12.0
golang.org/x/net v0.35.0 golang.org/x/net v0.35.0
@@ -12,14 +12,7 @@ require (
require ( require (
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
github.com/bep/debounce v1.2.1 // 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-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/godbus/dbus/v5 v5.1.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect github.com/gorilla/websocket v1.5.3 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // 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/leaanthony/u v1.1.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // 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/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.4.7 // 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 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc= 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 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= 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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= 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 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 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/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 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI= 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.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 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= 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.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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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 h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 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/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 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o= 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 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ= 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 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= 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-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-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-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= 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= 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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+22 -5
View File
@@ -11,6 +11,7 @@ import (
"path/filepath" "path/filepath"
"sort" "sort"
"strings" "strings"
"sync"
"sync/atomic" "sync/atomic"
"time" "time"
@@ -100,6 +101,8 @@ type App struct {
browserInboxEvents map[string]bool browserInboxEvents map[string]bool
browserInboxEnabled atomic.Bool browserInboxEnabled atomic.Bool
allowQuit atomic.Bool allowQuit atomic.Bool
trayReady atomic.Bool
quitOnce sync.Once
} }
// SetNotificationService attaches the core-owned plugin notification scheduler. // SetNotificationService attaches the core-owned plugin notification scheduler.
@@ -221,11 +224,23 @@ func (a *App) Shutdown(ctx context.Context) {
cleanupNativeNotifications(ctx) 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 { func (a *App) BeforeClose(ctx context.Context) bool {
if a.allowQuit.Load() { if a.allowQuit.Load() {
return false return false
} }
if !a.trayReady.Load() {
log.Printf("[app] tray is unavailable; allowing normal window close")
return false
}
hideNativeWindow(ctx) hideNativeWindow(ctx)
return true return true
} }
@@ -243,10 +258,12 @@ func (a *App) Quit() {
if a == nil { if a == nil {
return return
} }
a.allowQuit.Store(true) a.quitOnce.Do(func() {
if a.ctx != nil { a.allowQuit.Store(true)
quitNativeApplication(a.ctx) if a.ctx != nil {
} quitNativeApplication(a.ctx)
}
})
} }
// NativeNotificationSender delivers scheduler items through the Wails runtime. // NativeNotificationSender delivers scheduler items through the Wails runtime.
+35 -2
View File
@@ -319,7 +319,7 @@ func TestNativeNotificationSenderUsesStablePluginScopedID(t *testing.T) {
} }
} }
func TestBeforeCloseHidesWindowUntilUserChoosesQuit(t *testing.T) { func TestBeforeCloseAllowsExitUntilTrayIsReady(t *testing.T) {
oldHide := hideNativeWindow oldHide := hideNativeWindow
defer func() { hideNativeWindow = oldHide }() defer func() { hideNativeWindow = oldHide }()
@@ -327,8 +327,25 @@ func TestBeforeCloseHidesWindowUntilUserChoosesQuit(t *testing.T) {
hideNativeWindow = func(context.Context) { hideCalls++ } hideNativeWindow = func(context.Context) { hideCalls++ }
app := &App{} 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 { 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 { if hideCalls != 1 {
t.Fatalf("hide calls = %d, want 1", hideCalls) t.Fatalf("hide calls = %d, want 1", hideCalls)
@@ -353,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) { func TestShowWindowUsesWailsContext(t *testing.T) {
oldShow := showNativeWindow oldShow := showNativeWindow
defer func() { showNativeWindow = oldShow }() defer func() { showNativeWindow = oldShow }()
+25 -5
View File
@@ -78,9 +78,10 @@ type WindowState struct {
// Manager provides thread-safe access to app settings. // Manager provides thread-safe access to app settings.
type Manager struct { type Manager struct {
mu sync.RWMutex mu sync.RWMutex
config *Config config *Config
configPath string configPath string
onLanguageChanged func(string)
} }
// DefaultConfigPath returns the default path for app settings. // DefaultConfigPath returns the default path for app settings.
@@ -230,13 +231,32 @@ func (m *Manager) UpdateLanguage(language string) error {
} }
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock()
if m.config == nil { if m.config == nil {
m.config = defaultConfig() m.config = defaultConfig()
} }
m.config.Language = language m.config.Language = language
m.config.LastOpenedAt = time.Now().UTC().Format(time.RFC3339) 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. // UpdateSync replaces sync settings without changing unrelated app settings.
+29
View File
@@ -3,6 +3,7 @@ package appsettings
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"reflect"
"testing" "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) { func TestLoad_CorruptConfig(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
path := filepath.Join(dir, "config.json") path := filepath.Join(dir, "config.json")
+265 -25
View File
@@ -1,20 +1,39 @@
// Package tray owns the native tray menu wiring for the desktop shell. // Package tray owns the native tray menu wiring for the desktop shell.
package tray 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. // MenuItem exposes click events from a native tray menu item.
type MenuItem interface { type MenuItem interface {
Clicked() <-chan struct{} 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. // Backend is the platform tray implementation.
type Backend interface { type Backend interface {
Register(onReady func(), onExit func()) Start(callbacks BackendCallbacks) error
SetIcon(icon []byte) SetIcon(icon []byte) error
SetTooltip(tooltip string) SetTooltip(tooltip string) error
AddMenuItem(title, tooltip string) MenuItem AddMenuItem(title, tooltip string) (MenuItem, error)
Quit() Stop()
} }
// Actions are executed from the native tray menu. // Actions are executed from the native tray menu.
@@ -23,11 +42,72 @@ type Actions struct {
Quit func() 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. // Controller initializes one native tray and routes its menu actions.
type Controller struct { type Controller struct {
backend Backend backend Backend
icon []byte 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. // New creates a tray controller for one application process.
@@ -35,36 +115,196 @@ func New(backend Backend, icon []byte) *Controller {
return &Controller{ return &Controller{
backend: backend, backend: backend,
icon: append([]byte(nil), icon...), icon: append([]byte(nil), icon...),
labels: englishLabels(),
} }
} }
// Start registers the native tray without taking over the Wails event loop. // SetReadyChangedHandler receives readiness changes after native initialization
func (c *Controller) Start(actions Actions) { // has either completed or ended. It lets the Wails close policy avoid hiding a
if c == nil || c.backend == nil { // window before the tray can bring it back.
func (c *Controller) SetReadyChangedHandler(handler func(bool)) {
if c == nil {
return return
} }
c.start.Do(func() { c.mu.Lock()
c.backend.Register(func() { c.readyChanged = handler
c.backend.SetIcon(c.icon) c.mu.Unlock()
c.backend.SetTooltip("Verstak") }
show := c.backend.AddMenuItem("Show Verstak", "Show the Verstak window")
quit := c.backend.AddMenuItem("Quit", "Quit Verstak") // Ready reports whether the tray has completed icon and menu initialization.
if actions.Show != nil && show != nil { func (c *Controller) Ready() bool {
go routeClicks(show.Clicked(), actions.Show) return c != nil && c.ready.Load()
} }
if actions.Quit != nil && quit != nil {
go routeClicks(quit.Clicked(), actions.Quit) // SetLabels updates the current and future native tray menu labels.
} func (c *Controller) SetLabels(labels Labels) {
}, nil) 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. // Stop releases the native tray after Wails has begun application shutdown.
func (c *Controller) Stop() { func (c *Controller) Stop() {
if c == nil || c.backend == nil { if c == nil || c.backend == nil || !c.started.Load() {
return 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()) { func routeClicks(clicked <-chan struct{}, action func()) {
+166 -30
View File
@@ -1,47 +1,77 @@
package tray package tray
import ( import (
"errors"
"sync"
"testing" "testing"
"time" "time"
) )
type fakeMenuItem struct { type fakeMenuItem struct {
clicked chan struct{} clicked chan struct{}
title string
tooltip string
} }
func (i *fakeMenuItem) Clicked() <-chan struct{} { func (i *fakeMenuItem) Clicked() <-chan struct{} { return i.clicked }
return i.clicked func (i *fakeMenuItem) SetTitle(title string) { i.title = title }
} func (i *fakeMenuItem) SetTooltip(tooltip string) { i.tooltip = tooltip }
type fakeBackend struct { type fakeBackend struct {
icon []byte callbacks BackendCallbacks
tooltip string icon []byte
items map[string]*fakeMenuItem tooltip string
quitCalls int items map[string]*fakeMenuItem
registering bool startErr error
iconErr error
menuErr error
starts int
stops int
} }
func (b *fakeBackend) Register(onReady func(), _ func()) { func (b *fakeBackend) Start(callbacks BackendCallbacks) error {
b.registering = true b.starts++
onReady() 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...) 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 b.tooltip = tooltip
return nil
} }
func (b *fakeBackend) AddMenuItem(title, _ string) MenuItem { func (b *fakeBackend) AddMenuItem(title, tooltip string) (MenuItem, error) {
item := &fakeMenuItem{clicked: make(chan struct{}, 1)} if b.menuErr != nil {
return nil, b.menuErr
}
item := &fakeMenuItem{clicked: make(chan struct{}, 1), title: title, tooltip: tooltip}
b.items[title] = item b.items[title] = item
return item return item, nil
} }
func (b *fakeBackend) Quit() { func (b *fakeBackend) Stop() { b.stops++ }
b.quitCalls++
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{}) { 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)} 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 := 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{}{} }, Show: func() { showCalls <- struct{}{} },
Quit: func() { quitCalls <- struct{}{} }, Quit: func() { quitCalls <- struct{}{} },
}) }); err != nil {
t.Fatalf("Start() error = %v", err)
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)
} }
backend.ready()
backend.leftClick()
waitFor(t, showCalls)
showItem := backend.items["Show Verstak"] showItem := backend.items["Show Verstak"]
quitItem := backend.items["Quit"] quitItem := backend.items["Quit"]
if showItem == nil || quitItem == nil { if showItem == nil || quitItem == nil {
t.Fatalf("tray menu = %#v, want Show Verstak and Quit", backend.items) t.Fatalf("tray menu = %#v, want Show Verstak and Quit", backend.items)
} }
showItem.clicked <- struct{}{} showItem.clicked <- struct{}{}
waitFor(t, showCalls) waitFor(t, showCalls)
quitItem.clicked <- struct{}{} quitItem.clicked <- struct{}{}
waitFor(t, quitCalls) waitFor(t, quitCalls)
} }
func TestControllerStopsNativeTrayBackend(t *testing.T) { func TestControllerStopCallsBackendOnce(t *testing.T) {
backend := &fakeBackend{items: make(map[string]*fakeMenuItem)} backend := &fakeBackend{items: make(map[string]*fakeMenuItem)}
controller := New(backend, []byte{1}) controller := New(backend, []byte{1})
if err := controller.Start(Actions{}); err != nil {
t.Fatalf("Start() error = %v", err)
}
controller.Stop()
controller.Stop() controller.Stop()
if backend.quitCalls != 1 { if backend.stops != 1 {
t.Fatalf("backend quit calls = %d, want 1", backend.quitCalls) 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 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 { type systrayMenuItem struct {
item *systray.MenuItem 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 { func NewNativeBackend() Backend {
return systrayBackend{} return &systrayBackend{}
} }
func (systrayBackend) Register(onReady func(), onExit func()) { func (b *systrayBackend) Start(callbacks BackendCallbacks) (err error) {
systray.Register(onReady, onExit) 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) { func (b *systrayBackend) SetIcon(icon []byte) error {
systray.SetIcon(icon) 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) systray.SetTooltip(tooltip)
return nil
} }
func (systrayBackend) AddMenuItem(title, tooltip string) MenuItem { func (b *systrayBackend) AddMenuItem(title, tooltip string) (MenuItem, error) {
return systrayMenuItem{item: systray.AddMenuItem(title, tooltip)} 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() { func (b *systrayBackend) Stop() {
systray.Quit() 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{} { func (item systrayMenuItem) Clicked() <-chan struct{} {
@@ -39,3 +144,15 @@ func (item systrayMenuItem) Clicked() <-chan struct{} {
} }
return item.item.ClickedCh 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

+11 -1
View File
@@ -230,6 +230,14 @@ func main() {
app = api.NewApp(capRegistry, contribRegistry, permRegistry, eventBus, plugins, vaultService, storageService, filesService, appSettingsMgr, pluginStateMgr, workspaceMgr, syncService, browserReceiver, debugEnabled) 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)) app.SetNotificationService(notifications.New(vaultService, api.NewNativeNotificationSender(), time.Now))
trayController := tray.New(tray.NewNativeBackend(), tray.DefaultIcon()) 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 { if browserReceiver != nil {
browserReceiverServer, err := browserreceiver.Start(browserreceiver.DefaultAddr, browserReceiver) browserReceiverServer, err := browserreceiver.Start(browserreceiver.DefaultAddr, browserReceiver)
if err != nil { if err != nil {
@@ -251,7 +259,9 @@ func main() {
OnStartup: app.Startup, OnStartup: app.Startup,
OnDomReady: func(ctx context.Context) { OnDomReady: func(ctx context.Context) {
app.DomReady(ctx) 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) { OnShutdown: func(ctx context.Context) {
trayController.Stop() trayController.Stop()
+1 -1
View File
@@ -3,7 +3,7 @@ Version: @VERSION@
Section: utils Section: utils
Priority: optional Priority: optional
Architecture: amd64 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> Maintainer: Verstak contributors <dev@verstak.app>
Homepage: https://github.com/mirivlad/verstak Homepage: https://github.com/mirivlad/verstak
Description: Local-first workspace Description: Local-first workspace
+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.
-9
View File
@@ -26,15 +26,6 @@ if ! command -v npm &>/dev/null; then
fi fi
echo " ✅ npm $(npm --version)" 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/) ── # ── Frontend (build first — Go //go:embed needs frontend/dist/) ──
echo "" echo ""
echo "[frontend]" echo "[frontend]"
-5
View File
@@ -106,11 +106,6 @@ while IFS= read -r candidate; do
done < "$APPDIR/.elf-queue" done < "$APPDIR/.elf-queue"
rm -f "$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 if command -v glib-compile-schemas >/dev/null; then
glib-compile-schemas "$APPDIR/usr/share/glib-2.0/schemas" glib-compile-schemas "$APPDIR/usr/share/glib-2.0/schemas"
fi fi
+12 -1
View File
@@ -6,6 +6,7 @@ VERSION="${1:-}"
REPOSITORY="mirivlad/verstak" REPOSITORY="mirivlad/verstak"
RELEASE_SCRIPT="${VERSTAK_RELEASE_SCRIPT:-$ROOT/scripts/release.sh}" RELEASE_SCRIPT="${VERSTAK_RELEASE_SCRIPT:-$ROOT/scripts/release.sh}"
RELEASE_DIR="${VERSTAK_RELEASE_DIR:-$ROOT/release}" RELEASE_DIR="${VERSTAK_RELEASE_DIR:-$ROOT/release}"
RELEASE_NOTES_DIR="${VERSTAK_RELEASE_NOTES_DIR:-$ROOT/release-notes}"
GIT_BIN="${GIT_BIN:-git}" GIT_BIN="${GIT_BIN:-git}"
GH_BIN="${GH_BIN:-gh}" GH_BIN="${GH_BIN:-gh}"
@@ -65,7 +66,17 @@ fi
if "$GH_BIN" release view "$VERSION" --repo "$REPOSITORY" >/dev/null 2>&1; then if "$GH_BIN" release view "$VERSION" --repo "$REPOSITORY" >/dev/null 2>&1; then
"$GH_BIN" release upload "$VERSION" "${ASSETS[@]}" --repo "$REPOSITORY" --clobber "$GH_BIN" release upload "$VERSION" "${ASSETS[@]}" --repo "$REPOSITORY" --clobber
else 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 if [[ "$VERSION" == *-* ]]; then
RELEASE_OPTIONS+=(--prerelease) RELEASE_OPTIONS+=(--prerelease)
else else
+8 -3
View File
@@ -26,11 +26,16 @@ grep -Fq 'gtk-update-icon-cache' "$ROOT/packaging/deb/postinst"
grep -Fq 'packaging/linux/verstak' "$ROOT/scripts/package-appimage.sh" grep -Fq 'packaging/linux/verstak' "$ROOT/scripts/package-appimage.sh"
grep -Fq 'usr/bin/verstak' "$ROOT/packaging/linux/AppRun" grep -Fq 'usr/bin/verstak' "$ROOT/packaging/linux/AppRun"
grep -Fq 'libwebkit2gtk-4.1-0' "$ROOT/packaging/deb/control" 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 'appimagetool' "$ROOT/scripts/package-appimage.sh"
grep -Fq 'WebKitWebProcess' "$ROOT/scripts/package-appimage.sh" grep -Fq 'WebKitWebProcess' "$ROOT/scripts/package-appimage.sh"
grep -Fq 'libayatana-appindicator3.so.' "$ROOT/scripts/package-appimage.sh" if grep -Fq 'appindicator' "$ROOT/scripts/package-appimage.sh" "$ROOT/scripts/build.sh"; then
grep -Fq 'ayatana-appindicator3-0.1' "$ROOT/scripts/build.sh" 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 if grep -Fq 'FixedVersionRuntime' "$ROOT/scripts/package-windows-portable.sh"; then
echo "Windows portable archive must not bundle Fixed Version WebView2" >&2 echo "Windows portable archive must not bundle Fixed Version WebView2" >&2
exit 1 exit 1
+7 -1
View File
@@ -18,9 +18,10 @@ if [[ ! -x "$PUBLISHER" ]]; then
exit 1 exit 1
fi fi
mkdir -p "$WORK/bin" "$WORK/release" "$WORK/state" mkdir -p "$WORK/bin" "$WORK/release" "$WORK/release-notes" "$WORK/state"
LOG="$WORK/log" LOG="$WORK/log"
export LOG export LOG
printf '## Highlights\n\nHuman-readable release notes.\n' > "$WORK/release-notes/$VERSION.md"
cat > "$WORK/release.sh" <<'SCRIPT' cat > "$WORK/release.sh" <<'SCRIPT'
#!/usr/bin/env bash #!/usr/bin/env bash
@@ -51,6 +52,7 @@ case "${1:-}" in
fi fi
echo test-commit echo test-commit
;; ;;
describe) echo v0.0.0-previous ;;
tag) touch "$TEST_STATE/tag"; printf 'tag:%s\n' "${3:-}" >> "$LOG" ;; tag) touch "$TEST_STATE/tag"; printf 'tag:%s\n' "${3:-}" >> "$LOG" ;;
push) printf 'push:%s:%s\n' "${2:-}" "${3:-}" >> "$LOG" ;; push) printf 'push:%s:%s\n' "${2:-}" "${3:-}" >> "$LOG" ;;
*) echo "unexpected git invocation: $*" >&2; exit 1 ;; *) echo "unexpected git invocation: $*" >&2; exit 1 ;;
@@ -76,6 +78,7 @@ chmod +x "$WORK/bin/gh"
run_publisher() { run_publisher() {
VERSTAK_RELEASE_SCRIPT="$WORK/release.sh" \ VERSTAK_RELEASE_SCRIPT="$WORK/release.sh" \
VERSTAK_RELEASE_DIR="$WORK/release" \ VERSTAK_RELEASE_DIR="$WORK/release" \
VERSTAK_RELEASE_NOTES_DIR="$WORK/release-notes" \
GIT_BIN="$WORK/bin/git" \ GIT_BIN="$WORK/bin/git" \
GH_BIN="$WORK/bin/gh" \ GH_BIN="$WORK/bin/gh" \
EXPECTED_ROOT="$ROOT" \ EXPECTED_ROOT="$ROOT" \
@@ -92,6 +95,9 @@ for asset in "${ASSET_NAMES[@]}"; do
grep -F "$asset" "$LOG" >/dev/null grep -F "$asset" "$LOG" >/dev/null
done done
grep -F "SHA256SUMS" "$LOG" >/dev/null 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 grep -F -- "--prerelease" "$LOG" >/dev/null
run_publisher run_publisher