Compare commits

..

No commits in common. "main" and "feature/mouse-back-forward" have entirely different histories.

179 changed files with 1369 additions and 25760 deletions

6
.gitignore vendored
View File

@ -1,10 +1,8 @@
# .gitignore
frontend/node_modules/
frontend/dist/
build/
verstak-desktop-res.syso
build/bin/verstak-desktop
smoke-platform
plugins/
vendor/
frontend/e2e-results/
release/
\nfrontend/e2e-results/

17
LICENSE
View File

@ -1,17 +0,0 @@
Verstak Desktop
Copyright (C) 2026 Verstak contributors
This program is free software: you can redistribute it and/or modify it under
the terms of the GNU Affero General Public License as published by the Free
Software Foundation, either version 3 of the License, or (at your option) any
later version.
This program is distributed in the hope that it will be useful, but WITHOUT
ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
details.
The complete license text is available at:
https://www.gnu.org/licenses/agpl-3.0.html
SPDX-License-Identifier: AGPL-3.0-or-later

342
README.md
View File

@ -1,341 +1,3 @@
<div align="center">
# verstak-desktop
<img src="packaging/linux/verstak.svg" width="112" alt="Verstak logo">
# Verstak
### Keep the context of your work together — locally.
Files, notes, links, browser captures, activity and work history
in one extensible local-first workbench.
**English** · [Русский](README.ru.md)
[![Release](https://img.shields.io/github/v/release/mirivlad/verstak?include_prereleases\&label=release)](https://github.com/mirivlad/verstak/releases)
![Status](https://img.shields.io/badge/status-alpha-orange)
![Platforms](https://img.shields.io/badge/platform-Linux%20%7C%20Windows-blue)
[![License](https://img.shields.io/github/license/mirivlad/verstak)](LICENSE)
[Download](https://github.com/mirivlad/verstak/releases/latest) ·
[Documentation](https://github.com/mirivlad/verstak-docs) ·
[Report an issue](https://github.com/mirivlad/verstak/issues)
</div>
> [!WARNING]
> Verstak is currently **alpha software**. APIs, storage formats and packaging may change before the first stable release. Use a test vault and keep backups of important data.
## What is Verstak?
Verstak is a local-first workbench that keeps the context around your work in one place.
A **Deal** can represent almost anything:
* a software project;
* a client or customer;
* a server or infrastructure site;
* a device under repair;
* an article or research topic;
* a training course;
* a personal long-term project.
Normally, information about such work is scattered across directories, notes, browser tabs, task managers, password managers, terminal history and memory.
Verstak brings it together in a local **vault** that remains under your control.
No account, cloud service or sync server is required for local 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. |
| --- | --- |
| ![Current Verstak Overview in a test vault](docs/screenshots/overview-en.png) | ![Current Verstak Deal files in a test vault](docs/screenshots/files-en.png) |
| Notes: keep Markdown notes next to the Deal 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) |
## Main features
| Feature | Description |
| ------------------------ | -------------------------------------------------------------------------------------------- |
| **Deals** | Organize files, notes and activity around durable projects, clients and other areas of work. |
| **Files** | Browse and manage ordinary files stored inside your vault. |
| **Notes** | Create Markdown notes, overview pages and links between related information. |
| **Overview** | Quickly return to recent work and see what may need attention. |
| **Browser Inbox** | Send pages, links, selections and files from a browser into Verstak. |
| **Activity and Journal** | Reconstruct work sessions and turn selected activity into journal entries. |
| **Todo** | Keep optional task lists inside individual Deals and across the whole vault. |
| **Search** | Search across notes, files and supported plugin data. |
| **Trash** | Restore deleted items or remove them permanently from one central location. |
| **Secrets** | Keep credentials and access information connected to the relevant Deal. |
| **Templates** | Create repeatable Deal structures for common types of work. |
| **Plugins** | Add, replace or disable tools without turning Verstak into one rigid monolithic application. |
| **Optional sync** | Synchronize vaults between devices using a self-hosted Verstak Sync Server. |
## Download and install
Download the latest build from the
[GitHub Releases page](https://github.com/mirivlad/verstak/releases/latest).
Release packages already include the matching official plugins.
| System | Download | Installation |
| --------------------------------- | ----------------------------------------- | --------------------------------------------- |
| Debian 13 / Ubuntu 24.04 or newer | `verstak_<version>_amd64.deb` | Install through APT. |
| Other x86_64 Linux distributions | `verstak-linux-x86_64-<version>.AppImage` | Make the file executable and run it. |
| Windows 10/11 x64 | `verstak-windows-amd64-<version>.zip` | Extract the archive and launch `Verstak.cmd`. |
### Debian and Ubuntu
```bash
sudo apt install ./verstak_*_amd64.deb
```
Launch Verstak from the application menu or run:
```bash
verstak
```
### AppImage
```bash
chmod +x verstak-linux-x86_64-*.AppImage
./verstak-linux-x86_64-*.AppImage
```
On a system without FUSE support:
```bash
APPIMAGE_EXTRACT_AND_RUN=1 ./verstak-linux-x86_64-*.AppImage
```
### Windows portable version
1. Download `verstak-windows-amd64-<version>.zip`.
2. Extract it to a local directory.
3. Run `Verstak.cmd`.
Do not run Verstak directly from inside the ZIP archive or from a network share.
Verstak uses the Microsoft WebView2 Runtime. It is already included with Windows 11 and most current Windows 10 installations. If Verstak does not start, install the
[Microsoft WebView2 Runtime x64](https://go.microsoft.com/fwlink/p/?LinkId=2124701).
## Background and tray mode
After the tray icon is ready, closing the main window keeps Verstak running in the system tray. One left click restores and focuses the window; a right click opens the native **Show Verstak** and **Quit** menu. **Quit** exits the application completely. If the tray cannot start, closing the window exits normally rather than leaving an unreachable process. Scheduled Todo reminders continue while the window is hidden.
### Verify a download
Each release includes a `SHA256SUMS` file.
On Linux:
```bash
sha256sum -c SHA256SUMS --ignore-missing
```
## First start
### 1. Create or open a vault
A **vault** is the root directory where Verstak stores your work.
Choose a writable local directory. For example:
```text
Documents/
└── Verstak/
```
Your files and notes remain ordinary files that can be accessed without Verstak.
### 2. Create your first Deal
Inside the vault, create a Deal for a project, client, server, device or another area of work.
For example:
```text
Home server
Customer Alpha
Verstak development
3D printer
Training course
```
### 3. Add context
Open the Deal and add the information needed to return to it later:
* notes and decisions;
* documents and source files;
* useful links;
* access information;
* journal entries;
* tasks;
* materials captured from the browser.
### 4. Return through Overview
The Overview screen shows recent changes, unfinished captures, activity and other useful entry points back into your work.
## How Verstak stores data
Verstak follows several principles:
* your vault is local and works without an account or internet connection;
* files and notes remain readable outside the application;
* synchronization is optional and is not the source of truth;
* the user should understand where data is stored and what happens to it;
* application tools are provided by plugins rather than being permanently embedded into the core.
The desktop application loads plugins from a `plugins/` directory located beside the executable.
## Browser integration
The optional browser extension can send the following into Browser Inbox:
* the current page;
* selected text;
* a link;
* a downloaded or selected file.
Repository:
[mirivlad/verstak-browser-extension](https://github.com/mirivlad/verstak-browser-extension)
To connect the extension:
1. Open Browser Inbox settings in Verstak.
2. Copy the Receiver URL and Pairing Token.
3. Paste them into the extension settings.
4. Save the settings and send a test page.
Passive domain activity tracking is disabled by default. When enabled, it sends bounded time totals by normalized domain. It does not send page contents, keystrokes or full browsing history.
## Optional synchronization
Local use does not require a server.
For synchronization between devices, deploy the optional self-hosted
[Verstak Sync Server](https://github.com/mirivlad/verstak-sync-server).
Each vault is connected separately. The local vault remains the primary copy of your data.
## Build from source
### Requirements
* Go 1.24 or newer;
* Node.js 20 or newer with npm;
* Python 3;
* Git;
* ImageMagick (`magick`) to generate the branded tray and application icons;
* Wails v2 build dependencies;
* WebKitGTK development packages on Linux.
See the
[Wails installation documentation](https://wails.io/docs/gettingstarted/installation/)
for distribution-specific dependencies.
### Clone the repositories
The desktop, SDK and official plugin repositories must be cloned as sibling directories:
```text
verstak-source/
├── verstak/
├── verstak-sdk/
└── verstak-official-plugins/
```
```bash
mkdir verstak-source
cd verstak-source
git clone https://github.com/mirivlad/verstak.git
git clone https://github.com/mirivlad/verstak-sdk.git
git clone https://github.com/mirivlad/verstak-official-plugins.git
```
### Build
```bash
cd verstak-sdk
./scripts/build.sh
cd ../verstak-official-plugins
./scripts/build.sh
cd ../verstak
./scripts/install-dev-plugins.sh
./scripts/build.sh
```
The resulting application will be located at:
```bash
./build/bin/verstak-desktop
```
Run it with additional diagnostics:
```bash
./build/bin/verstak-desktop --debug
```
## Build release packages locally
These commands create local artifacts in `release/`. They do not publish a GitHub Release.
### Debian package
```bash
./scripts/package-deb.sh v0.1.0-alpha.1
```
### AppImage
```bash
./scripts/package-appimage.sh v0.1.0-alpha.1
```
### Windows portable ZIP
The Windows archive can be cross-compiled on Linux using MinGW:
```bash
sudo apt install gcc-mingw-w64-x86-64 zip
./scripts/package-windows-portable.sh v0.1.0-alpha.1
```
## Project repositories
| Repository | Purpose |
| ---------------------------------------------------------------------------------- | ------------------------------------------------------ |
| [verstak](https://github.com/mirivlad/verstak) | Desktop application, core platform and UI shell |
| [verstak-official-plugins](https://github.com/mirivlad/verstak-official-plugins) | Official Files, Notes, Journal, Todo and other plugins |
| [verstak-sdk](https://github.com/mirivlad/verstak-sdk) | TypeScript SDK, schemas and plugin contracts |
| [verstak-browser-extension](https://github.com/mirivlad/verstak-browser-extension) | Browser capture and optional activity integration |
| [verstak-sync-server](https://github.com/mirivlad/verstak-sync-server) | Optional self-hosted synchronization server |
| [verstak-docs](https://github.com/mirivlad/verstak-docs) | Product and platform architecture documentation |
Compatible components should be built from the same release line.
## Development status
Verstak is under active development.
The current alpha is intended for testing, feedback and experimentation. Backward compatibility is not guaranteed until the first stable release.
Bug reports and feature discussions are welcome in
[GitHub Issues](https://github.com/mirivlad/verstak/issues).
## License
Copyright © 2026 Verstak contributors.
Verstak is licensed under the
[GNU Affero General Public License v3.0 or later](LICENSE).
Verstak Core Platform + UI Shell — plugin runtime, capability registry, vault API, event bus, sync client

View File

@ -1,340 +0,0 @@
<div align="center">
<img src="packaging/linux/verstak.svg" width="112" alt="Логотип Верстака">
# Верстак
### Рабочий контекст остаётся рядом — и хранится локально.
Файлы, заметки, ссылки, материалы из браузера, активность и история работы
в одной расширяемой локальной среде.
[English](README.md) · **Русский**
[![Релиз](https://img.shields.io/github/v/release/mirivlad/verstak?include_prereleases\&label=release)](https://github.com/mirivlad/verstak/releases)
![Статус](https://img.shields.io/badge/status-alpha-orange)
![Платформы](https://img.shields.io/badge/platform-Linux%20%7C%20Windows-blue)
[![Лицензия](https://img.shields.io/github/license/mirivlad/verstak)](LICENSE)
[Скачать](https://github.com/mirivlad/verstak/releases/latest) ·
[Документация](https://github.com/mirivlad/verstak-docs) ·
[Сообщить о проблеме](https://github.com/mirivlad/verstak/issues)
</div>
> [!WARNING]
> Верстак пока находится на стадии **alpha**. До первого стабильного выпуска могут меняться API, форматы хранения и состав пакетов. Для знакомства используйте тестовый vault и сохраняйте резервные копии важных данных.
## Что такое Верстак
Верстак — это local-first рабочая среда, которая удерживает в одном месте контекст вокруг ваших дел.
**Делом** может быть что угодно:
* программный проект;
* клиент или заказчик;
* сервер или площадка;
* ремонтируемое устройство;
* статья или исследование;
* учебный курс;
* личный долгосрочный проект.
Обычно информация о таком деле расползается по папкам, заметкам, браузерным вкладкам, менеджерам задач, хранилищам паролей, истории терминала и памяти.
Верстак собирает её в один локальный **vault**, который остаётся под вашим контролем.
Для локальной работы не нужны учётная запись, облачный сервис или собственный сервер.
## Верстак в работе
| Обзор: возвращает к делу и показывает недавнюю работу, входящие и то, что требует внимания. | Файлы: обычные папки и документы прямо внутри дела. |
| --- | --- |
| ![Актуальный экран «Обзор» Верстака в тестовом vault](docs/screenshots/overview-ru.png) | ![Актуальные файлы дела в тестовом vault Верстака](docs/screenshots/files-ru.png) |
| Заметки: Markdown-заметки остаются рядом с делом, которое они описывают. | Журнал: фиксирует завершённую работу и её контекст. |
| --- | --- |
| ![Актуальные заметки Верстака в тестовом vault](docs/screenshots/notes-ru.png) | ![Актуальный журнал Верстака в тестовом vault](docs/screenshots/journal-ru.png) |
## Основные возможности
| Возможность | Что она даёт |
| ------------------------ | ---------------------------------------------------------------------------------------------- |
| **Дела** | Объединяют файлы, заметки и историю работы вокруг проектов, клиентов и других направлений. |
| **Файлы** | Позволяют работать с обычными файлами, расположенными внутри vault. |
| **Заметки** | Markdown-заметки, обзорные страницы и связи между материалами. |
| **Обзор** | Помогает быстро вернуться к недавней работе и увидеть то, что требует внимания. |
| **Входящие из браузера** | Принимают страницы, ссылки, выделенный текст и файлы из браузера. |
| **Активность и журнал** | Помогают восстановить рабочие сессии и превратить выбранную активность в записи журнала. |
| **Задачи** | Необязательные списки задач внутри отдельных дел и во всём vault. |
| **Поиск** | Ищет по заметкам, файлам и данным поддерживаемых плагинов. |
| **Корзина** | Позволяет восстановить удалённое или окончательно удалить его из одного общего раздела. |
| **Секреты** | Хранит доступы и учётные данные рядом с делом, к которому они относятся. |
| **Шаблоны** | Создают повторяемую структуру для однотипных дел. |
| **Плагины** | Позволяют добавлять, заменять и отключать инструменты, не превращая Верстак в жёсткий монолит. |
| **Синхронизация** | Необязательно синхронизирует vault между устройствами через собственный сервер. |
## Скачивание и установка
Готовые сборки находятся на странице
[GitHub Releases](https://github.com/mirivlad/verstak/releases/latest).
В релизные пакеты уже входят совместимые версии официальных плагинов. Устанавливать их отдельно не требуется.
| Система | Какой файл скачать | Как установить |
| -------------------------------- | ---------------------------------------- | -------------------------------------------- |
| Debian 13 / Ubuntu 24.04 и новее | `verstak_<версия>_amd64.deb` | Установить через APT. |
| Другие дистрибутивы Linux x86_64 | `verstak-linux-x86_64-<версия>.AppImage` | Сделать файл исполняемым и запустить. |
| Windows 10/11 x64 | `verstak-windows-amd64-<версия>.zip` | Распаковать архив и запустить `Verstak.cmd`. |
### Debian и Ubuntu
```bash
sudo apt install ./verstak_*_amd64.deb
```
После установки запустите Верстак из меню приложений или командой:
```bash
verstak
```
### AppImage
```bash
chmod +x verstak-linux-x86_64-*.AppImage
./verstak-linux-x86_64-*.AppImage
```
Если в системе нет поддержки FUSE:
```bash
APPIMAGE_EXTRACT_AND_RUN=1 ./verstak-linux-x86_64-*.AppImage
```
### Портативная версия для Windows
1. Скачайте `verstak-windows-amd64-<версия>.zip`.
2. Распакуйте архив в локальную папку.
3. Запустите `Verstak.cmd`.
Не запускайте Верстак прямо из ZIP-архива или с сетевого диска.
Верстак использует Microsoft WebView2 Runtime. Он уже присутствует в Windows 11 и большинстве актуальных установок Windows 10. Если приложение не запускается, установите
[Microsoft WebView2 Runtime x64](https://go.microsoft.com/fwlink/p/?LinkId=2124701).
## Фоновая работа и трей
После готовности значка в трее закрытие главного окна не завершает Верстак. Один левый клик возвращает и фокусирует окно, правый клик открывает нативное меню **«Показать Верстак»** и **«Выйти»**. **«Выйти»** полностью завершает приложение. Если трей не удалось запустить, закрытие окна завершает приложение обычным образом и не оставляет недоступный процесс. Пока окно скрыто, напоминания Todo продолжают работать.
### Проверка скачанного файла
В каждый релиз входит файл `SHA256SUMS`.
Проверка в Linux:
```bash
sha256sum -c SHA256SUMS --ignore-missing
```
## Первый запуск
### 1. Создайте или откройте vault
**Vault** — это корневая папка, в которой Верстак хранит ваши дела.
Выберите доступный для записи локальный каталог. Например:
```text
Документы/
└── Верстак/
```
Файлы и заметки остаются обычными файлами. Их можно открыть и без Верстака.
### 2. Создайте первое дело
Внутри vault создайте дело для проекта, клиента, сервера, устройства или другого направления работы.
Например:
```text
Домашний сервер
Заказчик Альфа
Разработка Верстака
3D-принтер
Учебный курс
```
### 3. Соберите контекст
Добавьте в дело всё, что поможет вернуться к нему через неделю, месяц или год:
* заметки и принятые решения;
* документы и исходные файлы;
* полезные ссылки;
* данные для доступа;
* записи журнала;
* задачи;
* материалы, отправленные из браузера.
### 4. Возвращайтесь через Обзор
Экран «Обзор» показывает недавние изменения, необработанные входящие, активность и другие подходящие точки для продолжения работы.
## Как Верстак хранит данные
Верстак придерживается нескольких принципов:
* vault хранится локально и доступен без учётной записи и интернета;
* файлы и заметки остаются читаемыми вне приложения;
* синхронизация является дополнением, а не источником истины;
* пользователь должен понимать, где находятся его данные и что с ними происходит;
* инструменты реализуются плагинами, а не навсегда встраиваются в ядро.
Настольное приложение загружает плагины из каталога `plugins/`, расположенного рядом с исполняемым файлом.
## Интеграция с браузером
Необязательное расширение позволяет отправлять во «Входящие из браузера»:
* текущую страницу;
* выделенный текст;
* ссылку;
* выбранный или скачанный файл.
Репозиторий расширения:
[mirivlad/verstak-browser-extension](https://github.com/mirivlad/verstak-browser-extension)
Чтобы подключить расширение:
1. Откройте настройки «Входящих из браузера» в Верстаке.
2. Скопируйте адрес приёмника и токен сопряжения.
3. Вставьте их в настройки расширения.
4. Сохраните настройки и отправьте тестовую страницу.
Пассивный учёт активности по доменам по умолчанию отключён. После включения расширение передаёт только ограниченные интервалы времени для нормализованных доменов. Оно не отправляет содержимое страниц, нажатия клавиш или полную историю посещений.
## Необязательная синхронизация
Для локального использования сервер не нужен.
Для синхронизации между устройствами можно развернуть собственный
[Verstak Sync Server](https://github.com/mirivlad/verstak-sync-server).
Каждый vault подключается отдельно. Основной копией данных остаётся локальный vault.
## Сборка из исходного кода
### Требования
* Go 1.24 или новее;
* Node.js 20 или новее и npm;
* Python 3;
* Git;
* ImageMagick (`magick`) для генерации фирменных значков трея и приложения;
* зависимости для сборки Wails v2;
* пакеты разработки WebKitGTK в Linux.
Зависимости для конкретного дистрибутива перечислены в
[документации Wails](https://wails.io/docs/gettingstarted/installation/).
### Клонирование репозиториев
Desktop, SDK и официальные плагины должны находиться в соседних каталогах:
```text
verstak-source/
├── verstak/
├── verstak-sdk/
└── verstak-official-plugins/
```
```bash
mkdir verstak-source
cd verstak-source
git clone https://github.com/mirivlad/verstak.git
git clone https://github.com/mirivlad/verstak-sdk.git
git clone https://github.com/mirivlad/verstak-official-plugins.git
```
### Сборка
```bash
cd verstak-sdk
./scripts/build.sh
cd ../verstak-official-plugins
./scripts/build.sh
cd ../verstak
./scripts/install-dev-plugins.sh
./scripts/build.sh
```
Готовое приложение появится по адресу:
```bash
./build/bin/verstak-desktop
```
Запуск с дополнительной диагностикой:
```bash
./build/bin/verstak-desktop --debug
```
## Локальная сборка релизных пакетов
Следующие команды создают пакеты в каталоге `release/`, но не публикуют GitHub Release.
### Пакет Debian
```bash
./scripts/package-deb.sh v0.1.0-alpha.1
```
### AppImage
```bash
./scripts/package-appimage.sh v0.1.0-alpha.1
```
### Портативный ZIP для Windows
Windows-версию можно собрать в Linux при помощи MinGW:
```bash
sudo apt install gcc-mingw-w64-x86-64 zip
./scripts/package-windows-portable.sh v0.1.0-alpha.1
```
## Репозитории проекта
| Репозиторий | Назначение |
| ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| [verstak](https://github.com/mirivlad/verstak) | Настольное приложение, ядро платформы и оболочка интерфейса |
| [verstak-official-plugins](https://github.com/mirivlad/verstak-official-plugins) | Официальные плагины файлов, заметок, журнала, задач и других инструментов |
| [verstak-sdk](https://github.com/mirivlad/verstak-sdk) | TypeScript SDK, схемы и контракты плагинов |
| [verstak-browser-extension](https://github.com/mirivlad/verstak-browser-extension) | Отправка материалов из браузера и необязательный учёт активности |
| [verstak-sync-server](https://github.com/mirivlad/verstak-sync-server) | Необязательный собственный сервер синхронизации |
| [verstak-docs](https://github.com/mirivlad/verstak-docs) | Продуктовая и архитектурная документация |
Совместимые компоненты следует собирать из одной релизной линии.
## Состояние разработки
Верстак активно развивается.
Текущая alpha предназначена для тестирования, обратной связи и экспериментов. До первого стабильного выпуска обратная совместимость не гарантируется.
Сообщения об ошибках и предложения принимаются в
[GitHub Issues](https://github.com/mirivlad/verstak/issues).
## Лицензия
Copyright © 2026 Verstak contributors.
Верстак распространяется на условиях
[GNU Affero General Public License v3.0 или новее](LICENSE).

View File

@ -85,21 +85,19 @@ Frontend bundles are mounted with a plugin-scoped API created by
- `capabilities.list/get/has`
- `commands.register/execute` for handlers declared in `contributes.commands`
- `events.publish/subscribe` using the bundled frontend event bus
- `files.list/metadata/readText/readBytes/writeText/createFolder/move/trash/listTrash/restoreTrash/deleteTrash/openExternal/showInFolder`
for canonical vault-relative slash paths guarded by `files.read`,
`files.write`, `files.delete`, and `files.openExternal`. Backslashes,
Windows absolute paths, UNC paths, traversal, `.verstak` variants, and
symlink read/write/move/trash/external-open operations are rejected. Text
read/write is UTF-8 only and limited to 2 MB for text reads; `readBytes`
returns base64 for regular files up to 8 MB.
- `files.list/metadata/readText/writeText/createFolder/move/trash` for
canonical vault-relative slash paths guarded by `files.read`, `files.write`,
and `files.delete`. Backslashes, Windows absolute paths, UNC paths,
traversal, `.verstak` variants, and symlink read/write/move/trash operations
are rejected. Text read/write is UTF-8 only and limited to 2 MB for reads.
- `workbench.openResource/editResource` for routing vault resources to
contributed `openProviders`. Plugins must declare `workbench.open`; this is a
policy/contract check. Files and Notes plugins call this API and do not import
a concrete editor plugin.
Editor/viewer plugins contribute providers with `contributes.openProviders`.
Workbench selects by resource kind, request mode, extension/mime, context
(`generic-text`, `generic-markdown`, `notes-markdown`), user preference, priority, then
Workbench selects by resource kind, extension/mime, context (`generic-text`,
`generic-markdown`, `notes-markdown`), user preference, priority, then
deterministic `pluginId/providerId` tie-break. If nothing matches, Workbench
shows `no-provider` fallback instead of a core editor.

View File

@ -25,28 +25,37 @@ These tests:
| File | Suite | Tests | Status |
|------|-------|-------|--------|
| `plugin-manager-disable-enable.spec.js` | A: Disable/Enable refresh | 4 | pass |
| `plugin-manager-disable-enable.spec.js` | A: Disable/Enable refresh | 4 | 3 pass, 1 fail* |
| `sidebar-opens-view.spec.js` | B: Sidebar → view routing | 3 | 3 pass |
| `reload-updates-state.spec.js` | C: Reload updates UI | 4 | pass |
| `reload-updates-state.spec.js` | C: Reload updates UI | 3 | 2 pass, 1 fail* |
## Resolved bugs covered by tests
\* Failing tests document **known bugs** (see below).
### Bug M5-1: Sidebar updates when plugin state changes
## Known bugs detected by tests
**Previous symptom:** After disabling a plugin in Plugin Manager, the sidebar
item for that plugin remained visible. Reloading after external mock state
changes also left stale sidebar items.
### Bug M5-1: Sidebar does not update when plugin state changes
**Current behavior:** `PluginManager.svelte` dispatches
`verstak:plugins-changed` after reload/enable/disable flows. `Sidebar.svelte`
listens for that event and re-fetches plugins, vault status, and contributions.
**Symptom:** After disabling a plugin in Plugin Manager, the sidebar item for
that plugin remains visible. After re-enabling, it stays visible (doesn't
disappear then reappear — it was never gone).
**Regression coverage:**
**Root cause:** `Sidebar.svelte` loads plugin/contribution data once in
`onMount` and stores it in local `sidebarItems`. When `PluginManager`
disables/enables a plugin and calls `ReloadPlugins`, the `PluginManager`
component re-fetches data, but `Sidebar` does not react to the change — it
still holds the stale list.
**Affected tests:**
- `A: Disable plugin: button changes to Enable, sidebar item disappears`
- `A: Disable → Enable full flow in sequence`
- `C: Reload after mock state change reflects new plugin status`
**Fix needed:** Sidebar must either:
1. Re-fetch contributions when it receives a custom event (e.g.
`verstak:plugins-reloaded`), or
2. Read plugin state reactively from a shared store that both
PluginManager and Sidebar subscribe to.
## What is NOT tested
### Real desktop GUI (WebKitGTK + Wails native shell)

View File

@ -181,18 +181,18 @@ type Entry struct {
- Create: `internal/core/files/path.go`
- Create: `internal/core/files/path_test.go`
- [x] Add `NormalizeVaultRelativePath(relative string) (string, error)`.
- [x] Reject absolute paths, null bytes, `..`, and empty file paths.
- [x] Preserve path case, including canonical `Notes`.
- [x] Add `IsReservedPath(relative string) bool` returning true for `.verstak`
- [ ] Add `NormalizeVaultRelativePath(relative string) (string, error)`.
- [ ] Reject absolute paths, null bytes, `..`, and empty file paths.
- [ ] Preserve path case, including canonical `Notes`.
- [ ] Add `IsReservedPath(relative string) bool` returning true for `.verstak`
and `.verstak/...`.
- [x] Add tests:
- [ ] Add tests:
`TestNormalizeRejectsAbsolutePath`,
`TestNormalizeRejectsTraversal`,
`TestNormalizeRejectsNullByte`,
`TestNormalizePreservesCase`,
`TestReservedPathPolicy`.
- [x] Run:
- [ ] Run:
```bash
go test ./internal/core/files
@ -207,21 +207,21 @@ Expected: all `internal/core/files` tests pass.
- Create: `internal/core/files/service.go`
- Create/modify: `internal/core/files/service_test.go`
- [x] Define `Service` with a vault dependency that can return the current vault
- [ ] Define `Service` with a vault dependency that can return the current vault
root and status.
- [x] Implement `List(relativeDir string) ([]Entry, error)`.
- [x] Implement `Metadata(relativePath string) (Entry, error)`.
- [x] Implement `ReadText(relativePath string) (string, error)`.
- [x] Implement `WriteText(relativePath, content string, overwrite bool) (Entry, error)`.
- [x] Implement `Mkdir(relativePath string) (Entry, error)`.
- [x] Implement `Move(fromRelativePath, toRelativePath string, overwrite bool) (Entry, error)`.
- [x] Implement `Trash(relativePath string) (Entry, error)`.
- [x] Use the shared path policy for every public method.
- [x] Block `.verstak` paths in every public method.
- [x] Add tests for closed vault, list, metadata, text read/write, mkdir, move,
- [ ] Implement `List(relativeDir string) ([]Entry, error)`.
- [ ] Implement `Metadata(relativePath string) (Entry, error)`.
- [ ] Implement `ReadText(relativePath string) (string, error)`.
- [ ] Implement `WriteText(relativePath, content string, overwrite bool) (Entry, error)`.
- [ ] Implement `Mkdir(relativePath string) (Entry, error)`.
- [ ] Implement `Move(fromRelativePath, toRelativePath string, overwrite bool) (Entry, error)`.
- [ ] Implement `Trash(relativePath string) (Entry, error)`.
- [ ] Use the shared path policy for every public method.
- [ ] Block `.verstak` paths in every public method.
- [ ] Add tests for closed vault, list, metadata, text read/write, mkdir, move,
trash, overwrite false conflict, overwrite true replace, and reserved path
rejection.
- [x] Run:
- [ ] Run:
```bash
go test ./internal/core/files
@ -236,13 +236,13 @@ Expected: all `internal/core/files` tests pass.
- Modify: `internal/core/files/service.go`
- Modify: `internal/core/files/service_test.go`
- [x] Write text content to a temp file in the target directory.
- [x] Rename the temp file into the final path only after successful write.
- [x] Remove temp file on write failure.
- [x] Add test `TestWriteTextIsAtomicOnFailure` using a controlled failing path
- [ ] Write text content to a temp file in the target directory.
- [ ] Rename the temp file into the final path only after successful write.
- [ ] Remove temp file on write failure.
- [ ] Add test `TestWriteTextIsAtomicOnFailure` using a controlled failing path
or permission-denied directory.
- [x] Add test `TestWriteTextDoesNotLeaveTempFile`.
- [x] Run:
- [ ] Add test `TestWriteTextDoesNotLeaveTempFile`.
- [ ] Run:
```bash
go test ./internal/core/files
@ -258,12 +258,12 @@ Expected: all `internal/core/files` tests pass.
- Modify: `main.go`
- Modify: `internal/api/app_test.go`
- [x] Register permissions: `files.read`, `files.write`, `files.delete`.
- [x] Register core capability `verstak/core/files/v1` when vault services are
- [ ] Register permissions: `files.read`, `files.write`, `files.delete`.
- [ ] Register core capability `verstak/core/files/v1` when vault services are
initialized.
- [x] Add API guard tests proving each Files bridge method rejects plugins that
- [ ] Add API guard tests proving each Files bridge method rejects plugins that
are missing the required permission.
- [x] Run:
- [ ] Run:
```bash
go test ./internal/core/permissions ./internal/api
@ -282,13 +282,13 @@ Expected: permission registry and API tests pass.
- Modify after Wails generation or by hand if generation is unavailable:
`frontend/wailsjs/go/api/App.js`
- [x] Add `files.Service` to `api.App`.
- [x] Add plugin-scoped methods listed in "Public Backend Shape".
- [x] Use `requirePluginAccess(pluginID, permission)` for every method.
- [x] Return readable errors for closed vault, missing file, reserved path,
- [ ] Add `files.Service` to `api.App`.
- [ ] Add plugin-scoped methods listed in "Public Backend Shape".
- [ ] Use `requirePluginAccess(pluginID, permission)` for every method.
- [ ] Return readable errors for closed vault, missing file, reserved path,
conflict, and missing permission.
- [x] Add tests for successful read/write/list/mkdir/move/trash through `App`.
- [x] Run:
- [ ] Add tests for successful read/write/list/mkdir/move/trash through `App`.
- [ ] Run:
```bash
go test ./internal/api
@ -305,16 +305,16 @@ Expected: API tests pass.
- Add/modify focused frontend tests under `frontend/e2e/` only if existing test
coverage cannot validate the shape outside Playwright.
- [x] Add `api.files.list(relativeDir)`.
- [x] Add `api.files.metadata(relativePath)`.
- [x] Add `api.files.readText(relativePath)`.
- [x] Add `api.files.writeText(relativePath, content, options)`.
- [x] Add `api.files.mkdir(relativePath)`.
- [x] Add `api.files.move(fromRelativePath, toRelativePath, options)`.
- [x] Add `api.files.trash(relativePath)`.
- [x] Keep all calls plugin-scoped; plugin code must not pass `pluginId`.
- [x] Mock readable errors for reserved path and missing permission.
- [x] Run:
- [ ] Add `api.files.list(relativeDir)`.
- [ ] Add `api.files.metadata(relativePath)`.
- [ ] Add `api.files.readText(relativePath)`.
- [ ] Add `api.files.writeText(relativePath, content, options)`.
- [ ] Add `api.files.mkdir(relativePath)`.
- [ ] Add `api.files.move(fromRelativePath, toRelativePath, options)`.
- [ ] Add `api.files.trash(relativePath)`.
- [ ] Keep all calls plugin-scoped; plugin code must not pass `pluginId`.
- [ ] Mock readable errors for reserved path and missing permission.
- [ ] Run:
```bash
cd frontend
@ -331,11 +331,11 @@ Expected: frontend build passes.
- Modify: `../verstak-sdk/src/test-utils.ts`
- Modify: `../verstak-sdk/src/plugin-api.test.ts`
- [x] Add `files` API TypeScript interfaces matching the frontend API names.
- [x] Add mock Files API methods in `createMockPluginAPI`.
- [x] Add contract tests for API shape, text write/read, reserved path error, and
- [ ] Add `files` API TypeScript interfaces matching the frontend API names.
- [ ] Add mock Files API methods in `createMockPluginAPI`.
- [ ] Add contract tests for API shape, text write/read, reserved path error, and
trash result shape.
- [x] Run:
- [ ] Run:
```bash
cd ../verstak-sdk
@ -353,36 +353,36 @@ Expected: SDK check, build, and tests pass.
- Modify: `docs/PLUGIN_RUNTIME.md`
- Modify: `docs/NOTES_FILES_PLUGIN_PLAN.md`
- [x] Document Files Core API as functional for Milestone 6a.
- [x] Keep Notes API documented as planned until Milestone 6b or later.
- [x] Document `.verstak` reserved path policy.
- [x] Document slash-only path policy, Windows/UNC rejection, and symlink policy.
- [x] Document text-only write support and deferred binary streaming.
- [ ] Document Files Core API as functional for Milestone 6a.
- [ ] Keep Notes API documented as planned until Milestone 6b or later.
- [ ] Document `.verstak` reserved path policy.
- [ ] Document slash-only path policy, Windows/UNC rejection, and symlink policy.
- [ ] Document text-only write support and deferred binary streaming.
## Task 9: Final Verification
- [x] Run desktop backend tests:
- [ ] Run desktop backend tests:
```bash
cd verstak-desktop
go test ./...
```
- [x] Run desktop frontend build:
- [ ] Run desktop frontend build:
```bash
cd verstak-desktop/frontend
npm run build
```
- [x] Run desktop e2e:
- [ ] Run desktop e2e:
```bash
cd verstak-desktop/frontend
npm run test:e2e -- --reporter=list
```
- [x] Run official plugins checks:
- [ ] Run official plugins checks:
```bash
cd verstak-official-plugins
@ -390,7 +390,7 @@ cd verstak-official-plugins
./scripts/build.sh
```
- [x] Run SDK checks:
- [ ] Run SDK checks:
```bash
cd verstak-sdk

View File

@ -54,9 +54,8 @@ Canonical rules:
Canonical scoped paths:
- Notes live under `<workspace>/Notes/`.
- `Overview.md` is allowed as an ordinary Markdown filename, but the platform
must not create it automatically or treat it as a dedicated UI entity.
- Workspace overview notes live under `<workspace>/Notes/`.
- The default overview note is `<workspace>/Notes/Overview.md`.
- `<workspace>` is the top-level physical folder name under the vault root.
- Files plugin workspace views are scoped with `workspaceRootPath`, which is the
selected top-level workspace folder name.
@ -69,9 +68,8 @@ Visibility requirements:
- Outside Verstak, the files must remain useful as normal Markdown.
There is no canonical metadata workspace tree. Adding `note` as a workspace node
type is not part of the next milestone. The official Notes plugin can index and
manage Markdown files inside canonical `Notes/` folders under each top-level
workspace.
type is not part of the next milestone. The Notes service can index and manage
Markdown files inside canonical `Notes/` folders under each top-level workspace.
## Title To Filename Contract
@ -101,9 +99,9 @@ Examples:
Same-folder collisions must not be solved silently with `_2`, `_3`, or timestamp
suffixes.
Creating or renaming a note must return a conflict error if the normalized target
filename already exists in the target `Notes/` folder. The UI should show a clear
dialog or notification and ask the user to change the title.
`CreateNote` and `RenameNote` must return a conflict error if the normalized
target filename already exists in the target `Notes/` folder. The UI should show a
clear dialog or notification and ask the user to change the title.
Required conflict metadata:
@ -161,17 +159,16 @@ Later Files methods:
- `OpenExternal(relativePath)` with explicit permission and UX confirmation.
- `RevealInFileManager(relativePath)`.
## Official Notes Plugin Model
## Notes Service Model
The official Notes plugin is a semantic layer over Markdown files managed through
the public Files plugin API. There is no core desktop Notes service or
`verstak/core/notes/v1` capability in v2.
Notes API is a semantic layer over Markdown files managed by the Files/path
policy.
Rules:
- A note physically is a `.md` file.
- Notes plugin and Files API must not create two sources of truth.
- Notes plugin reads/writes the same files that Files API lists.
- Notes API and Files API must not create two sources of truth.
- Notes API reads/writes the same files that Files API lists.
- The note title is the semantic source of truth and is projected to the filename.
If frontmatter or a first-heading convention is introduced later, `RenameNote`
must keep that visible title metadata and the filename synchronized.
@ -181,27 +178,27 @@ Rules:
watcher/indexer must observe it.
- Until watcher/indexer exists, external changes require reload/rescan.
Minimum Notes plugin behavior:
Minimum Notes methods:
- list Markdown files in a scoped canonical `Notes/` folder;
- create a Markdown note by writing the normalized filename atomically with
`overwrite: false`;
- rename a note by moving the file to the normalized filename;
- open and edit notes through Workbench providers;
- never special-case `Overview.md` in the UI.
- `ListNotes(scope)` where `scope` resolves to a canonical `Notes/` folder.
- `GetNote(notePath)`.
- `CreateNote(scope, title, initialBody)`.
- `RenameNote(notePath, newTitle)`.
- `UpdateNoteBody(notePath, body)`.
- `TrashNote(notePath)`.
Later Notes plugin behavior:
Later Notes methods:
- search notes;
- list backlinks;
- resolve note links;
- export notes.
- `SearchNotes(query, filters)`.
- `ListBacklinks(notePath)`.
- `ResolveNoteLinks(notePath)`.
- `ExportNote(notePath, format)`.
## Notes Vs Files Relationship
Files owns safe raw vault file access. Notes owns note semantics.
The same physical note must be visible through both surfaces:
The same physical note must be visible through both APIs:
- Files sees `Project/Notes/Overview.md` as a file.
- Notes sees `Project/Notes/Overview.md` as a note with title `Overview`.
@ -241,6 +238,7 @@ real isolation boundary.
Recommended capabilities:
- `verstak/core/files/v1`
- `verstak/core/notes/v1`
- `verstak/files/v1` provided by the official Files plugin.
- `verstak/notes/v1` provided by the official Notes plugin.
@ -391,7 +389,7 @@ Backend Go tests:
- Atomic text writes and temp-file cleanup on failure.
- Notes `Notes/` folder casing and no lowercase `notes`.
- Title to filename normalization.
- note create/rename conflict errors without silent suffixes.
- `CreateNote` and `RenameNote` conflict errors without silent suffixes.
- Notes and Files read the same physical `.md` file.
- Permission checks for `files.*`, `notes.*`, `vault.read`, and `vault.write`.

View File

@ -112,25 +112,17 @@ capRegistry.Register("verstak-desktop", []string{"verstak/core/vault/v1"})
### Plugin capability resolution
```
1. Исключить disabled plugins.
2. Пока есть неразрешённые plugins:
- plugin с уже доступными `requires` атомарно регистрирует `provides`;
- при конфликте `provides` plugin получает `failed`;
- если за проход ничего не разрешилось, остальные получают
`missing-required-capability`.
3. После разрешения всех обязательных зависимостей проверить
`optionalRequires`: отсутствующие переводят plugin в `degraded`.
4. Зарегистрировать `contributes` только для `loaded` и `degraded` plugins.
foreach plugin:
1. зарегистрировать plugin.provides в capability registry
2. проверить plugin.requires — если есть missing → missing-required-capability
3. проверить plugin.optionalRequires — если есть missing → degraded
4. иначе → loaded
5. зарегистрировать plugin.contributes в contribution registry
```
Таким образом, порядок discovery не влияет на обычные зависимости между
plugins. При конкурирующих providers одного capability порядок discovery служит
tie-breaker. `provides` плагина с неразрешённым `requires` или ошибкой
регистрации не попадают в registry.
### Capability конфликт
Два plugin не могут предоставлять один и тот же capability. При попытке повторной регистрации второй plugin получает `failed`; регистрация атомарна, поэтому ни один capability такого plugin не остаётся в registry.
Два plugin не могут предоставлять один и тот же capability. При попытке повторной регистрации — ошибка и статус `failed` для второго плагина.
## Plugin Manifest Format
@ -197,22 +189,19 @@ Icon fields use shell icon names rendered through the bundled Lucide SVG wrapper
| Боковая панель | `sidebarItems` | Элементы в sidebar слева | ✅ Sidebar.svelte (из ContributionRegistry) |
| Основные панели | `views` | Полноценные страницы/панели | ✅ ViewContainer.svelte (PluginBundleHost — real frontend bundle) |
| Панели настроек | `settingsPanels` | Панели в Plugin Manager | ✅ PluginManager.svelte (кнопка Settings, открывает modal) |
| Команды | `commands` | Команды для command palette | ✅ ContributionRegistry + CommandPalette UI |
| Команды | `commands` | Команды для command palette | ✅ ContributionRegistry (UI command palette не реализован) |
| Open/edit providers | `openProviders` | Провайдеры viewer/editor для Workbench routing | ✅ ContributionRegistry + минимальный Workbench host |
| Действия над файлами | `fileActions` | Provider actions for Files surface | ✅ Files plugin context menu |
| Действия над заметками | `noteActions` | Provider actions for Notes surface | ✅ Notes plugin row actions |
| Контекстное меню | `contextMenuEntries` | Provider context menu entries | ✅ Files plugin context menu |
| Провайдеры поиска | `searchProviders` | Search provider discovery | ✅ Contribution summary + Search plugin |
| Провайдеры активности | `activityProviders` | Activity event subscriptions | ✅ Backend activity recorder |
| Элементы status bar | `statusBarItems` | Status bar labels/actions | ✅ StatusBar.svelte host |
### Планируемые contribution points
| Тип | Поле manifest | Статус |
|---|---|---|
| Sidecar-backed command handlers | `commands` | Planned; bundled frontend handlers are current runtime |
| Import/export providers | `importers` / `exporters` | Planned |
| Protocol receivers | `protocol.receivers` | Planned |
| Действия над файлами | `fileActions` | Registry готов, UI не реализован |
| Действия над заметками | `noteActions` | Registry готов, UI не реализован |
| Контекстное меню | `contextMenuEntries` | Registry готов, UI не реализован |
| Провайдеры поиска | `searchProviders` | Registry готов, UI не реализован |
| Провайдеры активности | `activityProviders` | Registry готов, UI не реализован |
| Элементы status bar | `statusBarItems` | Registry готов, UI не реализован |
### Структура contribution points в manifest
@ -259,7 +248,7 @@ Icon fields use shell icon names rendered through the bundled Lucide SVG wrapper
1. Plugin `Register(pluginID, contributions)` — все contributions регистрируются
2. `Unregister(pluginID)` — удаляет все contributions указанного plugin
3. Reload сначала удаляет contributions всех plugins предыдущего discovery, затем регистрирует только `loaded`/`degraded` plugins
3. Reload: `Unregister → Register` (предотвращает дублирование)
4. Disable plugin → `Unregister` (contributions исчезают из UI)
5. Enable plugin → `Register` при следующем Reload
6. Registry idempotent: Register удаляет старые записи перед добавлением новых
@ -328,8 +317,7 @@ the Workbench helper.
{
"kind": "vault-file",
"extensions": [".md", ".markdown"],
"contexts": ["generic-markdown", "notes-markdown"],
"modes": ["view"]
"contexts": ["generic-markdown", "notes-markdown"]
},
{
"kind": "vault-file",
@ -345,7 +333,7 @@ the Workbench helper.
```
Selection uses enabled loaded/degraded provider plugins, resource kind,
request mode, extension/mime, context, user preference, priority, then deterministic
extension/mime, context, user preference, priority, then deterministic
`pluginId/providerId` fallback. If nothing matches, Workbench returns
`status: "no-provider"` and shows the fallback view instead of a core editor.
@ -396,35 +384,10 @@ contributions summary.
- `commands.execute(commandId, args)` — backend сначала проверяет plugin status,
permission `commands.register` и что command объявлен в `contributes.commands`
именно этим plugin. Затем frontend registry вызывает зарегистрированный handler.
- `commands.executeFor(pluginId, commandId, args)` — то же выполнение, но для
command другого plugin-provider. Используется host surfaces вроде Files/Notes,
которые читают contribution action и вызывают объявленный provider handler.
- Если command объявлен в manifest, но handler не зарегистрирован, API возвращает
понятную ошибку `declared-but-unhandled`.
- Handler registry очищается при component unmount, reload/disable flow и
`api.dispose()`.
- Shell command palette открывается через `Ctrl+K` / `Cmd+K` или
`Ctrl+Shift+P` / `Cmd+Shift+P`, показывает commands enabled plugins,
фильтрует по title/id/plugin и вызывает зарегистрированные bundled frontend
handlers.
`statusBarItems`
- Shell status bar renders enabled plugin `statusBarItems` contributions.
- Items support `left`, `center`, and `right` positions. Missing position
defaults to `left`.
- The host refreshes on plugin reload/enable/disable through
`verstak:plugins-changed`.
- `handler` is preserved in the contribution summary for future action routing;
current host only renders status labels.
`contributions`
- `contributions.list()` — возвращает весь flattened contribution summary.
- `contributions.list(point)` — возвращает массив contribution records для
конкретного поля (`fileActions`, `noteActions`, `contextMenuEntries`, etc).
- Runtime records include `pluginId`, so consumer surfaces can call
`commands.executeFor(record.pluginId, record.handler, args)`.
`events`
@ -435,45 +398,22 @@ contributions summary.
- Handler получает envelope `{ name, pluginId, payload, timestamp }`.
- Subscriptions очищаются при component unmount, reload/disable flow и
`api.dispose()`.
- Core publishes `file.changed` for Files API mutations and live vault watcher
changes. Watcher payloads use `operation: "external.create"`,
`"external.update"`, or `"external.delete"`, include vault-relative `path`,
`type`, `workspaceRootPath`, and `external: true`.
`files`
- `files.list(relativeDir)` — list directory using a vault-relative path.
- `files.metadata(relativePath)` — returns file/folder/symlink metadata.
- `files.readText(relativePath)` — reads a UTF-8 regular file, with a size limit.
- `files.readBytes(relativePath)` — reads a regular file up to 8 MB and returns
`{ relativePath, size, mimeHint, dataBase64 }` for bounded binary preview use.
- `files.writeText(relativePath, content, options)` — atomically writes text via
temp-file-and-rename. `options.createIfMissing` and `options.overwrite`
control conflicts.
- `files.writeBytes(relativePath, dataBase64, options)` — decodes a base64
payload up to 8 MB and atomically writes bytes via temp-file-and-rename.
`options.createIfMissing` and `options.overwrite` control conflicts.
- `files.createFolder(relativePath)` — creates one folder when the parent exists.
- `files.move(from, to, options)` — moves a file or folder; rejects moving a
folder into itself and conflicts unless `options.overwrite` is true.
- `files.trash(relativePath)` — moves a file/folder into internal
`.verstak/trash/files/<trashId>/...` and returns trash metadata.
- `files.listTrash()` — returns internal file trash metadata, newest first.
- `files.restoreTrash(trashId, options)` — restores a file/folder from internal
trash to its original vault-relative path, or to `options.targetPath`.
Conflicts are rejected unless `options.overwrite` is true.
- `files.deleteTrash(trashId)` — permanently removes a file/folder already in
internal trash. This operation cannot be undone.
- UI policy: the Deal `Files` plugin displays only live files and folders.
The global official `verstak.trash` plugin owns listing, restoring, filtering,
and permanently deleting deleted items across the vault.
- `files.openExternal(relativePath)` — opens a vault-relative file/folder in
the OS default application.
- `files.showInFolder(relativePath)` — reveals a vault-relative file/folder in
the OS file manager where the platform supports it.
- Backend requires plugin exists, enabled, status `loaded`/`degraded`, open
vault, and `files.read`, `files.write`, `files.delete`, or
`files.openExternal`.
vault, and `files.read`, `files.write`, or `files.delete`.
- All paths are canonical vault-relative slash paths. Backslashes, POSIX
absolute paths, Windows drive paths, UNC/network paths, `..`, null bytes,
symlink traversal, and public access to `.verstak/` are rejected.
@ -482,26 +422,9 @@ contributions summary.
- `files.metadata` may report a final symlink as `type: "symlink"`, but
`files.list` through a symlink directory and all read/write/move/trash
operations through symlinks are forbidden in Milestone 6a.
- `readText` is limited to UTF-8 regular files up to 2 MB. `readBytes` and
`writeBytes` are bounded byte contracts up to 8 MB; chunked streaming is
deferred.
- Live watcher refresh is active while Verstak is running and a vault is open.
It performs an initial no-event snapshot, then publishes `file.changed` for
external creates, updates, and deletes outside `.verstak/`. It does not keep a
persistent snapshot or report what changed while Verstak was closed.
`sync`
- `sync.now()` pushes local operations, pulls remote operations, and returns
`{ pushed, pulled, serverSequence, conflicts?, applyErrors? }`.
- `conflicts` is an array of server-reported sync conflicts. Conflict objects
may include `op_id`, `entity_type`, `entity_id`, `reason`, and additional
server fields. The Sync plugin must show conflict details instead of only a
count, and it must not silently resolve or overwrite local data.
- `applyErrors` lists local apply failures for pulled operations. These are
user-visible warnings and do not imply that sync was fully successful.
- Transport push/pull uses bounded retry/backoff for transient HTTP/network
failures. Client/auth errors are not retried.
- Files API is text-only for read/write in Milestone 6a. `readText` is limited
to UTF-8 regular files up to 2 MB. Binary streaming, watcher, restore,
external editor integration, and Files UI plugin are deferred.
`dispose`
@ -564,29 +487,17 @@ bundled runtime. Это реальный runtime contract для cooperative bun
| `api.capabilities.has(id)` | ✅ Работает | Boolean wrapper над `get` |
| `api.commands.register(id, handler)` | ✅ Работает | Регистрирует bundled frontend handler для объявленной command |
| `api.commands.execute(id, args)` | ✅ Работает | Валидирует declaration/permission/backend state и вызывает bundled handler |
| `api.commands.executeFor(pluginId, id, args)` | ✅ Работает | Выполняет handler другого plugin-provider после backend validation |
| `api.contributions.list(point?)` | ✅ Работает | Возвращает flattened contribution summary или массив по point |
| Command Palette UI | ✅ Работает | `Ctrl/Cmd+K`, фильтр enabled plugin commands, вызов registered frontend handlers |
| `api.events.publish(type, payload)` | ✅ Работает | Валидирует permission и публикует во frontend event bus |
| `api.events.subscribe(type, handler)` | ✅ Работает | Валидирует permission и подписывает handler на frontend event bus |
| `api.files.list(relativeDir)` | ✅ Работает | Список vault-relative директории, `.verstak` скрыта |
| `api.files.metadata(relativePath)` | ✅ Работает | Metadata для файла/папки/symlink без чтения содержимого |
| `api.files.readText(relativePath)` | ✅ Работает | Читает UTF-8 regular file до 2 MB |
| `api.files.readBytes(relativePath)` | ✅ Работает | Читает regular file до 8 MB как base64 payload |
| `api.files.writeText(relativePath, content, options)` | ✅ Работает | Atomic text write с явным create/overwrite policy |
| `api.files.writeBytes(relativePath, dataBase64, options)` | ✅ Работает | Atomic bounded byte write до 8 MB с явным create/overwrite policy |
| `api.files.createFolder(relativePath)` | ✅ Работает | Создаёт vault-relative folder |
| `api.files.move(from, to, options)` | ✅ Работает | Move file/folder с conflict и path-policy checks |
| `api.files.trash(relativePath)` | ✅ Работает | Перемещает в internal trash и сохраняет metadata исходного объекта |
| `api.files.listTrash()` | ✅ Работает | Возвращает metadata internal file trash |
| `api.files.restoreTrash(trashId, options)` | ✅ Работает | Восстанавливает из internal trash, conflict-safe по умолчанию |
| `api.files.deleteTrash(trashId)` | ✅ Работает | Необратимо удаляет запись и payload из internal trash |
| `api.files.openExternal(relativePath)` | ✅ Работает | Открывает vault file/folder во внешнем приложении, требует `files.openExternal` |
| `api.files.showInFolder(relativePath)` | ✅ Работает | Показывает vault file/folder в системном файловом менеджере, требует `files.openExternal` |
| `api.files.trash(relativePath)` | ✅ Работает | Перемещает в internal trash, permanent delete нет |
| `api.workbench.openResource(request)` | ✅ Работает | Routes vault resources to `openProviders` |
| `api.workbench.editResource(request)` | ✅ Работает | Same routing, forcing `mode: "edit"` |
| `api.sync.now()` | ✅ Работает | Push/pull с bounded retry/backoff для transient HTTP/network failures |
| `api.sync.status()` | ✅ Работает | Возвращает configured/connected/error/revoked state, lastError, unpushed count |
| `api.dispose()` | ✅ Работает | Очищает command handlers и event subscriptions текущего API instance |
Ограничения:
@ -633,12 +544,13 @@ bundled runtime. Это реальный runtime contract для cooperative bun
`ReloadPlugins()` в `internal/api/app.go` позволяет перезагрузить plugins без перезапуска приложения:
1. Unregister contributions всех plugins предыдущего discovery.
2. Unregister all non-core capabilities.
3. Re-register core capabilities + vault + Deal manager (если открыт).
4. Re-scan discovery directories и повторить capability resolution.
5. Register contributions для loaded/degraded plugins (disabled/failed — не регистрируются).
6. Update plugins list.
1. Unregister all non-core capabilities.
2. Re-register core capabilities + vault + workspace (если открыт).
3. Re-scan discovery directories.
4. For each plugin: re-run capability resolution.
5. **Unregister contributions** before re-registering (предотвращает дубли).
6. Register contributions for loaded/degraded plugins (disabled/failed — не регистрируются).
7. Update plugins list.
Frontend вызывает это при нажатии "Reload" в Plugin Manager.
@ -838,109 +750,90 @@ Vault plugin state хранится **внутри vault** в `.verstak/plugins.
- `./scripts/smoke-platform.sh` — ✅ (enable/disable/plugins.json)
- `./scripts/build.sh` — ✅
## Дело: core capability
## Workspace Core Capability
Дело — это физическая папка верхнего уровня внутри vault root. Filesystem
является source of truth для списка Дел.
Workspace — это физическая папка верхнего уровня внутри vault root. Filesystem
является source of truth для списка workspaces.
Пример:
```
<vault>/
Deal/
Workspace/
Notes/
Overview.md
Project/
ClientA/
.verstak/
```
Нет единого `<vault>/Deal/` контейнера для всех Дел. Папка `Deal/` может быть
обычным Делом, но `Project/` и `ClientA/` являются такими же Делами на том же
уровне.
Нет единого `<vault>/Workspace/` контейнера для всех workspaces. Папка
`Workspace/` может быть обычным workspace, но `Project/` и `ClientA/` являются
такими же workspace на том же уровне.
### Хранение
Существование и список Дел хранятся только в filesystem:
Workspace existence/list хранится только в filesystem:
- `ListWorkspaces()` читает top-level directories из vault root.
- `.verstak`, reserved/internal directories, top-level files и symlinks не
считаются Делами.
- `.verstak/workspace*.json` не является source of truth для списка Дел.
- Нет virtual workspace tree, которое
считаются workspaces.
- `.verstak/workspace*.json` не является source of truth для списка workspaces.
- Нет persisted workspace path mapping и нет virtual workspace tree, которое
мапится на произвольные папки.
`.verstak` может хранить только metadata, которая не заменяет filesystem:
- UI state: выбранное Дело, expanded folders, sort/pin state, preferences.
- Semantic snapshot: applied template snapshot, enabled feature areas, exact
`workspaceTools`, and folder conventions.
- UI state: selected workspace, expanded folders, sort/pin state, preferences.
- Semantic snapshot: applied template snapshot, enabled feature areas, folder
conventions.
При создании Дела runtime генерирует UUID и сохраняет его в
`.verstak/workspace.json`. `workspaceId` — постоянная личность Дела;
`workspaceRootPath` и `workspaceName` — изменяемый адрес и presentation fields.
Переименование обновляет путь, но не ID. Новая папка с прежним именем получает
другой ID и не может перехватить старые привязки.
Template snapshot копируется в metadata при создании workspace. Workspace
identity при этом остаётся именем top-level folder; `metadata.workspaceName`
является presentation/snapshot field, not canonical identity. Если сохранённое
значение расходится с именем папки, runtime возвращает canonical `workspaceName`
равным имени папки без filesystem side effects.
```json
{
"workspaceId": "1eb7cc69-52e8-4a18-ae2a-50d5229c5b60",
"workspaceName": "Project",
"createdFromTemplate": {
"templateId": "project",
"templateName": "Project",
"templateId": "client-project",
"templateName": "Client Project",
"templateVersion": 1,
"appliedAt": "2026-06-19T12:00:00Z",
"workspaceTools": [
"verstak.notes",
"verstak.files",
"verstak.todo",
"verstak.journal",
"verstak.activity",
"verstak.browser-inbox"
]
"appliedAt": "2026-06-19T12:00:00Z"
},
"features": {
"notes": true,
"files": true,
"todo": true,
"journal": true,
"activity": true,
"browser-inbox": true
"secrets": true,
"activity": false
},
"folders": {
"notes": "Notes",
"files": "Files"
},
"workspaceTools": [
"verstak.notes",
"verstak.files",
"verstak.todo",
"verstak.journal",
"verstak.activity",
"verstak.browser-inbox"
]
"files": "Files",
"secrets": "Secrets"
}
}
```
Если original template удалён или изменён позже, существующее Дело
Если original template удалён или изменён позже, существующий workspace
открывается по сохранённому snapshot и не мутирует автоматически. Template
update/migration может быть только явной future feature. Если metadata
отсутствует, Дело открывается как generic Deal минимум с `files: true`.
отсутствует, workspace открывается как generic workspace минимум с `files: true`.
### API
- `ListWorkspaces()` — список top-level папок Дел с их `workspaceId`.
- `ListWorkspaceTemplates()` — selectable built-in templates с presentation
metadata и `workspaceTools`.
- `ListWorkspaces()` — список top-level physical folders.
- `CreateWorkspace(name, templateId?)` — создать `<vault>/<name>/`, применить
template один раз, сохранить snapshot metadata.
- `RenameWorkspace(oldName, newName)` — физически переименовать top-level папку
Дела и обновить metadata name, не меняя `workspaceId`.
- `TrashWorkspace(name)` — перенести весь top-level folder Дела в internal
- `RenameWorkspace(oldName, newName)` — физически переименовать top-level folder
и обновить metadata key/name.
- `TrashWorkspace(name)` — перенести весь top-level workspace folder в internal
trash policy.
- `GetWorkspaceMetadata(name)` — прочитать metadata или вернуть generic fallback.
- `UpdateWorkspaceMetadata(name, patch)` — обновить metadata без влияния на
существование Дела.
существование workspace.
Deprecated compatibility APIs:
@ -950,7 +843,7 @@ Deprecated compatibility APIs:
- `ArchiveWorkspaceNode(...)` — wrapper over `TrashWorkspace`.
- `MoveWorkspaceNode(...)` — unsupported; old nested/mapped moves are rejected.
- `GetCurrentWorkspaceNode()` / `SetCurrentWorkspaceNode(...)` — wrappers over
selected top-level Deal UI state.
selected top-level workspace UI state.
Эти методы существуют только для постепенного frontend/Wails cleanup. Они не
должны создавать или сохранять nested workspace tree и не должны восстанавливать
@ -958,45 +851,38 @@ Deprecated compatibility APIs:
### Capability
`verstak/core/workspace/v1` — техническое имя capability; оно регистрируется
только когда vault открыт и Deal manager инициализирован.
`verstak/core/workspace/v1` — регистрируется только когда vault открыт и workspace инициализирован.
### Правила
- Имя Дела — один safe folder name, не path.
- Workspace name — один safe folder name, не path.
- Reject: empty, slash, backslash, absolute-looking paths, `..`, null byte,
`.verstak`, reserved/internal names, symlink Дел, conflicts.
- WorkspaceItems получают техническое поле `workspaceRootPath`, равное имени top-level папки
`.verstak`, reserved/internal names, symlink workspaces, conflicts.
- WorkspaceItems получают `workspaceRootPath`, равный имени top-level папки
(`Project`, `ClientA`, etc). Files plugin показывает именно эту папку.
- Files API остаётся raw vault-relative API: `Project/Notes/example.md`,
- Files API остаётся raw vault-relative API: `Project/Notes/Overview.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
content.
### Lifecycle Events
Runtime publishes lifecycle events Дел после успешных операций:
- `workspace.created` — payload includes `operation: "create"`, `workspaceId`,
`workspaceRootPath`, `workspaceName`, and optional `templateId`.
- `workspace.renamed` — payload includes `operation: "rename"`, `workspaceId`,
new `workspaceRootPath` / `workspaceName`, and previous path fields.
- `workspace.trashed` — payload includes `operation: "trash"`, `workspaceId`,
`workspaceRootPath`, `workspaceName`, `trashId`, `trashPath`, and `deletedAt`.
- `workspace.selected` — payload includes `operation: "select"`, `workspaceId`,
`workspaceRootPath`, and `workspaceName`.
Official Activity subscribes to these events and stores them through the normal
activity provider path.
**Planned (not yet implemented in runtime):**
- `workspace.node.created`
- `workspace.node.renamed`
- `workspace.node.moved`
- `workspace.node.archived`
- `workspace.node.selected`
- `workspace.error`
### UI
Список Дел в sidebar:
- Flat list of top-level папок Дел.
- Создать Дело, переименовать Дело, переместить Дело в корзину.
- Selection is stored as selected Deal name.
- No expand/collapse tree and no case/folder node creation in core.
Workspace list в sidebar:
- Flat list of top-level workspace folders.
- Create workspace, rename workspace, trash workspace.
- Selection is stored as selected workspace name.
- No expand/collapse workspace tree and no case/folder node creation in core.
---

View File

@ -1,87 +0,0 @@
# UX/UI Refactor Plan
## Assumptions
- `verstak-desktop` is the core platform and UI shell. Notes, files, editor, activity, journal, browser inbox, and search behavior stay plugin-owned.
- The old `~/git/verstak` UI is a visual and interaction reference, not an architecture source.
- The v2 shell should improve orientation, density, keyboard/mouse ergonomics, and responsive behavior without reintroducing the v1 monolith.
## Reference Rules
Keep from v1:
- compact dark workbench rhythm;
- clear title/header zones;
- dense rows and tabs for repeated work;
- action controls that appear where the user is working;
- custom, in-app interaction surfaces instead of browser-default dialogs where the flow is important.
Avoid from v1:
- putting user business workflows into `App.svelte`;
- direct coupling between shell and notes/files/editor internals;
- global mutable UI state that plugins must know about;
- moving plugin behavior into core for visual convenience.
## Mimo Delegation Model
Use `~/bin/mimo.sh run --dir <repo> "<task>"` only for bounded junior tasks.
Good tasks:
- compare two small components and write a short report to `/tmp`;
- draft CSS for a named component within existing tokens;
- inspect one test file and suggest missing assertions;
- make a one-component mechanical change after the target behavior is already specified.
Do not delegate:
- architecture decisions;
- plugin/core boundary decisions;
- final diff review;
- verification claims;
- commits or pushes.
Every mimo result must be reviewed with `git diff` and verified independently.
## Work Plan
1. Shell orientation
- Move persistent search and workspace context into the workspace header.
- Keep exactly one global search entry visible at a time.
- Preserve search availability in global plugin views.
2. Responsive shell
- Make narrow viewports usable by stacking sidebar above workspace content.
- Ensure tabs and workspace cards do not force horizontal page overflow.
- Add Playwright coverage for mobile geometry.
3. Today surface
- Make Today feel like a work-resume surface, not a static card grid.
- Keep data loading from plugin settings/contributions; do not add business logic to core.
- Improve empty states and quick actions based on available workspace tools.
4. Plugin manager polish
- Improve scanning, status density, and permission readability.
- Keep enable/disable/status behavior unchanged.
- Verify degraded/failed/disabled plugin paths.
5. Files/workbench ergonomics
- Use the existing files plugin comparison report as input.
- Prefer plugin-local improvements: context menu, keyboard navigation, selection, and custom confirmation.
- Do not add file-manager logic to the shell.
## Verification Gates
Before commit:
- `npm run build`
- focused Playwright suite for the touched flow
- full `npm run test:e2e` when App, Sidebar, WorkspaceHost, PluginManager, or shared shell layout changes
- desktop and mobile screenshots inspected manually
Before push:
- re-run the relevant verification from a clean current worktree state;
- inspect `git status` and `git diff --stat`;
- push only after tests and visual smoke have current evidence.

View File

@ -1,40 +0,0 @@
# Deal Templates
Deal templates choose which dynamic plugin Deal tabs are visible when a new
top-level Deal is created. They do not enable or disable plugins for the
whole vault and do not affect global sidebar tools.
## Built-in templates
| Template | Deal tabs |
| --- | --- |
| General | Notes, Files, Journal, Activity, Browser Inbox |
| Project | Notes, Files, Todos, Journal, Activity, Browser Inbox |
| Writing | Notes, Files, Journal |
| Admin | Notes, Files, Secrets, Todos, Journal |
| Minimal | Notes, Files |
The create-Deal modal displays the selected template description and its
included plugin tabs before the folder is created.
## Metadata and compatibility
Creation stores a template snapshot in `.verstak/workspaces/` metadata. The
snapshot contains the template id, name, version, applied time, and an ordered
`workspaceTools` list of plugin IDs. Existing Deal metadata without
`workspaceTools` remains compatible: its Deal continues to show all globally
enabled Deal plugins rather than unexpectedly hiding tabs.
Templates are applied once. Editing the built-in catalog or creating another
Deal with a different template never changes an existing Deal snapshot.
There is no template editor or post-creation template switcher yet.
## Global tools and unavailable plugins
Template visibility applies only to `workspaceItems` in the selected Deal.
Global views and sidebar items, such as global Todos, Browser Inbox, and Trash,
remain available according to the normal plugin enablement state.
The Deal host intersects the snapshot with dynamically discovered, globally
enabled plugin contributions. If a template references a plugin that is missing or
disabled, that tab is simply absent; the other template tabs remain usable.

Binary file not shown.

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: 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: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

View File

@ -1,350 +0,0 @@
# Native Notifications, Tray, and Public README Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Deliver native Todo reminders and Windows/Linux tray operation, then publish updated alpha artifacts and public README documentation with real application screenshots.
**Architecture:** Desktop core stores and delivers plugin-owned notification schedules and controls the tray lifecycle. Todo uses the public plugin API to replace its desired reminders. A small core tray adapter keeps the Wails process alive after window close. Documentation uses the supplied English and Russian README sources plus screenshots from an actual test vault.
**Tech Stack:** Go 1.24, Wails v2.12 runtime notifications, `fyne.io/systray`, plain JavaScript plugin API, Node smoke tests, Playwright, bash packaging.
## Global constraints
- Support Windows and Linux only; no background daemon after an explicit Quit.
- Closing a window hides it only after the tray reports ready; otherwise normal
window close ends the process.
- `verstak.todo` requires `verstak/core/notifications/v1` and `notifications.schedule`.
- Plugins cannot receive Wails runtime access.
- Keep Wails generated bindings out of commits.
- Commit each independent change and immediately push it to GitHub and `mirror`.
---
### Task 1: Persisted notification scheduler
**Files:**
- Create: `internal/core/notifications/manager.go`
- Create: `internal/core/notifications/store.go`
- Create: `internal/core/notifications/manager_test.go`
**Interfaces:**
- `type Request struct { ID, DueAt, Title, Body string }`
- `type Item struct { PluginID, ID, DueAt, Title, Body, SentForDueAt string }`
- `type Sender interface { Send(context.Context, Item) error }`
- `Manager.Replace(pluginID string, requests []Request) error`, `Clear(pluginID string) error`, `Start(context.Context)`, and `Stop()`.
- Persistent state: `<vault>/.verstak/notifications/schedules.json`.
- [ ] **Step 1: Add failing scheduler tests**
```go
func TestTickRetriesFailedSendAndAcknowledgesOneDelivery(t *testing.T) {
sender := &fakeSender{err: errors.New("unavailable")}
m := newTestManager(t, sender, time.Date(2026, 7, 14, 12, 0, 0, 0, time.UTC))
requireNoError(t, m.Replace("verstak.todo", []Request{{ID: "todo-1", DueAt: "2026-07-14T11:00:00Z", Title: "Reminder"}}))
m.Tick(context.Background())
if sender.calls != 1 || m.Items()[0].SentForDueAt != "" { t.Fatal("failed send was acknowledged") }
sender.err = nil
m.Tick(context.Background())
m.Tick(context.Background())
if sender.calls != 2 || m.Items()[0].SentForDueAt != "2026-07-14T11:00:00Z" { t.Fatal("delivery was not exactly once") }
}
```
Also test stale records are removed by `Replace`, changing `DueAt` rearms a record, state reloads from disk, and a past-due unsent item is sent once.
- [ ] **Step 2: Confirm red**
Run: `GOCACHE=/tmp/verstak-go-cache go test ./internal/core/notifications -count=1`
Expected: fails because the package does not exist.
- [ ] **Step 3: Implement the minimal manager**
Use one mutex, injected clock/sender, an atomic same-directory temp-file rename, and a 30-second ticker. Preserve `SentForDueAt` only if the requested due time is unchanged. Validate non-empty unique IDs, RFC3339 UTC dates, at most 500 requests, and bounded text. Mark sent only after `Sender.Send` succeeds.
- [ ] **Step 4: Verify and commit**
Run:
```bash
gofmt -w internal/core/notifications/*.go
GOCACHE=/tmp/verstak-go-cache go test ./internal/core/notifications ./internal/core/... -count=1
git diff --check
```
Commit/push:
```bash
git add internal/core/notifications
git commit -m "feat: add persisted notification scheduler"
git push https://github.com/mirivlad/verstak.git main
git push mirror main
```
### Task 2: Permission-gated core/plugin notification API
**Files:**
- Modify: `main.go`
- Modify: `internal/api/app.go`
- Modify: `internal/api/app_test.go`
- Modify: `internal/core/permissions/registry.go`
- Modify: `frontend/src/lib/plugin-host/VerstakPluginAPI.js`
- Modify: `frontend/tests/plugin-api-contributions-test.mjs`
**Interfaces:**
- Core capability: `verstak/core/notifications/v1`.
- Permission: `notifications.schedule`, non-dangerous.
- Bound methods: `ReplacePluginNotifications(pluginID string, items []notifications.Request) string` and `ClearPluginNotifications(pluginID string) string`.
- Plugin methods: `api.notifications.replace(items)` and `api.notifications.clear()`.
- [ ] **Step 1: Add failing backend and host-API tests**
```go
func TestReplacePluginNotificationsRequiresCapabilityAndPermission(t *testing.T) {
app, scheduler := newNotificationTestApp(t, manifestWithoutNotificationPermission)
if got := app.ReplacePluginNotifications("example", []notifications.Request{{ID: "r", DueAt: "2026-07-14T10:00:00Z", Title: "R"}}); got == "" {
t.Fatal("missing permission was accepted")
}
if scheduler.replaceCalls != 0 { t.Fatalf("replace calls = %d", scheduler.replaceCalls) }
}
```
The JS test mocks `App.ReplacePluginNotifications`, calls `api.notifications.replace`, and asserts both plugin ID forwarding and a namespaced rejection for a non-empty backend error.
- [ ] **Step 2: Confirm red**
```bash
GOCACHE=/tmp/verstak-go-cache go test ./internal/api -run TestReplacePluginNotificationsRequiresCapabilityAndPermission -count=1
node frontend/tests/plugin-api-contributions-test.mjs
```
Expected: missing API methods.
- [ ] **Step 3: Implement guarded bridge**
Register the capability in `main.go`, construct the scheduler, and guard bound calls with both existing access helpers before forwarding. Add `notifications.schedule` to the registry. Add the public host methods using `callBackendErrorString`; do not expose `window.runtime` to plugins.
- [ ] **Step 4: Verify and commit**
```bash
gofmt -w main.go internal/api/app.go internal/api/app_test.go internal/core/permissions/registry.go
GOCACHE=/tmp/verstak-go-cache go test ./internal/api -count=1
node frontend/tests/plugin-api-contributions-test.mjs
git diff --check
```
```bash
git add main.go internal/api/app.go internal/api/app_test.go internal/core/permissions/registry.go frontend/src/lib/plugin-host/VerstakPluginAPI.js frontend/tests/plugin-api-contributions-test.mjs
git commit -m "feat: expose scheduled notifications to plugins"
git push https://github.com/mirivlad/verstak.git main
git push mirror main
```
### Task 3: Todo reminder synchronization and Wails lifecycle
**Files:**
- Modify: `main.go`
- Modify: `internal/api/app.go`
- Modify: `internal/api/app_test.go`
- Modify: `../verstak-official-plugins/plugins/todo/plugin.json`
- Modify: `../verstak-official-plugins/plugins/todo/frontend/src/index.js`
- Modify: `../verstak-official-plugins/plugins/todo/locales/en.json`
- Modify: `../verstak-official-plugins/plugins/todo/locales/ru.json`
- Modify: `../verstak-official-plugins/scripts/smoke-todo-plugin.js`
**Interfaces:**
- `App.DomReady(ctx)` initializes Wails notifications before starting the scheduler.
- `App.Shutdown(ctx)` stops it before Wails cleanup.
- Todo replaces the complete reminder list only after Todo persistence succeeds and after Todo data loads.
- [ ] **Step 1: Add failing lifecycle and Todo smoke tests**
The App test uses function variables for Wails calls and proves initialize → start and stop → cleanup order. The Todo smoke mock records `notifications.replace`; it asserts one open reminder request with the Todo ID and a later empty replacement after completion or deletion.
- [ ] **Step 2: Confirm red**
```bash
GOCACHE=/tmp/verstak-go-cache go test ./internal/api -run 'TestDomReadyInitializesNotificationsBeforeScheduler|TestShutdownStopsNotifications' -count=1
cd ../verstak-official-plugins && node scripts/smoke-todo-plugin.js
```
Expected: lifecycle methods and schedule calls are absent.
- [ ] **Step 3: Implement lifecycle and Todo desired state**
Use `runtime.InitializeNotifications`, `runtime.SendNotification`, and `runtime.CleanupNotifications` only in core. Register `OnDomReady`/ `OnShutdown` in Wails options. Todo maps open valid `reminderAt` values to ISO UTC using `new Date(todo.reminderAt).toISOString()`, localizes title/body, and calls `api.notifications.replace`. A scheduling failure reports status but never rolls back already-saved Todo data.
- [ ] **Step 4: Verify and commit both repositories**
```bash
GOCACHE=/tmp/verstak-go-cache go test ./internal/api -count=1
cd ../verstak-official-plugins && node scripts/smoke-todo-plugin.js && ./scripts/check.sh
```
```bash
cd ../verstak-desktop
git add main.go internal/api/app.go internal/api/app_test.go
git commit -m "feat: start native notifications with desktop app"
git push https://github.com/mirivlad/verstak.git main
git push mirror main
cd ../verstak-official-plugins
git add plugins/todo scripts/smoke-todo-plugin.js
git commit -m "feat: schedule native Todo reminders"
git push https://github.com/mirivlad/verstak-official-plugins.git main
git push mirror main
```
### Task 4: Windows/Linux tray, close policy, and single instance
**Files:**
- Create: `internal/shell/tray/controller.go`
- Create: `internal/shell/tray/controller_test.go`
- Modify: `main.go`
- Modify: `internal/api/app.go`
- Modify: `internal/api/app_test.go`
- Modify: `go.mod`
- Modify: `go.sum`
**Interfaces:**
- `tray.Start(icon []byte, actions Actions) error`, with `Actions.Show` and `Actions.Quit`.
- `App.BeforeClose(ctx) bool` hides and returns true unless `App.Quit()` made shutdown explicit.
- `App.ShowWindow()` reveals the existing window.
- [ ] **Step 1: Add failing close-policy and tray action tests**
```go
func TestBeforeCloseHidesWindowUntilExplicitQuit(t *testing.T) {
app, window := newWindowTestApp(t)
if prevent := app.BeforeClose(context.Background()); !prevent || window.hideCalls != 1 { t.Fatal("ordinary close must hide") }
app.Quit()
if prevent := app.BeforeClose(context.Background()); prevent { t.Fatal("explicit quit was prevented") }
}
```
Use a fake tray adapter to assert exactly two labels, **Show Verstak** and **Quit**, and one callback invocation per click.
- [ ] **Step 2: Confirm red**
```bash
GOCACHE=/tmp/verstak-go-cache go test ./internal/api -run TestBeforeCloseHidesWindowUntilExplicitQuit -count=1
GOCACHE=/tmp/verstak-go-cache go test ./internal/shell/tray -count=1
```
Expected: absent packages/methods.
- [ ] **Step 3: Implement adapter and Wails wiring**
Use `fyne.io/systray`. The production adapter calls `RunWithExternalLoop` so the
Windows native message loop runs alongside Wails, embeds a multi-resolution
ICO on Windows and PNG on Linux, routes one left click to `app.ShowWindow`, and
leaves the platform-native right-click menu active. `main.go` registers
`OnBeforeClose` and uses `options.SingleInstanceLock` whose second launch calls
`app.ShowWindow`. Do not embed ignored Wails-generated files from `build/`.
- [ ] **Step 4: Verify and commit**
```bash
gofmt -w main.go internal/api/app.go internal/api/app_test.go internal/shell/tray/*.go
GOCACHE=/tmp/verstak-go-cache go test ./internal/shell/tray ./internal/api -count=1
./scripts/build.sh
! rg -n 'getlantern|appindicator' go.mod go.sum packaging scripts/build.sh
git diff --check
```
```bash
git add main.go internal/api/app.go internal/api/app_test.go internal/shell/tray go.mod go.sum
git commit -m "feat: keep desktop app in system tray"
git push https://github.com/mirivlad/verstak.git main
git push mirror main
```
### Task 5: Packaging and public README screenshots
**Files:**
- Modify: `packaging/deb/control`
- Modify: `scripts/build.sh`
- Modify: `scripts/package-appimage.sh`
- Modify: `scripts/test-package-formats.sh`
- Modify: `README.md`
- Create: `README.ru.md`
- Create: `docs/screenshots/overview.png`
- Create: `docs/screenshots/workspace-files-notes.png`
- Create: `docs/screenshots/activity-journal.png`
- [ ] **Step 1: Add failing package-contract checks**
Add assertions that the Linux build guidance, Debian metadata, and AppImage
packager no longer require the removed AppIndicator backend.
- [ ] **Step 2: Confirm red**
Run: `./scripts/test-package-formats.sh`
Expected: the first tray dependency assertion fails.
- [ ] **Step 3: Implement portable package support**
Keep Debian and AppImage focused on the Wails/WebKitGTK runtime. Do not add a
tray-specific AppIndicator dependency. Keep the Windows system-WebView2 policy
unchanged.
- [ ] **Step 4: Install the supplied public README sources**
Replace this repository's `README.md` with the supplied English source at the workspace root and add the supplied Russian source as `README.ru.md`. Add an image strip after “What is Verstak?” / “Что такое Верстак?” that links to three tracked PNG screenshots and uses matching localized alt text.
- [ ] **Step 5: Produce real screenshots**
Build and run the desktop app against `/home/mirivlad/Nextcloud/Verstak/VerstakVault` on the active X display. Capture:
1. Overview showing recent work and entry points;
2. a populated workspace Files/Notes view;
3. Activity with a suggested session and its Journal review.
Use `scrot` or ImageMagick `import`, crop only surrounding desktop chrome, inspect every PNG visually, and do not add generated mockups. Remove all non-user-safe text from the test vault before capture.
- [ ] **Step 6: Verify documentation and package contracts, commit, push**
```bash
./scripts/test-package-formats.sh
git diff --check
test -s docs/screenshots/overview.png
test -s docs/screenshots/workspace-files-notes.png
test -s docs/screenshots/activity-journal.png
```
```bash
git add packaging/deb/control scripts/build.sh scripts/package-appimage.sh scripts/test-package-formats.sh README.md README.ru.md docs/screenshots
git commit -m "docs: add public README and product screenshots"
git push https://github.com/mirivlad/verstak.git main
git push mirror main
```
### Task 6: Complete verification and GitHub alpha releases
**Files:**
- No source changes expected unless verification exposes a defect.
- [ ] **Step 1: Run all checks**
```bash
GOCACHE=/tmp/verstak-go-cache ./scripts/test.sh
./scripts/check.sh
./scripts/test-package-formats.sh
./scripts/test-build-windows.sh
cd ../verstak-official-plugins && ./scripts/check.sh && ./scripts/test-package-portable.sh && ./scripts/test-publish-github-release.sh
```
- [ ] **Step 2: Build and checksum both alpha releases**
```bash
cd ../verstak-official-plugins && ./scripts/release.sh v0.1.0-alpha.2 && (cd release && sha256sum --check SHA256SUMS)
cd ../verstak-desktop && ./scripts/release.sh v0.1.0-alpha.2 && (cd release && sha256sum --check SHA256SUMS)
```
- [ ] **Step 3: Publish and inspect releases**
```bash
cd ../verstak-official-plugins && ./scripts/publish-github-release.sh v0.1.0-alpha.2
cd ../verstak-desktop && ./scripts/publish-github-release.sh v0.1.0-alpha.2
gh release view v0.1.0-alpha.2 -R mirivlad/verstak-official-plugins
gh release view v0.1.0-alpha.2 -R mirivlad/verstak
```
The final report distinguishes automated verification from the two required real desktop manual checks: tray close/show/quit and a near-future Todo notification while the window is hidden.

View File

@ -1,182 +0,0 @@
# Native Notifications and System Tray Design
**Status:** implemented; tray reliability update recorded on 2026-07-15
## Goal
Deliver Todo reminders as native notifications on Windows and Linux, and keep
Verstak running in the system tray when its main window is closed. The feature
must work in the portable Windows archive, Debian package, and AppImage.
## Scope
- The desktop core owns notification delivery, scheduling, and tray lifetime.
- `verstak.todo` owns the Todo-specific reminder policy and text.
- No new official notifications plugin is introduced. A dynamic plugin cannot
call Wails directly, so it cannot be the native-notification transport.
- macOS is out of scope for this alpha. The interfaces remain platform-neutral
where that costs nothing.
## Tray behavior
On Windows and Linux, a tray icon is initialized after Wails reaches
`OnDomReady`. Its menu contains exactly two actions:
1. **Show Verstak** — shows and focuses the existing main window.
2. **Quit** — exits the process deliberately.
One left click restores and focuses the existing main window. A native right
click opens the menu. Closing the main window with its window-manager close
control hides the window only after the tray has successfully initialized; it
then keeps the process, plugins, local browser receiver, and reminder scheduler
alive. If tray initialization fails or the native message loop exits, the
ordinary close path exits normally rather than leaving an unreachable process.
The quit action allows the close lifecycle to finish and exits normally.
The app has a single-instance lock. If a user launches the executable while an
instance is hidden in the tray, the existing instance shows its window instead
of creating a second process.
The implementation uses `fyne.io/systray` through a small
`internal/shell/tray` adapter. `RunWithExternalLoop` starts the Windows native
message loop without making Wails relinquish ownership of its GUI lifecycle.
The icon is a source-controlled multi-resolution ICO on Windows (16, 20, 24,
32, 48, and 256 pixels with transparency) and a PNG on Linux. Both are embedded
in the binary, so a clean build does not depend on ignored Wails-generated
files. Tray readiness is published only after icon, tooltip, and menu creation
all succeed; lifecycle diagnostics are logged for startup, readiness, clicks,
failure fallback, and shutdown.
## Notification capability and permission
The core registers the capability:
```
verstak/core/notifications/v1
```
Plugins that use it must both require that capability and declare the
`notifications.schedule` permission. The plugin-host API exposes only two
operations within the calling plugin namespace:
```
api.notifications.replace(items)
api.notifications.clear()
```
`replace` is an atomic desired-state replacement, not an append operation. An
item contains a plugin-local stable `id`, an ISO-8601 UTC `dueAt`, a title, and
a body. The core supplies the plugin ID and rejects calls from disabled,
missing-permission, or undeclared-capability plugins. It also validates empty
IDs, duplicate IDs, invalid timestamps, and unsafe oversized text.
No plugin can send arbitrary immediate native notifications or address another
plugin's schedules in this alpha.
## Scheduler and persistence
`internal/core/notifications` persists one canonical schedule file at:
```
<vault>/.verstak/notifications/schedules.json
```
Each record contains `{pluginId, id, dueAt, title, body, sentForDueAt}`.
The composite `(pluginId, id)` is unique. Replacing an item with the same due
time preserves `sentForDueAt`; changing `dueAt` clears it. Replacing a plugin's
list removes its stale records. This provides deterministic cancellation for
completed, deleted, and rescheduled Todos.
The manager starts after Wails reaches `OnDomReady`, initializes Wails native
notifications, and evaluates the persisted schedule immediately and then at
least every 30 seconds. A sender is injected behind an interface for unit
tests. After a successful delivery, the manager atomically records
`sentForDueAt`. A delivery error leaves the schedule pending and is logged for
a later retry.
An expired record that has not been sent is delivered once after the next app
start. A record already sent for its current due time is never sent again.
Completely quitting Verstak stops the scheduler: no separate daemon or OS
background service is added. Hiding the window in the tray does **not** stop it.
The core calls `CleanupNotifications` during shutdown, including on Linux where
it releases the D-Bus connection.
## Todo behavior
`verstak.todo` adds the core notifications capability to `requires` and adds
the `notifications.schedule` permission. After every successful Todo storage
write, it derives the complete desired reminder list:
- include only open Todos with a valid `reminderAt`;
- use the Todo ID as the stable notification ID;
- convert local `datetime-local` input to an ISO-8601 UTC instant;
- use the Todo title in the notification body and locale-aware reminder text;
- call `api.notifications.replace` with the full list.
The same replacement runs after loading persisted Todos, so a transient
schedule-write failure repairs itself next time the Todo view is opened. A
schedule API failure does not roll back Todo data; the UI reports the failure
instead. The existing in-view overdue/reminder badge remains useful context and
is not removed.
## Packaging
`fyne.io/systray` supplies the Windows message loop and uses the session D-Bus
on Linux. It does not require the removed AppIndicator development or runtime
package. The Windows release build still uses `x86_64-w64-mingw32-gcc` for the
Wails application itself.
The existing AppImage packager traverses `ldd` for the desktop executable and
copies non-glibc runtime libraries; it does not require a tray-specific shared
library.
## Public README and product screenshots
The workspace-root `README.md` and `README.ru.md` supplied by the maintainer
become the public repository documents: the English source replaces this
repository's `README.md`, and the Russian source is committed as
`README.ru.md`. Their existing language links and public-release instructions
are retained.
Three screenshots are captured from the real desktop application using the
test vault, then committed under `docs/screenshots/`:
1. `overview.png` — returning to recent work and useful next actions;
2. `workspace-files-notes.png` — ordinary vault files and Markdown notes in a
workspace;
3. `activity-journal.png` — review of a factual activity session as a Journal
entry.
Both README variants include the same three images with localized alt text.
They are factual UI captures, not generated illustrative mockups. Capture data
must be limited to the disposable test vault and inspected before commit so no
credentials or personal content are published.
## Test and manual verification
Automated tests cover:
- schedule replacement, cancellation, rescheduling, persistence, one-time
overdue delivery, failed-send retry, and permission/capability rejection;
- Todo desired-list derivation and calls after create/edit/status/delete;
- close policy: ordinary close hides only after tray readiness, while explicit
quit permits shutdown;
- tray controller action wiring, readiness/failure fallback, idempotent stop,
left-click reveal, and second-instance window reveal;
- true multi-resolution Windows ICO data, Linux PNG data, and Linux/Windows
build and package dependency expectations.
Manual smoke tests are required because neither unit tests nor Playwright can
assert a real desktop notification area or OS toast:
1. On Linux and Windows, start Verstak, verify the tray icon and tooltip, use
one left click to reveal the window, use the right-click menu to reveal it,
close the window, and use **Quit** to terminate it.
2. Set a Todo reminder for a near future time, hide the window in the tray, and
observe one native notification.
3. Quit before a future reminder, relaunch after it expires, and observe one
overdue notification with no duplicate on the next scheduler scan.
4. Inspect all three README screenshots at their committed size and confirm
that they show only intended test-vault data and explain the feature named
in their localized captions.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

View File

@ -1,96 +0,0 @@
# UI Polish Milestone
## Assumptions
- This milestone is visual polish only: no backend API changes, no plugin contract changes, and no feature removal.
- English remains the baseline UI language for this pass. Hardcoded Russian strings that are visible in core UI should be converted to English where touched.
- Official plugins are plain JS bundles with injected CSS. Introducing a new shared UI package would be larger than this milestone, so shared primitives will be expressed as global design tokens/classes plus consistent local CSS in each bundle.
- Desktop workbench density should stay compact. The goal is consistency, not a marketing-style redesign.
## Audit Findings
### Existing Tokens
- `frontend/src/App.svelte` already defines partial global button classes: `btn-primary`, `btn-secondary`, `btn-danger`, `btn-ghost`, `btn-icon`, plus global button focus/disabled behavior.
- Most shell and plugin surfaces hardcode the same dark palette directly: `#1a1a2e`, `#16213e`, `#0f3460`, `#4ecca3`, `#e94560`, `#8b8ba8`.
- There are no named CSS variables for semantic surface, selected row, border, muted text, warning, or focus ring.
- Plugin bundles repeat their own toolbar/button/list/empty/error styles instead of reusing shell-level primitives.
### Repeated Patterns
- Page/workspace headers: `WorkspaceHost.svelte`, `WorkbenchHost.svelte`, `ViewContainer.svelte`, plugin toolbars.
- Tab bars: workspace tabs use a custom active style separate from selected rows and sidebar active states.
- Toolbars: Files, Notes, Search, Activity, Journal, Browser Inbox, and default editor use similar but inconsistent dark toolbar rules.
- List rows: Files rows, Notes rows, Activity rows, Journal rows, Secrets list items, Browser Inbox rows, Global Search results, WorkspaceTree rows.
- Cards/panels: Today panels, worklog suggestions, Secrets detail/form, Browser Inbox detail text, plugin manager cards.
- Empty/error states: many are plain centered text or show technical details as primary text.
### Candidate Primitives
Implement as global classes/tokens first, then apply locally:
- PageHeader / WorkspaceHeader: compact title, metadata, right actions/search.
- TabBar / TabButton: consistent active tab, hover, focus.
- Toolbar / ToolbarGroup: grouped controls with separators and predictable height.
- Button variants: primary, secondary, ghost, danger, icon.
- ListRow: hover, selected, disabled, metadata line, actions zone.
- Card: flat desktop panel with 6px radius and subtle border.
- SplitPane: list/detail grid with shared border and selected item states.
- EmptyState: centered title, hint, optional action.
- InlineAlert: readable message plus optional details/debug text.
- StatusBadge: compact semantic label for type, status, warning/error.
- Menu: context menu with same surface, hover, danger item, separator.
## Implementation Plan
1. Add semantic CSS variables and global primitive classes in `frontend/src/App.svelte`.
- Colors: background, surface, surface-muted, surface-hover, surface-selected, border, border-strong, text-primary, text-secondary, text-muted, accent, accent-muted, danger, warning, success.
- Spacing: 4/8/12/16/24/32 via variables.
- Radius, font sizes, focus ring, and modest elevation tokens.
2. Normalize shell surfaces.
- Update `WorkspaceHost.svelte`, `WorkbenchHost.svelte`, `ViewContainer.svelte`, `Sidebar.svelte`, `WorkspaceTree.svelte`, `GlobalSearch.svelte`, and `TodaySurface.svelte` to use tokens and shared state rules.
- Convert visible Russian shell search/empty strings to English.
- Keep plugin manager architecture untouched; only global tokens should make it less visually divergent.
3. Normalize Files.
- Group toolbar into Navigation, Create, Selection, Clipboard/More, and filter/sort areas.
- Keep existing context menu and operations, restyle menu with shared surface/hover/danger states.
- Make single row click open when no modifier is used; keep ctrl/meta/shift selection behavior and keyboard selection.
- Reduce actions column visual noise by showing secondary row actions on hover/selection and using a compact More/action group.
4. Normalize Notes.
- Make New Note the primary page action.
- Keep filter/sort inside the toolbar group.
- Make entire note row readable and clickable; row actions remain visible enough and use icon-button states.
- Use shared empty/status/error styling.
5. Normalize Search.
- Render results as compact rows/cards with type badge/icon, title/path, secondary match reason, and compact Open button.
- Show provider errors as inline alert-style secondary information, not as raw main status text.
6. Normalize Activity and Journal.
- Add human-readable event labels such as "Workspace selected", "File opened", "Note edited", and "Work session detected".
- Move raw event names/source IDs into a details block or secondary metadata.
- Style plugin errors through inline alert patterns.
- Present worklog suggestions as cards with consistent title/summary/minutes actions.
7. Normalize Secrets and Browser Inbox.
- Keep split-pane list/detail layout.
- Use shared list rows, detail card/form surfaces, and empty states.
- Render "Value hidden" as password-like hidden field state.
- Use button variants: primary for save/create, secondary for copy/edit/remove where appropriate, danger for delete/clear/remove destructive actions.
8. Verify.
- Run desktop frontend build and relevant Playwright checks if available.
- Run official plugin smoke/check script.
- Capture before/after screenshots or save available after screenshots under `docs/ui-polish-assets/`.
## Out of Scope
- Backend API or Wails method changes.
- Plugin manifest/contribution contract changes.
- New heavy UI dependencies.
- Full i18n extraction.
- Redesigning the product information architecture.
- Rebuilding official plugins into Svelte packages.

View File

@ -1,173 +0,0 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
test.describe('Activity workflow', () => {
let consoleCollector;
test.beforeEach(async ({ page }) => {
consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
});
test.afterEach(async () => {
consoleCollector.assertNoErrors();
});
test('workspace activity explains empty event flow', async ({ page }) => {
await page.getByRole('tab', { name: 'Activity' }).click();
const activity = page.locator('.activity-root');
await expect(activity).toBeVisible({ timeout: 10000 });
await expect(activity.locator('.activity-title')).toContainText('Activity');
await expect(activity.locator('.activity-count')).toHaveText('0 events');
await expect(activity.locator('.activity-empty')).toContainText('No activity events yet');
await expect(activity.locator('.activity-empty')).toContainText('File changes, browser captures, and conversions will appear here');
await expect(activity.locator('[data-activity-action="clear"]')).toBeDisabled();
});
test('clearing activity requires destructive confirmation for the current case', async ({ page }) => {
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.activity', {
'events:workspace:Project': [{
activityId: 'activity-to-clear',
occurredAt: '2026-06-30T08:00:00.000Z',
type: 'note.saved',
title: 'Keep until confirmed',
workspaceRootPath: 'Project',
}],
});
});
await page.getByRole('tab', { name: 'Activity' }).click();
const activity = page.locator('.activity-root');
await expect(activity.locator('.activity-count')).toHaveText('1 event');
await activity.locator('[data-activity-action="clear"]').click();
const confirmation = activity.locator('[data-activity-clear-confirmation]');
await expect(confirmation).toBeVisible();
await expect(confirmation).toContainText(/Project|Дело/);
await expect(activity.locator('.activity-count')).toHaveText('1 event');
await confirmation.locator('[data-activity-clear-cancel]').click();
await expect(confirmation).toHaveCount(0);
await expect(activity.locator('.activity-count')).toHaveText('1 event');
await activity.locator('[data-activity-action="clear"]').click();
await activity.locator('[data-activity-clear-confirm]').click();
await expect(activity.locator('.activity-count')).toHaveText('0 events');
});
test('workspace activity keeps raw events and renders factual work session candidates', async ({ page }) => {
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.activity', {
'events:workspace:Project': [
{
activityId: 'activity-e2e-capture',
occurredAt: '2026-06-30T08:00:00.000Z',
type: 'browser.capture.selection',
title: 'Research Capture',
summary: 'Selected text from the article',
sourcePluginId: 'verstak.browser-inbox',
workspaceRootPath: 'Project',
},
{
activityId: 'activity-e2e-note',
occurredAt: '2026-06-30T08:20:00.000Z',
type: 'note.saved',
title: 'Saved note',
summary: 'Project/Notes/Research Capture.md',
sourcePluginId: 'verstak.files',
workspaceRootPath: 'Project',
},
{
activityId: 'activity-e2e-open',
occurredAt: '2026-06-30T08:30:00.000Z',
type: 'file.opened',
title: 'Selected file',
summary: 'Project/Notes/Research Capture.md',
sourcePluginId: 'verstak.files',
workspaceRootPath: 'Project',
},
],
});
});
await page.getByRole('tab', { name: 'Activity' }).click();
const activity = page.locator('.activity-root');
await expect(activity.locator('.activity-count')).toHaveText('3 events');
await expect(activity.locator('[data-activity-id="activity-e2e-capture"]')).toContainText('Research Capture');
await expect(activity.locator('[data-activity-id="activity-e2e-note"]')).toContainText('Saved note');
await expect(activity.locator('[data-activity-id="activity-e2e-open"]')).toContainText('Selected file');
const candidateSection = activity.locator('[data-activity-section="work-session-candidates"]');
await expect(candidateSection).toContainText('Possible journal entries');
const candidate = candidateSection.locator('[data-work-session-candidate]');
await expect(candidate).toHaveCount(1);
await expect(candidate).toContainText('Deal: Project');
await expect(candidate).toContainText('Estimated duration: 10 min');
await expect(candidate).toContainText('Activities: 2');
await expect(candidate).not.toContainText('Project work on');
await expect(candidate.locator('[data-work-session-action="review"]')).toBeVisible();
await expect(candidate.locator('[data-work-session-action="dismiss"]')).toBeVisible();
});
test('Review opens an empty Journal form with selectable candidate activities', async ({ page }) => {
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.activity', {
'events:workspace:Project': [
{
activityId: 'review-capture',
occurredAt: '2026-06-30T08:00:00.000Z',
type: 'browser.capture.selection',
title: 'Research Capture',
workspaceRootPath: 'Project',
},
{
activityId: 'review-note',
occurredAt: '2026-06-30T08:20:00.000Z',
type: 'note.saved',
title: 'Saved note',
workspaceRootPath: 'Project',
},
],
});
});
await page.getByRole('tab', { name: 'Activity' }).click();
await page.locator('[data-work-session-candidate] [data-work-session-action="review"]').click();
await expect(page.getByRole('tab', { name: 'Journal' })).toHaveAttribute('aria-selected', 'true');
const journal = page.locator('.journal-root');
await expect(journal).toBeVisible({ timeout: 10000 });
await expect(journal.locator('[data-journal-candidate]')).toContainText('Deal: Project');
await expect(journal.locator('[data-journal-candidate]')).toContainText('Estimated duration: 10 min');
await expect(journal.locator('[data-journal-input="title"]')).toHaveValue('');
await expect(journal.locator('[data-journal-input="summary"]')).toHaveValue('');
await expect(journal.locator('[data-journal-input="minutes"]')).toHaveValue('10');
const activityInputs = journal.locator('[data-journal-candidate-activity]');
await expect(activityInputs).toHaveCount(2);
await expect(activityInputs.nth(0)).toBeChecked();
await expect(activityInputs.nth(1)).toBeChecked();
await journal.locator('[data-journal-input="title"]').fill('Review research capture');
await journal.locator('[data-journal-input="summary"]').fill('Read the capture and updated the project note.');
await activityInputs.nth(1).uncheck();
await journal.locator('[data-journal-action="save-entry"]').click();
await expect(journal).toContainText('Review research capture');
await expect(journal).toContainText('10 min');
const stored = await page.evaluate(async () => {
const result = await window.go.api.App.ReadPluginSettings('verstak.journal');
return Array.isArray(result) ? result[0]['worklog:workspace:Project'] : result['worklog:workspace:Project'];
});
await expect(stored).toEqual([expect.objectContaining({
title: 'Review research capture',
summary: 'Read the capture and updated the project note.',
sourceCandidateId: expect.any(String),
activityIds: ['review-capture'],
})]);
});
});

View File

@ -1,157 +0,0 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
test.describe('Browser workflow', () => {
let consoleCollector;
test.beforeEach(async ({ page }) => {
consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
});
test.afterEach(async () => {
consoleCollector.assertNoErrors();
});
test('global inbox explains empty capture flow and exposes assignment filters', async ({ page }) => {
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser' }).click();
const inbox = page.locator('.browser-inbox-root');
await expect(inbox).toBeVisible({ timeout: 10000 });
await expect(inbox.locator('.browser-inbox-title')).toHaveText('Browser');
await expect(inbox.locator('.browser-inbox-count')).toHaveText('0 items');
await expect(inbox.locator('.browser-inbox-empty')).toContainText('No browser materials yet');
await expect(inbox.locator('.browser-inbox-empty')).toContainText('Send a page, selection, or link from the extension');
await expect(inbox.locator('[data-browser-inbox-filter="status"]')).toBeVisible();
await expect(inbox.locator('[data-browser-inbox-filter="workspace"]')).toBeVisible();
await expect(inbox.locator('[data-browser-inbox-action="clear"]')).toBeDisabled();
});
test('workspace inbox renders stored captures with conversion actions', async ({ page }) => {
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.browser-inbox', {
'captures:global': [{
captureId: 'capture-e2e-1',
capturedAt: '2026-06-30T08:00:00.000Z',
kind: 'file',
url: 'https://example.com/report',
title: 'Research Report',
domain: 'example.com',
text: 'Selected page text',
fileName: 'report.txt',
fileText: 'Report file contents',
workspaceRootPath: 'Project',
browserName: 'Firefox',
}],
});
});
await page.getByRole('tab', { name: 'Browser' }).click();
const inbox = page.locator('.browser-inbox-root');
await expect(inbox.locator('.browser-inbox-count')).toHaveText('1 item');
await expect(inbox.locator('[data-browser-capture-id="capture-e2e-1"]')).toContainText('Research Report');
await expect(inbox.locator('.browser-inbox-detail-title')).toHaveText('Research Report');
await expect(inbox.locator('.browser-inbox-meta')).toContainText('example.com');
await expect(inbox.locator('.browser-inbox-text').first()).toContainText('Selected page text');
await expect(inbox.locator('[data-browser-inbox-action="create-note"]')).toBeVisible();
await expect(inbox.locator('[data-browser-inbox-action="create-link"]')).toBeVisible();
await expect(inbox.locator('[data-browser-inbox-action="create-file"]')).toBeVisible();
await expect(inbox.locator('[data-browser-inbox-action="remove"]')).toBeVisible();
});
test('global inbox assigns, reassigns, filters, marks, and deletes captures', async ({ page }) => {
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.browser-inbox', {
'captures:global': [
{
captureId: 'inbox-unassigned',
capturedAt: '2026-06-30T08:20:00.000Z',
kind: 'page',
url: 'https://example.com/unassigned',
title: 'Unassigned research',
domain: 'example.com',
},
{
captureId: 'inbox-client-processed',
capturedAt: '2026-06-30T08:10:00.000Z',
kind: 'link',
url: 'https://client.example.com/processed',
title: 'Processed client link',
domain: 'client.example.com',
workspaceRootPath: 'ClientA',
processed: true,
},
{
captureId: 'inbox-project-open',
capturedAt: '2026-06-30T08:00:00.000Z',
kind: 'selection',
title: 'Project quote',
text: 'A project quote',
workspaceRootPath: 'Project',
},
],
});
});
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser' }).click();
const inbox = page.locator('.browser-inbox-root');
await expect(inbox.locator('.browser-inbox-count')).toHaveText('3 items');
const statusFilter = inbox.locator('[data-browser-inbox-filter="status"]');
const workspaceFilter = inbox.locator('[data-browser-inbox-filter="workspace"]');
await statusFilter.selectOption('unassigned');
await expect(inbox.locator('[data-browser-capture-id="inbox-unassigned"]')).toBeVisible();
await expect(inbox.locator('[data-browser-capture-id="inbox-client-processed"]')).toHaveCount(0);
await statusFilter.selectOption('all');
await workspaceFilter.selectOption('ClientA');
await expect(inbox.locator('[data-browser-capture-id="inbox-client-processed"]')).toBeVisible();
await expect(inbox.locator('[data-browser-capture-id="inbox-project-open"]')).toHaveCount(0);
await workspaceFilter.selectOption('');
await statusFilter.selectOption('unprocessed');
await expect(inbox.locator('[data-browser-capture-id="inbox-unassigned"]')).toBeVisible();
await expect(inbox.locator('[data-browser-capture-id="inbox-project-open"]')).toBeVisible();
await expect(inbox.locator('[data-browser-capture-id="inbox-client-processed"]')).toHaveCount(0);
await statusFilter.selectOption('all');
const assignment = inbox.locator('[data-browser-inbox-assignment="inbox-unassigned"]');
await assignment.selectOption('ClientA');
await expect.poll(async () => page.evaluate(async () => {
const result = await window.go.api.App.ReadPluginSettings('verstak.browser-inbox');
const settings = Array.isArray(result) ? result[0] : result;
return settings['captures:global'].find((capture) => capture.captureId === 'inbox-unassigned').workspaceRootPath;
})).toBe('ClientA');
await inbox.locator('[data-browser-inbox-assignment="inbox-unassigned"]').selectOption('Project');
await expect.poll(async () => page.evaluate(async () => {
const result = await window.go.api.App.ReadPluginSettings('verstak.browser-inbox');
const settings = Array.isArray(result) ? result[0] : result;
return settings['captures:global'].find((capture) => capture.captureId === 'inbox-unassigned').workspaceRootPath;
})).toBe('Project');
await inbox.locator('[data-browser-inbox-action="clear-assignment"]').click();
await expect.poll(async () => page.evaluate(async () => {
const result = await window.go.api.App.ReadPluginSettings('verstak.browser-inbox');
const settings = Array.isArray(result) ? result[0] : result;
return settings['captures:global'].find((capture) => capture.captureId === 'inbox-unassigned').workspaceRootPath || '';
})).toBe('');
await inbox.locator('[data-browser-inbox-action="toggle-processed"]').click();
await expect.poll(async () => page.evaluate(async () => {
const result = await window.go.api.App.ReadPluginSettings('verstak.browser-inbox');
const settings = Array.isArray(result) ? result[0] : result;
return settings['captures:global'].find((capture) => capture.captureId === 'inbox-unassigned').processed;
})).toBe(true);
await inbox.locator('[data-browser-inbox-action="toggle-processed"]').click();
await inbox.locator('[data-browser-inbox-action="remove"]').click();
await expect.poll(async () => page.evaluate(async () => {
const result = await window.go.api.App.ReadPluginSettings('verstak.browser-inbox');
const settings = Array.isArray(result) ? result[0] : result;
return settings['captures:global'].some((capture) => capture.captureId === 'inbox-unassigned');
})).toBe(false);
});
});

View File

@ -1,143 +0,0 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
test.describe('Command Palette', () => {
let consoleCollector;
test.beforeEach(async ({ page }) => {
consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
});
test.afterEach(async () => {
consoleCollector.assertNoErrors();
});
test('opens with keyboard, filters commands, and executes registered frontend handler', async ({ page }) => {
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' }).click();
await expect(page.locator('.pt-root')).toBeVisible({ timeout: 10000 });
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+K' : 'Control+K');
const palette = page.locator('.command-palette');
await expect(palette).toBeVisible();
await expect(palette.locator('[data-command-id="verstak.platform-test.show-version"]')).toBeVisible();
await palette.locator('[data-command-palette-input]').fill('version');
await expect(palette.locator('[data-command-id="verstak.platform-test.show-version"]')).toBeVisible();
await expect(palette.locator('[data-command-id="verstak.platform-test.run-tests"]')).not.toBeVisible();
await page.keyboard.press('Enter');
await expect(palette).not.toBeVisible();
await expect(page.locator('[data-command-palette-status="success"]')).toContainText('Show Version Info');
await expect(page.locator('[data-command-palette-status="success"]')).toContainText('handled');
});
test('Escape closes the palette without changing current view', async ({ page }) => {
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+K' : 'Control+K');
await expect(page.locator('.command-palette')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.locator('.command-palette')).not.toBeVisible();
await expect(page.locator('.workspace-host')).toBeVisible();
});
test('shows user workflow commands before diagnostics and opens workspace tools', async ({ page }) => {
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+K' : 'Control+K');
const palette = page.locator('.command-palette');
await expect(palette).toBeVisible();
const items = palette.locator('.command-palette-item');
await expect(items.nth(0)).toHaveAttribute('data-command-id', 'verstak.shell.open-overview');
await expect(items.nth(1)).toHaveAttribute('data-command-id', 'verstak.shell.open-files');
await expect(items.nth(2)).toHaveAttribute('data-command-id', 'verstak.shell.open-activity');
await expect(items.nth(3)).toHaveAttribute('data-command-id', 'verstak.shell.open-browser-inbox');
await palette.locator('[data-command-palette-input]').fill('activity');
await expect(palette.locator('[data-command-id="verstak.shell.open-activity"]')).toBeVisible();
await expect(palette.locator('[data-command-id="verstak.platform-test.run-tests"]')).not.toBeVisible();
await page.keyboard.press('Enter');
await expect(palette).not.toBeVisible();
await expect(page.getByRole('tab', { name: 'Activity' })).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.activity-root')).toBeVisible({ timeout: 10000 });
});
test('starts file creation workflows from user commands', async ({ page }) => {
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+K' : 'Control+K');
let palette = page.locator('.command-palette');
await palette.locator('[data-command-palette-input]').fill('markdown');
await expect(palette.locator('[data-command-id="verstak.shell.create-markdown"]')).toBeVisible();
await page.keyboard.press('Enter');
await expect(palette).not.toBeVisible();
await expect(page.getByRole('tab', { name: 'Files' })).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.files-root')).toBeVisible({ timeout: 10000 });
await expect(page.locator('[data-files-create-input]')).toBeVisible();
await page.locator('[data-files-create-input]').fill('Palette Note.md');
await page.locator('[data-files-create-confirm]').click();
await expect(page.locator('[data-file-name="Palette Note.md"]')).toBeVisible();
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+K' : 'Control+K');
palette = page.locator('.command-palette');
await palette.locator('[data-command-palette-input]').fill('text file');
await expect(palette.locator('[data-command-id="verstak.shell.create-text"]')).toBeVisible();
await page.keyboard.press('Enter');
await expect(page.locator('[data-files-create-input]')).toBeVisible();
await page.locator('[data-files-create-input]').fill('Palette Text.txt');
await page.locator('[data-files-create-confirm]').click();
await expect(page.locator('[data-file-name="Palette Text.txt"]')).toBeVisible();
});
test('runs sync workflow commands', async ({ page }) => {
await page.evaluate(async () => {
const err = await window.go.api.App.PluginSyncConfigure('verstak.sync', 'https://sync.example.test', 'alice', 'secret');
if (err) throw new Error(err);
});
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+K' : 'Control+K');
let palette = page.locator('.command-palette');
await palette.locator('[data-command-palette-input]').fill('sync now');
await expect(palette.locator('[data-command-id="verstak.shell.sync-now"]')).toBeVisible();
await page.keyboard.press('Enter');
await expect(palette).not.toBeVisible();
await expect(page.locator('[data-command-palette-status="success"]')).toContainText('Sync Now');
await expect(page.locator('[data-command-palette-status="success"]')).toContainText('handled');
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+K' : 'Control+K');
palette = page.locator('.command-palette');
await palette.locator('[data-command-palette-input]').fill('sync settings');
await expect(palette.locator('[data-command-id="verstak.shell.open-sync-settings"]')).toBeVisible();
await page.keyboard.press('Enter');
await expect(page.locator('.plugin-manager')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.modal[aria-label="Plugin Settings"]')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.modal-header h3')).toContainText('Sync');
});
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

@ -1,5 +1,5 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState, openPluginManager } from './helpers.js';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
test.describe('F: Default Editor Plugin', () => {
let consoleCollector;
@ -36,69 +36,6 @@ test.describe('F: Default Editor Plugin', () => {
await expect(textarea).toHaveValue('Buy groceries\nWrite tests');
});
test('secret link opens its exact secret and closing it restores the note preview', async ({ page }) => {
const notePath = 'Notes/Secret Link.md';
const noteContent = '# Secret link\n\n[Target secret](verstak-secret://target.secret)';
await page.evaluate(async ({ notePath, noteContent }) => {
const writeError = await window.go.api.App.WriteVaultTextFile(
'verstak.platform-test',
notePath,
noteContent,
{ createIfMissing: true, overwrite: true },
);
if (writeError) throw new Error(writeError);
const [result, openError] = await window.go.api.App.OpenWorkbenchResource('verstak.platform-test', {
kind: 'vault-file',
path: notePath,
extension: '.md',
context: { sourceView: 'notes', isInsideNotesFolder: true, notesMode: true },
});
if (openError) throw new Error(openError);
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
}, { notePath, noteContent });
const note = page.locator('[data-editor-mode="notes-markdown"]');
await expect(note).toBeVisible({ timeout: 10000 });
await expect(note.locator('[data-preview]')).toContainText('Target secret');
await note.locator('.secret-link').click();
const secrets = page.locator('.secrets-root');
await expect(secrets).toBeVisible({ timeout: 10000 });
await expect(secrets.locator('.secrets-item.active .secrets-item-title')).toHaveText('Target secret');
await expect(secrets.locator('.secrets-card h2')).toHaveText('Target secret');
await page.locator('.workbench-host .close-btn').click();
await expect(note).toBeVisible({ timeout: 10000 });
await expect(note.locator('[data-preview]')).toContainText('Target secret');
await expect(note.locator('[data-editor-textarea]')).toHaveCount(0);
await expect(note.locator('[data-save-state]')).toHaveText('');
const storedContent = await page.evaluate(async ({ notePath }) => {
const [content, readError] = await window.go.api.App.ReadVaultTextFile('verstak.platform-test', notePath);
if (readError) throw new Error(readError);
return content;
}, { notePath });
expect(storedContent).toBe(noteContent);
});
test('unavailable secret does not fall back to the first secret', async ({ page }) => {
await page.evaluate(async () => {
const [result, openError] = await window.go.api.App.OpenWorkbenchResource('verstak.default-editor', {
kind: 'secret',
path: 'missing.secret',
mode: 'view',
});
if (openError) throw new Error(openError);
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
});
const secrets = page.locator('.secrets-root');
await expect(secrets).toBeVisible({ timeout: 10000 });
await expect(secrets.locator('.secrets-item.active')).toHaveCount(0);
await expect(secrets.locator('.secrets-status.error')).toContainText(/unavailable|недоступен/i);
});
test('editor supports markdown toolbar split save reopen and revert', async ({ page }) => {
await page.evaluate(async () => {
const err = await window.go.api.App.WriteVaultTextFile(
@ -175,10 +112,10 @@ test.describe('F: Default Editor Plugin', () => {
const workbench = page.locator('.workbench-host');
await expect(workbench).toBeVisible({ timeout: 10000 });
const title = workbench.locator('.workbench-title');
await expect(title).toHaveText('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 () => {
const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.platform-test', {
kind: 'vault-file',
@ -193,18 +130,18 @@ test.describe('F: Default Editor Plugin', () => {
const workbench = page.locator('.workbench-host');
await expect(workbench).toBeVisible({ timeout: 10000 });
const title = workbench.locator('.workbench-title');
await expect(title).toHaveText('Overview');
await expect(title).toHaveText('Notes/Overview.md');
});
test('default-editor plugin is listed as loaded in plugin manager', async ({ page }) => {
await openPluginManager(page);
await page.locator('.sidebar .nav-item').filter({ hasText: 'Plugin Manager' }).click();
const card = page.locator('.plugin-card').filter({ hasText: 'verstak.default-editor' });
await expect(card).toBeVisible({ timeout: 10000 });
await expect(card.locator('.status-badge')).toHaveText('loaded');
});
test('disable default-editor plugin removes its providers', async ({ page }) => {
await openPluginManager(page);
await page.locator('.sidebar .nav-item').filter({ hasText: 'Plugin Manager' }).click();
const card = page.locator('.plugin-card').filter({ hasText: 'verstak.default-editor' });
await card.locator('button.btn-disable').click();
await expect(card.locator('button.btn-enable')).toBeVisible({ timeout: 10000 });
@ -224,7 +161,7 @@ test.describe('F: Default Editor Plugin', () => {
});
test('default-editor plugin card shows openProviders contribution count', async ({ page }) => {
await openPluginManager(page);
await page.locator('.sidebar .nav-item').filter({ hasText: 'Plugin Manager' }).click();
const card = page.locator('.plugin-card').filter({ hasText: 'verstak.default-editor' });
await expect(card).toBeVisible({ timeout: 10000 });
await expect(card.locator('.meta-row').filter({ hasText: 'Contributions:' })).toContainText('3 openProviders');

View File

@ -1,10 +1,5 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState, openPluginManager } from './helpers.js';
async function openFilesTool(page) {
await page.getByRole('tab', { name: 'Files' }).click();
await expect(page.locator('.files-root')).toBeVisible({ timeout: 10000 });
}
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
test.describe('G: Files Plugin', () => {
let consoleCollector;
@ -21,7 +16,7 @@ test.describe('G: Files Plugin', () => {
});
test('files plugin appears in Plugin Manager as loaded', async ({ page }) => {
await openPluginManager(page);
await page.locator('.sidebar .nav-item').filter({ hasText: 'Plugin Manager' }).click();
const card = page.locator('.plugin-card').filter({ hasText: 'verstak.files' });
await expect(card).toBeVisible({ timeout: 10000 });
await expect(card.locator('.status-badge')).toHaveText('loaded');
@ -34,7 +29,6 @@ test.describe('G: Files Plugin', () => {
test('workspace Files view is scoped to selected workspace folder', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await openFilesTool(page);
await expect(page.locator('.workspace-host')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.files-item-name').filter({ hasText: 'project-only.txt' })).toBeVisible({ timeout: 10000 });
@ -48,7 +42,6 @@ test.describe('G: Files Plugin', () => {
test('files explorer supports create navigate rename filter sort open and trash', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await openFilesTool(page);
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
await page.locator('[data-files-action="new-folder"]').click();
@ -87,211 +80,34 @@ test.describe('G: Files Plugin', () => {
await expect(page.locator('.files-breadcrumb')).toContainText('Daily', { timeout: 10000 });
await expect(page.locator('[data-file-name="Journal.md"]')).toBeVisible({ timeout: 10000 });
await page.locator('[data-file-name="Journal.md"]').click();
page.once('dialog', (dialog) => dialog.accept());
await page.locator('[data-files-action="trash"]').click();
await expect(page.locator('.files-modal-overlay')).toBeVisible();
await expect(page.locator('.files-modal-title')).toContainText('Journal.md');
await page.locator('.files-modal-btn.confirm').click();
await expect(page.locator('[data-file-name="Journal.md"]')).toHaveCount(0);
await page.locator('[data-files-action="up"]').click();
await expect(page.locator('.files-breadcrumb')).not.toContainText('Daily');
});
test('files explorer shows inline rename validation errors', async ({ page }) => {
test('files explorer uses icon controls and no row New Here action', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await openFilesTool(page);
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
await page.locator('[data-file-name="project-only.txt"]').click();
await page.locator('[data-files-action="rename"]').click();
await page.locator('[data-files-rename-input]').fill('bad/name.txt');
await page.locator('[data-files-rename-confirm]').click();
await expect(page.locator('[data-files-rename-error]')).toBeVisible();
await expect(page.locator('[data-files-rename-error]')).toContainText('Invalid characters');
await expect(page.locator('[data-files-rename-input]')).toBeVisible();
await expect(page.locator('[data-file-name="project-only.txt"]')).toBeVisible();
await expect(page.locator('[data-file-name="bad"]')).toHaveCount(0);
});
test('files explorer shows inline rename conflict errors', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await openFilesTool(page);
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
await page.locator('[data-file-name="project-only.txt"]').click();
await page.locator('[data-files-action="rename"]').click();
await page.locator('[data-files-rename-input]').fill('Notes');
await page.locator('[data-files-rename-confirm]').click();
await expect(page.locator('[data-files-rename-error]')).toBeVisible();
await expect(page.locator('[data-files-rename-error]')).toContainText('already exists');
await expect(page.locator('[data-files-rename-input]')).toBeVisible();
await expect(page.locator('[data-files-rename-input]')).toHaveValue('Notes');
await expect(page.locator('[data-file-name="project-only.txt"]')).toBeVisible();
await expect(page.locator('[data-file-name="Notes"]')).toBeVisible();
});
test('files explorer shows inline create validation errors', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await openFilesTool(page);
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
await page.locator('[data-files-action="new-folder"]').click();
await page.locator('[data-files-create-input]').fill('bad/name');
await page.locator('[data-files-create-confirm]').click();
await expect(page.locator('[data-files-create-error]')).toBeVisible();
await expect(page.locator('[data-files-create-error]')).toContainText('Invalid characters');
await expect(page.locator('[data-files-create-input]')).toBeVisible();
await expect(page.locator('[data-file-name="bad"]')).toHaveCount(0);
});
test('files explorer uses rich file icons by file type', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await openFilesTool(page);
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
async function createFile(action, name) {
await page.locator(`[data-files-action="${action}"]`).click();
await page.locator('[data-files-create-input]').fill(name);
await page.locator('[data-files-create-confirm]').click();
await expect(page.locator(`[data-file-name="${name}"]`)).toBeVisible();
}
await createFile('new-markdown', 'IconNote.md');
await createFile('new-text', 'Photo.png');
await createFile('new-text', 'Manual.pdf');
await createFile('new-text', 'Script.js');
const iconFor = (name) => page.locator(`[data-file-name="${name}"] .files-item-icon`);
await expect(iconFor('Notes')).toHaveAttribute('data-file-icon', 'folder');
await expect(iconFor('project-only.txt')).toHaveAttribute('data-file-icon', 'text');
await expect(iconFor('IconNote.md')).toHaveAttribute('data-file-icon', 'markdown');
await expect(iconFor('Photo.png')).toHaveAttribute('data-file-icon', 'image');
await expect(iconFor('Manual.pdf')).toHaveAttribute('data-file-icon', 'pdf');
await expect(iconFor('Script.js')).toHaveAttribute('data-file-icon', 'code');
await expect(iconFor('Manual.pdf')).toHaveAttribute('aria-label', 'PDF file');
await expect(iconFor('Photo.png')).toHaveAttribute('title', 'Image file');
});
test('files explorer offers create actions in empty folders', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await openFilesTool(page);
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
await page.locator('[data-files-action="new-folder"]').click();
await page.locator('[data-files-create-input]').fill('EmptyQuickActions');
await page.locator('[data-files-create-confirm]').click();
await page.locator('[data-file-name="EmptyQuickActions"]').dblclick();
const emptyState = page.locator('.files-empty');
await expect(emptyState).toContainText('Empty folder');
await expect(emptyState.locator('[data-files-empty-action="new-folder"]')).toBeVisible();
await expect(emptyState.locator('[data-files-empty-action="new-markdown"]')).toBeVisible();
await expect(emptyState.locator('[data-files-empty-action="new-text"]')).toBeVisible();
await emptyState.locator('[data-files-empty-action="new-markdown"]').click();
await page.locator('[data-files-create-input]').fill('FromEmpty.md');
await page.locator('[data-files-create-confirm]').click();
await expect(page.locator('[data-file-name="FromEmpty.md"]')).toBeVisible();
await expect(page.locator('.files-empty')).toHaveCount(0);
});
test('files explorer shows no-match feedback while filtering', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await openFilesTool(page);
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
await page.locator('[data-files-filter]').fill('zzzz-no-matches');
const emptyState = page.locator('.files-empty');
await expect(emptyState).toBeVisible();
await expect(emptyState).toContainText('No matches');
await expect(emptyState.locator('[data-files-empty-action="clear-filter"]')).toBeVisible();
await expect(page.locator('[data-file-name="project-only.txt"]')).toHaveCount(0);
await emptyState.locator('[data-files-empty-action="clear-filter"]').click();
await expect(page.locator('[data-files-filter]')).toHaveValue('');
await expect(page.locator('.files-empty')).toHaveCount(0);
await expect(page.locator('[data-file-name="project-only.txt"]')).toBeVisible();
});
test('files explorer keeps removed items out of live files without Trash metadata', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await openFilesTool(page);
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
await page.locator('[data-files-action="new-text"]').click();
await page.locator('[data-files-create-input]').fill('RestoreMe.txt');
await page.locator('[data-files-create-confirm]').click();
await expect(page.locator('[data-file-name="RestoreMe.txt"]')).toBeVisible();
await page.locator('[data-file-name="RestoreMe.txt"]').click();
await page.locator('[data-files-action="trash"]').click();
await page.locator('.files-modal-btn.confirm').click();
await expect(page.locator('[data-file-name="RestoreMe.txt"]')).toHaveCount(0);
await expect(page.locator('[data-files-action="trash-view"]')).toHaveCount(0);
await expect(page.locator('[data-files-restore-trash]')).toHaveCount(0);
});
test('files explorer uses labeled controls and no row New Here action', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await openFilesTool(page);
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
const actionIcons = {
back: 'back',
forward: 'forward',
up: 'up',
refresh: 'refresh',
'new-folder': 'folderAdd',
'new-markdown': 'markdownAdd',
'new-text': 'textAdd',
open: 'open',
rename: 'rename',
trash: 'trash',
cut: 'cut',
copy: 'copy',
paste: 'paste',
};
const pathData = [];
for (const [action, icon] of Object.entries(actionIcons)) {
for (const action of ['back', 'forward', 'up', 'refresh', 'new-folder', 'new-markdown', 'new-text', 'open', 'rename', 'trash', 'cut', 'copy', 'paste']) {
const button = page.locator(`[data-files-action="${action}"]`);
await expect(button).toHaveAttribute('title', /.+/);
await expect(button).toHaveAttribute('aria-label', /.+/);
await expect(button).toHaveAttribute('data-files-icon', icon);
await expect(button.locator('svg')).toBeVisible();
await expect(button).toHaveText('');
pathData.push(await button.locator('svg path').getAttribute('d'));
await expect(button).not.toHaveText(/\S/);
}
expect(new Set(pathData).size).toBe(pathData.length);
await expect(page.locator('.files-row-btn').filter({ hasText: 'New here' })).toHaveCount(0);
const rowIcons = {
'row-open': 'open',
'row-rename': 'rename',
'row-trash': 'trash',
};
const rowPathData = [];
for (const [action, icon] of Object.entries(rowIcons)) {
const rowButton = page.locator(`[data-file-name="Notes"] [data-files-action="${action}"]`);
await expect(rowButton).toBeVisible();
await expect(rowButton).toHaveAttribute('title', /.+/);
await expect(rowButton).toHaveAttribute('aria-label', /.+/);
await expect(rowButton).toHaveAttribute('data-files-icon', icon);
await expect(rowButton).toHaveText('');
expect(await rowButton.evaluate((node) => node.innerHTML)).toContain('<svg');
rowPathData.push(await rowButton.locator('svg path').getAttribute('d'));
}
expect(new Set(rowPathData).size).toBe(rowPathData.length);
const firstRowButton = page.locator('[data-file-name="Notes"] .files-row-btn').first();
await expect(firstRowButton).toBeVisible();
await expect(firstRowButton).not.toHaveText(/\S/);
expect(await firstRowButton.evaluate((node) => node.innerHTML)).toContain('<svg');
});
test('files explorer supports empty-space context paste after cutting a folder', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await openFilesTool(page);
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
await page.locator('[data-files-action="new-folder"]').click();
@ -314,48 +130,8 @@ test.describe('G: Files Plugin', () => {
await expect(page.locator('[data-file-name="CutMe"]')).toHaveCount(0);
});
test('files explorer navigates by clicking breadcrumb segments', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await openFilesTool(page);
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
await page.locator('[data-files-action="new-folder"]').click();
await page.locator('[data-files-create-input]').fill('BreadcrumbParent');
await page.locator('[data-files-create-confirm]').click();
await page.locator('[data-file-name="BreadcrumbParent"]').dblclick();
await expect(page.locator('.files-breadcrumb')).toContainText('BreadcrumbParent');
await page.locator('[data-files-action="new-folder"]').click();
await page.locator('[data-files-create-input]').fill('BreadcrumbChild');
await page.locator('[data-files-create-confirm]').click();
await page.locator('[data-file-name="BreadcrumbChild"]').dblclick();
await expect(page.locator('.files-breadcrumb')).toContainText('BreadcrumbChild');
await page.locator('.files-breadcrumb-item').filter({ hasText: 'BreadcrumbParent' }).click();
await expect(page.locator('.files-breadcrumb')).toContainText('BreadcrumbParent');
await expect(page.locator('.files-breadcrumb')).not.toContainText('BreadcrumbChild');
await expect(page.locator('[data-file-name="BreadcrumbChild"]')).toBeVisible();
});
test('files explorer duplicates a file from the context menu', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await openFilesTool(page);
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
await page.locator('[data-file-name="project-only.txt"]').click({ button: 'right' });
await page.locator('[data-files-menu-action="duplicate"]').click();
await expect(page.locator('[data-file-name="project-only.txt"]')).toBeVisible();
await expect(page.locator('[data-file-name="project-only (copy).txt"]')).toBeVisible();
await page.locator('[data-file-name="project-only.txt"]').click({ button: 'right' });
await page.locator('[data-files-menu-action="duplicate"]').click();
await expect(page.locator('[data-file-name="project-only (copy 2).txt"]')).toBeVisible();
});
test('files explorer supports multiselect and internal drag/drop move', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await openFilesTool(page);
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
await page.locator('[data-files-action="new-folder"]').click();
@ -388,32 +164,8 @@ test.describe('G: Files Plugin', () => {
await expect(page.locator('[data-file-name="DragTwo.txt"]')).toBeVisible();
});
test('files explorer supports keyboard row selection and clearing selection', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await openFilesTool(page);
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
const rows = page.locator('.files-item');
await expect(rows.nth(1)).toBeVisible();
await rows.nth(0).click();
await expect(rows.nth(0)).toHaveClass(/selected/);
await page.locator('.files-root').focus();
await page.keyboard.press('ArrowDown');
await expect(rows.nth(1)).toHaveClass(/selected/);
await expect(rows.nth(0)).not.toHaveClass(/selected/);
await page.keyboard.press('ArrowUp');
await expect(rows.nth(0)).toHaveClass(/selected/);
await page.keyboard.press('Escape');
await expect(page.locator('.files-item.selected')).toHaveCount(0);
});
test('files history persists in workspace context and handles mouse back forward buttons', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await openFilesTool(page);
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
await page.locator('[data-file-name="Notes"]').dblclick();
@ -439,7 +191,6 @@ test.describe('G: Files Plugin', () => {
test('workbench close and mouse back return from editor to the previous Files folder', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await openFilesTool(page);
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
await page.locator('[data-file-name="Notes"]').dblclick();
@ -513,7 +264,7 @@ test.describe('G: Files Plugin', () => {
const workbench = page.locator('.workbench-host');
await expect(workbench).toBeVisible({ timeout: 10000 });
await expect(workbench.locator('.workbench-title')).toHaveText('readme.md');
await expect(workbench.locator('.workbench-title')).toHaveText('Docs/readme.md');
const preview = page.locator('.de-preview');
await expect(preview).toBeVisible({ timeout: 10000 });
@ -535,7 +286,7 @@ test.describe('G: Files Plugin', () => {
const workbench = page.locator('.workbench-host');
await expect(workbench).toBeVisible({ timeout: 10000 });
await expect(workbench.locator('.workbench-title')).toHaveText('Overview');
await expect(workbench.locator('.workbench-title')).toHaveText('Notes/Overview.md');
});
test('files plugin card shows openProviders in contributions', async ({ page }) => {

View File

@ -11,13 +11,6 @@ export async function waitForAppReady(page) {
await page.waitForTimeout(1000);
}
/** Open the secondary Plugin Manager route from the status-bar settings menu. */
export async function openPluginManager(page) {
await page.locator('[data-settings-menu-button]').click();
await page.locator('[data-settings-action="plugin-manager"]').click();
await page.waitForSelector('.plugin-manager', { state: 'visible', timeout: 10000 });
}
/** Collect all console errors since last reset */
export function setupConsoleCollector(page) {
const errors = [];

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

@ -1,62 +0,0 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
test.describe('Desktop localization', () => {
let consoleCollector;
test.beforeEach(async ({ page }) => {
consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
});
test.afterEach(async () => {
consoleCollector.assertNoErrors();
});
test('switches shell language live and persists the choice', async ({ page }) => {
await page.locator('[data-settings-menu-button]').click();
await expect(page.locator('[data-settings-language="system"]')).toHaveAttribute('aria-checked', 'true');
await page.locator('[data-settings-language="ru"]').click();
await expect(page.locator('[data-settings-menu-button]')).toHaveAttribute('title', 'Настройки');
await expect(page.locator('.vault-status')).toContainText('Хранилище: открыто');
await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Тест платформы' })).toBeVisible();
await expect(page.locator('[data-status-item-id="verstak.platform-test.status"]')).toContainText('Все тесты пройдены');
const project = page.locator('.wt-node').filter({ hasText: 'Project' });
await project.locator('button[title="Переименовать Дело"]').click();
await expect(page.locator('button[title="Сохранить новое название"]')).toHaveText('Сохранить');
await page.locator('.wt-rename').press('Escape');
await page.locator('[data-settings-menu-button]').click();
await expect(page.locator('[data-settings-action="plugin-manager"]')).toContainText('Менеджер плагинов');
await expect(page.locator('[data-settings-language="ru"]')).toHaveAttribute('aria-checked', 'true');
await page.locator('[data-settings-action="plugin-manager"]').click();
const pluginManager = page.locator('.plugin-manager');
await expect(pluginManager).toContainText('Зарегистрировано возможностей:');
await expect(pluginManager.locator('.registry-section')).toContainText('Реестр возможностей');
await expect(pluginManager.locator('.registry-section')).toContainText('Возможность');
await expect(pluginManager.locator('.plugin-filters')).toContainText('Фильтры');
await expect(pluginManager.locator('[data-plugin-filter-results]')).toContainText('Показано плагинов:');
await expect(pluginManager.locator('[data-plugin-filter="status"]')).toContainText('Выключенные');
await expect(pluginManager).not.toContainText('Workspace-scoped');
await page.reload();
await waitForAppReady(page);
await expect(page.locator('[data-settings-menu-button]')).toHaveAttribute('title', 'Настройки');
await page.locator('[data-settings-menu-button]').click();
await expect(page.locator('[data-settings-language="ru"]')).toHaveAttribute('aria-checked', 'true');
});
test('switches back to English without reloading', async ({ page }) => {
await page.locator('[data-settings-menu-button]').click();
await page.locator('[data-settings-language="ru"]').click();
await page.locator('[data-settings-menu-button]').click();
await page.locator('[data-settings-language="en"]').click();
await expect(page.locator('[data-settings-menu-button]')).toHaveAttribute('title', 'Settings');
await expect(page.locator('.vault-status')).toContainText('Vault: open');
});
});

View File

@ -1,5 +1,5 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState, openPluginManager } from './helpers.js';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
test.describe('D: Plugin API bridge', () => {
let consoleCollector;
@ -26,7 +26,7 @@ test.describe('D: Plugin API bridge', () => {
await page.locator('.pt-save-setting').click();
await expect(saved).toHaveText('Saved setting: persisted through bridge', { timeout: 10000 });
await openPluginManager(page);
await page.locator('.sidebar .nav-item').filter({ hasText: 'Plugin Manager' }).click();
await expect.poll(() => page.evaluate(() => Object.keys(window.__VERSTAK_COMMAND_HANDLERS__ || {}).length)).toBe(0);
await expect.poll(() => page.evaluate(() => (window.__VERSTAK_EVENT_HANDLERS__?.['verstak.platform-test.echo'] || []).length)).toBe(0);
await page.locator('button.reload-btn').click();
@ -58,7 +58,7 @@ test.describe('D: Plugin API bridge', () => {
const workbench = page.locator('.workbench-host');
await expect(workbench).toBeVisible({ timeout: 10000 });
await expect(workbench.locator('.workbench-title')).toHaveText('Overview');
await expect(workbench.locator('.workbench-title')).toHaveText('Notes/Overview.md');
});
test('workbench routes markdown files to default-editor provider', async ({ page }) => {
@ -76,7 +76,7 @@ test.describe('D: Plugin API bridge', () => {
const workbench = page.locator('.workbench-host');
await expect(workbench).toBeVisible({ timeout: 10000 });
const title = workbench.locator('.workbench-title');
await expect(title).toHaveText('readme.md');
await expect(title).toHaveText('Docs/readme.md');
});
test('workbench shows no-provider fallback when no provider matches', async ({ page }) => {
@ -91,85 +91,8 @@ test.describe('D: Plugin API bridge', () => {
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
});
const fallback = page.locator('[data-workbench-status="no-provider"]');
await expect(fallback).toBeVisible();
await expect(fallback).toContainText('No viewer/editor available');
await expect(fallback).not.toContainText('generic-text');
});
test('sync plugin API routes through mocked Wails bridge', async ({ page }) => {
const result = await page.evaluate(async () => {
const api = window.createPluginAPI('verstak.sync');
const initial = await api.sync.status();
await api.sync.testConnection('https://sync.example.test', 'alice', 'secret');
await api.sync.configure('https://sync.example.test', 'alice', 'secret');
await api.sync.setInterval(15);
const configured = await api.sync.status();
const syncNow = await api.sync.now();
await api.sync.resetKey();
const reset = await api.sync.status();
await api.sync.disconnect();
const disconnected = await api.sync.status();
api.dispose();
return { initial, configured, syncNow, reset, disconnected };
});
expect(result.initial.statusLabel).toBe('disabled');
expect(result.configured.configured).toBe(true);
expect(result.configured.serverUrl).toBe('https://sync.example.test');
expect(result.configured.syncInterval).toBe(15);
expect(result.syncNow).toEqual({ pushed: 0, pulled: 0, serverSequence: 0 });
expect(result.reset.configured).toBe(false);
expect(result.reset.serverUrl).toBe('https://sync.example.test');
expect(result.reset.tokenStored).toBe(false);
expect(result.reset.statusLabel).toBe('disconnected');
expect(result.disconnected.configured).toBe(false);
expect(result.disconnected.statusLabel).toBe('disabled');
});
test('files API permanently deletes an existing trash entry through the bridge', async ({ page }) => {
const result = await page.evaluate(async () => {
const api = window.createPluginAPI('verstak.files');
await api.files.writeText('Project/bridge-trash.txt', 'remove me', { createIfMissing: true });
const trash = await api.files.trash('Project/bridge-trash.txt');
const before = await api.files.listTrash();
await api.files.deleteTrash(trash.trashId);
const after = await api.files.listTrash();
let readError = '';
try {
await api.files.readText('Project/bridge-trash.txt');
} catch (error) {
readError = String(error && error.message || error);
}
api.dispose();
return { before, after, readError };
});
expect(result.before).toHaveLength(1);
expect(result.after).toEqual([]);
expect(result.readError).toContain('not-found: Project/bridge-trash.txt');
});
test('backend plugin events are dispatched to subscribed frontend handlers', async ({ page }) => {
const result = await page.evaluate(async () => {
const api = window.createPluginAPI('verstak.platform-test');
let received = null;
const unsubscribe = await api.events.subscribe('browser.capture.page', (event) => {
received = event;
});
window.__VERSTAK_DISPATCH_BACKEND_EVENT__({
name: 'browser.capture.page',
timestamp: '2026-06-27T00:00:00.000Z',
payload: { url: 'https://example.com/article' }
});
unsubscribe();
api.dispose();
return received;
});
expect(result.name).toBe('browser.capture.page');
expect(result.payload.url).toBe('https://example.com/article');
expect(result.timestamp).toBe('2026-06-27T00:00:00.000Z');
await expect(page.locator('[data-workbench-status="no-provider"]')).toBeVisible();
await expect(page.locator('[data-workbench-status="no-provider"]')).toContainText('No viewer/editor available');
});
test('platform-test command and event handlers are cleaned up after leaving plugin view', async ({ page }) => {
@ -180,7 +103,7 @@ test.describe('D: Plugin API bridge', () => {
await expect.poll(() => page.evaluate(() => Object.keys(window.__VERSTAK_COMMAND_HANDLERS__ || {}).length)).toBe(1);
await expect.poll(() => page.evaluate(() => (window.__VERSTAK_EVENT_HANDLERS__?.['verstak.platform-test.echo'] || []).length)).toBe(1);
await openPluginManager(page);
await page.locator('.sidebar .nav-item').filter({ hasText: 'Plugin Manager' }).click();
await expect.poll(() => page.evaluate(() => Object.keys(window.__VERSTAK_COMMAND_HANDLERS__ || {}).length)).toBe(0);
await expect.poll(() => page.evaluate(() => (window.__VERSTAK_EVENT_HANDLERS__?.['verstak.platform-test.echo'] || []).length)).toBe(0);
@ -190,7 +113,7 @@ test.describe('D: Plugin API bridge', () => {
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' }).click();
await expect(page.locator('.pt-command-result')).toContainText('Command: handled', { timeout: 10000 });
await openPluginManager(page);
await page.locator('.sidebar .nav-item').filter({ hasText: 'Plugin Manager' }).click();
const pluginCard = page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' });
await pluginCard.locator('button.btn-disable').click();
await expect(pluginCard.locator('button.btn-enable')).toBeVisible({ timeout: 10000 });
@ -200,7 +123,7 @@ test.describe('D: Plugin API bridge', () => {
});
test('platform-test settings panel loads bundle content returned as raw string', async ({ page }) => {
await openPluginManager(page);
await page.locator('.sidebar .nav-item').filter({ hasText: 'Plugin Manager' }).click();
const pluginCard = page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' });
await pluginCard.locator('button.btn-settings').click();

View File

@ -12,7 +12,7 @@
* 8. Verify plugin sidebar item returns
*/
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState, openPluginManager } from './helpers.js';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
test.describe('A: Plugin Manager Disable/Enable refresh', () => {
let consoleCollector;
@ -22,7 +22,6 @@ test.describe('A: Plugin Manager Disable/Enable refresh', () => {
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
await openPluginManager(page);
});
test.afterEach(async () => {

View File

@ -1,5 +1,5 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState, openPluginManager, setPluginStatus } from './helpers.js';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
test.describe('E: Plugin Manager layout', () => {
let consoleCollector;
@ -16,11 +16,9 @@ test.describe('E: Plugin Manager layout', () => {
});
test('plugin list scrolls through the global main scroll surface and stays responsive', async ({ page }) => {
await openPluginManager(page);
const basePluginCount = await page.locator('.plugin-card').count();
await page.evaluate(() => window.__wailsMock.addSyntheticPlugins(18));
await page.locator('button.reload-btn').click();
await expect(page.locator('.plugin-card')).toHaveCount(basePluginCount + 18, { timeout: 10000 });
await expect(page.locator('.plugin-card')).toHaveCount(21, { timeout: 10000 });
const manager = page.locator('.plugin-manager');
const scrollSurface = page.locator('.content.scroll-surface');
@ -73,71 +71,6 @@ test.describe('E: Plugin Manager layout', () => {
expect(buttonStyle.borderRadius).toBe('6px');
});
test('plugin manager summarizes plugin health and elevated permissions before the list', async ({ page }) => {
await openPluginManager(page);
const health = page.locator('[data-plugin-manager-summary="health"]');
await expect(health).toBeVisible();
await expect(health.locator('[data-plugin-status-summary="loaded"]')).toContainText('12');
await expect(health.locator('[data-plugin-status-summary="failed"]')).toContainText('0');
await expect(health.locator('[data-plugin-status-summary="disabled"]')).toContainText('0');
const risk = page.locator('[data-plugin-manager-summary="risk"]');
await expect(risk).toBeVisible();
await expect(risk.locator('[data-plugin-risk-summary="elevated-permissions"]')).toContainText('6');
await expect(risk).toContainText('elevated permissions');
});
test('plugin manager filters by state, declared permissions, capabilities, settings and source', async ({ page }) => {
await setPluginStatus(page, 'verstak.todo', 'disabled', false);
await page.evaluate(() => window.__wailsMock.addSyntheticPlugins(1, 'third-party'));
await openPluginManager(page);
const results = page.locator('[data-plugin-filter-results]');
await expect(results).toContainText('Showing');
const filterSelectStyle = await page.locator('[data-plugin-filter="status"]').evaluate((node) => {
const style = getComputedStyle(node);
return { appearance: style.appearance, backgroundImage: style.backgroundImage };
});
expect(filterSelectStyle.appearance).toBe('none');
expect(filterSelectStyle.backgroundImage).not.toBe('none');
await page.locator('[data-plugin-filter="status"]').selectOption('disabled');
await expect(page.locator('.plugin-card')).toHaveCount(1);
await expect(page.locator('.plugin-card')).toContainText('Todos');
await page.locator('button.reload-btn').click();
await expect(page.locator('[data-plugin-filter="status"]')).toHaveValue('disabled');
await expect(page.locator('.plugin-card')).toHaveCount(1);
await page.locator('[data-plugin-filter-reset]').click();
await page.locator('[data-plugin-filter-permission="notifications.schedule"]').check();
await expect(page.locator('.plugin-card')).toHaveCount(1);
await expect(page.locator('.plugin-card')).toContainText('Todos');
await page.locator('[data-plugin-filter-permission="secrets.read"]').check();
await expect(page.locator('.plugin-card')).toHaveCount(2);
await page.locator('[data-plugin-filter-permission="secrets.read"]').uncheck();
await page.locator('[data-plugin-filter-reset]').click();
await page.locator('[data-plugin-filter-capability="verstak/core/notifications/v1"]').check();
await expect(page.locator('.plugin-card')).toHaveCount(1);
await expect(page.locator('.plugin-card')).toContainText('Todos');
await page.locator('[data-plugin-filter-capability="secret-store"]').check();
await expect(page.locator('.plugin-card')).toHaveCount(2);
await page.locator('[data-plugin-filter-capability="secret-store"]').uncheck();
await page.locator('[data-plugin-filter-reset]').click();
await page.locator('[data-plugin-filter="settings"]').selectOption('with');
await expect(page.locator('.plugin-card').filter({ hasText: 'Platform Test' })).toBeVisible();
await expect(page.locator('.plugin-card').filter({ hasText: 'Default Editor' })).toHaveCount(0);
await page.locator('[data-plugin-filter="source"]').selectOption('third-party');
await expect(page.locator('.plugin-card')).toHaveCount(0);
await expect(page.locator('[data-plugin-filter-empty]')).toBeVisible();
await page.locator('[data-plugin-filter-reset]').click();
await expect(page.locator('.plugin-card')).toHaveCount(13);
});
test('workspace selection keeps exactly one active node', async ({ page }) => {
const selected = page.locator('.wt-node.selected .wt-label');
await expect(selected).toHaveCount(1);
@ -149,34 +82,27 @@ test.describe('E: Plugin Manager layout', () => {
await expect(selected).toHaveText('Test');
});
test('workspace tools render Overview first with Files as one tab', async ({ page }) => {
test('workspace tools render as tabs with Files as one tab', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
const tabs = page.locator('.workspace-tabs');
await expect(tabs).toBeVisible({ timeout: 10000 });
const overviewTab = tabs.locator('[role="tab"]').filter({ hasText: 'Overview' });
const filesTab = tabs.locator('[role="tab"]').filter({ hasText: 'Files' });
await expect(overviewTab).toBeVisible();
await expect(overviewTab).toHaveAttribute('aria-selected', 'true');
await expect(filesTab).toBeVisible();
await expect(filesTab).toHaveAttribute('aria-selected', 'false');
await expect(filesTab).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.workspace-tool')).toHaveCount(0);
await expect(page.locator('[data-overview-root]')).toBeVisible();
await filesTab.click();
await expect(page.locator('.files-root')).toBeVisible();
});
test('workspace sidebar creates renames and trashes top-level workspaces', async ({ page }) => {
await page.locator('button[title="New Deal"]').click();
const modal = page.locator('[data-workspace-create-modal]');
await modal.locator('[data-workspace-name]').fill('ClientA');
await modal.getByRole('button', { name: 'Create Deal' }).click();
await page.locator('button[title="New workspace"]').click();
await page.locator('.wt-create input').fill('ClientA');
await page.locator('.wt-btn-primary', { hasText: 'Create' }).click();
await expect(page.locator('.wt-label').filter({ hasText: 'ClientA' })).toBeVisible();
const client = page.locator('.wt-node').filter({ hasText: 'ClientA' });
await client.locator('button[title="Rename Deal"]').click();
await client.locator('button[title="Rename workspace"]').click();
await page.locator('.wt-rename').fill('ClientB');
await page.locator('button[title="Save rename"]').click();
@ -184,7 +110,7 @@ test.describe('E: Plugin Manager layout', () => {
await expect(page.locator('.wt-label').filter({ hasText: 'ClientA' })).toHaveCount(0);
const renamed = page.locator('.wt-node').filter({ hasText: 'ClientB' });
await renamed.locator('button[title="Move Deal to trash"]').click();
await renamed.locator('button[title="Trash workspace"]').click();
await expect(page.locator('.wt-label').filter({ hasText: 'ClientB' })).toHaveCount(0);
});

View File

@ -7,7 +7,7 @@
* 3. Verify UI reflects the updated state
*/
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState, setPluginStatus, openPluginManager } from './helpers.js';
import { waitForAppReady, setupConsoleCollector, resetMockState, setPluginStatus } from './helpers.js';
test.describe('C: Reload updates UI state', () => {
let consoleCollector;
@ -17,7 +17,6 @@ test.describe('C: Reload updates UI state', () => {
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
await openPluginManager(page);
});
test.afterEach(async () => {

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

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

View File

@ -1,49 +0,0 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState, openPluginManager } from './helpers.js';
test.describe('Status Bar host', () => {
let consoleCollector;
test.beforeEach(async ({ page }) => {
consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
});
test.afterEach(async () => {
consoleCollector.assertNoErrors();
});
test('renders enabled plugin statusBarItems', async ({ page }) => {
const statusBar = page.locator('.status-bar');
await expect(statusBar).toBeVisible();
await expect(statusBar.locator('.vault-status')).toContainText('Vault: open');
await expect(statusBar.locator('[data-status-item-id="verstak.platform-test.status"]')).toContainText('All Tests Pass');
});
test('opens settings menu with plugin manager and plugin settings', async ({ page }) => {
await page.locator('[data-settings-menu-button]').click();
await expect(page.locator('[data-settings-action="plugin-manager"]')).toBeVisible();
await expect(page.locator('[data-settings-panel-id="verstak.sync.settings"]')).toBeVisible();
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' }).click();
await expect(page.locator('.view-container .view-header h2')).toHaveText('Platform Diagnostics');
await page.locator('[data-settings-menu-button]').click();
await page.locator('[data-settings-action="plugin-manager"]').click();
await expect(page.locator('.plugin-manager')).toBeVisible();
});
test('refreshes statusBarItems after disabling plugin', async ({ page }) => {
const pluginCard = page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' });
await expect(page.locator('[data-status-item-id="verstak.platform-test.status"]')).toBeVisible();
await openPluginManager(page);
await pluginCard.locator('button.btn-disable').click();
await expect(pluginCard.locator('button.btn-enable')).toBeVisible({ timeout: 10000 });
await expect(page.locator('[data-status-item-id="verstak.platform-test.status"]')).not.toBeVisible();
});
});

View File

@ -1,161 +0,0 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
test.describe('Todo plugin workflow', () => {
let consoleCollector;
test.beforeEach(async ({ page }) => {
consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
});
test.afterEach(async () => {
consoleCollector.assertNoErrors();
});
test('workspace Todo supports CRUD, reminders, and manual Journal conversion', async ({ page }) => {
await page.getByRole('tab', { name: 'Todos' }).click();
const todos = page.locator('.todo-root');
await expect(todos).toBeVisible({ timeout: 10000 });
await expect(todos).toContainText('Todos · Project');
await todos.locator('[data-todo-action="add"]').click();
await todos.locator('[data-todo-input="title"]').fill('Prepare project review');
await todos.locator('[data-todo-input="description"]').fill('Collect factual review notes.');
await todos.locator('[data-todo-input="priority"]').selectOption('high');
await todos.locator('[data-todo-input="dueAt"]').fill('2000-01-01');
await todos.locator('[data-todo-input="reminderDate"]').fill('2000-01-01');
const reminderTime = todos.locator('[data-todo-input="reminderTime"]');
await expect(reminderTime).toHaveAttribute('type', 'text');
await reminderTime.fill('09:30');
await todos.locator('[data-todo-action="save"]').click();
await expect(todos).toContainText('Overdue');
await expect(todos).toContainText('Reminder due');
await expect.poll(async () => page.evaluate(async () => {
const result = await window.go.api.App.ReadPluginSettings('verstak.todo');
const settings = Array.isArray(result) ? result[0] : result;
const todo = settings['todos:global'].find((item) => item.title === 'Prepare project review');
return todo && [todo.workspaceRootPath, todo.priority, todo.dueAt, todo.reminderDate, todo.reminderAt].join('|');
})).toBe('Project|high|2000-01-01|2000-01-01|2000-01-01T09:30');
await todos.locator('[data-todo-action="edit"]').click();
await todos.locator('[data-todo-input="title"]').fill('Prepare project review updated');
await todos.locator('[data-todo-input="reminderTime"]').fill('not-a-time');
await todos.locator('[data-todo-action="save"]').click();
await expect(todos).toContainText('Enter a valid reminder time');
await todos.locator('[data-todo-input="reminderTime"]').fill('');
await todos.locator('[data-todo-action="save"]').click();
await expect(todos).toContainText('Prepare project review updated');
await expect.poll(async () => page.evaluate(async () => {
const result = await window.go.api.App.ReadPluginSettings('verstak.todo');
const settings = Array.isArray(result) ? result[0] : result;
const todo = settings['todos:global'].find((item) => item.title === 'Prepare project review updated');
return todo && [todo.reminderDate, todo.reminderAt].join('|');
})).toBe('2000-01-01|');
await todos.locator('[data-todo-action="mark-done"]').click();
await expect(todos.locator('[data-todo-action="create-journal-entry"]')).toBeVisible();
await todos.locator('[data-todo-action="create-journal-entry"]').click();
const journal = page.locator('.journal-root');
await expect(page.getByRole('tab', { name: 'Journal' })).toHaveAttribute('aria-selected', 'true');
await expect(journal).toBeVisible({ timeout: 10000 });
await expect(journal).toContainText('Create journal entry from completed todo');
await expect(journal.locator('[data-journal-input="title"]')).toHaveValue('Prepare project review updated');
await expect(journal.locator('[data-journal-input="summary"]')).toHaveValue('Collect factual review notes.');
await expect(journal.locator('[data-journal-input="minutes"]')).toHaveValue('0');
await journal.locator('[data-journal-input="title"]').fill('Prepare project review handoff');
await journal.locator('[data-journal-input="summary"]').fill('Reviewed factual project notes before handoff.');
await journal.locator('[data-journal-action="save-entry"]').click();
await expect.poll(async () => page.evaluate(async () => {
const todoResult = await window.go.api.App.ReadPluginSettings('verstak.todo');
const todoSettings = Array.isArray(todoResult) ? todoResult[0] : todoResult;
const todo = todoSettings['todos:global'].find((item) => item.title === 'Prepare project review updated');
const journalResult = await window.go.api.App.ReadPluginSettings('verstak.journal');
const journalSettings = Array.isArray(journalResult) ? journalResult[0] : journalResult;
const entry = journalSettings['worklog:workspace:Project'].find((item) => item.sourceTodoId === todo.id);
return entry && [entry.title, entry.summary, entry.minutes].join('|');
})).toBe('Prepare project review handoff|Reviewed factual project notes before handoff.|0');
await page.getByRole('tab', { name: 'Todos' }).click();
await page.locator('.todo-root [data-todo-action="delete"]').click();
await expect.poll(async () => page.evaluate(async () => {
const result = await window.go.api.App.ReadPluginSettings('verstak.todo');
const settings = Array.isArray(result) ? result[0] : result;
return settings['todos:global'].some((item) => item.title === 'Prepare project review updated');
})).toBe(false);
});
test('global Todos filter by workspace and Overview only exposes current workspace attention', async ({ page }) => {
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.todo', {
'todos:global': [
{
id: 'todo-project-open',
title: 'Project deadline',
workspaceRootPath: 'Project',
status: 'open',
priority: 'high',
dueAt: '2000-01-01',
reminderAt: '2000-01-01T09:00',
createdAt: '2026-06-30T08:00:00.000Z',
updatedAt: '2026-06-30T08:00:00.000Z',
},
{
id: 'todo-project-done',
title: 'Project completed item',
workspaceRootPath: 'Project',
status: 'done',
priority: 'normal',
createdAt: '2026-06-30T08:01:00.000Z',
updatedAt: '2026-06-30T08:01:00.000Z',
completedAt: '2026-06-30T08:02:00.000Z',
},
{
id: 'todo-test-open',
title: 'Test workspace item',
workspaceRootPath: 'Test',
status: 'open',
priority: 'normal',
dueAt: '2000-01-01',
createdAt: '2026-06-30T08:03:00.000Z',
updatedAt: '2026-06-30T08:03:00.000Z',
},
],
});
});
const overview = page.locator('[data-overview-root]');
await overview.locator('[data-overview-action="refresh"]').click();
const attention = overview.locator('[data-overview-section="attention"]');
await expect(attention).toContainText('Project deadline');
await expect(attention).not.toContainText('Test workspace item');
await attention.locator('.overview-attention-row', { hasText: 'Project deadline' }).getByRole('button', { name: 'Open Todos' }).click();
await expect(page.getByRole('tab', { name: 'Todos' })).toHaveAttribute('aria-selected', 'true');
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Todos' }).click();
const globalTodos = page.locator('.todo-root');
await expect(globalTodos).toContainText('Project deadline');
await expect(globalTodos).toContainText('Test workspace item');
await globalTodos.locator('[data-todo-filter="workspace"]').selectOption('Project');
await expect(globalTodos).toContainText('Project deadline');
await expect(globalTodos).toContainText('Project completed item');
await expect(globalTodos).not.toContainText('Test workspace item');
await globalTodos.locator('[data-todo-filter="status"]').selectOption('done');
await expect(globalTodos).toContainText('Project completed item');
await expect(globalTodos).not.toContainText('Project deadline');
await page.locator('.wt-label').filter({ hasText: 'Test' }).click();
await page.getByRole('tab', { name: 'Todos' }).click();
const workspaceTodos = page.locator('.todo-root');
await expect(workspaceTodos).toContainText('Test workspace item');
await expect(workspaceTodos).not.toContainText('Project deadline');
});
});

View File

@ -1,121 +0,0 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
async function openTrash(page) {
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Trash' }).click();
await expect(page.locator('.trash-root')).toBeVisible({ timeout: 10000 });
}
async function createTrashEntry(page, path, content, deletedAt) {
return page.evaluate(async ({ path, content, deletedAt }) => {
const api = window.createPluginAPI('verstak.files');
await api.files.writeText(path, content, { createIfMissing: true });
const entry = await api.files.trash(path);
window.__wailsMock.setTrashDeletedAt(entry.trashId, deletedAt);
api.dispose();
return entry;
}, { path, content, deletedAt });
}
test.describe('L: Global Trash Plugin', () => {
let consoleCollector;
test.beforeEach(async ({ page }) => {
consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
});
test.afterEach(async () => {
consoleCollector.assertNoErrors();
});
test('moves a Files item into global Trash without exposing Trash metadata in Files', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await page.getByRole('tab', { name: 'Files' }).click();
await expect(page.locator('.files-root')).toBeVisible({ timeout: 10000 });
await page.locator('[data-files-action="new-text"]').click();
await page.locator('[data-files-create-input]').fill('GlobalTrash.txt');
await page.locator('[data-files-create-confirm]').click();
await expect(page.locator('[data-file-name="GlobalTrash.txt"]')).toBeVisible();
await page.locator('[data-file-name="GlobalTrash.txt"]').click();
await page.locator('[data-files-action="trash"]').click();
await page.locator('.files-modal-btn.confirm').click();
await expect(page.locator('[data-file-name="GlobalTrash.txt"]')).toHaveCount(0);
await expect(page.locator('[data-files-action="trash-view"]')).toHaveCount(0);
await openTrash(page);
const row = page.locator('[data-trash-row]').filter({ hasText: 'GlobalTrash.txt' });
await expect(row).toBeVisible();
await expect(row).toContainText('Project');
await expect(row).toContainText('Project/GlobalTrash.txt');
});
test('filters and sorts global Trash and restores without overwriting a conflict', async ({ page }) => {
const older = await createTrashEntry(page, 'Project/Older.txt', 'older original', '2026-06-20T08:00:00.000Z');
const newest = await createTrashEntry(page, 'Test/Newest.txt', 'newest original', '2026-06-29T08:00:00.000Z');
const restore = await createTrashEntry(page, 'Project/Restore.txt', 'restore original', '2026-06-25T08:00:00.000Z');
const conflict = await page.evaluate(async () => {
const api = window.createPluginAPI('verstak.files');
await api.files.writeText('Project/Conflict.txt', 'trashed content', { createIfMissing: true });
const entry = await api.files.trash('Project/Conflict.txt');
await api.files.writeText('Project/Conflict.txt', 'existing content', { createIfMissing: true });
window.__wailsMock.setTrashDeletedAt(entry.trashId, '2026-06-24T08:00:00.000Z');
api.dispose();
return entry;
});
await openTrash(page);
await page.locator('[data-trash-filter-workspace]').selectOption('Project');
await expect(page.locator(`[data-trash-row="${older.trashId}"]`)).toBeVisible();
await expect(page.locator(`[data-trash-row="${newest.trashId}"]`)).toHaveCount(0);
await page.locator('[data-trash-filter-workspace]').selectOption('');
await page.locator('[data-trash-filter-search]').fill('Newest');
await expect(page.locator(`[data-trash-row="${newest.trashId}"]`)).toBeVisible();
await expect(page.locator(`[data-trash-row="${older.trashId}"]`)).toHaveCount(0);
await page.locator('[data-trash-filter-search]').fill('');
await page.locator('[data-trash-sort]').selectOption('date-asc');
await expect(page.locator('[data-trash-row]').first()).toHaveAttribute('data-trash-row', older.trashId);
await page.locator(`[data-trash-restore="${restore.trashId}"]`).click();
await expect(page.locator(`[data-trash-row="${restore.trashId}"]`)).toHaveCount(0);
await expect.poll(() => page.evaluate(async () => {
const api = window.createPluginAPI('verstak.files');
const content = await api.files.readText('Project/Restore.txt');
api.dispose();
return content;
})).toBe('restore original');
await page.locator(`[data-trash-restore="${conflict.trashId}"]`).click();
await expect(page.locator('[data-trash-status]')).toContainText('Restore blocked');
await expect(page.locator(`[data-trash-row="${conflict.trashId}"]`)).toBeVisible();
await expect.poll(() => page.evaluate(async () => {
const api = window.createPluginAPI('verstak.files');
const content = await api.files.readText('Project/Conflict.txt');
api.dispose();
return content;
})).toBe('existing content');
});
test('requires confirmation before permanently deleting a Trash item', async ({ page }) => {
const entry = await createTrashEntry(page, 'Test/Permanent.txt', 'remove forever', '2026-06-26T08:00:00.000Z');
await openTrash(page);
await page.locator(`[data-trash-delete="${entry.trashId}"]`).click();
await expect(page.locator(`[data-trash-confirm="${entry.trashId}"]`)).toBeVisible();
await expect(page.locator(`[data-trash-row="${entry.trashId}"]`)).toBeVisible();
await page.locator(`[data-trash-confirm-delete="${entry.trashId}"]`).click();
await expect(page.locator(`[data-trash-row="${entry.trashId}"]`)).toHaveCount(0);
await expect.poll(() => page.evaluate(async (trashId) => {
const api = window.createPluginAPI('verstak.files');
const entries = await api.files.listTrash();
api.dispose();
return entries.some((item) => item.trashId === trashId);
}, entry.trashId)).toBe(false);
});
});

View File

@ -1,118 +0,0 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, resetMockState, openPluginManager } from './helpers.js';
test.describe('UX follow-up fixes', () => {
test.beforeEach(async ({ page }) => {
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
});
test('global search stays available after opening tool sidebar views', async ({ page }) => {
const search = page.locator('[data-global-search-input]');
await expect(search).toBeVisible();
await expect(page.locator('.workspace-header [data-global-search-input]')).toBeVisible();
await page.locator('.sidebar .nav-item').filter({ hasText: 'Activity' }).click();
await expect(page.locator('.activity-root')).toBeVisible({ timeout: 10000 });
await expect(search).toBeVisible();
await expect(page.locator('.sidebar [data-global-search-input]')).toBeVisible();
await page.locator('.sidebar .nav-item').filter({ hasText: 'Browser' }).click();
await expect(page.locator('.browser-inbox-root')).toBeVisible({ timeout: 10000 });
await expect(search).toBeVisible();
});
test('global search types ahead across workspaces and file contents with keyboard layout fallback', async ({ page }) => {
const search = page.locator('[data-global-search-input]');
await search.fill('Зкщоусе');
await expect(page.locator('[data-global-search-results]')).toContainText('Project', { timeout: 10000 });
await search.fill('project file');
await expect(page.locator('[data-global-search-results]')).toContainText('project-only.txt', { timeout: 10000 });
});
test('global search folder results open the workspace Files context', async ({ page }) => {
const search = page.locator('[data-global-search-input]');
await search.fill('Project/Notes');
const folderResult = page.locator('[data-global-search-result-type="Folder"][data-global-search-result-path="Project/Notes"]');
await expect(folderResult).toBeVisible({ timeout: 10000 });
await folderResult.click();
await expect(page.locator('.workspace-title')).toHaveText('Project', { timeout: 10000 });
await expect(page.getByRole('tab', { name: 'Files' })).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.files-root')).toBeVisible();
await expect(page.locator('.files-breadcrumb')).toContainText('Notes');
await expect(page.locator('.files-item')).toContainText('Overview.md');
});
test('global search opens indexed browser inbox results', async ({ page }) => {
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.browser-inbox', {
'captures:workspace:Project': [{
captureId: 'capture-search-1',
capturedAt: '2026-06-30T08:00:00.000Z',
kind: 'page',
url: 'https://example.com/research',
title: 'Research Search Result',
domain: 'example.com',
text: 'Searchable captured browser text',
workspaceRootPath: 'Project',
browserName: 'Firefox',
}],
});
});
const search = page.locator('[data-global-search-input]');
await search.fill('Research Search Result');
const result = page.locator('[data-global-search-result-type="Browser"]').filter({ hasText: 'Research Search Result' });
await expect(result).toBeVisible({ timeout: 10000 });
await result.click();
await expect(page.locator('.browser-inbox-root')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.browser-inbox-detail-title')).toHaveText('Research Search Result');
});
test('workspace Search input keeps focus while typing', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await page.getByRole('tab', { name: 'Search' }).click();
const searchInput = page.locator('[data-search-input="query"]');
await expect(searchInput).toBeVisible({ timeout: 10000 });
await searchInput.click();
await page.keyboard.press('p');
await expect(searchInput).toBeFocused();
await page.keyboard.press('r');
await expect(searchInput).toBeFocused();
await expect(searchInput).toHaveValue('pr');
});
test('mobile workspace layout gives content full width below the sidebar', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.reload();
await waitForAppReady(page);
const workspaceBox = await page.locator('.workspace-host').boundingBox();
const sidebarBox = await page.locator('.sidebar').boundingBox();
expect(workspaceBox.width).toBeGreaterThan(340);
expect(workspaceBox.y).toBeGreaterThan(sidebarBox.y + sidebarBox.height - 1);
await expect(page.locator('.workspace-header [data-global-search-input]')).toBeVisible();
await expect(page.getByRole('tab', { name: 'Overview' })).toBeVisible();
const hasHorizontalOverflow = await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth);
expect(hasHorizontalOverflow).toBe(false);
});
test('plugin settings modal gives complex panels enough space', async ({ page }) => {
await openPluginManager(page);
await page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' }).getByRole('button', { name: 'Settings' }).click();
const modal = page.locator('.modal[aria-label="Plugin Settings"]');
await expect(modal).toBeVisible({ timeout: 10000 });
const box = await modal.boundingBox();
expect(box.width).toBeGreaterThanOrEqual(760);
expect(box.height).toBeGreaterThanOrEqual(560);
});
});

View File

@ -1,110 +0,0 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState, openPluginManager } from './helpers.js';
test.describe('UX P0 shell flow', () => {
let consoleCollector;
test.beforeEach(async ({ page }) => {
consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
});
test.afterEach(async () => {
consoleCollector.assertNoErrors();
});
test('starts in the first workspace instead of Plugin Manager', async ({ page }) => {
await expect(page.locator('.workspace-host')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.plugin-manager')).toHaveCount(0);
await expect(page.locator('.wt-node.selected .wt-label')).toHaveText('Project');
await expect(page.locator('.workspace-title')).toHaveText('Project');
});
test('workspace selection and main content stay in sync across plugin manager round trip', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Test' }).click();
await expect(page.locator('.workspace-title')).toHaveText('Test', { timeout: 10000 });
await expect(page.locator('.wt-node.selected .wt-label')).toHaveText('Test');
await expect(page.locator('.plugin-manager')).toHaveCount(0);
await openPluginManager(page);
await expect(page.locator('.plugin-manager')).toBeVisible();
await expect(page.locator('.wt-node.selected .wt-label')).toHaveCount(0);
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await expect(page.locator('.workspace-title')).toHaveText('Project', { timeout: 10000 });
await expect(page.locator('.plugin-manager')).toHaveCount(0);
});
test('Deal header does not expose the internal workspace type badge', async ({ page }) => {
await expect(page.locator('.workspace-host')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.workspace-type')).toHaveCount(0);
});
test('status bar plugin contribution failures do not render large error panels', async ({ page }) => {
await expect(page.locator('.workspace-host')).toBeVisible({ timeout: 10000 });
await expect(page.getByText('Plugin View Error')).toHaveCount(0);
await expect(page.locator('.status-bar [data-status-item-id]')).toHaveCount(2);
});
test('Plugin Manager remains reachable from the settings menu', async ({ page }) => {
await openPluginManager(page);
await expect(page.locator('.plugin-manager')).toBeVisible();
await expect(page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' })).toBeVisible();
});
});
test.describe('UX quick wins', () => {
test('Files screen uses readable dates and understandable action controls', async ({ page }) => {
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await page.getByRole('tab', { name: 'Files' }).click();
const files = page.locator('.files-root');
await expect(files).toBeVisible({ timeout: 10000 });
await expect(files.getByText(/T\d{2}:\d{2}:\d{2}/)).toHaveCount(0);
const actions = [
['new-folder', 'New folder'],
['new-markdown', 'New markdown file'],
['new-text', 'New text file'],
];
for (const [action, label] of actions) {
const button = page.locator(`[data-files-action="${action}"]`).first();
await expect(button).toBeVisible();
await expect(button).toHaveAttribute('title', label);
await expect(button).toHaveAttribute('aria-label', label);
await expect(button).toHaveAttribute('data-files-icon', /.+/);
await expect(button.locator('svg')).toBeVisible();
}
});
test('Vault Selection is localized and has a clear primary action', async ({ browser }) => {
const page = await browser.newPage();
await page.addInitScript(() => {
window.go = { api: { App: {
GetAppSettings: async () => ({ currentVaultPath: '', recentVaults: ['/tmp/verstak-recent-vault'] }),
GetVaultStatus: async () => ({ status: 'closed', path: '', vaultId: '' }),
SelectDirectory: async () => '',
SelectVaultForOpen: async () => '',
CreateVault: async () => null,
OpenVault: async () => null,
SetCurrentVault: async () => '',
WriteFrontendLog: async () => {},
} } };
});
await page.goto('/');
await page.waitForSelector('.vault-selection', { timeout: 10000 });
await expect(page.getByText('Choose a vault to start working')).toBeVisible();
await expect(page.getByRole('button', { name: 'Create vault' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Open existing' })).toBeVisible();
await page.close();
});
});

View File

@ -1,322 +0,0 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
test.describe('UX Overview workspace flow', () => {
let consoleCollector;
test.beforeEach(async ({ page }) => {
consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
});
test.afterEach(async () => {
consoleCollector.assertNoErrors();
});
test('workspace opens with Overview before plugin tools', async ({ page }) => {
await expect(page.locator('.workspace-host')).toBeVisible({ timeout: 10000 });
const tabs = page.getByRole('tab');
await expect(tabs.nth(0)).toHaveText('Overview');
await expect(tabs.nth(1)).toHaveText('Notes');
await expect(page.getByRole('tab', { name: 'Overview' })).toHaveAttribute('aria-selected', 'true');
await expect(page.getByRole('tab', { name: 'Today' })).toHaveCount(0);
const overview = page.locator('[data-overview-root]');
await expect(overview).toBeVisible();
await expect(overview.locator('[data-overview-section="continue"]')).toContainText('Continue working');
await expect(overview.locator('[data-overview-section="recent"]')).toContainText('Recent changes');
await expect(overview.locator('[data-overview-section="attention"]')).toContainText('Needs attention');
await expect(overview.locator('[data-overview-section="quick-actions"]')).toHaveCount(0);
const summaryCards = overview.locator('button[data-overview-summary]');
await expect(summaryCards).toHaveCount(6);
await expect(overview.locator('[data-overview-summary="notes"]')).toContainText('1 total');
await expect(overview.locator('[data-overview-summary="notes"]')).toContainText('0 recent changes');
await expect(overview.locator('[data-overview-summary="captures"]')).toContainText('0 captures to review');
await expect(overview.locator('[data-overview-summary="activity"]')).toContainText('0 recorded events');
await expect(overview.locator('[data-overview-summary="journal"]')).toContainText('0 journal entries');
await expect(overview).toContainText('No clear resume point yet');
await expect(overview).toContainText('No meaningful changes for this filter yet');
});
test('Overview summary cards navigate to their corresponding workspace tools', async ({ page }) => {
const overview = page.locator('[data-overview-root]');
await overview.locator('[data-overview-summary="notes"]').click();
await expect(page.getByRole('tab', { name: 'Notes' })).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.notes-root')).toBeVisible({ timeout: 10000 });
await page.getByRole('tab', { name: 'Overview' }).click();
await overview.locator('[data-overview-summary="files"]').click();
await expect(page.getByRole('tab', { name: 'Files' })).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.files-root')).toBeVisible({ timeout: 10000 });
await page.getByRole('tab', { name: 'Overview' }).click();
await overview.locator('[data-overview-summary="captures"]').click();
await expect(page.getByRole('tab', { name: 'Browser' })).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.browser-inbox-root')).toBeVisible({ timeout: 10000 });
await page.getByRole('tab', { name: 'Overview' }).click();
await overview.locator('[data-overview-summary="activity"]').click();
await expect(page.getByRole('tab', { name: 'Activity' })).toHaveAttribute('aria-selected', 'true');
await page.getByRole('tab', { name: 'Overview' }).click();
await overview.locator('[data-overview-summary="journal"]').click();
await expect(page.getByRole('tab', { name: 'Journal' })).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.journal-root')).toBeVisible({ timeout: 10000 });
await page.getByRole('tab', { name: 'Overview' }).click();
await overview.locator('[data-overview-summary="attention"]').click();
await expect(page.getByRole('tab', { name: 'Browser' })).toHaveAttribute('aria-selected', 'true');
});
test('Overview keeps the Notes total factual when the Files plugin is unavailable', async ({ page }) => {
await page.evaluate(() => window.__wailsMock.setPluginStatus('verstak.files', 'disabled', false));
await page.locator('[data-overview-action="refresh"]').click();
await expect(page.locator('[data-overview-summary="notes"]')).toContainText('1 total');
});
test('Overview hides Browser cards and actions when the current Deal does not include it', async ({ page }) => {
await page.locator('button[title="New Deal"]').click();
const modal = page.locator('[data-workspace-create-modal]');
await modal.locator('[data-workspace-name]').fill('MinimalOverview');
await modal.locator('[data-workspace-template]').selectOption('minimal');
await modal.getByRole('button', { name: 'Create Deal' }).click();
await expect(page.getByRole('tab', { name: 'Browser' })).toHaveCount(0);
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.browser-inbox', {
'captures:global': [{
captureId: 'hidden-browser-capture',
capturedAt: '2026-07-14T08:00:00.000Z',
kind: 'page',
title: 'Inbox material must stay hidden',
workspaceRootPath: 'MinimalOverview',
}],
});
});
const overview = page.locator('[data-overview-root]');
await overview.locator('[data-overview-action="refresh"]').click();
await expect(overview.locator('[data-overview-summary="captures"]')).toHaveCount(0);
await expect(overview.locator('[data-overview-action="browser-inbox"]')).toHaveCount(0);
await expect(overview).not.toContainText('Inbox material must stay hidden');
});
test('Overview refreshes when Browser is disabled through plugin state changes', async ({ page }) => {
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.browser-inbox', {
'captures:global': [{
captureId: 'disabled-browser-capture',
capturedAt: '2026-07-14T08:00:00.000Z',
kind: 'page',
title: 'Disabled inbox material',
workspaceRootPath: 'Project',
}],
});
window.__wailsMock.setPluginStatus('verstak.browser-inbox', 'disabled', false);
window.dispatchEvent(new CustomEvent('verstak:plugins-changed'));
});
const overview = page.locator('[data-overview-root]');
await expect(page.getByRole('tab', { name: 'Browser' })).toHaveCount(0);
await expect(overview.locator('[data-overview-summary="captures"]')).toHaveCount(0);
await expect(overview.locator('[data-overview-action="browser-inbox"]')).toHaveCount(0);
});
test('Overview prioritizes resume work and filters meaningful recent changes', async ({ page }) => {
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.browser-inbox', {
'captures:global': [
{
captureId: 'overview-capture-1',
capturedAt: '2026-06-30T08:00:00.000Z',
kind: 'page',
url: 'https://example.com/research',
title: 'Research Report',
domain: 'example.com',
workspaceRootPath: 'Project',
},
{
captureId: 'overview-capture-2',
capturedAt: '2026-06-30T08:15:00.000Z',
kind: 'selection',
title: 'Quote to process',
domain: 'example.com',
workspaceRootPath: 'Project',
},
{
captureId: 'overview-client-capture',
capturedAt: '2026-06-30T08:30:00.000Z',
kind: 'page',
title: 'Client capture must stay global',
workspaceRootPath: 'ClientA',
},
{
captureId: 'overview-unassigned-capture',
capturedAt: '2026-06-30T08:35:00.000Z',
kind: 'page',
title: 'Unassigned capture must stay global',
},
],
});
await window.go.api.App.WritePluginSettings('verstak.activity', {
'events:workspace:Project': [
{
activityId: 'overview-selected-file',
occurredAt: '2026-06-30T08:50:00.000Z',
type: 'file.selected',
title: 'Selected file',
summary: 'Project/draft.md',
workspaceRootPath: 'Project',
},
{
activityId: 'overview-opened-file',
occurredAt: '2026-06-30T08:40:00.000Z',
type: 'file.opened',
title: 'draft.md',
summary: 'Project/draft.md',
workspaceRootPath: 'Project',
},
{
activityId: 'overview-note',
occurredAt: '2026-06-30T08:25:00.000Z',
type: 'note.saved',
title: 'Overview',
summary: 'Project/Notes/Overview.md',
workspaceRootPath: 'Project',
},
{
activityId: 'overview-file',
occurredAt: '2026-06-30T08:20:00.000Z',
type: 'file.changed',
title: 'draft.md',
summary: 'Project/draft.md',
workspaceRootPath: 'Project',
},
{
activityId: 'overview-workspace-selected',
occurredAt: '2026-06-30T08:10:00.000Z',
type: 'case.selected',
title: 'Workspace selected',
workspaceRootPath: 'Project',
},
],
'work-session-candidates:workspace:Project': [
{
candidateId: 'work-session:Project:overview-note:overview-file',
workspaceRootPath: 'Project',
startedAt: '2026-06-30T08:15:00.000Z',
endedAt: '2026-06-30T08:25:00.000Z',
estimatedMinutes: 10,
activityCount: 2,
activityIds: ['overview-file', 'overview-note'],
activities: [
{ activityId: 'overview-file', type: 'file.changed', occurredAt: '2026-06-30T08:20:00.000Z' },
{ activityId: 'overview-note', type: 'note.saved', occurredAt: '2026-06-30T08:25:00.000Z' },
],
},
],
});
await window.go.api.App.WritePluginSettings('verstak.journal', {
'worklog:workspace:Project': [
{
entryId: 'overview-journal-1',
date: '2026-06-30',
title: 'Write project summary',
summary: 'Turn recent captures into a worklog entry',
minutes: 35,
workspaceRootPath: 'Project',
},
],
});
});
await page.locator('[data-overview-action="refresh"]').click();
const overview = page.locator('[data-overview-root]');
await expect(overview.locator('[data-overview-summary="notes"]')).toContainText('1 total');
await expect(overview.locator('[data-overview-summary="notes"]')).toContainText('1 recent change');
await expect(overview.locator('[data-overview-summary="files"]')).toContainText('1 recent change');
await expect(overview.locator('[data-overview-summary="captures"]')).toContainText('2');
await expect(overview.locator('[data-overview-summary="activity"]')).toContainText('5 recorded events');
await expect(overview.locator('[data-overview-summary="journal"]')).toContainText('1');
await expect(overview.locator('[data-overview-summary="attention"]')).toContainText('3');
const attention = overview.locator('[data-overview-section="attention"]');
await expect(attention).toContainText('Possible journal entry');
await expect(attention).toContainText('Deal: Project · 10 min · 2 activities');
await attention.locator('.overview-attention-row', { hasText: 'Possible journal entry' }).getByRole('button', { name: 'Review candidate' }).click();
await expect(page.getByRole('tab', { name: 'Journal' })).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.journal-root [data-journal-candidate]')).toContainText('Deal: Project');
await page.locator('.journal-modal-actions').getByRole('button', { name: 'Cancel' }).click();
await page.getByRole('tab', { name: 'Overview' }).click();
const resume = overview.locator('[data-overview-section="continue"]');
const candidates = resume.locator('[data-overview-continue-item]');
await expect(candidates).toHaveCount(4);
await expect(candidates.nth(0)).toContainText('Quote to process');
await expect(candidates.nth(1)).toContainText('Research Report');
await expect(candidates.nth(2)).toContainText('Edited note "Overview"');
await expect(candidates.nth(3)).toContainText('Changed file "draft.md"');
await candidates.nth(0).click();
await expect(page.getByRole('tab', { name: 'Browser' })).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.browser-inbox-root')).toBeVisible({ timeout: 10000 });
await page.getByRole('tab', { name: 'Overview' }).click();
const recent = overview.locator('[data-overview-section="recent"]');
await expect(recent).toContainText('Edited note "Overview"');
await expect(recent).toContainText('Changed file "draft.md"');
await expect(recent).toContainText('Captured page "Research Report"');
await expect(recent).toContainText('Added journal entry "Write project summary"');
await expect(recent).not.toContainText('Selected file');
await expect(recent).not.toContainText('Workspace selected');
await expect(recent).not.toContainText('file.opened');
await expect(recent).not.toContainText('Client capture must stay global');
await expect(recent).not.toContainText('Unassigned capture must stay global');
await expect(recent.locator('[data-overview-recent-item]')).toHaveCount(5);
await expect(recent.locator('[data-overview-recent-item] button')).toHaveCount(0);
await overview.locator('[data-overview-filter="notes"]').click();
await expect(recent).toContainText('Edited note "Overview"');
await expect(recent).not.toContainText('Changed file "draft.md"');
await expect(recent).not.toContainText('Research Report');
await overview.locator('[data-overview-filter="captures"]').click();
await expect(recent).toContainText('Captured page "Research Report"');
await expect(recent).toContainText('Captured selection "Quote to process"');
await expect(recent).not.toContainText('Edited note "Overview"');
await overview.locator('[data-overview-filter="journal"]').click();
await expect(recent).toContainText('Added journal entry "Write project summary"');
await expect(recent).not.toContainText('Changed file "draft.md"');
});
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

@ -1,119 +0,0 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
test.describe('Workspace templates', () => {
let consoleCollector;
test.beforeEach(async ({ page }) => {
consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
});
test.afterEach(async () => {
consoleCollector.assertNoErrors();
});
async function openCreateModal(page) {
await page.locator('button[title="New Deal"]').click();
const modal = page.locator('[data-workspace-create-modal]');
await expect(modal).toBeVisible();
return modal;
}
test('creation modal validates names, shows template tools, and persists the selected snapshot', async ({ page }) => {
const modal = await openCreateModal(page);
const templateSelect = modal.locator('[data-workspace-template]');
await expect(templateSelect).toHaveValue('default');
await expect(templateSelect).toHaveCSS('appearance', 'none');
await expect(modal.locator('[data-workspace-template-tools]')).toContainText('Notes');
await expect(modal.locator('[data-workspace-template-tools]')).toContainText('Browser');
await modal.getByRole('button', { name: 'Create Deal' }).click();
await expect(modal.locator('[data-workspace-create-error]')).toContainText('Name is required');
await modal.locator('[data-workspace-name]').fill('bad/name');
await modal.getByRole('button', { name: 'Create Deal' }).click();
await expect(modal.locator('[data-workspace-create-error]')).toContainText('Could not create the Deal. Please try again.');
await modal.locator('[data-workspace-name]').fill('ProjectPlan');
await modal.locator('[data-workspace-template]').selectOption('project');
await expect(modal.locator('[data-workspace-template-description]')).toContainText('Project planning');
await expect(modal.locator('[data-workspace-template-tools]')).toContainText('Todos');
await modal.getByRole('button', { name: 'Create Deal' }).click();
await expect(page.locator('.wt-label').filter({ hasText: 'ProjectPlan' })).toBeVisible();
await expect.poll(async () => page.evaluate(async () => {
const result = await window.go.api.App.GetWorkspaceMetadata('ProjectPlan');
const metadata = Array.isArray(result) ? result[0] : result;
return {
templateId: metadata.createdFromTemplate?.templateId,
tools: metadata.workspaceTools,
};
})).toEqual({
templateId: 'project',
tools: ['verstak.notes', 'verstak.files', 'verstak.todo', 'verstak.journal', 'verstak.activity', 'verstak.browser-inbox'],
});
await expect(page.getByRole('tab', { name: 'Todos' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Journal' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Secrets' })).toHaveCount(0);
});
test('Minimal keeps global tools available while limiting workspace tabs', async ({ page }) => {
const modal = await openCreateModal(page);
await modal.locator('[data-workspace-name]').fill('MinimalSpace');
await modal.locator('[data-workspace-template]').selectOption('minimal');
await modal.getByRole('button', { name: 'Create Deal' }).click();
await expect(page.getByRole('tab', { name: 'Overview' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Notes' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Files' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Todos' })).toHaveCount(0);
await expect(page.getByRole('tab', { name: 'Journal' })).toHaveCount(0);
await expect(page.getByRole('tab', { name: 'Secrets' })).toHaveCount(0);
await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Todos' })).toBeVisible();
await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser' })).toBeVisible();
});
test('template explains an unavailable plugin and warns after incomplete creation', async ({ page }) => {
await page.evaluate(() => window.__wailsMock.setPluginStatus('verstak.todo', 'disabled', false));
const modal = await openCreateModal(page);
await modal.locator('[data-workspace-template]').selectOption('project');
const todo = modal.locator('[data-workspace-template-tool="verstak.todo"]');
await expect(todo).toContainText('Todos');
await expect(todo).toContainText('Plugin is disabled');
await expect(todo).toHaveAttribute('data-template-tool-status', 'unavailable');
await modal.locator('[data-workspace-name]').fill('ProjectWithWarning');
await modal.getByRole('button', { name: 'Create Deal' }).click();
const warning = page.locator('[data-workspace-template-warning]');
await expect(warning).toContainText('ProjectWithWarning');
await expect(warning).toContainText('Todos');
await expect(warning).toContainText('Plugin is disabled');
});
test('Admin shows Secrets when available and missing workspace plugins degrade without breaking tabs', async ({ page }) => {
let modal = await openCreateModal(page);
await modal.locator('[data-workspace-name]').fill('AdminSpace');
await modal.locator('[data-workspace-template]').selectOption('admin');
await modal.getByRole('button', { name: 'Create Deal' }).click();
await expect(page.getByRole('tab', { name: 'Secrets' })).toBeVisible();
await page.evaluate(() => window.__wailsMock.setPluginStatus('verstak.todo', 'disabled', false));
modal = await openCreateModal(page);
await modal.locator('[data-workspace-name]').fill('ProjectWithoutTodo');
await modal.locator('[data-workspace-template]').selectOption('project');
await modal.getByRole('button', { name: 'Create Deal' }).click();
await expect(page.getByRole('tab', { name: 'Notes' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Files' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Todos' })).toHaveCount(0);
await page.locator('.wt-label').filter({ hasText: 'AdminSpace' }).click();
await expect(page.getByRole('tab', { name: 'Secrets' })).toBeVisible();
});
});

View File

@ -1,27 +1,19 @@
<script>
import PluginManager from './lib/plugin-manager/PluginManager.svelte';
import Sidebar from './lib/shell/Sidebar.svelte';
import CommandPalette from './lib/shell/CommandPalette.svelte';
import StatusBar from './lib/shell/StatusBar.svelte';
import ViewContainer from './lib/shell/ViewContainer.svelte';
import VaultSelection from './lib/shell/VaultSelection.svelte';
import WorkbenchHost from './lib/shell/WorkbenchHost.svelte';
import WorkspaceHost from './lib/shell/WorkspaceHost.svelte';
import * as App from '../wailsjs/go/api/App';
import { debug } from './lib/log/debug.js';
import { onDestroy, onMount, tick } from 'svelte';
import { i18n } from './lib/i18n/index.js';
import { onMount } from 'svelte';
import { tick } from 'svelte';
let currentView = 'workspace';
let currentView = 'plugin-manager';
let vaultStatus = { status: 'unknown', path: '', vaultId: '' };
let needsVaultSelection = false;
let loading = true;
let locale = i18n.getLocale();
const unsubscribeLocale = i18n.subscribe((nextLocale) => { locale = nextLocale; });
$: tr = ((activeLocale) => (key, params, fallback) => {
void activeLocale;
return i18n.t(key, params, fallback);
})(locale);
let activeView = null;
let activeViewPluginId = '';
@ -31,7 +23,6 @@
let workspaceNodes = [];
let selectedWorkspaceName = '';
let activeWorkspaceToolKey = '';
let navigationStack = [];
let navigationIndex = -1;
let applyingNavigation = false;
@ -42,76 +33,6 @@
App.WriteFrontendLog('App', msg);
}
function resultOrError(response, fallbackValue) {
return typeof response === 'string' ? [fallbackValue, response] : [response, ''];
}
function workspaceName(workspace) {
return String(workspace?.name || workspace?.rootPath || workspace?.id || '');
}
function workspaceAsNode(workspace, order) {
const name = workspaceName(workspace);
return {
id: name,
workspaceId: workspace?.id || workspace?.workspaceId || '',
type: workspace?.type || 'space',
title: workspace?.title || name,
name,
rootPath: workspace?.rootPath || name,
status: workspace?.status || 'active',
order,
};
}
function emitWorkspaceActive(name) {
window.dispatchEvent(new CustomEvent('verstak:workspace-active-changed', {
detail: { workspaceName: name || '' }
}));
}
function clearWorkspaceSelection() {
selectedWorkspaceName = '';
emitWorkspaceActive('');
}
async function openDefaultWorkspaceRoute() {
try {
const [workspaces, err] = resultOrError(await App.ListWorkspaces(), []);
if (err || !workspaces || workspaces.length === 0) {
workspaceNodes = [];
selectedWorkspaceName = '';
currentView = 'workspace-empty';
emitWorkspaceActive('');
return;
}
workspaceNodes = workspaces.map(workspaceAsNode);
let currentWorkspace = null;
try {
currentWorkspace = await App.GetCurrentWorkspace();
} catch {
currentWorkspace = null;
}
const currentName = workspaceName(currentWorkspace);
const selected = workspaces.find((workspace) => workspaceName(workspace) === currentName) || workspaces[0];
selectedWorkspaceName = workspaceName(selected);
if (selectedWorkspaceName) {
try { await App.SetCurrentWorkspace(selectedWorkspaceName); } catch {}
currentView = 'workspace';
} else {
currentView = 'workspace-empty';
}
emitWorkspaceActive(selectedWorkspaceName);
} catch (e) {
debug.log('[App] openDefaultWorkspaceRoute ERROR', String(e));
workspaceNodes = [];
selectedWorkspaceName = '';
currentView = 'workspace-empty';
emitWorkspaceActive('');
}
}
function currentSnapshot() {
return {
currentView,
@ -121,7 +42,6 @@
activeSettingsPanelId,
openedResource,
selectedWorkspaceName,
activeWorkspaceToolKey,
};
}
@ -148,8 +68,6 @@
activeSettingsPanelId = snapshot.activeSettingsPanelId;
openedResource = snapshot.openedResource;
selectedWorkspaceName = snapshot.selectedWorkspaceName;
activeWorkspaceToolKey = snapshot.activeWorkspaceToolKey || '';
emitWorkspaceActive(currentView === 'workspace' ? selectedWorkspaceName : '');
applyingNavigation = false;
}
@ -168,7 +86,7 @@
}
function mouseHistoryDirection(event) {
if (currentView === 'workspace') return '';
if (currentView === 'workspace' || currentView === 'workbench') return '';
if (event.button === 3 || event.button === 8 || event.buttons === 8 || event.buttons === 128 || event.which === 8) return 'back';
if (event.button === 4 || event.button === 9 || event.buttons === 16 || event.buttons === 256 || event.which === 9) return 'forward';
return '';
@ -219,7 +137,6 @@
const settings = await App.GetAppSettings();
debug.log('[App] checkVault: GetAppSettings returned', settings);
flog('checkVault: GetAppSettings returned');
if (settings?.debug) debug.enable({ persist: false });
debug.log('[App] checkVault: calling GetVaultStatus...');
vaultStatus = await App.GetVaultStatus() || { status: 'unknown', path: '', vaultId: '' };
@ -234,7 +151,6 @@
debug.log('[App] checkVault: vault open, needsVaultSelection=false');
flog('checkVault: needsVaultSelection=false');
needsVaultSelection = false;
await openDefaultWorkspaceRoute();
}
} catch (e) {
debug.log('[App] checkVault: ERROR', String(e));
@ -248,18 +164,15 @@
flog('checkVault: END, loading=false');
}
async function onVaultOpened() {
function onVaultOpened() {
debug.log('[App] onVaultOpened');
needsVaultSelection = false;
vaultStatus = { status: 'open', path: '', vaultId: '' };
await openDefaultWorkspaceRoute();
pushNavigation();
}
function onNav(e) {
debug.log('[App] onNav:', e.detail.viewId);
currentView = e.detail.viewId;
if (currentView !== 'workspace') clearWorkspaceSelection();
pushNavigation();
}
@ -268,7 +181,6 @@
activeView = e.detail.viewId;
activeViewPluginId = e.detail.pluginId || '';
currentView = 'plugin-view';
clearWorkspaceSelection();
pushNavigation();
}
@ -277,35 +189,22 @@
activeSettingsPluginId = e.detail.pluginId;
activeSettingsPanelId = e.detail.panelId || '';
currentView = 'plugin-manager';
clearWorkspaceSelection();
pushNavigation();
}
function onWorkbenchOpened(e) {
debug.log('[App] onWorkbenchOpened:', e.detail?.request?.path, e.detail?.providerId);
if (currentView === 'workspace') pushNavigation();
openedResource = e.detail;
currentView = 'workbench';
pushNavigation();
}
function onWorkspaceToolSelected(e) {
activeWorkspaceToolKey = e.detail?.toolKey || '';
if (currentView === 'workspace') pushNavigation();
}
function onWorkspaceSelected(e) {
debug.log('[App] onWorkspaceSelected:', e.detail?.workspaceName);
selectedWorkspaceName = e.detail?.workspaceName || '';
workspaceNodes = e.detail?.nodes || workspaceNodes;
if (selectedWorkspaceName) {
activeView = null;
activeViewPluginId = '';
activeSettingsPluginId = '';
activeSettingsPanelId = '';
openedResource = null;
currentView = 'workspace';
emitWorkspaceActive(selectedWorkspaceName);
pushNavigation();
}
}
@ -368,7 +267,6 @@
window.addEventListener('verstak:close-settings', onCloseSettings);
window.addEventListener('verstak:workbench-opened', onWorkbenchOpened);
window.addEventListener('verstak:workspace-selected', onWorkspaceSelected);
window.addEventListener('verstak:workspace-tool-selected', onWorkspaceToolSelected);
window.addEventListener('verstak:navigate-back', onNavigateBack);
window.addEventListener('verstak:navigate-forward', onNavigateForward);
window.addEventListener('verstak:close-workbench', onCloseWorkbench);
@ -383,42 +281,28 @@
await checkVault();
pushNavigation();
});
onDestroy(unsubscribeLocale);
</script>
{#if loading}
<div class="app-loading">
<p>{tr('app.loading')}</p>
<p>Loading Verstak...</p>
</div>
{:else if needsVaultSelection}
<VaultSelection />
{:else}
<main>
<Sidebar
showGlobalSearch={currentView !== 'workspace' && currentView !== 'workspace-empty'}
{activeView}
{activeViewPluginId}
/>
<CommandPalette />
<Sidebar />
<section class="content-shell">
<section class="content scroll-surface">
{#if currentView === 'plugin-manager'}
<PluginManager {activeSettingsPluginId} {activeSettingsPanelId} />
{:else if currentView === 'workbench'}
<WorkbenchHost {openedResource} />
{:else if currentView === 'workspace' || currentView === 'workspace-empty'}
<WorkspaceHost
selectedWorkspaceName={selectedWorkspaceName}
nodes={workspaceNodes}
bind:activeToolKey={activeWorkspaceToolKey}
/>
{:else}
<ViewContainer {activeView} {activeViewPluginId} />
{/if}
</section>
<StatusBar />
<section class="content scroll-surface">
{#if currentView === 'plugin-manager'}
<PluginManager {activeSettingsPluginId} {activeSettingsPanelId} />
{:else if currentView === 'workbench'}
<WorkbenchHost {openedResource} />
{:else if currentView === 'workspace'}
<WorkspaceHost selectedWorkspaceName={selectedWorkspaceName} nodes={workspaceNodes} />
{:else}
<ViewContainer {activeView} {activeViewPluginId} />
{/if}
</section>
</main>
{/if}
@ -430,41 +314,6 @@
box-sizing: border-box;
}
:global(:root) {
--vt-color-background: #101020;
--vt-color-surface: #15152c;
--vt-color-surface-muted: #111629;
--vt-color-surface-hover: #1b2440;
--vt-color-surface-selected: rgba(78, 204, 163, 0.14);
--vt-color-border: #202b46;
--vt-color-border-strong: #2c456a;
--vt-color-text-primary: #f4f7fb;
--vt-color-text-secondary: #b7c0d4;
--vt-color-text-muted: #7f8aa3;
--vt-color-accent: #4ecca3;
--vt-color-accent-muted: rgba(78, 204, 163, 0.14);
--vt-color-danger: #e94560;
--vt-color-danger-muted: rgba(233, 69, 96, 0.14);
--vt-color-warning: #ffc857;
--vt-color-warning-muted: rgba(255, 200, 87, 0.14);
--vt-color-success: #4ecca3;
--vt-space-1: 0.25rem;
--vt-space-2: 0.5rem;
--vt-space-3: 0.75rem;
--vt-space-4: 1rem;
--vt-space-6: 1.5rem;
--vt-space-8: 2rem;
--vt-radius-sm: 4px;
--vt-radius-md: 6px;
--vt-radius-lg: 8px;
--vt-font-xs: 0.72rem;
--vt-font-sm: 0.8rem;
--vt-font-md: 0.88rem;
--vt-font-lg: 1rem;
--vt-focus-ring: 0 0 0 2px rgba(78, 204, 163, 0.34);
--vt-elevation-menu: 0 14px 32px rgba(0, 0, 0, 0.42);
}
:global(html),
:global(body),
:global(#app) {
@ -474,8 +323,8 @@
:global(body) {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--vt-color-background);
color: var(--vt-color-text-primary);
background: #1a1a2e;
color: #e0e0f0;
overflow: hidden;
}
@ -486,12 +335,12 @@
gap: 0.35rem;
min-height: 2rem;
padding: 0.4rem 0.85rem;
border: 1px solid var(--vt-color-border-strong);
border-radius: var(--vt-radius-md);
background: var(--vt-color-surface-hover);
color: var(--vt-color-text-primary);
border: 1px solid #1a3a5c;
border-radius: 6px;
background: #0f3460;
color: #e0e0f0;
font: inherit;
font-size: var(--vt-font-sm);
font-size: 0.85rem;
font-weight: 600;
line-height: 1.2;
cursor: pointer;
@ -499,14 +348,14 @@
}
:global(button:hover:not(:disabled)) {
background: #203050;
border-color: var(--vt-color-accent);
background: #1a3a5c;
border-color: #4ecca3;
color: #ffffff;
}
:global(button:focus-visible) {
outline: 0;
box-shadow: var(--vt-focus-ring);
outline: 2px solid #4ecca3;
outline-offset: 2px;
}
:global(button:disabled) {
@ -515,8 +364,8 @@
}
:global(.btn-primary) {
background: var(--vt-color-accent);
border-color: var(--vt-color-accent);
background: #4ecca3;
border-color: #4ecca3;
color: #101827;
}
@ -527,14 +376,14 @@
}
:global(.btn-secondary) {
background: var(--vt-color-surface-hover);
border-color: var(--vt-color-border-strong);
color: var(--vt-color-text-primary);
background: #0f3460;
border-color: #533483;
color: #e0e0f0;
}
:global(.btn-danger) {
background: var(--vt-color-danger);
border-color: var(--vt-color-danger);
background: #e94560;
border-color: #e94560;
color: #ffffff;
}
@ -546,13 +395,13 @@
:global(.btn-ghost) {
background: transparent;
border-color: transparent;
color: var(--vt-color-text-secondary);
color: #a0a0b8;
}
:global(.btn-ghost:hover:not(:disabled)) {
background: var(--vt-color-surface-hover);
border-color: var(--vt-color-border);
color: var(--vt-color-text-primary);
background: rgba(15, 52, 96, 0.55);
border-color: #0f3460;
color: #e0e0f0;
}
:global(.btn-icon) {
@ -561,236 +410,6 @@
padding: 0;
}
:global(.vt-page) {
min-width: 0;
min-height: 0;
height: 100%;
display: flex;
flex-direction: column;
background: var(--vt-color-background);
color: var(--vt-color-text-primary);
}
:global(.vt-page-header),
:global(.vt-toolbar) {
min-height: 2.75rem;
display: flex;
align-items: center;
gap: var(--vt-space-2);
padding: var(--vt-space-2) var(--vt-space-3);
border-bottom: 1px solid var(--vt-color-border);
background: var(--vt-color-surface-muted);
flex-shrink: 0;
}
:global(.vt-page-title) {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--vt-color-text-primary);
font-size: var(--vt-font-lg);
font-weight: 650;
}
:global(.vt-page-subtitle) {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--vt-color-text-muted);
font-size: var(--vt-font-sm);
}
:global(.vt-toolbar-group) {
display: inline-flex;
align-items: center;
gap: var(--vt-space-1);
padding: 0 var(--vt-space-2) 0 0;
border-right: 1px solid var(--vt-color-border);
}
:global(.vt-toolbar-group:last-child) {
border-right: 0;
padding-right: 0;
}
:global(.vt-button) {
min-height: 2rem;
border-radius: var(--vt-radius-md);
}
:global(.vt-icon-button) {
width: 2rem;
min-width: 2rem;
height: 2rem;
min-height: 2rem;
padding: 0;
}
:global(.vt-tabbar) {
display: flex;
align-items: center;
gap: var(--vt-space-1);
padding: var(--vt-space-1) var(--vt-space-3) 0;
border-bottom: 1px solid var(--vt-color-border);
background: #12162a;
overflow-x: auto;
flex-shrink: 0;
}
:global(.vt-tab) {
flex-shrink: 0;
min-height: 2.1rem;
padding: 0.38rem 0.78rem;
border: 1px solid transparent;
border-bottom: 0;
border-radius: var(--vt-radius-md) var(--vt-radius-md) 0 0;
background: transparent;
color: var(--vt-color-text-muted);
font-size: var(--vt-font-sm);
}
:global(.vt-tab:hover:not(:disabled)) {
background: var(--vt-color-surface-hover);
border-color: transparent;
color: var(--vt-color-text-primary);
}
:global(.vt-tab.is-active),
:global(.vt-tab.active) {
background: var(--vt-color-background);
border-color: var(--vt-color-border);
color: var(--vt-color-accent);
}
:global(.vt-list-row) {
border-bottom: 1px solid rgba(32, 43, 70, 0.72);
color: var(--vt-color-text-primary);
}
:global(.vt-list-row:hover) {
background: var(--vt-color-surface-hover);
}
:global(.vt-list-row.selected),
:global(.vt-list-row.active),
:global(.vt-list-row.is-selected) {
background: var(--vt-color-surface-selected);
box-shadow: inset 2px 0 0 var(--vt-color-accent);
}
:global(.vt-card) {
border: 1px solid var(--vt-color-border);
border-radius: var(--vt-radius-lg);
background: var(--vt-color-surface);
}
:global(.vt-split-pane) {
display: grid;
grid-template-columns: minmax(16rem, 22rem) minmax(0, 1fr);
min-height: 0;
height: 100%;
background: var(--vt-color-background);
}
:global(.vt-split-list) {
min-height: 0;
overflow: auto;
border-right: 1px solid var(--vt-color-border);
background: var(--vt-color-surface-muted);
}
:global(.vt-split-detail) {
min-width: 0;
min-height: 0;
overflow: auto;
padding: var(--vt-space-4);
}
:global(.vt-empty-state) {
min-height: 9rem;
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--vt-space-2);
padding: var(--vt-space-8);
color: var(--vt-color-text-muted);
font-size: var(--vt-font-sm);
line-height: 1.45;
text-align: center;
}
:global(.vt-empty-title) {
color: var(--vt-color-text-secondary);
font-size: var(--vt-font-md);
font-weight: 650;
}
:global(.vt-inline-alert) {
border: 1px solid var(--vt-color-border-strong);
border-radius: var(--vt-radius-md);
background: var(--vt-color-surface-muted);
color: var(--vt-color-text-secondary);
padding: var(--vt-space-3);
font-size: var(--vt-font-sm);
line-height: 1.45;
}
:global(.vt-inline-alert.error) {
border-color: rgba(233, 69, 96, 0.55);
background: var(--vt-color-danger-muted);
color: #ffc6ce;
}
:global(.vt-badge) {
display: inline-flex;
align-items: center;
min-height: 1.25rem;
padding: 0.1rem 0.4rem;
border: 1px solid var(--vt-color-border);
border-radius: var(--vt-radius-sm);
background: var(--vt-color-surface-muted);
color: var(--vt-color-text-muted);
font-size: var(--vt-font-xs);
font-weight: 650;
line-height: 1;
}
:global(.vt-badge.accent) {
border-color: rgba(78, 204, 163, 0.4);
background: var(--vt-color-accent-muted);
color: var(--vt-color-accent);
}
:global(.vt-menu) {
border: 1px solid var(--vt-color-border-strong);
border-radius: var(--vt-radius-md);
background: var(--vt-color-surface);
color: var(--vt-color-text-primary);
box-shadow: var(--vt-elevation-menu);
}
:global(.vt-menu-item) {
display: flex;
align-items: center;
gap: var(--vt-space-2);
padding: 0.42rem 0.7rem;
color: var(--vt-color-text-secondary);
cursor: pointer;
}
:global(.vt-menu-item:hover) {
background: var(--vt-color-surface-hover);
color: var(--vt-color-text-primary);
}
:global(.vt-menu-item.danger) {
color: #ff9aaa;
}
:global(.scroll-surface) {
min-width: 0;
min-height: 0;
@ -800,7 +419,7 @@
:global(*) {
scrollbar-width: thin;
scrollbar-color: var(--vt-color-border-strong) var(--vt-color-background);
scrollbar-color: #0f3460 #1a1a2e;
}
:global(*::-webkit-scrollbar) {
@ -809,12 +428,12 @@
}
:global(*::-webkit-scrollbar-track) {
background: var(--vt-color-background);
background: #1a1a2e;
}
:global(*::-webkit-scrollbar-thumb) {
background: var(--vt-color-border-strong);
border-radius: var(--vt-radius-sm);
background: #0f3460;
border-radius: 4px;
}
:global(*::-webkit-scrollbar-thumb:hover) {
@ -826,8 +445,8 @@
align-items: center;
justify-content: center;
height: 100vh;
background: var(--vt-color-background);
color: var(--vt-color-text-muted);
background: #1a1a2e;
color: #a0a0b8;
font-size: 1rem;
}
@ -835,34 +454,16 @@
display: flex;
height: 100vh;
width: 100%;
background: var(--vt-color-background);
background: #1a1a2e;
overflow: hidden;
}
.content-shell {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
}
.content {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
padding: 0;
}
@media (max-width: 720px) {
main {
flex-direction: column;
}
.content {
padding: 0.75rem;
}
padding: clamp(1rem, 2vw, 1.5rem);
}
</style>

View File

@ -1,308 +0,0 @@
export default {
'app.loading': 'Loading Verstak...',
'settings.title': 'Settings',
'settings.language': 'Language',
'settings.language.system': 'System / Системный',
'settings.language.en': 'English',
'settings.language.ru': 'Русский',
'settings.pluginManager': 'Plugin Manager',
'statusBar.label': 'Status bar',
'vault.label': 'Vault: {status}',
'vault.status.open': 'open',
'vault.status.closed': 'closed',
'vault.status.unknown': 'unknown',
'sidebar.tools': 'Tools',
'sidebar.error.contributions': 'Failed to load plugin contributions',
'sidebar.error.load': 'Failed to load sidebar',
'sidebar.error.ui': 'Plugin UI error',
'workspace.overview': 'Overview',
'workspace.search': 'Search in this Deal',
'workspace.tools': 'Deal tools',
'workspace.tool': 'Deal tool',
'workspace.emptyTools': 'No Deal tools available',
'workspace.emptyToolsHint': 'Enable plugins with Deal tools or open Plugin Manager from settings.',
'workspace.select': 'Select a Deal',
'workspace.selectHint': 'Use the + button in Deals to add your first project.',
'common.details': 'Details',
'common.plugin': 'Plugin',
'common.component': 'Component',
'pluginView.error': 'Plugin view error',
'pluginView.failed': 'Plugin UI failed',
'pluginView.noVisual': 'This plugin does not provide a visual view yet.',
'pluginView.viewId': 'View ID',
'pluginView.bundleUnavailable': 'Frontend bundle unavailable',
'pluginView.unavailable': 'View is unavailable.',
'pluginView.select': 'Select a plugin view from the sidebar',
'pluginView.selectHint': 'Plugin views will appear here',
'common.retry': '⟳ Retry',
'common.save': 'Save',
'common.source': 'Source',
'common.status': 'Status',
'common.capability': 'Capability',
'common.provider': 'Provider',
'status.loaded': 'loaded',
'status.degraded': 'degraded',
'status.failed': 'failed',
'status.disabled': 'disabled',
'status.missing': 'missing',
'pluginManager.reloading': '⟳ Reloading...',
'pluginManager.reload': '⟳ Reload',
'pluginManager.scanning': 'Scanning plugin directories...',
'pluginManager.summary.plugins': '{count} plugin(s) discovered',
'pluginManager.summary.capabilities': '{count} capabilities registered',
'pluginManager.summary.permissions': '{count} permissions known',
'pluginManager.scanSummary': 'Plugin scan summary',
'pluginManager.health': 'Plugin Health',
'pluginManager.permissionRisk': 'Permission Risk',
'pluginManager.elevatedPermissions': '{count} plugins request elevated permissions',
'pluginManager.filters': 'Filters',
'pluginManager.filterResults': 'Showing {visible} of {total} plugins',
'pluginManager.filterReset': 'Reset filters',
'pluginManager.filterState': 'State',
'pluginManager.filterAll': 'All',
'pluginManager.filterEnabled': 'Enabled',
'pluginManager.filterDisabled': 'Disabled',
'pluginManager.filterFailed': 'Problem',
'pluginManager.filterDegraded': 'Degraded',
'pluginManager.filterPermissions': 'Permissions',
'pluginManager.filterCapabilities': 'Capabilities',
'pluginManager.filterSettings': 'Settings',
'pluginManager.filterWithSettings': 'With settings',
'pluginManager.filterWithoutSettings': 'Without settings',
'pluginManager.filterSource': 'Source',
'pluginManager.filterOfficial': 'Official',
'pluginManager.filterThirdParty': 'Third-party or local',
'pluginManager.filterEmpty': 'No plugins match the selected filters.',
'pluginManager.none': 'No plugins found',
'pluginManager.scannedDirs': 'Plugin directories scanned:',
'pluginManager.userPlugins': 'user plugins',
'pluginManager.bundledPlugins': 'bundled plugins (app directory)',
'pluginManager.installHint': 'Place a plugin folder with plugin.json in one of these directories and click Reload.',
'pluginManager.missingTitle': 'Missing Installed Plugins',
'pluginManager.missingHint': 'These plugins are required by this vault but their packages are not installed locally.',
'pluginManager.missingPackage': "This plugin is listed in the vault's desired plugins but the package is not installed.",
'pluginManager.capabilityRegistry': 'Capability Registry ({count})',
'pluginManager.settingsError': 'Settings Error',
'pluginManager.pluginSettings': 'Plugin Settings',
'pluginManager.settingsBundleUnavailable': 'Settings panel frontend bundle not available',
'common.none': 'none',
'common.unknown': 'unknown',
'pluginCard.count.views': '{count} view(s)',
'pluginCard.count.commands': '{count} command(s)',
'pluginCard.count.searchProviders': '{count} search provider(s)',
'pluginCard.count.sidebar': '{count} sidebar item(s)',
'pluginCard.count.statusbar': '{count} status bar item(s)',
'pluginCard.count.openProviders': '{count} openProviders',
'pluginCard.count.workspace': '{count} Deal tool(s)',
'pluginCard.degraded': 'Plugin is usable, but some optional capabilities are unavailable.',
'pluginCard.name': 'Name',
'pluginCard.contributions': 'Contributions',
'pluginCard.technicalDetails': 'Technical details',
'pluginCard.apiVersion': 'API Version',
'pluginCard.root': 'Root',
'pluginCard.provides': 'Provides',
'pluginCard.requires': 'Requires',
'pluginCard.missingRequired': 'Missing required capabilities',
'pluginCard.optionalRequires': 'Optional Requires',
'pluginCard.optionalUnavailable': 'Optional capabilities not available — plugin running in degraded mode',
'pluginCard.permissions': 'Permissions',
'pluginCard.enabling': '⟳ Enabling...',
'pluginCard.enable': '▶ Enable',
'pluginCard.disabling': '⟳ Disabling...',
'pluginCard.disable': '⏸ Disable',
'pluginCard.openVault': 'Open a vault to manage plugin state',
'pluginCard.missingUiPermission': 'Plugin has UI contributions but lacks ui.register permission',
'common.loading': 'Loading...',
'common.browse': 'Browse...',
'vaultSelection.chooseNew': 'Choose or enter a folder for the new vault.',
'vaultSelection.createError': 'Could not create the vault. Please try again.',
'vaultSelection.openError': 'Could not open the vault. Please try again.',
'vaultSelection.chooseExisting': 'Choose or enter an existing vault.',
'vaultSelection.subtitle': 'Choose a vault to start working',
'vaultSelection.createTitle': 'Create a new vault',
'vaultSelection.createHint': 'Create a local vault folder for Deals and projects.',
'vaultSelection.pathPlaceholder': 'Choose or enter a path...',
'vaultSelection.creating': 'Creating...',
'vaultSelection.create': 'Create vault',
'vaultSelection.openTitle': 'Open an existing vault',
'vaultSelection.openHint': 'Use a vault that is already on this computer.',
'vaultSelection.opening': 'Opening...',
'vaultSelection.open': 'Open existing',
'vaultSelection.recent': 'Recent vaults',
'common.cancel': 'Cancel',
'common.close': 'Close',
'workspaceTree.title': 'Deals',
'workspaceTree.new': 'New Deal',
'workspaceTree.nameRequired': 'Name is required',
'workspaceTree.chooseTemplate': 'Choose a Deal template',
'workspaceTree.saveRename': 'Save rename',
'workspaceTree.rename': 'Rename Deal',
'workspaceTree.trash': 'Move Deal to trash',
'workspaceTree.create': 'Create Deal',
'workspaceTree.namePlaceholder': 'Deal name',
'workspaceTree.template': 'Template',
'workspaceTree.creating': 'Creating...',
'workspaceTree.templateAvailable': 'Available',
'workspaceTree.templateLimited': 'Available with limitations',
'workspaceTree.templateMissingPlugin': 'Plugin is not installed',
'workspaceTree.templatePluginDisabled': 'Plugin is disabled',
'workspaceTree.templateCapabilityUnavailable': 'A required capability is unavailable',
'workspaceTree.templateIncompatible': 'Plugin is incompatible with this version of Verstak',
'workspaceTree.templateLoadFailed': 'Plugin failed to load',
'workspaceTree.templateNotReady': 'Plugin is not ready',
'workspaceTree.templateToolTabs': 'Creates: {tabs}',
'workspaceTree.templateToolNoTabs': 'No Deal tab provided',
'workspaceTree.templateWillBeIncomplete': 'This Deal will be created without the unavailable components.',
'workspaceTree.templateIncompleteCreated': '«{name}» was created with unavailable components.',
'workspaceTree.templatesError': 'Could not load Deal templates. Please try again.',
'workspaceTree.loadError': 'Could not load Deals. Please try again.',
'workspaceTree.selectError': 'Could not select this Deal. Please try again.',
'workspaceTree.createError': 'Could not create the Deal. Please try again.',
'workspaceTree.renameError': 'Could not rename the Deal. Please try again.',
'workspaceTree.trashError': 'Could not move the Deal to trash. Please try again.',
'pluginManager.loadError': 'Could not load plugins. Please try again.',
'pluginManager.reloadError': 'Could not reload plugins. Please try again.',
'pluginManager.enableError': 'Could not enable the plugin. Please try again.',
'pluginManager.disableError': 'Could not disable the plugin. Please try again.',
'pluginManager.settingsLoadError': 'Could not load plugin settings. Please try again.',
'pluginManager.settingsUnavailable': 'Plugin settings are unavailable.',
'command.openOverview': 'Open Overview',
'command.openFiles': 'Open Files',
'command.openActivity': 'Open Activity',
'command.openBrowserInbox': 'Open Browser',
'command.createMarkdown': 'Create Markdown File',
'command.createText': 'Create Text File',
'command.syncNow': 'Sync Now',
'command.openSyncSettings': 'Open Sync Settings',
'command.openPluginManager': 'Open Plugin Manager',
'command.handled': '{title} handled',
'command.result': '{title} {status}',
'command.failed': 'Could not run {title}. Please try again.',
'command.statusHandled': 'handled',
'command.palette': 'Command Palette',
'command.run': 'Run command',
'command.none': 'No commands',
'search.indexing': 'Indexing...',
'search.placeholder': 'Search',
'search.global': 'Global search',
'search.noResults': 'No results',
'search.type.workspace': 'Deal',
'search.type.tool': 'Tool',
'search.type.folder': 'Folder',
'search.type.file': 'File',
'search.type.journal': 'Journal',
'search.type.browserInbox': 'Browser',
'search.type.activity': 'Activity',
'workbench.noProvider': 'No provider',
'workbench.noViewer': 'No viewer/editor available',
'workbench.noResource': 'No resource opened',
'bundle.noFrontend': 'Plugin has no frontend bundle',
'bundle.notFound': 'Plugin not found',
'bundle.loadFailed': 'Could not load the plugin interface. Please try again.',
'bundle.emptyContent': 'empty content',
'bundle.executionError': 'Could not start the plugin interface. Please try again.',
'bundle.registrationMissing': 'Bundle loaded but no VerstakPluginRegister call detected',
'bundle.componentMissing': 'The requested plugin interface is unavailable.',
'bundle.mountError': 'Could not open the plugin interface. Please try again.',
'bundle.mountUnavailable': 'Mount container not available',
'bundle.unexpectedError': 'Could not open the plugin interface. Please try again.',
'bundle.unknownError': 'Unknown error',
'bundle.frontendEntry': 'Frontend entry',
'bundle.availableComponents': 'Available components',
'bundle.loading': 'Loading plugin bundle...',
'overview.filter.all': 'All',
'overview.filter.notes': 'Notes',
'overview.filter.files': 'Files',
'overview.filter.captures': 'Captures',
'overview.filter.journal': 'Journal',
'overview.notes': 'Notes',
'overview.files': 'Files',
'overview.captures': 'Captures',
'overview.activity': 'Activity',
'overview.journal': 'Journal',
'overview.attention': 'Needs attention',
'overview.openNotes': 'Open Notes',
'overview.openFiles': 'Open Files',
'overview.reviewInbox': 'Review Inbox',
'overview.viewActivity': 'View Activity',
'overview.openJournal': 'Open Journal',
'overview.openTodos': 'Open Todos',
'overview.reviewCandidate': 'Review candidate',
'overview.reviewPending': 'Review pending items',
'overview.count.totalRecent.one': '{total} total · {recent} recent change',
'overview.count.totalRecent.many': '{total} total · {recent} recent changes',
'overview.count.recentChanges.one': '{count} recent change',
'overview.count.recentChanges.many': '{count} recent changes',
'overview.count.captures.one': '{count} capture to review',
'overview.count.captures.many': '{count} captures to review',
'overview.count.events.one': '{count} recorded event',
'overview.count.events.many': '{count} recorded events',
'overview.count.journal.one': '{count} journal entry',
'overview.count.journal.many': '{count} journal entries',
'overview.count.pending.one': '{count} pending item',
'overview.count.pending.many': '{count} pending items',
'overview.loadingContext': 'Loading Deal context...',
'overview.lastActive': 'Last active {time}',
'overview.noRecentActivity': 'No recent Deal activity',
'overview.refresh': 'Refresh',
'overview.summary': 'Deal overview summary',
'overview.continue': 'Continue working',
'overview.continueHint': 'Pick up the next useful item in this Deal.',
'overview.loadingSignals': 'Loading Deal signals...',
'overview.noResume': 'No clear resume point yet',
'overview.noResumeHint': 'Recent notes, files, captures, and journal entries will appear here.',
'overview.recentChanges': 'Recent changes',
'overview.recentChangesHint': 'Latest meaningful activity in this Deal.',
'overview.event.workspaceOpened': 'Opened Deal',
'overview.event.activity': 'Deal activity',
'overview.event.noteEdited': 'Edited note "{name}"',
'overview.event.noteOpened': 'Opened note "{name}"',
'overview.event.noteCreated': 'Created note "{name}"',
'overview.event.fileOpened': 'Opened file "{name}"',
'overview.event.fileChanged': 'Changed file "{name}"',
'overview.event.fileCreated': 'Created file "{name}"',
'overview.event.fileRemoved': 'Removed file "{name}"',
'overview.event.capturePage': 'Captured page "{name}"',
'overview.event.captureSelection': 'Captured selection "{name}"',
'overview.event.captureLink': 'Captured link "{name}"',
'overview.event.captureFile': 'Captured file "{name}"',
'overview.event.captureConverted': 'Converted capture "{name}"',
'overview.event.capture': 'Captured {kind} "{title}"',
'overview.event.journalAdded': 'Added journal entry "{name}"',
'overview.event.workSessionDetected': 'Work session detected',
'overview.event.todo': 'Todo "{title}"',
'overview.event.reviewCapture': 'Review capture "{title}"',
'overview.event.continueJournal': 'Continue journal entry "{title}"',
'overview.event.possibleJournalEntry': 'Possible journal entry',
'overview.captureKind.page': 'page',
'overview.captureKind.selection': 'selection',
'overview.captureKind.link': 'link',
'overview.captureKind.file': 'file',
'overview.captureKind.item': 'item',
'overview.todo.reminderDue': 'Reminder due',
'overview.todo.overdue': 'Overdue',
'overview.todo.dueSoon': 'Due soon',
'overview.todo.due': 'Due {date}',
'overview.candidateMeta': 'Deal: {deal} · {minutes} min · {activities} activities',
'overview.minutes': '{count} min',
'overview.browserCapture': 'Browser capture',
'overview.untitledTodo': 'Untitled todo',
'overview.untitledCapture': 'Untitled capture',
'overview.untitledJournal': 'Journal entry',
'overview.unknownDeal': 'Unknown Deal',
'overview.item': 'item',
'overview.overviewNote': 'Deal overview note',
'overview.recentFilter': 'Recent changes filter',
'overview.loadingRecent': 'Loading recent changes...',
'overview.noChanges': 'No meaningful changes for this filter yet.',
'overview.attentionHint': 'Pending captures, urgent todos, and possible journal entries.',
'overview.loadingPending': 'Loading pending items...',
'overview.noPending': 'No pending captures, urgent todos, or possible journal entries.',
'overview.keyResources': 'Key resources',
'overview.time.none': 'No timestamp',
'overview.time.now': 'Just now',
'overview.time.minutes': '{count} min ago',
'overview.time.hours': '{count}h ago',
'overview.time.yesterday': 'Yesterday',
'overview.time.days': '{count} days ago',
};

View File

@ -1,308 +0,0 @@
export default {
'app.loading': 'Загрузка Верстака...',
'settings.title': 'Настройки',
'settings.language': 'Язык',
'settings.language.system': 'System / Системный',
'settings.language.en': 'English',
'settings.language.ru': 'Русский',
'settings.pluginManager': 'Менеджер плагинов',
'statusBar.label': 'Строка состояния',
'vault.label': 'Хранилище: {status}',
'vault.status.open': 'открыто',
'vault.status.closed': 'закрыто',
'vault.status.unknown': 'неизвестно',
'sidebar.tools': 'Инструменты',
'sidebar.error.contributions': 'Не удалось загрузить элементы плагинов',
'sidebar.error.load': 'Не удалось загрузить боковую панель',
'sidebar.error.ui': 'Ошибка интерфейса плагина',
'workspace.overview': 'Обзор',
'workspace.search': 'Поиск в Деле',
'workspace.tools': 'Инструменты Дела',
'workspace.tool': 'Инструмент Дела',
'workspace.emptyTools': 'Нет доступных инструментов',
'workspace.emptyToolsHint': 'Включите плагины с инструментами Дела или откройте Менеджер плагинов в настройках.',
'workspace.select': 'Выберите Дело',
'workspace.selectHint': 'Используйте кнопку + в разделе Дел, чтобы добавить первый проект.',
'common.details': 'Подробнее',
'common.plugin': 'Плагин',
'common.component': 'Компонент',
'pluginView.error': 'Ошибка представления плагина',
'pluginView.failed': 'Ошибка интерфейса плагина',
'pluginView.noVisual': 'Этот плагин пока не предоставляет визуальное представление.',
'pluginView.viewId': 'ID представления',
'pluginView.bundleUnavailable': 'Frontend bundle недоступен',
'pluginView.unavailable': 'Представление недоступно.',
'pluginView.select': 'Выберите представление плагина на боковой панели',
'pluginView.selectHint': 'Представления плагинов появятся здесь',
'common.retry': '⟳ Повторить',
'common.save': 'Сохранить',
'common.source': 'Источник',
'common.status': 'Состояние',
'common.capability': 'Возможность',
'common.provider': 'Поставщик',
'status.loaded': 'загружено',
'status.degraded': 'ограничено',
'status.failed': 'ошибка',
'status.disabled': 'выключено',
'status.missing': 'отсутствует',
'pluginManager.reloading': '⟳ Обновление...',
'pluginManager.reload': '⟳ Обновить',
'pluginManager.scanning': 'Сканирование каталогов плагинов...',
'pluginManager.summary.plugins': 'Обнаружено плагинов: {count}',
'pluginManager.summary.capabilities': 'Зарегистрировано возможностей: {count}',
'pluginManager.summary.permissions': 'Известно разрешений: {count}',
'pluginManager.scanSummary': 'Сводка проверки плагинов',
'pluginManager.health': 'Состояние плагинов',
'pluginManager.permissionRisk': 'Риск разрешений',
'pluginManager.elevatedPermissions': 'Плагинов с повышенными разрешениями: {count}',
'pluginManager.filters': 'Фильтры',
'pluginManager.filterResults': 'Показано плагинов: {visible} из {total}',
'pluginManager.filterReset': 'Сбросить фильтры',
'pluginManager.filterState': 'Состояние',
'pluginManager.filterAll': 'Все',
'pluginManager.filterEnabled': 'Включённые',
'pluginManager.filterDisabled': 'Выключенные',
'pluginManager.filterFailed': 'С проблемами',
'pluginManager.filterDegraded': 'Ограниченные',
'pluginManager.filterPermissions': 'Разрешения',
'pluginManager.filterCapabilities': 'Возможности',
'pluginManager.filterSettings': 'Настройки',
'pluginManager.filterWithSettings': 'С настройками',
'pluginManager.filterWithoutSettings': 'Без настроек',
'pluginManager.filterSource': 'Источник',
'pluginManager.filterOfficial': 'Официальные',
'pluginManager.filterThirdParty': 'Сторонние или локальные',
'pluginManager.filterEmpty': 'Нет плагинов, соответствующих выбранным фильтрам.',
'pluginManager.none': 'Плагины не найдены',
'pluginManager.scannedDirs': 'Проверенные каталоги плагинов:',
'pluginManager.userPlugins': 'пользовательские плагины',
'pluginManager.bundledPlugins': 'встроенные плагины (каталог приложения)',
'pluginManager.installHint': 'Поместите папку плагина с plugin.json в один из этих каталогов и нажмите «Обновить».',
'pluginManager.missingTitle': 'Отсутствующие установленные плагины',
'pluginManager.missingHint': 'Эти плагины требуются хранилищу, но их пакеты не установлены на этом устройстве.',
'pluginManager.missingPackage': 'Плагин указан среди желаемых плагинов хранилища, но его пакет не установлен.',
'pluginManager.capabilityRegistry': 'Реестр возможностей ({count})',
'pluginManager.settingsError': 'Ошибка настроек',
'pluginManager.pluginSettings': 'Настройки плагина',
'pluginManager.settingsBundleUnavailable': 'Frontend bundle панели настроек недоступен',
'common.none': 'нет',
'common.unknown': 'неизвестно',
'pluginCard.count.views': 'представлений: {count}',
'pluginCard.count.commands': 'команд: {count}',
'pluginCard.count.searchProviders': 'поставщиков поиска: {count}',
'pluginCard.count.sidebar': 'элементов боковой панели: {count}',
'pluginCard.count.statusbar': 'элементов строки состояния: {count}',
'pluginCard.count.openProviders': 'обработчиков открытия: {count}',
'pluginCard.count.workspace': 'инструментов Дела: {count}',
'pluginCard.degraded': 'Плагин работает, но некоторые необязательные возможности недоступны.',
'pluginCard.name': 'Название',
'pluginCard.contributions': 'Элементы интерфейса',
'pluginCard.technicalDetails': 'Технические сведения',
'pluginCard.apiVersion': 'Версия API',
'pluginCard.root': 'Корневой каталог',
'pluginCard.provides': 'Предоставляет',
'pluginCard.requires': 'Требует',
'pluginCard.missingRequired': 'Отсутствуют обязательные возможности',
'pluginCard.optionalRequires': 'Необязательные зависимости',
'pluginCard.optionalUnavailable': 'Необязательные возможности недоступны — плагин работает в ограниченном режиме',
'pluginCard.permissions': 'Разрешения',
'pluginCard.enabling': '⟳ Включение...',
'pluginCard.enable': '▶ Включить',
'pluginCard.disabling': '⟳ Выключение...',
'pluginCard.disable': '⏸ Выключить',
'pluginCard.openVault': 'Откройте хранилище для управления состоянием плагина',
'pluginCard.missingUiPermission': 'Плагин добавляет элементы интерфейса, но не имеет разрешения ui.register',
'common.loading': 'Загрузка...',
'common.browse': 'Обзор...',
'vaultSelection.chooseNew': 'Выберите или введите папку для нового хранилища.',
'vaultSelection.createError': 'Не удалось создать хранилище. Повторите попытку.',
'vaultSelection.openError': 'Не удалось открыть хранилище. Повторите попытку.',
'vaultSelection.chooseExisting': 'Выберите или введите существующее хранилище.',
'vaultSelection.subtitle': 'Выберите хранилище, чтобы начать работу',
'vaultSelection.createTitle': 'Создать новое хранилище',
'vaultSelection.createHint': 'Создайте локальную папку хранилища для Дел и проектов.',
'vaultSelection.pathPlaceholder': 'Выберите или введите путь...',
'vaultSelection.creating': 'Создание...',
'vaultSelection.create': 'Создать хранилище',
'vaultSelection.openTitle': 'Открыть существующее хранилище',
'vaultSelection.openHint': 'Используйте хранилище, которое уже находится на этом компьютере.',
'vaultSelection.opening': 'Открытие...',
'vaultSelection.open': 'Открыть',
'vaultSelection.recent': 'Недавние хранилища',
'common.cancel': 'Отмена',
'common.close': 'Закрыть',
'workspaceTree.title': 'Дела',
'workspaceTree.new': 'Новое Дело',
'workspaceTree.nameRequired': 'Введите название',
'workspaceTree.chooseTemplate': 'Выберите шаблон Дела',
'workspaceTree.saveRename': 'Сохранить новое название',
'workspaceTree.rename': 'Переименовать Дело',
'workspaceTree.trash': 'Переместить Дело в корзину',
'workspaceTree.create': 'Создать Дело',
'workspaceTree.namePlaceholder': 'Название Дела',
'workspaceTree.template': 'Шаблон',
'workspaceTree.creating': 'Создание...',
'workspaceTree.templateAvailable': 'Доступен',
'workspaceTree.templateLimited': 'Доступен с ограничениями',
'workspaceTree.templateMissingPlugin': 'Плагин не установлен',
'workspaceTree.templatePluginDisabled': 'Плагин выключен',
'workspaceTree.templateCapabilityUnavailable': 'Не хватает обязательной возможности',
'workspaceTree.templateIncompatible': 'Плагин несовместим с этой версией Верстака',
'workspaceTree.templateLoadFailed': 'Плагин не удалось загрузить',
'workspaceTree.templateNotReady': 'Плагин ещё не готов к работе',
'workspaceTree.templateToolTabs': 'Создаёт вкладки: {tabs}',
'workspaceTree.templateToolNoTabs': 'Не предоставляет вкладку Дела',
'workspaceTree.templateWillBeIncomplete': 'Дело будет создано без недоступных компонентов.',
'workspaceTree.templateIncompleteCreated': 'Дело «{name}» создано с недоступными компонентами.',
'workspaceTree.templatesError': 'Не удалось загрузить шаблоны Дел. Повторите попытку.',
'workspaceTree.loadError': 'Не удалось загрузить Дела. Повторите попытку.',
'workspaceTree.selectError': 'Не удалось выбрать это Дело. Повторите попытку.',
'workspaceTree.createError': 'Не удалось создать Дело. Повторите попытку.',
'workspaceTree.renameError': 'Не удалось переименовать Дело. Повторите попытку.',
'workspaceTree.trashError': 'Не удалось переместить Дело в корзину. Повторите попытку.',
'pluginManager.loadError': 'Не удалось загрузить плагины. Повторите попытку.',
'pluginManager.reloadError': 'Не удалось перезагрузить плагины. Повторите попытку.',
'pluginManager.enableError': 'Не удалось включить плагин. Повторите попытку.',
'pluginManager.disableError': 'Не удалось выключить плагин. Повторите попытку.',
'pluginManager.settingsLoadError': 'Не удалось загрузить настройки плагина. Повторите попытку.',
'pluginManager.settingsUnavailable': 'Настройки плагина недоступны.',
'command.openOverview': 'Открыть обзор',
'command.openFiles': 'Открыть файлы',
'command.openActivity': 'Открыть активность',
'command.openBrowserInbox': 'Открыть Браузер',
'command.createMarkdown': 'Создать файл Markdown',
'command.createText': 'Создать текстовый файл',
'command.syncNow': 'Синхронизировать сейчас',
'command.openSyncSettings': 'Открыть настройки синхронизации',
'command.openPluginManager': 'Открыть менеджер плагинов',
'command.handled': 'Выполнено: {title}',
'command.result': '{title}: {status}',
'command.failed': 'Не удалось выполнить команду «{title}». Повторите попытку.',
'command.statusHandled': 'выполнено',
'command.palette': 'Палитра команд',
'command.run': 'Выполнить команду',
'command.none': 'Команды не найдены',
'search.indexing': 'Индексирование...',
'search.placeholder': 'Поиск',
'search.global': 'Глобальный поиск',
'search.noResults': 'Ничего не найдено',
'search.type.workspace': 'Дело',
'search.type.tool': 'Инструмент',
'search.type.folder': 'Папка',
'search.type.file': 'Файл',
'search.type.journal': 'Журнал',
'search.type.browserInbox': 'Браузер',
'search.type.activity': 'Активность',
'workbench.noProvider': 'Нет обработчика',
'workbench.noViewer': 'Нет доступного средства просмотра или редактора',
'workbench.noResource': 'Ресурс не открыт',
'bundle.noFrontend': 'У плагина нет frontend bundle',
'bundle.notFound': 'Плагин не найден',
'bundle.loadFailed': 'Не удалось загрузить интерфейс плагина. Повторите попытку.',
'bundle.emptyContent': 'пустое содержимое',
'bundle.executionError': 'Не удалось запустить интерфейс плагина. Повторите попытку.',
'bundle.registrationMissing': 'Bundle загружен, но вызов VerstakPluginRegister не обнаружен',
'bundle.componentMissing': 'Запрошенный интерфейс плагина недоступен.',
'bundle.mountError': 'Не удалось открыть интерфейс плагина. Повторите попытку.',
'bundle.mountUnavailable': 'Контейнер для подключения недоступен',
'bundle.unexpectedError': 'Не удалось открыть интерфейс плагина. Повторите попытку.',
'bundle.unknownError': 'Неизвестная ошибка',
'bundle.frontendEntry': 'Точка входа frontend',
'bundle.availableComponents': 'Доступные компоненты',
'bundle.loading': 'Загрузка bundle плагина...',
'overview.filter.all': 'Все',
'overview.filter.notes': 'Заметки',
'overview.filter.files': 'Файлы',
'overview.filter.captures': 'Сохранённое',
'overview.filter.journal': 'Журнал',
'overview.notes': 'Заметки',
'overview.files': 'Файлы',
'overview.captures': 'Сохранённое',
'overview.activity': 'Активность',
'overview.journal': 'Журнал',
'overview.attention': 'Требует внимания',
'overview.openNotes': 'Открыть заметки',
'overview.openFiles': 'Открыть файлы',
'overview.reviewInbox': 'Разобрать входящие',
'overview.viewActivity': 'Открыть активность',
'overview.openJournal': 'Открыть журнал',
'overview.openTodos': 'Открыть задачи',
'overview.reviewCandidate': 'Просмотреть запись',
'overview.reviewPending': 'Разобрать ожидающее',
'overview.count.totalRecent.one': 'Всего: {total} · недавних изменений: {recent}',
'overview.count.totalRecent.many': 'Всего: {total} · недавних изменений: {recent}',
'overview.count.recentChanges.one': 'Недавних изменений: {count}',
'overview.count.recentChanges.many': 'Недавних изменений: {count}',
'overview.count.captures.one': 'На разбор: {count}',
'overview.count.captures.many': 'На разбор: {count}',
'overview.count.events.one': 'Событий: {count}',
'overview.count.events.many': 'Событий: {count}',
'overview.count.journal.one': 'Записей журнала: {count}',
'overview.count.journal.many': 'Записей журнала: {count}',
'overview.count.pending.one': 'Ожидают: {count}',
'overview.count.pending.many': 'Ожидают: {count}',
'overview.loadingContext': 'Загрузка данных Дела...',
'overview.lastActive': 'Последняя активность: {time}',
'overview.noRecentActivity': 'В Деле пока нет недавней активности',
'overview.refresh': 'Обновить',
'overview.summary': 'Сводка Дела',
'overview.continue': 'Продолжить работу',
'overview.continueHint': 'Вернитесь к следующему полезному делу в этом Деле.',
'overview.loadingSignals': 'Загрузка данных Дела...',
'overview.noResume': 'Пока неясно, с чего продолжить',
'overview.noResumeHint': 'Здесь появятся недавние заметки, файлы, сохранённое и записи журнала.',
'overview.recentChanges': 'Недавние изменения',
'overview.recentChangesHint': 'Последняя важная активность в этом Деле.',
'overview.event.workspaceOpened': 'Открыто Дело',
'overview.event.activity': 'Активность Дела',
'overview.event.noteEdited': 'Изменена заметка «{name}»',
'overview.event.noteOpened': 'Открыта заметка «{name}»',
'overview.event.noteCreated': 'Создана заметка «{name}»',
'overview.event.fileOpened': 'Открыт файл «{name}»',
'overview.event.fileChanged': 'Изменён файл «{name}»',
'overview.event.fileCreated': 'Создан файл «{name}»',
'overview.event.fileRemoved': 'Удалён файл «{name}»',
'overview.event.capturePage': 'Сохранена страница «{name}»',
'overview.event.captureSelection': 'Сохранено выделение «{name}»',
'overview.event.captureLink': 'Сохранена ссылка «{name}»',
'overview.event.captureFile': 'Сохранён файл «{name}»',
'overview.event.captureConverted': 'Сохранённое преобразовано: «{name}»',
'overview.event.capture': 'Сохранено: {kind} «{title}»',
'overview.event.journalAdded': 'Добавлена запись в журнал «{name}»',
'overview.event.workSessionDetected': 'Обнаружена рабочая сессия',
'overview.event.todo': 'Задача «{title}»',
'overview.event.reviewCapture': 'Разобрать сохранённое «{title}»',
'overview.event.continueJournal': 'Продолжить запись журнала «{title}»',
'overview.event.possibleJournalEntry': 'Возможная запись в журнал',
'overview.captureKind.page': 'страница',
'overview.captureKind.selection': 'выделение',
'overview.captureKind.link': 'ссылка',
'overview.captureKind.file': 'файл',
'overview.captureKind.item': 'элемент',
'overview.todo.reminderDue': 'Пора напомнить',
'overview.todo.overdue': 'Просрочено',
'overview.todo.dueSoon': 'Скоро срок',
'overview.todo.due': 'Срок: {date}',
'overview.candidateMeta': 'Дело: {deal} · {minutes} мин. · активностей: {activities}',
'overview.minutes': '{count} мин.',
'overview.browserCapture': 'Сохранённое из браузера',
'overview.untitledTodo': 'Задача без названия',
'overview.untitledCapture': 'Сохранённое без названия',
'overview.untitledJournal': 'Запись журнала',
'overview.unknownDeal': 'Неизвестное Дело',
'overview.item': 'элемент',
'overview.overviewNote': 'Обзорная заметка Дела',
'overview.recentFilter': 'Фильтр недавних изменений',
'overview.loadingRecent': 'Загрузка недавних изменений...',
'overview.noChanges': 'Для этого фильтра пока нет важных изменений.',
'overview.attentionHint': 'Неразобранное сохранённое, срочные задачи и возможные записи журнала.',
'overview.loadingPending': 'Загрузка ожидающих элементов...',
'overview.noPending': 'Нет неразобранного сохранённого, срочных задач или возможных записей журнала.',
'overview.keyResources': 'Ключевые ресурсы',
'overview.time.none': 'Время не указано',
'overview.time.now': 'Только что',
'overview.time.minutes': '{count} мин. назад',
'overview.time.hours': '{count} ч. назад',
'overview.time.yesterday': 'Вчера',
'overview.time.days': '{count} дн. назад',
};

View File

@ -1,200 +0,0 @@
import englishShellCatalog from './catalogs/en.js';
import russianShellCatalog from './catalogs/ru.js';
const LANGUAGE_PREFERENCES = new Set(['system', 'en', 'ru']);
const CONTRIBUTION_TEXT_FIELDS = {
views: 'title',
commands: 'title',
settingsPanels: 'title',
sidebarItems: 'title',
fileActions: 'label',
noteActions: 'label',
contextMenuEntries: 'label',
searchProviders: 'label',
statusBarItems: 'label',
openProviders: 'title',
workspaceItems: 'title',
};
function normalizeSystemLanguages(languages) {
const values = Array.isArray(languages) ? languages : [languages];
return values.map((value) => String(value || '').trim().toLowerCase()).filter(Boolean);
}
export function resolveLocale(preference, systemLanguages = []) {
if (!LANGUAGE_PREFERENCES.has(preference)) {
throw new Error(`unsupported language preference: ${preference}`);
}
if (preference === 'en' || preference === 'ru') return preference;
return normalizeSystemLanguages(systemLanguages).some((locale) => locale === 'ru' || locale.startsWith('ru-'))
? 'ru'
: 'en';
}
function interpolate(message, params) {
if (!params) return message;
return message.replace(/\{([A-Za-z0-9_.-]+)\}/g, (placeholder, name) => (
Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : placeholder
));
}
function browserLanguages() {
if (typeof navigator === 'undefined') return ['en'];
if (Array.isArray(navigator.languages) && navigator.languages.length > 0) return navigator.languages;
return [navigator.language || 'en'];
}
export function createI18n(options = {}) {
const shellCatalogs = options.shellCatalogs || { en: {}, ru: {} };
let systemLanguages = options.systemLanguages || browserLanguages;
let catalogLoader = options.loadPluginCatalog || (async () => ({}));
let preference = 'system';
let locale = 'en';
const listeners = new Set();
const pluginConfigs = new Map();
const pluginCatalogs = new Map();
function configure(next = {}) {
if (next.systemLanguages) systemLanguages = next.systemLanguages;
if (next.loadPluginCatalog) catalogLoader = next.loadPluginCatalog;
}
function notify() {
listeners.forEach((listener) => listener(locale));
}
async function initialize(initialPreference = 'system') {
preference = LANGUAGE_PREFERENCES.has(initialPreference) ? initialPreference : 'system';
locale = resolveLocale(preference, systemLanguages());
}
async function loadCatalog(pluginId, catalogLocale) {
if (!catalogLocale) return;
let catalogs = pluginCatalogs.get(pluginId);
if (!catalogs) {
catalogs = new Map();
pluginCatalogs.set(pluginId, catalogs);
}
if (catalogs.has(catalogLocale)) return;
const messages = await catalogLoader(pluginId, catalogLocale);
catalogs.set(catalogLocale, { ...(messages || {}) });
}
async function loadPlugin(pluginId, localization) {
if (!pluginId || !localization || !localization.defaultLocale || !localization.locales) return;
pluginConfigs.set(pluginId, localization);
const requested = localization.locales[locale] ? locale : '';
const defaultLocale = localization.defaultLocale;
await Promise.all(Array.from(new Set([requested, defaultLocale].filter(Boolean))).map((item) => loadCatalog(pluginId, item)));
}
async function setLanguagePreference(nextPreference) {
const nextLocale = resolveLocale(nextPreference, systemLanguages());
preference = nextPreference;
locale = nextLocale;
await Promise.all(Array.from(pluginConfigs.entries()).map(([pluginId, config]) => (
loadPlugin(pluginId, config).catch((error) => {
console.warn(`[i18n] failed to load ${pluginId} catalog for ${nextLocale}:`, error);
})
)));
notify();
}
function t(key, params, fallback) {
const message = shellCatalogs[locale]?.[key]
?? shellCatalogs.en?.[key]
?? fallback
?? key;
return interpolate(message, params);
}
function translatePlugin(pluginId, key, params, fallback) {
const config = pluginConfigs.get(pluginId);
const catalogs = pluginCatalogs.get(pluginId);
const message = catalogs?.get(locale)?.[key]
?? catalogs?.get(config?.defaultLocale)?.[key]
?? fallback
?? key;
return interpolate(message, params);
}
function localizeContributions(pluginId, contributions) {
if (!contributions) return contributions;
const localized = { ...contributions };
Object.entries(CONTRIBUTION_TEXT_FIELDS).forEach(([point, field]) => {
if (!Array.isArray(contributions[point])) return;
localized[point] = contributions[point].map((item) => ({
...item,
[field]: translatePlugin(
pluginId,
`contributions.${point}.${item.id}.${field}`,
undefined,
item[field],
),
}));
});
return localized;
}
function localizePlugin(plugin) {
if (!plugin) return plugin;
const hasStateWrapper = !!plugin.manifest;
const manifest = hasStateWrapper ? plugin.manifest : plugin;
const pluginId = manifest.id;
const localizedManifest = {
...manifest,
name: translatePlugin(pluginId, 'manifest.name', undefined, manifest.name),
description: translatePlugin(pluginId, 'manifest.description', undefined, manifest.description),
contributes: localizeContributions(pluginId, manifest.contributes),
};
return hasStateWrapper ? { ...plugin, manifest: localizedManifest } : localizedManifest;
}
function localizeContributionSummary(summary) {
if (!summary) return summary;
const localized = { ...summary };
Object.entries(CONTRIBUTION_TEXT_FIELDS).forEach(([point, field]) => {
if (!Array.isArray(summary[point])) return;
localized[point] = summary[point].map((item) => ({
...item,
[field]: translatePlugin(
item.pluginId,
`contributions.${point}.${item.id}.${field}`,
undefined,
item[field],
),
}));
});
return localized;
}
function subscribe(listener) {
listeners.add(listener);
listener(locale);
return () => listeners.delete(listener);
}
return {
configure,
initialize,
getLanguagePreference: () => preference,
getLocale: () => locale,
setLanguagePreference,
t,
subscribe,
loadPlugin,
translatePlugin,
localizeContributions,
localizePlugin,
localizeContributionSummary,
};
}
export const i18n = createI18n({
shellCatalogs: {
en: englishShellCatalog,
ru: russianShellCatalog,
},
});
export const t = (...args) => i18n.t(...args);

View File

@ -127,11 +127,9 @@ export var debug = {
log: log,
logf: logf,
isEnabled: function () { return ENABLED; },
enable: function (options) {
enable: function () {
ENABLED = true;
if (!options || options.persist !== false) {
try { localStorage.setItem('verstak-debug', 'true'); } catch (e) {}
}
try { localStorage.setItem('verstak-debug', 'true'); } catch (e) {}
},
disable: function () {
ENABLED = false;

View File

@ -2,7 +2,6 @@
import { onMount, onDestroy } from 'svelte';
import * as App from '../../../wailsjs/go/api/App';
import Icon from '../ui/Icon.svelte';
import { i18n } from '../i18n/index.js';
import { createPluginAPI } from './VerstakPluginAPI.js';
@ -19,13 +18,6 @@
let currentComponent = null;
let currentAPI = null;
let currentPropsKey = '';
let locale = i18n.getLocale();
let unsubscribeLocale = null;
$: tr = ((activeLocale) => (key, params, fallback) => {
void activeLocale;
return i18n.t(key, params, fallback);
})(locale);
$: activePluginId = pluginId || viewPluginId;
$: activeComponent = componentId;
@ -40,14 +32,9 @@
}
onDestroy(() => {
unsubscribeLocale?.();
cleanup();
});
onMount(() => {
unsubscribeLocale = i18n.subscribe((nextLocale) => locale = nextLocale);
});
function cleanup() {
if (currentAPI && typeof currentAPI.dispose === 'function') {
try {
@ -83,11 +70,6 @@
return { value: result, error: '' };
}
function reportError(key, fallback, details) {
console.warn('[PluginBundleHost] ' + key + ':', details);
return tr(key, undefined, fallback);
}
async function loadAndMount(pId, compId, nextPropsKey) {
// If same plugin+component and already mounted, skip
if (currentPluginId === pId && currentComponent === compId && currentPropsKey === nextPropsKey && loadState === 'loaded') {
@ -111,17 +93,11 @@
if (!info || info.status === 'no-frontend' || info.status === 'not-found') {
loadState = 'error';
errorText = info.status === 'no-frontend'
? tr('bundle.noFrontend')
: tr('bundle.notFound');
? 'Plugin has no frontend bundle'
: 'Plugin not found';
return;
}
try {
await i18n.loadPlugin(pId, info.localization);
} catch (catalogError) {
console.warn(`[PluginBundleHost] localization unavailable for ${pId}:`, catalogError);
}
// Check if bundle already loaded for this plugin
const reg = window.__VERSTAK_PLUGIN_REGISTRY__ || {};
if (!reg[pId]) {
@ -130,7 +106,7 @@
const content = assetResult.value;
if (assetResult.error || !content) {
loadState = 'error';
errorText = reportError('bundle.loadFailed', 'Could not load the plugin interface. Please try again.', assetResult.error || tr('bundle.emptyContent'));
errorText = 'Failed to load bundle: ' + (assetResult.error || 'empty content');
return;
}
@ -141,14 +117,15 @@
fn();
} catch (e) {
loadState = 'error';
errorText = reportError('bundle.executionError', 'Could not start the plugin interface. Please try again.', e);
errorText = 'Bundle execution error: ' + e.message;
console.error('[PluginBundleHost] bundle exec error:', e);
return;
}
// Verify registration happened
if (!window.__VERSTAK_PLUGIN_REGISTRY__[pId]) {
loadState = 'error';
errorText = tr('bundle.registrationMissing');
errorText = 'Bundle loaded but no VerstakPluginRegister call detected';
return;
}
}
@ -158,7 +135,8 @@
const comp = components[compId];
if (!comp || !comp.mount) {
loadState = 'error';
errorText = tr('bundle.componentMissing', undefined, 'The requested plugin interface is unavailable.');
errorText = 'Component "' + compId + '" not found in bundle. Available: '
+ (Object.keys(components).join(', ') || 'none');
return;
}
@ -178,15 +156,17 @@
errorText = '';
} catch (e) {
loadState = 'error';
errorText = reportError('bundle.mountError', 'Could not open the plugin interface. Please try again.', e);
errorText = 'Component mount error: ' + e.message;
console.error('[PluginBundleHost] mount error:', e);
}
} else {
loadState = 'error';
errorText = tr('bundle.mountUnavailable');
errorText = 'Mount container not available';
}
} catch (e) {
loadState = 'error';
errorText = reportError('bundle.unexpectedError', 'Could not open the plugin interface. Please try again.', e);
errorText = 'Unexpected error: ' + (e.message || e);
console.error('[PluginBundleHost] error:', e);
}
}
@ -200,32 +180,31 @@
<div class="plugin-bundle-host">
{#if loadState === 'idle'}
<div class="host-state idle">
<p>{tr('pluginView.select')}</p>
<p>Select a plugin view from the sidebar</p>
</div>
{:else if loadState === 'error'}
<div class="host-state error">
<Icon name="warning" size={24} class="error-icon" />
<p class="error-title">{tr('pluginView.error')}</p>
<p class="error-message">{errorText || tr('bundle.unknownError')}</p>
<details class="error-details">
<summary>{tr('common.details')}</summary>
<p><strong>{tr('common.plugin')}:</strong> {currentPluginId || tr('common.unknown')}</p>
<p><strong>{tr('common.component')}:</strong> {currentComponent || tr('common.unknown')}</p>
<p class="error-title">Plugin View Error</p>
<div class="error-details">
<p><strong>Plugin:</strong> {currentPluginId || 'unknown'}</p>
<p><strong>Component:</strong> {currentComponent || 'unknown'}</p>
<p class="error-message">{errorText || 'Unknown error'}</p>
{#if pluginInfo}
<p class="error-meta">{tr('bundle.frontendEntry')}: {pluginInfo.entry || tr('common.none')}</p>
<p class="error-meta">Frontend entry: {pluginInfo.entry || 'none'}</p>
{/if}
{#if getComponentList().length > 0}
<p class="error-meta">{tr('bundle.availableComponents')}: {getComponentList().join(', ')}</p>
<p class="error-meta">Available components: {getComponentList().join(', ')}</p>
{/if}
</details>
</div>
</div>
{:else}
{#if loadState === 'loading'}
<div class="host-state loading">
<div class="spinner"></div>
<p>{tr('bundle.loading')}</p>
<p>Loading plugin bundle...</p>
</div>
{/if}
<div
@ -303,21 +282,11 @@
max-width: 400px;
text-align: left;
background: #16213e;
padding: 0.5rem 0.75rem;
padding: 1rem;
border-radius: 6px;
border: 1px solid #0f3460;
}
.error-details[open] {
padding: 0.75rem 1rem;
}
.error-details summary {
cursor: pointer;
color: #e0e0f0;
font-weight: 600;
}
.error-details p {
margin: 0.3rem 0;
}
@ -328,8 +297,12 @@
.error-message {
color: #e94560;
max-width: 400px;
margin: 0 0 0.75rem;
font-family: monospace;
font-size: 0.8rem;
margin-top: 0.5rem !important;
padding: 0.5rem;
background: rgba(233, 69, 96, 0.1);
border-radius: 4px;
}
.error-meta {

View File

@ -1,5 +1,4 @@
import * as App from '../../../wailsjs/go/api/App';
import { i18n } from '../i18n/index.js';
window.__VERSTAK_PLUGIN_REGISTRY__ = window.__VERSTAK_PLUGIN_REGISTRY__ || {};
window.__VERSTAK_EVENT_HANDLERS__ = window.__VERSTAK_EVENT_HANDLERS__ || {};
@ -72,51 +71,10 @@ function dispatchLocalEvent(pluginId, eventName, payload) {
});
}
function dispatchBackendEvent(event) {
if (!event || !event.name) return;
const handlers = getEventHandlers(event.name).slice();
handlers.forEach(function(handler) {
try {
handler(event);
} catch (e) {
console.error('[VerstakPluginAPI] backend event handler error:', e);
}
});
}
window.__VERSTAK_DISPATCH_BACKEND_EVENT__ = dispatchBackendEvent;
if (!window.__VERSTAK_BACKEND_EVENT_BRIDGE__ && window.runtime && typeof window.runtime.EventsOnMultiple === 'function') {
window.__VERSTAK_BACKEND_EVENT_BRIDGE__ = window.runtime.EventsOnMultiple('verstak:plugin-event', dispatchBackendEvent, -1);
}
function commandKey(pluginId, commandId) {
return pluginId + ':' + commandId;
}
export async function executePluginCommand(pluginId, cmdId, args) {
if (!pluginId) {
throw new Error('executePluginCommand requires pluginId');
}
if (!cmdId) {
throw new Error('executePluginCommand requires command id');
}
const declared = await callBackend(pluginId, 'commands.execute(' + cmdId + ')', function() {
return App.ExecutePluginCommand(pluginId, cmdId, args || {});
});
const handler = window.__VERSTAK_COMMAND_HANDLERS__[commandKey(pluginId, cmdId)];
if (!handler) {
throw new Error('[plugin:' + pluginId + '] commands.execute(' + cmdId + ') failed: declared-but-unhandled');
}
const result = await handler(args || {}, declared);
return {
status: 'handled',
pluginId: pluginId,
commandId: cmdId,
result: result
};
}
export function createPluginAPI(pluginId) {
if (!pluginId) {
throw new Error('createPluginAPI requires pluginId');
@ -145,33 +103,6 @@ export function createPluginAPI(pluginId) {
return {
pluginId: pluginId,
i18n: {
getLocale: function() {
assertActive('i18n.getLocale');
return i18n.getLocale();
},
t: function(key, params, fallback) {
assertActive('i18n.t(' + key + ')');
return i18n.translatePlugin(pluginId, key, params, fallback);
},
onDidChangeLocale: function(listener) {
assertActive('i18n.onDidChangeLocale');
if (typeof listener !== 'function') {
throw new Error('i18n.onDidChangeLocale requires a listener function');
}
return trackCleanup(i18n.subscribe(listener));
}
},
ui: {
openSettings: function(panelId) {
assertActive('ui.openSettings');
window.dispatchEvent(new CustomEvent('verstak:open-settings', {
detail: { pluginId: pluginId, panelId: panelId || '' }
}));
}
},
capabilities: {
has: async function(capId) {
const info = await callBackend(pluginId, 'capabilities.has(' + capId + ')', function() {
@ -196,9 +127,7 @@ export function createPluginAPI(pluginId) {
await callBackendErrorString(pluginId, 'events.publish(' + type + ')', function() {
return App.PublishPluginEvent(pluginId, type, payload || {});
});
if (!window.__VERSTAK_BACKEND_EVENT_BRIDGE__) {
dispatchLocalEvent(pluginId, type, payload || {});
}
dispatchLocalEvent(pluginId, type, payload || {});
},
subscribe: function(type, handler) {
assertActive('events.subscribe(' + type + ')');
@ -220,24 +149,6 @@ export function createPluginAPI(pluginId) {
}
},
notifications: {
replace: function(items) {
assertActive('notifications.replace');
if (!Array.isArray(items)) {
throw new Error('notifications.replace requires an array');
}
return callBackendErrorString(pluginId, 'notifications.replace', function() {
return App.ReplacePluginNotifications(pluginId, items);
});
},
clear: function() {
assertActive('notifications.clear');
return callBackendErrorString(pluginId, 'notifications.clear', function() {
return App.ClearPluginNotifications(pluginId);
});
}
},
settings: {
read: async function(key) {
assertActive('settings.read');
@ -254,10 +165,12 @@ export function createPluginAPI(pluginId) {
if (!key) {
throw new Error('settings.write requires a key');
}
const settings = await this.read();
settings[key] = value;
await callBackendErrorString(pluginId, 'settings.write(' + key + ')', function() {
return App.WritePluginSetting(pluginId, key, value);
return App.WritePluginSettings(pluginId, settings);
});
return this.read();
return settings;
},
writeAll: function(settings) {
assertActive('settings.writeAll');
@ -267,107 +180,6 @@ export function createPluginAPI(pluginId) {
}
},
storage: {
data: {
read: function(name) {
assertActive('storage.data.read(' + name + ')');
if (!name) {
throw new Error('storage.data.read requires a name');
}
return callBackend(pluginId, 'storage.data.read(' + name + ')', function() {
return App.ReadPluginDataJSON(pluginId, name);
}).then(function(data) {
return data || {};
});
},
readNDJSON: function(name) {
assertActive('storage.data.readNDJSON(' + name + ')');
if (!name) {
throw new Error('storage.data.readNDJSON requires a name');
}
return callBackend(pluginId, 'storage.data.readNDJSON(' + name + ')', function() {
return App.ReadPluginDataNDJSON(pluginId, name);
}).then(function(records) {
return Array.isArray(records) ? records : [];
});
},
writeNDJSON: function(name, records) {
assertActive('storage.data.writeNDJSON(' + name + ')');
if (!name) {
throw new Error('storage.data.writeNDJSON requires a name');
}
return callBackendErrorString(pluginId, 'storage.data.writeNDJSON(' + name + ')', function() {
return App.WritePluginDataNDJSON(pluginId, name, Array.isArray(records) ? records : []);
});
},
write: function(name, data) {
assertActive('storage.data.write(' + name + ')');
if (!name) {
throw new Error('storage.data.write requires a name');
}
return callBackendErrorString(pluginId, 'storage.data.write(' + name + ')', function() {
return App.WritePluginDataJSON(pluginId, name, data || {});
});
}
}
},
secrets: {
status: function() {
assertActive('secrets.status');
return callBackend(pluginId, 'secrets.status', function() {
return App.PluginSecretsStatus(pluginId);
});
},
unlock: function(masterPassword) {
assertActive('secrets.unlock');
return callBackendErrorString(pluginId, 'secrets.unlock', function() {
return App.PluginSecretsUnlock(pluginId, String(masterPassword == null ? '' : masterPassword));
});
},
list: function() {
assertActive('secrets.list');
return callBackend(pluginId, 'secrets.list', function() {
return App.PluginSecretsList(pluginId);
}).then(function(records) {
return Array.isArray(records) ? records : [];
});
},
read: function(secretId) {
assertActive('secrets.read(' + secretId + ')');
if (!secretId) {
throw new Error('secrets.read requires a secret id');
}
return callBackend(pluginId, 'secrets.read(' + secretId + ')', function() {
return App.PluginSecretsRead(pluginId, secretId);
});
},
write: function(record) {
assertActive('secrets.write');
return callBackend(pluginId, 'secrets.write', function() {
return App.PluginSecretsWrite(pluginId, record || {});
});
},
delete: function(secretId) {
assertActive('secrets.delete(' + secretId + ')');
if (!secretId) {
throw new Error('secrets.delete requires a secret id');
}
return callBackendErrorString(pluginId, 'secrets.delete(' + secretId + ')', function() {
return App.PluginSecretsDelete(pluginId, secretId);
});
},
copyLink: function(secretId) {
assertActive('secrets.copyLink(' + secretId + ')');
if (!secretId) {
throw new Error('secrets.copyLink requires a secret id');
}
return callBackend(pluginId, 'secrets.copyLink(' + secretId + ')', function() {
return App.PluginSecretsCopyLink(pluginId, secretId);
});
}
},
files: {
list: function(relativeDir) {
assertActive('files.list');
@ -387,24 +199,12 @@ export function createPluginAPI(pluginId) {
return App.ReadVaultTextFile(pluginId, relativePath);
});
},
readBytes: function(relativePath) {
assertActive('files.readBytes(' + relativePath + ')');
return callBackend(pluginId, 'files.readBytes(' + relativePath + ')', function() {
return App.ReadVaultFileBytes(pluginId, relativePath);
});
},
writeText: function(relativePath, content, options) {
assertActive('files.writeText(' + relativePath + ')');
return callBackendErrorString(pluginId, 'files.writeText(' + relativePath + ')', function() {
return App.WriteVaultTextFile(pluginId, relativePath, String(content == null ? '' : content), options || {});
});
},
writeBytes: function(relativePath, dataBase64, options) {
assertActive('files.writeBytes(' + relativePath + ')');
return callBackendErrorString(pluginId, 'files.writeBytes(' + relativePath + ')', function() {
return App.WriteVaultFileBytes(pluginId, relativePath, String(dataBase64 == null ? '' : dataBase64), options || {});
});
},
createFolder: function(relativePath) {
assertActive('files.createFolder(' + relativePath + ')');
return callBackendErrorString(pluginId, 'files.createFolder(' + relativePath + ')', function() {
@ -422,102 +222,23 @@ export function createPluginAPI(pluginId) {
return callBackend(pluginId, 'files.trash(' + relativePath + ')', function() {
return App.TrashVaultPath(pluginId, relativePath);
});
},
listTrash: function() {
assertActive('files.listTrash');
return callBackend(pluginId, 'files.listTrash', function() {
return App.ListVaultTrash(pluginId);
});
},
restoreTrash: function(trashId, options) {
assertActive('files.restoreTrash(' + trashId + ')');
return callBackend(pluginId, 'files.restoreTrash(' + trashId + ')', function() {
return App.RestoreVaultTrash(pluginId, trashId, options || {});
});
},
deleteTrash: function(trashId) {
assertActive('files.deleteTrash(' + trashId + ')');
return callBackendErrorString(pluginId, 'files.deleteTrash(' + trashId + ')', function() {
return App.DeleteVaultTrash(pluginId, trashId);
});
},
openExternal: function(relativePath) {
assertActive('files.openExternal(' + relativePath + ')');
return callBackendErrorString(pluginId, 'files.openExternal(' + relativePath + ')', function() {
return App.OpenVaultPathExternal(pluginId, relativePath);
});
},
openURL: function(url) {
assertActive('files.openURL');
return callBackendErrorString(pluginId, 'files.openURL', function() {
return App.OpenExternalURL(pluginId, String(url == null ? '' : url));
});
},
showInFolder: function(relativePath) {
assertActive('files.showInFolder(' + relativePath + ')');
return callBackendErrorString(pluginId, 'files.showInFolder(' + relativePath + ')', function() {
return App.ShowVaultPathInFolder(pluginId, relativePath);
});
}
},
sync: {
status: function() {
assertActive('sync.status');
return callBackend(pluginId, 'sync.status', function() {
return App.PluginSyncStatus(pluginId);
});
},
configure: function(serverURL, username, password) {
assertActive('sync.configure');
return callBackendErrorString(pluginId, 'sync.configure', function() {
return App.PluginSyncConfigure(pluginId, serverURL || '', username || '', password || '');
});
},
disconnect: function() {
assertActive('sync.disconnect');
return callBackendErrorString(pluginId, 'sync.disconnect', function() {
return App.PluginSyncDisconnect(pluginId);
});
},
testConnection: function(serverURL, username, password) {
assertActive('sync.testConnection');
return callBackendErrorString(pluginId, 'sync.testConnection', function() {
return App.PluginSyncTestConnection(pluginId, serverURL || '', username || '', password || '');
});
},
setInterval: function(minutes) {
assertActive('sync.setInterval');
return callBackendErrorString(pluginId, 'sync.setInterval', function() {
return App.PluginSyncSetInterval(pluginId, Number(minutes) || 0);
});
},
resetKey: function() {
assertActive('sync.resetKey');
return callBackendErrorString(pluginId, 'sync.resetKey', function() {
return App.PluginSyncResetKey(pluginId);
});
},
now: function() {
assertActive('sync.now');
return callBackend(pluginId, 'sync.now', function() {
return App.PluginSyncNow(pluginId);
});
}
},
browserReceiver: {
pairing: function() {
assertActive('browserReceiver.pairing');
return callBackend(pluginId, 'browserReceiver.pairing', function() {
return App.PluginBrowserReceiverPairing(pluginId);
});
},
rotateToken: function() {
assertActive('browserReceiver.rotateToken');
return callBackend(pluginId, 'browserReceiver.rotateToken', function() {
return App.PluginRotateBrowserReceiverToken(pluginId);
});
backend: {
call: async function(method, ...args) {
assertActive('backend.call(' + method + ')');
try {
const App = window['go']?.['api']?.['App'];
if (!App || typeof App[method] !== 'function') {
throw new Error('Backend method not found: ' + method);
}
const result = await App[method](...args);
return result;
} catch (e) {
const message = e && e.message ? e.message : String(e);
throw new Error('[plugin:' + pluginId + '] backend.call(' + method + ') failed: ' + message);
}
}
},
@ -540,19 +261,6 @@ export function createPluginAPI(pluginId) {
}
},
contributions: {
list: async function(point) {
assertActive('contributions.list');
const summary = await callBackend(pluginId, 'contributions.list', function() {
return App.GetContributions();
});
if (!point) {
return summary || {};
}
return Array.isArray((summary || {})[point]) ? summary[point] : [];
}
},
commands: {
register: function(cmdId, handler) {
assertActive('commands.register(' + cmdId + ')');
@ -576,11 +284,20 @@ export function createPluginAPI(pluginId) {
},
execute: async function(cmdId, args) {
assertActive('commands.execute(' + cmdId + ')');
return executePluginCommand(pluginId, cmdId, args || {});
},
executeFor: async function(targetPluginId, cmdId, args) {
assertActive('commands.executeFor(' + targetPluginId + ':' + cmdId + ')');
return executePluginCommand(targetPluginId, cmdId, args || {});
const declared = await callBackend(pluginId, 'commands.execute(' + cmdId + ')', function() {
return App.ExecutePluginCommand(pluginId, cmdId, args || {});
});
const handler = window.__VERSTAK_COMMAND_HANDLERS__[commandKey(pluginId, cmdId)];
if (!handler) {
throw new Error('[plugin:' + pluginId + '] commands.execute(' + cmdId + ') failed: declared-but-unhandled');
}
const result = await handler(args || {}, declared);
return {
status: 'handled',
pluginId: pluginId,
commandId: cmdId,
result: result
};
}
},

View File

@ -1,7 +1,5 @@
<script>
import Icon from '../ui/Icon.svelte';
import { onDestroy } from 'svelte';
import { i18n } from '../i18n/index.js';
export let p = {};
export let capabilities = [];
export let permissions = [];
@ -10,13 +8,6 @@
export let settingsPanels = [];
export let onEnable = () => {};
export let onDisable = () => {};
let locale = i18n.getLocale();
const unsubscribeLocale = i18n.subscribe((nextLocale) => { locale = nextLocale; });
$: tr = ((activeLocale) => (key, params, fallback) => {
void activeLocale;
return i18n.t(key, params, fallback);
})(locale);
onDestroy(unsubscribeLocale);
$: m = p.manifest || {};
$: pluginId = m.id || 'unknown';
@ -39,23 +30,19 @@
$: contribCounts = {
views: (contributions.views || []).filter(v => v.pluginId === pluginId).length,
commands: (contributions.commands || []).filter(c => c.pluginId === pluginId).length,
searchProviders: (contributions.searchProviders || []).filter(s => s.pluginId === pluginId).length,
sidebar: (contributions.sidebarItems || []).filter(s => s.pluginId === pluginId).length,
statusbar: (contributions.statusBarItems || []).filter(s => s.pluginId === pluginId).length,
openProviders: (contributions.openProviders || []).filter(o => o.pluginId === pluginId).length,
workspaceItems: (contributions.workspaceItems || []).filter(w => w.pluginId === pluginId).length,
};
$: contribSummary = (() => {
const parts = [];
if (contribCounts.views > 0) parts.push(tr('pluginCard.count.views', { count: contribCounts.views }));
if (contribCounts.commands > 0) parts.push(tr('pluginCard.count.commands', { count: contribCounts.commands }));
if (contribCounts.searchProviders > 0) parts.push(tr('pluginCard.count.searchProviders', { count: contribCounts.searchProviders }));
if (contribCounts.sidebar > 0) parts.push(tr('pluginCard.count.sidebar', { count: contribCounts.sidebar }));
if (contribCounts.statusbar > 0) parts.push(tr('pluginCard.count.statusbar', { count: contribCounts.statusbar }));
if (contribCounts.openProviders > 0) parts.push(tr('pluginCard.count.openProviders', { count: contribCounts.openProviders }));
if (contribCounts.workspaceItems > 0) parts.push(tr('pluginCard.count.workspace', { count: contribCounts.workspaceItems }));
return parts.length > 0 ? parts.join(', ') : tr('common.none');
if (contribCounts.views > 0) parts.push(contribCounts.views + ' view' + (contribCounts.views !== 1 ? 's' : ''));
if (contribCounts.commands > 0) parts.push(contribCounts.commands + ' command' + (contribCounts.commands !== 1 ? 's' : ''));
if (contribCounts.sidebar > 0) parts.push(contribCounts.sidebar + ' sidebar' + (contribCounts.sidebar !== 1 ? 's' : ''));
if (contribCounts.statusbar > 0) parts.push(contribCounts.statusbar + ' statusbar' + (contribCounts.statusbar !== 1 ? 's' : ''));
if (contribCounts.openProviders > 0) parts.push(contribCounts.openProviders + ' openProvider' + (contribCounts.openProviders !== 1 ? 's' : ''));
return parts.length > 0 ? parts.join(', ') : 'none';
})();
$: dangerousPermissions = (m.permissions || []).filter(name => {
@ -90,11 +77,11 @@
<strong>{pluginId}</strong>
<span class="version">v{m.version || '?'}</span>
</div>
<span class="status-badge" style="color: {statusColor}">{tr(`status.${p.status}`, undefined, p.status)}</span>
<span class="status-badge" style="color: {statusColor}">{p.status}</span>
</div>
{#if p.status === 'degraded'}
<p class="degraded-text">{tr('pluginCard.degraded')}</p>
<p class="degraded-text">Plugin is usable, but some optional capabilities are unavailable.</p>
{/if}
{#if m.description}
@ -103,44 +90,40 @@
<div class="card-meta">
<div class="meta-row">
<span class="label">{tr('pluginCard.name')}:</span>
<span class="label">Name:</span>
<span>{m.name || '-'}</span>
</div>
<div class="meta-row">
<span class="label">{tr('common.source')}:</span>
<span>{m.source || tr('common.unknown')}</span>
<span class="label">API Version:</span>
<span>{m.apiVersion || '-'}</span>
</div>
<div class="meta-row">
<span class="label">{tr('pluginCard.contributions')}:</span>
<span class="label">Source:</span>
<span>{m.source || 'unknown'}</span>
</div>
<div class="meta-row">
<span class="label">Root:</span>
<span class="path">{p.rootPath || '-'}</span>
</div>
<div class="meta-row">
<span class="label">Contributions:</span>
<span>{contribSummary}</span>
</div>
</div>
<details class="plugin-details">
<summary>{tr('pluginCard.technicalDetails')}</summary>
<div class="card-meta technical-meta">
<div class="meta-row">
<span class="label">{tr('pluginCard.apiVersion')}:</span>
<span>{m.apiVersion || '-'}</span>
</div>
<div class="meta-row">
<span class="label">{tr('pluginCard.root')}:</span>
<span class="path">{p.rootPath || '-'}</span>
</div>
<!-- Capabilities -->
<div class="section">
<span class="section-title">Provides</span>
<div class="tags">
{#each m.provides || [] as cap}
<span class="tag provides">{cap}</span>
{/each}
</div>
</div>
{#if m.requires && m.requires.length > 0}
<div class="section">
<span class="section-title">{tr('pluginCard.provides')}</span>
<div class="tags">
{#each m.provides || [] as cap}
<span class="tag provides">{cap}</span>
{/each}
</div>
</div>
{#if m.requires && m.requires.length > 0}
<div class="section">
<span class="section-title">{tr('pluginCard.requires')}</span>
<span class="section-title">Requires</span>
<div class="tags">
{#each m.requires as req}
{@const found = capabilities.some(c => c.name === req)}
@ -151,14 +134,14 @@
{/each}
</div>
{#if missingRequired.length > 0}
<p class="warning"><Icon name="warning" size={12} /> {tr('pluginCard.missingRequired')}: {missingRequired.join(', ')}</p>
<p class="warning"><Icon name="warning" size={12} /> Missing required capabilities: {missingRequired.join(', ')}</p>
{/if}
</div>
{/if}
{/if}
{#if m.optionalRequires && m.optionalRequires.length > 0}
{#if m.optionalRequires && m.optionalRequires.length > 0}
<div class="section">
<span class="section-title">{tr('pluginCard.optionalRequires')}</span>
<span class="section-title">Optional Requires</span>
<div class="tags">
{#each m.optionalRequires as opt}
{@const found = capabilities.some(c => c.name === opt)}
@ -169,14 +152,15 @@
{/each}
</div>
{#if missingOptional.length > 0}
<p class="info"><Icon name="warning" size={12} /> {tr('pluginCard.optionalUnavailable')}</p>
<p class="info"><Icon name="warning" size={12} /> Optional capabilities not available — plugin running in degraded mode</p>
{/if}
</div>
{/if}
{/if}
{#if m.permissions && m.permissions.length > 0}
<!-- Permissions -->
{#if m.permissions && m.permissions.length > 0}
<div class="section">
<span class="section-title">{tr('pluginCard.permissions')}</span>
<span class="section-title">Permissions</span>
<div class="tags">
{#each m.permissions as perm}
{@const isDangerous = dangerousPermissions.includes(perm)}
@ -187,8 +171,7 @@
{/each}
</div>
</div>
{/if}
</details>
{/if}
<!-- Error -->
{#if p.error}
@ -199,28 +182,28 @@
<div class="card-actions">
{#if hasSettingsPanel}
<button class="btn-settings" on:click={() => window.dispatchEvent(new CustomEvent('verstak:open-settings', { detail: { pluginId: m.id, panelId: settingsPanels[0]?.id } }))} type="button" disabled={isDisabled || p.status === 'failed'}>
<Icon name="gear" size={14} /> {tr('settings.title')}
<Icon name="gear" size={14} /> Settings
</button>
{/if}
{#if vaultOpen && canToggle}
{#if isDisabled}
<button class="btn-enable" on:click={() => onEnable(m.id)} type="button" disabled={isBusy}>
{#if busyAction === 'enabling'}{tr('pluginCard.enabling')}{:else}{tr('pluginCard.enable')}{/if}
{#if busyAction === 'enabling'}⟳ Enabling...{:else}▶ Enable{/if}
</button>
{:else}
<button class="btn-disable" on:click={() => onDisable(m.id)} type="button" disabled={isBusy}>
{#if busyAction === 'disabling'}{tr('pluginCard.disabling')}{:else}{tr('pluginCard.disable')}{/if}
{#if busyAction === 'disabling'}⟳ Disabling...{:else}⏸ Disable{/if}
</button>
{/if}
{/if}
{#if !vaultOpen && canToggle}
<span class="vault-hint">{tr('pluginCard.openVault')}</span>
<span class="vault-hint">Open a vault to manage plugin state</span>
{/if}
</div>
<!-- Permission warnings -->
{#if !hasUIPermission && m.contributes && ((m.contributes.views || []).length > 0 || (m.contributes.sidebarItems || []).length > 0 || (m.contributes.settingsPanels || []).length > 0)}
<p class="warning"><Icon name="warning" size={12} /> {tr('pluginCard.missingUiPermission')}</p>
<p class="warning"><Icon name="warning" size={12} /> Plugin has UI contributions but lacks ui.register permission</p>
{/if}
</div>
@ -302,24 +285,6 @@
font-size: 0.8rem;
}
.technical-meta {
margin-top: 0.55rem;
}
.plugin-details {
margin: 0.6rem 0;
padding: 0.45rem 0;
border-top: 1px solid rgba(15, 52, 96, 0.75);
border-bottom: 1px solid rgba(15, 52, 96, 0.75);
}
.plugin-details summary {
cursor: pointer;
color: #8b8ba8;
font-size: 0.78rem;
font-weight: 600;
}
.meta-row {
display: flex;
gap: 0.5rem;

View File

@ -2,10 +2,9 @@
import Icon from '../ui/Icon.svelte';
import PluginCard from './PluginCard.svelte';
import PluginBundleHost from '../plugin-host/PluginBundleHost.svelte';
import { onDestroy, onMount, tick } from 'svelte';
import { onMount, tick } from 'svelte';
import { GetPlugins, GetCapabilities, GetPermissions, GetContributions, ReloadPlugins, GetVaultStatus, GetVaultPluginState, EnablePlugin, DisablePlugin, ReadPluginSettings, WritePluginSettings, GetPluginFrontendInfo, WriteFrontendLog } from '../../../wailsjs/go/api/App';
import { debug } from '../log/debug.js';
import { i18n } from '../i18n/index.js';
let plugins = [];
let capabilities = [];
@ -21,31 +20,20 @@
let settingsError = null;
let settingsPluginInfo = null;
let lastOpenedKey = '';
let locale = i18n.getLocale();
let unsubscribeLocale = null;
$: tr = ((activeLocale) => (key, params, fallback) => {
void activeLocale;
return i18n.t(key, params, fallback);
})(locale);
// Per-action loading state — shows feedback on specific buttons without hiding the whole list
let actionFeedback = {}; // { [pluginId]: 'enabling' | 'disabling' | null }
let reloading = false;
let toastMessage = '';
let toastType = 'success'; // 'success' | 'error' | 'info'
let statusFilter = 'all';
let permissionFilters = [];
let capabilityFilters = [];
let settingsFilter = 'all';
let sourceFilter = 'all';
export let activeSettingsPluginId = '';
export let activeSettingsPanelId = '';
$: if (activeSettingsPluginId) {
const settingsPanelCount = (contributions.settingsPanels || []).length;
const key = `${activeSettingsPluginId}:${activeSettingsPanelId || '*'}`;
if (key !== lastOpenedKey || settingsPanelCount === 0) {
$: if (activeSettingsPluginId && activeSettingsPanelId) {
const key = `${activeSettingsPluginId}:${activeSettingsPanelId}`;
if (key !== lastOpenedKey) {
lastOpenedKey = key;
openSettingsFromProps(activeSettingsPluginId, activeSettingsPanelId);
}
}
@ -58,12 +46,6 @@
}, 4000);
}
function reportError(key, fallback, details) {
debug.log('[PluginManager] ' + key + ':', String(details));
WriteFrontendLog('PluginManager', key + ': ' + String(details)).catch(() => {});
return tr(key, undefined, fallback);
}
function notifyPluginsChanged() {
window.dispatchEvent(new CustomEvent('verstak:plugins-changed'));
}
@ -89,7 +71,6 @@
async function openSettingsFromProps(pluginId, panelId) {
const panel = (contributions.settingsPanels || []).find(sp => sp.pluginId === pluginId && (!panelId || sp.id === panelId));
if (panel) {
lastOpenedKey = `${pluginId}:${panelId || '*'}`;
settingsPanel = panel;
settingsPluginId = pluginId;
settingsError = null;
@ -100,14 +81,14 @@
ReadPluginSettings(pluginId).then(result => {
const unpacked = unpackBackendResult(result);
if (unpacked.error) {
settingsError = reportError('pluginManager.settingsLoadError', 'Could not load plugin settings. Please try again.', unpacked.error);
settingsError = unpacked.error;
settingsData = {};
return;
}
settingsData = unpacked.value || {};
}).catch(() => { settingsData = {}; });
} 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.`;
}
}
@ -127,10 +108,7 @@
try {
debug.log('[PluginManager] loadAll: calling GetPlugins...');
const p = await GetPlugins();
await Promise.all((p || []).map((plugin) => (
i18n.loadPlugin(plugin.manifest?.id, plugin.manifest?.localization).catch(() => {})
)));
plugins = (p || []).map((plugin) => i18n.localizePlugin(plugin));
plugins = p || [];
debug.log('[PluginManager] loadAll: GetPlugins returned', plugins.length, 'plugins');
for (var i = 0; i < plugins.length; i++) {
debug.log('[PluginManager] loadAll: plugin[' + i + ']:', plugins[i].manifest?.id, 'status:', plugins[i].status, 'enabled:', plugins[i].enabled);
@ -138,7 +116,7 @@
} catch (e) {
debug.log('[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;
return;
}
@ -154,7 +132,7 @@
vaultStatus = v || { status: 'unknown', path: '', vaultId: '' };
capabilities = caps || [];
permissions = perms || [];
contributions = i18n.localizeContributionSummary(contribs || {});
contributions = contribs || {};
debug.log('[PluginManager] loadAll: vault=' + vaultStatus.status + ' caps=' + capabilities.length + ' perms=' + permissions.length);
WriteFrontendLog('PluginManager', 'loadAll: vault=' + vaultStatus.status + ' caps=' + capabilities.length + ' perms=' + permissions.length);
} catch (e) {
@ -177,18 +155,7 @@
WriteFrontendLog('PluginManager', 'loadAll: END, loading=false');
}
onMount(() => {
unsubscribeLocale = i18n.subscribe((nextLocale) => {
const changed = locale !== nextLocale;
locale = nextLocale;
if (changed) loadAll();
});
loadAll();
});
onDestroy(() => {
if (unsubscribeLocale) unsubscribeLocale();
});
onMount(() => { loadAll(); });
async function reload() {
debug.log('[PluginManager] reload: START');
@ -202,7 +169,7 @@
resultMsg = `Reloaded ${count} plugin(s). ${summary}`;
} catch (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;
return;
}
@ -222,7 +189,7 @@
if (err) {
debug.log('[PluginManager] enablePlugin: ERROR:', err);
actionFeedback = { ...actionFeedback, [pluginId]: null };
error = reportError('pluginManager.enableError', 'Could not enable the plugin. Please try again.', err);
error = 'Enable: ' + err;
return;
}
debug.log('[PluginManager] enablePlugin: success, reloading...');
@ -243,7 +210,7 @@
if (err) {
debug.log('[PluginManager] disablePlugin: ERROR:', err);
actionFeedback = { ...actionFeedback, [pluginId]: null };
error = reportError('pluginManager.disableError', 'Could not disable the plugin. Please try again.', err);
error = 'Disable: ' + err;
return;
}
debug.log('[PluginManager] disablePlugin: success, reloading...');
@ -259,93 +226,6 @@
$: totalPlugins = plugins.length;
$: totalCaps = capabilities.length;
$: totalPerms = permissions.length;
$: statusSummary = computeStatusSummary(plugins);
$: elevatedPermissionPluginCount = computeElevatedPermissionPluginCount(plugins, permissions);
$: filterPermissions = collectManifestValues(plugins, 'permissions');
$: filterCapabilities = collectCapabilities(plugins);
$: hasReliableSourceMetadata = plugins.some((plugin) => sourceGroup(plugin) !== '');
$: visiblePlugins = filterPlugins(plugins, statusFilter, permissionFilters, capabilityFilters, settingsFilter, sourceFilter, contributions);
$: filtersActive = statusFilter !== 'all' || permissionFilters.length > 0 || capabilityFilters.length > 0 || settingsFilter !== 'all' || sourceFilter !== 'all';
function computeStatusSummary(pluginRows) {
return pluginRows.reduce((summary, plugin) => {
const status = plugin?.status || 'unknown';
if (status === 'loaded') summary.loaded += 1;
else if (status === 'degraded') summary.degraded += 1;
else if (status === 'failed' || status === 'incompatible' || status === 'missing-required-capability') summary.failed += 1;
else if (status === 'disabled' || plugin?.enabled === false) summary.disabled += 1;
else summary.other += 1;
return summary;
}, { loaded: 0, degraded: 0, failed: 0, disabled: 0, other: 0 });
}
function computeElevatedPermissionPluginCount(pluginRows, permissionRows) {
const dangerous = new Set((permissionRows || []).filter(permission => permission.dangerous).map(permission => permission.name));
return (pluginRows || []).filter(plugin => (plugin?.manifest?.permissions || []).some(permission => dangerous.has(permission))).length;
}
function collectManifestValues(pluginRows, key) {
return Array.from(new Set((pluginRows || []).flatMap((plugin) => plugin?.manifest?.[key] || [])))
.sort((left, right) => left.localeCompare(right));
}
function collectCapabilities(pluginRows) {
return Array.from(new Set((pluginRows || []).flatMap((plugin) => [
...(plugin?.manifest?.provides || []),
...(plugin?.manifest?.requires || []),
...(plugin?.manifest?.optionalRequires || []),
]))).sort((left, right) => left.localeCompare(right));
}
function pluginStatusGroup(plugin) {
const status = plugin?.status || '';
if (status === 'failed' || status === 'incompatible' || status === 'missing-required-capability') return 'failed';
if (status === 'degraded') return 'degraded';
if (status === 'disabled' || plugin?.enabled === false) return 'disabled';
return 'enabled';
}
function sourceGroup(plugin) {
const source = plugin?.manifest?.source;
if (source === 'official') return 'official';
if (source === 'local' || source === 'third-party') return 'third-party';
return '';
}
function hasSettings(plugin, activeContributions) {
const pluginId = plugin?.manifest?.id;
return Boolean(pluginId && (activeContributions.settingsPanels || []).some((panel) => panel.pluginId === pluginId));
}
function includesAny(values, expected) {
return expected.length === 0 || expected.some((value) => values.includes(value));
}
function filterPlugins(pluginRows, activeStatusFilter, activePermissionFilters, activeCapabilityFilters, activeSettingsFilter, activeSourceFilter, activeContributions) {
return pluginRows.filter((plugin) => matchesFilters(plugin, activeStatusFilter, activePermissionFilters, activeCapabilityFilters, activeSettingsFilter, activeSourceFilter, activeContributions));
}
function matchesFilters(plugin, activeStatusFilter, activePermissionFilters, activeCapabilityFilters, activeSettingsFilter, activeSourceFilter, activeContributions) {
if (activeStatusFilter !== 'all' && pluginStatusGroup(plugin) !== activeStatusFilter) return false;
if (!includesAny(plugin?.manifest?.permissions || [], activePermissionFilters)) return false;
const declaredCapabilities = [
...(plugin?.manifest?.provides || []),
...(plugin?.manifest?.requires || []),
...(plugin?.manifest?.optionalRequires || []),
];
if (!includesAny(declaredCapabilities, activeCapabilityFilters)) return false;
if (activeSettingsFilter === 'with' && !hasSettings(plugin, activeContributions)) return false;
if (activeSettingsFilter === 'without' && hasSettings(plugin, activeContributions)) return false;
return activeSourceFilter === 'all' || sourceGroup(plugin) === activeSourceFilter;
}
function resetFilters() {
statusFilter = 'all';
permissionFilters = [];
capabilityFilters = [];
settingsFilter = 'all';
sourceFilter = 'all';
}
function closeSettings() {
settingsPanel = null;
@ -372,116 +252,33 @@
<header>
<div class="header-left">
<h2>{tr('settings.pluginManager')}</h2>
<h2>Plugin Manager</h2>
{#if vaultStatus.status !== 'unknown'}
<span class="vault-badge" class:vault-open={vaultStatus.status === 'open'} class:vault-not-created={vaultStatus.status === 'not-created'} class:vault-closed={vaultStatus.status === 'closed'} class:vault-error={vaultStatus.status === 'error'}>
{tr('vault.label', { status: tr(`vault.status.${vaultStatus.status}`, undefined, vaultStatus.status) })}{#if vaultStatus.path} ({vaultStatus.path}){/if}
Vault: {vaultStatus.status}{#if vaultStatus.path} ({vaultStatus.path}){/if}
</span>
{/if}
</div>
<button class="reload-btn" on:click={reload} type="button" disabled={loading || reloading}>
{reloading ? tr('pluginManager.reloading') : tr('pluginManager.reload')}
{reloading ? '⟳ Reloading...' : '⟳ Reload'}
</button>
</header>
{#if loading}
<div class="loading">{tr('pluginManager.scanning')}</div>
<div class="loading">Scanning plugin directories...</div>
{:else if error}
<div class="error">
<Icon name="warning" size={24} class="error-icon" />
<div class="error-message">{error}</div>
<button class="retry-btn" on:click={loadAll} type="button">{tr('common.retry')}</button>
<button class="retry-btn" on:click={loadAll} type="button">⟳ Retry</button>
</div>
{:else}
<div class="summary">
<span class="badge">{tr('pluginManager.summary.plugins', { count: totalPlugins })}</span>
<span class="badge">{tr('pluginManager.summary.capabilities', { count: totalCaps })}</span>
<span class="badge">{tr('pluginManager.summary.permissions', { count: totalPerms })}</span>
<span class="badge">{totalPlugins} plugin(s) discovered</span>
<span class="badge">{totalCaps} capabilities registered</span>
<span class="badge">{totalPerms} permissions known</span>
</div>
<div class="scan-summary" aria-label={tr('pluginManager.scanSummary')}>
<section class="scan-card" data-plugin-manager-summary="health">
<div class="scan-card-title">{tr('pluginManager.health')}</div>
<div class="scan-metrics">
<span data-plugin-status-summary="loaded"><strong>{statusSummary.loaded}</strong> {tr('status.loaded')}</span>
<span data-plugin-status-summary="degraded"><strong>{statusSummary.degraded}</strong> {tr('status.degraded')}</span>
<span data-plugin-status-summary="failed"><strong>{statusSummary.failed}</strong> {tr('status.failed')}</span>
<span data-plugin-status-summary="disabled"><strong>{statusSummary.disabled}</strong> {tr('status.disabled')}</span>
</div>
</section>
<section class="scan-card" data-plugin-manager-summary="risk">
<div class="scan-card-title">{tr('pluginManager.permissionRisk')}</div>
<div class="scan-metrics">
<span data-plugin-risk-summary="elevated-permissions">{tr('pluginManager.elevatedPermissions', { count: elevatedPermissionPluginCount })}</span>
</div>
</section>
</div>
<section class="plugin-filters" aria-label={tr('pluginManager.filters')}>
<div class="filter-header">
<div>
<h3>{tr('pluginManager.filters')}</h3>
<p data-plugin-filter-results>{tr('pluginManager.filterResults', { visible: visiblePlugins.length, total: totalPlugins })}</p>
</div>
<button class="filter-reset" data-plugin-filter-reset type="button" on:click={resetFilters} disabled={!filtersActive}>{tr('pluginManager.filterReset')}</button>
</div>
<div class="filter-grid">
<label class="filter-select-label">
<span>{tr('pluginManager.filterState')}</span>
<select class="filter-select" data-plugin-filter="status" bind:value={statusFilter}>
<option value="all">{tr('pluginManager.filterAll')}</option>
<option value="enabled">{tr('pluginManager.filterEnabled')}</option>
<option value="disabled">{tr('pluginManager.filterDisabled')}</option>
<option value="failed">{tr('pluginManager.filterFailed')}</option>
<option value="degraded">{tr('pluginManager.filterDegraded')}</option>
</select>
</label>
<label class="filter-select-label">
<span>{tr('pluginManager.filterSettings')}</span>
<select class="filter-select" data-plugin-filter="settings" bind:value={settingsFilter}>
<option value="all">{tr('pluginManager.filterAll')}</option>
<option value="with">{tr('pluginManager.filterWithSettings')}</option>
<option value="without">{tr('pluginManager.filterWithoutSettings')}</option>
</select>
</label>
{#if hasReliableSourceMetadata}
<label class="filter-select-label">
<span>{tr('pluginManager.filterSource')}</span>
<select class="filter-select" data-plugin-filter="source" bind:value={sourceFilter}>
<option value="all">{tr('pluginManager.filterAll')}</option>
<option value="official">{tr('pluginManager.filterOfficial')}</option>
<option value="third-party">{tr('pluginManager.filterThirdParty')}</option>
</select>
</label>
{/if}
</div>
{#if filterPermissions.length > 0}
<fieldset class="filter-options">
<legend>{tr('pluginManager.filterPermissions')}</legend>
<div class="filter-option-list">
{#each filterPermissions as permission}
<label><input data-plugin-filter-permission={permission} type="checkbox" bind:group={permissionFilters} value={permission} /> <code>{permission}</code></label>
{/each}
</div>
</fieldset>
{/if}
{#if filterCapabilities.length > 0}
<fieldset class="filter-options">
<legend>{tr('pluginManager.filterCapabilities')}</legend>
<div class="filter-option-list">
{#each filterCapabilities as capability}
<label><input data-plugin-filter-capability={capability} type="checkbox" bind:group={capabilityFilters} value={capability} /> <code>{capability}</code></label>
{/each}
</div>
</fieldset>
{/if}
</section>
{#if plugins.length === 0 && missingInstalled.length === 0}
<div class="empty">
<div class="empty-icon">
@ -489,21 +286,17 @@
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
</div>
<p>{tr('pluginManager.none')}</p>
<p class="hint">{tr('pluginManager.scannedDirs')}</p>
<p>No plugins found</p>
<p class="hint">Plugin directories scanned:</p>
<ul class="hint-list">
<li><code>~/.config/verstak/plugins/</code>{tr('pluginManager.userPlugins')}</li>
<li><code>./plugins/</code>{tr('pluginManager.bundledPlugins')}</li>
<li><code>~/.config/verstak/plugins/</code> — user plugins</li>
<li><code>./plugins/</code> — bundled plugins (app directory)</li>
</ul>
<p class="hint">{tr('pluginManager.installHint')}</p>
</div>
{:else if visiblePlugins.length === 0}
<div class="empty filter-empty" data-plugin-filter-empty>
<p>{tr('pluginManager.filterEmpty')}</p>
<p class="hint">Place a plugin folder with <code>plugin.json</code> in one of these directories and click Reload.</p>
</div>
{:else}
<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} />
{/each}
</div>
@ -511,8 +304,8 @@
{#if missingInstalled.length > 0}
<div class="missing-section">
<h3>{tr('pluginManager.missingTitle')}</h3>
<p class="missing-hint">{tr('pluginManager.missingHint')}</p>
<h3>Missing Installed Plugins</h3>
<p class="missing-hint">These plugins are required by this vault but their packages are not installed locally.</p>
<div class="plugin-list">
{#each missingInstalled as mp}
<div class="plugin-card missing-card">
@ -522,12 +315,12 @@
<strong>{mp.id}</strong>
{#if mp.version}<span class="version">v{mp.version}</span>{/if}
</div>
<span class="status-badge" style="color: #e94560">{tr('status.missing')}</span>
<span class="status-badge" style="color: #e94560">missing</span>
</div>
<p class="missing-text">
{tr('pluginManager.missingPackage')}
This plugin is listed in the vault's desired plugins but the package is not installed.
{#if mp.source && mp.source !== 'unknown'}
<span class="source-hint">{tr('common.source')}: {mp.source}</span>
<span class="source-hint">Source: {mp.source}</span>
{/if}
</p>
</div>
@ -538,10 +331,10 @@
{#if capabilities.length > 0}
<details class="registry-section">
<summary>{tr('pluginManager.capabilityRegistry', { count: totalCaps })}</summary>
<summary>Capability Registry ({totalCaps})</summary>
<table>
<thead>
<tr><th>{tr('common.capability')}</th><th>{tr('common.provider')}</th><th>{tr('common.source')}</th><th>{tr('common.status')}</th></tr>
<tr><th>Capability</th><th>Provider</th><th>Source</th><th>Status</th></tr>
</thead>
<tbody>
{#each capabilities as cap}
@ -562,9 +355,9 @@
{#key `settings-${settingsPluginId}`}
{#if settingsError}
<div class="modal-overlay" on:click|self={closeSettings} on:keydown|self={(e) => e.key === 'Escape' && closeSettings()} role="presentation">
<div class="modal" role="dialog" aria-modal="true" aria-label={tr('pluginManager.settingsError')}>
<div class="modal" role="dialog" aria-modal="true" aria-label="Settings Error">
<div class="modal-header">
<h3>{tr('pluginManager.settingsError')}</h3>
<h3>Settings Error</h3>
<button class="modal-close" on:click={closeSettings} type="button"></button>
</div>
<div class="modal-body">
@ -574,21 +367,21 @@
</div>
{:else if settingsPanel}
<div class="modal-overlay" on:click|self={closeSettings} on:keydown|self={(e) => e.key === 'Escape' && closeSettings()} role="presentation">
<div class="modal" role="dialog" aria-modal="true" aria-label={tr('pluginManager.pluginSettings')}>
<div class="modal" role="dialog" aria-modal="true" aria-label="Plugin Settings">
<div class="modal-header">
<h3>{settingsPanel.title}</h3>
<button class="modal-close" on:click={closeSettings} type="button"></button>
</div>
<div class="modal-body">
<p class="settings-hint">{tr('common.plugin')}: <code>{settingsPluginId}</code></p>
<p class="settings-hint">Plugin: <code>{settingsPluginId}</code></p>
{#if settingsPluginInfo && settingsPluginInfo.entry}
<PluginBundleHost
pluginId={settingsPluginId}
componentId={settingsPanel.component || settingsPanel.id}
/>
{:else}
<p class="settings-hint">{tr('common.component')}: <code>{settingsPanel.component}</code></p>
<p class="placeholder">{tr('pluginManager.settingsBundleUnavailable')}</p>
<p class="settings-hint">Component: <code>{settingsPanel.component}</code></p>
<p class="placeholder">Settings panel frontend bundle not available</p>
{/if}
</div>
</div>
@ -677,146 +470,6 @@
background: #16213e; padding: 0.25rem 0.75rem; border-radius: 12px;
font-size: 0.8rem; color: #a0a0b8; border: 1px solid #0f3460;
}
.scan-summary {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr);
gap: 0.75rem;
margin-bottom: 1rem;
}
.scan-card {
min-width: 0;
padding: 0.75rem;
border: 1px solid #0f3460;
border-radius: 8px;
background: #121a2c;
}
.scan-card-title {
color: #e0e0f0;
font-size: 0.82rem;
font-weight: 700;
margin-bottom: 0.5rem;
}
.scan-metrics {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.45rem;
}
.scan-metrics span {
min-height: 1.55rem;
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.18rem 0.5rem;
border: 1px solid rgba(78, 204, 163, 0.16);
border-radius: 5px;
color: #a0a0b8;
background: #101626;
font-size: 0.76rem;
}
.scan-metrics strong {
color: #f4f7fb;
font-size: 0.88rem;
}
.plugin-filters {
margin-bottom: 1rem;
padding: 0.75rem;
border: 1px solid #0f3460;
border-radius: 8px;
background: #121a2c;
}
.filter-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.filter-header h3 {
margin: 0;
color: #e0e0f0;
font-size: 0.9rem;
}
.filter-header p {
margin: 0.2rem 0 0;
color: #a0a0b8;
font-size: 0.78rem;
}
.filter-reset {
flex: 0 0 auto;
padding: 0.35rem 0.65rem;
border: 1px solid #533483;
border-radius: 5px;
background: #16213e;
color: #e0e0e0;
cursor: pointer;
font-size: 0.78rem;
}
.filter-reset:hover:not(:disabled) { background: #0f3460; }
.filter-reset:disabled { cursor: not-allowed; opacity: 0.5; }
.filter-grid {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
}
.filter-select-label {
display: grid;
gap: 0.28rem;
min-width: 10rem;
color: #a0a0b8;
font-size: 0.76rem;
font-weight: 600;
}
.filter-select {
min-height: 2rem;
padding: 0.3rem 1.9rem 0.3rem 0.55rem;
border: 1px solid #0f3460;
border-radius: 5px;
background: #16213e;
background-image: linear-gradient(45deg, transparent 50%, #a0a0b8 50%), linear-gradient(135deg, #a0a0b8 50%, transparent 50%);
background-position: calc(100% - 14px) 50%, calc(100% - 9px) 50%;
background-size: 5px 5px, 5px 5px;
background-repeat: no-repeat;
color: #e0e0e0;
font: inherit;
appearance: none;
}
.filter-select option { background: #16213e; color: #e0e0e0; }
.filter-select:focus { outline: 2px solid #533483; outline-offset: 1px; }
.filter-options {
min-width: 0;
margin: 0.75rem 0 0;
padding: 0.55rem 0.65rem 0.65rem;
border: 1px solid rgba(15, 52, 96, 0.85);
border-radius: 6px;
}
.filter-options legend {
padding: 0 0.25rem;
color: #a0a0b8;
font-size: 0.76rem;
font-weight: 600;
}
.filter-option-list {
display: flex;
flex-wrap: wrap;
gap: 0.4rem 0.75rem;
}
.filter-option-list label {
display: inline-flex;
align-items: center;
gap: 0.28rem;
min-width: 0;
color: #c6c6d8;
font-size: 0.75rem;
}
.filter-option-list input { accent-color: #6c4fa3; }
.filter-option-list code {
max-width: min(31rem, 72vw);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.filter-empty { margin-bottom: 1.5rem; }
.empty {
padding: 2rem; text-align: center; color: #a0a0b8;
background: #16213e; border-radius: 8px; border: 1px dashed #0f3460;
@ -825,6 +478,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 li { margin: 0.25rem 0; }
.hint code { background: #0f3460; padding: 0.1rem 0.3rem; border-radius: 3px; }
.plugin-list { display: flex; flex-direction: column; gap: 0.75rem; margin-bottom: 1.5rem; min-width: 0; }
.missing-section { margin-bottom: 1.5rem; }
@ -859,7 +513,7 @@
}
.modal {
background: #16213e; border: 1px solid #0f3460; border-radius: 8px;
width: min(880px, calc(100vw - 4rem)); max-width: calc(100vw - 4rem); height: min(680px, calc(100vh - 4rem)); max-height: calc(100vh - 4rem); display: flex; flex-direction: column;
width: 480px; max-width: 90vw; max-height: 80vh; display: flex; flex-direction: column;
}
.modal-header {
display: flex; align-items: center; justify-content: space-between;
@ -868,8 +522,7 @@
.modal-header h3 { margin: 0; color: #e0e0f0; font-size: 1.1rem; }
.modal-close { background: none; border: none; color: #a0a0b8; font-size: 1.2rem; cursor: pointer; padding: 0.2rem 0.5rem; }
.modal-close:hover { color: #e94560; }
.modal-body { padding: 1rem; overflow: auto; min-height: 0; flex: 1; display: flex; flex-direction: column; }
.modal-body :global(.plugin-bundle-host) { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.modal-body { padding: 1rem; overflow-y: auto; }
.settings-hint { color: #666; font-size: 0.8rem; margin: 0.25rem 0; }
.settings-hint code { color: #4ecca3; }
@ -887,26 +540,8 @@
width: 100%;
}
.scan-summary {
grid-template-columns: 1fr;
}
.filter-header {
align-items: stretch;
flex-direction: column;
}
.filter-reset {
width: 100%;
}
.filter-select-label {
flex: 1 1 10rem;
}
.modal {
width: min(880px, calc(100vw - 2rem));
height: min(680px, calc(100vh - 2rem));
width: min(480px, calc(100vw - 2rem));
max-height: calc(100vh - 2rem);
}
}

View File

@ -1,415 +0,0 @@
<script>
import { onDestroy, onMount, tick } from 'svelte';
import * as App from '../../../wailsjs/go/api/App';
import { executePluginCommand } from '../plugin-host/VerstakPluginAPI.js';
import { i18n } from '../i18n/index.js';
import { debug } from '../log/debug.js';
let open = false;
let query = '';
let commands = [];
let selectedIndex = 0;
let inputEl = null;
let statusMessage = '';
let statusType = '';
let locale = i18n.getLocale();
let unsubscribeLocale = null;
$: tr = ((activeLocale) => (key, params, fallback) => {
void activeLocale;
return i18n.t(key, params, fallback);
})(locale);
const inactiveStatuses = new Set(['disabled', 'failed', 'incompatible', 'missing-required-capability']);
$: shellCommands = [
{ id: 'verstak.shell.open-overview', title: tr('command.openOverview'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 10, shellAction: 'overview' },
{ id: 'verstak.shell.open-files', title: tr('command.openFiles'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 20, shellAction: 'files' },
{ id: 'verstak.shell.open-activity', title: tr('command.openActivity'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 30, shellAction: 'activity' },
{ id: 'verstak.shell.open-browser-inbox', title: tr('command.openBrowserInbox'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 40, shellAction: 'browser-inbox' },
{ id: 'verstak.shell.create-markdown', title: tr('command.createMarkdown'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 50, shellAction: 'create-markdown' },
{ id: 'verstak.shell.create-text', title: tr('command.createText'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 60, shellAction: 'create-text' },
{ id: 'verstak.shell.sync-now', title: tr('command.syncNow'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 70, shellAction: 'sync-now' },
{ id: 'verstak.shell.open-sync-settings', title: tr('command.openSyncSettings'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 80, shellAction: 'sync-settings' },
{ id: 'verstak.shell.open-plugin-manager', title: tr('command.openPluginManager'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 90, shellAction: 'plugin-manager' },
];
$: normalizedQuery = query.trim().toLowerCase();
$: filteredCommands = commands.filter((command) => {
if (!normalizedQuery) return true;
return [
command.title,
command.id,
command.pluginId,
command.pluginName,
].filter(Boolean).join(' ').toLowerCase().includes(normalizedQuery);
});
$: if (selectedIndex >= filteredCommands.length) {
selectedIndex = Math.max(0, filteredCommands.length - 1);
}
async function loadCommands() {
const [plugins, contributions] = await Promise.all([
App.GetPlugins().catch(() => []),
App.GetContributions().catch(() => ({})),
]);
const pluginById = new Map((plugins || []).map((plugin) => [plugin.manifest?.id, plugin]));
await Promise.all((plugins || []).map((plugin) => i18n.loadPlugin(
plugin.manifest?.id,
plugin.manifest?.localization,
).catch(() => {})));
const localizedPlugins = (plugins || []).map((plugin) => i18n.localizePlugin(plugin));
const localizedPluginById = new Map(localizedPlugins.map((plugin) => [plugin.manifest?.id, plugin]));
const localizedContributions = i18n.localizeContributionSummary(contributions || {});
const pluginCommands = (localizedContributions.commands || [])
.filter((command) => {
const plugin = pluginById.get(command.pluginId);
if (!plugin) return false;
return !inactiveStatuses.has(plugin.status);
})
.map((command) => {
const plugin = localizedPluginById.get(command.pluginId);
return {
...command,
pluginName: plugin?.manifest?.name || command.pluginId,
priority: 1000,
};
});
commands = [...shellCommands, ...pluginCommands].sort((a, b) => {
const priority = (a.priority || 1000) - (b.priority || 1000);
if (priority) return priority;
const title = String(a.title || a.id).localeCompare(String(b.title || b.id));
if (title) return title;
return String(a.pluginId).localeCompare(String(b.pluginId));
});
}
async function openPalette() {
await loadCommands();
query = '';
selectedIndex = 0;
open = true;
await tick();
inputEl?.focus();
}
function closePalette() {
open = false;
query = '';
selectedIndex = 0;
}
function setStatus(type, message) {
statusType = type;
statusMessage = message;
window.clearTimeout(setStatus.timer);
setStatus.timer = window.setTimeout(() => {
statusType = '';
statusMessage = '';
}, 4000);
}
function clickWorkspaceTool(label) {
const tabs = Array.from(document.querySelectorAll('.workspace-tabs [role="tab"]'));
const tab = tabs.find((node) => String(node.textContent || '').trim().toLowerCase() === label);
if (tab) {
tab.click();
return true;
}
return false;
}
async function openWorkspaceTool(label) {
if (clickWorkspaceTool(label)) return;
const selectedWorkspace = document.querySelector('.wt-node.selected .wt-label');
const firstWorkspace = document.querySelector('.wt-label');
const workspaceButton = selectedWorkspace || firstWorkspace;
if (workspaceButton) {
workspaceButton.click();
await tick();
await new Promise((resolve) => requestAnimationFrame(resolve));
}
clickWorkspaceTool(label);
}
async function startFilesCreate(action) {
await openWorkspaceTool('files');
await tick();
await new Promise((resolve) => requestAnimationFrame(resolve));
const button = document.querySelector(`[data-files-action="${action}"]`);
if (!button) {
throw new Error(`Files action not available: ${action}`);
}
button.click();
}
async function runShellCommand(command) {
if (command.shellAction === 'plugin-manager') {
window.dispatchEvent(new CustomEvent('verstak:open-settings', { detail: {} }));
return;
}
if (command.shellAction === 'sync-settings') {
window.dispatchEvent(new CustomEvent('verstak:open-settings', {
detail: { pluginId: 'verstak.sync', panelId: 'verstak.sync.settings' }
}));
return;
}
if (command.shellAction === 'sync-now') {
const result = await App.PluginSyncNow('verstak.sync');
if (typeof result === 'string' && result) {
throw new Error(result);
}
if (Array.isArray(result) && result[1]) {
throw new Error(result[1]);
}
return;
}
if (command.shellAction === 'create-markdown') {
await startFilesCreate('new-markdown');
return;
}
if (command.shellAction === 'create-text') {
await startFilesCreate('new-text');
return;
}
const actionToTab = {
overview: 'overview',
files: 'files',
activity: 'activity',
'browser-inbox': 'browser inbox',
};
await openWorkspaceTool(actionToTab[command.shellAction] || command.shellAction);
}
async function runCommand(command) {
if (!command) return;
try {
if (command.shellAction) {
await runShellCommand(command);
closePalette();
setStatus('success', tr('command.handled', { title: command.title || command.id }));
return;
}
const result = await executePluginCommand(command.pluginId, command.id, {
source: 'command-palette',
});
closePalette();
setStatus('success', tr('command.result', { title: command.title || command.id, status: result.status || tr('command.statusHandled') }));
} catch (err) {
const details = err?.message || String(err);
debug.log('[CommandPalette] command failed:', details);
App.WriteFrontendLog('CommandPalette', `command failed: ${details}`).catch(() => {});
setStatus('error', tr('command.failed', {
title: command.title || command.id,
}, `Could not run ${command.title || command.id}. Please try again.`));
}
}
function moveSelection(delta) {
if (filteredCommands.length === 0) return;
selectedIndex = (selectedIndex + delta + filteredCommands.length) % filteredCommands.length;
}
function onWindowKeydown(event) {
const key = event.key || '';
const comboOpen = (event.ctrlKey || event.metaKey) && (key.toLowerCase() === 'k' || (event.shiftKey && key.toLowerCase() === 'p'));
if (!open && comboOpen) {
event.preventDefault();
openPalette();
return;
}
if (!open) return;
if (key === 'Escape') {
event.preventDefault();
closePalette();
} else if (key === 'ArrowDown') {
event.preventDefault();
moveSelection(1);
} else if (key === 'ArrowUp') {
event.preventDefault();
moveSelection(-1);
} else if (key === 'Enter') {
event.preventDefault();
runCommand(filteredCommands[selectedIndex]);
}
}
function onOverlayMouseDown(event) {
if (event.target === event.currentTarget) closePalette();
}
if (typeof window !== 'undefined') {
window.addEventListener('keydown', onWindowKeydown);
}
onMount(() => {
unsubscribeLocale = i18n.subscribe((nextLocale) => {
const changed = locale !== nextLocale;
locale = nextLocale;
if (changed && open) loadCommands();
});
});
onDestroy(() => {
unsubscribeLocale?.();
if (typeof window !== 'undefined') {
window.removeEventListener('keydown', onWindowKeydown);
window.clearTimeout(setStatus.timer);
}
});
</script>
{#if statusMessage}
<div
class="command-palette-toast"
data-command-palette-status={statusType}
>
{statusMessage}
</div>
{/if}
{#if open}
<div class="command-palette-overlay" role="presentation" on:mousedown={onOverlayMouseDown}>
<section class="command-palette" role="dialog" aria-modal="true" aria-label={tr('command.palette')}>
<input
bind:this={inputEl}
bind:value={query}
class="command-palette-input"
data-command-palette-input
placeholder={tr('command.run')}
aria-label={tr('command.run')}
/>
<div class="command-palette-list" role="listbox">
{#if filteredCommands.length === 0}
<div class="command-palette-empty">{tr('command.none')}</div>
{:else}
{#each filteredCommands as command, index}
<button
type="button"
class:selected={index === selectedIndex}
class="command-palette-item"
data-command-id={command.id}
on:mouseenter={() => selectedIndex = index}
on:click={() => runCommand(command)}
>
<span class="command-palette-title">{command.title || command.id}</span>
<span class="command-palette-meta">{command.pluginName} · {command.id}</span>
</button>
{/each}
{/if}
</div>
</section>
</div>
{/if}
<style>
.command-palette-overlay {
position: fixed;
inset: 0;
z-index: 10000;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 10vh 1rem 1rem;
background: rgba(0, 0, 0, 0.48);
}
.command-palette {
width: min(640px, 100%);
max-height: min(560px, 80vh);
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid #254466;
border-radius: 8px;
background: #101526;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.45);
}
.command-palette-input {
width: 100%;
min-height: 3rem;
padding: 0.75rem 1rem;
border: 0;
border-bottom: 1px solid #213650;
background: #0b1020;
color: #f4f7fb;
font: inherit;
font-size: 1rem;
outline: none;
}
.command-palette-list {
min-height: 0;
overflow: auto;
padding: 0.35rem;
}
.command-palette-item {
width: 100%;
min-height: 3rem;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.18rem;
padding: 0.55rem 0.75rem;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: #e0e0f0;
text-align: left;
}
.command-palette-item:hover,
.command-palette-item.selected {
border-color: #4ecca3;
background: #17243a;
}
.command-palette-title {
font-size: 0.9rem;
font-weight: 650;
}
.command-palette-meta {
max-width: 100%;
overflow: hidden;
color: #8da2bd;
font-size: 0.74rem;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.command-palette-empty {
padding: 1.25rem;
color: #8da2bd;
font-size: 0.9rem;
text-align: center;
}
.command-palette-toast {
position: fixed;
right: 1rem;
bottom: 1rem;
z-index: 10001;
max-width: min(420px, calc(100vw - 2rem));
padding: 0.7rem 0.9rem;
border: 1px solid #254466;
border-radius: 8px;
background: #101526;
color: #e0e0f0;
font-size: 0.85rem;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.35);
}
.command-palette-toast[data-command-palette-status="success"] {
border-color: #4ecca3;
}
.command-palette-toast[data-command-palette-status="error"] {
border-color: #e74c3c;
color: #ffd6d1;
}
</style>

View File

@ -1,443 +0,0 @@
<script>
import { onMount } from 'svelte';
import * as App from '../../../wailsjs/go/api/App';
import Icon from '../ui/Icon.svelte';
import { i18n } from '../i18n/index.js';
const TEXT_EXTENSIONS = new Set(['txt', 'md', 'markdown', 'log', 'json', 'csv', 'yaml', 'yml', 'toml']);
const FILE_INDEX_LIMIT = 220;
const RESULT_LIMIT = 8;
const RU = 'ёйцукенгшщзхъфывапролджэячсмитьбю';
const EN = '`qwertyuiop[]asdfghjkl;\\zxcvbnm,.';
let query = '';
let index = [];
let results = [];
let focused = false;
let loading = true;
let searchTimer = null;
let buildSeq = 0;
let locale = i18n.getLocale();
$: tr = ((activeLocale) => (key, params, fallback) => {
void activeLocale;
return i18n.t(key, params, fallback);
})(locale);
$: scheduleSearch(query);
onMount(() => {
const unsubscribeLocale = i18n.subscribe((nextLocale) => {
const changed = locale !== nextLocale;
locale = nextLocale;
if (changed) buildIndex();
});
buildIndex();
return () => {
unsubscribeLocale();
clearTimeout(searchTimer);
};
});
function normalize(value) {
return String(value == null ? '' : value).trim().toLowerCase();
}
function swapLayout(value, from, to) {
return String(value || '').split('').map(ch => {
const lower = ch.toLowerCase();
const idx = from.indexOf(lower);
if (idx === -1) return ch;
const mapped = to[idx] || ch;
return ch === lower ? mapped : mapped.toUpperCase();
}).join('');
}
function queryVariants(value) {
const base = normalize(value);
return [...new Set([
base,
normalize(swapLayout(base, RU, EN)),
normalize(swapLayout(base, EN, RU)),
].filter(Boolean))];
}
function matchScore(item, variants) {
const haystack = normalize(`${item.title} ${item.subtitle || ''} ${item.keywords || ''}`);
for (const variant of variants) {
if (!variant) continue;
if (normalize(item.title) === variant) return 100;
if (normalize(item.title).startsWith(variant)) return 80;
if (haystack.includes(variant)) return 50;
}
return 0;
}
function scheduleSearch(value) {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => runSearch(value), 80);
}
function runSearch(value) {
const variants = queryVariants(value);
if (!variants.length) {
results = [];
return;
}
results = index
.map(item => ({ item, score: matchScore(item, variants) }))
.filter(row => row.score > 0)
.sort((a, b) => b.score - a.score || a.item.rank - b.item.rank || a.item.title.localeCompare(b.item.title))
.slice(0, RESULT_LIMIT)
.map(row => row.item);
}
function workspaceTitle(node) {
return node?.title || node?.name || node?.id || node?.rootPath || '';
}
function workspaceName(node) {
return node?.name || node?.id || node?.rootPath || '';
}
async function resultOrEmpty(promise, fallback) {
try {
const response = await promise;
if (Array.isArray(response) && response.length === 2) return response[1] ? fallback : response[0];
return response || fallback;
} catch (_) {
return fallback;
}
}
async function listFilesRecursive(dir = '', depth = 0, acc = []) {
if (acc.length >= FILE_INDEX_LIMIT || depth > 5) return acc;
const entries = await resultOrEmpty(App.ListVaultFiles('verstak.search', dir), []);
for (const entry of entries || []) {
if (acc.length >= FILE_INDEX_LIMIT) break;
const path = entry.relativePath || entry.path || entry.name || '';
if (!path) continue;
acc.push(entry);
if (entry.type === 'folder') await listFilesRecursive(path, depth + 1, acc);
}
return acc;
}
async function readFileSnippet(path) {
const ext = String(path).split('.').pop().toLowerCase();
if (!TEXT_EXTENSIONS.has(ext)) return '';
const text = await resultOrEmpty(App.ReadVaultTextFile('verstak.search', path), '');
return String(text || '').slice(0, 900);
}
function pluginToolKind(pluginId, label) {
if (pluginId === 'verstak.browser-inbox') return 'browser-inbox';
if (pluginId === 'verstak.activity') return 'activity';
if (pluginId === 'verstak.journal') return 'journal';
return String(label || pluginId || '').toLowerCase();
}
async function indexPluginSettings(pluginId, label, rank, view, nodes) {
const settings = await resultOrEmpty(App.ReadPluginSettings(pluginId), {});
const items = [];
Object.keys(settings || {}).forEach(key => {
const value = settings[key];
const rows = Array.isArray(value) ? value : [];
rows.forEach(row => {
if (!row || typeof row !== 'object') return;
const title = row.title || row.summary || row.url || row.captureId || row.activityId || row.entryId || label;
const workspaceName = row.workspaceRootPath || row.workspaceName || '';
items.push({
type: label,
title,
subtitle: row.url || row.summary || row.workspaceRootPath || key,
keywords: JSON.stringify(row),
rank,
action: workspaceName ? 'workspace-tool' : (view ? 'view' : ''),
viewId: view?.id || '',
pluginId,
workspaceName,
toolKind: pluginToolKind(pluginId, label),
nodes,
});
});
});
return items;
}
async function buildIndex() {
const seq = ++buildSeq;
loading = true;
const next = [];
const tree = await resultOrEmpty(App.GetWorkspaceTree(), { nodes: [] });
const nodes = Array.isArray(tree.nodes) ? tree.nodes : [];
nodes.forEach(node => {
next.push({
type: 'Workspace',
typeLabel: tr('search.type.workspace'),
title: workspaceTitle(node),
subtitle: tr('search.type.workspace'),
keywords: `${node.id || ''} ${node.rootPath || ''}`,
rank: 10,
action: 'workspace',
workspaceName: workspaceName(node),
nodes,
});
});
const [rawPlugins, rawContributions] = await Promise.all([
resultOrEmpty(App.GetPlugins(), []),
resultOrEmpty(App.GetContributions(), {}),
]);
await Promise.all((rawPlugins || []).map((plugin) => i18n.loadPlugin(
plugin.manifest?.id,
plugin.manifest?.localization,
).catch(() => {})));
const contributions = i18n.localizeContributionSummary(rawContributions || {});
const viewByPluginId = new Map();
(contributions.views || []).forEach(view => {
if (view.pluginId && !viewByPluginId.has(view.pluginId)) viewByPluginId.set(view.pluginId, view);
});
(contributions.sidebarItems || []).forEach(item => {
next.push({
type: 'Tool',
typeLabel: tr('search.type.tool'),
title: item.title || item.id,
subtitle: item.pluginId || '',
keywords: `${item.id || ''} ${item.view || ''}`,
rank: 20,
action: 'view',
viewId: item.view || item.id,
pluginId: item.pluginId,
});
});
const files = await listFilesRecursive();
for (const entry of files) {
const path = entry.relativePath || entry.path || entry.name || '';
const snippet = await readFileSnippet(path);
next.push({
type: entry.type === 'folder' ? 'Folder' : 'File',
typeLabel: tr(entry.type === 'folder' ? 'search.type.folder' : 'search.type.file'),
title: path.split('/').pop() || path,
subtitle: path,
keywords: snippet,
rank: entry.type === 'folder' ? 30 : 40,
action: entry.type === 'folder' ? 'file-folder' : 'file',
path,
nodes,
});
}
const pluginItems = await Promise.all([
indexPluginSettings('verstak.journal', tr('search.type.journal'), 50, viewByPluginId.get('verstak.journal'), nodes),
indexPluginSettings('verstak.browser-inbox', tr('search.type.browserInbox'), 55, viewByPluginId.get('verstak.browser-inbox'), nodes),
indexPluginSettings('verstak.activity', tr('search.type.activity'), 60, viewByPluginId.get('verstak.activity'), nodes),
]);
if (seq !== buildSeq) return;
index = next.concat(pluginItems.flat());
loading = false;
runSearch(query);
}
function handleFocus() {
focused = true;
buildIndex();
}
async function openResult(item) {
query = '';
results = [];
if (item.action === 'workspace') {
window.dispatchEvent(new CustomEvent('verstak:workspace-selected', {
detail: { workspaceName: item.workspaceName, nodes: item.nodes || [] }
}));
return;
}
if (item.action === 'view') {
window.dispatchEvent(new CustomEvent('verstak:open-view', {
detail: { viewId: item.viewId, pluginId: item.pluginId }
}));
return;
}
if (item.action === 'workspace-tool') {
const workspaceName = item.workspaceName || '';
if (workspaceName) {
window.dispatchEvent(new CustomEvent('verstak:workspace-selected', {
detail: { workspaceName, nodes: item.nodes || [] }
}));
window.dispatchEvent(new CustomEvent('verstak:workspace-open-tool', {
detail: { kind: item.toolKind || item.type || '' }
}));
}
return;
}
if (item.action === 'file-folder') {
const parts = String(item.path || '').split('/').filter(Boolean);
const workspaceName = parts[0] || '';
const localPath = parts.slice(1).join('/');
if (workspaceName) {
window.__filesHistoryByWorkspace = window.__filesHistoryByWorkspace || {};
window.__filesHistoryByWorkspace[workspaceName] = {
stack: [localPath],
index: 0,
currentPath: localPath,
};
const detail = { workspaceName };
if (Array.isArray(item.nodes) && item.nodes.length > 0) detail.nodes = item.nodes;
window.dispatchEvent(new CustomEvent('verstak:workspace-selected', {
detail
}));
window.dispatchEvent(new CustomEvent('verstak:workspace-open-tool', {
detail: { kind: 'files' }
}));
}
return;
}
if (item.action === 'file') {
const response = await App.OpenWorkbenchResource('verstak.search', {
kind: 'vault-file',
path: item.path,
mode: 'view',
context: { sourceView: 'global-search' }
});
const [result, err] = Array.isArray(response) ? response : [response, ''];
if (!err && result) {
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
}
}
}
</script>
<div class="global-search" class:open={focused && (query || results.length)}>
<div class="global-search-box">
<Icon name="search" size={14} class="global-search-icon" />
<input
bind:value={query}
on:focus={handleFocus}
on:blur={() => setTimeout(() => focused = false, 120)}
type="search"
placeholder={loading ? tr('search.indexing') : tr('search.placeholder')}
aria-label={tr('search.global')}
data-global-search-input
/>
</div>
{#if focused && query}
<div class="global-search-results" data-global-search-results>
{#if results.length}
{#each results as item}
<button
type="button"
class="global-search-result"
data-global-search-result-type={item.type}
data-global-search-result-path={item.path || ''}
on:mousedown|preventDefault={() => openResult(item)}
>
<span class="global-search-result-title">{item.title}</span>
<span class="global-search-result-meta">{item.typeLabel || item.type} · {item.subtitle}</span>
</button>
{/each}
{:else}
<div class="global-search-empty vt-empty-title">{tr('search.noResults')}</div>
{/if}
</div>
{/if}
</div>
<style>
.global-search {
position: relative;
padding: 0.55rem 0.75rem;
border-bottom: 1px solid var(--vt-color-border);
flex-shrink: 0;
}
.global-search-box {
display: flex;
align-items: center;
gap: 0.35rem;
height: 2rem;
padding: 0 0.55rem;
border: 1px solid var(--vt-color-border-strong);
border-radius: var(--vt-radius-md);
background: #0f1424;
color: var(--vt-color-text-muted);
}
:global(.global-search-icon) {
color: var(--vt-color-text-muted);
flex-shrink: 0;
}
.global-search input {
width: 100%;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
color: var(--vt-color-text-primary);
font: inherit;
font-size: 0.78rem;
}
.global-search input::placeholder {
color: var(--vt-color-text-muted);
}
.global-search-box:focus-within {
border-color: var(--vt-color-accent);
box-shadow: var(--vt-focus-ring);
}
.global-search-results {
position: absolute;
left: 0.75rem;
right: 0.75rem;
top: calc(100% - 0.25rem);
z-index: 400;
max-height: 20rem;
overflow: auto;
border: 1px solid var(--vt-color-border-strong);
border-radius: var(--vt-radius-md);
background: var(--vt-color-surface);
box-shadow: var(--vt-elevation-menu);
}
.global-search-result {
display: flex;
flex-direction: column;
gap: 0.12rem;
width: 100%;
padding: 0.55rem 0.65rem;
border: 0;
border-bottom: 1px solid rgba(40, 70, 111, 0.55);
background: transparent;
color: var(--vt-color-text-primary);
text-align: left;
cursor: pointer;
}
.global-search-result:hover {
background: var(--vt-color-surface-hover);
}
.global-search-result-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.8rem;
}
.global-search-result-meta,
.global-search-empty {
color: var(--vt-color-text-muted);
font-size: 0.7rem;
}
.global-search-empty {
padding: 0.7rem;
}
</style>

View File

@ -2,14 +2,8 @@
import { onDestroy, onMount } from 'svelte';
import * as App from '../../../wailsjs/go/api/App';
import WorkspaceTree from './WorkspaceTree.svelte';
import GlobalSearch from './GlobalSearch.svelte';
import Icon from '../ui/Icon.svelte';
import { debug } from '../log/debug.js';
import { i18n } from '../i18n/index.js';
export let showGlobalSearch = true;
export let activeView = null;
export let activeViewPluginId = '';
function flog(msg) {
App.WriteFrontendLog('Sidebar', msg);
@ -19,16 +13,12 @@
let vaultStatus = { status: 'unknown', path: '', vaultId: '' };
let sidebarItems = [];
let errorMessage = '';
let locale = i18n.getLocale();
let unsubscribeLocale = null;
$: tr = ((activeLocale) => (key, params, fallback) => {
void activeLocale;
return i18n.t(key, params, fallback);
})(locale);
let navItems = [
{ id: 'plugin-manager', label: 'Plugin Manager', icon: 'puzzle' },
];
$: vaultOpen = vaultStatus.status === 'open';
$: activeSidebarKey = activeView ? `${activeViewPluginId}:${activeView}` : '';
async function loadSidebar() {
debug.log('[Sidebar] onMount: START');
@ -42,18 +32,14 @@
App.GetVaultStatus().catch(() => ({ status: 'unknown', path: '', vaultId: '' })),
App.GetContributions().catch(() => { contribErr = true; return {}; }),
]);
await Promise.all((p || []).map((plugin) => (
i18n.loadPlugin(plugin.manifest?.id, plugin.manifest?.localization).catch(() => {})
)));
plugins = (p || []).map((plugin) => i18n.localizePlugin(plugin));
const localizedContributions = i18n.localizeContributionSummary(contribs || {});
plugins = p || [];
vaultStatus = v;
debug.log('[Sidebar] onMount: plugins=' + plugins.length + ' vault=' + vaultStatus.status);
flog('onMount: plugins=' + plugins.length + ' vault=' + vaultStatus.status);
if (contribErr) {
errorMessage = tr('sidebar.error.contributions');
errorMessage = 'Failed to load plugin contributions';
}
sidebarItems = (localizedContributions.sidebarItems || []).filter(item => {
sidebarItems = (contribs.sidebarItems || []).filter(item => {
const plugin = plugins.find(p => p.manifest?.id === item.pluginId);
if (!plugin) return false;
return plugin.status !== 'disabled' && plugin.status !== 'failed' && plugin.status !== 'incompatible' && plugin.status !== 'missing-required-capability';
@ -65,27 +51,26 @@
debug.log('[Sidebar] onMount: ERROR:', String(e));
flog('onMount: ERROR: ' + String(e));
console.error('[Sidebar] load error:', e);
errorMessage = tr('sidebar.error.load');
errorMessage = 'Failed to load sidebar';
}
debug.log('[Sidebar] onMount: END');
flog('onMount: END');
}
onMount(() => {
unsubscribeLocale = i18n.subscribe((nextLocale) => {
const changed = locale !== nextLocale;
locale = nextLocale;
if (changed) loadSidebar();
});
loadSidebar();
window.addEventListener('verstak:plugins-changed', loadSidebar);
});
onDestroy(() => {
if (unsubscribeLocale) unsubscribeLocale();
window.removeEventListener('verstak:plugins-changed', loadSidebar);
});
function handleNav(id) {
debug.log('[Sidebar] handleNav:', id);
window.dispatchEvent(new CustomEvent('verstak:nav', { detail: { viewId: id } }));
}
function handleSidebarItem(item) {
debug.log('[Sidebar] handleSidebarItem:', item.id, '-> view:', item.view);
// Use item.view (the view contribution ID) if available, fall back to item.id
@ -100,18 +85,25 @@
<span class="sidebar-title">Verstak</span>
</div>
{#if vaultOpen && showGlobalSearch}
<GlobalSearch />
{/if}
<nav class="sidebar-nav">
{#each navItems as item}
<button
class="nav-item"
on:click={() => handleNav(item.id)}
type="button"
>
<Icon name={item.icon} size={16} class="nav-icon" />
<span class="nav-label">{item.label}</span>
</button>
{/each}
</nav>
{#if sidebarItems.length > 0}
<div class="sidebar-section">
<span class="section-label">{tr('sidebar.tools')}</span>
<span class="section-label">Plugins</span>
{#each sidebarItems as item}
<button
class="nav-item plugin-item vt-list-row"
class:is-active={activeSidebarKey === `${item.pluginId || ''}:${item.view || item.id}`}
aria-current={activeSidebarKey === `${item.pluginId || ''}:${item.view || item.id}` ? 'page' : undefined}
class="nav-item plugin-item"
on:click={() => handleSidebarItem(item)}
type="button"
>
@ -130,7 +122,12 @@
{#if errorMessage}
<span class="sidebar-error">
<Icon name="warning" size={10} class="sidebar-error-icon" />
{tr('sidebar.error.ui')}
Plugin UI error
</span>
{/if}
{#if vaultStatus.status !== 'unknown'}
<span class="vault-indicator" class:vault-open={vaultStatus.status === 'open'} class:vault-closed={vaultStatus.status !== 'open'}>
● Vault: {vaultStatus.status}
</span>
{/if}
</div>
@ -140,10 +137,10 @@
.sidebar {
width: 220px;
min-width: 220px;
background: var(--vt-color-surface-muted);
background: #16213e;
display: flex;
flex-direction: column;
border-right: 1px solid var(--vt-color-border);
border-right: 1px solid #0f3460;
overflow: hidden;
}
@ -152,28 +149,36 @@
align-items: center;
gap: 0.5rem;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--vt-color-border);
border-bottom: 1px solid #0f3460;
}
:global(.sidebar-logo) {
width: 1.2rem;
height: 1.2rem;
color: var(--vt-color-accent);
color: #4ecca3;
flex-shrink: 0;
}
.sidebar-title {
color: var(--vt-color-text-primary);
color: #e0e0f0;
font-size: 1rem;
font-weight: 600;
}
.sidebar-nav {
display: flex;
flex-direction: column;
padding: 0.5rem 0.75rem;
gap: 0.15rem;
}
.sidebar-section {
display: flex;
flex-direction: column;
padding: 0.45rem 0.6rem 0.55rem;
padding: 0.5rem 0.75rem;
gap: 0.15rem;
border-bottom: 1px solid var(--vt-color-border);
border-top: 1px solid #0f3460;
margin-top: 0.25rem;
}
:global(workspace-tree) {
@ -184,56 +189,43 @@
}
.section-label {
color: var(--vt-color-text-muted);
color: #666;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.25rem 0.45rem 0.35rem;
font-weight: 600;
padding: 0.25rem 0.5rem;
}
.nav-item {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0.45rem;
min-height: 1.85rem;
padding: 0.15rem 0.45rem;
gap: 0.6rem;
padding: 0.45rem 0.75rem;
background: none;
border: none;
color: var(--vt-color-text-secondary);
font-size: 0.78rem;
color: #a0a0b8;
font-size: 0.85rem;
cursor: pointer;
border-radius: var(--vt-radius-sm);
border-radius: 6px;
text-align: left;
width: 100%;
transition: background 0.15s, color 0.15s;
}
.nav-item:hover {
background: var(--vt-color-surface-hover);
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;
background: #0f3460;
color: #e0e0f0;
}
:global(.nav-icon) {
width: 0.9rem;
height: 0.9rem;
width: 1.2rem;
height: 1.2rem;
flex-shrink: 0;
color: currentColor;
}
:global(.nav-icon.icon-plugin) {
color: var(--vt-color-text-muted);
opacity: 0.9;
color: #a78bfa;
}
.nav-label {
@ -245,7 +237,20 @@
.sidebar-footer {
margin-top: auto;
padding: 0.75rem 1.25rem;
border-top: 1px solid var(--vt-color-border);
border-top: 1px solid #0f3460;
}
.vault-indicator {
font-size: 0.7rem;
color: #666;
}
.vault-indicator.vault-open {
color: #4ecca3;
}
.vault-indicator.vault-closed {
color: #a0a0b8;
}
.sidebar-error {
@ -253,33 +258,10 @@
align-items: center;
gap: 0.25rem;
font-size: 0.7rem;
color: var(--vt-color-danger);
color: #e94560;
margin-bottom: 0.25rem;
}
:global(.sidebar-error-icon) {
color: var(--vt-color-danger);
}
@media (max-width: 720px) {
.sidebar {
width: 100%;
min-width: 0;
max-height: 14rem;
border-right: 0;
border-bottom: 1px solid var(--vt-color-border);
}
.sidebar-header {
padding: 0.75rem 1rem;
}
.sidebar-section {
max-height: 5.5rem;
overflow: auto;
}
.sidebar-footer {
display: none;
}
color: #e94560;
}
</style>

View File

@ -1,395 +0,0 @@
<script>
import { onDestroy, onMount } from 'svelte';
import * as App from '../../../wailsjs/go/api/App';
import Icon from '../ui/Icon.svelte';
import { i18n } from '../i18n/index.js';
let items = [];
let settingsPanels = [];
let vaultStatus = { status: 'unknown', path: '', vaultId: '' };
let settingsOpen = false;
let locale = i18n.getLocale();
let selectedLanguage = i18n.getLanguagePreference();
let unsubscribeLocale = null;
$: tr = ((activeLocale) => (key, params, fallback) => {
void activeLocale;
return i18n.t(key, params, fallback);
})(locale);
$: leftItems = items.filter((item) => item.position === 'left');
$: centerItems = items.filter((item) => item.position === 'center');
$: rightItems = items.filter((item) => item.position === 'right');
$: vaultOpen = vaultStatus.status === 'open';
$: vaultStatusLabel = tr(`vault.status.${vaultStatus.status || 'unknown'}`, undefined, vaultStatus.status || 'unknown');
$: vaultLabel = tr('vault.label', { status: vaultStatusLabel });
const inactiveStatuses = new Set(['disabled', 'failed', 'incompatible', 'missing-required-capability']);
async function loadStatusBar() {
const [rawPlugins, rawContributions, vault] = await Promise.all([
App.GetPlugins().catch(() => []),
App.GetContributions().catch(() => ({})),
App.GetVaultStatus().catch(() => ({ status: 'unknown', path: '', vaultId: '' })),
]);
await Promise.all((rawPlugins || []).map((plugin) => (
i18n.loadPlugin(plugin.manifest?.id, plugin.manifest?.localization).catch(() => {})
)));
const plugins = (rawPlugins || []).map((plugin) => i18n.localizePlugin(plugin));
const contributions = i18n.localizeContributionSummary(rawContributions || {});
const pluginById = new Map((plugins || []).map((plugin) => [plugin.manifest?.id, plugin]));
vaultStatus = vault || { status: 'unknown', path: '', vaultId: '' };
items = (contributions.statusBarItems || [])
.filter((item) => {
const plugin = pluginById.get(item.pluginId);
if (!plugin) return false;
return !inactiveStatuses.has(plugin.status);
})
.map((item) => ({
...item,
position: item.position || 'left',
}));
settingsPanels = (contributions.settingsPanels || [])
.filter((panel) => {
const plugin = pluginById.get(panel.pluginId);
if (!plugin) return false;
return !inactiveStatuses.has(plugin.status);
})
.sort((a, b) => String(a.title || a.id).localeCompare(String(b.title || b.id)));
}
function openPluginManager() {
settingsOpen = false;
window.dispatchEvent(new CustomEvent('verstak:close-settings'));
window.dispatchEvent(new CustomEvent('verstak:nav', { detail: { viewId: 'plugin-manager' } }));
}
function openSettingsPanel(panel) {
settingsOpen = false;
window.dispatchEvent(new CustomEvent('verstak:open-settings', {
detail: { pluginId: panel.pluginId, panelId: panel.id }
}));
}
async function selectLanguage(language) {
const err = await App.UpdateAppSettings({ language });
if (err) {
throw new Error(err);
}
await i18n.setLanguagePreference(language);
selectedLanguage = language;
settingsOpen = false;
}
function toggleSettings(event) {
event.stopPropagation();
settingsOpen = !settingsOpen;
}
function closeSettings() {
settingsOpen = false;
}
onMount(() => {
unsubscribeLocale = i18n.subscribe((nextLocale) => {
const changed = locale !== nextLocale;
locale = nextLocale;
selectedLanguage = i18n.getLanguagePreference();
if (changed) loadStatusBar();
});
loadStatusBar();
window.addEventListener('verstak:plugins-changed', loadStatusBar);
window.addEventListener('verstak:vault-opened', loadStatusBar);
window.addEventListener('click', closeSettings);
});
onDestroy(() => {
if (unsubscribeLocale) unsubscribeLocale();
window.removeEventListener('verstak:plugins-changed', loadStatusBar);
window.removeEventListener('verstak:vault-opened', loadStatusBar);
window.removeEventListener('click', closeSettings);
});
</script>
<footer class="status-bar" aria-label={tr('statusBar.label')}>
<div class="status-bar-group status-left">
<span
class="vault-status"
class:vault-open={vaultOpen}
class:vault-closed={!vaultOpen}
title={vaultStatus.path || vaultStatus.vaultId || vaultLabel}
>
<Icon name="vault" size={13} class="status-icon" />
{vaultLabel}
</span>
{#each leftItems as item}
<span
class:status-bar-warning={item.handler}
class="status-bar-item"
data-status-item-id={item.id}
title={item.handler ? `${item.pluginId}: compact status only` : item.pluginId}
>
{#if item.handler}<Icon name="warning" size={11} class="status-warning-icon" />{/if}
{item.label || item.id}
</span>
{/each}
</div>
<div class="status-bar-group status-center">
{#each centerItems as item}
<span
class:status-bar-warning={item.handler}
class="status-bar-item"
data-status-item-id={item.id}
title={item.handler ? `${item.pluginId}: compact status only` : item.pluginId}
>
{#if item.handler}<Icon name="warning" size={11} class="status-warning-icon" />{/if}
{item.label || item.id}
</span>
{/each}
</div>
<div class="status-bar-group status-right">
{#each rightItems as item}
<span
class:status-bar-warning={item.handler}
class="status-bar-item"
data-status-item-id={item.id}
title={item.handler ? `${item.pluginId}: compact status only` : item.pluginId}
>
{#if item.handler}<Icon name="warning" size={11} class="status-warning-icon" />{/if}
{item.label || item.id}
</span>
{/each}
<div class="settings-menu-wrap">
<button
class="settings-button"
class:active={settingsOpen}
type="button"
title={tr('settings.title')}
aria-haspopup="menu"
aria-expanded={settingsOpen}
data-settings-menu-button
on:click={toggleSettings}
>
<Icon name="settings" size={14} class="settings-icon" />
<Icon name="chevronDown" size={12} class="settings-chevron" />
</button>
{#if settingsOpen}
<div class="settings-menu" role="menu">
<div class="settings-menu-heading">{tr('settings.language')}</div>
{#each ['system', 'en', 'ru'] as language}
<button
class="settings-menu-item language-item"
class:active-language={selectedLanguage === language}
type="button"
role="menuitemradio"
aria-checked={selectedLanguage === language}
data-settings-language={language}
on:click={() => selectLanguage(language)}
>
<span class="language-check" aria-hidden="true">{selectedLanguage === language ? '✓' : ''}</span>
<span>{tr(`settings.language.${language}`)}</span>
</button>
{/each}
<div class="settings-menu-separator"></div>
<button
class="settings-menu-item"
type="button"
role="menuitem"
data-settings-action="plugin-manager"
on:click={openPluginManager}
>
<Icon name="puzzle" size={14} class="settings-menu-icon" />
<span>{tr('settings.pluginManager')}</span>
</button>
{#if settingsPanels.length > 0}
<div class="settings-menu-separator"></div>
{#each settingsPanels as panel}
<button
class="settings-menu-item"
type="button"
role="menuitem"
data-settings-panel-id={panel.id}
title={panel.pluginId}
on:click={() => openSettingsPanel(panel)}
>
<Icon name={panel.icon || 'settings'} size={14} class="settings-menu-icon" />
<span>{panel.title || panel.id}</span>
</button>
{/each}
{/if}
</div>
{/if}
</div>
</div>
</footer>
<style>
.status-bar {
position: relative;
z-index: 100;
min-height: 1.7rem;
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center;
gap: 0.5rem;
padding: 0.2rem 0.65rem;
border-top: 1px solid #16213e;
background: #111629;
color: #9fb2ca;
font-size: 0.74rem;
}
.status-bar-group {
min-width: 0;
display: flex;
align-items: center;
gap: 0.4rem;
overflow: hidden;
}
.status-center {
justify-content: center;
}
.status-right {
justify-content: flex-end;
}
.status-bar-item {
display: inline-flex;
align-items: center;
gap: 0.25rem;
max-width: 18rem;
overflow: hidden;
padding: 0.12rem 0.35rem;
border-radius: 4px;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-bar-warning {
color: #ffc857;
}
:global(.status-warning-icon) {
flex-shrink: 0;
color: currentColor;
}
.vault-status {
display: inline-flex;
align-items: center;
gap: 0.3rem;
max-width: 24rem;
min-width: 0;
overflow: hidden;
padding: 0.12rem 0.35rem;
border-radius: 4px;
color: #a0a0b8;
white-space: nowrap;
text-overflow: ellipsis;
}
.vault-status.vault-open {
color: #4ecca3;
}
.vault-status.vault-closed {
color: #9fb2ca;
}
:global(.status-icon) {
flex-shrink: 0;
color: currentColor;
}
.settings-menu-wrap {
position: relative;
display: inline-flex;
align-items: center;
flex-shrink: 0;
}
.settings-button {
min-height: 1.35rem;
height: 1.35rem;
padding: 0 0.35rem;
gap: 0.15rem;
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
color: #9fb2ca;
}
.settings-button:hover,
.settings-button.active {
border-color: #1a3a5c;
background: #16213e;
color: #e0e0f0;
}
:global(.settings-icon),
:global(.settings-chevron),
:global(.settings-menu-icon) {
flex-shrink: 0;
color: currentColor;
}
.settings-menu {
position: fixed;
right: 0.65rem;
bottom: 2rem;
z-index: 10000;
min-width: 13rem;
padding: 0.3rem;
border: 1px solid #1a3a5c;
border-radius: 6px;
background: #12122a;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
}
.settings-menu-item {
width: 100%;
min-height: 1.8rem;
justify-content: flex-start;
gap: 0.45rem;
padding: 0.3rem 0.45rem;
border: none;
border-radius: 4px;
background: transparent;
color: #cfd8e3;
font-size: 0.78rem;
font-weight: 500;
text-align: left;
}
.settings-menu-item:hover {
background: #0f3460;
color: #ffffff;
}
.settings-menu-heading {
padding: 0.25rem 0.45rem 0.2rem;
color: #7f8aa3;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.language-check {
width: 0.9rem;
color: #4ecca3;
text-align: center;
}
.active-language {
background: rgba(78, 204, 163, 0.1);
}
.settings-menu-separator {
height: 1px;
margin: 0.25rem 0.2rem;
background: #1a3a5c;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -1,8 +1,7 @@
<script>
import { onDestroy, onMount } from 'svelte';
import { onMount } from 'svelte';
import * as App from '../../../wailsjs/go/api/App';
import Icon from '../ui/Icon.svelte';
import { i18n } from '../i18n/index.js';
let appSettings = {};
let recentVaults = [];
@ -12,19 +11,6 @@
let opening = false;
let newVaultPath = '';
let openVaultPath = '';
let locale = i18n.getLocale();
const unsubscribeLocale = i18n.subscribe((nextLocale) => { locale = nextLocale; });
$: tr = ((activeLocale) => (key, params, fallback) => {
void activeLocale;
return i18n.t(key, params, fallback);
})(locale);
function reportError(key, fallback, details) {
console.warn('[VaultSelection] operation failed:', details);
return tr(key, undefined, fallback);
}
onDestroy(unsubscribeLocale);
onMount(async () => {
try {
@ -53,20 +39,20 @@
async function createVault() {
error = '';
if (!newVaultPath.trim()) {
error = tr('vaultSelection.chooseNew');
error = 'Please enter or select a path for the new vault';
return;
}
creating = true;
try {
const createErr = await App.CreateVault(newVaultPath.trim());
if (createErr) {
error = reportError('vaultSelection.createError', 'Could not create the vault. Please try again.', createErr);
error = 'Create vault: ' + createErr;
creating = false;
return;
}
const openErr = await App.OpenVault(newVaultPath.trim());
if (openErr) {
error = reportError('vaultSelection.openError', 'Could not open the vault. Please try again.', openErr);
error = 'Open vault: ' + openErr;
creating = false;
return;
}
@ -76,7 +62,7 @@
}
window.dispatchEvent(new CustomEvent('verstak:vault-opened'));
} catch (e) {
error = reportError('vaultSelection.createError', 'Could not create the vault. Please try again.', e);
error = String(e);
creating = false;
}
}
@ -84,14 +70,14 @@
async function openExistingVault() {
error = '';
if (!openVaultPath.trim()) {
error = tr('vaultSelection.chooseExisting');
error = 'Please enter or select a path to an existing vault';
return;
}
opening = true;
try {
const openErr = await App.OpenVault(openVaultPath.trim());
if (openErr) {
error = reportError('vaultSelection.openError', 'Could not open the vault. Please try again.', openErr);
error = 'Open vault: ' + openErr;
opening = false;
return;
}
@ -101,7 +87,7 @@
}
window.dispatchEvent(new CustomEvent('verstak:vault-opened'));
} catch (e) {
error = reportError('vaultSelection.openError', 'Could not open the vault. Please try again.', e);
error = String(e);
opening = false;
}
}
@ -112,7 +98,7 @@
try {
const openErr = await App.OpenVault(path);
if (openErr) {
error = reportError('vaultSelection.openError', 'Could not open the vault. Please try again.', openErr);
error = 'Open vault: ' + openErr;
opening = false;
return;
}
@ -122,7 +108,7 @@
}
window.dispatchEvent(new CustomEvent('verstak:vault-opened'));
} catch (e) {
error = reportError('vaultSelection.openError', 'Could not open the vault. Please try again.', e);
error = String(e);
opening = false;
}
}
@ -131,7 +117,7 @@
{#if loading}
<div class="vault-selection">
<div class="vault-selection-inner">
<p class="loading-text">{tr('common.loading')}</p>
<p class="loading-text">Loading...</p>
</div>
</div>
{:else}
@ -144,7 +130,7 @@
<line x1="9" y1="14" x2="15" y2="14"/>
</svg>
<h1>Verstak</h1>
<p class="subtitle">{tr('vaultSelection.subtitle')}</p>
<p class="subtitle">Choose a vault to get started</p>
</div>
{#if error}
@ -156,43 +142,43 @@
<div class="actions">
<div class="action-card">
<h3>{tr('vaultSelection.createTitle')}</h3>
<p class="hint">{tr('vaultSelection.createHint')}</p>
<h3>Create New Vault</h3>
<p class="hint">Create a new vault folder. This will be your workspace.</p>
<div class="input-row">
<input
type="text"
bind:value={newVaultPath}
placeholder={tr('vaultSelection.pathPlaceholder')}
placeholder="Select or type a path..."
disabled={creating}
/>
<button class="btn-secondary" on:click={browseNewVault} type="button" disabled={creating}>
{tr('common.browse')}
Browse…
</button>
</div>
<div class="button-row">
<button class="btn-primary" on:click={createVault} type="button" disabled={creating}>
{creating ? tr('vaultSelection.creating') : tr('vaultSelection.create')}
{creating ? 'Creating...' : 'Create & Open'}
</button>
</div>
</div>
<div class="action-card">
<h3>{tr('vaultSelection.openTitle')}</h3>
<p class="hint">{tr('vaultSelection.openHint')}</p>
<h3>Open Existing Vault</h3>
<p class="hint">Open a vault that already exists on this computer.</p>
<div class="input-row">
<input
type="text"
bind:value={openVaultPath}
placeholder={tr('vaultSelection.pathPlaceholder')}
placeholder="Select or type a path..."
disabled={opening}
/>
<button class="btn-secondary" on:click={browseOpenVault} type="button" disabled={opening}>
{tr('common.browse')}
Browse…
</button>
</div>
<div class="button-row">
<button class="btn-secondary open-existing-btn" on:click={openExistingVault} type="button" disabled={opening}>
{opening ? tr('vaultSelection.opening') : tr('vaultSelection.open')}
<button class="btn-primary" on:click={openExistingVault} type="button" disabled={opening}>
{opening ? 'Opening...' : 'Open'}
</button>
</div>
</div>
@ -200,7 +186,7 @@
{#if recentVaults.length > 0}
<div class="recent-section">
<h3>{tr('vaultSelection.recent')}</h3>
<h3>Recent Vaults</h3>
<ul class="recent-list">
{#each recentVaults as path}
<li>
@ -223,7 +209,7 @@
align-items: center;
justify-content: center;
height: 100vh;
background: var(--vt-color-background);
background: #1a1a2e;
padding: 2rem;
}
.vault-selection-inner {
@ -231,7 +217,7 @@
width: 100%;
}
.loading-text {
color: var(--vt-color-text-muted);
color: #a0a0b8;
text-align: center;
font-size: 1rem;
}
@ -240,26 +226,26 @@
margin-bottom: 2rem;
}
.logo h1 {
color: var(--vt-color-text-primary);
color: #e0e0f0;
font-size: 1.8rem;
margin: 0.5rem 0 0.25rem;
}
.subtitle {
color: var(--vt-color-text-muted);
color: #a0a0b8;
font-size: 0.95rem;
margin: 0;
}
.error-box {
background: var(--vt-color-danger-muted);
border: 1px solid rgba(233, 69, 96, 0.5);
border-radius: var(--vt-radius-lg);
background: rgba(233, 69, 96, 0.1);
border: 1px solid #e94560;
border-radius: 8px;
padding: 0.75rem 1rem;
margin-bottom: 1.5rem;
display: flex;
align-items: flex-start;
gap: 0.5rem;
font-size: 0.85rem;
color: #ffc6ce;
color: #e94560;
}
:global(.error-icon) { flex-shrink: 0; }
.error-text { word-break: break-word; }
@ -270,18 +256,18 @@
margin-bottom: 1.5rem;
}
.action-card {
background: var(--vt-color-surface);
border: 1px solid var(--vt-color-border);
border-radius: var(--vt-radius-lg);
background: #16213e;
border: 1px solid #0f3460;
border-radius: 8px;
padding: 1.25rem;
}
.action-card h3 {
color: var(--vt-color-text-primary);
color: #e0e0f0;
font-size: 1rem;
margin: 0 0 0.25rem;
}
.hint {
color: var(--vt-color-text-muted);
color: #a0a0b8;
font-size: 0.8rem;
margin: 0 0 0.75rem;
}
@ -292,28 +278,27 @@
}
.input-row input {
flex: 1;
background: #0f1424;
border: 1px solid var(--vt-color-border-strong);
color: var(--vt-color-text-primary);
background: #0f3460;
border: 1px solid #1a3a5c;
color: #e0e0f0;
padding: 0.5rem 0.75rem;
border-radius: 6px;
font-size: 0.9rem;
}
.input-row input:focus {
outline: none;
border-color: var(--vt-color-accent);
box-shadow: var(--vt-focus-ring);
border-color: #4ecca3;
}
.input-row input::placeholder {
color: var(--vt-color-text-muted);
color: #666;
}
.button-row {
display: flex;
justify-content: flex-end;
}
.btn-primary {
background: var(--vt-color-accent);
color: #101827;
background: #4ecca3;
color: #1a1a2e;
border: none;
padding: 0.5rem 1.25rem;
border-radius: 6px;
@ -329,9 +314,9 @@
cursor: not-allowed;
}
.btn-secondary {
background: var(--vt-color-surface-hover);
color: var(--vt-color-text-secondary);
border: 1px solid var(--vt-color-border-strong);
background: #0f3460;
color: #a0a0b8;
border: 1px solid #1a3a5c;
padding: 0.5rem 0.75rem;
border-radius: 6px;
cursor: pointer;
@ -339,21 +324,21 @@
white-space: nowrap;
}
.btn-secondary:hover:not(:disabled) {
background: var(--vt-color-surface-hover);
color: var(--vt-color-text-primary);
background: #1a3a5c;
color: #e0e0f0;
}
.btn-secondary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.recent-section {
background: var(--vt-color-surface);
border: 1px solid var(--vt-color-border);
border-radius: var(--vt-radius-lg);
background: #16213e;
border: 1px solid #0f3460;
border-radius: 8px;
padding: 1rem 1.25rem;
}
.recent-section h3 {
color: var(--vt-color-text-muted);
color: #a0a0b8;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
@ -371,14 +356,14 @@
width: 100%;
background: none;
border: none;
color: var(--vt-color-text-primary);
color: #e0e0f0;
padding: 0.4rem 0;
cursor: pointer;
text-align: left;
font-size: 0.85rem;
}
.recent-item:hover {
color: var(--vt-color-accent);
color: #4ecca3;
}
.recent-item:disabled {
opacity: 0.5;

View File

@ -1,9 +1,8 @@
<script>
import PluginBundleHost from '../plugin-host/PluginBundleHost.svelte';
import { onDestroy, onMount } from 'svelte';
import { onMount } from 'svelte';
import * as App from '../../../wailsjs/go/api/App';
import Icon from '../ui/Icon.svelte';
import { i18n } from '../i18n/index.js';
export let activeView = null;
export let activeViewPluginId = null;
@ -11,40 +10,18 @@
let views = [];
let plugins = [];
let renderError = null;
let locale = i18n.getLocale();
let unsubscribeLocale = null;
$: tr = ((activeLocale) => (key, params, fallback) => {
void activeLocale;
return i18n.t(key, params, fallback);
})(locale);
async function loadViews() {
onMount(async () => {
try {
const [contribs, pluginList] = await Promise.all([
App.GetContributions().catch(() => ({ views: [] })),
App.GetPlugins().catch(() => []),
]);
await Promise.all((pluginList || []).map((plugin) => (
i18n.loadPlugin(plugin.manifest?.id, plugin.manifest?.localization).catch(() => {})
)));
views = i18n.localizeContributionSummary(contribs || {}).views || [];
plugins = (pluginList || []).map((plugin) => i18n.localizePlugin(plugin));
views = contribs.views || [];
plugins = pluginList;
} catch (e) {
console.error('[ViewContainer] load error:', e);
}
}
onMount(() => {
unsubscribeLocale = i18n.subscribe((nextLocale) => {
const changed = locale !== nextLocale;
locale = nextLocale;
if (changed) loadViews();
});
loadViews();
});
onDestroy(() => {
if (unsubscribeLocale) unsubscribeLocale();
});
$: currentView = views.find(v => v.id === activeView && v.pluginId === activeViewPluginId);
@ -62,7 +39,7 @@
}
function onHostError(e) {
renderError = e.detail?.message || tr('pluginView.error');
renderError = e.detail?.message || 'Plugin view error';
}
</script>
@ -70,13 +47,10 @@
{#if renderError}
<div class="view-container">
<div class="error-boundary">
<div class="error-fallback vt-inline-alert error">
<div class="error-fallback">
<Icon name="warning" size={24} class="error-icon" />
<p class="error-title">{tr('pluginView.failed')}</p>
<details class="error-details">
<summary>{tr('common.details')}</summary>
<p class="error-text">{renderError}</p>
</details>
<p class="error-title">Plugin UI failed</p>
<p class="error-text">{renderError}</p>
</div>
</div>
</div>
@ -86,6 +60,11 @@
<div class="view-header">
<Icon name={currentView.icon || 'logo'} size={20} class="view-icon" />
<h2>{currentView.title}</h2>
{#if hasFrontend}
<span class="frontend-badge">frontend bundle</span>
{:else}
<span class="no-frontend-badge">no frontend bundle</span>
{/if}
</div>
<div class="view-content">
{#if hasFrontend}
@ -95,14 +74,11 @@
/>
{:else}
<div class="placeholder">
<p class="placeholder-label">{tr('pluginView.noVisual')}</p>
<details class="placeholder-details">
<summary>{tr('common.details')}</summary>
<p class="placeholder-info"><span class="placeholder-key">{tr('common.plugin')}:</span> <strong>{currentView.pluginId}</strong></p>
<p class="placeholder-info"><span class="placeholder-key">{tr('pluginView.viewId')}:</span> <code>{currentView.id}</code></p>
<p class="placeholder-info"><span class="placeholder-key">{tr('common.component')}:</span> <code>{currentView.component}</code></p>
</details>
<p class="placeholder-badge vt-badge">{tr('pluginView.bundleUnavailable')}</p>
<p class="placeholder-label">Plugin View Host</p>
<p class="placeholder-info"><span class="placeholder-key">Plugin:</span> <strong>{currentView.pluginId}</strong></p>
<p class="placeholder-info"><span class="placeholder-key">View ID:</span> <code>{currentView.id}</code></p>
<p class="placeholder-info"><span class="placeholder-key">Component:</span> <code>{currentView.component}</code></p>
<p class="placeholder-badge">frontend bundle not available</p>
</div>
{/if}
</div>
@ -110,12 +86,12 @@
</div>
{:else if activeView}
<div class="view-container empty">
<p>{tr('pluginView.unavailable')}</p>
<p>View "{activeView}" not found in contributions</p>
</div>
{:else}
<div class="view-container empty">
<p>{tr('pluginView.select')}</p>
<p class="sub">{tr('pluginView.selectHint')}</p>
<p>Select a plugin view from the sidebar</p>
<p class="sub">Plugin views will appear here</p>
</div>
{/if}
{/key}
@ -126,7 +102,7 @@
display: flex;
flex-direction: column;
min-width: 0;
background: var(--vt-color-background);
background: #1a1a2e;
}
.view {
flex: 1;
@ -136,7 +112,7 @@
padding: 1.5rem;
}
.view.degraded {
border-left: 3px solid var(--vt-color-warning);
border-left: 3px solid #ffc857;
}
.view-header {
display: flex;
@ -144,52 +120,72 @@
gap: 0.5rem;
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--vt-color-border);
border-bottom: 1px solid #16213e;
}
.view-header h2 {
margin: 0;
font-size: 1.2rem;
color: var(--vt-color-text-primary);
color: #e0e0f0;
flex: 1;
}
:global(.view-icon) {
width: 1.3rem;
height: 1.3rem;
color: var(--vt-color-text-muted);
color: #a78bfa;
flex-shrink: 0;
}
.frontend-badge {
font-size: 0.7rem;
padding: 0.15rem 0.5rem;
background: rgba(78, 204, 163, 0.15);
color: #4ecca3;
border-radius: 8px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.no-frontend-badge {
font-size: 0.7rem;
padding: 0.15rem 0.5rem;
background: rgba(233, 69, 96, 0.1);
color: #e94560;
border-radius: 8px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.view-content {
flex: 1;
min-width: 0;
}
.placeholder {
color: var(--vt-color-text-muted);
color: #666;
font-style: italic;
padding: 2rem;
text-align: center;
border: 1px dashed var(--vt-color-border-strong);
border-radius: var(--vt-radius-lg);
border: 1px dashed #333;
border-radius: 8px;
}
.placeholder-label {
font-size: 1rem;
color: var(--vt-color-text-secondary);
color: #a0a0b8;
font-weight: 600;
margin-bottom: 1rem;
font-style: normal;
}
.placeholder-info {
font-size: 0.85rem;
color: var(--vt-color-text-muted);
color: #666;
margin: 0.3rem 0;
font-style: normal;
}
.placeholder-key {
color: var(--vt-color-text-muted);
color: #a0a0b8;
}
.placeholder-info strong { color: var(--vt-color-accent); }
.placeholder-info strong { color: #4ecca3; }
.placeholder-info code {
color: var(--vt-color-text-primary);
background: var(--vt-color-surface-muted);
color: #e0e0f0;
background: #16213e;
padding: 0.1rem 0.3rem;
border-radius: 3px;
font-size: 0.8rem;
@ -198,8 +194,8 @@
display: inline-block;
margin-top: 1rem;
padding: 0.25rem 0.75rem;
background: var(--vt-color-surface-muted);
color: var(--vt-color-text-secondary);
background: #533483;
color: #e0e0f0;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
@ -217,16 +213,16 @@
padding: 2rem;
}
:global(.error-icon) {
color: var(--vt-color-danger);
color: #e94560;
}
.error-title {
color: var(--vt-color-danger);
color: #e94560;
font-size: 1.1rem;
font-weight: 600;
margin: 0.5rem 0;
}
.error-text {
color: var(--vt-color-text-secondary);
color: #a0a0b8;
font-size: 0.85rem;
font-family: monospace;
margin-top: 0.5rem;
@ -237,13 +233,8 @@
align-items: center;
justify-content: center;
height: 100%;
color: var(--vt-color-text-muted);
color: #555;
font-size: 1rem;
text-align: center;
}
.empty .sub { font-size: 0.85rem; color: var(--vt-color-text-muted); margin-top: 0.5rem; }
.placeholder-details,
.error-details { margin-top: 0.75rem; color: var(--vt-color-text-muted); }
.placeholder-details summary,
.error-details summary { cursor: pointer; color: var(--vt-color-text-secondary); }
.empty .sub { font-size: 0.85rem; color: #444; margin-top: 0.5rem; }
</style>

View File

@ -1,18 +1,8 @@
<script>
import { onDestroy } from 'svelte';
import PluginBundleHost from '../plugin-host/PluginBundleHost.svelte';
import Icon from '../ui/Icon.svelte';
import { i18n } from '../i18n/index.js';
import { debug } from '../log/debug.js';
export let openedResource = null;
let locale = i18n.getLocale();
const unsubscribeLocale = i18n.subscribe((nextLocale) => locale = nextLocale);
$: tr = ((activeLocale) => (key, params, fallback) => {
void activeLocale;
return i18n.t(key, params, fallback);
})(locale);
$: providerPluginId = openedResource?.providerPluginId || '';
$: providerComponent = openedResource?.providerComponent || '';
@ -22,7 +12,6 @@
$: requestContext = openedResource?.request?.context?.notesMode || openedResource?.request?.context?.isInsideNotesFolder
? 'notes-markdown'
: ((openedResource?.request?.extension === '.md' || openedResource?.request?.extension === '.markdown') ? 'generic-markdown' : 'generic-text');
$: resourceTitle = resourceDisplayTitle(resourcePath, requestContext);
$: componentProps = openedResource || {};
$: mountKey = [
providerPluginId,
@ -32,40 +21,29 @@
requestContext,
].join(':');
function resourceDisplayTitle(path, context) {
const fileName = String(path || '').split('/').filter(Boolean).pop() || String(path || '');
return context === 'notes-markdown' ? fileName.replace(/\.(md|markdown)$/i, '') : fileName;
}
function closeWorkbench() {
window.dispatchEvent(new CustomEvent('verstak:close-workbench', { cancelable: true }));
}
onDestroy(unsubscribeLocale);
</script>
<div class="workbench-host vt-page">
<div class="workbench-host">
{#if openedResource?.status === 'no-provider'}
<div class="workbench-header vt-page-header">
<span class="workbench-title vt-page-title">{resourceTitle}</span>
<span class="workbench-provider vt-badge">{tr('workbench.noProvider')}</span>
<button class="close-btn btn-ghost btn-icon" type="button" title={tr('common.close')} aria-label={tr('common.close')} on:click={closeWorkbench}>
<div class="workbench-header">
<span class="workbench-title">{resourcePath}</span>
<span class="workbench-provider">no-provider</span>
<button class="close-btn" type="button" title="Close" aria-label="Close" on:click={closeWorkbench}>
<Icon name="x" size={18} />
</button>
</div>
<div class="workbench-empty no-provider vt-empty-state" data-workbench-status="no-provider">
<p class="vt-empty-title">{tr('workbench.noViewer')}</p>
{#if debug.isEnabled()}
<p class="workbench-meta">{requestMode} · {requestContext}</p>
{/if}
<div class="workbench-empty no-provider" data-workbench-status="no-provider">
<p>No viewer/editor available</p>
<p class="workbench-meta">{requestMode} · {requestContext}</p>
</div>
{:else if openedResource}
<div class="workbench-header vt-page-header">
<span class="workbench-title vt-page-title">{resourceTitle}</span>
{#if debug.isEnabled() && providerId}
<span class="workbench-provider vt-badge accent" data-debug-provider>{providerId}</span>
{/if}
<button class="close-btn btn-ghost btn-icon" type="button" title={tr('common.close')} aria-label={tr('common.close')} on:click={closeWorkbench}>
<div class="workbench-header">
<span class="workbench-title">{resourcePath}</span>
<span class="workbench-provider">{providerId}</span>
<button class="close-btn" type="button" title="Close" aria-label="Close" on:click={closeWorkbench}>
<Icon name="x" size={18} />
</button>
</div>
@ -79,8 +57,8 @@
{/key}
</div>
{:else}
<div class="workbench-empty vt-empty-state">
<p class="vt-empty-title">{tr('workbench.noResource')}</p>
<div class="workbench-empty">
<p>No resource opened</p>
</div>
{/if}
</div>
@ -93,20 +71,20 @@
height: 100%;
display: flex;
flex-direction: column;
background: var(--vt-color-background);
background: #1a1a2e;
}
.workbench-header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: var(--vt-space-2) var(--vt-space-4);
border-bottom: 1px solid var(--vt-color-border);
padding: 0.75rem 1rem;
border-bottom: 1px solid #16213e;
flex-shrink: 0;
}
.workbench-title {
color: var(--vt-color-text-primary);
color: #e0e0f0;
font-size: 0.95rem;
font-weight: 600;
min-width: 0;
@ -120,19 +98,21 @@
height: 2rem;
min-height: 0;
padding: 0;
border-radius: var(--vt-radius-md);
color: var(--vt-color-text-secondary);
border: 1px solid #1a3a5c;
border-radius: 4px;
background: #0f3460;
color: #a0a0b8;
flex-shrink: 0;
cursor: pointer;
}
.close-btn:hover {
color: var(--vt-color-text-primary);
background: var(--vt-color-surface-hover);
color: #e0e0f0;
background: #1a4a7a;
}
.workbench-provider {
color: var(--vt-color-accent);
color: #4ecca3;
font-size: 0.75rem;
margin-left: auto;
}
@ -152,7 +132,7 @@
display: flex;
align-items: center;
justify-content: center;
color: var(--vt-color-text-muted);
color: #666;
}
.workbench-empty.no-provider {
@ -162,7 +142,7 @@
.workbench-meta {
margin: 0;
color: var(--vt-color-text-muted);
color: #8b8ba8;
font-size: 0.8rem;
}
</style>

View File

@ -1,260 +1,89 @@
<script>
import PluginBundleHost from '../plugin-host/PluginBundleHost.svelte';
import GlobalSearch from './GlobalSearch.svelte';
import TodaySurface from './TodaySurface.svelte';
import * as App from '../../../wailsjs/go/api/App';
import { onDestroy, onMount } from 'svelte';
import { i18n } from '../i18n/index.js';
export let selectedWorkspaceName = '';
export let nodes = [];
export let activeToolKey = '';
let contributions = {};
let plugins = [];
let discoveredWorkspaceTools = [];
let workspaceTools = [];
let workspaceMetadata = null;
let metadataWorkspaceRoot = '';
let toolsLoaded = false;
let requestedToolKind = '';
let requestedToolRequest = null;
let activeToolRequest = null;
let requestedWorkspaceRoot = '';
let locale = i18n.getLocale();
let unsubscribeLocale = null;
$: tr = ((activeLocale) => (key, params, fallback) => {
void activeLocale;
return i18n.t(key, params, fallback);
})(locale);
// TODO: Rename TodaySurface.svelte to OverviewSurface.svelte in a refactor-only follow-up.
$: overviewTool = { id: '__overview', title: tr('workspace.overview'), pluginId: 'verstak.shell', component: 'TodaySurface', shell: true };
const toolOrder = new Map([
['notes', 10],
['files', 20],
['todo', 30],
['activity', 40],
['browser', 50],
['inbox', 50],
['secrets', 60],
['search', 90],
]);
let activeToolKey = '';
$: selectedWorkspace = nodes.find(n => n.id === selectedWorkspaceName || n.name === selectedWorkspaceName || n.rootPath === selectedWorkspaceName) || null;
$: workspaceRootPath = selectedWorkspace?.rootPath || selectedWorkspace?.name || selectedWorkspace?.id || '';
$: workspaceId = selectedWorkspace?.workspaceId || '';
$: workspaceTitle = selectedWorkspace?.title || selectedWorkspace?.name || selectedWorkspace?.id || selectedWorkspaceName;
$: if (workspaceRootPath !== metadataWorkspaceRoot) {
metadataWorkspaceRoot = workspaceRootPath;
workspaceMetadata = null;
if (workspaceRootPath) loadWorkspaceMetadata(workspaceRootPath);
}
$: workspaceTools = sortWorkspaceTools(filterWorkspaceTools(discoveredWorkspaceTools, workspaceMetadata));
$: if (workspaceRootPath !== requestedWorkspaceRoot) {
requestedWorkspaceRoot = workspaceRootPath;
requestedToolRequest = null;
activeToolRequest = null;
}
$: displayedTools = selectedWorkspace ? [overviewTool, ...workspaceTools] : [];
$: activeTool = displayedTools.find(tool => toolKey(tool) === activeToolKey) || displayedTools[0] || null;
$: if (displayedTools.length > 0 && (!activeToolKey || (toolsLoaded && !displayedTools.some(tool => toolKey(tool) === activeToolKey)))) {
activeToolKey = toolKey(overviewTool);
}
$: if (requestedToolKind && workspaceTools.length > 0) {
const match = findWorkspaceTool(requestedToolKind);
if (match) {
const toolRequest = requestedToolRequest;
requestedToolKind = '';
requestedToolRequest = null;
selectTool(match, toolRequest);
}
$: workspaceType = selectedWorkspace?.type || 'workspace';
$: activeTool = workspaceTools.find(tool => toolKey(tool) === activeToolKey) || workspaceTools[0] || null;
$: if (workspaceTools.length > 0 && (!activeToolKey || !workspaceTools.some(tool => toolKey(tool) === activeToolKey))) {
activeToolKey = toolKey(workspaceTools[0]);
}
$: if (selectedWorkspaceName) loadTools();
onMount(() => {
unsubscribeLocale = i18n.subscribe((nextLocale) => {
const changed = locale !== nextLocale;
locale = nextLocale;
if (changed && selectedWorkspaceName) loadTools();
});
window.addEventListener('verstak:workspace-open-tool', handleWorkspaceOpenTool);
window.addEventListener('verstak:plugins-changed', handlePluginsChanged);
});
onDestroy(() => {
if (unsubscribeLocale) unsubscribeLocale();
window.removeEventListener('verstak:workspace-open-tool', handleWorkspaceOpenTool);
window.removeEventListener('verstak:plugins-changed', handlePluginsChanged);
});
function toolKey(tool) {
return `${tool?.pluginId || ''}:${tool?.id || ''}`;
}
function toolRank(tool) {
const text = `${tool?.title || ''} ${tool?.id || ''} ${tool?.pluginId || ''}`.toLowerCase();
for (const [needle, rank] of toolOrder.entries()) {
if (text.includes(needle)) return rank;
}
return 1000;
}
function sortWorkspaceTools(tools) {
return [...tools].sort((a, b) => {
const rankDiff = toolRank(a) - toolRank(b);
if (rankDiff !== 0) return rankDiff;
return String(a.title || a.id).localeCompare(String(b.title || b.id));
});
}
function filterWorkspaceTools(tools, metadata) {
if (!Array.isArray(metadata?.workspaceTools)) return tools;
const allowedPluginIds = new Set(metadata.workspaceTools);
return tools.filter(tool => allowedPluginIds.has(tool.pluginId));
}
function resultOrError(response, fallbackValue) {
if (Array.isArray(response) && typeof response[1] === 'string') {
return [response[0] || fallbackValue, response[1] || ''];
}
return typeof response === 'string' ? [fallbackValue, response] : [response || fallbackValue, ''];
}
async function loadWorkspaceMetadata(rootPath) {
try {
const [metadata, err] = resultOrError(await App.GetWorkspaceMetadata(rootPath), null);
if (rootPath !== workspaceRootPath) return;
workspaceMetadata = err ? null : metadata;
} catch (_) {
if (rootPath === workspaceRootPath) workspaceMetadata = null;
}
}
function selectTool(tool, toolRequest = null) {
activeToolKey = toolKey(tool);
activeToolRequest = toolRequest;
window.dispatchEvent(new CustomEvent('verstak:workspace-tool-selected', {
detail: {
toolKey: activeToolKey,
toolId: tool?.id || '',
pluginId: tool?.pluginId || '',
},
}));
}
function findWorkspaceTool(kind) {
kind = String(kind || '').toLowerCase();
return workspaceTools.find(tool => {
const text = `${tool?.title || ''} ${tool?.id || ''} ${tool?.pluginId || ''}`.toLowerCase();
if (kind === 'browser-inbox') return text.includes('browser') || text.includes('inbox');
return text.includes(kind);
});
}
function requestWorkspaceTool(kind, toolRequest = null) {
requestedToolKind = String(kind || '').toLowerCase();
requestedToolRequest = toolRequest;
const match = findWorkspaceTool(requestedToolKind);
if (match) {
requestedToolKind = '';
requestedToolRequest = null;
selectTool(match, toolRequest);
}
}
function openWorkspaceTool(event) {
requestWorkspaceTool(event?.detail?.kind, event?.detail?.toolRequest || null);
}
function handleWorkspaceOpenTool(event) {
requestWorkspaceTool(event?.detail?.kind, event?.detail?.toolRequest || null);
}
function handlePluginsChanged() {
if (selectedWorkspaceName) loadTools();
}
async function loadTools() {
try {
toolsLoaded = false;
const [c, p] = await Promise.all([
App.GetContributions().catch(() => ({})),
App.GetPlugins().catch(() => []),
]);
await Promise.all((p || []).map((plugin) => (
i18n.loadPlugin(plugin.manifest?.id, plugin.manifest?.localization).catch(() => {})
)));
contributions = i18n.localizeContributionSummary(c || {});
plugins = (p || []).map((plugin) => i18n.localizePlugin(plugin));
contributions = c || {};
plugins = p || [];
const enabledIds = new Set(
plugins.filter(pl => pl.enabled && (pl.status === 'loaded' || pl.status === 'degraded')).map(pl => pl.manifest?.id)
);
discoveredWorkspaceTools = (contributions.workspaceItems || []).filter(tool => enabledIds.has(tool.pluginId));
workspaceTools = (contributions.workspaceItems || []).filter(tool => enabledIds.has(tool.pluginId));
} catch (e) {
console.error('[WorkspaceHost] loadTools error:', e);
} finally {
toolsLoaded = true;
}
}
</script>
<div class="workspace-host vt-page">
<div class="workspace-host">
{#if selectedWorkspace}
<div class="workspace-header vt-page-header">
<div class="workspace-title-group">
<span class="workspace-title vt-page-title">{workspaceTitle}</span>
</div>
<div class="workspace-search" aria-label={tr('workspace.search')}>
<GlobalSearch />
</div>
<div class="workspace-header">
<span class="workspace-title">{workspaceTitle}</span>
<span class="workspace-type">{workspaceType}</span>
</div>
{#if displayedTools.length > 0}
<div class="workspace-tabs vt-tabbar" role="tablist" aria-label={tr('workspace.tools')}>
{#each displayedTools as tool (tool.id + tool.pluginId)}
{#if workspaceTools.length > 0}
<div class="workspace-tabs" role="tablist" aria-label="Workspace tools">
{#each workspaceTools as tool (tool.id + tool.pluginId)}
<button
class="vt-tab"
class:is-active={toolKey(tool) === toolKey(activeTool)}
class:active={toolKey(tool) === toolKey(activeTool)}
role="tab"
aria-selected={toolKey(tool) === toolKey(activeTool)}
type="button"
title={tool.pluginId}
on:click={() => selectTool(tool)}
on:click={() => activeToolKey = toolKey(tool)}
>
{tool.title || tool.id}
</button>
{/each}
</div>
<div class="workspace-tool-content" role="tabpanel" aria-label={activeTool?.title || activeTool?.id || tr('workspace.tool')}>
<div class="workspace-tool-content" role="tabpanel" aria-label={activeTool?.title || activeTool?.id || 'Workspace tool'}>
{#if activeTool}
{#if activeTool.shell}
<TodaySurface
{workspaceRootPath}
availableTools={displayedTools}
on:openTool={openWorkspaceTool}
/>
{:else}
<PluginBundleHost
pluginId={activeTool.pluginId}
componentId={activeTool.component}
componentProps={{ workspaceName: selectedWorkspaceName, workspaceNodeId: selectedWorkspaceName, workspaceNode: selectedWorkspace, workspaceRootPath, workspaceId, toolRequest: activeToolRequest }}
/>
{/if}
<PluginBundleHost
pluginId={activeTool.pluginId}
componentId={activeTool.component}
componentProps={{ workspaceName: selectedWorkspaceName, workspaceNodeId: selectedWorkspaceName, workspaceNode: selectedWorkspace, workspaceRootPath }}
/>
{/if}
</div>
{:else}
<div class="workspace-empty vt-empty-state">
<p class="vt-empty-title">{tr('workspace.emptyTools')}</p>
<p class="workspace-hint">{tr('workspace.emptyToolsHint')}</p>
<div class="workspace-empty">
<p>No workspace tools available</p>
<p class="workspace-hint">Install plugins that contribute workspaceItems to see tools here.</p>
</div>
{/if}
{:else}
<div class="workspace-empty vt-empty-state">
<p class="vt-empty-title">{tr('workspace.select')}</p>
<p class="workspace-hint">{tr('workspace.selectHint')}</p>
<div class="workspace-empty">
<p>Select a workspace node from the sidebar</p>
</div>
{/if}
</div>
@ -266,101 +95,64 @@
display: flex;
flex-direction: column;
height: 100%;
background: var(--vt-color-background);
background: #1a1a2e;
}
.workspace-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: var(--vt-space-2) var(--vt-space-4);
border-bottom: 1px solid var(--vt-color-border);
gap: 0.75rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid #16213e;
flex-shrink: 0;
}
.workspace-title-group {
min-width: 0;
display: flex;
align-items: center;
gap: 0.75rem;
}
.workspace-title {
color: var(--vt-color-text-primary);
color: #e0e0f0;
font-size: 0.95rem;
font-weight: 600;
}
.workspace-search {
width: min(27rem, 46vw);
min-width: 16rem;
flex-shrink: 1;
}
.workspace-search :global(.global-search) {
padding: 0;
border-bottom: 0;
}
.workspace-search :global(.global-search-box) {
background: #0f1424;
border-color: var(--vt-color-border-strong);
}
.workspace-search :global(.global-search-results) {
left: 0;
right: 0;
top: calc(100% + 0.35rem);
}
@media (max-width: 720px) {
.workspace-header {
align-items: stretch;
flex-direction: column;
}
.workspace-search {
width: 100%;
min-width: 0;
}
.workspace-type {
color: #4ecca3;
font-size: 0.75rem;
padding: 0.1rem 0.4rem;
border-radius: 3px;
background: #1a2a3a;
}
.workspace-tabs {
display: flex;
align-items: center;
gap: 0.25rem;
padding: var(--vt-space-1) var(--vt-space-3) 0;
background: #12162a;
border-bottom: 1px solid var(--vt-color-border);
padding: 0.35rem 0.75rem 0;
background: #12122a;
border-bottom: 1px solid #16213e;
flex-shrink: 0;
overflow-x: auto;
scrollbar-gutter: auto;
}
.workspace-tabs button {
flex-shrink: 0;
min-height: 2rem;
padding: 0.35rem 0.8rem;
border: 1px solid transparent;
border-bottom: none;
border-radius: var(--vt-radius-md) var(--vt-radius-md) 0 0;
border-radius: 6px 6px 0 0;
background: transparent;
color: var(--vt-color-text-muted);
color: #8b8ba8;
cursor: pointer;
font: inherit;
font-size: 0.8rem;
}
.workspace-tabs button:hover {
color: var(--vt-color-text-primary);
background: var(--vt-color-surface-hover);
color: #e0e0f0;
background: rgba(15, 52, 96, 0.4);
}
.workspace-tabs button.is-active {
color: var(--vt-color-accent);
background: var(--vt-color-background);
border-color: var(--vt-color-border);
.workspace-tabs button.active {
color: #4ecca3;
background: #1a1a2e;
border-color: #16213e;
}
.workspace-tool-content {
@ -375,15 +167,13 @@
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--vt-color-text-muted);
color: #666;
gap: 0.5rem;
padding: 2rem;
text-align: center;
}
.workspace-hint {
font-size: 0.8rem;
color: var(--vt-color-text-muted);
color: #555;
max-width: 300px;
text-align: center;
}

View File

@ -5,10 +5,9 @@
</script>
<script>
import { onDestroy, onMount } from 'svelte';
import { onMount } from 'svelte';
import * as App from '../../../wailsjs/go/api/App';
import Icon from '../ui/Icon.svelte';
import { i18n } from '../i18n/index.js';
let loading = true;
let localError = '';
@ -16,156 +15,17 @@
let currentWorkspaceId = '';
let showCreate = false;
let newWorkspaceName = '';
let workspaceTemplates = [];
let templatePluginNames = {};
let templatePlugins = {};
let templateCapabilities = new Set();
let selectedTemplateId = 'default';
let createError = '';
let templateWarning = null;
let templatesLoading = false;
let creating = false;
let renamingId = '';
let renameValue = '';
let busyId = '';
let locale = i18n.getLocale();
let unsubscribeLocale = null;
$: tr = ((activeLocale) => (key, params, fallback) => {
void activeLocale;
return i18n.t(key, params, fallback);
})(locale);
function reportError(key, fallback, details) {
console.warn('[WorkspaceTree] operation failed:', details);
return tr(key, undefined, fallback);
}
onMount(() => {
unsubscribeLocale = i18n.subscribe((nextLocale) => {
const changed = locale !== nextLocale;
locale = nextLocale;
if (changed) loadWorkspaceTemplates();
});
loadWorkspaces();
loadWorkspaceTemplates();
window.addEventListener('verstak:workspace-active-changed', onActiveWorkspaceChanged);
});
onDestroy(() => {
if (unsubscribeLocale) unsubscribeLocale();
window.removeEventListener('verstak:workspace-active-changed', onActiveWorkspaceChanged);
});
function onActiveWorkspaceChanged(event) {
currentWorkspaceId = event.detail?.workspaceName || '';
activeWorkspaceId.set(currentWorkspaceId);
}
onMount(loadWorkspaces);
function resultOrError(response, fallbackValue) {
if (Array.isArray(response) && typeof response[1] === 'string') {
return [response[0] || fallbackValue, response[1] || ''];
}
return typeof response === 'string' ? [fallbackValue, response] : [response, ''];
}
$: selectedTemplate = workspaceTemplates.find(template => template.id === selectedTemplateId) || workspaceTemplates[0] || null;
$: selectedTemplateTools = (selectedTemplate?.workspaceTools || []).map((pluginId) => (
templateToolState(pluginId, templatePlugins, templateCapabilities, templatePluginNames, tr)
));
$: selectedTemplateIssues = selectedTemplateTools.filter(tool => tool.status !== 'available');
function toolLabel(pluginId, names = templatePluginNames) {
return names[pluginId] || String(pluginId || '').replace(/^verstak\./, '');
}
function templateToolState(pluginId, plugins, capabilities, names, translate) {
const plugin = plugins[pluginId];
if (!plugin) {
return {
pluginId,
name: toolLabel(pluginId, names),
tabs: [],
status: 'unavailable',
reason: translate('workspaceTree.templateMissingPlugin'),
};
}
const manifest = plugin.manifest || {};
const tabs = Array.isArray(manifest.contributes?.workspaceItems)
? manifest.contributes.workspaceItems.map(item => item?.title || item?.id).filter(Boolean)
: [];
const pluginStatus = String(plugin.status || '').toLowerCase();
const missingCapability = Array.isArray(manifest.requires)
&& manifest.requires.some(capabilityId => !capabilities.has(capabilityId));
let status = 'available';
let reason = translate('workspaceTree.templateAvailable');
if (!plugin.enabled || pluginStatus === 'disabled') {
status = 'unavailable';
reason = translate('workspaceTree.templatePluginDisabled');
} else if (pluginStatus === 'missing-required-capability' || missingCapability) {
status = 'unavailable';
reason = translate('workspaceTree.templateCapabilityUnavailable');
} else if (pluginStatus === 'incompatible') {
status = 'unavailable';
reason = translate('workspaceTree.templateIncompatible');
} else if (pluginStatus === 'failed') {
status = 'unavailable';
reason = translate('workspaceTree.templateLoadFailed');
} else if (pluginStatus === 'degraded') {
status = 'limited';
reason = translate('workspaceTree.templateLimited');
} else if (pluginStatus !== 'loaded') {
status = 'unavailable';
reason = translate('workspaceTree.templateNotReady');
}
return { pluginId, name: manifest.name || toolLabel(pluginId, names), tabs, status, reason };
}
async function loadWorkspaceTemplates() {
templatesLoading = true;
try {
const [templates, plugins, capabilities] = await Promise.all([
App.ListWorkspaceTemplates ? App.ListWorkspaceTemplates() : [],
App.GetPlugins ? App.GetPlugins() : [],
App.GetCapabilities ? App.GetCapabilities() : [],
]);
const [list, err] = resultOrError(templates, []);
if (err) {
createError = reportError('workspaceTree.templatesError', 'Could not load Deal templates. Please try again.', err);
workspaceTemplates = [];
return;
}
workspaceTemplates = Array.isArray(list) ? list : [];
await Promise.all((Array.isArray(plugins) ? plugins : []).map((plugin) => (
i18n.loadPlugin(plugin.manifest?.id, plugin.manifest?.localization).catch(() => {})
)));
const localizedPlugins = (Array.isArray(plugins) ? plugins : []).map((plugin) => i18n.localizePlugin(plugin));
templatePluginNames = localizedPlugins.reduce((names, plugin) => {
const id = plugin?.manifest?.id;
const name = plugin?.manifest?.name;
if (id && name) names[id] = name;
return names;
}, {});
templatePlugins = localizedPlugins.reduce((result, plugin) => {
const id = plugin?.manifest?.id;
if (id) result[id] = plugin;
return result;
}, {});
const [capabilityList] = resultOrError(capabilities, []);
templateCapabilities = new Set((Array.isArray(capabilityList) ? capabilityList : []).map(capability => capability?.name).filter(Boolean));
if (!workspaceTemplates.some(template => template.id === selectedTemplateId)) {
selectedTemplateId = workspaceTemplates[0]?.id || '';
}
} catch (error) {
createError = reportError('workspaceTree.templatesError', 'Could not load Deal templates. Please try again.', error);
workspaceTemplates = [];
} finally {
templatesLoading = false;
}
}
function wsName(workspace) {
return String(workspace?.name || workspace?.rootPath || '');
}
@ -193,28 +53,17 @@
try {
const [list, err] = resultOrError(await App.ListWorkspaces(), []);
if (err) {
localError = reportError('workspaceTree.loadError', 'Could not load Deals. Please try again.', err);
localError = err;
workspaces = [];
} else {
workspaces = list || [];
if (!currentWorkspaceId) {
let currentWorkspace = null;
try {
currentWorkspace = await App.GetCurrentWorkspace();
} catch {
currentWorkspace = null;
}
const currentName = wsName(currentWorkspace);
if (workspaces.some((ws) => wsName(ws) === currentName)) {
currentWorkspaceId = currentName;
}
} else if (!workspaces.some((ws) => wsName(ws) === currentWorkspaceId)) {
currentWorkspaceId = '';
if (!currentWorkspaceId || !workspaces.some((ws) => wsName(ws) === currentWorkspaceId)) {
currentWorkspaceId = wsName(workspaces[0] || {});
}
activeWorkspaceId.set(currentWorkspaceId);
}
} catch (e) {
localError = reportError('workspaceTree.loadError', 'Could not load Deals. Please try again.', e);
localError = String(e);
}
loading = false;
}
@ -223,7 +72,7 @@
const id = wsName(workspace);
const err = await App.SetCurrentWorkspace(id);
if (err) {
localError = reportError('workspaceTree.selectError', 'Could not select this Deal. Please try again.', err);
localError = err;
return;
}
currentWorkspaceId = id;
@ -235,24 +84,12 @@
async function doCreate() {
const name = newWorkspaceName.trim();
if (!name) {
createError = tr('workspaceTree.nameRequired');
return;
}
if (!selectedTemplate) {
createError = tr('workspaceTree.chooseTemplate');
return;
}
const creationIssues = selectedTemplateIssues.map(tool => ({
pluginId: tool.pluginId,
name: tool.name,
reason: tool.reason,
}));
if (!name) return;
creating = true;
createError = '';
const [, err] = resultOrError(await App.CreateWorkspace(name, selectedTemplate.id), null);
localError = '';
const [, err] = resultOrError(await App.CreateWorkspace(name, 'default'), null);
if (err) {
createError = reportError('workspaceTree.createError', 'Could not create the Deal. Please try again.', err);
localError = err;
creating = false;
return;
}
@ -262,25 +99,6 @@
await loadWorkspaces();
const created = workspaces.find((ws) => wsName(ws) === name);
if (created) await selectWorkspace(created);
templateWarning = creationIssues.length > 0 ? { workspaceName: name, issues: creationIssues } : null;
}
async function openCreateDialog() {
showCreate = true;
newWorkspaceName = '';
createError = '';
await loadWorkspaceTemplates();
}
function closeCreateDialog() {
if (creating) return;
showCreate = false;
newWorkspaceName = '';
createError = '';
}
function dismissTemplateWarning() {
templateWarning = null;
}
function startRename(workspace) {
@ -304,7 +122,7 @@
busyId = oldName;
const err = await App.RenameWorkspace(oldName, newName);
if (err) {
localError = reportError('workspaceTree.renameError', 'Could not rename the Deal. Please try again.', err);
localError = err;
busyId = '';
return;
}
@ -322,7 +140,7 @@
busyId = name;
const [, err] = resultOrError(await App.TrashWorkspace(name), null);
if (err) {
localError = reportError('workspaceTree.trashError', 'Could not move the Deal to trash. Please try again.', err);
localError = err;
busyId = '';
return;
}
@ -338,12 +156,12 @@
<div class="wt">
<div class="wt-header">
<span class="wt-title">{tr('workspaceTree.title')}</span>
<button class="wt-btn" on:click={openCreateDialog} title={tr('workspaceTree.new')} type="button">+</button>
<span class="wt-title">Workspaces</span>
<button class="wt-btn" on:click={() => { showCreate = true; newWorkspaceName = ''; }} title="New workspace" type="button">+</button>
</div>
{#if loading}
<div class="wt-loading">{tr('common.loading')}</div>
<div class="wt-loading">Loading...</div>
{:else if localError}
<div class="wt-error">{localError}</div>
{/if}
@ -351,7 +169,7 @@
<div class="wt-list">
{#each workspaces as workspace (wsName(workspace))}
{@const id = wsName(workspace)}
<div class="wt-node vt-list-row" class:selected={id === $activeWorkspaceId}>
<div class="wt-node" class:selected={id === $activeWorkspaceId}>
<div class="wt-row">
<span class="wt-icon"><Icon name="space" size={13} class="wt-node-icon" /></span>
{#if renamingId === id}
@ -364,14 +182,14 @@
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={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={() => commitRename(workspace)} title="Save rename" type="button" disabled={busyId === id}>OK</button>
<button class="wt-btn wt-btn-small wt-always" on:click={cancelRename} title="Cancel rename" type="button" disabled={busyId === id}>x</button>
{:else}
<button class="wt-label" on:click={() => selectWorkspace(workspace)} type="button">{id}</button>
<button class="wt-icon-btn" on:click={() => startRename(workspace)} title={tr('workspaceTree.rename')} type="button" disabled={busyId === id}>
<button class="wt-icon-btn" on:click={() => startRename(workspace)} title="Rename workspace" type="button" disabled={busyId === id}>
<Icon name="edit" size={12} />
</button>
<button class="wt-icon-btn danger" on:click={() => trashWorkspace(workspace)} title={tr('workspaceTree.trash')} type="button" disabled={busyId === id}>
<button class="wt-icon-btn danger" on:click={() => trashWorkspace(workspace)} title="Trash workspace" type="button" disabled={busyId === id}>
<Icon name="trash" size={12} />
</button>
{/if}
@ -380,69 +198,16 @@
{/each}
</div>
{#if templateWarning}
<section class="workspace-template-warning" data-workspace-template-warning role="alert">
<div>
<strong>{tr('workspaceTree.templateIncompleteCreated', { name: templateWarning.workspaceName })}</strong>
{#each templateWarning.issues as issue (issue.pluginId)}
<div class="workspace-template-warning-item">{issue.name}: {issue.reason}</div>
{/each}
</div>
<button class="wt-btn" type="button" on:click={dismissTemplateWarning} title={tr('common.close')}>{tr('common.close')}</button>
</section>
{/if}
{#if showCreate}
<div class="workspace-create-overlay" data-workspace-create-modal role="dialog" aria-modal="true" aria-label={tr('workspaceTree.create')}>
<div class="workspace-create-modal">
<div class="workspace-create-header">
<div>
<h2>{tr('workspaceTree.new')}</h2>
</div>
<button class="wt-btn" on:click={closeCreateDialog} type="button" disabled={creating}>{tr('common.close')}</button>
</div>
<label class="workspace-create-field">
<span>{tr('pluginCard.name')}</span>
<input data-workspace-name type="text" bind:value={newWorkspaceName} placeholder={tr('workspaceTree.namePlaceholder')} disabled={creating} on:keydown={(event) => event.key === 'Enter' && doCreate()} />
</label>
<label class="workspace-create-field">
<span>{tr('workspaceTree.template')}</span>
<select data-workspace-template bind:value={selectedTemplateId} disabled={creating || templatesLoading || !workspaceTemplates.length}>
{#each workspaceTemplates as template (template.id)}
<option value={template.id}>{template.name}</option>
{/each}
</select>
</label>
{#if selectedTemplate}
<div class="workspace-template-summary">
<p data-workspace-template-description>{selectedTemplate.description}</p>
<div class="workspace-template-tools" data-workspace-template-tools>
{#each selectedTemplateTools as tool (tool.pluginId)}
<div
class="workspace-template-tool"
class:limited={tool.status === 'limited'}
class:unavailable={tool.status === 'unavailable'}
data-workspace-template-tool={tool.pluginId}
data-template-tool-status={tool.status}
>
<span class="workspace-template-tool-name">{tool.name}</span>
<span class="workspace-template-tool-tabs">{tool.tabs.length ? tr('workspaceTree.templateToolTabs', { tabs: tool.tabs.join(', ') }) : tr('workspaceTree.templateToolNoTabs')}</span>
<span class="workspace-template-tool-reason">{tool.reason}</span>
</div>
{/each}
</div>
{#if selectedTemplateIssues.length > 0}
<p class="workspace-template-incomplete" data-workspace-template-incomplete>{tr('workspaceTree.templateWillBeIncomplete')}</p>
{/if}
</div>
{/if}
{#if createError}
<p class="workspace-create-error" data-workspace-create-error role="alert">{createError}</p>
{/if}
<div class="workspace-create-actions">
<button class="wt-btn-primary" on:click={doCreate} type="button" disabled={creating || templatesLoading || !selectedTemplate}>{creating ? tr('workspaceTree.creating') : tr('workspaceTree.create')}</button>
<button class="wt-btn" on:click={closeCreateDialog} type="button" disabled={creating}>{tr('common.cancel')}</button>
</div>
<div class="wt-create">
<div class="wt-create-header">
<span>New workspace</span>
<button class="wt-btn" on:click={() => { showCreate = false; newWorkspaceName = ''; }} type="button">x</button>
</div>
<input type="text" bind:value={newWorkspaceName} placeholder="Name..." disabled={creating} />
<div class="wt-create-actions">
<button class="wt-btn-primary" on:click={doCreate} type="button" disabled={creating || !newWorkspaceName.trim()}>{creating ? '...' : 'Create'}</button>
<button class="wt-btn" on:click={() => { showCreate = false; newWorkspaceName = ''; }} type="button" disabled={creating}>Cancel</button>
</div>
</div>
{/if}
@ -450,57 +215,35 @@
<style>
.wt { display: flex; flex-direction: column; flex: 1; overflow: hidden; position: relative; }
.wt-header { display: flex; align-items: center; justify-content: space-between; padding: 0.7rem 0.6rem 0.35rem; border-bottom: 1px solid var(--vt-color-border); flex-shrink: 0; }
.wt-title { color: var(--vt-color-text-muted); font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600; }
.wt-list { min-height: 0; overflow-y: auto; padding: 0.2rem 0.6rem; }
.wt-btn { min-height: 1.55rem; background: transparent; border: 1px solid transparent; color: var(--vt-color-text-muted); cursor: pointer; font-size: 0.78rem; padding: 0.12rem 0.38rem; border-radius: var(--vt-radius-sm); }
.wt-btn:hover:not(:disabled) { color: var(--vt-color-accent); background: var(--vt-color-accent-muted); border-color: rgba(78,204,163,0.25); }
.wt-btn-small { font-size: 0.7rem; opacity: 0; }
.wt-header { display: flex; align-items: center; justify-content: space-between; padding: 0.4rem 0.6rem; border-bottom: 1px solid #0f3460; flex-shrink: 0; }
.wt-title { color: #a0a0b8; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600; }
.wt-list { min-height: 0; overflow-y: auto; padding: 0.2rem 0; }
.wt-btn { min-height: 0; background: none; border: none; color: #666; cursor: pointer; font-size: 0.85rem; padding: 0.1rem 0.3rem; border-radius: 3px; }
.wt-btn:hover:not(:disabled) { color: #4ecca3; background: rgba(78,204,163,0.1); }
.wt-btn-small { font-size: 0.68rem; opacity: 0; }
.wt-always { opacity: 1; }
.wt-row:hover .wt-btn-small { opacity: 1; }
.wt-loading, .wt-error { padding: 0.5rem; font-size: 0.75rem; color: var(--vt-color-text-muted); }
.wt-error { color: var(--vt-color-danger); }
.wt-row { display: flex; align-items: center; gap: 0.45rem; padding: 0.18rem 0.45rem; min-height: 1.85rem; border-radius: var(--vt-radius-sm); }
.wt-row:hover { background: var(--vt-color-surface-hover); }
.wt-node.selected > .wt-row { background: var(--vt-color-surface-selected); box-shadow: inset 2px 0 0 var(--vt-color-accent); }
.wt-icon { width: 0.95rem; height: 0.95rem; display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; color: var(--vt-color-text-muted); }
.wt-loading, .wt-error { padding: 0.5rem; font-size: 0.75rem; color: #666; }
.wt-error { color: #e94560; }
.wt-row { display: flex; align-items: center; gap: 0.25rem; padding: 0.15rem 0.45rem; min-height: 1.7rem; }
.wt-row:hover { background: rgba(15,52,96,0.4); }
.wt-node.selected > .wt-row { background: rgba(78,204,163,0.1); }
.wt-icon { width: 0.9rem; height: 0.9rem; display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; color: #a0a0b8; }
:global(.wt-node-icon) { display: block; }
.wt-label { flex: 1; min-width: 0; min-height: 0; justify-content: flex-start; background: none; border: none; color: var(--vt-color-text-secondary); font-size: 0.78rem; text-align: left; cursor: pointer; padding: 0.1rem 0; border-radius: var(--vt-radius-sm); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wt-label:hover { color: var(--vt-color-accent); }
.wt-icon-btn { width: 1.45rem; height: 1.45rem; min-height: 0; padding: 0; border: 1px solid transparent; background: transparent; color: var(--vt-color-text-muted); opacity: 0.75; flex-shrink: 0; cursor: pointer; border-radius: var(--vt-radius-sm); }
.wt-label { flex: 1; min-width: 0; min-height: 0; justify-content: flex-start; background: none; border: none; color: #e0e0f0; font-size: 0.78rem; text-align: left; cursor: pointer; padding: 0.1rem 0.2rem; border-radius: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wt-label:hover { color: #4ecca3; }
.wt-icon-btn { width: 1.25rem; height: 1.25rem; min-height: 0; padding: 0; border: none; background: transparent; color: #666; opacity: 0; flex-shrink: 0; cursor: pointer; border-radius: 3px; }
.wt-row:hover .wt-icon-btn { opacity: 1; }
.wt-icon-btn:hover:not(:disabled) { color: var(--vt-color-accent); background: var(--vt-color-accent-muted); border-color: rgba(78,204,163,0.25); }
.wt-icon-btn.danger:hover:not(:disabled) { color: var(--vt-color-danger); background: var(--vt-color-danger-muted); border-color: rgba(233,69,96,0.35); }
.wt-rename { flex: 1; min-width: 0; background: #0f1424; border: 1px solid var(--vt-color-border-strong); color: var(--vt-color-text-primary); padding: 0.2rem 0.35rem; border-radius: var(--vt-radius-sm); font-size: 0.78rem; }
.wt-rename:focus { outline: none; border-color: var(--vt-color-accent); box-shadow: var(--vt-focus-ring); }
.workspace-create-overlay { position: fixed; inset: 0; z-index: 10000; display: flex; align-items: center; justify-content: center; padding: 1rem; background: rgba(4, 8, 18, 0.7); }
.workspace-create-modal { width: min(34rem, 100%); display: grid; gap: 0.85rem; padding: 1rem; border: 1px solid var(--vt-color-border-strong); border-radius: var(--vt-radius-lg); background: var(--vt-color-surface); box-shadow: 0 18px 44px rgba(0, 0, 0, 0.38); }
.workspace-create-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; }
.workspace-create-header h2 { margin: 0; font-size: 1rem; }
.workspace-create-field { display: grid; gap: 0.35rem; color: var(--vt-color-text-muted); font-size: 0.75rem; }
.workspace-create-field input, .workspace-create-field select { width: 100%; min-height: 2rem; box-sizing: border-box; border: 1px solid var(--vt-color-border-strong); border-radius: var(--vt-radius-sm); background: #0f1424; color: var(--vt-color-text-primary); padding: 0.35rem 0.5rem; font: inherit; font-size: 0.84rem; }
.workspace-create-field select { appearance: none; background-color: #0f1424; background-image: linear-gradient(45deg, transparent 50%, var(--vt-color-text-muted) 50%), linear-gradient(135deg, var(--vt-color-text-muted) 50%, transparent 50%); background-position: calc(100% - 14px) 50%, calc(100% - 9px) 50%; background-size: 5px 5px, 5px 5px; background-repeat: no-repeat; padding-right: 1.7rem; }
.workspace-create-field select option { background: #0f1424; color: var(--vt-color-text-primary); }
.workspace-create-field input:focus, .workspace-create-field select:focus { outline: none; border-color: var(--vt-color-accent); box-shadow: var(--vt-focus-ring); }
.workspace-template-summary { display: grid; gap: 0.55rem; padding: 0.75rem; border: 1px solid var(--vt-color-border); border-radius: var(--vt-radius-md); background: var(--vt-color-surface-muted); }
.workspace-template-summary p { margin: 0; color: var(--vt-color-text-secondary); font-size: 0.8rem; line-height: 1.45; }
.workspace-template-tools { display: grid; gap: 0.4rem; }
.workspace-template-tool { display: grid; grid-template-columns: minmax(7rem, 1fr) minmax(0, 1.4fr) minmax(0, 1.2fr); gap: 0.5rem; align-items: center; padding: 0.45rem 0.55rem; border: 1px solid var(--vt-color-border-strong); border-radius: var(--vt-radius-sm); color: var(--vt-color-text-secondary); font-size: 0.72rem; }
.workspace-template-tool-name { color: var(--vt-color-text-primary); font-weight: 600; }
.workspace-template-tool-tabs { color: var(--vt-color-text-muted); }
.workspace-template-tool-reason { color: var(--vt-color-accent); }
.workspace-template-tool.limited { border-color: rgba(220, 164, 57, 0.58); background: rgba(220, 164, 57, 0.08); }
.workspace-template-tool.limited .workspace-template-tool-reason { color: #e8bc63; }
.workspace-template-tool.unavailable { border-color: rgba(233, 69, 96, 0.58); background: var(--vt-color-danger-muted); }
.workspace-template-tool.unavailable .workspace-template-tool-reason { color: var(--vt-color-danger); }
.workspace-template-incomplete { color: var(--vt-color-danger) !important; }
.workspace-template-warning { display: flex; gap: 0.5rem; align-items: flex-start; justify-content: space-between; margin: 0.25rem 0.6rem 0.5rem; padding: 0.6rem; border: 1px solid rgba(233, 69, 96, 0.58); border-radius: var(--vt-radius-md); background: var(--vt-color-danger-muted); color: var(--vt-color-text-secondary); font-size: 0.73rem; line-height: 1.4; }
.workspace-template-warning strong { color: var(--vt-color-danger); }
.workspace-template-warning-item { margin-top: 0.2rem; }
@media (max-width: 700px) { .workspace-template-tool { grid-template-columns: 1fr; gap: 0.18rem; } }
.workspace-create-error { margin: 0; color: var(--vt-color-danger); font-size: 0.78rem; line-height: 1.4; }
.workspace-create-actions { display: flex; gap: 0.4rem; justify-content: flex-end; }
.wt-btn-primary { background: var(--vt-color-accent); color: #101827; border: none; padding: 0.3rem 0.6rem; border-radius: var(--vt-radius-sm); cursor: pointer; font-size: 0.75rem; font-weight: 600; }
.wt-icon-btn:hover:not(:disabled) { color: #4ecca3; background: rgba(78,204,163,0.1); }
.wt-icon-btn.danger:hover:not(:disabled) { color: #e94560; background: rgba(233,69,96,0.12); }
.wt-rename { flex: 1; min-width: 0; background: #0f3460; border: 1px solid #1a3a5c; color: #e0e0f0; padding: 0.2rem 0.35rem; border-radius: 4px; font-size: 0.78rem; }
.wt-rename:focus { outline: none; border-color: #4ecca3; }
.wt-create { position: absolute; bottom: 0; left: 0; right: 0; background: #16213e; border-top: 1px solid #0f3460; padding: 0.6rem; z-index: 10; }
.wt-create-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.4rem; color: #a0a0b8; font-size: 0.7rem; text-transform: uppercase; }
.wt-create input { width: 100%; background: #0f3460; border: 1px solid #1a3a5c; color: #e0e0f0; padding: 0.35rem 0.5rem; border-radius: 4px; font-size: 0.8rem; margin-bottom: 0.4rem; box-sizing: border-box; }
.wt-create input:focus { outline: none; border-color: #4ecca3; }
.wt-create-actions { display: flex; gap: 0.4rem; justify-content: flex-end; }
.wt-btn-primary { background: #4ecca3; color: #1a1a2e; border: none; padding: 0.3rem 0.6rem; border-radius: 4px; cursor: pointer; font-size: 0.75rem; font-weight: 600; }
.wt-btn-primary:hover:not(:disabled) { background: #3dbb92; }
.wt-btn-primary:disabled, .wt-btn:disabled, .wt-icon-btn:disabled { opacity: 0.5; cursor: not-allowed; }
</style>

File diff suppressed because it is too large Load Diff

View File

@ -11,7 +11,6 @@
* - NO dependency on system icon fonts
* - If name is not found, renders a default circle icon
*/
import Activity from 'lucide-svelte/icons/activity';
import Briefcase from 'lucide-svelte/icons/briefcase';
import ChevronDown from 'lucide-svelte/icons/chevron-down';
import ChevronLeft from 'lucide-svelte/icons/chevron-left';
@ -19,9 +18,7 @@
import Circle from 'lucide-svelte/icons/circle';
import FlaskConical from 'lucide-svelte/icons/flask-conical';
import Folder from 'lucide-svelte/icons/folder';
import Inbox from 'lucide-svelte/icons/inbox';
import LayoutGrid from 'lucide-svelte/icons/layout-grid';
import ListTodo from 'lucide-svelte/icons/list-todo';
import PanelsTopLeft from 'lucide-svelte/icons/panels-top-left';
import Pencil from 'lucide-svelte/icons/pencil';
import Plug from 'lucide-svelte/icons/plug';
@ -37,7 +34,6 @@
export let className = '';
const icons = {
activity: Activity,
case: Briefcase,
chevronDown: ChevronDown,
chevronLeft: ChevronLeft,
@ -47,8 +43,6 @@
flask: FlaskConical,
folder: Folder,
gear: Settings,
inbox: Inbox,
'list-todo': ListTodo,
logo: PanelsTopLeft,
plugin: Plug,
puzzle: Puzzle,
@ -63,7 +57,6 @@
'🧪': 'flask',
danger: 'warning',
settings: 'gear',
todo: 'list-todo',
};
$: iconKey = aliases[name] || name || 'dot';

View File

@ -1,33 +1,7 @@
import App from './App.svelte';
import * as Backend from '../wailsjs/go/api/App';
import { i18n } from './lib/i18n/index.js';
function unpack(result) {
if (Array.isArray(result) && result.length === 2 && (typeof result[1] === 'string' || result[1] == null)) {
if (result[1]) throw new Error(result[1]);
return result[0];
}
return result;
}
i18n.configure({
loadPluginCatalog: async (pluginId, locale) => unpack(await Backend.GetPluginLocalization(pluginId, locale)),
const app = new App({
target: document.getElementById('app'),
});
async function start() {
try {
const settings = await Backend.GetAppSettings();
await i18n.initialize(settings?.language || 'system');
} catch (error) {
console.error('[i18n] initialization failed:', error);
await i18n.initialize('system');
}
return new App({
target: document.getElementById('app'),
});
}
const app = start();
export default app;

View File

@ -1,32 +0,0 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import vm from 'node:vm';
const source = fs.readFileSync(path.resolve('frontend/src/lib/log/debug.js'), 'utf8');
const values = new Map();
const localStorage = {
getItem(key) { return values.has(key) ? values.get(key) : null; },
setItem(key, value) { values.set(key, String(value)); },
removeItem(key) { values.delete(key); },
};
const context = vm.createContext({
console: { log() {} },
localStorage,
window: { location: { search: '' } },
});
const module = new vm.SourceTextModule(source, { context, identifier: 'debug.js' });
await module.link(() => { throw new Error('debug.js must not import modules'); });
await module.evaluate();
const { debug } = module.namespace;
debug.enable({ persist: false });
assert.equal(debug.isEnabled(), true, 'session debug should enable diagnostics');
assert.equal(
localStorage.getItem('verstak-debug'),
null,
'a --debug session must not make diagnostics persist into ordinary launches',
);
console.log('debug mode smoke passed');

View File

@ -1,112 +0,0 @@
import assert from 'node:assert/strict';
import {
createI18n,
resolveLocale,
} from '../src/lib/i18n/index.js';
import englishShellCatalog from '../src/lib/i18n/catalogs/en.js';
import russianShellCatalog from '../src/lib/i18n/catalogs/ru.js';
assert.deepEqual(
Object.keys(russianShellCatalog).sort(),
Object.keys(englishShellCatalog).sort(),
'English and Russian shell catalogs must have identical keys',
);
assert.equal(resolveLocale('system', ['ru-RU']), 'ru');
assert.equal(resolveLocale('system', ['uk-UA', 'en-US']), 'en');
assert.equal(resolveLocale('ru', ['en-US']), 'ru');
assert.equal(resolveLocale('en', ['ru-RU']), 'en');
assert.throws(() => resolveLocale('de', ['de-DE']), /unsupported language/);
const catalogs = {
'localized.plugin': {
en: { 'manifest.name': 'Localized Plugin', greeting: 'Hello, {name}!' },
ru: {
'manifest.name': 'Локализованный плагин',
'contributions.views.localized.view.title': 'Локализованный экран',
greeting: 'Привет, {name}!',
},
},
};
const loads = [];
const service = createI18n({
shellCatalogs: {
en: { loading: 'Loading {name}...', fallbackOnly: 'English fallback' },
ru: { loading: 'Загрузка {name}...' },
},
systemLanguages: () => ['ru-RU'],
loadPluginCatalog: async (pluginId, locale) => {
loads.push(`${pluginId}:${locale}`);
return catalogs[pluginId]?.[locale] || {};
},
});
await service.initialize('system');
assert.equal(service.getLanguagePreference(), 'system');
assert.equal(service.getLocale(), 'ru');
assert.equal(service.t('loading', { name: 'Верстак' }), 'Загрузка Верстак...');
assert.equal(service.t('fallbackOnly'), 'English fallback');
assert.equal(service.t('missing', undefined, 'Explicit fallback'), 'Explicit fallback');
assert.equal(service.t('unknown'), 'unknown');
await service.loadPlugin('localized.plugin', {
defaultLocale: 'en',
locales: { en: 'locales/en.json', ru: 'locales/ru.json' },
});
assert.deepEqual(loads.sort(), ['localized.plugin:en', 'localized.plugin:ru']);
assert.equal(service.translatePlugin('localized.plugin', 'greeting', { name: 'Мир' }), 'Привет, Мир!');
const plugin = {
manifest: {
id: 'localized.plugin',
name: 'Localized Plugin',
description: 'Literal description',
contributes: {
views: [{ id: 'localized.view', title: 'Literal View', component: 'View' }],
},
},
};
const localized = service.localizePlugin(plugin);
assert.notEqual(localized, plugin);
assert.equal(localized.manifest.name, 'Локализованный плагин');
assert.equal(localized.manifest.description, 'Literal description');
assert.equal(localized.manifest.contributes.views[0].title, 'Локализованный экран');
assert.equal(plugin.manifest.name, 'Localized Plugin');
const summary = service.localizeContributionSummary({
views: [{ pluginId: 'localized.plugin', id: 'localized.view', title: 'Literal View', component: 'View' }],
});
assert.equal(summary.views[0].title, 'Локализованный экран');
let notifications = 0;
const unsubscribe = service.subscribe(() => { notifications += 1; });
assert.equal(notifications, 1);
await service.setLanguagePreference('en');
assert.equal(service.getLocale(), 'en');
assert.equal(service.translatePlugin('localized.plugin', 'greeting', { name: 'World' }), 'Hello, World!');
assert.equal(notifications, 2);
unsubscribe();
await service.setLanguagePreference('ru');
assert.equal(notifications, 2);
const resilientService = createI18n({
shellCatalogs: { en: { title: 'Title' }, ru: { title: 'Заголовок' } },
systemLanguages: () => ['en-US'],
loadPluginCatalog: async () => { throw new Error('broken catalog'); },
});
await resilientService.initialize('en');
await assert.rejects(
resilientService.loadPlugin('broken.plugin', {
defaultLocale: 'en',
locales: { en: 'locales/en.json', ru: 'locales/ru.json' },
}),
/broken catalog/,
);
let resilientNotification = '';
resilientService.subscribe((nextLocale) => { resilientNotification = nextLocale; });
await resilientService.setLanguagePreference('ru');
assert.equal(resilientService.getLanguagePreference(), 'ru');
assert.equal(resilientService.getLocale(), 'ru');
assert.equal(resilientNotification, 'ru');
assert.equal(resilientService.t('title'), 'Заголовок');
console.log('i18n service tests passed');

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,155 +0,0 @@
import fs from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
const pluginData = {};
const scheduledNotifications = [];
globalThis.window = {
__VERSTAK_PLUGIN_REGISTRY__: {},
__VERSTAK_EVENT_HANDLERS__: {},
__VERSTAK_COMMAND_HANDLERS__: {},
go: {
api: {
App: {
GetContributions: () => Promise.resolve({
fileActions: [
{
pluginId: 'provider.plugin',
id: 'provider.file.action',
label: 'Provider File Action',
handler: 'provider.command',
},
],
noteActions: [],
contextMenuEntries: [],
}),
ExecutePluginCommand: (pluginId, commandId, args) => Promise.resolve([{
status: 'declared',
pluginId,
commandId,
args,
}, '']),
ReadPluginDataJSON: (pluginId, name) => Promise.resolve([
Object.assign({}, (pluginData[pluginId] && pluginData[pluginId][name]) || {}),
'',
]),
WritePluginDataJSON: (pluginId, name, data) => {
pluginData[pluginId] = pluginData[pluginId] || {};
pluginData[pluginId][name] = Object.assign({}, data || {});
return Promise.resolve('');
},
ReplacePluginNotifications: (pluginId, items) => {
scheduledNotifications.push({ pluginId, items });
return Promise.resolve(items[0]?.id === 'rejected' ? 'notification permission denied' : '');
},
ClearPluginNotifications: (pluginId) => {
scheduledNotifications.push({ pluginId, clear: true });
return Promise.resolve('');
},
},
},
},
};
globalThis.__mockApp = window.go.api.App;
const localeListeners = new Set();
globalThis.__mockI18n = {
getLocale: () => 'ru',
translatePlugin: (_pluginId, key, params, fallback) => {
const messages = { greeting: 'Привет, {name}!' };
const message = messages[key] || fallback || key;
return message.replace(/\{([^}]+)\}/g, (placeholder, name) => (
Object.prototype.hasOwnProperty.call(params || {}, name) ? String(params[name]) : placeholder
));
},
subscribe: (listener) => {
localeListeners.add(listener);
listener('ru');
return () => localeListeners.delete(listener);
},
};
const sourcePath = path.resolve('frontend/src/lib/plugin-host/VerstakPluginAPI.js');
const source = fs.readFileSync(sourcePath, 'utf8')
.replace("import * as App from '../../../wailsjs/go/api/App';", 'const App = globalThis.__mockApp;')
.replace("import { i18n } from '../i18n/index.js';", 'const i18n = globalThis.__mockI18n;');
const tempPath = path.resolve('/tmp/verstak-plugin-api-contributions-test.mjs');
fs.writeFileSync(tempPath, source);
const apiModule = await import(pathToFileURL(tempPath).href + '?t=' + Date.now());
const api = apiModule.createPluginAPI('verstak.files');
if (!api.contributions || typeof api.contributions.list !== 'function') {
throw new Error('api.contributions.list is missing');
}
if (!api.commands || typeof api.commands.executeFor !== 'function') {
throw new Error('api.commands.executeFor is missing');
}
if (!api.i18n || typeof api.i18n.getLocale !== 'function' || typeof api.i18n.t !== 'function' || typeof api.i18n.onDidChangeLocale !== 'function') {
throw new Error('api.i18n contract is missing');
}
if (api.i18n.getLocale() !== 'ru' || api.i18n.t('greeting', { name: 'Мир' }) !== 'Привет, Мир!') {
throw new Error('api.i18n locale or translation is incorrect');
}
let localeNotifications = 0;
api.i18n.onDidChangeLocale(() => { localeNotifications += 1; });
if (localeNotifications !== 1 || localeListeners.size !== 1) {
throw new Error('api.i18n locale subscription was not registered');
}
const fileActions = await api.contributions.list('fileActions');
if (fileActions.length !== 1 || fileActions[0].id !== 'provider.file.action') {
throw new Error(`unexpected file actions: ${JSON.stringify(fileActions)}`);
}
window.__VERSTAK_COMMAND_HANDLERS__['provider.plugin:provider.command'] = (args, declared) => ({
handledPath: args.path,
declaredPlugin: declared.pluginId,
});
const result = await api.commands.executeFor('provider.plugin', 'provider.command', {
source: 'files',
path: 'Project/Docs/readme.md',
});
if (result.status !== 'handled' || result.result.handledPath !== 'Project/Docs/readme.md') {
throw new Error(`unexpected executeFor result: ${JSON.stringify(result)}`);
}
if (!api.storage || !api.storage.data || typeof api.storage.data.read !== 'function' || typeof api.storage.data.write !== 'function') {
throw new Error('api.storage.data read/write is missing');
}
await api.storage.data.write('search-index', { version: 1, workspaceRootPath: 'Project' });
const stored = await api.storage.data.read('search-index');
if (stored.version !== 1 || stored.workspaceRootPath !== 'Project') {
throw new Error(`unexpected storage data: ${JSON.stringify(stored)}`);
}
if (!api.notifications || typeof api.notifications.replace !== 'function' || typeof api.notifications.clear !== 'function') {
throw new Error('api.notifications replace/clear contract is missing');
}
await api.notifications.replace([{ id: 'reminder-1', dueAt: '2026-07-14T10:00:00Z', title: 'Reminder' }]);
await api.notifications.clear();
if (scheduledNotifications.length !== 2
|| scheduledNotifications[0].pluginId !== 'verstak.files'
|| scheduledNotifications[0].items[0].id !== 'reminder-1'
|| scheduledNotifications[1].pluginId !== 'verstak.files'
|| !scheduledNotifications[1].clear) {
throw new Error(`unexpected notification calls: ${JSON.stringify(scheduledNotifications)}`);
}
let rejected = false;
try {
await api.notifications.replace([{ id: 'rejected', dueAt: '2026-07-14T10:00:00Z', title: 'Reminder' }]);
} catch (err) {
rejected = String(err.message || err).includes('[plugin:verstak.files] notifications.replace failed: notification permission denied');
}
if (!rejected) {
throw new Error('notification backend errors must be plugin-scoped rejections');
}
api.dispose();
if (localeListeners.size !== 0) {
throw new Error('api.i18n locale subscription was not disposed');
}
console.log('plugin api contributions smoke passed');

View File

@ -1,72 +0,0 @@
import fs from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
const calls = [];
globalThis.window = {
__VERSTAK_PLUGIN_REGISTRY__: {},
__VERSTAK_EVENT_HANDLERS__: {},
__VERSTAK_COMMAND_HANDLERS__: {},
go: {
api: {
App: {
ReadVaultFileBytes: (pluginId, relativePath) => {
calls.push({ method: 'ReadVaultFileBytes', pluginId, relativePath });
return Promise.resolve([{ relativePath, size: 4, mimeHint: 'image/png', dataBase64: 'iVBORw==' }, '']);
},
RestoreVaultTrash: (pluginId, trashId, options) => {
calls.push({ method: 'RestoreVaultTrash', pluginId, trashId, options });
return Promise.resolve(['Docs/restored.txt', '']);
},
WriteVaultFileBytes: (pluginId, relativePath, dataBase64, options) => {
calls.push({ method: 'WriteVaultFileBytes', pluginId, relativePath, dataBase64, options });
return Promise.resolve('');
},
},
},
},
};
globalThis.__mockApp = window.go.api.App;
const sourcePath = path.resolve('frontend/src/lib/plugin-host/VerstakPluginAPI.js');
const source = fs.readFileSync(sourcePath, 'utf8')
.replace("import * as App from '../../../wailsjs/go/api/App';", 'const App = globalThis.__mockApp;');
const tempPath = path.resolve('/tmp/verstak-plugin-api-files-test.mjs');
fs.writeFileSync(tempPath, source);
const apiModule = await import(pathToFileURL(tempPath).href + '?t=' + Date.now());
const api = apiModule.createPluginAPI('verstak.files');
if (!api.files || typeof api.files.restoreTrash !== 'function') {
throw new Error('api.files.restoreTrash is missing');
}
if (typeof api.files.readBytes !== 'function') {
throw new Error('api.files.readBytes is missing');
}
if (typeof api.files.writeBytes !== 'function') {
throw new Error('api.files.writeBytes is missing');
}
const bytes = await api.files.readBytes('Docs/image.png');
if (bytes.dataBase64 !== 'iVBORw==' || bytes.mimeHint !== 'image/png') {
throw new Error(`unexpected readBytes result: ${JSON.stringify(bytes)}`);
}
await api.files.writeBytes('Docs/copy.png', 'iVBORw==', { createIfMissing: true });
const restored = await api.files.restoreTrash('trash-1', { overwrite: true });
if (restored !== 'Docs/restored.txt') {
throw new Error(`unexpected restore result: ${JSON.stringify(restored)}`);
}
if (calls.length !== 3 || calls[0].method !== 'ReadVaultFileBytes' || calls[0].pluginId !== 'verstak.files' || calls[0].relativePath !== 'Docs/image.png') {
throw new Error(`unexpected ReadVaultFileBytes call: ${JSON.stringify(calls)}`);
}
if (calls[1].method !== 'WriteVaultFileBytes' || calls[1].pluginId !== 'verstak.files' || calls[1].relativePath !== 'Docs/copy.png' || calls[1].dataBase64 !== 'iVBORw==' || calls[1].options.createIfMissing !== true) {
throw new Error(`unexpected WriteVaultFileBytes call: ${JSON.stringify(calls)}`);
}
if (calls[2].method !== 'RestoreVaultTrash' || calls[2].pluginId !== 'verstak.files' || calls[2].trashId !== 'trash-1' || calls[2].options.overwrite !== true) {
throw new Error(`unexpected RestoreVaultTrash call: ${JSON.stringify(calls)}`);
}
console.log('plugin api files smoke passed');

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,57 +0,0 @@
import fs from 'node:fs';
import path from 'node:path';
const root = path.resolve('.');
function read(relativePath) {
return fs.readFileSync(path.join(root, relativePath), 'utf8');
}
function assertIncludes(source, needle, message) {
if (!source.includes(needle)) {
throw new Error(message);
}
}
const workspaceHost = read('frontend/src/lib/shell/WorkspaceHost.svelte');
const statusBar = read('frontend/src/lib/shell/StatusBar.svelte');
const pluginManager = read('frontend/src/lib/plugin-manager/PluginManager.svelte');
const syncManifest = JSON.parse(read('../verstak-official-plugins/plugins/sync/plugin.json'));
assertIncludes(
workspaceHost,
'data-workspace-search',
'WorkspaceHost should expose a stable workspace header search slot'
);
assertIncludes(
workspaceHost,
'toolOrder',
'WorkspaceHost should define usage-based workspace tool ordering'
);
assertIncludes(
workspaceHost,
'sortWorkspaceTools',
'WorkspaceHost should sort workspace tools by expected usage'
);
assertIncludes(
statusBar,
"import PluginBundleHost from '../plugin-host/PluginBundleHost.svelte';",
'StatusBar should mount plugin-provided status bar components'
);
assertIncludes(
statusBar,
'componentId={item.handler}',
'StatusBar should use statusBarItems.handler as the component id'
);
const syncStatus = syncManifest.contributes.statusBarItems.find((item) => item.id === 'verstak.sync.status');
if (!syncStatus || syncStatus.handler !== 'SyncStatusBar') {
throw new Error('Sync statusBarItem should declare handler "SyncStatusBar"');
}
if (/lastOpenedKey\s*=\s*key;\s*openSettingsFromProps\(activeSettingsPluginId,\s*activeSettingsPanelId\)/s.test(pluginManager)) {
throw new Error('PluginManager should not mark settings panel as opened before resolving contributions');
}
console.log('shell source contract smoke passed');

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,13 +0,0 @@
import fs from 'node:fs';
import path from 'node:path';
const bindingsPath = path.resolve('frontend/wailsjs/go/api/App.js');
const bindings = fs.readFileSync(bindingsPath, 'utf8');
for (const method of ['ReplacePluginNotifications', 'ClearPluginNotifications']) {
if (!bindings.includes(`export function ${method}(`)) {
throw new Error(`Wails binding does not export ${method}`);
}
}
console.log('Wails notification bindings are present');

View File

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

View File

@ -1,19 +1,15 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import {workspace} from '../models';
import {context} from '../models';
import {workbench} from '../models';
import {capability} from '../models';
import {api} from '../models';
import {permissions} from '../models';
import {plugin} from '../models';
import {files} from '../models';
import {notifications} from '../models';
export function ArchiveWorkspaceNode(arg1:string):Promise<string>;
export function ClearPluginNotifications(arg1:string):Promise<string>;
export function CloseVault():Promise<void>;
export function CreateVault(arg1:string):Promise<void>;
@ -24,12 +20,8 @@ export function CreateWorkspace(arg1:string,arg2:string):Promise<workspace.Works
export function CreateWorkspaceNode(arg1:string,arg2:string,arg3:string):Promise<Record<string, any>>;
export function DeleteVaultTrash(arg1:string,arg2:string):Promise<string>;
export function DisablePlugin(arg1:string):Promise<string>;
export function DomReady(arg1:context.Context):Promise<void>;
export function EditWorkbenchResource(arg1:string,arg2:Record<string, any>):Promise<workbench.OpenResourceResult|string>;
export function EnablePlugin(arg1:string):Promise<string>;
@ -54,8 +46,6 @@ export function GetPluginCapability(arg1:string,arg2:string):Promise<Record<stri
export function GetPluginFrontendInfo(arg1:string):Promise<Record<string, any>>;
export function GetPluginLocalization(arg1:string,arg2:string):Promise<Record<string, string>|string>;
export function GetPlugins():Promise<Array<plugin.Plugin>>;
export function GetVaultFileMetadata(arg1:string,arg2:string):Promise<files.FileMetadata|string>;
@ -76,74 +66,24 @@ export function ListPluginCapabilities(arg1:string):Promise<Array<capability.Ent
export function ListVaultFiles(arg1:string,arg2:string):Promise<Array<files.FileEntry>|string>;
export function ListVaultTrash(arg1:string):Promise<Array<files.TrashEntry>|string>;
export function ListWorkspaceIdentities():Promise<Array<workspace.WorkspaceIdentity>|string>;
export function ListWorkspaceTemplates():Promise<Array<workspace.WorkspaceTemplate>|string>;
export function ListWorkspaces():Promise<Array<workspace.Workspace>|string>;
export function MoveVaultPath(arg1:string,arg2:string,arg3:string,arg4:files.MoveOptions):Promise<string>;
export function MoveWorkspaceNode(arg1:string,arg2:string):Promise<string>;
export function OpenExternalURL(arg1:string,arg2:string):Promise<string>;
export function OpenVault(arg1:string):Promise<void>;
export function OpenVaultPathExternal(arg1:string,arg2:string):Promise<string>;
export function OpenWorkbenchResource(arg1:string,arg2:Record<string, any>):Promise<workbench.OpenResourceResult|string>;
export function PluginBrowserReceiverPairing(arg1:string):Promise<Record<string, string>|string>;
export function PluginRotateBrowserReceiverToken(arg1:string):Promise<Record<string, string>|string>;
export function PluginSecretsCopyLink(arg1:string,arg2:string):Promise<string|string>;
export function PluginSecretsDelete(arg1:string,arg2:string):Promise<string>;
export function PluginSecretsList(arg1:string):Promise<Array<Record<string, any>>|string>;
export function PluginSecretsRead(arg1:string,arg2:string):Promise<Record<string, any>|string>;
export function PluginSecretsStatus(arg1:string):Promise<Record<string, any>|string>;
export function PluginSecretsUnlock(arg1:string,arg2:string):Promise<string>;
export function PluginSecretsWrite(arg1:string,arg2:Record<string, any>):Promise<Record<string, any>|string>;
export function PluginSyncConfigure(arg1:string,arg2:string,arg3:string,arg4:string):Promise<string>;
export function PluginSyncDisconnect(arg1:string):Promise<string>;
export function PluginSyncNow(arg1:string):Promise<Record<string, any>|string>;
export function PluginSyncResetKey(arg1:string):Promise<string>;
export function PluginSyncSetInterval(arg1:string,arg2:number):Promise<string>;
export function PluginSyncStatus(arg1:string):Promise<api.SyncStatusDTO|string>;
export function PluginSyncTestConnection(arg1:string,arg2:string,arg3:string,arg4:string):Promise<string>;
export function PublishPluginEvent(arg1:string,arg2:string,arg3:Record<string, any>):Promise<string>;
export function PurgeWorkspaceTrash(arg1:string):Promise<string>;
export function Quit():Promise<void>;
export function ReadPluginDataJSON(arg1:string,arg2:string):Promise<Record<string, any>>;
export function ReadPluginDataNDJSON(arg1:string,arg2:string):Promise<Array<Record<string, any>>>;
export function ReadPluginSetting(arg1:string,arg2:string):Promise<any>;
export function ReadPluginSettings(arg1:string):Promise<Record<string, any>|string>;
export function ReadVaultFileBytes(arg1:string,arg2:string):Promise<files.FileBytes|string>;
export function ReadVaultTextFile(arg1:string,arg2:string):Promise<string|string>;
export function RecordDesiredPlugin(arg1:string,arg2:string,arg3:string):Promise<string>;
@ -154,13 +94,7 @@ export function RenameWorkspace(arg1:string,arg2:string):Promise<string>;
export function RenameWorkspaceNode(arg1:string,arg2:string):Promise<string>;
export function RepairWorkspaceIdentity(arg1:string,arg2:string):Promise<string>;
export function ReplacePluginNotifications(arg1:string,arg2:Array<notifications.Request>):Promise<string>;
export function RestoreVaultTrash(arg1:string,arg2:string,arg3:files.RestoreOptions):Promise<string|string>;
export function RestoreWorkspaceTrash(arg1:string,arg2:string):Promise<workspace.Workspace|string>;
export function ResetSyncKey():Promise<void>;
export function SelectDirectory():Promise<string>;
@ -172,16 +106,20 @@ export function SetCurrentWorkspace(arg1:string):Promise<string>;
export function SetCurrentWorkspaceNode(arg1:string):Promise<string>;
export function SetNotificationService(arg1:api.notificationService):Promise<void>;
export function ShowVaultPathInFolder(arg1:string,arg2:string):Promise<string>;
export function ShowWindow():Promise<void>;
export function Shutdown(arg1:context.Context):Promise<void>;
export function SubscribePluginEvent(arg1:string,arg2:string):Promise<string>;
export function SyncConfigure(arg1:string,arg2:string,arg3:string):Promise<void>;
export function SyncDisconnect():Promise<void>;
export function SyncNow():Promise<Record<string, any>>;
export function SyncSetInterval(arg1:number):Promise<void>;
export function SyncStatus():Promise<api.SyncStatusDTO>;
export function SyncTestConnection(arg1:string,arg2:string,arg3:string):Promise<void>;
export function TrashVaultPath(arg1:string,arg2:string):Promise<files.TrashResult|string>;
export function TrashWorkspace(arg1:string):Promise<workspace.TrashResult|string>;
@ -196,12 +134,8 @@ export function WriteFrontendLog(arg1:string,arg2:string):Promise<void>;
export function WritePluginDataJSON(arg1:string,arg2:string,arg3:Record<string, any>):Promise<string>;
export function WritePluginDataNDJSON(arg1:string,arg2:string,arg3:Array<Record<string, any>>):Promise<string>;
export function WritePluginSetting(arg1:string,arg2:string,arg3:any):Promise<string>;
export function WritePluginSettings(arg1:string,arg2:Record<string, any>):Promise<string>;
export function WriteVaultFileBytes(arg1:string,arg2:string,arg3:string,arg4:files.WriteOptions):Promise<string>;
export function WriteVaultTextFile(arg1:string,arg2:string,arg3:string,arg4:files.WriteOptions):Promise<string>;

View File

@ -6,10 +6,6 @@ export function ArchiveWorkspaceNode(arg1) {
return window['go']['api']['App']['ArchiveWorkspaceNode'](arg1);
}
export function ClearPluginNotifications(arg1) {
return window['go']['api']['App']['ClearPluginNotifications'](arg1);
}
export function CloseVault() {
return window['go']['api']['App']['CloseVault']();
}
@ -30,18 +26,10 @@ export function CreateWorkspaceNode(arg1, arg2, arg3) {
return window['go']['api']['App']['CreateWorkspaceNode'](arg1, arg2, arg3);
}
export function DeleteVaultTrash(arg1, arg2) {
return window['go']['api']['App']['DeleteVaultTrash'](arg1, arg2);
}
export function DisablePlugin(arg1) {
return window['go']['api']['App']['DisablePlugin'](arg1);
}
export function DomReady(arg1) {
return window['go']['api']['App']['DomReady'](arg1);
}
export function EditWorkbenchResource(arg1, arg2) {
return window['go']['api']['App']['EditWorkbenchResource'](arg1, arg2);
}
@ -90,10 +78,6 @@ export function GetPluginFrontendInfo(arg1) {
return window['go']['api']['App']['GetPluginFrontendInfo'](arg1);
}
export function GetPluginLocalization(arg1, arg2) {
return window['go']['api']['App']['GetPluginLocalization'](arg1, arg2);
}
export function GetPlugins() {
return window['go']['api']['App']['GetPlugins']();
}
@ -134,18 +118,6 @@ export function ListVaultFiles(arg1, arg2) {
return window['go']['api']['App']['ListVaultFiles'](arg1, arg2);
}
export function ListVaultTrash(arg1) {
return window['go']['api']['App']['ListVaultTrash'](arg1);
}
export function ListWorkspaceIdentities() {
return window['go']['api']['App']['ListWorkspaceIdentities']();
}
export function ListWorkspaceTemplates() {
return window['go']['api']['App']['ListWorkspaceTemplates']();
}
export function ListWorkspaces() {
return window['go']['api']['App']['ListWorkspaces']();
}
@ -158,106 +130,22 @@ export function MoveWorkspaceNode(arg1, arg2) {
return window['go']['api']['App']['MoveWorkspaceNode'](arg1, arg2);
}
export function OpenExternalURL(arg1, arg2) {
return window['go']['api']['App']['OpenExternalURL'](arg1, arg2);
}
export function OpenVault(arg1) {
return window['go']['api']['App']['OpenVault'](arg1);
}
export function OpenVaultPathExternal(arg1, arg2) {
return window['go']['api']['App']['OpenVaultPathExternal'](arg1, arg2);
}
export function OpenWorkbenchResource(arg1, arg2) {
return window['go']['api']['App']['OpenWorkbenchResource'](arg1, arg2);
}
export function PluginBrowserReceiverPairing(arg1) {
return window['go']['api']['App']['PluginBrowserReceiverPairing'](arg1);
}
export function PluginRotateBrowserReceiverToken(arg1) {
return window['go']['api']['App']['PluginRotateBrowserReceiverToken'](arg1);
}
export function PluginSecretsCopyLink(arg1, arg2) {
return window['go']['api']['App']['PluginSecretsCopyLink'](arg1, arg2);
}
export function PluginSecretsDelete(arg1, arg2) {
return window['go']['api']['App']['PluginSecretsDelete'](arg1, arg2);
}
export function PluginSecretsList(arg1) {
return window['go']['api']['App']['PluginSecretsList'](arg1);
}
export function PluginSecretsRead(arg1, arg2) {
return window['go']['api']['App']['PluginSecretsRead'](arg1, arg2);
}
export function PluginSecretsStatus(arg1) {
return window['go']['api']['App']['PluginSecretsStatus'](arg1);
}
export function PluginSecretsUnlock(arg1, arg2) {
return window['go']['api']['App']['PluginSecretsUnlock'](arg1, arg2);
}
export function PluginSecretsWrite(arg1, arg2) {
return window['go']['api']['App']['PluginSecretsWrite'](arg1, arg2);
}
export function PluginSyncConfigure(arg1, arg2, arg3, arg4) {
return window['go']['api']['App']['PluginSyncConfigure'](arg1, arg2, arg3, arg4);
}
export function PluginSyncDisconnect(arg1) {
return window['go']['api']['App']['PluginSyncDisconnect'](arg1);
}
export function PluginSyncNow(arg1) {
return window['go']['api']['App']['PluginSyncNow'](arg1);
}
export function PluginSyncResetKey(arg1) {
return window['go']['api']['App']['PluginSyncResetKey'](arg1);
}
export function PluginSyncSetInterval(arg1, arg2) {
return window['go']['api']['App']['PluginSyncSetInterval'](arg1, arg2);
}
export function PluginSyncStatus(arg1) {
return window['go']['api']['App']['PluginSyncStatus'](arg1);
}
export function PluginSyncTestConnection(arg1, arg2, arg3, arg4) {
return window['go']['api']['App']['PluginSyncTestConnection'](arg1, arg2, arg3, arg4);
}
export function PublishPluginEvent(arg1, arg2, arg3) {
return window['go']['api']['App']['PublishPluginEvent'](arg1, arg2, arg3);
}
export function PurgeWorkspaceTrash(arg1) {
return window['go']['api']['App']['PurgeWorkspaceTrash'](arg1);
}
export function Quit() {
return window['go']['api']['App']['Quit']();
}
export function ReadPluginDataJSON(arg1, arg2) {
return window['go']['api']['App']['ReadPluginDataJSON'](arg1, arg2);
}
export function ReadPluginDataNDJSON(arg1, arg2) {
return window['go']['api']['App']['ReadPluginDataNDJSON'](arg1, arg2);
}
export function ReadPluginSetting(arg1, arg2) {
return window['go']['api']['App']['ReadPluginSetting'](arg1, arg2);
}
@ -266,10 +154,6 @@ export function ReadPluginSettings(arg1) {
return window['go']['api']['App']['ReadPluginSettings'](arg1);
}
export function ReadVaultFileBytes(arg1, arg2) {
return window['go']['api']['App']['ReadVaultFileBytes'](arg1, arg2);
}
export function ReadVaultTextFile(arg1, arg2) {
return window['go']['api']['App']['ReadVaultTextFile'](arg1, arg2);
}
@ -290,20 +174,8 @@ export function RenameWorkspaceNode(arg1, arg2) {
return window['go']['api']['App']['RenameWorkspaceNode'](arg1, arg2);
}
export function RepairWorkspaceIdentity(arg1, arg2) {
return window['go']['api']['App']['RepairWorkspaceIdentity'](arg1, arg2);
}
export function ReplacePluginNotifications(arg1, arg2) {
return window['go']['api']['App']['ReplacePluginNotifications'](arg1, arg2);
}
export function RestoreVaultTrash(arg1, arg2, arg3) {
return window['go']['api']['App']['RestoreVaultTrash'](arg1, arg2, arg3);
}
export function RestoreWorkspaceTrash(arg1, arg2) {
return window['go']['api']['App']['RestoreWorkspaceTrash'](arg1, arg2);
export function ResetSyncKey() {
return window['go']['api']['App']['ResetSyncKey']();
}
export function SelectDirectory() {
@ -326,26 +198,34 @@ export function SetCurrentWorkspaceNode(arg1) {
return window['go']['api']['App']['SetCurrentWorkspaceNode'](arg1);
}
export function SetNotificationService(arg1) {
return window['go']['api']['App']['SetNotificationService'](arg1);
}
export function ShowVaultPathInFolder(arg1, arg2) {
return window['go']['api']['App']['ShowVaultPathInFolder'](arg1, arg2);
}
export function ShowWindow() {
return window['go']['api']['App']['ShowWindow']();
}
export function Shutdown(arg1) {
return window['go']['api']['App']['Shutdown'](arg1);
}
export function SubscribePluginEvent(arg1, arg2) {
return window['go']['api']['App']['SubscribePluginEvent'](arg1, arg2);
}
export function SyncConfigure(arg1, arg2, arg3) {
return window['go']['api']['App']['SyncConfigure'](arg1, arg2, arg3);
}
export function SyncDisconnect() {
return window['go']['api']['App']['SyncDisconnect']();
}
export function SyncNow() {
return window['go']['api']['App']['SyncNow']();
}
export function SyncSetInterval(arg1) {
return window['go']['api']['App']['SyncSetInterval'](arg1);
}
export function SyncStatus() {
return window['go']['api']['App']['SyncStatus']();
}
export function SyncTestConnection(arg1, arg2, arg3) {
return window['go']['api']['App']['SyncTestConnection'](arg1, arg2, arg3);
}
export function TrashVaultPath(arg1, arg2) {
return window['go']['api']['App']['TrashVaultPath'](arg1, arg2);
}
@ -374,10 +254,6 @@ export function WritePluginDataJSON(arg1, arg2, arg3) {
return window['go']['api']['App']['WritePluginDataJSON'](arg1, arg2, arg3);
}
export function WritePluginDataNDJSON(arg1, arg2, arg3) {
return window['go']['api']['App']['WritePluginDataNDJSON'](arg1, arg2, arg3);
}
export function WritePluginSetting(arg1, arg2, arg3) {
return window['go']['api']['App']['WritePluginSetting'](arg1, arg2, arg3);
}
@ -386,10 +262,6 @@ export function WritePluginSettings(arg1, arg2) {
return window['go']['api']['App']['WritePluginSettings'](arg1, arg2);
}
export function WriteVaultFileBytes(arg1, arg2, arg3, arg4) {
return window['go']['api']['App']['WriteVaultFileBytes'](arg1, arg2, arg3, arg4);
}
export function WriteVaultTextFile(arg1, arg2, arg3, arg4) {
return window['go']['api']['App']['WriteVaultTextFile'](arg1, arg2, arg3, arg4);
}

File diff suppressed because it is too large Load Diff

3
go.mod
View File

@ -3,10 +3,8 @@ module github.com/verstak/verstak-desktop
go 1.24.4
require (
fyne.io/systray v1.12.2
github.com/google/uuid v1.6.0
github.com/wailsapp/wails/v2 v2.12.0
golang.org/x/net v0.35.0
)
require (
@ -34,6 +32,7 @@ require (
github.com/wailsapp/go-webview2 v1.0.22 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
)

2
go.sum
View File

@ -1,5 +1,3 @@
fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA=
fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,110 +0,0 @@
package api
import (
"os"
"strings"
"testing"
corefiles "github.com/verstak/verstak-desktop/internal/core/files"
)
func TestSyncNowAgainstRealServerTwoVaults(t *testing.T) {
serverURL := os.Getenv("VERSTAK_SYNC_SMOKE_SERVER_URL")
deviceA := os.Getenv("VERSTAK_SYNC_SMOKE_DEVICE_A")
deviceB := os.Getenv("VERSTAK_SYNC_SMOKE_DEVICE_B")
apiKeyA := os.Getenv("VERSTAK_SYNC_SMOKE_KEY_A")
apiKeyB := os.Getenv("VERSTAK_SYNC_SMOKE_KEY_B")
if serverURL == "" || deviceA == "" || deviceB == "" || apiKeyA == "" || apiKeyB == "" {
t.Skip("set VERSTAK_SYNC_SMOKE_* env vars to run the real sync-server smoke test")
}
appA, _ := newSyncFilesTestApp(t, []string{"files.read", "files.write", "files.delete"}, deviceA)
appB, _ := newSyncFilesTestApp(t, []string{"files.read", "files.write", "files.delete"}, deviceB)
if err := appA.syncSvc.SetState(serverURL, apiKeyA); err != nil {
t.Fatalf("appA SetState: %v", err)
}
if err := appB.syncSvc.SetState(serverURL, apiKeyB); err != nil {
t.Fatalf("appB SetState: %v", err)
}
if errStr := appA.CreateVaultFolder("files.plugin", "Shared"); errStr != "" {
t.Fatalf("appA CreateVaultFolder: %s", errStr)
}
if errStr := appA.WriteVaultTextFile("files.plugin", "Shared/one.txt", "from A", corefiles.WriteOptions{CreateIfMissing: true}); errStr != "" {
t.Fatalf("appA WriteVaultTextFile: %s", errStr)
}
expectSyncCounts(t, appA, 2, 2)
expectSyncCounts(t, appB, 0, 2)
expectText(t, appB, "Shared/one.txt", "from A")
if errStr := appB.WriteVaultTextFile("files.plugin", "Shared/one.txt", "from B", corefiles.WriteOptions{Overwrite: true}); errStr != "" {
t.Fatalf("appB update: %s", errStr)
}
if errStr := appB.MoveVaultPath("files.plugin", "Shared/one.txt", "Shared/two.txt", corefiles.MoveOptions{}); errStr != "" {
t.Fatalf("appB move: %s", errStr)
}
expectSyncCounts(t, appB, 2, 2)
expectSyncCounts(t, appA, 0, 2)
expectText(t, appA, "Shared/two.txt", "from B")
if _, errStr := appA.TrashVaultPath("files.plugin", "Shared/two.txt"); errStr != "" {
t.Fatalf("appA trash: %s", errStr)
}
expectSyncCounts(t, appA, 1, 1)
expectSyncCounts(t, appB, 0, 1)
if _, errStr := appB.GetVaultFileMetadata("files.plugin", "Shared/two.txt"); !strings.Contains(errStr, "not-found") {
t.Fatalf("appB deleted file metadata err = %q, want not-found", errStr)
}
if errStr := appB.CreateVaultFolder("files.plugin", "Shared/Folder"); errStr != "" {
t.Fatalf("appB create folder: %s", errStr)
}
expectSyncCounts(t, appB, 1, 1)
expectSyncCounts(t, appA, 0, 1)
if meta, errStr := appA.GetVaultFileMetadata("files.plugin", "Shared/Folder"); errStr != "" || meta.Type != corefiles.FileTypeFolder {
t.Fatalf("appA folder metadata = %+v err=%q, want folder", meta, errStr)
}
if errStr := appA.MoveVaultPath("files.plugin", "Shared/Folder", "Shared/Archive", corefiles.MoveOptions{}); errStr != "" {
t.Fatalf("appA move folder: %s", errStr)
}
expectSyncCounts(t, appA, 1, 1)
expectSyncCounts(t, appB, 0, 1)
if _, errStr := appB.GetVaultFileMetadata("files.plugin", "Shared/Folder"); !strings.Contains(errStr, "not-found") {
t.Fatalf("appB moved folder old metadata err = %q, want not-found", errStr)
}
if meta, errStr := appB.GetVaultFileMetadata("files.plugin", "Shared/Archive"); errStr != "" || meta.Type != corefiles.FileTypeFolder {
t.Fatalf("appB moved folder metadata = %+v err=%q, want folder", meta, errStr)
}
if _, errStr := appB.TrashVaultPath("files.plugin", "Shared/Archive"); errStr != "" {
t.Fatalf("appB trash folder: %s", errStr)
}
expectSyncCounts(t, appB, 1, 1)
expectSyncCounts(t, appA, 0, 1)
if _, errStr := appA.GetVaultFileMetadata("files.plugin", "Shared/Archive"); !strings.Contains(errStr, "not-found") {
t.Fatalf("appA deleted folder metadata err = %q, want not-found", errStr)
}
}
func expectSyncCounts(t *testing.T, app *App, pushed, pulled int) {
t.Helper()
result, err := app.syncNow()
if err != nil {
t.Fatalf("syncNow: %v", err)
}
if result["pushed"] != pushed || result["pulled"] != pulled {
t.Fatalf("sync result = %#v, want pushed=%d pulled=%d", result, pushed, pulled)
}
}
func expectText(t *testing.T, app *App, path, want string) {
t.Helper()
text, errStr := app.ReadVaultTextFile("files.plugin", path)
if errStr != "" {
t.Fatalf("ReadVaultTextFile(%s): %s", path, errStr)
}
if text != want {
t.Fatalf("ReadVaultTextFile(%s) = %q, want %q", path, text, want)
}
}

View File

@ -4,46 +4,26 @@
package appsettings
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// Config represents the application settings stored in ~/.config/verstak/config.json.
type Config struct {
SchemaVersion int `json:"schemaVersion"`
CurrentVaultPath string `json:"currentVaultPath"`
RecentVaults []string `json:"recentVaults"`
Theme string `json:"theme"`
Language string `json:"language"`
DevMode bool `json:"devMode"`
UserPluginsDir string `json:"userPluginsDir"`
Workbench WorkbenchPreferences `json:"workbench,omitempty"`
Sync SyncSettings `json:"sync,omitempty"`
BrowserReceiver BrowserReceiverSettings `json:"browserReceiver,omitempty"`
WindowState *WindowState `json:"windowState,omitempty"`
LastOpenedAt string `json:"lastOpenedAt"`
}
const (
LanguageSystem = "system"
LanguageEnglish = "en"
LanguageRussian = "ru"
)
func validLanguage(language string) bool {
switch language {
case LanguageSystem, LanguageEnglish, LanguageRussian:
return true
default:
return false
}
SchemaVersion int `json:"schemaVersion"`
CurrentVaultPath string `json:"currentVaultPath"`
RecentVaults []string `json:"recentVaults"`
Theme string `json:"theme"`
DevMode bool `json:"devMode"`
UserPluginsDir string `json:"userPluginsDir"`
Workbench WorkbenchPreferences `json:"workbench,omitempty"`
Sync SyncSettings `json:"sync,omitempty"`
WindowState *WindowState `json:"windowState,omitempty"`
LastOpenedAt string `json:"lastOpenedAt"`
}
type WorkbenchPreferences struct {
@ -64,11 +44,6 @@ type SyncSettings struct {
LastError string `json:"lastError,omitempty"`
}
// BrowserReceiverSettings holds the installation-local browser capture pairing secret.
type BrowserReceiverSettings struct {
Token string `json:"token,omitempty"`
}
// WindowState stores the last window position and size.
type WindowState struct {
Width int `json:"width"`
@ -78,10 +53,9 @@ type WindowState struct {
// Manager provides thread-safe access to app settings.
type Manager struct {
mu sync.RWMutex
config *Config
configPath string
onLanguageChanged func(string)
mu sync.RWMutex
config *Config
configPath string
}
// DefaultConfigPath returns the default path for app settings.
@ -138,9 +112,6 @@ func (m *Manager) Load() error {
if cfg.Theme == "" {
cfg.Theme = "dark"
}
if !validLanguage(cfg.Language) {
cfg.Language = LanguageSystem
}
if cfg.RecentVaults == nil {
cfg.RecentVaults = []string{}
}
@ -199,9 +170,6 @@ func (m *Manager) Update(patch *Config) error {
if patch.Theme != "" {
m.config.Theme = patch.Theme
}
if patch.Language != "" && validLanguage(patch.Language) {
m.config.Language = patch.Language
}
if patch.UserPluginsDir != "" {
m.config.UserPluginsDir = patch.UserPluginsDir
}
@ -223,92 +191,6 @@ func (m *Manager) Update(patch *Config) error {
return m.saveLocked()
}
// UpdateLanguage validates and persists the installation-local UI language.
func (m *Manager) UpdateLanguage(language string) error {
language = strings.TrimSpace(language)
if !validLanguage(language) {
return fmt.Errorf("unsupported language %q", language)
}
m.mu.Lock()
if m.config == nil {
m.config = defaultConfig()
}
m.config.Language = language
m.config.LastOpenedAt = time.Now().UTC().Format(time.RFC3339)
err := m.saveLocked()
handler := m.onLanguageChanged
m.mu.Unlock()
if err != nil {
return err
}
if handler != nil {
handler(language)
}
return nil
}
// SetLanguageChangedHandler registers the shell callback invoked after a
// language preference has been durably updated.
func (m *Manager) SetLanguageChangedHandler(handler func(string)) {
if m == nil {
return
}
m.mu.Lock()
m.onLanguageChanged = handler
m.mu.Unlock()
}
// UpdateSync replaces sync settings without changing unrelated app settings.
func (m *Manager) UpdateSync(syncSettings SyncSettings) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.config == nil {
m.config = defaultConfig()
}
m.config.Sync = syncSettings
m.config.LastOpenedAt = time.Now().UTC().Format(time.RFC3339)
return m.saveLocked()
}
// EnsureBrowserReceiverToken returns the persisted pairing token, creating it when absent.
func (m *Manager) EnsureBrowserReceiverToken() (string, error) {
m.mu.Lock()
defer m.mu.Unlock()
return m.updateBrowserReceiverTokenLocked(false)
}
// RotateBrowserReceiverToken replaces the persisted pairing token.
func (m *Manager) RotateBrowserReceiverToken() (string, error) {
m.mu.Lock()
defer m.mu.Unlock()
return m.updateBrowserReceiverTokenLocked(true)
}
func (m *Manager) updateBrowserReceiverTokenLocked(force bool) (string, error) {
if m.config == nil {
m.config = defaultConfig()
}
current := strings.TrimSpace(m.config.BrowserReceiver.Token)
if current != "" && !force {
return current, nil
}
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return "", fmt.Errorf("generate browser receiver token: %w", err)
}
token := base64.RawURLEncoding.EncodeToString(bytes)
m.config.BrowserReceiver.Token = token
if err := m.saveLocked(); err != nil {
m.config.BrowserReceiver.Token = current
return "", err
}
return token, nil
}
// SetCurrentVault updates the current vault path and adds to recents.
func (m *Manager) SetCurrentVault(path string) error {
m.mu.Lock()
@ -346,7 +228,6 @@ func defaultConfig() *Config {
CurrentVaultPath: "",
RecentVaults: []string{},
Theme: "dark",
Language: LanguageSystem,
DevMode: false,
UserPluginsDir: filepath.Join(os.Getenv("HOME"), ".config", "verstak", "plugins"),
Workbench: WorkbenchPreferences{},
@ -363,12 +244,10 @@ func copyConfig(c *Config) *Config {
CurrentVaultPath: c.CurrentVaultPath,
RecentVaults: recent,
Theme: c.Theme,
Language: c.Language,
DevMode: c.DevMode,
UserPluginsDir: c.UserPluginsDir,
Workbench: c.Workbench,
Sync: c.Sync,
BrowserReceiver: c.BrowserReceiver,
LastOpenedAt: c.LastOpenedAt,
}
if c.WindowState != nil {

View File

@ -3,7 +3,6 @@ package appsettings
import (
"os"
"path/filepath"
"reflect"
"testing"
)
@ -26,81 +25,6 @@ func TestLoad_DefaultCreation(t *testing.T) {
if cfg.CurrentVaultPath != "" {
t.Errorf("CurrentVaultPath: got %q, want empty", cfg.CurrentVaultPath)
}
if cfg.Language != "system" {
t.Errorf("Language: got %q, want %q", cfg.Language, "system")
}
}
func TestLoad_MissingLanguageDefaultsToSystem(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"schemaVersion":1,"theme":"dark","devMode":true}`), 0o600); err != nil {
t.Fatal(err)
}
m := NewManager(path)
if err := m.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
if got := m.Get().Language; got != "system" {
t.Fatalf("Language = %q, want system", got)
}
}
func TestUpdateLanguagePersistsAndPreservesUnrelatedSettings(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
m := NewManager(path)
if err := m.Load(); err != nil {
t.Fatal(err)
}
if err := m.Update(&Config{Theme: "light", DevMode: true}); err != nil {
t.Fatal(err)
}
if err := m.UpdateLanguage("ru"); err != nil {
t.Fatalf("UpdateLanguage: %v", err)
}
reloaded := NewManager(path)
if err := reloaded.Load(); err != nil {
t.Fatal(err)
}
cfg := reloaded.Get()
if cfg.Language != "ru" || cfg.Theme != "light" || !cfg.DevMode {
t.Fatalf("settings after language update = %+v", cfg)
}
if err := reloaded.UpdateLanguage("de"); err == nil {
t.Fatal("UpdateLanguage(de) succeeded, want validation error")
}
if got := reloaded.Get().Language; got != "ru" {
t.Fatalf("Language after rejected update = %q, want ru", got)
}
}
func 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) {
@ -128,43 +52,6 @@ func TestLoad_CorruptConfig(t *testing.T) {
// Just verify no panic
}
func TestBrowserReceiverTokenPersistsAndRotates(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
manager := NewManager(path)
if err := manager.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
firstToken, err := manager.EnsureBrowserReceiverToken()
if err != nil {
t.Fatalf("EnsureBrowserReceiverToken: %v", err)
}
if firstToken == "" {
t.Fatal("EnsureBrowserReceiverToken returned an empty token")
}
reloaded := NewManager(path)
if err := reloaded.Load(); err != nil {
t.Fatalf("reload settings: %v", err)
}
persistedToken, err := reloaded.EnsureBrowserReceiverToken()
if err != nil {
t.Fatalf("EnsureBrowserReceiverToken after reload: %v", err)
}
if persistedToken != firstToken {
t.Fatalf("persisted token = %q, want %q", persistedToken, firstToken)
}
rotatedToken, err := reloaded.RotateBrowserReceiverToken()
if err != nil {
t.Fatalf("RotateBrowserReceiverToken: %v", err)
}
if rotatedToken == "" || rotatedToken == firstToken {
t.Fatalf("rotated token = %q, want new non-empty token", rotatedToken)
}
}
func TestSetCurrentVault(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
@ -268,51 +155,6 @@ func TestUpdate_WorkbenchPreferences(t *testing.T) {
}
}
func TestUpdateSync(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
m := NewManager(path)
if err := m.Load(); err != nil {
t.Fatal(err)
}
if err := m.Update(&Config{DevMode: true}); err != nil {
t.Fatalf("Update dev mode: %v", err)
}
if err := m.UpdateSync(SyncSettings{
Enabled: true,
ServerURL: "https://sync.example",
DeviceID: "device-1",
DeviceName: "Desktop",
SyncInterval: 15,
LastStatus: "connected",
LastSyncAt: "2026-06-27T00:00:00Z",
LastError: "previous",
}); err != nil {
t.Fatalf("UpdateSync: %v", err)
}
reloaded := NewManager(path)
if err := reloaded.Load(); err != nil {
t.Fatal(err)
}
cfg := reloaded.Get()
if !cfg.Sync.Enabled ||
cfg.Sync.ServerURL != "https://sync.example" ||
cfg.Sync.DeviceID != "device-1" ||
cfg.Sync.DeviceName != "Desktop" ||
cfg.Sync.SyncInterval != 15 ||
cfg.Sync.LastStatus != "connected" ||
cfg.Sync.LastSyncAt != "2026-06-27T00:00:00Z" ||
cfg.Sync.LastError != "previous" {
t.Fatalf("sync settings = %+v", cfg.Sync)
}
if !cfg.DevMode {
t.Fatal("UpdateSync changed DevMode")
}
}
func TestAppSettings_NotInsideVault(t *testing.T) {
// App settings path should be under ~/.config/verstak/, not inside vault
path := DefaultConfigPath()

View File

@ -1,638 +0,0 @@
// Package browserreceiver hosts the local HTTP protocol used by the browser extension.
package browserreceiver
import (
"context"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"strings"
"sync"
"time"
"github.com/verstak/verstak-desktop/internal/core/events"
"github.com/verstak/verstak-desktop/internal/core/hostname"
)
const (
capturePath = "/api/browser-inbox/v1/captures"
activityBatchPath = "/api/browser-activity/v1/batches"
DefaultAddr = "127.0.0.1:47731"
DefaultCaptureURL = "http://" + DefaultAddr + capturePath
DefaultActivityURL = "http://" + DefaultAddr + activityBatchPath
receiverTokenHeader = "X-Verstak-Receiver-Token"
)
const (
maxCaptureBodyBytes = 12 * 1024 * 1024
maxCaptureIDBytes = 256
maxCapturedAtBytes = 64
maxCaptureSourceBytes = 128
maxPageURLBytes = 4096
maxPageTitleBytes = 512
maxPageDomainBytes = 255
maxSelectionTextBytes = 20 * 1024
maxLinkTextBytes = 512
maxBrowserNameBytes = 64
maxFileNameBytes = 255
maxFileMimeBytes = 128
maxFileTextBytes = 2 * 1024 * 1024
maxFileBytes = 8 * 1024 * 1024
maxFileDataBase64Bytes = 4 * ((maxFileBytes + 2) / 3)
maxActivityBodyBytes = 256 * 1024
maxActivityEntries = 100
maxActivityDuration = 10 * time.Minute
)
type Receiver struct {
bus *events.Bus
workspaceProvider WorkspaceProvider
optionsMu sync.RWMutex
options Options
}
type WorkspaceProvider func() string
type Options struct {
RequireToken bool
ReceiverToken string
Available func() bool
Persist func(events.Event) error
ActivityAvailable func() bool
PersistActivity func(events.Event) error
}
type Server struct {
listener net.Listener
server *http.Server
}
type CapturePayload struct {
SchemaVersion int `json:"schemaVersion"`
CaptureID string `json:"captureId"`
CapturedAt string `json:"capturedAt"`
Source string `json:"source"`
Kind string `json:"kind"`
Page CapturePage `json:"page"`
Selection *CaptureSelection `json:"selection,omitempty"`
Link *CaptureLink `json:"link,omitempty"`
File *CaptureFile `json:"file,omitempty"`
Browser *CaptureBrowser `json:"browser,omitempty"`
Context interface{} `json:"context,omitempty"`
}
type CapturePage struct {
URL string `json:"url"`
Title string `json:"title"`
Domain string `json:"domain"`
}
type CaptureSelection struct {
Text string `json:"text"`
}
type CaptureLink struct {
URL string `json:"url"`
Text string `json:"text"`
}
type CaptureFile struct {
Name string `json:"name"`
Mime string `json:"mime"`
Size int64 `json:"size"`
Text string `json:"text"`
DataBase64 string `json:"dataBase64"`
}
type CaptureBrowser struct {
Name string `json:"name"`
}
// ActivityBatchPayload contains only domain-level time accounting. It never
// accepts or emits page URLs, titles, content, or navigation history.
type ActivityBatchPayload struct {
SchemaVersion int `json:"schemaVersion"`
BatchID string `json:"batchId"`
CreatedAt string `json:"createdAt"`
Source string `json:"source"`
Entries []ActivityEntry `json:"entries"`
}
type ActivityEntry struct {
Hostname string `json:"hostname"`
StartedAt string `json:"startedAt"`
EndedAt string `json:"endedAt"`
DurationSeconds int64 `json:"durationSeconds"`
}
func New(bus *events.Bus, providers ...WorkspaceProvider) *Receiver {
return NewWithOptions(bus, Options{}, providers...)
}
func NewWithOptions(bus *events.Bus, options Options, providers ...WorkspaceProvider) *Receiver {
var provider WorkspaceProvider
if len(providers) > 0 {
provider = providers[0]
}
return &Receiver{bus: bus, workspaceProvider: provider, options: options}
}
// SetReceiverToken updates the active token without restarting the local server.
func (r *Receiver) SetReceiverToken(token string) {
if r == nil {
return
}
r.optionsMu.Lock()
defer r.optionsMu.Unlock()
r.options.RequireToken = true
r.options.ReceiverToken = strings.TrimSpace(token)
}
// SetPersistence configures durable capture storage and its availability gate.
func (r *Receiver) SetPersistence(available func() bool, persist func(events.Event) error) {
if r == nil {
return
}
r.optionsMu.Lock()
defer r.optionsMu.Unlock()
r.options.Available = available
r.options.Persist = persist
}
// SetActivityPersistence configures durable passive browser activity storage.
// An acknowledgement is sent only after persist returns successfully.
func (r *Receiver) SetActivityPersistence(available func() bool, persist func(events.Event) error) {
if r == nil {
return
}
r.optionsMu.Lock()
defer r.optionsMu.Unlock()
r.options.ActivityAvailable = available
r.options.PersistActivity = persist
}
func Start(addr string, receiver *Receiver) (*Server, error) {
if receiver == nil {
return nil, fmt.Errorf("receiver is required")
}
listener, err := net.Listen("tcp", addr)
if err != nil {
return nil, err
}
s := &Server{
listener: listener,
server: &http.Server{
Handler: receiver,
},
}
go func() {
if err := s.server.Serve(listener); err != nil && err != http.ErrServerClosed {
log.Printf("[browserreceiver] serve: %v", err)
}
}()
return s, nil
}
func (s *Server) URL() string {
if s == nil || s.listener == nil {
return ""
}
return "http://" + s.listener.Addr().String()
}
func (s *Server) Close() error {
if s == nil || s.server == nil {
return nil
}
return s.server.Shutdown(context.Background())
}
func (r *Receiver) ServeHTTP(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "application/json")
if req.URL.Path == activityBatchPath {
r.serveActivityBatch(w, req)
return
}
if req.URL.Path != capturePath {
http.NotFound(w, req)
return
}
if req.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "method not allowed"})
return
}
if err := r.validateReceiverToken(req); err != nil {
writeError(w, http.StatusUnauthorized, err.Error())
return
}
options := r.currentOptions()
if options.Available != nil && !options.Available() {
writeError(w, http.StatusServiceUnavailable, "browser inbox unavailable")
return
}
defer req.Body.Close()
decoder := json.NewDecoder(http.MaxBytesReader(w, req.Body, maxCaptureBodyBytes))
var payload CapturePayload
if err := decoder.Decode(&payload); err != nil {
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
writeError(w, http.StatusRequestEntityTooLarge, "capture payload exceeds limit")
return
}
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
writeError(w, http.StatusRequestEntityTooLarge, "capture payload exceeds limit")
return
}
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
if err := payload.Validate(); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
eventName := "browser.capture." + payload.Kind
if options.Persist == nil && (r.bus == nil || !r.bus.HasSubscribers(eventName)) {
writeError(w, http.StatusServiceUnavailable, "browser inbox unavailable")
return
}
eventPayload := payload.EventPayload()
r.annotateWorkspace(eventPayload)
event := events.Event{
Name: eventName,
Timestamp: time.Now().UTC().Format(time.RFC3339Nano),
Payload: eventPayload,
}
if options.Persist != nil {
if err := options.Persist(event); err != nil {
log.Printf("[browserreceiver] persist %s: %v", payload.CaptureID, err)
writeError(w, http.StatusServiceUnavailable, "browser inbox unavailable")
return
}
}
if r.bus != nil {
r.bus.Publish(event)
}
w.WriteHeader(http.StatusAccepted)
_ = json.NewEncoder(w).Encode(map[string]string{
"status": "accepted",
"captureId": payload.CaptureID,
})
}
func (r *Receiver) serveActivityBatch(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "method not allowed"})
return
}
if err := r.validateReceiverToken(req); err != nil {
writeError(w, http.StatusUnauthorized, err.Error())
return
}
options := r.currentOptions()
if options.ActivityAvailable == nil || !options.ActivityAvailable() || options.PersistActivity == nil {
writeError(w, http.StatusServiceUnavailable, "activity storage unavailable")
return
}
defer req.Body.Close()
decoder := json.NewDecoder(http.MaxBytesReader(w, req.Body, maxActivityBodyBytes))
var batch ActivityBatchPayload
if err := decoder.Decode(&batch); err != nil {
writeActivityDecodeError(w, err)
return
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
writeActivityDecodeError(w, err)
return
}
if err := batch.NormalizeAndValidate(); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
event := events.Event{
Name: "browser.activity.batch",
Timestamp: time.Now().UTC().Format(time.RFC3339Nano),
Payload: batch.EventPayload(),
}
if err := options.PersistActivity(event); err != nil {
log.Printf("[browserreceiver] persist activity %s: %v", batch.BatchID, err)
writeError(w, http.StatusServiceUnavailable, "activity storage unavailable")
return
}
if r.bus != nil {
r.bus.Publish(event)
}
w.WriteHeader(http.StatusAccepted)
_ = json.NewEncoder(w).Encode(map[string]string{
"status": "accepted",
"batchId": batch.BatchID,
})
}
func (r *Receiver) currentOptions() Options {
if r == nil {
return Options{}
}
r.optionsMu.RLock()
defer r.optionsMu.RUnlock()
return r.options
}
func (r *Receiver) validateReceiverToken(req *http.Request) error {
if r == nil {
return nil
}
r.optionsMu.RLock()
requireToken := r.options.RequireToken
expected := strings.TrimSpace(r.options.ReceiverToken)
r.optionsMu.RUnlock()
if !requireToken {
return nil
}
if expected == "" {
return fmt.Errorf("receiver token required")
}
supplied := strings.TrimSpace(req.Header.Get(receiverTokenHeader))
if supplied == "" {
return fmt.Errorf("receiver token required")
}
if subtle.ConstantTimeCompare([]byte(supplied), []byte(expected)) != 1 {
return fmt.Errorf("receiver token invalid")
}
return nil
}
func (r *Receiver) annotateWorkspace(payload map[string]interface{}) {
if r == nil || r.workspaceProvider == nil || payload == nil {
return
}
if _, ok := payload["workspaceRootPath"]; ok {
return
}
workspaceRoot := strings.TrimSpace(r.workspaceProvider())
if workspaceRoot == "" {
return
}
payload["workspaceRootPath"] = workspaceRoot
payload["workspaceName"] = workspaceRoot
}
func (p CapturePayload) Validate() error {
if p.SchemaVersion != 1 {
return fmt.Errorf("unsupported schemaVersion")
}
if strings.TrimSpace(p.CaptureID) == "" {
return fmt.Errorf("captureId is required")
}
if err := validateCaptureText(p.CaptureID, "captureId", maxCaptureIDBytes); err != nil {
return err
}
if strings.TrimSpace(p.CapturedAt) == "" {
return fmt.Errorf("capturedAt is required")
}
if err := validateCaptureText(p.CapturedAt, "capturedAt", maxCapturedAtBytes); err != nil {
return err
}
if p.Kind != "page" && p.Kind != "selection" && p.Kind != "link" && p.Kind != "file" {
return fmt.Errorf("unsupported kind")
}
if strings.TrimSpace(p.Page.URL) == "" {
return fmt.Errorf("page.url is required")
}
if err := validateCaptureText(p.Source, "source", maxCaptureSourceBytes); err != nil {
return err
}
if err := validateCaptureText(p.Page.URL, "page.url", maxPageURLBytes); err != nil {
return err
}
if err := validateCaptureText(p.Page.Title, "page.title", maxPageTitleBytes); err != nil {
return err
}
if err := validateCaptureText(p.Page.Domain, "page.domain", maxPageDomainBytes); err != nil {
return err
}
if p.Browser != nil {
if err := validateCaptureText(p.Browser.Name, "browser.name", maxBrowserNameBytes); err != nil {
return err
}
}
if p.Kind == "selection" && (p.Selection == nil || strings.TrimSpace(p.Selection.Text) == "") {
return fmt.Errorf("selection.text is required")
}
if p.Kind == "selection" {
if err := validateCaptureText(p.Selection.Text, "selection.text", maxSelectionTextBytes); err != nil {
return err
}
}
if p.Kind == "link" {
if p.Link == nil || strings.TrimSpace(p.Link.URL) == "" {
return fmt.Errorf("link.url is required")
}
if err := validateCaptureText(p.Link.URL, "link.url", maxPageURLBytes); err != nil {
return err
}
if err := validateCaptureText(p.Link.Text, "link.text", maxLinkTextBytes); err != nil {
return err
}
}
if p.Kind == "file" {
if err := p.validateFile(); err != nil {
return err
}
}
return nil
}
// NormalizeAndValidate validates a batch before it is made durable and replaces
// each submitted hostname with the shared canonical A-label form.
func (p *ActivityBatchPayload) NormalizeAndValidate() error {
if p == nil || p.SchemaVersion != 1 {
return fmt.Errorf("unsupported schemaVersion")
}
if strings.TrimSpace(p.BatchID) == "" {
return fmt.Errorf("batchId is required")
}
if err := validateCaptureText(p.BatchID, "batchId", maxCaptureIDBytes); err != nil {
return err
}
if strings.TrimSpace(p.CreatedAt) == "" {
return fmt.Errorf("createdAt is required")
}
if _, err := time.Parse(time.RFC3339, p.CreatedAt); err != nil {
return fmt.Errorf("createdAt is invalid")
}
if err := validateCaptureText(p.CreatedAt, "createdAt", maxCapturedAtBytes); err != nil {
return err
}
if strings.TrimSpace(p.Source) == "" {
return fmt.Errorf("source is required")
}
if err := validateCaptureText(p.Source, "source", maxCaptureSourceBytes); err != nil {
return err
}
if len(p.Entries) == 0 || len(p.Entries) > maxActivityEntries {
return fmt.Errorf("entries must contain between 1 and %d items", maxActivityEntries)
}
for index := range p.Entries {
entry := &p.Entries[index]
canonical := hostname.NormalizeHostnameV1(entry.Hostname)
if canonical == "" {
return fmt.Errorf("entries[%d].hostname is invalid", index)
}
startedAt, err := time.Parse(time.RFC3339, entry.StartedAt)
if err != nil {
return fmt.Errorf("entries[%d].startedAt is invalid", index)
}
endedAt, err := time.Parse(time.RFC3339, entry.EndedAt)
if err != nil {
return fmt.Errorf("entries[%d].endedAt is invalid", index)
}
interval := endedAt.Sub(startedAt)
if interval <= 0 || interval > maxActivityDuration {
return fmt.Errorf("entries[%d] interval must be between 1 second and %s", index, maxActivityDuration)
}
if entry.DurationSeconds <= 0 || entry.DurationSeconds > int64(maxActivityDuration/time.Second) || time.Duration(entry.DurationSeconds)*time.Second > interval {
return fmt.Errorf("entries[%d].durationSeconds is invalid", index)
}
entry.Hostname = canonical
}
return nil
}
func (p ActivityBatchPayload) EventPayload() map[string]interface{} {
entries := make([]map[string]interface{}, 0, len(p.Entries))
for _, entry := range p.Entries {
entries = append(entries, map[string]interface{}{
"hostname": entry.Hostname,
"startedAt": entry.StartedAt,
"endedAt": entry.EndedAt,
"durationSeconds": entry.DurationSeconds,
})
}
return map[string]interface{}{
"batchId": strings.TrimSpace(p.BatchID),
"createdAt": strings.TrimSpace(p.CreatedAt),
"source": strings.TrimSpace(p.Source),
"entries": entries,
}
}
func (p CapturePayload) validateFile() error {
if p.File == nil || strings.TrimSpace(p.File.Name) == "" {
return fmt.Errorf("file.name is required")
}
if p.File.Text == "" && strings.TrimSpace(p.File.DataBase64) == "" {
return fmt.Errorf("file.text or file.dataBase64 is required")
}
if p.File.Size < 0 || p.File.Size > maxFileBytes {
return fmt.Errorf("file.size exceeds limit")
}
if err := validateCaptureText(p.File.Name, "file.name", maxFileNameBytes); err != nil {
return err
}
if err := validateCaptureText(p.File.Mime, "file.mime", maxFileMimeBytes); err != nil {
return err
}
if err := validateCaptureText(p.File.Text, "file.text", maxFileTextBytes); err != nil {
return err
}
dataBase64 := strings.TrimSpace(p.File.DataBase64)
if dataBase64 == "" {
return nil
}
if len(dataBase64) > maxFileDataBase64Bytes {
return fmt.Errorf("file.dataBase64 exceeds limit")
}
data, err := base64.StdEncoding.DecodeString(dataBase64)
if err != nil {
return fmt.Errorf("file.dataBase64 is invalid")
}
if len(data) > maxFileBytes {
return fmt.Errorf("file.dataBase64 exceeds limit")
}
return nil
}
func validateCaptureText(value, field string, maxBytes int) error {
if len(value) > maxBytes {
return fmt.Errorf("%s exceeds limit", field)
}
return nil
}
func (p CapturePayload) EventPayload() map[string]interface{} {
pageURL := strings.TrimSpace(p.Page.URL)
result := map[string]interface{}{
"captureId": strings.TrimSpace(p.CaptureID),
"capturedAt": strings.TrimSpace(p.CapturedAt),
"source": strings.TrimSpace(p.Source),
"kind": p.Kind,
"url": pageURL,
"title": strings.TrimSpace(p.Page.Title),
"domain": captureDomain(pageURL, p.Page.Domain),
}
if p.Browser != nil {
result["browserName"] = strings.TrimSpace(p.Browser.Name)
}
if p.Context != nil {
result["context"] = p.Context
}
switch p.Kind {
case "selection":
result["text"] = strings.TrimSpace(p.Selection.Text)
case "link":
linkURL := strings.TrimSpace(p.Link.URL)
result["url"] = linkURL
result["title"] = strings.TrimSpace(p.Link.Text)
result["domain"] = captureDomain(linkURL, "")
case "file":
result["fileName"] = strings.TrimSpace(p.File.Name)
result["fileMime"] = strings.TrimSpace(p.File.Mime)
result["fileSize"] = p.File.Size
result["fileText"] = p.File.Text
result["fileDataBase64"] = strings.TrimSpace(p.File.DataBase64)
}
return result
}
func captureDomain(rawURL, fallback string) string {
if normalized := hostname.NormalizeURLHostnameV1(rawURL); normalized != "" {
return normalized
}
return hostname.NormalizeHostnameV1(fallback)
}
func writeActivityDecodeError(w http.ResponseWriter, err error) {
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
writeError(w, http.StatusRequestEntityTooLarge, "activity payload exceeds limit")
return
}
writeError(w, http.StatusBadRequest, "invalid JSON")
}
func writeError(w http.ResponseWriter, status int, message string) {
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]string{"error": message})
}

View File

@ -1,684 +0,0 @@
package browserreceiver
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/verstak/verstak-desktop/internal/core/events"
)
func TestReceiverAcceptsSelectionCaptureAndPublishesEvent(t *testing.T) {
bus := events.NewBus()
received := make(chan events.Event, 1)
bus.Subscribe("browser.capture.selection", func(event events.Event) {
received <- event
})
receiver := New(bus)
body := `{
"schemaVersion": 1,
"captureId": "capture-123",
"capturedAt": "2026-06-27T00:00:00.000Z",
"source": "verstak-browser-extension",
"kind": "selection",
"page": {
"url": "https://example.com/article",
"title": "Example Article",
"domain": "example.com"
},
"selection": {
"text": "selected text"
},
"browser": {
"name": "Chromium"
}
}`
req := httptest.NewRequest(http.MethodPost, "/api/browser-inbox/v1/captures", bytes.NewBufferString(body))
rec := httptest.NewRecorder()
receiver.ServeHTTP(rec, req)
if rec.Code != http.StatusAccepted {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusAccepted, rec.Body.String())
}
var response map[string]string
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
t.Fatalf("response json: %v", err)
}
if response["status"] != "accepted" {
t.Fatalf("response status = %q, want accepted", response["status"])
}
if response["captureId"] != "capture-123" {
t.Fatalf("response captureId = %q, want capture-123", response["captureId"])
}
event := <-received
if event.Name != "browser.capture.selection" {
t.Fatalf("event name = %q, want browser.capture.selection", event.Name)
}
payload, ok := event.Payload.(map[string]interface{})
if !ok {
t.Fatalf("event payload type = %T, want map[string]interface{}", event.Payload)
}
if payload["captureId"] != "capture-123" {
t.Fatalf("payload captureId = %v, want capture-123", payload["captureId"])
}
if payload["url"] != "https://example.com/article" {
t.Fatalf("payload url = %v, want https://example.com/article", payload["url"])
}
if payload["title"] != "Example Article" {
t.Fatalf("payload title = %v, want Example Article", payload["title"])
}
if payload["text"] != "selected text" {
t.Fatalf("payload text = %v, want selected text", payload["text"])
}
if payload["capturedAt"] != "2026-06-27T00:00:00.000Z" {
t.Fatalf("payload capturedAt = %v, want documented timestamp", payload["capturedAt"])
}
if payload["domain"] != "example.com" {
t.Fatalf("payload domain = %v, want example.com", payload["domain"])
}
}
func TestReceiverAcceptsFileCaptureAndPublishesEvent(t *testing.T) {
bus := events.NewBus()
received := make(chan events.Event, 1)
bus.Subscribe("browser.capture.file", func(event events.Event) {
received <- event
})
receiver := New(bus)
body := `{
"schemaVersion": 1,
"captureId": "capture-file",
"capturedAt": "2026-06-29T02:00:00.000Z",
"source": "verstak-browser-extension",
"kind": "file",
"page": {
"url": "https://example.com/files",
"title": "Example Files",
"domain": "example.com"
},
"file": {
"name": "notes.txt",
"mime": "text/plain",
"size": 11,
"text": "hello file",
"dataBase64": "aGVsbG8gZmlsZQ=="
},
"browser": {
"name": "Firefox"
}
}`
req := httptest.NewRequest(http.MethodPost, "/api/browser-inbox/v1/captures", bytes.NewBufferString(body))
rec := httptest.NewRecorder()
receiver.ServeHTTP(rec, req)
if rec.Code != http.StatusAccepted {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusAccepted, rec.Body.String())
}
event := <-received
if event.Name != "browser.capture.file" {
t.Fatalf("event name = %q, want browser.capture.file", event.Name)
}
payload, ok := event.Payload.(map[string]interface{})
if !ok {
t.Fatalf("event payload type = %T, want map[string]interface{}", event.Payload)
}
if payload["kind"] != "file" {
t.Fatalf("payload kind = %v, want file", payload["kind"])
}
if payload["fileName"] != "notes.txt" {
t.Fatalf("payload fileName = %v, want notes.txt", payload["fileName"])
}
if payload["fileMime"] != "text/plain" {
t.Fatalf("payload fileMime = %v, want text/plain", payload["fileMime"])
}
if payload["fileSize"] != int64(11) {
t.Fatalf("payload fileSize = %v, want 11", payload["fileSize"])
}
if payload["fileText"] != "hello file" {
t.Fatalf("payload fileText = %v, want hello file", payload["fileText"])
}
if payload["fileDataBase64"] != "aGVsbG8gZmlsZQ==" {
t.Fatalf("payload fileDataBase64 = %v, want aGVsbG8gZmlsZQ==", payload["fileDataBase64"])
}
}
func TestReceiverAnnotatesCaptureWithCurrentWorkspace(t *testing.T) {
bus := events.NewBus()
received := make(chan events.Event, 1)
bus.Subscribe("browser.capture.page", func(event events.Event) {
received <- event
})
receiver := New(bus, func() string { return "Project" })
body := `{
"schemaVersion": 1,
"captureId": "capture-workspace",
"capturedAt": "2026-06-27T00:00:00.000Z",
"source": "verstak-browser-extension",
"kind": "page",
"page": {
"url": "https://example.com/article",
"title": "Example Article"
}
}`
req := httptest.NewRequest(http.MethodPost, "/api/browser-inbox/v1/captures", bytes.NewBufferString(body))
rec := httptest.NewRecorder()
receiver.ServeHTTP(rec, req)
if rec.Code != http.StatusAccepted {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusAccepted, rec.Body.String())
}
event := <-received
payload, ok := event.Payload.(map[string]interface{})
if !ok {
t.Fatalf("event payload type = %T, want map[string]interface{}", event.Payload)
}
if payload["workspaceRootPath"] != "Project" {
t.Fatalf("payload workspaceRootPath = %v, want Project", payload["workspaceRootPath"])
}
if payload["workspaceName"] != "Project" {
t.Fatalf("payload workspaceName = %v, want Project", payload["workspaceName"])
}
}
func TestReceiverLeavesCaptureUnassignedWithoutCurrentWorkspace(t *testing.T) {
bus := events.NewBus()
received := make(chan events.Event, 1)
bus.Subscribe("browser.capture.page", func(event events.Event) {
received <- event
})
receiver := New(bus, func() string { return "" })
body := `{
"schemaVersion": 1,
"captureId": "capture-unassigned",
"capturedAt": "2026-06-27T00:00:00.000Z",
"source": "verstak-browser-extension",
"kind": "page",
"page": {
"url": "https://example.com/article",
"title": "Example Article"
}
}`
req := httptest.NewRequest(http.MethodPost, "/api/browser-inbox/v1/captures", bytes.NewBufferString(body))
rec := httptest.NewRecorder()
receiver.ServeHTTP(rec, req)
if rec.Code != http.StatusAccepted {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusAccepted, rec.Body.String())
}
event := <-received
payload, ok := event.Payload.(map[string]interface{})
if !ok {
t.Fatalf("event payload type = %T, want map[string]interface{}", event.Payload)
}
if _, ok := payload["workspaceRootPath"]; ok {
t.Fatalf("workspaceRootPath = %v, want absent for an unassigned capture", payload["workspaceRootPath"])
}
if _, ok := payload["workspaceName"]; ok {
t.Fatalf("workspaceName = %v, want absent for an unassigned capture", payload["workspaceName"])
}
}
func TestReceiverAcceptsDomainActivityBatchAfterDurablePersistence(t *testing.T) {
bus := events.NewBus()
received := make(chan events.Event, 1)
bus.Subscribe("browser.activity.batch", func(event events.Event) {
received <- event
})
persisted := 0
receiver := NewWithOptions(bus, Options{
ActivityAvailable: func() bool { return true },
PersistActivity: func(event events.Event) error {
persisted++
return nil
},
})
body := `{
"schemaVersion": 1,
"batchId": "batch-123",
"createdAt": "2026-07-12T10:05:00.000Z",
"source": "verstak-browser-extension",
"entries": [{
"hostname": "пример.рф",
"startedAt": "2026-07-12T10:00:00.000Z",
"endedAt": "2026-07-12T10:05:00.000Z",
"durationSeconds": 300
}]
}`
req := httptest.NewRequest(http.MethodPost, "/api/browser-activity/v1/batches", bytes.NewBufferString(body))
rec := httptest.NewRecorder()
receiver.ServeHTTP(rec, req)
if rec.Code != http.StatusAccepted {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusAccepted, rec.Body.String())
}
if persisted != 1 {
t.Fatalf("persisted = %d, want 1 before acknowledgement", persisted)
}
var response map[string]string
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
t.Fatalf("response json: %v", err)
}
if response["batchId"] != "batch-123" || response["status"] != "accepted" {
t.Fatalf("response = %+v, want accepted batch-123", response)
}
event := <-received
payload, ok := event.Payload.(map[string]interface{})
if !ok {
t.Fatalf("event payload type = %T, want map[string]interface{}", event.Payload)
}
if payload["batchId"] != "batch-123" {
t.Fatalf("event payload = %+v, want batch id", payload)
}
entries, ok := payload["entries"].([]map[string]interface{})
if !ok || len(entries) != 1 || entries[0]["hostname"] != "xn--e1afmkfd.xn--p1ai" {
t.Fatalf("event entries = %+v, want one canonical hostname", payload["entries"])
}
if _, ok := payload["url"]; ok {
t.Fatalf("activity payload must not contain URL: %+v", payload)
}
}
func TestReceiverRejectsDomainActivityWithoutConsumerOrValidInterval(t *testing.T) {
for _, tc := range []struct {
name string
receiver *Receiver
body string
wantCode int
}{
{
name: "consumer unavailable",
receiver: New(events.NewBus()),
body: `{"schemaVersion":1,"batchId":"batch-a","createdAt":"2026-07-12T10:05:00Z","source":"verstak-browser-extension","entries":[{"hostname":"example.com","startedAt":"2026-07-12T10:00:00Z","endedAt":"2026-07-12T10:05:00Z","durationSeconds":300}]}`,
wantCode: http.StatusServiceUnavailable,
},
{
name: "duration too long",
receiver: NewWithOptions(events.NewBus(), Options{
ActivityAvailable: func() bool { return true },
PersistActivity: func(events.Event) error { return nil },
}),
body: `{"schemaVersion":1,"batchId":"batch-b","createdAt":"2026-07-12T10:05:00Z","source":"verstak-browser-extension","entries":[{"hostname":"example.com","startedAt":"2026-07-12T10:00:00Z","endedAt":"2026-07-12T10:20:01Z","durationSeconds":1201}]}`,
wantCode: http.StatusBadRequest,
},
} {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/browser-activity/v1/batches", strings.NewReader(tc.body))
rec := httptest.NewRecorder()
tc.receiver.ServeHTTP(rec, req)
if rec.Code != tc.wantCode {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, tc.wantCode, rec.Body.String())
}
})
}
}
func TestReceiverRequiresTokenWhenPaired(t *testing.T) {
bus := events.NewBus()
received := make(chan events.Event, 1)
bus.Subscribe("browser.capture.page", func(event events.Event) {
received <- event
})
receiver := NewWithOptions(bus, Options{RequireToken: true, ReceiverToken: "pair-token"})
body := `{
"schemaVersion": 1,
"captureId": "capture-paired",
"capturedAt": "2026-06-27T00:00:00.000Z",
"source": "verstak-browser-extension",
"kind": "page",
"page": {
"url": "https://example.com/article",
"title": "Example Article"
}
}`
for _, tc := range []struct {
name string
token string
wantError string
}{
{name: "missing", token: "", wantError: "receiver token required"},
{name: "wrong", token: "wrong-token", wantError: "receiver token invalid"},
} {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/browser-inbox/v1/captures", bytes.NewBufferString(body))
if tc.token != "" {
req.Header.Set("X-Verstak-Receiver-Token", tc.token)
}
rec := httptest.NewRecorder()
receiver.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusUnauthorized, rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte(tc.wantError)) {
t.Fatalf("response body = %q, want %q", rec.Body.String(), tc.wantError)
}
select {
case event := <-received:
t.Fatalf("unexpected event published for rejected capture: %#v", event)
default:
}
})
}
}
func TestReceiverAcceptsPairedToken(t *testing.T) {
bus := events.NewBus()
received := make(chan events.Event, 1)
bus.Subscribe("browser.capture.page", func(event events.Event) {
received <- event
})
receiver := NewWithOptions(bus, Options{RequireToken: true, ReceiverToken: "pair-token"})
body := `{
"schemaVersion": 1,
"captureId": "capture-paired",
"capturedAt": "2026-06-27T00:00:00.000Z",
"source": "verstak-browser-extension",
"kind": "page",
"page": {
"url": "https://example.com/article",
"title": "Example Article"
}
}`
req := httptest.NewRequest(http.MethodPost, "/api/browser-inbox/v1/captures", bytes.NewBufferString(body))
req.Header.Set("X-Verstak-Receiver-Token", "pair-token")
rec := httptest.NewRecorder()
receiver.ServeHTTP(rec, req)
if rec.Code != http.StatusAccepted {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusAccepted, rec.Body.String())
}
event := <-received
if event.Name != "browser.capture.page" {
t.Fatalf("event name = %q, want browser.capture.page", event.Name)
}
}
func TestReceiverRejectsCaptureWhenInboxIsUnavailable(t *testing.T) {
bus := events.NewBus()
bus.Subscribe("browser.capture.page", func(event events.Event) {
t.Fatalf("unexpected event while inbox is unavailable: %#v", event)
})
receiver := NewWithOptions(bus, Options{
Available: func() bool { return false },
})
req := httptest.NewRequest(http.MethodPost, capturePath, strings.NewReader(`{
"schemaVersion": 1,
"captureId": "capture-no-vault",
"capturedAt": "2026-07-11T12:00:00Z",
"kind": "page",
"page": {"url": "https://example.com"}
}`))
res := httptest.NewRecorder()
receiver.ServeHTTP(res, req)
if res.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want %d; body=%s", res.Code, http.StatusServiceUnavailable, res.Body.String())
}
}
func TestReceiverDoesNotAcknowledgeFailedPersistence(t *testing.T) {
bus := events.NewBus()
published := false
bus.Subscribe("browser.capture.page", func(event events.Event) {
published = true
})
receiver := NewWithOptions(bus, Options{
Available: func() bool { return true },
Persist: func(event events.Event) error {
return errors.New("disk full")
},
})
req := httptest.NewRequest(http.MethodPost, capturePath, strings.NewReader(`{
"schemaVersion": 1,
"captureId": "capture-disk-full",
"capturedAt": "2026-07-11T12:00:00Z",
"kind": "page",
"page": {"url": "https://example.com"}
}`))
res := httptest.NewRecorder()
receiver.ServeHTTP(res, req)
if res.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want %d; body=%s", res.Code, http.StatusServiceUnavailable, res.Body.String())
}
if published {
t.Fatal("capture event was published after persistence failed")
}
}
func TestReceiverRotatesPairedToken(t *testing.T) {
bus := events.NewBus()
bus.Subscribe("browser.capture.page", func(event events.Event) {})
receiver := NewWithOptions(bus, Options{RequireToken: true, ReceiverToken: "old-token"})
body := `{
"schemaVersion": 1,
"captureId": "capture-rotated-token",
"capturedAt": "2026-06-27T00:00:00.000Z",
"kind": "page",
"page": {"url": "https://example.com"}
}`
request := func(token string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, capturePath, bytes.NewBufferString(body))
req.Header.Set(receiverTokenHeader, token)
res := httptest.NewRecorder()
receiver.ServeHTTP(res, req)
return res
}
if res := request("old-token"); res.Code != http.StatusAccepted {
t.Fatalf("old token before rotation status = %d, want %d", res.Code, http.StatusAccepted)
}
receiver.SetReceiverToken("new-token")
if res := request("old-token"); res.Code != http.StatusUnauthorized {
t.Fatalf("old token after rotation status = %d, want %d", res.Code, http.StatusUnauthorized)
}
if res := request("new-token"); res.Code != http.StatusAccepted {
t.Fatalf("new token after rotation status = %d, want %d", res.Code, http.StatusAccepted)
}
}
func TestServerStartsOnLocalAddressAndAcceptsCapture(t *testing.T) {
bus := events.NewBus()
bus.Subscribe("browser.capture.page", func(event events.Event) {})
receiver := New(bus)
server, err := Start("127.0.0.1:0", receiver)
if err != nil {
t.Fatalf("Start: %v", err)
}
defer server.Close()
response, err := http.Post(server.URL()+capturePath, "application/json", bytes.NewBufferString(`{
"schemaVersion": 1,
"captureId": "capture-server",
"capturedAt": "2026-06-27T00:00:00.000Z",
"source": "verstak-browser-extension",
"kind": "page",
"page": {
"url": "https://example.com/article",
"title": "Example Article"
}
}`))
if err != nil {
t.Fatalf("post capture: %v", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusAccepted {
body, _ := io.ReadAll(response.Body)
t.Fatalf("status = %d, want %d; body=%s", response.StatusCode, http.StatusAccepted, string(body))
}
}
func TestReceiverRejectsCaptureWhenNoConsumerIsRegistered(t *testing.T) {
receiver := New(events.NewBus())
body := `{
"schemaVersion": 1,
"captureId": "capture-queued",
"capturedAt": "2026-06-27T00:00:00.000Z",
"source": "verstak-browser-extension",
"kind": "page",
"page": {
"url": "https://example.com/article",
"title": "Example Article"
}
}`
req := httptest.NewRequest(http.MethodPost, "/api/browser-inbox/v1/captures", bytes.NewBufferString(body))
rec := httptest.NewRecorder()
receiver.ServeHTTP(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusServiceUnavailable, rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte("browser inbox unavailable")) {
t.Fatalf("response body = %q, want unavailable error", rec.Body.String())
}
}
func TestReceiverRejectsInvalidCapturePayload(t *testing.T) {
receiver := New(events.NewBus())
body := `{
"schemaVersion": 1,
"captureId": "capture-123",
"capturedAt": "2026-06-27T00:00:00.000Z",
"source": "verstak-browser-extension",
"kind": "link",
"page": {
"url": "https://example.com/article",
"title": "Example Article"
}
}`
req := httptest.NewRequest(http.MethodPost, "/api/browser-inbox/v1/captures", bytes.NewBufferString(body))
rec := httptest.NewRecorder()
receiver.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte("link.url is required")) {
t.Fatalf("response body = %q, want validation error", rec.Body.String())
}
}
func TestReceiverRejectsOversizedCaptureBody(t *testing.T) {
bus := events.NewBus()
received := make(chan events.Event, 1)
bus.Subscribe("browser.capture.page", func(event events.Event) {
received <- event
})
receiver := New(bus)
const maxCaptureBodyBytes = 12 * 1024 * 1024
body := fmt.Sprintf(`{
"schemaVersion": 1,
"captureId": "capture-oversized",
"capturedAt": "2026-06-27T00:00:00.000Z",
"kind": "page",
"page": {"url": "https://example.com", "title": %q}
}`, strings.Repeat("x", maxCaptureBodyBytes))
req := httptest.NewRequest(http.MethodPost, "/api/browser-inbox/v1/captures", strings.NewReader(body))
rec := httptest.NewRecorder()
receiver.ServeHTTP(rec, req)
if rec.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusRequestEntityTooLarge, rec.Body.String())
}
select {
case event := <-received:
t.Fatalf("unexpected event published for oversized capture: %#v", event)
default:
}
}
func TestCapturePayloadRejectsUnsafeFileContent(t *testing.T) {
const maxFileBytes = 8 * 1024 * 1024
const maxFileTextBytes = 2 * 1024 * 1024
newFilePayload := func() CapturePayload {
return CapturePayload{
SchemaVersion: 1,
CaptureID: "capture-file-validation",
CapturedAt: "2026-06-27T00:00:00.000Z",
Kind: "file",
Page: CapturePage{URL: "https://example.com"},
File: &CaptureFile{Name: "attachment.bin", Size: 1, DataBase64: "AQ=="},
}
}
tests := []struct {
name string
edit func(*CapturePayload)
want string
}{
{
name: "oversized declared size",
edit: func(payload *CapturePayload) {
payload.File.Size = maxFileBytes + 1
},
want: "file.size exceeds limit",
},
{
name: "invalid base64",
edit: func(payload *CapturePayload) {
payload.File.DataBase64 = "not base64"
},
want: "file.dataBase64 is invalid",
},
{
name: "oversized decoded data",
edit: func(payload *CapturePayload) {
payload.File.DataBase64 = base64.StdEncoding.EncodeToString(make([]byte, maxFileBytes+1))
},
want: "file.dataBase64 exceeds limit",
},
{
name: "oversized text",
edit: func(payload *CapturePayload) {
payload.File.DataBase64 = ""
payload.File.Text = strings.Repeat("x", maxFileTextBytes+1)
},
want: "file.text exceeds limit",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
payload := newFilePayload()
tt.edit(&payload)
if err := payload.Validate(); err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("Validate() error = %v, want %q", err, tt.want)
}
})
}
}

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...)
}

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