Author SHA1 Message Date
mirivlad d607209e95 Обновлены Wails биндинги: MoveWorkspace, GetFolderMetadata, SetFolderMetadata 2026-07-18 19:04:47 +08:00
mirivlad 3bf67fb1e9 Убрано log.Fatalf в main.go для workspace/v1 (теперь platform capability) 2026-07-18 18:58:26 +08:00
mirivlad 2ce87f6fb1 Убрано дублирование регистрации verstak/core/workspace/v1 2026-07-18 18:57:06 +08:00
mirivlad 0b5fd1cac5 Добавлен verstak/core/workspace/v1 в platform capabilities 2026-07-18 18:55:52 +08:00
mirivlad b10f243e34 Вложенные Дела, folder metadata, плагин workspace-folders
- Nested workspace model: маркеры .verstak/workspace.json на любой глубине
- Folder metadata API: иконка, цвет, порядок (.verstak/folder-metadata/)
- MoveWorkspace, GetFolderMetadata, SetFolderMetadata Wails биндинги
- Contribution point workspaceTree — singleton в registry
- Sidebar.svelte монтирует PluginBundleHost при наличии workspaceTree
- VerstakPluginAPI: workspaces.* и folders.* методы
- WorkspaceTree.svelte: рекурсивное дерево, выбор родительской папки
- i18n: строки EN+RU, release notes v0.1.0-alpha.8
2026-07-18 18:54:42 +08:00
mirivlad 1ca759cc2e Документация: удалены реализованные milestone-планы, обновлён PLUGIN_RUNTIME и NOTES_FILES_PLUGIN_PLAN 2026-07-18 18:06:44 +08:00
mirivlad 4ae08ef88e Use paginated blob transport for file sync 2026-07-17 05:09:41 +08:00
mirivlad e3d8078ad5 Harden core snapshot sync and workspace lifecycle 2026-07-17 04:10:59 +08:00
mirivlad ba0ba5f8c4 fix: make branded icon generation deterministic 2026-07-15 19:24:59 +08:00
mirivlad bf965e8bf3 fix: use Verstak brand icon in desktop builds 2026-07-15 19:17:13 +08:00
mirivlad 6eeebd39ad fix: restore Windows tray lifecycle 2026-07-15 18:56:22 +08:00
mirivlad 3c1f2c7177 docs: add historical release notes 2026-07-15 07:50:17 +08:00
mirivlad a9576ac141 docs: require human release notes 2026-07-15 07:42:02 +08:00
mirivlad fb4bb2a0c3 fix: localize native tray menu 2026-07-15 03:31:00 +08:00
mirivlad 76bc312bb9 fix: localize deal rename confirmation 2026-07-15 03:17:03 +08:00
mirivlad d353b5b335 docs: refresh README screenshots 2026-07-15 02:32:21 +08:00
mirivlad 3d78e0deaf fix: hide technical workbench metadata 2026-07-15 02:32:08 +08:00
mirivlad 41aeefc182 docs: describe Deals as the user-facing unit 2026-07-14 23:57:50 +08:00
mirivlad f9b20d51ff fix: style plugin manager filter selects 2026-07-14 23:47:51 +08:00
mirivlad 8be107013d feat: add plugin manager filters 2026-07-14 23:46:06 +08:00
mirivlad ddcd799f16 test: expect Deal labels in activity review 2026-07-14 23:32:08 +08:00
mirivlad 7b7b892332 test: align plugin mock terminology 2026-07-14 23:27:40 +08:00
mirivlad be3755e50e chore: remove unused plugin manager style 2026-07-14 23:25:43 +08:00
mirivlad dbfc4e8d0a fix: localize plugin capability labels 2026-07-14 23:23:24 +08:00
mirivlad cd44ee3866 test: expect Deal label in journal review 2026-07-14 23:13:59 +08:00
mirivlad a0262b97b3 fix: localize overview activity labels 2026-07-14 23:03:14 +08:00
mirivlad 2da414fb3b fix: rename Browser workspace tool 2026-07-14 22:33:43 +08:00
mirivlad 5cad11f3f9 fix: use Deal terminology in desktop shell 2026-07-14 22:18:52 +08:00
mirivlad dbef71f830 fix: refresh desktop integration on updates 2026-07-14 22:04:25 +08:00
mirivlad 963049add3 fix: localize shell operation errors 2026-07-14 22:00:24 +08:00
mirivlad 6179ad73e7 test: cover typed Todo reminder times 2026-07-14 20:50:14 +08:00
mirivlad 1e397d0211 test: cover global secrets workflow 2026-07-14 20:41:40 +08:00
mirivlad 47d38bcd4d fix: scope overview to available tools 2026-07-14 20:21:40 +08:00
mirivlad 9d06642ca6 feat: explain unavailable template tools 2026-07-14 20:10:29 +08:00
mirivlad b6290aa8ee test: cover global Journal entry assignment 2026-07-14 19:58:39 +08:00
mirivlad c3836ef1a6 test: exercise Todo reminder controls from source 2026-07-14 19:39:11 +08:00
mirivlad e223ccccc6 test: cover activity deletion confirmation 2026-07-14 19:19:11 +08:00
mirivlad fe64e1f822 test: cover secret workbench navigation 2026-07-14 19:14:01 +08:00
mirivlad fc82b3e6b1 fix: validate bundled plugin requirements 2026-07-14 19:04:03 +08:00
mirivlad 680846da72 fix: style workspace template selector 2026-07-14 08:00:38 +08:00
mirivlad d1f9157c05 docs: publish bilingual product README 2026-07-14 03:19:27 +08:00
mirivlad a00207d86e fix: keep command-line debug session-only 2026-07-14 03:19:06 +08:00
mirivlad fe912c9659 build: refresh notification Wails bindings 2026-07-14 02:59:38 +08:00
mirivlad 8e3dedf0c5 build: package Linux tray runtime 2026-07-14 02:55:52 +08:00
mirivlad d8a4213569 feat: keep Verstak running in the system tray 2026-07-14 02:54:30 +08:00
mirivlad e33c63d3ad feat: run scheduled native notifications 2026-07-14 02:42:50 +08:00
mirivlad 96dd499e0b feat: expose scheduled notifications to plugins 2026-07-14 02:37:28 +08:00
mirivlad 67753927cb feat: add persisted notification scheduler 2026-07-14 02:34:00 +08:00
mirivlad 55edaa0209 docs: make tray icons reproducible 2026-07-14 02:31:12 +08:00
mirivlad 4d6a8bd8cd docs: plan notifications tray and public readme 2026-07-14 02:29:26 +08:00
mirivlad 9fc0ff764c docs: specify native notifications and tray 2026-07-14 02:23:15 +08:00
mirivlad eb5d6b1f06 build: ignore generated Windows resources 2026-07-14 02:14:10 +08:00
mirivlad 030f66d806 build: publish portable desktop releases 2026-07-14 01:58:42 +08:00
mirivlad da76b89856 build: use system WebView2 for Windows archive 2026-07-13 22:53:03 +08:00
mirivlad 20952e8477 build: add portable desktop packages 2026-07-13 22:29:58 +08:00
mirivlad f4e911c14a build: add Windows desktop test bundle 2026-07-13 21:46:48 +08:00
mirivlad 47a8d565e8 build: publish desktop releases to GitHub 2026-07-13 05:01:34 +08:00
mirivlad 581e6b27d8 docs: document public alpha setup 2026-07-13 03:57:58 +08:00
mirivlad ca5290015a build: guard desktop release prerequisites 2026-07-12 21:38:24 +08:00
mirivlad ad0c00bade docs: prepare desktop alpha release 2026-07-12 21:21:45 +08:00
mirivlad 4db5a7a132 feat: show plugin diagnostics only in debug mode 2026-07-12 21:18:09 +08:00
mirivlad c567b229cc feat: scope overview to durable activity data 2026-07-12 21:11:53 +08:00
mirivlad 5a249dcf24 feat: persist handled activity session watermarks 2026-07-12 20:48:57 +08:00
mirivlad 6b556c59d9 feat: scope activity and browser links by durable workspace 2026-07-12 20:39:03 +08:00
mirivlad fca9fff4b4 feat: retain archived browser inbox captures 2026-07-12 17:43:12 +08:00
mirivlad bcf49ed32c feat: persist passive browser activity batches 2026-07-12 17:29:07 +08:00
mirivlad 39052ffe39 feat: add canonical hostname normalization 2026-07-12 17:16:53 +08:00
mirivlad e1a6b2af64 feat: publish workspace trash lifecycle 2026-07-12 17:11:00 +08:00
mirivlad 5280d245ee feat: add durable workspace identities 2026-07-12 17:06:34 +08:00
140 changed files with 11823 additions and 1732 deletions
+3 -1
View File
@@ -1,8 +1,10 @@
# .gitignore # .gitignore
frontend/node_modules/ frontend/node_modules/
frontend/dist/ frontend/dist/
build/bin/verstak-desktop build/
verstak-desktop-res.syso
smoke-platform smoke-platform
plugins/ plugins/
vendor/ vendor/
frontend/e2e-results/ frontend/e2e-results/
release/
+17
View File
@@ -0,0 +1,17 @@
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
+367 -2
View File
@@ -1,3 +1,368 @@
# verstak-desktop <div align="center">
Verstak Core Platform + UI Shell — plugin runtime, capability registry, vault API, event bus, sync client <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.
Desktop core maintains an atomic local sync snapshot under `.verstak/sync/`.
It scans when a vault opens, before a manual sync, and after debounced external
file-watcher events, so files changed in an editor or while Desktop was closed
are reconciled too. The watcher only speeds up discovery. A first connection
pulls before it publishes a local bootstrap: an empty local vault does not send
deletes, and incompatible files become visible conflicts rather than silent
overwrites. Pairing can optionally use an existing remote vault ID to restore
that scope on a new device.
The current transport supports ordinary files and folders plus workspace (Deal)
create/rename/trash/restore with a durable workspace UUID. `.verstak`, trash,
temporary files, and symlinks are excluded from ordinary file sync. UTF-8 text
remains inline only within the existing 2 MB bound; binary and larger content
is staged privately under `.verstak/sync/blobs`, uploaded through the scoped
Blob API, and represented in the operation log only by SHA-256 and size. The
download is streamed, hash/size-verified, and atomically applied. A file over
the configured server blob limit or otherwise unsupported remains a visible
unresolved warning instead of being treated as synchronized.
Pull is paginated. Desktop applies operations in increasing server sequence,
persists its cursor only after each successful operation, stops before later
pages at the first failure, and retries after restart. Blob references are
authorized per user/vault; a missing or corrupt blob likewise leaves the cursor
unchanged. The server is optional and file bytes are not end-to-end encrypted.
Operation-log retention/checkpoints and synchronization of Secrets, plugin
settings, Todo, Journal, Activity, and Browser Inbox remain future milestones.
## 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).
+340
View File
@@ -0,0 +1,340 @@
<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).
-403
View File
@@ -1,403 +0,0 @@
# Milestone 6a Files Core Service Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use
> `superpowers:subagent-driven-development` or `superpowers:executing-plans` to
> implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for
> tracking.
**Goal:** Add a safe backend Files core service and plugin bridge for
vault-relative text file operations.
**Architecture:** Files Core is a backend service under `internal/core/files`
that accepts vault-relative paths, enforces reserved path policy, and performs
atomic text writes. The Wails API exposes plugin-scoped methods guarded by plugin
state and `files.*` permissions; SDK types describe the bridge shape.
**Tech Stack:** Go backend services/tests, Wails-bound API methods, TypeScript
SDK type definitions, existing Playwright/Vitest checks.
---
## Implementation Status
Milestone 6a is implemented.
Actual backend package:
- `internal/core/files/types.go`
- `internal/core/files/path_policy.go`
- `internal/core/files/service.go`
- `internal/core/files/*_test.go`
Actual plugin-scoped Wails methods:
```go
func (a *App) ListVaultFiles(pluginID string, relativeDir string) ([]files.FileEntry, string)
func (a *App) GetVaultFileMetadata(pluginID string, relativePath string) (files.FileMetadata, string)
func (a *App) ReadVaultTextFile(pluginID string, relativePath string) (string, string)
func (a *App) WriteVaultTextFile(pluginID string, relativePath string, content string, options files.WriteOptions) string
func (a *App) CreateVaultFolder(pluginID string, relativePath string) string
func (a *App) MoveVaultPath(pluginID string, fromRelativePath string, toRelativePath string, options files.MoveOptions) string
func (a *App) TrashVaultPath(pluginID string, relativePath string) (files.TrashResult, string)
```
Actual bundled frontend API:
- `api.files.list(relativeDir)`
- `api.files.metadata(relativePath)`
- `api.files.readText(relativePath)`
- `api.files.writeText(relativePath, content, options)`
- `api.files.createFolder(relativePath)`
- `api.files.move(fromRelativePath, toRelativePath, options)`
- `api.files.trash(relativePath)`
Implemented limits:
- canonical vault-relative slash paths only;
- backslashes, POSIX absolute paths, Windows drive paths, UNC paths, traversal,
null bytes, and empty file paths are rejected;
- `.verstak/` is reserved case-insensitively and hidden from public Files API;
- metadata may report symlinks, but list-through-symlink and
read/write/move/trash through symlink are forbidden;
- text read/write only, with `readText` limited to UTF-8 files up to 2 MB;
- trash uses `.verstak/trash/files/<trashId>/...` with restore metadata, but
restore itself is deferred;
- binary streaming, watcher, external editor, Files UI, Notes service, sidecar,
sandbox/security isolation deferred.
---
## Scope
Implement:
- Backend Files service.
- Safe vault-relative path handling.
- Reserved `.verstak/` policy.
- List files.
- Read/write text files.
- Create folder.
- Move path.
- Trash path.
- Atomic writes.
- Backend tests.
- SDK bridge shape draft.
Do not implement:
- Full Notes plugin.
- Notes UI.
- Sync.
- Watcher.
- Binary streaming.
- External editor integration.
- Sidecar/security isolation.
## Canonical Policy
All public Files API methods use canonical vault-relative slash paths.
Rejected inputs:
- absolute paths;
- backslashes;
- Windows drive paths and UNC/network paths;
- paths containing `..` after normalization;
- null bytes;
- empty paths where a file path is required;
- access to `.verstak/` through the public plugin Files API, including
`.Verstak` case variants.
Delete behavior:
- `TrashVaultPath` moves files/folders into `.verstak/trash`.
- Trash metadata includes `originalPath`, `deletedAt`, `originalType`,
`trashId`, and `basename`.
- Permanent delete is out of scope.
- Restore is out of scope.
Write behavior:
- Text writes use a temporary file in the target directory and rename into place.
- Existing files are overwritten only when the method explicitly allows overwrite.
- Parent directory must exist unless the method explicitly creates it.
Binary behavior:
- Binary files can appear in list/metadata results.
- Binary read/write streaming is out of scope.
## Public Backend Shape
Implemented plugin-scoped Wails methods:
```go
func (a *App) ListVaultFiles(pluginID string, relativeDir string) ([]files.FileEntry, string)
func (a *App) GetVaultFileMetadata(pluginID string, relativePath string) (files.FileMetadata, string)
func (a *App) ReadVaultTextFile(pluginID string, relativePath string) (string, string)
func (a *App) WriteVaultTextFile(pluginID string, relativePath string, content string, options files.WriteOptions) string
func (a *App) CreateVaultFolder(pluginID string, relativePath string) string
func (a *App) MoveVaultPath(pluginID string, fromRelativePath string, toRelativePath string, options files.MoveOptions) string
func (a *App) TrashVaultPath(pluginID string, relativePath string) (files.TrashResult, string)
```
Permission mapping:
- `ListVaultFiles`, `GetVaultFileMetadata`, `ReadVaultTextFile`: `files.read`.
- `WriteVaultTextFile`, `CreateVaultFolder`, `MoveVaultPath`: `files.write`.
- `TrashVaultPath`: `files.delete`.
## Data Types
Create `internal/core/files/types.go`:
```go
package files
type EntryKind string
const (
KindFile EntryKind = "file"
KindDirectory EntryKind = "directory"
)
type Entry struct {
Name string `json:"name"`
Path string `json:"path"`
Kind EntryKind `json:"kind"`
Size int64 `json:"size"`
ModifiedAt string `json:"modifiedAt"`
IsText bool `json:"isText"`
IsBinary bool `json:"isBinary"`
IsHidden bool `json:"isHidden"`
IsReserved bool `json:"isReserved"`
}
```
## Task 1: Path Policy
**Files:**
- 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`
and `.verstak/...`.
- [x] Add tests:
`TestNormalizeRejectsAbsolutePath`,
`TestNormalizeRejectsTraversal`,
`TestNormalizeRejectsNullByte`,
`TestNormalizePreservesCase`,
`TestReservedPathPolicy`.
- [x] Run:
```bash
go test ./internal/core/files
```
Expected: all `internal/core/files` tests pass.
## Task 2: Files Service
**Files:**
- 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
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,
trash, overwrite false conflict, overwrite true replace, and reserved path
rejection.
- [x] Run:
```bash
go test ./internal/core/files
```
Expected: all `internal/core/files` tests pass.
## Task 3: Atomic Writes
**Files:**
- 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
or permission-denied directory.
- [x] Add test `TestWriteTextDoesNotLeaveTempFile`.
- [x] Run:
```bash
go test ./internal/core/files
```
Expected: all `internal/core/files` tests pass.
## Task 4: Permissions And Capabilities
**Files:**
- Modify: `internal/core/permissions/registry.go`
- 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
initialized.
- [x] Add API guard tests proving each Files bridge method rejects plugins that
are missing the required permission.
- [x] Run:
```bash
go test ./internal/core/permissions ./internal/api
```
Expected: permission registry and API tests pass.
## Task 5: Wails API Bridge
**Files:**
- Modify: `internal/api/app.go`
- Modify: `internal/api/app_test.go`
- Modify after Wails generation or by hand if generation is unavailable:
`frontend/wailsjs/go/api/App.d.ts`
- 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,
conflict, and missing permission.
- [x] Add tests for successful read/write/list/mkdir/move/trash through `App`.
- [x] Run:
```bash
go test ./internal/api
```
Expected: API tests pass.
## Task 6: Frontend Plugin API Draft
**Files:**
- Modify: `frontend/src/lib/plugin-host/VerstakPluginAPI.js`
- Modify: `frontend/src/lib/test/wails-mock.js`
- 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:
```bash
cd frontend
npm run build
```
Expected: frontend build passes.
## Task 7: SDK Bridge Shape Draft
**Files:**
- Modify: `../verstak-sdk/src/plugin-api.ts`
- 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
trash result shape.
- [x] Run:
```bash
cd ../verstak-sdk
./scripts/check.sh
./scripts/build.sh
./scripts/test.sh
```
Expected: SDK check, build, and tests pass.
## Task 8: Documentation
**Files:**
- 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.
## Task 9: Final Verification
- [x] Run desktop backend tests:
```bash
cd verstak-desktop
go test ./...
```
- [x] Run desktop frontend build:
```bash
cd verstak-desktop/frontend
npm run build
```
- [x] Run desktop e2e:
```bash
cd verstak-desktop/frontend
npm run test:e2e -- --reporter=list
```
- [x] Run official plugins checks:
```bash
cd verstak-official-plugins
./scripts/check.sh
./scripts/build.sh
```
- [x] Run SDK checks:
```bash
cd verstak-sdk
./scripts/check.sh
./scripts/build.sh
./scripts/test.sh
```
Expected: all commands exit 0. Existing Svelte unused CSS warnings are acceptable
only if they remain warnings and do not fail the build.
-148
View File
@@ -1,148 +0,0 @@
# Milestone 6b - Open/Edit Provider Registry + Workbench Routing Skeleton
This milestone adds the minimal infrastructure layer for open/edit routing before
Files UI, Notes UI, or editor implementation starts. It does not implement those
plugins and does not add a concrete core-owned Markdown editor.
## Existing Architecture Found
| Path | Section/title | Summary |
|---|---|---|
| `../verstak-docs/00_README.md` | Main architecture invariant | Core does not know concrete notes, file manager, or markdown editor features. It owns vault, plugin runtime, capability registry, contribution points, permissions, settings, events, storage, and UI shell. |
| `../verstak-docs/01_Product_Vision.md` | What is not in core / platform goal | Markdown editor, file manager, preview, and notes workflow are plugins. Users should be able to replace the markdown editor or install multiple editors. |
| `../verstak-docs/02_Platform_Architecture.md` | UI Shell / Capability Registry | UI Shell knows contribution points, not concrete note editor or file preview implementations. Files plugin checks editor/viewer capabilities instead of depending on `official.markdown-editor`. |
| `../verstak-docs/03_Repositories.md` | Repository split | `verstak-desktop` is Core Platform + UI Shell and does not contain mandatory notes, file manager, or markdown editor modules. Official plugins live separately. |
| `../verstak-docs/04_Plugin_System.md` | Goal / capabilities instead of plugin names / contribution points | Notes, file manager, editor, and viewer are plugin functions. The docs explicitly reject `requires: ["official.markdown-editor"]` and prefer `optionalRequires: ["editor.text.markdown"]`. |
| `../verstak-docs/05_Official_Plugins.md` | official.files / official.notes / official.markdown-editor | Files optionally depends on editor/viewer capabilities. Markdown editor provides `editor.text`, `editor.text.markdown`, and `editor.note.markdown`, and must not own note storage or depend directly on `official.notes`. |
| `../verstak-docs/06_Migration_Strategy.md` | Do not / Definition of Done | Do not make notes/files/editor mandatory core parts. The platform transition is done only when notes/files/editor/preview/activity work as plugins. |
| `docs/NOTES_FILES_PLUGIN_PLAN.md` | Canonical Notes Model | Notes are ordinary Markdown files in canonical `Notes/` folders. No lowercase `notes`, no `.verstak/notes`, no plugin-data note content, no UUID-only filenames. |
| `docs/NOTES_FILES_PLUGIN_PLAN.md` | Files Service Model | Files Core is raw vault file access and does not understand note semantics. Milestone 6a exposes safe text file methods and defers UI, watcher, binary streaming, external editor, and restore. |
| `docs/PLUGIN_RUNTIME.md` | Contribution Points / Bundled Frontend Plugin API | Current runtime hosts views/sidebar/settings/commands and exposes plugin-scoped Files API. `fileActions`, `noteActions`, `contextMenuEntries`, search/activity/status bar entries are registered but not hosted. |
| `internal/core/contribution/registry.go` | Contribution registry | Registry has the established contribution points and now extends that model with `openProviders`. |
| `internal/core/plugin/plugin.go` | Manifest contributions | Plugin manifest types now include `openProviders` alongside existing contribution points. |
| `frontend/src/lib/shell/WorkbenchHost.svelte` and `frontend/src/lib/plugin-host/PluginBundleHost.svelte` | Frontend plugin host | Workbench mounts the selected provider component by plugin id and component id. It remains generic and does not know a concrete editor. |
| `frontend/src/lib/plugin-host/VerstakPluginAPI.js` | Plugin API | Bundled plugins can call `api.workbench.openResource()` and `api.workbench.editResource()` in addition to settings, capabilities, events, commands, and Files API. |
| `../verstak-sdk/src/types.ts` and `../verstak-sdk/schemas/manifest.json` | SDK contribution contracts | SDK types/schema define `openProviders`, `OpenResourceRequest`, provider supports, and `files.*` permissions. |
## What Matches The Desired Model
- Files/Notes/Editor are already documented as plugins, not core modules.
- Official plugins are expected to use the same runtime as community plugins.
- Files plugin is already documented as capability-driven, not hard-wired to a markdown editor.
- Markdown editor is already documented as replaceable via capabilities.
- Notes are already documented as Markdown files under canonical `Notes/` folders, without `.verstak/notes`, UUID note entities, or a second storage truth.
- Desktop code has no hardcoded Markdown editor component.
- Existing plugin host can mount arbitrary plugin components, which is enough foundation for an editor provider host.
## Contradictions Found
- `docs/NOTES_FILES_PLUGIN_PLAN.md` still said Files Core API/capability/permissions were unavailable, while later sections and code show Milestone 6a implemented them.
- `../verstak-docs/05_Official_Plugins.md` calls notes "first-class Verstak entities". That is acceptable only as UI semantics; implementation must not create a separate note storage entity.
- `../verstak-docs/05_Official_Plugins.md` lists "Open externally" as a Files fallback. External open remains deferred and must not enter Milestone 6b.
- `../verstak-sdk/schemas/manifest.json` previously did not include `files.read`, `files.write`, or `files.delete` in the permissions enum, while SDK TS types, desktop permissions, and official `platform-test` manifest already used them. Milestone 6b resolves this 6a contract cleanup item.
## Missing Before 6b
- `openProviders` contribution point.
- `OpenResourceRequest` contract.
- Workbench open/edit routing API for Files/Notes plugins.
- Provider selection model using resource kind, extension/mime, notes context, user preference, provider priority, deterministic fallback, and disabled provider fallback.
- User preferences for default text editor provider, default markdown editor provider, and default notes-context markdown editor provider.
- Host slot/tab that mounts the selected provider component with an open resource request.
- Tests for provider registration, selection, preferences, disabled provider fallback, and notes-context routing.
## Added In 6b
- `contributes.openProviders` in SDK schema/types and desktop manifest structs.
- Desktop contribution registry support for registering, replacing, listing, and
unregistering open providers.
- `OpenResourceRequest`, `OpenResourceContext`, `OpenResourceResult`, and
opened-resource state in the Workbench routing skeleton.
- Provider selection by resource kind, extension/mime, `generic-text`,
`generic-markdown`, or `notes-markdown` context, user preference, priority,
deterministic `pluginId/providerId` fallback, and active plugin filtering.
- `workbench.open` policy permission for plugins that request Workbench routing.
- Draft app settings preferences for default text, markdown, and notes-context
markdown providers.
- `api.workbench.openResource()` and `api.workbench.editResource()` exposed to
frontend plugin bundles.
- Minimal Workbench host that mounts the selected provider component from the
selected provider plugin.
- `no-provider` fallback state when no matching provider exists.
- `platform-test` diagnostic open provider used only to prove routing.
## Decision
Open/edit provider should be an extension of the existing contribution registry,
not a parallel system. It should add `openProviders` beside `views`, `commands`,
`fileActions`, and `noteActions`.
Capabilities remain useful for broad availability and degraded mode, but they are
too coarse for choosing between multiple providers. Provider selection needs a
declarative provider contribution with `supports`, priority, and component id.
## Correct Notes Model
- Notes are a contextual view over ordinary Markdown files under canonical
`Notes/` folders.
- `.md` inside `Notes/` opens through the selected markdown editor with
notes-context.
- `.md` outside `Notes/` opens through the selected markdown editor in generic
markdown mode.
- Plain text opens through the selected text editor provider in `generic-text`
context.
- Files and Notes call open/edit resource; neither embeds a concrete editor.
- Editor provider selection belongs to Workbench/provider registry.
- No `.verstak/notes`, no UUID note entities, no second truth separate from the
`.md` file.
## Minimal Infrastructure Changes
1. Add `openProviders` to SDK manifest/schema/types.
2. Add `openProviders` to desktop plugin manifest structs and contribution
registry.
3. Add `OpenResourceRequest` and provider support match types.
4. Add Workbench/provider selection service with deterministic rules.
5. Add user preferences for `defaultTextEditorProvider`,
`defaultMarkdownEditorProvider`, and `defaultNotesMarkdownEditorProvider`.
6. Add frontend host plumbing so Workbench can mount the selected plugin
component.
7. Fix `verstak-sdk/schemas/manifest.json` permissions enum for `files.*`.
## Proposed Milestone 6b Scope
In scope:
- Contribution/types/schema support for `openProviders`.
- Workbench `openResource`/`editResource` routing API.
- Provider selection with notes context and user preferences.
- Minimal host tab/slot for provider component mounting.
- Diagnostic provider plugin contribution sufficient to prove routing.
- Tests for routing, provider selection, disabled fallback, and notes-context
markdown.
Out of scope:
- Full Files UI feature set.
- Full Notes UI feature set.
- Full editor implementation.
- Real default editor plugin (Milestone 6c).
- Files plugin open/edit integration.
- Notes plugin open/edit integration.
- Hardcoded core Markdown editor.
- Watcher/sync/binary streaming/external editor.
- Sidecar/security boundary.
- Large rewrite.
## 6b-fix: Infrastructure Gaps Closed
Post-review fixes before Milestone 6c:
- Added `openProviders` to `verstak-sdk/schemas/contributions.json` contribution points.
- Added provider matching tests: text/markdown preference, MIME matching, extension
case-insensitivity, multiple supports entries, kind mismatch.
- Added disabled provider exclusion test in `api/app_test.go`.
- Fixed SDK `build.sh`/`test.sh` to detect incomplete `node_modules`.
- Documented disabled provider lifecycle: contributions remain in registry until
ReloadPlugins; `activeOpenProviders()` filters disabled/unloaded at request time.
- Installed Playwright browsers for E2E tests.
-65
View File
@@ -1,65 +0,0 @@
# Milestone 6c — Default Editor Plugin
## Goal
Create the official Default Editor Plugin as an openProvider for text, generic
markdown, and notes-context markdown files. Core desktop does not own or import
the editor component.
## What was built
### Plugin: `verstak.default-editor`
Location: `verstak-official-plugins/plugins/default-editor/`
**Manifest declares 3 openProviders:**
| Provider ID | Context | Extensions |
|-------------|---------|------------|
| `verstak.default-editor.text` | `generic-text` | `.txt`, `.log`, `.conf`, `.ini`, `.toml`, `.yaml`, `.yml`, `.json`, `.csv` |
| `verstak.default-editor.markdown` | `generic-markdown` | `.md`, `.markdown` |
| `verstak.default-editor.notes-markdown` | `notes-markdown` | `.md`, `.markdown` |
All providers use the same `DefaultEditor` component (unified, not 3 separate editors).
**Permissions:** `files.read`, `files.write`, `workbench.open`
**Capabilities required:** `verstak/core/files/v1`, `verstak/core/workbench/v1`
### Frontend component: `DefaultEditor`
- **Modes:** text (textarea), generic-markdown (editor + preview), notes-markdown (editor + preview + notes badge)
- **File loading:** `api.files.readText(path)` with loading/error states
- **Saving:** `api.files.writeText(path, content, { overwrite: true })` with dirty/saved/error states
- **Keyboard:** Ctrl+S / Cmd+S save, Tab indentation
- **Markdown preview:** Simple renderer (no raw HTML, no script injection)
- **Notes context:** Badge + info bar, no separate note entity, no `.verstak/notes`
## Verification
- `go test ./...` — PASS
- `go vet ./...` — PASS
- `npm run build` (frontend) — PASS
- `npm run test:e2e` — 28/28 PASS (20 existing + 8 new)
- `scripts/check.sh` (official-plugins) — PASS
- `scripts/build.sh` (official-plugins) — PASS
- SDK checks — PASS
## Manual testing
Until Files UI plugin exists, use platform-test diagnostics panel to manually
open files via workbench: click "Open Text Diagnostic", "Open Markdown Diagnostic",
or "Open Notes Diagnostic" buttons. These call `api.workbench.editResource` and
route through the default-editor provider.
## Deferred
- CodeMirror/Monaco editor
- Backlinks, internal link navigation
- Secret widgets
- Image asset pipeline
- Files UI plugin
- Notes UI plugin
- Watcher/sync
- External open
- Sidecar/security isolation
-64
View File
@@ -1,64 +0,0 @@
# Milestone 6d — Minimal Files Plugin
## Goal
Create a minimal Files plugin that shows vault files/folders and opens files
through Workbench openResource. No editor embedded.
## What was built
### Plugin: `verstak.files`
Location: `verstak-official-plugins/plugins/files/`
**Contributions:**
- views: `verstak.files.view` → `FilesView` component
- No sidebarItems — Files is not a global sidebar item
**Permissions:** `files.read`, `files.write`, `workbench.open`, `ui.register`
### Files View
- Root listing on mount
- Folder navigation (double-click)
- File open via `api.workbench.openResource()`
- Breadcrumb navigation
- Create folder/file buttons
- Refresh button
- Loading/error/empty states
- `.verstak` filtered out
### Provider priority
- default-editor: priority 50
- platform-test diagnostic: priority 10
- default-editor wins for normal file opens
### Bundle fix
Fixed missing opening quote in STYLES string (`.files-empty` → `'.files-empty'`).
Added automated bundle execution check to `scripts/check.sh`.
## 6d-hotfix
- Removed sidebarItems from Files plugin (Files is not a global sidebar item)
- Added `[frontend bundle execution]` check to `check.sh` — verifies all plugin
bundles parse via `new Function()` and register via `VerstakPluginRegister`
- Updated E2E tests: Files no longer expected in global sidebar
- Documented: sidebarItems are global shell navigation, not workspace template tabs
## Verification
- `go test ./...` — PASS
- `go vet ./...` — PASS
- `npm run build` — PASS
- `npm run test:e2e` — 34/34 PASS
- Official plugins — 3 plugins built, bundle execution check passes
- SDK — 11/11 tests pass
## Deferred
- Notes plugin, rename/move/trash UI, drag-and-drop, context menu,
watcher/inotify, sync, external open, binary streaming, sidecar/security,
workspace template host (Milestone 6d2)
+20 -29
View File
@@ -1,39 +1,30 @@
# Notes/Files Plugin Architecture Plan # Notes/Files Plugin Architecture Plan
This document locks the Notes/Files/Open architecture for the next milestones. This document captures the architectural decisions for Notes, Files, and
Files Core Service was implemented in Milestone 6a; this document still does not Workbench routing. Most items described below are now implemented; this
start Notes UI, Notes plugin, Files UI plugin, editor plugin, watcher, sync, or document remains as a reference for the canonical data model and rules.
binary streaming.
## Current Readiness ## Current Readiness
The platform is ready for bundled plugin UI experiments. Files Core is available All key components are implemented:
as a safe vault-scoped text file API. Notes, Files UI, and editor/viewer UI still
need plugin-level implementations and host surfaces before real product use.
Already available: - Files Core API with safe vault-scoped text and binary file operations.
- Notes plugin as a semantic view over canonical `Notes/` Markdown files.
- Plugin discovery, lifecycle, settings, capabilities, bundled commands, and - Files UI plugin with folder navigation and Workbench-based file opening.
bundled frontend events. - Default Editor plugin providing text/markdown openProviders.
- Workspace lifecycle APIs for top-level physical folders under the vault root. - File Preview plugin for images.
- Plugin-owned internal storage directories: - Workbench open/edit routing with provider selection.
`.verstak/plugin-data/<pluginId>`, `.verstak/plugin-settings/<pluginId>`, and
`.verstak/plugin-cache/<pluginId>`.
- Contribution registry entries for `fileActions`, `noteActions`,
`contextMenuEntries`, `searchProviders`, `activityProviders`, and
`statusBarItems`.
Not available yet:
- Notes plugin/API as a semantic view over Markdown files.
- Files UI plugin.
- Editor/viewer plugin.
- Open/edit resource routing and provider selection.
- UI hosts for file actions, note actions, context menus, search providers, - UI hosts for file actions, note actions, context menus, search providers,
activity providers, or status bar items. activity providers, and status bar items.
- Watcher/indexer for external filesystem changes. - File watcher for external filesystem changes.
- Real plugin isolation. Current permission checks are contract/policy checks, - Trash plugin for restore and permanent delete.
not a security boundary for bundled frontend JavaScript.
Not yet available:
- Real plugin isolation (bundled plugins run in shared JS context;
sidecar/sandbox is a future milestone).
- Chunked streaming/large-file import (bounded to 2 MB text / 8 MB bytes).
- Production-grade auto-update and release channel.
## Canonical Notes Model ## Canonical Notes Model
+86 -54
View File
@@ -464,7 +464,7 @@ contributions summary.
Conflicts are rejected unless `options.overwrite` is true. Conflicts are rejected unless `options.overwrite` is true.
- `files.deleteTrash(trashId)` — permanently removes a file/folder already in - `files.deleteTrash(trashId)` — permanently removes a file/folder already in
internal trash. This operation cannot be undone. internal trash. This operation cannot be undone.
- UI policy: the workspace `Files` plugin displays only live files and folders. - UI policy: the Deal `Files` plugin displays only live files and folders.
The global official `verstak.trash` plugin owns listing, restoring, filtering, The global official `verstak.trash` plugin owns listing, restoring, filtering,
and permanently deleting deleted items across the vault. and permanently deleting deleted items across the vault.
- `files.openExternal(relativePath)` — opens a vault-relative file/folder in - `files.openExternal(relativePath)` — opens a vault-relative file/folder in
@@ -477,8 +477,8 @@ contributions summary.
- All paths are canonical vault-relative slash paths. Backslashes, POSIX - All paths are canonical vault-relative slash paths. Backslashes, POSIX
absolute paths, Windows drive paths, UNC/network paths, `..`, null bytes, absolute paths, Windows drive paths, UNC/network paths, `..`, null bytes,
symlink traversal, and public access to `.verstak/` are rejected. symlink traversal, and public access to `.verstak/` are rejected.
- `.verstak` is reserved case-insensitively: `.verstak`, `.Verstak`, and any - `.verstak` is reserved case-insensitively in every path segment: its marker,
first path segment with that spelling are internal-only. sync snapshot, trash, and workspace identity are internal-only.
- `files.metadata` may report a final symlink as `type: "symlink"`, but - `files.metadata` may report a final symlink as `type: "symlink"`, but
`files.list` through a symlink directory and all read/write/move/trash `files.list` through a symlink directory and all read/write/move/trash
operations through symlinks are forbidden in Milestone 6a. operations through symlinks are forbidden in Milestone 6a.
@@ -486,20 +486,39 @@ contributions summary.
`writeBytes` are bounded byte contracts up to 8 MB; chunked streaming is `writeBytes` are bounded byte contracts up to 8 MB; chunked streaming is
deferred. deferred.
- Live watcher refresh is active while Verstak is running and a vault is open. - 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 It publishes `file.changed` as a UI hint and debounces a full core scanner;
external creates, updates, and deletes outside `.verstak/`. It does not keep a the persistent `.verstak/sync/snapshot.json` scanner, not the watcher, is the
persistent snapshot or report what changed while Verstak was closed. source of truth. It runs on open, before manual sync, after watcher events,
and therefore detects changes made while Verstak was closed. Internal paths,
trash, temporary files, and symlinks are excluded.
`sync` `sync`
- `sync.now()` pushes local operations, pulls remote operations, and returns - `sync.now()` scans local files, pulls/reconciles in `server_sequence` order,
`{ pushed, pulled, serverSequence, conflicts?, applyErrors? }`. then pushes local operations and returns `{ pushed, pulled, serverSequence,
conflicts? }`. A remote apply failure stops the batch immediately, keeps that
operation and later sequences unacknowledged, and retries it on the next run.
- `conflicts` is an array of server-reported sync conflicts. Conflict objects - `conflicts` is an array of server-reported sync conflicts. Conflict objects
may include `op_id`, `entity_type`, `entity_id`, `reason`, and additional may include `op_id`, `entity_type`, `entity_id`, `reason`, and additional
server fields. The Sync plugin must show conflict details instead of only a server fields. The Sync plugin must show conflict details instead of only a
count, and it must not silently resolve or overwrite local data. count, and it must not silently resolve or overwrite local data.
- `applyErrors` lists local apply failures for pulled operations. These are - `sync.status()` includes `vaultId` (the paired remote scope) and
user-visible warnings and do not imply that sync was fully successful. `lastWarning` for unresolved scanner input. A first connection pulls before
bootstrap: an empty local snapshot never publishes deletes, and incompatible
local/remote content is an explicit conflict rather than an overwrite.
- File and folder operations come from the scanner. An external rename is
intentionally represented as delete + create in this milestone. Workspace
lifecycle is a separate core `workspace` entity (`create`, `rename`,
`trash`, `restore`) carrying the durable `workspaceId`; plugins never obtain
access to the nested workspace marker.
- File transport remains bounded: UTF-8 text uses the 2 MB Files API limit;
binary and larger regular files are staged privately, sent through Blob API,
and referenced by SHA-256/size rather than base64 in `payload_json`. Desktop
streams and verifies downloads before atomic apply. Pull is paginated and the
durable cursor advances only after every applied sequence. Files above the
configured blob limit or otherwise unsupported remain unresolved warnings.
Operation retention/checkpoints and synchronization of Secrets, plugin
settings, Todo, Journal, Activity, and Browser Inbox are future work.
- Transport push/pull uses bounded retry/backoff for transient HTTP/network - Transport push/pull uses bounded retry/backoff for transient HTTP/network
failures. Client/auth errors are not retried. failures. Client/auth errors are not retried.
@@ -585,8 +604,8 @@ bundled runtime. Это реальный runtime contract для cooperative bun
| `api.files.showInFolder(relativePath)` | ✅ Работает | Показывает vault file/folder в системном файловом менеджере, требует `files.openExternal` | | `api.files.showInFolder(relativePath)` | ✅ Работает | Показывает vault file/folder в системном файловом менеджере, требует `files.openExternal` |
| `api.workbench.openResource(request)` | ✅ Работает | Routes vault resources to `openProviders` | | `api.workbench.openResource(request)` | ✅ Работает | Routes vault resources to `openProviders` |
| `api.workbench.editResource(request)` | ✅ Работает | Same routing, forcing `mode: "edit"` | | `api.workbench.editResource(request)` | ✅ Работает | Same routing, forcing `mode: "edit"` |
| `api.sync.now()` | ✅ Работает | Push/pull с bounded retry/backoff для transient HTTP/network failures | | `api.sync.now()` | ✅ Работает | Snapshot scan, строгий ordered pull/retry и bounded retry/backoff для transient HTTP/network failures |
| `api.sync.status()` | ✅ Работает | Возвращает configured/connected/error/revoked state, lastError, unpushed count | | `api.sync.status()` | ✅ Работает | Возвращает configured/connected/error/revoked, remote `vaultId`, lastError/lastWarning и unpushed count |
| `api.dispose()` | ✅ Работает | Очищает command handlers и event subscriptions текущего API instance | | `api.dispose()` | ✅ Работает | Очищает command handlers и event subscriptions текущего API instance |
Ограничения: Ограничения:
@@ -635,7 +654,7 @@ bundled runtime. Это реальный runtime contract для cooperative bun
1. Unregister contributions всех plugins предыдущего discovery. 1. Unregister contributions всех plugins предыдущего discovery.
2. Unregister all non-core capabilities. 2. Unregister all non-core capabilities.
3. Re-register core capabilities + vault + workspace (если открыт). 3. Re-register core capabilities + vault + Deal manager (если открыт).
4. Re-scan discovery directories и повторить capability resolution. 4. Re-scan discovery directories и повторить capability resolution.
5. Register contributions для loaded/degraded plugins (disabled/failed — не регистрируются). 5. Register contributions для loaded/degraded plugins (disabled/failed — не регистрируются).
6. Update plugins list. 6. Update plugins list.
@@ -710,6 +729,17 @@ not-created ──CreateVault──▶ open ──CloseVault──▶ closed
| `internal/core/permissions/registry.go` | PermissionsRegistry | | `internal/core/permissions/registry.go` | PermissionsRegistry |
| `internal/core/events/bus.go` | EventBus | | `internal/core/events/bus.go` | EventBus |
| `internal/api/app.go` | Wails API, ReloadPlugins | | `internal/api/app.go` | Wails API, ReloadPlugins |
| `internal/core/files/` | Files Core API: safe path policy, text/binary read/write, move, trash |
| `internal/core/filewatcher/` | Live filesystem watcher for external changes |
| `internal/core/workbench/` | Workbench open/edit provider routing |
| `internal/core/workspace/` | Workspace manager: create, rename, trash, templates, identity |
| `internal/core/notes/` | Notes file utilities (title↔filename, conflict detection) |
| `internal/core/sync/` | Sync scanner, snapshot, reconciliation, operation recording |
| `internal/core/secrets/` | AES-GCM secret store |
| `internal/core/browserreceiver/` | Local HTTP receiver for browser extension |
| `internal/core/notifications/` | Native desktop notifications |
| `internal/core/hostname/` | Hostname normalization for browser domain activity |
| `internal/core/externalopen/` | Platform-specific external file/folder opening |
| `internal/core/vault/vault.go` | Vault service: CreateVault, OpenVault, CloseVault, ResolveSafePath, plugin namespace paths | | `internal/core/vault/vault.go` | Vault service: CreateVault, OpenVault, CloseVault, ResolveSafePath, plugin namespace paths |
|| `internal/core/vault/vault_test.go` | Vault tests: layout creation, open/close, path traversal, events | || `internal/core/vault/vault_test.go` | Vault tests: layout creation, open/close, path traversal, events |
|| `internal/core/storage/api.go` | Plugin storage API: settings/data/cache JSON with namespace isolation | || `internal/core/storage/api.go` | Plugin storage API: settings/data/cache JSON with namespace isolation |
@@ -838,51 +868,52 @@ Vault plugin state хранится **внутри vault** в `.verstak/plugins.
- `./scripts/smoke-platform.sh` — ✅ (enable/disable/plugins.json) - `./scripts/smoke-platform.sh` — ✅ (enable/disable/plugins.json)
- `./scripts/build.sh` — ✅ - `./scripts/build.sh` — ✅
## Workspace Core Capability ## Дело: core capability
Workspace — это физическая папка верхнего уровня внутри vault root. Filesystem Дело — это физическая папка верхнего уровня внутри vault root. Filesystem
является source of truth для списка workspaces. является source of truth для списка Дел.
Пример: Пример:
``` ```
<vault>/ <vault>/
Workspace/ Deal/
Notes/ Notes/
Project/ Project/
ClientA/ ClientA/
.verstak/ .verstak/
``` ```
Нет единого `<vault>/Workspace/` контейнера для всех workspaces. Папка Нет единого `<vault>/Deal/` контейнера для всех Дел. Папка `Deal/` может быть
`Workspace/` может быть обычным workspace, но `Project/` и `ClientA/` являются обычным Делом, но `Project/` и `ClientA/` являются такими же Делами на том же
такими же workspace на том же уровне. уровне.
### Хранение ### Хранение
Workspace existence/list хранится только в filesystem: Существование и список Дел хранятся только в filesystem:
- `ListWorkspaces()` читает top-level directories из vault root. - `ListWorkspaces()` читает top-level directories из vault root.
- `.verstak`, reserved/internal directories, top-level files и symlinks не - `.verstak`, reserved/internal directories, top-level files и symlinks не
считаются workspaces. считаются Делами.
- `.verstak/workspace*.json` не является source of truth для списка workspaces. - `.verstak/workspace*.json` не является source of truth для списка Дел.
- Нет persisted workspace path mapping и нет virtual workspace tree, которое - Нет virtual workspace tree, которое
мапится на произвольные папки. мапится на произвольные папки.
`.verstak` может хранить только metadata, которая не заменяет filesystem: `.verstak` может хранить только metadata, которая не заменяет filesystem:
- UI state: selected workspace, expanded folders, sort/pin state, preferences. - UI state: выбранное Дело, expanded folders, sort/pin state, preferences.
- Semantic snapshot: applied template snapshot, enabled feature areas, exact - Semantic snapshot: applied template snapshot, enabled feature areas, exact
`workspaceTools`, and folder conventions. `workspaceTools`, and folder conventions.
Template snapshot копируется в metadata при создании workspace. Workspace При создании Дела runtime генерирует UUID и сохраняет его в
identity при этом остаётся именем top-level folder; `metadata.workspaceName` `.verstak/workspace.json`. `workspaceId` — постоянная личность Дела;
является presentation/snapshot field, not canonical identity. Если сохранённое `workspaceRootPath` и `workspaceName` — изменяемый адрес и presentation fields.
значение расходится с именем папки, runtime возвращает canonical `workspaceName` Переименование обновляет путь, но не ID. Новая папка с прежним именем получает
равным имени папки без filesystem side effects. другой ID и не может перехватить старые привязки.
```json ```json
{ {
"workspaceId": "1eb7cc69-52e8-4a18-ae2a-50d5229c5b60",
"workspaceName": "Project", "workspaceName": "Project",
"createdFromTemplate": { "createdFromTemplate": {
"templateId": "project", "templateId": "project",
@@ -921,25 +952,25 @@ identity при этом остаётся именем top-level folder; `metada
} }
``` ```
Если original template удалён или изменён позже, существующий workspace Если original template удалён или изменён позже, существующее Дело
открывается по сохранённому snapshot и не мутирует автоматически. Template открывается по сохранённому snapshot и не мутирует автоматически. Template
update/migration может быть только явной future feature. Если metadata update/migration может быть только явной future feature. Если metadata
отсутствует, workspace открывается как generic workspace минимум с `files: true`. отсутствует, Дело открывается как generic Deal минимум с `files: true`.
### API ### API
- `ListWorkspaces()` — список top-level physical folders. - `ListWorkspaces()` — список top-level папок Дел с их `workspaceId`.
- `ListWorkspaceTemplates()` — selectable built-in templates с presentation - `ListWorkspaceTemplates()` — selectable built-in templates с presentation
metadata и `workspaceTools`. metadata и `workspaceTools`.
- `CreateWorkspace(name, templateId?)` — создать `<vault>/<name>/`, применить - `CreateWorkspace(name, templateId?)` — создать `<vault>/<name>/`, применить
template один раз, сохранить snapshot metadata. template один раз, сохранить snapshot metadata.
- `RenameWorkspace(oldName, newName)` — физически переименовать top-level folder - `RenameWorkspace(oldName, newName)` — физически переименовать top-level папку
и обновить metadata key/name. Дела и обновить metadata name, не меняя `workspaceId`.
- `TrashWorkspace(name)` — перенести весь top-level workspace folder в internal - `TrashWorkspace(name)` — перенести весь top-level folder Дела в internal
trash policy. trash policy.
- `GetWorkspaceMetadata(name)` — прочитать metadata или вернуть generic fallback. - `GetWorkspaceMetadata(name)` — прочитать metadata или вернуть generic fallback.
- `UpdateWorkspaceMetadata(name, patch)` — обновить metadata без влияния на - `UpdateWorkspaceMetadata(name, patch)` — обновить metadata без влияния на
существование workspace. существование Дела.
Deprecated compatibility APIs: Deprecated compatibility APIs:
@@ -949,7 +980,7 @@ Deprecated compatibility APIs:
- `ArchiveWorkspaceNode(...)` — wrapper over `TrashWorkspace`. - `ArchiveWorkspaceNode(...)` — wrapper over `TrashWorkspace`.
- `MoveWorkspaceNode(...)` — unsupported; old nested/mapped moves are rejected. - `MoveWorkspaceNode(...)` — unsupported; old nested/mapped moves are rejected.
- `GetCurrentWorkspaceNode()` / `SetCurrentWorkspaceNode(...)` — wrappers over - `GetCurrentWorkspaceNode()` / `SetCurrentWorkspaceNode(...)` — wrappers over
selected top-level workspace UI state. selected top-level Deal UI state.
Эти методы существуют только для постепенного frontend/Wails cleanup. Они не Эти методы существуют только для постепенного frontend/Wails cleanup. Они не
должны создавать или сохранять nested workspace tree и не должны восстанавливать должны создавать или сохранять nested workspace tree и не должны восстанавливать
@@ -957,32 +988,33 @@ Deprecated compatibility APIs:
### Capability ### Capability
`verstak/core/workspace/v1` — регистрируется только когда vault открыт и workspace инициализирован. `verstak/core/workspace/v1` — техническое имя capability; оно регистрируется
только когда vault открыт и Deal manager инициализирован.
### Правила ### Правила
- Workspace name — один safe folder name, не path. - Имя Дела — один safe folder name, не path.
- Reject: empty, slash, backslash, absolute-looking paths, `..`, null byte, - Reject: empty, slash, backslash, absolute-looking paths, `..`, null byte,
`.verstak`, reserved/internal names, symlink workspaces, conflicts. `.verstak`, reserved/internal names, symlink Дел, conflicts.
- WorkspaceItems получают `workspaceRootPath`, равный имени top-level папки - WorkspaceItems получают техническое поле `workspaceRootPath`, равное имени top-level папки
(`Project`, `ClientA`, etc). Files plugin показывает именно эту папку. (`Project`, `ClientA`, etc). Files plugin показывает именно эту папку.
- Files API остаётся raw vault-relative API: `Project/Notes/example.md`, - Files API остаётся raw vault-relative API: `Project/Notes/example.md`,
`Project/docs/file.md`, `Test/readme.md`. `Project/docs/file.md`, `Test/readme.md`.
- Notes are ordinary Markdown files under `<workspace>/Notes/`; нет - Notes are ordinary Markdown files under `<Deal>/Notes/`; нет
`.verstak/notes`, UUID note storage или второго source of truth для note `.verstak/notes`, UUID note storage или второго source of truth для note
content. content.
### Lifecycle Events ### Lifecycle Events
Runtime publishes workspace lifecycle events after successful operations: Runtime publishes lifecycle events Дел после успешных операций:
- `workspace.created` — payload includes `operation: "create"`, - `workspace.created` — payload includes `operation: "create"`, `workspaceId`,
`workspaceRootPath`, `workspaceName`, and optional `templateId`. `workspaceRootPath`, `workspaceName`, and optional `templateId`.
- `workspace.renamed` — payload includes `operation: "rename"`, new - `workspace.renamed` — payload includes `operation: "rename"`, `workspaceId`,
`workspaceRootPath` / `workspaceName`, and previous workspace fields. new `workspaceRootPath` / `workspaceName`, and previous path fields.
- `workspace.trashed` — payload includes `operation: "trash"`, - `workspace.trashed` — payload includes `operation: "trash"`, `workspaceId`,
`workspaceRootPath`, `workspaceName`, `trashId`, `trashPath`, and `deletedAt`. `workspaceRootPath`, `workspaceName`, `trashId`, `trashPath`, and `deletedAt`.
- `workspace.selected` — payload includes `operation: "select"`, - `workspace.selected` — payload includes `operation: "select"`, `workspaceId`,
`workspaceRootPath`, and `workspaceName`. `workspaceRootPath`, and `workspaceName`.
Official Activity subscribes to these events and stores them through the normal Official Activity subscribes to these events and stores them through the normal
@@ -990,11 +1022,11 @@ activity provider path.
### UI ### UI
Workspace list в sidebar: Список Дел в sidebar:
- Flat list of top-level workspace folders. - Flat list of top-level папок Дел.
- Create workspace, rename workspace, trash workspace. - Создать Дело, переименовать Дело, переместить Дело в корзину.
- Selection is stored as selected workspace name. - Selection is stored as selected Deal name.
- No expand/collapse workspace tree and no case/folder node creation in core. - No expand/collapse tree and no case/folder node creation in core.
--- ---
+11 -11
View File
@@ -1,12 +1,12 @@
# Workspace Templates # Deal Templates
Workspace templates choose which dynamic plugin workspace tabs are visible when a Deal templates choose which dynamic plugin Deal tabs are visible when a new
new top-level workspace is created. They do not enable or disable plugins for the top-level Deal is created. They do not enable or disable plugins for the
whole vault and do not affect global sidebar tools. whole vault and do not affect global sidebar tools.
## Built-in templates ## Built-in templates
| Template | Workspace tabs | | Template | Deal tabs |
| --- | --- | | --- | --- |
| General | Notes, Files, Journal, Activity, Browser Inbox | | General | Notes, Files, Journal, Activity, Browser Inbox |
| Project | Notes, Files, Todos, Journal, Activity, Browser Inbox | | Project | Notes, Files, Todos, Journal, Activity, Browser Inbox |
@@ -14,27 +14,27 @@ whole vault and do not affect global sidebar tools.
| Admin | Notes, Files, Secrets, Todos, Journal | | Admin | Notes, Files, Secrets, Todos, Journal |
| Minimal | Notes, Files | | Minimal | Notes, Files |
The create-workspace modal displays the selected template description and its The create-Deal modal displays the selected template description and its
included plugin tabs before the folder is created. included plugin tabs before the folder is created.
## Metadata and compatibility ## Metadata and compatibility
Creation stores a template snapshot in `.verstak/workspaces/` metadata. The Creation stores a template snapshot in `.verstak/workspaces/` metadata. The
snapshot contains the template id, name, version, applied time, and an ordered snapshot contains the template id, name, version, applied time, and an ordered
`workspaceTools` list of plugin IDs. Existing workspace metadata without `workspaceTools` list of plugin IDs. Existing Deal metadata without
`workspaceTools` remains compatible: its workspace continues to show all globally `workspaceTools` remains compatible: its Deal continues to show all globally
enabled workspace plugins rather than unexpectedly hiding tabs. enabled Deal plugins rather than unexpectedly hiding tabs.
Templates are applied once. Editing the built-in catalog or creating another Templates are applied once. Editing the built-in catalog or creating another
workspace with a different template never changes an existing workspace snapshot. Deal with a different template never changes an existing Deal snapshot.
There is no template editor or post-creation template switcher yet. There is no template editor or post-creation template switcher yet.
## Global tools and unavailable plugins ## Global tools and unavailable plugins
Template visibility applies only to `workspaceItems` in the selected workspace. Template visibility applies only to `workspaceItems` in the selected Deal.
Global views and sidebar items, such as global Todos, Browser Inbox, and Trash, Global views and sidebar items, such as global Todos, Browser Inbox, and Trash,
remain available according to the normal plugin enablement state. remain available according to the normal plugin enablement state.
The workspace host intersects the snapshot with dynamically discovered, globally The Deal host intersects the snapshot with dynamically discovered, globally
enabled plugin contributions. If a template references a plugin that is missing or enabled plugin contributions. If a template references a plugin that is missing or
disabled, that tab is simply absent; the other template tabs remain usable. disabled, that tab is simply absent; the other template tabs remain usable.
Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

+38 -6
View File
@@ -27,6 +27,38 @@ test.describe('Activity workflow', () => {
await expect(activity.locator('[data-activity-action="clear"]')).toBeDisabled(); await expect(activity.locator('[data-activity-action="clear"]')).toBeDisabled();
}); });
test('clearing activity requires destructive confirmation for the current case', async ({ page }) => {
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.activity', {
'events:workspace:Project': [{
activityId: 'activity-to-clear',
occurredAt: '2026-06-30T08:00:00.000Z',
type: 'note.saved',
title: 'Keep until confirmed',
workspaceRootPath: 'Project',
}],
});
});
await page.getByRole('tab', { name: 'Activity' }).click();
const activity = page.locator('.activity-root');
await expect(activity.locator('.activity-count')).toHaveText('1 event');
await activity.locator('[data-activity-action="clear"]').click();
const confirmation = activity.locator('[data-activity-clear-confirmation]');
await expect(confirmation).toBeVisible();
await expect(confirmation).toContainText(/Project|Дело/);
await expect(activity.locator('.activity-count')).toHaveText('1 event');
await confirmation.locator('[data-activity-clear-cancel]').click();
await expect(confirmation).toHaveCount(0);
await expect(activity.locator('.activity-count')).toHaveText('1 event');
await activity.locator('[data-activity-action="clear"]').click();
await activity.locator('[data-activity-clear-confirm]').click();
await expect(activity.locator('.activity-count')).toHaveText('0 events');
});
test('workspace activity keeps raw events and renders factual work session candidates', async ({ page }) => { test('workspace activity keeps raw events and renders factual work session candidates', async ({ page }) => {
await page.evaluate(async () => { await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.activity', { await window.go.api.App.WritePluginSettings('verstak.activity', {
@@ -74,8 +106,8 @@ test.describe('Activity workflow', () => {
await expect(candidateSection).toContainText('Possible journal entries'); await expect(candidateSection).toContainText('Possible journal entries');
const candidate = candidateSection.locator('[data-work-session-candidate]'); const candidate = candidateSection.locator('[data-work-session-candidate]');
await expect(candidate).toHaveCount(1); await expect(candidate).toHaveCount(1);
await expect(candidate).toContainText('Workspace: Project'); await expect(candidate).toContainText('Deal: Project');
await expect(candidate).toContainText('Estimated duration: 20 min'); await expect(candidate).toContainText('Estimated duration: 10 min');
await expect(candidate).toContainText('Activities: 2'); await expect(candidate).toContainText('Activities: 2');
await expect(candidate).not.toContainText('Project work on'); await expect(candidate).not.toContainText('Project work on');
await expect(candidate.locator('[data-work-session-action="review"]')).toBeVisible(); await expect(candidate.locator('[data-work-session-action="review"]')).toBeVisible();
@@ -110,11 +142,11 @@ test.describe('Activity workflow', () => {
await expect(page.getByRole('tab', { name: 'Journal' })).toHaveAttribute('aria-selected', 'true'); await expect(page.getByRole('tab', { name: 'Journal' })).toHaveAttribute('aria-selected', 'true');
const journal = page.locator('.journal-root'); const journal = page.locator('.journal-root');
await expect(journal).toBeVisible({ timeout: 10000 }); await expect(journal).toBeVisible({ timeout: 10000 });
await expect(journal.locator('[data-journal-candidate]')).toContainText('Workspace: Project'); await expect(journal.locator('[data-journal-candidate]')).toContainText('Deal: Project');
await expect(journal.locator('[data-journal-candidate]')).toContainText('Estimated duration: 20 min'); await expect(journal.locator('[data-journal-candidate]')).toContainText('Estimated duration: 10 min');
await expect(journal.locator('[data-journal-input="title"]')).toHaveValue(''); await expect(journal.locator('[data-journal-input="title"]')).toHaveValue('');
await expect(journal.locator('[data-journal-input="summary"]')).toHaveValue(''); await expect(journal.locator('[data-journal-input="summary"]')).toHaveValue('');
await expect(journal.locator('[data-journal-input="minutes"]')).toHaveValue('20'); await expect(journal.locator('[data-journal-input="minutes"]')).toHaveValue('10');
const activityInputs = journal.locator('[data-journal-candidate-activity]'); const activityInputs = journal.locator('[data-journal-candidate-activity]');
await expect(activityInputs).toHaveCount(2); await expect(activityInputs).toHaveCount(2);
@@ -126,7 +158,7 @@ test.describe('Activity workflow', () => {
await journal.locator('[data-journal-action="save-entry"]').click(); await journal.locator('[data-journal-action="save-entry"]').click();
await expect(journal).toContainText('Review research capture'); await expect(journal).toContainText('Review research capture');
await expect(journal).toContainText('20 min'); await expect(journal).toContainText('10 min');
const stored = await page.evaluate(async () => { const stored = await page.evaluate(async () => {
const result = await window.go.api.App.ReadPluginSettings('verstak.journal'); const result = await window.go.api.App.ReadPluginSettings('verstak.journal');
return Array.isArray(result) ? result[0]['worklog:workspace:Project'] : result['worklog:workspace:Project']; return Array.isArray(result) ? result[0]['worklog:workspace:Project'] : result['worklog:workspace:Project'];
+7 -7
View File
@@ -1,7 +1,7 @@
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js'; import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
test.describe('Browser Inbox workflow', () => { test.describe('Browser workflow', () => {
let consoleCollector; let consoleCollector;
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
@@ -16,14 +16,14 @@ test.describe('Browser Inbox workflow', () => {
}); });
test('global inbox explains empty capture flow and exposes assignment filters', async ({ page }) => { test('global inbox explains empty capture flow and exposes assignment filters', async ({ page }) => {
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser Inbox' }).click(); await page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser' }).click();
const inbox = page.locator('.browser-inbox-root'); const inbox = page.locator('.browser-inbox-root');
await expect(inbox).toBeVisible({ timeout: 10000 }); await expect(inbox).toBeVisible({ timeout: 10000 });
await expect(inbox.locator('.browser-inbox-title')).toContainText('Browser Inbox'); await expect(inbox.locator('.browser-inbox-title')).toHaveText('Browser');
await expect(inbox.locator('.browser-inbox-count')).toHaveText('0 items'); await expect(inbox.locator('.browser-inbox-count')).toHaveText('0 items');
await expect(inbox.locator('.browser-inbox-empty')).toContainText('No browser captures yet'); await expect(inbox.locator('.browser-inbox-empty')).toContainText('No browser materials yet');
await expect(inbox.locator('.browser-inbox-empty')).toContainText('send a page, selection, link, or file from the browser extension'); await expect(inbox.locator('.browser-inbox-empty')).toContainText('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="status"]')).toBeVisible();
await expect(inbox.locator('[data-browser-inbox-filter="workspace"]')).toBeVisible(); await expect(inbox.locator('[data-browser-inbox-filter="workspace"]')).toBeVisible();
await expect(inbox.locator('[data-browser-inbox-action="clear"]')).toBeDisabled(); await expect(inbox.locator('[data-browser-inbox-action="clear"]')).toBeDisabled();
@@ -47,7 +47,7 @@ test.describe('Browser Inbox workflow', () => {
}], }],
}); });
}); });
await page.getByRole('tab', { name: 'Browser Inbox' }).click(); await page.getByRole('tab', { name: 'Browser' }).click();
const inbox = page.locator('.browser-inbox-root'); const inbox = page.locator('.browser-inbox-root');
await expect(inbox.locator('.browser-inbox-count')).toHaveText('1 item'); await expect(inbox.locator('.browser-inbox-count')).toHaveText('1 item');
@@ -94,7 +94,7 @@ test.describe('Browser Inbox workflow', () => {
], ],
}); });
}); });
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser Inbox' }).click(); await page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser' }).click();
const inbox = page.locator('.browser-inbox-root'); const inbox = page.locator('.browser-inbox-root');
await expect(inbox.locator('.browser-inbox-count')).toHaveText('3 items'); await expect(inbox.locator('.browser-inbox-count')).toHaveText('3 items');
+17 -1
View File
@@ -99,7 +99,7 @@ test.describe('Command Palette', () => {
test('runs sync workflow commands', async ({ page }) => { test('runs sync workflow commands', async ({ page }) => {
await page.evaluate(async () => { await page.evaluate(async () => {
const err = await window.go.api.App.PluginSyncConfigure('verstak.sync', 'https://sync.example.test', 'alice', 'secret'); const err = await window.go.api.App.PluginSyncConfigure('verstak.sync', 'https://sync.example.test', 'alice', 'secret', '');
if (err) throw new Error(err); if (err) throw new Error(err);
}); });
@@ -124,4 +124,20 @@ test.describe('Command Palette', () => {
await expect(page.locator('.modal[aria-label="Plugin Settings"]')).toBeVisible({ timeout: 10000 }); await expect(page.locator('.modal[aria-label="Plugin Settings"]')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.modal-header h3')).toContainText('Sync'); await expect(page.locator('.modal-header h3')).toContainText('Sync');
}); });
test('keeps technical command failures out of the palette status', async ({ page }) => {
await page.evaluate(() => {
window.go.api.App.PluginSyncNow = () => Promise.resolve('[plugin:verstak.sync] sync.now failed: internal service code 42');
});
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+K' : 'Control+K');
const palette = page.locator('.command-palette');
await palette.locator('[data-command-palette-input]').fill('sync now');
await page.keyboard.press('Enter');
const status = page.locator('[data-command-palette-status="error"]');
await expect(status).toContainText('Could not run Sync Now. Please try again.');
await expect(status).not.toContainText('[plugin:');
await expect(status).not.toContainText('sync.now');
});
}); });
+66 -3
View File
@@ -36,6 +36,69 @@ test.describe('F: Default Editor Plugin', () => {
await expect(textarea).toHaveValue('Buy groceries\nWrite tests'); await expect(textarea).toHaveValue('Buy groceries\nWrite tests');
}); });
test('secret link opens its exact secret and closing it restores the note preview', async ({ page }) => {
const notePath = 'Notes/Secret Link.md';
const noteContent = '# Secret link\n\n[Target secret](verstak-secret://target.secret)';
await page.evaluate(async ({ notePath, noteContent }) => {
const writeError = await window.go.api.App.WriteVaultTextFile(
'verstak.platform-test',
notePath,
noteContent,
{ createIfMissing: true, overwrite: true },
);
if (writeError) throw new Error(writeError);
const [result, openError] = await window.go.api.App.OpenWorkbenchResource('verstak.platform-test', {
kind: 'vault-file',
path: notePath,
extension: '.md',
context: { sourceView: 'notes', isInsideNotesFolder: true, notesMode: true },
});
if (openError) throw new Error(openError);
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
}, { notePath, noteContent });
const note = page.locator('[data-editor-mode="notes-markdown"]');
await expect(note).toBeVisible({ timeout: 10000 });
await expect(note.locator('[data-preview]')).toContainText('Target secret');
await note.locator('.secret-link').click();
const secrets = page.locator('.secrets-root');
await expect(secrets).toBeVisible({ timeout: 10000 });
await expect(secrets.locator('.secrets-item.active .secrets-item-title')).toHaveText('Target secret');
await expect(secrets.locator('.secrets-card h2')).toHaveText('Target secret');
await page.locator('.workbench-host .close-btn').click();
await expect(note).toBeVisible({ timeout: 10000 });
await expect(note.locator('[data-preview]')).toContainText('Target secret');
await expect(note.locator('[data-editor-textarea]')).toHaveCount(0);
await expect(note.locator('[data-save-state]')).toHaveText('');
const storedContent = await page.evaluate(async ({ notePath }) => {
const [content, readError] = await window.go.api.App.ReadVaultTextFile('verstak.platform-test', notePath);
if (readError) throw new Error(readError);
return content;
}, { notePath });
expect(storedContent).toBe(noteContent);
});
test('unavailable secret does not fall back to the first secret', async ({ page }) => {
await page.evaluate(async () => {
const [result, openError] = await window.go.api.App.OpenWorkbenchResource('verstak.default-editor', {
kind: 'secret',
path: 'missing.secret',
mode: 'view',
});
if (openError) throw new Error(openError);
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
});
const secrets = page.locator('.secrets-root');
await expect(secrets).toBeVisible({ timeout: 10000 });
await expect(secrets.locator('.secrets-item.active')).toHaveCount(0);
await expect(secrets.locator('.secrets-status.error')).toContainText(/unavailable|недоступен/i);
});
test('editor supports markdown toolbar split save reopen and revert', async ({ page }) => { test('editor supports markdown toolbar split save reopen and revert', async ({ page }) => {
await page.evaluate(async () => { await page.evaluate(async () => {
const err = await window.go.api.App.WriteVaultTextFile( const err = await window.go.api.App.WriteVaultTextFile(
@@ -112,10 +175,10 @@ test.describe('F: Default Editor Plugin', () => {
const workbench = page.locator('.workbench-host'); const workbench = page.locator('.workbench-host');
await expect(workbench).toBeVisible({ timeout: 10000 }); await expect(workbench).toBeVisible({ timeout: 10000 });
const title = workbench.locator('.workbench-title'); const title = workbench.locator('.workbench-title');
await expect(title).toHaveText('Docs/readme.md'); await expect(title).toHaveText('readme.md');
}); });
test('open .md with notes context routes to highest-priority provider', async ({ page }) => { test('opening a note uses its title rather than its technical path', async ({ page }) => {
await page.evaluate(async () => { await page.evaluate(async () => {
const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.platform-test', { const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.platform-test', {
kind: 'vault-file', kind: 'vault-file',
@@ -130,7 +193,7 @@ test.describe('F: Default Editor Plugin', () => {
const workbench = page.locator('.workbench-host'); const workbench = page.locator('.workbench-host');
await expect(workbench).toBeVisible({ timeout: 10000 }); await expect(workbench).toBeVisible({ timeout: 10000 });
const title = workbench.locator('.workbench-title'); const title = workbench.locator('.workbench-title');
await expect(title).toHaveText('Notes/Overview.md'); await expect(title).toHaveText('Overview');
}); });
test('default-editor plugin is listed as loaded in plugin manager', async ({ page }) => { test('default-editor plugin is listed as loaded in plugin manager', async ({ page }) => {
+2 -2
View File
@@ -513,7 +513,7 @@ test.describe('G: Files Plugin', () => {
const workbench = page.locator('.workbench-host'); const workbench = page.locator('.workbench-host');
await expect(workbench).toBeVisible({ timeout: 10000 }); await expect(workbench).toBeVisible({ timeout: 10000 });
await expect(workbench.locator('.workbench-title')).toHaveText('Docs/readme.md'); await expect(workbench.locator('.workbench-title')).toHaveText('readme.md');
const preview = page.locator('.de-preview'); const preview = page.locator('.de-preview');
await expect(preview).toBeVisible({ timeout: 10000 }); await expect(preview).toBeVisible({ timeout: 10000 });
@@ -535,7 +535,7 @@ test.describe('G: Files Plugin', () => {
const workbench = page.locator('.workbench-host'); const workbench = page.locator('.workbench-host');
await expect(workbench).toBeVisible({ timeout: 10000 }); await expect(workbench).toBeVisible({ timeout: 10000 });
await expect(workbench.locator('.workbench-title')).toHaveText('Notes/Overview.md'); await expect(workbench.locator('.workbench-title')).toHaveText('Overview');
}); });
test('files plugin card shows openProviders in contributions', async ({ page }) => { test('files plugin card shows openProviders in contributions', async ({ page }) => {
+35
View File
@@ -0,0 +1,35 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
test('global Journal creates an entry in the selected Deal', async ({ page }) => {
const consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.journal', {
'worklog:workspace:Project': [{ entryId: 'existing-project-entry', workspaceRootPath: 'Project', date: '2026-07-14', title: 'Existing entry', minutes: 5 }],
});
});
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Journal' }).click();
const journal = page.locator('.journal-root');
await expect(journal.locator('.journal-title')).toHaveText('Journal');
await journal.locator('[data-journal-action="add"]').click();
await journal.locator('[data-journal-input="workspaceRootPath"]').selectOption('Project');
await journal.locator('[data-journal-input="title"]').fill('Prepare project handoff');
await journal.locator('[data-journal-input="minutes"]').fill('30');
await journal.locator('[data-journal-action="save-entry"]').click();
await expect.poll(async () => page.evaluate(async () => {
const result = await window.go.api.App.ReadPluginSettings('verstak.journal');
const settings = Array.isArray(result) ? result[0] : result;
return settings['worklog:workspace:Project']?.[0]?.title;
})).toBe('Prepare project handoff');
await expect(journal).toContainText('Prepare project handoff');
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await page.getByRole('tab', { name: 'Journal' }).click();
await expect(page.locator('.journal-root')).toContainText('Prepare project handoff');
consoleCollector.assertNoErrors();
});
+14
View File
@@ -25,9 +25,23 @@ test.describe('Desktop localization', () => {
await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Тест платформы' })).toBeVisible(); await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Тест платформы' })).toBeVisible();
await expect(page.locator('[data-status-item-id="verstak.platform-test.status"]')).toContainText('Все тесты пройдены'); await expect(page.locator('[data-status-item-id="verstak.platform-test.status"]')).toContainText('Все тесты пройдены');
const project = page.locator('.wt-node').filter({ hasText: 'Project' });
await project.locator('button[title="Переименовать Дело"]').click();
await expect(page.locator('button[title="Сохранить новое название"]')).toHaveText('Сохранить');
await page.locator('.wt-rename').press('Escape');
await page.locator('[data-settings-menu-button]').click(); await page.locator('[data-settings-menu-button]').click();
await expect(page.locator('[data-settings-action="plugin-manager"]')).toContainText('Менеджер плагинов'); await expect(page.locator('[data-settings-action="plugin-manager"]')).toContainText('Менеджер плагинов');
await expect(page.locator('[data-settings-language="ru"]')).toHaveAttribute('aria-checked', 'true'); await expect(page.locator('[data-settings-language="ru"]')).toHaveAttribute('aria-checked', 'true');
await page.locator('[data-settings-action="plugin-manager"]').click();
const pluginManager = page.locator('.plugin-manager');
await expect(pluginManager).toContainText('Зарегистрировано возможностей:');
await expect(pluginManager.locator('.registry-section')).toContainText('Реестр возможностей');
await expect(pluginManager.locator('.registry-section')).toContainText('Возможность');
await expect(pluginManager.locator('.plugin-filters')).toContainText('Фильтры');
await expect(pluginManager.locator('[data-plugin-filter-results]')).toContainText('Показано плагинов:');
await expect(pluginManager.locator('[data-plugin-filter="status"]')).toContainText('Выключенные');
await expect(pluginManager).not.toContainText('Workspace-scoped');
await page.reload(); await page.reload();
await waitForAppReady(page); await waitForAppReady(page);
+9 -5
View File
@@ -58,7 +58,7 @@ test.describe('D: Plugin API bridge', () => {
const workbench = page.locator('.workbench-host'); const workbench = page.locator('.workbench-host');
await expect(workbench).toBeVisible({ timeout: 10000 }); await expect(workbench).toBeVisible({ timeout: 10000 });
await expect(workbench.locator('.workbench-title')).toHaveText('Notes/Overview.md'); await expect(workbench.locator('.workbench-title')).toHaveText('Overview');
}); });
test('workbench routes markdown files to default-editor provider', async ({ page }) => { test('workbench routes markdown files to default-editor provider', async ({ page }) => {
@@ -76,7 +76,7 @@ test.describe('D: Plugin API bridge', () => {
const workbench = page.locator('.workbench-host'); const workbench = page.locator('.workbench-host');
await expect(workbench).toBeVisible({ timeout: 10000 }); await expect(workbench).toBeVisible({ timeout: 10000 });
const title = workbench.locator('.workbench-title'); const title = workbench.locator('.workbench-title');
await expect(title).toHaveText('Docs/readme.md'); await expect(title).toHaveText('readme.md');
}); });
test('workbench shows no-provider fallback when no provider matches', async ({ page }) => { test('workbench shows no-provider fallback when no provider matches', async ({ page }) => {
@@ -91,8 +91,10 @@ test.describe('D: Plugin API bridge', () => {
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result })); window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
}); });
await expect(page.locator('[data-workbench-status="no-provider"]')).toBeVisible(); const fallback = page.locator('[data-workbench-status="no-provider"]');
await expect(page.locator('[data-workbench-status="no-provider"]')).toContainText('No viewer/editor available'); await expect(fallback).toBeVisible();
await expect(fallback).toContainText('No viewer/editor available');
await expect(fallback).not.toContainText('generic-text');
}); });
test('sync plugin API routes through mocked Wails bridge', async ({ page }) => { test('sync plugin API routes through mocked Wails bridge', async ({ page }) => {
@@ -100,7 +102,7 @@ test.describe('D: Plugin API bridge', () => {
const api = window.createPluginAPI('verstak.sync'); const api = window.createPluginAPI('verstak.sync');
const initial = await api.sync.status(); const initial = await api.sync.status();
await api.sync.testConnection('https://sync.example.test', 'alice', 'secret'); await api.sync.testConnection('https://sync.example.test', 'alice', 'secret');
await api.sync.configure('https://sync.example.test', 'alice', 'secret'); await api.sync.configure('https://sync.example.test', 'alice', 'secret', 'existing-remote-vault');
await api.sync.setInterval(15); await api.sync.setInterval(15);
const configured = await api.sync.status(); const configured = await api.sync.status();
const syncNow = await api.sync.now(); const syncNow = await api.sync.now();
@@ -115,6 +117,8 @@ test.describe('D: Plugin API bridge', () => {
expect(result.initial.statusLabel).toBe('disabled'); expect(result.initial.statusLabel).toBe('disabled');
expect(result.configured.configured).toBe(true); expect(result.configured.configured).toBe(true);
expect(result.configured.serverUrl).toBe('https://sync.example.test'); expect(result.configured.serverUrl).toBe('https://sync.example.test');
expect(result.configured.vaultId).toBe('existing-remote-vault');
expect(result.configured.lastWarning).toBe('');
expect(result.configured.syncInterval).toBe(15); expect(result.configured.syncInterval).toBe(15);
expect(result.syncNow).toEqual({ pushed: 0, pulled: 0, serverSequence: 0 }); expect(result.syncNow).toEqual({ pushed: 0, pulled: 0, serverSequence: 0 });
expect(result.reset.configured).toBe(false); expect(result.reset.configured).toBe(false);
+55 -5
View File
@@ -1,5 +1,5 @@
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState, openPluginManager } from './helpers.js'; import { waitForAppReady, setupConsoleCollector, resetMockState, openPluginManager, setPluginStatus } from './helpers.js';
test.describe('E: Plugin Manager layout', () => { test.describe('E: Plugin Manager layout', () => {
let consoleCollector; let consoleCollector;
@@ -88,6 +88,56 @@ test.describe('E: Plugin Manager layout', () => {
await expect(risk).toContainText('elevated permissions'); await expect(risk).toContainText('elevated permissions');
}); });
test('plugin manager filters by state, declared permissions, capabilities, settings and source', async ({ page }) => {
await setPluginStatus(page, 'verstak.todo', 'disabled', false);
await page.evaluate(() => window.__wailsMock.addSyntheticPlugins(1, 'third-party'));
await openPluginManager(page);
const results = page.locator('[data-plugin-filter-results]');
await expect(results).toContainText('Showing');
const filterSelectStyle = await page.locator('[data-plugin-filter="status"]').evaluate((node) => {
const style = getComputedStyle(node);
return { appearance: style.appearance, backgroundImage: style.backgroundImage };
});
expect(filterSelectStyle.appearance).toBe('none');
expect(filterSelectStyle.backgroundImage).not.toBe('none');
await page.locator('[data-plugin-filter="status"]').selectOption('disabled');
await expect(page.locator('.plugin-card')).toHaveCount(1);
await expect(page.locator('.plugin-card')).toContainText('Todos');
await page.locator('button.reload-btn').click();
await expect(page.locator('[data-plugin-filter="status"]')).toHaveValue('disabled');
await expect(page.locator('.plugin-card')).toHaveCount(1);
await page.locator('[data-plugin-filter-reset]').click();
await page.locator('[data-plugin-filter-permission="notifications.schedule"]').check();
await expect(page.locator('.plugin-card')).toHaveCount(1);
await expect(page.locator('.plugin-card')).toContainText('Todos');
await page.locator('[data-plugin-filter-permission="secrets.read"]').check();
await expect(page.locator('.plugin-card')).toHaveCount(2);
await page.locator('[data-plugin-filter-permission="secrets.read"]').uncheck();
await page.locator('[data-plugin-filter-reset]').click();
await page.locator('[data-plugin-filter-capability="verstak/core/notifications/v1"]').check();
await expect(page.locator('.plugin-card')).toHaveCount(1);
await expect(page.locator('.plugin-card')).toContainText('Todos');
await page.locator('[data-plugin-filter-capability="secret-store"]').check();
await expect(page.locator('.plugin-card')).toHaveCount(2);
await page.locator('[data-plugin-filter-capability="secret-store"]').uncheck();
await page.locator('[data-plugin-filter-reset]').click();
await page.locator('[data-plugin-filter="settings"]').selectOption('with');
await expect(page.locator('.plugin-card').filter({ hasText: 'Platform Test' })).toBeVisible();
await expect(page.locator('.plugin-card').filter({ hasText: 'Default Editor' })).toHaveCount(0);
await page.locator('[data-plugin-filter="source"]').selectOption('third-party');
await expect(page.locator('.plugin-card')).toHaveCount(0);
await expect(page.locator('[data-plugin-filter-empty]')).toBeVisible();
await page.locator('[data-plugin-filter-reset]').click();
await expect(page.locator('.plugin-card')).toHaveCount(13);
});
test('workspace selection keeps exactly one active node', async ({ page }) => { test('workspace selection keeps exactly one active node', async ({ page }) => {
const selected = page.locator('.wt-node.selected .wt-label'); const selected = page.locator('.wt-node.selected .wt-label');
await expect(selected).toHaveCount(1); await expect(selected).toHaveCount(1);
@@ -118,15 +168,15 @@ test.describe('E: Plugin Manager layout', () => {
}); });
test('workspace sidebar creates renames and trashes top-level workspaces', async ({ page }) => { test('workspace sidebar creates renames and trashes top-level workspaces', async ({ page }) => {
await page.locator('button[title="New workspace"]').click(); await page.locator('button[title="New Deal"]').click();
const modal = page.locator('[data-workspace-create-modal]'); const modal = page.locator('[data-workspace-create-modal]');
await modal.locator('[data-workspace-name]').fill('ClientA'); await modal.locator('[data-workspace-name]').fill('ClientA');
await modal.getByRole('button', { name: 'Create workspace' }).click(); await modal.getByRole('button', { name: 'Create Deal' }).click();
await expect(page.locator('.wt-label').filter({ hasText: 'ClientA' })).toBeVisible(); await expect(page.locator('.wt-label').filter({ hasText: 'ClientA' })).toBeVisible();
const client = page.locator('.wt-node').filter({ hasText: 'ClientA' }); const client = page.locator('.wt-node').filter({ hasText: 'ClientA' });
await client.locator('button[title="Rename workspace"]').click(); await client.locator('button[title="Rename Deal"]').click();
await page.locator('.wt-rename').fill('ClientB'); await page.locator('.wt-rename').fill('ClientB');
await page.locator('button[title="Save rename"]').click(); await page.locator('button[title="Save rename"]').click();
@@ -134,7 +184,7 @@ test.describe('E: Plugin Manager layout', () => {
await expect(page.locator('.wt-label').filter({ hasText: 'ClientA' })).toHaveCount(0); await expect(page.locator('.wt-label').filter({ hasText: 'ClientA' })).toHaveCount(0);
const renamed = page.locator('.wt-node').filter({ hasText: 'ClientB' }); const renamed = page.locator('.wt-node').filter({ hasText: 'ClientB' });
await renamed.locator('button[title="Trash workspace"]').click(); await renamed.locator('button[title="Move Deal to trash"]').click();
await expect(page.locator('.wt-label').filter({ hasText: 'ClientB' })).toHaveCount(0); await expect(page.locator('.wt-label').filter({ hasText: 'ClientB' })).toHaveCount(0);
}); });
+50
View File
@@ -0,0 +1,50 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
test('global Secrets lists, filters, and creates Deal-scoped secrets', async ({ page }) => {
const consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
await page.evaluate(async () => {
const [record, error] = await window.go.api.App.PluginSecretsWrite('verstak.secrets', {
id: 'project.global-secret',
title: 'Project API',
username: 'project-user',
value: 'project-value',
scope: { kind: 'workspace', workspaceRootPath: 'Project' },
});
if (error || !record?.id) throw new Error(error || 'could not create a project secret');
});
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Secrets' }).click();
const secrets = page.locator('.secrets-root');
await expect(secrets).toBeVisible({ timeout: 10000 });
await expect(secrets).toContainText('First secret');
await expect(secrets).toContainText('Project API');
await secrets.locator('[data-secret-scope-filter]').selectOption('workspace:Project');
await expect(secrets).toContainText('Project API');
await expect(secrets).not.toContainText('First secret');
await secrets.locator('[data-secret-search]').fill('no matching secret');
await expect(secrets).toContainText('No secrets');
await secrets.locator('[data-secret-search]').fill('');
await secrets.locator('[data-secret-scope-filter]').selectOption('all');
await secrets.getByRole('button', { name: 'New' }).click();
await secrets.locator('[data-secret-title]').fill('Project deployment token');
await secrets.locator('[data-secret-value]').fill('deployment-token-value');
await secrets.locator('[data-secret-scope]').selectOption('workspace');
await secrets.locator('[data-secret-workspace]').selectOption('Project');
await secrets.locator('[data-secret-save]').click();
await expect.poll(async () => page.evaluate(async () => {
const [records, error] = await window.go.api.App.PluginSecretsList('verstak.secrets');
if (error) throw new Error(error);
const record = records.find((item) => item.title === 'Project deployment token');
return record && [record.scope?.kind, record.scope?.workspaceRootPath].join('|');
})).toBe('workspace|Project');
consoleCollector.assertNoErrors();
});
+31 -4
View File
@@ -30,18 +30,45 @@ test.describe('B: Sidebar opens plugin view by item.view', () => {
test('Sidebar item exists with correct label', async ({ page }) => { test('Sidebar item exists with correct label', async ({ page }) => {
await expect(page.locator('.sidebar .nav-item').filter({ hasText: 'Plugin Manager' })).not.toBeVisible(); await expect(page.locator('.sidebar .nav-item').filter({ hasText: 'Plugin Manager' })).not.toBeVisible();
await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Activity' })).toBeVisible(); await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Activity' })).toBeVisible();
await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser Inbox' })).toBeVisible(); await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser' })).toBeVisible();
const sidebarItem = page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' }); const sidebarItem = page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' });
await expect(sidebarItem).toBeVisible(); await expect(sidebarItem).toBeVisible();
}); });
test('Global Activity and Browser Inbox sidebar items open plugin views', async ({ page }) => { test('Global Activity and Browser sidebar items open plugin views', async ({ page }) => {
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Activity' }).click(); await page.locator('.sidebar .plugin-item').filter({ hasText: 'Activity' }).click();
await expect(page.locator('.view-container .view-header h2')).toHaveText('Activity', { timeout: 10000 }); await expect(page.locator('.view-container .view-header h2')).toHaveText('Activity', { timeout: 10000 });
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser Inbox' }).click(); await page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser' }).click();
await expect(page.locator('.view-container .view-header h2')).toHaveText('Browser Inbox', { timeout: 10000 }); 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 }) => { test('Click sidebar item opens diagnostics view by view ID, not sidebar ID', async ({ page }) => {
+16 -3
View File
@@ -27,7 +27,10 @@ test.describe('Todo plugin workflow', () => {
await todos.locator('[data-todo-input="description"]').fill('Collect factual review notes.'); await todos.locator('[data-todo-input="description"]').fill('Collect factual review notes.');
await todos.locator('[data-todo-input="priority"]').selectOption('high'); await todos.locator('[data-todo-input="priority"]').selectOption('high');
await todos.locator('[data-todo-input="dueAt"]').fill('2000-01-01'); await todos.locator('[data-todo-input="dueAt"]').fill('2000-01-01');
await todos.locator('[data-todo-input="reminderAt"]').fill('2000-01-01T09:00'); await todos.locator('[data-todo-input="reminderDate"]').fill('2000-01-01');
const reminderTime = todos.locator('[data-todo-input="reminderTime"]');
await expect(reminderTime).toHaveAttribute('type', 'text');
await reminderTime.fill('09:30');
await todos.locator('[data-todo-action="save"]').click(); await todos.locator('[data-todo-action="save"]').click();
await expect(todos).toContainText('Overdue'); await expect(todos).toContainText('Overdue');
@@ -36,13 +39,23 @@ test.describe('Todo plugin workflow', () => {
const result = await window.go.api.App.ReadPluginSettings('verstak.todo'); const result = await window.go.api.App.ReadPluginSettings('verstak.todo');
const settings = Array.isArray(result) ? result[0] : result; const settings = Array.isArray(result) ? result[0] : result;
const todo = settings['todos:global'].find((item) => item.title === 'Prepare project review'); const todo = settings['todos:global'].find((item) => item.title === 'Prepare project review');
return todo && [todo.workspaceRootPath, todo.priority, todo.dueAt, todo.reminderAt].join('|'); return todo && [todo.workspaceRootPath, todo.priority, todo.dueAt, todo.reminderDate, todo.reminderAt].join('|');
})).toBe('Project|high|2000-01-01|2000-01-01T09:00'); })).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-action="edit"]').click();
await todos.locator('[data-todo-input="title"]').fill('Prepare project review updated'); await todos.locator('[data-todo-input="title"]').fill('Prepare project review updated');
await todos.locator('[data-todo-input="reminderTime"]').fill('not-a-time');
await todos.locator('[data-todo-action="save"]').click();
await expect(todos).toContainText('Enter a valid reminder time');
await todos.locator('[data-todo-input="reminderTime"]').fill('');
await todos.locator('[data-todo-action="save"]').click(); await todos.locator('[data-todo-action="save"]').click();
await expect(todos).toContainText('Prepare project review updated'); await expect(todos).toContainText('Prepare project review updated');
await expect.poll(async () => page.evaluate(async () => {
const result = await window.go.api.App.ReadPluginSettings('verstak.todo');
const settings = Array.isArray(result) ? result[0] : result;
const todo = settings['todos:global'].find((item) => item.title === 'Prepare project review updated');
return todo && [todo.reminderDate, todo.reminderAt].join('|');
})).toBe('2000-01-01|');
await todos.locator('[data-todo-action="mark-done"]').click(); await todos.locator('[data-todo-action="mark-done"]').click();
await expect(todos.locator('[data-todo-action="create-journal-entry"]')).toBeVisible(); await expect(todos.locator('[data-todo-action="create-journal-entry"]')).toBeVisible();
+2 -2
View File
@@ -18,7 +18,7 @@ test.describe('UX follow-up fixes', () => {
await expect(search).toBeVisible(); await expect(search).toBeVisible();
await expect(page.locator('.sidebar [data-global-search-input]')).toBeVisible(); await expect(page.locator('.sidebar [data-global-search-input]')).toBeVisible();
await page.locator('.sidebar .nav-item').filter({ hasText: 'Browser Inbox' }).click(); await page.locator('.sidebar .nav-item').filter({ hasText: 'Browser' }).click();
await expect(page.locator('.browser-inbox-root')).toBeVisible({ timeout: 10000 }); await expect(page.locator('.browser-inbox-root')).toBeVisible({ timeout: 10000 });
await expect(search).toBeVisible(); await expect(search).toBeVisible();
}); });
@@ -67,7 +67,7 @@ test.describe('UX follow-up fixes', () => {
const search = page.locator('[data-global-search-input]'); const search = page.locator('[data-global-search-input]');
await search.fill('Research Search Result'); await search.fill('Research Search Result');
const result = page.locator('[data-global-search-result-type="Browser Inbox"]').filter({ hasText: 'Research Search Result' }); const result = page.locator('[data-global-search-result-type="Browser"]').filter({ hasText: 'Research Search Result' });
await expect(result).toBeVisible({ timeout: 10000 }); await expect(result).toBeVisible({ timeout: 10000 });
await result.click(); await result.click();
+5
View File
@@ -38,6 +38,11 @@ test.describe('UX P0 shell flow', () => {
await expect(page.locator('.plugin-manager')).toHaveCount(0); await expect(page.locator('.plugin-manager')).toHaveCount(0);
}); });
test('Deal header does not expose the internal workspace type badge', async ({ page }) => {
await expect(page.locator('.workspace-host')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.workspace-type')).toHaveCount(0);
});
test('status bar plugin contribution failures do not render large error panels', async ({ page }) => { test('status bar plugin contribution failures do not render large error panels', async ({ page }) => {
await expect(page.locator('.workspace-host')).toBeVisible({ timeout: 10000 }); await expect(page.locator('.workspace-host')).toBeVisible({ timeout: 10000 });
await expect(page.getByText('Plugin View Error')).toHaveCount(0); await expect(page.getByText('Plugin View Error')).toHaveCount(0);
+77 -5
View File
@@ -56,7 +56,7 @@ test.describe('UX Overview workspace flow', () => {
await page.getByRole('tab', { name: 'Overview' }).click(); await page.getByRole('tab', { name: 'Overview' }).click();
await overview.locator('[data-overview-summary="captures"]').click(); await overview.locator('[data-overview-summary="captures"]').click();
await expect(page.getByRole('tab', { name: 'Browser Inbox' })).toHaveAttribute('aria-selected', 'true'); await expect(page.getByRole('tab', { name: 'Browser' })).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.browser-inbox-root')).toBeVisible({ timeout: 10000 }); await expect(page.locator('.browser-inbox-root')).toBeVisible({ timeout: 10000 });
await page.getByRole('tab', { name: 'Overview' }).click(); await page.getByRole('tab', { name: 'Overview' }).click();
@@ -70,7 +70,7 @@ test.describe('UX Overview workspace flow', () => {
await page.getByRole('tab', { name: 'Overview' }).click(); await page.getByRole('tab', { name: 'Overview' }).click();
await overview.locator('[data-overview-summary="attention"]').click(); await overview.locator('[data-overview-summary="attention"]').click();
await expect(page.getByRole('tab', { name: 'Browser Inbox' })).toHaveAttribute('aria-selected', 'true'); await expect(page.getByRole('tab', { name: 'Browser' })).toHaveAttribute('aria-selected', 'true');
}); });
test('Overview keeps the Notes total factual when the Files plugin is unavailable', async ({ page }) => { test('Overview keeps the Notes total factual when the Files plugin is unavailable', async ({ page }) => {
@@ -80,6 +80,54 @@ test.describe('UX Overview workspace flow', () => {
await expect(page.locator('[data-overview-summary="notes"]')).toContainText('1 total'); await expect(page.locator('[data-overview-summary="notes"]')).toContainText('1 total');
}); });
test('Overview hides Browser cards and actions when the current Deal does not include it', async ({ page }) => {
await page.locator('button[title="New Deal"]').click();
const modal = page.locator('[data-workspace-create-modal]');
await modal.locator('[data-workspace-name]').fill('MinimalOverview');
await modal.locator('[data-workspace-template]').selectOption('minimal');
await modal.getByRole('button', { name: 'Create Deal' }).click();
await expect(page.getByRole('tab', { name: 'Browser' })).toHaveCount(0);
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.browser-inbox', {
'captures:global': [{
captureId: 'hidden-browser-capture',
capturedAt: '2026-07-14T08:00:00.000Z',
kind: 'page',
title: 'Inbox material must stay hidden',
workspaceRootPath: 'MinimalOverview',
}],
});
});
const overview = page.locator('[data-overview-root]');
await overview.locator('[data-overview-action="refresh"]').click();
await expect(overview.locator('[data-overview-summary="captures"]')).toHaveCount(0);
await expect(overview.locator('[data-overview-action="browser-inbox"]')).toHaveCount(0);
await expect(overview).not.toContainText('Inbox material must stay hidden');
});
test('Overview refreshes when Browser is disabled through plugin state changes', async ({ page }) => {
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.browser-inbox', {
'captures:global': [{
captureId: 'disabled-browser-capture',
capturedAt: '2026-07-14T08:00:00.000Z',
kind: 'page',
title: 'Disabled inbox material',
workspaceRootPath: 'Project',
}],
});
window.__wailsMock.setPluginStatus('verstak.browser-inbox', 'disabled', false);
window.dispatchEvent(new CustomEvent('verstak:plugins-changed'));
});
const overview = page.locator('[data-overview-root]');
await expect(page.getByRole('tab', { name: 'Browser' })).toHaveCount(0);
await expect(overview.locator('[data-overview-summary="captures"]')).toHaveCount(0);
await expect(overview.locator('[data-overview-action="browser-inbox"]')).toHaveCount(0);
});
test('Overview prioritizes resume work and filters meaningful recent changes', async ({ page }) => { test('Overview prioritizes resume work and filters meaningful recent changes', async ({ page }) => {
await page.evaluate(async () => { await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.browser-inbox', { await window.go.api.App.WritePluginSettings('verstak.browser-inbox', {
@@ -200,10 +248,10 @@ test.describe('UX Overview workspace flow', () => {
await expect(overview.locator('[data-overview-summary="attention"]')).toContainText('3'); await expect(overview.locator('[data-overview-summary="attention"]')).toContainText('3');
const attention = overview.locator('[data-overview-section="attention"]'); const attention = overview.locator('[data-overview-section="attention"]');
await expect(attention).toContainText('Possible journal entry'); await expect(attention).toContainText('Possible journal entry');
await expect(attention).toContainText('Workspace: Project · 10 min · 2 activities'); await expect(attention).toContainText('Deal: Project · 10 min · 2 activities');
await attention.locator('.overview-attention-row', { hasText: 'Possible journal entry' }).getByRole('button', { name: 'Review candidate' }).click(); await attention.locator('.overview-attention-row', { hasText: 'Possible journal entry' }).getByRole('button', { name: 'Review candidate' }).click();
await expect(page.getByRole('tab', { name: 'Journal' })).toHaveAttribute('aria-selected', 'true'); await expect(page.getByRole('tab', { name: 'Journal' })).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.journal-root [data-journal-candidate]')).toContainText('Workspace: Project'); await expect(page.locator('.journal-root [data-journal-candidate]')).toContainText('Deal: Project');
await page.locator('.journal-modal-actions').getByRole('button', { name: 'Cancel' }).click(); await page.locator('.journal-modal-actions').getByRole('button', { name: 'Cancel' }).click();
await page.getByRole('tab', { name: 'Overview' }).click(); await page.getByRole('tab', { name: 'Overview' }).click();
@@ -216,7 +264,7 @@ test.describe('UX Overview workspace flow', () => {
await expect(candidates.nth(3)).toContainText('Changed file "draft.md"'); await expect(candidates.nth(3)).toContainText('Changed file "draft.md"');
await candidates.nth(0).click(); await candidates.nth(0).click();
await expect(page.getByRole('tab', { name: 'Browser Inbox' })).toHaveAttribute('aria-selected', 'true'); await expect(page.getByRole('tab', { name: 'Browser' })).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.browser-inbox-root')).toBeVisible({ timeout: 10000 }); await expect(page.locator('.browser-inbox-root')).toBeVisible({ timeout: 10000 });
await page.getByRole('tab', { name: 'Overview' }).click(); await page.getByRole('tab', { name: 'Overview' }).click();
@@ -247,4 +295,28 @@ test.describe('UX Overview workspace flow', () => {
await expect(recent).toContainText('Added journal entry "Write project summary"'); await expect(recent).toContainText('Added journal entry "Write project summary"');
await expect(recent).not.toContainText('Changed file "draft.md"'); await expect(recent).not.toContainText('Changed file "draft.md"');
}); });
test('Overview localizes activity labels without exposing internal event names', async ({ page }) => {
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.activity', {
'events:workspace:Project': [{
activityId: 'overview-russian-note',
occurredAt: '2026-06-30T08:25:00.000Z',
type: 'note.saved',
title: 'Локализация',
summary: 'Project/Notes/Localization.md',
workspaceRootPath: 'Project',
}],
});
});
await page.locator('[data-settings-menu-button]').click();
await page.locator('[data-settings-language="ru"]').click();
const overview = page.locator('[data-overview-root]');
await overview.locator('[data-overview-action="refresh"]').click();
const recent = overview.locator('[data-overview-section="recent"]');
await expect(recent).toContainText('Изменена заметка «Локализация»');
await expect(recent).not.toContainText('note.saved');
await expect(recent).not.toContainText('Edited note');
});
}); });
+32 -11
View File
@@ -16,7 +16,7 @@ test.describe('Workspace templates', () => {
}); });
async function openCreateModal(page) { async function openCreateModal(page) {
await page.locator('button[title="New workspace"]').click(); await page.locator('button[title="New Deal"]').click();
const modal = page.locator('[data-workspace-create-modal]'); const modal = page.locator('[data-workspace-create-modal]');
await expect(modal).toBeVisible(); await expect(modal).toBeVisible();
return modal; return modal;
@@ -24,22 +24,24 @@ test.describe('Workspace templates', () => {
test('creation modal validates names, shows template tools, and persists the selected snapshot', async ({ page }) => { test('creation modal validates names, shows template tools, and persists the selected snapshot', async ({ page }) => {
const modal = await openCreateModal(page); const modal = await openCreateModal(page);
await expect(modal.locator('[data-workspace-template]')).toHaveValue('default'); const templateSelect = modal.locator('[data-workspace-template]');
await expect(templateSelect).toHaveValue('default');
await expect(templateSelect).toHaveCSS('appearance', 'none');
await expect(modal.locator('[data-workspace-template-tools]')).toContainText('Notes'); await expect(modal.locator('[data-workspace-template-tools]')).toContainText('Notes');
await expect(modal.locator('[data-workspace-template-tools]')).toContainText('Browser Inbox'); await expect(modal.locator('[data-workspace-template-tools]')).toContainText('Browser');
await modal.getByRole('button', { name: 'Create workspace' }).click(); await modal.getByRole('button', { name: 'Create Deal' }).click();
await expect(modal.locator('[data-workspace-create-error]')).toContainText('Name is required'); await expect(modal.locator('[data-workspace-create-error]')).toContainText('Name is required');
await modal.locator('[data-workspace-name]').fill('bad/name'); await modal.locator('[data-workspace-name]').fill('bad/name');
await modal.getByRole('button', { name: 'Create workspace' }).click(); await modal.getByRole('button', { name: 'Create Deal' }).click();
await expect(modal.locator('[data-workspace-create-error]')).toContainText('invalid-workspace-name'); 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-name]').fill('ProjectPlan');
await modal.locator('[data-workspace-template]').selectOption('project'); await modal.locator('[data-workspace-template]').selectOption('project');
await expect(modal.locator('[data-workspace-template-description]')).toContainText('Project planning'); await expect(modal.locator('[data-workspace-template-description]')).toContainText('Project planning');
await expect(modal.locator('[data-workspace-template-tools]')).toContainText('Todos'); await expect(modal.locator('[data-workspace-template-tools]')).toContainText('Todos');
await modal.getByRole('button', { name: 'Create workspace' }).click(); await modal.getByRole('button', { name: 'Create Deal' }).click();
await expect(page.locator('.wt-label').filter({ hasText: 'ProjectPlan' })).toBeVisible(); await expect(page.locator('.wt-label').filter({ hasText: 'ProjectPlan' })).toBeVisible();
await expect.poll(async () => page.evaluate(async () => { await expect.poll(async () => page.evaluate(async () => {
@@ -63,7 +65,7 @@ test.describe('Workspace templates', () => {
const modal = await openCreateModal(page); const modal = await openCreateModal(page);
await modal.locator('[data-workspace-name]').fill('MinimalSpace'); await modal.locator('[data-workspace-name]').fill('MinimalSpace');
await modal.locator('[data-workspace-template]').selectOption('minimal'); await modal.locator('[data-workspace-template]').selectOption('minimal');
await modal.getByRole('button', { name: 'Create workspace' }).click(); await modal.getByRole('button', { name: 'Create Deal' }).click();
await expect(page.getByRole('tab', { name: 'Overview' })).toBeVisible(); await expect(page.getByRole('tab', { name: 'Overview' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Notes' })).toBeVisible(); await expect(page.getByRole('tab', { name: 'Notes' })).toBeVisible();
@@ -72,21 +74,40 @@ test.describe('Workspace templates', () => {
await expect(page.getByRole('tab', { name: 'Journal' })).toHaveCount(0); await expect(page.getByRole('tab', { name: 'Journal' })).toHaveCount(0);
await expect(page.getByRole('tab', { name: 'Secrets' })).toHaveCount(0); await expect(page.getByRole('tab', { name: 'Secrets' })).toHaveCount(0);
await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Todos' })).toBeVisible(); await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Todos' })).toBeVisible();
await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser Inbox' })).toBeVisible(); await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser' })).toBeVisible();
});
test('template explains an unavailable plugin and warns after incomplete creation', async ({ page }) => {
await page.evaluate(() => window.__wailsMock.setPluginStatus('verstak.todo', 'disabled', false));
const modal = await openCreateModal(page);
await modal.locator('[data-workspace-template]').selectOption('project');
const todo = modal.locator('[data-workspace-template-tool="verstak.todo"]');
await expect(todo).toContainText('Todos');
await expect(todo).toContainText('Plugin is disabled');
await expect(todo).toHaveAttribute('data-template-tool-status', 'unavailable');
await modal.locator('[data-workspace-name]').fill('ProjectWithWarning');
await modal.getByRole('button', { name: 'Create Deal' }).click();
const warning = page.locator('[data-workspace-template-warning]');
await expect(warning).toContainText('ProjectWithWarning');
await expect(warning).toContainText('Todos');
await expect(warning).toContainText('Plugin is disabled');
}); });
test('Admin shows Secrets when available and missing workspace plugins degrade without breaking tabs', async ({ page }) => { test('Admin shows Secrets when available and missing workspace plugins degrade without breaking tabs', async ({ page }) => {
let modal = await openCreateModal(page); let modal = await openCreateModal(page);
await modal.locator('[data-workspace-name]').fill('AdminSpace'); await modal.locator('[data-workspace-name]').fill('AdminSpace');
await modal.locator('[data-workspace-template]').selectOption('admin'); await modal.locator('[data-workspace-template]').selectOption('admin');
await modal.getByRole('button', { name: 'Create workspace' }).click(); await modal.getByRole('button', { name: 'Create Deal' }).click();
await expect(page.getByRole('tab', { name: 'Secrets' })).toBeVisible(); await expect(page.getByRole('tab', { name: 'Secrets' })).toBeVisible();
await page.evaluate(() => window.__wailsMock.setPluginStatus('verstak.todo', 'disabled', false)); await page.evaluate(() => window.__wailsMock.setPluginStatus('verstak.todo', 'disabled', false));
modal = await openCreateModal(page); modal = await openCreateModal(page);
await modal.locator('[data-workspace-name]').fill('ProjectWithoutTodo'); await modal.locator('[data-workspace-name]').fill('ProjectWithoutTodo');
await modal.locator('[data-workspace-template]').selectOption('project'); await modal.locator('[data-workspace-template]').selectOption('project');
await modal.getByRole('button', { name: 'Create workspace' }).click(); await modal.getByRole('button', { name: 'Create Deal' }).click();
await expect(page.getByRole('tab', { name: 'Notes' })).toBeVisible(); await expect(page.getByRole('tab', { name: 'Notes' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Files' })).toBeVisible(); await expect(page.getByRole('tab', { name: 'Files' })).toBeVisible();
+7 -1
View File
@@ -54,6 +54,7 @@
const name = workspaceName(workspace); const name = workspaceName(workspace);
return { return {
id: name, id: name,
workspaceId: workspace?.id || workspace?.workspaceId || '',
type: workspace?.type || 'space', type: workspace?.type || 'space',
title: workspace?.title || name, title: workspace?.title || name,
name, name,
@@ -218,6 +219,7 @@
const settings = await App.GetAppSettings(); const settings = await App.GetAppSettings();
debug.log('[App] checkVault: GetAppSettings returned', settings); debug.log('[App] checkVault: GetAppSettings returned', settings);
flog('checkVault: GetAppSettings returned'); flog('checkVault: GetAppSettings returned');
if (settings?.debug) debug.enable({ persist: false });
debug.log('[App] checkVault: calling GetVaultStatus...'); debug.log('[App] checkVault: calling GetVaultStatus...');
vaultStatus = await App.GetVaultStatus() || { status: 'unknown', path: '', vaultId: '' }; vaultStatus = await App.GetVaultStatus() || { status: 'unknown', path: '', vaultId: '' };
@@ -393,7 +395,11 @@
<VaultSelection /> <VaultSelection />
{:else} {:else}
<main> <main>
<Sidebar showGlobalSearch={currentView !== 'workspace' && currentView !== 'workspace-empty'} /> <Sidebar
showGlobalSearch={currentView !== 'workspace' && currentView !== 'workspace-empty'}
{activeView}
{activeViewPluginId}
/>
<CommandPalette /> <CommandPalette />
<section class="content-shell"> <section class="content-shell">
+124 -32
View File
@@ -16,13 +16,13 @@ export default {
'sidebar.error.load': 'Failed to load sidebar', 'sidebar.error.load': 'Failed to load sidebar',
'sidebar.error.ui': 'Plugin UI error', 'sidebar.error.ui': 'Plugin UI error',
'workspace.overview': 'Overview', 'workspace.overview': 'Overview',
'workspace.search': 'Workspace search', 'workspace.search': 'Search in this Deal',
'workspace.tools': 'Workspace tools', 'workspace.tools': 'Deal tools',
'workspace.tool': 'Workspace tool', 'workspace.tool': 'Deal tool',
'workspace.emptyTools': 'No workspace tools available', 'workspace.emptyTools': 'No Deal tools available',
'workspace.emptyToolsHint': 'Enable plugins with workspace tools or open Plugin Manager from settings.', 'workspace.emptyToolsHint': 'Enable plugins with Deal tools or open Plugin Manager from settings.',
'workspace.select': 'Select a workspace', 'workspace.select': 'Select a Deal',
'workspace.selectHint': 'Use the + button in Workspaces to add your first project.', 'workspace.selectHint': 'Use the + button in Deals to add your first project.',
'common.details': 'Details', 'common.details': 'Details',
'common.plugin': 'Plugin', 'common.plugin': 'Plugin',
'common.component': 'Component', 'common.component': 'Component',
@@ -35,6 +35,7 @@ export default {
'pluginView.select': 'Select a plugin view from the sidebar', 'pluginView.select': 'Select a plugin view from the sidebar',
'pluginView.selectHint': 'Plugin views will appear here', 'pluginView.selectHint': 'Plugin views will appear here',
'common.retry': '⟳ Retry', 'common.retry': '⟳ Retry',
'common.save': 'Save',
'common.source': 'Source', 'common.source': 'Source',
'common.status': 'Status', 'common.status': 'Status',
'common.capability': 'Capability', 'common.capability': 'Capability',
@@ -54,6 +55,24 @@ export default {
'pluginManager.health': 'Plugin Health', 'pluginManager.health': 'Plugin Health',
'pluginManager.permissionRisk': 'Permission Risk', 'pluginManager.permissionRisk': 'Permission Risk',
'pluginManager.elevatedPermissions': '{count} plugins request elevated permissions', 'pluginManager.elevatedPermissions': '{count} plugins request elevated permissions',
'pluginManager.filters': 'Filters',
'pluginManager.filterResults': 'Showing {visible} of {total} plugins',
'pluginManager.filterReset': 'Reset filters',
'pluginManager.filterState': 'State',
'pluginManager.filterAll': 'All',
'pluginManager.filterEnabled': 'Enabled',
'pluginManager.filterDisabled': 'Disabled',
'pluginManager.filterFailed': 'Problem',
'pluginManager.filterDegraded': 'Degraded',
'pluginManager.filterPermissions': 'Permissions',
'pluginManager.filterCapabilities': 'Capabilities',
'pluginManager.filterSettings': 'Settings',
'pluginManager.filterWithSettings': 'With settings',
'pluginManager.filterWithoutSettings': 'Without settings',
'pluginManager.filterSource': 'Source',
'pluginManager.filterOfficial': 'Official',
'pluginManager.filterThirdParty': 'Third-party or local',
'pluginManager.filterEmpty': 'No plugins match the selected filters.',
'pluginManager.none': 'No plugins found', 'pluginManager.none': 'No plugins found',
'pluginManager.scannedDirs': 'Plugin directories scanned:', 'pluginManager.scannedDirs': 'Plugin directories scanned:',
'pluginManager.userPlugins': 'user plugins', 'pluginManager.userPlugins': 'user plugins',
@@ -74,7 +93,7 @@ export default {
'pluginCard.count.sidebar': '{count} sidebar item(s)', 'pluginCard.count.sidebar': '{count} sidebar item(s)',
'pluginCard.count.statusbar': '{count} status bar item(s)', 'pluginCard.count.statusbar': '{count} status bar item(s)',
'pluginCard.count.openProviders': '{count} openProviders', 'pluginCard.count.openProviders': '{count} openProviders',
'pluginCard.count.workspace': '{count} workspace item(s)', 'pluginCard.count.workspace': '{count} Deal tool(s)',
'pluginCard.degraded': 'Plugin is usable, but some optional capabilities are unavailable.', 'pluginCard.degraded': 'Plugin is usable, but some optional capabilities are unavailable.',
'pluginCard.name': 'Name', 'pluginCard.name': 'Name',
'pluginCard.contributions': 'Contributions', 'pluginCard.contributions': 'Contributions',
@@ -96,12 +115,12 @@ export default {
'common.loading': 'Loading...', 'common.loading': 'Loading...',
'common.browse': 'Browse...', 'common.browse': 'Browse...',
'vaultSelection.chooseNew': 'Choose or enter a folder for the new vault.', 'vaultSelection.chooseNew': 'Choose or enter a folder for the new vault.',
'vaultSelection.createError': 'Could not create vault: {error}', 'vaultSelection.createError': 'Could not create the vault. Please try again.',
'vaultSelection.openError': 'Could not open vault: {error}', 'vaultSelection.openError': 'Could not open the vault. Please try again.',
'vaultSelection.chooseExisting': 'Choose or enter an existing vault.', 'vaultSelection.chooseExisting': 'Choose or enter an existing vault.',
'vaultSelection.subtitle': 'Choose a vault to start working', 'vaultSelection.subtitle': 'Choose a vault to start working',
'vaultSelection.createTitle': 'Create a new vault', 'vaultSelection.createTitle': 'Create a new vault',
'vaultSelection.createHint': 'Create a local vault folder for workspaces and projects.', 'vaultSelection.createHint': 'Create a local vault folder for Deals and projects.',
'vaultSelection.pathPlaceholder': 'Choose or enter a path...', 'vaultSelection.pathPlaceholder': 'Choose or enter a path...',
'vaultSelection.creating': 'Creating...', 'vaultSelection.creating': 'Creating...',
'vaultSelection.create': 'Create vault', 'vaultSelection.create': 'Create vault',
@@ -112,21 +131,54 @@ export default {
'vaultSelection.recent': 'Recent vaults', 'vaultSelection.recent': 'Recent vaults',
'common.cancel': 'Cancel', 'common.cancel': 'Cancel',
'common.close': 'Close', 'common.close': 'Close',
'workspaceTree.title': 'Workspaces', 'workspaceTree.title': 'Deals',
'workspaceTree.new': 'New workspace', 'workspaceTree.new': 'New Deal',
'workspaceTree.nameRequired': 'Name is required', 'workspaceTree.nameRequired': 'Name is required',
'workspaceTree.chooseTemplate': 'Choose a workspace template', 'workspaceTree.chooseTemplate': 'Choose a Deal template',
'workspaceTree.saveRename': 'Save rename', 'workspaceTree.saveRename': 'Save rename',
'workspaceTree.rename': 'Rename workspace', 'workspaceTree.rename': 'Rename Deal',
'workspaceTree.trash': 'Trash workspace', 'workspaceTree.trash': 'Move Deal to trash',
'workspaceTree.create': 'Create workspace', 'workspaceTree.create': 'Create Deal',
'workspaceTree.namePlaceholder': 'Workspace name', 'workspaceTree.namePlaceholder': 'Deal name',
'workspaceTree.template': 'Template', 'workspaceTree.template': 'Template',
'workspaceTree.creating': 'Creating...', 'workspaceTree.creating': 'Creating...',
'workspaceTree.templateAvailable': 'Available',
'workspaceTree.templateLimited': 'Available with limitations',
'workspaceTree.templateMissingPlugin': 'Plugin is not installed',
'workspaceTree.templatePluginDisabled': 'Plugin is disabled',
'workspaceTree.templateCapabilityUnavailable': 'A required capability is unavailable',
'workspaceTree.templateIncompatible': 'Plugin is incompatible with this version of Verstak',
'workspaceTree.templateLoadFailed': 'Plugin failed to load',
'workspaceTree.templateNotReady': 'Plugin is not ready',
'workspaceTree.templateToolTabs': 'Creates: {tabs}',
'workspaceTree.templateToolNoTabs': 'No Deal tab provided',
'workspaceTree.templateWillBeIncomplete': 'This Deal will be created without the unavailable components.',
'workspaceTree.templateIncompleteCreated': '«{name}» was created with unavailable components.',
'workspaceTree.templatesError': 'Could not load Deal templates. Please try again.',
'workspaceTree.loadError': 'Could not load Deals. Please try again.',
'workspaceTree.selectError': 'Could not select this Deal. Please try again.',
'workspaceTree.createError': 'Could not create the Deal. Please try again.',
'workspaceTree.renameError': 'Could not rename the Deal. Please try again.',
'workspaceTree.trashError': 'Could not move the Deal to trash. Please try again.',
'workspaceTree.parentFolder': 'Parent folder',
'workspaceTree.rootFolder': '(vault root)',
'workspaceTree.folderTitle': 'Folders',
'workspaceTree.folderEmpty': 'No Deals here',
'workspaceTree.createFolder': 'Create Folder',
'workspaceTree.folderNamePlaceholder': 'Folder name',
'workspaceTree.folderIcon': 'Icon',
'workspaceTree.folderColor': 'Color',
'workspaceTree.folderCreateError': 'Could not create the folder. Please try again.',
'pluginManager.loadError': 'Could not load plugins. Please try again.',
'pluginManager.reloadError': 'Could not reload plugins. Please try again.',
'pluginManager.enableError': 'Could not enable the plugin. Please try again.',
'pluginManager.disableError': 'Could not disable the plugin. Please try again.',
'pluginManager.settingsLoadError': 'Could not load plugin settings. Please try again.',
'pluginManager.settingsUnavailable': 'Plugin settings are unavailable.',
'command.openOverview': 'Open Overview', 'command.openOverview': 'Open Overview',
'command.openFiles': 'Open Files', 'command.openFiles': 'Open Files',
'command.openActivity': 'Open Activity', 'command.openActivity': 'Open Activity',
'command.openBrowserInbox': 'Open Browser Inbox', 'command.openBrowserInbox': 'Open Browser',
'command.createMarkdown': 'Create Markdown File', 'command.createMarkdown': 'Create Markdown File',
'command.createText': 'Create Text File', 'command.createText': 'Create Text File',
'command.syncNow': 'Sync Now', 'command.syncNow': 'Sync Now',
@@ -134,6 +186,7 @@ export default {
'command.openPluginManager': 'Open Plugin Manager', 'command.openPluginManager': 'Open Plugin Manager',
'command.handled': '{title} handled', 'command.handled': '{title} handled',
'command.result': '{title} {status}', 'command.result': '{title} {status}',
'command.failed': 'Could not run {title}. Please try again.',
'command.statusHandled': 'handled', 'command.statusHandled': 'handled',
'command.palette': 'Command Palette', 'command.palette': 'Command Palette',
'command.run': 'Run command', 'command.run': 'Run command',
@@ -142,26 +195,26 @@ export default {
'search.placeholder': 'Search', 'search.placeholder': 'Search',
'search.global': 'Global search', 'search.global': 'Global search',
'search.noResults': 'No results', 'search.noResults': 'No results',
'search.type.workspace': 'Workspace', 'search.type.workspace': 'Deal',
'search.type.tool': 'Tool', 'search.type.tool': 'Tool',
'search.type.folder': 'Folder', 'search.type.folder': 'Folder',
'search.type.file': 'File', 'search.type.file': 'File',
'search.type.journal': 'Journal', 'search.type.journal': 'Journal',
'search.type.browserInbox': 'Browser Inbox', 'search.type.browserInbox': 'Browser',
'search.type.activity': 'Activity', 'search.type.activity': 'Activity',
'workbench.noProvider': 'No provider', 'workbench.noProvider': 'No provider',
'workbench.noViewer': 'No viewer/editor available', 'workbench.noViewer': 'No viewer/editor available',
'workbench.noResource': 'No resource opened', 'workbench.noResource': 'No resource opened',
'bundle.noFrontend': 'Plugin has no frontend bundle', 'bundle.noFrontend': 'Plugin has no frontend bundle',
'bundle.notFound': 'Plugin not found', 'bundle.notFound': 'Plugin not found',
'bundle.loadFailed': 'Failed to load bundle: {error}', 'bundle.loadFailed': 'Could not load the plugin interface. Please try again.',
'bundle.emptyContent': 'empty content', 'bundle.emptyContent': 'empty content',
'bundle.executionError': 'Bundle execution error: {error}', 'bundle.executionError': 'Could not start the plugin interface. Please try again.',
'bundle.registrationMissing': 'Bundle loaded but no VerstakPluginRegister call detected', 'bundle.registrationMissing': 'Bundle loaded but no VerstakPluginRegister call detected',
'bundle.componentMissing': 'Component "{component}" not found in bundle. Available: {available}', 'bundle.componentMissing': 'The requested plugin interface is unavailable.',
'bundle.mountError': 'Component mount error: {error}', 'bundle.mountError': 'Could not open the plugin interface. Please try again.',
'bundle.mountUnavailable': 'Mount container not available', 'bundle.mountUnavailable': 'Mount container not available',
'bundle.unexpectedError': 'Unexpected error: {error}', 'bundle.unexpectedError': 'Could not open the plugin interface. Please try again.',
'bundle.unknownError': 'Unknown error', 'bundle.unknownError': 'Unknown error',
'bundle.frontendEntry': 'Frontend entry', 'bundle.frontendEntry': 'Frontend entry',
'bundle.availableComponents': 'Available components', 'bundle.availableComponents': 'Available components',
@@ -197,18 +250,57 @@ export default {
'overview.count.journal.many': '{count} journal entries', 'overview.count.journal.many': '{count} journal entries',
'overview.count.pending.one': '{count} pending item', 'overview.count.pending.one': '{count} pending item',
'overview.count.pending.many': '{count} pending items', 'overview.count.pending.many': '{count} pending items',
'overview.loadingContext': 'Loading workspace context...', 'overview.loadingContext': 'Loading Deal context...',
'overview.lastActive': 'Last active {time}', 'overview.lastActive': 'Last active {time}',
'overview.noRecentActivity': 'No recent workspace activity', 'overview.noRecentActivity': 'No recent Deal activity',
'overview.refresh': 'Refresh', 'overview.refresh': 'Refresh',
'overview.summary': 'Workspace overview summary', 'overview.summary': 'Deal overview summary',
'overview.continue': 'Continue working', 'overview.continue': 'Continue working',
'overview.continueHint': 'Pick up the next useful item in this workspace.', 'overview.continueHint': 'Pick up the next useful item in this Deal.',
'overview.loadingSignals': 'Loading workspace signals...', 'overview.loadingSignals': 'Loading Deal signals...',
'overview.noResume': 'No clear resume point yet', 'overview.noResume': 'No clear resume point yet',
'overview.noResumeHint': 'Recent notes, files, captures, and journal entries will appear here.', 'overview.noResumeHint': 'Recent notes, files, captures, and journal entries will appear here.',
'overview.recentChanges': 'Recent changes', 'overview.recentChanges': 'Recent changes',
'overview.recentChangesHint': 'Latest meaningful activity in this workspace.', 'overview.recentChangesHint': 'Latest meaningful activity in this Deal.',
'overview.event.workspaceOpened': 'Opened Deal',
'overview.event.activity': 'Deal activity',
'overview.event.noteEdited': 'Edited note "{name}"',
'overview.event.noteOpened': 'Opened note "{name}"',
'overview.event.noteCreated': 'Created note "{name}"',
'overview.event.fileOpened': 'Opened file "{name}"',
'overview.event.fileChanged': 'Changed file "{name}"',
'overview.event.fileCreated': 'Created file "{name}"',
'overview.event.fileRemoved': 'Removed file "{name}"',
'overview.event.capturePage': 'Captured page "{name}"',
'overview.event.captureSelection': 'Captured selection "{name}"',
'overview.event.captureLink': 'Captured link "{name}"',
'overview.event.captureFile': 'Captured file "{name}"',
'overview.event.captureConverted': 'Converted capture "{name}"',
'overview.event.capture': 'Captured {kind} "{title}"',
'overview.event.journalAdded': 'Added journal entry "{name}"',
'overview.event.workSessionDetected': 'Work session detected',
'overview.event.todo': 'Todo "{title}"',
'overview.event.reviewCapture': 'Review capture "{title}"',
'overview.event.continueJournal': 'Continue journal entry "{title}"',
'overview.event.possibleJournalEntry': 'Possible journal entry',
'overview.captureKind.page': 'page',
'overview.captureKind.selection': 'selection',
'overview.captureKind.link': 'link',
'overview.captureKind.file': 'file',
'overview.captureKind.item': 'item',
'overview.todo.reminderDue': 'Reminder due',
'overview.todo.overdue': 'Overdue',
'overview.todo.dueSoon': 'Due soon',
'overview.todo.due': 'Due {date}',
'overview.candidateMeta': 'Deal: {deal} · {minutes} min · {activities} activities',
'overview.minutes': '{count} min',
'overview.browserCapture': 'Browser capture',
'overview.untitledTodo': 'Untitled todo',
'overview.untitledCapture': 'Untitled capture',
'overview.untitledJournal': 'Journal entry',
'overview.unknownDeal': 'Unknown Deal',
'overview.item': 'item',
'overview.overviewNote': 'Deal overview note',
'overview.recentFilter': 'Recent changes filter', 'overview.recentFilter': 'Recent changes filter',
'overview.loadingRecent': 'Loading recent changes...', 'overview.loadingRecent': 'Loading recent changes...',
'overview.noChanges': 'No meaningful changes for this filter yet.', 'overview.noChanges': 'No meaningful changes for this filter yet.',
+129 -37
View File
@@ -16,13 +16,13 @@ export default {
'sidebar.error.load': 'Не удалось загрузить боковую панель', 'sidebar.error.load': 'Не удалось загрузить боковую панель',
'sidebar.error.ui': 'Ошибка интерфейса плагина', 'sidebar.error.ui': 'Ошибка интерфейса плагина',
'workspace.overview': 'Обзор', 'workspace.overview': 'Обзор',
'workspace.search': 'Поиск в рабочем пространстве', 'workspace.search': 'Поиск в Деле',
'workspace.tools': 'Инструменты рабочего пространства', 'workspace.tools': 'Инструменты Дела',
'workspace.tool': 'Инструмент рабочего пространства', 'workspace.tool': 'Инструмент Дела',
'workspace.emptyTools': 'Нет доступных инструментов', 'workspace.emptyTools': 'Нет доступных инструментов',
'workspace.emptyToolsHint': 'Включите плагины с инструментами рабочего пространства или откройте Менеджер плагинов в настройках.', 'workspace.emptyToolsHint': 'Включите плагины с инструментами Дела или откройте Менеджер плагинов в настройках.',
'workspace.select': 'Выберите рабочее пространство', 'workspace.select': 'Выберите Дело',
'workspace.selectHint': 'Используйте кнопку + в разделе рабочих пространств, чтобы добавить первый проект.', 'workspace.selectHint': 'Используйте кнопку + в разделе Дел, чтобы добавить первый проект.',
'common.details': 'Подробнее', 'common.details': 'Подробнее',
'common.plugin': 'Плагин', 'common.plugin': 'Плагин',
'common.component': 'Компонент', 'common.component': 'Компонент',
@@ -35,9 +35,10 @@ export default {
'pluginView.select': 'Выберите представление плагина на боковой панели', 'pluginView.select': 'Выберите представление плагина на боковой панели',
'pluginView.selectHint': 'Представления плагинов появятся здесь', 'pluginView.selectHint': 'Представления плагинов появятся здесь',
'common.retry': '⟳ Повторить', 'common.retry': '⟳ Повторить',
'common.save': 'Сохранить',
'common.source': 'Источник', 'common.source': 'Источник',
'common.status': 'Состояние', 'common.status': 'Состояние',
'common.capability': 'Capability', 'common.capability': 'Возможность',
'common.provider': 'Поставщик', 'common.provider': 'Поставщик',
'status.loaded': 'загружено', 'status.loaded': 'загружено',
'status.degraded': 'ограничено', 'status.degraded': 'ограничено',
@@ -48,12 +49,30 @@ export default {
'pluginManager.reload': '⟳ Обновить', 'pluginManager.reload': '⟳ Обновить',
'pluginManager.scanning': 'Сканирование каталогов плагинов...', 'pluginManager.scanning': 'Сканирование каталогов плагинов...',
'pluginManager.summary.plugins': 'Обнаружено плагинов: {count}', 'pluginManager.summary.plugins': 'Обнаружено плагинов: {count}',
'pluginManager.summary.capabilities': 'Зарегистрировано capabilities: {count}', 'pluginManager.summary.capabilities': 'Зарегистрировано возможностей: {count}',
'pluginManager.summary.permissions': 'Известно разрешений: {count}', 'pluginManager.summary.permissions': 'Известно разрешений: {count}',
'pluginManager.scanSummary': 'Сводка проверки плагинов', 'pluginManager.scanSummary': 'Сводка проверки плагинов',
'pluginManager.health': 'Состояние плагинов', 'pluginManager.health': 'Состояние плагинов',
'pluginManager.permissionRisk': 'Риск разрешений', 'pluginManager.permissionRisk': 'Риск разрешений',
'pluginManager.elevatedPermissions': 'Плагинов с повышенными разрешениями: {count}', 'pluginManager.elevatedPermissions': 'Плагинов с повышенными разрешениями: {count}',
'pluginManager.filters': 'Фильтры',
'pluginManager.filterResults': 'Показано плагинов: {visible} из {total}',
'pluginManager.filterReset': 'Сбросить фильтры',
'pluginManager.filterState': 'Состояние',
'pluginManager.filterAll': 'Все',
'pluginManager.filterEnabled': 'Включённые',
'pluginManager.filterDisabled': 'Выключенные',
'pluginManager.filterFailed': 'С проблемами',
'pluginManager.filterDegraded': 'Ограниченные',
'pluginManager.filterPermissions': 'Разрешения',
'pluginManager.filterCapabilities': 'Возможности',
'pluginManager.filterSettings': 'Настройки',
'pluginManager.filterWithSettings': 'С настройками',
'pluginManager.filterWithoutSettings': 'Без настроек',
'pluginManager.filterSource': 'Источник',
'pluginManager.filterOfficial': 'Официальные',
'pluginManager.filterThirdParty': 'Сторонние или локальные',
'pluginManager.filterEmpty': 'Нет плагинов, соответствующих выбранным фильтрам.',
'pluginManager.none': 'Плагины не найдены', 'pluginManager.none': 'Плагины не найдены',
'pluginManager.scannedDirs': 'Проверенные каталоги плагинов:', 'pluginManager.scannedDirs': 'Проверенные каталоги плагинов:',
'pluginManager.userPlugins': 'пользовательские плагины', 'pluginManager.userPlugins': 'пользовательские плагины',
@@ -62,7 +81,7 @@ export default {
'pluginManager.missingTitle': 'Отсутствующие установленные плагины', 'pluginManager.missingTitle': 'Отсутствующие установленные плагины',
'pluginManager.missingHint': 'Эти плагины требуются хранилищу, но их пакеты не установлены на этом устройстве.', 'pluginManager.missingHint': 'Эти плагины требуются хранилищу, но их пакеты не установлены на этом устройстве.',
'pluginManager.missingPackage': 'Плагин указан среди желаемых плагинов хранилища, но его пакет не установлен.', 'pluginManager.missingPackage': 'Плагин указан среди желаемых плагинов хранилища, но его пакет не установлен.',
'pluginManager.capabilityRegistry': 'Реестр capabilities ({count})', 'pluginManager.capabilityRegistry': 'Реестр возможностей ({count})',
'pluginManager.settingsError': 'Ошибка настроек', 'pluginManager.settingsError': 'Ошибка настроек',
'pluginManager.pluginSettings': 'Настройки плагина', 'pluginManager.pluginSettings': 'Настройки плагина',
'pluginManager.settingsBundleUnavailable': 'Frontend bundle панели настроек недоступен', 'pluginManager.settingsBundleUnavailable': 'Frontend bundle панели настроек недоступен',
@@ -74,8 +93,8 @@ export default {
'pluginCard.count.sidebar': 'элементов боковой панели: {count}', 'pluginCard.count.sidebar': 'элементов боковой панели: {count}',
'pluginCard.count.statusbar': 'элементов строки состояния: {count}', 'pluginCard.count.statusbar': 'элементов строки состояния: {count}',
'pluginCard.count.openProviders': 'обработчиков открытия: {count}', 'pluginCard.count.openProviders': 'обработчиков открытия: {count}',
'pluginCard.count.workspace': 'инструментов рабочего пространства: {count}', 'pluginCard.count.workspace': 'инструментов Дела: {count}',
'pluginCard.degraded': 'Плагин работает, но некоторые необязательные capabilities недоступны.', 'pluginCard.degraded': 'Плагин работает, но некоторые необязательные возможности недоступны.',
'pluginCard.name': 'Название', 'pluginCard.name': 'Название',
'pluginCard.contributions': 'Элементы интерфейса', 'pluginCard.contributions': 'Элементы интерфейса',
'pluginCard.technicalDetails': 'Технические сведения', 'pluginCard.technicalDetails': 'Технические сведения',
@@ -83,9 +102,9 @@ export default {
'pluginCard.root': 'Корневой каталог', 'pluginCard.root': 'Корневой каталог',
'pluginCard.provides': 'Предоставляет', 'pluginCard.provides': 'Предоставляет',
'pluginCard.requires': 'Требует', 'pluginCard.requires': 'Требует',
'pluginCard.missingRequired': 'Отсутствуют обязательные capabilities', 'pluginCard.missingRequired': 'Отсутствуют обязательные возможности',
'pluginCard.optionalRequires': 'Необязательные зависимости', 'pluginCard.optionalRequires': 'Необязательные зависимости',
'pluginCard.optionalUnavailable': 'Необязательные capabilities недоступны — плагин работает в ограниченном режиме', 'pluginCard.optionalUnavailable': 'Необязательные возможности недоступны — плагин работает в ограниченном режиме',
'pluginCard.permissions': 'Разрешения', 'pluginCard.permissions': 'Разрешения',
'pluginCard.enabling': '⟳ Включение...', 'pluginCard.enabling': '⟳ Включение...',
'pluginCard.enable': '▶ Включить', 'pluginCard.enable': '▶ Включить',
@@ -96,12 +115,12 @@ export default {
'common.loading': 'Загрузка...', 'common.loading': 'Загрузка...',
'common.browse': 'Обзор...', 'common.browse': 'Обзор...',
'vaultSelection.chooseNew': 'Выберите или введите папку для нового хранилища.', 'vaultSelection.chooseNew': 'Выберите или введите папку для нового хранилища.',
'vaultSelection.createError': 'Не удалось создать хранилище: {error}', 'vaultSelection.createError': 'Не удалось создать хранилище. Повторите попытку.',
'vaultSelection.openError': 'Не удалось открыть хранилище: {error}', 'vaultSelection.openError': 'Не удалось открыть хранилище. Повторите попытку.',
'vaultSelection.chooseExisting': 'Выберите или введите существующее хранилище.', 'vaultSelection.chooseExisting': 'Выберите или введите существующее хранилище.',
'vaultSelection.subtitle': 'Выберите хранилище, чтобы начать работу', 'vaultSelection.subtitle': 'Выберите хранилище, чтобы начать работу',
'vaultSelection.createTitle': 'Создать новое хранилище', 'vaultSelection.createTitle': 'Создать новое хранилище',
'vaultSelection.createHint': 'Создайте локальную папку хранилища для рабочих пространств и проектов.', 'vaultSelection.createHint': 'Создайте локальную папку хранилища для Дел и проектов.',
'vaultSelection.pathPlaceholder': 'Выберите или введите путь...', 'vaultSelection.pathPlaceholder': 'Выберите или введите путь...',
'vaultSelection.creating': 'Создание...', 'vaultSelection.creating': 'Создание...',
'vaultSelection.create': 'Создать хранилище', 'vaultSelection.create': 'Создать хранилище',
@@ -112,21 +131,54 @@ export default {
'vaultSelection.recent': 'Недавние хранилища', 'vaultSelection.recent': 'Недавние хранилища',
'common.cancel': 'Отмена', 'common.cancel': 'Отмена',
'common.close': 'Закрыть', 'common.close': 'Закрыть',
'workspaceTree.title': 'Рабочие пространства', 'workspaceTree.title': 'Дела',
'workspaceTree.new': 'Новое рабочее пространство', 'workspaceTree.new': 'Новое Дело',
'workspaceTree.nameRequired': 'Введите название', 'workspaceTree.nameRequired': 'Введите название',
'workspaceTree.chooseTemplate': 'Выберите шаблон рабочего пространства', 'workspaceTree.chooseTemplate': 'Выберите шаблон Дела',
'workspaceTree.saveRename': 'Сохранить новое название', 'workspaceTree.saveRename': 'Сохранить новое название',
'workspaceTree.rename': 'Переименовать рабочее пространство', 'workspaceTree.rename': 'Переименовать Дело',
'workspaceTree.trash': 'Переместить рабочее пространство в корзину', 'workspaceTree.trash': 'Переместить Дело в корзину',
'workspaceTree.create': 'Создать рабочее пространство', 'workspaceTree.create': 'Создать Дело',
'workspaceTree.namePlaceholder': 'Название рабочего пространства', 'workspaceTree.namePlaceholder': 'Название Дела',
'workspaceTree.template': 'Шаблон', 'workspaceTree.template': 'Шаблон',
'workspaceTree.creating': 'Создание...', 'workspaceTree.creating': 'Создание...',
'workspaceTree.templateAvailable': 'Доступен',
'workspaceTree.templateLimited': 'Доступен с ограничениями',
'workspaceTree.templateMissingPlugin': 'Плагин не установлен',
'workspaceTree.templatePluginDisabled': 'Плагин выключен',
'workspaceTree.templateCapabilityUnavailable': 'Не хватает обязательной возможности',
'workspaceTree.templateIncompatible': 'Плагин несовместим с этой версией Верстака',
'workspaceTree.templateLoadFailed': 'Плагин не удалось загрузить',
'workspaceTree.templateNotReady': 'Плагин ещё не готов к работе',
'workspaceTree.templateToolTabs': 'Создаёт вкладки: {tabs}',
'workspaceTree.templateToolNoTabs': 'Не предоставляет вкладку Дела',
'workspaceTree.templateWillBeIncomplete': 'Дело будет создано без недоступных компонентов.',
'workspaceTree.templateIncompleteCreated': 'Дело «{name}» создано с недоступными компонентами.',
'workspaceTree.templatesError': 'Не удалось загрузить шаблоны Дел. Повторите попытку.',
'workspaceTree.loadError': 'Не удалось загрузить Дела. Повторите попытку.',
'workspaceTree.selectError': 'Не удалось выбрать это Дело. Повторите попытку.',
'workspaceTree.createError': 'Не удалось создать Дело. Повторите попытку.',
'workspaceTree.renameError': 'Не удалось переименовать Дело. Повторите попытку.',
'workspaceTree.trashError': 'Не удалось переместить Дело в корзину. Повторите попытку.',
'workspaceTree.parentFolder': 'Родительская папка',
'workspaceTree.rootFolder': '(корень vault)',
'workspaceTree.folderTitle': 'Папки',
'workspaceTree.folderEmpty': 'Здесь нет Дел',
'workspaceTree.createFolder': 'Создать папку',
'workspaceTree.folderNamePlaceholder': 'Название папки',
'workspaceTree.folderIcon': 'Иконка',
'workspaceTree.folderColor': 'Цвет',
'workspaceTree.folderCreateError': 'Не удалось создать папку. Повторите попытку.',
'pluginManager.loadError': 'Не удалось загрузить плагины. Повторите попытку.',
'pluginManager.reloadError': 'Не удалось перезагрузить плагины. Повторите попытку.',
'pluginManager.enableError': 'Не удалось включить плагин. Повторите попытку.',
'pluginManager.disableError': 'Не удалось выключить плагин. Повторите попытку.',
'pluginManager.settingsLoadError': 'Не удалось загрузить настройки плагина. Повторите попытку.',
'pluginManager.settingsUnavailable': 'Настройки плагина недоступны.',
'command.openOverview': 'Открыть обзор', 'command.openOverview': 'Открыть обзор',
'command.openFiles': 'Открыть файлы', 'command.openFiles': 'Открыть файлы',
'command.openActivity': 'Открыть активность', 'command.openActivity': 'Открыть активность',
'command.openBrowserInbox': 'Открыть входящие из браузера', 'command.openBrowserInbox': 'Открыть Браузер',
'command.createMarkdown': 'Создать файл Markdown', 'command.createMarkdown': 'Создать файл Markdown',
'command.createText': 'Создать текстовый файл', 'command.createText': 'Создать текстовый файл',
'command.syncNow': 'Синхронизировать сейчас', 'command.syncNow': 'Синхронизировать сейчас',
@@ -134,6 +186,7 @@ export default {
'command.openPluginManager': 'Открыть менеджер плагинов', 'command.openPluginManager': 'Открыть менеджер плагинов',
'command.handled': 'Выполнено: {title}', 'command.handled': 'Выполнено: {title}',
'command.result': '{title}: {status}', 'command.result': '{title}: {status}',
'command.failed': 'Не удалось выполнить команду «{title}». Повторите попытку.',
'command.statusHandled': 'выполнено', 'command.statusHandled': 'выполнено',
'command.palette': 'Палитра команд', 'command.palette': 'Палитра команд',
'command.run': 'Выполнить команду', 'command.run': 'Выполнить команду',
@@ -142,26 +195,26 @@ export default {
'search.placeholder': 'Поиск', 'search.placeholder': 'Поиск',
'search.global': 'Глобальный поиск', 'search.global': 'Глобальный поиск',
'search.noResults': 'Ничего не найдено', 'search.noResults': 'Ничего не найдено',
'search.type.workspace': 'Рабочее пространство', 'search.type.workspace': 'Дело',
'search.type.tool': 'Инструмент', 'search.type.tool': 'Инструмент',
'search.type.folder': 'Папка', 'search.type.folder': 'Папка',
'search.type.file': 'Файл', 'search.type.file': 'Файл',
'search.type.journal': 'Журнал', 'search.type.journal': 'Журнал',
'search.type.browserInbox': 'Входящие из браузера', 'search.type.browserInbox': 'Браузер',
'search.type.activity': 'Активность', 'search.type.activity': 'Активность',
'workbench.noProvider': 'Нет обработчика', 'workbench.noProvider': 'Нет обработчика',
'workbench.noViewer': 'Нет доступного средства просмотра или редактора', 'workbench.noViewer': 'Нет доступного средства просмотра или редактора',
'workbench.noResource': 'Ресурс не открыт', 'workbench.noResource': 'Ресурс не открыт',
'bundle.noFrontend': 'У плагина нет frontend bundle', 'bundle.noFrontend': 'У плагина нет frontend bundle',
'bundle.notFound': 'Плагин не найден', 'bundle.notFound': 'Плагин не найден',
'bundle.loadFailed': 'Не удалось загрузить bundle: {error}', 'bundle.loadFailed': 'Не удалось загрузить интерфейс плагина. Повторите попытку.',
'bundle.emptyContent': 'пустое содержимое', 'bundle.emptyContent': 'пустое содержимое',
'bundle.executionError': 'Ошибка выполнения bundle: {error}', 'bundle.executionError': 'Не удалось запустить интерфейс плагина. Повторите попытку.',
'bundle.registrationMissing': 'Bundle загружен, но вызов VerstakPluginRegister не обнаружен', 'bundle.registrationMissing': 'Bundle загружен, но вызов VerstakPluginRegister не обнаружен',
'bundle.componentMissing': 'Компонент «{component}» не найден в bundle. Доступны: {available}', 'bundle.componentMissing': 'Запрошенный интерфейс плагина недоступен.',
'bundle.mountError': 'Ошибка подключения компонента: {error}', 'bundle.mountError': 'Не удалось открыть интерфейс плагина. Повторите попытку.',
'bundle.mountUnavailable': 'Контейнер для подключения недоступен', 'bundle.mountUnavailable': 'Контейнер для подключения недоступен',
'bundle.unexpectedError': 'Непредвиденная ошибка: {error}', 'bundle.unexpectedError': 'Не удалось открыть интерфейс плагина. Повторите попытку.',
'bundle.unknownError': 'Неизвестная ошибка', 'bundle.unknownError': 'Неизвестная ошибка',
'bundle.frontendEntry': 'Точка входа frontend', 'bundle.frontendEntry': 'Точка входа frontend',
'bundle.availableComponents': 'Доступные компоненты', 'bundle.availableComponents': 'Доступные компоненты',
@@ -197,18 +250,57 @@ export default {
'overview.count.journal.many': 'Записей журнала: {count}', 'overview.count.journal.many': 'Записей журнала: {count}',
'overview.count.pending.one': 'Ожидают: {count}', 'overview.count.pending.one': 'Ожидают: {count}',
'overview.count.pending.many': 'Ожидают: {count}', 'overview.count.pending.many': 'Ожидают: {count}',
'overview.loadingContext': 'Загрузка данных рабочего пространства...', 'overview.loadingContext': 'Загрузка данных Дела...',
'overview.lastActive': 'Последняя активность: {time}', 'overview.lastActive': 'Последняя активность: {time}',
'overview.noRecentActivity': 'В рабочем пространстве пока нет недавней активности', 'overview.noRecentActivity': 'В Деле пока нет недавней активности',
'overview.refresh': 'Обновить', 'overview.refresh': 'Обновить',
'overview.summary': 'Сводка рабочего пространства', 'overview.summary': 'Сводка Дела',
'overview.continue': 'Продолжить работу', 'overview.continue': 'Продолжить работу',
'overview.continueHint': 'Вернитесь к следующему полезному делу в этом рабочем пространстве.', 'overview.continueHint': 'Вернитесь к следующему полезному делу в этом Деле.',
'overview.loadingSignals': 'Загрузка рабочих данных...', 'overview.loadingSignals': 'Загрузка данных Дела...',
'overview.noResume': 'Пока неясно, с чего продолжить', 'overview.noResume': 'Пока неясно, с чего продолжить',
'overview.noResumeHint': 'Здесь появятся недавние заметки, файлы, сохранённое и записи журнала.', 'overview.noResumeHint': 'Здесь появятся недавние заметки, файлы, сохранённое и записи журнала.',
'overview.recentChanges': 'Недавние изменения', 'overview.recentChanges': 'Недавние изменения',
'overview.recentChangesHint': 'Последняя важная активность в этом рабочем пространстве.', 'overview.recentChangesHint': 'Последняя важная активность в этом Деле.',
'overview.event.workspaceOpened': 'Открыто Дело',
'overview.event.activity': 'Активность Дела',
'overview.event.noteEdited': 'Изменена заметка «{name}»',
'overview.event.noteOpened': 'Открыта заметка «{name}»',
'overview.event.noteCreated': 'Создана заметка «{name}»',
'overview.event.fileOpened': 'Открыт файл «{name}»',
'overview.event.fileChanged': 'Изменён файл «{name}»',
'overview.event.fileCreated': 'Создан файл «{name}»',
'overview.event.fileRemoved': 'Удалён файл «{name}»',
'overview.event.capturePage': 'Сохранена страница «{name}»',
'overview.event.captureSelection': 'Сохранено выделение «{name}»',
'overview.event.captureLink': 'Сохранена ссылка «{name}»',
'overview.event.captureFile': 'Сохранён файл «{name}»',
'overview.event.captureConverted': 'Сохранённое преобразовано: «{name}»',
'overview.event.capture': 'Сохранено: {kind} «{title}»',
'overview.event.journalAdded': 'Добавлена запись в журнал «{name}»',
'overview.event.workSessionDetected': 'Обнаружена рабочая сессия',
'overview.event.todo': 'Задача «{title}»',
'overview.event.reviewCapture': 'Разобрать сохранённое «{title}»',
'overview.event.continueJournal': 'Продолжить запись журнала «{title}»',
'overview.event.possibleJournalEntry': 'Возможная запись в журнал',
'overview.captureKind.page': 'страница',
'overview.captureKind.selection': 'выделение',
'overview.captureKind.link': 'ссылка',
'overview.captureKind.file': 'файл',
'overview.captureKind.item': 'элемент',
'overview.todo.reminderDue': 'Пора напомнить',
'overview.todo.overdue': 'Просрочено',
'overview.todo.dueSoon': 'Скоро срок',
'overview.todo.due': 'Срок: {date}',
'overview.candidateMeta': 'Дело: {deal} · {minutes} мин. · активностей: {activities}',
'overview.minutes': '{count} мин.',
'overview.browserCapture': 'Сохранённое из браузера',
'overview.untitledTodo': 'Задача без названия',
'overview.untitledCapture': 'Сохранённое без названия',
'overview.untitledJournal': 'Запись журнала',
'overview.unknownDeal': 'Неизвестное Дело',
'overview.item': 'элемент',
'overview.overviewNote': 'Обзорная заметка Дела',
'overview.recentFilter': 'Фильтр недавних изменений', 'overview.recentFilter': 'Фильтр недавних изменений',
'overview.loadingRecent': 'Загрузка недавних изменений...', 'overview.loadingRecent': 'Загрузка недавних изменений...',
'overview.noChanges': 'Для этого фильтра пока нет важных изменений.', 'overview.noChanges': 'Для этого фильтра пока нет важных изменений.',
+4 -2
View File
@@ -127,9 +127,11 @@ export var debug = {
log: log, log: log,
logf: logf, logf: logf,
isEnabled: function () { return ENABLED; }, isEnabled: function () { return ENABLED; },
enable: function () { enable: function (options) {
ENABLED = true; ENABLED = true;
try { localStorage.setItem('verstak-debug', 'true'); } catch (e) {} if (!options || options.persist !== false) {
try { localStorage.setItem('verstak-debug', 'true'); } catch (e) {}
}
}, },
disable: function () { disable: function () {
ENABLED = false; ENABLED = false;
@@ -83,6 +83,11 @@
return { value: result, error: '' }; return { value: result, error: '' };
} }
function reportError(key, fallback, details) {
console.warn('[PluginBundleHost] ' + key + ':', details);
return tr(key, undefined, fallback);
}
async function loadAndMount(pId, compId, nextPropsKey) { async function loadAndMount(pId, compId, nextPropsKey) {
// If same plugin+component and already mounted, skip // If same plugin+component and already mounted, skip
if (currentPluginId === pId && currentComponent === compId && currentPropsKey === nextPropsKey && loadState === 'loaded') { if (currentPluginId === pId && currentComponent === compId && currentPropsKey === nextPropsKey && loadState === 'loaded') {
@@ -125,7 +130,7 @@
const content = assetResult.value; const content = assetResult.value;
if (assetResult.error || !content) { if (assetResult.error || !content) {
loadState = 'error'; loadState = 'error';
errorText = tr('bundle.loadFailed', { error: assetResult.error || tr('bundle.emptyContent') }); errorText = reportError('bundle.loadFailed', 'Could not load the plugin interface. Please try again.', assetResult.error || tr('bundle.emptyContent'));
return; return;
} }
@@ -136,8 +141,7 @@
fn(); fn();
} catch (e) { } catch (e) {
loadState = 'error'; loadState = 'error';
errorText = tr('bundle.executionError', { error: e.message }); errorText = reportError('bundle.executionError', 'Could not start the plugin interface. Please try again.', e);
console.error('[PluginBundleHost] bundle exec error:', e);
return; return;
} }
@@ -154,10 +158,7 @@
const comp = components[compId]; const comp = components[compId];
if (!comp || !comp.mount) { if (!comp || !comp.mount) {
loadState = 'error'; loadState = 'error';
errorText = tr('bundle.componentMissing', { errorText = tr('bundle.componentMissing', undefined, 'The requested plugin interface is unavailable.');
component: compId,
available: Object.keys(components).join(', ') || tr('common.none'),
});
return; return;
} }
@@ -177,8 +178,7 @@
errorText = ''; errorText = '';
} catch (e) { } catch (e) {
loadState = 'error'; loadState = 'error';
errorText = tr('bundle.mountError', { error: e.message }); errorText = reportError('bundle.mountError', 'Could not open the plugin interface. Please try again.', e);
console.error('[PluginBundleHost] mount error:', e);
} }
} else { } else {
loadState = 'error'; loadState = 'error';
@@ -186,8 +186,7 @@
} }
} catch (e) { } catch (e) {
loadState = 'error'; loadState = 'error';
errorText = tr('bundle.unexpectedError', { error: e.message || e }); errorText = reportError('bundle.unexpectedError', 'Could not open the plugin interface. Please try again.', e);
console.error('[PluginBundleHost] error:', e);
} }
} }
@@ -208,17 +207,18 @@
<div class="host-state error"> <div class="host-state error">
<Icon name="warning" size={24} class="error-icon" /> <Icon name="warning" size={24} class="error-icon" />
<p class="error-title">{tr('pluginView.error')}</p> <p class="error-title">{tr('pluginView.error')}</p>
<div class="error-details"> <p class="error-message">{errorText || tr('bundle.unknownError')}</p>
<details class="error-details">
<summary>{tr('common.details')}</summary>
<p><strong>{tr('common.plugin')}:</strong> {currentPluginId || tr('common.unknown')}</p> <p><strong>{tr('common.plugin')}:</strong> {currentPluginId || tr('common.unknown')}</p>
<p><strong>{tr('common.component')}:</strong> {currentComponent || tr('common.unknown')}</p> <p><strong>{tr('common.component')}:</strong> {currentComponent || tr('common.unknown')}</p>
<p class="error-message">{errorText || tr('bundle.unknownError')}</p>
{#if pluginInfo} {#if pluginInfo}
<p class="error-meta">{tr('bundle.frontendEntry')}: {pluginInfo.entry || tr('common.none')}</p> <p class="error-meta">{tr('bundle.frontendEntry')}: {pluginInfo.entry || tr('common.none')}</p>
{/if} {/if}
{#if getComponentList().length > 0} {#if getComponentList().length > 0}
<p class="error-meta">{tr('bundle.availableComponents')}: {getComponentList().join(', ')}</p> <p class="error-meta">{tr('bundle.availableComponents')}: {getComponentList().join(', ')}</p>
{/if} {/if}
</div> </details>
</div> </div>
{:else} {:else}
@@ -303,11 +303,21 @@
max-width: 400px; max-width: 400px;
text-align: left; text-align: left;
background: #16213e; background: #16213e;
padding: 1rem; padding: 0.5rem 0.75rem;
border-radius: 6px; border-radius: 6px;
border: 1px solid #0f3460; border: 1px solid #0f3460;
} }
.error-details[open] {
padding: 0.75rem 1rem;
}
.error-details summary {
cursor: pointer;
color: #e0e0f0;
font-weight: 600;
}
.error-details p { .error-details p {
margin: 0.3rem 0; margin: 0.3rem 0;
} }
@@ -318,12 +328,8 @@
.error-message { .error-message {
color: #e94560; color: #e94560;
font-family: monospace; max-width: 400px;
font-size: 0.8rem; margin: 0 0 0.75rem;
margin-top: 0.5rem !important;
padding: 0.5rem;
background: rgba(233, 69, 96, 0.1);
border-radius: 4px;
} }
.error-meta { .error-meta {
@@ -220,6 +220,24 @@ 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: { settings: {
read: async function(key) { read: async function(key) {
assertActive('settings.read'); assertActive('settings.read');
@@ -262,6 +280,26 @@ export function createPluginAPI(pluginId) {
return 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) { write: function(name, data) {
assertActive('storage.data.write(' + name + ')'); assertActive('storage.data.write(' + name + ')');
if (!name) { if (!name) {
@@ -409,6 +447,12 @@ export function createPluginAPI(pluginId) {
return App.OpenVaultPathExternal(pluginId, relativePath); 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) { showInFolder: function(relativePath) {
assertActive('files.showInFolder(' + relativePath + ')'); assertActive('files.showInFolder(' + relativePath + ')');
return callBackendErrorString(pluginId, 'files.showInFolder(' + relativePath + ')', function() { return callBackendErrorString(pluginId, 'files.showInFolder(' + relativePath + ')', function() {
@@ -424,10 +468,10 @@ export function createPluginAPI(pluginId) {
return App.PluginSyncStatus(pluginId); return App.PluginSyncStatus(pluginId);
}); });
}, },
configure: function(serverURL, username, password) { configure: function(serverURL, username, password, vaultId) {
assertActive('sync.configure'); assertActive('sync.configure');
return callBackendErrorString(pluginId, 'sync.configure', function() { return callBackendErrorString(pluginId, 'sync.configure', function() {
return App.PluginSyncConfigure(pluginId, serverURL || '', username || '', password || ''); return App.PluginSyncConfigure(pluginId, serverURL || '', username || '', password || '', vaultId || '');
}); });
}, },
disconnect: function() { disconnect: function() {
@@ -540,6 +584,48 @@ export function createPluginAPI(pluginId) {
} }
}, },
workspaces: {
list: async function() {
assertActive('workspaces.list');
return callBackend(pluginId, 'workspaces.list', () => App.ListWorkspaces());
},
getCurrent: async function() {
assertActive('workspaces.getCurrent');
return callBackend(pluginId, 'workspaces.getCurrent', () => App.GetCurrentWorkspace());
},
getTree: async function() {
assertActive('workspaces.getTree');
return callBackend(pluginId, 'workspaces.getTree', () => App.GetWorkspaceTree());
},
select: async function(path) {
assertActive('workspaces.select');
return callBackendErrorString(pluginId, 'workspaces.select', () => App.SetCurrentWorkspace(path));
},
create: async function(path, templateId) {
assertActive('workspaces.create');
return callBackend(pluginId, 'workspaces.create', () => App.CreateWorkspace(path, templateId || 'default'));
},
trash: async function(path) {
assertActive('workspaces.trash');
return callBackend(pluginId, 'workspaces.trash', () => App.TrashWorkspace(path));
},
move: async function(id, newParentId) {
assertActive('workspaces.move');
return callBackendErrorString(pluginId, 'workspaces.move', () => App.MoveWorkspace(id, newParentId));
}
},
folders: {
getMetadata: async function(path) {
assertActive('folders.getMetadata');
return callBackend(pluginId, 'folders.getMetadata', () => App.GetFolderMetadata(path));
},
setMetadata: async function(path, meta) {
assertActive('folders.setMetadata');
return callBackendErrorString(pluginId, 'folders.setMetadata', () => App.SetFolderMetadata(path, meta));
}
},
dispose: function() { dispose: function() {
if (disposed) return; if (disposed) return;
disposed = true; disposed = true;
@@ -33,6 +33,11 @@
let reloading = false; let reloading = false;
let toastMessage = ''; let toastMessage = '';
let toastType = 'success'; // 'success' | 'error' | 'info' let toastType = 'success'; // 'success' | 'error' | 'info'
let statusFilter = 'all';
let permissionFilters = [];
let capabilityFilters = [];
let settingsFilter = 'all';
let sourceFilter = 'all';
export let activeSettingsPluginId = ''; export let activeSettingsPluginId = '';
export let activeSettingsPanelId = ''; export let activeSettingsPanelId = '';
@@ -53,6 +58,12 @@
}, 4000); }, 4000);
} }
function reportError(key, fallback, details) {
debug.log('[PluginManager] ' + key + ':', String(details));
WriteFrontendLog('PluginManager', key + ': ' + String(details)).catch(() => {});
return tr(key, undefined, fallback);
}
function notifyPluginsChanged() { function notifyPluginsChanged() {
window.dispatchEvent(new CustomEvent('verstak:plugins-changed')); window.dispatchEvent(new CustomEvent('verstak:plugins-changed'));
} }
@@ -89,14 +100,14 @@
ReadPluginSettings(pluginId).then(result => { ReadPluginSettings(pluginId).then(result => {
const unpacked = unpackBackendResult(result); const unpacked = unpackBackendResult(result);
if (unpacked.error) { if (unpacked.error) {
settingsError = unpacked.error; settingsError = reportError('pluginManager.settingsLoadError', 'Could not load plugin settings. Please try again.', unpacked.error);
settingsData = {}; settingsData = {};
return; return;
} }
settingsData = unpacked.value || {}; settingsData = unpacked.value || {};
}).catch(() => { settingsData = {}; }); }).catch(() => { settingsData = {}; });
} else { } else {
settingsError = `Settings panel not found for plugin "${pluginId}". Check that the plugin is enabled and has settingsPanels in its manifest.`; settingsError = tr('pluginManager.settingsUnavailable', undefined, 'Plugin settings are unavailable.');
} }
} }
@@ -127,7 +138,7 @@
} catch (e) { } catch (e) {
debug.log('[PluginManager] loadAll: GetPlugins ERROR:', String(e)); debug.log('[PluginManager] loadAll: GetPlugins ERROR:', String(e));
WriteFrontendLog('PluginManager', 'loadAll: GetPlugins ERROR: ' + String(e)); WriteFrontendLog('PluginManager', 'loadAll: GetPlugins ERROR: ' + String(e));
error = 'GetPlugins: ' + String(e); error = reportError('pluginManager.loadError', 'Could not load plugins. Please try again.', e);
loading = false; loading = false;
return; return;
} }
@@ -191,7 +202,7 @@
resultMsg = `Reloaded ${count} plugin(s). ${summary}`; resultMsg = `Reloaded ${count} plugin(s). ${summary}`;
} catch (e) { } catch (e) {
debug.log('[PluginManager] reload: ReloadPlugins ERROR:', String(e)); debug.log('[PluginManager] reload: ReloadPlugins ERROR:', String(e));
error = 'Reload: ' + String(e); error = reportError('pluginManager.reloadError', 'Could not reload plugins. Please try again.', e);
reloading = false; reloading = false;
return; return;
} }
@@ -211,7 +222,7 @@
if (err) { if (err) {
debug.log('[PluginManager] enablePlugin: ERROR:', err); debug.log('[PluginManager] enablePlugin: ERROR:', err);
actionFeedback = { ...actionFeedback, [pluginId]: null }; actionFeedback = { ...actionFeedback, [pluginId]: null };
error = 'Enable: ' + err; error = reportError('pluginManager.enableError', 'Could not enable the plugin. Please try again.', err);
return; return;
} }
debug.log('[PluginManager] enablePlugin: success, reloading...'); debug.log('[PluginManager] enablePlugin: success, reloading...');
@@ -232,7 +243,7 @@
if (err) { if (err) {
debug.log('[PluginManager] disablePlugin: ERROR:', err); debug.log('[PluginManager] disablePlugin: ERROR:', err);
actionFeedback = { ...actionFeedback, [pluginId]: null }; actionFeedback = { ...actionFeedback, [pluginId]: null };
error = 'Disable: ' + err; error = reportError('pluginManager.disableError', 'Could not disable the plugin. Please try again.', err);
return; return;
} }
debug.log('[PluginManager] disablePlugin: success, reloading...'); debug.log('[PluginManager] disablePlugin: success, reloading...');
@@ -250,6 +261,11 @@
$: totalPerms = permissions.length; $: totalPerms = permissions.length;
$: statusSummary = computeStatusSummary(plugins); $: statusSummary = computeStatusSummary(plugins);
$: elevatedPermissionPluginCount = computeElevatedPermissionPluginCount(plugins, permissions); $: elevatedPermissionPluginCount = computeElevatedPermissionPluginCount(plugins, permissions);
$: filterPermissions = collectManifestValues(plugins, 'permissions');
$: filterCapabilities = collectCapabilities(plugins);
$: hasReliableSourceMetadata = plugins.some((plugin) => sourceGroup(plugin) !== '');
$: visiblePlugins = filterPlugins(plugins, statusFilter, permissionFilters, capabilityFilters, settingsFilter, sourceFilter, contributions);
$: filtersActive = statusFilter !== 'all' || permissionFilters.length > 0 || capabilityFilters.length > 0 || settingsFilter !== 'all' || sourceFilter !== 'all';
function computeStatusSummary(pluginRows) { function computeStatusSummary(pluginRows) {
return pluginRows.reduce((summary, plugin) => { return pluginRows.reduce((summary, plugin) => {
@@ -268,6 +284,69 @@
return (pluginRows || []).filter(plugin => (plugin?.manifest?.permissions || []).some(permission => dangerous.has(permission))).length; return (pluginRows || []).filter(plugin => (plugin?.manifest?.permissions || []).some(permission => dangerous.has(permission))).length;
} }
function collectManifestValues(pluginRows, key) {
return Array.from(new Set((pluginRows || []).flatMap((plugin) => plugin?.manifest?.[key] || [])))
.sort((left, right) => left.localeCompare(right));
}
function collectCapabilities(pluginRows) {
return Array.from(new Set((pluginRows || []).flatMap((plugin) => [
...(plugin?.manifest?.provides || []),
...(plugin?.manifest?.requires || []),
...(plugin?.manifest?.optionalRequires || []),
]))).sort((left, right) => left.localeCompare(right));
}
function pluginStatusGroup(plugin) {
const status = plugin?.status || '';
if (status === 'failed' || status === 'incompatible' || status === 'missing-required-capability') return 'failed';
if (status === 'degraded') return 'degraded';
if (status === 'disabled' || plugin?.enabled === false) return 'disabled';
return 'enabled';
}
function sourceGroup(plugin) {
const source = plugin?.manifest?.source;
if (source === 'official') return 'official';
if (source === 'local' || source === 'third-party') return 'third-party';
return '';
}
function hasSettings(plugin, activeContributions) {
const pluginId = plugin?.manifest?.id;
return Boolean(pluginId && (activeContributions.settingsPanels || []).some((panel) => panel.pluginId === pluginId));
}
function includesAny(values, expected) {
return expected.length === 0 || expected.some((value) => values.includes(value));
}
function filterPlugins(pluginRows, activeStatusFilter, activePermissionFilters, activeCapabilityFilters, activeSettingsFilter, activeSourceFilter, activeContributions) {
return pluginRows.filter((plugin) => matchesFilters(plugin, activeStatusFilter, activePermissionFilters, activeCapabilityFilters, activeSettingsFilter, activeSourceFilter, activeContributions));
}
function matchesFilters(plugin, activeStatusFilter, activePermissionFilters, activeCapabilityFilters, activeSettingsFilter, activeSourceFilter, activeContributions) {
if (activeStatusFilter !== 'all' && pluginStatusGroup(plugin) !== activeStatusFilter) return false;
if (!includesAny(plugin?.manifest?.permissions || [], activePermissionFilters)) return false;
const declaredCapabilities = [
...(plugin?.manifest?.provides || []),
...(plugin?.manifest?.requires || []),
...(plugin?.manifest?.optionalRequires || []),
];
if (!includesAny(declaredCapabilities, activeCapabilityFilters)) return false;
if (activeSettingsFilter === 'with' && !hasSettings(plugin, activeContributions)) return false;
if (activeSettingsFilter === 'without' && hasSettings(plugin, activeContributions)) return false;
return activeSourceFilter === 'all' || sourceGroup(plugin) === activeSourceFilter;
}
function resetFilters() {
statusFilter = 'all';
permissionFilters = [];
capabilityFilters = [];
settingsFilter = 'all';
sourceFilter = 'all';
}
function closeSettings() { function closeSettings() {
settingsPanel = null; settingsPanel = null;
settingsPluginId = ''; settingsPluginId = '';
@@ -339,6 +418,70 @@
</section> </section>
</div> </div>
<section class="plugin-filters" aria-label={tr('pluginManager.filters')}>
<div class="filter-header">
<div>
<h3>{tr('pluginManager.filters')}</h3>
<p data-plugin-filter-results>{tr('pluginManager.filterResults', { visible: visiblePlugins.length, total: totalPlugins })}</p>
</div>
<button class="filter-reset" data-plugin-filter-reset type="button" on:click={resetFilters} disabled={!filtersActive}>{tr('pluginManager.filterReset')}</button>
</div>
<div class="filter-grid">
<label class="filter-select-label">
<span>{tr('pluginManager.filterState')}</span>
<select class="filter-select" data-plugin-filter="status" bind:value={statusFilter}>
<option value="all">{tr('pluginManager.filterAll')}</option>
<option value="enabled">{tr('pluginManager.filterEnabled')}</option>
<option value="disabled">{tr('pluginManager.filterDisabled')}</option>
<option value="failed">{tr('pluginManager.filterFailed')}</option>
<option value="degraded">{tr('pluginManager.filterDegraded')}</option>
</select>
</label>
<label class="filter-select-label">
<span>{tr('pluginManager.filterSettings')}</span>
<select class="filter-select" data-plugin-filter="settings" bind:value={settingsFilter}>
<option value="all">{tr('pluginManager.filterAll')}</option>
<option value="with">{tr('pluginManager.filterWithSettings')}</option>
<option value="without">{tr('pluginManager.filterWithoutSettings')}</option>
</select>
</label>
{#if hasReliableSourceMetadata}
<label class="filter-select-label">
<span>{tr('pluginManager.filterSource')}</span>
<select class="filter-select" data-plugin-filter="source" bind:value={sourceFilter}>
<option value="all">{tr('pluginManager.filterAll')}</option>
<option value="official">{tr('pluginManager.filterOfficial')}</option>
<option value="third-party">{tr('pluginManager.filterThirdParty')}</option>
</select>
</label>
{/if}
</div>
{#if filterPermissions.length > 0}
<fieldset class="filter-options">
<legend>{tr('pluginManager.filterPermissions')}</legend>
<div class="filter-option-list">
{#each filterPermissions as permission}
<label><input data-plugin-filter-permission={permission} type="checkbox" bind:group={permissionFilters} value={permission} /> <code>{permission}</code></label>
{/each}
</div>
</fieldset>
{/if}
{#if filterCapabilities.length > 0}
<fieldset class="filter-options">
<legend>{tr('pluginManager.filterCapabilities')}</legend>
<div class="filter-option-list">
{#each filterCapabilities as capability}
<label><input data-plugin-filter-capability={capability} type="checkbox" bind:group={capabilityFilters} value={capability} /> <code>{capability}</code></label>
{/each}
</div>
</fieldset>
{/if}
</section>
{#if plugins.length === 0 && missingInstalled.length === 0} {#if plugins.length === 0 && missingInstalled.length === 0}
<div class="empty"> <div class="empty">
<div class="empty-icon"> <div class="empty-icon">
@@ -354,9 +497,13 @@
</ul> </ul>
<p class="hint">{tr('pluginManager.installHint')}</p> <p class="hint">{tr('pluginManager.installHint')}</p>
</div> </div>
{:else if visiblePlugins.length === 0}
<div class="empty filter-empty" data-plugin-filter-empty>
<p>{tr('pluginManager.filterEmpty')}</p>
</div>
{:else} {:else}
<div class="plugin-list"> <div class="plugin-list">
{#each plugins as p} {#each visiblePlugins as p}
<PluginCard {p} {capabilities} {permissions} {contributions} {vaultOpen} {actionFeedback} settingsPanels={(contributions.settingsPanels || []).filter(sp => sp.pluginId === p.manifest?.id)} onEnable={enablePlugin} onDisable={disablePlugin} /> <PluginCard {p} {capabilities} {permissions} {contributions} {vaultOpen} {actionFeedback} settingsPanels={(contributions.settingsPanels || []).filter(sp => sp.pluginId === p.manifest?.id)} onEnable={enablePlugin} onDisable={disablePlugin} />
{/each} {/each}
</div> </div>
@@ -571,6 +718,105 @@
color: #f4f7fb; color: #f4f7fb;
font-size: 0.88rem; font-size: 0.88rem;
} }
.plugin-filters {
margin-bottom: 1rem;
padding: 0.75rem;
border: 1px solid #0f3460;
border-radius: 8px;
background: #121a2c;
}
.filter-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.filter-header h3 {
margin: 0;
color: #e0e0f0;
font-size: 0.9rem;
}
.filter-header p {
margin: 0.2rem 0 0;
color: #a0a0b8;
font-size: 0.78rem;
}
.filter-reset {
flex: 0 0 auto;
padding: 0.35rem 0.65rem;
border: 1px solid #533483;
border-radius: 5px;
background: #16213e;
color: #e0e0e0;
cursor: pointer;
font-size: 0.78rem;
}
.filter-reset:hover:not(:disabled) { background: #0f3460; }
.filter-reset:disabled { cursor: not-allowed; opacity: 0.5; }
.filter-grid {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
}
.filter-select-label {
display: grid;
gap: 0.28rem;
min-width: 10rem;
color: #a0a0b8;
font-size: 0.76rem;
font-weight: 600;
}
.filter-select {
min-height: 2rem;
padding: 0.3rem 1.9rem 0.3rem 0.55rem;
border: 1px solid #0f3460;
border-radius: 5px;
background: #16213e;
background-image: linear-gradient(45deg, transparent 50%, #a0a0b8 50%), linear-gradient(135deg, #a0a0b8 50%, transparent 50%);
background-position: calc(100% - 14px) 50%, calc(100% - 9px) 50%;
background-size: 5px 5px, 5px 5px;
background-repeat: no-repeat;
color: #e0e0e0;
font: inherit;
appearance: none;
}
.filter-select option { background: #16213e; color: #e0e0e0; }
.filter-select:focus { outline: 2px solid #533483; outline-offset: 1px; }
.filter-options {
min-width: 0;
margin: 0.75rem 0 0;
padding: 0.55rem 0.65rem 0.65rem;
border: 1px solid rgba(15, 52, 96, 0.85);
border-radius: 6px;
}
.filter-options legend {
padding: 0 0.25rem;
color: #a0a0b8;
font-size: 0.76rem;
font-weight: 600;
}
.filter-option-list {
display: flex;
flex-wrap: wrap;
gap: 0.4rem 0.75rem;
}
.filter-option-list label {
display: inline-flex;
align-items: center;
gap: 0.28rem;
min-width: 0;
color: #c6c6d8;
font-size: 0.75rem;
}
.filter-option-list input { accent-color: #6c4fa3; }
.filter-option-list code {
max-width: min(31rem, 72vw);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.filter-empty { margin-bottom: 1.5rem; }
.empty { .empty {
padding: 2rem; text-align: center; color: #a0a0b8; padding: 2rem; text-align: center; color: #a0a0b8;
background: #16213e; border-radius: 8px; border: 1px dashed #0f3460; background: #16213e; border-radius: 8px; border: 1px dashed #0f3460;
@@ -579,7 +825,6 @@
.hint { font-size: 0.85rem; margin-top: 0.5rem; opacity: 0.7; } .hint { font-size: 0.85rem; margin-top: 0.5rem; opacity: 0.7; }
.hint-list { list-style: none; padding: 0; margin: 0.5rem 0; font-size: 0.8rem; opacity: 0.7; } .hint-list { list-style: none; padding: 0; margin: 0.5rem 0; font-size: 0.8rem; opacity: 0.7; }
.hint-list li { margin: 0.25rem 0; } .hint-list li { margin: 0.25rem 0; }
.hint code { background: #0f3460; padding: 0.1rem 0.3rem; border-radius: 3px; }
.plugin-list { display: flex; flex-direction: column; gap: 0.75rem; margin-bottom: 1.5rem; min-width: 0; } .plugin-list { display: flex; flex-direction: column; gap: 0.75rem; margin-bottom: 1.5rem; min-width: 0; }
.missing-section { margin-bottom: 1.5rem; } .missing-section { margin-bottom: 1.5rem; }
@@ -646,6 +891,19 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.filter-header {
align-items: stretch;
flex-direction: column;
}
.filter-reset {
width: 100%;
}
.filter-select-label {
flex: 1 1 10rem;
}
.modal { .modal {
width: min(880px, calc(100vw - 2rem)); width: min(880px, calc(100vw - 2rem));
height: min(680px, calc(100vh - 2rem)); height: min(680px, calc(100vh - 2rem));
+7 -1
View File
@@ -3,6 +3,7 @@
import * as App from '../../../wailsjs/go/api/App'; import * as App from '../../../wailsjs/go/api/App';
import { executePluginCommand } from '../plugin-host/VerstakPluginAPI.js'; import { executePluginCommand } from '../plugin-host/VerstakPluginAPI.js';
import { i18n } from '../i18n/index.js'; import { i18n } from '../i18n/index.js';
import { debug } from '../log/debug.js';
let open = false; let open = false;
let query = ''; let query = '';
@@ -194,7 +195,12 @@
closePalette(); closePalette();
setStatus('success', tr('command.result', { title: command.title || command.id, status: result.status || tr('command.statusHandled') })); setStatus('success', tr('command.result', { title: command.title || command.id, status: result.status || tr('command.statusHandled') }));
} catch (err) { } catch (err) {
setStatus('error', `${command.title || command.id}: ${err?.message || String(err)}`); const details = err?.message || String(err);
debug.log('[CommandPalette] command failed:', details);
App.WriteFrontendLog('CommandPalette', `command failed: ${details}`).catch(() => {});
setStatus('error', tr('command.failed', {
title: command.title || command.id,
}, `Could not run ${command.title || command.id}. Please try again.`));
} }
} }
+35 -1
View File
@@ -2,12 +2,15 @@
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import * as App from '../../../wailsjs/go/api/App'; import * as App from '../../../wailsjs/go/api/App';
import WorkspaceTree from './WorkspaceTree.svelte'; import WorkspaceTree from './WorkspaceTree.svelte';
import PluginBundleHost from '../plugin-host/PluginBundleHost.svelte';
import GlobalSearch from './GlobalSearch.svelte'; import GlobalSearch from './GlobalSearch.svelte';
import Icon from '../ui/Icon.svelte'; import Icon from '../ui/Icon.svelte';
import { debug } from '../log/debug.js'; import { debug } from '../log/debug.js';
import { i18n } from '../i18n/index.js'; import { i18n } from '../i18n/index.js';
export let showGlobalSearch = true; export let showGlobalSearch = true;
export let activeView = null;
export let activeViewPluginId = '';
function flog(msg) { function flog(msg) {
App.WriteFrontendLog('Sidebar', msg); App.WriteFrontendLog('Sidebar', msg);
@@ -16,6 +19,7 @@
let plugins = []; let plugins = [];
let vaultStatus = { status: 'unknown', path: '', vaultId: '' }; let vaultStatus = { status: 'unknown', path: '', vaultId: '' };
let sidebarItems = []; let sidebarItems = [];
let workspaceTreeProvider = null;
let errorMessage = ''; let errorMessage = '';
let locale = i18n.getLocale(); let locale = i18n.getLocale();
let unsubscribeLocale = null; let unsubscribeLocale = null;
@@ -26,6 +30,7 @@
})(locale); })(locale);
$: vaultOpen = vaultStatus.status === 'open'; $: vaultOpen = vaultStatus.status === 'open';
$: activeSidebarKey = activeView ? `${activeViewPluginId}:${activeView}` : '';
async function loadSidebar() { async function loadSidebar() {
debug.log('[Sidebar] onMount: START'); debug.log('[Sidebar] onMount: START');
@@ -56,6 +61,19 @@
return plugin.status !== 'disabled' && plugin.status !== 'failed' && plugin.status !== 'incompatible' && plugin.status !== 'missing-required-capability'; return plugin.status !== 'disabled' && plugin.status !== 'failed' && plugin.status !== 'incompatible' && plugin.status !== 'missing-required-capability';
}); });
sidebarItems.sort((a, b) => (a.position || 100) - (b.position || 100)); sidebarItems.sort((a, b) => (a.position || 100) - (b.position || 100));
// Check for workspaceTree contribution
const wtContrib = (localizedContributions.workspaceTree || null);
if (wtContrib && wtContrib.pluginId && wtContrib.component) {
const wtPlugin = plugins.find(p => p.manifest?.id === wtContrib.pluginId);
if (wtPlugin && wtPlugin.status !== 'disabled' && wtPlugin.status !== 'failed' && wtPlugin.status !== 'incompatible' && wtPlugin.status !== 'missing-required-capability') {
workspaceTreeProvider = { pluginId: wtContrib.pluginId, component: wtContrib.component };
} else {
workspaceTreeProvider = null;
}
} else {
workspaceTreeProvider = null;
}
debug.log('[Sidebar] onMount: sidebarItems=' + sidebarItems.length); debug.log('[Sidebar] onMount: sidebarItems=' + sidebarItems.length);
flog('onMount: sidebarItems=' + sidebarItems.length); flog('onMount: sidebarItems=' + sidebarItems.length);
} catch (e) { } catch (e) {
@@ -107,6 +125,8 @@
{#each sidebarItems as item} {#each sidebarItems as item}
<button <button
class="nav-item plugin-item vt-list-row" class="nav-item plugin-item vt-list-row"
class:is-active={activeSidebarKey === `${item.pluginId || ''}:${item.view || item.id}`}
aria-current={activeSidebarKey === `${item.pluginId || ''}:${item.view || item.id}` ? 'page' : undefined}
on:click={() => handleSidebarItem(item)} on:click={() => handleSidebarItem(item)}
type="button" type="button"
> >
@@ -118,7 +138,11 @@
{/if} {/if}
{#if vaultOpen} {#if vaultOpen}
<WorkspaceTree /> {#if workspaceTreeProvider}
<PluginBundleHost pluginId={workspaceTreeProvider.pluginId} componentId={workspaceTreeProvider.component} />
{:else}
<WorkspaceTree />
{/if}
{/if} {/if}
<div class="sidebar-footer"> <div class="sidebar-footer">
@@ -210,6 +234,16 @@
color: var(--vt-color-text-primary); color: var(--vt-color-text-primary);
} }
.nav-item.is-active {
background: var(--vt-color-surface-selected);
color: var(--vt-color-accent);
}
.nav-item.is-active :global(.nav-icon.icon-plugin) {
color: currentColor;
opacity: 1;
}
:global(.nav-icon) { :global(.nav-icon) {
width: 0.9rem; width: 0.9rem;
height: 0.9rem; height: 0.9rem;
+139 -88
View File
@@ -30,6 +30,7 @@
let keyResources = []; let keyResources = [];
let totalNotes = 0; let totalNotes = 0;
let loadedWorkspaceRoot = ''; let loadedWorkspaceRoot = '';
let loadedToolKey = '';
let toolProbe = 0; let toolProbe = 0;
let locale = i18n.getLocale(); let locale = i18n.getLocale();
let unsubscribeLocale = null; let unsubscribeLocale = null;
@@ -38,17 +39,25 @@
void activeLocale; void activeLocale;
return i18n.t(key, params, fallback); return i18n.t(key, params, fallback);
})(locale); })(locale);
$: FILTERS = ['all', 'notes', 'files', 'captures', 'journal'].map((key) => ({
key,
label: tr(`overview.filter.${key}`),
}));
function countText(key, count, params = {}) { function countText(key, count, params = {}) {
return tr(`${key}.${count === 1 ? 'one' : 'many'}`, { count, ...params }); return tr(`${key}.${count === 1 ? 'one' : 'many'}`, { count, ...params });
} }
$: hasNotes = hasTool('notes', availableTools); $: hasNotes = hasTool('notes', availableTools);
$: hasFiles = hasTool('files', availableTools);
$: hasBrowserInbox = hasTool('browser-inbox', availableTools);
$: hasActivity = hasTool('activity', availableTools);
$: hasJournal = hasTool('journal', availableTools);
$: hasTodos = hasTool('todo', availableTools); $: hasTodos = hasTool('todo', availableTools);
$: recentChanges = buildRecentChanges(activityEvents, captures, journalEntries); $: overviewToolKey = [hasNotes, hasFiles, hasBrowserInbox, hasActivity, hasJournal, hasTodos].join('|');
$: FILTERS = ['all']
.concat(hasNotes ? ['notes'] : [])
.concat(hasFiles ? ['files'] : [])
.concat(hasBrowserInbox ? ['captures'] : [])
.concat(hasJournal ? ['journal'] : [])
.map((key) => ({ key, label: tr(`overview.filter.${key}`) }));
$: if (!FILTERS.some(filter => filter.key === activeFilter)) activeFilter = 'all';
$: recentChanges = filterAvailableItems(buildRecentChanges(activityEvents, captures, journalEntries), overviewToolKey);
$: filteredRecentChanges = activeFilter === 'all' $: filteredRecentChanges = activeFilter === 'all'
? recentChanges ? recentChanges
: recentChanges.filter(item => item.category === activeFilter); : recentChanges.filter(item => item.category === activeFilter);
@@ -58,18 +67,19 @@
$: linkedCandidateIds = new Set(journalEntries.map(item => String(item?.sourceCandidateId || '')).filter(Boolean)); $: linkedCandidateIds = new Set(journalEntries.map(item => String(item?.sourceCandidateId || '')).filter(Boolean));
$: pendingWorkSessionCandidates = workSessionCandidates.filter(item => !linkedCandidateIds.has(String(item?.candidateId || ''))); $: pendingWorkSessionCandidates = workSessionCandidates.filter(item => !linkedCandidateIds.has(String(item?.candidateId || '')));
$: urgentTodos = hasTodos ? todos.filter(item => todoAttentionState(item)) : []; $: urgentTodos = hasTodos ? todos.filter(item => todoAttentionState(item)) : [];
$: needsAttention = buildNeedsAttention(unprocessedCaptures, pendingWorkSessionCandidates, urgentTodos); $: needsAttention = filterAvailableItems(buildNeedsAttention(unprocessedCaptures, pendingWorkSessionCandidates, urgentTodos), overviewToolKey);
$: continueItems = buildContinueItems(activityEvents, unprocessedCaptures, journalEntries, urgentTodos); $: continueItems = filterAvailableItems(buildContinueItems(activityEvents, unprocessedCaptures, journalEntries, urgentTodos), overviewToolKey);
$: attentionActionKind = needsAttention[0]?.actionKind || 'browser-inbox'; $: hasAttentionTools = hasBrowserInbox || hasTodos || (hasActivity && hasJournal);
$: attentionActionKind = needsAttention[0]?.actionKind || fallbackAttentionAction();
$: lastActive = lastActiveDate([...recentChanges, ...continueItems], captures, journalEntries, todos); $: lastActive = lastActiveDate([...recentChanges, ...continueItems], captures, journalEntries, todos);
$: summaryItems = [ $: summaryItems = [
{ key: 'notes', label: tr('overview.notes'), count: totalNotes, detail: countText('overview.count.totalRecent', noteRecentChanges, { total: totalNotes, recent: noteRecentChanges }), actionKind: 'notes', actionLabel: tr('overview.openNotes') }, hasNotes ? { key: 'notes', label: tr('overview.notes'), count: totalNotes, detail: countText('overview.count.totalRecent', noteRecentChanges, { total: totalNotes, recent: noteRecentChanges }), actionKind: 'notes', actionLabel: tr('overview.openNotes') } : null,
{ key: 'files', label: tr('overview.files'), count: fileRecentChanges, detail: countText('overview.count.recentChanges', fileRecentChanges), actionKind: 'files', actionLabel: tr('overview.openFiles') }, hasFiles ? { key: 'files', label: tr('overview.files'), count: fileRecentChanges, detail: countText('overview.count.recentChanges', fileRecentChanges), actionKind: 'files', actionLabel: tr('overview.openFiles') } : null,
{ key: 'captures', label: tr('overview.captures'), count: unprocessedCaptures.length, detail: countText('overview.count.captures', unprocessedCaptures.length), actionKind: 'browser-inbox', actionLabel: tr('overview.reviewInbox') }, hasBrowserInbox ? { key: 'captures', label: tr('overview.captures'), count: unprocessedCaptures.length, detail: countText('overview.count.captures', unprocessedCaptures.length), actionKind: 'browser-inbox', actionLabel: tr('overview.reviewInbox') } : null,
{ key: 'activity', label: tr('overview.activity'), count: activityEvents.length, detail: countText('overview.count.events', activityEvents.length), actionKind: 'activity', actionLabel: tr('overview.viewActivity') }, hasActivity ? { key: 'activity', label: tr('overview.activity'), count: activityEvents.length, detail: countText('overview.count.events', activityEvents.length), actionKind: 'activity', actionLabel: tr('overview.viewActivity') } : null,
{ key: 'journal', label: tr('overview.journal'), count: journalEntries.length, detail: countText('overview.count.journal', journalEntries.length), actionKind: 'journal', actionLabel: tr('overview.openJournal') }, hasJournal ? { key: 'journal', label: tr('overview.journal'), count: journalEntries.length, detail: countText('overview.count.journal', journalEntries.length), actionKind: 'journal', actionLabel: tr('overview.openJournal') } : null,
{ key: 'attention', label: tr('overview.attention'), count: needsAttention.length, detail: countText('overview.count.pending', needsAttention.length), actionKind: attentionActionKind, actionLabel: tr('overview.reviewPending') }, hasAttentionTools ? { key: 'attention', label: tr('overview.attention'), count: needsAttention.length, detail: countText('overview.count.pending', needsAttention.length), actionKind: attentionActionKind, actionLabel: tr('overview.reviewPending') } : null,
]; ].filter(Boolean);
onMount(() => { onMount(() => {
unsubscribeLocale = i18n.subscribe((nextLocale) => locale = nextLocale); unsubscribeLocale = i18n.subscribe((nextLocale) => locale = nextLocale);
@@ -78,7 +88,7 @@
onDestroy(() => unsubscribeLocale?.()); onDestroy(() => unsubscribeLocale?.());
$: if (workspaceRootPath && workspaceRootPath !== loadedWorkspaceRoot) { $: if (workspaceRootPath && (workspaceRootPath !== loadedWorkspaceRoot || overviewToolKey !== loadedToolKey)) {
loadOverview(); loadOverview();
} }
@@ -89,11 +99,29 @@
const label = `${tool?.title || ''} ${tool?.id || ''} ${tool?.pluginId || ''}`.toLowerCase(); const label = `${tool?.title || ''} ${tool?.id || ''} ${tool?.pluginId || ''}`.toLowerCase();
return label.includes(name); return label.includes(name);
}); });
if (fromProps) return true; return fromProps;
if (typeof document === 'undefined') return false; }
return Array.from(document.querySelectorAll('.workspace-tabs [role="tab"]')).some(tab => {
return String(tab.textContent || '').trim().toLowerCase().includes(name); function actionIsAvailable(kind) {
}); if (kind === 'notes') return hasNotes;
if (kind === 'files') return hasFiles;
if (kind === 'browser-inbox') return hasBrowserInbox;
if (kind === 'activity') return hasActivity;
if (kind === 'journal') return hasJournal;
if (kind === 'todo') return hasTodos;
return false;
}
function filterAvailableItems(items, _toolKey) {
void _toolKey;
return (items || []).filter(item => actionIsAvailable(item?.actionKind));
}
function fallbackAttentionAction() {
if (hasBrowserInbox) return 'browser-inbox';
if (hasTodos) return 'todo';
if (hasActivity && hasJournal) return 'journal';
return '';
} }
function decodeTuple(response, fallback) { function decodeTuple(response, fallback) {
@@ -136,6 +164,7 @@
].flatMap(key => normalizeRows(settings?.[key])).filter(item => { ].flatMap(key => normalizeRows(settings?.[key])).filter(item => {
const tagged = String(item.workspaceRootPath || item.workspaceName || item.workspaceNodeId || '').trim(); const tagged = String(item.workspaceRootPath || item.workspaceName || item.workspaceNodeId || '').trim();
if (tagged !== workspace) return false; if (tagged !== workspace) return false;
if (String(item.globalState || 'inbox') === 'archived') return false;
const captureId = String(item.captureId || ''); const captureId = String(item.captureId || '');
if (!captureId || seen.has(captureId)) return !captureId; if (!captureId || seen.has(captureId)) return !captureId;
seen.add(captureId); seen.add(captureId);
@@ -166,7 +195,7 @@
} }
function todoTitle(item) { function todoTitle(item) {
return String(item?.title || item?.name || 'Untitled todo').trim() || 'Untitled todo'; return String(item?.title || item?.name || tr('overview.untitledTodo')).trim() || tr('overview.untitledTodo');
} }
function todoAttentionState(item) { function todoAttentionState(item) {
@@ -174,9 +203,9 @@
const now = Date.now(); const now = Date.now();
const dueAt = todoDateMs(item?.dueAt); const dueAt = todoDateMs(item?.dueAt);
const reminderAt = todoDateMs(item?.reminderAt); const reminderAt = todoDateMs(item?.reminderAt);
if (reminderAt && reminderAt <= now) return 'Reminder due'; if (reminderAt && reminderAt <= now) return tr('overview.todo.reminderDue');
if (dueAt && dueAt < now) return 'Overdue'; if (dueAt && dueAt < now) return tr('overview.todo.overdue');
if (dueAt && dueAt <= now + 3 * 24 * 60 * 60 * 1000) return 'Due soon'; if (dueAt && dueAt <= now + 3 * 24 * 60 * 60 * 1000) return tr('overview.todo.dueSoon');
return ''; return '';
} }
@@ -209,17 +238,12 @@
return fileName(path).replace(/\.(md|markdown|txt)$/i, '').replace(/_/g, ' ') || 'Untitled'; return fileName(path).replace(/\.(md|markdown|txt)$/i, '').replace(/_/g, ' ') || 'Untitled';
} }
function quoted(value) {
const clean = String(value || '').trim();
return clean ? `"${clean}"` : '';
}
function entityName(item) { function entityName(item) {
const payload = item?.payload && typeof item.payload === 'object' ? item.payload : {}; const payload = item?.payload && typeof item.payload === 'object' ? item.payload : {};
const path = payload.path || payload.notePath || item?.path || item?.relativePath || looksLikePath(item?.summary); const path = payload.path || payload.notePath || item?.path || item?.relativePath || looksLikePath(item?.summary);
const title = String(item?.title || payload.title || '').trim(); const title = String(item?.title || payload.title || '').trim();
if (path && (!title || /^(saved note|file opened|file changed|activity event)$/i.test(title))) return titleFromPath(path); if (path && (!title || /^(saved note|file opened|file changed|activity event)$/i.test(title))) return titleFromPath(path);
return title || titleFromPath(path) || String(item?.summary || item?.activityId || 'item'); return title || titleFromPath(path) || String(item?.summary || item?.activityId || tr('overview.item'));
} }
function looksLikePath(value) { function looksLikePath(value) {
@@ -229,11 +253,11 @@
} }
function captureTitle(capture) { function captureTitle(capture) {
return capture?.title || capture?.fileName || capture?.url || capture?.captureId || 'Untitled capture'; return capture?.title || capture?.fileName || capture?.url || capture?.captureId || tr('overview.untitledCapture');
} }
function journalTitle(entry) { function journalTitle(entry) {
return entry?.title || entry?.summary || entry?.date || entry?.entryId || 'Journal entry'; return entry?.title || entry?.summary || entry?.date || entry?.entryId || tr('overview.untitledJournal');
} }
function itemTimeLabel(item) { function itemTimeLabel(item) {
@@ -283,22 +307,31 @@
function activityTitle(item) { function activityTitle(item) {
const type = String(item?.type || '').toLowerCase(); const type = String(item?.type || '').toLowerCase();
const name = entityName(item); const name = entityName(item);
if (type === 'note.saved' || type === 'note.edited') return `Edited note ${quoted(name)}`; if (type === 'note.saved' || type === 'note.edited') return tr('overview.event.noteEdited', { name });
if (type === 'note.opened') return `Opened note ${quoted(name)}`; if (type === 'note.opened') return tr('overview.event.noteOpened', { name });
if (type === 'note.created') return `Created note ${quoted(name)}`; if (type === 'note.created') return tr('overview.event.noteCreated', { name });
if (type === 'file.opened') return `Opened file ${quoted(name)}`; if (type === 'file.opened') return tr('overview.event.fileOpened', { name });
if (type === 'file.changed') return `Changed file ${quoted(name)}`; if (type === 'file.changed') return tr('overview.event.fileChanged', { name });
if (type === 'file.created') return `Created file ${quoted(name)}`; if (type === 'file.created') return tr('overview.event.fileCreated', { name });
if (type === 'file.deleted' || type === 'file.trashed') return `Removed file ${quoted(name)}`; if (type === 'file.deleted' || type === 'file.trashed') return tr('overview.event.fileRemoved', { name });
if (type === 'browser.capture.page') return `Captured page ${quoted(name)}`; if (type === 'browser.capture.page') return tr('overview.event.capturePage', { name });
if (type === 'browser.capture.selection') return `Captured selection ${quoted(name)}`; if (type === 'browser.capture.selection') return tr('overview.event.captureSelection', { name });
if (type === 'browser.capture.link') return `Captured link ${quoted(name)}`; if (type === 'browser.capture.link') return tr('overview.event.captureLink', { name });
if (type === 'browser.capture.file') return `Captured file ${quoted(name)}`; if (type === 'browser.capture.file') return tr('overview.event.captureFile', { name });
if (type === 'browser.capture.converted') return `Converted capture ${quoted(name)}`; if (type === 'browser.capture.converted') return tr('overview.event.captureConverted', { name });
if (type === 'journal.entry.added' || type === 'worklog.entry.added') return `Added journal entry ${quoted(name)}`; if (type === 'journal.entry.added' || type === 'worklog.entry.added') return tr('overview.event.journalAdded', { name });
if (type === 'action.started') return 'Work session detected'; if (type === 'action.started') return tr('overview.event.workSessionDetected');
if (type === 'workspace.opened') return 'Workspace opened'; if (type === 'workspace.opened') return tr('overview.event.workspaceOpened');
return item?.title || item?.summary || 'Workspace activity'; return item?.title || item?.summary || tr('overview.event.activity');
}
function captureKindLabel(capture) {
const kind = String(capture?.kind || '').toLowerCase();
if (kind === 'page') return tr('overview.captureKind.page');
if (kind === 'selection') return tr('overview.captureKind.selection');
if (kind === 'link') return tr('overview.captureKind.link');
if (kind === 'file') return tr('overview.captureKind.file');
return tr('overview.captureKind.item');
} }
function actionForCategory(category) { function actionForCategory(category) {
@@ -319,7 +352,7 @@
id: item.activityId || `${item.type}:${timeValue(item)}`, id: item.activityId || `${item.type}:${timeValue(item)}`,
category, category,
title: activityTitle(item), title: activityTitle(item),
meta: `${itemTimeLabel(item)}${item.sourcePluginId ? ' · ' + item.sourcePluginId.replace('verstak.', '') : ''}`, meta: itemTimeLabel(item),
time: timeValue(item), time: timeValue(item),
absolute: absoluteTime(timeValue(item)), absolute: absoluteTime(timeValue(item)),
actionKind: action.kind, actionKind: action.kind,
@@ -329,8 +362,8 @@
const captureItems = captureRows.map(item => ({ const captureItems = captureRows.map(item => ({
id: item.captureId || `capture:${timeValue(item)}`, id: item.captureId || `capture:${timeValue(item)}`,
category: 'captures', category: 'captures',
title: `Captured ${item.kind || 'item'} ${quoted(captureTitle(item))}`, title: tr('overview.event.capture', { kind: captureKindLabel(item), title: captureTitle(item) }),
meta: `${itemTimeLabel(item)} · ${item.domain || item.url || 'Browser capture'}`, meta: `${itemTimeLabel(item)} · ${item.domain || item.url || tr('overview.browserCapture')}`,
time: timeValue(item), time: timeValue(item),
absolute: absoluteTime(timeValue(item)), absolute: absoluteTime(timeValue(item)),
actionKind: 'browser-inbox', actionKind: 'browser-inbox',
@@ -339,8 +372,8 @@
const journalItems = journalRows.map(item => ({ const journalItems = journalRows.map(item => ({
id: item.entryId || `journal:${timeValue(item)}`, id: item.entryId || `journal:${timeValue(item)}`,
category: 'journal', category: 'journal',
title: `Added journal entry ${quoted(journalTitle(item))}`, title: tr('overview.event.journalAdded', { name: journalTitle(item) }),
meta: `${itemTimeLabel(item)}${item.minutes ? ' · ' + item.minutes + ' min' : ''}`, meta: `${itemTimeLabel(item)}${item.minutes ? ' · ' + tr('overview.minutes', { count: item.minutes }) : ''}`,
time: timeValue(item), time: timeValue(item),
absolute: absoluteTime(timeValue(item)), absolute: absoluteTime(timeValue(item)),
actionKind: 'journal', actionKind: 'journal',
@@ -380,8 +413,8 @@
const todoCandidates = [...todoRows].sort((a, b) => todoDateMs(todoTimeValue(a)) - todoDateMs(todoTimeValue(b))).map(item => ({ const todoCandidates = [...todoRows].sort((a, b) => todoDateMs(todoTimeValue(a)) - todoDateMs(todoTimeValue(b))).map(item => ({
id: item.id || `todo:${todoTitle(item)}`, id: item.id || `todo:${todoTitle(item)}`,
category: 'todo', category: 'todo',
title: `Todo ${quoted(todoTitle(item))}`, title: tr('overview.event.todo', { title: todoTitle(item) }),
meta: `${todoAttentionState(item)}${item.dueAt ? ` · Due ${item.dueAt}` : ''}`, meta: `${todoAttentionState(item)}${item.dueAt ? ` · ${tr('overview.todo.due', { date: item.dueAt })}` : ''}`,
time: todoTimeValue(item), time: todoTimeValue(item),
absolute: absoluteTime(todoTimeValue(item)), absolute: absoluteTime(todoTimeValue(item)),
actionKind: 'todo', actionKind: 'todo',
@@ -390,8 +423,8 @@
const captureCandidates = sortByTime(captureRows).map(item => ({ const captureCandidates = sortByTime(captureRows).map(item => ({
id: item.captureId || `capture:${timeValue(item)}`, id: item.captureId || `capture:${timeValue(item)}`,
category: 'captures', category: 'captures',
title: `Review capture ${quoted(captureTitle(item))}`, title: tr('overview.event.reviewCapture', { title: captureTitle(item) }),
meta: `${itemTimeLabel(item)} · ${item.domain || item.kind || 'Browser capture'}`, meta: `${itemTimeLabel(item)} · ${item.domain || captureKindLabel(item) || tr('overview.browserCapture')}`,
time: timeValue(item), time: timeValue(item),
absolute: absoluteTime(timeValue(item)), absolute: absoluteTime(timeValue(item)),
actionKind: 'browser-inbox', actionKind: 'browser-inbox',
@@ -406,7 +439,7 @@
const journalCandidates = sortByTime(journalRows).map(item => ({ const journalCandidates = sortByTime(journalRows).map(item => ({
id: item.entryId || `journal:${timeValue(item)}`, id: item.entryId || `journal:${timeValue(item)}`,
category: 'journal', category: 'journal',
title: `Continue journal entry ${quoted(journalTitle(item))}`, title: tr('overview.event.continueJournal', { title: journalTitle(item) }),
meta: itemTimeLabel(item), meta: itemTimeLabel(item),
time: timeValue(item), time: timeValue(item),
absolute: absoluteTime(timeValue(item)), absolute: absoluteTime(timeValue(item)),
@@ -420,14 +453,18 @@
const captureItems = sortByTime(captureRows).slice(0, 4).map(item => ({ const captureItems = sortByTime(captureRows).slice(0, 4).map(item => ({
id: item.captureId || `capture:${timeValue(item)}`, id: item.captureId || `capture:${timeValue(item)}`,
title: captureTitle(item), title: captureTitle(item),
meta: `${item.kind || 'capture'} · ${itemTimeLabel(item)}`, meta: `${captureKindLabel(item)} · ${itemTimeLabel(item)}`,
actionKind: 'browser-inbox', actionKind: 'browser-inbox',
actionLabel: tr('overview.reviewInbox'), actionLabel: tr('overview.reviewInbox'),
})); }));
const candidateItems = sortByTime(candidates).slice(0, 4).map(item => ({ const candidateItems = sortByTime(candidates).slice(0, 4).map(item => ({
id: item.candidateId || `work-session:${timeValue(item)}`, id: item.candidateId || `work-session:${timeValue(item)}`,
title: 'Possible journal entry', title: tr('overview.event.possibleJournalEntry'),
meta: `Workspace: ${item.workspaceRootPath || workspaceRootPath || 'Unknown'} · ${item.estimatedMinutes || 0} min · ${item.activityCount || (item.activityIds || []).length || 0} activities`, meta: tr('overview.candidateMeta', {
deal: item.workspaceRootPath || workspaceRootPath || tr('overview.unknownDeal'),
minutes: item.estimatedMinutes || 0,
activities: item.activityCount || (item.activityIds || []).length || 0,
}),
actionKind: 'journal', actionKind: 'journal',
actionLabel: tr('overview.reviewCandidate'), actionLabel: tr('overview.reviewCandidate'),
toolRequest: { type: 'work-session-candidate', candidate: item }, toolRequest: { type: 'work-session-candidate', candidate: item },
@@ -435,7 +472,7 @@
const todoItems = [...todoRows].sort((a, b) => todoDateMs(todoTimeValue(a)) - todoDateMs(todoTimeValue(b))).slice(0, 4).map(item => ({ const todoItems = [...todoRows].sort((a, b) => todoDateMs(todoTimeValue(a)) - todoDateMs(todoTimeValue(b))).slice(0, 4).map(item => ({
id: item.id || `todo:${todoTitle(item)}`, id: item.id || `todo:${todoTitle(item)}`,
title: todoTitle(item), title: todoTitle(item),
meta: `${todoAttentionState(item)}${item.dueAt ? ` · Due ${item.dueAt}` : ''}`, meta: `${todoAttentionState(item)}${item.dueAt ? ` · ${tr('overview.todo.due', { date: item.dueAt })}` : ''}`,
actionKind: 'todo', actionKind: 'todo',
actionLabel: tr('overview.openTodos'), actionLabel: tr('overview.openTodos'),
})); }));
@@ -476,12 +513,12 @@
} }
} }
async function loadWorkspaceResources() { async function loadWorkspaceResources(toolState) {
const workspace = String(workspaceRootPath || '').trim(); const workspace = String(workspaceRootPath || '').trim();
if (!workspace) return { keyResources: [], totalNotes: 0 }; if (!workspace) return { keyResources: [], totalNotes: 0 };
const [rootEntries, notesEntries] = await Promise.all([ const [rootEntries, notesEntries] = await Promise.all([
listFiles('verstak.files', workspace), toolState.files ? listFiles('verstak.files', workspace) : Promise.resolve([]),
listFiles('verstak.notes', `${workspace}/Notes`), toolState.notes ? listFiles('verstak.notes', `${workspace}/Notes`) : Promise.resolve([]),
]); ]);
const noteFiles = notesEntries.filter(item => item?.type === 'file'); const noteFiles = notesEntries.filter(item => item?.type === 'file');
const overview = [...notesEntries, ...rootEntries].find(item => /(^|\/)overview\.md$/i.test(String(item.relativePath || item.name || ''))); const overview = [...notesEntries, ...rootEntries].find(item => /(^|\/)overview\.md$/i.test(String(item.relativePath || item.name || '')));
@@ -490,40 +527,54 @@
keyResources: overview ? [{ keyResources: overview ? [{
id: overview.relativePath || overview.name, id: overview.relativePath || overview.name,
title: overview.name || fileName(overview.relativePath) || 'Overview.md', title: overview.name || fileName(overview.relativePath) || 'Overview.md',
meta: overview.relativePath || 'Workspace overview note', meta: overview.relativePath || tr('overview.overviewNote'),
actionKind: hasNotes ? 'notes' : 'files', actionKind: toolState.notes ? 'notes' : 'files',
actionLabel: hasNotes ? tr('overview.openNotes') : tr('overview.openFiles'), actionLabel: toolState.notes ? tr('overview.openNotes') : tr('overview.openFiles'),
}] : [], }] : [],
}; };
} }
async function loadOverview() { async function loadOverview() {
const workspaceAtStart = String(workspaceRootPath || '').trim(); const workspaceAtStart = String(workspaceRootPath || '').trim();
const toolKeyAtStart = overviewToolKey;
const toolState = {
notes: hasNotes,
files: hasFiles,
browserInbox: hasBrowserInbox,
activity: hasActivity,
journal: hasJournal,
todos: hasTodos,
};
loadedWorkspaceRoot = workspaceAtStart; loadedWorkspaceRoot = workspaceAtStart;
loadedToolKey = toolKeyAtStart;
loading = true; loading = true;
const [browserSettings, activitySettings, journalSettings, todoSettings, resources] = await Promise.all([ const [browserSettings, activitySettings, activityRecords, journalSettings, todoSettings, resources] = await Promise.all([
readPluginSettings('verstak.browser-inbox'), toolState.browserInbox ? readPluginSettings('verstak.browser-inbox') : Promise.resolve({}),
readPluginSettings('verstak.activity'), toolState.activity ? readPluginSettings('verstak.activity') : Promise.resolve({}),
readPluginSettings('verstak.journal'), toolState.activity && App.ReadPluginDataNDJSON
readPluginSettings('verstak.todo'), ? App.ReadPluginDataNDJSON('verstak.activity', 'activity-events')
loadWorkspaceResources(), .then(value => decodeTuple(value, []))
.catch(() => [])
: Promise.resolve([]),
toolState.journal ? readPluginSettings('verstak.journal') : Promise.resolve({}),
toolState.todos ? readPluginSettings('verstak.todo') : Promise.resolve({}),
loadWorkspaceResources(toolState),
]); ]);
if (workspaceAtStart !== String(workspaceRootPath || '').trim()) return; if (workspaceAtStart !== String(workspaceRootPath || '').trim() || toolKeyAtStart !== overviewToolKey) return;
captures = browserCaptureRowsForWorkspace(browserSettings); captures = toolState.browserInbox ? browserCaptureRowsForWorkspace(browserSettings) : [];
activityEvents = rowsFor(activitySettings, [ activityEvents = toolState.activity ? normalizeRows(activityRecords).filter(item => {
workspaceKey('events:workspace:'), const tagged = String(item.workspaceRootPath || item.workspaceName || item.workspaceNodeId || '').trim();
'events:global', return !tagged || tagged === workspaceAtStart;
'events', }) : [];
]); journalEntries = toolState.journal ? rowsFor(journalSettings, [
journalEntries = rowsFor(journalSettings, [
workspaceKey('worklog:workspace:'), workspaceKey('worklog:workspace:'),
'worklog', 'worklog',
]); ]) : [];
todos = todoRowsForWorkspace(todoSettings); todos = toolState.todos ? todoRowsForWorkspace(todoSettings) : [];
workSessionCandidates = rowsFor(activitySettings, [ workSessionCandidates = toolState.activity && toolState.journal ? rowsFor(activitySettings, [
workspaceKey('work-session-candidates:workspace:'), workspaceKey('work-session-candidates:workspace:'),
]); ]) : [];
keyResources = resources.keyResources; keyResources = resources.keyResources;
totalNotes = resources.totalNotes; totalNotes = resources.totalNotes;
loading = false; loading = false;
@@ -652,7 +703,7 @@
</main> </main>
<aside class="overview-side"> <aside class="overview-side">
{#if needsAttention.length || !loading} {#if hasAttentionTools && (needsAttention.length || !loading)}
<section class="today-panel overview-panel" data-overview-section="attention"> <section class="today-panel overview-panel" data-overview-section="attention">
<div class="today-panel-head overview-panel-head"> <div class="today-panel-head overview-panel-head">
<div> <div>
+13 -7
View File
@@ -18,6 +18,12 @@
void activeLocale; void activeLocale;
return i18n.t(key, params, fallback); return i18n.t(key, params, fallback);
})(locale); })(locale);
function reportError(key, fallback, details) {
console.warn('[VaultSelection] operation failed:', details);
return tr(key, undefined, fallback);
}
onDestroy(unsubscribeLocale); onDestroy(unsubscribeLocale);
onMount(async () => { onMount(async () => {
@@ -54,13 +60,13 @@
try { try {
const createErr = await App.CreateVault(newVaultPath.trim()); const createErr = await App.CreateVault(newVaultPath.trim());
if (createErr) { if (createErr) {
error = tr('vaultSelection.createError', { error: createErr }); error = reportError('vaultSelection.createError', 'Could not create the vault. Please try again.', createErr);
creating = false; creating = false;
return; return;
} }
const openErr = await App.OpenVault(newVaultPath.trim()); const openErr = await App.OpenVault(newVaultPath.trim());
if (openErr) { if (openErr) {
error = tr('vaultSelection.openError', { error: openErr }); error = reportError('vaultSelection.openError', 'Could not open the vault. Please try again.', openErr);
creating = false; creating = false;
return; return;
} }
@@ -70,7 +76,7 @@
} }
window.dispatchEvent(new CustomEvent('verstak:vault-opened')); window.dispatchEvent(new CustomEvent('verstak:vault-opened'));
} catch (e) { } catch (e) {
error = String(e); error = reportError('vaultSelection.createError', 'Could not create the vault. Please try again.', e);
creating = false; creating = false;
} }
} }
@@ -85,7 +91,7 @@
try { try {
const openErr = await App.OpenVault(openVaultPath.trim()); const openErr = await App.OpenVault(openVaultPath.trim());
if (openErr) { if (openErr) {
error = tr('vaultSelection.openError', { error: openErr }); error = reportError('vaultSelection.openError', 'Could not open the vault. Please try again.', openErr);
opening = false; opening = false;
return; return;
} }
@@ -95,7 +101,7 @@
} }
window.dispatchEvent(new CustomEvent('verstak:vault-opened')); window.dispatchEvent(new CustomEvent('verstak:vault-opened'));
} catch (e) { } catch (e) {
error = String(e); error = reportError('vaultSelection.openError', 'Could not open the vault. Please try again.', e);
opening = false; opening = false;
} }
} }
@@ -106,7 +112,7 @@
try { try {
const openErr = await App.OpenVault(path); const openErr = await App.OpenVault(path);
if (openErr) { if (openErr) {
error = tr('vaultSelection.openError', { error: openErr }); error = reportError('vaultSelection.openError', 'Could not open the vault. Please try again.', openErr);
opening = false; opening = false;
return; return;
} }
@@ -116,7 +122,7 @@
} }
window.dispatchEvent(new CustomEvent('verstak:vault-opened')); window.dispatchEvent(new CustomEvent('verstak:vault-opened'));
} catch (e) { } catch (e) {
error = String(e); error = reportError('vaultSelection.openError', 'Could not open the vault. Please try again.', e);
opening = false; opening = false;
} }
} }
+15 -4
View File
@@ -3,6 +3,7 @@
import PluginBundleHost from '../plugin-host/PluginBundleHost.svelte'; import PluginBundleHost from '../plugin-host/PluginBundleHost.svelte';
import Icon from '../ui/Icon.svelte'; import Icon from '../ui/Icon.svelte';
import { i18n } from '../i18n/index.js'; import { i18n } from '../i18n/index.js';
import { debug } from '../log/debug.js';
export let openedResource = null; export let openedResource = null;
let locale = i18n.getLocale(); let locale = i18n.getLocale();
@@ -21,6 +22,7 @@
$: requestContext = openedResource?.request?.context?.notesMode || openedResource?.request?.context?.isInsideNotesFolder $: requestContext = openedResource?.request?.context?.notesMode || openedResource?.request?.context?.isInsideNotesFolder
? 'notes-markdown' ? 'notes-markdown'
: ((openedResource?.request?.extension === '.md' || openedResource?.request?.extension === '.markdown') ? 'generic-markdown' : 'generic-text'); : ((openedResource?.request?.extension === '.md' || openedResource?.request?.extension === '.markdown') ? 'generic-markdown' : 'generic-text');
$: resourceTitle = resourceDisplayTitle(resourcePath, requestContext);
$: componentProps = openedResource || {}; $: componentProps = openedResource || {};
$: mountKey = [ $: mountKey = [
providerPluginId, providerPluginId,
@@ -30,6 +32,11 @@
requestContext, requestContext,
].join(':'); ].join(':');
function resourceDisplayTitle(path, context) {
const fileName = String(path || '').split('/').filter(Boolean).pop() || String(path || '');
return context === 'notes-markdown' ? fileName.replace(/\.(md|markdown)$/i, '') : fileName;
}
function closeWorkbench() { function closeWorkbench() {
window.dispatchEvent(new CustomEvent('verstak:close-workbench', { cancelable: true })); window.dispatchEvent(new CustomEvent('verstak:close-workbench', { cancelable: true }));
} }
@@ -40,7 +47,7 @@
<div class="workbench-host vt-page"> <div class="workbench-host vt-page">
{#if openedResource?.status === 'no-provider'} {#if openedResource?.status === 'no-provider'}
<div class="workbench-header vt-page-header"> <div class="workbench-header vt-page-header">
<span class="workbench-title vt-page-title">{resourcePath}</span> <span class="workbench-title vt-page-title">{resourceTitle}</span>
<span class="workbench-provider vt-badge">{tr('workbench.noProvider')}</span> <span class="workbench-provider vt-badge">{tr('workbench.noProvider')}</span>
<button class="close-btn btn-ghost btn-icon" type="button" title={tr('common.close')} aria-label={tr('common.close')} on:click={closeWorkbench}> <button class="close-btn btn-ghost btn-icon" type="button" title={tr('common.close')} aria-label={tr('common.close')} on:click={closeWorkbench}>
<Icon name="x" size={18} /> <Icon name="x" size={18} />
@@ -48,12 +55,16 @@
</div> </div>
<div class="workbench-empty no-provider vt-empty-state" data-workbench-status="no-provider"> <div class="workbench-empty no-provider vt-empty-state" data-workbench-status="no-provider">
<p class="vt-empty-title">{tr('workbench.noViewer')}</p> <p class="vt-empty-title">{tr('workbench.noViewer')}</p>
<p class="workbench-meta">{requestMode} · {requestContext}</p> {#if debug.isEnabled()}
<p class="workbench-meta">{requestMode} · {requestContext}</p>
{/if}
</div> </div>
{:else if openedResource} {:else if openedResource}
<div class="workbench-header vt-page-header"> <div class="workbench-header vt-page-header">
<span class="workbench-title vt-page-title">{resourcePath}</span> <span class="workbench-title vt-page-title">{resourceTitle}</span>
<span class="workbench-provider vt-badge accent">{providerId}</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}> <button class="close-btn btn-ghost btn-icon" type="button" title={tr('common.close')} aria-label={tr('common.close')} on:click={closeWorkbench}>
<Icon name="x" size={18} /> <Icon name="x" size={18} />
</button> </button>
+8 -11
View File
@@ -43,8 +43,8 @@
$: selectedWorkspace = nodes.find(n => n.id === selectedWorkspaceName || n.name === selectedWorkspaceName || n.rootPath === selectedWorkspaceName) || null; $: selectedWorkspace = nodes.find(n => n.id === selectedWorkspaceName || n.name === selectedWorkspaceName || n.rootPath === selectedWorkspaceName) || null;
$: workspaceRootPath = selectedWorkspace?.rootPath || selectedWorkspace?.name || selectedWorkspace?.id || ''; $: workspaceRootPath = selectedWorkspace?.rootPath || selectedWorkspace?.name || selectedWorkspace?.id || '';
$: workspaceId = selectedWorkspace?.workspaceId || '';
$: workspaceTitle = selectedWorkspace?.title || selectedWorkspace?.name || selectedWorkspace?.id || selectedWorkspaceName; $: workspaceTitle = selectedWorkspace?.title || selectedWorkspace?.name || selectedWorkspace?.id || selectedWorkspaceName;
$: workspaceType = selectedWorkspace?.type || 'workspace';
$: if (workspaceRootPath !== metadataWorkspaceRoot) { $: if (workspaceRootPath !== metadataWorkspaceRoot) {
metadataWorkspaceRoot = workspaceRootPath; metadataWorkspaceRoot = workspaceRootPath;
workspaceMetadata = null; workspaceMetadata = null;
@@ -79,11 +79,13 @@
if (changed && selectedWorkspaceName) loadTools(); if (changed && selectedWorkspaceName) loadTools();
}); });
window.addEventListener('verstak:workspace-open-tool', handleWorkspaceOpenTool); window.addEventListener('verstak:workspace-open-tool', handleWorkspaceOpenTool);
window.addEventListener('verstak:plugins-changed', handlePluginsChanged);
}); });
onDestroy(() => { onDestroy(() => {
if (unsubscribeLocale) unsubscribeLocale(); if (unsubscribeLocale) unsubscribeLocale();
window.removeEventListener('verstak:workspace-open-tool', handleWorkspaceOpenTool); window.removeEventListener('verstak:workspace-open-tool', handleWorkspaceOpenTool);
window.removeEventListener('verstak:plugins-changed', handlePluginsChanged);
}); });
function toolKey(tool) { function toolKey(tool) {
@@ -169,6 +171,10 @@
requestWorkspaceTool(event?.detail?.kind, event?.detail?.toolRequest || null); requestWorkspaceTool(event?.detail?.kind, event?.detail?.toolRequest || null);
} }
function handlePluginsChanged() {
if (selectedWorkspaceName) loadTools();
}
async function loadTools() { async function loadTools() {
try { try {
toolsLoaded = false; toolsLoaded = false;
@@ -200,7 +206,6 @@
<div class="workspace-header vt-page-header"> <div class="workspace-header vt-page-header">
<div class="workspace-title-group"> <div class="workspace-title-group">
<span class="workspace-title vt-page-title">{workspaceTitle}</span> <span class="workspace-title vt-page-title">{workspaceTitle}</span>
<span class="workspace-type vt-badge accent">{workspaceType}</span>
</div> </div>
<div class="workspace-search" aria-label={tr('workspace.search')}> <div class="workspace-search" aria-label={tr('workspace.search')}>
<GlobalSearch /> <GlobalSearch />
@@ -235,7 +240,7 @@
<PluginBundleHost <PluginBundleHost
pluginId={activeTool.pluginId} pluginId={activeTool.pluginId}
componentId={activeTool.component} componentId={activeTool.component}
componentProps={{ workspaceName: selectedWorkspaceName, workspaceNodeId: selectedWorkspaceName, workspaceNode: selectedWorkspace, workspaceRootPath, toolRequest: activeToolRequest }} componentProps={{ workspaceName: selectedWorkspaceName, workspaceNodeId: selectedWorkspaceName, workspaceNode: selectedWorkspace, workspaceRootPath, workspaceId, toolRequest: activeToolRequest }}
/> />
{/if} {/if}
{/if} {/if}
@@ -287,14 +292,6 @@
font-weight: 600; font-weight: 600;
} }
.workspace-type {
color: var(--vt-color-accent);
font-size: 0.75rem;
padding: 0.1rem 0.4rem;
border-radius: var(--vt-radius-sm);
background: var(--vt-color-accent-muted);
}
.workspace-search { .workspace-search {
width: min(27rem, 46vw); width: min(27rem, 46vw);
min-width: 16rem; min-width: 16rem;
+329 -168
View File
@@ -13,25 +13,35 @@
let loading = true; let loading = true;
let localError = ''; let localError = '';
let workspaces = []; let workspaces = [];
let treeNodes = [];
let currentWorkspaceId = ''; let currentWorkspaceId = '';
let showCreate = false; let showCreate = false;
let newWorkspaceName = ''; let newWorkspaceName = '';
let workspaceTemplates = []; let workspaceTemplates = [];
let templatePluginNames = {}; let templatePluginNames = {};
let templatePlugins = {};
let templateCapabilities = new Set();
let selectedTemplateId = 'default'; let selectedTemplateId = 'default';
let createError = ''; let createError = '';
let templateWarning = null;
let templatesLoading = false; let templatesLoading = false;
let creating = false; let creating = false;
let renamingId = '';
let renameValue = '';
let busyId = ''; let busyId = '';
let expandedFolders = {};
let parentFolderPath = '';
let locale = i18n.getLocale(); let locale = i18n.getLocale();
let unsubscribeLocale = null; let unsubscribeLocale = null;
$: tr = ((activeLocale) => (key, params, fallback) => { $: tr = ((activeLocale) => (key, params, fallback) => {
void activeLocale; void activeLocale;
return i18n.t(key, params, fallback); return i18n.t(key, params, fallback);
})(locale); })(locale);
function reportError(key, fallback, details) {
console.warn('[WorkspaceTree] operation failed:', details);
return tr(key, undefined, fallback);
}
onMount(() => { onMount(() => {
unsubscribeLocale = i18n.subscribe((nextLocale) => { unsubscribeLocale = i18n.subscribe((nextLocale) => {
const changed = locale !== nextLocale; const changed = locale !== nextLocale;
@@ -61,64 +71,167 @@
} }
$: selectedTemplate = workspaceTemplates.find(template => template.id === selectedTemplateId) || workspaceTemplates[0] || null; $: selectedTemplate = workspaceTemplates.find(template => template.id === selectedTemplateId) || workspaceTemplates[0] || null;
$: selectedTemplateTools = (selectedTemplate?.workspaceTools || []).map((pluginId) => (
templateToolState(pluginId, templatePlugins, templateCapabilities, templatePluginNames, tr)
));
$: selectedTemplateIssues = selectedTemplateTools.filter(tool => tool.status !== 'available');
function toolLabel(pluginId) { function toolLabel(pluginId, names = templatePluginNames) {
return templatePluginNames[pluginId] || String(pluginId || '').replace(/^verstak\./, ''); 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() { async function loadWorkspaceTemplates() {
templatesLoading = true; templatesLoading = true;
try { try {
const [templates, plugins] = await Promise.all([ const [templates, plugins, capabilities] = await Promise.all([
App.ListWorkspaceTemplates ? App.ListWorkspaceTemplates() : [], App.ListWorkspaceTemplates ? App.ListWorkspaceTemplates() : [],
App.GetPlugins ? App.GetPlugins() : [], App.GetPlugins ? App.GetPlugins() : [],
App.GetCapabilities ? App.GetCapabilities() : [],
]); ]);
const [list, err] = resultOrError(templates, []); const [list, err] = resultOrError(templates, []);
if (err) { if (err) { createError = reportError('workspaceTree.templatesError', 'Could not load Deal templates.', err); workspaceTemplates = []; return; }
createError = err;
workspaceTemplates = [];
return;
}
workspaceTemplates = Array.isArray(list) ? list : []; workspaceTemplates = Array.isArray(list) ? list : [];
await Promise.all((Array.isArray(plugins) ? plugins : []).map((plugin) => ( await Promise.all((Array.isArray(plugins) ? plugins : []).map((plugin) => i18n.loadPlugin(plugin.manifest?.id, plugin.manifest?.localization).catch(() => {})));
i18n.loadPlugin(plugin.manifest?.id, plugin.manifest?.localization).catch(() => {}) const localizedPlugins = (Array.isArray(plugins) ? plugins : []).map((plugin) => i18n.localizePlugin(plugin));
))); templatePluginNames = localizedPlugins.reduce((names, plugin) => { const id = plugin?.manifest?.id; const name = plugin?.manifest?.name; if (id && name) names[id] = name; return names; }, {});
templatePluginNames = (Array.isArray(plugins) ? plugins : []).map((plugin) => i18n.localizePlugin(plugin)).reduce((names, plugin) => { templatePlugins = localizedPlugins.reduce((result, plugin) => { const id = plugin?.manifest?.id; if (id) result[id] = plugin; return result; }, {});
const id = plugin?.manifest?.id; const [capabilityList] = resultOrError(capabilities, []);
const name = plugin?.manifest?.name; templateCapabilities = new Set((Array.isArray(capabilityList) ? capabilityList : []).map(capability => capability?.name).filter(Boolean));
if (id && name) names[id] = name; if (!workspaceTemplates.some(template => template.id === selectedTemplateId)) selectedTemplateId = workspaceTemplates[0]?.id || '';
return names; } catch (error) { createError = reportError('workspaceTree.templatesError', 'Could not load Deal templates.', error); workspaceTemplates = []; }
}, {}); finally { templatesLoading = false; }
if (!workspaceTemplates.some(template => template.id === selectedTemplateId)) {
selectedTemplateId = workspaceTemplates[0]?.id || '';
}
} catch (error) {
createError = String(error);
workspaceTemplates = [];
} finally {
templatesLoading = false;
}
} }
function wsName(workspace) { function wsName(workspace) {
return String(workspace?.name || workspace?.rootPath || ''); return String(workspace?.name || workspace?.path || '');
} }
function asNode(workspace, order) { function wsPath(workspace) {
const name = wsName(workspace); return String(workspace?.path || workspace?.rootPath || '');
return { }
id: name,
type: 'space', function buildTreeNodes(list) {
title: name, const nodes = [];
name, const nodeMap = {};
rootPath: workspace.rootPath || name, const folders = [];
status: 'active',
order, // Collect all nodes: workspaces + folder nodes from GetTree
}; // First try the tree from GetTree if available
if (treeNodes.length > 0) {
const folderMap = {};
const wsMap = {};
for (const node of treeNodes) {
if (node.type === 'folder') {
folders.push(node);
folderMap[node.id] = node;
} else if (node.type === 'space') {
wsMap[node.id] = node;
}
}
// Match workspaces to their parent folders
const result = [];
for (const ws of list) {
const path = wsPath(ws);
const tn = wsMap[path];
result.push({
id: path,
type: 'space',
title: wsName(ws),
name: wsName(ws),
path: path,
rootPath: path,
parentId: tn?.parentId || null,
status: 'active',
order: tn?.order || 0,
workspace: ws,
});
}
for (const folder of folders) {
result.push({
id: folder.id,
type: 'folder',
title: folder.title,
name: folder.title,
path: folder.path || folder.id,
parentId: folder.parentId || null,
status: 'active',
order: folder.order || 0,
folder: folder,
});
}
return result;
}
// Fallback: build flat tree from workspace paths
const visibleFolders = new Set();
for (const ws of list) {
const path = wsPath(ws);
const parts = path.split('/');
const name = parts[parts.length - 1];
const parentId = parts.length > 1 ? parts.slice(0, -1).join('/') : null;
// Ensure all parent folders exist
if (parentId) {
let current = '';
for (let i = 0; i < parts.length - 1; i++) {
current = current ? current + '/' + parts[i] : parts[i];
if (!visibleFolders.has(current)) {
visibleFolders.add(current);
nodes.push({
id: current,
type: 'folder',
title: parts[i],
name: parts[i],
path: current,
parentId: i > 0 ? parts.slice(0, i).join('/') : null,
status: 'active',
order: 0,
});
}
}
}
nodes.push({
id: path,
type: 'space',
title: name,
name: name,
path: path,
rootPath: path,
parentId: parentId,
status: 'active',
order: nodes.length,
workspace: ws,
});
}
return nodes;
} }
function nodesForEvent() { function nodesForEvent() {
return workspaces.map(asNode); return workspaces.map((ws, i) => ({
id: wsPath(ws), type: 'space', title: wsName(ws),
name: wsName(ws), rootPath: wsPath(ws), status: 'active', order: i,
}));
} }
async function loadWorkspaces() { async function loadWorkspaces() {
@@ -126,138 +239,142 @@
localError = ''; localError = '';
try { try {
const [list, err] = resultOrError(await App.ListWorkspaces(), []); const [list, err] = resultOrError(await App.ListWorkspaces(), []);
if (err) { if (err) { localError = reportError('workspaceTree.loadError', 'Could not load Deals.', err); workspaces = []; }
localError = err; else { workspaces = list || []; }
workspaces = [];
} else { // Try to get tree nodes
workspaces = list || []; try {
if (!currentWorkspaceId) { const tree = await App.GetWorkspaceTree();
let currentWorkspace = null; if (tree && tree.nodes) treeNodes = tree.nodes;
try { } catch { treeNodes = []; }
currentWorkspace = await App.GetCurrentWorkspace();
} catch { const allNodes = buildTreeNodes(workspaces);
currentWorkspace = null;
} if (!currentWorkspaceId) {
const currentName = wsName(currentWorkspace); let currentWorkspace = null;
if (workspaces.some((ws) => wsName(ws) === currentName)) { try { currentWorkspace = await App.GetCurrentWorkspace(); } catch { currentWorkspace = null; }
currentWorkspaceId = currentName; const currentPath = currentWorkspace?.path || currentWorkspace?.rootPath || '';
} if (allNodes.some(n => n.id === currentPath)) currentWorkspaceId = currentPath;
} else if (!workspaces.some((ws) => wsName(ws) === currentWorkspaceId)) { } else if (!allNodes.some(n => n.id === currentWorkspaceId)) {
currentWorkspaceId = ''; currentWorkspaceId = '';
}
activeWorkspaceId.set(currentWorkspaceId);
} }
} catch (e) { activeWorkspaceId.set(currentWorkspaceId);
localError = String(e); workspaces = allNodes;
} } catch (e) { localError = reportError('workspaceTree.loadError', 'Could not load Deals.', e); }
loading = false; loading = false;
} }
async function selectWorkspace(workspace) { function getChildren(parentId) {
const id = wsName(workspace); const id = parentId || null;
return workspaces.filter(n => (n.parentId || null) === id).sort((a, b) => {
if (a.type === 'folder' && b.type !== 'folder') return -1;
if (a.type !== 'folder' && b.type === 'folder') return 1;
return (a.order || 0) - (b.order || 0) || a.title.localeCompare(b.title);
});
}
function isExpanded(node) {
if (node.type !== 'folder') return false;
if (expandedFolders[node.id] !== undefined) return expandedFolders[node.id];
return true; // default expanded
}
function toggleFolder(node) {
expandedFolders[node.id] = !isExpanded(node);
expandedFolders = expandedFolders; // trigger reactivity
}
async function selectWorkspace(node) {
const id = node.id;
const err = await App.SetCurrentWorkspace(id); const err = await App.SetCurrentWorkspace(id);
if (err) { if (err) { localError = reportError('workspaceTree.selectError', 'Could not select this Deal.', err); return; }
localError = err;
return;
}
currentWorkspaceId = id; currentWorkspaceId = id;
activeWorkspaceId.set(id); activeWorkspaceId.set(id);
window.dispatchEvent(new CustomEvent('verstak:workspace-selected', { window.dispatchEvent(new CustomEvent('verstak:workspace-selected', {
detail: { workspaceName: id, nodes: nodesForEvent() } detail: { workspaceName: id, workspacePath: id, nodes: nodesForEvent() }
})); }));
} }
function buildCreatePath() {
const name = newWorkspaceName.trim();
if (parentFolderPath) return parentFolderPath + '/' + name;
return name;
}
async function doCreate() { async function doCreate() {
const name = newWorkspaceName.trim(); const name = newWorkspaceName.trim();
if (!name) { if (!name) { createError = tr('workspaceTree.nameRequired'); return; }
createError = tr('workspaceTree.nameRequired'); if (!selectedTemplate) { createError = tr('workspaceTree.chooseTemplate'); return; }
return; const path = buildCreatePath();
}
if (!selectedTemplate) {
createError = tr('workspaceTree.chooseTemplate');
return;
}
creating = true; creating = true;
createError = ''; createError = '';
const [, err] = resultOrError(await App.CreateWorkspace(name, selectedTemplate.id), null); const [, err] = resultOrError(await App.CreateWorkspace(path, selectedTemplate.id), null);
if (err) { if (err) { createError = reportError('workspaceTree.createError', 'Could not create the Deal.', err); creating = false; return; }
createError = err;
creating = false;
return;
}
showCreate = false; showCreate = false;
newWorkspaceName = ''; newWorkspaceName = '';
parentFolderPath = '';
creating = false; creating = false;
await loadWorkspaces(); await loadWorkspaces();
const created = workspaces.find((ws) => wsName(ws) === name); const created = workspaces.find((ws) => ws.id === path);
if (created) await selectWorkspace(created); if (created) await selectWorkspace(created);
const creationIssues = selectedTemplateIssues.map(tool => ({ pluginId: tool.pluginId, name: tool.name, reason: tool.reason }));
templateWarning = creationIssues.length > 0 ? { workspaceName: name, issues: creationIssues } : null;
} }
function openCreateDialog() { async function openCreateDialog() {
showCreate = true; showCreate = true;
newWorkspaceName = ''; newWorkspaceName = '';
parentFolderPath = '';
createError = ''; createError = '';
if (!workspaceTemplates.length && !templatesLoading) loadWorkspaceTemplates(); await loadWorkspaceTemplates();
} }
function closeCreateDialog() { function closeCreateDialog() {
if (creating) return; if (creating) return;
showCreate = false; showCreate = false;
newWorkspaceName = ''; newWorkspaceName = '';
parentFolderPath = '';
createError = ''; createError = '';
} }
function startRename(workspace) { function dismissTemplateWarning() { templateWarning = null; }
renamingId = wsName(workspace);
renameValue = renamingId; function startRename(node) {
localError = ''; busyId = node.id;
// For rename, we store the full path but edit just the name part
// Actually, let's keep rename simple: edit the last segment
} }
function cancelRename() { function cancelRename() { }
renamingId = '';
renameValue = '';
}
async function commitRename(workspace) { async function commitRename() { }
const oldName = wsName(workspace);
const newName = renameValue.trim();
if (!newName || newName === oldName) {
cancelRename();
return;
}
busyId = oldName;
const err = await App.RenameWorkspace(oldName, newName);
if (err) {
localError = err;
busyId = '';
return;
}
renamingId = '';
renameValue = '';
busyId = '';
currentWorkspaceId = newName;
await loadWorkspaces();
const renamed = workspaces.find((ws) => wsName(ws) === newName);
if (renamed) await selectWorkspace(renamed);
}
async function trashWorkspace(workspace) { async function trashWorkspace(node) {
const name = wsName(workspace); const path = node.id;
busyId = name; busyId = path;
const [, err] = resultOrError(await App.TrashWorkspace(name), null); const [, err] = resultOrError(await App.TrashWorkspace(path), null);
if (err) { if (err) { localError = reportError('workspaceTree.trashError', 'Could not move to trash.', err); busyId = ''; return; }
localError = err; if (currentWorkspaceId === path) currentWorkspaceId = '';
busyId = '';
return;
}
if (currentWorkspaceId === name) currentWorkspaceId = '';
busyId = ''; busyId = '';
await loadWorkspaces(); await loadWorkspaces();
if (currentWorkspaceId) { if (currentWorkspaceId) {
const selected = workspaces.find((ws) => wsName(ws) === currentWorkspaceId); const selected = workspaces.find((ws) => ws.id === currentWorkspaceId);
if (selected) await selectWorkspace(selected); if (selected) await selectWorkspace(selected);
} }
} }
// Available parent folders for create dialog
$: parentFolderOptions = getFolderOptions();
function getFolderOptions() {
const opts = [{ id: '', name: tr('workspaceTree.rootFolder') || '(root)' }];
for (const node of workspaces) {
if (node.type === 'folder') {
opts.push({ id: node.id, name: node.id });
}
}
return opts;
}
</script> </script>
<div class="wt"> <div class="wt">
@@ -273,50 +390,74 @@
{/if} {/if}
<div class="wt-list"> <div class="wt-list">
{#each workspaces as workspace (wsName(workspace))} {#each getChildren(null) as node (node.id)}
{@const id = wsName(workspace)} {#if node.type === 'folder'}
<div class="wt-node vt-list-row" class:selected={id === $activeWorkspaceId}> {@const expanded = isExpanded(node)}
<div class="wt-row"> <div class="wt-folder">
<span class="wt-icon"><Icon name="space" size={13} class="wt-node-icon" /></span> <div class="wt-row wt-folder-row" on:click={() => toggleFolder(node)}>
{#if renamingId === id} <span class="wt-chevron">{expanded ? '▾' : '▸'}</span>
<input <span class="wt-icon"><Icon name="folder" size={13} /></span>
class="wt-rename" <span class="wt-label wt-folder-label">{node.title}</span>
bind:value={renameValue} </div>
disabled={busyId === id} {#if expanded}
on:keydown={(e) => { {#each getChildren(node.id) as child (child.id)}
if (e.key === 'Enter') commitRename(workspace); <div class="wt-node vt-list-row" class:selected={child.id === $activeWorkspaceId} style="padding-left: 1.2rem;">
if (e.key === 'Escape') cancelRename(); <div class="wt-row">
}} <span class="wt-icon"><Icon name="space" size={13} class="wt-node-icon" /></span>
/> <button class="wt-label" on:click={() => selectWorkspace(child)} type="button">{child.title}</button>
<button class="wt-btn wt-btn-small wt-always" on:click={() => commitRename(workspace)} title={tr('workspaceTree.saveRename')} type="button" disabled={busyId === id}>OK</button> <button class="wt-icon-btn danger" on:click={() => trashWorkspace(child)} title={tr('workspaceTree.trash')} type="button" disabled={busyId === child.id}>
<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> <Icon name="trash" size={12} />
{:else} </button>
<button class="wt-label" on:click={() => selectWorkspace(workspace)} type="button">{id}</button> </div>
<button class="wt-icon-btn" on:click={() => startRename(workspace)} title={tr('workspaceTree.rename')} type="button" disabled={busyId === id}> </div>
<Icon name="edit" size={12} /> {/each}
</button>
<button class="wt-icon-btn danger" on:click={() => trashWorkspace(workspace)} title={tr('workspaceTree.trash')} type="button" disabled={busyId === id}>
<Icon name="trash" size={12} />
</button>
{/if} {/if}
</div> </div>
</div> {:else}
<div class="wt-node vt-list-row" class:selected={node.id === $activeWorkspaceId}>
<div class="wt-row">
<span class="wt-icon"><Icon name="space" size={13} class="wt-node-icon" /></span>
<button class="wt-label" on:click={() => selectWorkspace(node)} type="button">{node.title}</button>
<button class="wt-icon-btn danger" on:click={() => trashWorkspace(node)} title={tr('workspaceTree.trash')} type="button" disabled={busyId === node.id}>
<Icon name="trash" size={12} />
</button>
</div>
</div>
{/if}
{/each} {/each}
</div> </div>
{#if templateWarning}
<section class="workspace-template-warning" data-workspace-template-warning role="alert">
<div>
<strong>{tr('workspaceTree.templateIncompleteCreated', { name: templateWarning.workspaceName })}</strong>
{#each templateWarning.issues as issue (issue.pluginId)}
<div class="workspace-template-warning-item">{issue.name}: {issue.reason}</div>
{/each}
</div>
<button class="wt-btn" type="button" on:click={dismissTemplateWarning} title={tr('common.close')}>{tr('common.close')}</button>
</section>
{/if}
{#if showCreate} {#if showCreate}
<div class="workspace-create-overlay" data-workspace-create-modal role="dialog" aria-modal="true" aria-label={tr('workspaceTree.create')}> <div class="workspace-create-overlay" data-workspace-create-modal role="dialog" aria-modal="true" aria-label={tr('workspaceTree.create')}>
<div class="workspace-create-modal"> <div class="workspace-create-modal">
<div class="workspace-create-header"> <div class="workspace-create-header">
<div> <div><h2>{tr('workspaceTree.new')}</h2></div>
<h2>{tr('workspaceTree.new')}</h2>
</div>
<button class="wt-btn" on:click={closeCreateDialog} type="button" disabled={creating}>{tr('common.close')}</button> <button class="wt-btn" on:click={closeCreateDialog} type="button" disabled={creating}>{tr('common.close')}</button>
</div> </div>
<label class="workspace-create-field"> <label class="workspace-create-field">
<span>{tr('pluginCard.name')}</span> <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()} /> <input data-workspace-name type="text" bind:value={newWorkspaceName} placeholder={tr('workspaceTree.namePlaceholder')} disabled={creating} on:keydown={(event) => event.key === 'Enter' && doCreate()} />
</label> </label>
<label class="workspace-create-field">
<span>{tr('workspaceTree.parentFolder') || 'Parent folder'}</span>
<select data-workspace-parent bind:value={parentFolderPath} disabled={creating}>
{#each parentFolderOptions as opt (opt.id)}
<option value={opt.id}>{opt.name}</option>
{/each}
</select>
</label>
<label class="workspace-create-field"> <label class="workspace-create-field">
<span>{tr('workspaceTree.template')}</span> <span>{tr('workspaceTree.template')}</span>
<select data-workspace-template bind:value={selectedTemplateId} disabled={creating || templatesLoading || !workspaceTemplates.length}> <select data-workspace-template bind:value={selectedTemplateId} disabled={creating || templatesLoading || !workspaceTemplates.length}>
@@ -329,10 +470,17 @@
<div class="workspace-template-summary"> <div class="workspace-template-summary">
<p data-workspace-template-description>{selectedTemplate.description}</p> <p data-workspace-template-description>{selectedTemplate.description}</p>
<div class="workspace-template-tools" data-workspace-template-tools> <div class="workspace-template-tools" data-workspace-template-tools>
{#each selectedTemplate.workspaceTools || [] as pluginId (pluginId)} {#each selectedTemplateTools as tool (tool.pluginId)}
<span>{toolLabel(pluginId)}</span> <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} {/each}
</div> </div>
{#if selectedTemplateIssues.length > 0}
<p class="workspace-template-incomplete" data-workspace-template-incomplete>{tr('workspaceTree.templateWillBeIncomplete')}</p>
{/if}
</div> </div>
{/if} {/if}
{#if createError} {#if createError}
@@ -354,35 +502,48 @@
.wt-list { min-height: 0; overflow-y: auto; padding: 0.2rem 0.6rem; } .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 { 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: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-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-loading, .wt-error { padding: 0.5rem; font-size: 0.75rem; color: var(--vt-color-text-muted); }
.wt-error { color: var(--vt-color-danger); } .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 { 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-row:hover { background: var(--vt-color-surface-hover); }
.wt-folder-row { cursor: pointer; }
.wt-folder-row:hover { color: var(--vt-color-accent); }
.wt-chevron { width: 0.8rem; font-size: 0.65rem; color: var(--vt-color-text-muted); flex-shrink: 0; user-select: none; }
.wt-folder-label { color: var(--vt-color-text-secondary); font-weight: 500; }
.wt-node.selected > .wt-row { background: var(--vt-color-surface-selected); box-shadow: inset 2px 0 0 var(--vt-color-accent); } .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-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); }
:global(.wt-node-icon) { display: block; } :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 { 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-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-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; flex-shrink: 0; cursor: pointer; border-radius: var(--vt-radius-sm); }
.wt-row:hover .wt-icon-btn { opacity: 1; } .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: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-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-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-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 { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; }
.workspace-create-header h2 { margin: 0; font-size: 1rem; } .workspace-create-header h2 { margin: 0; font-size: 1rem; }
.workspace-create-field { display: grid; gap: 0.35rem; color: var(--vt-color-text-muted); font-size: 0.75rem; } .workspace-create-field { display: grid; gap: 0.35rem; color: var(--vt-color-text-muted); font-size: 0.75rem; }
.workspace-create-field input, .workspace-create-field select { width: 100%; min-height: 2rem; box-sizing: border-box; border: 1px solid var(--vt-color-border-strong); border-radius: var(--vt-radius-sm); background: #0f1424; color: var(--vt-color-text-primary); padding: 0.35rem 0.5rem; font: inherit; font-size: 0.84rem; } .workspace-create-field input, .workspace-create-field select { width: 100%; min-height: 2rem; box-sizing: border-box; border: 1px solid var(--vt-color-border-strong); border-radius: var(--vt-radius-sm); background: #0f1424; color: var(--vt-color-text-primary); padding: 0.35rem 0.5rem; font: inherit; font-size: 0.84rem; }
.workspace-create-field select { appearance: none; background-color: #0f1424; background-image: linear-gradient(45deg, transparent 50%, var(--vt-color-text-muted) 50%), linear-gradient(135deg, var(--vt-color-text-muted) 50%, transparent 50%); background-position: calc(100% - 14px) 50%, calc(100% - 9px) 50%; background-size: 5px 5px, 5px 5px; background-repeat: no-repeat; padding-right: 1.7rem; }
.workspace-create-field select option { background: #0f1424; color: var(--vt-color-text-primary); }
.workspace-create-field input:focus, .workspace-create-field select:focus { outline: none; border-color: var(--vt-color-accent); box-shadow: var(--vt-focus-ring); } .workspace-create-field input:focus, .workspace-create-field select:focus { outline: none; border-color: var(--vt-color-accent); box-shadow: var(--vt-focus-ring); }
.workspace-template-summary { display: grid; gap: 0.55rem; padding: 0.75rem; border: 1px solid var(--vt-color-border); border-radius: var(--vt-radius-md); background: var(--vt-color-surface-muted); } .workspace-template-summary { display: grid; gap: 0.55rem; padding: 0.75rem; border: 1px solid var(--vt-color-border); border-radius: var(--vt-radius-md); background: var(--vt-color-surface-muted); }
.workspace-template-summary p { margin: 0; color: var(--vt-color-text-secondary); font-size: 0.8rem; line-height: 1.45; } .workspace-template-summary p { margin: 0; color: var(--vt-color-text-secondary); font-size: 0.8rem; line-height: 1.45; }
.workspace-template-tools { display: flex; flex-wrap: wrap; gap: 0.35rem; } .workspace-template-tools { display: grid; gap: 0.4rem; }
.workspace-template-tools span { min-height: 1.35rem; display: inline-flex; align-items: center; padding: 0 0.35rem; border: 1px solid var(--vt-color-border-strong); border-radius: var(--vt-radius-sm); color: var(--vt-color-text-secondary); font-size: 0.72rem; } .workspace-template-tool { 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-error { margin: 0; color: var(--vt-color-danger); font-size: 0.78rem; line-height: 1.4; }
.workspace-create-actions { display: flex; gap: 0.4rem; justify-content: flex-end; } .workspace-create-actions { display: flex; gap: 0.4rem; justify-content: flex-end; }
.wt-btn-primary { background: var(--vt-color-accent); color: #101827; border: none; padding: 0.3rem 0.6rem; border-radius: var(--vt-radius-sm); cursor: pointer; font-size: 0.75rem; font-weight: 600; } .wt-btn-primary { background: var(--vt-color-accent); color: #101827; border: none; padding: 0.3rem 0.6rem; border-radius: var(--vt-radius-sm); cursor: pointer; font-size: 0.75rem; font-weight: 600; }
+175 -51
View File
@@ -4,6 +4,12 @@
* Каждый метод возвращает Promise с данными, совместимыми с Wails-контрактом. * Каждый метод возвращает Promise с данными, совместимыми с Wails-контрактом.
* Состояние мутабельно — тесты могут менять его между сценариями. * Состояние мутабельно — тесты могут менять его между сценариями.
*/ */
import defaultEditorSource from '../../../../../verstak-official-plugins/plugins/default-editor/frontend/src/index.js?raw';
import secretsSource from '../../../../../verstak-official-plugins/plugins/secrets/frontend/src/index.js?raw';
import activitySource from '../../../../../verstak-official-plugins/plugins/activity/frontend/src/index.js?raw';
import todoSource from '../../../../../verstak-official-plugins/plugins/todo/frontend/src/index.js?raw';
import journalSource from '../../../../../verstak-official-plugins/plugins/journal/frontend/src/index.js?raw';
(function () { (function () {
if (window.__wailsMockReady) return; if (window.__wailsMockReady) return;
@@ -144,7 +150,7 @@
name: 'Notes', name: 'Notes',
version: '0.1.0', version: '0.1.0',
apiVersion: '0.1.0', apiVersion: '0.1.0',
description: 'Workspace-scoped notes manager.', description: 'Deal-scoped notes manager.',
source: 'official', source: 'official',
icon: 'edit', icon: 'edit',
provides: ['verstak/notes/v1'], provides: ['verstak/notes/v1'],
@@ -191,7 +197,7 @@
name: 'Activity', name: 'Activity',
version: '0.1.0', version: '0.1.0',
apiVersion: '0.1.0', apiVersion: '0.1.0',
description: 'Workspace-scoped activity log for public plugin events.', description: 'Deal-scoped activity log for public plugin events.',
source: 'official', source: 'official',
icon: 'activity', icon: 'activity',
provides: ['activity.log', 'activity.provider', 'activity.reconstruction'], provides: ['activity.log', 'activity.provider', 'activity.reconstruction'],
@@ -215,11 +221,11 @@
name: 'Journal', name: 'Journal',
version: '0.1.0', version: '0.1.0',
apiVersion: '0.1.0', apiVersion: '0.1.0',
description: 'Workspace-scoped journal with user-authored entries and optional Activity links.', description: 'Deal-scoped journal with user-authored entries and optional Activity links.',
source: 'official', source: 'official',
icon: 'book-open', icon: 'book-open',
provides: ['worklog', 'journal', 'report.worklog'], provides: ['worklog', 'journal', 'report.worklog'],
permissions: ['storage.namespace', 'ui.register'], permissions: ['events.publish', 'files.read', 'storage.namespace', 'ui.register'],
frontend: { entry: 'frontend/dist/index.js' }, frontend: { entry: 'frontend/dist/index.js' },
contributes: { contributes: {
views: [{ id: 'verstak.journal.view', title: 'Journal', icon: 'book-open', component: 'JournalView' }], views: [{ id: 'verstak.journal.view', title: 'Journal', icon: 'book-open', component: 'JournalView' }],
@@ -236,19 +242,19 @@
manifest: { manifest: {
schemaVersion: 1, schemaVersion: 1,
id: 'verstak.browser-inbox', id: 'verstak.browser-inbox',
name: 'Browser Inbox', name: 'Browser',
version: '0.1.0', version: '0.1.0',
apiVersion: '0.1.0', apiVersion: '0.1.0',
description: 'Global browser capture queue with explicit workspace assignment.', description: 'Global browser materials with explicit Deal assignment.',
source: 'official', source: 'official',
icon: 'inbox', icon: 'inbox',
provides: ['browser.inbox'], provides: ['browser.inbox'],
permissions: ['events.subscribe', 'files.read', 'storage.namespace', 'ui.register'], permissions: ['events.subscribe', 'files.read', 'storage.namespace', 'ui.register'],
frontend: { entry: 'frontend/dist/index.js' }, frontend: { entry: 'frontend/dist/index.js' },
contributes: { contributes: {
views: [{ id: 'verstak.browser-inbox.view', title: 'Browser Inbox', icon: 'inbox', component: 'BrowserInboxView' }], views: [{ id: 'verstak.browser-inbox.view', title: 'Browser', icon: 'inbox', component: 'BrowserInboxView' }],
sidebarItems: [{ id: 'verstak.browser-inbox.sidebar', title: 'Browser Inbox', icon: 'inbox', view: 'verstak.browser-inbox.view', position: 30 }], sidebarItems: [{ id: 'verstak.browser-inbox.sidebar', title: 'Browser', icon: 'inbox', view: 'verstak.browser-inbox.view', position: 30 }],
workspaceItems: [{ id: 'verstak.browser-inbox.workspace', title: 'Browser Inbox', icon: 'inbox', component: 'BrowserInboxView' }] workspaceItems: [{ id: 'verstak.browser-inbox.workspace', title: 'Browser', icon: 'inbox', component: 'BrowserInboxView' }]
} }
}, },
rootPath: '/tmp/verstak-test/plugins/browser-inbox', rootPath: '/tmp/verstak-test/plugins/browser-inbox',
@@ -265,7 +271,7 @@
name: 'Search', name: 'Search',
version: '0.1.0', version: '0.1.0',
apiVersion: '0.1.0', apiVersion: '0.1.0',
description: 'Workspace-scoped vault text search provider.', description: 'Deal-scoped vault text search provider.',
source: 'official', source: 'official',
icon: 'search', icon: 'search',
provides: ['verstak/search/v1', 'search.provider'], provides: ['verstak/search/v1', 'search.provider'],
@@ -291,7 +297,7 @@
'verstak.sync': 'Синхронизация', 'verstak.sync': 'Синхронизация',
'verstak.activity': 'Активность', 'verstak.activity': 'Активность',
'verstak.journal': 'Журнал', 'verstak.journal': 'Журнал',
'verstak.browser-inbox': 'Входящие из браузера', 'verstak.browser-inbox': 'Браузер',
'verstak.search': 'Поиск', 'verstak.search': 'Поиск',
'verstak.trash': 'Корзина', 'verstak.trash': 'Корзина',
'verstak.todo': 'Задачи', 'verstak.todo': 'Задачи',
@@ -359,7 +365,9 @@
var pluginSettings = { var pluginSettings = {
'verstak.platform-test': { savedText: 'initial value' } 'verstak.platform-test': { savedText: 'initial value' }
}; };
var pluginNotifications = {};
var pluginData = {}; var pluginData = {};
var secretRecords = makeDefaultSecretRecords();
var vaultFiles = makeDefaultVaultFiles(); var vaultFiles = makeDefaultVaultFiles();
var externalOpens = []; var externalOpens = [];
var trashEntries = []; var trashEntries = [];
@@ -404,12 +412,33 @@
return JSON.parse(JSON.stringify(value)); return JSON.parse(JSON.stringify(value));
} }
function makeDefaultSecretRecords() {
return [
{
id: 'first.secret',
title: 'First secret',
username: 'first-user',
value: 'first-value',
scope: { kind: 'global' },
updatedAt: '2026-07-14T00:00:00Z'
},
{
id: 'target.secret',
title: 'Target secret',
username: 'target-user',
value: 'target-value',
scope: { kind: 'global' },
updatedAt: '2026-07-14T00:00:00Z'
}
];
}
function builtInWorkspaceTemplates() { function builtInWorkspaceTemplates() {
return [ return [
{ {
id: 'default', id: 'default',
name: 'General', name: 'General',
description: 'Everyday workspace with notes, files, journal, activity, and browser captures.', description: 'Everyday Deal with notes, files, journal, activity, and browser captures.',
version: 2, version: 2,
workspaceTools: ['verstak.notes', 'verstak.files', 'verstak.journal', 'verstak.activity', 'verstak.browser-inbox'], workspaceTools: ['verstak.notes', 'verstak.files', 'verstak.journal', 'verstak.activity', 'verstak.browser-inbox'],
folders: ['Notes', 'Files'], folders: ['Notes', 'Files'],
@@ -427,7 +456,7 @@
{ {
id: 'writing', id: 'writing',
name: 'Writing', name: 'Writing',
description: 'Focused notes, files, and journal workspace for documentation and writing.', description: 'Focused notes, files, and journal Deal for documentation and writing.',
version: 1, version: 1,
workspaceTools: ['verstak.notes', 'verstak.files', 'verstak.journal'], workspaceTools: ['verstak.notes', 'verstak.files', 'verstak.journal'],
folders: ['Notes', 'Files'], folders: ['Notes', 'Files'],
@@ -436,7 +465,7 @@
{ {
id: 'admin', id: 'admin',
name: 'Admin', name: 'Admin',
description: 'Infrastructure workspace with secrets, todos, and journal.', description: 'Infrastructure Deal with secrets, todos, and journal.',
version: 1, version: 1,
workspaceTools: ['verstak.notes', 'verstak.files', 'verstak.secrets', 'verstak.todo', 'verstak.journal'], workspaceTools: ['verstak.notes', 'verstak.files', 'verstak.secrets', 'verstak.todo', 'verstak.journal'],
folders: ['Notes', 'Files', 'Secrets'], folders: ['Notes', 'Files', 'Secrets'],
@@ -445,7 +474,7 @@
{ {
id: 'minimal', id: 'minimal',
name: 'Minimal', name: 'Minimal',
description: 'Only notes and files for a lightweight workspace.', description: 'Only notes and files for a lightweight Deal.',
version: 1, version: 1,
workspaceTools: ['verstak.notes', 'verstak.files'], workspaceTools: ['verstak.notes', 'verstak.files'],
folders: ['Notes', 'Files'], folders: ['Notes', 'Files'],
@@ -541,11 +570,12 @@
name: 'Todos', name: 'Todos',
version: '0.1.0', version: '0.1.0',
apiVersion: '0.1.0', apiVersion: '0.1.0',
description: 'Global and workspace todo tracking with due and reminder metadata.', description: 'Global and Deal-scoped todo tracking with due and reminder metadata.',
source: 'official', source: 'official',
icon: 'list-todo', icon: 'list-todo',
provides: ['todo.list', 'todo.workspace'], provides: ['todo.list', 'todo.workspace'],
permissions: ['files.read', 'storage.namespace', 'ui.register'], requires: ['verstak/core/notifications/v1'],
permissions: ['files.read', 'storage.namespace', 'ui.register', 'notifications.schedule'],
frontend: { entry: 'frontend/dist/index.js' }, frontend: { entry: 'frontend/dist/index.js' },
contributes: { contributes: {
views: [{ id: 'verstak.todo.view', title: 'Todos', icon: 'list-todo', component: 'TodoView' }], views: [{ id: 'verstak.todo.view', title: 'Todos', icon: 'list-todo', component: 'TodoView' }],
@@ -568,13 +598,22 @@
name: 'Secrets', name: 'Secrets',
version: '0.1.0', version: '0.1.0',
apiVersion: '0.1.0', apiVersion: '0.1.0',
description: 'Encrypted global and workspace-scoped secret manager.', description: 'Encrypted global and Deal-scoped secret manager.',
source: 'official', source: 'official',
icon: 'key-round', icon: 'key-round',
provides: ['secret-store', 'secrets.read-ui', 'secrets.write-ui'], provides: ['secret-store', 'secrets.read-ui', 'secrets.write-ui'],
permissions: ['secrets.read', 'secrets.write', 'ui.register'], permissions: ['files.read', 'secrets.read', 'secrets.write', 'ui.register'],
frontend: { entry: 'frontend/dist/index.js' }, frontend: { entry: 'frontend/src/index.js' },
contributes: { contributes: {
views: [{ id: 'verstak.secrets.view', title: 'Secrets', icon: 'key-round', component: 'SecretsView' }],
sidebarItems: [{ id: 'verstak.secrets.sidebar', title: 'Secrets', icon: 'key-round', view: 'verstak.secrets.view', position: 45 }],
openProviders: [{
id: 'verstak.secrets.secret',
title: 'Secrets',
priority: 100,
component: 'SecretsView',
supports: [{ kind: 'secret', modes: ['view'] }]
}],
workspaceItems: [{ id: 'verstak.secrets.workspace', title: 'Secrets', icon: 'key-round', component: 'SecretsView' }] workspaceItems: [{ id: 'verstak.secrets.workspace', title: 'Secrets', icon: 'key-round', component: 'SecretsView' }]
} }
}, },
@@ -587,6 +626,7 @@
return { return {
configured: false, configured: false,
serverUrl: '', serverUrl: '',
vaultId: '',
deviceId: 'mock-device', deviceId: 'mock-device',
deviceName: '', deviceName: '',
connected: false, connected: false,
@@ -596,6 +636,7 @@
lastSyncAt: '', lastSyncAt: '',
syncInterval: 0, syncInterval: 0,
lastError: '', lastError: '',
lastWarning: '',
statusLabel: 'disabled', statusLabel: 'disabled',
serverSequence: 0 serverSequence: 0
}; };
@@ -610,7 +651,7 @@
if (p.charAt(0) === '/' || /^[A-Za-z]:/.test(p)) return { error: 'invalid-path: absolute path rejected' }; if (p.charAt(0) === '/' || /^[A-Za-z]:/.test(p)) return { error: 'invalid-path: absolute path rejected' };
var parts = p.split('/').filter(Boolean); var parts = p.split('/').filter(Boolean);
if (parts.indexOf('..') !== -1) return { error: 'invalid-path: path-traversal' }; if (parts.indexOf('..') !== -1) return { error: 'invalid-path: path-traversal' };
if (parts[0] && parts[0].toLowerCase() === '.verstak') return { error: 'reserved-path: .verstak is internal' }; if (parts.some(function(part) { return part.toLowerCase() === '.verstak'; })) return { error: 'reserved-path: .verstak is internal' };
return { path: parts.join('/') }; return { path: parts.join('/') };
} }
@@ -678,6 +719,7 @@
caps.push({ name: 'verstak/core/files/v1', description: 'Files API', pluginId: 'verstak-desktop', status: 'stable' }); caps.push({ name: 'verstak/core/files/v1', description: 'Files API', pluginId: 'verstak-desktop', status: 'stable' });
caps.push({ name: 'verstak/core/workbench/v1', description: 'Workbench routing', pluginId: 'verstak-desktop', status: 'stable' }); caps.push({ name: 'verstak/core/workbench/v1', description: 'Workbench routing', pluginId: 'verstak-desktop', status: 'stable' });
caps.push({ name: 'verstak/core/sync/v1', description: 'Sync API', pluginId: 'verstak-desktop', status: 'stable' }); caps.push({ name: 'verstak/core/sync/v1', description: 'Sync API', pluginId: 'verstak-desktop', status: 'stable' });
caps.push({ name: 'verstak/core/notifications/v1', description: 'Native notifications', pluginId: 'verstak-desktop', status: 'stable' });
for (var id in pluginStates) { for (var id in pluginStates) {
var s = pluginStates[id]; var s = pluginStates[id];
if (s.status === 'loaded' && s.enabled && s.manifest && s.manifest.provides) { if (s.status === 'loaded' && s.enabled && s.manifest && s.manifest.provides) {
@@ -711,6 +753,7 @@
return { return {
configured: syncState.configured, configured: syncState.configured,
serverUrl: syncState.serverUrl, serverUrl: syncState.serverUrl,
vaultId: syncState.vaultId,
deviceId: syncState.deviceId, deviceId: syncState.deviceId,
deviceName: syncState.deviceName, deviceName: syncState.deviceName,
connected: syncState.connected, connected: syncState.connected,
@@ -720,6 +763,7 @@
lastSyncAt: syncState.lastSyncAt, lastSyncAt: syncState.lastSyncAt,
syncInterval: syncState.syncInterval, syncInterval: syncState.syncInterval,
lastError: syncState.lastError, lastError: syncState.lastError,
lastWarning: syncState.lastWarning,
statusLabel: syncState.statusLabel statusLabel: syncState.statusLabel
}; };
} }
@@ -2416,7 +2460,7 @@
} }
function title(capture) { function title(capture) {
return capture.title || capture.fileName || capture.url || capture.captureId || 'Untitled capture'; return capture.title || capture.fileName || capture.url || capture.captureId || 'Untitled material';
} }
function renderBrowserInbox(containerEl, props, api) { function renderBrowserInbox(containerEl, props, api) {
@@ -2433,7 +2477,7 @@
containerEl.innerHTML = ''; containerEl.innerHTML = '';
var root = el('div', { className: 'browser-inbox-root', 'data-plugin-id': PLUGIN_ID }); var root = el('div', { className: 'browser-inbox-root', 'data-plugin-id': PLUGIN_ID });
var toolbar = el('div', { className: 'browser-inbox-toolbar' }); var toolbar = el('div', { className: 'browser-inbox-toolbar' });
var titleEl = el('span', { className: 'browser-inbox-title', textContent: rootPath ? 'Browser Inbox · ' + rootPath : 'Browser Inbox' }); var titleEl = el('span', { className: 'browser-inbox-title', textContent: rootPath ? 'Browser · ' + rootPath : 'Browser' });
var countEl = el('span', { className: 'browser-inbox-count' }); var countEl = el('span', { className: 'browser-inbox-count' });
var statusEl = el('span', { className: 'browser-inbox-status' }); var statusEl = el('span', { className: 'browser-inbox-status' });
var filtersEl = el('div', { className: 'browser-inbox-filters' }); var filtersEl = el('div', { className: 'browser-inbox-filters' });
@@ -2517,7 +2561,7 @@
function renderWorkspaceFilterOptions() { function renderWorkspaceFilterOptions() {
if (rootPath) return; if (rootPath) return;
workspaceFilterEl.innerHTML = ''; workspaceFilterEl.innerHTML = '';
workspaceFilterEl.appendChild(option('', 'All workspaces')); workspaceFilterEl.appendChild(option('', 'All Deals'));
workspaceRoots().forEach(function (workspace) { workspaceRoots().forEach(function (workspace) {
workspaceFilterEl.appendChild(option(workspace, workspace)); workspaceFilterEl.appendChild(option(workspace, workspace));
}); });
@@ -2590,7 +2634,7 @@
}) : captures).map(function (capture) { return capture.captureId; }); }) : captures).map(function (capture) { return capture.captureId; });
captures = captures.filter(function (capture) { return ids.indexOf(capture.captureId) === -1; }); captures = captures.filter(function (capture) { return ids.indexOf(capture.captureId) === -1; });
selectedId = ''; selectedId = '';
statusText = rootPath ? 'Workspace captures cleared' : 'Inbox cleared'; statusText = rootPath ? 'Deal materials cleared' : 'Inbox cleared';
return persist(); return persist();
} }
@@ -2632,7 +2676,7 @@
listEl.appendChild(el('div', { listEl.appendChild(el('div', {
className: 'browser-inbox-empty', className: 'browser-inbox-empty',
textContent: captures.length === 0 textContent: captures.length === 0
? 'No browser captures yet. Keep this view open, then send a page, selection, link, or file from the browser extension.' ? 'No browser materials yet. Send a page, selection, or link from the extension.'
: 'No captures match the current filters.' : 'No captures match the current filters.'
})); }));
return; return;
@@ -2677,7 +2721,7 @@
el('div', { className: 'browser-inbox-meta-value', textContent: capture.domain || '-' }), el('div', { className: 'browser-inbox-meta-value', textContent: capture.domain || '-' }),
el('div', { className: 'browser-inbox-meta-label', textContent: 'Browser' }), el('div', { className: 'browser-inbox-meta-label', textContent: 'Browser' }),
el('div', { className: 'browser-inbox-meta-value', textContent: capture.browserName || capture.source || '-' }), el('div', { className: 'browser-inbox-meta-value', textContent: capture.browserName || capture.source || '-' }),
el('div', { className: 'browser-inbox-meta-label', textContent: 'Workspace' }), el('div', { className: 'browser-inbox-meta-label', textContent: 'Deal' }),
el('div', { className: 'browser-inbox-meta-value', textContent: capture.workspaceRootPath || 'Unassigned' }), el('div', { className: 'browser-inbox-meta-value', textContent: capture.workspaceRootPath || 'Unassigned' }),
el('div', { className: 'browser-inbox-meta-label', textContent: 'Status' }), el('div', { className: 'browser-inbox-meta-label', textContent: 'Status' }),
el('div', { className: 'browser-inbox-meta-value', textContent: capture.processed ? 'Processed' : 'Unprocessed' }) el('div', { className: 'browser-inbox-meta-value', textContent: capture.processed ? 'Processed' : 'Unprocessed' })
@@ -3328,6 +3372,43 @@
GetPluginLocalization: function (pluginId, locale) { GetPluginLocalization: function (pluginId, locale) {
return Promise.resolve([mockPluginCatalog(pluginId, locale), '']); return Promise.resolve([mockPluginCatalog(pluginId, locale), '']);
}, },
PluginSecretsStatus: function () {
return Promise.resolve([{ initialized: true, unlocked: true }, '']);
},
PluginSecretsUnlock: function () {
return Promise.resolve('');
},
PluginSecretsList: function () {
return Promise.resolve([secretRecords.map(function (record) {
var listed = cloneJson(record);
delete listed.value;
return listed;
}), '']);
},
PluginSecretsRead: function (_pluginId, secretID) {
var record = secretRecords.find(function (item) { return item.id === secretID; });
if (!record) return Promise.resolve([{}, 'not-found: secret ' + secretID]);
return Promise.resolve([cloneJson(record), '']);
},
PluginSecretsWrite: function (_pluginId, nextRecord) {
var record = Object.assign({}, nextRecord || {});
if (!record.id) return Promise.resolve([{}, 'secret id is required']);
record.scope = record.scope || { kind: 'global' };
record.updatedAt = new Date().toISOString();
var index = secretRecords.findIndex(function (item) { return item.id === record.id; });
if (index === -1) secretRecords.push(record);
else secretRecords[index] = record;
return Promise.resolve([cloneJson(record), '']);
},
PluginSecretsDelete: function (_pluginId, secretID) {
secretRecords = secretRecords.filter(function (record) { return record.id !== secretID; });
return Promise.resolve('');
},
PluginSecretsCopyLink: function (_pluginId, secretID) {
var record = secretRecords.find(function (item) { return item.id === secretID; });
if (!record) return Promise.resolve(['', 'not-found: secret ' + secretID]);
return Promise.resolve(['[' + (record.title || record.id) + '](verstak-secret://' + encodeURIComponent(record.id) + ')', '']);
},
ReadPluginSettings: function (pluginId) { ReadPluginSettings: function (pluginId) {
return Promise.resolve([Object.assign({}, pluginSettings[pluginId] || {}), '']); return Promise.resolve([Object.assign({}, pluginSettings[pluginId] || {}), '']);
}, },
@@ -3335,17 +3416,48 @@
pluginSettings[pluginId] = Object.assign({}, settings || {}); pluginSettings[pluginId] = Object.assign({}, settings || {});
return Promise.resolve(''); return Promise.resolve('');
}, },
ReadPluginSetting: function () { return Promise.resolve(null); }, ReadPluginSetting: function (pluginId, key) {
WritePluginSetting: function () { return Promise.resolve(null); }, return Promise.resolve([pluginSettings[pluginId] && pluginSettings[pluginId][key], '']);
},
WritePluginSetting: function (pluginId, key, value) {
pluginSettings[pluginId] = pluginSettings[pluginId] || {};
pluginSettings[pluginId][key] = value;
return Promise.resolve('');
},
ReplacePluginNotifications: function (pluginId, items) {
pluginNotifications[pluginId] = Array.isArray(items) ? items.slice() : [];
return Promise.resolve('');
},
ClearPluginNotifications: function (pluginId) {
delete pluginNotifications[pluginId];
return Promise.resolve('');
},
ReadPluginDataJSON: function (pluginId, name) { ReadPluginDataJSON: function (pluginId, name) {
var data = (pluginData[pluginId] && pluginData[pluginId][name]) || {}; var data = (pluginData[pluginId] && pluginData[pluginId][name]) || {};
return Promise.resolve([Object.assign({}, data), '']); return Promise.resolve([Object.assign({}, data), '']);
}, },
ReadPluginDataNDJSON: function (pluginId, name) {
var data = (pluginData[pluginId] && pluginData[pluginId][name]) || [];
if (!Array.isArray(data) && pluginId === 'verstak.activity' && name === 'activity-events') data = [];
if (!data.length && pluginId === 'verstak.activity' && name === 'activity-events') {
data = Object.keys(pluginSettings[pluginId] || {}).filter(function (key) {
return key === 'events' || key === 'events:global' || key.indexOf('events:workspace:') === 0;
}).flatMap(function (key) {
return Array.isArray(pluginSettings[pluginId][key]) ? pluginSettings[pluginId][key] : [];
});
}
return Promise.resolve([Array.isArray(data) ? data.slice() : [], '']);
},
WritePluginDataJSON: function (pluginId, name, data) { WritePluginDataJSON: function (pluginId, name, data) {
pluginData[pluginId] = pluginData[pluginId] || {}; pluginData[pluginId] = pluginData[pluginId] || {};
pluginData[pluginId][name] = Object.assign({}, data || {}); pluginData[pluginId][name] = Object.assign({}, data || {});
return Promise.resolve(''); return Promise.resolve('');
}, },
WritePluginDataNDJSON: function (pluginId, name, records) {
pluginData[pluginId] = pluginData[pluginId] || {};
pluginData[pluginId][name] = Array.isArray(records) ? records.slice() : [];
return Promise.resolve('');
},
OpenWorkbenchResource: function (pluginId, request) { OpenWorkbenchResource: function (pluginId, request) {
return openWorkbenchResource(pluginId, request || {}, ''); return openWorkbenchResource(pluginId, request || {}, '');
}, },
@@ -3369,11 +3481,12 @@
if (err) return Promise.resolve([{}, err]); if (err) return Promise.resolve([{}, err]);
return Promise.resolve([syncStatusDTO(), '']); return Promise.resolve([syncStatusDTO(), '']);
}, },
PluginSyncConfigure: function (pluginId, serverUrl) { PluginSyncConfigure: function (pluginId, serverUrl, username, password, vaultId) {
var err = requirePluginSyncPermission(pluginId, true); var err = requirePluginSyncPermission(pluginId, true);
if (err) return Promise.resolve(err); if (err) return Promise.resolve(err);
syncState.configured = true; syncState.configured = true;
syncState.serverUrl = serverUrl || ''; syncState.serverUrl = serverUrl || '';
syncState.vaultId = vaultId || 'test-vault-001';
syncState.deviceId = 'mock-device'; syncState.deviceId = 'mock-device';
syncState.deviceName = 'mock-device'; syncState.deviceName = 'mock-device';
syncState.connected = true; syncState.connected = true;
@@ -3383,6 +3496,7 @@
syncState.statusLabel = 'connected'; syncState.statusLabel = 'connected';
pluginSettings[pluginId] = Object.assign({}, pluginSettings[pluginId] || {}, { pluginSettings[pluginId] = Object.assign({}, pluginSettings[pluginId] || {}, {
serverUrl: syncState.serverUrl, serverUrl: syncState.serverUrl,
vaultId: syncState.vaultId,
syncStatus: syncState.statusLabel syncStatus: syncState.statusLabel
}); });
return Promise.resolve(''); return Promise.resolve('');
@@ -3438,8 +3552,8 @@
if (pluginId === 'verstak.platform-test' && assetPath === 'frontend/dist/index.js') { if (pluginId === 'verstak.platform-test' && assetPath === 'frontend/dist/index.js') {
return Promise.resolve(platformTestBundle()); return Promise.resolve(platformTestBundle());
} }
if (pluginId === 'verstak.default-editor' && assetPath === 'frontend/dist/index.js') { if (pluginId === 'verstak.default-editor') {
return Promise.resolve(defaultEditorBundle()); return Promise.resolve(defaultEditorSource);
} }
if (pluginId === 'verstak.files' && assetPath === 'frontend/dist/index.js') { if (pluginId === 'verstak.files' && assetPath === 'frontend/dist/index.js') {
return Promise.resolve(filesPluginBundle()); return Promise.resolve(filesPluginBundle());
@@ -3450,20 +3564,20 @@
if (pluginId === 'verstak.notes' && assetPath === 'frontend/dist/index.js') { if (pluginId === 'verstak.notes' && assetPath === 'frontend/dist/index.js') {
return Promise.resolve(simplePluginBundle('verstak.notes', 'NotesView', 'notes-root', 'Notes')); return Promise.resolve(simplePluginBundle('verstak.notes', 'NotesView', 'notes-root', 'Notes'));
} }
if (pluginId === 'verstak.activity' && assetPath === 'frontend/dist/index.js') { if (pluginId === 'verstak.activity') {
return Promise.resolve(activityBundle()); return Promise.resolve(activitySource);
} }
if (pluginId === 'verstak.journal' && assetPath === 'frontend/dist/index.js') { if (pluginId === 'verstak.journal' && assetPath === 'frontend/dist/index.js') {
return Promise.resolve(journalBundle()); return Promise.resolve(journalSource);
} }
if (pluginId === 'verstak.browser-inbox' && assetPath === 'frontend/dist/index.js') { if (pluginId === 'verstak.browser-inbox' && assetPath === 'frontend/dist/index.js') {
return Promise.resolve(browserInboxBundle()); return Promise.resolve(browserInboxBundle());
} }
if (pluginId === 'verstak.todo' && assetPath === 'frontend/dist/index.js') { if (pluginId === 'verstak.todo' && assetPath === 'frontend/dist/index.js') {
return Promise.resolve(todoBundle()); return Promise.resolve(todoSource);
} }
if (pluginId === 'verstak.secrets' && assetPath === 'frontend/dist/index.js') { if (pluginId === 'verstak.secrets') {
return Promise.resolve(simplePluginBundle('verstak.secrets', 'SecretsView', 'secrets-root', 'Secrets')); return Promise.resolve(secretsSource);
} }
if (pluginId === 'verstak.search' && assetPath === 'frontend/dist/index.js') { if (pluginId === 'verstak.search' && assetPath === 'frontend/dist/index.js') {
return Promise.resolve(searchPluginBundle()); return Promise.resolve(searchPluginBundle());
@@ -3678,6 +3792,13 @@
window.__wailsMockExternalOpens = externalOpens.slice(); window.__wailsMockExternalOpens = externalOpens.slice();
return Promise.resolve(''); return Promise.resolve('');
}, },
OpenExternalURL: function (pluginId, rawURL) {
var err = requirePluginPermission(pluginId, 'files.openExternal');
if (err) return Promise.resolve(err);
externalOpens.push({ action: 'url', path: String(rawURL || '') });
window.__wailsMockExternalOpens = externalOpens.slice();
return Promise.resolve('');
},
ShowVaultPathInFolder: function (pluginId, relativePath) { ShowVaultPathInFolder: function (pluginId, relativePath) {
var err = requirePluginPermission(pluginId, 'files.openExternal'); var err = requirePluginPermission(pluginId, 'files.openExternal');
if (err) return Promise.resolve(err); if (err) return Promise.resolve(err);
@@ -3989,7 +4110,7 @@
name: 'Notes', name: 'Notes',
version: '0.1.0', version: '0.1.0',
apiVersion: '0.1.0', apiVersion: '0.1.0',
description: 'Workspace-scoped notes manager.', description: 'Deal-scoped notes manager.',
source: 'official', source: 'official',
icon: 'edit', icon: 'edit',
provides: ['verstak/notes/v1'], provides: ['verstak/notes/v1'],
@@ -4036,7 +4157,7 @@
name: 'Activity', name: 'Activity',
version: '0.1.0', version: '0.1.0',
apiVersion: '0.1.0', apiVersion: '0.1.0',
description: 'Workspace-scoped activity log for public plugin events.', description: 'Deal-scoped activity log for public plugin events.',
source: 'official', source: 'official',
icon: 'activity', icon: 'activity',
provides: ['activity.log', 'activity.provider', 'activity.reconstruction'], provides: ['activity.log', 'activity.provider', 'activity.reconstruction'],
@@ -4060,11 +4181,11 @@
name: 'Journal', name: 'Journal',
version: '0.1.0', version: '0.1.0',
apiVersion: '0.1.0', apiVersion: '0.1.0',
description: 'Workspace-scoped journal with user-authored entries and optional Activity links.', description: 'Deal-scoped journal with user-authored entries and optional Activity links.',
source: 'official', source: 'official',
icon: 'book-open', icon: 'book-open',
provides: ['worklog', 'journal', 'report.worklog'], provides: ['worklog', 'journal', 'report.worklog'],
permissions: ['storage.namespace', 'ui.register'], permissions: ['events.publish', 'files.read', 'storage.namespace', 'ui.register'],
frontend: { entry: 'frontend/dist/index.js' }, frontend: { entry: 'frontend/dist/index.js' },
contributes: { contributes: {
views: [{ id: 'verstak.journal.view', title: 'Journal', icon: 'book-open', component: 'JournalView' }], views: [{ id: 'verstak.journal.view', title: 'Journal', icon: 'book-open', component: 'JournalView' }],
@@ -4081,19 +4202,19 @@
manifest: { manifest: {
schemaVersion: 1, schemaVersion: 1,
id: 'verstak.browser-inbox', id: 'verstak.browser-inbox',
name: 'Browser Inbox', name: 'Browser',
version: '0.1.0', version: '0.1.0',
apiVersion: '0.1.0', apiVersion: '0.1.0',
description: 'Global browser capture queue with explicit workspace assignment.', description: 'Global browser materials with explicit Deal assignment.',
source: 'official', source: 'official',
icon: 'inbox', icon: 'inbox',
provides: ['browser.inbox'], provides: ['browser.inbox'],
permissions: ['events.subscribe', 'files.read', 'storage.namespace', 'ui.register'], permissions: ['events.subscribe', 'files.read', 'storage.namespace', 'ui.register'],
frontend: { entry: 'frontend/dist/index.js' }, frontend: { entry: 'frontend/dist/index.js' },
contributes: { contributes: {
views: [{ id: 'verstak.browser-inbox.view', title: 'Browser Inbox', icon: 'inbox', component: 'BrowserInboxView' }], views: [{ id: 'verstak.browser-inbox.view', title: 'Browser', icon: 'inbox', component: 'BrowserInboxView' }],
sidebarItems: [{ id: 'verstak.browser-inbox.sidebar', title: 'Browser Inbox', icon: 'inbox', view: 'verstak.browser-inbox.view', position: 30 }], sidebarItems: [{ id: 'verstak.browser-inbox.sidebar', title: 'Browser', icon: 'inbox', view: 'verstak.browser-inbox.view', position: 30 }],
workspaceItems: [{ id: 'verstak.browser-inbox.workspace', title: 'Browser Inbox', icon: 'inbox', component: 'BrowserInboxView' }] workspaceItems: [{ id: 'verstak.browser-inbox.workspace', title: 'Browser', icon: 'inbox', component: 'BrowserInboxView' }]
} }
}, },
rootPath: '/tmp/verstak-test/plugins/browser-inbox', rootPath: '/tmp/verstak-test/plugins/browser-inbox',
@@ -4110,7 +4231,7 @@
name: 'Search', name: 'Search',
version: '0.1.0', version: '0.1.0',
apiVersion: '0.1.0', apiVersion: '0.1.0',
description: 'Workspace-scoped vault text search provider.', description: 'Deal-scoped vault text search provider.',
source: 'official', source: 'official',
icon: 'search', icon: 'search',
provides: ['verstak/search/v1', 'search.provider'], provides: ['verstak/search/v1', 'search.provider'],
@@ -4140,6 +4261,8 @@
workbenchPreferences = {}; workbenchPreferences = {};
openedResources = []; openedResources = [];
pluginSettings = { 'verstak.platform-test': { savedText: 'initial value' } }; pluginSettings = { 'verstak.platform-test': { savedText: 'initial value' } };
pluginNotifications = {};
secretRecords = makeDefaultSecretRecords();
vaultFiles = makeDefaultVaultFiles(); vaultFiles = makeDefaultVaultFiles();
externalOpens = []; externalOpens = [];
trashEntries = []; trashEntries = [];
@@ -4159,8 +4282,9 @@
getPluginState: function (pluginId) { getPluginState: function (pluginId) {
return pluginStates[pluginId] ? Object.assign({}, pluginStates[pluginId]) : null; return pluginStates[pluginId] ? Object.assign({}, pluginStates[pluginId]) : null;
}, },
addSyntheticPlugins: function (count) { addSyntheticPlugins: function (count, source) {
var total = Number(count || 0); var total = Number(count || 0);
var pluginSource = source === 'official' || source === 'local' || source === 'third-party' ? source : 'third-party';
for (var i = 1; i <= total; i++) { for (var i = 1; i <= total; i++) {
var id = 'verstak.synthetic-layout-' + String(i).padStart(2, '0'); var id = 'verstak.synthetic-layout-' + String(i).padStart(2, '0');
pluginStates[id] = { pluginStates[id] = {
@@ -4173,7 +4297,7 @@
version: '0.0.' + i, version: '0.0.' + i,
apiVersion: '0.1.0', apiVersion: '0.1.0',
description: 'Synthetic plugin used by frontend layout tests.', description: 'Synthetic plugin used by frontend layout tests.',
source: 'test', source: pluginSource,
provides: ['verstak/synthetic-layout-' + i + '/v1'], provides: ['verstak/synthetic-layout-' + i + '/v1'],
requires: [], requires: [],
optionalRequires: [], optionalRequires: [],
@@ -4193,7 +4317,7 @@
vaultPluginState.enabledPlugins.push(id); vaultPluginState.enabledPlugins.push(id);
} }
if (!vaultPluginState.desiredPlugins.some(function (p) { return p.id === id; })) { if (!vaultPluginState.desiredPlugins.some(function (p) { return p.id === id; })) {
vaultPluginState.desiredPlugins.push({ id: id, version: '0.0.' + i, source: 'test' }); vaultPluginState.desiredPlugins.push({ id: id, version: '0.0.' + i, source: pluginSource });
} }
} }
}, },
+32
View File
@@ -0,0 +1,32 @@
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');
@@ -0,0 +1,36 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const files = [
'src/lib/shell/CommandPalette.svelte',
'src/lib/plugin-manager/PluginManager.svelte',
'src/lib/shell/VaultSelection.svelte',
'src/lib/shell/WorkspaceTree.svelte',
'src/lib/plugin-host/PluginBundleHost.svelte',
];
const patterns = [
/(?:^|\n)\s*(?:error|localError|createError)\s*=\s*(?:err|String\()/,
/(?:^|\n)\s*error\s*=\s*['"].*['"]\s*\+\s*(?:err|String\()/,
/tr\([^\n]*\{\s*error\s*:/,
];
const violations = [];
for (const relativePath of files) {
const source = fs.readFileSync(path.join(root, relativePath), 'utf8');
for (const pattern of patterns) {
if (pattern.test(source)) violations.push(relativePath);
}
}
if (violations.length) {
throw new Error(`Technical backend errors reach desktop UI in: ${[...new Set(violations)].join(', ')}`);
}
const bundleHost = fs.readFileSync(path.join(root, 'src/lib/plugin-host/PluginBundleHost.svelte'), 'utf8');
if (!/<p class="error-message">\{errorText \|\| tr\('bundle\.unknownError'\)\}<\/p>\s*<details class="error-details">/.test(bundleHost)) {
throw new Error('Plugin bundle errors must keep the user-facing message outside expandable technical details');
}
console.log('desktop UI does not interpolate raw backend errors');
@@ -3,6 +3,7 @@ import path from 'node:path';
import { pathToFileURL } from 'node:url'; import { pathToFileURL } from 'node:url';
const pluginData = {}; const pluginData = {};
const scheduledNotifications = [];
globalThis.window = { globalThis.window = {
__VERSTAK_PLUGIN_REGISTRY__: {}, __VERSTAK_PLUGIN_REGISTRY__: {},
@@ -38,6 +39,14 @@ globalThis.window = {
pluginData[pluginId][name] = Object.assign({}, data || {}); pluginData[pluginId][name] = Object.assign({}, data || {});
return Promise.resolve(''); 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('');
},
}, },
}, },
}, },
@@ -116,6 +125,28 @@ if (stored.version !== 1 || stored.workspaceRootPath !== 'Project') {
throw new Error(`unexpected storage data: ${JSON.stringify(stored)}`); 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(); api.dispose();
if (localeListeners.size !== 0) { if (localeListeners.size !== 0) {
throw new Error('api.i18n locale subscription was not disposed'); throw new Error('api.i18n locale subscription was not disposed');
+9
View File
@@ -0,0 +1,9 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
const source = await readFile(new URL('../src/lib/shell/WorkspaceTree.svelte', import.meta.url), 'utf8');
assert.match(source, /\.workspace-create-field select\s*\{[^}]*appearance:\s*none/, 'workspace template select must hide the native arrow');
assert.match(source, /\.workspace-create-field select option\s*\{[^}]*background/, 'workspace template options must use the application surface');
console.log('workspace select style contract passed');
+28
View File
@@ -0,0 +1,28 @@
import en from '../src/lib/i18n/catalogs/en.js';
import ru from '../src/lib/i18n/catalogs/ru.js';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const forbidden = [
['English catalog', /\bworkspace(?:s)?\b/i],
['Russian catalog', /рабоч(?:ее|ие|ем|его|их)\s+пространств(?:о|а|е)?/i],
];
for (const [name, pattern] of forbidden) {
const violations = Object.entries(name === 'English catalog' ? en : ru)
.filter(([, value]) => pattern.test(value));
if (violations.length) {
throw new Error(`${name} still exposes workspace terminology: ${violations.map(([key]) => key).join(', ')}`);
}
}
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const overview = fs.readFileSync(path.join(root, 'src/lib/shell/TodaySurface.svelte'), 'utf8');
for (const phrase of ['Workspace opened', 'Workspace activity', 'Workspace overview note']) {
if (overview.includes(phrase)) {
throw new Error(`Overview exposes workspace terminology: ${phrase}`);
}
}
console.log('desktop user terminology uses Deal/Дело');
+13
View File
@@ -0,0 +1,13 @@
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');
+4
View File
@@ -1,3 +1,4 @@
import { resolve } from 'node:path';
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte'; import { svelte } from '@sveltejs/vite-plugin-svelte';
@@ -13,6 +14,9 @@ export default defineConfig(({ mode }) => {
server: { server: {
port: isTest ? 5174 : 5173, port: isTest ? 5174 : 5173,
strictPort: true, strictPort: true,
fs: {
allow: [resolve(__dirname, '..', '..')],
},
}, },
optimizeDeps: { optimizeDeps: {
include: [ include: [
+40 -2
View File
@@ -1,15 +1,19 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT // This file is automatically generated. DO NOT EDIT
import {workspace} from '../models'; import {workspace} from '../models';
import {context} from '../models';
import {workbench} from '../models'; import {workbench} from '../models';
import {capability} from '../models'; import {capability} from '../models';
import {api} from '../models'; import {api} from '../models';
import {permissions} from '../models'; import {permissions} from '../models';
import {plugin} from '../models'; import {plugin} from '../models';
import {files} from '../models'; import {files} from '../models';
import {notifications} from '../models';
export function ArchiveWorkspaceNode(arg1:string):Promise<string>; export function ArchiveWorkspaceNode(arg1:string):Promise<string>;
export function ClearPluginNotifications(arg1:string):Promise<string>;
export function CloseVault():Promise<void>; export function CloseVault():Promise<void>;
export function CreateVault(arg1:string):Promise<void>; export function CreateVault(arg1:string):Promise<void>;
@@ -24,6 +28,8 @@ export function DeleteVaultTrash(arg1:string,arg2:string):Promise<string>;
export function DisablePlugin(arg1: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 EditWorkbenchResource(arg1:string,arg2:Record<string, any>):Promise<workbench.OpenResourceResult|string>;
export function EnablePlugin(arg1:string):Promise<string>; export function EnablePlugin(arg1:string):Promise<string>;
@@ -40,6 +46,8 @@ export function GetCurrentWorkspace():Promise<Record<string, any>>;
export function GetCurrentWorkspaceNode():Promise<Record<string, any>>; export function GetCurrentWorkspaceNode():Promise<Record<string, any>>;
export function GetFolderMetadata(arg1:string):Promise<workspace.FolderMetadata|string>;
export function GetPermissions():Promise<Array<permissions.Entry>>; export function GetPermissions():Promise<Array<permissions.Entry>>;
export function GetPluginAssetContent(arg1:string,arg2:string):Promise<string|string>; export function GetPluginAssetContent(arg1:string,arg2:string):Promise<string|string>;
@@ -72,14 +80,20 @@ export function ListVaultFiles(arg1:string,arg2:string):Promise<Array<files.File
export function ListVaultTrash(arg1:string):Promise<Array<files.TrashEntry>|string>; export function ListVaultTrash(arg1:string):Promise<Array<files.TrashEntry>|string>;
export function ListWorkspaces():Promise<Array<workspace.Workspace>|string>; export function ListWorkspaceIdentities():Promise<Array<workspace.WorkspaceIdentity>|string>;
export function ListWorkspaceTemplates():Promise<Array<workspace.WorkspaceTemplate>|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 MoveVaultPath(arg1:string,arg2:string,arg3:string,arg4:files.MoveOptions):Promise<string>;
export function MoveWorkspace(arg1:string,arg2:string):Promise<string>;
export function MoveWorkspaceNode(arg1:string,arg2:string):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 OpenVault(arg1:string):Promise<void>;
export function OpenVaultPathExternal(arg1:string,arg2:string):Promise<string>; export function OpenVaultPathExternal(arg1:string,arg2:string):Promise<string>;
@@ -104,7 +118,7 @@ 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 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 PluginSyncConfigure(arg1:string,arg2:string,arg3:string,arg4:string,arg5:string):Promise<string>;
export function PluginSyncDisconnect(arg1:string):Promise<string>; export function PluginSyncDisconnect(arg1:string):Promise<string>;
@@ -120,8 +134,14 @@ export function PluginSyncTestConnection(arg1:string,arg2:string,arg3:string,arg
export function PublishPluginEvent(arg1:string,arg2:string,arg3:Record<string, any>):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 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 ReadPluginSetting(arg1:string,arg2:string):Promise<any>;
export function ReadPluginSettings(arg1:string):Promise<Record<string, any>|string>; export function ReadPluginSettings(arg1:string):Promise<Record<string, any>|string>;
@@ -138,8 +158,14 @@ export function RenameWorkspace(arg1:string,arg2:string):Promise<string>;
export function RenameWorkspaceNode(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 RestoreVaultTrash(arg1:string,arg2:string,arg3:files.RestoreOptions):Promise<string|string>;
export function RestoreWorkspaceTrash(arg1:string,arg2:string):Promise<workspace.Workspace|string>;
export function SelectDirectory():Promise<string>; export function SelectDirectory():Promise<string>;
export function SelectVaultForOpen():Promise<string>; export function SelectVaultForOpen():Promise<string>;
@@ -150,8 +176,18 @@ export function SetCurrentWorkspace(arg1:string):Promise<string>;
export function SetCurrentWorkspaceNode(arg1:string):Promise<string>; export function SetCurrentWorkspaceNode(arg1:string):Promise<string>;
export function SetFolderMetadata(arg1:string,arg2:workspace.FolderMetadata):Promise<string>;
export function SetNotificationService(arg1:api.notificationService):Promise<void>;
export function SetTrayReady(arg1:boolean):Promise<void>;
export function ShowVaultPathInFolder(arg1:string,arg2:string):Promise<string>; 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 SubscribePluginEvent(arg1:string,arg2:string):Promise<string>;
export function TrashVaultPath(arg1:string,arg2:string):Promise<files.TrashResult|string>; export function TrashVaultPath(arg1:string,arg2:string):Promise<files.TrashResult|string>;
@@ -168,6 +204,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 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 WritePluginSetting(arg1:string,arg2:string,arg3:any):Promise<string>;
export function WritePluginSettings(arg1:string,arg2:Record<string, any>):Promise<string>; export function WritePluginSettings(arg1:string,arg2:Record<string, any>):Promise<string>;
+76 -4
View File
@@ -6,6 +6,10 @@ export function ArchiveWorkspaceNode(arg1) {
return window['go']['api']['App']['ArchiveWorkspaceNode'](arg1); return window['go']['api']['App']['ArchiveWorkspaceNode'](arg1);
} }
export function ClearPluginNotifications(arg1) {
return window['go']['api']['App']['ClearPluginNotifications'](arg1);
}
export function CloseVault() { export function CloseVault() {
return window['go']['api']['App']['CloseVault'](); return window['go']['api']['App']['CloseVault']();
} }
@@ -34,6 +38,10 @@ export function DisablePlugin(arg1) {
return window['go']['api']['App']['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) { export function EditWorkbenchResource(arg1, arg2) {
return window['go']['api']['App']['EditWorkbenchResource'](arg1, arg2); return window['go']['api']['App']['EditWorkbenchResource'](arg1, arg2);
} }
@@ -66,6 +74,10 @@ export function GetCurrentWorkspaceNode() {
return window['go']['api']['App']['GetCurrentWorkspaceNode'](); return window['go']['api']['App']['GetCurrentWorkspaceNode']();
} }
export function GetFolderMetadata(arg1) {
return window['go']['api']['App']['GetFolderMetadata'](arg1);
}
export function GetPermissions() { export function GetPermissions() {
return window['go']['api']['App']['GetPermissions'](); return window['go']['api']['App']['GetPermissions']();
} }
@@ -130,22 +142,34 @@ export function ListVaultTrash(arg1) {
return window['go']['api']['App']['ListVaultTrash'](arg1); return window['go']['api']['App']['ListVaultTrash'](arg1);
} }
export function ListWorkspaces() { export function ListWorkspaceIdentities() {
return window['go']['api']['App']['ListWorkspaces'](); return window['go']['api']['App']['ListWorkspaceIdentities']();
} }
export function ListWorkspaceTemplates() { export function ListWorkspaceTemplates() {
return window['go']['api']['App']['ListWorkspaceTemplates'](); return window['go']['api']['App']['ListWorkspaceTemplates']();
} }
export function ListWorkspaces() {
return window['go']['api']['App']['ListWorkspaces']();
}
export function MoveVaultPath(arg1, arg2, arg3, arg4) { export function MoveVaultPath(arg1, arg2, arg3, arg4) {
return window['go']['api']['App']['MoveVaultPath'](arg1, arg2, arg3, arg4); return window['go']['api']['App']['MoveVaultPath'](arg1, arg2, arg3, arg4);
} }
export function MoveWorkspace(arg1, arg2) {
return window['go']['api']['App']['MoveWorkspace'](arg1, arg2);
}
export function MoveWorkspaceNode(arg1, arg2) { export function MoveWorkspaceNode(arg1, arg2) {
return window['go']['api']['App']['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) { export function OpenVault(arg1) {
return window['go']['api']['App']['OpenVault'](arg1); return window['go']['api']['App']['OpenVault'](arg1);
} }
@@ -194,8 +218,8 @@ export function PluginSecretsWrite(arg1, arg2) {
return window['go']['api']['App']['PluginSecretsWrite'](arg1, arg2); return window['go']['api']['App']['PluginSecretsWrite'](arg1, arg2);
} }
export function PluginSyncConfigure(arg1, arg2, arg3, arg4) { export function PluginSyncConfigure(arg1, arg2, arg3, arg4, arg5) {
return window['go']['api']['App']['PluginSyncConfigure'](arg1, arg2, arg3, arg4); return window['go']['api']['App']['PluginSyncConfigure'](arg1, arg2, arg3, arg4, arg5);
} }
export function PluginSyncDisconnect(arg1) { export function PluginSyncDisconnect(arg1) {
@@ -226,10 +250,22 @@ export function PublishPluginEvent(arg1, arg2, arg3) {
return window['go']['api']['App']['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) { export function ReadPluginDataJSON(arg1, arg2) {
return window['go']['api']['App']['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) { export function ReadPluginSetting(arg1, arg2) {
return window['go']['api']['App']['ReadPluginSetting'](arg1, arg2); return window['go']['api']['App']['ReadPluginSetting'](arg1, arg2);
} }
@@ -262,10 +298,22 @@ export function RenameWorkspaceNode(arg1, arg2) {
return window['go']['api']['App']['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) { export function RestoreVaultTrash(arg1, arg2, arg3) {
return window['go']['api']['App']['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 SelectDirectory() { export function SelectDirectory() {
return window['go']['api']['App']['SelectDirectory'](); return window['go']['api']['App']['SelectDirectory']();
} }
@@ -286,10 +334,30 @@ export function SetCurrentWorkspaceNode(arg1) {
return window['go']['api']['App']['SetCurrentWorkspaceNode'](arg1); return window['go']['api']['App']['SetCurrentWorkspaceNode'](arg1);
} }
export function SetFolderMetadata(arg1, arg2) {
return window['go']['api']['App']['SetFolderMetadata'](arg1, arg2);
}
export function SetNotificationService(arg1) {
return window['go']['api']['App']['SetNotificationService'](arg1);
}
export function SetTrayReady(arg1) {
return window['go']['api']['App']['SetTrayReady'](arg1);
}
export function ShowVaultPathInFolder(arg1, arg2) { export function ShowVaultPathInFolder(arg1, arg2) {
return window['go']['api']['App']['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) { export function SubscribePluginEvent(arg1, arg2) {
return window['go']['api']['App']['SubscribePluginEvent'](arg1, arg2); return window['go']['api']['App']['SubscribePluginEvent'](arg1, arg2);
} }
@@ -322,6 +390,10 @@ export function WritePluginDataJSON(arg1, arg2, arg3) {
return window['go']['api']['App']['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) { export function WritePluginSetting(arg1, arg2, arg3) {
return window['go']['api']['App']['WritePluginSetting'](arg1, arg2, arg3); return window['go']['api']['App']['WritePluginSetting'](arg1, arg2, arg3);
} }
+115 -13
View File
@@ -1,5 +1,19 @@
export namespace api { export namespace api {
export class FlatWorkspaceTree {
pluginId: string;
component: string;
static createFrom(source: any = {}) {
return new FlatWorkspaceTree(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.pluginId = source["pluginId"];
this.component = source["component"];
}
}
export class FlatContextMenuEntry { export class FlatContextMenuEntry {
pluginId: string; pluginId: string;
id: string; id: string;
@@ -258,6 +272,7 @@ export namespace api {
fileActions: FlatAction[]; fileActions: FlatAction[];
noteActions: FlatAction[]; noteActions: FlatAction[];
contextMenuEntries: FlatContextMenuEntry[]; contextMenuEntries: FlatContextMenuEntry[];
workspaceTree?: FlatWorkspaceTree;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new ContributionSummary(source); return new ContributionSummary(source);
@@ -276,6 +291,7 @@ export namespace api {
this.fileActions = this.convertValues(source["fileActions"], FlatAction); this.fileActions = this.convertValues(source["fileActions"], FlatAction);
this.noteActions = this.convertValues(source["noteActions"], FlatAction); this.noteActions = this.convertValues(source["noteActions"], FlatAction);
this.contextMenuEntries = this.convertValues(source["contextMenuEntries"], FlatContextMenuEntry); this.contextMenuEntries = this.convertValues(source["contextMenuEntries"], FlatContextMenuEntry);
this.workspaceTree = this.convertValues(source["workspaceTree"], FlatWorkspaceTree);
} }
convertValues(a: any, classs: any, asMap: boolean = false): any { convertValues(a: any, classs: any, asMap: boolean = false): any {
@@ -307,9 +323,11 @@ export namespace api {
export class SyncStatusDTO { export class SyncStatusDTO {
configured: boolean; configured: boolean;
serverUrl: string; serverUrl: string;
vaultId: string;
deviceId: string; deviceId: string;
deviceName: string; deviceName: string;
connected: boolean; connected: boolean;
@@ -319,6 +337,7 @@ export namespace api {
lastSyncAt: string; lastSyncAt: string;
syncInterval: number; syncInterval: number;
lastError: string; lastError: string;
lastWarning: string;
statusLabel: string; statusLabel: string;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
@@ -329,6 +348,7 @@ export namespace api {
if ('string' === typeof source) source = JSON.parse(source); if ('string' === typeof source) source = JSON.parse(source);
this.configured = source["configured"]; this.configured = source["configured"];
this.serverUrl = source["serverUrl"]; this.serverUrl = source["serverUrl"];
this.vaultId = source["vaultId"];
this.deviceId = source["deviceId"]; this.deviceId = source["deviceId"];
this.deviceName = source["deviceName"]; this.deviceName = source["deviceName"];
this.connected = source["connected"]; this.connected = source["connected"];
@@ -338,6 +358,7 @@ export namespace api {
this.lastSyncAt = source["lastSyncAt"]; this.lastSyncAt = source["lastSyncAt"];
this.syncInterval = source["syncInterval"]; this.syncInterval = source["syncInterval"];
this.lastError = source["lastError"]; this.lastError = source["lastError"];
this.lastWarning = source["lastWarning"];
this.statusLabel = source["statusLabel"]; this.statusLabel = source["statusLabel"];
} }
} }
@@ -538,6 +559,29 @@ export namespace files {
} }
export namespace notifications {
export class Request {
id: string;
dueAt: string;
title: string;
body?: string;
static createFrom(source: any = {}) {
return new Request(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.dueAt = source["dueAt"];
this.title = source["title"];
this.body = source["body"];
}
}
}
export namespace permissions { export namespace permissions {
export class Entry { export class Entry {
@@ -853,6 +897,18 @@ export namespace plugin {
this.component = source["component"]; this.component = source["component"];
} }
} }
export class ContributionWorkspaceTree {
component: string;
static createFrom(source: any = {}) {
return new ContributionWorkspaceTree(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.component = source["component"];
}
}
export class Contributions { export class Contributions {
views?: ContributionView[]; views?: ContributionView[];
commands?: ContributionCommand[]; commands?: ContributionCommand[];
@@ -866,6 +922,7 @@ export namespace plugin {
statusBarItems?: ContributionStatusBarItem[]; statusBarItems?: ContributionStatusBarItem[];
openProviders?: ContributionOpenProvider[]; openProviders?: ContributionOpenProvider[];
workspaceItems?: ContributionWorkspaceItem[]; workspaceItems?: ContributionWorkspaceItem[];
workspaceTree?: ContributionWorkspaceTree;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new Contributions(source); return new Contributions(source);
@@ -885,6 +942,7 @@ export namespace plugin {
this.statusBarItems = this.convertValues(source["statusBarItems"], ContributionStatusBarItem); this.statusBarItems = this.convertValues(source["statusBarItems"], ContributionStatusBarItem);
this.openProviders = this.convertValues(source["openProviders"], ContributionOpenProvider); this.openProviders = this.convertValues(source["openProviders"], ContributionOpenProvider);
this.workspaceItems = this.convertValues(source["workspaceItems"], ContributionWorkspaceItem); this.workspaceItems = this.convertValues(source["workspaceItems"], ContributionWorkspaceItem);
this.workspaceTree = this.convertValues(source["workspaceTree"], ContributionWorkspaceTree);
} }
convertValues(a: any, classs: any, asMap: boolean = false): any { convertValues(a: any, classs: any, asMap: boolean = false): any {
@@ -1230,27 +1288,24 @@ export namespace workbench {
export namespace workspace { export namespace workspace {
export class WorkspaceTemplate { export class FolderMetadata {
id: string; icon?: string;
name: string; color?: string;
description: string; order?: number;
version: number; updatedAt?: string;
workspaceTools: string[];
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new WorkspaceTemplate(source); return new FolderMetadata(source);
} }
constructor(source: any = {}) { constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source); if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"]; this.icon = source["icon"];
this.name = source["name"]; this.color = source["color"];
this.description = source["description"]; this.order = source["order"];
this.version = source["version"]; this.updatedAt = source["updatedAt"];
this.workspaceTools = source["workspaceTools"];
} }
} }
export class TemplateSnapshot { export class TemplateSnapshot {
templateId: string; templateId: string;
templateName: string; templateName: string;
@@ -1272,7 +1327,9 @@ export namespace workspace {
} }
} }
export class Metadata { export class Metadata {
workspaceId?: string;
workspaceName: string; workspaceName: string;
workspacePath?: string;
createdFromTemplate?: TemplateSnapshot; createdFromTemplate?: TemplateSnapshot;
features?: Record<string, boolean>; features?: Record<string, boolean>;
folders?: Record<string, string>; folders?: Record<string, string>;
@@ -1285,7 +1342,9 @@ export namespace workspace {
constructor(source: any = {}) { constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source); if ('string' === typeof source) source = JSON.parse(source);
this.workspaceId = source["workspaceId"];
this.workspaceName = source["workspaceName"]; this.workspaceName = source["workspaceName"];
this.workspacePath = source["workspacePath"];
this.createdFromTemplate = this.convertValues(source["createdFromTemplate"], TemplateSnapshot); this.createdFromTemplate = this.convertValues(source["createdFromTemplate"], TemplateSnapshot);
this.features = source["features"]; this.features = source["features"];
this.folders = source["folders"]; this.folders = source["folders"];
@@ -1327,6 +1386,7 @@ export namespace workspace {
} }
export class TrashResult { export class TrashResult {
workspaceId: string;
originalPath: string; originalPath: string;
trashPath: string; trashPath: string;
trashId: string; trashId: string;
@@ -1338,6 +1398,7 @@ export namespace workspace {
constructor(source: any = {}) { constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source); if ('string' === typeof source) source = JSON.parse(source);
this.workspaceId = source["workspaceId"];
this.originalPath = source["originalPath"]; this.originalPath = source["originalPath"];
this.trashPath = source["trashPath"]; this.trashPath = source["trashPath"];
this.trashId = source["trashId"]; this.trashId = source["trashId"];
@@ -1345,7 +1406,9 @@ export namespace workspace {
} }
} }
export class Workspace { export class Workspace {
id: string;
name: string; name: string;
path: string;
rootPath: string; rootPath: string;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
@@ -1354,9 +1417,48 @@ export namespace workspace {
constructor(source: any = {}) { constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source); if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"]; this.name = source["name"];
this.path = source["path"];
this.rootPath = source["rootPath"]; this.rootPath = source["rootPath"];
} }
} }
export class WorkspaceIdentity {
workspaceId: string;
rootPath: string;
state: string;
static createFrom(source: any = {}) {
return new WorkspaceIdentity(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.workspaceId = source["workspaceId"];
this.rootPath = source["rootPath"];
this.state = source["state"];
}
}
export class WorkspaceTemplate {
id: string;
name: string;
description: string;
version: number;
workspaceTools: string[];
static createFrom(source: any = {}) {
return new WorkspaceTemplate(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.description = source["description"];
this.version = source["version"];
this.workspaceTools = source["workspaceTools"];
}
}
} }
+2 -1
View File
@@ -3,8 +3,10 @@ module github.com/verstak/verstak-desktop
go 1.24.4 go 1.24.4
require ( require (
fyne.io/systray v1.12.2
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/wailsapp/wails/v2 v2.12.0 github.com/wailsapp/wails/v2 v2.12.0
golang.org/x/net v0.35.0
) )
require ( require (
@@ -32,7 +34,6 @@ require (
github.com/wailsapp/go-webview2 v1.0.22 // indirect github.com/wailsapp/go-webview2 v1.0.22 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect github.com/wailsapp/mimetype v1.4.1 // indirect
golang.org/x/crypto v0.33.0 // 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/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect golang.org/x/text v0.22.0 // indirect
) )
+2
View File
@@ -1,3 +1,5 @@
fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA=
fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA= git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc= git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
+1144 -115
View File
File diff suppressed because it is too large Load Diff
+1057 -25
View File
File diff suppressed because it is too large Load Diff
+116 -6
View File
@@ -1,11 +1,14 @@
package api package api
import ( import (
"bytes"
"os" "os"
"path/filepath"
"strings" "strings"
"testing" "testing"
corefiles "github.com/verstak/verstak-desktop/internal/core/files" corefiles "github.com/verstak/verstak-desktop/internal/core/files"
"github.com/verstak/verstak-desktop/internal/core/workspace"
) )
func TestSyncNowAgainstRealServerTwoVaults(t *testing.T) { func TestSyncNowAgainstRealServerTwoVaults(t *testing.T) {
@@ -18,8 +21,16 @@ func TestSyncNowAgainstRealServerTwoVaults(t *testing.T) {
t.Skip("set VERSTAK_SYNC_SMOKE_* env vars to run the real sync-server smoke test") 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) appA, rootA := newSyncFilesTestApp(t, []string{"files.read", "files.write", "files.delete"}, deviceA)
appB, _ := newSyncFilesTestApp(t, []string{"files.read", "files.write", "files.delete"}, deviceB) appB, rootB := newSyncFilesTestApp(t, []string{"files.read", "files.write", "files.delete"}, deviceB)
appA.workspace = workspace.NewManager(rootA)
appB.workspace = workspace.NewManager(rootB)
if err := appA.workspace.Load(); err != nil {
t.Fatalf("load workspace A: %v", err)
}
if err := appB.workspace.Load(); err != nil {
t.Fatalf("load workspace B: %v", err)
}
if err := appA.syncSvc.SetState(serverURL, apiKeyA); err != nil { if err := appA.syncSvc.SetState(serverURL, apiKeyA); err != nil {
t.Fatalf("appA SetState: %v", err) t.Fatalf("appA SetState: %v", err)
} }
@@ -43,8 +54,8 @@ func TestSyncNowAgainstRealServerTwoVaults(t *testing.T) {
if errStr := appB.MoveVaultPath("files.plugin", "Shared/one.txt", "Shared/two.txt", corefiles.MoveOptions{}); errStr != "" { if errStr := appB.MoveVaultPath("files.plugin", "Shared/one.txt", "Shared/two.txt", corefiles.MoveOptions{}); errStr != "" {
t.Fatalf("appB move: %s", errStr) t.Fatalf("appB move: %s", errStr)
} }
expectSyncCounts(t, appB, 2, 2) expectSyncCounts(t, appB, 3, 3)
expectSyncCounts(t, appA, 0, 2) expectSyncCounts(t, appA, 0, 3)
expectText(t, appA, "Shared/two.txt", "from B") expectText(t, appA, "Shared/two.txt", "from B")
if _, errStr := appA.TrashVaultPath("files.plugin", "Shared/two.txt"); errStr != "" { if _, errStr := appA.TrashVaultPath("files.plugin", "Shared/two.txt"); errStr != "" {
@@ -68,8 +79,8 @@ func TestSyncNowAgainstRealServerTwoVaults(t *testing.T) {
if errStr := appA.MoveVaultPath("files.plugin", "Shared/Folder", "Shared/Archive", corefiles.MoveOptions{}); errStr != "" { if errStr := appA.MoveVaultPath("files.plugin", "Shared/Folder", "Shared/Archive", corefiles.MoveOptions{}); errStr != "" {
t.Fatalf("appA move folder: %s", errStr) t.Fatalf("appA move folder: %s", errStr)
} }
expectSyncCounts(t, appA, 1, 1) expectSyncCounts(t, appA, 2, 2)
expectSyncCounts(t, appB, 0, 1) expectSyncCounts(t, appB, 0, 2)
if _, errStr := appB.GetVaultFileMetadata("files.plugin", "Shared/Folder"); !strings.Contains(errStr, "not-found") { 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) t.Fatalf("appB moved folder old metadata err = %q, want not-found", errStr)
} }
@@ -85,6 +96,83 @@ func TestSyncNowAgainstRealServerTwoVaults(t *testing.T) {
if _, errStr := appA.GetVaultFileMetadata("files.plugin", "Shared/Archive"); !strings.Contains(errStr, "not-found") { 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) t.Fatalf("appA deleted folder metadata err = %q, want not-found", errStr)
} }
if err := os.WriteFile(filepath.Join(rootA, "Shared", "external.txt"), []byte("external while running"), 0o644); err != nil {
t.Fatalf("external create: %v", err)
}
expectSyncCounts(t, appA, 1, 1)
expectSyncCounts(t, appB, 0, 1)
expectText(t, appB, "Shared/external.txt", "external while running")
assertNoUnpushedOps(t, appB)
// This exceeds the former 8 MiB inline/base64 ceiling. The operation must
// contain only a blob reference; the actual bytes travel through Blob API.
binary := make([]byte, corefiles.MaxBinaryReadBytes+1)
for i := range binary {
binary[i] = byte(i % 251)
}
if err := os.WriteFile(filepath.Join(rootA, "Shared", "large.bin"), binary, 0o644); err != nil {
t.Fatalf("write large binary: %v", err)
}
expectSyncCounts(t, appA, 1, 1)
expectSyncCounts(t, appB, 0, 1)
received, err := os.ReadFile(filepath.Join(rootB, "Shared", "large.bin"))
if err != nil {
t.Fatalf("read synced large binary: %v", err)
}
if !bytes.Equal(received, binary) {
t.Fatal("large binary content differs after blob sync")
}
assertNoUnpushedOps(t, appB)
if err := os.WriteFile(filepath.Join(rootB, "Shared", "external.txt"), []byte("external while closed"), 0o644); err != nil {
t.Fatalf("offline external update: %v", err)
}
if err := os.WriteFile(filepath.Join(rootB, "Shared", "offline-created.txt"), []byte("created while closed"), 0o644); err != nil {
t.Fatalf("offline external create: %v", err)
}
if err := os.Remove(filepath.Join(rootB, "Shared", "external.txt")); err != nil {
t.Fatalf("offline external delete: %v", err)
}
expectSyncCounts(t, appB, 2, 2)
expectSyncCounts(t, appA, 0, 2)
if _, errStr := appA.GetVaultFileMetadata("files.plugin", "Shared/external.txt"); !strings.Contains(errStr, "not-found") {
t.Fatalf("offline deleted file remained on appA: %q", errStr)
}
expectText(t, appA, "Shared/offline-created.txt", "created while closed")
assertNoUnpushedOps(t, appA)
deal, errStr := appA.CreateWorkspace("Synced Deal", "minimal")
if errStr != "" {
t.Fatalf("appA CreateWorkspace: %s", errStr)
}
expectSyncCounts(t, appA, 1, 1)
expectSyncCounts(t, appB, 0, 1)
assertWorkspaceIdentity(t, appB, "Synced Deal", deal.ID)
if errStr := appA.RenameWorkspace("Synced Deal", "Renamed Deal"); errStr != "" {
t.Fatalf("appA RenameWorkspace: %s", errStr)
}
expectSyncCounts(t, appA, 1, 1)
expectSyncCounts(t, appB, 0, 1)
assertWorkspaceIdentity(t, appB, "Renamed Deal", deal.ID)
trash, errStr := appA.TrashWorkspace("Renamed Deal")
if errStr != "" {
t.Fatalf("appA TrashWorkspace: %s", errStr)
}
expectSyncCounts(t, appA, 1, 1)
expectSyncCounts(t, appB, 0, 1)
if _, err := appB.workspace.GetWorkspaceIdentity("Renamed Deal"); err == nil {
t.Fatal("trashed workspace is still active on appB")
}
if _, errStr := appA.RestoreWorkspaceTrash(trash.TrashID, "Restored Deal"); errStr != "" {
t.Fatalf("appA RestoreWorkspaceTrash: %s", errStr)
}
expectSyncCounts(t, appA, 1, 1)
expectSyncCounts(t, appB, 0, 1)
assertWorkspaceIdentity(t, appB, "Restored Deal", deal.ID)
} }
func expectSyncCounts(t *testing.T, app *App, pushed, pulled int) { func expectSyncCounts(t *testing.T, app *App, pushed, pulled int) {
@@ -108,3 +196,25 @@ func expectText(t *testing.T, app *App, path, want string) {
t.Fatalf("ReadVaultTextFile(%s) = %q, want %q", path, text, want) t.Fatalf("ReadVaultTextFile(%s) = %q, want %q", path, text, want)
} }
} }
func assertNoUnpushedOps(t *testing.T, app *App) {
t.Helper()
ops, err := app.syncSvc.GetUnpushedOps()
if err != nil {
t.Fatalf("GetUnpushedOps: %v", err)
}
if len(ops) != 0 {
t.Fatalf("remote operations were echoed as local operations: %#v", ops)
}
}
func assertWorkspaceIdentity(t *testing.T, app *App, name, wantID string) {
t.Helper()
identity, err := app.workspace.GetWorkspaceIdentity(name)
if err != nil {
t.Fatalf("GetWorkspaceIdentity(%s): %v", name, err)
}
if identity.WorkspaceID != wantID {
t.Fatalf("workspace %s ID = %s, want %s", name, identity.WorkspaceID, wantID)
}
}
+25 -5
View File
@@ -78,9 +78,10 @@ type WindowState struct {
// Manager provides thread-safe access to app settings. // Manager provides thread-safe access to app settings.
type Manager struct { type Manager struct {
mu sync.RWMutex mu sync.RWMutex
config *Config config *Config
configPath string configPath string
onLanguageChanged func(string)
} }
// DefaultConfigPath returns the default path for app settings. // DefaultConfigPath returns the default path for app settings.
@@ -230,13 +231,32 @@ func (m *Manager) UpdateLanguage(language string) error {
} }
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock()
if m.config == nil { if m.config == nil {
m.config = defaultConfig() m.config = defaultConfig()
} }
m.config.Language = language m.config.Language = language
m.config.LastOpenedAt = time.Now().UTC().Format(time.RFC3339) m.config.LastOpenedAt = time.Now().UTC().Format(time.RFC3339)
return m.saveLocked() err := m.saveLocked()
handler := m.onLanguageChanged
m.mu.Unlock()
if err != nil {
return err
}
if handler != nil {
handler(language)
}
return nil
}
// SetLanguageChangedHandler registers the shell callback invoked after a
// language preference has been durably updated.
func (m *Manager) SetLanguageChangedHandler(handler func(string)) {
if m == nil {
return
}
m.mu.Lock()
m.onLanguageChanged = handler
m.mu.Unlock()
} }
// UpdateSync replaces sync settings without changing unrelated app settings. // UpdateSync replaces sync settings without changing unrelated app settings.
+29
View File
@@ -3,6 +3,7 @@ package appsettings
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"reflect"
"testing" "testing"
) )
@@ -74,6 +75,34 @@ func TestUpdateLanguagePersistsAndPreservesUnrelatedSettings(t *testing.T) {
} }
} }
func TestUpdateLanguageNotifiesTheDesktopShellAfterPersisting(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
manager := NewManager(path)
if err := manager.Load(); err != nil {
t.Fatal(err)
}
var received []string
manager.SetLanguageChangedHandler(func(language string) {
received = append(received, language)
})
if err := manager.UpdateLanguage(LanguageRussian); err != nil {
t.Fatalf("UpdateLanguage: %v", err)
}
if got := manager.Get().Language; got != LanguageRussian {
t.Fatalf("persisted language = %q, want %q", got, LanguageRussian)
}
if !reflect.DeepEqual(received, []string{LanguageRussian}) {
t.Fatalf("language notifications = %#v, want [%q]", received, LanguageRussian)
}
if err := manager.UpdateLanguage("de"); err == nil {
t.Fatal("UpdateLanguage(de) succeeded, want validation failure")
}
if !reflect.DeepEqual(received, []string{LanguageRussian}) {
t.Fatalf("language notification fired after rejected update: %#v", received)
}
}
func TestLoad_CorruptConfig(t *testing.T) { func TestLoad_CorruptConfig(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
path := filepath.Join(dir, "config.json") path := filepath.Join(dir, "config.json")
+184 -8
View File
@@ -12,18 +12,20 @@ import (
"log" "log"
"net" "net"
"net/http" "net/http"
"net/url"
"strings" "strings"
"sync" "sync"
"time" "time"
"github.com/verstak/verstak-desktop/internal/core/events" "github.com/verstak/verstak-desktop/internal/core/events"
"github.com/verstak/verstak-desktop/internal/core/hostname"
) )
const ( const (
capturePath = "/api/browser-inbox/v1/captures" capturePath = "/api/browser-inbox/v1/captures"
activityBatchPath = "/api/browser-activity/v1/batches"
DefaultAddr = "127.0.0.1:47731" DefaultAddr = "127.0.0.1:47731"
DefaultCaptureURL = "http://" + DefaultAddr + capturePath DefaultCaptureURL = "http://" + DefaultAddr + capturePath
DefaultActivityURL = "http://" + DefaultAddr + activityBatchPath
receiverTokenHeader = "X-Verstak-Receiver-Token" receiverTokenHeader = "X-Verstak-Receiver-Token"
) )
@@ -43,6 +45,9 @@ const (
maxFileTextBytes = 2 * 1024 * 1024 maxFileTextBytes = 2 * 1024 * 1024
maxFileBytes = 8 * 1024 * 1024 maxFileBytes = 8 * 1024 * 1024
maxFileDataBase64Bytes = 4 * ((maxFileBytes + 2) / 3) maxFileDataBase64Bytes = 4 * ((maxFileBytes + 2) / 3)
maxActivityBodyBytes = 256 * 1024
maxActivityEntries = 100
maxActivityDuration = 10 * time.Minute
) )
type Receiver struct { type Receiver struct {
@@ -55,10 +60,12 @@ type Receiver struct {
type WorkspaceProvider func() string type WorkspaceProvider func() string
type Options struct { type Options struct {
RequireToken bool RequireToken bool
ReceiverToken string ReceiverToken string
Available func() bool Available func() bool
Persist func(events.Event) error Persist func(events.Event) error
ActivityAvailable func() bool
PersistActivity func(events.Event) error
} }
type Server struct { type Server struct {
@@ -107,6 +114,23 @@ type CaptureBrowser struct {
Name string `json:"name"` 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 { func New(bus *events.Bus, providers ...WorkspaceProvider) *Receiver {
return NewWithOptions(bus, Options{}, providers...) return NewWithOptions(bus, Options{}, providers...)
} }
@@ -141,6 +165,18 @@ func (r *Receiver) SetPersistence(available func() bool, persist func(events.Eve
r.options.Persist = persist 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) { func Start(addr string, receiver *Receiver) (*Server, error) {
if receiver == nil { if receiver == nil {
return nil, fmt.Errorf("receiver is required") return nil, fmt.Errorf("receiver is required")
@@ -181,6 +217,10 @@ func (s *Server) Close() error {
func (r *Receiver) ServeHTTP(w http.ResponseWriter, req *http.Request) { func (r *Receiver) ServeHTTP(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
if req.URL.Path == activityBatchPath {
r.serveActivityBatch(w, req)
return
}
if req.URL.Path != capturePath { if req.URL.Path != capturePath {
http.NotFound(w, req) http.NotFound(w, req)
return return
@@ -256,6 +296,59 @@ func (r *Receiver) ServeHTTP(w http.ResponseWriter, req *http.Request) {
}) })
} }
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 { func (r *Receiver) currentOptions() Options {
if r == nil { if r == nil {
return Options{} return Options{}
@@ -370,6 +463,80 @@ func (p CapturePayload) Validate() error {
return nil 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 { func (p CapturePayload) validateFile() error {
if p.File == nil || strings.TrimSpace(p.File.Name) == "" { if p.File == nil || strings.TrimSpace(p.File.Name) == "" {
return fmt.Errorf("file.name is required") return fmt.Errorf("file.name is required")
@@ -450,10 +617,19 @@ func (p CapturePayload) EventPayload() map[string]interface{} {
} }
func captureDomain(rawURL, fallback string) string { func captureDomain(rawURL, fallback string) string {
if u, err := url.Parse(strings.TrimSpace(rawURL)); err == nil && u.Hostname() != "" { if normalized := hostname.NormalizeURLHostnameV1(rawURL); normalized != "" {
return u.Hostname() return normalized
} }
return strings.TrimSpace(fallback) 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) { func writeError(w http.ResponseWriter, status int, message string) {
@@ -238,6 +238,101 @@ func TestReceiverLeavesCaptureUnassignedWithoutCurrentWorkspace(t *testing.T) {
} }
} }
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) { func TestReceiverRequiresTokenWhenPaired(t *testing.T) {
bus := events.NewBus() bus := events.NewBus()
received := make(chan events.Event, 1) received := make(chan events.Event, 1)
+21
View File
@@ -0,0 +1,21 @@
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",
"verstak/core/workspace/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...)
}
+22
View File
@@ -24,6 +24,13 @@ type Registry struct {
statusBarItems []ContributionStatusBarItem statusBarItems []ContributionStatusBarItem
openProviders []ContributionOpenProvider openProviders []ContributionOpenProvider
workspaceItems []ContributionWorkspaceItem workspaceItems []ContributionWorkspaceItem
workspaceTree *ContributionWorkspaceTree
}
// ContributionWorkspaceTree is a singleton contribution for replacing the Deal tree.
type ContributionWorkspaceTree struct {
PluginID string `json:"pluginId"`
Component string `json:"component"`
} }
// ContributionPointType defines the type of contribution point. // ContributionPointType defines the type of contribution point.
@@ -42,6 +49,7 @@ const (
PointStatusBar ContributionPointType = "statusBarItems" PointStatusBar ContributionPointType = "statusBarItems"
PointOpenProviders ContributionPointType = "openProviders" PointOpenProviders ContributionPointType = "openProviders"
PointWorkspaceItems ContributionPointType = "workspaceItems" PointWorkspaceItems ContributionPointType = "workspaceItems"
PointWorkspaceTree ContributionPointType = "workspaceTree"
) )
// ListByPoint returns all contributions for a given point type. // ListByPoint returns all contributions for a given point type.
@@ -184,6 +192,7 @@ func (r *Registry) Register(pluginID string, c *plugin.Contributions) {
r.statusBarItems = removeStatusBarItems(r.statusBarItems, pluginID) r.statusBarItems = removeStatusBarItems(r.statusBarItems, pluginID)
r.openProviders = removeOpenProviders(r.openProviders, pluginID) r.openProviders = removeOpenProviders(r.openProviders, pluginID)
r.workspaceItems = removeWorkspaceItems(r.workspaceItems, pluginID) r.workspaceItems = removeWorkspaceItems(r.workspaceItems, pluginID)
r.workspaceTree = nil
for _, item := range c.Views { for _, item := range c.Views {
r.views = append(r.views, ContributionView{PluginID: pluginID, Item: item}) r.views = append(r.views, ContributionView{PluginID: pluginID, Item: item})
@@ -221,6 +230,9 @@ func (r *Registry) Register(pluginID string, c *plugin.Contributions) {
for _, item := range c.WorkspaceItems { for _, item := range c.WorkspaceItems {
r.workspaceItems = append(r.workspaceItems, ContributionWorkspaceItem{PluginID: pluginID, Item: item}) r.workspaceItems = append(r.workspaceItems, ContributionWorkspaceItem{PluginID: pluginID, Item: item})
} }
if c.WorkspaceTree != nil && r.workspaceTree == nil {
r.workspaceTree = &ContributionWorkspaceTree{PluginID: pluginID, Component: c.WorkspaceTree.Component}
}
} }
// Unregister removes all contributions from a plugin. // Unregister removes all contributions from a plugin.
@@ -240,6 +252,9 @@ func (r *Registry) Unregister(pluginID string) {
r.statusBarItems = removeStatusBarItems(r.statusBarItems, pluginID) r.statusBarItems = removeStatusBarItems(r.statusBarItems, pluginID)
r.openProviders = removeOpenProviders(r.openProviders, pluginID) r.openProviders = removeOpenProviders(r.openProviders, pluginID)
r.workspaceItems = removeWorkspaceItems(r.workspaceItems, pluginID) r.workspaceItems = removeWorkspaceItems(r.workspaceItems, pluginID)
if r.workspaceTree != nil && r.workspaceTree.PluginID == pluginID {
r.workspaceTree = nil
}
} }
// Getters — sorted for deterministic display. // Getters — sorted for deterministic display.
@@ -367,6 +382,13 @@ func (r *Registry) WorkspaceItems() []ContributionWorkspaceItem {
return result return result
} }
// WorkspaceTree returns the singleton workspaceTree contribution, or nil.
func (r *Registry) WorkspaceTree() *ContributionWorkspaceTree {
r.mu.RLock()
defer r.mu.RUnlock()
return r.workspaceTree
}
// ─── Remove helpers ───────────────────────────────────────── // ─── Remove helpers ─────────────────────────────────────────
func removeViews(items []ContributionView, pluginID string) []ContributionView { func removeViews(items []ContributionView, pluginID string) []ContributionView {
+11 -4
View File
@@ -25,8 +25,7 @@ func IsReservedPath(relativePath string) bool {
if cleaned == "" { if cleaned == "" {
return false return false
} }
first := strings.Split(cleaned, "/")[0] return containsReservedSegment(cleaned)
return strings.EqualFold(first, ".verstak")
} }
func normalizeRelativePath(input string, allowRoot bool) (string, error) { func normalizeRelativePath(input string, allowRoot bool) (string, error) {
@@ -67,8 +66,16 @@ func IsReservedPathNoNormalize(cleaned string) bool {
if cleaned == "" { if cleaned == "" {
return false return false
} }
first := strings.Split(cleaned, "/")[0] return containsReservedSegment(cleaned)
return strings.EqualFold(first, ".verstak") }
func containsReservedSegment(cleaned string) bool {
for _, segment := range strings.Split(cleaned, "/") {
if strings.EqualFold(segment, ".verstak") {
return true
}
}
return false
} }
func looksAbsolute(input string) bool { func looksAbsolute(input string) bool {
+4
View File
@@ -43,6 +43,7 @@ func TestNormalizeRelativeFileRejectsUnsafePaths(t *testing.T) {
".verstak/vault.json", ".verstak/vault.json",
"./.verstak", "./.verstak",
".verstak/trash", ".verstak/trash",
"Workspace/.verstak/workspace.json",
"folder/../.verstak", "folder/../.verstak",
".Verstak", ".Verstak",
} }
@@ -70,6 +71,9 @@ func TestReservedPathPolicy(t *testing.T) {
if IsReservedPath("Notes/.verstak.md") { if IsReservedPath("Notes/.verstak.md") {
t.Fatal("Notes/.verstak.md should not be reserved") t.Fatal("Notes/.verstak.md should not be reserved")
} }
if !IsReservedPath("Workspace/.verstak/workspace.json") {
t.Fatal("nested .verstak should be reserved")
}
} }
func TestNormalizeRelativeFileAcceptsOnlySlashSeparatedRelativePaths(t *testing.T) { func TestNormalizeRelativeFileAcceptsOnlySlashSeparatedRelativePaths(t *testing.T) {
+80
View File
@@ -4,6 +4,7 @@ import (
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"io/fs" "io/fs"
"mime" "mime"
"os" "os"
@@ -201,6 +202,85 @@ func (s *Service) WriteVaultFileBytes(relativePath string, dataBase64 string, op
return s.writeVaultFileData(relativePath, data, options) return s.writeVaultFileData(relativePath, data, options)
} }
// WriteVaultFileFromPath streams a verified temporary file into the vault and
// replaces the destination atomically. It is used by core sync so a Blob never
// needs to be base64-decoded or held in memory.
func (s *Service) WriteVaultFileFromPath(relativePath, sourcePath string, options WriteOptions) error {
source, err := os.Open(sourcePath)
if err != nil {
return err
}
defer source.Close()
info, err := source.Stat()
if err != nil {
return err
}
if !info.Mode().IsRegular() {
return fmt.Errorf("blob source is not a regular file")
}
root, rel, full, err := s.resolveFile(relativePath)
if err != nil {
return err
}
if err := rejectSymlinkPath(root, rel, true); err != nil {
return err
}
parent := filepath.Dir(full)
if info, err := os.Stat(parent); err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("parent-not-found: %s", pathDir(rel))
}
return err
} else if !info.IsDir() {
return fmt.Errorf("parent-not-directory: %s", pathDir(rel))
}
existing, err := os.Lstat(full)
if err == nil {
if existing.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("symlink-not-allowed: %s", rel)
}
if !existing.Mode().IsRegular() {
return fmt.Errorf("not-regular-file: %s", rel)
}
if !options.Overwrite {
return fmt.Errorf("conflict: %s", rel)
}
} else if os.IsNotExist(err) {
if !options.CreateIfMissing {
return fmt.Errorf("not-found: %s", rel)
}
} else {
return err
}
tmp, err := os.CreateTemp(parent, ".verstak-write-*")
if err != nil {
return err
}
tmpPath := tmp.Name()
cleanup := true
defer func() {
if cleanup {
_ = os.Remove(tmpPath)
}
}()
if _, err := io.Copy(tmp, source); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Rename(tmpPath, full); err != nil {
return err
}
cleanup = false
return nil
}
func (s *Service) writeVaultFileData(relativePath string, data []byte, options WriteOptions) error { func (s *Service) writeVaultFileData(relativePath string, data []byte, options WriteOptions) error {
root, rel, full, err := s.resolveFile(relativePath) root, rel, full, err := s.resolveFile(relativePath)
if err != nil { if err != nil {
+31 -7
View File
@@ -35,11 +35,23 @@ type Service struct {
bus *events.Bus bus *events.Bus
interval time.Duration interval time.Duration
mu sync.Mutex mu sync.Mutex
root string root string
cancel chan struct{} cancel chan struct{}
done chan struct{} done chan struct{}
current map[string]snapshotEntry current map[string]snapshotEntry
onChange func()
}
// SetOnChange installs a lightweight notification used by core services that
// need a debounced reconciliation after the watcher has observed a change.
func (s *Service) SetOnChange(callback func()) {
if s == nil {
return
}
s.mu.Lock()
s.onChange = callback
s.mu.Unlock()
} }
// NewService creates a watcher. The interval parameter is mainly for tests. // NewService creates a watcher. The interval parameter is mainly for tests.
@@ -133,22 +145,30 @@ func (s *Service) poll(root string) {
s.mu.Lock() s.mu.Lock()
prev := s.current prev := s.current
s.current = next s.current = next
callback := s.onChange
s.mu.Unlock() s.mu.Unlock()
changed := false
for path, entry := range next { for path, entry := range next {
old, ok := prev[path] old, ok := prev[path]
if !ok { if !ok {
s.publish(path, "external.create", entry.kind) s.publish(path, "external.create", entry.kind)
changed = true
continue continue
} }
if entry.kind == entryFile && (entry.size != old.size || !entry.modTime.Equal(old.modTime)) { if entry.kind == entryFile && (entry.size != old.size || !entry.modTime.Equal(old.modTime)) {
s.publish(path, "external.update", entry.kind) s.publish(path, "external.update", entry.kind)
changed = true
} }
} }
for path, entry := range prev { for path, entry := range prev {
if _, ok := next[path]; !ok { if _, ok := next[path]; !ok {
s.publish(path, "external.delete", entry.kind) s.publish(path, "external.delete", entry.kind)
changed = true
} }
} }
if changed && callback != nil {
callback()
}
} }
func (s *Service) publish(path, operation string, kind entryKind) { func (s *Service) publish(path, operation string, kind entryKind) {
@@ -217,8 +237,12 @@ func kindFromInfo(info fs.FileInfo) entryKind {
} }
func isReserved(rel string) bool { func isReserved(rel string) bool {
first := strings.Split(filepath.ToSlash(rel), "/")[0] for _, segment := range strings.Split(filepath.ToSlash(rel), "/") {
return strings.EqualFold(first, ".verstak") if strings.EqualFold(segment, ".verstak") {
return true
}
}
return false
} }
func workspaceRoot(path string) string { func workspaceRoot(path string) string {
+20
View File
@@ -74,6 +74,26 @@ func TestServiceIgnoresReservedVerstakPaths(t *testing.T) {
} }
} }
func TestServiceCallsChangeCallbackForExternalChanges(t *testing.T) {
root := t.TempDir()
service := NewService(events.NewBus(), 10*time.Millisecond)
changed := make(chan struct{}, 1)
service.SetOnChange(func() { changed <- struct{}{} })
if err := service.Start(root); err != nil {
t.Fatalf("Start: %v", err)
}
t.Cleanup(service.Stop)
if err := os.WriteFile(filepath.Join(root, "external.txt"), []byte("change"), 0o644); err != nil {
t.Fatal(err)
}
select {
case <-changed:
case <-time.After(500 * time.Millisecond):
t.Fatal("timed out waiting for watcher callback")
}
}
func waitForEvent(t *testing.T, eventCh <-chan events.Event) events.Event { func waitForEvent(t *testing.T, eventCh <-chan events.Event) events.Event {
t.Helper() t.Helper()
select { select {
+123
View File
@@ -0,0 +1,123 @@
package hostname
import (
"net/netip"
"net/url"
"strings"
"unicode"
"golang.org/x/net/idna"
)
const (
maxDNSHostnameLength = 253
maxDNSLabelLength = 63
)
// NormalizeHostnameV1 returns the canonical A-label hostname used by domain
// bindings. It accepts a bare DNS name, IPv4 address, bracketed IPv6 literal,
// localhost, or a single-label internal hostname. Invalid input returns "".
func NormalizeHostnameV1(input string) string {
value := strings.TrimSpace(input)
if value == "" || strings.IndexFunc(value, unicode.IsSpace) >= 0 || strings.ContainsAny(value, "\\/?#@") {
return ""
}
if strings.HasPrefix(value, "[") || strings.HasSuffix(value, "]") {
if !strings.HasPrefix(value, "[") || !strings.HasSuffix(value, "]") {
return ""
}
return normalizeIPv6(value[1 : len(value)-1])
}
if strings.Contains(value, ":") {
return ""
}
value = strings.TrimSuffix(value, ".")
if value == "" || strings.HasSuffix(value, ".") {
return ""
}
if isNumericHostname(value) {
return normalizeIPv4(value)
}
return normalizeDNS(value)
}
// NormalizeURLHostnameV1 returns the canonical hostname from an HTTP(S) URL.
// Ports, paths, credentials, and fragments are intentionally not preserved.
func NormalizeURLHostnameV1(input string) string {
value := strings.TrimSpace(input)
if value == "" {
return ""
}
parsed, err := url.ParseRequestURI(value)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return ""
}
host := parsed.Hostname()
if host == "" {
return ""
}
if address, err := netip.ParseAddr(host); err == nil && address.Is6() {
return address.String()
}
return NormalizeHostnameV1(host)
}
func normalizeIPv4(value string) string {
address, err := netip.ParseAddr(value)
if err != nil || !address.Is4() || address.String() != value {
return ""
}
return address.String()
}
func normalizeIPv6(value string) string {
address, err := netip.ParseAddr(value)
if err != nil || !address.Is6() {
return ""
}
return address.String()
}
func normalizeDNS(value string) string {
ascii, err := idna.Lookup.ToASCII(value)
if err != nil {
return ""
}
ascii = strings.ToLower(strings.TrimSuffix(ascii, "."))
if ascii == "" || strings.HasSuffix(ascii, ".") || len(ascii) > maxDNSHostnameLength {
return ""
}
for _, label := range strings.Split(ascii, ".") {
if len(label) == 0 || len(label) > maxDNSLabelLength || !isDNSLabel(label) {
return ""
}
}
return ascii
}
func isNumericHostname(value string) bool {
for _, char := range value {
if (char < '0' || char > '9') && char != '.' {
return false
}
}
return true
}
func isDNSLabel(label string) bool {
if !isASCIIAlphaNum(label[0]) || !isASCIIAlphaNum(label[len(label)-1]) {
return false
}
for _, char := range label {
if !isASCIIAlphaNum(byte(char)) && char != '-' {
return false
}
}
return true
}
func isASCIIAlphaNum(char byte) bool {
return char >= 'a' && char <= 'z' || char >= '0' && char <= '9'
}
+50
View File
@@ -0,0 +1,50 @@
package hostname
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
type normalizationVector struct {
Input string `json:"input"`
Output string `json:"output"`
}
type normalizationVectors struct {
Bare []normalizationVector `json:"bare"`
URL []normalizationVector `json:"url"`
}
func TestNormalizeHostnameV1Vectors(t *testing.T) {
vectors := loadVectors(t)
for _, vector := range vectors.Bare {
if got := NormalizeHostnameV1(vector.Input); got != vector.Output {
t.Errorf("NormalizeHostnameV1(%q) = %q, want %q", vector.Input, got, vector.Output)
}
}
}
func TestNormalizeURLHostnameV1Vectors(t *testing.T) {
vectors := loadVectors(t)
for _, vector := range vectors.URL {
if got := NormalizeURLHostnameV1(vector.Input); got != vector.Output {
t.Errorf("NormalizeURLHostnameV1(%q) = %q, want %q", vector.Input, got, vector.Output)
}
}
}
func loadVectors(t *testing.T) normalizationVectors {
t.Helper()
path := filepath.Join("testdata", "hostname-normalization-v1.json")
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
var vectors normalizationVectors
if err := json.Unmarshal(data, &vectors); err != nil {
t.Fatalf("decode %s: %v", path, err)
}
return vectors
}
@@ -0,0 +1,34 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://raw.githubusercontent.com/mirivlad/verstak-sdk/main/schemas/hostname-normalization-v1.json",
"title": "Verstak canonical hostname normalization v1 test vectors",
"description": "Canonical hostnames are lowercase ASCII A-labels without a port or trailing DNS dot. Bare hostnames accept DNS names, IPv4, bracketed IPv6, localhost, and internal single-label names. URL inputs accept only HTTP(S). Invalid or excessively long input normalizes to an empty string.",
"version": 1,
"bare": [
{ "input": "example.com", "output": "example.com" },
{ "input": " Example.COM. ", "output": "example.com" },
{ "input": "пример.рф", "output": "xn--e1afmkfd.xn--p1ai" },
{ "input": "bücher.example", "output": "xn--bcher-kva.example" },
{ "input": "127.0.0.1", "output": "127.0.0.1" },
{ "input": "[2001:db8::1]", "output": "2001:db8::1" },
{ "input": "localhost", "output": "localhost" },
{ "input": "intranet", "output": "intranet" },
{ "input": "", "output": "" },
{ "input": "https://example.com", "output": "" },
{ "input": "example.com:443", "output": "" },
{ "input": "user@example.com", "output": "" },
{ "input": "bad host", "output": "" },
{ "input": "example..com", "output": "" },
{ "input": "example.com..", "output": "" },
{ "input": "127.000.000.001", "output": "" },
{ "input": "[2001:db8::1]:443", "output": "" },
{ "input": "a...............................................................example", "output": "" }
],
"url": [
{ "input": "https://пример.рф/path", "output": "xn--e1afmkfd.xn--p1ai" },
{ "input": "http://Example.COM.:8080/path", "output": "example.com" },
{ "input": "https://[2001:db8::1]/", "output": "2001:db8::1" },
{ "input": "ftp://example.com/path", "output": "" },
{ "input": "not a URL", "output": "" }
]
}
+254
View File
@@ -0,0 +1,254 @@
// Package notifications persists plugin-owned reminder schedules and delivers
// due entries through a desktop-native sender.
package notifications
import (
"context"
"fmt"
"sort"
"strings"
"sync"
"time"
)
const (
maxRequestsPerPlugin = 500
maxIDLength = 256
maxTitleLength = 512
maxBodyLength = 4096
)
// VaultPath provides a stable operation while a vault is open.
type VaultPath interface {
WithOpenPath(func(string) error) error
}
// Request is a desired scheduled notification in a plugin's own namespace.
type Request struct {
ID string `json:"id"`
DueAt string `json:"dueAt"`
Title string `json:"title"`
Body string `json:"body,omitempty"`
}
// Item is the persisted form of a plugin notification schedule.
type Item struct {
PluginID string `json:"pluginId"`
ID string `json:"id"`
DueAt string `json:"dueAt"`
Title string `json:"title"`
Body string `json:"body,omitempty"`
SentForDueAt string `json:"sentForDueAt,omitempty"`
}
// Sender delivers one native desktop notification.
type Sender interface {
Send(context.Context, Item) error
}
// Manager owns canonical schedules for every authorized plugin.
type Manager struct {
mu sync.Mutex
vault VaultPath
sender Sender
now func() time.Time
items []Item
loaded bool
stop chan struct{}
}
// New creates a notification manager. The supplied clock is used by tests; a
// nil clock uses the current time.
func New(vault VaultPath, sender Sender, now func() time.Time) *Manager {
if now == nil {
now = time.Now
}
return &Manager{vault: vault, sender: sender, now: now}
}
// Replace atomically replaces one plugin's desired schedules.
func (m *Manager) Replace(pluginID string, requests []Request) error {
if err := validateRequests(pluginID, requests); err != nil {
return err
}
m.mu.Lock()
defer m.mu.Unlock()
if err := m.loadLocked(); err != nil {
return err
}
previous := make(map[string]Item)
kept := make([]Item, 0, len(m.items)+len(requests))
for _, item := range m.items {
if item.PluginID == pluginID {
previous[item.ID] = item
continue
}
kept = append(kept, item)
}
for _, request := range requests {
item := Item{
PluginID: pluginID,
ID: request.ID,
DueAt: request.DueAt,
Title: request.Title,
Body: request.Body,
}
if old, ok := previous[request.ID]; ok && old.DueAt == request.DueAt {
item.SentForDueAt = old.SentForDueAt
}
kept = append(kept, item)
}
sortItems(kept)
if err := m.writeLocked(kept); err != nil {
return err
}
m.items = kept
return nil
}
// Clear removes every schedule owned by a plugin.
func (m *Manager) Clear(pluginID string) error {
return m.Replace(pluginID, nil)
}
// Items returns a snapshot of the current schedule state.
func (m *Manager) Items() []Item {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.loadLocked(); err != nil {
return nil
}
return append([]Item(nil), m.items...)
}
// Path reports the canonical schedule path while the vault is open.
func (m *Manager) Path() string {
if m == nil || m.vault == nil {
return ""
}
var path string
if err := m.vault.WithOpenPath(func(vaultPath string) error {
path = schedulePath(vaultPath)
return nil
}); err != nil {
return ""
}
return path
}
// Start immediately checks due schedules, then repeats every 30 seconds.
func (m *Manager) Start(ctx context.Context) {
if m == nil {
return
}
m.mu.Lock()
if m.stop != nil {
m.mu.Unlock()
return
}
stop := make(chan struct{})
m.stop = stop
m.mu.Unlock()
go func() {
m.Tick(ctx)
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
m.Tick(ctx)
case <-stop:
return
case <-ctx.Done():
return
}
}
}()
}
// Stop stops a running scheduler. It is safe to call repeatedly.
func (m *Manager) Stop() {
if m == nil {
return
}
m.mu.Lock()
defer m.mu.Unlock()
if m.stop == nil {
return
}
close(m.stop)
m.stop = nil
}
// Tick delivers each overdue schedule once. A sender failure leaves the item
// pending for a later tick.
func (m *Manager) Tick(ctx context.Context) {
if m == nil || m.sender == nil {
return
}
m.mu.Lock()
defer m.mu.Unlock()
if err := m.loadLocked(); err != nil {
return
}
now := m.now().UTC()
for index, item := range m.items {
dueAt, err := time.Parse(time.RFC3339, item.DueAt)
if err != nil || dueAt.After(now) || item.SentForDueAt == item.DueAt {
continue
}
if err := m.sender.Send(ctx, item); err != nil {
continue
}
updated := append([]Item(nil), m.items...)
updated[index].SentForDueAt = item.DueAt
if err := m.writeLocked(updated); err != nil {
continue
}
m.items = updated
}
}
func validateRequests(pluginID string, requests []Request) error {
if strings.TrimSpace(pluginID) == "" || strings.ContainsAny(pluginID, "/\\") {
return fmt.Errorf("invalid plugin ID %q", pluginID)
}
if len(requests) > maxRequestsPerPlugin {
return fmt.Errorf("plugin %q has more than %d notification schedules", pluginID, maxRequestsPerPlugin)
}
seen := make(map[string]bool, len(requests))
for _, request := range requests {
if request.ID == "" || len(request.ID) > maxIDLength {
return fmt.Errorf("notification ID is empty or too long")
}
if seen[request.ID] {
return fmt.Errorf("duplicate notification ID %q", request.ID)
}
seen[request.ID] = true
if !strings.HasSuffix(request.DueAt, "Z") {
return fmt.Errorf("notification %q dueAt must be UTC RFC3339", request.ID)
}
if _, err := time.Parse(time.RFC3339, request.DueAt); err != nil {
return fmt.Errorf("notification %q has invalid dueAt: %w", request.ID, err)
}
if strings.TrimSpace(request.Title) == "" || len(request.Title) > maxTitleLength {
return fmt.Errorf("notification %q title is empty or too long", request.ID)
}
if len(request.Body) > maxBodyLength {
return fmt.Errorf("notification %q body is too long", request.ID)
}
}
return nil
}
func sortItems(items []Item) {
sort.Slice(items, func(left, right int) bool {
if items[left].PluginID != items[right].PluginID {
return items[left].PluginID < items[right].PluginID
}
return items[left].ID < items[right].ID
})
}
+131
View File
@@ -0,0 +1,131 @@
package notifications
import (
"context"
"errors"
"path/filepath"
"testing"
"time"
)
type testVault struct {
path string
}
func (v testVault) WithOpenPath(operation func(string) error) error {
return operation(v.path)
}
type fakeSender struct {
err error
items []Item
}
func (s *fakeSender) Send(_ context.Context, item Item) error {
s.items = append(s.items, item)
return s.err
}
func testManager(t *testing.T, sender *fakeSender, now time.Time) *Manager {
t.Helper()
return New(testVault{path: t.TempDir()}, sender, func() time.Time { return now })
}
func request(id, dueAt string) Request {
return Request{ID: id, DueAt: dueAt, Title: "Reminder " + id, Body: "Todo " + id}
}
func TestReplaceRemovesStalePluginSchedules(t *testing.T) {
manager := testManager(t, &fakeSender{}, time.Date(2026, 7, 14, 9, 0, 0, 0, time.UTC))
if err := manager.Replace("verstak.todo", []Request{
request("first", "2026-07-14T10:00:00Z"),
request("second", "2026-07-14T11:00:00Z"),
}); err != nil {
t.Fatalf("initial replace: %v", err)
}
if err := manager.Replace("verstak.todo", []Request{request("second", "2026-07-14T11:00:00Z")}); err != nil {
t.Fatalf("stale-item replace: %v", err)
}
items := manager.Items()
if len(items) != 1 || items[0].PluginID != "verstak.todo" || items[0].ID != "second" {
t.Fatalf("items = %#v, want only second Todo reminder", items)
}
}
func TestReplaceReschedulesDeliveredItem(t *testing.T) {
now := time.Date(2026, 7, 14, 12, 0, 0, 0, time.UTC)
sender := &fakeSender{}
manager := testManager(t, sender, now)
if err := manager.Replace("verstak.todo", []Request{request("todo-1", "2026-07-14T11:00:00Z")}); err != nil {
t.Fatalf("initial replace: %v", err)
}
manager.Tick(context.Background())
if len(sender.items) != 1 || manager.Items()[0].SentForDueAt == "" {
t.Fatalf("first delivery = %#v, state = %#v", sender.items, manager.Items())
}
if err := manager.Replace("verstak.todo", []Request{request("todo-1", "2026-07-14T13:00:00Z")}); err != nil {
t.Fatalf("reschedule: %v", err)
}
if got := manager.Items()[0].SentForDueAt; got != "" {
t.Fatalf("rescheduled item kept old acknowledgment %q", got)
}
}
func TestTickRetriesFailedSendAndAcknowledgesOneDelivery(t *testing.T) {
now := time.Date(2026, 7, 14, 12, 0, 0, 0, time.UTC)
sender := &fakeSender{err: errors.New("notification unavailable")}
manager := testManager(t, sender, now)
if err := manager.Replace("verstak.todo", []Request{request("todo-1", "2026-07-14T11:00:00Z")}); err != nil {
t.Fatalf("replace: %v", err)
}
manager.Tick(context.Background())
if len(sender.items) != 1 || manager.Items()[0].SentForDueAt != "" {
t.Fatalf("failed send was acknowledged: sent=%d items=%#v", len(sender.items), manager.Items())
}
sender.err = nil
manager.Tick(context.Background())
manager.Tick(context.Background())
if len(sender.items) != 2 {
t.Fatalf("send attempts = %d, want 2", len(sender.items))
}
if got := manager.Items()[0].SentForDueAt; got != "2026-07-14T11:00:00Z" {
t.Fatalf("sent acknowledgment = %q", got)
}
}
func TestPersistentSchedulesReloadAndDeliverOverdueOnlyOnce(t *testing.T) {
path := t.TempDir()
now := time.Date(2026, 7, 14, 12, 0, 0, 0, time.UTC)
first := New(testVault{path: path}, &fakeSender{}, func() time.Time { return now })
if err := first.Replace("verstak.todo", []Request{request("todo-1", "2026-07-14T11:00:00Z")}); err != nil {
t.Fatalf("replace: %v", err)
}
if got, want := filepath.Join(path, ".verstak", "notifications", "schedules.json"), first.Path(); got != want {
t.Fatalf("schedule path = %q, want %q", got, want)
}
sender := &fakeSender{}
restarted := New(testVault{path: path}, sender, func() time.Time { return now })
restarted.Tick(context.Background())
restarted.Tick(context.Background())
if len(sender.items) != 1 || sender.items[0].ID != "todo-1" {
t.Fatalf("overdue delivery = %#v", sender.items)
}
}
func TestReplaceRejectsInvalidRequests(t *testing.T) {
manager := testManager(t, &fakeSender{}, time.Now().UTC())
for _, requests := range [][]Request{
{{ID: "", DueAt: "2026-07-14T10:00:00Z", Title: "title"}},
{{ID: "same", DueAt: "2026-07-14T10:00:00Z", Title: "title"}, {ID: "same", DueAt: "2026-07-14T11:00:00Z", Title: "title"}},
{{ID: "bad-date", DueAt: "not-a-date", Title: "title"}},
} {
if err := manager.Replace("verstak.todo", requests); err == nil {
t.Fatalf("Replace(%#v) succeeded", requests)
}
}
}
+80
View File
@@ -0,0 +1,80 @@
package notifications
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
)
type storedSchedules struct {
Items []Item `json:"items"`
}
func schedulePath(vaultPath string) string {
return filepath.Join(vaultPath, ".verstak", "notifications", "schedules.json")
}
func (m *Manager) loadLocked() error {
if m.loaded {
return nil
}
if m.vault == nil {
return fmt.Errorf("notification vault is not initialized")
}
var loaded []Item
if err := m.vault.WithOpenPath(func(vaultPath string) error {
path := schedulePath(vaultPath)
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
loaded = []Item{}
return nil
}
if err != nil {
return fmt.Errorf("read notification schedules: %w", err)
}
var stored storedSchedules
if err := json.Unmarshal(data, &stored); err != nil {
return fmt.Errorf("decode notification schedules: %w", err)
}
for _, item := range stored.Items {
if err := validateRequests(item.PluginID, []Request{{ID: item.ID, DueAt: item.DueAt, Title: item.Title, Body: item.Body}}); err != nil {
return fmt.Errorf("invalid stored notification schedule: %w", err)
}
loaded = append(loaded, item)
}
sortItems(loaded)
return nil
}); err != nil {
return err
}
m.items = loaded
m.loaded = true
return nil
}
func (m *Manager) writeLocked(items []Item) error {
if m.vault == nil {
return fmt.Errorf("notification vault is not initialized")
}
return m.vault.WithOpenPath(func(vaultPath string) error {
data, err := json.MarshalIndent(storedSchedules{Items: items}, "", " ")
if err != nil {
return fmt.Errorf("encode notification schedules: %w", err)
}
path := schedulePath(vaultPath)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create notification schedule directory: %w", err)
}
temporary := filepath.Join(filepath.Dir(path), fmt.Sprintf(".schedules.%d.tmp", time.Now().UnixNano()))
if err := os.WriteFile(temporary, data, 0o644); err != nil {
return fmt.Errorf("write notification schedules: %w", err)
}
if err := os.Rename(temporary, path); err != nil {
_ = os.Remove(temporary)
return fmt.Errorf("replace notification schedules: %w", err)
}
return nil
})
}
+1
View File
@@ -39,6 +39,7 @@ func (r *Registry) registerDefaults() {
{Name: "files.openExternal", Description: "Open vault files and folders in external OS applications", Dangerous: true}, {Name: "files.openExternal", Description: "Open vault files and folders in external OS applications", Dangerous: true},
{Name: "storage.namespace", Description: "Read/write plugin's own storage namespace", Dangerous: false}, {Name: "storage.namespace", Description: "Read/write plugin's own storage namespace", Dangerous: false},
{Name: "storage.migrations", Description: "Run database migrations in plugin namespace", Dangerous: false}, {Name: "storage.migrations", Description: "Run database migrations in plugin namespace", Dangerous: false},
{Name: "notifications.schedule", Description: "Schedule native notifications in the plugin's own namespace", Dangerous: false},
{Name: "events.publish", Description: "Publish events to the event bus", Dangerous: false}, {Name: "events.publish", Description: "Publish events to the event bus", Dangerous: false},
{Name: "events.subscribe", Description: "Subscribe to events on the event bus", Dangerous: false}, {Name: "events.subscribe", Description: "Subscribe to events on the event bus", Dangerous: false},
{Name: "ui.register", Description: "Register UI components and contributions", Dangerous: false}, {Name: "ui.register", Description: "Register UI components and contributions", Dangerous: false},
+40 -12
View File
@@ -3,25 +3,16 @@ package plugin
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"runtime"
"testing" "testing"
"github.com/verstak/verstak-desktop/internal/core/capability" "github.com/verstak/verstak-desktop/internal/core/capability"
) )
// coreCapabilities lists the 5 core capabilities that the platform registers // registerCoreCapabilities registers the desktop's core capabilities on a registry.
// before any plugins are loaded.
var coreCapabilities = []string{
"verstak/core/plugin-manager/v1",
"verstak/core/capability-registry/v1",
"verstak/core/contribution-registry/v1",
"verstak/core/permissions/v1",
"verstak/core/events/v1",
}
// registerCoreCapabilities registers the 5 core capabilities on a registry.
func registerCoreCapabilities(t *testing.T, reg *capability.Registry) { func registerCoreCapabilities(t *testing.T, reg *capability.Registry) {
t.Helper() t.Helper()
if err := reg.Register("verstak-core", coreCapabilities); err != nil { if err := reg.Register(capability.CorePluginID, capability.CorePlatformCapabilities()); err != nil {
t.Fatalf("failed to register core capabilities: %v", err) t.Fatalf("failed to register core capabilities: %v", err)
} }
} }
@@ -76,6 +67,43 @@ func TestLifecycle_CoreCapabilitiesRegisteredBeforePlugins(t *testing.T) {
} }
} }
func TestBundledOfficialPluginRequirementsResolve(t *testing.T) {
plugins, errs := DiscoverPlugins([]string{bundledPluginDir(t)})
if len(errs) > 0 {
t.Fatalf("discover bundled plugins: %v", errs)
}
reg := capability.NewRegistry()
registerCoreCapabilities(t, reg)
ResolveLifecycle(plugins, reg, nil)
foundTodo := false
for _, loaded := range plugins {
if loaded.Manifest.ID == "verstak.todo" {
foundTodo = true
}
if loaded.Status == StatusMissingRequiredCapability {
t.Fatalf("bundled plugin %q is missing required capabilities: %s", loaded.Manifest.ID, loaded.Error)
}
}
if !foundTodo {
t.Fatal("bundled Todo manifest was not discovered")
}
}
func bundledPluginDir(t *testing.T) string {
t.Helper()
if configured := os.Getenv("VERSTAK_RELEASE_PLUGIN_DIR"); configured != "" {
return configured
}
_, sourceFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("could not resolve lifecycle test path")
}
return filepath.Join(filepath.Dir(sourceFile), "../../../plugins")
}
// TestLifecycle_MissingRequiredCapability verifies that when the required // TestLifecycle_MissingRequiredCapability verifies that when the required
// capability is NOT registered, CheckRequired reports it as missing and the // capability is NOT registered, CheckRequired reports it as missing and the
// plugin status should be set to StatusMissingRequiredCapability. // plugin status should be set to StatusMissingRequiredCapability.
+6
View File
@@ -77,6 +77,12 @@ type Contributions struct {
StatusBarItems []ContributionStatusBarItem `json:"statusBarItems,omitempty"` StatusBarItems []ContributionStatusBarItem `json:"statusBarItems,omitempty"`
OpenProviders []ContributionOpenProvider `json:"openProviders,omitempty"` OpenProviders []ContributionOpenProvider `json:"openProviders,omitempty"`
WorkspaceItems []ContributionWorkspaceItem `json:"workspaceItems,omitempty"` WorkspaceItems []ContributionWorkspaceItem `json:"workspaceItems,omitempty"`
WorkspaceTree *ContributionWorkspaceTree `json:"workspaceTree,omitempty"`
}
// ContributionWorkspaceTree represents a singleton workspaceTree contribution.
type ContributionWorkspaceTree struct {
Component string `json:"component"`
} }
// ContributionView represents a view contribution. // ContributionView represents a view contribution.
+209
View File
@@ -20,6 +20,17 @@ type Storage struct {
vault *vault.Vault vault *vault.Vault
} }
// NDJSONRetention bounds append-only plugin data. Records are compacted after
// a successful append so settings.json never becomes an event log.
type NDJSONRetention struct {
TimestampField string
MaxAge time.Duration
MaxEntries int
MaxBytes int64
DeduplicateField string
DeduplicateValue string
}
// New creates a new Storage instance backed by the given vault. // New creates a new Storage instance backed by the given vault.
func New(v *vault.Vault) *Storage { func New(v *vault.Vault) *Storage {
return &Storage{vault: v} return &Storage{vault: v}
@@ -234,6 +245,204 @@ func (s *Storage) WritePluginDataJSON(pluginID, name string, data map[string]int
return atomicWrite(path, encoded) return atomicWrite(path, encoded)
} }
// ReadPluginDataNDJSON reads an append-only named data file. A missing file is
// represented by an empty slice.
func (s *Storage) ReadPluginDataNDJSON(pluginID, name string) ([]map[string]interface{}, error) {
if err := validatePluginID(pluginID); err != nil {
return nil, err
}
if err := validateStorageName("data", name); err != nil {
return nil, err
}
s.mu.RLock()
defer s.mu.RUnlock()
var records []map[string]interface{}
err := s.withOpenVault(func(vaultPath string) error {
var err error
records, err = readPluginDataNDJSONAt(vaultPath, pluginID, name)
return err
})
return records, err
}
// AppendPluginDataNDJSON appends records durably and then applies bounded
// retention. It returns false without writing when the supplied idempotency
// value is already present in the retained log.
func (s *Storage) AppendPluginDataNDJSON(pluginID, name string, records []map[string]interface{}, retention NDJSONRetention) (bool, error) {
if err := validatePluginID(pluginID); err != nil {
return false, err
}
if err := validateStorageName("data", name); err != nil {
return false, err
}
if len(records) == 0 {
return false, fmt.Errorf("NDJSON records are empty")
}
if retention.MaxEntries < 0 || retention.MaxBytes < 0 || retention.MaxAge < 0 {
return false, fmt.Errorf("NDJSON retention values must not be negative")
}
s.mu.Lock()
defer s.mu.Unlock()
stored := false
err := s.withOpenVault(func(vaultPath string) error {
existing, err := readPluginDataNDJSONAt(vaultPath, pluginID, name)
if err != nil {
return err
}
if retention.DeduplicateField != "" && retention.DeduplicateValue != "" {
for _, record := range existing {
if fmt.Sprint(record[retention.DeduplicateField]) == retention.DeduplicateValue {
return nil
}
}
}
path := pluginDataNDJSONPath(vaultPath, pluginID, name)
if err := appendNDJSON(path, records); err != nil {
return err
}
stored = true
compacted := compactNDJSONRecords(append(existing, records...), retention, time.Now().UTC())
if !sameNDJSONRecords(append(existing, records...), compacted) {
return writeNDJSON(path, compacted)
}
return nil
})
return stored, err
}
// WritePluginDataNDJSON replaces a named append-only data file. It is reserved
// for explicit user actions such as clearing Activity, never normal event
// ingestion.
func (s *Storage) WritePluginDataNDJSON(pluginID, name string, records []map[string]interface{}) error {
if err := validatePluginID(pluginID); err != nil {
return err
}
if err := validateStorageName("data", name); err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
return s.withOpenVault(func(vaultPath string) error {
return writeNDJSON(pluginDataNDJSONPath(vaultPath, pluginID, name), records)
})
}
func pluginDataNDJSONPath(vaultPath, pluginID, name string) string {
return filepath.Join(vaultPath, ".verstak", "plugin-data", pluginID, name+".ndjson")
}
func readPluginDataNDJSONAt(vaultPath, pluginID, name string) ([]map[string]interface{}, error) {
data, err := os.ReadFile(pluginDataNDJSONPath(vaultPath, pluginID, name))
if err != nil {
if os.IsNotExist(err) {
return []map[string]interface{}{}, nil
}
return nil, fmt.Errorf("failed to read NDJSON data %s for plugin %s: %w", name, pluginID, err)
}
if len(data) == 0 {
return []map[string]interface{}{}, nil
}
lines := strings.Split(strings.TrimSuffix(string(data), "\n"), "\n")
records := make([]map[string]interface{}, 0, len(lines))
for index, line := range lines {
if strings.TrimSpace(line) == "" {
continue
}
var record map[string]interface{}
if err := json.Unmarshal([]byte(line), &record); err != nil {
return nil, fmt.Errorf("corrupt NDJSON data %s line %d for plugin %s: %w", name, index+1, pluginID, err)
}
records = append(records, record)
}
return records, nil
}
func appendNDJSON(path string, records []map[string]interface{}) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("failed to create NDJSON data directory: %w", err)
}
file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
return fmt.Errorf("failed to open NDJSON data file: %w", err)
}
defer file.Close()
for _, record := range records {
encoded, err := json.Marshal(record)
if err != nil {
return fmt.Errorf("failed to marshal NDJSON record: %w", err)
}
if _, err := file.Write(append(encoded, '\n')); err != nil {
return fmt.Errorf("failed to append NDJSON record: %w", err)
}
}
if err := file.Sync(); err != nil {
return fmt.Errorf("failed to sync NDJSON data: %w", err)
}
return nil
}
func writeNDJSON(path string, records []map[string]interface{}) error {
data := make([]byte, 0)
for _, record := range records {
encoded, err := json.Marshal(record)
if err != nil {
return fmt.Errorf("failed to marshal NDJSON record: %w", err)
}
data = append(data, encoded...)
data = append(data, '\n')
}
return atomicWrite(path, data)
}
func compactNDJSONRecords(records []map[string]interface{}, retention NDJSONRetention, now time.Time) []map[string]interface{} {
kept := make([]map[string]interface{}, 0, len(records))
cutoff := now.Add(-retention.MaxAge)
for _, record := range records {
if retention.MaxAge > 0 && retention.TimestampField != "" {
if raw, ok := record[retention.TimestampField].(string); ok {
if timestamp, err := time.Parse(time.RFC3339, raw); err == nil && timestamp.Before(cutoff) {
continue
}
}
}
kept = append(kept, record)
}
if retention.MaxEntries > 0 && len(kept) > retention.MaxEntries {
kept = kept[len(kept)-retention.MaxEntries:]
}
if retention.MaxBytes > 0 {
for len(kept) > 0 && ndjsonSize(kept) > retention.MaxBytes {
kept = kept[1:]
}
}
return kept
}
func ndjsonSize(records []map[string]interface{}) int64 {
var total int64
for _, record := range records {
encoded, err := json.Marshal(record)
if err == nil {
total += int64(len(encoded) + 1)
}
}
return total
}
func sameNDJSONRecords(left, right []map[string]interface{}) bool {
if len(left) != len(right) {
return false
}
for index := range left {
leftJSON, leftErr := json.Marshal(left[index])
rightJSON, rightErr := json.Marshal(right[index])
if leftErr != nil || rightErr != nil || string(leftJSON) != string(rightJSON) {
return false
}
}
return true
}
// ─── Cache JSON API ─────────────────────────────────────── // ─── Cache JSON API ───────────────────────────────────────
// ReadPluginCacheJSON reads a named JSON cache file for a plugin. // ReadPluginCacheJSON reads a named JSON cache file for a plugin.
+91
View File
@@ -0,0 +1,91 @@
package storage
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestAppendPluginDataNDJSONCompactsAndDeduplicates(t *testing.T) {
s, vaultDir := newTestStorage(t)
retention := NDJSONRetention{
TimestampField: "occurredAt",
MaxAge: 60 * 24 * time.Hour,
MaxEntries: 2,
MaxBytes: 16 * 1024,
DeduplicateField: "sourceBatchId",
DeduplicateValue: "batch-1",
}
old := time.Now().UTC().Add(-61 * 24 * time.Hour).Format(time.RFC3339)
now := time.Now().UTC().Format(time.RFC3339)
stored, err := s.AppendPluginDataNDJSON("verstak.activity", "activity-events", []map[string]interface{}{
{"activityId": "old", "sourceBatchId": "old-batch", "occurredAt": old},
}, retention)
if err != nil || !stored {
t.Fatalf("old append = (%v, %v), want (true, nil)", stored, err)
}
stored, err = s.AppendPluginDataNDJSON("verstak.activity", "activity-events", []map[string]interface{}{
{"activityId": "current-1", "sourceBatchId": "batch-1", "occurredAt": now},
}, retention)
if err != nil || !stored {
t.Fatalf("first current append = (%v, %v), want (true, nil)", stored, err)
}
stored, err = s.AppendPluginDataNDJSON("verstak.activity", "activity-events", []map[string]interface{}{
{"activityId": "duplicate", "sourceBatchId": "batch-1", "occurredAt": now},
}, retention)
if err != nil || stored {
t.Fatalf("duplicate append = (%v, %v), want (false, nil)", stored, err)
}
for _, item := range []struct {
id string
batch string
}{
{id: "current-2", batch: "batch-3"},
{id: "current-3", batch: "batch-4"},
} {
retention.DeduplicateValue = item.batch
if stored, err := s.AppendPluginDataNDJSON("verstak.activity", "activity-events", []map[string]interface{}{
{"activityId": item.id, "sourceBatchId": item.batch, "occurredAt": now},
}, retention); err != nil || !stored {
t.Fatalf("append %s = (%v, %v), want (true, nil)", item.id, stored, err)
}
}
records, err := s.ReadPluginDataNDJSON("verstak.activity", "activity-events")
if err != nil {
t.Fatalf("ReadPluginDataNDJSON: %v", err)
}
if len(records) != 2 || records[0]["activityId"] != "current-2" || records[1]["activityId"] != "current-3" {
t.Fatalf("records = %+v, want current-2 and current-3", records)
}
if _, err := os.Stat(filepath.Join(vaultDir, "VerstakVault", ".verstak", "plugin-data", "verstak.activity", "activity-events.ndjson")); err != nil {
t.Fatalf("activity data file missing: %v", err)
}
}
func TestWritePluginDataNDJSONReplacesRecordsForExplicitUserClear(t *testing.T) {
s, _ := newTestStorage(t)
if _, err := s.AppendPluginDataNDJSON("verstak.activity", "activity-events", []map[string]interface{}{
{"activityId": "one", "occurredAt": time.Now().UTC().Format(time.RFC3339)},
{"activityId": "two", "occurredAt": time.Now().UTC().Format(time.RFC3339)},
}, NDJSONRetention{}); err != nil {
t.Fatalf("AppendPluginDataNDJSON: %v", err)
}
if err := s.WritePluginDataNDJSON("verstak.activity", "activity-events", []map[string]interface{}{
{"activityId": "two", "occurredAt": time.Now().UTC().Format(time.RFC3339)},
}); err != nil {
t.Fatalf("WritePluginDataNDJSON: %v", err)
}
records, err := s.ReadPluginDataNDJSON("verstak.activity", "activity-events")
if err != nil {
t.Fatalf("ReadPluginDataNDJSON: %v", err)
}
if len(records) != 1 || records[0]["activityId"] != "two" {
t.Fatalf("records = %+v, want only two", records)
}
}
+171 -36
View File
@@ -2,6 +2,8 @@ package sync
import ( import (
"bytes" "bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@@ -216,68 +218,147 @@ func (c *Client) Push(ops []Op) (*PushResponse, error) {
// PullRequest is the payload for POST /sync/pull. // PullRequest is the payload for POST /sync/pull.
type PullRequest struct { type PullRequest struct {
SinceSequence int `json:"since_sequence"` SinceSequence int `json:"since_sequence"`
PageLimit int `json:"page_limit,omitempty"`
} }
// PullResponse is the response from POST /sync/pull. // PullResponse is the response from POST /sync/pull.
type PullResponse struct { type PullResponse struct {
ServerSequence int `json:"server_sequence"` ServerSequence int `json:"server_sequence"`
Ops []Op `json:"ops"` PageLastSequence int `json:"page_last_sequence"`
HasMore bool `json:"has_more"`
Ops []Op `json:"ops"`
}
// BlobReference is the only binary content representation permitted inside a
// sync operation. The bytes travel via the Blob API, never payload_json.
type BlobReference struct {
SHA256 string `json:"sha256"`
Size int64 `json:"size"`
}
// ServerError carries a public stable error code. UI layers map Code to their
// own localized wording and must not rely on a server diagnostic string.
type ServerError struct {
Status int
Code string
}
func (e *ServerError) Error() string {
if e.Code == "" {
return fmt.Sprintf("sync-server:request_failed (HTTP %d)", e.Status)
}
return fmt.Sprintf("sync-server:%s (HTTP %d)", e.Code, e.Status)
} }
// Pull fetches remote operations since a given sequence. // Pull fetches remote operations since a given sequence.
func (c *Client) Pull(sinceSequence int) (*PullResponse, error) { func (c *Client) Pull(sinceSequence int) (*PullResponse, error) {
req := PullRequest{SinceSequence: sinceSequence} return c.PullPage(sinceSequence, 0)
}
// PullPage fetches one bounded ordered page. The caller advances its durable
// cursor only after every returned operation has applied successfully.
func (c *Client) PullPage(sinceSequence, pageLimit int) (*PullResponse, error) {
req := PullRequest{SinceSequence: sinceSequence, PageLimit: pageLimit}
var resp PullResponse var resp PullResponse
if err := c.post("/api/v1/sync/pull", req, &resp); err != nil { if err := c.post("/api/v1/sync/pull", req, &resp); err != nil {
return nil, err return nil, err
} }
// Servers before pull pagination did not include page_last_sequence. Keep
// the desktop compatible during rolling upgrades without ever advancing
// beyond an operation actually present in the response.
if resp.PageLastSequence == 0 && len(resp.Ops) > 0 {
resp.PageLastSequence = resp.Ops[len(resp.Ops)-1].ServerSequence
}
return &resp, nil return &resp, nil
} }
// UploadBlob uploads a file to the server and returns its SHA-256. // UploadBlob streams a local file through a multipart pipe. It keeps the
func (c *Client) UploadBlob(localPath string) (sha256 string, err error) { // process memory bounded even when the file is many times larger than an
var b bytes.Buffer // inline sync payload.
w := multipart.NewWriter(&b) func (c *Client) UploadBlob(localPath string) (BlobReference, error) {
fw, err := w.CreateFormFile("file", filepath.Base(localPath)) info, err := os.Stat(localPath)
if err != nil { if err != nil {
return "", err return BlobReference{}, err
} }
f, err := os.Open(localPath) if !info.Mode().IsRegular() {
if err != nil { return BlobReference{}, fmt.Errorf("blob source is not a regular file")
return "", err
} }
defer f.Close() reader, writer := io.Pipe()
if _, err := io.Copy(fw, f); err != nil { multipartWriter := multipart.NewWriter(writer)
return "", err writeDone := make(chan error, 1)
} go func() {
w.Close() defer func() {
_ = writer.Close()
}()
part, err := multipartWriter.CreateFormFile("file", filepath.Base(localPath))
if err == nil {
file, openErr := os.Open(localPath)
if openErr != nil {
err = openErr
} else {
_, err = io.Copy(part, file)
closeErr := file.Close()
if err == nil {
err = closeErr
}
}
}
if closeErr := multipartWriter.Close(); err == nil {
err = closeErr
}
if err != nil {
_ = writer.CloseWithError(err)
}
writeDone <- err
}()
req, err := http.NewRequest("POST", c.ServerURL+"/api/v1/blobs/", &b) req, err := http.NewRequest("POST", c.ServerURL+"/api/v1/blobs/", reader)
if err != nil { if err != nil {
return "", err _ = reader.Close()
return BlobReference{}, err
} }
req.Header.Set("Content-Type", w.FormDataContentType()) req.Header.Set("Content-Type", multipartWriter.FormDataContentType())
req.Header.Set("Authorization", "Bearer "+c.bearerToken()) req.Header.Set("Authorization", "Bearer "+c.bearerToken())
resp, err := c.HTTP.Do(req) resp, err := c.HTTP.Do(req)
if err != nil { if err != nil {
return "", err _ = reader.Close()
<-writeDone
return BlobReference{}, err
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode >= http.StatusBadRequest {
var result struct { writeErr := <-writeDone
SHA256 string `json:"sha256"` if writeErr != nil {
Size int `json:"size"` return BlobReference{}, writeErr
}
return BlobReference{}, c.readErrorBody(resp, resp.StatusCode)
} }
var result BlobReference
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err return BlobReference{}, err
} }
return result.SHA256, nil if err := <-writeDone; err != nil {
return BlobReference{}, err
}
if result.Size != info.Size() || !validBlobSHA256(result.SHA256) {
return BlobReference{}, fmt.Errorf("invalid blob upload response")
}
return result, nil
} }
// DownloadBlob downloads a blob by SHA-256 hash. // DownloadBlob downloads a blob by SHA-256 hash.
func (c *Client) DownloadBlob(sha256, destPath string) error { func (c *Client) DownloadBlob(shaHex, destPath string) error {
req, err := http.NewRequest("GET", c.ServerURL+"/api/v1/blobs/"+sha256, nil) return c.DownloadBlobVerified(shaHex, -1, destPath)
}
// DownloadBlobVerified streams to a temporary file, verifies the announced
// hash and size, and only then atomically makes the file visible to the vault.
func (c *Client) DownloadBlobVerified(shaHex string, expectedSize int64, destPath string) error {
if !validBlobSHA256(shaHex) || expectedSize < -1 {
return fmt.Errorf("invalid blob reference")
}
req, err := http.NewRequest("GET", c.ServerURL+"/api/v1/blobs/"+shaHex, nil)
if err != nil { if err != nil {
return err return err
} }
@@ -289,17 +370,65 @@ func (c *Client) DownloadBlob(sha256, destPath string) error {
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != 200 { if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download blob: HTTP %d", resp.StatusCode) return c.readErrorBody(resp, resp.StatusCode)
} }
out, err := os.Create(destPath) if expectedSize >= 0 && resp.ContentLength >= 0 && resp.ContentLength != expectedSize {
return fmt.Errorf("download blob: size mismatch")
}
if err := os.MkdirAll(filepath.Dir(destPath), 0o750); err != nil {
return err
}
out, err := os.CreateTemp(filepath.Dir(destPath), ".verstak-blob-*")
if err != nil { if err != nil {
return err return err
} }
defer out.Close() tmpPath := out.Name()
_, err = io.Copy(out, resp.Body) cleanup := true
return err defer func() {
if cleanup {
_ = os.Remove(tmpPath)
}
}()
hash := sha256.New()
limit := int64(1<<63 - 1)
if expectedSize >= 0 {
limit = expectedSize + 1
}
written, err := io.Copy(io.MultiWriter(out, hash), io.LimitReader(resp.Body, limit))
if err != nil {
_ = out.Close()
return err
}
if expectedSize >= 0 && written != expectedSize {
_ = out.Close()
return fmt.Errorf("download blob: size mismatch")
}
if actual := hex.EncodeToString(hash.Sum(nil)); actual != shaHex {
_ = out.Close()
return fmt.Errorf("download blob: SHA-256 mismatch")
}
if err := out.Sync(); err != nil {
_ = out.Close()
return err
}
if err := out.Close(); err != nil {
return err
}
if err := os.Rename(tmpPath, destPath); err != nil {
return err
}
cleanup = false
return nil
}
func validBlobSHA256(value string) bool {
if len(value) != sha256.Size*2 {
return false
}
_, err := hex.DecodeString(value)
return err == nil
} }
func minInt(a, b int) int { func minInt(a, b int) int {
@@ -430,5 +559,11 @@ func (c *Client) readErrorBody(resp *http.Response, statusCode int) error {
if strings.Contains(lower, "<html") || strings.Contains(lower, "<!doctype") { if strings.Contains(lower, "<html") || strings.Contains(lower, "<!doctype") {
return fmt.Errorf("not a Verstak Sync server (HTTP %d)", statusCode) return fmt.Errorf("not a Verstak Sync server (HTTP %d)", statusCode)
} }
return fmt.Errorf("server error (HTTP %d)", statusCode) var payload struct {
Code string `json:"code"`
}
if err := json.Unmarshal([]byte(body), &payload); err == nil && payload.Code != "" {
return &ServerError{Status: statusCode, Code: payload.Code}
}
return &ServerError{Status: statusCode, Code: "request_failed"}
} }
+97
View File
@@ -1,9 +1,14 @@
package sync package sync
import ( import (
"crypto/sha256"
"encoding/json" "encoding/json"
"fmt"
"io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os"
"path/filepath"
"testing" "testing"
"time" "time"
) )
@@ -119,3 +124,95 @@ func TestPairDeviceSendsVaultID(t *testing.T) {
t.Fatalf("paired vault ID = %q, want vault-123", pairedVaultID) t.Fatalf("paired vault ID = %q, want vault-123", pairedVaultID)
} }
} }
func TestPullPageReadsPaginationMetadata(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/sync/pull" {
http.NotFound(w, r)
return
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"server_sequence": 9, "page_last_sequence": 4, "has_more": true,
"ops": []map[string]interface{}{{"op_id": "op-4", "server_sequence": 4, "device_id": "other", "entity_type": "file", "entity_id": "a.bin", "op_type": "update", "payload_json": `{}`, "created_at": "2026-01-01T00:00:00Z"}},
})
}))
defer server.Close()
client := NewClient(server.URL, "", "device", t.TempDir())
client.DeviceToken = "token"
response, err := client.PullPage(2, 2)
if err != nil {
t.Fatal(err)
}
if response.PageLastSequence != 4 || !response.HasMore || response.ServerSequence != 9 {
t.Fatalf("pagination response = %+v", response)
}
}
func TestUploadBlobStreamsMultipartAndChecksReturnedSize(t *testing.T) {
data := []byte("streamed binary payload")
path := filepath.Join(t.TempDir(), "blob.bin")
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(1024); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
file, _, err := r.FormFile("file")
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
defer file.Close()
received, err := io.ReadAll(file)
if err != nil || string(received) != string(data) {
http.Error(w, "bad upload", http.StatusBadRequest)
return
}
hash := sha256.Sum256(data)
_ = json.NewEncoder(w).Encode(map[string]interface{}{"sha256": fmt.Sprintf("%x", hash[:]), "size": len(data)})
}))
defer server.Close()
client := NewClient(server.URL, "", "device", t.TempDir())
client.DeviceToken = "token"
ref, err := client.UploadBlob(path)
if err != nil {
t.Fatal(err)
}
if ref.Size != int64(len(data)) {
t.Fatalf("uploaded reference = %+v", ref)
}
}
func TestDownloadBlobVerifiesHashAndLeavesNoCorruptDestination(t *testing.T) {
dest := filepath.Join(t.TempDir(), "received.bin")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Length", "7")
_, _ = w.Write([]byte("corrupt"))
}))
defer server.Close()
client := NewClient(server.URL, "", "device", t.TempDir())
client.DeviceToken = "token"
err := client.DownloadBlobVerified("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 7, dest)
if err == nil {
t.Fatal("corrupt blob was accepted")
}
if _, err := os.Stat(dest); !os.IsNotExist(err) {
t.Fatalf("corrupt destination remains: %v", err)
}
}
func TestClientPreservesStableServerErrorCode(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusRequestEntityTooLarge)
_, _ = w.Write([]byte(`{"error":"internal wording must not reach UI","code":"quota_exceeded"}`))
}))
defer server.Close()
client := NewClient(server.URL, "token", "device", t.TempDir())
err := client.post("/api/v1/sync/push", map[string]string{}, nil)
serverErr, ok := err.(*ServerError)
if !ok || serverErr.Code != "quota_exceeded" || serverErr.Status != http.StatusRequestEntityTooLarge {
t.Fatalf("error = %#v, want quota server error", err)
}
}
+189 -18
View File
@@ -5,25 +5,30 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
) )
const ( const (
EntityNode = "node" EntityNode = "node"
EntityNote = "note" EntityNote = "note"
EntityFile = "file" EntityFile = "file"
EntityFolder = "folder" EntityFolder = "folder"
EntityAction = "action" EntityWorkspace = "workspace"
EntityWorklog = "worklog" EntityAction = "action"
EntityWorklog = "worklog"
) )
const ( const (
OpCreate = "create" OpCreate = "create"
OpUpdate = "update" OpUpdate = "update"
OpDelete = "delete" OpDelete = "delete"
OpMove = "move" OpMove = "move"
OpRename = "rename"
OpTrash = "trash"
OpRestore = "restore"
) )
// Op represents a sync operation. // Op represents a sync operation.
@@ -45,11 +50,14 @@ type Op struct {
// syncState persists connection state to JSON file. // syncState persists connection state to JSON file.
type syncState struct { type syncState struct {
ServerURL string `json:"server_url"` ServerURL string `json:"server_url"`
APIKey string `json:"api_key"` APIKey string `json:"api_key"`
DeviceID string `json:"device_id"` DeviceID string `json:"device_id"`
LastPullSeq int `json:"last_pull_seq"` LastPullSeq int `json:"last_pull_seq"`
LastSyncAt string `json:"last_sync_at"` LastSyncAt string `json:"last_sync_at"`
BootstrapComplete bool `json:"bootstrap_complete"`
LastWarning string `json:"last_warning"`
RemoteVaultID string `json:"remote_vault_id"`
} }
// Service records and manages sync operations using JSON file storage. // Service records and manages sync operations using JSON file storage.
@@ -81,6 +89,14 @@ func (s *Service) statePath() string {
return filepath.Join(s.syncDir(), "state.json") return filepath.Join(s.syncDir(), "state.json")
} }
func (s *Service) snapshotPath() string {
return filepath.Join(s.syncDir(), "snapshot.json")
}
func (s *Service) scanJournalPath() string {
return filepath.Join(s.syncDir(), "scan-journal.json")
}
func (s *Service) ensureDir() error { func (s *Service) ensureDir() error {
return os.MkdirAll(s.syncDir(), 0o755) return os.MkdirAll(s.syncDir(), 0o755)
} }
@@ -113,11 +129,33 @@ func (s *Service) RecordOp(entityType, entityID, opType string, payload interfac
CreatedAt: now, CreatedAt: now,
} }
return s.recordOps([]Op{op})
}
// recordOps is idempotent by op ID so a scanner recovery journal can safely
// resume after a crash between recording operations and replacing its snapshot.
func (s *Service) recordOps(newOps []Op) error {
if err := s.ensureDir(); err != nil {
return err
}
ops, err := s.loadOps() ops, err := s.loadOps()
if err != nil { if err != nil {
return err return err
} }
ops = append(ops, op) existing := make(map[string]bool, len(ops))
for _, op := range ops {
existing[op.OpID] = true
}
for _, op := range newOps {
if op.OpID == "" || existing[op.OpID] {
continue
}
if op.ID == "" {
op.ID = op.OpID
}
ops = append(ops, op)
existing[op.OpID] = true
}
return s.saveOps(ops) return s.saveOps(ops)
} }
@@ -160,6 +198,42 @@ func (s *Service) GetUnpushedOps() ([]Op, error) {
return unpushed, nil return unpushed, nil
} }
// HasUnpushedPath reports whether a local operation still owns a path (or one
// of its descendants). Pull uses it to turn an incoming overwrite/delete into
// a visible conflict instead of silently replacing a local external edit.
func (s *Service) HasUnpushedPath(path string) (bool, error) {
ops, err := s.GetUnpushedOps()
if err != nil {
return false, err
}
for _, op := range ops {
if syncPathsOverlap(path, op.EntityID) {
return true, nil
}
var payload struct {
Path string `json:"path"`
FromPath string `json:"fromPath"`
ToPath string `json:"toPath"`
}
if op.PayloadJSON == "" || json.Unmarshal([]byte(op.PayloadJSON), &payload) != nil {
continue
}
if syncPathsOverlap(path, payload.Path) || syncPathsOverlap(path, payload.FromPath) || syncPathsOverlap(path, payload.ToPath) {
return true, nil
}
}
return false, nil
}
func syncPathsOverlap(left, right string) bool {
left = strings.Trim(left, "/")
right = strings.Trim(right, "/")
if left == "" || right == "" {
return false
}
return left == right || strings.HasPrefix(left, right+"/") || strings.HasPrefix(right, left+"/")
}
// MarkPushed marks ops as pushed to server. // MarkPushed marks ops as pushed to server.
func (s *Service) MarkPushed(opIDs []string) error { func (s *Service) MarkPushed(opIDs []string) error {
ops, err := s.loadOps() ops, err := s.loadOps()
@@ -244,6 +318,66 @@ func (s *Service) SetLastSyncAt(t string) error {
return s.saveState(st) return s.saveState(st)
} }
// BootstrapComplete reports whether the initial pull/reconcile/bootstrap cycle
// finished successfully for this vault connection.
func (s *Service) BootstrapComplete() (bool, error) {
st, err := s.loadState()
if err != nil {
return false, err
}
return st.BootstrapComplete, nil
}
// SetBootstrapComplete marks the initial reconciliation as complete only after
// all remote operations were applied and the local initial snapshot was queued.
func (s *Service) SetBootstrapComplete(done bool) error {
st, err := s.loadState()
if err != nil {
return err
}
st.BootstrapComplete = done
return s.saveState(st)
}
// LastWarning returns the persistent scanner warning shown by sync status.
func (s *Service) LastWarning() (string, error) {
st, err := s.loadState()
if err != nil {
return "", err
}
return st.LastWarning, nil
}
// SetLastWarning persists an unresolved scanner condition. An empty string
// clears the warning once a later complete scan no longer reports it.
func (s *Service) SetLastWarning(message string) error {
st, err := s.loadState()
if err != nil {
return err
}
st.LastWarning = message
return s.saveState(st)
}
// RemoteVaultID returns the optional target vault chosen while pairing a new
// local vault for restore. Empty means this vault's own durable ID was used.
func (s *Service) RemoteVaultID() (string, error) {
st, err := s.loadState()
if err != nil {
return "", err
}
return st.RemoteVaultID, nil
}
func (s *Service) SetRemoteVaultID(vaultID string) error {
st, err := s.loadState()
if err != nil {
return err
}
st.RemoteVaultID = vaultID
return s.saveState(st)
}
// GetDeviceID returns the device ID used by this service. // GetDeviceID returns the device ID used by this service.
func (s *Service) GetDeviceID() string { func (s *Service) GetDeviceID() string {
return s.deviceID return s.deviceID
@@ -288,7 +422,7 @@ func (s *Service) saveOps(ops []Op) error {
if err != nil { if err != nil {
return fmt.Errorf("marshal ops: %w", err) return fmt.Errorf("marshal ops: %w", err)
} }
return os.WriteFile(s.opsPath(), data, 0o644) return atomicWriteFile(s.opsPath(), data, 0o600)
} }
func (s *Service) loadState() (*syncState, error) { func (s *Service) loadState() (*syncState, error) {
@@ -311,5 +445,42 @@ func (s *Service) saveState(st *syncState) error {
if err != nil { if err != nil {
return fmt.Errorf("marshal state: %w", err) return fmt.Errorf("marshal state: %w", err)
} }
return os.WriteFile(s.statePath(), data, 0o644) return atomicWriteFile(s.statePath(), data, 0o600)
}
func atomicWriteFile(path string, data []byte, perm os.FileMode) error {
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(path), ".verstak-sync-*")
if err != nil {
return err
}
tmpPath := tmp.Name()
cleanup := true
defer func() {
if cleanup {
_ = os.Remove(tmpPath)
}
}()
if err := tmp.Chmod(perm); err != nil {
_ = tmp.Close()
return err
}
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Rename(tmpPath, path); err != nil {
return err
}
cleanup = false
return nil
} }
+854
View File
@@ -0,0 +1,854 @@
package sync
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"time"
"unicode"
"unicode/utf8"
"github.com/google/uuid"
corefiles "github.com/verstak/verstak-desktop/internal/core/files"
)
const snapshotVersion = 1
// maxOperationFileBytes is an explicit desktop-side safety ceiling. Binary
// content is streamed through the Blob API rather than embedded in operations,
// so it is intentionally higher than the plugin Files API read limit.
const maxOperationFileBytes int64 = 256 * 1024 * 1024
// BlobCachePath is core-private durable staging for a local operation's
// immutable binary content. It remains excluded from ordinary file sync.
func BlobCachePath(vaultRoot, hash string) string {
return filepath.Join(vaultRoot, ".verstak", "sync", "blobs", hash)
}
// Snapshot is the durable local view of the synchronizable part of a vault.
// Its entries are only files and folders whose latest state was successfully
// represented by an operation or intentionally accepted as an initial baseline.
type Snapshot struct {
Version int `json:"version"`
Entries map[string]SnapshotEntry `json:"entries"`
Workspaces map[string]WorkspaceSnapshot `json:"workspaces,omitempty"`
TrashedWorkspaces map[string]WorkspaceSnapshot `json:"trashedWorkspaces,omitempty"`
WorkspacesInitialized bool `json:"workspacesInitialized,omitempty"`
Unresolved map[string]string `json:"unresolved,omitempty"`
}
// SnapshotEntry stores only stable filesystem facts needed for reconciliation.
type SnapshotEntry struct {
Path string `json:"path"`
Type string `json:"type"`
Size int64 `json:"size"`
ModifiedAt string `json:"modifiedAt"`
Hash string `json:"hash,omitempty"`
}
// WorkspaceSnapshot keeps the core-owned identity and creation metadata of a
// top-level workspace. The marker itself remains excluded from normal file
// sync and is never exposed through the Files API.
type WorkspaceSnapshot struct {
WorkspaceID string `json:"workspaceId"`
Path string `json:"path"`
Metadata json.RawMessage `json:"metadata,omitempty"`
Entries map[string]SnapshotEntry `json:"entries,omitempty"`
}
type scanJournal struct {
Snapshot Snapshot `json:"snapshot"`
Ops []Op `json:"ops"`
}
type scannedVault struct {
Entries map[string]SnapshotEntry
Workspaces map[string]WorkspaceSnapshot
Unresolved map[string]string
}
// LoadSnapshot returns the current durable scanner snapshot. A missing
// snapshot is represented by an empty snapshot, which is useful to callers
// that only need to inspect it.
func (s *Service) LoadSnapshot() (Snapshot, error) {
snapshot, exists, err := s.loadSnapshot()
if err != nil {
return Snapshot{}, err
}
if !exists {
return newSnapshot(), nil
}
return snapshot, nil
}
// ScanAndRecord scans the whole synchronizable vault and records exactly the
// detected local changes. On its first run it writes a baseline and deliberately
// produces no operations; bootstrap decides what can safely be published.
func (s *Service) ScanAndRecord() ([]string, error) {
if err := s.recoverScanJournal(); err != nil {
return nil, err
}
previous, exists, err := s.loadSnapshot()
if err != nil {
return nil, err
}
current, warnings, err := scanVault(s.vaultRoot, previous)
if err != nil {
return nil, err
}
next := snapshotFromScan(current, previous, exists)
if !exists {
if err := s.saveSnapshot(next); err != nil {
return nil, err
}
return warnings, nil
}
ops, next, err := diffSnapshots(previous, next, s.deviceID, s.vaultRoot)
if err != nil {
return nil, err
}
if len(ops) == 0 {
if err := s.saveSnapshot(next); err != nil {
return nil, err
}
return warnings, nil
}
if err := s.commitScanTransaction(next, ops); err != nil {
return nil, err
}
return warnings, nil
}
// RecordBootstrapOps records creates for a pre-pull local snapshot. It is used
// only after a successful initial pull, so an empty new vault never turns into
// remote delete operations. Callers may pass the snapshot captured before the
// pull; remote-only entries added during reconciliation are therefore not
// reflected back to the server as local creates.
func (s *Service) RecordBootstrapOps(initial Snapshot) error {
if err := s.recoverScanJournal(); err != nil {
return err
}
current, exists, err := s.loadSnapshot()
if err != nil {
return err
}
if !exists {
return fmt.Errorf("bootstrap requires an initial snapshot")
}
ops, _, err := diffSnapshots(newSnapshot(), initial, s.deviceID, s.vaultRoot)
if err != nil {
return err
}
existing, err := s.GetUnpushedOps()
if err != nil {
return err
}
existingCreates := make(map[string]bool, len(existing))
for _, op := range existing {
if op.OpType == OpCreate {
existingCreates[op.EntityType+"\x00"+op.EntityID] = true
}
}
filtered := ops[:0]
for _, op := range ops {
if op.OpType == OpCreate && existingCreates[op.EntityType+"\x00"+op.EntityID] {
continue
}
filtered = append(filtered, op)
}
ops = filtered
if len(ops) == 0 {
return nil
}
return s.commitScanTransaction(current, ops)
}
// RebaseSnapshot accepts filesystem changes that were applied from a remote
// operation without producing any outgoing operation for them.
func (s *Service) RebaseSnapshot() ([]string, error) {
if err := s.recoverScanJournal(); err != nil {
return nil, err
}
previous, exists, err := s.loadSnapshot()
if err != nil {
return nil, err
}
current, warnings, err := scanVault(s.vaultRoot, previous)
if err != nil {
return nil, err
}
next := snapshotFromScan(current, previous, exists)
acceptWorkspaceLifecycle(previous, &next)
if err := s.saveSnapshot(next); err != nil {
return nil, err
}
return warnings, nil
}
func newSnapshot() Snapshot {
return Snapshot{
Version: snapshotVersion,
Entries: make(map[string]SnapshotEntry),
Workspaces: make(map[string]WorkspaceSnapshot),
TrashedWorkspaces: make(map[string]WorkspaceSnapshot),
WorkspacesInitialized: true,
Unresolved: make(map[string]string),
}
}
func (s *Service) loadSnapshot() (Snapshot, bool, error) {
data, err := os.ReadFile(s.snapshotPath())
if err != nil {
if os.IsNotExist(err) {
return Snapshot{}, false, nil
}
return Snapshot{}, false, fmt.Errorf("read snapshot: %w", err)
}
var snapshot Snapshot
if err := json.Unmarshal(data, &snapshot); err != nil {
return Snapshot{}, false, fmt.Errorf("parse snapshot: %w", err)
}
if snapshot.Version != snapshotVersion {
return Snapshot{}, false, fmt.Errorf("unsupported snapshot version: %d", snapshot.Version)
}
if snapshot.Entries == nil {
snapshot.Entries = make(map[string]SnapshotEntry)
}
if snapshot.Workspaces == nil {
snapshot.Workspaces = make(map[string]WorkspaceSnapshot)
}
if snapshot.TrashedWorkspaces == nil {
snapshot.TrashedWorkspaces = make(map[string]WorkspaceSnapshot)
}
if snapshot.Unresolved == nil {
snapshot.Unresolved = make(map[string]string)
}
return snapshot, true, nil
}
func (s *Service) saveSnapshot(snapshot Snapshot) error {
data, err := json.MarshalIndent(snapshot, "", " ")
if err != nil {
return fmt.Errorf("marshal snapshot: %w", err)
}
return atomicWriteFile(s.snapshotPath(), data, 0o600)
}
func (s *Service) loadScanJournal() (scanJournal, bool, error) {
data, err := os.ReadFile(s.scanJournalPath())
if err != nil {
if os.IsNotExist(err) {
return scanJournal{}, false, nil
}
return scanJournal{}, false, fmt.Errorf("read scan journal: %w", err)
}
var journal scanJournal
if err := json.Unmarshal(data, &journal); err != nil {
return scanJournal{}, false, fmt.Errorf("parse scan journal: %w", err)
}
return journal, true, nil
}
func (s *Service) saveScanJournal(journal scanJournal) error {
data, err := json.MarshalIndent(journal, "", " ")
if err != nil {
return fmt.Errorf("marshal scan journal: %w", err)
}
return atomicWriteFile(s.scanJournalPath(), data, 0o600)
}
func (s *Service) recoverScanJournal() error {
journal, exists, err := s.loadScanJournal()
if err != nil || !exists {
return err
}
if err := s.recordOps(journal.Ops); err != nil {
return err
}
if err := s.saveSnapshot(journal.Snapshot); err != nil {
return err
}
if err := os.Remove(s.scanJournalPath()); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func (s *Service) commitScanTransaction(snapshot Snapshot, ops []Op) error {
journal := scanJournal{Snapshot: snapshot, Ops: ops}
if err := s.saveScanJournal(journal); err != nil {
return err
}
if err := s.recordOps(ops); err != nil {
return err
}
if err := s.saveSnapshot(snapshot); err != nil {
return err
}
if err := os.Remove(s.scanJournalPath()); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func scanVault(root string, previous Snapshot) (scannedVault, []string, error) {
result := scannedVault{
Entries: make(map[string]SnapshotEntry),
Workspaces: make(map[string]WorkspaceSnapshot),
Unresolved: make(map[string]string),
}
var warnings []string
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if path == root {
return nil
}
rel, err := filepath.Rel(root, path)
if err != nil {
return err
}
rel = filepath.ToSlash(rel)
if excludedFromSync(rel) {
if entry.IsDir() {
return filepath.SkipDir
}
return nil
}
if entry.Type()&os.ModeSymlink != 0 {
if entry.IsDir() {
return filepath.SkipDir
}
return nil
}
info, err := entry.Info()
if err != nil {
return err
}
if info.IsDir() {
result.Entries[rel] = SnapshotEntry{
Path: rel,
Type: EntityFolder,
ModifiedAt: info.ModTime().UTC().Format(time.RFC3339Nano),
}
return nil
}
if !info.Mode().IsRegular() {
return nil
}
if info.Size() > maxOperationFileBytes {
message := fmt.Sprintf("file-too-large: %s (%d bytes exceeds %d bytes)", rel, info.Size(), maxOperationFileBytes)
result.Unresolved[rel] = message
warnings = append(warnings, message)
return nil
}
hash, err := sha256File(path)
if err != nil {
return fmt.Errorf("hash %s: %w", rel, err)
}
result.Entries[rel] = SnapshotEntry{
Path: rel,
Type: EntityFile,
Size: info.Size(),
ModifiedAt: info.ModTime().UTC().Format(time.RFC3339Nano),
Hash: hash,
}
return nil
})
if err != nil {
return scannedVault{}, nil, err
}
workspaces, workspaceWarnings, err := scanWorkspaceSnapshots(root, previous.Workspaces)
if err != nil {
return scannedVault{}, nil, err
}
for workspaceID, workspace := range workspaces {
result.Workspaces[workspaceID] = workspace
}
for _, warning := range workspaceWarnings {
warnings = append(warnings, warning)
if strings.HasPrefix(warning, "duplicate-workspace-id: ") {
path := strings.TrimPrefix(warning, "duplicate-workspace-id: ")
result.Unresolved[path] = warning
removeEntriesUnder(result.Entries, path)
}
}
sort.Strings(warnings)
return result, warnings, nil
}
func scanWorkspaceSnapshots(root string, preferred map[string]WorkspaceSnapshot) (map[string]WorkspaceSnapshot, []string, error) {
entries, err := os.ReadDir(root)
if err != nil {
return nil, nil, err
}
candidates := make(map[string][]WorkspaceSnapshot)
var warnings []string
for _, entry := range entries {
if !entry.IsDir() || entry.Type()&os.ModeSymlink != 0 || strings.EqualFold(entry.Name(), ".verstak") {
continue
}
path := filepath.Join(root, entry.Name(), ".verstak", "workspace.json")
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
continue
}
return nil, nil, fmt.Errorf("read workspace marker %s: %w", entry.Name(), err)
}
var marker struct {
WorkspaceID string `json:"workspaceId"`
}
if err := json.Unmarshal(data, &marker); err != nil {
warnings = append(warnings, fmt.Sprintf("invalid-workspace-id: %s", entry.Name()))
continue
}
if _, err := uuid.Parse(marker.WorkspaceID); err != nil {
warnings = append(warnings, fmt.Sprintf("invalid-workspace-id: %s", entry.Name()))
continue
}
metadata, err := readWorkspaceMetadataSnapshot(root, entry.Name())
if err != nil {
return nil, nil, err
}
candidates[marker.WorkspaceID] = append(candidates[marker.WorkspaceID], WorkspaceSnapshot{
WorkspaceID: marker.WorkspaceID,
Path: entry.Name(),
Metadata: metadata,
})
}
workspaces := make(map[string]WorkspaceSnapshot, len(candidates))
for workspaceID, choices := range candidates {
sort.Slice(choices, func(i, j int) bool { return choices[i].Path < choices[j].Path })
selected := choices[0]
if old, ok := preferred[workspaceID]; ok {
for _, choice := range choices {
if choice.Path == old.Path {
selected = choice
break
}
}
}
workspaces[workspaceID] = selected
for _, choice := range choices {
if choice.Path != selected.Path {
warnings = append(warnings, "duplicate-workspace-id: "+choice.Path)
}
}
}
return workspaces, warnings, nil
}
func readWorkspaceMetadataSnapshot(root, name string) (json.RawMessage, error) {
path := filepath.Join(root, ".verstak", "workspaces", name, "metadata.json")
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("read workspace metadata %s: %w", name, err)
}
if !json.Valid(data) {
return nil, fmt.Errorf("invalid workspace metadata: %s", name)
}
return json.RawMessage(append([]byte(nil), data...)), nil
}
func excludedFromSync(rel string) bool {
rel = filepath.ToSlash(rel)
for _, segment := range strings.Split(rel, "/") {
if strings.EqualFold(segment, ".verstak") {
return true
}
}
base := filepath.Base(rel)
return strings.HasPrefix(base, ".verstak-write-") || strings.HasSuffix(base, ".tmp") || strings.HasSuffix(base, ".swp") || strings.HasSuffix(base, "~")
}
func sha256File(path string) (string, error) {
file, err := os.Open(path)
if err != nil {
return "", err
}
defer file.Close()
hash := sha256.New()
if _, err := io.Copy(hash, file); err != nil {
return "", err
}
return hex.EncodeToString(hash.Sum(nil)), nil
}
func snapshotFromScan(current scannedVault, previous Snapshot, previousExists bool) Snapshot {
next := newSnapshot()
for path, entry := range current.Entries {
next.Entries[path] = entry
}
for workspaceID, workspace := range current.Workspaces {
next.Workspaces[workspaceID] = workspace
}
for workspaceID, workspace := range previous.TrashedWorkspaces {
next.TrashedWorkspaces[workspaceID] = workspace
}
for path, message := range current.Unresolved {
next.Unresolved[path] = message
copyEntriesUnder(next.Entries, previous.Entries, path)
}
if !previousExists {
return next
}
for path, message := range previous.Unresolved {
if _, supported := current.Entries[path]; supported {
continue
}
if _, stillUnsupported := current.Unresolved[path]; stillUnsupported {
continue
}
next.Unresolved[path] = "unresolved sync file disappeared before it could be synchronized: " + message
copyEntriesUnder(next.Entries, previous.Entries, path)
}
return next
}
func diffSnapshots(previous, next Snapshot, deviceID, vaultRoot string) ([]Op, Snapshot, error) {
workspaceOps, err := diffWorkspaceSnapshots(&previous, &next, deviceID)
if err != nil {
return nil, next, err
}
var createsOrUpdates []Op
var deletes []Op
for path, entry := range next.Entries {
old, existed := previous.Entries[path]
if existed && entriesEqual(old, entry) {
continue
}
if unresolvedPath(next.Unresolved, path) {
continue
}
opType := OpCreate
if existed {
opType = OpUpdate
}
payload, err := payloadForEntry(vaultRoot, entry)
if err != nil {
return nil, next, err
}
createsOrUpdates = append(createsOrUpdates, newSnapshotOp(deviceID, entry.Type, path, opType, payload))
}
for path, old := range previous.Entries {
if _, exists := next.Entries[path]; exists {
continue
}
if unresolvedPath(previous.Unresolved, path) {
continue
}
deletes = append(deletes, newSnapshotOp(deviceID, old.Type, path, OpDelete, map[string]string{"path": path}))
}
sort.Slice(createsOrUpdates, func(i, j int) bool {
left, right := createsOrUpdates[i], createsOrUpdates[j]
leftDepth, rightDepth := pathDepth(left.EntityID), pathDepth(right.EntityID)
if leftDepth != rightDepth {
return leftDepth < rightDepth
}
if left.EntityType != right.EntityType {
return left.EntityType == EntityFolder
}
return left.EntityID < right.EntityID
})
sort.Slice(deletes, func(i, j int) bool {
left, right := deletes[i], deletes[j]
leftDepth, rightDepth := pathDepth(left.EntityID), pathDepth(right.EntityID)
if leftDepth != rightDepth {
return leftDepth > rightDepth
}
if left.EntityType != right.EntityType {
return left.EntityType == EntityFile
}
return left.EntityID < right.EntityID
})
return append(workspaceOps, append(createsOrUpdates, deletes...)...), next, nil
}
func diffWorkspaceSnapshots(previous, next *Snapshot, deviceID string) ([]Op, error) {
var ops []Op
if !previous.WorkspacesInitialized {
return ops, nil
}
for workspaceID, oldWorkspace := range previous.Workspaces {
currentWorkspace, active := next.Workspaces[workspaceID]
if active {
if currentWorkspace.Path != oldWorkspace.Path {
ops = append(ops, newWorkspaceSnapshotOp(deviceID, workspaceID, OpRename, currentWorkspace, oldWorkspace.Path))
remapEntriesPrefix(previous.Entries, oldWorkspace.Path, currentWorkspace.Path)
}
continue
}
ops = append(ops, newWorkspaceSnapshotOp(deviceID, workspaceID, OpTrash, oldWorkspace, ""))
oldWorkspace.Entries = entriesUnder(previous.Entries, oldWorkspace.Path)
removeEntriesUnder(previous.Entries, oldWorkspace.Path)
next.TrashedWorkspaces[workspaceID] = oldWorkspace
}
for workspaceID, currentWorkspace := range next.Workspaces {
if _, alreadyActive := previous.Workspaces[workspaceID]; alreadyActive {
continue
}
if trashedWorkspace, wasTrashed := previous.TrashedWorkspaces[workspaceID]; wasTrashed {
ops = append(ops, newWorkspaceSnapshotOp(deviceID, workspaceID, OpRestore, currentWorkspace, ""))
copyRemappedEntries(previous.Entries, trashedWorkspace.Entries, trashedWorkspace.Path, currentWorkspace.Path)
delete(next.TrashedWorkspaces, workspaceID)
continue
}
ops = append(ops, newWorkspaceSnapshotOp(deviceID, workspaceID, OpCreate, currentWorkspace, ""))
delete(next.Entries, currentWorkspace.Path)
}
sort.Slice(ops, func(i, j int) bool {
if ops[i].OpType != ops[j].OpType {
return workspaceOpOrder(ops[i].OpType) < workspaceOpOrder(ops[j].OpType)
}
return ops[i].EntityID < ops[j].EntityID
})
return ops, nil
}
func workspaceOpOrder(opType string) int {
switch opType {
case OpCreate:
return 0
case OpRename:
return 1
case OpRestore:
return 2
case OpTrash:
return 3
default:
return 4
}
}
type snapshotWorkspacePayload struct {
WorkspaceID string `json:"workspaceId"`
Path string `json:"path"`
PreviousPath string `json:"previousPath,omitempty"`
Name string `json:"name"`
Metadata json.RawMessage `json:"metadata,omitempty"`
}
func newWorkspaceSnapshotOp(deviceID, workspaceID, opType string, workspace WorkspaceSnapshot, previousPath string) Op {
payload, _ := json.Marshal(snapshotWorkspacePayload{
WorkspaceID: workspaceID,
Path: workspace.Path,
PreviousPath: previousPath,
Name: workspace.Path,
Metadata: workspace.Metadata,
})
return newSnapshotOp(deviceID, EntityWorkspace, workspaceID, opType, json.RawMessage(payload))
}
func acceptWorkspaceLifecycle(previous Snapshot, next *Snapshot) {
for workspaceID, oldWorkspace := range previous.Workspaces {
if _, stillActive := next.Workspaces[workspaceID]; !stillActive {
oldWorkspace.Entries = entriesUnder(previous.Entries, oldWorkspace.Path)
next.TrashedWorkspaces[workspaceID] = oldWorkspace
}
}
for workspaceID := range next.Workspaces {
delete(next.TrashedWorkspaces, workspaceID)
}
}
func unresolvedPath(unresolved map[string]string, path string) bool {
for unresolvedPath := range unresolved {
if path == unresolvedPath || strings.HasPrefix(path, unresolvedPath+"/") {
return true
}
}
return false
}
func copyEntriesUnder(destination, source map[string]SnapshotEntry, root string) {
for path, entry := range source {
if path == root || strings.HasPrefix(path, root+"/") {
destination[path] = entry
}
}
}
func removeEntriesUnder(entries map[string]SnapshotEntry, root string) {
for path := range entries {
if path == root || strings.HasPrefix(path, root+"/") {
delete(entries, path)
}
}
}
func remapEntriesPrefix(entries map[string]SnapshotEntry, oldPrefix, newPrefix string) {
type remappedEntry struct {
oldPath string
entry SnapshotEntry
}
var remapped []remappedEntry
for path, entry := range entries {
if path != oldPrefix && !strings.HasPrefix(path, oldPrefix+"/") {
continue
}
suffix := strings.TrimPrefix(path, oldPrefix)
entry.Path = newPrefix + suffix
remapped = append(remapped, remappedEntry{oldPath: path, entry: entry})
}
for _, item := range remapped {
delete(entries, item.oldPath)
entries[item.entry.Path] = item.entry
}
}
func entriesUnder(entries map[string]SnapshotEntry, root string) map[string]SnapshotEntry {
result := make(map[string]SnapshotEntry)
copyEntriesUnder(result, entries, root)
return result
}
func copyRemappedEntries(destination, source map[string]SnapshotEntry, oldPrefix, newPrefix string) {
for path, entry := range source {
if path != oldPrefix && !strings.HasPrefix(path, oldPrefix+"/") {
continue
}
suffix := strings.TrimPrefix(path, oldPrefix)
entry.Path = newPrefix + suffix
destination[entry.Path] = entry
}
}
func entriesEqual(left, right SnapshotEntry) bool {
return left.Type == right.Type && left.Size == right.Size && left.Hash == right.Hash
}
func payloadForEntry(vaultRoot string, entry SnapshotEntry) (map[string]interface{}, error) {
payload := map[string]interface{}{"path": entry.Path, "contentHash": entry.Hash}
if entry.Type == EntityFolder {
return payload, nil
}
path := filepath.Join(vaultRoot, filepath.FromSlash(entry.Path))
if hash, err := sha256File(path); err != nil {
return nil, err
} else if hash != entry.Hash {
return nil, fmt.Errorf("file changed during scan: %s", entry.Path)
}
if entry.Size > corefiles.MaxTextFileBytes {
if err := cacheBlob(path, BlobCachePath(vaultRoot, entry.Hash), entry.Hash); err != nil {
return nil, err
}
payload["blob"] = map[string]interface{}{"sha256": entry.Hash, "size": entry.Size}
return payload, nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
if isSyncText(data) {
payload["content"] = string(data)
return payload, nil
}
if err := cacheBlob(path, BlobCachePath(vaultRoot, entry.Hash), entry.Hash); err != nil {
return nil, err
}
payload["blob"] = map[string]interface{}{"sha256": entry.Hash, "size": entry.Size}
return payload, nil
}
func cacheBlob(source, destination, wantHash string) error {
if info, err := os.Lstat(destination); err == nil {
if !info.Mode().IsRegular() {
return fmt.Errorf("blob cache target is not a regular file")
}
if hash, err := sha256File(destination); err == nil && hash == wantHash {
return nil
}
if err := os.Remove(destination); err != nil {
return err
}
} else if !os.IsNotExist(err) {
return err
}
if err := os.MkdirAll(filepath.Dir(destination), 0o750); err != nil {
return err
}
in, err := os.Open(source)
if err != nil {
return err
}
defer in.Close()
tmp, err := os.CreateTemp(filepath.Dir(destination), ".blob-*")
if err != nil {
return err
}
tmpPath := tmp.Name()
cleanup := true
defer func() {
if cleanup {
_ = os.Remove(tmpPath)
}
}()
hash := sha256.New()
if _, err := io.Copy(io.MultiWriter(tmp, hash), in); err != nil {
_ = tmp.Close()
return err
}
if actual := hex.EncodeToString(hash.Sum(nil)); actual != wantHash {
_ = tmp.Close()
return fmt.Errorf("file changed during blob staging")
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Rename(tmpPath, destination); err != nil {
return err
}
cleanup = false
return nil
}
func newSnapshotOp(deviceID, entityType, entityID, opType string, payload interface{}) Op {
data, _ := json.Marshal(payload)
id := uuid.NewString()
return Op{
ID: id,
OpID: id,
DeviceID: deviceID,
EntityType: entityType,
EntityID: entityID,
OpType: opType,
PayloadJSON: string(data),
CreatedAt: time.Now().UTC().Format(time.RFC3339Nano),
}
}
func pathDepth(path string) int {
if path == "" {
return 0
}
return strings.Count(path, "/") + 1
}
func isSyncText(data []byte) bool {
if !utf8.Valid(data) {
return false
}
for _, r := range string(data) {
if unicode.IsControl(r) && r != '\n' && r != '\r' && r != '\t' {
return false
}
}
return true
}
+445
View File
@@ -0,0 +1,445 @@
package sync
import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/google/uuid"
corefiles "github.com/verstak/verstak-desktop/internal/core/files"
)
func TestScanAndRecordTracksExternalWorkspaceLifecycleByIdentity(t *testing.T) {
root := t.TempDir()
workspaceID := uuid.NewString()
createSnapshotWorkspace(t, root, "Project", workspaceID)
if err := os.Mkdir(filepath.Join(root, "Project", "Files"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "Project", "Files", "note.txt"), []byte("one"), 0o644); err != nil {
t.Fatal(err)
}
service := NewService(root, "device-a")
if _, err := service.ScanAndRecord(); err != nil {
t.Fatalf("baseline: %v", err)
}
if err := os.Rename(filepath.Join(root, "Project"), filepath.Join(root, "Renamed")); err != nil {
t.Fatal(err)
}
if _, err := service.ScanAndRecord(); err != nil {
t.Fatalf("scan rename: %v", err)
}
assertWorkspaceSnapshotOp(t, unpushedOps(t, service), 0, OpRename, workspaceID, "Renamed", "Project")
if _, err := service.ScanAndRecord(); err != nil {
t.Fatalf("unchanged renamed scan: %v", err)
}
if got := len(unpushedOps(t, service)); got != 1 {
t.Fatalf("unchanged rename produced %d operations, want 1", got)
}
trashPath := filepath.Join(root, ".verstak", "trash", "workspaces", "external-trash", "Renamed")
if err := os.MkdirAll(filepath.Dir(trashPath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.Rename(filepath.Join(root, "Renamed"), trashPath); err != nil {
t.Fatal(err)
}
if _, err := service.ScanAndRecord(); err != nil {
t.Fatalf("scan trash: %v", err)
}
assertWorkspaceSnapshotOp(t, unpushedOps(t, service), 1, OpTrash, workspaceID, "Renamed", "")
if err := os.Rename(trashPath, filepath.Join(root, "Restored")); err != nil {
t.Fatal(err)
}
if _, err := service.ScanAndRecord(); err != nil {
t.Fatalf("scan restore: %v", err)
}
assertWorkspaceSnapshotOp(t, unpushedOps(t, service), 2, OpRestore, workspaceID, "Restored", "")
createSnapshotWorkspace(t, root, "Copied", workspaceID)
warnings, err := service.ScanAndRecord()
if err != nil {
t.Fatalf("scan duplicate identity: %v", err)
}
if len(warnings) != 1 || !strings.Contains(warnings[0], "duplicate-workspace-id: Copied") {
t.Fatalf("duplicate identity warnings = %v", warnings)
}
if got := len(unpushedOps(t, service)); got != 3 {
t.Fatalf("duplicate identity created operations: %d, want 3", got)
}
}
func createSnapshotWorkspace(t *testing.T, root, name, workspaceID string) {
t.Helper()
if err := os.MkdirAll(filepath.Join(root, name, ".verstak"), 0o755); err != nil {
t.Fatal(err)
}
marker, err := json.Marshal(map[string]string{"workspaceId": workspaceID})
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, name, ".verstak", "workspace.json"), marker, 0o600); err != nil {
t.Fatal(err)
}
metadataPath := filepath.Join(root, ".verstak", "workspaces", name, "metadata.json")
if err := os.MkdirAll(filepath.Dir(metadataPath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(metadataPath, []byte(`{"workspaceId":"`+workspaceID+`","workspaceName":"`+name+`"}`), 0o600); err != nil {
t.Fatal(err)
}
}
func assertWorkspaceSnapshotOp(t *testing.T, ops []Op, index int, opType, workspaceID, path, previousPath string) {
t.Helper()
if len(ops) <= index {
t.Fatalf("operations = %#v, want index %d", ops, index)
}
op := ops[index]
if op.EntityType != EntityWorkspace || op.EntityID != workspaceID || op.OpType != opType {
t.Fatalf("workspace operation = %+v", op)
}
var payload snapshotWorkspacePayload
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
t.Fatalf("decode workspace payload: %v", err)
}
if payload.Path != path || payload.PreviousPath != previousPath || payload.WorkspaceID != workspaceID {
t.Fatalf("workspace payload = %+v", payload)
}
}
func TestScanAndRecordBaselinesThenRecordsExternalChanges(t *testing.T) {
root := t.TempDir()
service := NewService(root, "device-a")
warnings, err := service.ScanAndRecord()
if err != nil {
t.Fatalf("initial ScanAndRecord: %v", err)
}
if len(warnings) != 0 {
t.Fatalf("initial warnings = %v, want none", warnings)
}
assertUnpushedCount(t, service, 0)
if err := os.Mkdir(filepath.Join(root, "Docs"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "Docs", "note.txt"), []byte("one"), 0o644); err != nil {
t.Fatal(err)
}
warnings, err = service.ScanAndRecord()
if err != nil {
t.Fatalf("scan create: %v", err)
}
if len(warnings) != 0 {
t.Fatalf("create warnings = %v", warnings)
}
ops := unpushedOps(t, service)
if len(ops) != 2 {
t.Fatalf("create ops = %#v, want folder and file", ops)
}
if ops[0].EntityType != EntityFolder || ops[0].EntityID != "Docs" || ops[0].OpType != OpCreate {
t.Fatalf("folder op = %+v", ops[0])
}
if ops[1].EntityType != EntityFile || ops[1].EntityID != "Docs/note.txt" || ops[1].OpType != OpCreate {
t.Fatalf("file op = %+v", ops[1])
}
var createPayload map[string]interface{}
if err := json.Unmarshal([]byte(ops[1].PayloadJSON), &createPayload); err != nil {
t.Fatalf("decode file payload: %v", err)
}
if createPayload["content"] != "one" || createPayload["contentHash"] == "" {
t.Fatalf("file payload = %#v", createPayload)
}
if _, err := service.ScanAndRecord(); err != nil {
t.Fatalf("unchanged scan: %v", err)
}
assertUnpushedCount(t, service, 2)
if err := os.WriteFile(filepath.Join(root, "Docs", "note.txt"), []byte("two"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := service.ScanAndRecord(); err != nil {
t.Fatalf("scan update: %v", err)
}
ops = unpushedOps(t, service)
if len(ops) != 3 || ops[2].EntityType != EntityFile || ops[2].OpType != OpUpdate {
t.Fatalf("update ops = %#v", ops)
}
if err := os.Remove(filepath.Join(root, "Docs", "note.txt")); err != nil {
t.Fatal(err)
}
if err := os.Remove(filepath.Join(root, "Docs")); err != nil {
t.Fatal(err)
}
if _, err := service.ScanAndRecord(); err != nil {
t.Fatalf("scan delete: %v", err)
}
ops = unpushedOps(t, service)
if len(ops) != 5 {
t.Fatalf("delete ops = %#v", ops)
}
if ops[3].EntityType != EntityFile || ops[3].OpType != OpDelete || ops[4].EntityType != EntityFolder || ops[4].OpType != OpDelete {
t.Fatalf("delete ordering = %#v", ops[3:])
}
}
func TestSnapshotUsesBlobReferenceForBinaryFileBeyondInlineLimit(t *testing.T) {
root := t.TempDir()
service := NewService(root, "device-a")
if _, err := service.ScanAndRecord(); err != nil {
t.Fatalf("baseline: %v", err)
}
data := make([]byte, corefiles.MaxBinaryReadBytes+1)
for i := range data {
data[i] = byte(i % 251)
}
if err := os.WriteFile(filepath.Join(root, "large.bin"), data, 0o600); err != nil {
t.Fatal(err)
}
if _, err := service.ScanAndRecord(); err != nil {
t.Fatalf("scan binary: %v", err)
}
ops := unpushedOps(t, service)
if len(ops) != 1 {
t.Fatalf("operations = %#v, want one file create", ops)
}
var payload struct {
DataBase64 *string `json:"dataBase64"`
Blob *struct {
SHA256 string `json:"sha256"`
Size int64 `json:"size"`
} `json:"blob"`
}
if err := json.Unmarshal([]byte(ops[0].PayloadJSON), &payload); err != nil {
t.Fatal(err)
}
if payload.DataBase64 != nil || payload.Blob == nil || payload.Blob.Size != int64(len(data)) || payload.Blob.SHA256 == "" {
t.Fatalf("binary sync payload = %s, want blob reference without base64", ops[0].PayloadJSON)
}
}
func TestScanAndRecordNeverTreatsInitialFilesAsDeletes(t *testing.T) {
root := t.TempDir()
if err := os.Mkdir(filepath.Join(root, "Existing"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "Existing", "before-sync.txt"), []byte("local"), 0o644); err != nil {
t.Fatal(err)
}
service := NewService(root, "device-a")
if _, err := service.ScanAndRecord(); err != nil {
t.Fatalf("initial ScanAndRecord: %v", err)
}
assertUnpushedCount(t, service, 0)
snapshot, err := service.LoadSnapshot()
if err != nil {
t.Fatalf("LoadSnapshot: %v", err)
}
if snapshot.Entries["Existing/before-sync.txt"].Hash == "" {
t.Fatalf("snapshot = %#v, expected content hash", snapshot.Entries)
}
}
func TestScanAndRecordFindsChangesMadeWhileDesktopWasClosed(t *testing.T) {
root := t.TempDir()
initial := NewService(root, "device-a")
if _, err := initial.ScanAndRecord(); err != nil {
t.Fatalf("initial baseline: %v", err)
}
if err := os.WriteFile(filepath.Join(root, "offline.txt"), []byte("created while closed"), 0o644); err != nil {
t.Fatal(err)
}
restarted := NewService(root, "device-a")
if _, err := restarted.ScanAndRecord(); err != nil {
t.Fatalf("scan after offline create: %v", err)
}
ops := unpushedOps(t, restarted)
if len(ops) != 1 || ops[0].OpType != OpCreate || ops[0].EntityID != "offline.txt" {
t.Fatalf("offline create operations = %#v", ops)
}
if err := os.WriteFile(filepath.Join(root, "offline.txt"), []byte("updated while closed"), 0o644); err != nil {
t.Fatal(err)
}
restarted = NewService(root, "device-a")
if _, err := restarted.ScanAndRecord(); err != nil {
t.Fatalf("scan after offline update: %v", err)
}
if err := os.Remove(filepath.Join(root, "offline.txt")); err != nil {
t.Fatal(err)
}
restarted = NewService(root, "device-a")
if _, err := restarted.ScanAndRecord(); err != nil {
t.Fatalf("scan after offline delete: %v", err)
}
ops = unpushedOps(t, restarted)
if len(ops) != 3 || ops[1].OpType != OpUpdate || ops[2].OpType != OpDelete {
t.Fatalf("offline lifecycle operations = %#v", ops)
}
}
func TestRecordBootstrapOpsPublishesExistingFilesWithoutDeletes(t *testing.T) {
root := t.TempDir()
if err := os.Mkdir(filepath.Join(root, "Existing"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "Existing", "before-sync.txt"), []byte("local"), 0o644); err != nil {
t.Fatal(err)
}
service := NewService(root, "device-a")
if _, err := service.ScanAndRecord(); err != nil {
t.Fatal(err)
}
initial, err := service.LoadSnapshot()
if err != nil {
t.Fatal(err)
}
if err := service.RecordBootstrapOps(initial); err != nil {
t.Fatalf("RecordBootstrapOps: %v", err)
}
ops := unpushedOps(t, service)
if len(ops) != 2 {
t.Fatalf("bootstrap ops = %#v, want create folder and file", ops)
}
for _, op := range ops {
if op.OpType != OpCreate {
t.Fatalf("bootstrap op = %+v, initial scan must not create delete", op)
}
}
empty := newSnapshot()
if err := service.RecordBootstrapOps(empty); err != nil {
t.Fatalf("empty bootstrap: %v", err)
}
if got := len(unpushedOps(t, service)); got != 2 {
t.Fatalf("empty bootstrap added operations = %d, want 2", got)
}
}
func TestSyncBootstrapAndWarningStateSurviveRestart(t *testing.T) {
root := t.TempDir()
service := NewService(root, "device-a")
if err := service.SetBootstrapComplete(true); err != nil {
t.Fatalf("SetBootstrapComplete: %v", err)
}
if err := service.SetLastWarning("file-too-large: archive.bin"); err != nil {
t.Fatalf("SetLastWarning: %v", err)
}
restarted := NewService(root, "")
bootstrapped, err := restarted.BootstrapComplete()
if err != nil {
t.Fatalf("BootstrapComplete: %v", err)
}
if !bootstrapped {
t.Fatal("bootstrap state was lost after restart")
}
warning, err := restarted.LastWarning()
if err != nil {
t.Fatalf("LastWarning: %v", err)
}
if warning != "file-too-large: archive.bin" {
t.Fatalf("warning = %q", warning)
}
}
func TestScanAndRecordSkipsReservedTemporaryAndSymlinkPaths(t *testing.T) {
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, ".verstak", "sync"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, ".verstak", "sync", "state.json"), []byte("{}"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, ".verstak-write-local"), []byte("temporary"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "draft.tmp"), []byte("temporary"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "normal.txt"), []byte("normal"), 0o600); err != nil {
t.Fatal(err)
}
if runtime.GOOS != "windows" {
if err := os.Symlink(filepath.Join(root, "normal.txt"), filepath.Join(root, "normal-link.txt")); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
}
service := NewService(root, "device-a")
if _, err := service.ScanAndRecord(); err != nil {
t.Fatalf("baseline: %v", err)
}
snapshot, err := service.LoadSnapshot()
if err != nil {
t.Fatal(err)
}
for _, path := range []string{".verstak/sync/state.json", ".verstak-write-local", "draft.tmp", "normal-link.txt"} {
if _, ok := snapshot.Entries[path]; ok {
t.Fatalf("reserved path %q was included in snapshot %#v", path, snapshot.Entries)
}
}
if _, ok := snapshot.Entries["normal.txt"]; !ok {
t.Fatalf("normal file missing from snapshot %#v", snapshot.Entries)
}
}
func TestScanAndRecordKeepsUnsupportedFileUnresolved(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "too-large.bin")
if err := os.WriteFile(path, nil, 0o600); err != nil {
t.Fatal(err)
}
if err := os.Truncate(path, maxOperationFileBytes+1); err != nil {
t.Fatal(err)
}
service := NewService(root, "device-a")
warnings, err := service.ScanAndRecord()
if err != nil {
t.Fatalf("scan unsupported file: %v", err)
}
if len(warnings) != 1 || !strings.Contains(warnings[0], "too-large.bin") || !strings.Contains(warnings[0], "file-too-large") {
t.Fatalf("warnings = %v", warnings)
}
assertUnpushedCount(t, service, 0)
snapshot, err := service.LoadSnapshot()
if err != nil {
t.Fatal(err)
}
if _, ok := snapshot.Entries["too-large.bin"]; ok {
t.Fatalf("unsupported file was marked synchronized: %#v", snapshot.Entries)
}
warnings, err = service.ScanAndRecord()
if err != nil || len(warnings) != 1 {
t.Fatalf("second scan warnings=%v err=%v, unresolved file must remain visible", warnings, err)
}
}
func unpushedOps(t *testing.T, service *Service) []Op {
t.Helper()
ops, err := service.GetUnpushedOps()
if err != nil {
t.Fatalf("GetUnpushedOps: %v", err)
}
return ops
}
func assertUnpushedCount(t *testing.T, service *Service, want int) {
t.Helper()
if got := len(unpushedOps(t, service)); got != want {
t.Fatalf("unpushed ops = %d, want %d", got, want)
}
}
File diff suppressed because it is too large Load Diff
+351 -18
View File
@@ -7,12 +7,16 @@ import (
"runtime" "runtime"
"strings" "strings"
"testing" "testing"
"github.com/google/uuid"
) )
func TestListWorkspacesReadsTopLevelPhysicalFolders(t *testing.T) { func TestListWorkspacesReadsTopLevelPhysicalFolders(t *testing.T) {
vaultDir := newVaultDir(t) vaultDir := newVaultDir(t)
mustMkdir(t, filepath.Join(vaultDir, "Project")) mustMkdir(t, filepath.Join(vaultDir, "Project"))
mustWriteWorkspaceMarker(t, filepath.Join(vaultDir, "Project"))
mustMkdir(t, filepath.Join(vaultDir, "Test")) mustMkdir(t, filepath.Join(vaultDir, "Test"))
mustWriteWorkspaceMarker(t, filepath.Join(vaultDir, "Test"))
mustMkdir(t, filepath.Join(vaultDir, ".verstak")) mustMkdir(t, filepath.Join(vaultDir, ".verstak"))
mustMkdir(t, filepath.Join(vaultDir, ".git")) mustMkdir(t, filepath.Join(vaultDir, ".git"))
mustWrite(t, filepath.Join(vaultDir, "readme.md"), "not a workspace") mustWrite(t, filepath.Join(vaultDir, "readme.md"), "not a workspace")
@@ -34,6 +38,61 @@ func TestListWorkspacesReadsTopLevelPhysicalFolders(t *testing.T) {
} }
} }
func TestListWorkspacesIncludesNestedWorkspaces(t *testing.T) {
vaultDir := newVaultDir(t)
mustMkdir(t, filepath.Join(vaultDir, "Clients"))
mustMkdir(t, filepath.Join(vaultDir, "Clients", "Romashka"))
mustWriteWorkspaceMarker(t, filepath.Join(vaultDir, "Clients", "Romashka"))
mustMkdir(t, filepath.Join(vaultDir, "Clients", "Alpha"))
mustWriteWorkspaceMarker(t, filepath.Join(vaultDir, "Clients", "Alpha"))
mustMkdir(t, filepath.Join(vaultDir, "Personal"))
mustWriteWorkspaceMarker(t, filepath.Join(vaultDir, "Personal"))
m := NewManager(vaultDir)
if err := m.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
workspaces, err := m.ListWorkspaces()
if err != nil {
t.Fatalf("ListWorkspaces: %v", err)
}
if len(workspaces) != 3 {
t.Fatalf("workspaces = %d, want 3", len(workspaces))
}
paths := make([]string, len(workspaces))
for i, ws := range workspaces {
paths[i] = ws.Path
}
wantPaths := []string{"Clients/Alpha", "Clients/Romashka", "Personal"}
if strings.Join(paths, ",") != strings.Join(wantPaths, ",") {
t.Fatalf("paths = %v, want %v", paths, wantPaths)
}
}
func TestCreateWorkspaceNested(t *testing.T) {
vaultDir := newVaultDir(t)
m := NewManager(vaultDir)
// Create parent folder first
mustMkdir(t, filepath.Join(vaultDir, "Clients"))
ws, err := m.CreateWorkspace("Clients/Project", "")
if err != nil {
t.Fatalf("CreateWorkspace nested: %v", err)
}
if ws.Path != "Clients/Project" {
t.Fatalf("workspace path = %q, want Clients/Project", ws.Path)
}
if _, err := os.Stat(filepath.Join(vaultDir, "Clients", "Project")); err != nil {
t.Fatalf("workspace folder missing: %v", err)
}
// Verify marker
if _, err := os.Stat(filepath.Join(vaultDir, "Clients", "Project", ".verstak", "workspace.json")); err != nil {
t.Fatalf("marker missing: %v", err)
}
}
func TestListWorkspacesExcludesTopLevelSymlink(t *testing.T) { func TestListWorkspacesExcludesTopLevelSymlink(t *testing.T) {
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
t.Skip("symlink creation needs extra privileges on Windows") t.Skip("symlink creation needs extra privileges on Windows")
@@ -292,6 +351,254 @@ func TestTrashWorkspaceMovesFolderToTrashAndRemovesFromList(t *testing.T) {
} }
} }
func TestCreateWorkspaceWritesDurableIdentityMarker(t *testing.T) {
vaultDir := newVaultDir(t)
m := NewManager(vaultDir)
if _, err := m.CreateWorkspace("Client", "default"); err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
markerPath := filepath.Join(vaultDir, "Client", ".verstak", "workspace.json")
data, err := os.ReadFile(markerPath)
if err != nil {
t.Fatalf("read identity marker: %v", err)
}
var marker map[string]string
if err := json.Unmarshal(data, &marker); err != nil {
t.Fatalf("decode identity marker: %v", err)
}
if marker["workspaceId"] == "" {
t.Fatalf("workspaceId = %q, want UUID", marker["workspaceId"])
}
}
func TestListWorkspacesKeepsIdentityAfterRename(t *testing.T) {
vaultDir := newVaultDir(t)
m := NewManager(vaultDir)
created, err := m.CreateWorkspace("Client", "default")
if err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
if err := m.RenameWorkspace("Client", "Client-2026"); err != nil {
t.Fatalf("RenameWorkspace: %v", err)
}
workspaces, err := m.ListWorkspaces()
if err != nil {
t.Fatalf("ListWorkspaces: %v", err)
}
if len(workspaces) != 1 {
t.Fatalf("workspaces = %+v, want one", workspaces)
}
if workspaces[0].Name != "Client-2026" {
t.Fatalf("workspace name = %q, want Client-2026", workspaces[0].Name)
}
if workspaces[0].ID != created.ID {
t.Fatalf("workspace ID = %q, want %q", workspaces[0].ID, created.ID)
}
}
func TestListWorkspaceIdentitiesMarksCopiedMarkerAsDuplicate(t *testing.T) {
vaultDir := newVaultDir(t)
m := NewManager(vaultDir)
if _, err := m.CreateWorkspace("Original", "default"); err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
if err := os.Mkdir(filepath.Join(vaultDir, "Copied"), 0o755); err != nil {
t.Fatalf("mkdir copied: %v", err)
}
originalMarker := filepath.Join(vaultDir, "Original", ".verstak", "workspace.json")
copiedMarker := filepath.Join(vaultDir, "Copied", ".verstak", "workspace.json")
if err := os.MkdirAll(filepath.Dir(copiedMarker), 0o755); err != nil {
t.Fatalf("mkdir copied marker: %v", err)
}
data, err := os.ReadFile(originalMarker)
if err != nil {
t.Fatalf("read original marker: %v", err)
}
if err := os.WriteFile(copiedMarker, data, 0o644); err != nil {
t.Fatalf("write copied marker: %v", err)
}
identities, err := m.ListWorkspaceIdentities()
if err != nil {
t.Fatalf("ListWorkspaceIdentities: %v", err)
}
if len(identities) != 2 {
t.Fatalf("identities = %+v, want two", identities)
}
for _, identity := range identities {
if identity.State != "duplicate" {
t.Fatalf("identity %+v state = %q, want duplicate", identity, identity.State)
}
}
}
func TestRepairWorkspaceIdentityRegeneratesCopiedMarker(t *testing.T) {
vaultDir := newVaultDir(t)
m := NewManager(vaultDir)
if _, err := m.CreateWorkspace("Original", "default"); err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
if err := os.Mkdir(filepath.Join(vaultDir, "Copied"), 0o755); err != nil {
t.Fatalf("mkdir copied: %v", err)
}
originalMarker := filepath.Join(vaultDir, "Original", ".verstak", "workspace.json")
copiedMarker := filepath.Join(vaultDir, "Copied", ".verstak", "workspace.json")
if err := os.MkdirAll(filepath.Dir(copiedMarker), 0o755); err != nil {
t.Fatalf("mkdir copied marker: %v", err)
}
data, err := os.ReadFile(originalMarker)
if err != nil {
t.Fatalf("read original marker: %v", err)
}
if err := os.WriteFile(copiedMarker, data, 0o644); err != nil {
t.Fatalf("write copied marker: %v", err)
}
if err := m.RepairWorkspaceIdentity("Original", "Copied"); err != nil {
t.Fatalf("RepairWorkspaceIdentity: %v", err)
}
identities, err := m.ListWorkspaceIdentities()
if err != nil {
t.Fatalf("ListWorkspaceIdentities: %v", err)
}
if len(identities) != 2 || identities[0].State != "active" || identities[1].State != "active" {
t.Fatalf("identities after repair = %+v", identities)
}
if identities[0].WorkspaceID == identities[1].WorkspaceID {
t.Fatalf("repair kept duplicate ID %q", identities[0].WorkspaceID)
}
}
func TestRestoreWorkspaceTrashPreservesIdentity(t *testing.T) {
vaultDir := newVaultDir(t)
m := NewManager(vaultDir)
created, err := m.CreateWorkspace("Client", "default")
if err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
trashed, err := m.TrashWorkspace("Client")
if err != nil {
t.Fatalf("TrashWorkspace: %v", err)
}
restored, err := m.RestoreWorkspaceTrash(trashed.TrashID, "Client-Restored")
if err != nil {
t.Fatalf("RestoreWorkspaceTrash: %v", err)
}
if restored.Name != "Client-Restored" || restored.ID != created.ID {
t.Fatalf("restored = %+v, want name Client-Restored and ID %q", restored, created.ID)
}
if _, err := os.Stat(filepath.Join(vaultDir, "Client-Restored", ".verstak", "workspace.json")); err != nil {
t.Fatalf("restored marker: %v", err)
}
}
func TestApplySyncedWorkspaceLifecycleKeepsIdentityAndRejectsNameConflict(t *testing.T) {
vaultDir := newVaultDir(t)
m := NewManager(vaultDir)
workspaceID := "5f0f96d9-61c8-4b6b-8c3a-a1b9a0f40001"
meta := Metadata{
WorkspaceID: workspaceID,
WorkspaceName: "Remote",
CreatedFromTemplate: &TemplateSnapshot{
TemplateID: "minimal",
TemplateName: "Minimal",
TemplateVersion: 1,
AppliedAt: "2026-07-17T00:00:00Z",
},
Features: map[string]bool{"files": true},
Folders: map[string]string{"notes": "Notes"},
}
created, err := m.CreateWorkspaceFromSync("Remote", workspaceID, meta)
if err != nil {
t.Fatalf("CreateWorkspaceFromSync: %v", err)
}
if created.ID != workspaceID || created.Name != "Remote" {
t.Fatalf("created = %+v", created)
}
if _, err := m.CreateWorkspaceFromSync("Remote", workspaceID, meta); err != nil {
t.Fatalf("replayed create: %v", err)
}
if err := m.RenameWorkspaceFromSync(workspaceID, "Remote", "Remote-Renamed"); err != nil {
t.Fatalf("RenameWorkspaceFromSync: %v", err)
}
if err := m.RenameWorkspaceFromSync(workspaceID, "Remote", "Remote-Renamed"); err != nil {
t.Fatalf("replayed rename: %v", err)
}
trashed, err := m.TrashWorkspaceFromSync(workspaceID, "Remote-Renamed")
if err != nil {
t.Fatalf("TrashWorkspaceFromSync: %v", err)
}
if trashed.WorkspaceID != workspaceID {
t.Fatalf("trashed = %+v", trashed)
}
if _, err := m.TrashWorkspaceFromSync(workspaceID, "Remote-Renamed"); err != nil {
t.Fatalf("replayed trash: %v", err)
}
restored, err := m.RestoreWorkspaceFromSync(workspaceID, "Remote-Restored")
if err != nil {
t.Fatalf("RestoreWorkspaceFromSync: %v", err)
}
if restored.ID != workspaceID || restored.Name != "Remote-Restored" {
t.Fatalf("restored = %+v", restored)
}
if _, err := m.RestoreWorkspaceFromSync(workspaceID, "Remote-Restored"); err != nil {
t.Fatalf("replayed restore: %v", err)
}
if _, err := m.CreateWorkspace("Taken", "minimal"); err != nil {
t.Fatalf("CreateWorkspace Taken: %v", err)
}
if err := m.RenameWorkspaceFromSync(workspaceID, "Remote-Restored", "Taken"); err == nil || !strings.Contains(err.Error(), "conflict") {
t.Fatalf("rename conflict error = %v, want conflict", err)
}
if _, err := m.CreateWorkspaceFromSync("Copied", workspaceID, meta); err == nil || !strings.Contains(err.Error(), "conflict") {
t.Fatalf("duplicate workspace ID error = %v, want conflict", err)
}
stored, err := m.GetWorkspaceMetadata("Remote-Restored")
if err != nil {
t.Fatalf("GetWorkspaceMetadata: %v", err)
}
if stored.WorkspaceID != workspaceID || stored.CreatedFromTemplate == nil || stored.CreatedFromTemplate.TemplateID != "minimal" {
t.Fatalf("stored metadata = %+v", stored)
}
}
func TestCreateWorkspaceFromSyncRejectsUnsafeMetadataFolder(t *testing.T) {
vaultDir := newVaultDir(t)
m := NewManager(vaultDir)
if _, err := m.CreateWorkspaceFromSync("Remote", uuid.NewString(), Metadata{
Folders: map[string]string{"files": "../escaped"},
}); err == nil || !strings.Contains(err.Error(), "invalid workspace folder") {
t.Fatalf("unsafe synced metadata error = %v", err)
}
if _, err := os.Stat(filepath.Join(vaultDir, "escaped")); !os.IsNotExist(err) {
t.Fatalf("unsafe synced metadata created path outside workspace: %v", err)
}
}
func TestPurgeWorkspaceTrashRemovesPayload(t *testing.T) {
vaultDir := newVaultDir(t)
m := NewManager(vaultDir)
if _, err := m.CreateWorkspace("Client", "default"); err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
trashed, err := m.TrashWorkspace("Client")
if err != nil {
t.Fatalf("TrashWorkspace: %v", err)
}
if err := m.PurgeWorkspaceTrash(trashed.TrashID); err != nil {
t.Fatalf("PurgeWorkspaceTrash: %v", err)
}
if _, err := os.Stat(filepath.Join(vaultDir, ".verstak", "trash", "workspaces", trashed.TrashID)); !os.IsNotExist(err) {
t.Fatalf("workspace trash remains, stat err=%v", err)
}
}
func TestCreateAndRenameConflictsAreExplicit(t *testing.T) { func TestCreateAndRenameConflictsAreExplicit(t *testing.T) {
vaultDir := newVaultDir(t) vaultDir := newVaultDir(t)
mustMkdir(t, filepath.Join(vaultDir, "Existing")) mustMkdir(t, filepath.Join(vaultDir, "Existing"))
@@ -306,23 +613,24 @@ func TestCreateAndRenameConflictsAreExplicit(t *testing.T) {
} }
} }
func TestInvalidWorkspaceNamesRejected(t *testing.T) { func TestInvalidWorkspacePathsRejected(t *testing.T) {
vaultDir := newVaultDir(t) vaultDir := newVaultDir(t)
m := NewManager(vaultDir) m := NewManager(vaultDir)
names := []string{"", " ", "A/B", `A\B`, "/abs", `C:\abs`, "..", "a..b", "bad\x00name", ".verstak", ".Verstak", ".git"} paths := []string{"", " ", `A\B`, "/abs", `C:\abs`, "..", "a/../b", "bad\x00name", ".verstak", ".Verstak", ".git"}
for _, name := range names { for _, path := range paths {
if _, err := m.CreateWorkspace(name, ""); err == nil { if _, err := m.CreateWorkspace(path, ""); err == nil {
t.Fatalf("CreateWorkspace(%q) succeeded, want invalid name error", name) t.Fatalf("CreateWorkspace(%q) succeeded, want invalid path error", path)
} }
} }
} }
func TestCompatibilityTreeIsDerivedFromTopLevelFolders(t *testing.T) { func TestCompatibilityTreeIncludesFoldersAndNestedWorkspaces(t *testing.T) {
vaultDir := newVaultDir(t) vaultDir := newVaultDir(t)
mustMkdir(t, filepath.Join(vaultDir, "Project")) mustMkdir(t, filepath.Join(vaultDir, "Project"))
mustMkdir(t, filepath.Join(vaultDir, "Project", "Nested")) mustWriteWorkspaceMarker(t, filepath.Join(vaultDir, "Project"))
mustMkdir(t, filepath.Join(vaultDir, "Test")) mustMkdir(t, filepath.Join(vaultDir, "Test"))
mustWriteWorkspaceMarker(t, filepath.Join(vaultDir, "Test"))
m := NewManager(vaultDir) m := NewManager(vaultDir)
if err := m.Load(); err != nil { if err := m.Load(); err != nil {
@@ -332,34 +640,43 @@ func TestCompatibilityTreeIsDerivedFromTopLevelFolders(t *testing.T) {
if len(tree.Nodes) != 2 { if len(tree.Nodes) != 2 {
t.Fatalf("nodes = %+v, want 2 top-level workspaces", tree.Nodes) t.Fatalf("nodes = %+v, want 2 top-level workspaces", tree.Nodes)
} }
if tree.Nodes[0].ID != "Project" || tree.Nodes[0].Title != "Project" || tree.Nodes[0].Path != "" { if tree.Nodes[0].ID != "Project" || tree.Nodes[0].Title != "Project" {
t.Fatalf("first compatibility node = %+v, want derived workspace without persisted path mapping", tree.Nodes[0]) t.Fatalf("first compatibility node = %+v, want workspace node", tree.Nodes[0])
} }
for _, node := range tree.Nodes { for _, node := range tree.Nodes {
if node.ParentID != "" {
t.Fatalf("compatibility tree should be flat, got child node %+v", node)
}
if node.ID == "Nested" || node.Title == "Nested" { if node.ID == "Nested" || node.Title == "Nested" {
t.Fatalf("nested folders must not become workspace nodes: %+v", tree.Nodes) t.Fatalf("nested folders without markers must not become workspace nodes: %+v", tree.Nodes)
}
}
// Workspace nodes should NOT have ParentID for root-level ones
for _, node := range tree.Nodes {
if node.Type == TypeSpace && node.ID == "Project" && node.ParentID != "" {
t.Fatalf("root workspace should have empty ParentID: %+v", node)
} }
} }
} }
func TestMoveNodeCompatibilityDoesNotCreateNestedWorkspaceModel(t *testing.T) { func TestMoveNodeCreatesNestedWorkspaceModel(t *testing.T) {
vaultDir := newVaultDir(t) vaultDir := newVaultDir(t)
mustMkdir(t, filepath.Join(vaultDir, "Project")) mustMkdir(t, filepath.Join(vaultDir, "Project"))
mustWriteWorkspaceMarker(t, filepath.Join(vaultDir, "Project"))
mustMkdir(t, filepath.Join(vaultDir, "Test")) mustMkdir(t, filepath.Join(vaultDir, "Test"))
m := NewManager(vaultDir) m := NewManager(vaultDir)
if err := m.Load(); err != nil { if err := m.Load(); err != nil {
t.Fatalf("Load: %v", err) t.Fatalf("Load: %v", err)
} }
// Move workspace into Test folder (which is a plain folder without marker)
err := m.MoveNode("Project", "Test") err := m.MoveNode("Project", "Test")
if err == nil || !strings.Contains(err.Error(), "top-level only") { if err != nil {
t.Fatalf("MoveNode error = %v, want top-level only", err) t.Fatalf("MoveNode error = %v, want success", err)
} }
if _, statErr := os.Stat(filepath.Join(vaultDir, "Test", "Project")); !os.IsNotExist(statErr) { if _, statErr := os.Stat(filepath.Join(vaultDir, "Test", "Project")); statErr != nil {
t.Fatalf("MoveNode created nested mapped workspace, stat err=%v", statErr) t.Fatalf("MoveNode did not create nested folder, stat err=%v", statErr)
}
// Verify marker moved
if _, statErr := os.Stat(filepath.Join(vaultDir, "Test", "Project", ".verstak", "workspace.json")); statErr != nil {
t.Fatalf("marker not in new location: %v", statErr)
} }
} }
@@ -386,6 +703,22 @@ func TestMetadataFileShape(t *testing.T) {
} }
} }
func mustWriteWorkspaceMarker(t *testing.T, dir string) {
t.Helper()
markerPath := filepath.Join(dir, ".verstak", "workspace.json")
if err := os.MkdirAll(filepath.Dir(markerPath), 0o755); err != nil {
t.Fatalf("MkdirAll(%s): %v", filepath.Dir(markerPath), err)
}
marker := workspaceIdentityMarker{WorkspaceID: uuid.NewString()}
data, err := json.Marshal(marker)
if err != nil {
t.Fatalf("marshal marker: %v", err)
}
if err := os.WriteFile(markerPath, data, 0o644); err != nil {
t.Fatalf("WriteFile(%s): %v", markerPath, err)
}
}
func newVaultDir(t *testing.T) string { func newVaultDir(t *testing.T) string {
t.Helper() t.Helper()
vaultDir := filepath.Join(t.TempDir(), "vault") vaultDir := filepath.Join(t.TempDir(), "vault")
+314
View File
@@ -0,0 +1,314 @@
// Package tray owns the native tray menu wiring for the desktop shell.
package tray
import (
"errors"
"log"
"strings"
"sync"
"sync/atomic"
)
var errBackendUnavailable = errors.New("tray backend is unavailable")
// MenuItem exposes click events from a native tray menu item.
type MenuItem interface {
Clicked() <-chan struct{}
SetTitle(title string)
SetTooltip(tooltip string)
}
// BackendCallbacks are invoked by the native tray implementation.
// A backend keeps the platform-native secondary-click handler enabled so the
// operating system opens the menu on a right click.
type BackendCallbacks struct {
Ready func()
Exit func()
LeftClick func()
}
// Backend is the platform tray implementation.
type Backend interface {
Start(callbacks BackendCallbacks) error
SetIcon(icon []byte) error
SetTooltip(tooltip string) error
AddMenuItem(title, tooltip string) (MenuItem, error)
Stop()
}
// Actions are executed from the native tray menu.
type Actions struct {
Show func()
Quit func()
}
// Labels contains the user-visible text for one tray menu locale.
type Labels struct {
ShowTitle string
ShowTooltip string
QuitTitle string
QuitTooltip string
}
func englishLabels() Labels {
return Labels{
ShowTitle: "Show Verstak",
ShowTooltip: "Show the Verstak window",
QuitTitle: "Quit",
QuitTooltip: "Quit Verstak",
}
}
func russianLabels() Labels {
return Labels{
ShowTitle: "Показать Верстак",
ShowTooltip: "Показать окно Верстака",
QuitTitle: "Выйти",
QuitTooltip: "Завершить Верстак",
}
}
// LabelsForPreference resolves the saved language preference for native tray UI.
// System locale values are intentionally passed in by the shell so this package
// stays independent of process environment and is deterministic in tests.
func LabelsForPreference(preference string, systemLocales ...string) Labels {
if strings.EqualFold(strings.TrimSpace(preference), "ru") {
return russianLabels()
}
if strings.EqualFold(strings.TrimSpace(preference), "system") {
for _, locale := range systemLocales {
locale = strings.ToLower(strings.TrimSpace(locale))
if locale == "" {
continue
}
if strings.HasPrefix(locale, "ru") {
return russianLabels()
}
return englishLabels()
}
}
return englishLabels()
}
// Controller initializes one native tray and routes its menu actions.
type Controller struct {
backend Backend
icon []byte
startOnce sync.Once
readyOnce sync.Once
stopOnce sync.Once
started atomic.Bool
ready atomic.Bool
mu sync.RWMutex
labels Labels
actions Actions
show MenuItem
quit MenuItem
startErr error
readyChanged func(bool)
}
// New creates a tray controller for one application process.
func New(backend Backend, icon []byte) *Controller {
return &Controller{
backend: backend,
icon: append([]byte(nil), icon...),
labels: englishLabels(),
}
}
// SetReadyChangedHandler receives readiness changes after native initialization
// has either completed or ended. It lets the Wails close policy avoid hiding a
// window before the tray can bring it back.
func (c *Controller) SetReadyChangedHandler(handler func(bool)) {
if c == nil {
return
}
c.mu.Lock()
c.readyChanged = handler
c.mu.Unlock()
}
// Ready reports whether the tray has completed icon and menu initialization.
func (c *Controller) Ready() bool {
return c != nil && c.ready.Load()
}
// SetLabels updates the current and future native tray menu labels.
func (c *Controller) SetLabels(labels Labels) {
if c == nil {
return
}
c.mu.Lock()
c.labels = labels
show, quit := c.show, c.quit
c.mu.Unlock()
applyLabels(show, quit, labels)
}
// Start connects the tray to Wails without taking over Wails' GUI event loop.
// Ready remains false until the backend callback and all required menu setup
// finish successfully.
func (c *Controller) Start(actions Actions) error {
if c == nil || c.backend == nil {
return errBackendUnavailable
}
c.startOnce.Do(func() {
c.mu.Lock()
c.actions = actions
c.mu.Unlock()
c.started.Store(true)
log.Printf("[tray] starting native backend")
err := c.backend.Start(BackendCallbacks{
Ready: c.onReady,
Exit: c.onExit,
LeftClick: c.onLeftClick,
})
if err != nil {
c.mu.Lock()
c.startErr = err
c.mu.Unlock()
c.started.Store(false)
c.setReady(false)
log.Printf("[tray] backend start failed: %v; falling back to normal window close", err)
}
})
c.mu.RLock()
err := c.startErr
c.mu.RUnlock()
return err
}
func (c *Controller) onReady() {
if c == nil {
return
}
if !c.started.Load() {
log.Printf("[tray] ready callback ignored after backend startup failed")
return
}
c.readyOnce.Do(func() {
log.Printf("[tray] native backend reported ready")
if err := c.backend.SetIcon(c.icon); err != nil {
c.fail("icon setup", err)
return
}
if err := c.backend.SetTooltip("Verstak"); err != nil {
c.fail("tooltip setup", err)
return
}
c.mu.RLock()
labels := c.labels
actions := c.actions
c.mu.RUnlock()
show, err := c.backend.AddMenuItem(labels.ShowTitle, labels.ShowTooltip)
if err != nil || show == nil {
if err == nil {
err = errors.New("show menu item is nil")
}
c.fail("show menu creation", err)
return
}
quit, err := c.backend.AddMenuItem(labels.QuitTitle, labels.QuitTooltip)
if err != nil || quit == nil {
if err == nil {
err = errors.New("quit menu item is nil")
}
c.fail("quit menu creation", err)
return
}
c.mu.Lock()
c.show, c.quit = show, quit
labels = c.labels
c.mu.Unlock()
applyLabels(show, quit, labels)
if actions.Show != nil {
go routeClicks(show.Clicked(), func() {
log.Printf("[tray] Show command")
actions.Show()
})
}
if actions.Quit != nil {
go routeClicks(quit.Clicked(), func() {
log.Printf("[tray] Quit command")
actions.Quit()
})
}
c.setReady(true)
log.Printf("[tray] native tray is ready")
})
}
func (c *Controller) onLeftClick() {
if c == nil {
return
}
log.Printf("[tray] left click")
if !c.Ready() {
log.Printf("[tray] left click ignored while tray is not ready")
return
}
c.mu.RLock()
show := c.actions.Show
c.mu.RUnlock()
if show != nil {
show()
}
}
func (c *Controller) onExit() {
if c == nil {
return
}
c.setReady(false)
log.Printf("[tray] native message loop ended; falling back to normal window close")
}
func (c *Controller) fail(stage string, err error) {
c.setReady(false)
log.Printf("[tray] %s failed: %v; falling back to normal window close", stage, err)
c.Stop()
}
func (c *Controller) setReady(ready bool) {
if c == nil || c.ready.Swap(ready) == ready {
return
}
c.mu.RLock()
handler := c.readyChanged
c.mu.RUnlock()
if handler != nil {
handler(ready)
}
}
func applyLabels(show, quit MenuItem, labels Labels) {
if show != nil {
show.SetTitle(labels.ShowTitle)
show.SetTooltip(labels.ShowTooltip)
}
if quit != nil {
quit.SetTitle(labels.QuitTitle)
quit.SetTooltip(labels.QuitTooltip)
}
}
// Stop releases the native tray after Wails has begun application shutdown.
func (c *Controller) Stop() {
if c == nil || c.backend == nil || !c.started.Load() {
return
}
c.stopOnce.Do(func() {
c.setReady(false)
log.Printf("[tray] stopping native backend")
c.backend.Stop()
})
}
func routeClicks(clicked <-chan struct{}, action func()) {
for range clicked {
action()
}
}
+227
View File
@@ -0,0 +1,227 @@
package tray
import (
"errors"
"sync"
"testing"
"time"
)
type fakeMenuItem struct {
clicked chan struct{}
title string
tooltip string
}
func (i *fakeMenuItem) Clicked() <-chan struct{} { return i.clicked }
func (i *fakeMenuItem) SetTitle(title string) { i.title = title }
func (i *fakeMenuItem) SetTooltip(tooltip string) { i.tooltip = tooltip }
type fakeBackend struct {
callbacks BackendCallbacks
icon []byte
tooltip string
items map[string]*fakeMenuItem
startErr error
iconErr error
menuErr error
starts int
stops int
}
func (b *fakeBackend) Start(callbacks BackendCallbacks) error {
b.starts++
b.callbacks = callbacks
return b.startErr
}
func (b *fakeBackend) SetIcon(icon []byte) error {
b.icon = append([]byte(nil), icon...)
return b.iconErr
}
func (b *fakeBackend) SetTooltip(tooltip string) error {
b.tooltip = tooltip
return nil
}
func (b *fakeBackend) AddMenuItem(title, tooltip string) (MenuItem, error) {
if b.menuErr != nil {
return nil, b.menuErr
}
item := &fakeMenuItem{clicked: make(chan struct{}, 1), title: title, tooltip: tooltip}
b.items[title] = item
return item, nil
}
func (b *fakeBackend) Stop() { b.stops++ }
func (b *fakeBackend) ready() {
if b.callbacks.Ready != nil {
b.callbacks.Ready()
}
}
func (b *fakeBackend) exited() {
if b.callbacks.Exit != nil {
b.callbacks.Exit()
}
}
func (b *fakeBackend) leftClick() {
if b.callbacks.LeftClick != nil {
b.callbacks.LeftClick()
}
}
func waitFor(t *testing.T, signal <-chan struct{}) {
t.Helper()
select {
case <-signal:
case <-time.After(time.Second):
t.Fatal("timed out waiting for tray action")
}
}
func TestControllerBecomesReadyOnlyAfterBackendCallback(t *testing.T) {
backend := &fakeBackend{items: make(map[string]*fakeMenuItem)}
controller := New(backend, []byte{1, 2, 3})
if err := controller.Start(Actions{}); err != nil {
t.Fatalf("Start() error = %v", err)
}
if controller.Ready() {
t.Fatal("tray became ready before backend callback")
}
backend.ready()
if !controller.Ready() {
t.Fatal("tray did not become ready after backend callback")
}
if string(backend.icon) != string([]byte{1, 2, 3}) || backend.tooltip != "Verstak" {
t.Fatalf("tray initialization = icon:%v tooltip:%q", backend.icon, backend.tooltip)
}
}
func TestControllerRoutesLeftClickAndMenuActionsToShowAndQuit(t *testing.T) {
backend := &fakeBackend{items: make(map[string]*fakeMenuItem)}
showCalls := make(chan struct{}, 2)
quitCalls := make(chan struct{}, 1)
controller := New(backend, []byte{1})
if err := controller.Start(Actions{
Show: func() { showCalls <- struct{}{} },
Quit: func() { quitCalls <- struct{}{} },
}); err != nil {
t.Fatalf("Start() error = %v", err)
}
backend.ready()
backend.leftClick()
waitFor(t, showCalls)
showItem := backend.items["Show Verstak"]
quitItem := backend.items["Quit"]
if showItem == nil || quitItem == nil {
t.Fatalf("tray menu = %#v, want Show Verstak and Quit", backend.items)
}
showItem.clicked <- struct{}{}
waitFor(t, showCalls)
quitItem.clicked <- struct{}{}
waitFor(t, quitCalls)
}
func TestControllerStopCallsBackendOnce(t *testing.T) {
backend := &fakeBackend{items: make(map[string]*fakeMenuItem)}
controller := New(backend, []byte{1})
if err := controller.Start(Actions{}); err != nil {
t.Fatalf("Start() error = %v", err)
}
controller.Stop()
controller.Stop()
if backend.stops != 1 {
t.Fatalf("backend stop calls = %d, want 1", backend.stops)
}
}
func TestControllerStartupAndSetupFailuresNeverBecomeReady(t *testing.T) {
for name, backend := range map[string]*fakeBackend{
"start": {items: make(map[string]*fakeMenuItem), startErr: errors.New("start failed")},
"icon": {items: make(map[string]*fakeMenuItem), iconErr: errors.New("icon failed")},
"menu": {items: make(map[string]*fakeMenuItem), menuErr: errors.New("menu failed")},
} {
t.Run(name, func(t *testing.T) {
controller := New(backend, []byte{1})
err := controller.Start(Actions{})
if name == "start" && err == nil {
t.Fatal("Start() error = nil, want startup failure")
}
if name != "start" && err != nil {
t.Fatalf("Start() error = %v, want nil before ready callback", err)
}
backend.ready()
if controller.Ready() {
t.Fatal("failed tray became ready")
}
})
}
}
func TestControllerExitRevokesReadinessAndKeepsLocalizedMenuItems(t *testing.T) {
backend := &fakeBackend{items: make(map[string]*fakeMenuItem)}
controller := New(backend, []byte{1})
controller.SetLabels(LabelsForPreference("ru"))
if err := controller.Start(Actions{}); err != nil {
t.Fatalf("Start() error = %v", err)
}
backend.ready()
show := backend.items["Показать Верстак"]
quit := backend.items["Выйти"]
if show == nil || quit == nil {
t.Fatalf("Russian tray menu = %#v", backend.items)
}
controller.SetLabels(LabelsForPreference("en"))
if show.title != "Show Verstak" || quit.title != "Quit" {
t.Fatalf("English tray menu after update = show:%q quit:%q", show.title, quit.title)
}
backend.exited()
if controller.Ready() {
t.Fatal("tray stayed ready after backend exit")
}
}
func TestLabelsForSystemRussianLocale(t *testing.T) {
labels := LabelsForPreference("system", "", "ru_RU.UTF-8", "en_US.UTF-8")
if labels.ShowTitle != "Показать Верстак" || labels.QuitTitle != "Выйти" {
t.Fatalf("system Russian labels = %#v", labels)
}
labels = LabelsForPreference("system", "C.UTF-8", "", "ru_RU.UTF-8")
if labels.ShowTitle != "Show Verstak" || labels.QuitTitle != "Quit" {
t.Fatalf("higher-priority system locale must win, got %#v", labels)
}
}
func TestControllerReadyNotificationIsSafeAcrossCallbacks(t *testing.T) {
backend := &fakeBackend{items: make(map[string]*fakeMenuItem)}
controller := New(backend, []byte{1})
var changes []bool
var mu sync.Mutex
controller.SetReadyChangedHandler(func(ready bool) {
mu.Lock()
changes = append(changes, ready)
mu.Unlock()
})
if err := controller.Start(Actions{}); err != nil {
t.Fatalf("Start() error = %v", err)
}
backend.ready()
backend.exited()
mu.Lock()
defer mu.Unlock()
if len(changes) != 2 || !changes[0] || changes[1] {
t.Fatalf("ready changes = %#v, want [true false]", changes)
}
}

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