13 Commits
32 changed files with 846 additions and 173 deletions
+16 -16
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. |
@@ -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;
@@ -245,15 +245,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 -7
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 продолжают работать, пока окно скрыто.
### Проверка скачанного файла ### Проверка скачанного файла
@@ -244,15 +244,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

+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'),
})); }));
+10 -2
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>
{#if debug.isEnabled()}
<p class="workbench-meta">{requestMode} · {requestContext}</p> <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 });
} }
} }
}, },
+22 -2
View File
@@ -81,6 +81,7 @@ 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")
+92 -3
View File
@@ -1,11 +1,16 @@
// 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 (
"strings"
"sync"
)
// 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)
} }
// Backend is the platform tray implementation. // Backend is the platform tray implementation.
@@ -23,11 +28,63 @@ 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 start sync.Once
mu sync.RWMutex
labels Labels
show MenuItem
quit MenuItem
} }
// New creates a tray controller for one application process. // New creates a tray controller for one application process.
@@ -35,9 +92,22 @@ 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(),
} }
} }
// SetLabels updates the current and future native tray menu labels.
func (c *Controller) SetLabels(labels Labels) {
if c == nil {
return
}
c.mu.Lock()
c.labels = labels
show, quit := c.show, c.quit
c.mu.Unlock()
applyLabels(show, quit, labels)
}
// Start registers the native tray without taking over the Wails event loop. // Start registers the native tray without taking over the Wails event loop.
func (c *Controller) Start(actions Actions) { func (c *Controller) Start(actions Actions) {
if c == nil || c.backend == nil { if c == nil || c.backend == nil {
@@ -47,8 +117,16 @@ func (c *Controller) Start(actions Actions) {
c.backend.Register(func() { c.backend.Register(func() {
c.backend.SetIcon(c.icon) c.backend.SetIcon(c.icon)
c.backend.SetTooltip("Verstak") c.backend.SetTooltip("Verstak")
show := c.backend.AddMenuItem("Show Verstak", "Show the Verstak window") c.mu.RLock()
quit := c.backend.AddMenuItem("Quit", "Quit Verstak") labels := c.labels
c.mu.RUnlock()
show := c.backend.AddMenuItem(labels.ShowTitle, labels.ShowTooltip)
quit := c.backend.AddMenuItem(labels.QuitTitle, labels.QuitTooltip)
c.mu.Lock()
c.show, c.quit = show, quit
labels = c.labels
c.mu.Unlock()
applyLabels(show, quit, labels)
if actions.Show != nil && show != nil { if actions.Show != nil && show != nil {
go routeClicks(show.Clicked(), actions.Show) go routeClicks(show.Clicked(), actions.Show)
} }
@@ -59,6 +137,17 @@ func (c *Controller) Start(actions Actions) {
}) })
} }
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 {
+46 -1
View File
@@ -7,12 +7,22 @@ import (
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 icon []byte
tooltip string tooltip string
@@ -35,7 +45,7 @@ func (b *fakeBackend) SetTooltip(tooltip string) {
} }
func (b *fakeBackend) AddMenuItem(title, _ string) MenuItem { func (b *fakeBackend) AddMenuItem(title, _ string) MenuItem {
item := &fakeMenuItem{clicked: make(chan struct{}, 1)} item := &fakeMenuItem{clicked: make(chan struct{}, 1), title: title}
b.items[title] = item b.items[title] = item
return item return item
} }
@@ -89,3 +99,38 @@ func TestControllerStopsNativeTrayBackend(t *testing.T) {
t.Fatalf("backend quit calls = %d, want 1", backend.quitCalls) t.Fatalf("backend quit calls = %d, want 1", backend.quitCalls)
} }
} }
func TestControllerUsesAndUpdatesLocalizedLabels(t *testing.T) {
backend := &fakeBackend{items: make(map[string]*fakeMenuItem)}
controller := New(backend, []byte{1})
controller.SetLabels(LabelsForPreference("ru"))
controller.Start(Actions{})
show := backend.items["Показать Верстак"]
quit := backend.items["Выйти"]
if show == nil || quit == nil {
t.Fatalf("Russian tray menu = %#v", backend.items)
}
if show.tooltip != "Показать окно Верстака" || quit.tooltip != "Завершить Верстак" {
t.Fatalf("Russian tray tooltips = show:%q quit:%q", show.tooltip, quit.tooltip)
}
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)
}
if show.tooltip != "Show the Verstak window" || quit.tooltip != "Quit Verstak" {
t.Fatalf("English tray tooltips after update = show:%q quit:%q", show.tooltip, quit.tooltip)
}
}
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)
}
}
+12
View File
@@ -39,3 +39,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)
}
}
+7
View File
@@ -230,6 +230,13 @@ 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())
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 {