Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d607209e95 | ||
|
|
3bf67fb1e9 | ||
|
|
2ce87f6fb1 | ||
|
|
0b5fd1cac5 | ||
|
|
b10f243e34 | ||
|
|
1ca759cc2e | ||
|
|
4ae08ef88e | ||
|
|
e3d8078ad5 | ||
|
|
ba0ba5f8c4 | ||
|
|
bf965e8bf3 | ||
|
|
6eeebd39ad | ||
|
|
3c1f2c7177 | ||
|
|
a9576ac141 | ||
|
|
fb4bb2a0c3 | ||
|
|
76bc312bb9 | ||
|
|
d353b5b335 | ||
|
|
3d78e0deaf | ||
|
|
41aeefc182 | ||
|
|
f9b20d51ff | ||
|
|
8be107013d | ||
|
|
ddcd799f16 | ||
|
|
7b7b892332 | ||
|
|
be3755e50e | ||
|
|
dbfc4e8d0a | ||
|
|
cd44ee3866 | ||
|
|
a0262b97b3 | ||
|
|
2da414fb3b | ||
|
|
5cad11f3f9 | ||
|
|
dbef71f830 | ||
|
|
963049add3 | ||
|
|
6179ad73e7 | ||
|
|
1e397d0211 | ||
|
|
47d38bcd4d | ||
|
|
9d06642ca6 | ||
|
|
b6290aa8ee | ||
|
|
c3836ef1a6 | ||
|
|
e223ccccc6 | ||
|
|
fe64e1f822 | ||
|
|
fc82b3e6b1 | ||
|
|
680846da72 | ||
|
|
d1f9157c05 | ||
|
|
a00207d86e | ||
|
|
fe912c9659 | ||
|
|
8e3dedf0c5 | ||
|
|
d8a4213569 | ||
|
|
e33c63d3ad | ||
|
|
96dd499e0b | ||
|
|
67753927cb | ||
|
|
55edaa0209 | ||
|
|
4d6a8bd8cd | ||
|
|
9fc0ff764c | ||
|
|
eb5d6b1f06 | ||
|
|
030f66d806 | ||
|
|
da76b89856 | ||
|
|
20952e8477 | ||
|
|
f4e911c14a | ||
|
|
47a8d565e8 | ||
|
|
581e6b27d8 | ||
|
|
ca5290015a | ||
|
|
ad0c00bade | ||
|
|
4db5a7a132 | ||
|
|
c567b229cc | ||
|
|
5a249dcf24 | ||
|
|
6b556c59d9 | ||
|
|
fca9fff4b4 | ||
|
|
bcf49ed32c | ||
|
|
39052ffe39 | ||
|
|
e1a6b2af64 | ||
|
|
5280d245ee |
@@ -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/
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
[](https://github.com/mirivlad/verstak/releases)
|
||||||
|

|
||||||
|

|
||||||
|
[](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. |
|
||||||
|
| --- | --- |
|
||||||
|
|  |  |
|
||||||
|
|
||||||
|
| Notes: keep Markdown notes next to the Deal they describe. | Journal: record a completed session and retain its context. |
|
||||||
|
| --- | --- |
|
||||||
|
|  |  |
|
||||||
|
|
||||||
|
## 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).
|
||||||
|
|||||||
@@ -0,0 +1,340 @@
|
|||||||
|
<div align="center">
|
||||||
|
|
||||||
|
<img src="packaging/linux/verstak.svg" width="112" alt="Логотип Верстака">
|
||||||
|
|
||||||
|
# Верстак
|
||||||
|
|
||||||
|
### Рабочий контекст остаётся рядом — и хранится локально.
|
||||||
|
|
||||||
|
Файлы, заметки, ссылки, материалы из браузера, активность и история работы
|
||||||
|
в одной расширяемой локальной среде.
|
||||||
|
|
||||||
|
[English](README.md) · **Русский**
|
||||||
|
|
||||||
|
[](https://github.com/mirivlad/verstak/releases)
|
||||||
|

|
||||||
|

|
||||||
|
[](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**, который остаётся под вашим контролем.
|
||||||
|
|
||||||
|
Для локальной работы не нужны учётная запись, облачный сервис или собственный сервер.
|
||||||
|
|
||||||
|
## Верстак в работе
|
||||||
|
|
||||||
|
| Обзор: возвращает к делу и показывает недавнюю работу, входящие и то, что требует внимания. | Файлы: обычные папки и документы прямо внутри дела. |
|
||||||
|
| --- | --- |
|
||||||
|
|  |  |
|
||||||
|
|
||||||
|
| Заметки: Markdown-заметки остаются рядом с делом, которое они описывают. | Журнал: фиксирует завершённую работу и её контекст. |
|
||||||
|
| --- | --- |
|
||||||
|
|  |  |
|
||||||
|
|
||||||
|
## Основные возможности
|
||||||
|
|
||||||
|
| Возможность | Что она даёт |
|
||||||
|
| ------------------------ | ---------------------------------------------------------------------------------------------- |
|
||||||
|
| **Дела** | Объединяют файлы, заметки и историю работы вокруг проектов, клиентов и других направлений. |
|
||||||
|
| **Файлы** | Позволяют работать с обычными файлами, расположенными внутри 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).
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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
|
|
||||||
@@ -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)
|
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 61 KiB |
@@ -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'];
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 }) => {
|
||||||
|
|||||||
@@ -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 }) => {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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 }) => {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
@@ -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': 'Для этого фильтра пока нет важных изменений.',
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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.`));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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');
|
||||||
@@ -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/Дело');
|
||||||
@@ -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');
|
||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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...)
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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'
|
||||||
|
}
|
||||||
@@ -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": "" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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},
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||