Compare commits

..

No commits in common. "main" and "v0.1.0-alpha.2" have entirely different histories.

65 changed files with 450 additions and 1937 deletions

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 workbench. in one extensible local-first workspace.
**English** · [Русский](README.ru.md) **English** · [Русский](README.ru.md)
@ -29,7 +29,7 @@ in one extensible local-first workbench.
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 **Deal** can represent almost anything: A **workspace** 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 Deal and see its recent work, captures and items that need attention. | Files: manage ordinary folders and documents inside a Deal. | | 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. |
| --- | --- | | --- | --- |
| ![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) | | ![Verstak Overview in a test vault](docs/screenshots/overview.png) | ![Verstak workspace files in a test vault](docs/screenshots/workspace-files.png) |
| Notes: keep Markdown notes next to the Deal they describe. | Journal: record a completed session and retain its context. | | Notes: keep Markdown notes next to the workspace they describe. | Journal: record a completed session and retain its context. |
| --- | --- | | --- | --- |
| ![Current Verstak notes in a test vault](docs/screenshots/notes-en.png) | ![Current Verstak journal in a test vault](docs/screenshots/journal-en.png) | | ![Verstak notes in a test vault](docs/screenshots/notes.png) | ![Verstak journal in a test vault](docs/screenshots/journal.png) |
## Main features ## Main features
| Feature | Description | | Feature | Description |
| ------------------------ | -------------------------------------------------------------------------------------------- | | ------------------------ | -------------------------------------------------------------------------------------------- |
| **Deals** | Organize files, notes and activity around durable projects, clients and other areas of work. | | **Workspaces** | 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 Deals and across the whole vault. | | **Todo** | Keep optional task lists inside individual workspaces 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 Deal. | | **Secrets** | Keep credentials and access information connected to the relevant workspace. |
| **Templates** | Create repeatable Deal structures for common types of work. | | **Templates** | Create repeatable workspace 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 Deal ### 2. Create your first workspace
Inside the vault, create a Deal for a project, client, server, device or another area of work. Inside the vault, create a workspace 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 Deal and add the information needed to return to it later: Open the workspace 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-source/ verstak-workspace/
├── verstak/ ├── verstak/
├── verstak-sdk/ ├── verstak-sdk/
└── verstak-official-plugins/ └── verstak-official-plugins/
``` ```
```bash ```bash
mkdir verstak-source mkdir verstak-workspace
cd verstak-source cd verstak-workspace
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

View File

@ -7,7 +7,7 @@
### Рабочий контекст остаётся рядом — и хранится локально. ### Рабочий контекст остаётся рядом — и хранится локально.
Файлы, заметки, ссылки, материалы из браузера, активность и история работы Файлы, заметки, ссылки, материалы из браузера, активность и история работы
в одной расширяемой локальной среде. в одном расширяемом рабочем пространстве.
[English](README.md) · **Русский** [English](README.md) · **Русский**
@ -49,11 +49,11 @@
| Обзор: возвращает к делу и показывает недавнюю работу, входящие и то, что требует внимания. | Файлы: обычные папки и документы прямо внутри дела. | | Обзор: возвращает к делу и показывает недавнюю работу, входящие и то, что требует внимания. | Файлы: обычные папки и документы прямо внутри дела. |
| --- | --- | | --- | --- |
| ![Актуальный экран «Обзор» Верстака в тестовом vault](docs/screenshots/overview-ru.png) | ![Актуальные файлы дела в тестовом vault Верстака](docs/screenshots/files-ru.png) | | ![Экран «Обзор» Верстака в тестовом vault](docs/screenshots/overview.png) | ![Файлы дела в тестовом vault Верстака](docs/screenshots/workspace-files.png) |
| Заметки: Markdown-заметки остаются рядом с делом, которое они описывают. | Журнал: фиксирует завершённую работу и её контекст. | | Заметки: Markdown-заметки остаются рядом с делом, которое они описывают. | Журнал: фиксирует завершённую работу и её контекст. |
| --- | --- | | --- | --- |
| ![Актуальные заметки Верстака в тестовом vault](docs/screenshots/notes-ru.png) | ![Актуальный журнал Верстака в тестовом vault](docs/screenshots/journal-ru.png) | | ![Заметки Верстака в тестовом vault](docs/screenshots/notes.png) | ![Журнал Верстака в тестовом vault](docs/screenshots/journal.png) |
## Основные возможности ## Основные возможности
@ -124,7 +124,7 @@ APPIMAGE_EXTRACT_AND_RUN=1 ./verstak-linux-x86_64-*.AppImage
## Фоновая работа и трей ## Фоновая работа и трей
Закрытие главного окна не завершает Верстак: приложение остаётся в системном трее. Выберите **«Показать Верстак»**, чтобы вернуть окно, или **«Выйти»**, чтобы полностью завершить приложение. Благодаря этому напоминания Todo продолжают работать, пока окно скрыто. Закрытие главного окна не завершает Верстак: приложение остаётся в системном трее. Выберите **Show Verstak**, чтобы вернуть окно, или **Quit**, чтобы полностью завершить приложение. Благодаря этому напоминания Todo продолжают работать, пока окно скрыто.
### Проверка скачанного файла ### Проверка скачанного файла
@ -244,15 +244,15 @@ sha256sum -c SHA256SUMS --ignore-missing
Desktop, SDK и официальные плагины должны находиться в соседних каталогах: Desktop, SDK и официальные плагины должны находиться в соседних каталогах:
```text ```text
verstak-source/ verstak-workspace/
├── verstak/ ├── verstak/
├── verstak-sdk/ ├── verstak-sdk/
└── verstak-official-plugins/ └── verstak-official-plugins/
``` ```
```bash ```bash
mkdir verstak-source mkdir verstak-workspace
cd verstak-source cd verstak-workspace
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

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 Deal `Files` plugin displays only live files and folders. - UI policy: the workspace `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 + Deal manager (если открыт). 3. Re-register core capabilities + vault + workspace (если открыт).
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,52 +838,51 @@ 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` — ✅
## Дело: core capability ## Workspace Core Capability
Дело — это физическая папка верхнего уровня внутри vault root. Filesystem Workspace — это физическая папка верхнего уровня внутри vault root. Filesystem
является source of truth для списка Дел. является source of truth для списка workspaces.
Пример: Пример:
``` ```
<vault>/ <vault>/
Deal/ Workspace/
Notes/ Notes/
Project/ Project/
ClientA/ ClientA/
.verstak/ .verstak/
``` ```
Нет единого `<vault>/Deal/` контейнера для всех Дел. Папка `Deal/` может быть Нет единого `<vault>/Workspace/` контейнера для всех workspaces. Папка
обычным Делом, но `Project/` и `ClientA/` являются такими же Делами на том же `Workspace/` может быть обычным workspace, но `Project/` и `ClientA/` являются
уровне. такими же workspace на том же уровне.
### Хранение ### Хранение
Существование и список Дел хранятся только в filesystem: Workspace existence/list хранится только в 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 для списка Дел. - `.verstak/workspace*.json` не является source of truth для списка workspaces.
- Нет virtual workspace tree, которое - Нет persisted workspace path mapping и нет virtual workspace tree, которое
мапится на произвольные папки. мапится на произвольные папки.
`.verstak` может хранить только metadata, которая не заменяет filesystem: `.verstak` может хранить только metadata, которая не заменяет filesystem:
- UI state: выбранное Дело, expanded folders, sort/pin state, preferences. - UI state: selected workspace, 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.
При создании Дела runtime генерирует UUID и сохраняет его в Template snapshot копируется в metadata при создании workspace. Workspace
`.verstak/workspace.json`. `workspaceId` — постоянная личность Дела; identity при этом остаётся именем top-level folder; `metadata.workspaceName`
`workspaceRootPath` и `workspaceName` — изменяемый адрес и presentation fields. является presentation/snapshot field, not canonical identity. Если сохранённое
Переименование обновляет путь, но не ID. Новая папка с прежним именем получает значение расходится с именем папки, runtime возвращает canonical `workspaceName`
другой ID и не может перехватить старые привязки. равным имени папки без filesystem side effects.
```json ```json
{ {
"workspaceId": "1eb7cc69-52e8-4a18-ae2a-50d5229c5b60",
"workspaceName": "Project", "workspaceName": "Project",
"createdFromTemplate": { "createdFromTemplate": {
"templateId": "project", "templateId": "project",
@ -922,25 +921,25 @@ Vault plugin state хранится **внутри vault** в `.verstak/plugins.
} }
``` ```
Если original template удалён или изменён позже, существующее Дело Если original template удалён или изменён позже, существующий workspace
открывается по сохранённому snapshot и не мутирует автоматически. Template открывается по сохранённому snapshot и не мутирует автоматически. Template
update/migration может быть только явной future feature. Если metadata update/migration может быть только явной future feature. Если metadata
отсутствует, Дело открывается как generic Deal минимум с `files: true`. отсутствует, workspace открывается как generic workspace минимум с `files: true`.
### API ### API
- `ListWorkspaces()` — список top-level папок Дел с их `workspaceId`. - `ListWorkspaces()` — список top-level physical folders.
- `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 папку - `RenameWorkspace(oldName, newName)` — физически переименовать top-level folder
Дела и обновить metadata name, не меняя `workspaceId`. и обновить metadata key/name.
- `TrashWorkspace(name)` — перенести весь top-level folder Дела в internal - `TrashWorkspace(name)` — перенести весь top-level workspace 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:
@ -950,7 +949,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 Deal UI state. selected top-level workspace UI state.
Эти методы существуют только для постепенного frontend/Wails cleanup. Они не Эти методы существуют только для постепенного frontend/Wails cleanup. Они не
должны создавать или сохранять nested workspace tree и не должны восстанавливать должны создавать или сохранять nested workspace tree и не должны восстанавливать
@ -958,33 +957,32 @@ Deprecated compatibility APIs:
### Capability ### Capability
`verstak/core/workspace/v1` — техническое имя capability; оно регистрируется `verstak/core/workspace/v1` — регистрируется только когда vault открыт и workspace инициализирован.
только когда vault открыт и Deal manager инициализирован.
### Правила ### Правила
- Имя Дела — один safe folder name, не path. - Workspace name — один 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 Дел, conflicts. `.verstak`, reserved/internal names, symlink workspaces, 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 `<Deal>/Notes/`; нет - Notes are ordinary Markdown files under `<workspace>/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 lifecycle events Дел после успешных операций: Runtime publishes workspace lifecycle events after successful operations:
- `workspace.created` — payload includes `operation: "create"`, `workspaceId`, - `workspace.created` — payload includes `operation: "create"`,
`workspaceRootPath`, `workspaceName`, and optional `templateId`. `workspaceRootPath`, `workspaceName`, and optional `templateId`.
- `workspace.renamed` — payload includes `operation: "rename"`, `workspaceId`, - `workspace.renamed` — payload includes `operation: "rename"`, new
new `workspaceRootPath` / `workspaceName`, and previous path fields. `workspaceRootPath` / `workspaceName`, and previous workspace fields.
- `workspace.trashed` — payload includes `operation: "trash"`, `workspaceId`, - `workspace.trashed` — payload includes `operation: "trash"`,
`workspaceRootPath`, `workspaceName`, `trashId`, `trashPath`, and `deletedAt`. `workspaceRootPath`, `workspaceName`, `trashId`, `trashPath`, and `deletedAt`.
- `workspace.selected` — payload includes `operation: "select"`, `workspaceId`, - `workspace.selected` — payload includes `operation: "select"`,
`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
@ -992,11 +990,11 @@ activity provider path.
### UI ### UI
Список Дел в sidebar: Workspace list в sidebar:
- Flat list of top-level папок Дел. - Flat list of top-level workspace folders.
- Создать Дело, переименовать Дело, переместить Дело в корзину. - Create workspace, rename workspace, trash workspace.
- Selection is stored as selected Deal name. - Selection is stored as selected workspace name.
- No expand/collapse tree and no case/folder node creation in core. - No expand/collapse workspace tree and no case/folder node creation in core.
--- ---

View File

@ -1,12 +1,12 @@
# Deal Templates # Workspace Templates
Deal templates choose which dynamic plugin Deal tabs are visible when a new Workspace templates choose which dynamic plugin workspace tabs are visible when a
top-level Deal is created. They do not enable or disable plugins for the new top-level workspace 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 | Deal tabs | | Template | Workspace 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-Deal modal displays the selected template description and its The create-workspace 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 Deal metadata without `workspaceTools` list of plugin IDs. Existing workspace metadata without
`workspaceTools` remains compatible: its Deal continues to show all globally `workspaceTools` remains compatible: its workspace continues to show all globally
enabled Deal plugins rather than unexpectedly hiding tabs. enabled workspace 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
Deal with a different template never changes an existing Deal snapshot. workspace with a different template never changes an existing workspace 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 Deal. Template visibility applies only to `workspaceItems` in the selected workspace.
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 Deal host intersects the snapshot with dynamically discovered, globally The workspace 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.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

View File

@ -27,38 +27,6 @@ test.describe('Activity workflow', () => {
await expect(activity.locator('[data-activity-action="clear"]')).toBeDisabled(); await expect(activity.locator('[data-activity-action="clear"]')).toBeDisabled();
}); });
test('clearing activity requires destructive confirmation for the current case', async ({ page }) => {
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.activity', {
'events:workspace:Project': [{
activityId: 'activity-to-clear',
occurredAt: '2026-06-30T08:00:00.000Z',
type: 'note.saved',
title: 'Keep until confirmed',
workspaceRootPath: 'Project',
}],
});
});
await page.getByRole('tab', { name: 'Activity' }).click();
const activity = page.locator('.activity-root');
await expect(activity.locator('.activity-count')).toHaveText('1 event');
await activity.locator('[data-activity-action="clear"]').click();
const confirmation = activity.locator('[data-activity-clear-confirmation]');
await expect(confirmation).toBeVisible();
await expect(confirmation).toContainText(/Project|Дело/);
await expect(activity.locator('.activity-count')).toHaveText('1 event');
await confirmation.locator('[data-activity-clear-cancel]').click();
await expect(confirmation).toHaveCount(0);
await expect(activity.locator('.activity-count')).toHaveText('1 event');
await activity.locator('[data-activity-action="clear"]').click();
await activity.locator('[data-activity-clear-confirm]').click();
await expect(activity.locator('.activity-count')).toHaveText('0 events');
});
test('workspace activity keeps raw events and renders factual work session candidates', async ({ page }) => { test('workspace activity keeps raw events and renders factual work session candidates', async ({ page }) => {
await page.evaluate(async () => { await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.activity', { await window.go.api.App.WritePluginSettings('verstak.activity', {
@ -106,8 +74,8 @@ 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('Deal: Project'); await expect(candidate).toContainText('Workspace: Project');
await expect(candidate).toContainText('Estimated duration: 10 min'); await expect(candidate).toContainText('Estimated duration: 20 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');
await expect(candidate.locator('[data-work-session-action="review"]')).toBeVisible(); await expect(candidate.locator('[data-work-session-action="review"]')).toBeVisible();
@ -142,11 +110,11 @@ 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('Deal: Project'); await expect(journal.locator('[data-journal-candidate]')).toContainText('Workspace: Project');
await expect(journal.locator('[data-journal-candidate]')).toContainText('Estimated duration: 10 min'); await expect(journal.locator('[data-journal-candidate]')).toContainText('Estimated duration: 20 min');
await expect(journal.locator('[data-journal-input="title"]')).toHaveValue(''); await expect(journal.locator('[data-journal-input="title"]')).toHaveValue('');
await expect(journal.locator('[data-journal-input="summary"]')).toHaveValue(''); await expect(journal.locator('[data-journal-input="summary"]')).toHaveValue('');
await expect(journal.locator('[data-journal-input="minutes"]')).toHaveValue('10'); await expect(journal.locator('[data-journal-input="minutes"]')).toHaveValue('20');
const activityInputs = journal.locator('[data-journal-candidate-activity]'); const activityInputs = journal.locator('[data-journal-candidate-activity]');
await expect(activityInputs).toHaveCount(2); await expect(activityInputs).toHaveCount(2);
@ -158,7 +126,7 @@ test.describe('Activity workflow', () => {
await journal.locator('[data-journal-action="save-entry"]').click(); await journal.locator('[data-journal-action="save-entry"]').click();
await expect(journal).toContainText('Review research capture'); await expect(journal).toContainText('Review research capture');
await expect(journal).toContainText('10 min'); await expect(journal).toContainText('20 min');
const stored = await page.evaluate(async () => { const stored = await page.evaluate(async () => {
const result = await window.go.api.App.ReadPluginSettings('verstak.journal'); const result = await window.go.api.App.ReadPluginSettings('verstak.journal');
return Array.isArray(result) ? result[0]['worklog:workspace:Project'] : result['worklog:workspace:Project']; return Array.isArray(result) ? result[0]['worklog:workspace:Project'] : result['worklog:workspace:Project'];

View File

@ -1,7 +1,7 @@
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js'; import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
test.describe('Browser workflow', () => { test.describe('Browser Inbox workflow', () => {
let consoleCollector; let consoleCollector;
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
@ -16,14 +16,14 @@ test.describe('Browser workflow', () => {
}); });
test('global inbox explains empty capture flow and exposes assignment filters', async ({ page }) => { test('global inbox explains empty capture flow and exposes assignment filters', async ({ page }) => {
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser' }).click(); await page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser Inbox' }).click();
const inbox = page.locator('.browser-inbox-root'); const inbox = page.locator('.browser-inbox-root');
await expect(inbox).toBeVisible({ timeout: 10000 }); await expect(inbox).toBeVisible({ timeout: 10000 });
await expect(inbox.locator('.browser-inbox-title')).toHaveText('Browser'); await expect(inbox.locator('.browser-inbox-title')).toContainText('Browser Inbox');
await expect(inbox.locator('.browser-inbox-count')).toHaveText('0 items'); await expect(inbox.locator('.browser-inbox-count')).toHaveText('0 items');
await expect(inbox.locator('.browser-inbox-empty')).toContainText('No browser materials yet'); await expect(inbox.locator('.browser-inbox-empty')).toContainText('No browser captures yet');
await expect(inbox.locator('.browser-inbox-empty')).toContainText('Send a page, selection, or link from the extension'); await expect(inbox.locator('.browser-inbox-empty')).toContainText('send a page, selection, link, or file from the browser extension');
await expect(inbox.locator('[data-browser-inbox-filter="status"]')).toBeVisible(); await expect(inbox.locator('[data-browser-inbox-filter="status"]')).toBeVisible();
await expect(inbox.locator('[data-browser-inbox-filter="workspace"]')).toBeVisible(); await expect(inbox.locator('[data-browser-inbox-filter="workspace"]')).toBeVisible();
await expect(inbox.locator('[data-browser-inbox-action="clear"]')).toBeDisabled(); await expect(inbox.locator('[data-browser-inbox-action="clear"]')).toBeDisabled();
@ -47,7 +47,7 @@ test.describe('Browser workflow', () => {
}], }],
}); });
}); });
await page.getByRole('tab', { name: 'Browser' }).click(); await page.getByRole('tab', { name: 'Browser Inbox' }).click();
const inbox = page.locator('.browser-inbox-root'); const inbox = page.locator('.browser-inbox-root');
await expect(inbox.locator('.browser-inbox-count')).toHaveText('1 item'); await expect(inbox.locator('.browser-inbox-count')).toHaveText('1 item');
@ -94,7 +94,7 @@ test.describe('Browser workflow', () => {
], ],
}); });
}); });
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser' }).click(); await page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser Inbox' }).click();
const inbox = page.locator('.browser-inbox-root'); const inbox = page.locator('.browser-inbox-root');
await expect(inbox.locator('.browser-inbox-count')).toHaveText('3 items'); await expect(inbox.locator('.browser-inbox-count')).toHaveText('3 items');

View File

@ -124,20 +124,4 @@ test.describe('Command Palette', () => {
await expect(page.locator('.modal[aria-label="Plugin Settings"]')).toBeVisible({ timeout: 10000 }); await expect(page.locator('.modal[aria-label="Plugin Settings"]')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.modal-header h3')).toContainText('Sync'); await expect(page.locator('.modal-header h3')).toContainText('Sync');
}); });
test('keeps technical command failures out of the palette status', async ({ page }) => {
await page.evaluate(() => {
window.go.api.App.PluginSyncNow = () => Promise.resolve('[plugin:verstak.sync] sync.now failed: internal service code 42');
});
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+K' : 'Control+K');
const palette = page.locator('.command-palette');
await palette.locator('[data-command-palette-input]').fill('sync now');
await page.keyboard.press('Enter');
const status = page.locator('[data-command-palette-status="error"]');
await expect(status).toContainText('Could not run Sync Now. Please try again.');
await expect(status).not.toContainText('[plugin:');
await expect(status).not.toContainText('sync.now');
});
}); });

View File

@ -36,69 +36,6 @@ test.describe('F: Default Editor Plugin', () => {
await expect(textarea).toHaveValue('Buy groceries\nWrite tests'); await expect(textarea).toHaveValue('Buy groceries\nWrite tests');
}); });
test('secret link opens its exact secret and closing it restores the note preview', async ({ page }) => {
const notePath = 'Notes/Secret Link.md';
const noteContent = '# Secret link\n\n[Target secret](verstak-secret://target.secret)';
await page.evaluate(async ({ notePath, noteContent }) => {
const writeError = await window.go.api.App.WriteVaultTextFile(
'verstak.platform-test',
notePath,
noteContent,
{ createIfMissing: true, overwrite: true },
);
if (writeError) throw new Error(writeError);
const [result, openError] = await window.go.api.App.OpenWorkbenchResource('verstak.platform-test', {
kind: 'vault-file',
path: notePath,
extension: '.md',
context: { sourceView: 'notes', isInsideNotesFolder: true, notesMode: true },
});
if (openError) throw new Error(openError);
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
}, { notePath, noteContent });
const note = page.locator('[data-editor-mode="notes-markdown"]');
await expect(note).toBeVisible({ timeout: 10000 });
await expect(note.locator('[data-preview]')).toContainText('Target secret');
await note.locator('.secret-link').click();
const secrets = page.locator('.secrets-root');
await expect(secrets).toBeVisible({ timeout: 10000 });
await expect(secrets.locator('.secrets-item.active .secrets-item-title')).toHaveText('Target secret');
await expect(secrets.locator('.secrets-card h2')).toHaveText('Target secret');
await page.locator('.workbench-host .close-btn').click();
await expect(note).toBeVisible({ timeout: 10000 });
await expect(note.locator('[data-preview]')).toContainText('Target secret');
await expect(note.locator('[data-editor-textarea]')).toHaveCount(0);
await expect(note.locator('[data-save-state]')).toHaveText('');
const storedContent = await page.evaluate(async ({ notePath }) => {
const [content, readError] = await window.go.api.App.ReadVaultTextFile('verstak.platform-test', notePath);
if (readError) throw new Error(readError);
return content;
}, { notePath });
expect(storedContent).toBe(noteContent);
});
test('unavailable secret does not fall back to the first secret', async ({ page }) => {
await page.evaluate(async () => {
const [result, openError] = await window.go.api.App.OpenWorkbenchResource('verstak.default-editor', {
kind: 'secret',
path: 'missing.secret',
mode: 'view',
});
if (openError) throw new Error(openError);
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
});
const secrets = page.locator('.secrets-root');
await expect(secrets).toBeVisible({ timeout: 10000 });
await expect(secrets.locator('.secrets-item.active')).toHaveCount(0);
await expect(secrets.locator('.secrets-status.error')).toContainText(/unavailable|недоступен/i);
});
test('editor supports markdown toolbar split save reopen and revert', async ({ page }) => { test('editor supports markdown toolbar split save reopen and revert', async ({ page }) => {
await page.evaluate(async () => { await page.evaluate(async () => {
const err = await window.go.api.App.WriteVaultTextFile( const err = await window.go.api.App.WriteVaultTextFile(
@ -175,10 +112,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('readme.md'); await expect(title).toHaveText('Docs/readme.md');
}); });
test('opening a note uses its title rather than its technical path', async ({ page }) => { test('open .md with notes context routes to highest-priority provider', 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 +130,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('Overview'); await expect(title).toHaveText('Notes/Overview.md');
}); });
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 }) => {

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('readme.md'); await expect(workbench.locator('.workbench-title')).toHaveText('Docs/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('Overview'); await expect(workbench.locator('.workbench-title')).toHaveText('Notes/Overview.md');
}); });
test('files plugin card shows openProviders in contributions', async ({ page }) => { test('files plugin card shows openProviders in contributions', async ({ page }) => {

View File

@ -1,35 +0,0 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
test('global Journal creates an entry in the selected Deal', async ({ page }) => {
const consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.journal', {
'worklog:workspace:Project': [{ entryId: 'existing-project-entry', workspaceRootPath: 'Project', date: '2026-07-14', title: 'Existing entry', minutes: 5 }],
});
});
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Journal' }).click();
const journal = page.locator('.journal-root');
await expect(journal.locator('.journal-title')).toHaveText('Journal');
await journal.locator('[data-journal-action="add"]').click();
await journal.locator('[data-journal-input="workspaceRootPath"]').selectOption('Project');
await journal.locator('[data-journal-input="title"]').fill('Prepare project handoff');
await journal.locator('[data-journal-input="minutes"]').fill('30');
await journal.locator('[data-journal-action="save-entry"]').click();
await expect.poll(async () => page.evaluate(async () => {
const result = await window.go.api.App.ReadPluginSettings('verstak.journal');
const settings = Array.isArray(result) ? result[0] : result;
return settings['worklog:workspace:Project']?.[0]?.title;
})).toBe('Prepare project handoff');
await expect(journal).toContainText('Prepare project handoff');
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await page.getByRole('tab', { name: 'Journal' }).click();
await expect(page.locator('.journal-root')).toContainText('Prepare project handoff');
consoleCollector.assertNoErrors();
});

View File

@ -25,23 +25,9 @@ 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);

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('Overview'); await expect(workbench.locator('.workbench-title')).toHaveText('Notes/Overview.md');
}); });
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('readme.md'); await expect(title).toHaveText('Docs/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,10 +91,8 @@ test.describe('D: Plugin API bridge', () => {
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result })); window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
}); });
const fallback = page.locator('[data-workbench-status="no-provider"]'); await expect(page.locator('[data-workbench-status="no-provider"]')).toBeVisible();
await expect(fallback).toBeVisible(); await expect(page.locator('[data-workbench-status="no-provider"]')).toContainText('No viewer/editor available');
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 }) => {

View File

@ -1,5 +1,5 @@
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState, openPluginManager, setPluginStatus } from './helpers.js'; import { waitForAppReady, setupConsoleCollector, resetMockState, openPluginManager } from './helpers.js';
test.describe('E: Plugin Manager layout', () => { test.describe('E: Plugin Manager layout', () => {
let consoleCollector; let consoleCollector;
@ -88,56 +88,6 @@ 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);
@ -168,15 +118,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 Deal"]').click(); await page.locator('button[title="New workspace"]').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 Deal' }).click(); await modal.getByRole('button', { name: 'Create workspace' }).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 Deal"]').click(); await client.locator('button[title="Rename workspace"]').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();
@ -184,7 +134,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="Move Deal to trash"]').click(); await renamed.locator('button[title="Trash workspace"]').click();
await expect(page.locator('.wt-label').filter({ hasText: 'ClientB' })).toHaveCount(0); await expect(page.locator('.wt-label').filter({ hasText: 'ClientB' })).toHaveCount(0);
}); });

View File

@ -1,50 +0,0 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
test('global Secrets lists, filters, and creates Deal-scoped secrets', async ({ page }) => {
const consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
await page.evaluate(async () => {
const [record, error] = await window.go.api.App.PluginSecretsWrite('verstak.secrets', {
id: 'project.global-secret',
title: 'Project API',
username: 'project-user',
value: 'project-value',
scope: { kind: 'workspace', workspaceRootPath: 'Project' },
});
if (error || !record?.id) throw new Error(error || 'could not create a project secret');
});
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Secrets' }).click();
const secrets = page.locator('.secrets-root');
await expect(secrets).toBeVisible({ timeout: 10000 });
await expect(secrets).toContainText('First secret');
await expect(secrets).toContainText('Project API');
await secrets.locator('[data-secret-scope-filter]').selectOption('workspace:Project');
await expect(secrets).toContainText('Project API');
await expect(secrets).not.toContainText('First secret');
await secrets.locator('[data-secret-search]').fill('no matching secret');
await expect(secrets).toContainText('No secrets');
await secrets.locator('[data-secret-search]').fill('');
await secrets.locator('[data-secret-scope-filter]').selectOption('all');
await secrets.getByRole('button', { name: 'New' }).click();
await secrets.locator('[data-secret-title]').fill('Project deployment token');
await secrets.locator('[data-secret-value]').fill('deployment-token-value');
await secrets.locator('[data-secret-scope]').selectOption('workspace');
await secrets.locator('[data-secret-workspace]').selectOption('Project');
await secrets.locator('[data-secret-save]').click();
await expect.poll(async () => page.evaluate(async () => {
const [records, error] = await window.go.api.App.PluginSecretsList('verstak.secrets');
if (error) throw new Error(error);
const record = records.find((item) => item.title === 'Project deployment token');
return record && [record.scope?.kind, record.scope?.workspaceRootPath].join('|');
})).toBe('workspace|Project');
consoleCollector.assertNoErrors();
});

View File

@ -30,45 +30,18 @@ test.describe('B: Sidebar opens plugin view by item.view', () => {
test('Sidebar item exists with correct label', async ({ page }) => { test('Sidebar item exists with correct label', async ({ page }) => {
await expect(page.locator('.sidebar .nav-item').filter({ hasText: 'Plugin Manager' })).not.toBeVisible(); await expect(page.locator('.sidebar .nav-item').filter({ hasText: 'Plugin Manager' })).not.toBeVisible();
await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Activity' })).toBeVisible(); await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Activity' })).toBeVisible();
await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser' })).toBeVisible(); await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser Inbox' })).toBeVisible();
const sidebarItem = page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' }); const sidebarItem = page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' });
await expect(sidebarItem).toBeVisible(); await expect(sidebarItem).toBeVisible();
}); });
test('Global Activity and Browser sidebar items open plugin views', async ({ page }) => { test('Global Activity and Browser Inbox sidebar items open plugin views', async ({ page }) => {
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Activity' }).click(); await page.locator('.sidebar .plugin-item').filter({ hasText: 'Activity' }).click();
await expect(page.locator('.view-container .view-header h2')).toHaveText('Activity', { timeout: 10000 }); await expect(page.locator('.view-container .view-header h2')).toHaveText('Activity', { timeout: 10000 });
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser' }).click(); await page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser Inbox' }).click();
await expect(page.locator('.view-container .view-header h2')).toHaveText('Browser', { timeout: 10000 }); await expect(page.locator('.view-container .view-header h2')).toHaveText('Browser Inbox', { timeout: 10000 });
});
test('selected global tool remains visibly active through navigation and sidebar reloads', async ({ page }) => {
const activity = page.locator('.sidebar .plugin-item').filter({ hasText: 'Activity' });
const browserInbox = page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser' });
await activity.click();
await expect(activity).toHaveAttribute('aria-current', 'page');
await expect(activity).toHaveClass(/is-active/);
await page.evaluate(() => {
window.dispatchEvent(new CustomEvent('verstak:plugins-changed'));
});
await expect(activity).toHaveAttribute('aria-current', 'page');
await page.evaluate(() => {
window.dispatchEvent(new CustomEvent('verstak:open-view', {
detail: { viewId: 'verstak.browser-inbox.view', pluginId: 'verstak.browser-inbox' },
}));
});
await expect(page.locator('.view-container .view-header h2')).toHaveText('Browser', { timeout: 10000 });
await expect(browserInbox).toHaveAttribute('aria-current', 'page');
await expect(browserInbox).toHaveClass(/is-active/);
await expect(activity).not.toHaveAttribute('aria-current', 'page');
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await expect(browserInbox).not.toHaveAttribute('aria-current', 'page');
}); });
test('Click sidebar item opens diagnostics view by view ID, not sidebar ID', async ({ page }) => { test('Click sidebar item opens diagnostics view by view ID, not sidebar ID', async ({ page }) => {

View File

@ -27,10 +27,7 @@ test.describe('Todo plugin workflow', () => {
await todos.locator('[data-todo-input="description"]').fill('Collect factual review notes.'); await todos.locator('[data-todo-input="description"]').fill('Collect factual review notes.');
await todos.locator('[data-todo-input="priority"]').selectOption('high'); await todos.locator('[data-todo-input="priority"]').selectOption('high');
await todos.locator('[data-todo-input="dueAt"]').fill('2000-01-01'); await todos.locator('[data-todo-input="dueAt"]').fill('2000-01-01');
await todos.locator('[data-todo-input="reminderDate"]').fill('2000-01-01'); await todos.locator('[data-todo-input="reminderAt"]').fill('2000-01-01T09:00');
const reminderTime = todos.locator('[data-todo-input="reminderTime"]');
await expect(reminderTime).toHaveAttribute('type', 'text');
await reminderTime.fill('09:30');
await todos.locator('[data-todo-action="save"]').click(); await todos.locator('[data-todo-action="save"]').click();
await expect(todos).toContainText('Overdue'); await expect(todos).toContainText('Overdue');
@ -39,23 +36,13 @@ test.describe('Todo plugin workflow', () => {
const result = await window.go.api.App.ReadPluginSettings('verstak.todo'); const result = await window.go.api.App.ReadPluginSettings('verstak.todo');
const settings = Array.isArray(result) ? result[0] : result; const settings = Array.isArray(result) ? result[0] : result;
const todo = settings['todos:global'].find((item) => item.title === 'Prepare project review'); const todo = settings['todos:global'].find((item) => item.title === 'Prepare project review');
return todo && [todo.workspaceRootPath, todo.priority, todo.dueAt, todo.reminderDate, todo.reminderAt].join('|'); return todo && [todo.workspaceRootPath, todo.priority, todo.dueAt, todo.reminderAt].join('|');
})).toBe('Project|high|2000-01-01|2000-01-01|2000-01-01T09:30'); })).toBe('Project|high|2000-01-01|2000-01-01T09:00');
await todos.locator('[data-todo-action="edit"]').click(); await todos.locator('[data-todo-action="edit"]').click();
await todos.locator('[data-todo-input="title"]').fill('Prepare project review updated'); await todos.locator('[data-todo-input="title"]').fill('Prepare project review updated');
await todos.locator('[data-todo-input="reminderTime"]').fill('not-a-time');
await todos.locator('[data-todo-action="save"]').click();
await expect(todos).toContainText('Enter a valid reminder time');
await todos.locator('[data-todo-input="reminderTime"]').fill('');
await todos.locator('[data-todo-action="save"]').click(); await todos.locator('[data-todo-action="save"]').click();
await expect(todos).toContainText('Prepare project review updated'); await expect(todos).toContainText('Prepare project review updated');
await expect.poll(async () => page.evaluate(async () => {
const result = await window.go.api.App.ReadPluginSettings('verstak.todo');
const settings = Array.isArray(result) ? result[0] : result;
const todo = settings['todos:global'].find((item) => item.title === 'Prepare project review updated');
return todo && [todo.reminderDate, todo.reminderAt].join('|');
})).toBe('2000-01-01|');
await todos.locator('[data-todo-action="mark-done"]').click(); await todos.locator('[data-todo-action="mark-done"]').click();
await expect(todos.locator('[data-todo-action="create-journal-entry"]')).toBeVisible(); await expect(todos.locator('[data-todo-action="create-journal-entry"]')).toBeVisible();

View File

@ -18,7 +18,7 @@ test.describe('UX follow-up fixes', () => {
await expect(search).toBeVisible(); await expect(search).toBeVisible();
await expect(page.locator('.sidebar [data-global-search-input]')).toBeVisible(); await expect(page.locator('.sidebar [data-global-search-input]')).toBeVisible();
await page.locator('.sidebar .nav-item').filter({ hasText: 'Browser' }).click(); await page.locator('.sidebar .nav-item').filter({ hasText: 'Browser Inbox' }).click();
await expect(page.locator('.browser-inbox-root')).toBeVisible({ timeout: 10000 }); await expect(page.locator('.browser-inbox-root')).toBeVisible({ timeout: 10000 });
await expect(search).toBeVisible(); await expect(search).toBeVisible();
}); });
@ -67,7 +67,7 @@ test.describe('UX follow-up fixes', () => {
const search = page.locator('[data-global-search-input]'); const search = page.locator('[data-global-search-input]');
await search.fill('Research Search Result'); await search.fill('Research Search Result');
const result = page.locator('[data-global-search-result-type="Browser"]').filter({ hasText: 'Research Search Result' }); const result = page.locator('[data-global-search-result-type="Browser Inbox"]').filter({ hasText: 'Research Search Result' });
await expect(result).toBeVisible({ timeout: 10000 }); await expect(result).toBeVisible({ timeout: 10000 });
await result.click(); await result.click();

View File

@ -38,11 +38,6 @@ test.describe('UX P0 shell flow', () => {
await expect(page.locator('.plugin-manager')).toHaveCount(0); await expect(page.locator('.plugin-manager')).toHaveCount(0);
}); });
test('Deal header does not expose the internal workspace type badge', async ({ page }) => {
await expect(page.locator('.workspace-host')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.workspace-type')).toHaveCount(0);
});
test('status bar plugin contribution failures do not render large error panels', async ({ page }) => { test('status bar plugin contribution failures do not render large error panels', async ({ page }) => {
await expect(page.locator('.workspace-host')).toBeVisible({ timeout: 10000 }); await expect(page.locator('.workspace-host')).toBeVisible({ timeout: 10000 });
await expect(page.getByText('Plugin View Error')).toHaveCount(0); await expect(page.getByText('Plugin View Error')).toHaveCount(0);

View File

@ -56,7 +56,7 @@ test.describe('UX Overview workspace flow', () => {
await page.getByRole('tab', { name: 'Overview' }).click(); await page.getByRole('tab', { name: 'Overview' }).click();
await overview.locator('[data-overview-summary="captures"]').click(); await overview.locator('[data-overview-summary="captures"]').click();
await expect(page.getByRole('tab', { name: 'Browser' })).toHaveAttribute('aria-selected', 'true'); await expect(page.getByRole('tab', { name: 'Browser Inbox' })).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.browser-inbox-root')).toBeVisible({ timeout: 10000 }); await expect(page.locator('.browser-inbox-root')).toBeVisible({ timeout: 10000 });
await page.getByRole('tab', { name: 'Overview' }).click(); await page.getByRole('tab', { name: 'Overview' }).click();
@ -70,7 +70,7 @@ test.describe('UX Overview workspace flow', () => {
await page.getByRole('tab', { name: 'Overview' }).click(); await page.getByRole('tab', { name: 'Overview' }).click();
await overview.locator('[data-overview-summary="attention"]').click(); await overview.locator('[data-overview-summary="attention"]').click();
await expect(page.getByRole('tab', { name: 'Browser' })).toHaveAttribute('aria-selected', 'true'); await expect(page.getByRole('tab', { name: 'Browser Inbox' })).toHaveAttribute('aria-selected', 'true');
}); });
test('Overview keeps the Notes total factual when the Files plugin is unavailable', async ({ page }) => { test('Overview keeps the Notes total factual when the Files plugin is unavailable', async ({ page }) => {
@ -80,54 +80,6 @@ test.describe('UX Overview workspace flow', () => {
await expect(page.locator('[data-overview-summary="notes"]')).toContainText('1 total'); await expect(page.locator('[data-overview-summary="notes"]')).toContainText('1 total');
}); });
test('Overview hides Browser cards and actions when the current Deal does not include it', async ({ page }) => {
await page.locator('button[title="New Deal"]').click();
const modal = page.locator('[data-workspace-create-modal]');
await modal.locator('[data-workspace-name]').fill('MinimalOverview');
await modal.locator('[data-workspace-template]').selectOption('minimal');
await modal.getByRole('button', { name: 'Create Deal' }).click();
await expect(page.getByRole('tab', { name: 'Browser' })).toHaveCount(0);
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.browser-inbox', {
'captures:global': [{
captureId: 'hidden-browser-capture',
capturedAt: '2026-07-14T08:00:00.000Z',
kind: 'page',
title: 'Inbox material must stay hidden',
workspaceRootPath: 'MinimalOverview',
}],
});
});
const overview = page.locator('[data-overview-root]');
await overview.locator('[data-overview-action="refresh"]').click();
await expect(overview.locator('[data-overview-summary="captures"]')).toHaveCount(0);
await expect(overview.locator('[data-overview-action="browser-inbox"]')).toHaveCount(0);
await expect(overview).not.toContainText('Inbox material must stay hidden');
});
test('Overview refreshes when Browser is disabled through plugin state changes', async ({ page }) => {
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.browser-inbox', {
'captures:global': [{
captureId: 'disabled-browser-capture',
capturedAt: '2026-07-14T08:00:00.000Z',
kind: 'page',
title: 'Disabled inbox material',
workspaceRootPath: 'Project',
}],
});
window.__wailsMock.setPluginStatus('verstak.browser-inbox', 'disabled', false);
window.dispatchEvent(new CustomEvent('verstak:plugins-changed'));
});
const overview = page.locator('[data-overview-root]');
await expect(page.getByRole('tab', { name: 'Browser' })).toHaveCount(0);
await expect(overview.locator('[data-overview-summary="captures"]')).toHaveCount(0);
await expect(overview.locator('[data-overview-action="browser-inbox"]')).toHaveCount(0);
});
test('Overview prioritizes resume work and filters meaningful recent changes', async ({ page }) => { test('Overview prioritizes resume work and filters meaningful recent changes', async ({ page }) => {
await page.evaluate(async () => { await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.browser-inbox', { await window.go.api.App.WritePluginSettings('verstak.browser-inbox', {
@ -248,10 +200,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('Deal: Project · 10 min · 2 activities'); await expect(attention).toContainText('Workspace: Project · 10 min · 2 activities');
await attention.locator('.overview-attention-row', { hasText: 'Possible journal entry' }).getByRole('button', { name: 'Review candidate' }).click(); await 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('Deal: Project'); await expect(page.locator('.journal-root [data-journal-candidate]')).toContainText('Workspace: Project');
await page.locator('.journal-modal-actions').getByRole('button', { name: 'Cancel' }).click(); await page.locator('.journal-modal-actions').getByRole('button', { name: 'Cancel' }).click();
await page.getByRole('tab', { name: 'Overview' }).click(); await page.getByRole('tab', { name: 'Overview' }).click();
@ -264,7 +216,7 @@ test.describe('UX Overview workspace flow', () => {
await expect(candidates.nth(3)).toContainText('Changed file "draft.md"'); await expect(candidates.nth(3)).toContainText('Changed file "draft.md"');
await candidates.nth(0).click(); await candidates.nth(0).click();
await expect(page.getByRole('tab', { name: 'Browser' })).toHaveAttribute('aria-selected', 'true'); await expect(page.getByRole('tab', { name: 'Browser Inbox' })).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.browser-inbox-root')).toBeVisible({ timeout: 10000 }); await expect(page.locator('.browser-inbox-root')).toBeVisible({ timeout: 10000 });
await page.getByRole('tab', { name: 'Overview' }).click(); await page.getByRole('tab', { name: 'Overview' }).click();
@ -295,28 +247,4 @@ 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');
});
}); });

View File

@ -16,7 +16,7 @@ test.describe('Workspace templates', () => {
}); });
async function openCreateModal(page) { async function openCreateModal(page) {
await page.locator('button[title="New Deal"]').click(); await page.locator('button[title="New workspace"]').click();
const modal = page.locator('[data-workspace-create-modal]'); const modal = page.locator('[data-workspace-create-modal]');
await expect(modal).toBeVisible(); await expect(modal).toBeVisible();
return modal; return modal;
@ -24,24 +24,22 @@ test.describe('Workspace templates', () => {
test('creation modal validates names, shows template tools, and persists the selected snapshot', async ({ page }) => { test('creation modal validates names, shows template tools, and persists the selected snapshot', async ({ page }) => {
const modal = await openCreateModal(page); const modal = await openCreateModal(page);
const templateSelect = modal.locator('[data-workspace-template]'); await expect(modal.locator('[data-workspace-template]')).toHaveValue('default');
await expect(templateSelect).toHaveValue('default');
await expect(templateSelect).toHaveCSS('appearance', 'none');
await expect(modal.locator('[data-workspace-template-tools]')).toContainText('Notes'); await expect(modal.locator('[data-workspace-template-tools]')).toContainText('Notes');
await expect(modal.locator('[data-workspace-template-tools]')).toContainText('Browser'); await expect(modal.locator('[data-workspace-template-tools]')).toContainText('Browser Inbox');
await modal.getByRole('button', { name: 'Create Deal' }).click(); await modal.getByRole('button', { name: 'Create workspace' }).click();
await expect(modal.locator('[data-workspace-create-error]')).toContainText('Name is required'); await expect(modal.locator('[data-workspace-create-error]')).toContainText('Name is required');
await modal.locator('[data-workspace-name]').fill('bad/name'); await modal.locator('[data-workspace-name]').fill('bad/name');
await modal.getByRole('button', { name: 'Create Deal' }).click(); await modal.getByRole('button', { name: 'Create workspace' }).click();
await expect(modal.locator('[data-workspace-create-error]')).toContainText('Could not create the Deal. Please try again.'); await expect(modal.locator('[data-workspace-create-error]')).toContainText('invalid-workspace-name');
await modal.locator('[data-workspace-name]').fill('ProjectPlan'); await modal.locator('[data-workspace-name]').fill('ProjectPlan');
await modal.locator('[data-workspace-template]').selectOption('project'); await modal.locator('[data-workspace-template]').selectOption('project');
await expect(modal.locator('[data-workspace-template-description]')).toContainText('Project planning'); await expect(modal.locator('[data-workspace-template-description]')).toContainText('Project planning');
await expect(modal.locator('[data-workspace-template-tools]')).toContainText('Todos'); await expect(modal.locator('[data-workspace-template-tools]')).toContainText('Todos');
await modal.getByRole('button', { name: 'Create Deal' }).click(); await modal.getByRole('button', { name: 'Create workspace' }).click();
await expect(page.locator('.wt-label').filter({ hasText: 'ProjectPlan' })).toBeVisible(); await expect(page.locator('.wt-label').filter({ hasText: 'ProjectPlan' })).toBeVisible();
await expect.poll(async () => page.evaluate(async () => { await expect.poll(async () => page.evaluate(async () => {
@ -65,7 +63,7 @@ test.describe('Workspace templates', () => {
const modal = await openCreateModal(page); const modal = await openCreateModal(page);
await modal.locator('[data-workspace-name]').fill('MinimalSpace'); await modal.locator('[data-workspace-name]').fill('MinimalSpace');
await modal.locator('[data-workspace-template]').selectOption('minimal'); await modal.locator('[data-workspace-template]').selectOption('minimal');
await modal.getByRole('button', { name: 'Create Deal' }).click(); await modal.getByRole('button', { name: 'Create workspace' }).click();
await expect(page.getByRole('tab', { name: 'Overview' })).toBeVisible(); await expect(page.getByRole('tab', { name: 'Overview' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Notes' })).toBeVisible(); await expect(page.getByRole('tab', { name: 'Notes' })).toBeVisible();
@ -74,40 +72,21 @@ test.describe('Workspace templates', () => {
await expect(page.getByRole('tab', { name: 'Journal' })).toHaveCount(0); await expect(page.getByRole('tab', { name: 'Journal' })).toHaveCount(0);
await expect(page.getByRole('tab', { name: 'Secrets' })).toHaveCount(0); await expect(page.getByRole('tab', { name: 'Secrets' })).toHaveCount(0);
await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Todos' })).toBeVisible(); await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Todos' })).toBeVisible();
await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser' })).toBeVisible(); await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser Inbox' })).toBeVisible();
});
test('template explains an unavailable plugin and warns after incomplete creation', async ({ page }) => {
await page.evaluate(() => window.__wailsMock.setPluginStatus('verstak.todo', 'disabled', false));
const modal = await openCreateModal(page);
await modal.locator('[data-workspace-template]').selectOption('project');
const todo = modal.locator('[data-workspace-template-tool="verstak.todo"]');
await expect(todo).toContainText('Todos');
await expect(todo).toContainText('Plugin is disabled');
await expect(todo).toHaveAttribute('data-template-tool-status', 'unavailable');
await modal.locator('[data-workspace-name]').fill('ProjectWithWarning');
await modal.getByRole('button', { name: 'Create Deal' }).click();
const warning = page.locator('[data-workspace-template-warning]');
await expect(warning).toContainText('ProjectWithWarning');
await expect(warning).toContainText('Todos');
await expect(warning).toContainText('Plugin is disabled');
}); });
test('Admin shows Secrets when available and missing workspace plugins degrade without breaking tabs', async ({ page }) => { test('Admin shows Secrets when available and missing workspace plugins degrade without breaking tabs', async ({ page }) => {
let modal = await openCreateModal(page); let modal = await openCreateModal(page);
await modal.locator('[data-workspace-name]').fill('AdminSpace'); await modal.locator('[data-workspace-name]').fill('AdminSpace');
await modal.locator('[data-workspace-template]').selectOption('admin'); await modal.locator('[data-workspace-template]').selectOption('admin');
await modal.getByRole('button', { name: 'Create Deal' }).click(); await modal.getByRole('button', { name: 'Create workspace' }).click();
await expect(page.getByRole('tab', { name: 'Secrets' })).toBeVisible(); await expect(page.getByRole('tab', { name: 'Secrets' })).toBeVisible();
await page.evaluate(() => window.__wailsMock.setPluginStatus('verstak.todo', 'disabled', false)); await page.evaluate(() => window.__wailsMock.setPluginStatus('verstak.todo', 'disabled', false));
modal = await openCreateModal(page); modal = await openCreateModal(page);
await modal.locator('[data-workspace-name]').fill('ProjectWithoutTodo'); await modal.locator('[data-workspace-name]').fill('ProjectWithoutTodo');
await modal.locator('[data-workspace-template]').selectOption('project'); await modal.locator('[data-workspace-template]').selectOption('project');
await modal.getByRole('button', { name: 'Create Deal' }).click(); await modal.getByRole('button', { name: 'Create workspace' }).click();
await expect(page.getByRole('tab', { name: 'Notes' })).toBeVisible(); await expect(page.getByRole('tab', { name: 'Notes' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Files' })).toBeVisible(); await expect(page.getByRole('tab', { name: 'Files' })).toBeVisible();

View File

@ -395,11 +395,7 @@
<VaultSelection /> <VaultSelection />
{:else} {:else}
<main> <main>
<Sidebar <Sidebar showGlobalSearch={currentView !== 'workspace' && currentView !== 'workspace-empty'} />
showGlobalSearch={currentView !== 'workspace' && currentView !== 'workspace-empty'}
{activeView}
{activeViewPluginId}
/>
<CommandPalette /> <CommandPalette />
<section class="content-shell"> <section class="content-shell">

View File

@ -16,13 +16,13 @@ export default {
'sidebar.error.load': 'Failed to load sidebar', 'sidebar.error.load': 'Failed to load sidebar',
'sidebar.error.ui': 'Plugin UI error', 'sidebar.error.ui': 'Plugin UI error',
'workspace.overview': 'Overview', 'workspace.overview': 'Overview',
'workspace.search': 'Search in this Deal', 'workspace.search': 'Workspace search',
'workspace.tools': 'Deal tools', 'workspace.tools': 'Workspace tools',
'workspace.tool': 'Deal tool', 'workspace.tool': 'Workspace tool',
'workspace.emptyTools': 'No Deal tools available', 'workspace.emptyTools': 'No workspace tools available',
'workspace.emptyToolsHint': 'Enable plugins with Deal tools or open Plugin Manager from settings.', 'workspace.emptyToolsHint': 'Enable plugins with workspace tools or open Plugin Manager from settings.',
'workspace.select': 'Select a Deal', 'workspace.select': 'Select a workspace',
'workspace.selectHint': 'Use the + button in Deals to add your first project.', 'workspace.selectHint': 'Use the + button in Workspaces to add your first project.',
'common.details': 'Details', 'common.details': 'Details',
'common.plugin': 'Plugin', 'common.plugin': 'Plugin',
'common.component': 'Component', 'common.component': 'Component',
@ -35,7 +35,6 @@ 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',
@ -55,24 +54,6 @@ 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',
@ -93,7 +74,7 @@ export default {
'pluginCard.count.sidebar': '{count} sidebar item(s)', 'pluginCard.count.sidebar': '{count} sidebar item(s)',
'pluginCard.count.statusbar': '{count} status bar item(s)', 'pluginCard.count.statusbar': '{count} status bar item(s)',
'pluginCard.count.openProviders': '{count} openProviders', 'pluginCard.count.openProviders': '{count} openProviders',
'pluginCard.count.workspace': '{count} Deal tool(s)', 'pluginCard.count.workspace': '{count} workspace item(s)',
'pluginCard.degraded': 'Plugin is usable, but some optional capabilities are unavailable.', 'pluginCard.degraded': 'Plugin is usable, but some optional capabilities are unavailable.',
'pluginCard.name': 'Name', 'pluginCard.name': 'Name',
'pluginCard.contributions': 'Contributions', 'pluginCard.contributions': 'Contributions',
@ -115,12 +96,12 @@ export default {
'common.loading': 'Loading...', 'common.loading': 'Loading...',
'common.browse': 'Browse...', 'common.browse': 'Browse...',
'vaultSelection.chooseNew': 'Choose or enter a folder for the new vault.', 'vaultSelection.chooseNew': 'Choose or enter a folder for the new vault.',
'vaultSelection.createError': 'Could not create the vault. Please try again.', 'vaultSelection.createError': 'Could not create vault: {error}',
'vaultSelection.openError': 'Could not open the vault. Please try again.', 'vaultSelection.openError': 'Could not open vault: {error}',
'vaultSelection.chooseExisting': 'Choose or enter an existing vault.', 'vaultSelection.chooseExisting': 'Choose or enter an existing vault.',
'vaultSelection.subtitle': 'Choose a vault to start working', 'vaultSelection.subtitle': 'Choose a vault to start working',
'vaultSelection.createTitle': 'Create a new vault', 'vaultSelection.createTitle': 'Create a new vault',
'vaultSelection.createHint': 'Create a local vault folder for Deals and projects.', 'vaultSelection.createHint': 'Create a local vault folder for workspaces and projects.',
'vaultSelection.pathPlaceholder': 'Choose or enter a path...', 'vaultSelection.pathPlaceholder': 'Choose or enter a path...',
'vaultSelection.creating': 'Creating...', 'vaultSelection.creating': 'Creating...',
'vaultSelection.create': 'Create vault', 'vaultSelection.create': 'Create vault',
@ -131,45 +112,21 @@ export default {
'vaultSelection.recent': 'Recent vaults', 'vaultSelection.recent': 'Recent vaults',
'common.cancel': 'Cancel', 'common.cancel': 'Cancel',
'common.close': 'Close', 'common.close': 'Close',
'workspaceTree.title': 'Deals', 'workspaceTree.title': 'Workspaces',
'workspaceTree.new': 'New Deal', 'workspaceTree.new': 'New workspace',
'workspaceTree.nameRequired': 'Name is required', 'workspaceTree.nameRequired': 'Name is required',
'workspaceTree.chooseTemplate': 'Choose a Deal template', 'workspaceTree.chooseTemplate': 'Choose a workspace template',
'workspaceTree.saveRename': 'Save rename', 'workspaceTree.saveRename': 'Save rename',
'workspaceTree.rename': 'Rename Deal', 'workspaceTree.rename': 'Rename workspace',
'workspaceTree.trash': 'Move Deal to trash', 'workspaceTree.trash': 'Trash workspace',
'workspaceTree.create': 'Create Deal', 'workspaceTree.create': 'Create workspace',
'workspaceTree.namePlaceholder': 'Deal name', 'workspaceTree.namePlaceholder': 'Workspace name',
'workspaceTree.template': 'Template', 'workspaceTree.template': 'Template',
'workspaceTree.creating': 'Creating...', 'workspaceTree.creating': 'Creating...',
'workspaceTree.templateAvailable': 'Available',
'workspaceTree.templateLimited': 'Available with limitations',
'workspaceTree.templateMissingPlugin': 'Plugin is not installed',
'workspaceTree.templatePluginDisabled': 'Plugin is disabled',
'workspaceTree.templateCapabilityUnavailable': 'A required capability is unavailable',
'workspaceTree.templateIncompatible': 'Plugin is incompatible with this version of Verstak',
'workspaceTree.templateLoadFailed': 'Plugin failed to load',
'workspaceTree.templateNotReady': 'Plugin is not ready',
'workspaceTree.templateToolTabs': 'Creates: {tabs}',
'workspaceTree.templateToolNoTabs': 'No Deal tab provided',
'workspaceTree.templateWillBeIncomplete': 'This Deal will be created without the unavailable components.',
'workspaceTree.templateIncompleteCreated': '«{name}» was created with unavailable components.',
'workspaceTree.templatesError': 'Could not load Deal templates. Please try again.',
'workspaceTree.loadError': 'Could not load Deals. Please try again.',
'workspaceTree.selectError': 'Could not select this Deal. Please try again.',
'workspaceTree.createError': 'Could not create the Deal. Please try again.',
'workspaceTree.renameError': 'Could not rename the Deal. Please try again.',
'workspaceTree.trashError': 'Could not move the Deal to trash. Please try again.',
'pluginManager.loadError': 'Could not load plugins. Please try again.',
'pluginManager.reloadError': 'Could not reload plugins. Please try again.',
'pluginManager.enableError': 'Could not enable the plugin. Please try again.',
'pluginManager.disableError': 'Could not disable the plugin. Please try again.',
'pluginManager.settingsLoadError': 'Could not load plugin settings. Please try again.',
'pluginManager.settingsUnavailable': 'Plugin settings are unavailable.',
'command.openOverview': 'Open Overview', 'command.openOverview': 'Open Overview',
'command.openFiles': 'Open Files', 'command.openFiles': 'Open Files',
'command.openActivity': 'Open Activity', 'command.openActivity': 'Open Activity',
'command.openBrowserInbox': 'Open Browser', 'command.openBrowserInbox': 'Open Browser Inbox',
'command.createMarkdown': 'Create Markdown File', 'command.createMarkdown': 'Create Markdown File',
'command.createText': 'Create Text File', 'command.createText': 'Create Text File',
'command.syncNow': 'Sync Now', 'command.syncNow': 'Sync Now',
@ -177,7 +134,6 @@ export default {
'command.openPluginManager': 'Open Plugin Manager', 'command.openPluginManager': 'Open Plugin Manager',
'command.handled': '{title} handled', 'command.handled': '{title} handled',
'command.result': '{title} {status}', 'command.result': '{title} {status}',
'command.failed': 'Could not run {title}. Please try again.',
'command.statusHandled': 'handled', 'command.statusHandled': 'handled',
'command.palette': 'Command Palette', 'command.palette': 'Command Palette',
'command.run': 'Run command', 'command.run': 'Run command',
@ -186,26 +142,26 @@ export default {
'search.placeholder': 'Search', 'search.placeholder': 'Search',
'search.global': 'Global search', 'search.global': 'Global search',
'search.noResults': 'No results', 'search.noResults': 'No results',
'search.type.workspace': 'Deal', 'search.type.workspace': 'Workspace',
'search.type.tool': 'Tool', 'search.type.tool': 'Tool',
'search.type.folder': 'Folder', 'search.type.folder': 'Folder',
'search.type.file': 'File', 'search.type.file': 'File',
'search.type.journal': 'Journal', 'search.type.journal': 'Journal',
'search.type.browserInbox': 'Browser', 'search.type.browserInbox': 'Browser Inbox',
'search.type.activity': 'Activity', 'search.type.activity': 'Activity',
'workbench.noProvider': 'No provider', 'workbench.noProvider': 'No provider',
'workbench.noViewer': 'No viewer/editor available', 'workbench.noViewer': 'No viewer/editor available',
'workbench.noResource': 'No resource opened', 'workbench.noResource': 'No resource opened',
'bundle.noFrontend': 'Plugin has no frontend bundle', 'bundle.noFrontend': 'Plugin has no frontend bundle',
'bundle.notFound': 'Plugin not found', 'bundle.notFound': 'Plugin not found',
'bundle.loadFailed': 'Could not load the plugin interface. Please try again.', 'bundle.loadFailed': 'Failed to load bundle: {error}',
'bundle.emptyContent': 'empty content', 'bundle.emptyContent': 'empty content',
'bundle.executionError': 'Could not start the plugin interface. Please try again.', 'bundle.executionError': 'Bundle execution error: {error}',
'bundle.registrationMissing': 'Bundle loaded but no VerstakPluginRegister call detected', 'bundle.registrationMissing': 'Bundle loaded but no VerstakPluginRegister call detected',
'bundle.componentMissing': 'The requested plugin interface is unavailable.', 'bundle.componentMissing': 'Component "{component}" not found in bundle. Available: {available}',
'bundle.mountError': 'Could not open the plugin interface. Please try again.', 'bundle.mountError': 'Component mount error: {error}',
'bundle.mountUnavailable': 'Mount container not available', 'bundle.mountUnavailable': 'Mount container not available',
'bundle.unexpectedError': 'Could not open the plugin interface. Please try again.', 'bundle.unexpectedError': 'Unexpected error: {error}',
'bundle.unknownError': 'Unknown error', 'bundle.unknownError': 'Unknown error',
'bundle.frontendEntry': 'Frontend entry', 'bundle.frontendEntry': 'Frontend entry',
'bundle.availableComponents': 'Available components', 'bundle.availableComponents': 'Available components',
@ -241,57 +197,18 @@ export default {
'overview.count.journal.many': '{count} journal entries', 'overview.count.journal.many': '{count} journal entries',
'overview.count.pending.one': '{count} pending item', 'overview.count.pending.one': '{count} pending item',
'overview.count.pending.many': '{count} pending items', 'overview.count.pending.many': '{count} pending items',
'overview.loadingContext': 'Loading Deal context...', 'overview.loadingContext': 'Loading workspace context...',
'overview.lastActive': 'Last active {time}', 'overview.lastActive': 'Last active {time}',
'overview.noRecentActivity': 'No recent Deal activity', 'overview.noRecentActivity': 'No recent workspace activity',
'overview.refresh': 'Refresh', 'overview.refresh': 'Refresh',
'overview.summary': 'Deal overview summary', 'overview.summary': 'Workspace overview summary',
'overview.continue': 'Continue working', 'overview.continue': 'Continue working',
'overview.continueHint': 'Pick up the next useful item in this Deal.', 'overview.continueHint': 'Pick up the next useful item in this workspace.',
'overview.loadingSignals': 'Loading Deal signals...', 'overview.loadingSignals': 'Loading workspace signals...',
'overview.noResume': 'No clear resume point yet', 'overview.noResume': 'No clear resume point yet',
'overview.noResumeHint': 'Recent notes, files, captures, and journal entries will appear here.', 'overview.noResumeHint': 'Recent notes, files, captures, and journal entries will appear here.',
'overview.recentChanges': 'Recent changes', 'overview.recentChanges': 'Recent changes',
'overview.recentChangesHint': 'Latest meaningful activity in this Deal.', 'overview.recentChangesHint': 'Latest meaningful activity in this workspace.',
'overview.event.workspaceOpened': 'Opened Deal',
'overview.event.activity': 'Deal activity',
'overview.event.noteEdited': 'Edited note "{name}"',
'overview.event.noteOpened': 'Opened note "{name}"',
'overview.event.noteCreated': 'Created note "{name}"',
'overview.event.fileOpened': 'Opened file "{name}"',
'overview.event.fileChanged': 'Changed file "{name}"',
'overview.event.fileCreated': 'Created file "{name}"',
'overview.event.fileRemoved': 'Removed file "{name}"',
'overview.event.capturePage': 'Captured page "{name}"',
'overview.event.captureSelection': 'Captured selection "{name}"',
'overview.event.captureLink': 'Captured link "{name}"',
'overview.event.captureFile': 'Captured file "{name}"',
'overview.event.captureConverted': 'Converted capture "{name}"',
'overview.event.capture': 'Captured {kind} "{title}"',
'overview.event.journalAdded': 'Added journal entry "{name}"',
'overview.event.workSessionDetected': 'Work session detected',
'overview.event.todo': 'Todo "{title}"',
'overview.event.reviewCapture': 'Review capture "{title}"',
'overview.event.continueJournal': 'Continue journal entry "{title}"',
'overview.event.possibleJournalEntry': 'Possible journal entry',
'overview.captureKind.page': 'page',
'overview.captureKind.selection': 'selection',
'overview.captureKind.link': 'link',
'overview.captureKind.file': 'file',
'overview.captureKind.item': 'item',
'overview.todo.reminderDue': 'Reminder due',
'overview.todo.overdue': 'Overdue',
'overview.todo.dueSoon': 'Due soon',
'overview.todo.due': 'Due {date}',
'overview.candidateMeta': 'Deal: {deal} · {minutes} min · {activities} activities',
'overview.minutes': '{count} min',
'overview.browserCapture': 'Browser capture',
'overview.untitledTodo': 'Untitled todo',
'overview.untitledCapture': 'Untitled capture',
'overview.untitledJournal': 'Journal entry',
'overview.unknownDeal': 'Unknown Deal',
'overview.item': 'item',
'overview.overviewNote': 'Deal overview note',
'overview.recentFilter': 'Recent changes filter', 'overview.recentFilter': 'Recent changes filter',
'overview.loadingRecent': 'Loading recent changes...', 'overview.loadingRecent': 'Loading recent changes...',
'overview.noChanges': 'No meaningful changes for this filter yet.', 'overview.noChanges': 'No meaningful changes for this filter yet.',

View File

@ -16,13 +16,13 @@ export default {
'sidebar.error.load': 'Не удалось загрузить боковую панель', 'sidebar.error.load': 'Не удалось загрузить боковую панель',
'sidebar.error.ui': 'Ошибка интерфейса плагина', 'sidebar.error.ui': 'Ошибка интерфейса плагина',
'workspace.overview': 'Обзор', 'workspace.overview': 'Обзор',
'workspace.search': 'Поиск в Деле', 'workspace.search': 'Поиск в рабочем пространстве',
'workspace.tools': 'Инструменты Дела', 'workspace.tools': 'Инструменты рабочего пространства',
'workspace.tool': 'Инструмент Дела', 'workspace.tool': 'Инструмент рабочего пространства',
'workspace.emptyTools': 'Нет доступных инструментов', 'workspace.emptyTools': 'Нет доступных инструментов',
'workspace.emptyToolsHint': 'Включите плагины с инструментами Дела или откройте Менеджер плагинов в настройках.', 'workspace.emptyToolsHint': 'Включите плагины с инструментами рабочего пространства или откройте Менеджер плагинов в настройках.',
'workspace.select': 'Выберите Дело', 'workspace.select': 'Выберите рабочее пространство',
'workspace.selectHint': 'Используйте кнопку + в разделе Дел, чтобы добавить первый проект.', 'workspace.selectHint': 'Используйте кнопку + в разделе рабочих пространств, чтобы добавить первый проект.',
'common.details': 'Подробнее', 'common.details': 'Подробнее',
'common.plugin': 'Плагин', 'common.plugin': 'Плагин',
'common.component': 'Компонент', 'common.component': 'Компонент',
@ -35,10 +35,9 @@ 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': 'Возможность', 'common.capability': 'Capability',
'common.provider': 'Поставщик', 'common.provider': 'Поставщик',
'status.loaded': 'загружено', 'status.loaded': 'загружено',
'status.degraded': 'ограничено', 'status.degraded': 'ограничено',
@ -49,30 +48,12 @@ export default {
'pluginManager.reload': '⟳ Обновить', 'pluginManager.reload': '⟳ Обновить',
'pluginManager.scanning': 'Сканирование каталогов плагинов...', 'pluginManager.scanning': 'Сканирование каталогов плагинов...',
'pluginManager.summary.plugins': 'Обнаружено плагинов: {count}', 'pluginManager.summary.plugins': 'Обнаружено плагинов: {count}',
'pluginManager.summary.capabilities': 'Зарегистрировано возможностей: {count}', 'pluginManager.summary.capabilities': 'Зарегистрировано 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': 'пользовательские плагины',
@ -81,7 +62,7 @@ export default {
'pluginManager.missingTitle': 'Отсутствующие установленные плагины', 'pluginManager.missingTitle': 'Отсутствующие установленные плагины',
'pluginManager.missingHint': 'Эти плагины требуются хранилищу, но их пакеты не установлены на этом устройстве.', 'pluginManager.missingHint': 'Эти плагины требуются хранилищу, но их пакеты не установлены на этом устройстве.',
'pluginManager.missingPackage': 'Плагин указан среди желаемых плагинов хранилища, но его пакет не установлен.', 'pluginManager.missingPackage': 'Плагин указан среди желаемых плагинов хранилища, но его пакет не установлен.',
'pluginManager.capabilityRegistry': 'Реестр возможностей ({count})', 'pluginManager.capabilityRegistry': 'Реестр capabilities ({count})',
'pluginManager.settingsError': 'Ошибка настроек', 'pluginManager.settingsError': 'Ошибка настроек',
'pluginManager.pluginSettings': 'Настройки плагина', 'pluginManager.pluginSettings': 'Настройки плагина',
'pluginManager.settingsBundleUnavailable': 'Frontend bundle панели настроек недоступен', 'pluginManager.settingsBundleUnavailable': 'Frontend bundle панели настроек недоступен',
@ -93,8 +74,8 @@ export default {
'pluginCard.count.sidebar': 'элементов боковой панели: {count}', 'pluginCard.count.sidebar': 'элементов боковой панели: {count}',
'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': 'Плагин работает, но некоторые необязательные возможности недоступны.', 'pluginCard.degraded': 'Плагин работает, но некоторые необязательные capabilities недоступны.',
'pluginCard.name': 'Название', 'pluginCard.name': 'Название',
'pluginCard.contributions': 'Элементы интерфейса', 'pluginCard.contributions': 'Элементы интерфейса',
'pluginCard.technicalDetails': 'Технические сведения', 'pluginCard.technicalDetails': 'Технические сведения',
@ -102,9 +83,9 @@ export default {
'pluginCard.root': 'Корневой каталог', 'pluginCard.root': 'Корневой каталог',
'pluginCard.provides': 'Предоставляет', 'pluginCard.provides': 'Предоставляет',
'pluginCard.requires': 'Требует', 'pluginCard.requires': 'Требует',
'pluginCard.missingRequired': 'Отсутствуют обязательные возможности', 'pluginCard.missingRequired': 'Отсутствуют обязательные capabilities',
'pluginCard.optionalRequires': 'Необязательные зависимости', 'pluginCard.optionalRequires': 'Необязательные зависимости',
'pluginCard.optionalUnavailable': 'Необязательные возможности недоступны — плагин работает в ограниченном режиме', 'pluginCard.optionalUnavailable': 'Необязательные capabilities недоступны — плагин работает в ограниченном режиме',
'pluginCard.permissions': 'Разрешения', 'pluginCard.permissions': 'Разрешения',
'pluginCard.enabling': '⟳ Включение...', 'pluginCard.enabling': '⟳ Включение...',
'pluginCard.enable': '▶ Включить', 'pluginCard.enable': '▶ Включить',
@ -115,12 +96,12 @@ export default {
'common.loading': 'Загрузка...', 'common.loading': 'Загрузка...',
'common.browse': 'Обзор...', 'common.browse': 'Обзор...',
'vaultSelection.chooseNew': 'Выберите или введите папку для нового хранилища.', 'vaultSelection.chooseNew': 'Выберите или введите папку для нового хранилища.',
'vaultSelection.createError': 'Не удалось создать хранилище. Повторите попытку.', 'vaultSelection.createError': 'Не удалось создать хранилище: {error}',
'vaultSelection.openError': 'Не удалось открыть хранилище. Повторите попытку.', 'vaultSelection.openError': 'Не удалось открыть хранилище: {error}',
'vaultSelection.chooseExisting': 'Выберите или введите существующее хранилище.', 'vaultSelection.chooseExisting': 'Выберите или введите существующее хранилище.',
'vaultSelection.subtitle': 'Выберите хранилище, чтобы начать работу', 'vaultSelection.subtitle': 'Выберите хранилище, чтобы начать работу',
'vaultSelection.createTitle': 'Создать новое хранилище', 'vaultSelection.createTitle': 'Создать новое хранилище',
'vaultSelection.createHint': 'Создайте локальную папку хранилища для Дел и проектов.', 'vaultSelection.createHint': 'Создайте локальную папку хранилища для рабочих пространств и проектов.',
'vaultSelection.pathPlaceholder': 'Выберите или введите путь...', 'vaultSelection.pathPlaceholder': 'Выберите или введите путь...',
'vaultSelection.creating': 'Создание...', 'vaultSelection.creating': 'Создание...',
'vaultSelection.create': 'Создать хранилище', 'vaultSelection.create': 'Создать хранилище',
@ -131,45 +112,21 @@ export default {
'vaultSelection.recent': 'Недавние хранилища', 'vaultSelection.recent': 'Недавние хранилища',
'common.cancel': 'Отмена', 'common.cancel': 'Отмена',
'common.close': 'Закрыть', 'common.close': 'Закрыть',
'workspaceTree.title': 'Дела', 'workspaceTree.title': 'Рабочие пространства',
'workspaceTree.new': 'Новое Дело', 'workspaceTree.new': 'Новое рабочее пространство',
'workspaceTree.nameRequired': 'Введите название', 'workspaceTree.nameRequired': 'Введите название',
'workspaceTree.chooseTemplate': 'Выберите шаблон Дела', 'workspaceTree.chooseTemplate': 'Выберите шаблон рабочего пространства',
'workspaceTree.saveRename': 'Сохранить новое название', 'workspaceTree.saveRename': 'Сохранить новое название',
'workspaceTree.rename': 'Переименовать Дело', 'workspaceTree.rename': 'Переименовать рабочее пространство',
'workspaceTree.trash': 'Переместить Дело в корзину', 'workspaceTree.trash': 'Переместить рабочее пространство в корзину',
'workspaceTree.create': 'Создать Дело', 'workspaceTree.create': 'Создать рабочее пространство',
'workspaceTree.namePlaceholder': 'Название Дела', 'workspaceTree.namePlaceholder': 'Название рабочего пространства',
'workspaceTree.template': 'Шаблон', 'workspaceTree.template': 'Шаблон',
'workspaceTree.creating': 'Создание...', 'workspaceTree.creating': 'Создание...',
'workspaceTree.templateAvailable': 'Доступен',
'workspaceTree.templateLimited': 'Доступен с ограничениями',
'workspaceTree.templateMissingPlugin': 'Плагин не установлен',
'workspaceTree.templatePluginDisabled': 'Плагин выключен',
'workspaceTree.templateCapabilityUnavailable': 'Не хватает обязательной возможности',
'workspaceTree.templateIncompatible': 'Плагин несовместим с этой версией Верстака',
'workspaceTree.templateLoadFailed': 'Плагин не удалось загрузить',
'workspaceTree.templateNotReady': 'Плагин ещё не готов к работе',
'workspaceTree.templateToolTabs': 'Создаёт вкладки: {tabs}',
'workspaceTree.templateToolNoTabs': 'Не предоставляет вкладку Дела',
'workspaceTree.templateWillBeIncomplete': 'Дело будет создано без недоступных компонентов.',
'workspaceTree.templateIncompleteCreated': 'Дело «{name}» создано с недоступными компонентами.',
'workspaceTree.templatesError': 'Не удалось загрузить шаблоны Дел. Повторите попытку.',
'workspaceTree.loadError': 'Не удалось загрузить Дела. Повторите попытку.',
'workspaceTree.selectError': 'Не удалось выбрать это Дело. Повторите попытку.',
'workspaceTree.createError': 'Не удалось создать Дело. Повторите попытку.',
'workspaceTree.renameError': 'Не удалось переименовать Дело. Повторите попытку.',
'workspaceTree.trashError': 'Не удалось переместить Дело в корзину. Повторите попытку.',
'pluginManager.loadError': 'Не удалось загрузить плагины. Повторите попытку.',
'pluginManager.reloadError': 'Не удалось перезагрузить плагины. Повторите попытку.',
'pluginManager.enableError': 'Не удалось включить плагин. Повторите попытку.',
'pluginManager.disableError': 'Не удалось выключить плагин. Повторите попытку.',
'pluginManager.settingsLoadError': 'Не удалось загрузить настройки плагина. Повторите попытку.',
'pluginManager.settingsUnavailable': 'Настройки плагина недоступны.',
'command.openOverview': 'Открыть обзор', 'command.openOverview': 'Открыть обзор',
'command.openFiles': 'Открыть файлы', 'command.openFiles': 'Открыть файлы',
'command.openActivity': 'Открыть активность', 'command.openActivity': 'Открыть активность',
'command.openBrowserInbox': 'Открыть Браузер', 'command.openBrowserInbox': 'Открыть входящие из браузера',
'command.createMarkdown': 'Создать файл Markdown', 'command.createMarkdown': 'Создать файл Markdown',
'command.createText': 'Создать текстовый файл', 'command.createText': 'Создать текстовый файл',
'command.syncNow': 'Синхронизировать сейчас', 'command.syncNow': 'Синхронизировать сейчас',
@ -177,7 +134,6 @@ export default {
'command.openPluginManager': 'Открыть менеджер плагинов', 'command.openPluginManager': 'Открыть менеджер плагинов',
'command.handled': 'Выполнено: {title}', 'command.handled': 'Выполнено: {title}',
'command.result': '{title}: {status}', 'command.result': '{title}: {status}',
'command.failed': 'Не удалось выполнить команду «{title}». Повторите попытку.',
'command.statusHandled': 'выполнено', 'command.statusHandled': 'выполнено',
'command.palette': 'Палитра команд', 'command.palette': 'Палитра команд',
'command.run': 'Выполнить команду', 'command.run': 'Выполнить команду',
@ -186,26 +142,26 @@ export default {
'search.placeholder': 'Поиск', 'search.placeholder': 'Поиск',
'search.global': 'Глобальный поиск', 'search.global': 'Глобальный поиск',
'search.noResults': 'Ничего не найдено', 'search.noResults': 'Ничего не найдено',
'search.type.workspace': 'Дело', 'search.type.workspace': 'Рабочее пространство',
'search.type.tool': 'Инструмент', 'search.type.tool': 'Инструмент',
'search.type.folder': 'Папка', 'search.type.folder': 'Папка',
'search.type.file': 'Файл', 'search.type.file': 'Файл',
'search.type.journal': 'Журнал', 'search.type.journal': 'Журнал',
'search.type.browserInbox': 'Браузер', 'search.type.browserInbox': 'Входящие из браузера',
'search.type.activity': 'Активность', 'search.type.activity': 'Активность',
'workbench.noProvider': 'Нет обработчика', 'workbench.noProvider': 'Нет обработчика',
'workbench.noViewer': 'Нет доступного средства просмотра или редактора', 'workbench.noViewer': 'Нет доступного средства просмотра или редактора',
'workbench.noResource': 'Ресурс не открыт', 'workbench.noResource': 'Ресурс не открыт',
'bundle.noFrontend': 'У плагина нет frontend bundle', 'bundle.noFrontend': 'У плагина нет frontend bundle',
'bundle.notFound': 'Плагин не найден', 'bundle.notFound': 'Плагин не найден',
'bundle.loadFailed': 'Не удалось загрузить интерфейс плагина. Повторите попытку.', 'bundle.loadFailed': 'Не удалось загрузить bundle: {error}',
'bundle.emptyContent': 'пустое содержимое', 'bundle.emptyContent': 'пустое содержимое',
'bundle.executionError': 'Не удалось запустить интерфейс плагина. Повторите попытку.', 'bundle.executionError': 'Ошибка выполнения bundle: {error}',
'bundle.registrationMissing': 'Bundle загружен, но вызов VerstakPluginRegister не обнаружен', 'bundle.registrationMissing': 'Bundle загружен, но вызов VerstakPluginRegister не обнаружен',
'bundle.componentMissing': 'Запрошенный интерфейс плагина недоступен.', 'bundle.componentMissing': 'Компонент «{component}» не найден в bundle. Доступны: {available}',
'bundle.mountError': 'Не удалось открыть интерфейс плагина. Повторите попытку.', 'bundle.mountError': 'Ошибка подключения компонента: {error}',
'bundle.mountUnavailable': 'Контейнер для подключения недоступен', 'bundle.mountUnavailable': 'Контейнер для подключения недоступен',
'bundle.unexpectedError': 'Не удалось открыть интерфейс плагина. Повторите попытку.', 'bundle.unexpectedError': 'Непредвиденная ошибка: {error}',
'bundle.unknownError': 'Неизвестная ошибка', 'bundle.unknownError': 'Неизвестная ошибка',
'bundle.frontendEntry': 'Точка входа frontend', 'bundle.frontendEntry': 'Точка входа frontend',
'bundle.availableComponents': 'Доступные компоненты', 'bundle.availableComponents': 'Доступные компоненты',
@ -241,57 +197,18 @@ export default {
'overview.count.journal.many': 'Записей журнала: {count}', 'overview.count.journal.many': 'Записей журнала: {count}',
'overview.count.pending.one': 'Ожидают: {count}', 'overview.count.pending.one': 'Ожидают: {count}',
'overview.count.pending.many': 'Ожидают: {count}', 'overview.count.pending.many': 'Ожидают: {count}',
'overview.loadingContext': 'Загрузка данных Дела...', 'overview.loadingContext': 'Загрузка данных рабочего пространства...',
'overview.lastActive': 'Последняя активность: {time}', 'overview.lastActive': 'Последняя активность: {time}',
'overview.noRecentActivity': 'В Деле пока нет недавней активности', 'overview.noRecentActivity': 'В рабочем пространстве пока нет недавней активности',
'overview.refresh': 'Обновить', 'overview.refresh': 'Обновить',
'overview.summary': 'Сводка Дела', 'overview.summary': 'Сводка рабочего пространства',
'overview.continue': 'Продолжить работу', 'overview.continue': 'Продолжить работу',
'overview.continueHint': 'Вернитесь к следующему полезному делу в этом Деле.', 'overview.continueHint': 'Вернитесь к следующему полезному делу в этом рабочем пространстве.',
'overview.loadingSignals': 'Загрузка данных Дела...', 'overview.loadingSignals': 'Загрузка рабочих данных...',
'overview.noResume': 'Пока неясно, с чего продолжить', 'overview.noResume': 'Пока неясно, с чего продолжить',
'overview.noResumeHint': 'Здесь появятся недавние заметки, файлы, сохранённое и записи журнала.', 'overview.noResumeHint': 'Здесь появятся недавние заметки, файлы, сохранённое и записи журнала.',
'overview.recentChanges': 'Недавние изменения', 'overview.recentChanges': 'Недавние изменения',
'overview.recentChangesHint': 'Последняя важная активность в этом Деле.', 'overview.recentChangesHint': 'Последняя важная активность в этом рабочем пространстве.',
'overview.event.workspaceOpened': 'Открыто Дело',
'overview.event.activity': 'Активность Дела',
'overview.event.noteEdited': 'Изменена заметка «{name}»',
'overview.event.noteOpened': 'Открыта заметка «{name}»',
'overview.event.noteCreated': 'Создана заметка «{name}»',
'overview.event.fileOpened': 'Открыт файл «{name}»',
'overview.event.fileChanged': 'Изменён файл «{name}»',
'overview.event.fileCreated': 'Создан файл «{name}»',
'overview.event.fileRemoved': 'Удалён файл «{name}»',
'overview.event.capturePage': 'Сохранена страница «{name}»',
'overview.event.captureSelection': 'Сохранено выделение «{name}»',
'overview.event.captureLink': 'Сохранена ссылка «{name}»',
'overview.event.captureFile': 'Сохранён файл «{name}»',
'overview.event.captureConverted': 'Сохранённое преобразовано: «{name}»',
'overview.event.capture': 'Сохранено: {kind} «{title}»',
'overview.event.journalAdded': 'Добавлена запись в журнал «{name}»',
'overview.event.workSessionDetected': 'Обнаружена рабочая сессия',
'overview.event.todo': 'Задача «{title}»',
'overview.event.reviewCapture': 'Разобрать сохранённое «{title}»',
'overview.event.continueJournal': 'Продолжить запись журнала «{title}»',
'overview.event.possibleJournalEntry': 'Возможная запись в журнал',
'overview.captureKind.page': 'страница',
'overview.captureKind.selection': 'выделение',
'overview.captureKind.link': 'ссылка',
'overview.captureKind.file': 'файл',
'overview.captureKind.item': 'элемент',
'overview.todo.reminderDue': 'Пора напомнить',
'overview.todo.overdue': 'Просрочено',
'overview.todo.dueSoon': 'Скоро срок',
'overview.todo.due': 'Срок: {date}',
'overview.candidateMeta': 'Дело: {deal} · {minutes} мин. · активностей: {activities}',
'overview.minutes': '{count} мин.',
'overview.browserCapture': 'Сохранённое из браузера',
'overview.untitledTodo': 'Задача без названия',
'overview.untitledCapture': 'Сохранённое без названия',
'overview.untitledJournal': 'Запись журнала',
'overview.unknownDeal': 'Неизвестное Дело',
'overview.item': 'элемент',
'overview.overviewNote': 'Обзорная заметка Дела',
'overview.recentFilter': 'Фильтр недавних изменений', 'overview.recentFilter': 'Фильтр недавних изменений',
'overview.loadingRecent': 'Загрузка недавних изменений...', 'overview.loadingRecent': 'Загрузка недавних изменений...',
'overview.noChanges': 'Для этого фильтра пока нет важных изменений.', 'overview.noChanges': 'Для этого фильтра пока нет важных изменений.',

View File

@ -83,11 +83,6 @@
return { value: result, error: '' }; return { value: result, error: '' };
} }
function reportError(key, fallback, details) {
console.warn('[PluginBundleHost] ' + key + ':', details);
return tr(key, undefined, fallback);
}
async function loadAndMount(pId, compId, nextPropsKey) { async function loadAndMount(pId, compId, nextPropsKey) {
// If same plugin+component and already mounted, skip // If same plugin+component and already mounted, skip
if (currentPluginId === pId && currentComponent === compId && currentPropsKey === nextPropsKey && loadState === 'loaded') { if (currentPluginId === pId && currentComponent === compId && currentPropsKey === nextPropsKey && loadState === 'loaded') {
@ -130,7 +125,7 @@
const content = assetResult.value; const content = assetResult.value;
if (assetResult.error || !content) { if (assetResult.error || !content) {
loadState = 'error'; loadState = 'error';
errorText = reportError('bundle.loadFailed', 'Could not load the plugin interface. Please try again.', assetResult.error || tr('bundle.emptyContent')); errorText = tr('bundle.loadFailed', { error: assetResult.error || tr('bundle.emptyContent') });
return; return;
} }
@ -141,7 +136,8 @@
fn(); fn();
} catch (e) { } catch (e) {
loadState = 'error'; loadState = 'error';
errorText = reportError('bundle.executionError', 'Could not start the plugin interface. Please try again.', e); errorText = tr('bundle.executionError', { error: e.message });
console.error('[PluginBundleHost] bundle exec error:', e);
return; return;
} }
@ -158,7 +154,10 @@
const comp = components[compId]; const comp = components[compId];
if (!comp || !comp.mount) { if (!comp || !comp.mount) {
loadState = 'error'; loadState = 'error';
errorText = tr('bundle.componentMissing', undefined, 'The requested plugin interface is unavailable.'); errorText = tr('bundle.componentMissing', {
component: compId,
available: Object.keys(components).join(', ') || tr('common.none'),
});
return; return;
} }
@ -178,7 +177,8 @@
errorText = ''; errorText = '';
} catch (e) { } catch (e) {
loadState = 'error'; loadState = 'error';
errorText = reportError('bundle.mountError', 'Could not open the plugin interface. Please try again.', e); errorText = tr('bundle.mountError', { error: e.message });
console.error('[PluginBundleHost] mount error:', e);
} }
} else { } else {
loadState = 'error'; loadState = 'error';
@ -186,7 +186,8 @@
} }
} catch (e) { } catch (e) {
loadState = 'error'; loadState = 'error';
errorText = reportError('bundle.unexpectedError', 'Could not open the plugin interface. Please try again.', e); errorText = tr('bundle.unexpectedError', { error: e.message || e });
console.error('[PluginBundleHost] error:', e);
} }
} }
@ -207,18 +208,17 @@
<div class="host-state error"> <div class="host-state error">
<Icon name="warning" size={24} class="error-icon" /> <Icon name="warning" size={24} class="error-icon" />
<p class="error-title">{tr('pluginView.error')}</p> <p class="error-title">{tr('pluginView.error')}</p>
<p class="error-message">{errorText || tr('bundle.unknownError')}</p> <div class="error-details">
<details class="error-details">
<summary>{tr('common.details')}</summary>
<p><strong>{tr('common.plugin')}:</strong> {currentPluginId || tr('common.unknown')}</p> <p><strong>{tr('common.plugin')}:</strong> {currentPluginId || tr('common.unknown')}</p>
<p><strong>{tr('common.component')}:</strong> {currentComponent || tr('common.unknown')}</p> <p><strong>{tr('common.component')}:</strong> {currentComponent || tr('common.unknown')}</p>
<p class="error-message">{errorText || tr('bundle.unknownError')}</p>
{#if pluginInfo} {#if pluginInfo}
<p class="error-meta">{tr('bundle.frontendEntry')}: {pluginInfo.entry || tr('common.none')}</p> <p class="error-meta">{tr('bundle.frontendEntry')}: {pluginInfo.entry || tr('common.none')}</p>
{/if} {/if}
{#if getComponentList().length > 0} {#if getComponentList().length > 0}
<p class="error-meta">{tr('bundle.availableComponents')}: {getComponentList().join(', ')}</p> <p class="error-meta">{tr('bundle.availableComponents')}: {getComponentList().join(', ')}</p>
{/if} {/if}
</details> </div>
</div> </div>
{:else} {:else}
@ -303,21 +303,11 @@
max-width: 400px; max-width: 400px;
text-align: left; text-align: left;
background: #16213e; background: #16213e;
padding: 0.5rem 0.75rem; padding: 1rem;
border-radius: 6px; border-radius: 6px;
border: 1px solid #0f3460; border: 1px solid #0f3460;
} }
.error-details[open] {
padding: 0.75rem 1rem;
}
.error-details summary {
cursor: pointer;
color: #e0e0f0;
font-weight: 600;
}
.error-details p { .error-details p {
margin: 0.3rem 0; margin: 0.3rem 0;
} }
@ -328,8 +318,12 @@
.error-message { .error-message {
color: #e94560; color: #e94560;
max-width: 400px; font-family: monospace;
margin: 0 0 0.75rem; font-size: 0.8rem;
margin-top: 0.5rem !important;
padding: 0.5rem;
background: rgba(233, 69, 96, 0.1);
border-radius: 4px;
} }
.error-meta { .error-meta {

View File

@ -33,11 +33,6 @@
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 = '';
@ -58,12 +53,6 @@
}, 4000); }, 4000);
} }
function reportError(key, fallback, details) {
debug.log('[PluginManager] ' + key + ':', String(details));
WriteFrontendLog('PluginManager', key + ': ' + String(details)).catch(() => {});
return tr(key, undefined, fallback);
}
function notifyPluginsChanged() { function notifyPluginsChanged() {
window.dispatchEvent(new CustomEvent('verstak:plugins-changed')); window.dispatchEvent(new CustomEvent('verstak:plugins-changed'));
} }
@ -100,14 +89,14 @@
ReadPluginSettings(pluginId).then(result => { ReadPluginSettings(pluginId).then(result => {
const unpacked = unpackBackendResult(result); const unpacked = unpackBackendResult(result);
if (unpacked.error) { if (unpacked.error) {
settingsError = reportError('pluginManager.settingsLoadError', 'Could not load plugin settings. Please try again.', unpacked.error); settingsError = unpacked.error;
settingsData = {}; settingsData = {};
return; return;
} }
settingsData = unpacked.value || {}; settingsData = unpacked.value || {};
}).catch(() => { settingsData = {}; }); }).catch(() => { settingsData = {}; });
} else { } else {
settingsError = tr('pluginManager.settingsUnavailable', undefined, 'Plugin settings are unavailable.'); settingsError = `Settings panel not found for plugin "${pluginId}". Check that the plugin is enabled and has settingsPanels in its manifest.`;
} }
} }
@ -138,7 +127,7 @@
} catch (e) { } catch (e) {
debug.log('[PluginManager] loadAll: GetPlugins ERROR:', String(e)); debug.log('[PluginManager] loadAll: GetPlugins ERROR:', String(e));
WriteFrontendLog('PluginManager', 'loadAll: GetPlugins ERROR: ' + String(e)); WriteFrontendLog('PluginManager', 'loadAll: GetPlugins ERROR: ' + String(e));
error = reportError('pluginManager.loadError', 'Could not load plugins. Please try again.', e); error = 'GetPlugins: ' + String(e);
loading = false; loading = false;
return; return;
} }
@ -202,7 +191,7 @@
resultMsg = `Reloaded ${count} plugin(s). ${summary}`; resultMsg = `Reloaded ${count} plugin(s). ${summary}`;
} catch (e) { } catch (e) {
debug.log('[PluginManager] reload: ReloadPlugins ERROR:', String(e)); debug.log('[PluginManager] reload: ReloadPlugins ERROR:', String(e));
error = reportError('pluginManager.reloadError', 'Could not reload plugins. Please try again.', e); error = 'Reload: ' + String(e);
reloading = false; reloading = false;
return; return;
} }
@ -222,7 +211,7 @@
if (err) { if (err) {
debug.log('[PluginManager] enablePlugin: ERROR:', err); debug.log('[PluginManager] enablePlugin: ERROR:', err);
actionFeedback = { ...actionFeedback, [pluginId]: null }; actionFeedback = { ...actionFeedback, [pluginId]: null };
error = reportError('pluginManager.enableError', 'Could not enable the plugin. Please try again.', err); error = 'Enable: ' + err;
return; return;
} }
debug.log('[PluginManager] enablePlugin: success, reloading...'); debug.log('[PluginManager] enablePlugin: success, reloading...');
@ -243,7 +232,7 @@
if (err) { if (err) {
debug.log('[PluginManager] disablePlugin: ERROR:', err); debug.log('[PluginManager] disablePlugin: ERROR:', err);
actionFeedback = { ...actionFeedback, [pluginId]: null }; actionFeedback = { ...actionFeedback, [pluginId]: null };
error = reportError('pluginManager.disableError', 'Could not disable the plugin. Please try again.', err); error = 'Disable: ' + err;
return; return;
} }
debug.log('[PluginManager] disablePlugin: success, reloading...'); debug.log('[PluginManager] disablePlugin: success, reloading...');
@ -261,11 +250,6 @@
$: 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) => {
@ -284,69 +268,6 @@
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 = '';
@ -418,70 +339,6 @@
</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">
@ -497,13 +354,9 @@
</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 visiblePlugins as p} {#each plugins 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>
@ -718,105 +571,6 @@
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;
@ -825,6 +579,7 @@
.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; }
@ -891,19 +646,6 @@
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));

View File

@ -3,7 +3,6 @@
import * as App from '../../../wailsjs/go/api/App'; import * as App from '../../../wailsjs/go/api/App';
import { executePluginCommand } from '../plugin-host/VerstakPluginAPI.js'; import { executePluginCommand } from '../plugin-host/VerstakPluginAPI.js';
import { i18n } from '../i18n/index.js'; import { i18n } from '../i18n/index.js';
import { debug } from '../log/debug.js';
let open = false; let open = false;
let query = ''; let query = '';
@ -195,12 +194,7 @@
closePalette(); closePalette();
setStatus('success', tr('command.result', { title: command.title || command.id, status: result.status || tr('command.statusHandled') })); setStatus('success', tr('command.result', { title: command.title || command.id, status: result.status || tr('command.statusHandled') }));
} catch (err) { } catch (err) {
const details = err?.message || String(err); setStatus('error', `${command.title || command.id}: ${err?.message || String(err)}`);
debug.log('[CommandPalette] command failed:', details);
App.WriteFrontendLog('CommandPalette', `command failed: ${details}`).catch(() => {});
setStatus('error', tr('command.failed', {
title: command.title || command.id,
}, `Could not run ${command.title || command.id}. Please try again.`));
} }
} }

View File

@ -8,8 +8,6 @@
import { i18n } from '../i18n/index.js'; import { i18n } from '../i18n/index.js';
export let showGlobalSearch = true; export let showGlobalSearch = true;
export let activeView = null;
export let activeViewPluginId = '';
function flog(msg) { function flog(msg) {
App.WriteFrontendLog('Sidebar', msg); App.WriteFrontendLog('Sidebar', msg);
@ -28,7 +26,6 @@
})(locale); })(locale);
$: vaultOpen = vaultStatus.status === 'open'; $: vaultOpen = vaultStatus.status === 'open';
$: activeSidebarKey = activeView ? `${activeViewPluginId}:${activeView}` : '';
async function loadSidebar() { async function loadSidebar() {
debug.log('[Sidebar] onMount: START'); debug.log('[Sidebar] onMount: START');
@ -110,8 +107,6 @@
{#each sidebarItems as item} {#each sidebarItems as item}
<button <button
class="nav-item plugin-item vt-list-row" class="nav-item plugin-item vt-list-row"
class:is-active={activeSidebarKey === `${item.pluginId || ''}:${item.view || item.id}`}
aria-current={activeSidebarKey === `${item.pluginId || ''}:${item.view || item.id}` ? 'page' : undefined}
on:click={() => handleSidebarItem(item)} on:click={() => handleSidebarItem(item)}
type="button" type="button"
> >
@ -215,16 +210,6 @@
color: var(--vt-color-text-primary); color: var(--vt-color-text-primary);
} }
.nav-item.is-active {
background: var(--vt-color-surface-selected);
color: var(--vt-color-accent);
}
.nav-item.is-active :global(.nav-icon.icon-plugin) {
color: currentColor;
opacity: 1;
}
:global(.nav-icon) { :global(.nav-icon) {
width: 0.9rem; width: 0.9rem;
height: 0.9rem; height: 0.9rem;

View File

@ -30,7 +30,6 @@
let keyResources = []; let keyResources = [];
let totalNotes = 0; let totalNotes = 0;
let loadedWorkspaceRoot = ''; let loadedWorkspaceRoot = '';
let loadedToolKey = '';
let toolProbe = 0; let toolProbe = 0;
let locale = i18n.getLocale(); let locale = i18n.getLocale();
let unsubscribeLocale = null; let unsubscribeLocale = null;
@ -39,25 +38,17 @@
void activeLocale; void activeLocale;
return i18n.t(key, params, fallback); return i18n.t(key, params, fallback);
})(locale); })(locale);
$: FILTERS = ['all', 'notes', 'files', 'captures', 'journal'].map((key) => ({
key,
label: tr(`overview.filter.${key}`),
}));
function countText(key, count, params = {}) { function countText(key, count, params = {}) {
return tr(`${key}.${count === 1 ? 'one' : 'many'}`, { count, ...params }); return tr(`${key}.${count === 1 ? 'one' : 'many'}`, { count, ...params });
} }
$: hasNotes = hasTool('notes', availableTools); $: hasNotes = hasTool('notes', availableTools);
$: hasFiles = hasTool('files', availableTools);
$: hasBrowserInbox = hasTool('browser-inbox', availableTools);
$: hasActivity = hasTool('activity', availableTools);
$: hasJournal = hasTool('journal', availableTools);
$: hasTodos = hasTool('todo', availableTools); $: hasTodos = hasTool('todo', availableTools);
$: overviewToolKey = [hasNotes, hasFiles, hasBrowserInbox, hasActivity, hasJournal, hasTodos].join('|'); $: recentChanges = buildRecentChanges(activityEvents, captures, journalEntries);
$: FILTERS = ['all']
.concat(hasNotes ? ['notes'] : [])
.concat(hasFiles ? ['files'] : [])
.concat(hasBrowserInbox ? ['captures'] : [])
.concat(hasJournal ? ['journal'] : [])
.map((key) => ({ key, label: tr(`overview.filter.${key}`) }));
$: if (!FILTERS.some(filter => filter.key === activeFilter)) activeFilter = 'all';
$: recentChanges = filterAvailableItems(buildRecentChanges(activityEvents, captures, journalEntries), overviewToolKey);
$: filteredRecentChanges = activeFilter === 'all' $: filteredRecentChanges = activeFilter === 'all'
? recentChanges ? recentChanges
: recentChanges.filter(item => item.category === activeFilter); : recentChanges.filter(item => item.category === activeFilter);
@ -67,19 +58,18 @@
$: linkedCandidateIds = new Set(journalEntries.map(item => String(item?.sourceCandidateId || '')).filter(Boolean)); $: linkedCandidateIds = new Set(journalEntries.map(item => String(item?.sourceCandidateId || '')).filter(Boolean));
$: pendingWorkSessionCandidates = workSessionCandidates.filter(item => !linkedCandidateIds.has(String(item?.candidateId || ''))); $: pendingWorkSessionCandidates = workSessionCandidates.filter(item => !linkedCandidateIds.has(String(item?.candidateId || '')));
$: urgentTodos = hasTodos ? todos.filter(item => todoAttentionState(item)) : []; $: urgentTodos = hasTodos ? todos.filter(item => todoAttentionState(item)) : [];
$: needsAttention = filterAvailableItems(buildNeedsAttention(unprocessedCaptures, pendingWorkSessionCandidates, urgentTodos), overviewToolKey); $: needsAttention = buildNeedsAttention(unprocessedCaptures, pendingWorkSessionCandidates, urgentTodos);
$: continueItems = filterAvailableItems(buildContinueItems(activityEvents, unprocessedCaptures, journalEntries, urgentTodos), overviewToolKey); $: continueItems = buildContinueItems(activityEvents, unprocessedCaptures, journalEntries, urgentTodos);
$: hasAttentionTools = hasBrowserInbox || hasTodos || (hasActivity && hasJournal); $: attentionActionKind = needsAttention[0]?.actionKind || 'browser-inbox';
$: attentionActionKind = needsAttention[0]?.actionKind || fallbackAttentionAction();
$: lastActive = lastActiveDate([...recentChanges, ...continueItems], captures, journalEntries, todos); $: lastActive = lastActiveDate([...recentChanges, ...continueItems], captures, journalEntries, todos);
$: summaryItems = [ $: summaryItems = [
hasNotes ? { key: 'notes', label: tr('overview.notes'), count: totalNotes, detail: countText('overview.count.totalRecent', noteRecentChanges, { total: totalNotes, recent: noteRecentChanges }), actionKind: 'notes', actionLabel: tr('overview.openNotes') } : null, { key: 'notes', label: tr('overview.notes'), count: totalNotes, detail: countText('overview.count.totalRecent', noteRecentChanges, { total: totalNotes, recent: noteRecentChanges }), actionKind: 'notes', actionLabel: tr('overview.openNotes') },
hasFiles ? { key: 'files', label: tr('overview.files'), count: fileRecentChanges, detail: countText('overview.count.recentChanges', fileRecentChanges), actionKind: 'files', actionLabel: tr('overview.openFiles') } : null, { key: 'files', label: tr('overview.files'), count: fileRecentChanges, detail: countText('overview.count.recentChanges', fileRecentChanges), actionKind: 'files', actionLabel: tr('overview.openFiles') },
hasBrowserInbox ? { key: 'captures', label: tr('overview.captures'), count: unprocessedCaptures.length, detail: countText('overview.count.captures', unprocessedCaptures.length), actionKind: 'browser-inbox', actionLabel: tr('overview.reviewInbox') } : null, { key: 'captures', label: tr('overview.captures'), count: unprocessedCaptures.length, detail: countText('overview.count.captures', unprocessedCaptures.length), actionKind: 'browser-inbox', actionLabel: tr('overview.reviewInbox') },
hasActivity ? { key: 'activity', label: tr('overview.activity'), count: activityEvents.length, detail: countText('overview.count.events', activityEvents.length), actionKind: 'activity', actionLabel: tr('overview.viewActivity') } : null, { key: 'activity', label: tr('overview.activity'), count: activityEvents.length, detail: countText('overview.count.events', activityEvents.length), actionKind: 'activity', actionLabel: tr('overview.viewActivity') },
hasJournal ? { key: 'journal', label: tr('overview.journal'), count: journalEntries.length, detail: countText('overview.count.journal', journalEntries.length), actionKind: 'journal', actionLabel: tr('overview.openJournal') } : null, { key: 'journal', label: tr('overview.journal'), count: journalEntries.length, detail: countText('overview.count.journal', journalEntries.length), actionKind: 'journal', actionLabel: tr('overview.openJournal') },
hasAttentionTools ? { key: 'attention', label: tr('overview.attention'), count: needsAttention.length, detail: countText('overview.count.pending', needsAttention.length), actionKind: attentionActionKind, actionLabel: tr('overview.reviewPending') } : null, { key: 'attention', label: tr('overview.attention'), count: needsAttention.length, detail: countText('overview.count.pending', needsAttention.length), actionKind: attentionActionKind, actionLabel: tr('overview.reviewPending') },
].filter(Boolean); ];
onMount(() => { onMount(() => {
unsubscribeLocale = i18n.subscribe((nextLocale) => locale = nextLocale); unsubscribeLocale = i18n.subscribe((nextLocale) => locale = nextLocale);
@ -88,7 +78,7 @@
onDestroy(() => unsubscribeLocale?.()); onDestroy(() => unsubscribeLocale?.());
$: if (workspaceRootPath && (workspaceRootPath !== loadedWorkspaceRoot || overviewToolKey !== loadedToolKey)) { $: if (workspaceRootPath && workspaceRootPath !== loadedWorkspaceRoot) {
loadOverview(); loadOverview();
} }
@ -99,29 +89,11 @@
const label = `${tool?.title || ''} ${tool?.id || ''} ${tool?.pluginId || ''}`.toLowerCase(); const label = `${tool?.title || ''} ${tool?.id || ''} ${tool?.pluginId || ''}`.toLowerCase();
return label.includes(name); return label.includes(name);
}); });
return fromProps; if (fromProps) return true;
} if (typeof document === 'undefined') return false;
return Array.from(document.querySelectorAll('.workspace-tabs [role="tab"]')).some(tab => {
function actionIsAvailable(kind) { return String(tab.textContent || '').trim().toLowerCase().includes(name);
if (kind === 'notes') return hasNotes; });
if (kind === 'files') return hasFiles;
if (kind === 'browser-inbox') return hasBrowserInbox;
if (kind === 'activity') return hasActivity;
if (kind === 'journal') return hasJournal;
if (kind === 'todo') return hasTodos;
return false;
}
function filterAvailableItems(items, _toolKey) {
void _toolKey;
return (items || []).filter(item => actionIsAvailable(item?.actionKind));
}
function fallbackAttentionAction() {
if (hasBrowserInbox) return 'browser-inbox';
if (hasTodos) return 'todo';
if (hasActivity && hasJournal) return 'journal';
return '';
} }
function decodeTuple(response, fallback) { function decodeTuple(response, fallback) {
@ -195,7 +167,7 @@
} }
function todoTitle(item) { function todoTitle(item) {
return String(item?.title || item?.name || tr('overview.untitledTodo')).trim() || tr('overview.untitledTodo'); return String(item?.title || item?.name || 'Untitled todo').trim() || 'Untitled todo';
} }
function todoAttentionState(item) { function todoAttentionState(item) {
@ -203,9 +175,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 tr('overview.todo.reminderDue'); if (reminderAt && reminderAt <= now) return 'Reminder due';
if (dueAt && dueAt < now) return tr('overview.todo.overdue'); if (dueAt && dueAt < now) return 'Overdue';
if (dueAt && dueAt <= now + 3 * 24 * 60 * 60 * 1000) return tr('overview.todo.dueSoon'); if (dueAt && dueAt <= now + 3 * 24 * 60 * 60 * 1000) return 'Due soon';
return ''; return '';
} }
@ -238,12 +210,17 @@
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 || tr('overview.item')); return title || titleFromPath(path) || String(item?.summary || item?.activityId || 'item');
} }
function looksLikePath(value) { function looksLikePath(value) {
@ -253,11 +230,11 @@
} }
function captureTitle(capture) { function captureTitle(capture) {
return capture?.title || capture?.fileName || capture?.url || capture?.captureId || tr('overview.untitledCapture'); return capture?.title || capture?.fileName || capture?.url || capture?.captureId || 'Untitled capture';
} }
function journalTitle(entry) { function journalTitle(entry) {
return entry?.title || entry?.summary || entry?.date || entry?.entryId || tr('overview.untitledJournal'); return entry?.title || entry?.summary || entry?.date || entry?.entryId || 'Journal entry';
} }
function itemTimeLabel(item) { function itemTimeLabel(item) {
@ -307,31 +284,22 @@
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 tr('overview.event.noteEdited', { name }); if (type === 'note.saved' || type === 'note.edited') return `Edited note ${quoted(name)}`;
if (type === 'note.opened') return tr('overview.event.noteOpened', { name }); if (type === 'note.opened') return `Opened note ${quoted(name)}`;
if (type === 'note.created') return tr('overview.event.noteCreated', { name }); if (type === 'note.created') return `Created note ${quoted(name)}`;
if (type === 'file.opened') return tr('overview.event.fileOpened', { name }); if (type === 'file.opened') return `Opened file ${quoted(name)}`;
if (type === 'file.changed') return tr('overview.event.fileChanged', { name }); if (type === 'file.changed') return `Changed file ${quoted(name)}`;
if (type === 'file.created') return tr('overview.event.fileCreated', { name }); if (type === 'file.created') return `Created file ${quoted(name)}`;
if (type === 'file.deleted' || type === 'file.trashed') return tr('overview.event.fileRemoved', { name }); if (type === 'file.deleted' || type === 'file.trashed') return `Removed file ${quoted(name)}`;
if (type === 'browser.capture.page') return tr('overview.event.capturePage', { name }); if (type === 'browser.capture.page') return `Captured page ${quoted(name)}`;
if (type === 'browser.capture.selection') return tr('overview.event.captureSelection', { name }); if (type === 'browser.capture.selection') return `Captured selection ${quoted(name)}`;
if (type === 'browser.capture.link') return tr('overview.event.captureLink', { name }); if (type === 'browser.capture.link') return `Captured link ${quoted(name)}`;
if (type === 'browser.capture.file') return tr('overview.event.captureFile', { name }); if (type === 'browser.capture.file') return `Captured file ${quoted(name)}`;
if (type === 'browser.capture.converted') return tr('overview.event.captureConverted', { name }); if (type === 'browser.capture.converted') return `Converted capture ${quoted(name)}`;
if (type === 'journal.entry.added' || type === 'worklog.entry.added') return tr('overview.event.journalAdded', { name }); if (type === 'journal.entry.added' || type === 'worklog.entry.added') return `Added journal entry ${quoted(name)}`;
if (type === 'action.started') return tr('overview.event.workSessionDetected'); if (type === 'action.started') return 'Work session detected';
if (type === 'workspace.opened') return tr('overview.event.workspaceOpened'); if (type === 'workspace.opened') return 'Workspace opened';
return item?.title || item?.summary || tr('overview.event.activity'); return item?.title || item?.summary || 'Workspace 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) {
@ -352,7 +320,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), meta: `${itemTimeLabel(item)}${item.sourcePluginId ? ' · ' + item.sourcePluginId.replace('verstak.', '') : ''}`,
time: timeValue(item), time: timeValue(item),
absolute: absoluteTime(timeValue(item)), absolute: absoluteTime(timeValue(item)),
actionKind: action.kind, actionKind: action.kind,
@ -362,8 +330,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: tr('overview.event.capture', { kind: captureKindLabel(item), title: captureTitle(item) }), title: `Captured ${item.kind || 'item'} ${quoted(captureTitle(item))}`,
meta: `${itemTimeLabel(item)} · ${item.domain || item.url || tr('overview.browserCapture')}`, meta: `${itemTimeLabel(item)} · ${item.domain || item.url || 'Browser capture'}`,
time: timeValue(item), time: timeValue(item),
absolute: absoluteTime(timeValue(item)), absolute: absoluteTime(timeValue(item)),
actionKind: 'browser-inbox', actionKind: 'browser-inbox',
@ -372,8 +340,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: tr('overview.event.journalAdded', { name: journalTitle(item) }), title: `Added journal entry ${quoted(journalTitle(item))}`,
meta: `${itemTimeLabel(item)}${item.minutes ? ' · ' + tr('overview.minutes', { count: item.minutes }) : ''}`, meta: `${itemTimeLabel(item)}${item.minutes ? ' · ' + item.minutes + ' min' : ''}`,
time: timeValue(item), time: timeValue(item),
absolute: absoluteTime(timeValue(item)), absolute: absoluteTime(timeValue(item)),
actionKind: 'journal', actionKind: 'journal',
@ -413,8 +381,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: tr('overview.event.todo', { title: todoTitle(item) }), title: `Todo ${quoted(todoTitle(item))}`,
meta: `${todoAttentionState(item)}${item.dueAt ? ` · ${tr('overview.todo.due', { date: item.dueAt })}` : ''}`, meta: `${todoAttentionState(item)}${item.dueAt ? ` · Due ${item.dueAt}` : ''}`,
time: todoTimeValue(item), time: todoTimeValue(item),
absolute: absoluteTime(todoTimeValue(item)), absolute: absoluteTime(todoTimeValue(item)),
actionKind: 'todo', actionKind: 'todo',
@ -423,8 +391,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: tr('overview.event.reviewCapture', { title: captureTitle(item) }), title: `Review capture ${quoted(captureTitle(item))}`,
meta: `${itemTimeLabel(item)} · ${item.domain || captureKindLabel(item) || tr('overview.browserCapture')}`, meta: `${itemTimeLabel(item)} · ${item.domain || item.kind || 'Browser capture'}`,
time: timeValue(item), time: timeValue(item),
absolute: absoluteTime(timeValue(item)), absolute: absoluteTime(timeValue(item)),
actionKind: 'browser-inbox', actionKind: 'browser-inbox',
@ -439,7 +407,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: tr('overview.event.continueJournal', { title: journalTitle(item) }), title: `Continue journal entry ${quoted(journalTitle(item))}`,
meta: itemTimeLabel(item), meta: itemTimeLabel(item),
time: timeValue(item), time: timeValue(item),
absolute: absoluteTime(timeValue(item)), absolute: absoluteTime(timeValue(item)),
@ -453,18 +421,14 @@
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: `${captureKindLabel(item)} · ${itemTimeLabel(item)}`, meta: `${item.kind || 'capture'} · ${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: tr('overview.event.possibleJournalEntry'), title: 'Possible journal entry',
meta: tr('overview.candidateMeta', { meta: `Workspace: ${item.workspaceRootPath || workspaceRootPath || 'Unknown'} · ${item.estimatedMinutes || 0} min · ${item.activityCount || (item.activityIds || []).length || 0} activities`,
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 },
@ -472,7 +436,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 ? ` · ${tr('overview.todo.due', { date: item.dueAt })}` : ''}`, meta: `${todoAttentionState(item)}${item.dueAt ? ` · Due ${item.dueAt}` : ''}`,
actionKind: 'todo', actionKind: 'todo',
actionLabel: tr('overview.openTodos'), actionLabel: tr('overview.openTodos'),
})); }));
@ -513,12 +477,12 @@
} }
} }
async function loadWorkspaceResources(toolState) { async function loadWorkspaceResources() {
const workspace = String(workspaceRootPath || '').trim(); const workspace = String(workspaceRootPath || '').trim();
if (!workspace) return { keyResources: [], totalNotes: 0 }; if (!workspace) return { keyResources: [], totalNotes: 0 };
const [rootEntries, notesEntries] = await Promise.all([ const [rootEntries, notesEntries] = await Promise.all([
toolState.files ? listFiles('verstak.files', workspace) : Promise.resolve([]), listFiles('verstak.files', workspace),
toolState.notes ? listFiles('verstak.notes', `${workspace}/Notes`) : Promise.resolve([]), listFiles('verstak.notes', `${workspace}/Notes`),
]); ]);
const noteFiles = notesEntries.filter(item => item?.type === 'file'); const noteFiles = notesEntries.filter(item => item?.type === 'file');
const overview = [...notesEntries, ...rootEntries].find(item => /(^|\/)overview\.md$/i.test(String(item.relativePath || item.name || ''))); const overview = [...notesEntries, ...rootEntries].find(item => /(^|\/)overview\.md$/i.test(String(item.relativePath || item.name || '')));
@ -527,54 +491,44 @@
keyResources: overview ? [{ keyResources: overview ? [{
id: overview.relativePath || overview.name, id: overview.relativePath || overview.name,
title: overview.name || fileName(overview.relativePath) || 'Overview.md', title: overview.name || fileName(overview.relativePath) || 'Overview.md',
meta: overview.relativePath || tr('overview.overviewNote'), meta: overview.relativePath || 'Workspace overview note',
actionKind: toolState.notes ? 'notes' : 'files', actionKind: hasNotes ? 'notes' : 'files',
actionLabel: toolState.notes ? tr('overview.openNotes') : tr('overview.openFiles'), actionLabel: hasNotes ? tr('overview.openNotes') : tr('overview.openFiles'),
}] : [], }] : [],
}; };
} }
async function loadOverview() { async function loadOverview() {
const workspaceAtStart = String(workspaceRootPath || '').trim(); const workspaceAtStart = String(workspaceRootPath || '').trim();
const toolKeyAtStart = overviewToolKey;
const toolState = {
notes: hasNotes,
files: hasFiles,
browserInbox: hasBrowserInbox,
activity: hasActivity,
journal: hasJournal,
todos: hasTodos,
};
loadedWorkspaceRoot = workspaceAtStart; loadedWorkspaceRoot = workspaceAtStart;
loadedToolKey = toolKeyAtStart;
loading = true; loading = true;
const [browserSettings, activitySettings, activityRecords, journalSettings, todoSettings, resources] = await Promise.all([ const [browserSettings, activitySettings, activityRecords, journalSettings, todoSettings, resources] = await Promise.all([
toolState.browserInbox ? readPluginSettings('verstak.browser-inbox') : Promise.resolve({}), readPluginSettings('verstak.browser-inbox'),
toolState.activity ? readPluginSettings('verstak.activity') : Promise.resolve({}), readPluginSettings('verstak.activity'),
toolState.activity && App.ReadPluginDataNDJSON App.ReadPluginDataNDJSON
? App.ReadPluginDataNDJSON('verstak.activity', 'activity-events') ? App.ReadPluginDataNDJSON('verstak.activity', 'activity-events')
.then(value => decodeTuple(value, [])) .then(value => decodeTuple(value, []))
.catch(() => []) .catch(() => [])
: Promise.resolve([]), : Promise.resolve([]),
toolState.journal ? readPluginSettings('verstak.journal') : Promise.resolve({}), readPluginSettings('verstak.journal'),
toolState.todos ? readPluginSettings('verstak.todo') : Promise.resolve({}), readPluginSettings('verstak.todo'),
loadWorkspaceResources(toolState), loadWorkspaceResources(),
]); ]);
if (workspaceAtStart !== String(workspaceRootPath || '').trim() || toolKeyAtStart !== overviewToolKey) return; if (workspaceAtStart !== String(workspaceRootPath || '').trim()) return;
captures = toolState.browserInbox ? browserCaptureRowsForWorkspace(browserSettings) : []; captures = browserCaptureRowsForWorkspace(browserSettings);
activityEvents = toolState.activity ? normalizeRows(activityRecords).filter(item => { activityEvents = normalizeRows(activityRecords).filter(item => {
const tagged = String(item.workspaceRootPath || item.workspaceName || item.workspaceNodeId || '').trim(); const tagged = String(item.workspaceRootPath || item.workspaceName || item.workspaceNodeId || '').trim();
return !tagged || tagged === workspaceAtStart; return !tagged || tagged === workspaceAtStart;
}) : []; });
journalEntries = toolState.journal ? rowsFor(journalSettings, [ journalEntries = rowsFor(journalSettings, [
workspaceKey('worklog:workspace:'), workspaceKey('worklog:workspace:'),
'worklog', 'worklog',
]) : []; ]);
todos = toolState.todos ? todoRowsForWorkspace(todoSettings) : []; todos = todoRowsForWorkspace(todoSettings);
workSessionCandidates = toolState.activity && toolState.journal ? rowsFor(activitySettings, [ workSessionCandidates = rowsFor(activitySettings, [
workspaceKey('work-session-candidates:workspace:'), workspaceKey('work-session-candidates:workspace:'),
]) : []; ]);
keyResources = resources.keyResources; keyResources = resources.keyResources;
totalNotes = resources.totalNotes; totalNotes = resources.totalNotes;
loading = false; loading = false;
@ -703,7 +657,7 @@
</main> </main>
<aside class="overview-side"> <aside class="overview-side">
{#if hasAttentionTools && (needsAttention.length || !loading)} {#if needsAttention.length || !loading}
<section class="today-panel overview-panel" data-overview-section="attention"> <section class="today-panel overview-panel" data-overview-section="attention">
<div class="today-panel-head overview-panel-head"> <div class="today-panel-head overview-panel-head">
<div> <div>

View File

@ -18,12 +18,6 @@
void activeLocale; void activeLocale;
return i18n.t(key, params, fallback); return i18n.t(key, params, fallback);
})(locale); })(locale);
function reportError(key, fallback, details) {
console.warn('[VaultSelection] operation failed:', details);
return tr(key, undefined, fallback);
}
onDestroy(unsubscribeLocale); onDestroy(unsubscribeLocale);
onMount(async () => { onMount(async () => {
@ -60,13 +54,13 @@
try { try {
const createErr = await App.CreateVault(newVaultPath.trim()); const createErr = await App.CreateVault(newVaultPath.trim());
if (createErr) { if (createErr) {
error = reportError('vaultSelection.createError', 'Could not create the vault. Please try again.', createErr); error = tr('vaultSelection.createError', { error: createErr });
creating = false; creating = false;
return; return;
} }
const openErr = await App.OpenVault(newVaultPath.trim()); const openErr = await App.OpenVault(newVaultPath.trim());
if (openErr) { if (openErr) {
error = reportError('vaultSelection.openError', 'Could not open the vault. Please try again.', openErr); error = tr('vaultSelection.openError', { error: openErr });
creating = false; creating = false;
return; return;
} }
@ -76,7 +70,7 @@
} }
window.dispatchEvent(new CustomEvent('verstak:vault-opened')); window.dispatchEvent(new CustomEvent('verstak:vault-opened'));
} catch (e) { } catch (e) {
error = reportError('vaultSelection.createError', 'Could not create the vault. Please try again.', e); error = String(e);
creating = false; creating = false;
} }
} }
@ -91,7 +85,7 @@
try { try {
const openErr = await App.OpenVault(openVaultPath.trim()); const openErr = await App.OpenVault(openVaultPath.trim());
if (openErr) { if (openErr) {
error = reportError('vaultSelection.openError', 'Could not open the vault. Please try again.', openErr); error = tr('vaultSelection.openError', { error: openErr });
opening = false; opening = false;
return; return;
} }
@ -101,7 +95,7 @@
} }
window.dispatchEvent(new CustomEvent('verstak:vault-opened')); window.dispatchEvent(new CustomEvent('verstak:vault-opened'));
} catch (e) { } catch (e) {
error = reportError('vaultSelection.openError', 'Could not open the vault. Please try again.', e); error = String(e);
opening = false; opening = false;
} }
} }
@ -112,7 +106,7 @@
try { try {
const openErr = await App.OpenVault(path); const openErr = await App.OpenVault(path);
if (openErr) { if (openErr) {
error = reportError('vaultSelection.openError', 'Could not open the vault. Please try again.', openErr); error = tr('vaultSelection.openError', { error: openErr });
opening = false; opening = false;
return; return;
} }
@ -122,7 +116,7 @@
} }
window.dispatchEvent(new CustomEvent('verstak:vault-opened')); window.dispatchEvent(new CustomEvent('verstak:vault-opened'));
} catch (e) { } catch (e) {
error = reportError('vaultSelection.openError', 'Could not open the vault. Please try again.', e); error = String(e);
opening = false; opening = false;
} }
} }

View File

@ -22,7 +22,6 @@
$: 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,
@ -32,11 +31,6 @@
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 }));
} }
@ -47,7 +41,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">{resourceTitle}</span> <span class="workbench-title vt-page-title">{resourcePath}</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} />
@ -55,13 +49,11 @@
</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">{resourceTitle}</span> <span class="workbench-title vt-page-title">{resourcePath}</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}

View File

@ -45,6 +45,7 @@
$: workspaceRootPath = selectedWorkspace?.rootPath || selectedWorkspace?.name || selectedWorkspace?.id || ''; $: workspaceRootPath = selectedWorkspace?.rootPath || selectedWorkspace?.name || selectedWorkspace?.id || '';
$: workspaceId = selectedWorkspace?.workspaceId || ''; $: workspaceId = selectedWorkspace?.workspaceId || '';
$: workspaceTitle = selectedWorkspace?.title || selectedWorkspace?.name || selectedWorkspace?.id || selectedWorkspaceName; $: workspaceTitle = selectedWorkspace?.title || selectedWorkspace?.name || selectedWorkspace?.id || selectedWorkspaceName;
$: workspaceType = selectedWorkspace?.type || 'workspace';
$: if (workspaceRootPath !== metadataWorkspaceRoot) { $: if (workspaceRootPath !== metadataWorkspaceRoot) {
metadataWorkspaceRoot = workspaceRootPath; metadataWorkspaceRoot = workspaceRootPath;
workspaceMetadata = null; workspaceMetadata = null;
@ -79,13 +80,11 @@
if (changed && selectedWorkspaceName) loadTools(); if (changed && selectedWorkspaceName) loadTools();
}); });
window.addEventListener('verstak:workspace-open-tool', handleWorkspaceOpenTool); window.addEventListener('verstak:workspace-open-tool', handleWorkspaceOpenTool);
window.addEventListener('verstak:plugins-changed', handlePluginsChanged);
}); });
onDestroy(() => { onDestroy(() => {
if (unsubscribeLocale) unsubscribeLocale(); if (unsubscribeLocale) unsubscribeLocale();
window.removeEventListener('verstak:workspace-open-tool', handleWorkspaceOpenTool); window.removeEventListener('verstak:workspace-open-tool', handleWorkspaceOpenTool);
window.removeEventListener('verstak:plugins-changed', handlePluginsChanged);
}); });
function toolKey(tool) { function toolKey(tool) {
@ -171,10 +170,6 @@
requestWorkspaceTool(event?.detail?.kind, event?.detail?.toolRequest || null); requestWorkspaceTool(event?.detail?.kind, event?.detail?.toolRequest || null);
} }
function handlePluginsChanged() {
if (selectedWorkspaceName) loadTools();
}
async function loadTools() { async function loadTools() {
try { try {
toolsLoaded = false; toolsLoaded = false;
@ -206,6 +201,7 @@
<div class="workspace-header vt-page-header"> <div class="workspace-header vt-page-header">
<div class="workspace-title-group"> <div class="workspace-title-group">
<span class="workspace-title vt-page-title">{workspaceTitle}</span> <span class="workspace-title vt-page-title">{workspaceTitle}</span>
<span class="workspace-type vt-badge accent">{workspaceType}</span>
</div> </div>
<div class="workspace-search" aria-label={tr('workspace.search')}> <div class="workspace-search" aria-label={tr('workspace.search')}>
<GlobalSearch /> <GlobalSearch />
@ -292,6 +288,14 @@
font-weight: 600; font-weight: 600;
} }
.workspace-type {
color: var(--vt-color-accent);
font-size: 0.75rem;
padding: 0.1rem 0.4rem;
border-radius: var(--vt-radius-sm);
background: var(--vt-color-accent-muted);
}
.workspace-search { .workspace-search {
width: min(27rem, 46vw); width: min(27rem, 46vw);
min-width: 16rem; min-width: 16rem;

View File

@ -18,11 +18,8 @@
let newWorkspaceName = ''; let newWorkspaceName = '';
let workspaceTemplates = []; let workspaceTemplates = [];
let templatePluginNames = {}; let templatePluginNames = {};
let templatePlugins = {};
let templateCapabilities = new Set();
let selectedTemplateId = 'default'; let selectedTemplateId = 'default';
let createError = ''; let createError = '';
let templateWarning = null;
let templatesLoading = false; let templatesLoading = false;
let creating = false; let creating = false;
let renamingId = ''; let renamingId = '';
@ -35,11 +32,6 @@
return i18n.t(key, params, fallback); return i18n.t(key, params, fallback);
})(locale); })(locale);
function reportError(key, fallback, details) {
console.warn('[WorkspaceTree] operation failed:', details);
return tr(key, undefined, fallback);
}
onMount(() => { onMount(() => {
unsubscribeLocale = i18n.subscribe((nextLocale) => { unsubscribeLocale = i18n.subscribe((nextLocale) => {
const changed = locale !== nextLocale; const changed = locale !== nextLocale;
@ -69,71 +61,21 @@
} }
$: selectedTemplate = workspaceTemplates.find(template => template.id === selectedTemplateId) || workspaceTemplates[0] || null; $: selectedTemplate = workspaceTemplates.find(template => template.id === selectedTemplateId) || workspaceTemplates[0] || null;
$: selectedTemplateTools = (selectedTemplate?.workspaceTools || []).map((pluginId) => (
templateToolState(pluginId, templatePlugins, templateCapabilities, templatePluginNames, tr)
));
$: selectedTemplateIssues = selectedTemplateTools.filter(tool => tool.status !== 'available');
function toolLabel(pluginId, names = templatePluginNames) { function toolLabel(pluginId) {
return names[pluginId] || String(pluginId || '').replace(/^verstak\./, ''); return templatePluginNames[pluginId] || String(pluginId || '').replace(/^verstak\./, '');
}
function templateToolState(pluginId, plugins, capabilities, names, translate) {
const plugin = plugins[pluginId];
if (!plugin) {
return {
pluginId,
name: toolLabel(pluginId, names),
tabs: [],
status: 'unavailable',
reason: translate('workspaceTree.templateMissingPlugin'),
};
}
const manifest = plugin.manifest || {};
const tabs = Array.isArray(manifest.contributes?.workspaceItems)
? manifest.contributes.workspaceItems.map(item => item?.title || item?.id).filter(Boolean)
: [];
const pluginStatus = String(plugin.status || '').toLowerCase();
const missingCapability = Array.isArray(manifest.requires)
&& manifest.requires.some(capabilityId => !capabilities.has(capabilityId));
let status = 'available';
let reason = translate('workspaceTree.templateAvailable');
if (!plugin.enabled || pluginStatus === 'disabled') {
status = 'unavailable';
reason = translate('workspaceTree.templatePluginDisabled');
} else if (pluginStatus === 'missing-required-capability' || missingCapability) {
status = 'unavailable';
reason = translate('workspaceTree.templateCapabilityUnavailable');
} else if (pluginStatus === 'incompatible') {
status = 'unavailable';
reason = translate('workspaceTree.templateIncompatible');
} else if (pluginStatus === 'failed') {
status = 'unavailable';
reason = translate('workspaceTree.templateLoadFailed');
} else if (pluginStatus === 'degraded') {
status = 'limited';
reason = translate('workspaceTree.templateLimited');
} else if (pluginStatus !== 'loaded') {
status = 'unavailable';
reason = translate('workspaceTree.templateNotReady');
}
return { pluginId, name: manifest.name || toolLabel(pluginId, names), tabs, status, reason };
} }
async function loadWorkspaceTemplates() { async function loadWorkspaceTemplates() {
templatesLoading = true; templatesLoading = true;
try { try {
const [templates, plugins, capabilities] = await Promise.all([ const [templates, plugins] = await Promise.all([
App.ListWorkspaceTemplates ? App.ListWorkspaceTemplates() : [], App.ListWorkspaceTemplates ? App.ListWorkspaceTemplates() : [],
App.GetPlugins ? App.GetPlugins() : [], App.GetPlugins ? App.GetPlugins() : [],
App.GetCapabilities ? App.GetCapabilities() : [],
]); ]);
const [list, err] = resultOrError(templates, []); const [list, err] = resultOrError(templates, []);
if (err) { if (err) {
createError = reportError('workspaceTree.templatesError', 'Could not load Deal templates. Please try again.', err); createError = err;
workspaceTemplates = []; workspaceTemplates = [];
return; return;
} }
@ -141,25 +83,17 @@
await Promise.all((Array.isArray(plugins) ? plugins : []).map((plugin) => ( await Promise.all((Array.isArray(plugins) ? plugins : []).map((plugin) => (
i18n.loadPlugin(plugin.manifest?.id, plugin.manifest?.localization).catch(() => {}) i18n.loadPlugin(plugin.manifest?.id, plugin.manifest?.localization).catch(() => {})
))); )));
const localizedPlugins = (Array.isArray(plugins) ? plugins : []).map((plugin) => i18n.localizePlugin(plugin)); templatePluginNames = (Array.isArray(plugins) ? plugins : []).map((plugin) => i18n.localizePlugin(plugin)).reduce((names, plugin) => {
templatePluginNames = localizedPlugins.reduce((names, plugin) => {
const id = plugin?.manifest?.id; const id = plugin?.manifest?.id;
const name = plugin?.manifest?.name; const name = plugin?.manifest?.name;
if (id && name) names[id] = name; if (id && name) names[id] = name;
return names; return names;
}, {}); }, {});
templatePlugins = localizedPlugins.reduce((result, plugin) => {
const id = plugin?.manifest?.id;
if (id) result[id] = plugin;
return result;
}, {});
const [capabilityList] = resultOrError(capabilities, []);
templateCapabilities = new Set((Array.isArray(capabilityList) ? capabilityList : []).map(capability => capability?.name).filter(Boolean));
if (!workspaceTemplates.some(template => template.id === selectedTemplateId)) { if (!workspaceTemplates.some(template => template.id === selectedTemplateId)) {
selectedTemplateId = workspaceTemplates[0]?.id || ''; selectedTemplateId = workspaceTemplates[0]?.id || '';
} }
} catch (error) { } catch (error) {
createError = reportError('workspaceTree.templatesError', 'Could not load Deal templates. Please try again.', error); createError = String(error);
workspaceTemplates = []; workspaceTemplates = [];
} finally { } finally {
templatesLoading = false; templatesLoading = false;
@ -193,7 +127,7 @@
try { try {
const [list, err] = resultOrError(await App.ListWorkspaces(), []); const [list, err] = resultOrError(await App.ListWorkspaces(), []);
if (err) { if (err) {
localError = reportError('workspaceTree.loadError', 'Could not load Deals. Please try again.', err); localError = err;
workspaces = []; workspaces = [];
} else { } else {
workspaces = list || []; workspaces = list || [];
@ -214,7 +148,7 @@
activeWorkspaceId.set(currentWorkspaceId); activeWorkspaceId.set(currentWorkspaceId);
} }
} catch (e) { } catch (e) {
localError = reportError('workspaceTree.loadError', 'Could not load Deals. Please try again.', e); localError = String(e);
} }
loading = false; loading = false;
} }
@ -223,7 +157,7 @@
const id = wsName(workspace); const id = wsName(workspace);
const err = await App.SetCurrentWorkspace(id); const err = await App.SetCurrentWorkspace(id);
if (err) { if (err) {
localError = reportError('workspaceTree.selectError', 'Could not select this Deal. Please try again.', err); localError = err;
return; return;
} }
currentWorkspaceId = id; currentWorkspaceId = id;
@ -243,16 +177,11 @@
createError = tr('workspaceTree.chooseTemplate'); createError = tr('workspaceTree.chooseTemplate');
return; return;
} }
const creationIssues = selectedTemplateIssues.map(tool => ({
pluginId: tool.pluginId,
name: tool.name,
reason: tool.reason,
}));
creating = true; creating = true;
createError = ''; createError = '';
const [, err] = resultOrError(await App.CreateWorkspace(name, selectedTemplate.id), null); const [, err] = resultOrError(await App.CreateWorkspace(name, selectedTemplate.id), null);
if (err) { if (err) {
createError = reportError('workspaceTree.createError', 'Could not create the Deal. Please try again.', err); createError = err;
creating = false; creating = false;
return; return;
} }
@ -262,14 +191,13 @@
await loadWorkspaces(); await loadWorkspaces();
const created = workspaces.find((ws) => wsName(ws) === name); const created = workspaces.find((ws) => wsName(ws) === name);
if (created) await selectWorkspace(created); if (created) await selectWorkspace(created);
templateWarning = creationIssues.length > 0 ? { workspaceName: name, issues: creationIssues } : null;
} }
async function openCreateDialog() { function openCreateDialog() {
showCreate = true; showCreate = true;
newWorkspaceName = ''; newWorkspaceName = '';
createError = ''; createError = '';
await loadWorkspaceTemplates(); if (!workspaceTemplates.length && !templatesLoading) loadWorkspaceTemplates();
} }
function closeCreateDialog() { function closeCreateDialog() {
@ -279,10 +207,6 @@
createError = ''; createError = '';
} }
function dismissTemplateWarning() {
templateWarning = null;
}
function startRename(workspace) { function startRename(workspace) {
renamingId = wsName(workspace); renamingId = wsName(workspace);
renameValue = renamingId; renameValue = renamingId;
@ -304,7 +228,7 @@
busyId = oldName; busyId = oldName;
const err = await App.RenameWorkspace(oldName, newName); const err = await App.RenameWorkspace(oldName, newName);
if (err) { if (err) {
localError = reportError('workspaceTree.renameError', 'Could not rename the Deal. Please try again.', err); localError = err;
busyId = ''; busyId = '';
return; return;
} }
@ -322,7 +246,7 @@
busyId = name; busyId = name;
const [, err] = resultOrError(await App.TrashWorkspace(name), null); const [, err] = resultOrError(await App.TrashWorkspace(name), null);
if (err) { if (err) {
localError = reportError('workspaceTree.trashError', 'Could not move the Deal to trash. Please try again.', err); localError = err;
busyId = ''; busyId = '';
return; return;
} }
@ -364,7 +288,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}>{tr('common.save')}</button> <button class="wt-btn wt-btn-small wt-always" on:click={() => commitRename(workspace)} title={tr('workspaceTree.saveRename')} type="button" disabled={busyId === id}>OK</button>
<button class="wt-btn wt-btn-small wt-always" on:click={cancelRename} title={tr('common.cancel')} type="button" disabled={busyId === id}>{tr('common.cancel')}</button> <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>
@ -380,18 +304,6 @@
{/each} {/each}
</div> </div>
{#if templateWarning}
<section class="workspace-template-warning" data-workspace-template-warning role="alert">
<div>
<strong>{tr('workspaceTree.templateIncompleteCreated', { name: templateWarning.workspaceName })}</strong>
{#each templateWarning.issues as issue (issue.pluginId)}
<div class="workspace-template-warning-item">{issue.name}: {issue.reason}</div>
{/each}
</div>
<button class="wt-btn" type="button" on:click={dismissTemplateWarning} title={tr('common.close')}>{tr('common.close')}</button>
</section>
{/if}
{#if showCreate} {#if showCreate}
<div class="workspace-create-overlay" data-workspace-create-modal role="dialog" aria-modal="true" aria-label={tr('workspaceTree.create')}> <div class="workspace-create-overlay" data-workspace-create-modal role="dialog" aria-modal="true" aria-label={tr('workspaceTree.create')}>
<div class="workspace-create-modal"> <div class="workspace-create-modal">
@ -417,23 +329,10 @@
<div class="workspace-template-summary"> <div class="workspace-template-summary">
<p data-workspace-template-description>{selectedTemplate.description}</p> <p data-workspace-template-description>{selectedTemplate.description}</p>
<div class="workspace-template-tools" data-workspace-template-tools> <div class="workspace-template-tools" data-workspace-template-tools>
{#each selectedTemplateTools as tool (tool.pluginId)} {#each selectedTemplate.workspaceTools || [] as pluginId (pluginId)}
<div <span>{toolLabel(pluginId)}</span>
class="workspace-template-tool"
class:limited={tool.status === 'limited'}
class:unavailable={tool.status === 'unavailable'}
data-workspace-template-tool={tool.pluginId}
data-template-tool-status={tool.status}
>
<span class="workspace-template-tool-name">{tool.name}</span>
<span class="workspace-template-tool-tabs">{tool.tabs.length ? tr('workspaceTree.templateToolTabs', { tabs: tool.tabs.join(', ') }) : tr('workspaceTree.templateToolNoTabs')}</span>
<span class="workspace-template-tool-reason">{tool.reason}</span>
</div>
{/each} {/each}
</div> </div>
{#if selectedTemplateIssues.length > 0}
<p class="workspace-template-incomplete" data-workspace-template-incomplete>{tr('workspaceTree.templateWillBeIncomplete')}</p>
{/if}
</div> </div>
{/if} {/if}
{#if createError} {#if createError}
@ -479,25 +378,11 @@
.workspace-create-header h2 { margin: 0; font-size: 1rem; } .workspace-create-header h2 { margin: 0; font-size: 1rem; }
.workspace-create-field { display: grid; gap: 0.35rem; color: var(--vt-color-text-muted); font-size: 0.75rem; } .workspace-create-field { display: grid; gap: 0.35rem; color: var(--vt-color-text-muted); font-size: 0.75rem; }
.workspace-create-field input, .workspace-create-field select { width: 100%; min-height: 2rem; box-sizing: border-box; border: 1px solid var(--vt-color-border-strong); border-radius: var(--vt-radius-sm); background: #0f1424; color: var(--vt-color-text-primary); padding: 0.35rem 0.5rem; font: inherit; font-size: 0.84rem; } .workspace-create-field input, .workspace-create-field select { width: 100%; min-height: 2rem; box-sizing: border-box; border: 1px solid var(--vt-color-border-strong); border-radius: var(--vt-radius-sm); background: #0f1424; color: var(--vt-color-text-primary); padding: 0.35rem 0.5rem; font: inherit; font-size: 0.84rem; }
.workspace-create-field select { appearance: none; background-color: #0f1424; background-image: linear-gradient(45deg, transparent 50%, var(--vt-color-text-muted) 50%), linear-gradient(135deg, var(--vt-color-text-muted) 50%, transparent 50%); background-position: calc(100% - 14px) 50%, calc(100% - 9px) 50%; background-size: 5px 5px, 5px 5px; background-repeat: no-repeat; padding-right: 1.7rem; }
.workspace-create-field select option { background: #0f1424; color: var(--vt-color-text-primary); }
.workspace-create-field input:focus, .workspace-create-field select:focus { outline: none; border-color: var(--vt-color-accent); box-shadow: var(--vt-focus-ring); } .workspace-create-field input:focus, .workspace-create-field select:focus { outline: none; border-color: var(--vt-color-accent); box-shadow: var(--vt-focus-ring); }
.workspace-template-summary { display: grid; gap: 0.55rem; padding: 0.75rem; border: 1px solid var(--vt-color-border); border-radius: var(--vt-radius-md); background: var(--vt-color-surface-muted); } .workspace-template-summary { display: grid; gap: 0.55rem; padding: 0.75rem; border: 1px solid var(--vt-color-border); border-radius: var(--vt-radius-md); background: var(--vt-color-surface-muted); }
.workspace-template-summary p { margin: 0; color: var(--vt-color-text-secondary); font-size: 0.8rem; line-height: 1.45; } .workspace-template-summary p { margin: 0; color: var(--vt-color-text-secondary); font-size: 0.8rem; line-height: 1.45; }
.workspace-template-tools { display: grid; gap: 0.4rem; } .workspace-template-tools { display: flex; flex-wrap: wrap; gap: 0.35rem; }
.workspace-template-tool { display: grid; grid-template-columns: minmax(7rem, 1fr) minmax(0, 1.4fr) minmax(0, 1.2fr); gap: 0.5rem; align-items: center; padding: 0.45rem 0.55rem; border: 1px solid var(--vt-color-border-strong); border-radius: var(--vt-radius-sm); color: var(--vt-color-text-secondary); font-size: 0.72rem; } .workspace-template-tools span { min-height: 1.35rem; display: inline-flex; align-items: center; padding: 0 0.35rem; border: 1px solid var(--vt-color-border-strong); border-radius: var(--vt-radius-sm); color: var(--vt-color-text-secondary); font-size: 0.72rem; }
.workspace-template-tool-name { color: var(--vt-color-text-primary); font-weight: 600; }
.workspace-template-tool-tabs { color: var(--vt-color-text-muted); }
.workspace-template-tool-reason { color: var(--vt-color-accent); }
.workspace-template-tool.limited { border-color: rgba(220, 164, 57, 0.58); background: rgba(220, 164, 57, 0.08); }
.workspace-template-tool.limited .workspace-template-tool-reason { color: #e8bc63; }
.workspace-template-tool.unavailable { border-color: rgba(233, 69, 96, 0.58); background: var(--vt-color-danger-muted); }
.workspace-template-tool.unavailable .workspace-template-tool-reason { color: var(--vt-color-danger); }
.workspace-template-incomplete { color: var(--vt-color-danger) !important; }
.workspace-template-warning { display: flex; gap: 0.5rem; align-items: flex-start; justify-content: space-between; margin: 0.25rem 0.6rem 0.5rem; padding: 0.6rem; border: 1px solid rgba(233, 69, 96, 0.58); border-radius: var(--vt-radius-md); background: var(--vt-color-danger-muted); color: var(--vt-color-text-secondary); font-size: 0.73rem; line-height: 1.4; }
.workspace-template-warning strong { color: var(--vt-color-danger); }
.workspace-template-warning-item { margin-top: 0.2rem; }
@media (max-width: 700px) { .workspace-template-tool { grid-template-columns: 1fr; gap: 0.18rem; } }
.workspace-create-error { margin: 0; color: var(--vt-color-danger); font-size: 0.78rem; line-height: 1.4; } .workspace-create-error { margin: 0; color: var(--vt-color-danger); font-size: 0.78rem; line-height: 1.4; }
.workspace-create-actions { display: flex; gap: 0.4rem; justify-content: flex-end; } .workspace-create-actions { display: flex; gap: 0.4rem; justify-content: flex-end; }
.wt-btn-primary { background: var(--vt-color-accent); color: #101827; border: none; padding: 0.3rem 0.6rem; border-radius: var(--vt-radius-sm); cursor: pointer; font-size: 0.75rem; font-weight: 600; } .wt-btn-primary { background: var(--vt-color-accent); color: #101827; border: none; padding: 0.3rem 0.6rem; border-radius: var(--vt-radius-sm); cursor: pointer; font-size: 0.75rem; font-weight: 600; }

View File

@ -4,12 +4,6 @@
* Каждый метод возвращает Promise с данными, совместимыми с Wails-контрактом. * Каждый метод возвращает Promise с данными, совместимыми с Wails-контрактом.
* Состояние мутабельно тесты могут менять его между сценариями. * Состояние мутабельно тесты могут менять его между сценариями.
*/ */
import defaultEditorSource from '../../../../../verstak-official-plugins/plugins/default-editor/frontend/src/index.js?raw';
import secretsSource from '../../../../../verstak-official-plugins/plugins/secrets/frontend/src/index.js?raw';
import activitySource from '../../../../../verstak-official-plugins/plugins/activity/frontend/src/index.js?raw';
import todoSource from '../../../../../verstak-official-plugins/plugins/todo/frontend/src/index.js?raw';
import journalSource from '../../../../../verstak-official-plugins/plugins/journal/frontend/src/index.js?raw';
(function () { (function () {
if (window.__wailsMockReady) return; if (window.__wailsMockReady) return;
@ -150,7 +144,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: 'Deal-scoped notes manager.', description: 'Workspace-scoped notes manager.',
source: 'official', source: 'official',
icon: 'edit', icon: 'edit',
provides: ['verstak/notes/v1'], provides: ['verstak/notes/v1'],
@ -197,7 +191,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: 'Deal-scoped activity log for public plugin events.', description: 'Workspace-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,11 +215,11 @@ 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: 'Deal-scoped journal with user-authored entries and optional Activity links.', description: 'Workspace-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'],
permissions: ['events.publish', 'files.read', 'storage.namespace', 'ui.register'], permissions: ['storage.namespace', 'ui.register'],
frontend: { entry: 'frontend/dist/index.js' }, frontend: { entry: 'frontend/dist/index.js' },
contributes: { contributes: {
views: [{ id: 'verstak.journal.view', title: 'Journal', icon: 'book-open', component: 'JournalView' }], views: [{ id: 'verstak.journal.view', title: 'Journal', icon: 'book-open', component: 'JournalView' }],
@ -242,19 +236,19 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
manifest: { manifest: {
schemaVersion: 1, schemaVersion: 1,
id: 'verstak.browser-inbox', id: 'verstak.browser-inbox',
name: 'Browser', name: 'Browser Inbox',
version: '0.1.0', version: '0.1.0',
apiVersion: '0.1.0', apiVersion: '0.1.0',
description: 'Global browser materials with explicit Deal assignment.', description: 'Global browser capture queue with explicit workspace assignment.',
source: 'official', source: 'official',
icon: 'inbox', icon: 'inbox',
provides: ['browser.inbox'], provides: ['browser.inbox'],
permissions: ['events.subscribe', 'files.read', 'storage.namespace', 'ui.register'], permissions: ['events.subscribe', 'files.read', 'storage.namespace', 'ui.register'],
frontend: { entry: 'frontend/dist/index.js' }, frontend: { entry: 'frontend/dist/index.js' },
contributes: { contributes: {
views: [{ id: 'verstak.browser-inbox.view', title: 'Browser', icon: 'inbox', component: 'BrowserInboxView' }], views: [{ id: 'verstak.browser-inbox.view', title: 'Browser Inbox', icon: 'inbox', component: 'BrowserInboxView' }],
sidebarItems: [{ id: 'verstak.browser-inbox.sidebar', title: 'Browser', icon: 'inbox', view: 'verstak.browser-inbox.view', position: 30 }], sidebarItems: [{ id: 'verstak.browser-inbox.sidebar', title: 'Browser Inbox', icon: 'inbox', view: 'verstak.browser-inbox.view', position: 30 }],
workspaceItems: [{ id: 'verstak.browser-inbox.workspace', title: 'Browser', icon: 'inbox', component: 'BrowserInboxView' }] workspaceItems: [{ id: 'verstak.browser-inbox.workspace', title: 'Browser Inbox', icon: 'inbox', component: 'BrowserInboxView' }]
} }
}, },
rootPath: '/tmp/verstak-test/plugins/browser-inbox', rootPath: '/tmp/verstak-test/plugins/browser-inbox',
@ -271,7 +265,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: 'Deal-scoped vault text search provider.', description: 'Workspace-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'],
@ -297,7 +291,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
'verstak.sync': 'Синхронизация', 'verstak.sync': 'Синхронизация',
'verstak.activity': 'Активность', 'verstak.activity': 'Активность',
'verstak.journal': 'Журнал', 'verstak.journal': 'Журнал',
'verstak.browser-inbox': 'Браузер', 'verstak.browser-inbox': 'Входящие из браузера',
'verstak.search': 'Поиск', 'verstak.search': 'Поиск',
'verstak.trash': 'Корзина', 'verstak.trash': 'Корзина',
'verstak.todo': 'Задачи', 'verstak.todo': 'Задачи',
@ -365,9 +359,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
var pluginSettings = { var pluginSettings = {
'verstak.platform-test': { savedText: 'initial value' } 'verstak.platform-test': { savedText: 'initial value' }
}; };
var pluginNotifications = {};
var pluginData = {}; var pluginData = {};
var secretRecords = makeDefaultSecretRecords();
var vaultFiles = makeDefaultVaultFiles(); var vaultFiles = makeDefaultVaultFiles();
var externalOpens = []; var externalOpens = [];
var trashEntries = []; var trashEntries = [];
@ -412,33 +404,12 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
return JSON.parse(JSON.stringify(value)); return JSON.parse(JSON.stringify(value));
} }
function makeDefaultSecretRecords() {
return [
{
id: 'first.secret',
title: 'First secret',
username: 'first-user',
value: 'first-value',
scope: { kind: 'global' },
updatedAt: '2026-07-14T00:00:00Z'
},
{
id: 'target.secret',
title: 'Target secret',
username: 'target-user',
value: 'target-value',
scope: { kind: 'global' },
updatedAt: '2026-07-14T00:00:00Z'
}
];
}
function builtInWorkspaceTemplates() { function builtInWorkspaceTemplates() {
return [ return [
{ {
id: 'default', id: 'default',
name: 'General', name: 'General',
description: 'Everyday Deal with notes, files, journal, activity, and browser captures.', description: 'Everyday workspace 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 +427,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
{ {
id: 'writing', id: 'writing',
name: 'Writing', name: 'Writing',
description: 'Focused notes, files, and journal Deal for documentation and writing.', description: 'Focused notes, files, and journal workspace 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 +436,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
{ {
id: 'admin', id: 'admin',
name: 'Admin', name: 'Admin',
description: 'Infrastructure Deal with secrets, todos, and journal.', description: 'Infrastructure workspace 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 +445,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 Deal.', description: 'Only notes and files for a lightweight workspace.',
version: 1, version: 1,
workspaceTools: ['verstak.notes', 'verstak.files'], workspaceTools: ['verstak.notes', 'verstak.files'],
folders: ['Notes', 'Files'], folders: ['Notes', 'Files'],
@ -570,12 +541,11 @@ 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 Deal-scoped todo tracking with due and reminder metadata.', description: 'Global and workspace 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'],
requires: ['verstak/core/notifications/v1'], permissions: ['files.read', 'storage.namespace', 'ui.register'],
permissions: ['files.read', 'storage.namespace', 'ui.register', 'notifications.schedule'],
frontend: { entry: 'frontend/dist/index.js' }, frontend: { entry: 'frontend/dist/index.js' },
contributes: { contributes: {
views: [{ id: 'verstak.todo.view', title: 'Todos', icon: 'list-todo', component: 'TodoView' }], views: [{ id: 'verstak.todo.view', title: 'Todos', icon: 'list-todo', component: 'TodoView' }],
@ -598,22 +568,13 @@ 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 Deal-scoped secret manager.', description: 'Encrypted global and workspace-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'],
permissions: ['files.read', 'secrets.read', 'secrets.write', 'ui.register'], permissions: ['secrets.read', 'secrets.write', 'ui.register'],
frontend: { entry: 'frontend/src/index.js' }, frontend: { entry: 'frontend/dist/index.js' },
contributes: { contributes: {
views: [{ id: 'verstak.secrets.view', title: 'Secrets', icon: 'key-round', component: 'SecretsView' }],
sidebarItems: [{ id: 'verstak.secrets.sidebar', title: 'Secrets', icon: 'key-round', view: 'verstak.secrets.view', position: 45 }],
openProviders: [{
id: 'verstak.secrets.secret',
title: 'Secrets',
priority: 100,
component: 'SecretsView',
supports: [{ kind: 'secret', modes: ['view'] }]
}],
workspaceItems: [{ id: 'verstak.secrets.workspace', title: 'Secrets', icon: 'key-round', component: 'SecretsView' }] workspaceItems: [{ id: 'verstak.secrets.workspace', title: 'Secrets', icon: 'key-round', component: 'SecretsView' }]
} }
}, },
@ -717,7 +678,6 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
caps.push({ name: 'verstak/core/files/v1', description: 'Files API', pluginId: 'verstak-desktop', status: 'stable' }); caps.push({ name: 'verstak/core/files/v1', description: 'Files API', pluginId: 'verstak-desktop', status: 'stable' });
caps.push({ name: 'verstak/core/workbench/v1', description: 'Workbench routing', pluginId: 'verstak-desktop', status: 'stable' }); caps.push({ name: 'verstak/core/workbench/v1', description: 'Workbench routing', pluginId: 'verstak-desktop', status: 'stable' });
caps.push({ name: 'verstak/core/sync/v1', description: 'Sync API', pluginId: 'verstak-desktop', status: 'stable' }); caps.push({ name: 'verstak/core/sync/v1', description: 'Sync API', pluginId: 'verstak-desktop', status: 'stable' });
caps.push({ name: 'verstak/core/notifications/v1', description: 'Native notifications', pluginId: 'verstak-desktop', status: 'stable' });
for (var id in pluginStates) { for (var id in pluginStates) {
var s = pluginStates[id]; var s = pluginStates[id];
if (s.status === 'loaded' && s.enabled && s.manifest && s.manifest.provides) { if (s.status === 'loaded' && s.enabled && s.manifest && s.manifest.provides) {
@ -2456,7 +2416,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
} }
function title(capture) { function title(capture) {
return capture.title || capture.fileName || capture.url || capture.captureId || 'Untitled material'; return capture.title || capture.fileName || capture.url || capture.captureId || 'Untitled capture';
} }
function renderBrowserInbox(containerEl, props, api) { function renderBrowserInbox(containerEl, props, api) {
@ -2473,7 +2433,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
containerEl.innerHTML = ''; containerEl.innerHTML = '';
var root = el('div', { className: 'browser-inbox-root', 'data-plugin-id': PLUGIN_ID }); var root = el('div', { className: 'browser-inbox-root', 'data-plugin-id': PLUGIN_ID });
var toolbar = el('div', { className: 'browser-inbox-toolbar' }); var toolbar = el('div', { className: 'browser-inbox-toolbar' });
var titleEl = el('span', { className: 'browser-inbox-title', textContent: rootPath ? 'Browser · ' + rootPath : 'Browser' }); var titleEl = el('span', { className: 'browser-inbox-title', textContent: rootPath ? 'Browser Inbox · ' + rootPath : 'Browser Inbox' });
var countEl = el('span', { className: 'browser-inbox-count' }); var countEl = el('span', { className: 'browser-inbox-count' });
var statusEl = el('span', { className: 'browser-inbox-status' }); var statusEl = el('span', { className: 'browser-inbox-status' });
var filtersEl = el('div', { className: 'browser-inbox-filters' }); var filtersEl = el('div', { className: 'browser-inbox-filters' });
@ -2557,7 +2517,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
function renderWorkspaceFilterOptions() { function renderWorkspaceFilterOptions() {
if (rootPath) return; if (rootPath) return;
workspaceFilterEl.innerHTML = ''; workspaceFilterEl.innerHTML = '';
workspaceFilterEl.appendChild(option('', 'All Deals')); workspaceFilterEl.appendChild(option('', 'All workspaces'));
workspaceRoots().forEach(function (workspace) { workspaceRoots().forEach(function (workspace) {
workspaceFilterEl.appendChild(option(workspace, workspace)); workspaceFilterEl.appendChild(option(workspace, workspace));
}); });
@ -2630,7 +2590,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
}) : captures).map(function (capture) { return capture.captureId; }); }) : captures).map(function (capture) { return capture.captureId; });
captures = captures.filter(function (capture) { return ids.indexOf(capture.captureId) === -1; }); captures = captures.filter(function (capture) { return ids.indexOf(capture.captureId) === -1; });
selectedId = ''; selectedId = '';
statusText = rootPath ? 'Deal materials cleared' : 'Inbox cleared'; statusText = rootPath ? 'Workspace captures cleared' : 'Inbox cleared';
return persist(); return persist();
} }
@ -2672,7 +2632,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
listEl.appendChild(el('div', { listEl.appendChild(el('div', {
className: 'browser-inbox-empty', className: 'browser-inbox-empty',
textContent: captures.length === 0 textContent: captures.length === 0
? 'No browser materials yet. Send a page, selection, or link from the extension.' ? 'No browser captures yet. Keep this view open, then send a page, selection, link, or file from the browser extension.'
: 'No captures match the current filters.' : 'No captures match the current filters.'
})); }));
return; return;
@ -2717,7 +2677,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
el('div', { className: 'browser-inbox-meta-value', textContent: capture.domain || '-' }), el('div', { className: 'browser-inbox-meta-value', textContent: capture.domain || '-' }),
el('div', { className: 'browser-inbox-meta-label', textContent: 'Browser' }), el('div', { className: 'browser-inbox-meta-label', textContent: 'Browser' }),
el('div', { className: 'browser-inbox-meta-value', textContent: capture.browserName || capture.source || '-' }), el('div', { className: 'browser-inbox-meta-value', textContent: capture.browserName || capture.source || '-' }),
el('div', { className: 'browser-inbox-meta-label', textContent: 'Deal' }), el('div', { className: 'browser-inbox-meta-label', textContent: 'Workspace' }),
el('div', { className: 'browser-inbox-meta-value', textContent: capture.workspaceRootPath || 'Unassigned' }), el('div', { className: 'browser-inbox-meta-value', textContent: capture.workspaceRootPath || 'Unassigned' }),
el('div', { className: 'browser-inbox-meta-label', textContent: 'Status' }), el('div', { className: 'browser-inbox-meta-label', textContent: 'Status' }),
el('div', { className: 'browser-inbox-meta-value', textContent: capture.processed ? 'Processed' : 'Unprocessed' }) el('div', { className: 'browser-inbox-meta-value', textContent: capture.processed ? 'Processed' : 'Unprocessed' })
@ -3368,43 +3328,6 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
GetPluginLocalization: function (pluginId, locale) { GetPluginLocalization: function (pluginId, locale) {
return Promise.resolve([mockPluginCatalog(pluginId, locale), '']); return Promise.resolve([mockPluginCatalog(pluginId, locale), '']);
}, },
PluginSecretsStatus: function () {
return Promise.resolve([{ initialized: true, unlocked: true }, '']);
},
PluginSecretsUnlock: function () {
return Promise.resolve('');
},
PluginSecretsList: function () {
return Promise.resolve([secretRecords.map(function (record) {
var listed = cloneJson(record);
delete listed.value;
return listed;
}), '']);
},
PluginSecretsRead: function (_pluginId, secretID) {
var record = secretRecords.find(function (item) { return item.id === secretID; });
if (!record) return Promise.resolve([{}, 'not-found: secret ' + secretID]);
return Promise.resolve([cloneJson(record), '']);
},
PluginSecretsWrite: function (_pluginId, nextRecord) {
var record = Object.assign({}, nextRecord || {});
if (!record.id) return Promise.resolve([{}, 'secret id is required']);
record.scope = record.scope || { kind: 'global' };
record.updatedAt = new Date().toISOString();
var index = secretRecords.findIndex(function (item) { return item.id === record.id; });
if (index === -1) secretRecords.push(record);
else secretRecords[index] = record;
return Promise.resolve([cloneJson(record), '']);
},
PluginSecretsDelete: function (_pluginId, secretID) {
secretRecords = secretRecords.filter(function (record) { return record.id !== secretID; });
return Promise.resolve('');
},
PluginSecretsCopyLink: function (_pluginId, secretID) {
var record = secretRecords.find(function (item) { return item.id === secretID; });
if (!record) return Promise.resolve(['', 'not-found: secret ' + secretID]);
return Promise.resolve(['[' + (record.title || record.id) + '](verstak-secret://' + encodeURIComponent(record.id) + ')', '']);
},
ReadPluginSettings: function (pluginId) { ReadPluginSettings: function (pluginId) {
return Promise.resolve([Object.assign({}, pluginSettings[pluginId] || {}), '']); return Promise.resolve([Object.assign({}, pluginSettings[pluginId] || {}), '']);
}, },
@ -3412,22 +3335,8 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
pluginSettings[pluginId] = Object.assign({}, settings || {}); pluginSettings[pluginId] = Object.assign({}, settings || {});
return Promise.resolve(''); return Promise.resolve('');
}, },
ReadPluginSetting: function (pluginId, key) { ReadPluginSetting: function () { return Promise.resolve(null); },
return Promise.resolve([pluginSettings[pluginId] && pluginSettings[pluginId][key], '']); WritePluginSetting: function () { return Promise.resolve(null); },
},
WritePluginSetting: function (pluginId, key, value) {
pluginSettings[pluginId] = pluginSettings[pluginId] || {};
pluginSettings[pluginId][key] = value;
return Promise.resolve('');
},
ReplacePluginNotifications: function (pluginId, items) {
pluginNotifications[pluginId] = Array.isArray(items) ? items.slice() : [];
return Promise.resolve('');
},
ClearPluginNotifications: function (pluginId) {
delete pluginNotifications[pluginId];
return Promise.resolve('');
},
ReadPluginDataJSON: function (pluginId, name) { ReadPluginDataJSON: function (pluginId, name) {
var data = (pluginData[pluginId] && pluginData[pluginId][name]) || {}; var data = (pluginData[pluginId] && pluginData[pluginId][name]) || {};
return Promise.resolve([Object.assign({}, data), '']); return Promise.resolve([Object.assign({}, data), '']);
@ -3546,8 +3455,8 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
if (pluginId === 'verstak.platform-test' && assetPath === 'frontend/dist/index.js') { if (pluginId === 'verstak.platform-test' && assetPath === 'frontend/dist/index.js') {
return Promise.resolve(platformTestBundle()); return Promise.resolve(platformTestBundle());
} }
if (pluginId === 'verstak.default-editor') { if (pluginId === 'verstak.default-editor' && assetPath === 'frontend/dist/index.js') {
return Promise.resolve(defaultEditorSource); return Promise.resolve(defaultEditorBundle());
} }
if (pluginId === 'verstak.files' && assetPath === 'frontend/dist/index.js') { if (pluginId === 'verstak.files' && assetPath === 'frontend/dist/index.js') {
return Promise.resolve(filesPluginBundle()); return Promise.resolve(filesPluginBundle());
@ -3558,20 +3467,20 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
if (pluginId === 'verstak.notes' && assetPath === 'frontend/dist/index.js') { if (pluginId === 'verstak.notes' && assetPath === 'frontend/dist/index.js') {
return Promise.resolve(simplePluginBundle('verstak.notes', 'NotesView', 'notes-root', 'Notes')); return Promise.resolve(simplePluginBundle('verstak.notes', 'NotesView', 'notes-root', 'Notes'));
} }
if (pluginId === 'verstak.activity') { if (pluginId === 'verstak.activity' && assetPath === 'frontend/dist/index.js') {
return Promise.resolve(activitySource); return Promise.resolve(activityBundle());
} }
if (pluginId === 'verstak.journal' && assetPath === 'frontend/dist/index.js') { if (pluginId === 'verstak.journal' && assetPath === 'frontend/dist/index.js') {
return Promise.resolve(journalSource); return Promise.resolve(journalBundle());
} }
if (pluginId === 'verstak.browser-inbox' && assetPath === 'frontend/dist/index.js') { if (pluginId === 'verstak.browser-inbox' && assetPath === 'frontend/dist/index.js') {
return Promise.resolve(browserInboxBundle()); return Promise.resolve(browserInboxBundle());
} }
if (pluginId === 'verstak.todo' && assetPath === 'frontend/dist/index.js') { if (pluginId === 'verstak.todo' && assetPath === 'frontend/dist/index.js') {
return Promise.resolve(todoSource); return Promise.resolve(todoBundle());
} }
if (pluginId === 'verstak.secrets') { if (pluginId === 'verstak.secrets' && assetPath === 'frontend/dist/index.js') {
return Promise.resolve(secretsSource); return Promise.resolve(simplePluginBundle('verstak.secrets', 'SecretsView', 'secrets-root', 'Secrets'));
} }
if (pluginId === 'verstak.search' && assetPath === 'frontend/dist/index.js') { if (pluginId === 'verstak.search' && assetPath === 'frontend/dist/index.js') {
return Promise.resolve(searchPluginBundle()); return Promise.resolve(searchPluginBundle());
@ -4104,7 +4013,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: 'Deal-scoped notes manager.', description: 'Workspace-scoped notes manager.',
source: 'official', source: 'official',
icon: 'edit', icon: 'edit',
provides: ['verstak/notes/v1'], provides: ['verstak/notes/v1'],
@ -4151,7 +4060,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: 'Deal-scoped activity log for public plugin events.', description: 'Workspace-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,11 +4084,11 @@ 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: 'Deal-scoped journal with user-authored entries and optional Activity links.', description: 'Workspace-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'],
permissions: ['events.publish', 'files.read', 'storage.namespace', 'ui.register'], permissions: ['storage.namespace', 'ui.register'],
frontend: { entry: 'frontend/dist/index.js' }, frontend: { entry: 'frontend/dist/index.js' },
contributes: { contributes: {
views: [{ id: 'verstak.journal.view', title: 'Journal', icon: 'book-open', component: 'JournalView' }], views: [{ id: 'verstak.journal.view', title: 'Journal', icon: 'book-open', component: 'JournalView' }],
@ -4196,19 +4105,19 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
manifest: { manifest: {
schemaVersion: 1, schemaVersion: 1,
id: 'verstak.browser-inbox', id: 'verstak.browser-inbox',
name: 'Browser', name: 'Browser Inbox',
version: '0.1.0', version: '0.1.0',
apiVersion: '0.1.0', apiVersion: '0.1.0',
description: 'Global browser materials with explicit Deal assignment.', description: 'Global browser capture queue with explicit workspace assignment.',
source: 'official', source: 'official',
icon: 'inbox', icon: 'inbox',
provides: ['browser.inbox'], provides: ['browser.inbox'],
permissions: ['events.subscribe', 'files.read', 'storage.namespace', 'ui.register'], permissions: ['events.subscribe', 'files.read', 'storage.namespace', 'ui.register'],
frontend: { entry: 'frontend/dist/index.js' }, frontend: { entry: 'frontend/dist/index.js' },
contributes: { contributes: {
views: [{ id: 'verstak.browser-inbox.view', title: 'Browser', icon: 'inbox', component: 'BrowserInboxView' }], views: [{ id: 'verstak.browser-inbox.view', title: 'Browser Inbox', icon: 'inbox', component: 'BrowserInboxView' }],
sidebarItems: [{ id: 'verstak.browser-inbox.sidebar', title: 'Browser', icon: 'inbox', view: 'verstak.browser-inbox.view', position: 30 }], sidebarItems: [{ id: 'verstak.browser-inbox.sidebar', title: 'Browser Inbox', icon: 'inbox', view: 'verstak.browser-inbox.view', position: 30 }],
workspaceItems: [{ id: 'verstak.browser-inbox.workspace', title: 'Browser', icon: 'inbox', component: 'BrowserInboxView' }] workspaceItems: [{ id: 'verstak.browser-inbox.workspace', title: 'Browser Inbox', icon: 'inbox', component: 'BrowserInboxView' }]
} }
}, },
rootPath: '/tmp/verstak-test/plugins/browser-inbox', rootPath: '/tmp/verstak-test/plugins/browser-inbox',
@ -4225,7 +4134,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: 'Deal-scoped vault text search provider.', description: 'Workspace-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'],
@ -4255,8 +4164,6 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
workbenchPreferences = {}; workbenchPreferences = {};
openedResources = []; openedResources = [];
pluginSettings = { 'verstak.platform-test': { savedText: 'initial value' } }; pluginSettings = { 'verstak.platform-test': { savedText: 'initial value' } };
pluginNotifications = {};
secretRecords = makeDefaultSecretRecords();
vaultFiles = makeDefaultVaultFiles(); vaultFiles = makeDefaultVaultFiles();
externalOpens = []; externalOpens = [];
trashEntries = []; trashEntries = [];
@ -4276,9 +4183,8 @@ 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, source) { addSyntheticPlugins: function (count) {
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] = {
@ -4291,7 +4197,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: pluginSource, source: 'test',
provides: ['verstak/synthetic-layout-' + i + '/v1'], provides: ['verstak/synthetic-layout-' + i + '/v1'],
requires: [], requires: [],
optionalRequires: [], optionalRequires: [],
@ -4311,7 +4217,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: pluginSource }); vaultPluginState.desiredPlugins.push({ id: id, version: '0.0.' + i, source: 'test' });
} }
} }
}, },

View File

@ -1,36 +0,0 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const files = [
'src/lib/shell/CommandPalette.svelte',
'src/lib/plugin-manager/PluginManager.svelte',
'src/lib/shell/VaultSelection.svelte',
'src/lib/shell/WorkspaceTree.svelte',
'src/lib/plugin-host/PluginBundleHost.svelte',
];
const patterns = [
/(?:^|\n)\s*(?:error|localError|createError)\s*=\s*(?:err|String\()/,
/(?:^|\n)\s*error\s*=\s*['"].*['"]\s*\+\s*(?:err|String\()/,
/tr\([^\n]*\{\s*error\s*:/,
];
const violations = [];
for (const relativePath of files) {
const source = fs.readFileSync(path.join(root, relativePath), 'utf8');
for (const pattern of patterns) {
if (pattern.test(source)) violations.push(relativePath);
}
}
if (violations.length) {
throw new Error(`Technical backend errors reach desktop UI in: ${[...new Set(violations)].join(', ')}`);
}
const bundleHost = fs.readFileSync(path.join(root, 'src/lib/plugin-host/PluginBundleHost.svelte'), 'utf8');
if (!/<p class="error-message">\{errorText \|\| tr\('bundle\.unknownError'\)\}<\/p>\s*<details class="error-details">/.test(bundleHost)) {
throw new Error('Plugin bundle errors must keep the user-facing message outside expandable technical details');
}
console.log('desktop UI does not interpolate raw backend errors');

View File

@ -1,9 +0,0 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
const source = await readFile(new URL('../src/lib/shell/WorkspaceTree.svelte', import.meta.url), 'utf8');
assert.match(source, /\.workspace-create-field select\s*\{[^}]*appearance:\s*none/, 'workspace template select must hide the native arrow');
assert.match(source, /\.workspace-create-field select option\s*\{[^}]*background/, 'workspace template options must use the application surface');
console.log('workspace select style contract passed');

View File

@ -1,28 +0,0 @@
import en from '../src/lib/i18n/catalogs/en.js';
import ru from '../src/lib/i18n/catalogs/ru.js';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const forbidden = [
['English catalog', /\bworkspace(?:s)?\b/i],
['Russian catalog', /рабоч(?:ее|ие|ем|его|их)\s+пространств(?:о|а|е)?/i],
];
for (const [name, pattern] of forbidden) {
const violations = Object.entries(name === 'English catalog' ? en : ru)
.filter(([, value]) => pattern.test(value));
if (violations.length) {
throw new Error(`${name} still exposes workspace terminology: ${violations.map(([key]) => key).join(', ')}`);
}
}
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const overview = fs.readFileSync(path.join(root, 'src/lib/shell/TodaySurface.svelte'), 'utf8');
for (const phrase of ['Workspace opened', 'Workspace activity', 'Workspace overview note']) {
if (overview.includes(phrase)) {
throw new Error(`Overview exposes workspace terminology: ${phrase}`);
}
}
console.log('desktop user terminology uses Deal/Дело');

View File

@ -1,4 +1,3 @@
import { resolve } from 'node:path';
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte'; import { svelte } from '@sveltejs/vite-plugin-svelte';
@ -14,9 +13,6 @@ export default defineConfig(({ mode }) => {
server: { server: {
port: isTest ? 5174 : 5173, port: isTest ? 5174 : 5173,
strictPort: true, strictPort: true,
fs: {
allow: [resolve(__dirname, '..', '..')],
},
}, },
optimizeDeps: { optimizeDeps: {
include: [ include: [

View File

@ -1064,23 +1064,30 @@ func (a *App) ReloadPlugins() (int, string) {
// Unregister all non-core capabilities // Unregister all non-core capabilities
a.capRegistry.UnregisterAll() a.capRegistry.UnregisterAll()
// Re-register the same core capabilities as initial startup. Keeping this // Re-register core capabilities
// list in the capability package prevents a plugin reload from dropping a coreCaps := []string{
// required capability such as native notifications. "verstak/core/plugin-manager/v1",
if err := a.capRegistry.Register(capability.CorePluginID, capability.CorePlatformCapabilities()); err != nil { "verstak/core/capability-registry/v1",
"verstak/core/contribution-registry/v1",
"verstak/core/permissions/v1",
"verstak/core/events/v1",
"verstak/core/files/v1",
"verstak/core/workbench/v1",
}
if err := a.capRegistry.Register("verstak-desktop", coreCaps); err != nil {
log.Printf("[api] ReloadPlugins: failed to re-register core capabilities: %v", err) log.Printf("[api] ReloadPlugins: failed to re-register core capabilities: %v", err)
} }
// Re-register vault capability if vault is open // Re-register vault capability if vault is open
if a.vault != nil && a.vault.GetVaultStatus() == vault.StatusOpen { if a.vault != nil && a.vault.GetVaultStatus() == vault.StatusOpen {
if err := a.capRegistry.Register(capability.CorePluginID, []string{"verstak/core/vault/v1"}); err != nil { if err := a.capRegistry.Register("verstak-desktop", []string{"verstak/core/vault/v1"}); err != nil {
log.Printf("[api] ReloadPlugins: failed to re-register vault capability: %v", err) log.Printf("[api] ReloadPlugins: failed to re-register vault capability: %v", err)
} }
} }
// Re-register workspace capability if workspace is initialized // Re-register workspace capability if workspace is initialized
if a.workspace != nil && a.workspace.IsInitialized() { if a.workspace != nil && a.workspace.IsInitialized() {
if err := a.capRegistry.Register(capability.CorePluginID, []string{"verstak/core/workspace/v1"}); err != nil { if err := a.capRegistry.Register("verstak-desktop", []string{"verstak/core/workspace/v1"}); err != nil {
log.Printf("[api] ReloadPlugins: failed to re-register workspace capability: %v", err) log.Printf("[api] ReloadPlugins: failed to re-register workspace capability: %v", err)
} }
} }

View File

@ -225,19 +225,6 @@ func TestReplaceAndClearPluginNotificationsStayWithinPluginNamespace(t *testing.
} }
} }
func TestReloadPluginsRestoresNotificationCoreCapability(t *testing.T) {
app := &App{
capRegistry: capability.NewRegistry(),
contribRegistry: contribution.NewRegistry(),
}
app.ReloadPlugins()
if !app.capRegistry.Has("verstak/core/notifications/v1") {
t.Fatal("ReloadPlugins did not restore verstak/core/notifications/v1")
}
}
func TestDomReadyInitializesNotificationsBeforeScheduler(t *testing.T) { func TestDomReadyInitializesNotificationsBeforeScheduler(t *testing.T) {
oldInitialize := initializeNativeNotifications oldInitialize := initializeNativeNotifications
defer func() { initializeNativeNotifications = oldInitialize }() defer func() { initializeNativeNotifications = oldInitialize }()

View File

@ -78,10 +78,9 @@ type WindowState struct {
// Manager provides thread-safe access to app settings. // Manager provides thread-safe access to app settings.
type Manager struct { type Manager struct {
mu sync.RWMutex mu sync.RWMutex
config *Config config *Config
configPath string configPath string
onLanguageChanged func(string)
} }
// DefaultConfigPath returns the default path for app settings. // DefaultConfigPath returns the default path for app settings.
@ -231,32 +230,13 @@ 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)
err := m.saveLocked() return 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.

View File

@ -3,7 +3,6 @@ package appsettings
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"reflect"
"testing" "testing"
) )
@ -75,34 +74,6 @@ 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")

View File

@ -1,20 +0,0 @@
package capability
const CorePluginID = "verstak-desktop"
var platformCapabilities = []string{
"verstak/core/plugin-manager/v1",
"verstak/core/capability-registry/v1",
"verstak/core/contribution-registry/v1",
"verstak/core/permissions/v1",
"verstak/core/events/v1",
"verstak/core/files/v1",
"verstak/core/workbench/v1",
"verstak/core/notifications/v1",
}
// CorePlatformCapabilities returns a copy of the capabilities registered by
// the desktop before dynamic plugins are resolved.
func CorePlatformCapabilities() []string {
return append([]string(nil), platformCapabilities...)
}

View File

@ -3,16 +3,25 @@ package plugin
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"runtime"
"testing" "testing"
"github.com/verstak/verstak-desktop/internal/core/capability" "github.com/verstak/verstak-desktop/internal/core/capability"
) )
// registerCoreCapabilities registers the desktop's core capabilities on a registry. // coreCapabilities lists the 5 core capabilities that the platform registers
// before any plugins are loaded.
var coreCapabilities = []string{
"verstak/core/plugin-manager/v1",
"verstak/core/capability-registry/v1",
"verstak/core/contribution-registry/v1",
"verstak/core/permissions/v1",
"verstak/core/events/v1",
}
// registerCoreCapabilities registers the 5 core capabilities on a registry.
func registerCoreCapabilities(t *testing.T, reg *capability.Registry) { func registerCoreCapabilities(t *testing.T, reg *capability.Registry) {
t.Helper() t.Helper()
if err := reg.Register(capability.CorePluginID, capability.CorePlatformCapabilities()); err != nil { if err := reg.Register("verstak-core", coreCapabilities); err != nil {
t.Fatalf("failed to register core capabilities: %v", err) t.Fatalf("failed to register core capabilities: %v", err)
} }
} }
@ -67,43 +76,6 @@ func TestLifecycle_CoreCapabilitiesRegisteredBeforePlugins(t *testing.T) {
} }
} }
func TestBundledOfficialPluginRequirementsResolve(t *testing.T) {
plugins, errs := DiscoverPlugins([]string{bundledPluginDir(t)})
if len(errs) > 0 {
t.Fatalf("discover bundled plugins: %v", errs)
}
reg := capability.NewRegistry()
registerCoreCapabilities(t, reg)
ResolveLifecycle(plugins, reg, nil)
foundTodo := false
for _, loaded := range plugins {
if loaded.Manifest.ID == "verstak.todo" {
foundTodo = true
}
if loaded.Status == StatusMissingRequiredCapability {
t.Fatalf("bundled plugin %q is missing required capabilities: %s", loaded.Manifest.ID, loaded.Error)
}
}
if !foundTodo {
t.Fatal("bundled Todo manifest was not discovered")
}
}
func bundledPluginDir(t *testing.T) string {
t.Helper()
if configured := os.Getenv("VERSTAK_RELEASE_PLUGIN_DIR"); configured != "" {
return configured
}
_, sourceFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("could not resolve lifecycle test path")
}
return filepath.Join(filepath.Dir(sourceFile), "../../../plugins")
}
// TestLifecycle_MissingRequiredCapability verifies that when the required // TestLifecycle_MissingRequiredCapability verifies that when the required
// capability is NOT registered, CheckRequired reports it as missing and the // capability is NOT registered, CheckRequired reports it as missing and the
// plugin status should be set to StatusMissingRequiredCapability. // plugin status should be set to StatusMissingRequiredCapability.

View File

@ -1,16 +1,11 @@
// 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 ( import "sync"
"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.
@ -28,63 +23,11 @@ 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.
@ -92,22 +35,9 @@ 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 {
@ -117,16 +47,8 @@ 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")
c.mu.RLock() show := c.backend.AddMenuItem("Show Verstak", "Show the Verstak window")
labels := c.labels quit := c.backend.AddMenuItem("Quit", "Quit Verstak")
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)
} }
@ -137,17 +59,6 @@ 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 {

View File

@ -7,22 +7,12 @@ 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
@ -45,7 +35,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), title: title} item := &fakeMenuItem{clicked: make(chan struct{}, 1)}
b.items[title] = item b.items[title] = item
return item return item
} }
@ -99,38 +89,3 @@ 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)
}
}

View File

@ -39,15 +39,3 @@ 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)
}
}

20
main.go
View File

@ -86,8 +86,17 @@ func main() {
} }
// ─── Register Core Capabilities ───────────────────────── // ─── Register Core Capabilities ─────────────────────────
corePluginID := capability.CorePluginID corePluginID := "verstak-desktop"
coreCaps := capability.CorePlatformCapabilities() coreCaps := []string{
"verstak/core/plugin-manager/v1",
"verstak/core/capability-registry/v1",
"verstak/core/contribution-registry/v1",
"verstak/core/permissions/v1",
"verstak/core/events/v1",
"verstak/core/files/v1",
"verstak/core/workbench/v1",
"verstak/core/notifications/v1",
}
if err := capRegistry.Register(corePluginID, coreCaps); err != nil { if err := capRegistry.Register(corePluginID, coreCaps); err != nil {
log.Fatalf("[main] failed to register core capabilities: %v", err) log.Fatalf("[main] failed to register core capabilities: %v", err)
} }
@ -230,13 +239,6 @@ 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 {

View File

@ -1,13 +0,0 @@
#!/bin/sh
set -eu
if command -v update-desktop-database >/dev/null 2>&1; then
update-desktop-database -q /usr/share/applications || true
fi
if command -v gtk-update-icon-cache >/dev/null 2>&1; then
gtk-update-icon-cache -q -t -f /usr/share/icons/hicolor || true
fi
exit 0

View File

@ -10,4 +10,4 @@ export GST_PLUGIN_SYSTEM_PATH_1_0="$HERE/usr/lib/gstreamer-1.0"
export WEBKIT_EXEC_PATH="$HERE/usr/lib/webkit2gtk-4.1" export WEBKIT_EXEC_PATH="$HERE/usr/lib/webkit2gtk-4.1"
cd "$HERE/usr/bin" cd "$HERE/usr/bin"
exec "$HERE/usr/bin/verstak" "$@" exec "$HERE/usr/bin/verstak-desktop" "$@"

View File

@ -1,6 +0,0 @@
#!/usr/bin/env sh
set -eu
HERE="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
exec "$HERE/verstak-desktop" "$@"

View File

@ -2,10 +2,8 @@
Type=Application Type=Application
Name=Verstak Name=Verstak
Comment=Local-first workspace Comment=Local-first workspace
Exec=verstak %U Exec=verstak-desktop %U
Icon=verstak Icon=verstak
Terminal=false Terminal=false
Categories=Office; Categories=Office;Utility;
StartupNotify=true StartupNotify=true
X-Verstak-Desktop-Entry=true
X-AppImage-Name=Verstak

View File

@ -13,7 +13,6 @@ fi
echo "=== verstak desktop Linux amd64 bundle ===" echo "=== verstak desktop Linux amd64 bundle ==="
(cd "$OFFICIAL_PLUGINS" && ./scripts/build.sh) (cd "$OFFICIAL_PLUGINS" && ./scripts/build.sh)
"$ROOT/scripts/install-dev-plugins.sh" "$ROOT/scripts/install-dev-plugins.sh"
(cd "$ROOT" && go test ./internal/core/plugin -run TestBundledOfficialPluginRequirementsResolve -count=1)
"$ROOT/scripts/build.sh" "$ROOT/scripts/build.sh"
BINARY="$ROOT/build/bin/verstak-desktop" BINARY="$ROOT/build/bin/verstak-desktop"

View File

@ -52,7 +52,6 @@ if [[ ! -d "$WINDOWS_PLUGIN_DIST" ]]; then
echo "Windows plugin packages were not produced: $WINDOWS_PLUGIN_DIST" >&2 echo "Windows plugin packages were not produced: $WINDOWS_PLUGIN_DIST" >&2
exit 1 exit 1
fi fi
VERSTAK_RELEASE_PLUGIN_DIR="$WINDOWS_PLUGIN_DIST" go test ./internal/core/plugin -run TestBundledOfficialPluginRequirementsResolve -count=1
# Wails' -compiler option selects a Go binary, not a C compiler. Cross-CGO # Wails' -compiler option selects a Go binary, not a C compiler. Cross-CGO
# therefore has to be supplied through the standard Go environment instead. # therefore has to be supplied through the standard Go environment instead.

View File

@ -45,7 +45,6 @@ mkdir -p "$APPDIR/usr/bin" "$APPDIR/usr/lib" "$APPDIR/usr/share/applications" \
"$APPDIR/usr/share/icons/hicolor/scalable/apps" "$APPDIR/usr/share/icons/hicolor/scalable/apps"
install -m 755 "$ROOT/packaging/linux/AppRun" "$APPDIR/AppRun" install -m 755 "$ROOT/packaging/linux/AppRun" "$APPDIR/AppRun"
install -m 755 "$BUNDLE/verstak-desktop" "$APPDIR/usr/bin/verstak-desktop" install -m 755 "$BUNDLE/verstak-desktop" "$APPDIR/usr/bin/verstak-desktop"
install -m 755 "$ROOT/packaging/linux/verstak" "$APPDIR/usr/bin/verstak"
install -m 644 "$ROOT/packaging/linux/verstak.desktop" "$APPDIR/verstak.desktop" install -m 644 "$ROOT/packaging/linux/verstak.desktop" "$APPDIR/verstak.desktop"
install -m 644 "$ROOT/packaging/linux/verstak.desktop" "$APPDIR/usr/share/applications/verstak.desktop" install -m 644 "$ROOT/packaging/linux/verstak.desktop" "$APPDIR/usr/share/applications/verstak.desktop"
install -m 644 "$ROOT/packaging/linux/verstak.svg" "$APPDIR/verstak.svg" install -m 644 "$ROOT/packaging/linux/verstak.svg" "$APPDIR/verstak.svg"

View File

@ -28,7 +28,6 @@ mkdir -p "$STAGING/DEBIAN" "$STAGING/opt/verstak" "$STAGING/usr/bin" \
"$STAGING/usr/share/doc/verstak" "$STAGING/usr/share/doc/verstak"
sed "s/@VERSION@/$PACKAGE_VERSION/g" "$ROOT/packaging/deb/control" > "$STAGING/DEBIAN/control" sed "s/@VERSION@/$PACKAGE_VERSION/g" "$ROOT/packaging/deb/control" > "$STAGING/DEBIAN/control"
install -m 755 "$ROOT/packaging/deb/postinst" "$STAGING/DEBIAN/postinst"
install -m 755 "$ROOT/packaging/deb/verstak" "$STAGING/usr/bin/verstak" install -m 755 "$ROOT/packaging/deb/verstak" "$STAGING/usr/bin/verstak"
install -m 755 "$BUNDLE/verstak-desktop" "$STAGING/opt/verstak/verstak-desktop" install -m 755 "$BUNDLE/verstak-desktop" "$STAGING/opt/verstak/verstak-desktop"
install -m 644 "$BUNDLE/README.md" "$BUNDLE/LICENSE" "$STAGING/usr/share/doc/verstak/" install -m 644 "$BUNDLE/README.md" "$BUNDLE/LICENSE" "$STAGING/usr/share/doc/verstak/"

View File

@ -15,16 +15,6 @@ done
grep -Fq 'dpkg-deb' "$ROOT/scripts/package-deb.sh" grep -Fq 'dpkg-deb' "$ROOT/scripts/package-deb.sh"
grep -Fq -- '--build' "$ROOT/scripts/package-deb.sh" grep -Fq -- '--build' "$ROOT/scripts/package-deb.sh"
grep -Fq 'packaging/deb/control' "$ROOT/scripts/package-deb.sh" grep -Fq 'packaging/deb/control' "$ROOT/scripts/package-deb.sh"
grep -Fq 'packaging/deb/postinst' "$ROOT/scripts/package-deb.sh"
test -x "$ROOT/packaging/deb/postinst"
sh -n "$ROOT/packaging/deb/postinst"
grep -Fxq 'Exec=verstak %U' "$ROOT/packaging/linux/verstak.desktop"
grep -Fxq 'X-Verstak-Desktop-Entry=true' "$ROOT/packaging/linux/verstak.desktop"
grep -Fxq 'Categories=Office;' "$ROOT/packaging/linux/verstak.desktop"
grep -Fq 'update-desktop-database' "$ROOT/packaging/deb/postinst"
grep -Fq 'gtk-update-icon-cache' "$ROOT/packaging/deb/postinst"
grep -Fq 'packaging/linux/verstak' "$ROOT/scripts/package-appimage.sh"
grep -Fq 'usr/bin/verstak' "$ROOT/packaging/linux/AppRun"
grep -Fq 'libwebkit2gtk-4.1-0' "$ROOT/packaging/deb/control" grep -Fq 'libwebkit2gtk-4.1-0' "$ROOT/packaging/deb/control"
grep -Fq 'libayatana-appindicator3-1' "$ROOT/packaging/deb/control" grep -Fq 'libayatana-appindicator3-1' "$ROOT/packaging/deb/control"
grep -Fq 'appimagetool' "$ROOT/scripts/package-appimage.sh" grep -Fq 'appimagetool' "$ROOT/scripts/package-appimage.sh"
@ -51,7 +41,5 @@ grep -Fq 'package-appimage.sh' "$ROOT/scripts/release.sh"
grep -Fq 'package-windows-portable.sh' "$ROOT/scripts/release.sh" grep -Fq 'package-windows-portable.sh' "$ROOT/scripts/release.sh"
git -C "$ROOT" check-ignore -q verstak-desktop-res.syso git -C "$ROOT" check-ignore -q verstak-desktop-res.syso
grep -Fq 'chmod -R a+rX' "$ROOT/scripts/build-linux-bundle.sh" grep -Fq 'chmod -R a+rX' "$ROOT/scripts/build-linux-bundle.sh"
grep -Fq 'TestBundledOfficialPluginRequirementsResolve' "$ROOT/scripts/build-linux-bundle.sh"
grep -Fq 'TestBundledOfficialPluginRequirementsResolve' "$ROOT/scripts/build-windows.sh"
echo "desktop package script contracts passed" echo "desktop package script contracts passed"

View File

@ -47,10 +47,6 @@ DEBUG_MODE_STATUS=0
(cd "$ROOT" && node --experimental-vm-modules frontend/tests/debug-mode-test.mjs) || DEBUG_MODE_STATUS=$? (cd "$ROOT" && node --experimental-vm-modules frontend/tests/debug-mode-test.mjs) || DEBUG_MODE_STATUS=$?
report "session-only debug mode" "$DEBUG_MODE_STATUS" report "session-only debug mode" "$DEBUG_MODE_STATUS"
SELECT_STYLE_STATUS=0
(cd "$ROOT" && node frontend/tests/select-styles-test.mjs) || SELECT_STYLE_STATUS=$?
report "workspace select styles" "$SELECT_STYLE_STATUS"
# ── Frontend tests ── # ── Frontend tests ──
echo "[frontend]" echo "[frontend]"
if ensure_npm_deps "$ROOT/frontend"; then if ensure_npm_deps "$ROOT/frontend"; then