Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
477b372110 | ||
|
|
c129c1a2ac | ||
|
|
7f0cc308c2 | ||
|
|
8b1151e03a | ||
|
|
15f1f413d0 | ||
|
|
e412cfc45a | ||
|
|
2007b909d1 | ||
|
|
8dafde4519 | ||
|
|
1d1eaa6bc7 | ||
|
|
3f27ba79a4 | ||
|
|
8445ea11db | ||
|
|
e5f5d4d33f | ||
|
|
b056016929 | ||
|
|
a323710a6f | ||
|
|
0f293640f9 | ||
|
|
89c261824f | ||
|
|
18da09e06b | ||
|
|
b845e8b5e2 | ||
|
|
5b4c792de5 | ||
|
|
12b1e5a496 | ||
|
|
a540da707d | ||
|
|
74c627f706 | ||
|
|
029bb7a1a8 | ||
|
|
d2a07baf82 | ||
|
|
3c54ef55c9 | ||
|
|
3b0cd2e429 | ||
|
|
501d7a76d0 | ||
|
|
05bfe1d993 | ||
|
|
03dbede414 | ||
|
|
5c47ca2480 | ||
|
|
167d593245 | ||
|
|
9d06b164d3 | ||
|
|
b0d80e230f | ||
|
|
d3dfa2d652 | ||
|
|
67468e92f7 | ||
|
|
80e53ff252 | ||
|
|
578ce2f2a5 | ||
|
|
b28039cf0d | ||
|
|
268e79d2f0 | ||
|
|
46f754cc2d | ||
|
|
4bb9e84c35 | ||
|
|
63c2c1314f | ||
|
|
56c685212b | ||
|
|
7a7b3c7a3e | ||
|
|
fe91784a8e | ||
|
|
df4538532f | ||
|
|
2cbf542b13 | ||
|
|
7363313f1e | ||
|
|
874b7c7ffb | ||
|
|
8f6559cdb6 | ||
|
|
9f3448c1ea | ||
|
|
04ce8c5bf3 | ||
|
|
57677d1b1b | ||
|
|
aa4905a097 | ||
|
|
1d521251a2 | ||
|
|
5a74809ab7 | ||
|
|
cc0c050985 | ||
|
|
e5bdaec0aa | ||
|
|
ed4b117a94 | ||
|
|
d46bc9436d | ||
|
|
1f1001108b | ||
|
|
9729b432d6 | ||
|
|
93597a2c45 | ||
|
|
6d30015a1d | ||
|
|
0c788bed80 | ||
|
|
35012025b6 | ||
|
|
a2791c494f | ||
|
|
fb68c54409 | ||
|
|
f87f8235bd | ||
|
|
173cc93258 | ||
|
|
6cc37972d1 | ||
|
|
7630a31286 | ||
|
|
1fb9db73ec | ||
|
|
28a4e10e79 | ||
|
|
24444a8588 | ||
|
|
03175aa46d | ||
|
|
0b6b0d0926 | ||
|
|
b4e66c9779 | ||
|
|
d644c5bb79 | ||
|
|
8e5690e8f7 | ||
|
|
0ac473d720 | ||
|
|
4de5a74a55 | ||
|
|
db67c370ab | ||
|
|
ed69746332 | ||
|
|
5c979174f1 | ||
|
|
a6412fa070 | ||
|
|
6ed6df311a | ||
|
|
a100f5a441 | ||
|
|
6d2f7858eb | ||
|
|
c2e14cae69 | ||
|
|
05ef1449bc | ||
|
|
1dff97a9c0 | ||
|
|
a96ffb5801 | ||
|
|
86eeadd2a9 | ||
|
|
9bb35a9fd0 | ||
|
|
67345a194a | ||
|
|
5fa2c0ddf9 | ||
|
|
5c9ae7f93b | ||
|
|
6eecf5d005 | ||
|
|
252d075f9b | ||
|
|
ffb3446cc3 | ||
|
|
dd199f38ee | ||
|
|
e9758ec1b5 | ||
|
|
ca089a82e9 | ||
|
|
7530e21dfd | ||
|
|
6202157cbf | ||
|
|
a6f9e85f13 |
@@ -1,4 +1,10 @@
|
|||||||
|
# .gitignore
|
||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
frontend/dist/
|
frontend/dist/
|
||||||
build/bin/verstak-desktop
|
build/
|
||||||
|
verstak-desktop-res.syso
|
||||||
|
smoke-platform
|
||||||
plugins/
|
plugins/
|
||||||
|
vendor/
|
||||||
|
frontend/e2e-results/
|
||||||
|
release/
|
||||||
|
|||||||
@@ -91,3 +91,89 @@ verstak-desktop/
|
|||||||
wails.json
|
wails.json
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Verification language policy
|
||||||
|
|
||||||
|
**Do not say "checked", "verified", "works", or "all good" unless a concrete
|
||||||
|
verification command or user scenario was executed.**
|
||||||
|
|
||||||
|
Use exact labels:
|
||||||
|
- Build checked
|
||||||
|
- Unit tests checked
|
||||||
|
- Backend behavior checked
|
||||||
|
- Frontend E2E checked
|
||||||
|
- Real desktop GUI checked
|
||||||
|
- Not checked / requires manual verification
|
||||||
|
|
||||||
|
If GUI behavior was not clicked and asserted, report:
|
||||||
|
"GUI behavior was not verified."
|
||||||
|
|
||||||
|
### GUI testing
|
||||||
|
|
||||||
|
Frontend E2E tests are in `frontend/e2e/`. Run with `npm run test:e2e`.
|
||||||
|
These tests use Playwright + mocked Wails bindings. They test Svelte component
|
||||||
|
logic and user interactions in a real Chromium browser.
|
||||||
|
|
||||||
|
**Limitations:** Playwright tests do NOT test the real WebKitGTK/Wails native
|
||||||
|
shell. For real desktop GUI verification, a separate AT-SPI/xdotool layer is
|
||||||
|
needed (not yet implemented).
|
||||||
|
|
||||||
|
See `docs/GUI_TESTING.md` for details.
|
||||||
|
|
||||||
|
## Debug logging
|
||||||
|
|
||||||
|
**Always use debug logging when investigating issues. Never rely on "it should work" — look at the logs.**
|
||||||
|
|
||||||
|
### Backend debug
|
||||||
|
|
||||||
|
Enable with `--debug` flag:
|
||||||
|
```bash
|
||||||
|
./verstak-desktop --debug
|
||||||
|
```
|
||||||
|
|
||||||
|
Logs go to: `~/.local/share/verstak/debug/verstak-YYYY-MM-DD-HHMMSS.log`
|
||||||
|
|
||||||
|
View in real-time:
|
||||||
|
```bash
|
||||||
|
tail -f ~/.local/share/verstak/debug/verstak-*.log
|
||||||
|
```
|
||||||
|
|
||||||
|
What's logged (when `--debug` is active):
|
||||||
|
- Plugin discovery: dirs scanned, each plugin found (id, name, version, source, root)
|
||||||
|
- Plugin lifecycle: capability registration, contribution registration, status transitions
|
||||||
|
- API calls: GetPlugins, GetContributions, GetCapabilities, ReloadPlugins, EnablePlugin, DisablePlugin
|
||||||
|
- Vault operations: open/close/status
|
||||||
|
|
||||||
|
### Frontend debug
|
||||||
|
|
||||||
|
Enable via **either**:
|
||||||
|
1. URL query param: `?debug` (not practical in Wails, use #2)
|
||||||
|
2. In browser console: `localStorage.setItem('verstak-debug', 'true')` then reload
|
||||||
|
|
||||||
|
Logs go to:
|
||||||
|
- Browser console (with `[debug]` prefix)
|
||||||
|
- localStorage buffer: `localStorage.getItem('verstak-debug-log')` (last 1000 entries)
|
||||||
|
|
||||||
|
Export frontend log from console:
|
||||||
|
```javascript
|
||||||
|
copy(JSON.parse(localStorage.getItem('verstak-debug-log')))
|
||||||
|
// or
|
||||||
|
window.__verstakDebug.exportLog()
|
||||||
|
```
|
||||||
|
|
||||||
|
What's logged:
|
||||||
|
- App startup: checkVault, GetAppSettings, GetVaultStatus
|
||||||
|
- Navigation: onNav, onOpenView, onOpenSettings, onCloseSettings
|
||||||
|
- PluginManager: loadAll (start/end, plugin count, each plugin status), reload, enable/disable
|
||||||
|
- Sidebar: onMount (plugins loaded, sidebar items count), handleNav, handleSidebarItem
|
||||||
|
|
||||||
|
### Debug workflow
|
||||||
|
|
||||||
|
1. User reports issue
|
||||||
|
2. Restart app with `--debug` and reproduce
|
||||||
|
3. Run `tail -f ~/.local/share/verstak/debug/verstak-*.log` and share output
|
||||||
|
4. For frontend issues: enable frontend debug view `exportLog()` output
|
||||||
|
5. Analyze logs, identify root cause, fix
|
||||||
|
6. Verify fix by asking user to reproduce again with debug on
|
||||||
|
|
||||||
|
**Never skip step 2-4. Always look at real logs before proposing fixes.**
|
||||||
|
|||||||
@@ -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,341 @@
|
|||||||
# 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
|
||||||
|
|
||||||
|
Closing the main window keeps Verstak running in the system tray. Use **Show Verstak** to bring it back and **Quit** to exit the application completely. This lets scheduled Todo reminders continue while the window is hidden.
|
||||||
|
|
||||||
|
### Verify a download
|
||||||
|
|
||||||
|
Each release includes a `SHA256SUMS` file.
|
||||||
|
|
||||||
|
On Linux:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sha256sum -c SHA256SUMS --ignore-missing
|
||||||
|
```
|
||||||
|
|
||||||
|
## First start
|
||||||
|
|
||||||
|
### 1. Create or open a vault
|
||||||
|
|
||||||
|
A **vault** is the root directory where Verstak stores your work.
|
||||||
|
|
||||||
|
Choose a writable local directory. For example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Documents/
|
||||||
|
└── Verstak/
|
||||||
|
```
|
||||||
|
|
||||||
|
Your files and notes remain ordinary files that can be accessed without Verstak.
|
||||||
|
|
||||||
|
### 2. Create your first Deal
|
||||||
|
|
||||||
|
Inside the vault, create a Deal for a project, client, server, device or another area of work.
|
||||||
|
|
||||||
|
For example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Home server
|
||||||
|
Customer Alpha
|
||||||
|
Verstak development
|
||||||
|
3D printer
|
||||||
|
Training course
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Add context
|
||||||
|
|
||||||
|
Open the Deal and add the information needed to return to it later:
|
||||||
|
|
||||||
|
* notes and decisions;
|
||||||
|
* documents and source files;
|
||||||
|
* useful links;
|
||||||
|
* access information;
|
||||||
|
* journal entries;
|
||||||
|
* tasks;
|
||||||
|
* materials captured from the browser.
|
||||||
|
|
||||||
|
### 4. Return through Overview
|
||||||
|
|
||||||
|
The Overview screen shows recent changes, unfinished captures, activity and other useful entry points back into your work.
|
||||||
|
|
||||||
|
## How Verstak stores data
|
||||||
|
|
||||||
|
Verstak follows several principles:
|
||||||
|
|
||||||
|
* your vault is local and works without an account or internet connection;
|
||||||
|
* files and notes remain readable outside the application;
|
||||||
|
* synchronization is optional and is not the source of truth;
|
||||||
|
* the user should understand where data is stored and what happens to it;
|
||||||
|
* application tools are provided by plugins rather than being permanently embedded into the core.
|
||||||
|
|
||||||
|
The desktop application loads plugins from a `plugins/` directory located beside the executable.
|
||||||
|
|
||||||
|
## Browser integration
|
||||||
|
|
||||||
|
The optional browser extension can send the following into Browser Inbox:
|
||||||
|
|
||||||
|
* the current page;
|
||||||
|
* selected text;
|
||||||
|
* a link;
|
||||||
|
* a downloaded or selected file.
|
||||||
|
|
||||||
|
Repository:
|
||||||
|
|
||||||
|
[mirivlad/verstak-browser-extension](https://github.com/mirivlad/verstak-browser-extension)
|
||||||
|
|
||||||
|
To connect the extension:
|
||||||
|
|
||||||
|
1. Open Browser Inbox settings in Verstak.
|
||||||
|
2. Copy the Receiver URL and Pairing Token.
|
||||||
|
3. Paste them into the extension settings.
|
||||||
|
4. Save the settings and send a test page.
|
||||||
|
|
||||||
|
Passive domain activity tracking is disabled by default. When enabled, it sends bounded time totals by normalized domain. It does not send page contents, keystrokes or full browsing history.
|
||||||
|
|
||||||
|
## Optional synchronization
|
||||||
|
|
||||||
|
Local use does not require a server.
|
||||||
|
|
||||||
|
For synchronization between devices, deploy the optional self-hosted
|
||||||
|
[Verstak Sync Server](https://github.com/mirivlad/verstak-sync-server).
|
||||||
|
|
||||||
|
Each vault is connected separately. The local vault remains the primary copy of your data.
|
||||||
|
|
||||||
|
## Build from source
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
|
||||||
|
* Go 1.24 or newer;
|
||||||
|
* Node.js 20 or newer with npm;
|
||||||
|
* Python 3;
|
||||||
|
* Git;
|
||||||
|
* Wails v2 build dependencies;
|
||||||
|
* WebKitGTK development packages on Linux.
|
||||||
|
* Ayatana AppIndicator development files on Linux (`sudo apt install libayatana-appindicator3-dev`).
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
|
## Фоновая работа и трей
|
||||||
|
|
||||||
|
Закрытие главного окна не завершает Верстак: приложение остаётся в системном трее. Выберите **Show Verstak**, чтобы вернуть окно, или **Quit**, чтобы полностью завершить приложение. Благодаря этому напоминания 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;
|
||||||
|
* зависимости для сборки Wails v2;
|
||||||
|
* пакеты разработки WebKitGTK в Linux.
|
||||||
|
* файлы разработки Ayatana AppIndicator в Linux (`sudo apt install libayatana-appindicator3-dev`).
|
||||||
|
|
||||||
|
Зависимости для конкретного дистрибутива перечислены в
|
||||||
|
[документации 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,17 +1,26 @@
|
|||||||
// Smoke-platform validates that the platform-test plugin is discovered correctly
|
// Package api provides Wails-bound methods for the frontend.
|
||||||
// by the Verstak desktop runtime. This runs headless — no Wails GUI needed.
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/verstak/verstak-desktop/internal/core/capability"
|
"github.com/verstak/verstak-desktop/internal/core/capability"
|
||||||
|
"github.com/verstak/verstak-desktop/internal/core/contribution"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/plugin"
|
"github.com/verstak/verstak-desktop/internal/core/plugin"
|
||||||
|
"github.com/verstak/verstak-desktop/internal/core/pluginstate"
|
||||||
|
"github.com/verstak/verstak-desktop/internal/core/vault"
|
||||||
|
"github.com/verstak/verstak-desktop/internal/core/workspace"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
testEnableDisable := flag.Bool("test-enable-disable", false, "Test enable/disable lifecycle")
|
||||||
|
testWorkspace := flag.Bool("test-workspace", false, "Test workspace/cases lifecycle")
|
||||||
|
testContributions := flag.Bool("test-contributions", false, "Test contribution registry lifecycle")
|
||||||
|
flag.Parse()
|
||||||
exitCode := 0
|
exitCode := 0
|
||||||
defer func() {
|
defer func() {
|
||||||
os.Exit(exitCode)
|
os.Exit(exitCode)
|
||||||
@@ -20,6 +29,21 @@ func main() {
|
|||||||
root, _ := os.Getwd()
|
root, _ := os.Getwd()
|
||||||
pluginDir := filepath.Join(root, "plugins")
|
pluginDir := filepath.Join(root, "plugins")
|
||||||
|
|
||||||
|
if *testContributions {
|
||||||
|
runContributionsTest(root)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if *testWorkspace {
|
||||||
|
runWorkspaceTest(root)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if *testEnableDisable {
|
||||||
|
runEnableDisableTest(root)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Printf("=== smoke-platform: headless plugin verification ===\n\n")
|
fmt.Printf("=== smoke-platform: headless plugin verification ===\n\n")
|
||||||
fmt.Printf(" plugin dir: %s\n", pluginDir)
|
fmt.Printf(" plugin dir: %s\n", pluginDir)
|
||||||
|
|
||||||
@@ -152,7 +176,6 @@ func main() {
|
|||||||
fmt.Printf("\n[capability registration]\n")
|
fmt.Printf("\n[capability registration]\n")
|
||||||
reg := capability.NewRegistry()
|
reg := capability.NewRegistry()
|
||||||
|
|
||||||
// Register core capabilities (same list as main.go)
|
|
||||||
coreCaps := []string{
|
coreCaps := []string{
|
||||||
"verstak/core/plugin-manager/v1",
|
"verstak/core/plugin-manager/v1",
|
||||||
"verstak/core/capability-registry/v1",
|
"verstak/core/capability-registry/v1",
|
||||||
@@ -167,7 +190,6 @@ func main() {
|
|||||||
fmt.Printf(" ✅ registered %d core capabilities\n", len(coreCaps))
|
fmt.Printf(" ✅ registered %d core capabilities\n", len(coreCaps))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register vault capability (core service)
|
|
||||||
if err := reg.Register("verstak-desktop", []string{"verstak/core/vault/v1"}); err != nil {
|
if err := reg.Register("verstak-desktop", []string{"verstak/core/vault/v1"}); err != nil {
|
||||||
fmt.Printf(" ❌ register vault capability: %v\n", err)
|
fmt.Printf(" ❌ register vault capability: %v\n", err)
|
||||||
allGood = false
|
allGood = false
|
||||||
@@ -175,7 +197,13 @@ func main() {
|
|||||||
fmt.Printf(" ✅ registered vault capability\n")
|
fmt.Printf(" ✅ registered vault capability\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register plugin capabilities
|
if err := reg.Register("verstak-desktop", []string{"verstak/core/workspace/v1"}); err != nil {
|
||||||
|
fmt.Printf(" ❌ register workspace capability: %v\n", err)
|
||||||
|
allGood = false
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" ✅ registered workspace capability\n")
|
||||||
|
}
|
||||||
|
|
||||||
for _, p := range m.Provides {
|
for _, p := range m.Provides {
|
||||||
if err := reg.Register(m.ID, []string{p}); err != nil {
|
if err := reg.Register(m.ID, []string{p}); err != nil {
|
||||||
fmt.Printf(" ❌ register capability %s: %v\n", p, err)
|
fmt.Printf(" ❌ register capability %s: %v\n", p, err)
|
||||||
@@ -196,7 +224,16 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 9. Verify required capabilities resolved ──
|
// ── 9. Verify workspace capability by name ──
|
||||||
|
fmt.Printf("\n[workspace capability verification]\n")
|
||||||
|
if reg.Has("verstak/core/workspace/v1") {
|
||||||
|
fmt.Printf(" ✅ workspace capability present: verstak/core/workspace/v1\n")
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" ❌ workspace capability MISSING: verstak/core/workspace/v1\n")
|
||||||
|
allGood = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 10. Verify required capabilities resolved ──
|
||||||
fmt.Printf("\n[required capability resolution]\n")
|
fmt.Printf("\n[required capability resolution]\n")
|
||||||
missingRequired := reg.CheckRequired(m.Requires)
|
missingRequired := reg.CheckRequired(m.Requires)
|
||||||
if len(missingRequired) > 0 {
|
if len(missingRequired) > 0 {
|
||||||
@@ -208,7 +245,7 @@ func main() {
|
|||||||
fmt.Printf(" ✅ all required capabilities resolved\n")
|
fmt.Printf(" ✅ all required capabilities resolved\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 10. Check optional capabilities ──
|
// ── 11. Check optional capabilities ──
|
||||||
fmt.Printf("\n[optional capability resolution]\n")
|
fmt.Printf("\n[optional capability resolution]\n")
|
||||||
missingOptional := reg.CheckRequired(m.OptionalRequires)
|
missingOptional := reg.CheckRequired(m.OptionalRequires)
|
||||||
if len(missingOptional) > 0 {
|
if len(missingOptional) > 0 {
|
||||||
@@ -217,7 +254,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 11. Determine expected status ──
|
// ── 12. Determine expected status ──
|
||||||
fmt.Printf("\n[plugin status]\n")
|
fmt.Printf("\n[plugin status]\n")
|
||||||
expectedStatus := "loaded"
|
expectedStatus := "loaded"
|
||||||
if len(missingOptional) > 0 {
|
if len(missingOptional) > 0 {
|
||||||
@@ -236,18 +273,18 @@ func main() {
|
|||||||
allGood = false
|
allGood = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 12. Total capability count ──
|
// ── 13. Total capability count ──
|
||||||
fmt.Printf("\n[capability count]\n")
|
fmt.Printf("\n[capability count]\n")
|
||||||
totalCaps := len(reg.List())
|
totalCaps := len(reg.List())
|
||||||
fmt.Printf(" total capabilities: %d\n", totalCaps)
|
fmt.Printf(" total capabilities: %d\n", totalCaps)
|
||||||
if totalCaps >= 8 {
|
if totalCaps >= 9 {
|
||||||
fmt.Printf(" ✅ total capabilities >= 8 (%d)\n", totalCaps)
|
fmt.Printf(" ✅ total capabilities >= 9 (%d)\n", totalCaps)
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf(" ❌ total capabilities < 8 (got %d, expected >= 8)\n", totalCaps)
|
fmt.Printf(" ❌ total capabilities < 9 (got %d, expected >= 9)\n", totalCaps)
|
||||||
allGood = false
|
allGood = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 13. Summary ──
|
// ── 14. Summary ──
|
||||||
fmt.Printf("\n=== summary ===\n")
|
fmt.Printf("\n=== summary ===\n")
|
||||||
if allGood {
|
if allGood {
|
||||||
fmt.Printf("✅ smoke-platform passed\n")
|
fmt.Printf("✅ smoke-platform passed\n")
|
||||||
@@ -256,3 +293,748 @@ func main() {
|
|||||||
exitCode = 1
|
exitCode = 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// runEnableDisableTest tests the enable/disable lifecycle with vault plugin state.
|
||||||
|
func runEnableDisableTest(root string) {
|
||||||
|
exitCode := 0
|
||||||
|
defer func() {
|
||||||
|
os.Exit(exitCode)
|
||||||
|
}()
|
||||||
|
|
||||||
|
fmt.Printf("=== smoke-platform: enable/disable test ===\n\n")
|
||||||
|
|
||||||
|
// Create a temp vault
|
||||||
|
tmpDir, err := os.MkdirTemp("", "verstak-smoke-*")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf(" ❌ failed to create temp dir: %v\n", err)
|
||||||
|
exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
vaultPath := filepath.Join(tmpDir, "testvault")
|
||||||
|
fmt.Printf(" vault path: %s\n", vaultPath)
|
||||||
|
|
||||||
|
v := vault.NewVault(nil)
|
||||||
|
if err := v.CreateVault(vaultPath); err != nil {
|
||||||
|
fmt.Printf(" ❌ create vault: %v\n", err)
|
||||||
|
exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf(" ✅ vault created\n")
|
||||||
|
|
||||||
|
openedPath := v.GetVaultPath()
|
||||||
|
if err := v.OpenVault(openedPath); err != nil {
|
||||||
|
fmt.Printf(" ❌ open vault: %v\n", err)
|
||||||
|
exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf(" ✅ vault opened at %s\n", openedPath)
|
||||||
|
|
||||||
|
psm := pluginstate.NewManager(v)
|
||||||
|
if err := psm.Load(); err != nil {
|
||||||
|
fmt.Printf(" ❌ load plugin state: %v\n", err)
|
||||||
|
exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf(" ✅ plugin state loaded\n")
|
||||||
|
|
||||||
|
// Discover plugins
|
||||||
|
pluginDir := filepath.Join(root, "plugins")
|
||||||
|
plugins, _ := plugin.DiscoverPlugins([]string{pluginDir})
|
||||||
|
if len(plugins) == 0 {
|
||||||
|
fmt.Printf(" ❌ no plugins discovered\n")
|
||||||
|
exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf(" ✅ discovered %d plugin(s)\n", len(plugins))
|
||||||
|
|
||||||
|
var target *plugin.Plugin
|
||||||
|
for i, p := range plugins {
|
||||||
|
if p.Manifest.ID == "verstak.platform-test" {
|
||||||
|
target = &plugins[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if target == nil {
|
||||||
|
fmt.Printf(" ❌ platform-test not found\n")
|
||||||
|
exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf(" ✅ platform-test found\n")
|
||||||
|
|
||||||
|
// Register capabilities
|
||||||
|
reg := capability.NewRegistry()
|
||||||
|
coreCaps := []string{
|
||||||
|
"verstak/core/plugin-manager/v1",
|
||||||
|
"verstak/core/capability-registry/v1",
|
||||||
|
"verstak/core/contribution-registry/v1",
|
||||||
|
"verstak/core/permissions/v1",
|
||||||
|
"verstak/core/events/v1",
|
||||||
|
}
|
||||||
|
reg.Register("verstak-desktop", coreCaps)
|
||||||
|
reg.Register("verstak-desktop", []string{"verstak/core/vault/v1"})
|
||||||
|
for _, cap := range target.Manifest.Provides {
|
||||||
|
reg.Register(target.Manifest.ID, []string{cap})
|
||||||
|
}
|
||||||
|
totalCaps := len(reg.List())
|
||||||
|
fmt.Printf(" ✅ registered %d capabilities (core + plugin)\n", totalCaps)
|
||||||
|
|
||||||
|
// ── Test 1: Disable platform-test ──
|
||||||
|
fmt.Printf("\n[disable]\n")
|
||||||
|
if err := psm.DisablePlugin("verstak.platform-test"); err != nil {
|
||||||
|
fmt.Printf(" ❌ disable: %v\n", err)
|
||||||
|
exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf(" ✅ disabled platform-test\n")
|
||||||
|
|
||||||
|
if !psm.IsDisabled("verstak.platform-test") {
|
||||||
|
fmt.Printf(" ❌ IsDisabled returned false after disable\n")
|
||||||
|
exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf(" ✅ IsDisabled: true\n")
|
||||||
|
|
||||||
|
if psm.IsEnabled("verstak.platform-test") {
|
||||||
|
fmt.Printf(" ❌ IsEnabled returned true after disable\n")
|
||||||
|
exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf(" ✅ IsEnabled: false\n")
|
||||||
|
|
||||||
|
state := psm.Get()
|
||||||
|
found := false
|
||||||
|
for _, dp := range state.DesiredPlugins {
|
||||||
|
if dp.ID == "verstak.platform-test" {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found {
|
||||||
|
fmt.Printf(" ✅ platform-test in desiredPlugins\n")
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" ℹ️ platform-test not in desiredPlugins (ok if not recorded)\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Test 2: Enable platform-test ──
|
||||||
|
fmt.Printf("\n[enable]\n")
|
||||||
|
if err := psm.EnablePlugin("verstak.platform-test"); err != nil {
|
||||||
|
fmt.Printf(" ❌ enable: %v\n", err)
|
||||||
|
exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf(" ✅ enabled platform-test\n")
|
||||||
|
|
||||||
|
if !psm.IsEnabled("verstak.platform-test") {
|
||||||
|
fmt.Printf(" ❌ IsEnabled returned false after enable\n")
|
||||||
|
exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf(" ✅ IsEnabled: true\n")
|
||||||
|
|
||||||
|
if psm.IsDisabled("verstak.platform-test") {
|
||||||
|
fmt.Printf(" ❌ IsDisabled returned true after enable\n")
|
||||||
|
exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf(" ✅ IsDisabled: false\n")
|
||||||
|
|
||||||
|
// ── Test 3: Verify plugins.json on disk ──
|
||||||
|
fmt.Printf("\n[plugins.json verification]\n")
|
||||||
|
statePath := filepath.Join(v.GetVaultPath(), ".verstak", "plugins.json")
|
||||||
|
data, err := os.ReadFile(statePath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf(" ❌ read plugins.json: %v\n", err)
|
||||||
|
exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf(" ✅ plugins.json exists on disk\n")
|
||||||
|
fmt.Printf(" content:\n%s\n", string(data))
|
||||||
|
|
||||||
|
fmt.Printf("\n=== summary ===\n")
|
||||||
|
fmt.Printf("✅ enable/disable test passed\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// runContributionsTest verifies the full manifest → discovery → registry pipeline,
|
||||||
|
// mirroring the real ReloadPlugins() flow in internal/api/app.go.
|
||||||
|
//
|
||||||
|
// Flow:
|
||||||
|
// 1. plugin.json on disk → DiscoverPlugins() → parsed manifest
|
||||||
|
// 2. Manifest contributions fragment displayed (proof it comes from real plugin.json)
|
||||||
|
// 3. Capability registration + resolution → determine plugin status
|
||||||
|
// 4. Contribution registration (gated by status: only loaded/degraded get contributions)
|
||||||
|
// 5. Verify all 4 contribution types present by name, data matches manifest
|
||||||
|
// 6. Disable plugin → Unregister → contributions gone
|
||||||
|
// 7. Re-enable → Unregister+Register (ReloadPlugins) → contributions return
|
||||||
|
// 8. Reload → Unregister+Register → no duplicates
|
||||||
|
func runContributionsTest(root string) {
|
||||||
|
exitCode := 0
|
||||||
|
defer func() {
|
||||||
|
os.Exit(exitCode)
|
||||||
|
}()
|
||||||
|
|
||||||
|
fmt.Printf("=== smoke-platform: contribution registry test ===\n\n")
|
||||||
|
|
||||||
|
// Create temp vault
|
||||||
|
tmpDir, err := os.MkdirTemp("", "verstak-smoke-*")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf(" ❌ failed to create temp dir: %v\n", err)
|
||||||
|
exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
vaultPath := filepath.Join(tmpDir, "testvault")
|
||||||
|
fmt.Printf(" vault path: %s\n", vaultPath)
|
||||||
|
|
||||||
|
v := vault.NewVault(nil)
|
||||||
|
if err := v.CreateVault(vaultPath); err != nil {
|
||||||
|
fmt.Printf(" ❌ create vault: %v\n", err)
|
||||||
|
exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf(" ✅ vault created\n")
|
||||||
|
|
||||||
|
openedPath := v.GetVaultPath()
|
||||||
|
if err := v.OpenVault(openedPath); err != nil {
|
||||||
|
fmt.Printf(" ❌ open vault: %v\n", err)
|
||||||
|
exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf(" ✅ vault opened at %s\n", openedPath)
|
||||||
|
|
||||||
|
psm := pluginstate.NewManager(v)
|
||||||
|
if err := psm.Load(); err != nil {
|
||||||
|
fmt.Printf(" ❌ load plugin state: %v\n", err)
|
||||||
|
exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf(" ✅ plugin state loaded\n")
|
||||||
|
|
||||||
|
// ── Step 1: Discover plugins from disk (reads real plugin.json) ──
|
||||||
|
pluginDir := filepath.Join(root, "plugins")
|
||||||
|
plugins, _ := plugin.DiscoverPlugins([]string{pluginDir})
|
||||||
|
if len(plugins) == 0 {
|
||||||
|
fmt.Printf(" ❌ no plugins discovered\n")
|
||||||
|
exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf(" ✅ discovered %d plugin(s)\n", len(plugins))
|
||||||
|
|
||||||
|
// Find platform-test
|
||||||
|
var target *plugin.Plugin
|
||||||
|
for i, p := range plugins {
|
||||||
|
if p.Manifest.ID == "verstak.platform-test" {
|
||||||
|
target = &plugins[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if target == nil {
|
||||||
|
fmt.Printf(" ❌ platform-test not found\n")
|
||||||
|
exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf(" ✅ platform-test found: %s@%s\n", target.Manifest.ID, target.Manifest.Version)
|
||||||
|
|
||||||
|
// ── Step 2: Show manifest contributions fragment (proof it comes from plugin.json) ──
|
||||||
|
fmt.Printf("\n[manifest contributions from plugin.json]\n")
|
||||||
|
fmt.Printf(" plugin: %s\n", target.Manifest.ID)
|
||||||
|
if c := target.Manifest.Contributes; c != nil {
|
||||||
|
fmt.Printf(" sidebarItems: %d\n", len(c.SidebarItems))
|
||||||
|
for _, s := range c.SidebarItems {
|
||||||
|
fmt.Printf(" - id=%q title=%q view=%q\n", s.ID, s.Title, s.View)
|
||||||
|
}
|
||||||
|
fmt.Printf(" views: %d\n", len(c.Views))
|
||||||
|
for _, vw := range c.Views {
|
||||||
|
fmt.Printf(" - id=%q title=%q component=%q\n", vw.ID, vw.Title, vw.Component)
|
||||||
|
}
|
||||||
|
fmt.Printf(" settingsPanels: %d\n", len(c.SettingsPanels))
|
||||||
|
for _, s := range c.SettingsPanels {
|
||||||
|
fmt.Printf(" - id=%q title=%q component=%q\n", s.ID, s.Title, s.Component)
|
||||||
|
}
|
||||||
|
fmt.Printf(" commands: %d\n", len(c.Commands))
|
||||||
|
for _, cmd := range c.Commands {
|
||||||
|
fmt.Printf(" - id=%q title=%q\n", cmd.ID, cmd.Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 2b: Frontend bundle verification ──
|
||||||
|
fmt.Printf("\n[frontend bundle verification]\n")
|
||||||
|
frontendOk := true
|
||||||
|
|
||||||
|
// 2b.1 — Check manifest declares frontend.entry
|
||||||
|
if target.Manifest.Frontend == nil {
|
||||||
|
fmt.Printf(" ❌ manifest.frontend is nil — plugin has no frontend config\n")
|
||||||
|
frontendOk = false
|
||||||
|
} else {
|
||||||
|
expectedEntry := "frontend/dist/index.js"
|
||||||
|
if target.Manifest.Frontend.Entry != expectedEntry {
|
||||||
|
fmt.Printf(" ❌ frontend.entry: expected %q, got %q\n", expectedEntry, target.Manifest.Frontend.Entry)
|
||||||
|
frontendOk = false
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" ✅ frontend.entry: %s\n", target.Manifest.Frontend.Entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2b.2 — Resolve entry path and check file exists on disk
|
||||||
|
resolvedEntry := filepath.Join(target.RootPath, target.Manifest.Frontend.Entry)
|
||||||
|
if _, err := os.Stat(resolvedEntry); os.IsNotExist(err) {
|
||||||
|
fmt.Printf(" ❌ frontend bundle NOT FOUND at resolved path: %s\n", resolvedEntry)
|
||||||
|
frontendOk = false
|
||||||
|
} else if err != nil {
|
||||||
|
fmt.Printf(" ❌ frontend bundle stat error: %v\n", err)
|
||||||
|
frontendOk = false
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" ✅ frontend bundle exists: %s\n", resolvedEntry)
|
||||||
|
|
||||||
|
// 2b.3 — Read file and check for "VerstakPluginRegister"
|
||||||
|
data, err := os.ReadFile(resolvedEntry)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf(" ❌ failed to read frontend bundle: %v\n", err)
|
||||||
|
frontendOk = false
|
||||||
|
} else {
|
||||||
|
content := string(data)
|
||||||
|
|
||||||
|
if strings.Contains(content, "VerstakPluginRegister") {
|
||||||
|
fmt.Printf(" ✅ bundle contains VerstakPluginRegister contract\n")
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" ❌ bundle MISSING VerstakPluginRegister contract\n")
|
||||||
|
frontendOk = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2b.4 — Check for expected component names
|
||||||
|
expectedComponents := []string{"DiagnosticsPanel", "PlatformTestSettings"}
|
||||||
|
for _, comp := range expectedComponents {
|
||||||
|
if strings.Contains(content, comp) {
|
||||||
|
fmt.Printf(" ✅ component registered: %s\n", comp)
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" ❌ component MISSING from bundle: %s\n", comp)
|
||||||
|
frontendOk = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if frontendOk {
|
||||||
|
fmt.Printf(" ✅ frontend bundle checks passed\n")
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" ❌ frontend bundle checks FAILED\n")
|
||||||
|
exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 3: Register capabilities (simulates main.go + ReloadPlugins) ──
|
||||||
|
reg := capability.NewRegistry()
|
||||||
|
coreCaps := []string{
|
||||||
|
"verstak/core/plugin-manager/v1",
|
||||||
|
"verstak/core/capability-registry/v1",
|
||||||
|
"verstak/core/contribution-registry/v1",
|
||||||
|
"verstak/core/permissions/v1",
|
||||||
|
"verstak/core/events/v1",
|
||||||
|
}
|
||||||
|
_ = reg.Register("verstak-desktop", coreCaps)
|
||||||
|
_ = reg.Register("verstak-desktop", []string{"verstak/core/vault/v1"})
|
||||||
|
for _, capID := range target.Manifest.Provides {
|
||||||
|
_ = reg.Register(target.Manifest.ID, []string{capID})
|
||||||
|
}
|
||||||
|
fmt.Printf("\n[capability registration]\n")
|
||||||
|
fmt.Printf(" registered %d capabilities\n", len(reg.List()))
|
||||||
|
|
||||||
|
// ── Step 4: ReloadPlugins flow with capability gating ──
|
||||||
|
fmt.Printf("\n[register contributions (ReloadPlugins flow)]\n")
|
||||||
|
contribReg := contribution.NewRegistry()
|
||||||
|
|
||||||
|
// (a) Check disabled → skip
|
||||||
|
if target.Status == plugin.StatusDisabled {
|
||||||
|
fmt.Printf(" ❌ plugin is disabled — contributions should NOT be registered\n")
|
||||||
|
exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// (b) Capability resolution (same as ReloadPlugins)
|
||||||
|
missingRequired := reg.CheckRequired(target.Manifest.Requires)
|
||||||
|
missingOptional := reg.CheckRequired(target.Manifest.OptionalRequires)
|
||||||
|
status := plugin.StatusLoaded
|
||||||
|
if len(missingRequired) > 0 {
|
||||||
|
status = plugin.StatusMissingRequiredCapability
|
||||||
|
} else if len(missingOptional) > 0 {
|
||||||
|
status = plugin.StatusDegraded
|
||||||
|
}
|
||||||
|
|
||||||
|
// (c) Only loaded/degraded get contributions
|
||||||
|
if status == plugin.StatusLoaded || status == plugin.StatusDegraded {
|
||||||
|
fmt.Printf(" plugin status=%s → contributions WILL be registered\n", status)
|
||||||
|
contribReg.Register(target.Manifest.ID, target.Manifest.Contributes)
|
||||||
|
fmt.Printf(" contributions registered for %s\n", target.Manifest.ID)
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" ❌ plugin status=%s — contributions should NOT be registered\n", status)
|
||||||
|
exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 5: Verify contributions by name, data matches manifest ──
|
||||||
|
fmt.Printf("\n[verify contributions by name]\n")
|
||||||
|
allGood := true
|
||||||
|
|
||||||
|
sidebarItems := contribReg.SidebarItems()
|
||||||
|
if len(sidebarItems) != 1 || sidebarItems[0].Item.Title != "Platform Test" {
|
||||||
|
fmt.Printf(" ❌ sidebarItems mismatch: got %v\n", sidebarItems)
|
||||||
|
allGood = false
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" ✅ sidebarItem: plugin=%s id=%s title=%s\n",
|
||||||
|
sidebarItems[0].PluginID, sidebarItems[0].Item.ID, sidebarItems[0].Item.Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
views := contribReg.Views()
|
||||||
|
if len(views) != 1 || views[0].Item.Title != "Platform Diagnostics" {
|
||||||
|
fmt.Printf(" ❌ views mismatch: got %v\n", views)
|
||||||
|
allGood = false
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" ✅ view: plugin=%s id=%s title=%s component=%s\n",
|
||||||
|
views[0].PluginID, views[0].Item.ID, views[0].Item.Title, views[0].Item.Component)
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsPanels := contribReg.SettingsPanels()
|
||||||
|
if len(settingsPanels) != 1 || settingsPanels[0].Item.Title != "Platform Test Settings" {
|
||||||
|
fmt.Printf(" ❌ settingsPanels mismatch: got %v\n", settingsPanels)
|
||||||
|
allGood = false
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" ✅ settingsPanel: plugin=%s id=%s title=%s\n",
|
||||||
|
settingsPanels[0].PluginID, settingsPanels[0].Item.ID, settingsPanels[0].Item.Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
commands := contribReg.Commands()
|
||||||
|
if len(commands) != 2 {
|
||||||
|
fmt.Printf(" ❌ expected 2 commands, got %d\n", len(commands))
|
||||||
|
allGood = false
|
||||||
|
} else {
|
||||||
|
for _, c := range commands {
|
||||||
|
fmt.Printf(" command: plugin=%s id=%s title=%s\n", c.PluginID, c.Item.ID, c.Item.Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !allGood {
|
||||||
|
fmt.Printf(" ❌ some contribution types missing or data mismatch\n")
|
||||||
|
exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf(" ✅ all 4 contribution types present, data matches manifest\n")
|
||||||
|
|
||||||
|
// ── Step 6: Disable → Unregister → contributions removed ──
|
||||||
|
fmt.Printf("\n[disable plugin → Unregister → contributions removed]\n")
|
||||||
|
if err := psm.DisablePlugin("verstak.platform-test"); err != nil {
|
||||||
|
fmt.Printf(" ❌ disable: %v\n", err)
|
||||||
|
exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf(" disabled platform-test\n")
|
||||||
|
|
||||||
|
// ReloadPlugins: Unregister before next scan, disabled plugins don't get re-registered
|
||||||
|
contribReg.Unregister("verstak.platform-test")
|
||||||
|
remaining := len(contribReg.Views()) + len(contribReg.SidebarItems()) +
|
||||||
|
len(contribReg.Commands()) + len(contribReg.SettingsPanels())
|
||||||
|
fmt.Printf(" remaining contributions: %d\n", remaining)
|
||||||
|
if remaining != 0 {
|
||||||
|
fmt.Printf(" ❌ contributions not removed after disable\n")
|
||||||
|
exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf(" ✅ all contributions removed\n")
|
||||||
|
|
||||||
|
// ── Step 7: Re-enable → contributions return via Unregister+Register ──
|
||||||
|
fmt.Printf("\n[re-enable → Unregister+Register → contributions return]\n")
|
||||||
|
if err := psm.EnablePlugin("verstak.platform-test"); err != nil {
|
||||||
|
fmt.Printf(" ❌ enable: %v\n", err)
|
||||||
|
exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf(" enabled platform-test\n")
|
||||||
|
|
||||||
|
// ReloadPlugins: Unregister before Register (prevents duplicates)
|
||||||
|
contribReg.Unregister(target.Manifest.ID)
|
||||||
|
contribReg.Register(target.Manifest.ID, target.Manifest.Contributes)
|
||||||
|
count := len(contribReg.Views()) + len(contribReg.SidebarItems()) +
|
||||||
|
len(contribReg.Commands()) + len(contribReg.SettingsPanels())
|
||||||
|
fmt.Printf(" contributions after re-enable: %d\n", count)
|
||||||
|
if count < 4 {
|
||||||
|
fmt.Printf(" ❌ expected >= 4 contributions, got %d\n", count)
|
||||||
|
exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf(" ✅ contributions returned (count=%d)\n", count)
|
||||||
|
|
||||||
|
// ── Step 8: Reload → Unregister+Register → no duplicates ──
|
||||||
|
fmt.Printf("\n[reload → Unregister+Register → no duplicates]\n")
|
||||||
|
beforeCount := count
|
||||||
|
contribReg.Unregister(target.Manifest.ID)
|
||||||
|
contribReg.Register(target.Manifest.ID, target.Manifest.Contributes)
|
||||||
|
afterCount := len(contribReg.Views()) + len(contribReg.SidebarItems()) +
|
||||||
|
len(contribReg.Commands()) + len(contribReg.SettingsPanels())
|
||||||
|
fmt.Printf(" before reload: %d, after reload: %d\n", beforeCount, afterCount)
|
||||||
|
if afterCount != beforeCount {
|
||||||
|
fmt.Printf(" ❌ duplicate after reload: before=%d after=%d\n", beforeCount, afterCount)
|
||||||
|
exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf(" ✅ no duplicate contributions after reload (count=%d)\n", afterCount)
|
||||||
|
|
||||||
|
fmt.Printf("\n=== summary ===\n")
|
||||||
|
if exitCode == 0 {
|
||||||
|
fmt.Printf("✅ contribution registry test passed\n")
|
||||||
|
} else {
|
||||||
|
fmt.Printf("❌ contribution registry test failed\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runWorkspaceTest(root string) {
|
||||||
|
exitCode := 0
|
||||||
|
defer func() {
|
||||||
|
os.Exit(exitCode)
|
||||||
|
}()
|
||||||
|
|
||||||
|
fmt.Printf("=== smoke-platform: workspace test ===\n\n")
|
||||||
|
|
||||||
|
tmpDir, err := os.MkdirTemp("", "verstak-ws-smoke-*")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf(" ❌ failed to create temp dir: %v\n", err)
|
||||||
|
exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
vaultPath := filepath.Join(tmpDir, "testvault")
|
||||||
|
fmt.Printf(" vault path: %s\n", vaultPath)
|
||||||
|
|
||||||
|
v := vault.NewVault(nil)
|
||||||
|
if err := v.CreateVault(vaultPath); err != nil {
|
||||||
|
fmt.Printf(" ❌ create vault: %v\n", err)
|
||||||
|
exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf(" ✅ vault created\n")
|
||||||
|
|
||||||
|
openedPath := v.GetVaultPath()
|
||||||
|
if err := v.OpenVault(openedPath); err != nil {
|
||||||
|
fmt.Printf(" ❌ open vault: %v\n", err)
|
||||||
|
exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf(" ✅ vault opened at %s\n", openedPath)
|
||||||
|
|
||||||
|
fmt.Printf("\n[workspace init]\n")
|
||||||
|
ws := workspace.NewManager(openedPath)
|
||||||
|
if err := ws.Load(); err != nil {
|
||||||
|
fmt.Printf(" ❌ load workspace: %v\n", err)
|
||||||
|
exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf(" ✅ workspace loaded\n")
|
||||||
|
|
||||||
|
tree := ws.GetTree()
|
||||||
|
if len(tree.Nodes) != 1 {
|
||||||
|
fmt.Printf(" ❌ expected 1 root node, got %d\n", len(tree.Nodes))
|
||||||
|
exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf(" ✅ root node exists (id=%s)\n", tree.Nodes[0].ID)
|
||||||
|
rootID := tree.Nodes[0].ID
|
||||||
|
|
||||||
|
fmt.Printf("\n[workspace capability]\n")
|
||||||
|
reg := capability.NewRegistry()
|
||||||
|
reg.Register("verstak-desktop", []string{
|
||||||
|
"verstak/core/plugin-manager/v1",
|
||||||
|
"verstak/core/capability-registry/v1",
|
||||||
|
"verstak/core/contribution-registry/v1",
|
||||||
|
"verstak/core/permissions/v1",
|
||||||
|
"verstak/core/events/v1",
|
||||||
|
})
|
||||||
|
reg.Register("verstak-desktop", []string{"verstak/core/vault/v1"})
|
||||||
|
reg.Register("verstak-desktop", []string{"verstak/core/workspace/v1"})
|
||||||
|
if !reg.Has("verstak/core/workspace/v1") {
|
||||||
|
fmt.Printf(" ❌ workspace capability not registered\n")
|
||||||
|
exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf(" ✅ workspace capability registered\n")
|
||||||
|
totalCaps := len(reg.List())
|
||||||
|
if totalCaps < 7 {
|
||||||
|
fmt.Printf(" ❌ expected >= 7 capabilities, got %d\n", totalCaps)
|
||||||
|
exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf(" ✅ total capabilities >= 7 (%d)\n", totalCaps)
|
||||||
|
|
||||||
|
fmt.Printf("\n[create case]\n")
|
||||||
|
caseNode, err := ws.CreateNode(rootID, workspace.TypeCase, "Test Case")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf(" ❌ create case: %v\n", err)
|
||||||
|
exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf(" ✅ case created: %s\n", caseNode.Title)
|
||||||
|
|
||||||
|
fmt.Printf("\n[create folder]\n")
|
||||||
|
folderNode, err := ws.CreateNode(rootID, workspace.TypeFolder, "Test Folder")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf(" ❌ create folder: %v\n", err)
|
||||||
|
exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf(" ✅ folder created: %s\n", folderNode.Title)
|
||||||
|
|
||||||
|
fmt.Printf("\n[create nested case]\n")
|
||||||
|
nestedCase, err := ws.CreateNode(folderNode.ID, workspace.TypeCase, "Nested Case")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf(" ❌ create nested case: %v\n", err)
|
||||||
|
exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf(" ✅ nested case created: %s\n", nestedCase.Title)
|
||||||
|
|
||||||
|
fmt.Printf("\n[tree structure]\n")
|
||||||
|
tree = ws.GetTree()
|
||||||
|
if len(tree.Nodes) != 4 {
|
||||||
|
fmt.Printf(" ❌ expected 4 nodes, got %d\n", len(tree.Nodes))
|
||||||
|
exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf(" ✅ tree has 4 nodes\n")
|
||||||
|
children := ws.ListChildren(rootID)
|
||||||
|
if len(children) != 2 {
|
||||||
|
fmt.Printf(" ❌ expected 2 root children, got %d\n", len(children))
|
||||||
|
exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf(" ✅ root has 2 children\n")
|
||||||
|
|
||||||
|
fmt.Printf("\n[rename]\n")
|
||||||
|
if err := ws.RenameNode(caseNode.ID, "Renamed Case"); err != nil {
|
||||||
|
fmt.Printf(" ❌ rename: %v\n", err)
|
||||||
|
exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
renamed, _ := ws.GetNode(caseNode.ID)
|
||||||
|
if renamed.Title != "Renamed Case" {
|
||||||
|
fmt.Printf(" ❌ rename failed: got %q\n", renamed.Title)
|
||||||
|
exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf(" ✅ renamed to %q\n", renamed.Title)
|
||||||
|
|
||||||
|
fmt.Printf("\n[set current node]\n")
|
||||||
|
if err := ws.SetCurrentNode(caseNode.ID); err != nil {
|
||||||
|
fmt.Printf(" ❌ set current: %v\n", err)
|
||||||
|
exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
current, err := ws.GetCurrentNode()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf(" ❌ get current: %v\n", err)
|
||||||
|
exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if current.ID != caseNode.ID {
|
||||||
|
fmt.Printf(" ❌ current node mismatch\n")
|
||||||
|
exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf(" ✅ current node: %s\n", current.Title)
|
||||||
|
|
||||||
|
fmt.Printf("\n[archive]\n")
|
||||||
|
if err := ws.ArchiveNode(folderNode.ID); err != nil {
|
||||||
|
fmt.Printf(" ❌ archive: %v\n", err)
|
||||||
|
exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
archived, _ := ws.GetNode(folderNode.ID)
|
||||||
|
if archived.Status != workspace.StatusArchived {
|
||||||
|
fmt.Printf(" ❌ archive failed: status=%s\n", archived.Status)
|
||||||
|
exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf(" ✅ archived %s\n", archived.Title)
|
||||||
|
|
||||||
|
fmt.Printf("\n[reopen persistence]\n")
|
||||||
|
ws2 := workspace.NewManager(openedPath)
|
||||||
|
if err := ws2.Load(); err != nil {
|
||||||
|
fmt.Printf(" ❌ reopen: %v\n", err)
|
||||||
|
exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tree2 := ws2.GetTree()
|
||||||
|
if len(tree2.Nodes) != 4 {
|
||||||
|
fmt.Printf(" ❌ expected 4 nodes after reopen, got %d\n", len(tree2.Nodes))
|
||||||
|
exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf(" ✅ tree persisted: %d nodes\n", len(tree2.Nodes))
|
||||||
|
current2, err := ws2.GetCurrentNode()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf(" ❌ get current after reopen: %v\n", err)
|
||||||
|
exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if current2.ID != caseNode.ID {
|
||||||
|
fmt.Printf(" ❌ current node not persisted\n")
|
||||||
|
exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf(" ✅ current node persisted\n")
|
||||||
|
|
||||||
|
fmt.Printf("\n[workspace.json verification]\n")
|
||||||
|
wsPath := filepath.Join(openedPath, ".verstak", "workspace.json")
|
||||||
|
wsData, err := os.ReadFile(wsPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf(" ❌ read workspace.json: %v\n", err)
|
||||||
|
exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf(" ✅ workspace.json exists on disk\n")
|
||||||
|
fmt.Printf(" content:\n%s\n", string(wsData))
|
||||||
|
|
||||||
|
// ── Test 4-level deep tree ──
|
||||||
|
fmt.Printf("\n[4-level deep tree]\n")
|
||||||
|
folder1, err := ws.CreateNode(rootID, workspace.TypeFolder, "Level 1 Folder")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf(" ❌ create folder1: %v\n", err)
|
||||||
|
exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf(" ✅ created: %s\n", folder1.Title)
|
||||||
|
|
||||||
|
folder2, err := ws.CreateNode(folder1.ID, workspace.TypeFolder, "Level 2 Folder")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf(" ❌ create folder2: %v\n", err)
|
||||||
|
exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf(" ✅ created: %s\n", folder2.Title)
|
||||||
|
|
||||||
|
deepCase, err := ws.CreateNode(folder2.ID, workspace.TypeCase, "Deep Case")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf(" ❌ create deep case: %v\n", err)
|
||||||
|
exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf(" ✅ created: %s (depth 4)\n", deepCase.Title)
|
||||||
|
|
||||||
|
tree = ws.GetTree()
|
||||||
|
if len(tree.Nodes) != 7 {
|
||||||
|
fmt.Printf(" ❌ expected 7 nodes, got %d\n", len(tree.Nodes))
|
||||||
|
exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf(" ✅ tree has 7 nodes (4 levels deep)\n")
|
||||||
|
|
||||||
|
deepNode, _ := ws.GetNode(deepCase.ID)
|
||||||
|
if deepNode.ParentID != folder2.ID {
|
||||||
|
fmt.Printf(" ❌ deep case parent mismatch\n")
|
||||||
|
exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf(" ✅ deep case parent chain correct\n")
|
||||||
|
|
||||||
|
fmt.Printf("\n=== summary ===\n")
|
||||||
|
fmt.Printf("✅ workspace test passed\n")
|
||||||
|
}
|
||||||
|
|||||||
@@ -59,12 +59,59 @@ This validates:
|
|||||||
|
|
||||||
## Desktop Runtime Scanning Paths
|
## Desktop Runtime Scanning Paths
|
||||||
|
|
||||||
The desktop scans two directories for plugins:
|
The desktop resolves plugin directories in one shared backend resolver. Priority:
|
||||||
|
|
||||||
| Path | Purpose |
|
| Path | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
|
| `VERSTAK_PLUGIN_DIR` | Dev/test override. Multiple paths can be separated with the OS path separator |
|
||||||
|
| `./plugins/` | Bundled/dev plugins relative to the current working directory |
|
||||||
|
| `<binary-dir>/plugins/` | Packaged plugins shipped next to the desktop executable |
|
||||||
| `~/.config/verstak/plugins/` | User-installed plugins |
|
| `~/.config/verstak/plugins/` | User-installed plugins |
|
||||||
| `./plugins/` | Bundled/dev plugins (project-local) |
|
|
||||||
|
The resolver normalizes paths and removes duplicates before scanning. Missing
|
||||||
|
directories are ignored by discovery.
|
||||||
|
|
||||||
|
Discovery scans all resolved directories in order. If two plugin packages declare
|
||||||
|
the same `id`, the first package wins and later duplicates are skipped. The
|
||||||
|
warning includes both package paths, so during development check the log if an
|
||||||
|
updated plugin appears to be ignored.
|
||||||
|
|
||||||
|
## Bundled Plugin API During Development
|
||||||
|
|
||||||
|
Frontend bundles are mounted with a plugin-scoped API created by
|
||||||
|
`createPluginAPI(pluginId)`. The current API supports:
|
||||||
|
|
||||||
|
- `settings.read/write/writeAll`
|
||||||
|
- `capabilities.list/get/has`
|
||||||
|
- `commands.register/execute` for handlers declared in `contributes.commands`
|
||||||
|
- `events.publish/subscribe` using the bundled frontend event bus
|
||||||
|
- `files.list/metadata/readText/readBytes/writeText/createFolder/move/trash/listTrash/restoreTrash/deleteTrash/openExternal/showInFolder`
|
||||||
|
for canonical vault-relative slash paths guarded by `files.read`,
|
||||||
|
`files.write`, `files.delete`, and `files.openExternal`. Backslashes,
|
||||||
|
Windows absolute paths, UNC paths, traversal, `.verstak` variants, and
|
||||||
|
symlink read/write/move/trash/external-open operations are rejected. Text
|
||||||
|
read/write is UTF-8 only and limited to 2 MB for text reads; `readBytes`
|
||||||
|
returns base64 for regular files up to 8 MB.
|
||||||
|
- `workbench.openResource/editResource` for routing vault resources to
|
||||||
|
contributed `openProviders`. Plugins must declare `workbench.open`; this is a
|
||||||
|
policy/contract check. Files and Notes plugins call this API and do not import
|
||||||
|
a concrete editor plugin.
|
||||||
|
|
||||||
|
Editor/viewer plugins contribute providers with `contributes.openProviders`.
|
||||||
|
Workbench selects by resource kind, request mode, extension/mime, context
|
||||||
|
(`generic-text`, `generic-markdown`, `notes-markdown`), user preference, priority, then
|
||||||
|
deterministic `pluginId/providerId` tie-break. If nothing matches, Workbench
|
||||||
|
shows `no-provider` fallback instead of a core editor.
|
||||||
|
|
||||||
|
The official `verstak.default-editor` plugin provides three openProviders:
|
||||||
|
`verstak.default-editor.text` (generic-text), `verstak.default-editor.markdown`
|
||||||
|
(generic-markdown), and `verstak.default-editor.notes-markdown` (notes-context).
|
||||||
|
It uses a single unified `DefaultEditor` component with textarea-based editing,
|
||||||
|
simple markdown preview, dirty state tracking, and Ctrl+S save.
|
||||||
|
|
||||||
|
This is a cooperative contract, not a sandbox. Bundled plugins run in the same JS
|
||||||
|
context as the desktop frontend; real isolation is deferred to the sidecar/sandbox
|
||||||
|
milestone.
|
||||||
|
|
||||||
## Important Rules
|
## Important Rules
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
# GUI Testing
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Verstak Desktop uses **Playwright** for frontend E2E tests that run in a real
|
||||||
|
Chromium browser with mocked Wails bindings. This tests the Svelte component
|
||||||
|
logic, user interactions, and UI state transitions — without needing the actual
|
||||||
|
Wails desktop shell.
|
||||||
|
|
||||||
|
## What is tested
|
||||||
|
|
||||||
|
### Frontend E2E (Playwright)
|
||||||
|
|
||||||
|
Located in `frontend/e2e/`, run via `npm run test:e2e`.
|
||||||
|
|
||||||
|
These tests:
|
||||||
|
|
||||||
|
- Launch a Vite dev server with mock Wails bindings
|
||||||
|
- Open the app in a real Chromium browser via Playwright
|
||||||
|
- Simulate user clicks, wait for UI transitions, assert DOM state
|
||||||
|
- Collect console errors and page errors on failure
|
||||||
|
- Capture screenshots on failure
|
||||||
|
|
||||||
|
### Test suites
|
||||||
|
|
||||||
|
| File | Suite | Tests | Status |
|
||||||
|
|------|-------|-------|--------|
|
||||||
|
| `plugin-manager-disable-enable.spec.js` | A: Disable/Enable refresh | 4 | pass |
|
||||||
|
| `sidebar-opens-view.spec.js` | B: Sidebar → view routing | 3 | 3 pass |
|
||||||
|
| `reload-updates-state.spec.js` | C: Reload updates UI | 4 | pass |
|
||||||
|
|
||||||
|
## Resolved bugs covered by tests
|
||||||
|
|
||||||
|
### Bug M5-1: Sidebar updates when plugin state changes
|
||||||
|
|
||||||
|
**Previous symptom:** After disabling a plugin in Plugin Manager, the sidebar
|
||||||
|
item for that plugin remained visible. Reloading after external mock state
|
||||||
|
changes also left stale sidebar items.
|
||||||
|
|
||||||
|
**Current behavior:** `PluginManager.svelte` dispatches
|
||||||
|
`verstak:plugins-changed` after reload/enable/disable flows. `Sidebar.svelte`
|
||||||
|
listens for that event and re-fetches plugins, vault status, and contributions.
|
||||||
|
|
||||||
|
**Regression coverage:**
|
||||||
|
|
||||||
|
- `A: Disable plugin: button changes to Enable, sidebar item disappears`
|
||||||
|
- `A: Disable → Enable full flow in sequence`
|
||||||
|
- `C: Reload after mock state change reflects new plugin status`
|
||||||
|
|
||||||
|
## What is NOT tested
|
||||||
|
|
||||||
|
### Real desktop GUI (WebKitGTK + Wails native shell)
|
||||||
|
|
||||||
|
The Playwright tests run the frontend in a **standard Chromium browser** with
|
||||||
|
mocked Wails bindings. They do **not** test:
|
||||||
|
|
||||||
|
- Actual WebKitGTK rendering (Wails uses WebKitGTK, not Chromium)
|
||||||
|
- Native window management (minimize, maximize, resize)
|
||||||
|
- Native file dialogs (SelectDirectory, SelectVaultForOpen)
|
||||||
|
- Clipboard integration
|
||||||
|
- System tray / menu bar
|
||||||
|
- Plugin frontend bundle loading from real filesystem
|
||||||
|
- Wails event system (window.runtime.EventsOn/Emit)
|
||||||
|
|
||||||
|
For real Wails smoke tests, a separate layer is needed using:
|
||||||
|
- **AT-SPI2** (Linux accessibility tree inspection)
|
||||||
|
- **xdotool** / **ydotool** (input simulation)
|
||||||
|
- **scrot** / **import** (screenshot capture)
|
||||||
|
|
||||||
|
## Running tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
|
||||||
|
# Run all E2E tests (headless)
|
||||||
|
npm run test:e2e
|
||||||
|
|
||||||
|
# Run with Playwright UI (interactive)
|
||||||
|
npm run test:e2e:ui
|
||||||
|
|
||||||
|
# Run in headed browser (visible)
|
||||||
|
npm run test:e2e:headed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test infrastructure
|
||||||
|
|
||||||
|
### Mock bridge (`src/lib/test/wails-mock.js`)
|
||||||
|
|
||||||
|
Replaces `window['go']['api']['App']` with in-memory mock implementations of
|
||||||
|
all Wails backend methods. Provides:
|
||||||
|
|
||||||
|
- Mutable plugin state (enable/disable/status)
|
||||||
|
- Mutable vault state
|
||||||
|
- Mutable contributions (views, commands, sidebar items, settings panels)
|
||||||
|
- Test helpers via `window.__wailsMock`:
|
||||||
|
- `reset()` — reset all state to defaults
|
||||||
|
- `setPluginStatus(id, status, enabled)` — change plugin state
|
||||||
|
- `getPluginState(id)` — read current state
|
||||||
|
- `setVaultStatus(status)` — change vault state
|
||||||
|
|
||||||
|
### Test harness (`index.html`)
|
||||||
|
|
||||||
|
The same `index.html` is used for both production and test. It detects whether
|
||||||
|
the Wails runtime (`window['go']`) is present. If not (i.e. running in a plain
|
||||||
|
browser), it loads the mock bridge before the Svelte app.
|
||||||
|
|
||||||
|
### Playwright config (`playwright.config.js`)
|
||||||
|
|
||||||
|
- Dev server: `vite --mode test --port 5174`
|
||||||
|
- Browser: Chromium headless
|
||||||
|
- Timeouts: 30s test, 10s expect
|
||||||
|
- Workers: 1 (sequential)
|
||||||
|
- Screenshots: on failure
|
||||||
|
- Traces: on first retry
|
||||||
|
- Results: `e2e-results/test-results.json`
|
||||||
|
|
||||||
|
## Adding new tests
|
||||||
|
|
||||||
|
1. Create `e2e/your-test.spec.js`
|
||||||
|
2. Import helpers from `./helpers.js`
|
||||||
|
3. Use `test.beforeEach` to reset mock state and navigate to `/`
|
||||||
|
4. Use `test.afterEach` to assert no console errors
|
||||||
|
5. Write scenarios as user actions + assertions
|
||||||
|
6. Run with `npm run test:e2e`
|
||||||
|
|
||||||
|
### Selector conventions
|
||||||
|
|
||||||
|
- Plugin cards: `.plugin-card` filtered by text
|
||||||
|
- Buttons: `.btn-disable`, `.btn-enable`, `.btn-settings`, `.reload-btn`
|
||||||
|
- Sidebar items: `.sidebar .plugin-item`
|
||||||
|
- View container: `.view-container`
|
||||||
|
- View header: `.view-header h2`
|
||||||
|
- Status badges: `.status-badge`
|
||||||
|
- Toast: `.toast`
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# 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)
|
||||||
|
|
||||||
@@ -0,0 +1,428 @@
|
|||||||
|
# Notes/Files Plugin Architecture Plan
|
||||||
|
|
||||||
|
This document locks the Notes/Files/Open architecture for the next milestones.
|
||||||
|
Files Core Service was implemented in Milestone 6a; this document still does not
|
||||||
|
start Notes UI, Notes plugin, Files UI plugin, editor plugin, watcher, sync, or
|
||||||
|
binary streaming.
|
||||||
|
|
||||||
|
## Current Readiness
|
||||||
|
|
||||||
|
The platform is ready for bundled plugin UI experiments. Files Core is available
|
||||||
|
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:
|
||||||
|
|
||||||
|
- Plugin discovery, lifecycle, settings, capabilities, bundled commands, and
|
||||||
|
bundled frontend events.
|
||||||
|
- Workspace lifecycle APIs for top-level physical folders under the vault root.
|
||||||
|
- Plugin-owned internal storage directories:
|
||||||
|
`.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,
|
||||||
|
activity providers, or status bar items.
|
||||||
|
- Watcher/indexer for external filesystem changes.
|
||||||
|
- Real plugin isolation. Current permission checks are contract/policy checks,
|
||||||
|
not a security boundary for bundled frontend JavaScript.
|
||||||
|
|
||||||
|
## Canonical Notes Model
|
||||||
|
|
||||||
|
Notes are ordinary human-readable Markdown files inside the vault. They must be
|
||||||
|
visible and editable outside Verstak.
|
||||||
|
|
||||||
|
Canonical rules:
|
||||||
|
|
||||||
|
- Notes are `.md` files, not opaque records.
|
||||||
|
- Canonical folder name is exactly `Notes`.
|
||||||
|
- Do not create lowercase `notes`.
|
||||||
|
- Do not store user notes in `.verstak/notes/`.
|
||||||
|
- Do not store user notes in `.verstak/plugin-data/verstak.notes/`.
|
||||||
|
- Do not use UUID-only filenames for notes.
|
||||||
|
- The note title is the source of truth.
|
||||||
|
- The filename is a normalized projection from the title.
|
||||||
|
- `RenameNote` must update both the title and the `.md` filename.
|
||||||
|
|
||||||
|
Canonical scoped paths:
|
||||||
|
|
||||||
|
- Notes live under `<workspace>/Notes/`.
|
||||||
|
- `Overview.md` is allowed as an ordinary Markdown filename, but the platform
|
||||||
|
must not create it automatically or treat it as a dedicated UI entity.
|
||||||
|
- `<workspace>` is the top-level physical folder name under the vault root.
|
||||||
|
- Files plugin workspace views are scoped with `workspaceRootPath`, which is the
|
||||||
|
selected top-level workspace folder name.
|
||||||
|
|
||||||
|
Visibility requirements:
|
||||||
|
|
||||||
|
- Notes UI must show notes as semantic notes.
|
||||||
|
- Files UI must show the same `.md` files as ordinary files.
|
||||||
|
- External file managers must show the same `.md` files.
|
||||||
|
- Outside Verstak, the files must remain useful as normal Markdown.
|
||||||
|
|
||||||
|
There is no canonical metadata workspace tree. Adding `note` as a workspace node
|
||||||
|
type is not part of the next milestone. The official Notes plugin can index and
|
||||||
|
manage Markdown files inside canonical `Notes/` folders under each top-level
|
||||||
|
workspace.
|
||||||
|
|
||||||
|
## Title To Filename Contract
|
||||||
|
|
||||||
|
The title is the source of truth. The filename is derived from the title when a
|
||||||
|
note is created or renamed.
|
||||||
|
|
||||||
|
Normalization rules:
|
||||||
|
|
||||||
|
- Replace spaces with `_`.
|
||||||
|
- Replace typographic dashes with `-`.
|
||||||
|
- Allow only letters, digits, `.`, `_`, and `-`.
|
||||||
|
- Append `.md` if the normalized name does not already end with `.md`.
|
||||||
|
- Reject empty normalized names.
|
||||||
|
- Preserve canonical `Notes` folder casing.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
| Title | Filename |
|
||||||
|
|---|---|
|
||||||
|
| `Overview` | `Overview.md` |
|
||||||
|
| `Meeting Notes` | `Meeting_Notes.md` |
|
||||||
|
| `Plan — Phase 1` | `Plan_-_Phase_1.md` |
|
||||||
|
| `A/B Test: Result` | `AB_Test_Result.md` |
|
||||||
|
|
||||||
|
## Collision Policy
|
||||||
|
|
||||||
|
Same-folder collisions must not be solved silently with `_2`, `_3`, or timestamp
|
||||||
|
suffixes.
|
||||||
|
|
||||||
|
Creating or renaming a note must return a conflict error if the normalized target
|
||||||
|
filename already exists in the target `Notes/` folder. The UI should show a clear
|
||||||
|
dialog or notification and ask the user to change the title.
|
||||||
|
|
||||||
|
Required conflict metadata:
|
||||||
|
|
||||||
|
- requested title;
|
||||||
|
- normalized filename;
|
||||||
|
- target vault-relative path;
|
||||||
|
- existing vault-relative path.
|
||||||
|
|
||||||
|
## Files Service Model
|
||||||
|
|
||||||
|
Files service is the raw vault file layer. It works with vault-relative paths and
|
||||||
|
does not understand note semantics.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- All public Files API paths are canonical vault-relative slash paths.
|
||||||
|
- Backslashes are rejected instead of normalized.
|
||||||
|
- Absolute POSIX paths, Windows drive paths, and UNC/network paths are rejected.
|
||||||
|
- `..` traversal is rejected.
|
||||||
|
- Null bytes are rejected.
|
||||||
|
- `.verstak/` is reserved case-insensitively and hidden/forbidden by default.
|
||||||
|
- Access to `.verstak/` is allowed only through internal APIs, not through the
|
||||||
|
normal plugin Files API.
|
||||||
|
- Symlink read/write/move/trash operations are forbidden in Milestone 6a.
|
||||||
|
Metadata may report a final symlink as `type: "symlink"`.
|
||||||
|
- Writes must be atomic: write a temp file in the same directory, close it, then
|
||||||
|
rename.
|
||||||
|
- Delete must follow the trash policy until permanent delete is explicitly
|
||||||
|
designed.
|
||||||
|
- Trash metadata records `originalPath`, `deletedAt`, `originalType`, `trashId`,
|
||||||
|
and `basename` for future restore work. Restore is deferred.
|
||||||
|
- Binary files are deferred for write/streaming APIs. Milestone 6a lists binary
|
||||||
|
metadata but read/write is UTF-8 text only with a 2 MB read limit.
|
||||||
|
|
||||||
|
Minimum Files methods:
|
||||||
|
|
||||||
|
- `ListVaultFiles(relativeDir)`.
|
||||||
|
- `GetVaultFileMetadata(relativePath)`.
|
||||||
|
- `ReadVaultTextFile(relativePath)`.
|
||||||
|
- `WriteVaultTextFile(relativePath, content, options)`.
|
||||||
|
- `CreateVaultFolder(relativePath)`.
|
||||||
|
- `MoveVaultPath(fromRelativePath, toRelativePath)`.
|
||||||
|
- `TrashVaultPath(relativePath)`.
|
||||||
|
|
||||||
|
Milestone 6a status: implemented in `internal/core/files` and exposed to bundled
|
||||||
|
plugins as `api.files.list`, `api.files.metadata`, `api.files.readText`,
|
||||||
|
`api.files.writeText`, `api.files.createFolder`, `api.files.move`, and
|
||||||
|
`api.files.trash`. It is still text-only for reads/writes and has no watcher,
|
||||||
|
binary streaming, external editor integration, or Files UI plugin.
|
||||||
|
|
||||||
|
Later Files methods:
|
||||||
|
|
||||||
|
- `WatchVaultFiles(scope)` once watcher/event delivery is ready.
|
||||||
|
- `ReadVaultFileBytes` / `WriteVaultFileBytes` for binary files.
|
||||||
|
- `OpenExternal(relativePath)` with explicit permission and UX confirmation.
|
||||||
|
- `RevealInFileManager(relativePath)`.
|
||||||
|
|
||||||
|
## Official Notes Plugin Model
|
||||||
|
|
||||||
|
The official Notes plugin is a semantic layer over Markdown files managed through
|
||||||
|
the public Files plugin API. There is no core desktop Notes service or
|
||||||
|
`verstak/core/notes/v1` capability in v2.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- A note physically is a `.md` file.
|
||||||
|
- Notes plugin and Files API must not create two sources of truth.
|
||||||
|
- Notes plugin reads/writes the same files that Files API lists.
|
||||||
|
- The note title is the semantic source of truth and is projected to the filename.
|
||||||
|
If frontmatter or a first-heading convention is introduced later, `RenameNote`
|
||||||
|
must keep that visible title metadata and the filename synchronized.
|
||||||
|
- Other note metadata should be derived from the file path and filesystem
|
||||||
|
metadata, or from Markdown frontmatter if a future milestone introduces it.
|
||||||
|
- If a note is changed through Files API or an external editor, the future
|
||||||
|
watcher/indexer must observe it.
|
||||||
|
- Until watcher/indexer exists, external changes require reload/rescan.
|
||||||
|
|
||||||
|
Minimum Notes plugin behavior:
|
||||||
|
|
||||||
|
- list Markdown files in a scoped canonical `Notes/` folder;
|
||||||
|
- create a Markdown note by writing the normalized filename atomically with
|
||||||
|
`overwrite: false`;
|
||||||
|
- rename a note by moving the file to the normalized filename;
|
||||||
|
- open and edit notes through Workbench providers;
|
||||||
|
- never special-case `Overview.md` in the UI.
|
||||||
|
|
||||||
|
Later Notes plugin behavior:
|
||||||
|
|
||||||
|
- search notes;
|
||||||
|
- list backlinks;
|
||||||
|
- resolve note links;
|
||||||
|
- export notes.
|
||||||
|
|
||||||
|
## Notes Vs Files Relationship
|
||||||
|
|
||||||
|
Files owns safe raw vault file access. Notes owns note semantics.
|
||||||
|
|
||||||
|
The same physical note must be visible through both surfaces:
|
||||||
|
|
||||||
|
- Files sees `Project/Notes/Overview.md` as a file.
|
||||||
|
- Notes sees `Project/Notes/Overview.md` as a note with title `Overview`.
|
||||||
|
|
||||||
|
There must be no duplicate note content stored in plugin settings, plugin data,
|
||||||
|
or a separate `.verstak` note database. Indexes and caches may exist later, but
|
||||||
|
they must be rebuildable from the canonical Markdown files.
|
||||||
|
|
||||||
|
## Capabilities And Permissions
|
||||||
|
|
||||||
|
Existing permissions that remain useful:
|
||||||
|
|
||||||
|
- `vault.read` for existing vault-level read policy.
|
||||||
|
- `vault.write` for existing vault-level write policy.
|
||||||
|
- `vault.watch` for future watcher support.
|
||||||
|
- `ui.register` for sidebar/views/settings contributions.
|
||||||
|
- `commands.register` for bundled command handlers.
|
||||||
|
- `events.publish` and `events.subscribe` for frontend/backend event flows.
|
||||||
|
|
||||||
|
New permissions required before real Notes/Files plugins ship:
|
||||||
|
|
||||||
|
- `files.read`
|
||||||
|
- `files.write`
|
||||||
|
- `files.delete`
|
||||||
|
- `workbench.open`
|
||||||
|
|
||||||
|
Future Notes semantic permissions are deferred until a real Notes plugin/API
|
||||||
|
ships:
|
||||||
|
|
||||||
|
- `notes.read`
|
||||||
|
- `notes.write`
|
||||||
|
- `notes.delete`
|
||||||
|
|
||||||
|
Those permissions are still policy checks until sidecar/sandbox work provides a
|
||||||
|
real isolation boundary.
|
||||||
|
|
||||||
|
Recommended capabilities:
|
||||||
|
|
||||||
|
- `verstak/core/files/v1`
|
||||||
|
- `verstak/files/v1` provided by the official Files plugin.
|
||||||
|
- `verstak/notes/v1` provided by the official Notes plugin.
|
||||||
|
|
||||||
|
## Frontend Components And Extension Points
|
||||||
|
|
||||||
|
Contribution points already registered but not fully hosted:
|
||||||
|
|
||||||
|
- `fileActions`
|
||||||
|
- `noteActions`
|
||||||
|
- `contextMenuEntries`
|
||||||
|
- `searchProviders`
|
||||||
|
- `activityProviders`
|
||||||
|
- `statusBarItems`
|
||||||
|
|
||||||
|
UI work needed:
|
||||||
|
|
||||||
|
- Files view host with tree/list modes and selection state.
|
||||||
|
- Notes view host with note list, open/edit entry points, and preview/details
|
||||||
|
region.
|
||||||
|
- Context menu host that merges core actions with plugin contributions.
|
||||||
|
- Command palette host for contributed commands.
|
||||||
|
- Search provider host with cancellation/debounce and result ownership.
|
||||||
|
- Status bar host for lightweight plugin state.
|
||||||
|
- Selection/event model for active file, active note, and active workspace node.
|
||||||
|
|
||||||
|
The first implementation should host only the contribution points needed by the
|
||||||
|
official Notes and Files plugins.
|
||||||
|
|
||||||
|
## Open/Edit Resource Model
|
||||||
|
|
||||||
|
Files and Notes must not embed a concrete editor or viewer. They request that the
|
||||||
|
Workbench open or edit a resource. The Workbench/provider registry selects the
|
||||||
|
plugin component.
|
||||||
|
|
||||||
|
Required model:
|
||||||
|
|
||||||
|
- Files plugin lists files and calls open/edit for a vault file.
|
||||||
|
- Notes plugin presents Markdown files under canonical `Notes/` folders and calls
|
||||||
|
open/edit for the same vault file with notes context.
|
||||||
|
- `.md` or `.markdown` inside a canonical `Notes/` folder opens in markdown mode
|
||||||
|
with notes context.
|
||||||
|
- `.md` or `.markdown` outside `Notes/` opens in generic markdown mode.
|
||||||
|
- Plain text opens in `generic-text` mode.
|
||||||
|
- The same editor provider may support text, generic markdown, and notes-context
|
||||||
|
markdown.
|
||||||
|
- User preferences can select another provider for text, markdown, and
|
||||||
|
notes-context markdown.
|
||||||
|
- Community editor plugins can replace the default editor through the same
|
||||||
|
provider registry.
|
||||||
|
- Core desktop owns registry, routing, Workbench host slot/tab, and preferences.
|
||||||
|
- Core desktop does not own concrete Files UI, Notes UI, Markdown editor, or file
|
||||||
|
preview UI.
|
||||||
|
|
||||||
|
Minimal contribution extension:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"contributes": {
|
||||||
|
"openProviders": [
|
||||||
|
{
|
||||||
|
"id": "verstak.platform-test.markdown-diagnostic",
|
||||||
|
"title": "Platform Test Markdown Diagnostic",
|
||||||
|
"priority": 100,
|
||||||
|
"component": "MarkdownDiagnosticProvider",
|
||||||
|
"supports": [
|
||||||
|
{
|
||||||
|
"kind": "vault-file",
|
||||||
|
"extensions": [".md", ".markdown"],
|
||||||
|
"contexts": ["generic-markdown", "notes-markdown"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "vault-file",
|
||||||
|
"mime": ["text/plain"],
|
||||||
|
"extensions": [".txt", ".log", ".json", ".yaml", ".yml", ".toml", ".ini", ".conf"],
|
||||||
|
"contexts": ["generic-text"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Open request shape:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type OpenResourceRequest = {
|
||||||
|
kind: "vault-file";
|
||||||
|
path: string;
|
||||||
|
mode?: "view" | "edit";
|
||||||
|
mime?: string;
|
||||||
|
extension?: string;
|
||||||
|
context?: {
|
||||||
|
sourcePluginId?: string;
|
||||||
|
sourceView?: "files" | "notes" | string;
|
||||||
|
isInsideNotesFolder?: boolean;
|
||||||
|
notesScopePath?: string;
|
||||||
|
notesMode?: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Provider selection rules:
|
||||||
|
|
||||||
|
1. Match resource kind.
|
||||||
|
2. Match extension and/or mime.
|
||||||
|
3. Prefer providers that explicitly support the request context.
|
||||||
|
4. Apply user preference for text, markdown, or notes-context markdown when the
|
||||||
|
preferred provider is enabled and still supports the resource.
|
||||||
|
5. Otherwise choose highest priority.
|
||||||
|
6. Break ties deterministically by plugin id, then provider id.
|
||||||
|
7. If no provider matches, return a Workbench `no-provider` state rather than
|
||||||
|
hardcoding a core editor.
|
||||||
|
8. If a preferred provider plugin is disabled or unavailable, fall back to the
|
||||||
|
deterministic default and surface a non-blocking preference warning later.
|
||||||
|
|
||||||
|
Initial preferences:
|
||||||
|
|
||||||
|
- `defaultTextEditorProvider`;
|
||||||
|
- `defaultMarkdownEditorProvider`;
|
||||||
|
- `defaultNotesMarkdownEditorProvider`.
|
||||||
|
|
||||||
|
Per-extension overrides are deferred.
|
||||||
|
|
||||||
|
## Migration Risks
|
||||||
|
|
||||||
|
- Adding `note` as a workspace node type is a workspace schema migration and is
|
||||||
|
explicitly out of scope for the next milestone.
|
||||||
|
- The canonical path rules must be locked before writing real files into user
|
||||||
|
vaults.
|
||||||
|
- Rename behavior can break external links if link rewriting is not designed.
|
||||||
|
- Case/folder path ownership must be clear before scoped `Notes/` folders are
|
||||||
|
created.
|
||||||
|
- Raw Files API can expose `.verstak` internals unless reserved paths are blocked.
|
||||||
|
- File writes need atomic behavior and conflict handling before sync.
|
||||||
|
- Large/binary files require streaming or byte APIs; text APIs are not enough.
|
||||||
|
- External editor changes require reload/rescan until watcher/indexer exists.
|
||||||
|
- Bundled frontend plugins are trusted/cooperative and not isolated from the
|
||||||
|
shared JS context.
|
||||||
|
|
||||||
|
## Test Plan
|
||||||
|
|
||||||
|
Backend Go tests:
|
||||||
|
|
||||||
|
- Vault-relative path normalization and traversal rejection.
|
||||||
|
- Reserved `.verstak` path behavior.
|
||||||
|
- Files list/read/write/mkdir/move/trash with vault closed/open states.
|
||||||
|
- Atomic text writes and temp-file cleanup on failure.
|
||||||
|
- Notes `Notes/` folder casing and no lowercase `notes`.
|
||||||
|
- Title to filename normalization.
|
||||||
|
- note create/rename conflict errors without silent suffixes.
|
||||||
|
- Notes and Files read the same physical `.md` file.
|
||||||
|
- Permission checks for `files.*`, `notes.*`, `vault.read`, and `vault.write`.
|
||||||
|
|
||||||
|
Frontend/unit tests:
|
||||||
|
|
||||||
|
- SDK and plugin API shape for Files and Notes draft methods.
|
||||||
|
- Readable errors for missing permissions, closed vault, missing file, missing
|
||||||
|
note, reserved path, and collision.
|
||||||
|
- Contribution host rendering for note/file actions and context menus.
|
||||||
|
|
||||||
|
Playwright e2e tests:
|
||||||
|
|
||||||
|
- Create a text file, reload, and verify it is visible in Files.
|
||||||
|
- Create a note, reload, and verify it is visible in both Notes and Files.
|
||||||
|
- Rename a note and verify title plus filename change together.
|
||||||
|
- Attempt same-folder collision and verify user-facing conflict handling.
|
||||||
|
- External file change requires reload/rescan until watcher exists.
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
1. Define canonical vault-relative path rules and reserved path policy.
|
||||||
|
2. Implement Files core service with safe list/read/write/mkdir/move/trash.
|
||||||
|
3. Define open/edit resource request, provider contribution shape, and provider
|
||||||
|
selection rules.
|
||||||
|
4. Extend contribution registry/types with `openProviders`.
|
||||||
|
5. Add Workbench open/edit routing and a provider-hosted tab/slot.
|
||||||
|
6. Add preferences for text, markdown, and notes-context markdown provider ids.
|
||||||
|
7. Use `platform-test` diagnostic provider to verify routing; real default
|
||||||
|
editor plugin is deferred to Milestone 6c.
|
||||||
|
8. Build official Files plugin that calls open/edit resource.
|
||||||
|
9. Build official Notes plugin as a contextual view over Markdown files in
|
||||||
|
canonical `Notes/` folders.
|
||||||
|
10. Implement future Notes semantic helpers only as a facade over Markdown files,
|
||||||
|
never as a second source of truth.
|
||||||
@@ -6,19 +6,34 @@
|
|||||||
|
|
||||||
### Discovery Directories
|
### Discovery Directories
|
||||||
|
|
||||||
Plugins ищутся в двух директориях (порядок приоритета):
|
Plugins ищутся через единый resolver `internal/core/plugin.ResolveDiscoveryDirs`.
|
||||||
|
Порядок приоритета:
|
||||||
|
|
||||||
| Путь | Назначение | Коммитится |
|
| Путь | Назначение | Коммитится |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| `VERSTAK_PLUGIN_DIR` | Override для тестов/dev; можно передать несколько путей через OS path separator | Нет |
|
||||||
|
| `./plugins/` | Dev plugins относительно текущей рабочей директории/repo | Нет (`.gitignore`) |
|
||||||
|
| `<binary-dir>/plugins/` | Packaged plugins рядом с desktop binary | Зависит от дистрибутива |
|
||||||
| `~/.config/verstak/plugins/` | User-installed plugins | Нет (user home) |
|
| `~/.config/verstak/plugins/` | User-installed plugins | Нет (user home) |
|
||||||
| `./plugins/` | Bundled / dev plugins | Нет (`.gitignore`) |
|
|
||||||
|
Resolver нормализует пути, удаляет дубликаты и передает discovery только канонический
|
||||||
|
список директорий. Отсутствующие директории просто пропускаются на этапе scanning.
|
||||||
|
|
||||||
|
Discovery сканирует **все** resolved директории в указанном порядке. Если один и тот
|
||||||
|
же `plugin.id` найден несколько раз, применяется правило **first plugin wins**:
|
||||||
|
первый найденный plugin загружается, последующие plugins с тем же id пропускаются.
|
||||||
|
Конфликт логируется и возвращается как discovery warning с двумя путями: путь
|
||||||
|
пропущенного duplicate и путь уже загруженного winner.
|
||||||
|
|
||||||
### ./plugins/ как Dev/Install Target
|
### ./plugins/ как Dev/Install Target
|
||||||
|
|
||||||
Директория `./plugins/` в корне `verstak-desktop` используется как:
|
Директория `./plugins/` от текущей рабочей директории используется как:
|
||||||
|
|
||||||
- **Dev target** — `install-dev-plugins.sh` коприрует сюда собранные пакеты из `verstak-official-plugins/dist/`.
|
- **Dev target** — `install-dev-plugins.sh` коприрует сюда собранные пакеты из `verstak-official-plugins/dist/`.
|
||||||
- **Bundled plugins** — при дистрибутиве core может поставлять плагины здесь.
|
- **Local override** — при запуске desktop из repo позволяет быстро проверять packaged bundles.
|
||||||
|
|
||||||
|
В packaged-сборке bundled plugins должны лежать в `plugins/` рядом с executable.
|
||||||
|
Для тестов и локальных сценариев можно задать `VERSTAK_PLUGIN_DIR=/path/to/plugins`.
|
||||||
|
|
||||||
Директория **не коммитится**. Каждый разработчик устанавливает плагины через `install-dev-plugins.sh`.
|
Директория **не коммитится**. Каждый разработчик устанавливает плагины через `install-dev-plugins.sh`.
|
||||||
|
|
||||||
@@ -83,6 +98,8 @@ coreCaps := []string{
|
|||||||
"verstak/core/contribution-registry/v1",
|
"verstak/core/contribution-registry/v1",
|
||||||
"verstak/core/permissions/v1",
|
"verstak/core/permissions/v1",
|
||||||
"verstak/core/events/v1",
|
"verstak/core/events/v1",
|
||||||
|
"verstak/core/files/v1",
|
||||||
|
"verstak/core/workbench/v1",
|
||||||
}
|
}
|
||||||
capRegistry.Register("verstak-desktop", coreCaps)
|
capRegistry.Register("verstak-desktop", coreCaps)
|
||||||
|
|
||||||
@@ -95,17 +112,25 @@ capRegistry.Register("verstak-desktop", []string{"verstak/core/vault/v1"})
|
|||||||
### Plugin capability resolution
|
### Plugin capability resolution
|
||||||
|
|
||||||
```
|
```
|
||||||
foreach plugin:
|
1. Исключить disabled plugins.
|
||||||
1. зарегистрировать plugin.provides в capability registry
|
2. Пока есть неразрешённые plugins:
|
||||||
2. проверить plugin.requires — если есть missing → missing-required-capability
|
- plugin с уже доступными `requires` атомарно регистрирует `provides`;
|
||||||
3. проверить plugin.optionalRequires — если есть missing → degraded
|
- при конфликте `provides` plugin получает `failed`;
|
||||||
4. иначе → loaded
|
- если за проход ничего не разрешилось, остальные получают
|
||||||
5. зарегистрировать plugin.contributes в contribution registry
|
`missing-required-capability`.
|
||||||
|
3. После разрешения всех обязательных зависимостей проверить
|
||||||
|
`optionalRequires`: отсутствующие переводят plugin в `degraded`.
|
||||||
|
4. Зарегистрировать `contributes` только для `loaded` и `degraded` plugins.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Таким образом, порядок discovery не влияет на обычные зависимости между
|
||||||
|
plugins. При конкурирующих providers одного capability порядок discovery служит
|
||||||
|
tie-breaker. `provides` плагина с неразрешённым `requires` или ошибкой
|
||||||
|
регистрации не попадают в registry.
|
||||||
|
|
||||||
### Capability конфликт
|
### Capability конфликт
|
||||||
|
|
||||||
Два plugin не могут предоставлять один и тот же capability. При попытке повторной регистрации — ошибка и статус `failed` для второго плагина.
|
Два plugin не могут предоставлять один и тот же capability. При попытке повторной регистрации второй plugin получает `failed`; регистрация атомарна, поэтому ни один capability такого plugin не остаётся в registry.
|
||||||
|
|
||||||
## Plugin Manifest Format
|
## Plugin Manifest Format
|
||||||
|
|
||||||
@@ -129,7 +154,7 @@ foreach plugin:
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `description` | string | Описание плагина |
|
| `description` | string | Описание плагина |
|
||||||
| `source` | string | `"official"`, `"local"`, `"third-party"` |
|
| `source` | string | `"official"`, `"local"`, `"third-party"` |
|
||||||
| `icon` | string | Иконка (emoji или имя) |
|
| `icon` | string | Имя иконки из встроенного Lucide-набора shell |
|
||||||
| `requires` | string[] | Жёзкие capability-зависимости |
|
| `requires` | string[] | Жёзкие capability-зависимости |
|
||||||
| `optionalRequires` | string[] | Мягкие capability-зависимости |
|
| `optionalRequires` | string[] | Мягкие capability-зависимости |
|
||||||
| `frontend` | object | `{ "entry": "path/to/index.js", "style": "path/to/style.css" }` |
|
| `frontend` | object | `{ "entry": "path/to/index.js", "style": "path/to/style.css" }` |
|
||||||
@@ -150,7 +175,7 @@ foreach plugin:
|
|||||||
"provides": ["verstak/platform-test/v1"],
|
"provides": ["verstak/platform-test/v1"],
|
||||||
"requires": ["verstak/core/plugin-manager/v1"],
|
"requires": ["verstak/core/plugin-manager/v1"],
|
||||||
"optionalRequires": ["verstak/core/vault/v1", "verstak/core/sync/v1"],
|
"optionalRequires": ["verstak/core/vault/v1", "verstak/core/sync/v1"],
|
||||||
"permissions": ["vault.read", "events.publish", "ui.register"],
|
"permissions": ["vault.read", "events.publish", "ui.register", "workbench.open"],
|
||||||
"frontend": { "entry": "frontend/dist/index.js" },
|
"frontend": { "entry": "frontend/dist/index.js" },
|
||||||
"contributes": {
|
"contributes": {
|
||||||
"views": [{ "id": "my.view", "title": "My View", "component": "MyPanel" }],
|
"views": [{ "id": "my.view", "title": "My View", "component": "MyPanel" }],
|
||||||
@@ -161,30 +186,459 @@ foreach plugin:
|
|||||||
|
|
||||||
## Contribution Points
|
## Contribution Points
|
||||||
|
|
||||||
Плагины могут регистрировать UI contributions через поле `contributes`:
|
Плагины могут регистрировать UI contributions через поле `contributes` в `plugin.json`.
|
||||||
|
|
||||||
| Тип | Описание |
|
Icon fields use shell icon names rendered through the bundled Lucide SVG wrapper. Plugins must not rely on emoji, Unicode pictographs, or system icon fonts; if a plugin needs its own icon font, it must bundle the font and reference it from its own frontend bundle.
|
||||||
|
|
||||||
|
### Реализованные contribution points (Milestone 5a)
|
||||||
|
|
||||||
|
| Тип | Поле manifest | Описание | Frontend host |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Боковая панель | `sidebarItems` | Элементы в sidebar слева | ✅ Sidebar.svelte (из ContributionRegistry) |
|
||||||
|
| Основные панели | `views` | Полноценные страницы/панели | ✅ ViewContainer.svelte (PluginBundleHost — real frontend bundle) |
|
||||||
|
| Панели настроек | `settingsPanels` | Панели в Plugin Manager | ✅ PluginManager.svelte (кнопка Settings, открывает modal) |
|
||||||
|
| Команды | `commands` | Команды для command palette | ✅ ContributionRegistry + CommandPalette UI |
|
||||||
|
| Open/edit providers | `openProviders` | Провайдеры viewer/editor для Workbench routing | ✅ ContributionRegistry + минимальный Workbench host |
|
||||||
|
| Действия над файлами | `fileActions` | Provider actions for Files surface | ✅ Files plugin context menu |
|
||||||
|
| Действия над заметками | `noteActions` | Provider actions for Notes surface | ✅ Notes plugin row actions |
|
||||||
|
| Контекстное меню | `contextMenuEntries` | Provider context menu entries | ✅ Files plugin context menu |
|
||||||
|
| Провайдеры поиска | `searchProviders` | Search provider discovery | ✅ Contribution summary + Search plugin |
|
||||||
|
| Провайдеры активности | `activityProviders` | Activity event subscriptions | ✅ Backend activity recorder |
|
||||||
|
| Элементы status bar | `statusBarItems` | Status bar labels/actions | ✅ StatusBar.svelte host |
|
||||||
|
|
||||||
|
### Планируемые contribution points
|
||||||
|
|
||||||
|
| Тип | Поле manifest | Статус |
|
||||||
|
|---|---|---|
|
||||||
|
| Sidecar-backed command handlers | `commands` | Planned; bundled frontend handlers are current runtime |
|
||||||
|
| Import/export providers | `importers` / `exporters` | Planned |
|
||||||
|
| Protocol receivers | `protocol.receivers` | Planned |
|
||||||
|
|
||||||
|
### Структура contribution points в manifest
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"contributes": {
|
||||||
|
"sidebarItems": [
|
||||||
|
{
|
||||||
|
"id": "mypanel.sidebar",
|
||||||
|
"title": "My Panel",
|
||||||
|
"icon": "puzzle",
|
||||||
|
"view": "mypanel.view",
|
||||||
|
"position": 100
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"views": [
|
||||||
|
{
|
||||||
|
"id": "mypanel.view",
|
||||||
|
"title": "My Panel View",
|
||||||
|
"icon": "puzzle",
|
||||||
|
"component": "MyPanelComponent"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settingsPanels": [
|
||||||
|
{
|
||||||
|
"id": "mypanel.settings",
|
||||||
|
"title": "My Settings",
|
||||||
|
"component": "MySettingsPanel"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"commands": [
|
||||||
|
{
|
||||||
|
"id": "mypanel.cmd",
|
||||||
|
"title": "Do Something",
|
||||||
|
"icon": "gear",
|
||||||
|
"handler": "doSomething"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Contribution lifecycle
|
||||||
|
|
||||||
|
1. Plugin `Register(pluginID, contributions)` — все contributions регистрируются
|
||||||
|
2. `Unregister(pluginID)` — удаляет все contributions указанного plugin
|
||||||
|
3. Reload сначала удаляет contributions всех plugins предыдущего discovery, затем регистрирует только `loaded`/`degraded` plugins
|
||||||
|
4. Disable plugin → `Unregister` (contributions исчезают из UI)
|
||||||
|
5. Enable plugin → `Register` при следующем Reload
|
||||||
|
6. Registry idempotent: Register удаляет старые записи перед добавлением новых
|
||||||
|
|
||||||
|
## Bundled Frontend Plugin API
|
||||||
|
|
||||||
|
Bundled frontend plugins получают API от host через `createPluginAPI(pluginId)`.
|
||||||
|
Обычный plugin code не передает `pluginId` в методы API: scope закрепляется в
|
||||||
|
host при mount компонента. Это защищает нормальный cooperative path от случайного
|
||||||
|
доступа к чужому namespace.
|
||||||
|
|
||||||
|
Текущая модель безопасности честно ограничена:
|
||||||
|
|
||||||
|
- bundled frontend plugins исполняются в общем JS-контексте приложения;
|
||||||
|
- проверки permissions/capabilities сейчас являются contract/policy checks, а не
|
||||||
|
полноценной security boundary;
|
||||||
|
- malicious JS в общем контексте теоретически может обойти frontend wrapper;
|
||||||
|
- настоящая изоляция будет только после отдельного sidecar/sandbox milestone.
|
||||||
|
|
||||||
|
## Workbench Open/Edit Routing
|
||||||
|
|
||||||
|
Files and Notes plugins do not import or embed a concrete editor plugin. They
|
||||||
|
call `api.workbench.openResource(request)` or `api.workbench.editResource(request)`.
|
||||||
|
The backend requires the source plugin to be enabled, loaded/degraded, and to
|
||||||
|
declare `workbench.open`. This is a policy/contract check, not a security
|
||||||
|
boundary.
|
||||||
|
|
||||||
|
`OpenResourceRequest`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type OpenResourceRequest = {
|
||||||
|
kind: "vault-file";
|
||||||
|
path: string;
|
||||||
|
mode?: "view" | "edit";
|
||||||
|
mime?: string;
|
||||||
|
extension?: string;
|
||||||
|
context?: {
|
||||||
|
sourcePluginId?: string;
|
||||||
|
sourceView?: "files" | "notes" | string;
|
||||||
|
isInsideNotesFolder?: boolean;
|
||||||
|
notesScopePath?: string;
|
||||||
|
notesMode?: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Routing contexts are fixed as `generic-text`, `generic-markdown`, and
|
||||||
|
`notes-markdown`. `.md`/`.markdown` inside canonical `Notes/` folders uses
|
||||||
|
`notes-markdown`; markdown outside Notes uses `generic-markdown`; ordinary text
|
||||||
|
uses `generic-text`. Milestone 6b derives context from request fields; future
|
||||||
|
Files/Notes integrations can centralize canonical Notes folder auto-detection in
|
||||||
|
the Workbench helper.
|
||||||
|
|
||||||
|
`contributes.openProviders` extends the existing contribution registry:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"contributes": {
|
||||||
|
"openProviders": [
|
||||||
|
{
|
||||||
|
"id": "verstak.platform-test.markdown-diagnostic",
|
||||||
|
"title": "Platform Test Markdown Diagnostic",
|
||||||
|
"priority": 100,
|
||||||
|
"component": "MarkdownDiagnosticProvider",
|
||||||
|
"supports": [
|
||||||
|
{
|
||||||
|
"kind": "vault-file",
|
||||||
|
"extensions": [".md", ".markdown"],
|
||||||
|
"contexts": ["generic-markdown", "notes-markdown"],
|
||||||
|
"modes": ["view"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "vault-file",
|
||||||
|
"mime": ["text/plain"],
|
||||||
|
"extensions": [".txt", ".log"],
|
||||||
|
"contexts": ["generic-text"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Selection uses enabled loaded/degraded provider plugins, resource kind,
|
||||||
|
request mode, extension/mime, context, user preference, priority, then deterministic
|
||||||
|
`pluginId/providerId` fallback. If nothing matches, Workbench returns
|
||||||
|
`status: "no-provider"` and shows the fallback view instead of a core editor.
|
||||||
|
|
||||||
|
Disabled/failed/missing-required-capability plugins are excluded from provider
|
||||||
|
selection at request time by `activeOpenProviders()`. Their contributions may
|
||||||
|
remain in the registry until the next `ReloadPlugins()` cycle, but they never
|
||||||
|
match during routing.
|
||||||
|
|
||||||
|
Draft app-global preferences are `defaultTextEditorProvider`,
|
||||||
|
`defaultMarkdownEditorProvider`, and `defaultNotesMarkdownEditorProvider`.
|
||||||
|
Vault-scoped and per-extension overrides are deferred.
|
||||||
|
|
||||||
|
### Default Editor Plugin
|
||||||
|
|
||||||
|
The official `verstak.default-editor` plugin (`verstak-official-plugins/plugins/default-editor/`)
|
||||||
|
provides openProviders for text, generic markdown, and notes-context markdown files.
|
||||||
|
It uses `api.files.readText` / `api.files.writeText` for file I/O and mounts through
|
||||||
|
the standard `PluginBundleHost` / provider host mechanism. Core does not import or
|
||||||
|
reference this plugin directly.
|
||||||
|
|
||||||
|
Provider plugins may have no sidebar item — openProviders are contribution points
|
||||||
|
for workbench routing, not navigation. Plugin Manager displays openProviders in the
|
||||||
|
contributions summary.
|
||||||
|
|
||||||
|
### API methods
|
||||||
|
|
||||||
|
`settings`
|
||||||
|
|
||||||
|
- `settings.read()` — читает весь settings namespace текущего plugin.
|
||||||
|
- `settings.read(key)` — читает один ключ.
|
||||||
|
- `settings.write(key, value)` — обновляет один ключ и пишет namespace обратно.
|
||||||
|
- `settings.writeAll(settings)` — заменяет settings namespace.
|
||||||
|
- Backend требует plugin exists, enabled, status `loaded`/`degraded` и permission
|
||||||
|
`storage.namespace`.
|
||||||
|
|
||||||
|
`capabilities`
|
||||||
|
|
||||||
|
- `capabilities.list()` — возвращает текущий capability registry.
|
||||||
|
- `capabilities.get(name)` — возвращает `{ available, name, pluginId, status }`.
|
||||||
|
- `capabilities.has(name)` — boolean wrapper над `get`.
|
||||||
|
- Backend требует, чтобы plugin был enabled/loaded и декларировал dependency на
|
||||||
|
`verstak/core/capability-registry/v1` в `requires` или `optionalRequires`.
|
||||||
|
|
||||||
|
`commands`
|
||||||
|
|
||||||
|
- `commands.register(commandId, handler)` — регистрирует bundled frontend handler.
|
||||||
|
Возвращает `Promise<unsubscribe>`.
|
||||||
|
- `commands.execute(commandId, args)` — backend сначала проверяет plugin status,
|
||||||
|
permission `commands.register` и что command объявлен в `contributes.commands`
|
||||||
|
именно этим plugin. Затем frontend registry вызывает зарегистрированный handler.
|
||||||
|
- `commands.executeFor(pluginId, commandId, args)` — то же выполнение, но для
|
||||||
|
command другого plugin-provider. Используется host surfaces вроде Files/Notes,
|
||||||
|
которые читают contribution action и вызывают объявленный provider handler.
|
||||||
|
- Если command объявлен в manifest, но handler не зарегистрирован, API возвращает
|
||||||
|
понятную ошибку `declared-but-unhandled`.
|
||||||
|
- Handler registry очищается при component unmount, reload/disable flow и
|
||||||
|
`api.dispose()`.
|
||||||
|
- Shell command palette открывается через `Ctrl+K` / `Cmd+K` или
|
||||||
|
`Ctrl+Shift+P` / `Cmd+Shift+P`, показывает commands enabled plugins,
|
||||||
|
фильтрует по title/id/plugin и вызывает зарегистрированные bundled frontend
|
||||||
|
handlers.
|
||||||
|
|
||||||
|
`statusBarItems`
|
||||||
|
|
||||||
|
- Shell status bar renders enabled plugin `statusBarItems` contributions.
|
||||||
|
- Items support `left`, `center`, and `right` positions. Missing position
|
||||||
|
defaults to `left`.
|
||||||
|
- The host refreshes on plugin reload/enable/disable through
|
||||||
|
`verstak:plugins-changed`.
|
||||||
|
- `handler` is preserved in the contribution summary for future action routing;
|
||||||
|
current host only renders status labels.
|
||||||
|
|
||||||
|
`contributions`
|
||||||
|
|
||||||
|
- `contributions.list()` — возвращает весь flattened contribution summary.
|
||||||
|
- `contributions.list(point)` — возвращает массив contribution records для
|
||||||
|
конкретного поля (`fileActions`, `noteActions`, `contextMenuEntries`, etc).
|
||||||
|
- Runtime records include `pluginId`, so consumer surfaces can call
|
||||||
|
`commands.executeFor(record.pluginId, record.handler, args)`.
|
||||||
|
|
||||||
|
`events`
|
||||||
|
|
||||||
|
- `events.subscribe(eventName, handler)` — frontend-local subscription с backend
|
||||||
|
validation permission `events.subscribe`. Возвращает `Promise<unsubscribe>`.
|
||||||
|
- `events.publish(eventName, payload)` — backend проверяет `events.publish`, затем
|
||||||
|
событие dispatch'ится в bundled frontend event bus.
|
||||||
|
- Handler получает envelope `{ name, pluginId, payload, timestamp }`.
|
||||||
|
- Subscriptions очищаются при component unmount, reload/disable flow и
|
||||||
|
`api.dispose()`.
|
||||||
|
- Core publishes `file.changed` for Files API mutations and live vault watcher
|
||||||
|
changes. Watcher payloads use `operation: "external.create"`,
|
||||||
|
`"external.update"`, or `"external.delete"`, include vault-relative `path`,
|
||||||
|
`type`, `workspaceRootPath`, and `external: true`.
|
||||||
|
|
||||||
|
`files`
|
||||||
|
|
||||||
|
- `files.list(relativeDir)` — list directory using a vault-relative path.
|
||||||
|
- `files.metadata(relativePath)` — returns file/folder/symlink metadata.
|
||||||
|
- `files.readText(relativePath)` — reads a UTF-8 regular file, with a size limit.
|
||||||
|
- `files.readBytes(relativePath)` — reads a regular file up to 8 MB and returns
|
||||||
|
`{ relativePath, size, mimeHint, dataBase64 }` for bounded binary preview use.
|
||||||
|
- `files.writeText(relativePath, content, options)` — atomically writes text via
|
||||||
|
temp-file-and-rename. `options.createIfMissing` and `options.overwrite`
|
||||||
|
control conflicts.
|
||||||
|
- `files.writeBytes(relativePath, dataBase64, options)` — decodes a base64
|
||||||
|
payload up to 8 MB and atomically writes bytes via temp-file-and-rename.
|
||||||
|
`options.createIfMissing` and `options.overwrite` control conflicts.
|
||||||
|
- `files.createFolder(relativePath)` — creates one folder when the parent exists.
|
||||||
|
- `files.move(from, to, options)` — moves a file or folder; rejects moving a
|
||||||
|
folder into itself and conflicts unless `options.overwrite` is true.
|
||||||
|
- `files.trash(relativePath)` — moves a file/folder into internal
|
||||||
|
`.verstak/trash/files/<trashId>/...` and returns trash metadata.
|
||||||
|
- `files.listTrash()` — returns internal file trash metadata, newest first.
|
||||||
|
- `files.restoreTrash(trashId, options)` — restores a file/folder from internal
|
||||||
|
trash to its original vault-relative path, or to `options.targetPath`.
|
||||||
|
Conflicts are rejected unless `options.overwrite` is true.
|
||||||
|
- `files.deleteTrash(trashId)` — permanently removes a file/folder already in
|
||||||
|
internal trash. This operation cannot be undone.
|
||||||
|
- UI policy: the Deal `Files` plugin displays only live files and folders.
|
||||||
|
The global official `verstak.trash` plugin owns listing, restoring, filtering,
|
||||||
|
and permanently deleting deleted items across the vault.
|
||||||
|
- `files.openExternal(relativePath)` — opens a vault-relative file/folder in
|
||||||
|
the OS default application.
|
||||||
|
- `files.showInFolder(relativePath)` — reveals a vault-relative file/folder in
|
||||||
|
the OS file manager where the platform supports it.
|
||||||
|
- Backend requires plugin exists, enabled, status `loaded`/`degraded`, open
|
||||||
|
vault, and `files.read`, `files.write`, `files.delete`, or
|
||||||
|
`files.openExternal`.
|
||||||
|
- All paths are canonical vault-relative slash paths. Backslashes, POSIX
|
||||||
|
absolute paths, Windows drive paths, UNC/network paths, `..`, null bytes,
|
||||||
|
symlink traversal, and public access to `.verstak/` are rejected.
|
||||||
|
- `.verstak` is reserved case-insensitively: `.verstak`, `.Verstak`, and any
|
||||||
|
first path segment with that spelling are internal-only.
|
||||||
|
- `files.metadata` may report a final symlink as `type: "symlink"`, but
|
||||||
|
`files.list` through a symlink directory and all read/write/move/trash
|
||||||
|
operations through symlinks are forbidden in Milestone 6a.
|
||||||
|
- `readText` is limited to UTF-8 regular files up to 2 MB. `readBytes` and
|
||||||
|
`writeBytes` are bounded byte contracts up to 8 MB; chunked streaming is
|
||||||
|
deferred.
|
||||||
|
- Live watcher refresh is active while Verstak is running and a vault is open.
|
||||||
|
It performs an initial no-event snapshot, then publishes `file.changed` for
|
||||||
|
external creates, updates, and deletes outside `.verstak/`. It does not keep a
|
||||||
|
persistent snapshot or report what changed while Verstak was closed.
|
||||||
|
|
||||||
|
`sync`
|
||||||
|
|
||||||
|
- `sync.now()` pushes local operations, pulls remote operations, and returns
|
||||||
|
`{ pushed, pulled, serverSequence, conflicts?, applyErrors? }`.
|
||||||
|
- `conflicts` is an array of server-reported sync conflicts. Conflict objects
|
||||||
|
may include `op_id`, `entity_type`, `entity_id`, `reason`, and additional
|
||||||
|
server fields. The Sync plugin must show conflict details instead of only a
|
||||||
|
count, and it must not silently resolve or overwrite local data.
|
||||||
|
- `applyErrors` lists local apply failures for pulled operations. These are
|
||||||
|
user-visible warnings and do not imply that sync was fully successful.
|
||||||
|
- Transport push/pull uses bounded retry/backoff for transient HTTP/network
|
||||||
|
failures. Client/auth errors are not retried.
|
||||||
|
|
||||||
|
`dispose`
|
||||||
|
|
||||||
|
- `dispose()` вызывается host'ом при cleanup. Plugin code обычно не вызывает его
|
||||||
|
напрямую. Он удаляет зарегистрированные command handlers и event subscriptions.
|
||||||
|
|
||||||
|
### Runtime boundaries
|
||||||
|
|
||||||
|
| Layer | Current status |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `views` | Панели/страницы (component — Svelte) |
|
| Bundled frontend runtime | Functional for settings, capabilities, commands, events and text Files API |
|
||||||
| `commands` | Команды command palette |
|
| Backend validation | Checks plugin exists, enabled/loaded state, permissions and declarations |
|
||||||
| `settingsPanels` | Панели в Settings |
|
| Security boundary | Not implemented; bundled plugins share the desktop frontend JS context |
|
||||||
| `sidebarItems` | Элементы боковой панели |
|
| Sidecar/RPC/sandbox | Not implemented |
|
||||||
| `fileActions` | Действия над файлами |
|
|
||||||
| `noteActions` | Действия над заметками |
|
### Error boundary
|
||||||
| `contextMenuEntries` | Пункты контекстного меню |
|
|
||||||
| `searchProviders` | Провайдеры поиска |
|
- Ошибка в plugin view/settings placeholder не роняет shell
|
||||||
| `activityProviders` | Провайдеры активности |
|
- ViewContainer показывает "⚠️ Plugin UI failed" fallback
|
||||||
| `statusBarItems` | Элементы status bar |
|
- Error канал: `console.error` + видимый fallback в UI
|
||||||
|
|
||||||
|
## Frontend Bundle Contract
|
||||||
|
|
||||||
|
### Регистрация компонентов
|
||||||
|
|
||||||
|
Плагин регистрирует frontend компоненты через глобальную функцию `window.VerstakPluginRegister`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
window.VerstakPluginRegister('plugin.id', {
|
||||||
|
components: {
|
||||||
|
'ComponentName': {
|
||||||
|
mount: function(containerEl, props, api) {
|
||||||
|
// containerEl — div, созданный PluginBundleHost
|
||||||
|
// api — ограниченный VerstakPluginAPI
|
||||||
|
containerEl.innerHTML = '<h1>Hello from plugin!</h1>';
|
||||||
|
},
|
||||||
|
unmount: function(containerEl) {
|
||||||
|
// Очистка при смене view
|
||||||
|
containerEl.innerHTML = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### VerstakPluginAPI
|
||||||
|
|
||||||
|
API объект передаётся в `mount()` и содержит plugin-scoped методы текущего
|
||||||
|
bundled runtime. Это реальный runtime contract для cooperative bundled plugins,
|
||||||
|
но не sandbox/security boundary.
|
||||||
|
|
||||||
|
| Свойство | Статус | Описание |
|
||||||
|
|---|---|---|
|
||||||
|
| `api.pluginId` | ✅ Работает | ID плагина |
|
||||||
|
| `api.settings.read(key?)` | ✅ Работает | Читает plugin-scoped settings через backend bridge |
|
||||||
|
| `api.settings.write(key, value)` | ✅ Работает | Пишет один settings key через backend bridge |
|
||||||
|
| `api.settings.writeAll(settings)` | ✅ Работает | Заменяет settings namespace плагина |
|
||||||
|
| `api.capabilities.list()` | ✅ Работает | Возвращает capability registry |
|
||||||
|
| `api.capabilities.get(id)` | ✅ Работает | Возвращает capability entry/status |
|
||||||
|
| `api.capabilities.has(id)` | ✅ Работает | Boolean wrapper над `get` |
|
||||||
|
| `api.commands.register(id, handler)` | ✅ Работает | Регистрирует bundled frontend handler для объявленной command |
|
||||||
|
| `api.commands.execute(id, args)` | ✅ Работает | Валидирует declaration/permission/backend state и вызывает bundled handler |
|
||||||
|
| `api.commands.executeFor(pluginId, id, args)` | ✅ Работает | Выполняет handler другого plugin-provider после backend validation |
|
||||||
|
| `api.contributions.list(point?)` | ✅ Работает | Возвращает flattened contribution summary или массив по point |
|
||||||
|
| Command Palette UI | ✅ Работает | `Ctrl/Cmd+K`, фильтр enabled plugin commands, вызов registered frontend handlers |
|
||||||
|
| `api.events.publish(type, payload)` | ✅ Работает | Валидирует permission и публикует во frontend event bus |
|
||||||
|
| `api.events.subscribe(type, handler)` | ✅ Работает | Валидирует permission и подписывает handler на frontend event bus |
|
||||||
|
| `api.files.list(relativeDir)` | ✅ Работает | Список vault-relative директории, `.verstak` скрыта |
|
||||||
|
| `api.files.metadata(relativePath)` | ✅ Работает | Metadata для файла/папки/symlink без чтения содержимого |
|
||||||
|
| `api.files.readText(relativePath)` | ✅ Работает | Читает UTF-8 regular file до 2 MB |
|
||||||
|
| `api.files.readBytes(relativePath)` | ✅ Работает | Читает regular file до 8 MB как base64 payload |
|
||||||
|
| `api.files.writeText(relativePath, content, options)` | ✅ Работает | Atomic text write с явным create/overwrite policy |
|
||||||
|
| `api.files.writeBytes(relativePath, dataBase64, options)` | ✅ Работает | Atomic bounded byte write до 8 MB с явным create/overwrite policy |
|
||||||
|
| `api.files.createFolder(relativePath)` | ✅ Работает | Создаёт vault-relative folder |
|
||||||
|
| `api.files.move(from, to, options)` | ✅ Работает | Move file/folder с conflict и path-policy checks |
|
||||||
|
| `api.files.trash(relativePath)` | ✅ Работает | Перемещает в internal trash и сохраняет metadata исходного объекта |
|
||||||
|
| `api.files.listTrash()` | ✅ Работает | Возвращает metadata internal file trash |
|
||||||
|
| `api.files.restoreTrash(trashId, options)` | ✅ Работает | Восстанавливает из internal trash, conflict-safe по умолчанию |
|
||||||
|
| `api.files.deleteTrash(trashId)` | ✅ Работает | Необратимо удаляет запись и payload из internal trash |
|
||||||
|
| `api.files.openExternal(relativePath)` | ✅ Работает | Открывает vault file/folder во внешнем приложении, требует `files.openExternal` |
|
||||||
|
| `api.files.showInFolder(relativePath)` | ✅ Работает | Показывает vault file/folder в системном файловом менеджере, требует `files.openExternal` |
|
||||||
|
| `api.workbench.openResource(request)` | ✅ Работает | Routes vault resources to `openProviders` |
|
||||||
|
| `api.workbench.editResource(request)` | ✅ Работает | Same routing, forcing `mode: "edit"` |
|
||||||
|
| `api.sync.now()` | ✅ Работает | Push/pull с bounded retry/backoff для transient HTTP/network failures |
|
||||||
|
| `api.sync.status()` | ✅ Работает | Возвращает configured/connected/error/revoked state, lastError, unpushed count |
|
||||||
|
| `api.dispose()` | ✅ Работает | Очищает command handlers и event subscriptions текущего API instance |
|
||||||
|
|
||||||
|
Ограничения:
|
||||||
|
|
||||||
|
- permissions/capabilities checks являются contract/policy checks;
|
||||||
|
- bundled frontend plugins исполняются в общем JS-контексте;
|
||||||
|
- malicious JS не изолирован;
|
||||||
|
- sidecar process lifecycle, RPC transport и sandbox enforcement ещё не
|
||||||
|
реализованы.
|
||||||
|
- Files paths are slash-only vault-relative contract paths; backslashes,
|
||||||
|
Windows absolute paths, UNC paths, `.verstak` variants, traversal and symlink
|
||||||
|
operations are rejected by backend policy checks.
|
||||||
|
|
||||||
|
### Загрузка бандла
|
||||||
|
|
||||||
|
1. `PluginBundleHost` получает pluginId и componentId
|
||||||
|
2. Вызывает `App.GetPluginFrontendInfo(pluginId)` — получает entry/style/rootPath
|
||||||
|
3. Вызывает `App.GetPluginAssetContent(pluginId, entry)` — получает JS контент
|
||||||
|
4. Выполняет контент через `new Function(content)` — bundle вызывает `VerstakPluginRegister`
|
||||||
|
5. Находит компонент по componentId и вызывает `mount(container, props, api)`
|
||||||
|
6. При смене view — вызывает `unmount(container)` для старого компонента
|
||||||
|
|
||||||
|
### Безопасность asset path
|
||||||
|
|
||||||
|
| Правило | Проверка |
|
||||||
|
|---|---|
|
||||||
|
| Нет абсолютных путей | Пути, начинающиеся с `/` или `\`, отклоняются |
|
||||||
|
| Нет path traversal | Пути, содержащие `..`, отклоняются |
|
||||||
|
| Нет выхода за root | После `filepath.Join` проверяется, что путь внутри plugin root |
|
||||||
|
| Только существующие файлы | `os.ReadFile` возвращает ошибку если файл не существует |
|
||||||
|
|
||||||
|
### manifest frontend config
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"frontend": {
|
||||||
|
"entry": "frontend/dist/index.js",
|
||||||
|
"style": "frontend/style.css"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Reload
|
## Reload
|
||||||
|
|
||||||
`ReloadPlugins()` в `internal/api/app.go` позволяет перезагрузить plugins без перезапуска приложения:
|
`ReloadPlugins()` в `internal/api/app.go` позволяет перезагрузить plugins без перезапуска приложения:
|
||||||
|
|
||||||
1. Unregister all capabilities (кроме core).
|
1. Unregister contributions всех plugins предыдущего discovery.
|
||||||
2. Re-register core capabilities.
|
2. Unregister all non-core capabilities.
|
||||||
3. Re-scan discovery directories.
|
3. Re-register core capabilities + vault + Deal manager (если открыт).
|
||||||
4. Re-run capability resolution.
|
4. Re-scan discovery directories и повторить capability resolution.
|
||||||
5. Re-register contributions.
|
5. Register contributions для loaded/degraded plugins (disabled/failed — не регистрируются).
|
||||||
|
6. Update plugins list.
|
||||||
|
|
||||||
Frontend вызывает это при нажатии "Reload" в Plugin Manager.
|
Frontend вызывает это при нажатии "Reload" в Plugin Manager.
|
||||||
|
|
||||||
@@ -341,3 +795,229 @@ Vault plugin state хранится **внутри vault** в `.verstak/plugins.
|
|||||||
- Plugin Manager показывает "Missing installed plugin"
|
- Plugin Manager показывает "Missing installed plugin"
|
||||||
- Auto-install пока НЕ делается
|
- Auto-install пока НЕ делается
|
||||||
- Показывается подсказка: "Install official plugin package"
|
- Показывается подсказка: "Install official plugin package"
|
||||||
|
|
||||||
|
|
||||||
|
## UI Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ App.svelte │
|
||||||
|
│ ┌──────────┬──────────────────────────────────────┐ │
|
||||||
|
│ │ Sidebar │ Content area │ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ │ Verstak │ PluginManager | ViewContainer │ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ │ [icon] │ (padding: 1.5rem) │ │
|
||||||
|
│ │ Plugin │ │ │
|
||||||
|
│ │ Manager│ │ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ │ Plugins │ │ │
|
||||||
|
│ │ [icon] item1 │ │
|
||||||
|
│ │ [icon] item2 │ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ │ ● Vault │ │ │
|
||||||
|
│ └──────────┴──────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Sidebar** (220px): навигация (Plugin Manager), plugin sidebar items, vault status
|
||||||
|
- **Content**: Plugin Manager или ViewContainer в зависимости от выбранного view
|
||||||
|
- **Vault Selection**: полноэкранный экран, показывается когда vault не открыт
|
||||||
|
|
||||||
|
## Milestone 4b — UI Completion (2026-06-17)
|
||||||
|
|
||||||
|
Сделано:
|
||||||
|
- VaultSelection.svelte: исправлен flow (CreateVault → OpenVault → SetCurrentVault)
|
||||||
|
- Sidebar.svelte: полная навигация с отступами, plugin sidebar items, vault status
|
||||||
|
- App.svelte: обработка `verstak:nav` событий, global reset стилей
|
||||||
|
- PluginManager.svelte: исправлены отступы header
|
||||||
|
|
||||||
|
Проверки:
|
||||||
|
- `go test ./...` — 52 PASS
|
||||||
|
- `./scripts/check.sh` — ✅
|
||||||
|
- `./scripts/smoke-platform.sh` — ✅ (enable/disable/plugins.json)
|
||||||
|
- `./scripts/build.sh` — ✅
|
||||||
|
|
||||||
|
## Дело: core capability
|
||||||
|
|
||||||
|
Дело — это физическая папка верхнего уровня внутри vault root. Filesystem
|
||||||
|
является source of truth для списка Дел.
|
||||||
|
|
||||||
|
Пример:
|
||||||
|
|
||||||
|
```
|
||||||
|
<vault>/
|
||||||
|
Deal/
|
||||||
|
Notes/
|
||||||
|
Project/
|
||||||
|
ClientA/
|
||||||
|
.verstak/
|
||||||
|
```
|
||||||
|
|
||||||
|
Нет единого `<vault>/Deal/` контейнера для всех Дел. Папка `Deal/` может быть
|
||||||
|
обычным Делом, но `Project/` и `ClientA/` являются такими же Делами на том же
|
||||||
|
уровне.
|
||||||
|
|
||||||
|
### Хранение
|
||||||
|
|
||||||
|
Существование и список Дел хранятся только в filesystem:
|
||||||
|
|
||||||
|
- `ListWorkspaces()` читает top-level directories из vault root.
|
||||||
|
- `.verstak`, reserved/internal directories, top-level files и symlinks не
|
||||||
|
считаются Делами.
|
||||||
|
- `.verstak/workspace*.json` не является source of truth для списка Дел.
|
||||||
|
- Нет virtual workspace tree, которое
|
||||||
|
мапится на произвольные папки.
|
||||||
|
|
||||||
|
`.verstak` может хранить только metadata, которая не заменяет filesystem:
|
||||||
|
|
||||||
|
- UI state: выбранное Дело, expanded folders, sort/pin state, preferences.
|
||||||
|
- Semantic snapshot: applied template snapshot, enabled feature areas, exact
|
||||||
|
`workspaceTools`, and folder conventions.
|
||||||
|
|
||||||
|
При создании Дела runtime генерирует UUID и сохраняет его в
|
||||||
|
`.verstak/workspace.json`. `workspaceId` — постоянная личность Дела;
|
||||||
|
`workspaceRootPath` и `workspaceName` — изменяемый адрес и presentation fields.
|
||||||
|
Переименование обновляет путь, но не ID. Новая папка с прежним именем получает
|
||||||
|
другой ID и не может перехватить старые привязки.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"workspaceId": "1eb7cc69-52e8-4a18-ae2a-50d5229c5b60",
|
||||||
|
"workspaceName": "Project",
|
||||||
|
"createdFromTemplate": {
|
||||||
|
"templateId": "project",
|
||||||
|
"templateName": "Project",
|
||||||
|
"templateVersion": 1,
|
||||||
|
"appliedAt": "2026-06-19T12:00:00Z",
|
||||||
|
"workspaceTools": [
|
||||||
|
"verstak.notes",
|
||||||
|
"verstak.files",
|
||||||
|
"verstak.todo",
|
||||||
|
"verstak.journal",
|
||||||
|
"verstak.activity",
|
||||||
|
"verstak.browser-inbox"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"features": {
|
||||||
|
"notes": true,
|
||||||
|
"files": true,
|
||||||
|
"todo": true,
|
||||||
|
"journal": true,
|
||||||
|
"activity": true,
|
||||||
|
"browser-inbox": true
|
||||||
|
},
|
||||||
|
"folders": {
|
||||||
|
"notes": "Notes",
|
||||||
|
"files": "Files"
|
||||||
|
},
|
||||||
|
"workspaceTools": [
|
||||||
|
"verstak.notes",
|
||||||
|
"verstak.files",
|
||||||
|
"verstak.todo",
|
||||||
|
"verstak.journal",
|
||||||
|
"verstak.activity",
|
||||||
|
"verstak.browser-inbox"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Если original template удалён или изменён позже, существующее Дело
|
||||||
|
открывается по сохранённому snapshot и не мутирует автоматически. Template
|
||||||
|
update/migration может быть только явной future feature. Если metadata
|
||||||
|
отсутствует, Дело открывается как generic Deal минимум с `files: true`.
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
|
- `ListWorkspaces()` — список top-level папок Дел с их `workspaceId`.
|
||||||
|
- `ListWorkspaceTemplates()` — selectable built-in templates с presentation
|
||||||
|
metadata и `workspaceTools`.
|
||||||
|
- `CreateWorkspace(name, templateId?)` — создать `<vault>/<name>/`, применить
|
||||||
|
template один раз, сохранить snapshot metadata.
|
||||||
|
- `RenameWorkspace(oldName, newName)` — физически переименовать top-level папку
|
||||||
|
Дела и обновить metadata name, не меняя `workspaceId`.
|
||||||
|
- `TrashWorkspace(name)` — перенести весь top-level folder Дела в internal
|
||||||
|
trash policy.
|
||||||
|
- `GetWorkspaceMetadata(name)` — прочитать metadata или вернуть generic fallback.
|
||||||
|
- `UpdateWorkspaceMetadata(name, patch)` — обновить metadata без влияния на
|
||||||
|
существование Дела.
|
||||||
|
|
||||||
|
Deprecated compatibility APIs:
|
||||||
|
|
||||||
|
- `GetWorkspaceTree()` — flat view, derived from top-level folders. Не дерево.
|
||||||
|
- `CreateWorkspaceNode(...)` — wrapper over `CreateWorkspace`.
|
||||||
|
- `RenameWorkspaceNode(...)` — wrapper over `RenameWorkspace`.
|
||||||
|
- `ArchiveWorkspaceNode(...)` — wrapper over `TrashWorkspace`.
|
||||||
|
- `MoveWorkspaceNode(...)` — unsupported; old nested/mapped moves are rejected.
|
||||||
|
- `GetCurrentWorkspaceNode()` / `SetCurrentWorkspaceNode(...)` — wrappers over
|
||||||
|
selected top-level Deal UI state.
|
||||||
|
|
||||||
|
Эти методы существуют только для постепенного frontend/Wails cleanup. Они не
|
||||||
|
должны создавать или сохранять nested workspace tree и не должны восстанавливать
|
||||||
|
`WorkspaceNode.path` mapping.
|
||||||
|
|
||||||
|
### Capability
|
||||||
|
|
||||||
|
`verstak/core/workspace/v1` — техническое имя capability; оно регистрируется
|
||||||
|
только когда vault открыт и Deal manager инициализирован.
|
||||||
|
|
||||||
|
### Правила
|
||||||
|
|
||||||
|
- Имя Дела — один safe folder name, не path.
|
||||||
|
- Reject: empty, slash, backslash, absolute-looking paths, `..`, null byte,
|
||||||
|
`.verstak`, reserved/internal names, symlink Дел, conflicts.
|
||||||
|
- WorkspaceItems получают техническое поле `workspaceRootPath`, равное имени top-level папки
|
||||||
|
(`Project`, `ClientA`, etc). Files plugin показывает именно эту папку.
|
||||||
|
- Files API остаётся raw vault-relative API: `Project/Notes/example.md`,
|
||||||
|
`Project/docs/file.md`, `Test/readme.md`.
|
||||||
|
- Notes are ordinary Markdown files under `<Deal>/Notes/`; нет
|
||||||
|
`.verstak/notes`, UUID note storage или второго source of truth для note
|
||||||
|
content.
|
||||||
|
|
||||||
|
### Lifecycle Events
|
||||||
|
|
||||||
|
Runtime publishes lifecycle events Дел после успешных операций:
|
||||||
|
|
||||||
|
- `workspace.created` — payload includes `operation: "create"`, `workspaceId`,
|
||||||
|
`workspaceRootPath`, `workspaceName`, and optional `templateId`.
|
||||||
|
- `workspace.renamed` — payload includes `operation: "rename"`, `workspaceId`,
|
||||||
|
new `workspaceRootPath` / `workspaceName`, and previous path fields.
|
||||||
|
- `workspace.trashed` — payload includes `operation: "trash"`, `workspaceId`,
|
||||||
|
`workspaceRootPath`, `workspaceName`, `trashId`, `trashPath`, and `deletedAt`.
|
||||||
|
- `workspace.selected` — payload includes `operation: "select"`, `workspaceId`,
|
||||||
|
`workspaceRootPath`, and `workspaceName`.
|
||||||
|
|
||||||
|
Official Activity subscribes to these events and stores them through the normal
|
||||||
|
activity provider path.
|
||||||
|
|
||||||
|
### UI
|
||||||
|
|
||||||
|
Список Дел в sidebar:
|
||||||
|
- Flat list of top-level папок Дел.
|
||||||
|
- Создать Дело, переименовать Дело, переместить Дело в корзину.
|
||||||
|
- Selection is stored as selected Deal name.
|
||||||
|
- No expand/collapse tree and no case/folder node creation in core.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build Scripts
|
||||||
|
|
||||||
|
В `verstak-desktop/scripts/` есть два скрипта:
|
||||||
|
|
||||||
|
### `build.sh` — локальная детерминированная сборка
|
||||||
|
|
||||||
|
- Собирает **только** `verstak-desktop` (core platform).
|
||||||
|
- Не трогает другие репозитории.
|
||||||
|
- **Fail-fast**: любая ошибка (go vet, go test, frontend build, wails build) прерывает сборку.
|
||||||
|
- Проверяет: deps → frontend build → go mod download → go vet → go build → go test → wails build + plugin copy.
|
||||||
|
- Используется в CI и для повседневной работы над core.
|
||||||
|
|
||||||
|
### `update-and-build-all.sh` — dev helper для полной пересборки связки
|
||||||
|
|
||||||
|
- **Не для CI.** Только для разработки, когда нужно быстро собрать всё вместе.
|
||||||
|
- Шаги:
|
||||||
|
1. `git pull --ff-only` во всех 6 репозиториях
|
||||||
|
2. Сборка official plugins (frontend npm build + backend go build для каждого плагина)
|
||||||
|
3. Копирование собранных плагинов в `verstak-desktop/plugins/`
|
||||||
|
4. Запуск `build.sh` для сборки desktop
|
||||||
|
- Ошибки pull и сборки плагинов не прерывают скрипт (best-effort), но ошибка build.sh прерывает (fail-fast).
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# UX/UI Refactor Plan
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- `verstak-desktop` is the core platform and UI shell. Notes, files, editor, activity, journal, browser inbox, and search behavior stay plugin-owned.
|
||||||
|
- The old `~/git/verstak` UI is a visual and interaction reference, not an architecture source.
|
||||||
|
- The v2 shell should improve orientation, density, keyboard/mouse ergonomics, and responsive behavior without reintroducing the v1 monolith.
|
||||||
|
|
||||||
|
## Reference Rules
|
||||||
|
|
||||||
|
Keep from v1:
|
||||||
|
|
||||||
|
- compact dark workbench rhythm;
|
||||||
|
- clear title/header zones;
|
||||||
|
- dense rows and tabs for repeated work;
|
||||||
|
- action controls that appear where the user is working;
|
||||||
|
- custom, in-app interaction surfaces instead of browser-default dialogs where the flow is important.
|
||||||
|
|
||||||
|
Avoid from v1:
|
||||||
|
|
||||||
|
- putting user business workflows into `App.svelte`;
|
||||||
|
- direct coupling between shell and notes/files/editor internals;
|
||||||
|
- global mutable UI state that plugins must know about;
|
||||||
|
- moving plugin behavior into core for visual convenience.
|
||||||
|
|
||||||
|
## Mimo Delegation Model
|
||||||
|
|
||||||
|
Use `~/bin/mimo.sh run --dir <repo> "<task>"` only for bounded junior tasks.
|
||||||
|
|
||||||
|
Good tasks:
|
||||||
|
|
||||||
|
- compare two small components and write a short report to `/tmp`;
|
||||||
|
- draft CSS for a named component within existing tokens;
|
||||||
|
- inspect one test file and suggest missing assertions;
|
||||||
|
- make a one-component mechanical change after the target behavior is already specified.
|
||||||
|
|
||||||
|
Do not delegate:
|
||||||
|
|
||||||
|
- architecture decisions;
|
||||||
|
- plugin/core boundary decisions;
|
||||||
|
- final diff review;
|
||||||
|
- verification claims;
|
||||||
|
- commits or pushes.
|
||||||
|
|
||||||
|
Every mimo result must be reviewed with `git diff` and verified independently.
|
||||||
|
|
||||||
|
## Work Plan
|
||||||
|
|
||||||
|
1. Shell orientation
|
||||||
|
- Move persistent search and workspace context into the workspace header.
|
||||||
|
- Keep exactly one global search entry visible at a time.
|
||||||
|
- Preserve search availability in global plugin views.
|
||||||
|
|
||||||
|
2. Responsive shell
|
||||||
|
- Make narrow viewports usable by stacking sidebar above workspace content.
|
||||||
|
- Ensure tabs and workspace cards do not force horizontal page overflow.
|
||||||
|
- Add Playwright coverage for mobile geometry.
|
||||||
|
|
||||||
|
3. Today surface
|
||||||
|
- Make Today feel like a work-resume surface, not a static card grid.
|
||||||
|
- Keep data loading from plugin settings/contributions; do not add business logic to core.
|
||||||
|
- Improve empty states and quick actions based on available workspace tools.
|
||||||
|
|
||||||
|
4. Plugin manager polish
|
||||||
|
- Improve scanning, status density, and permission readability.
|
||||||
|
- Keep enable/disable/status behavior unchanged.
|
||||||
|
- Verify degraded/failed/disabled plugin paths.
|
||||||
|
|
||||||
|
5. Files/workbench ergonomics
|
||||||
|
- Use the existing files plugin comparison report as input.
|
||||||
|
- Prefer plugin-local improvements: context menu, keyboard navigation, selection, and custom confirmation.
|
||||||
|
- Do not add file-manager logic to the shell.
|
||||||
|
|
||||||
|
## Verification Gates
|
||||||
|
|
||||||
|
Before commit:
|
||||||
|
|
||||||
|
- `npm run build`
|
||||||
|
- focused Playwright suite for the touched flow
|
||||||
|
- full `npm run test:e2e` when App, Sidebar, WorkspaceHost, PluginManager, or shared shell layout changes
|
||||||
|
- desktop and mobile screenshots inspected manually
|
||||||
|
|
||||||
|
Before push:
|
||||||
|
|
||||||
|
- re-run the relevant verification from a clean current worktree state;
|
||||||
|
- inspect `git status` and `git diff --stat`;
|
||||||
|
- push only after tests and visual smoke have current evidence.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Deal Templates
|
||||||
|
|
||||||
|
Deal templates choose which dynamic plugin Deal tabs are visible when a new
|
||||||
|
top-level Deal is created. They do not enable or disable plugins for the
|
||||||
|
whole vault and do not affect global sidebar tools.
|
||||||
|
|
||||||
|
## Built-in templates
|
||||||
|
|
||||||
|
| Template | Deal tabs |
|
||||||
|
| --- | --- |
|
||||||
|
| General | Notes, Files, Journal, Activity, Browser Inbox |
|
||||||
|
| Project | Notes, Files, Todos, Journal, Activity, Browser Inbox |
|
||||||
|
| Writing | Notes, Files, Journal |
|
||||||
|
| Admin | Notes, Files, Secrets, Todos, Journal |
|
||||||
|
| Minimal | Notes, Files |
|
||||||
|
|
||||||
|
The create-Deal modal displays the selected template description and its
|
||||||
|
included plugin tabs before the folder is created.
|
||||||
|
|
||||||
|
## Metadata and compatibility
|
||||||
|
|
||||||
|
Creation stores a template snapshot in `.verstak/workspaces/` metadata. The
|
||||||
|
snapshot contains the template id, name, version, applied time, and an ordered
|
||||||
|
`workspaceTools` list of plugin IDs. Existing Deal metadata without
|
||||||
|
`workspaceTools` remains compatible: its Deal continues to show all globally
|
||||||
|
enabled Deal plugins rather than unexpectedly hiding tabs.
|
||||||
|
|
||||||
|
Templates are applied once. Editing the built-in catalog or creating another
|
||||||
|
Deal with a different template never changes an existing Deal snapshot.
|
||||||
|
There is no template editor or post-creation template switcher yet.
|
||||||
|
|
||||||
|
## Global tools and unavailable plugins
|
||||||
|
|
||||||
|
Template visibility applies only to `workspaceItems` in the selected Deal.
|
||||||
|
Global views and sidebar items, such as global Todos, Browser Inbox, and Trash,
|
||||||
|
remain available according to the normal plugin enablement state.
|
||||||
|
|
||||||
|
The Deal host intersects the snapshot with dynamically discovered, globally
|
||||||
|
enabled plugin contributions. If a template references a plugin that is missing or
|
||||||
|
disabled, that tab is simply absent; the other template tabs remain usable.
|
||||||
|
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 |
@@ -0,0 +1,341 @@
|
|||||||
|
# Native Notifications, Tray, and Public README Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Deliver native Todo reminders and Windows/Linux tray operation, then publish updated alpha artifacts and public README documentation with real application screenshots.
|
||||||
|
|
||||||
|
**Architecture:** Desktop core stores and delivers plugin-owned notification schedules and controls the tray lifecycle. Todo uses the public plugin API to replace its desired reminders. A small core tray adapter keeps the Wails process alive after window close. Documentation uses the supplied English and Russian README sources plus screenshots from an actual test vault.
|
||||||
|
|
||||||
|
**Tech Stack:** Go 1.24, Wails v2.12 runtime notifications, `getlantern/systray`, plain JavaScript plugin API, Node smoke tests, Playwright, bash packaging.
|
||||||
|
|
||||||
|
## Global constraints
|
||||||
|
|
||||||
|
- Support Windows and Linux only; no background daemon after an explicit Quit.
|
||||||
|
- Closing a window hides it; only tray **Quit** ends the process.
|
||||||
|
- `verstak.todo` requires `verstak/core/notifications/v1` and `notifications.schedule`.
|
||||||
|
- Plugins cannot receive Wails runtime access.
|
||||||
|
- Keep Wails generated bindings out of commits.
|
||||||
|
- Commit each independent change and immediately push it to GitHub and `mirror`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Persisted notification scheduler
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `internal/core/notifications/manager.go`
|
||||||
|
- Create: `internal/core/notifications/store.go`
|
||||||
|
- Create: `internal/core/notifications/manager_test.go`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- `type Request struct { ID, DueAt, Title, Body string }`
|
||||||
|
- `type Item struct { PluginID, ID, DueAt, Title, Body, SentForDueAt string }`
|
||||||
|
- `type Sender interface { Send(context.Context, Item) error }`
|
||||||
|
- `Manager.Replace(pluginID string, requests []Request) error`, `Clear(pluginID string) error`, `Start(context.Context)`, and `Stop()`.
|
||||||
|
- Persistent state: `<vault>/.verstak/notifications/schedules.json`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add failing scheduler tests**
|
||||||
|
|
||||||
|
```go
|
||||||
|
func TestTickRetriesFailedSendAndAcknowledgesOneDelivery(t *testing.T) {
|
||||||
|
sender := &fakeSender{err: errors.New("unavailable")}
|
||||||
|
m := newTestManager(t, sender, time.Date(2026, 7, 14, 12, 0, 0, 0, time.UTC))
|
||||||
|
requireNoError(t, m.Replace("verstak.todo", []Request{{ID: "todo-1", DueAt: "2026-07-14T11:00:00Z", Title: "Reminder"}}))
|
||||||
|
m.Tick(context.Background())
|
||||||
|
if sender.calls != 1 || m.Items()[0].SentForDueAt != "" { t.Fatal("failed send was acknowledged") }
|
||||||
|
sender.err = nil
|
||||||
|
m.Tick(context.Background())
|
||||||
|
m.Tick(context.Background())
|
||||||
|
if sender.calls != 2 || m.Items()[0].SentForDueAt != "2026-07-14T11:00:00Z" { t.Fatal("delivery was not exactly once") }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Also test stale records are removed by `Replace`, changing `DueAt` rearms a record, state reloads from disk, and a past-due unsent item is sent once.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Confirm red**
|
||||||
|
|
||||||
|
Run: `GOCACHE=/tmp/verstak-go-cache go test ./internal/core/notifications -count=1`
|
||||||
|
|
||||||
|
Expected: fails because the package does not exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement the minimal manager**
|
||||||
|
|
||||||
|
Use one mutex, injected clock/sender, an atomic same-directory temp-file rename, and a 30-second ticker. Preserve `SentForDueAt` only if the requested due time is unchanged. Validate non-empty unique IDs, RFC3339 UTC dates, at most 500 requests, and bounded text. Mark sent only after `Sender.Send` succeeds.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify and commit**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
gofmt -w internal/core/notifications/*.go
|
||||||
|
GOCACHE=/tmp/verstak-go-cache go test ./internal/core/notifications ./internal/core/... -count=1
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
Commit/push:
|
||||||
|
```bash
|
||||||
|
git add internal/core/notifications
|
||||||
|
git commit -m "feat: add persisted notification scheduler"
|
||||||
|
git push https://github.com/mirivlad/verstak.git main
|
||||||
|
git push mirror main
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 2: Permission-gated core/plugin notification API
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `main.go`
|
||||||
|
- Modify: `internal/api/app.go`
|
||||||
|
- Modify: `internal/api/app_test.go`
|
||||||
|
- Modify: `internal/core/permissions/registry.go`
|
||||||
|
- Modify: `frontend/src/lib/plugin-host/VerstakPluginAPI.js`
|
||||||
|
- Modify: `frontend/tests/plugin-api-contributions-test.mjs`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Core capability: `verstak/core/notifications/v1`.
|
||||||
|
- Permission: `notifications.schedule`, non-dangerous.
|
||||||
|
- Bound methods: `ReplacePluginNotifications(pluginID string, items []notifications.Request) string` and `ClearPluginNotifications(pluginID string) string`.
|
||||||
|
- Plugin methods: `api.notifications.replace(items)` and `api.notifications.clear()`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add failing backend and host-API tests**
|
||||||
|
|
||||||
|
```go
|
||||||
|
func TestReplacePluginNotificationsRequiresCapabilityAndPermission(t *testing.T) {
|
||||||
|
app, scheduler := newNotificationTestApp(t, manifestWithoutNotificationPermission)
|
||||||
|
if got := app.ReplacePluginNotifications("example", []notifications.Request{{ID: "r", DueAt: "2026-07-14T10:00:00Z", Title: "R"}}); got == "" {
|
||||||
|
t.Fatal("missing permission was accepted")
|
||||||
|
}
|
||||||
|
if scheduler.replaceCalls != 0 { t.Fatalf("replace calls = %d", scheduler.replaceCalls) }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The JS test mocks `App.ReplacePluginNotifications`, calls `api.notifications.replace`, and asserts both plugin ID forwarding and a namespaced rejection for a non-empty backend error.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Confirm red**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GOCACHE=/tmp/verstak-go-cache go test ./internal/api -run TestReplacePluginNotificationsRequiresCapabilityAndPermission -count=1
|
||||||
|
node frontend/tests/plugin-api-contributions-test.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: missing API methods.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement guarded bridge**
|
||||||
|
|
||||||
|
Register the capability in `main.go`, construct the scheduler, and guard bound calls with both existing access helpers before forwarding. Add `notifications.schedule` to the registry. Add the public host methods using `callBackendErrorString`; do not expose `window.runtime` to plugins.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify and commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gofmt -w main.go internal/api/app.go internal/api/app_test.go internal/core/permissions/registry.go
|
||||||
|
GOCACHE=/tmp/verstak-go-cache go test ./internal/api -count=1
|
||||||
|
node frontend/tests/plugin-api-contributions-test.mjs
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add main.go internal/api/app.go internal/api/app_test.go internal/core/permissions/registry.go frontend/src/lib/plugin-host/VerstakPluginAPI.js frontend/tests/plugin-api-contributions-test.mjs
|
||||||
|
git commit -m "feat: expose scheduled notifications to plugins"
|
||||||
|
git push https://github.com/mirivlad/verstak.git main
|
||||||
|
git push mirror main
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 3: Todo reminder synchronization and Wails lifecycle
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `main.go`
|
||||||
|
- Modify: `internal/api/app.go`
|
||||||
|
- Modify: `internal/api/app_test.go`
|
||||||
|
- Modify: `../verstak-official-plugins/plugins/todo/plugin.json`
|
||||||
|
- Modify: `../verstak-official-plugins/plugins/todo/frontend/src/index.js`
|
||||||
|
- Modify: `../verstak-official-plugins/plugins/todo/locales/en.json`
|
||||||
|
- Modify: `../verstak-official-plugins/plugins/todo/locales/ru.json`
|
||||||
|
- Modify: `../verstak-official-plugins/scripts/smoke-todo-plugin.js`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- `App.DomReady(ctx)` initializes Wails notifications before starting the scheduler.
|
||||||
|
- `App.Shutdown(ctx)` stops it before Wails cleanup.
|
||||||
|
- Todo replaces the complete reminder list only after Todo persistence succeeds and after Todo data loads.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add failing lifecycle and Todo smoke tests**
|
||||||
|
|
||||||
|
The App test uses function variables for Wails calls and proves initialize → start and stop → cleanup order. The Todo smoke mock records `notifications.replace`; it asserts one open reminder request with the Todo ID and a later empty replacement after completion or deletion.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Confirm red**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GOCACHE=/tmp/verstak-go-cache go test ./internal/api -run 'TestDomReadyInitializesNotificationsBeforeScheduler|TestShutdownStopsNotifications' -count=1
|
||||||
|
cd ../verstak-official-plugins && node scripts/smoke-todo-plugin.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: lifecycle methods and schedule calls are absent.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement lifecycle and Todo desired state**
|
||||||
|
|
||||||
|
Use `runtime.InitializeNotifications`, `runtime.SendNotification`, and `runtime.CleanupNotifications` only in core. Register `OnDomReady`/ `OnShutdown` in Wails options. Todo maps open valid `reminderAt` values to ISO UTC using `new Date(todo.reminderAt).toISOString()`, localizes title/body, and calls `api.notifications.replace`. A scheduling failure reports status but never rolls back already-saved Todo data.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify and commit both repositories**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GOCACHE=/tmp/verstak-go-cache go test ./internal/api -count=1
|
||||||
|
cd ../verstak-official-plugins && node scripts/smoke-todo-plugin.js && ./scripts/check.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ../verstak-desktop
|
||||||
|
git add main.go internal/api/app.go internal/api/app_test.go
|
||||||
|
git commit -m "feat: start native notifications with desktop app"
|
||||||
|
git push https://github.com/mirivlad/verstak.git main
|
||||||
|
git push mirror main
|
||||||
|
cd ../verstak-official-plugins
|
||||||
|
git add plugins/todo scripts/smoke-todo-plugin.js
|
||||||
|
git commit -m "feat: schedule native Todo reminders"
|
||||||
|
git push https://github.com/mirivlad/verstak-official-plugins.git main
|
||||||
|
git push mirror main
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 4: Windows/Linux tray, close policy, and single instance
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `internal/shell/tray/controller.go`
|
||||||
|
- Create: `internal/shell/tray/controller_test.go`
|
||||||
|
- Modify: `main.go`
|
||||||
|
- Modify: `internal/api/app.go`
|
||||||
|
- Modify: `internal/api/app_test.go`
|
||||||
|
- Modify: `go.mod`
|
||||||
|
- Modify: `go.sum`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- `tray.Start(icon []byte, actions Actions) error`, with `Actions.Show` and `Actions.Quit`.
|
||||||
|
- `App.BeforeClose(ctx) bool` hides and returns true unless `App.Quit()` made shutdown explicit.
|
||||||
|
- `App.ShowWindow()` reveals the existing window.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add failing close-policy and tray action tests**
|
||||||
|
|
||||||
|
```go
|
||||||
|
func TestBeforeCloseHidesWindowUntilExplicitQuit(t *testing.T) {
|
||||||
|
app, window := newWindowTestApp(t)
|
||||||
|
if prevent := app.BeforeClose(context.Background()); !prevent || window.hideCalls != 1 { t.Fatal("ordinary close must hide") }
|
||||||
|
app.Quit()
|
||||||
|
if prevent := app.BeforeClose(context.Background()); prevent { t.Fatal("explicit quit was prevented") }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use a fake tray adapter to assert exactly two labels, **Show Verstak** and **Quit**, and one callback invocation per click.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Confirm red**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GOCACHE=/tmp/verstak-go-cache go test ./internal/api -run TestBeforeCloseHidesWindowUntilExplicitQuit -count=1
|
||||||
|
GOCACHE=/tmp/verstak-go-cache go test ./internal/shell/tray -count=1
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: absent packages/methods.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement adapter and Wails wiring**
|
||||||
|
|
||||||
|
Add `github.com/getlantern/systray@v1.2.2`. The production adapter calls nonblocking `systray.Register`, sets compact source-controlled PNG bytes derived from the existing tracked logo, and starts goroutines for the two click channels. `main.go` registers `OnBeforeClose` and uses `options.SingleInstanceLock` whose second launch calls `app.ShowWindow`. Do not embed ignored Wails-generated files from `build/`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify and commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gofmt -w main.go internal/api/app.go internal/api/app_test.go internal/shell/tray/*.go
|
||||||
|
GOCACHE=/tmp/verstak-go-cache go test ./internal/shell/tray ./internal/api -count=1
|
||||||
|
./scripts/build.sh
|
||||||
|
ldd build/bin/verstak-desktop | grep -E 'ayatana-appindicator|appindicator'
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add main.go internal/api/app.go internal/api/app_test.go internal/shell/tray go.mod go.sum
|
||||||
|
git commit -m "feat: keep desktop app in system tray"
|
||||||
|
git push https://github.com/mirivlad/verstak.git main
|
||||||
|
git push mirror main
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 5: Packaging and public README screenshots
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `packaging/deb/control`
|
||||||
|
- Modify: `scripts/build.sh`
|
||||||
|
- Modify: `scripts/package-appimage.sh`
|
||||||
|
- Modify: `scripts/test-package-formats.sh`
|
||||||
|
- Modify: `README.md`
|
||||||
|
- Create: `README.ru.md`
|
||||||
|
- Create: `docs/screenshots/overview.png`
|
||||||
|
- Create: `docs/screenshots/workspace-files-notes.png`
|
||||||
|
- Create: `docs/screenshots/activity-journal.png`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add failing package-contract checks**
|
||||||
|
|
||||||
|
Add assertions for `libayatana-appindicator3-dev` in the Linux build guidance, `libayatana-appindicator3-1` in Debian dependencies, and `ayatana-appindicator` in the AppImage packing verification.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Confirm red**
|
||||||
|
|
||||||
|
Run: `./scripts/test-package-formats.sh`
|
||||||
|
|
||||||
|
Expected: the first tray dependency assertion fails.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement portable package support**
|
||||||
|
|
||||||
|
Declare the Debian runtime dependency. Make the Linux build error name the appindicator development header. Require the dynamically discovered appindicator library to appear in AppDir after the existing `ldd` traversal. Keep the Windows system-WebView2 policy unchanged.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Install the supplied public README sources**
|
||||||
|
|
||||||
|
Replace this repository's `README.md` with the supplied English source at the workspace root and add the supplied Russian source as `README.ru.md`. Add an image strip after “What is Verstak?” / “Что такое Верстак?” that links to three tracked PNG screenshots and uses matching localized alt text.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Produce real screenshots**
|
||||||
|
|
||||||
|
Build and run the desktop app against `/home/mirivlad/Nextcloud/Verstak/VerstakVault` on the active X display. Capture:
|
||||||
|
1. Overview showing recent work and entry points;
|
||||||
|
2. a populated workspace Files/Notes view;
|
||||||
|
3. Activity with a suggested session and its Journal review.
|
||||||
|
|
||||||
|
Use `scrot` or ImageMagick `import`, crop only surrounding desktop chrome, inspect every PNG visually, and do not add generated mockups. Remove all non-user-safe text from the test vault before capture.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Verify documentation and package contracts, commit, push**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/test-package-formats.sh
|
||||||
|
git diff --check
|
||||||
|
test -s docs/screenshots/overview.png
|
||||||
|
test -s docs/screenshots/workspace-files-notes.png
|
||||||
|
test -s docs/screenshots/activity-journal.png
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packaging/deb/control scripts/build.sh scripts/package-appimage.sh scripts/test-package-formats.sh README.md README.ru.md docs/screenshots
|
||||||
|
git commit -m "docs: add public README and product screenshots"
|
||||||
|
git push https://github.com/mirivlad/verstak.git main
|
||||||
|
git push mirror main
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 6: Complete verification and GitHub alpha releases
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- No source changes expected unless verification exposes a defect.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run all checks**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GOCACHE=/tmp/verstak-go-cache ./scripts/test.sh
|
||||||
|
./scripts/check.sh
|
||||||
|
./scripts/test-package-formats.sh
|
||||||
|
./scripts/test-build-windows.sh
|
||||||
|
cd ../verstak-official-plugins && ./scripts/check.sh && ./scripts/test-package-portable.sh && ./scripts/test-publish-github-release.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build and checksum both alpha releases**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ../verstak-official-plugins && ./scripts/release.sh v0.1.0-alpha.2 && (cd release && sha256sum --check SHA256SUMS)
|
||||||
|
cd ../verstak-desktop && ./scripts/release.sh v0.1.0-alpha.2 && (cd release && sha256sum --check SHA256SUMS)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Publish and inspect releases**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ../verstak-official-plugins && ./scripts/publish-github-release.sh v0.1.0-alpha.2
|
||||||
|
cd ../verstak-desktop && ./scripts/publish-github-release.sh v0.1.0-alpha.2
|
||||||
|
gh release view v0.1.0-alpha.2 -R mirivlad/verstak-official-plugins
|
||||||
|
gh release view v0.1.0-alpha.2 -R mirivlad/verstak
|
||||||
|
```
|
||||||
|
|
||||||
|
The final report distinguishes automated verification from the two required real desktop manual checks: tray close/show/quit and a near-future Todo notification while the window is hidden.
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
# Native Notifications and System Tray Design
|
||||||
|
|
||||||
|
**Status:** approved for implementation on 2026-07-14
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Deliver Todo reminders as native notifications on Windows and Linux, and keep
|
||||||
|
Verstak running in the system tray when its main window is closed. The feature
|
||||||
|
must work in the portable Windows archive, Debian package, and AppImage.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- The desktop core owns notification delivery, scheduling, and tray lifetime.
|
||||||
|
- `verstak.todo` owns the Todo-specific reminder policy and text.
|
||||||
|
- No new official notifications plugin is introduced. A dynamic plugin cannot
|
||||||
|
call Wails directly, so it cannot be the native-notification transport.
|
||||||
|
- macOS is out of scope for this alpha. The interfaces remain platform-neutral
|
||||||
|
where that costs nothing.
|
||||||
|
|
||||||
|
## Tray behavior
|
||||||
|
|
||||||
|
On Windows and Linux, a tray icon is registered before the Wails event loop
|
||||||
|
starts. Its menu contains exactly two actions:
|
||||||
|
|
||||||
|
1. **Show Verstak** — shows and focuses the existing main window.
|
||||||
|
2. **Quit** — exits the process deliberately.
|
||||||
|
|
||||||
|
Closing the main window with its window-manager close control hides the window
|
||||||
|
and keeps the process, plugins, local browser receiver, and reminder scheduler
|
||||||
|
alive. It does not terminate the application. The quit action temporarily
|
||||||
|
allows the close lifecycle to finish and then exits normally.
|
||||||
|
|
||||||
|
The app has a single-instance lock. If a user launches the executable while an
|
||||||
|
instance is hidden in the tray, the existing instance shows its window instead
|
||||||
|
of creating a second process.
|
||||||
|
|
||||||
|
The implementation uses `github.com/getlantern/systray` through a small
|
||||||
|
`internal/shell/tray` adapter. It uses `Register`, rather than its blocking
|
||||||
|
`Run`, so Wails remains the owner of the GUI event loop. A compact PNG derived
|
||||||
|
from the tracked project logo is encoded in the tray package, so a clean build
|
||||||
|
does not depend on ignored Wails-generated icon files. The library accepts PNG
|
||||||
|
icon bytes on both target platforms.
|
||||||
|
|
||||||
|
## Notification capability and permission
|
||||||
|
|
||||||
|
The core registers the capability:
|
||||||
|
|
||||||
|
```
|
||||||
|
verstak/core/notifications/v1
|
||||||
|
```
|
||||||
|
|
||||||
|
Plugins that use it must both require that capability and declare the
|
||||||
|
`notifications.schedule` permission. The plugin-host API exposes only two
|
||||||
|
operations within the calling plugin namespace:
|
||||||
|
|
||||||
|
```
|
||||||
|
api.notifications.replace(items)
|
||||||
|
api.notifications.clear()
|
||||||
|
```
|
||||||
|
|
||||||
|
`replace` is an atomic desired-state replacement, not an append operation. An
|
||||||
|
item contains a plugin-local stable `id`, an ISO-8601 UTC `dueAt`, a title, and
|
||||||
|
a body. The core supplies the plugin ID and rejects calls from disabled,
|
||||||
|
missing-permission, or undeclared-capability plugins. It also validates empty
|
||||||
|
IDs, duplicate IDs, invalid timestamps, and unsafe oversized text.
|
||||||
|
|
||||||
|
No plugin can send arbitrary immediate native notifications or address another
|
||||||
|
plugin's schedules in this alpha.
|
||||||
|
|
||||||
|
## Scheduler and persistence
|
||||||
|
|
||||||
|
`internal/core/notifications` persists one canonical schedule file at:
|
||||||
|
|
||||||
|
```
|
||||||
|
<vault>/.verstak/notifications/schedules.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Each record contains `{pluginId, id, dueAt, title, body, sentForDueAt}`.
|
||||||
|
The composite `(pluginId, id)` is unique. Replacing an item with the same due
|
||||||
|
time preserves `sentForDueAt`; changing `dueAt` clears it. Replacing a plugin's
|
||||||
|
list removes its stale records. This provides deterministic cancellation for
|
||||||
|
completed, deleted, and rescheduled Todos.
|
||||||
|
|
||||||
|
The manager starts after Wails reaches `OnDomReady`, initializes Wails native
|
||||||
|
notifications, and evaluates the persisted schedule immediately and then at
|
||||||
|
least every 30 seconds. A sender is injected behind an interface for unit
|
||||||
|
tests. After a successful delivery, the manager atomically records
|
||||||
|
`sentForDueAt`. A delivery error leaves the schedule pending and is logged for
|
||||||
|
a later retry.
|
||||||
|
|
||||||
|
An expired record that has not been sent is delivered once after the next app
|
||||||
|
start. A record already sent for its current due time is never sent again.
|
||||||
|
Completely quitting Verstak stops the scheduler: no separate daemon or OS
|
||||||
|
background service is added. Hiding the window in the tray does **not** stop it.
|
||||||
|
|
||||||
|
The core calls `CleanupNotifications` during shutdown, including on Linux where
|
||||||
|
it releases the D-Bus connection.
|
||||||
|
|
||||||
|
## Todo behavior
|
||||||
|
|
||||||
|
`verstak.todo` adds the core notifications capability to `requires` and adds
|
||||||
|
the `notifications.schedule` permission. After every successful Todo storage
|
||||||
|
write, it derives the complete desired reminder list:
|
||||||
|
|
||||||
|
- include only open Todos with a valid `reminderAt`;
|
||||||
|
- use the Todo ID as the stable notification ID;
|
||||||
|
- convert local `datetime-local` input to an ISO-8601 UTC instant;
|
||||||
|
- use the Todo title in the notification body and locale-aware reminder text;
|
||||||
|
- call `api.notifications.replace` with the full list.
|
||||||
|
|
||||||
|
The same replacement runs after loading persisted Todos, so a transient
|
||||||
|
schedule-write failure repairs itself next time the Todo view is opened. A
|
||||||
|
schedule API failure does not roll back Todo data; the UI reports the failure
|
||||||
|
instead. The existing in-view overdue/reminder badge remains useful context and
|
||||||
|
is not removed.
|
||||||
|
|
||||||
|
## Packaging
|
||||||
|
|
||||||
|
`getlantern/systray` requires CGO. The Windows release build already uses
|
||||||
|
`x86_64-w64-mingw32-gcc`; the Windows packaging tests must compile it with the
|
||||||
|
tray dependency included. Linux build instructions add
|
||||||
|
`libayatana-appindicator3-dev`. The Debian package declares the corresponding
|
||||||
|
runtime dependency `libayatana-appindicator3-1`.
|
||||||
|
|
||||||
|
The existing AppImage packager traverses `ldd` for the desktop executable and
|
||||||
|
copies non-glibc runtime libraries. Its verification is extended to prove that
|
||||||
|
the appindicator library is present in the AppDir when the tray implementation
|
||||||
|
is compiled in.
|
||||||
|
|
||||||
|
## Public README and product screenshots
|
||||||
|
|
||||||
|
The workspace-root `README.md` and `README.ru.md` supplied by the maintainer
|
||||||
|
become the public repository documents: the English source replaces this
|
||||||
|
repository's `README.md`, and the Russian source is committed as
|
||||||
|
`README.ru.md`. Their existing language links and public-release instructions
|
||||||
|
are retained.
|
||||||
|
|
||||||
|
Three screenshots are captured from the real desktop application using the
|
||||||
|
test vault, then committed under `docs/screenshots/`:
|
||||||
|
|
||||||
|
1. `overview.png` — returning to recent work and useful next actions;
|
||||||
|
2. `workspace-files-notes.png` — ordinary vault files and Markdown notes in a
|
||||||
|
workspace;
|
||||||
|
3. `activity-journal.png` — review of a factual activity session as a Journal
|
||||||
|
entry.
|
||||||
|
|
||||||
|
Both README variants include the same three images with localized alt text.
|
||||||
|
They are factual UI captures, not generated illustrative mockups. Capture data
|
||||||
|
must be limited to the disposable test vault and inspected before commit so no
|
||||||
|
credentials or personal content are published.
|
||||||
|
|
||||||
|
## Test and manual verification
|
||||||
|
|
||||||
|
Automated tests cover:
|
||||||
|
|
||||||
|
- schedule replacement, cancellation, rescheduling, persistence, one-time
|
||||||
|
overdue delivery, failed-send retry, and permission/capability rejection;
|
||||||
|
- Todo desired-list derivation and calls after create/edit/status/delete;
|
||||||
|
- close policy: ordinary close hides, explicit quit permits shutdown;
|
||||||
|
- tray controller action wiring and second-instance window reveal;
|
||||||
|
- Linux/Windows build scripts and package dependency expectations.
|
||||||
|
|
||||||
|
Manual smoke tests are required because neither unit tests nor Playwright can
|
||||||
|
assert a real desktop notification area or OS toast:
|
||||||
|
|
||||||
|
1. On Linux and Windows, start Verstak, close its window, use the tray menu to
|
||||||
|
reveal it, and use **Quit** to terminate it.
|
||||||
|
2. Set a Todo reminder for a near future time, hide the window in the tray, and
|
||||||
|
observe one native notification.
|
||||||
|
3. Quit before a future reminder, relaunch after it expires, and observe one
|
||||||
|
overdue notification with no duplicate on the next scheduler scan.
|
||||||
|
4. Inspect all three README screenshots at their committed size and confirm
|
||||||
|
that they show only intended test-vault data and explain the feature named
|
||||||
|
in their localized captions.
|
||||||
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 110 KiB |
@@ -0,0 +1,96 @@
|
|||||||
|
# UI Polish Milestone
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- This milestone is visual polish only: no backend API changes, no plugin contract changes, and no feature removal.
|
||||||
|
- English remains the baseline UI language for this pass. Hardcoded Russian strings that are visible in core UI should be converted to English where touched.
|
||||||
|
- Official plugins are plain JS bundles with injected CSS. Introducing a new shared UI package would be larger than this milestone, so shared primitives will be expressed as global design tokens/classes plus consistent local CSS in each bundle.
|
||||||
|
- Desktop workbench density should stay compact. The goal is consistency, not a marketing-style redesign.
|
||||||
|
|
||||||
|
## Audit Findings
|
||||||
|
|
||||||
|
### Existing Tokens
|
||||||
|
|
||||||
|
- `frontend/src/App.svelte` already defines partial global button classes: `btn-primary`, `btn-secondary`, `btn-danger`, `btn-ghost`, `btn-icon`, plus global button focus/disabled behavior.
|
||||||
|
- Most shell and plugin surfaces hardcode the same dark palette directly: `#1a1a2e`, `#16213e`, `#0f3460`, `#4ecca3`, `#e94560`, `#8b8ba8`.
|
||||||
|
- There are no named CSS variables for semantic surface, selected row, border, muted text, warning, or focus ring.
|
||||||
|
- Plugin bundles repeat their own toolbar/button/list/empty/error styles instead of reusing shell-level primitives.
|
||||||
|
|
||||||
|
### Repeated Patterns
|
||||||
|
|
||||||
|
- Page/workspace headers: `WorkspaceHost.svelte`, `WorkbenchHost.svelte`, `ViewContainer.svelte`, plugin toolbars.
|
||||||
|
- Tab bars: workspace tabs use a custom active style separate from selected rows and sidebar active states.
|
||||||
|
- Toolbars: Files, Notes, Search, Activity, Journal, Browser Inbox, and default editor use similar but inconsistent dark toolbar rules.
|
||||||
|
- List rows: Files rows, Notes rows, Activity rows, Journal rows, Secrets list items, Browser Inbox rows, Global Search results, WorkspaceTree rows.
|
||||||
|
- Cards/panels: Today panels, worklog suggestions, Secrets detail/form, Browser Inbox detail text, plugin manager cards.
|
||||||
|
- Empty/error states: many are plain centered text or show technical details as primary text.
|
||||||
|
|
||||||
|
### Candidate Primitives
|
||||||
|
|
||||||
|
Implement as global classes/tokens first, then apply locally:
|
||||||
|
|
||||||
|
- PageHeader / WorkspaceHeader: compact title, metadata, right actions/search.
|
||||||
|
- TabBar / TabButton: consistent active tab, hover, focus.
|
||||||
|
- Toolbar / ToolbarGroup: grouped controls with separators and predictable height.
|
||||||
|
- Button variants: primary, secondary, ghost, danger, icon.
|
||||||
|
- ListRow: hover, selected, disabled, metadata line, actions zone.
|
||||||
|
- Card: flat desktop panel with 6px radius and subtle border.
|
||||||
|
- SplitPane: list/detail grid with shared border and selected item states.
|
||||||
|
- EmptyState: centered title, hint, optional action.
|
||||||
|
- InlineAlert: readable message plus optional details/debug text.
|
||||||
|
- StatusBadge: compact semantic label for type, status, warning/error.
|
||||||
|
- Menu: context menu with same surface, hover, danger item, separator.
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
1. Add semantic CSS variables and global primitive classes in `frontend/src/App.svelte`.
|
||||||
|
- Colors: background, surface, surface-muted, surface-hover, surface-selected, border, border-strong, text-primary, text-secondary, text-muted, accent, accent-muted, danger, warning, success.
|
||||||
|
- Spacing: 4/8/12/16/24/32 via variables.
|
||||||
|
- Radius, font sizes, focus ring, and modest elevation tokens.
|
||||||
|
|
||||||
|
2. Normalize shell surfaces.
|
||||||
|
- Update `WorkspaceHost.svelte`, `WorkbenchHost.svelte`, `ViewContainer.svelte`, `Sidebar.svelte`, `WorkspaceTree.svelte`, `GlobalSearch.svelte`, and `TodaySurface.svelte` to use tokens and shared state rules.
|
||||||
|
- Convert visible Russian shell search/empty strings to English.
|
||||||
|
- Keep plugin manager architecture untouched; only global tokens should make it less visually divergent.
|
||||||
|
|
||||||
|
3. Normalize Files.
|
||||||
|
- Group toolbar into Navigation, Create, Selection, Clipboard/More, and filter/sort areas.
|
||||||
|
- Keep existing context menu and operations, restyle menu with shared surface/hover/danger states.
|
||||||
|
- Make single row click open when no modifier is used; keep ctrl/meta/shift selection behavior and keyboard selection.
|
||||||
|
- Reduce actions column visual noise by showing secondary row actions on hover/selection and using a compact More/action group.
|
||||||
|
|
||||||
|
4. Normalize Notes.
|
||||||
|
- Make New Note the primary page action.
|
||||||
|
- Keep filter/sort inside the toolbar group.
|
||||||
|
- Make entire note row readable and clickable; row actions remain visible enough and use icon-button states.
|
||||||
|
- Use shared empty/status/error styling.
|
||||||
|
|
||||||
|
5. Normalize Search.
|
||||||
|
- Render results as compact rows/cards with type badge/icon, title/path, secondary match reason, and compact Open button.
|
||||||
|
- Show provider errors as inline alert-style secondary information, not as raw main status text.
|
||||||
|
|
||||||
|
6. Normalize Activity and Journal.
|
||||||
|
- Add human-readable event labels such as "Workspace selected", "File opened", "Note edited", and "Work session detected".
|
||||||
|
- Move raw event names/source IDs into a details block or secondary metadata.
|
||||||
|
- Style plugin errors through inline alert patterns.
|
||||||
|
- Present worklog suggestions as cards with consistent title/summary/minutes actions.
|
||||||
|
|
||||||
|
7. Normalize Secrets and Browser Inbox.
|
||||||
|
- Keep split-pane list/detail layout.
|
||||||
|
- Use shared list rows, detail card/form surfaces, and empty states.
|
||||||
|
- Render "Value hidden" as password-like hidden field state.
|
||||||
|
- Use button variants: primary for save/create, secondary for copy/edit/remove where appropriate, danger for delete/clear/remove destructive actions.
|
||||||
|
|
||||||
|
8. Verify.
|
||||||
|
- Run desktop frontend build and relevant Playwright checks if available.
|
||||||
|
- Run official plugin smoke/check script.
|
||||||
|
- Capture before/after screenshots or save available after screenshots under `docs/ui-polish-assets/`.
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Backend API or Wails method changes.
|
||||||
|
- Plugin manifest/contribution contract changes.
|
||||||
|
- New heavy UI dependencies.
|
||||||
|
- Full i18n extraction.
|
||||||
|
- Redesigning the product information architecture.
|
||||||
|
- Rebuilding official plugins into Svelte packages.
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
|
||||||
|
|
||||||
|
test.describe('Activity workflow', () => {
|
||||||
|
let consoleCollector;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
consoleCollector = setupConsoleCollector(page);
|
||||||
|
await resetMockState(page);
|
||||||
|
await page.goto('/');
|
||||||
|
await waitForAppReady(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async () => {
|
||||||
|
consoleCollector.assertNoErrors();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('workspace activity explains empty event flow', async ({ page }) => {
|
||||||
|
await page.getByRole('tab', { name: 'Activity' }).click();
|
||||||
|
|
||||||
|
const activity = page.locator('.activity-root');
|
||||||
|
await expect(activity).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(activity.locator('.activity-title')).toContainText('Activity');
|
||||||
|
await expect(activity.locator('.activity-count')).toHaveText('0 events');
|
||||||
|
await expect(activity.locator('.activity-empty')).toContainText('No activity events yet');
|
||||||
|
await expect(activity.locator('.activity-empty')).toContainText('File changes, browser captures, and conversions will appear here');
|
||||||
|
await expect(activity.locator('[data-activity-action="clear"]')).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clearing activity requires destructive confirmation for the current case', async ({ page }) => {
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
await window.go.api.App.WritePluginSettings('verstak.activity', {
|
||||||
|
'events:workspace:Project': [{
|
||||||
|
activityId: 'activity-to-clear',
|
||||||
|
occurredAt: '2026-06-30T08:00:00.000Z',
|
||||||
|
type: 'note.saved',
|
||||||
|
title: 'Keep until confirmed',
|
||||||
|
workspaceRootPath: 'Project',
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('tab', { name: 'Activity' }).click();
|
||||||
|
const activity = page.locator('.activity-root');
|
||||||
|
await expect(activity.locator('.activity-count')).toHaveText('1 event');
|
||||||
|
|
||||||
|
await activity.locator('[data-activity-action="clear"]').click();
|
||||||
|
const confirmation = activity.locator('[data-activity-clear-confirmation]');
|
||||||
|
await expect(confirmation).toBeVisible();
|
||||||
|
await expect(confirmation).toContainText(/Project|Дело/);
|
||||||
|
await expect(activity.locator('.activity-count')).toHaveText('1 event');
|
||||||
|
|
||||||
|
await confirmation.locator('[data-activity-clear-cancel]').click();
|
||||||
|
await expect(confirmation).toHaveCount(0);
|
||||||
|
await expect(activity.locator('.activity-count')).toHaveText('1 event');
|
||||||
|
|
||||||
|
await activity.locator('[data-activity-action="clear"]').click();
|
||||||
|
await activity.locator('[data-activity-clear-confirm]').click();
|
||||||
|
await expect(activity.locator('.activity-count')).toHaveText('0 events');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('workspace activity keeps raw events and renders factual work session candidates', async ({ page }) => {
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
await window.go.api.App.WritePluginSettings('verstak.activity', {
|
||||||
|
'events:workspace:Project': [
|
||||||
|
{
|
||||||
|
activityId: 'activity-e2e-capture',
|
||||||
|
occurredAt: '2026-06-30T08:00:00.000Z',
|
||||||
|
type: 'browser.capture.selection',
|
||||||
|
title: 'Research Capture',
|
||||||
|
summary: 'Selected text from the article',
|
||||||
|
sourcePluginId: 'verstak.browser-inbox',
|
||||||
|
workspaceRootPath: 'Project',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
activityId: 'activity-e2e-note',
|
||||||
|
occurredAt: '2026-06-30T08:20:00.000Z',
|
||||||
|
type: 'note.saved',
|
||||||
|
title: 'Saved note',
|
||||||
|
summary: 'Project/Notes/Research Capture.md',
|
||||||
|
sourcePluginId: 'verstak.files',
|
||||||
|
workspaceRootPath: 'Project',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
activityId: 'activity-e2e-open',
|
||||||
|
occurredAt: '2026-06-30T08:30:00.000Z',
|
||||||
|
type: 'file.opened',
|
||||||
|
title: 'Selected file',
|
||||||
|
summary: 'Project/Notes/Research Capture.md',
|
||||||
|
sourcePluginId: 'verstak.files',
|
||||||
|
workspaceRootPath: 'Project',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('tab', { name: 'Activity' }).click();
|
||||||
|
|
||||||
|
const activity = page.locator('.activity-root');
|
||||||
|
await expect(activity.locator('.activity-count')).toHaveText('3 events');
|
||||||
|
await expect(activity.locator('[data-activity-id="activity-e2e-capture"]')).toContainText('Research Capture');
|
||||||
|
await expect(activity.locator('[data-activity-id="activity-e2e-note"]')).toContainText('Saved note');
|
||||||
|
await expect(activity.locator('[data-activity-id="activity-e2e-open"]')).toContainText('Selected file');
|
||||||
|
|
||||||
|
const candidateSection = activity.locator('[data-activity-section="work-session-candidates"]');
|
||||||
|
await expect(candidateSection).toContainText('Possible journal entries');
|
||||||
|
const candidate = candidateSection.locator('[data-work-session-candidate]');
|
||||||
|
await expect(candidate).toHaveCount(1);
|
||||||
|
await expect(candidate).toContainText('Deal: Project');
|
||||||
|
await expect(candidate).toContainText('Estimated duration: 10 min');
|
||||||
|
await expect(candidate).toContainText('Activities: 2');
|
||||||
|
await expect(candidate).not.toContainText('Project work on');
|
||||||
|
await expect(candidate.locator('[data-work-session-action="review"]')).toBeVisible();
|
||||||
|
await expect(candidate.locator('[data-work-session-action="dismiss"]')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Review opens an empty Journal form with selectable candidate activities', async ({ page }) => {
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
await window.go.api.App.WritePluginSettings('verstak.activity', {
|
||||||
|
'events:workspace:Project': [
|
||||||
|
{
|
||||||
|
activityId: 'review-capture',
|
||||||
|
occurredAt: '2026-06-30T08:00:00.000Z',
|
||||||
|
type: 'browser.capture.selection',
|
||||||
|
title: 'Research Capture',
|
||||||
|
workspaceRootPath: 'Project',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
activityId: 'review-note',
|
||||||
|
occurredAt: '2026-06-30T08:20:00.000Z',
|
||||||
|
type: 'note.saved',
|
||||||
|
title: 'Saved note',
|
||||||
|
workspaceRootPath: 'Project',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('tab', { name: 'Activity' }).click();
|
||||||
|
await page.locator('[data-work-session-candidate] [data-work-session-action="review"]').click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('tab', { name: 'Journal' })).toHaveAttribute('aria-selected', 'true');
|
||||||
|
const journal = page.locator('.journal-root');
|
||||||
|
await expect(journal).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(journal.locator('[data-journal-candidate]')).toContainText('Deal: Project');
|
||||||
|
await expect(journal.locator('[data-journal-candidate]')).toContainText('Estimated duration: 10 min');
|
||||||
|
await expect(journal.locator('[data-journal-input="title"]')).toHaveValue('');
|
||||||
|
await expect(journal.locator('[data-journal-input="summary"]')).toHaveValue('');
|
||||||
|
await expect(journal.locator('[data-journal-input="minutes"]')).toHaveValue('10');
|
||||||
|
|
||||||
|
const activityInputs = journal.locator('[data-journal-candidate-activity]');
|
||||||
|
await expect(activityInputs).toHaveCount(2);
|
||||||
|
await expect(activityInputs.nth(0)).toBeChecked();
|
||||||
|
await expect(activityInputs.nth(1)).toBeChecked();
|
||||||
|
await journal.locator('[data-journal-input="title"]').fill('Review research capture');
|
||||||
|
await journal.locator('[data-journal-input="summary"]').fill('Read the capture and updated the project note.');
|
||||||
|
await activityInputs.nth(1).uncheck();
|
||||||
|
await journal.locator('[data-journal-action="save-entry"]').click();
|
||||||
|
|
||||||
|
await expect(journal).toContainText('Review research capture');
|
||||||
|
await expect(journal).toContainText('10 min');
|
||||||
|
const stored = await page.evaluate(async () => {
|
||||||
|
const result = await window.go.api.App.ReadPluginSettings('verstak.journal');
|
||||||
|
return Array.isArray(result) ? result[0]['worklog:workspace:Project'] : result['worklog:workspace:Project'];
|
||||||
|
});
|
||||||
|
await expect(stored).toEqual([expect.objectContaining({
|
||||||
|
title: 'Review research capture',
|
||||||
|
summary: 'Read the capture and updated the project note.',
|
||||||
|
sourceCandidateId: expect.any(String),
|
||||||
|
activityIds: ['review-capture'],
|
||||||
|
})]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
|
||||||
|
|
||||||
|
test.describe('Browser workflow', () => {
|
||||||
|
let consoleCollector;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
consoleCollector = setupConsoleCollector(page);
|
||||||
|
await resetMockState(page);
|
||||||
|
await page.goto('/');
|
||||||
|
await waitForAppReady(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async () => {
|
||||||
|
consoleCollector.assertNoErrors();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('global inbox explains empty capture flow and exposes assignment filters', async ({ page }) => {
|
||||||
|
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser' }).click();
|
||||||
|
|
||||||
|
const inbox = page.locator('.browser-inbox-root');
|
||||||
|
await expect(inbox).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(inbox.locator('.browser-inbox-title')).toHaveText('Browser');
|
||||||
|
await expect(inbox.locator('.browser-inbox-count')).toHaveText('0 items');
|
||||||
|
await expect(inbox.locator('.browser-inbox-empty')).toContainText('No browser materials yet');
|
||||||
|
await expect(inbox.locator('.browser-inbox-empty')).toContainText('Send a page, selection, or link from the extension');
|
||||||
|
await expect(inbox.locator('[data-browser-inbox-filter="status"]')).toBeVisible();
|
||||||
|
await expect(inbox.locator('[data-browser-inbox-filter="workspace"]')).toBeVisible();
|
||||||
|
await expect(inbox.locator('[data-browser-inbox-action="clear"]')).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('workspace inbox renders stored captures with conversion actions', async ({ page }) => {
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
await window.go.api.App.WritePluginSettings('verstak.browser-inbox', {
|
||||||
|
'captures:global': [{
|
||||||
|
captureId: 'capture-e2e-1',
|
||||||
|
capturedAt: '2026-06-30T08:00:00.000Z',
|
||||||
|
kind: 'file',
|
||||||
|
url: 'https://example.com/report',
|
||||||
|
title: 'Research Report',
|
||||||
|
domain: 'example.com',
|
||||||
|
text: 'Selected page text',
|
||||||
|
fileName: 'report.txt',
|
||||||
|
fileText: 'Report file contents',
|
||||||
|
workspaceRootPath: 'Project',
|
||||||
|
browserName: 'Firefox',
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.getByRole('tab', { name: 'Browser' }).click();
|
||||||
|
|
||||||
|
const inbox = page.locator('.browser-inbox-root');
|
||||||
|
await expect(inbox.locator('.browser-inbox-count')).toHaveText('1 item');
|
||||||
|
await expect(inbox.locator('[data-browser-capture-id="capture-e2e-1"]')).toContainText('Research Report');
|
||||||
|
await expect(inbox.locator('.browser-inbox-detail-title')).toHaveText('Research Report');
|
||||||
|
await expect(inbox.locator('.browser-inbox-meta')).toContainText('example.com');
|
||||||
|
await expect(inbox.locator('.browser-inbox-text').first()).toContainText('Selected page text');
|
||||||
|
await expect(inbox.locator('[data-browser-inbox-action="create-note"]')).toBeVisible();
|
||||||
|
await expect(inbox.locator('[data-browser-inbox-action="create-link"]')).toBeVisible();
|
||||||
|
await expect(inbox.locator('[data-browser-inbox-action="create-file"]')).toBeVisible();
|
||||||
|
await expect(inbox.locator('[data-browser-inbox-action="remove"]')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('global inbox assigns, reassigns, filters, marks, and deletes captures', async ({ page }) => {
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
await window.go.api.App.WritePluginSettings('verstak.browser-inbox', {
|
||||||
|
'captures:global': [
|
||||||
|
{
|
||||||
|
captureId: 'inbox-unassigned',
|
||||||
|
capturedAt: '2026-06-30T08:20:00.000Z',
|
||||||
|
kind: 'page',
|
||||||
|
url: 'https://example.com/unassigned',
|
||||||
|
title: 'Unassigned research',
|
||||||
|
domain: 'example.com',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
captureId: 'inbox-client-processed',
|
||||||
|
capturedAt: '2026-06-30T08:10:00.000Z',
|
||||||
|
kind: 'link',
|
||||||
|
url: 'https://client.example.com/processed',
|
||||||
|
title: 'Processed client link',
|
||||||
|
domain: 'client.example.com',
|
||||||
|
workspaceRootPath: 'ClientA',
|
||||||
|
processed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
captureId: 'inbox-project-open',
|
||||||
|
capturedAt: '2026-06-30T08:00:00.000Z',
|
||||||
|
kind: 'selection',
|
||||||
|
title: 'Project quote',
|
||||||
|
text: 'A project quote',
|
||||||
|
workspaceRootPath: 'Project',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser' }).click();
|
||||||
|
|
||||||
|
const inbox = page.locator('.browser-inbox-root');
|
||||||
|
await expect(inbox.locator('.browser-inbox-count')).toHaveText('3 items');
|
||||||
|
const statusFilter = inbox.locator('[data-browser-inbox-filter="status"]');
|
||||||
|
const workspaceFilter = inbox.locator('[data-browser-inbox-filter="workspace"]');
|
||||||
|
|
||||||
|
await statusFilter.selectOption('unassigned');
|
||||||
|
await expect(inbox.locator('[data-browser-capture-id="inbox-unassigned"]')).toBeVisible();
|
||||||
|
await expect(inbox.locator('[data-browser-capture-id="inbox-client-processed"]')).toHaveCount(0);
|
||||||
|
|
||||||
|
await statusFilter.selectOption('all');
|
||||||
|
await workspaceFilter.selectOption('ClientA');
|
||||||
|
await expect(inbox.locator('[data-browser-capture-id="inbox-client-processed"]')).toBeVisible();
|
||||||
|
await expect(inbox.locator('[data-browser-capture-id="inbox-project-open"]')).toHaveCount(0);
|
||||||
|
|
||||||
|
await workspaceFilter.selectOption('');
|
||||||
|
await statusFilter.selectOption('unprocessed');
|
||||||
|
await expect(inbox.locator('[data-browser-capture-id="inbox-unassigned"]')).toBeVisible();
|
||||||
|
await expect(inbox.locator('[data-browser-capture-id="inbox-project-open"]')).toBeVisible();
|
||||||
|
await expect(inbox.locator('[data-browser-capture-id="inbox-client-processed"]')).toHaveCount(0);
|
||||||
|
|
||||||
|
await statusFilter.selectOption('all');
|
||||||
|
const assignment = inbox.locator('[data-browser-inbox-assignment="inbox-unassigned"]');
|
||||||
|
await assignment.selectOption('ClientA');
|
||||||
|
await expect.poll(async () => page.evaluate(async () => {
|
||||||
|
const result = await window.go.api.App.ReadPluginSettings('verstak.browser-inbox');
|
||||||
|
const settings = Array.isArray(result) ? result[0] : result;
|
||||||
|
return settings['captures:global'].find((capture) => capture.captureId === 'inbox-unassigned').workspaceRootPath;
|
||||||
|
})).toBe('ClientA');
|
||||||
|
|
||||||
|
await inbox.locator('[data-browser-inbox-assignment="inbox-unassigned"]').selectOption('Project');
|
||||||
|
await expect.poll(async () => page.evaluate(async () => {
|
||||||
|
const result = await window.go.api.App.ReadPluginSettings('verstak.browser-inbox');
|
||||||
|
const settings = Array.isArray(result) ? result[0] : result;
|
||||||
|
return settings['captures:global'].find((capture) => capture.captureId === 'inbox-unassigned').workspaceRootPath;
|
||||||
|
})).toBe('Project');
|
||||||
|
|
||||||
|
await inbox.locator('[data-browser-inbox-action="clear-assignment"]').click();
|
||||||
|
await expect.poll(async () => page.evaluate(async () => {
|
||||||
|
const result = await window.go.api.App.ReadPluginSettings('verstak.browser-inbox');
|
||||||
|
const settings = Array.isArray(result) ? result[0] : result;
|
||||||
|
return settings['captures:global'].find((capture) => capture.captureId === 'inbox-unassigned').workspaceRootPath || '';
|
||||||
|
})).toBe('');
|
||||||
|
|
||||||
|
await inbox.locator('[data-browser-inbox-action="toggle-processed"]').click();
|
||||||
|
await expect.poll(async () => page.evaluate(async () => {
|
||||||
|
const result = await window.go.api.App.ReadPluginSettings('verstak.browser-inbox');
|
||||||
|
const settings = Array.isArray(result) ? result[0] : result;
|
||||||
|
return settings['captures:global'].find((capture) => capture.captureId === 'inbox-unassigned').processed;
|
||||||
|
})).toBe(true);
|
||||||
|
|
||||||
|
await inbox.locator('[data-browser-inbox-action="toggle-processed"]').click();
|
||||||
|
await inbox.locator('[data-browser-inbox-action="remove"]').click();
|
||||||
|
await expect.poll(async () => page.evaluate(async () => {
|
||||||
|
const result = await window.go.api.App.ReadPluginSettings('verstak.browser-inbox');
|
||||||
|
const settings = Array.isArray(result) ? result[0] : result;
|
||||||
|
return settings['captures:global'].some((capture) => capture.captureId === 'inbox-unassigned');
|
||||||
|
})).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
|
||||||
|
|
||||||
|
test.describe('Command Palette', () => {
|
||||||
|
let consoleCollector;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
consoleCollector = setupConsoleCollector(page);
|
||||||
|
await resetMockState(page);
|
||||||
|
await page.goto('/');
|
||||||
|
await waitForAppReady(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async () => {
|
||||||
|
consoleCollector.assertNoErrors();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('opens with keyboard, filters commands, and executes registered frontend handler', async ({ page }) => {
|
||||||
|
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' }).click();
|
||||||
|
await expect(page.locator('.pt-root')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+K' : 'Control+K');
|
||||||
|
|
||||||
|
const palette = page.locator('.command-palette');
|
||||||
|
await expect(palette).toBeVisible();
|
||||||
|
await expect(palette.locator('[data-command-id="verstak.platform-test.show-version"]')).toBeVisible();
|
||||||
|
|
||||||
|
await palette.locator('[data-command-palette-input]').fill('version');
|
||||||
|
await expect(palette.locator('[data-command-id="verstak.platform-test.show-version"]')).toBeVisible();
|
||||||
|
await expect(palette.locator('[data-command-id="verstak.platform-test.run-tests"]')).not.toBeVisible();
|
||||||
|
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
|
||||||
|
await expect(palette).not.toBeVisible();
|
||||||
|
await expect(page.locator('[data-command-palette-status="success"]')).toContainText('Show Version Info');
|
||||||
|
await expect(page.locator('[data-command-palette-status="success"]')).toContainText('handled');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Escape closes the palette without changing current view', async ({ page }) => {
|
||||||
|
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+K' : 'Control+K');
|
||||||
|
await expect(page.locator('.command-palette')).toBeVisible();
|
||||||
|
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
|
await expect(page.locator('.command-palette')).not.toBeVisible();
|
||||||
|
await expect(page.locator('.workspace-host')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows user workflow commands before diagnostics and opens workspace tools', async ({ page }) => {
|
||||||
|
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+K' : 'Control+K');
|
||||||
|
|
||||||
|
const palette = page.locator('.command-palette');
|
||||||
|
await expect(palette).toBeVisible();
|
||||||
|
const items = palette.locator('.command-palette-item');
|
||||||
|
await expect(items.nth(0)).toHaveAttribute('data-command-id', 'verstak.shell.open-overview');
|
||||||
|
await expect(items.nth(1)).toHaveAttribute('data-command-id', 'verstak.shell.open-files');
|
||||||
|
await expect(items.nth(2)).toHaveAttribute('data-command-id', 'verstak.shell.open-activity');
|
||||||
|
await expect(items.nth(3)).toHaveAttribute('data-command-id', 'verstak.shell.open-browser-inbox');
|
||||||
|
|
||||||
|
await palette.locator('[data-command-palette-input]').fill('activity');
|
||||||
|
await expect(palette.locator('[data-command-id="verstak.shell.open-activity"]')).toBeVisible();
|
||||||
|
await expect(palette.locator('[data-command-id="verstak.platform-test.run-tests"]')).not.toBeVisible();
|
||||||
|
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
|
||||||
|
await expect(palette).not.toBeVisible();
|
||||||
|
await expect(page.getByRole('tab', { name: 'Activity' })).toHaveAttribute('aria-selected', 'true');
|
||||||
|
await expect(page.locator('.activity-root')).toBeVisible({ timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('starts file creation workflows from user commands', async ({ page }) => {
|
||||||
|
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+K' : 'Control+K');
|
||||||
|
|
||||||
|
let palette = page.locator('.command-palette');
|
||||||
|
await palette.locator('[data-command-palette-input]').fill('markdown');
|
||||||
|
await expect(palette.locator('[data-command-id="verstak.shell.create-markdown"]')).toBeVisible();
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
|
||||||
|
await expect(palette).not.toBeVisible();
|
||||||
|
await expect(page.getByRole('tab', { name: 'Files' })).toHaveAttribute('aria-selected', 'true');
|
||||||
|
await expect(page.locator('.files-root')).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.locator('[data-files-create-input]')).toBeVisible();
|
||||||
|
|
||||||
|
await page.locator('[data-files-create-input]').fill('Palette Note.md');
|
||||||
|
await page.locator('[data-files-create-confirm]').click();
|
||||||
|
await expect(page.locator('[data-file-name="Palette Note.md"]')).toBeVisible();
|
||||||
|
|
||||||
|
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+K' : 'Control+K');
|
||||||
|
palette = page.locator('.command-palette');
|
||||||
|
await palette.locator('[data-command-palette-input]').fill('text file');
|
||||||
|
await expect(palette.locator('[data-command-id="verstak.shell.create-text"]')).toBeVisible();
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
|
||||||
|
await expect(page.locator('[data-files-create-input]')).toBeVisible();
|
||||||
|
await page.locator('[data-files-create-input]').fill('Palette Text.txt');
|
||||||
|
await page.locator('[data-files-create-confirm]').click();
|
||||||
|
await expect(page.locator('[data-file-name="Palette Text.txt"]')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runs sync workflow commands', async ({ page }) => {
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const err = await window.go.api.App.PluginSyncConfigure('verstak.sync', 'https://sync.example.test', 'alice', 'secret');
|
||||||
|
if (err) throw new Error(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+K' : 'Control+K');
|
||||||
|
|
||||||
|
let palette = page.locator('.command-palette');
|
||||||
|
await palette.locator('[data-command-palette-input]').fill('sync now');
|
||||||
|
await expect(palette.locator('[data-command-id="verstak.shell.sync-now"]')).toBeVisible();
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
|
||||||
|
await expect(palette).not.toBeVisible();
|
||||||
|
await expect(page.locator('[data-command-palette-status="success"]')).toContainText('Sync Now');
|
||||||
|
await expect(page.locator('[data-command-palette-status="success"]')).toContainText('handled');
|
||||||
|
|
||||||
|
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+K' : 'Control+K');
|
||||||
|
palette = page.locator('.command-palette');
|
||||||
|
await palette.locator('[data-command-palette-input]').fill('sync settings');
|
||||||
|
await expect(palette.locator('[data-command-id="verstak.shell.open-sync-settings"]')).toBeVisible();
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
|
||||||
|
await expect(page.locator('.plugin-manager')).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.locator('.modal[aria-label="Plugin Settings"]')).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.locator('.modal-header h3')).toContainText('Sync');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps technical command failures out of the palette status', async ({ page }) => {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.go.api.App.PluginSyncNow = () => Promise.resolve('[plugin:verstak.sync] sync.now failed: internal service code 42');
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+K' : 'Control+K');
|
||||||
|
const palette = page.locator('.command-palette');
|
||||||
|
await palette.locator('[data-command-palette-input]').fill('sync now');
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
|
||||||
|
const status = page.locator('[data-command-palette-status="error"]');
|
||||||
|
await expect(status).toContainText('Could not run Sync Now. Please try again.');
|
||||||
|
await expect(status).not.toContainText('[plugin:');
|
||||||
|
await expect(status).not.toContainText('sync.now');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { waitForAppReady, setupConsoleCollector, resetMockState, openPluginManager } from './helpers.js';
|
||||||
|
|
||||||
|
test.describe('F: Default Editor Plugin', () => {
|
||||||
|
let consoleCollector;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
consoleCollector = setupConsoleCollector(page);
|
||||||
|
await resetMockState(page);
|
||||||
|
await page.goto('/');
|
||||||
|
await waitForAppReady(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async () => {
|
||||||
|
consoleCollector.assertNoErrors();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('open .txt file shows plain text editor mode', async ({ page }) => {
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.platform-test', {
|
||||||
|
kind: 'vault-file',
|
||||||
|
path: 'Docs/todo.txt',
|
||||||
|
extension: '.txt',
|
||||||
|
context: { sourceView: 'files' },
|
||||||
|
});
|
||||||
|
if (err) throw new Error(err);
|
||||||
|
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const editor = page.locator('[data-editor-mode="text"]');
|
||||||
|
await expect(editor).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(editor).toHaveAttribute('data-resource-path', 'Docs/todo.txt');
|
||||||
|
await expect(editor).toHaveAttribute('data-request-mode', 'view');
|
||||||
|
const textarea = editor.locator('[data-editor-textarea]');
|
||||||
|
await expect(textarea).toBeVisible();
|
||||||
|
await expect(textarea).toHaveValue('Buy groceries\nWrite tests');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('secret link opens its exact secret and closing it restores the note preview', async ({ page }) => {
|
||||||
|
const notePath = 'Notes/Secret Link.md';
|
||||||
|
const noteContent = '# Secret link\n\n[Target secret](verstak-secret://target.secret)';
|
||||||
|
|
||||||
|
await page.evaluate(async ({ notePath, noteContent }) => {
|
||||||
|
const writeError = await window.go.api.App.WriteVaultTextFile(
|
||||||
|
'verstak.platform-test',
|
||||||
|
notePath,
|
||||||
|
noteContent,
|
||||||
|
{ createIfMissing: true, overwrite: true },
|
||||||
|
);
|
||||||
|
if (writeError) throw new Error(writeError);
|
||||||
|
const [result, openError] = await window.go.api.App.OpenWorkbenchResource('verstak.platform-test', {
|
||||||
|
kind: 'vault-file',
|
||||||
|
path: notePath,
|
||||||
|
extension: '.md',
|
||||||
|
context: { sourceView: 'notes', isInsideNotesFolder: true, notesMode: true },
|
||||||
|
});
|
||||||
|
if (openError) throw new Error(openError);
|
||||||
|
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
|
||||||
|
}, { notePath, noteContent });
|
||||||
|
|
||||||
|
const note = page.locator('[data-editor-mode="notes-markdown"]');
|
||||||
|
await expect(note).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(note.locator('[data-preview]')).toContainText('Target secret');
|
||||||
|
await note.locator('.secret-link').click();
|
||||||
|
|
||||||
|
const secrets = page.locator('.secrets-root');
|
||||||
|
await expect(secrets).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(secrets.locator('.secrets-item.active .secrets-item-title')).toHaveText('Target secret');
|
||||||
|
await expect(secrets.locator('.secrets-card h2')).toHaveText('Target secret');
|
||||||
|
|
||||||
|
await page.locator('.workbench-host .close-btn').click();
|
||||||
|
await expect(note).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(note.locator('[data-preview]')).toContainText('Target secret');
|
||||||
|
await expect(note.locator('[data-editor-textarea]')).toHaveCount(0);
|
||||||
|
await expect(note.locator('[data-save-state]')).toHaveText('');
|
||||||
|
|
||||||
|
const storedContent = await page.evaluate(async ({ notePath }) => {
|
||||||
|
const [content, readError] = await window.go.api.App.ReadVaultTextFile('verstak.platform-test', notePath);
|
||||||
|
if (readError) throw new Error(readError);
|
||||||
|
return content;
|
||||||
|
}, { notePath });
|
||||||
|
expect(storedContent).toBe(noteContent);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unavailable secret does not fall back to the first secret', async ({ page }) => {
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const [result, openError] = await window.go.api.App.OpenWorkbenchResource('verstak.default-editor', {
|
||||||
|
kind: 'secret',
|
||||||
|
path: 'missing.secret',
|
||||||
|
mode: 'view',
|
||||||
|
});
|
||||||
|
if (openError) throw new Error(openError);
|
||||||
|
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const secrets = page.locator('.secrets-root');
|
||||||
|
await expect(secrets).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(secrets.locator('.secrets-item.active')).toHaveCount(0);
|
||||||
|
await expect(secrets.locator('.secrets-status.error')).toContainText(/unavailable|недоступен/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('editor supports markdown toolbar split save reopen and revert', async ({ page }) => {
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const err = await window.go.api.App.WriteVaultTextFile(
|
||||||
|
'verstak.platform-test',
|
||||||
|
'Project/Notes/editing.md',
|
||||||
|
'# Editing\n\nplain text',
|
||||||
|
{ createIfMissing: true, overwrite: true }
|
||||||
|
);
|
||||||
|
if (err) throw new Error(err);
|
||||||
|
const [result, openErr] = await window.go.api.App.OpenWorkbenchResource('verstak.platform-test', {
|
||||||
|
kind: 'vault-file',
|
||||||
|
path: 'Project/Notes/editing.md',
|
||||||
|
extension: '.md',
|
||||||
|
context: { sourceView: 'files', isInsideNotesFolder: true, notesMode: true },
|
||||||
|
});
|
||||||
|
if (openErr) throw new Error(openErr);
|
||||||
|
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const editor = page.locator('[data-editor-mode="notes-markdown"]');
|
||||||
|
await expect(editor).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(editor.locator('[data-notes-badge]')).toBeVisible();
|
||||||
|
|
||||||
|
await editor.locator('[data-editor-mode-button="edit"]').click();
|
||||||
|
const textarea = editor.locator('[data-editor-textarea]');
|
||||||
|
await expect(textarea).toBeVisible();
|
||||||
|
await textarea.fill('plain text');
|
||||||
|
await textarea.selectText();
|
||||||
|
await editor.locator('[data-md-action="bold"]').click();
|
||||||
|
await expect(textarea).toHaveValue('**plain text**');
|
||||||
|
|
||||||
|
await editor.locator('[data-md-action="heading"]').click();
|
||||||
|
await expect(textarea).toHaveValue('# **plain text**');
|
||||||
|
await expect(editor.locator('[data-save-state]')).toContainText('Modified');
|
||||||
|
|
||||||
|
await editor.locator('[data-editor-mode-button="split"]').click();
|
||||||
|
await expect(editor.locator('[data-editor-textarea]')).toBeVisible();
|
||||||
|
await expect(editor.locator('[data-preview]')).toBeVisible();
|
||||||
|
await expect(editor.locator('[data-preview]')).toContainText('plain text');
|
||||||
|
|
||||||
|
await textarea.press(process.platform === 'darwin' ? 'Meta+S' : 'Control+S');
|
||||||
|
await expect(editor.locator('[data-save-state]')).toContainText('Saved');
|
||||||
|
|
||||||
|
await textarea.fill('discard me');
|
||||||
|
page.once('dialog', (dialog) => dialog.accept());
|
||||||
|
await editor.locator('[data-editor-action="reload"]').click();
|
||||||
|
await expect(textarea).toHaveValue('# **plain text**');
|
||||||
|
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const [result, openErr] = await window.go.api.App.OpenWorkbenchResource('verstak.platform-test', {
|
||||||
|
kind: 'vault-file',
|
||||||
|
path: 'Project/Notes/editing.md',
|
||||||
|
extension: '.md',
|
||||||
|
context: { sourceView: 'files', isInsideNotesFolder: true, notesMode: true },
|
||||||
|
});
|
||||||
|
if (openErr) throw new Error(openErr);
|
||||||
|
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
|
||||||
|
});
|
||||||
|
await expect(page.locator('[data-editor-mode="notes-markdown"] [data-preview]')).toContainText('plain text', { timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('open .md file outside Notes routes to highest-priority provider', async ({ page }) => {
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.platform-test', {
|
||||||
|
kind: 'vault-file',
|
||||||
|
path: 'Docs/readme.md',
|
||||||
|
extension: '.md',
|
||||||
|
context: { sourceView: 'files' },
|
||||||
|
});
|
||||||
|
if (err) throw new Error(err);
|
||||||
|
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const workbench = page.locator('.workbench-host');
|
||||||
|
await expect(workbench).toBeVisible({ timeout: 10000 });
|
||||||
|
const title = workbench.locator('.workbench-title');
|
||||||
|
await expect(title).toHaveText('readme.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('opening a note uses its title rather than its technical path', async ({ page }) => {
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.platform-test', {
|
||||||
|
kind: 'vault-file',
|
||||||
|
path: 'Notes/Overview.md',
|
||||||
|
extension: '.md',
|
||||||
|
context: { sourceView: 'notes', isInsideNotesFolder: true, notesMode: true },
|
||||||
|
});
|
||||||
|
if (err) throw new Error(err);
|
||||||
|
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const workbench = page.locator('.workbench-host');
|
||||||
|
await expect(workbench).toBeVisible({ timeout: 10000 });
|
||||||
|
const title = workbench.locator('.workbench-title');
|
||||||
|
await expect(title).toHaveText('Overview');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('default-editor plugin is listed as loaded in plugin manager', async ({ page }) => {
|
||||||
|
await openPluginManager(page);
|
||||||
|
const card = page.locator('.plugin-card').filter({ hasText: 'verstak.default-editor' });
|
||||||
|
await expect(card).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(card.locator('.status-badge')).toHaveText('loaded');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disable default-editor plugin removes its providers', async ({ page }) => {
|
||||||
|
await openPluginManager(page);
|
||||||
|
const card = page.locator('.plugin-card').filter({ hasText: 'verstak.default-editor' });
|
||||||
|
await card.locator('button.btn-disable').click();
|
||||||
|
await expect(card.locator('button.btn-enable')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.platform-test', {
|
||||||
|
kind: 'vault-file',
|
||||||
|
path: 'Docs/todo.txt',
|
||||||
|
extension: '.txt',
|
||||||
|
context: { sourceView: 'files' },
|
||||||
|
});
|
||||||
|
if (err) throw new Error(err);
|
||||||
|
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(page.locator('[data-workbench-status="no-provider"]')).toBeVisible({ timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('default-editor plugin card shows openProviders contribution count', async ({ page }) => {
|
||||||
|
await openPluginManager(page);
|
||||||
|
const card = page.locator('.plugin-card').filter({ hasText: 'verstak.default-editor' });
|
||||||
|
await expect(card).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(card.locator('.meta-row').filter({ hasText: 'Contributions:' })).toContainText('3 openProviders');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('default-editor does not add sidebar item', async ({ page }) => {
|
||||||
|
const sidebarItems = page.locator('.sidebar .plugin-item');
|
||||||
|
const count = await sidebarItems.count();
|
||||||
|
const hasDefaultEditor = await page.locator('.sidebar .plugin-item').filter({ hasText: /default.editor/i }).count();
|
||||||
|
expect(hasDefaultEditor).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('platform-test workbench buttons open files via default-editor', async ({ page }) => {
|
||||||
|
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' }).click();
|
||||||
|
await expect(page.locator('.pt-command-result')).toContainText('Command: handled', { timeout: 10000 });
|
||||||
|
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.platform-test', {
|
||||||
|
kind: 'vault-file',
|
||||||
|
path: 'Docs/todo.txt',
|
||||||
|
extension: '.txt',
|
||||||
|
context: { sourceView: 'files' },
|
||||||
|
});
|
||||||
|
if (err) throw new Error(err);
|
||||||
|
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const editor = page.locator('[data-editor-mode="text"]');
|
||||||
|
await expect(editor).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(editor).toHaveAttribute('data-resource-path', 'Docs/todo.txt');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,553 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { waitForAppReady, setupConsoleCollector, resetMockState, openPluginManager } from './helpers.js';
|
||||||
|
|
||||||
|
async function openFilesTool(page) {
|
||||||
|
await page.getByRole('tab', { name: 'Files' }).click();
|
||||||
|
await expect(page.locator('.files-root')).toBeVisible({ timeout: 10000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('G: Files Plugin', () => {
|
||||||
|
let consoleCollector;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
consoleCollector = setupConsoleCollector(page);
|
||||||
|
await resetMockState(page);
|
||||||
|
await page.goto('/');
|
||||||
|
await waitForAppReady(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async () => {
|
||||||
|
consoleCollector.assertNoErrors();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('files plugin appears in Plugin Manager as loaded', async ({ page }) => {
|
||||||
|
await openPluginManager(page);
|
||||||
|
const card = page.locator('.plugin-card').filter({ hasText: 'verstak.files' });
|
||||||
|
await expect(card).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(card.locator('.status-badge')).toHaveText('loaded');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('files plugin does not add global sidebar item', async ({ page }) => {
|
||||||
|
const sidebarItem = page.locator('.sidebar .plugin-item').filter({ hasText: 'Files' });
|
||||||
|
await expect(sidebarItem).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('workspace Files view is scoped to selected workspace folder', async ({ page }) => {
|
||||||
|
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
|
||||||
|
await openFilesTool(page);
|
||||||
|
|
||||||
|
await expect(page.locator('.workspace-host')).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.locator('.files-item-name').filter({ hasText: 'project-only.txt' })).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.locator('.files-item-name').filter({ hasText: 'test-only.txt' })).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.locator('.wt-label').filter({ hasText: 'Test' }).click();
|
||||||
|
|
||||||
|
await expect(page.locator('.files-item-name').filter({ hasText: 'test-only.txt' })).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.locator('.files-item-name').filter({ hasText: 'project-only.txt' })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('files explorer supports create navigate rename filter sort open and trash', async ({ page }) => {
|
||||||
|
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
|
||||||
|
await openFilesTool(page);
|
||||||
|
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
|
||||||
|
|
||||||
|
await page.locator('[data-files-action="new-folder"]').click();
|
||||||
|
await page.locator('[data-files-create-input]').fill('Daily');
|
||||||
|
await page.locator('[data-files-create-confirm]').click();
|
||||||
|
await expect(page.locator('[data-file-name="Daily"]')).toBeVisible();
|
||||||
|
|
||||||
|
await page.locator('[data-file-name="Daily"]').dblclick();
|
||||||
|
await expect(page.locator('.files-breadcrumb')).toContainText('Daily');
|
||||||
|
|
||||||
|
await page.locator('[data-files-action="new-markdown"]').click();
|
||||||
|
await page.locator('[data-files-create-input]').fill('Log.md');
|
||||||
|
await page.locator('[data-files-create-confirm]').click();
|
||||||
|
await expect(page.locator('[data-file-name="Log.md"]')).toBeVisible();
|
||||||
|
|
||||||
|
await page.locator('[data-file-name="Log.md"]').click();
|
||||||
|
await page.locator('[data-files-action="rename"]').click();
|
||||||
|
await page.locator('[data-files-rename-input]').fill('Journal.md');
|
||||||
|
await page.locator('[data-files-rename-confirm]').click();
|
||||||
|
await expect(page.locator('[data-file-name="Journal.md"]')).toBeVisible();
|
||||||
|
await expect(page.locator('[data-file-name="Log.md"]')).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.locator('[data-files-filter]').fill('journ');
|
||||||
|
await expect(page.locator('[data-file-name="Journal.md"]')).toBeVisible();
|
||||||
|
await expect(page.locator('[data-file-name="project-only.txt"]')).toHaveCount(0);
|
||||||
|
await page.locator('[data-files-filter]').fill('');
|
||||||
|
|
||||||
|
await page.locator('[data-files-sort]').selectOption('modified-desc');
|
||||||
|
await expect(page.locator('[data-file-name="Journal.md"]')).toBeVisible();
|
||||||
|
|
||||||
|
await page.locator('[data-file-name="Journal.md"]').dblclick();
|
||||||
|
await expect(page.locator('[data-editor-mode="generic-markdown"]')).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.locator('[data-resource-path="Project/Daily/Journal.md"]')).toBeVisible();
|
||||||
|
|
||||||
|
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
|
||||||
|
await expect(page.locator('.files-breadcrumb')).toContainText('Daily', { timeout: 10000 });
|
||||||
|
await expect(page.locator('[data-file-name="Journal.md"]')).toBeVisible({ timeout: 10000 });
|
||||||
|
await page.locator('[data-file-name="Journal.md"]').click();
|
||||||
|
await page.locator('[data-files-action="trash"]').click();
|
||||||
|
await expect(page.locator('.files-modal-overlay')).toBeVisible();
|
||||||
|
await expect(page.locator('.files-modal-title')).toContainText('Journal.md');
|
||||||
|
await page.locator('.files-modal-btn.confirm').click();
|
||||||
|
await expect(page.locator('[data-file-name="Journal.md"]')).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.locator('[data-files-action="up"]').click();
|
||||||
|
await expect(page.locator('.files-breadcrumb')).not.toContainText('Daily');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('files explorer shows inline rename validation errors', async ({ page }) => {
|
||||||
|
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
|
||||||
|
await openFilesTool(page);
|
||||||
|
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
|
||||||
|
|
||||||
|
await page.locator('[data-file-name="project-only.txt"]').click();
|
||||||
|
await page.locator('[data-files-action="rename"]').click();
|
||||||
|
await page.locator('[data-files-rename-input]').fill('bad/name.txt');
|
||||||
|
await page.locator('[data-files-rename-confirm]').click();
|
||||||
|
|
||||||
|
await expect(page.locator('[data-files-rename-error]')).toBeVisible();
|
||||||
|
await expect(page.locator('[data-files-rename-error]')).toContainText('Invalid characters');
|
||||||
|
await expect(page.locator('[data-files-rename-input]')).toBeVisible();
|
||||||
|
await expect(page.locator('[data-file-name="project-only.txt"]')).toBeVisible();
|
||||||
|
await expect(page.locator('[data-file-name="bad"]')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('files explorer shows inline rename conflict errors', async ({ page }) => {
|
||||||
|
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
|
||||||
|
await openFilesTool(page);
|
||||||
|
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
|
||||||
|
|
||||||
|
await page.locator('[data-file-name="project-only.txt"]').click();
|
||||||
|
await page.locator('[data-files-action="rename"]').click();
|
||||||
|
await page.locator('[data-files-rename-input]').fill('Notes');
|
||||||
|
await page.locator('[data-files-rename-confirm]').click();
|
||||||
|
|
||||||
|
await expect(page.locator('[data-files-rename-error]')).toBeVisible();
|
||||||
|
await expect(page.locator('[data-files-rename-error]')).toContainText('already exists');
|
||||||
|
await expect(page.locator('[data-files-rename-input]')).toBeVisible();
|
||||||
|
await expect(page.locator('[data-files-rename-input]')).toHaveValue('Notes');
|
||||||
|
await expect(page.locator('[data-file-name="project-only.txt"]')).toBeVisible();
|
||||||
|
await expect(page.locator('[data-file-name="Notes"]')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('files explorer shows inline create validation errors', async ({ page }) => {
|
||||||
|
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
|
||||||
|
await openFilesTool(page);
|
||||||
|
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
|
||||||
|
|
||||||
|
await page.locator('[data-files-action="new-folder"]').click();
|
||||||
|
await page.locator('[data-files-create-input]').fill('bad/name');
|
||||||
|
await page.locator('[data-files-create-confirm]').click();
|
||||||
|
|
||||||
|
await expect(page.locator('[data-files-create-error]')).toBeVisible();
|
||||||
|
await expect(page.locator('[data-files-create-error]')).toContainText('Invalid characters');
|
||||||
|
await expect(page.locator('[data-files-create-input]')).toBeVisible();
|
||||||
|
await expect(page.locator('[data-file-name="bad"]')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('files explorer uses rich file icons by file type', async ({ page }) => {
|
||||||
|
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
|
||||||
|
await openFilesTool(page);
|
||||||
|
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
|
||||||
|
|
||||||
|
async function createFile(action, name) {
|
||||||
|
await page.locator(`[data-files-action="${action}"]`).click();
|
||||||
|
await page.locator('[data-files-create-input]').fill(name);
|
||||||
|
await page.locator('[data-files-create-confirm]').click();
|
||||||
|
await expect(page.locator(`[data-file-name="${name}"]`)).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
await createFile('new-markdown', 'IconNote.md');
|
||||||
|
await createFile('new-text', 'Photo.png');
|
||||||
|
await createFile('new-text', 'Manual.pdf');
|
||||||
|
await createFile('new-text', 'Script.js');
|
||||||
|
|
||||||
|
const iconFor = (name) => page.locator(`[data-file-name="${name}"] .files-item-icon`);
|
||||||
|
await expect(iconFor('Notes')).toHaveAttribute('data-file-icon', 'folder');
|
||||||
|
await expect(iconFor('project-only.txt')).toHaveAttribute('data-file-icon', 'text');
|
||||||
|
await expect(iconFor('IconNote.md')).toHaveAttribute('data-file-icon', 'markdown');
|
||||||
|
await expect(iconFor('Photo.png')).toHaveAttribute('data-file-icon', 'image');
|
||||||
|
await expect(iconFor('Manual.pdf')).toHaveAttribute('data-file-icon', 'pdf');
|
||||||
|
await expect(iconFor('Script.js')).toHaveAttribute('data-file-icon', 'code');
|
||||||
|
await expect(iconFor('Manual.pdf')).toHaveAttribute('aria-label', 'PDF file');
|
||||||
|
await expect(iconFor('Photo.png')).toHaveAttribute('title', 'Image file');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('files explorer offers create actions in empty folders', async ({ page }) => {
|
||||||
|
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
|
||||||
|
await openFilesTool(page);
|
||||||
|
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
|
||||||
|
|
||||||
|
await page.locator('[data-files-action="new-folder"]').click();
|
||||||
|
await page.locator('[data-files-create-input]').fill('EmptyQuickActions');
|
||||||
|
await page.locator('[data-files-create-confirm]').click();
|
||||||
|
await page.locator('[data-file-name="EmptyQuickActions"]').dblclick();
|
||||||
|
|
||||||
|
const emptyState = page.locator('.files-empty');
|
||||||
|
await expect(emptyState).toContainText('Empty folder');
|
||||||
|
await expect(emptyState.locator('[data-files-empty-action="new-folder"]')).toBeVisible();
|
||||||
|
await expect(emptyState.locator('[data-files-empty-action="new-markdown"]')).toBeVisible();
|
||||||
|
await expect(emptyState.locator('[data-files-empty-action="new-text"]')).toBeVisible();
|
||||||
|
|
||||||
|
await emptyState.locator('[data-files-empty-action="new-markdown"]').click();
|
||||||
|
await page.locator('[data-files-create-input]').fill('FromEmpty.md');
|
||||||
|
await page.locator('[data-files-create-confirm]').click();
|
||||||
|
|
||||||
|
await expect(page.locator('[data-file-name="FromEmpty.md"]')).toBeVisible();
|
||||||
|
await expect(page.locator('.files-empty')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('files explorer shows no-match feedback while filtering', async ({ page }) => {
|
||||||
|
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
|
||||||
|
await openFilesTool(page);
|
||||||
|
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
|
||||||
|
|
||||||
|
await page.locator('[data-files-filter]').fill('zzzz-no-matches');
|
||||||
|
|
||||||
|
const emptyState = page.locator('.files-empty');
|
||||||
|
await expect(emptyState).toBeVisible();
|
||||||
|
await expect(emptyState).toContainText('No matches');
|
||||||
|
await expect(emptyState.locator('[data-files-empty-action="clear-filter"]')).toBeVisible();
|
||||||
|
await expect(page.locator('[data-file-name="project-only.txt"]')).toHaveCount(0);
|
||||||
|
|
||||||
|
await emptyState.locator('[data-files-empty-action="clear-filter"]').click();
|
||||||
|
await expect(page.locator('[data-files-filter]')).toHaveValue('');
|
||||||
|
await expect(page.locator('.files-empty')).toHaveCount(0);
|
||||||
|
await expect(page.locator('[data-file-name="project-only.txt"]')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('files explorer keeps removed items out of live files without Trash metadata', async ({ page }) => {
|
||||||
|
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
|
||||||
|
await openFilesTool(page);
|
||||||
|
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
|
||||||
|
|
||||||
|
await page.locator('[data-files-action="new-text"]').click();
|
||||||
|
await page.locator('[data-files-create-input]').fill('RestoreMe.txt');
|
||||||
|
await page.locator('[data-files-create-confirm]').click();
|
||||||
|
await expect(page.locator('[data-file-name="RestoreMe.txt"]')).toBeVisible();
|
||||||
|
|
||||||
|
await page.locator('[data-file-name="RestoreMe.txt"]').click();
|
||||||
|
await page.locator('[data-files-action="trash"]').click();
|
||||||
|
await page.locator('.files-modal-btn.confirm').click();
|
||||||
|
await expect(page.locator('[data-file-name="RestoreMe.txt"]')).toHaveCount(0);
|
||||||
|
await expect(page.locator('[data-files-action="trash-view"]')).toHaveCount(0);
|
||||||
|
await expect(page.locator('[data-files-restore-trash]')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('files explorer uses labeled controls and no row New Here action', async ({ page }) => {
|
||||||
|
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
|
||||||
|
await openFilesTool(page);
|
||||||
|
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
|
||||||
|
|
||||||
|
const actionIcons = {
|
||||||
|
back: 'back',
|
||||||
|
forward: 'forward',
|
||||||
|
up: 'up',
|
||||||
|
refresh: 'refresh',
|
||||||
|
'new-folder': 'folderAdd',
|
||||||
|
'new-markdown': 'markdownAdd',
|
||||||
|
'new-text': 'textAdd',
|
||||||
|
open: 'open',
|
||||||
|
rename: 'rename',
|
||||||
|
trash: 'trash',
|
||||||
|
cut: 'cut',
|
||||||
|
copy: 'copy',
|
||||||
|
paste: 'paste',
|
||||||
|
};
|
||||||
|
|
||||||
|
const pathData = [];
|
||||||
|
for (const [action, icon] of Object.entries(actionIcons)) {
|
||||||
|
const button = page.locator(`[data-files-action="${action}"]`);
|
||||||
|
await expect(button).toHaveAttribute('title', /.+/);
|
||||||
|
await expect(button).toHaveAttribute('aria-label', /.+/);
|
||||||
|
await expect(button).toHaveAttribute('data-files-icon', icon);
|
||||||
|
await expect(button.locator('svg')).toBeVisible();
|
||||||
|
await expect(button).toHaveText('');
|
||||||
|
pathData.push(await button.locator('svg path').getAttribute('d'));
|
||||||
|
}
|
||||||
|
expect(new Set(pathData).size).toBe(pathData.length);
|
||||||
|
|
||||||
|
await expect(page.locator('.files-row-btn').filter({ hasText: 'New here' })).toHaveCount(0);
|
||||||
|
const rowIcons = {
|
||||||
|
'row-open': 'open',
|
||||||
|
'row-rename': 'rename',
|
||||||
|
'row-trash': 'trash',
|
||||||
|
};
|
||||||
|
const rowPathData = [];
|
||||||
|
for (const [action, icon] of Object.entries(rowIcons)) {
|
||||||
|
const rowButton = page.locator(`[data-file-name="Notes"] [data-files-action="${action}"]`);
|
||||||
|
await expect(rowButton).toBeVisible();
|
||||||
|
await expect(rowButton).toHaveAttribute('title', /.+/);
|
||||||
|
await expect(rowButton).toHaveAttribute('aria-label', /.+/);
|
||||||
|
await expect(rowButton).toHaveAttribute('data-files-icon', icon);
|
||||||
|
await expect(rowButton).toHaveText('');
|
||||||
|
expect(await rowButton.evaluate((node) => node.innerHTML)).toContain('<svg');
|
||||||
|
rowPathData.push(await rowButton.locator('svg path').getAttribute('d'));
|
||||||
|
}
|
||||||
|
expect(new Set(rowPathData).size).toBe(rowPathData.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('files explorer supports empty-space context paste after cutting a folder', async ({ page }) => {
|
||||||
|
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
|
||||||
|
await openFilesTool(page);
|
||||||
|
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
|
||||||
|
|
||||||
|
await page.locator('[data-files-action="new-folder"]').click();
|
||||||
|
await page.locator('[data-files-create-input]').fill('CutMe');
|
||||||
|
await page.locator('[data-files-create-confirm]').click();
|
||||||
|
await page.locator('[data-files-action="new-folder"]').click();
|
||||||
|
await page.locator('[data-files-create-input]').fill('Target');
|
||||||
|
await page.locator('[data-files-create-confirm]').click();
|
||||||
|
|
||||||
|
await page.locator('[data-file-name="CutMe"]').click({ button: 'right' });
|
||||||
|
await page.locator('[data-files-menu-action="cut"]').click();
|
||||||
|
|
||||||
|
await page.locator('[data-file-name="Target"]').dblclick();
|
||||||
|
await expect(page.locator('.files-breadcrumb')).toContainText('Target');
|
||||||
|
await page.locator('[data-files-list]').click({ button: 'right', position: { x: 24, y: 110 } });
|
||||||
|
await page.locator('[data-files-menu-action="paste"]').click();
|
||||||
|
|
||||||
|
await expect(page.locator('[data-file-name="CutMe"]')).toBeVisible();
|
||||||
|
await page.locator('[data-files-action="up"]').click();
|
||||||
|
await expect(page.locator('[data-file-name="CutMe"]')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('files explorer navigates by clicking breadcrumb segments', async ({ page }) => {
|
||||||
|
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
|
||||||
|
await openFilesTool(page);
|
||||||
|
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
|
||||||
|
|
||||||
|
await page.locator('[data-files-action="new-folder"]').click();
|
||||||
|
await page.locator('[data-files-create-input]').fill('BreadcrumbParent');
|
||||||
|
await page.locator('[data-files-create-confirm]').click();
|
||||||
|
await page.locator('[data-file-name="BreadcrumbParent"]').dblclick();
|
||||||
|
await expect(page.locator('.files-breadcrumb')).toContainText('BreadcrumbParent');
|
||||||
|
|
||||||
|
await page.locator('[data-files-action="new-folder"]').click();
|
||||||
|
await page.locator('[data-files-create-input]').fill('BreadcrumbChild');
|
||||||
|
await page.locator('[data-files-create-confirm]').click();
|
||||||
|
await page.locator('[data-file-name="BreadcrumbChild"]').dblclick();
|
||||||
|
await expect(page.locator('.files-breadcrumb')).toContainText('BreadcrumbChild');
|
||||||
|
|
||||||
|
await page.locator('.files-breadcrumb-item').filter({ hasText: 'BreadcrumbParent' }).click();
|
||||||
|
await expect(page.locator('.files-breadcrumb')).toContainText('BreadcrumbParent');
|
||||||
|
await expect(page.locator('.files-breadcrumb')).not.toContainText('BreadcrumbChild');
|
||||||
|
await expect(page.locator('[data-file-name="BreadcrumbChild"]')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('files explorer duplicates a file from the context menu', async ({ page }) => {
|
||||||
|
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
|
||||||
|
await openFilesTool(page);
|
||||||
|
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
|
||||||
|
|
||||||
|
await page.locator('[data-file-name="project-only.txt"]').click({ button: 'right' });
|
||||||
|
await page.locator('[data-files-menu-action="duplicate"]').click();
|
||||||
|
|
||||||
|
await expect(page.locator('[data-file-name="project-only.txt"]')).toBeVisible();
|
||||||
|
await expect(page.locator('[data-file-name="project-only (copy).txt"]')).toBeVisible();
|
||||||
|
|
||||||
|
await page.locator('[data-file-name="project-only.txt"]').click({ button: 'right' });
|
||||||
|
await page.locator('[data-files-menu-action="duplicate"]').click();
|
||||||
|
await expect(page.locator('[data-file-name="project-only (copy 2).txt"]')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('files explorer supports multiselect and internal drag/drop move', async ({ page }) => {
|
||||||
|
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
|
||||||
|
await openFilesTool(page);
|
||||||
|
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
|
||||||
|
|
||||||
|
await page.locator('[data-files-action="new-folder"]').click();
|
||||||
|
await page.locator('[data-files-create-input]').fill('DropTarget');
|
||||||
|
await page.locator('[data-files-create-confirm]').click();
|
||||||
|
await page.locator('[data-files-action="new-markdown"]').click();
|
||||||
|
await page.locator('[data-files-create-input]').fill('DragOne.md');
|
||||||
|
await page.locator('[data-files-create-confirm]').click();
|
||||||
|
await page.locator('[data-files-action="new-text"]').click();
|
||||||
|
await page.locator('[data-files-create-input]').fill('DragTwo.txt');
|
||||||
|
await page.locator('[data-files-create-confirm]').click();
|
||||||
|
|
||||||
|
await page.locator('[data-file-name="DragOne.md"]').click();
|
||||||
|
await page.locator('[data-file-name="DragTwo.txt"]').click({ modifiers: [process.platform === 'darwin' ? 'Meta' : 'Control'] });
|
||||||
|
await expect(page.locator('.files-item.selected')).toHaveCount(2);
|
||||||
|
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const source = document.querySelector('[data-file-name="DragOne.md"]');
|
||||||
|
const target = document.querySelector('[data-file-name="DropTarget"]');
|
||||||
|
const dt = new DataTransfer();
|
||||||
|
source.dispatchEvent(new DragEvent('dragstart', { bubbles: true, dataTransfer: dt }));
|
||||||
|
target.dispatchEvent(new DragEvent('dragover', { bubbles: true, dataTransfer: dt }));
|
||||||
|
target.dispatchEvent(new DragEvent('drop', { bubbles: true, dataTransfer: dt }));
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(page.locator('[data-file-name="DragOne.md"]')).toHaveCount(0);
|
||||||
|
await expect(page.locator('[data-file-name="DragTwo.txt"]')).toHaveCount(0);
|
||||||
|
await page.locator('[data-file-name="DropTarget"]').dblclick();
|
||||||
|
await expect(page.locator('[data-file-name="DragOne.md"]')).toBeVisible();
|
||||||
|
await expect(page.locator('[data-file-name="DragTwo.txt"]')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('files explorer supports keyboard row selection and clearing selection', async ({ page }) => {
|
||||||
|
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
|
||||||
|
await openFilesTool(page);
|
||||||
|
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
|
||||||
|
|
||||||
|
const rows = page.locator('.files-item');
|
||||||
|
await expect(rows.nth(1)).toBeVisible();
|
||||||
|
|
||||||
|
await rows.nth(0).click();
|
||||||
|
await expect(rows.nth(0)).toHaveClass(/selected/);
|
||||||
|
|
||||||
|
await page.locator('.files-root').focus();
|
||||||
|
await page.keyboard.press('ArrowDown');
|
||||||
|
await expect(rows.nth(1)).toHaveClass(/selected/);
|
||||||
|
await expect(rows.nth(0)).not.toHaveClass(/selected/);
|
||||||
|
|
||||||
|
await page.keyboard.press('ArrowUp');
|
||||||
|
await expect(rows.nth(0)).toHaveClass(/selected/);
|
||||||
|
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await expect(page.locator('.files-item.selected')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('files history persists in workspace context and handles mouse back forward buttons', async ({ page }) => {
|
||||||
|
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
|
||||||
|
await openFilesTool(page);
|
||||||
|
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
|
||||||
|
|
||||||
|
await page.locator('[data-file-name="Notes"]').dblclick();
|
||||||
|
await expect(page.locator('.files-breadcrumb')).toContainText('Notes');
|
||||||
|
|
||||||
|
await page.locator('.files-root').focus();
|
||||||
|
await page.keyboard.press('Alt+ArrowLeft');
|
||||||
|
await expect(page.locator('.files-breadcrumb')).not.toContainText('Notes');
|
||||||
|
|
||||||
|
await page.keyboard.press('Alt+ArrowRight');
|
||||||
|
await expect(page.locator('.files-breadcrumb')).toContainText('Notes');
|
||||||
|
|
||||||
|
await page.dispatchEvent('.files-root', 'mouseup', { button: 8, buttons: 128, bubbles: true, cancelable: true });
|
||||||
|
await expect(page.locator('.files-breadcrumb')).not.toContainText('Notes');
|
||||||
|
|
||||||
|
await page.dispatchEvent('.files-root', 'mouseup', { button: 9, buttons: 256, bubbles: true, cancelable: true });
|
||||||
|
await expect(page.locator('.files-breadcrumb')).toContainText('Notes');
|
||||||
|
|
||||||
|
await page.locator('.wt-label').filter({ hasText: 'Test' }).click();
|
||||||
|
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
|
||||||
|
await expect(page.locator('.files-breadcrumb')).toContainText('Notes');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('workbench close and mouse back return from editor to the previous Files folder', async ({ page }) => {
|
||||||
|
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
|
||||||
|
await openFilesTool(page);
|
||||||
|
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
|
||||||
|
|
||||||
|
await page.locator('[data-file-name="Notes"]').dblclick();
|
||||||
|
await expect(page.locator('.files-breadcrumb')).toContainText('Notes');
|
||||||
|
|
||||||
|
await page.locator('[data-file-name="Overview.md"]').dblclick();
|
||||||
|
await expect(page.locator('[data-editor-mode="notes-markdown"]')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
await page.dispatchEvent('body', 'mousedown', { button: 3, bubbles: true, cancelable: true });
|
||||||
|
await expect(page.locator('.workspace-host')).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.locator('.files-breadcrumb')).toContainText('Notes');
|
||||||
|
await expect(page.locator('[data-file-name="Overview.md"]')).toBeVisible();
|
||||||
|
|
||||||
|
await page.locator('[data-file-name="Overview.md"]').dblclick();
|
||||||
|
await expect(page.locator('[data-editor-mode="notes-markdown"]')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
await page.waitForTimeout(150);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
document.body.dispatchEvent(new PointerEvent('pointerdown', {
|
||||||
|
button: 3,
|
||||||
|
buttons: 8,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
pointerType: 'mouse'
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
await expect(page.locator('.workspace-host')).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.locator('.files-breadcrumb')).toContainText('Notes');
|
||||||
|
|
||||||
|
await page.locator('[data-file-name="Overview.md"]').dblclick();
|
||||||
|
await expect(page.locator('[data-editor-mode="notes-markdown"]')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
await page.locator('.workbench-header .close-btn[aria-label="Close"]').click();
|
||||||
|
await expect(page.locator('.workspace-host')).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.locator('.files-breadcrumb')).toContainText('Notes');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('open .txt via workbench from files context shows default-editor', async ({ page }) => {
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.files', {
|
||||||
|
kind: 'vault-file',
|
||||||
|
path: 'Docs/todo.txt',
|
||||||
|
extension: '.txt',
|
||||||
|
context: { sourcePluginId: 'verstak.files', sourceView: 'files' },
|
||||||
|
});
|
||||||
|
if (err) throw new Error(err);
|
||||||
|
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const editor = page.locator('[data-editor-mode="text"]');
|
||||||
|
await expect(editor).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(editor).toHaveAttribute('data-resource-path', 'Docs/todo.txt');
|
||||||
|
|
||||||
|
const textarea = page.locator('.de-textarea');
|
||||||
|
await expect(textarea).toBeVisible({ timeout: 10000 });
|
||||||
|
const textareaBox = await textarea.boundingBox();
|
||||||
|
expect(textareaBox.height).toBeGreaterThan(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('open .md via workbench from files context shows generic-markdown', async ({ page }) => {
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.files', {
|
||||||
|
kind: 'vault-file',
|
||||||
|
path: 'Docs/readme.md',
|
||||||
|
extension: '.md',
|
||||||
|
context: { sourcePluginId: 'verstak.files', sourceView: 'files' },
|
||||||
|
});
|
||||||
|
if (err) throw new Error(err);
|
||||||
|
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const workbench = page.locator('.workbench-host');
|
||||||
|
await expect(workbench).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(workbench.locator('.workbench-title')).toHaveText('readme.md');
|
||||||
|
|
||||||
|
const preview = page.locator('.de-preview');
|
||||||
|
await expect(preview).toBeVisible({ timeout: 10000 });
|
||||||
|
const previewBox = await preview.boundingBox();
|
||||||
|
expect(previewBox.height).toBeGreaterThan(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('open notes markdown via workbench from files context shows notes-markdown', async ({ page }) => {
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.files', {
|
||||||
|
kind: 'vault-file',
|
||||||
|
path: 'Notes/Overview.md',
|
||||||
|
extension: '.md',
|
||||||
|
context: { sourcePluginId: 'verstak.files', sourceView: 'files', isInsideNotesFolder: true, notesMode: true },
|
||||||
|
});
|
||||||
|
if (err) throw new Error(err);
|
||||||
|
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const workbench = page.locator('.workbench-host');
|
||||||
|
await expect(workbench).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(workbench.locator('.workbench-title')).toHaveText('Overview');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('files plugin card shows openProviders in contributions', async ({ page }) => {
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.files', {
|
||||||
|
kind: 'vault-file', path: 'Docs/todo.txt', extension: '.txt',
|
||||||
|
context: { sourcePluginId: 'verstak.files', sourceView: 'files' },
|
||||||
|
});
|
||||||
|
if (err) throw new Error(err);
|
||||||
|
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
|
||||||
|
});
|
||||||
|
const editor = page.locator('[data-editor-mode="text"]');
|
||||||
|
await expect(editor).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* Shared helpers for Verstak E2E tests.
|
||||||
|
*/
|
||||||
|
import { expect } from '@playwright/test';
|
||||||
|
|
||||||
|
/** Wait for the app to finish loading (loading screen disappears) */
|
||||||
|
export async function waitForAppReady(page) {
|
||||||
|
// App shows "Loading Verstak..." initially, then renders the main layout
|
||||||
|
await page.waitForSelector('main', { state: 'visible', timeout: 15000 });
|
||||||
|
// Wait a bit for all async data to load
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open the secondary Plugin Manager route from the status-bar settings menu. */
|
||||||
|
export async function openPluginManager(page) {
|
||||||
|
await page.locator('[data-settings-menu-button]').click();
|
||||||
|
await page.locator('[data-settings-action="plugin-manager"]').click();
|
||||||
|
await page.waitForSelector('.plugin-manager', { state: 'visible', timeout: 10000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collect all console errors since last reset */
|
||||||
|
export function setupConsoleCollector(page) {
|
||||||
|
const errors = [];
|
||||||
|
page.on('console', (msg) => {
|
||||||
|
if (msg.type() === 'error') {
|
||||||
|
errors.push(msg.text());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
page.on('pageerror', (err) => {
|
||||||
|
errors.push(err.message);
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
getErrors: () => errors,
|
||||||
|
assertNoErrors: () => {
|
||||||
|
if (errors.length > 0) {
|
||||||
|
throw new Error(`Console errors detected:\n${errors.join('\n')}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset mock state before each test */
|
||||||
|
export async function resetMockState(page) {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
if (window.__wailsMock) {
|
||||||
|
window.__wailsMock.reset();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set plugin status in mock */
|
||||||
|
export async function setPluginStatus(page, pluginId, status, enabled) {
|
||||||
|
await page.evaluate(
|
||||||
|
({ id, st, en }) => {
|
||||||
|
if (window.__wailsMock) {
|
||||||
|
window.__wailsMock.setPluginStatus(id, st, en);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ id: pluginId, st: status, en: enabled }
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
|
||||||
|
|
||||||
|
test.describe('Desktop localization', () => {
|
||||||
|
let consoleCollector;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
consoleCollector = setupConsoleCollector(page);
|
||||||
|
await resetMockState(page);
|
||||||
|
await page.goto('/');
|
||||||
|
await waitForAppReady(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async () => {
|
||||||
|
consoleCollector.assertNoErrors();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('switches shell language live and persists the choice', async ({ page }) => {
|
||||||
|
await page.locator('[data-settings-menu-button]').click();
|
||||||
|
await expect(page.locator('[data-settings-language="system"]')).toHaveAttribute('aria-checked', 'true');
|
||||||
|
await page.locator('[data-settings-language="ru"]').click();
|
||||||
|
|
||||||
|
await expect(page.locator('[data-settings-menu-button]')).toHaveAttribute('title', 'Настройки');
|
||||||
|
await expect(page.locator('.vault-status')).toContainText('Хранилище: открыто');
|
||||||
|
await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Тест платформы' })).toBeVisible();
|
||||||
|
await expect(page.locator('[data-status-item-id="verstak.platform-test.status"]')).toContainText('Все тесты пройдены');
|
||||||
|
|
||||||
|
await page.locator('[data-settings-menu-button]').click();
|
||||||
|
await expect(page.locator('[data-settings-action="plugin-manager"]')).toContainText('Менеджер плагинов');
|
||||||
|
await expect(page.locator('[data-settings-language="ru"]')).toHaveAttribute('aria-checked', 'true');
|
||||||
|
await page.locator('[data-settings-action="plugin-manager"]').click();
|
||||||
|
const pluginManager = page.locator('.plugin-manager');
|
||||||
|
await expect(pluginManager).toContainText('Зарегистрировано возможностей:');
|
||||||
|
await expect(pluginManager.locator('.registry-section')).toContainText('Реестр возможностей');
|
||||||
|
await expect(pluginManager.locator('.registry-section')).toContainText('Возможность');
|
||||||
|
await expect(pluginManager.locator('.plugin-filters')).toContainText('Фильтры');
|
||||||
|
await expect(pluginManager.locator('[data-plugin-filter-results]')).toContainText('Показано плагинов:');
|
||||||
|
await expect(pluginManager.locator('[data-plugin-filter="status"]')).toContainText('Выключенные');
|
||||||
|
await expect(pluginManager).not.toContainText('Workspace-scoped');
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await waitForAppReady(page);
|
||||||
|
await expect(page.locator('[data-settings-menu-button]')).toHaveAttribute('title', 'Настройки');
|
||||||
|
await page.locator('[data-settings-menu-button]').click();
|
||||||
|
await expect(page.locator('[data-settings-language="ru"]')).toHaveAttribute('aria-checked', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('switches back to English without reloading', async ({ page }) => {
|
||||||
|
await page.locator('[data-settings-menu-button]').click();
|
||||||
|
await page.locator('[data-settings-language="ru"]').click();
|
||||||
|
await page.locator('[data-settings-menu-button]').click();
|
||||||
|
await page.locator('[data-settings-language="en"]').click();
|
||||||
|
|
||||||
|
await expect(page.locator('[data-settings-menu-button]')).toHaveAttribute('title', 'Settings');
|
||||||
|
await expect(page.locator('.vault-status')).toContainText('Vault: open');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { waitForAppReady, setupConsoleCollector, resetMockState, openPluginManager } from './helpers.js';
|
||||||
|
|
||||||
|
test.describe('D: Plugin API bridge', () => {
|
||||||
|
let consoleCollector;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
consoleCollector = setupConsoleCollector(page);
|
||||||
|
await resetMockState(page);
|
||||||
|
await page.goto('/');
|
||||||
|
await waitForAppReady(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async () => {
|
||||||
|
consoleCollector.assertNoErrors();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('platform-test reads and writes settings through scoped API after reload', async ({ page }) => {
|
||||||
|
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' }).click();
|
||||||
|
|
||||||
|
const saved = page.locator('.pt-saved-setting');
|
||||||
|
await expect(saved).toHaveText('Saved setting: initial value', { timeout: 10000 });
|
||||||
|
|
||||||
|
const input = page.locator('.pt-setting-input');
|
||||||
|
await input.fill('persisted through bridge');
|
||||||
|
await page.locator('.pt-save-setting').click();
|
||||||
|
await expect(saved).toHaveText('Saved setting: persisted through bridge', { timeout: 10000 });
|
||||||
|
|
||||||
|
await openPluginManager(page);
|
||||||
|
await expect.poll(() => page.evaluate(() => Object.keys(window.__VERSTAK_COMMAND_HANDLERS__ || {}).length)).toBe(0);
|
||||||
|
await expect.poll(() => page.evaluate(() => (window.__VERSTAK_EVENT_HANDLERS__?.['verstak.platform-test.echo'] || []).length)).toBe(0);
|
||||||
|
await page.locator('button.reload-btn').click();
|
||||||
|
await expect(page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' }).locator('.status-badge')).toHaveText('loaded', { timeout: 10000 });
|
||||||
|
|
||||||
|
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' }).click();
|
||||||
|
|
||||||
|
await expect(page.locator('.pt-saved-setting')).toHaveText('Saved setting: persisted through bridge', { timeout: 10000 });
|
||||||
|
await expect(page.locator('.pt-badge')).toHaveAttribute('data-command-status', 'handled');
|
||||||
|
await expect(page.locator('.pt-badge')).toContainText('capability available');
|
||||||
|
await expect(page.locator('.pt-command-result')).toContainText('Command: handled 0.1.0 from bundled-frontend');
|
||||||
|
await expect(page.locator('.pt-event-result')).toHaveAttribute('data-event-status', 'received');
|
||||||
|
await expect(page.locator('.pt-event-result')).toContainText('Event: received hello-event');
|
||||||
|
await expect(page.locator('.pt-files-result')).toHaveAttribute('data-files-status', 'ok');
|
||||||
|
await expect(page.locator('.pt-files-result')).toContainText('Files: wrote/read/listed/moved/trashed');
|
||||||
|
await expect(page.locator('.pt-files-error-result')).toHaveAttribute('data-files-error-status', 'expected');
|
||||||
|
await expect(page.locator('.pt-files-error-result')).toContainText('Files error path: rejected reserved-path');
|
||||||
|
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.platform-test', {
|
||||||
|
kind: 'vault-file',
|
||||||
|
path: 'Notes/Overview.md',
|
||||||
|
extension: '.md',
|
||||||
|
context: { sourceView: 'notes', isInsideNotesFolder: true, notesMode: true },
|
||||||
|
});
|
||||||
|
if (err) throw new Error(err);
|
||||||
|
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const workbench = page.locator('.workbench-host');
|
||||||
|
await expect(workbench).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(workbench.locator('.workbench-title')).toHaveText('Overview');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('workbench routes markdown files to default-editor provider', async ({ page }) => {
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.platform-test', {
|
||||||
|
kind: 'vault-file',
|
||||||
|
path: 'Docs/readme.md',
|
||||||
|
extension: '.md',
|
||||||
|
context: { sourceView: 'files' },
|
||||||
|
});
|
||||||
|
if (err) throw new Error(err);
|
||||||
|
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const workbench = page.locator('.workbench-host');
|
||||||
|
await expect(workbench).toBeVisible({ timeout: 10000 });
|
||||||
|
const title = workbench.locator('.workbench-title');
|
||||||
|
await expect(title).toHaveText('readme.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('workbench shows no-provider fallback when no provider matches', async ({ page }) => {
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.platform-test', {
|
||||||
|
kind: 'vault-file',
|
||||||
|
path: 'Images/logo.png',
|
||||||
|
extension: '.png',
|
||||||
|
context: { sourceView: 'files' },
|
||||||
|
});
|
||||||
|
if (err) throw new Error(err);
|
||||||
|
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const fallback = page.locator('[data-workbench-status="no-provider"]');
|
||||||
|
await expect(fallback).toBeVisible();
|
||||||
|
await expect(fallback).toContainText('No viewer/editor available');
|
||||||
|
await expect(fallback).not.toContainText('generic-text');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sync plugin API routes through mocked Wails bridge', async ({ page }) => {
|
||||||
|
const result = await page.evaluate(async () => {
|
||||||
|
const api = window.createPluginAPI('verstak.sync');
|
||||||
|
const initial = await api.sync.status();
|
||||||
|
await api.sync.testConnection('https://sync.example.test', 'alice', 'secret');
|
||||||
|
await api.sync.configure('https://sync.example.test', 'alice', 'secret');
|
||||||
|
await api.sync.setInterval(15);
|
||||||
|
const configured = await api.sync.status();
|
||||||
|
const syncNow = await api.sync.now();
|
||||||
|
await api.sync.resetKey();
|
||||||
|
const reset = await api.sync.status();
|
||||||
|
await api.sync.disconnect();
|
||||||
|
const disconnected = await api.sync.status();
|
||||||
|
api.dispose();
|
||||||
|
return { initial, configured, syncNow, reset, disconnected };
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.initial.statusLabel).toBe('disabled');
|
||||||
|
expect(result.configured.configured).toBe(true);
|
||||||
|
expect(result.configured.serverUrl).toBe('https://sync.example.test');
|
||||||
|
expect(result.configured.syncInterval).toBe(15);
|
||||||
|
expect(result.syncNow).toEqual({ pushed: 0, pulled: 0, serverSequence: 0 });
|
||||||
|
expect(result.reset.configured).toBe(false);
|
||||||
|
expect(result.reset.serverUrl).toBe('https://sync.example.test');
|
||||||
|
expect(result.reset.tokenStored).toBe(false);
|
||||||
|
expect(result.reset.statusLabel).toBe('disconnected');
|
||||||
|
expect(result.disconnected.configured).toBe(false);
|
||||||
|
expect(result.disconnected.statusLabel).toBe('disabled');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('files API permanently deletes an existing trash entry through the bridge', async ({ page }) => {
|
||||||
|
const result = await page.evaluate(async () => {
|
||||||
|
const api = window.createPluginAPI('verstak.files');
|
||||||
|
await api.files.writeText('Project/bridge-trash.txt', 'remove me', { createIfMissing: true });
|
||||||
|
const trash = await api.files.trash('Project/bridge-trash.txt');
|
||||||
|
const before = await api.files.listTrash();
|
||||||
|
await api.files.deleteTrash(trash.trashId);
|
||||||
|
const after = await api.files.listTrash();
|
||||||
|
let readError = '';
|
||||||
|
try {
|
||||||
|
await api.files.readText('Project/bridge-trash.txt');
|
||||||
|
} catch (error) {
|
||||||
|
readError = String(error && error.message || error);
|
||||||
|
}
|
||||||
|
api.dispose();
|
||||||
|
return { before, after, readError };
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.before).toHaveLength(1);
|
||||||
|
expect(result.after).toEqual([]);
|
||||||
|
expect(result.readError).toContain('not-found: Project/bridge-trash.txt');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('backend plugin events are dispatched to subscribed frontend handlers', async ({ page }) => {
|
||||||
|
const result = await page.evaluate(async () => {
|
||||||
|
const api = window.createPluginAPI('verstak.platform-test');
|
||||||
|
let received = null;
|
||||||
|
const unsubscribe = await api.events.subscribe('browser.capture.page', (event) => {
|
||||||
|
received = event;
|
||||||
|
});
|
||||||
|
window.__VERSTAK_DISPATCH_BACKEND_EVENT__({
|
||||||
|
name: 'browser.capture.page',
|
||||||
|
timestamp: '2026-06-27T00:00:00.000Z',
|
||||||
|
payload: { url: 'https://example.com/article' }
|
||||||
|
});
|
||||||
|
unsubscribe();
|
||||||
|
api.dispose();
|
||||||
|
return received;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.name).toBe('browser.capture.page');
|
||||||
|
expect(result.payload.url).toBe('https://example.com/article');
|
||||||
|
expect(result.timestamp).toBe('2026-06-27T00:00:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('platform-test command and event handlers are cleaned up after leaving plugin view', async ({ page }) => {
|
||||||
|
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' }).click();
|
||||||
|
|
||||||
|
await expect(page.locator('.pt-command-result')).toContainText('Command: handled', { timeout: 10000 });
|
||||||
|
await expect(page.locator('.pt-event-result')).toHaveAttribute('data-event-status', 'received', { timeout: 10000 });
|
||||||
|
await expect.poll(() => page.evaluate(() => Object.keys(window.__VERSTAK_COMMAND_HANDLERS__ || {}).length)).toBe(1);
|
||||||
|
await expect.poll(() => page.evaluate(() => (window.__VERSTAK_EVENT_HANDLERS__?.['verstak.platform-test.echo'] || []).length)).toBe(1);
|
||||||
|
|
||||||
|
await openPluginManager(page);
|
||||||
|
|
||||||
|
await expect.poll(() => page.evaluate(() => Object.keys(window.__VERSTAK_COMMAND_HANDLERS__ || {}).length)).toBe(0);
|
||||||
|
await expect.poll(() => page.evaluate(() => (window.__VERSTAK_EVENT_HANDLERS__?.['verstak.platform-test.echo'] || []).length)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('platform-test cleanup remains empty after disable reload flow', async ({ page }) => {
|
||||||
|
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' }).click();
|
||||||
|
await expect(page.locator('.pt-command-result')).toContainText('Command: handled', { timeout: 10000 });
|
||||||
|
|
||||||
|
await openPluginManager(page);
|
||||||
|
const pluginCard = page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' });
|
||||||
|
await pluginCard.locator('button.btn-disable').click();
|
||||||
|
await expect(pluginCard.locator('button.btn-enable')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
await expect.poll(() => page.evaluate(() => Object.keys(window.__VERSTAK_COMMAND_HANDLERS__ || {}).length)).toBe(0);
|
||||||
|
await expect.poll(() => page.evaluate(() => (window.__VERSTAK_EVENT_HANDLERS__?.['verstak.platform-test.echo'] || []).length)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('platform-test settings panel loads bundle content returned as raw string', async ({ page }) => {
|
||||||
|
await openPluginManager(page);
|
||||||
|
|
||||||
|
const pluginCard = page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' });
|
||||||
|
await pluginCard.locator('button.btn-settings').click();
|
||||||
|
|
||||||
|
const modal = page.locator('.modal[aria-label="Plugin Settings"]');
|
||||||
|
await expect(modal).toBeVisible();
|
||||||
|
await expect(modal).toContainText('Platform Test Settings');
|
||||||
|
await expect(modal.locator('.host-state.error')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* Acceptance Test A: Plugin Manager Disable/Enable refresh
|
||||||
|
*
|
||||||
|
* Scenario:
|
||||||
|
* 1. Open Plugin Manager
|
||||||
|
* 2. See Platform Test as loaded/enabled
|
||||||
|
* 3. Click Disable
|
||||||
|
* 4. Verify Enable button appears
|
||||||
|
* 5. Verify plugin sidebar item disappears
|
||||||
|
* 6. Click Enable
|
||||||
|
* 7. Verify Disable button appears
|
||||||
|
* 8. Verify plugin sidebar item returns
|
||||||
|
*/
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { waitForAppReady, setupConsoleCollector, resetMockState, openPluginManager } from './helpers.js';
|
||||||
|
|
||||||
|
test.describe('A: Plugin Manager Disable/Enable refresh', () => {
|
||||||
|
let consoleCollector;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
consoleCollector = setupConsoleCollector(page);
|
||||||
|
await resetMockState(page);
|
||||||
|
await page.goto('/');
|
||||||
|
await waitForAppReady(page);
|
||||||
|
await openPluginManager(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async () => {
|
||||||
|
consoleCollector.assertNoErrors();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Platform Test plugin is initially visible and enabled', async ({ page }) => {
|
||||||
|
// Plugin Manager should show Platform Test plugin card
|
||||||
|
const pluginCard = page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' });
|
||||||
|
await expect(pluginCard).toBeVisible();
|
||||||
|
|
||||||
|
// Status should show "loaded"
|
||||||
|
const statusBadge = pluginCard.locator('.status-badge');
|
||||||
|
await expect(statusBadge).toHaveText('loaded');
|
||||||
|
|
||||||
|
// Disable button should be visible (not Enable)
|
||||||
|
const disableBtn = pluginCard.locator('button.btn-disable');
|
||||||
|
await expect(disableBtn).toBeVisible();
|
||||||
|
|
||||||
|
// Sidebar should have Platform Test item
|
||||||
|
const sidebarItem = page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' });
|
||||||
|
await expect(sidebarItem).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Disable plugin: button changes to Enable, sidebar item disappears', async ({ page }) => {
|
||||||
|
const pluginCard = page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' });
|
||||||
|
|
||||||
|
// Click Disable
|
||||||
|
const disableBtn = pluginCard.locator('button.btn-disable');
|
||||||
|
await expect(disableBtn).toBeVisible();
|
||||||
|
await disableBtn.click();
|
||||||
|
|
||||||
|
// Wait for UI to update after disable
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// After disable: Enable button should appear
|
||||||
|
const enableBtn = pluginCard.locator('button.btn-enable');
|
||||||
|
await expect(enableBtn).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// After disable: sidebar item for this plugin should disappear
|
||||||
|
const sidebarItem = page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' });
|
||||||
|
await expect(sidebarItem).not.toBeVisible();
|
||||||
|
|
||||||
|
// Status should show "disabled"
|
||||||
|
const statusBadge = pluginCard.locator('.status-badge');
|
||||||
|
await expect(statusBadge).toHaveText('disabled');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Re-enable plugin: button changes to Disable, sidebar item returns', async ({ page }) => {
|
||||||
|
const pluginCard = page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' });
|
||||||
|
|
||||||
|
// First disable
|
||||||
|
await pluginCard.locator('button.btn-disable').click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Wait for Enable button
|
||||||
|
const enableBtn = pluginCard.locator('button.btn-enable');
|
||||||
|
await expect(enableBtn).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Click Enable
|
||||||
|
await enableBtn.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// After re-enable: Disable button should appear
|
||||||
|
const disableBtn = pluginCard.locator('button.btn-disable');
|
||||||
|
await expect(disableBtn).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Sidebar item should return
|
||||||
|
const sidebarItem = page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' });
|
||||||
|
await expect(sidebarItem).toBeVisible();
|
||||||
|
|
||||||
|
// Status should show "loaded"
|
||||||
|
const statusBadge = pluginCard.locator('.status-badge');
|
||||||
|
await expect(statusBadge).toHaveText('loaded');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Disable → Enable full flow in sequence', async ({ page }) => {
|
||||||
|
const pluginCard = page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' });
|
||||||
|
const sidebar = page.locator('.sidebar');
|
||||||
|
|
||||||
|
// Initial state: enabled
|
||||||
|
await expect(pluginCard.locator('button.btn-disable')).toBeVisible();
|
||||||
|
await expect(sidebar.locator('.plugin-item').filter({ hasText: 'Platform Test' })).toBeVisible();
|
||||||
|
|
||||||
|
// Disable
|
||||||
|
await pluginCard.locator('button.btn-disable').click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
await expect(pluginCard.locator('button.btn-enable')).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(sidebar.locator('.plugin-item').filter({ hasText: 'Platform Test' })).not.toBeVisible();
|
||||||
|
|
||||||
|
// Enable
|
||||||
|
await pluginCard.locator('button.btn-enable').click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
await expect(pluginCard.locator('button.btn-disable')).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(sidebar.locator('.plugin-item').filter({ hasText: 'Platform Test' })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { waitForAppReady, setupConsoleCollector, resetMockState, openPluginManager, setPluginStatus } from './helpers.js';
|
||||||
|
|
||||||
|
test.describe('E: Plugin Manager layout', () => {
|
||||||
|
let consoleCollector;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
consoleCollector = setupConsoleCollector(page);
|
||||||
|
await resetMockState(page);
|
||||||
|
await page.goto('/');
|
||||||
|
await waitForAppReady(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async () => {
|
||||||
|
consoleCollector.assertNoErrors();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('plugin list scrolls through the global main scroll surface and stays responsive', async ({ page }) => {
|
||||||
|
await openPluginManager(page);
|
||||||
|
const basePluginCount = await page.locator('.plugin-card').count();
|
||||||
|
await page.evaluate(() => window.__wailsMock.addSyntheticPlugins(18));
|
||||||
|
await page.locator('button.reload-btn').click();
|
||||||
|
await expect(page.locator('.plugin-card')).toHaveCount(basePluginCount + 18, { timeout: 10000 });
|
||||||
|
|
||||||
|
const manager = page.locator('.plugin-manager');
|
||||||
|
const scrollSurface = page.locator('.content.scroll-surface');
|
||||||
|
await expect(manager).toBeVisible();
|
||||||
|
await expect(scrollSurface).toBeVisible();
|
||||||
|
|
||||||
|
const desktopMetrics = await scrollSurface.evaluate((node) => ({
|
||||||
|
clientHeight: node.clientHeight,
|
||||||
|
scrollHeight: node.scrollHeight,
|
||||||
|
overflowY: getComputedStyle(node).overflowY,
|
||||||
|
}));
|
||||||
|
expect(desktopMetrics.overflowY).toBe('auto');
|
||||||
|
expect(desktopMetrics.scrollHeight).toBeGreaterThan(desktopMetrics.clientHeight);
|
||||||
|
|
||||||
|
const scrolledTop = await scrollSurface.evaluate((node) => {
|
||||||
|
node.scrollTop = node.scrollHeight;
|
||||||
|
return node.scrollTop;
|
||||||
|
});
|
||||||
|
expect(scrolledTop).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 720, height: 640 });
|
||||||
|
await expect(manager).toBeVisible();
|
||||||
|
|
||||||
|
const narrowMetrics = await scrollSurface.evaluate((node) => ({
|
||||||
|
clientWidth: node.clientWidth,
|
||||||
|
scrollWidth: node.scrollWidth,
|
||||||
|
scrollTop: node.scrollTop,
|
||||||
|
}));
|
||||||
|
expect(narrowMetrics.scrollWidth).toBeLessThanOrEqual(narrowMetrics.clientWidth + 1);
|
||||||
|
expect(narrowMetrics.scrollTop).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('platform-test buttons use the global button contract', async ({ page }) => {
|
||||||
|
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' }).click();
|
||||||
|
|
||||||
|
const saveButton = page.locator('.pt-save-setting');
|
||||||
|
await expect(saveButton).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(saveButton).toHaveClass(/btn-primary/);
|
||||||
|
|
||||||
|
const buttonStyle = await saveButton.evaluate((node) => {
|
||||||
|
const style = getComputedStyle(node);
|
||||||
|
return {
|
||||||
|
display: style.display,
|
||||||
|
backgroundColor: style.backgroundColor,
|
||||||
|
borderRadius: style.borderRadius,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(buttonStyle.display).toBe('inline-flex');
|
||||||
|
expect(buttonStyle.backgroundColor).not.toBe('rgba(0, 0, 0, 0)');
|
||||||
|
expect(buttonStyle.borderRadius).toBe('6px');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('plugin manager summarizes plugin health and elevated permissions before the list', async ({ page }) => {
|
||||||
|
await openPluginManager(page);
|
||||||
|
|
||||||
|
const health = page.locator('[data-plugin-manager-summary="health"]');
|
||||||
|
await expect(health).toBeVisible();
|
||||||
|
await expect(health.locator('[data-plugin-status-summary="loaded"]')).toContainText('12');
|
||||||
|
await expect(health.locator('[data-plugin-status-summary="failed"]')).toContainText('0');
|
||||||
|
await expect(health.locator('[data-plugin-status-summary="disabled"]')).toContainText('0');
|
||||||
|
|
||||||
|
const risk = page.locator('[data-plugin-manager-summary="risk"]');
|
||||||
|
await expect(risk).toBeVisible();
|
||||||
|
await expect(risk.locator('[data-plugin-risk-summary="elevated-permissions"]')).toContainText('6');
|
||||||
|
await expect(risk).toContainText('elevated permissions');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('plugin manager filters by state, declared permissions, capabilities, settings and source', async ({ page }) => {
|
||||||
|
await setPluginStatus(page, 'verstak.todo', 'disabled', false);
|
||||||
|
await page.evaluate(() => window.__wailsMock.addSyntheticPlugins(1, 'third-party'));
|
||||||
|
await openPluginManager(page);
|
||||||
|
|
||||||
|
const results = page.locator('[data-plugin-filter-results]');
|
||||||
|
await expect(results).toContainText('Showing');
|
||||||
|
const filterSelectStyle = await page.locator('[data-plugin-filter="status"]').evaluate((node) => {
|
||||||
|
const style = getComputedStyle(node);
|
||||||
|
return { appearance: style.appearance, backgroundImage: style.backgroundImage };
|
||||||
|
});
|
||||||
|
expect(filterSelectStyle.appearance).toBe('none');
|
||||||
|
expect(filterSelectStyle.backgroundImage).not.toBe('none');
|
||||||
|
|
||||||
|
await page.locator('[data-plugin-filter="status"]').selectOption('disabled');
|
||||||
|
await expect(page.locator('.plugin-card')).toHaveCount(1);
|
||||||
|
await expect(page.locator('.plugin-card')).toContainText('Todos');
|
||||||
|
await page.locator('button.reload-btn').click();
|
||||||
|
await expect(page.locator('[data-plugin-filter="status"]')).toHaveValue('disabled');
|
||||||
|
await expect(page.locator('.plugin-card')).toHaveCount(1);
|
||||||
|
|
||||||
|
await page.locator('[data-plugin-filter-reset]').click();
|
||||||
|
await page.locator('[data-plugin-filter-permission="notifications.schedule"]').check();
|
||||||
|
await expect(page.locator('.plugin-card')).toHaveCount(1);
|
||||||
|
await expect(page.locator('.plugin-card')).toContainText('Todos');
|
||||||
|
await page.locator('[data-plugin-filter-permission="secrets.read"]').check();
|
||||||
|
await expect(page.locator('.plugin-card')).toHaveCount(2);
|
||||||
|
await page.locator('[data-plugin-filter-permission="secrets.read"]').uncheck();
|
||||||
|
|
||||||
|
await page.locator('[data-plugin-filter-reset]').click();
|
||||||
|
await page.locator('[data-plugin-filter-capability="verstak/core/notifications/v1"]').check();
|
||||||
|
await expect(page.locator('.plugin-card')).toHaveCount(1);
|
||||||
|
await expect(page.locator('.plugin-card')).toContainText('Todos');
|
||||||
|
await page.locator('[data-plugin-filter-capability="secret-store"]').check();
|
||||||
|
await expect(page.locator('.plugin-card')).toHaveCount(2);
|
||||||
|
await page.locator('[data-plugin-filter-capability="secret-store"]').uncheck();
|
||||||
|
|
||||||
|
await page.locator('[data-plugin-filter-reset]').click();
|
||||||
|
await page.locator('[data-plugin-filter="settings"]').selectOption('with');
|
||||||
|
await expect(page.locator('.plugin-card').filter({ hasText: 'Platform Test' })).toBeVisible();
|
||||||
|
await expect(page.locator('.plugin-card').filter({ hasText: 'Default Editor' })).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.locator('[data-plugin-filter="source"]').selectOption('third-party');
|
||||||
|
await expect(page.locator('.plugin-card')).toHaveCount(0);
|
||||||
|
await expect(page.locator('[data-plugin-filter-empty]')).toBeVisible();
|
||||||
|
|
||||||
|
await page.locator('[data-plugin-filter-reset]').click();
|
||||||
|
await expect(page.locator('.plugin-card')).toHaveCount(13);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('workspace selection keeps exactly one active node', async ({ page }) => {
|
||||||
|
const selected = page.locator('.wt-node.selected .wt-label');
|
||||||
|
await expect(selected).toHaveCount(1);
|
||||||
|
await expect(selected).toHaveText('Project');
|
||||||
|
|
||||||
|
await page.locator('.wt-label').filter({ hasText: 'Test' }).click();
|
||||||
|
|
||||||
|
await expect(selected).toHaveCount(1);
|
||||||
|
await expect(selected).toHaveText('Test');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('workspace tools render Overview first with Files as one tab', async ({ page }) => {
|
||||||
|
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
|
||||||
|
|
||||||
|
const tabs = page.locator('.workspace-tabs');
|
||||||
|
await expect(tabs).toBeVisible({ timeout: 10000 });
|
||||||
|
const overviewTab = tabs.locator('[role="tab"]').filter({ hasText: 'Overview' });
|
||||||
|
const filesTab = tabs.locator('[role="tab"]').filter({ hasText: 'Files' });
|
||||||
|
await expect(overviewTab).toBeVisible();
|
||||||
|
await expect(overviewTab).toHaveAttribute('aria-selected', 'true');
|
||||||
|
await expect(filesTab).toBeVisible();
|
||||||
|
await expect(filesTab).toHaveAttribute('aria-selected', 'false');
|
||||||
|
await expect(page.locator('.workspace-tool')).toHaveCount(0);
|
||||||
|
await expect(page.locator('[data-overview-root]')).toBeVisible();
|
||||||
|
|
||||||
|
await filesTab.click();
|
||||||
|
await expect(page.locator('.files-root')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('workspace sidebar creates renames and trashes top-level workspaces', async ({ page }) => {
|
||||||
|
await page.locator('button[title="New Deal"]').click();
|
||||||
|
const modal = page.locator('[data-workspace-create-modal]');
|
||||||
|
await modal.locator('[data-workspace-name]').fill('ClientA');
|
||||||
|
await modal.getByRole('button', { name: 'Create Deal' }).click();
|
||||||
|
|
||||||
|
await expect(page.locator('.wt-label').filter({ hasText: 'ClientA' })).toBeVisible();
|
||||||
|
|
||||||
|
const client = page.locator('.wt-node').filter({ hasText: 'ClientA' });
|
||||||
|
await client.locator('button[title="Rename Deal"]').click();
|
||||||
|
await page.locator('.wt-rename').fill('ClientB');
|
||||||
|
await page.locator('button[title="Save rename"]').click();
|
||||||
|
|
||||||
|
await expect(page.locator('.wt-label').filter({ hasText: 'ClientB' })).toBeVisible();
|
||||||
|
await expect(page.locator('.wt-label').filter({ hasText: 'ClientA' })).toHaveCount(0);
|
||||||
|
|
||||||
|
const renamed = page.locator('.wt-node').filter({ hasText: 'ClientB' });
|
||||||
|
await renamed.locator('button[title="Move Deal to trash"]').click();
|
||||||
|
|
||||||
|
await expect(page.locator('.wt-label').filter({ hasText: 'ClientB' })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shell icons render through bundled Lucide SVG components', async ({ page }) => {
|
||||||
|
const logo = page.locator('.sidebar-logo');
|
||||||
|
await expect(logo).toBeVisible();
|
||||||
|
await expect(logo).toHaveClass(/lucide/);
|
||||||
|
|
||||||
|
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
|
||||||
|
const workspaceIcon = page.locator('.wt-node-icon').first();
|
||||||
|
await expect(workspaceIcon).toBeVisible();
|
||||||
|
await expect(workspaceIcon).toHaveClass(/lucide/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* Acceptance Test C: Reload updates UI state
|
||||||
|
*
|
||||||
|
* Scenario:
|
||||||
|
* 1. Change mocked plugin state (e.g. disable a plugin in mock)
|
||||||
|
* 2. Click Reload button
|
||||||
|
* 3. Verify UI reflects the updated state
|
||||||
|
*/
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { waitForAppReady, setupConsoleCollector, resetMockState, setPluginStatus, openPluginManager } from './helpers.js';
|
||||||
|
|
||||||
|
test.describe('C: Reload updates UI state', () => {
|
||||||
|
let consoleCollector;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
consoleCollector = setupConsoleCollector(page);
|
||||||
|
await resetMockState(page);
|
||||||
|
await page.goto('/');
|
||||||
|
await waitForAppReady(page);
|
||||||
|
await openPluginManager(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async () => {
|
||||||
|
consoleCollector.assertNoErrors();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Reload after mock state change reflects new plugin status', async ({ page }) => {
|
||||||
|
const pluginCard = page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' });
|
||||||
|
|
||||||
|
// Initial state: loaded/enabled
|
||||||
|
await expect(pluginCard.locator('.status-badge')).toHaveText('loaded');
|
||||||
|
await expect(pluginCard.locator('button.btn-disable')).toBeVisible();
|
||||||
|
|
||||||
|
// Change mock state to disabled (simulating external state change)
|
||||||
|
await setPluginStatus(page, 'verstak.platform-test', 'disabled', false);
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
|
||||||
|
// Click Reload button in Plugin Manager header
|
||||||
|
const reloadBtn = page.locator('button.reload-btn');
|
||||||
|
await expect(reloadBtn).toBeVisible();
|
||||||
|
await reloadBtn.click();
|
||||||
|
|
||||||
|
// Wait for reload to complete and UI to update
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// After reload: status should reflect the disabled state
|
||||||
|
await expect(pluginCard.locator('.status-badge')).toHaveText('disabled', { timeout: 10000 });
|
||||||
|
|
||||||
|
// Enable button should appear (since plugin is now disabled)
|
||||||
|
await expect(pluginCard.locator('button.btn-enable')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Sidebar item should be gone (disabled plugins are filtered from sidebar)
|
||||||
|
const sidebarItem = page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' });
|
||||||
|
await expect(sidebarItem).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Reload restores plugin after re-enabling in mock', async ({ page }) => {
|
||||||
|
const pluginCard = page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' });
|
||||||
|
|
||||||
|
// Disable in mock, reload
|
||||||
|
await setPluginStatus(page, 'verstak.platform-test', 'disabled', false);
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
await page.locator('button.reload-btn').click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Verify disabled
|
||||||
|
await expect(pluginCard.locator('.status-badge')).toHaveText('disabled', { timeout: 10000 });
|
||||||
|
await expect(pluginCard.locator('button.btn-enable')).toBeVisible();
|
||||||
|
|
||||||
|
// Re-enable in mock
|
||||||
|
await setPluginStatus(page, 'verstak.platform-test', 'loaded', true);
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
|
||||||
|
// Reload again
|
||||||
|
await page.locator('button.reload-btn').click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// After reload: should be loaded again
|
||||||
|
await expect(pluginCard.locator('.status-badge')).toHaveText('loaded', { timeout: 10000 });
|
||||||
|
await expect(pluginCard.locator('button.btn-disable')).toBeVisible();
|
||||||
|
|
||||||
|
// Sidebar item should return
|
||||||
|
const sidebarItem = page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' });
|
||||||
|
await expect(sidebarItem).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Reload button is not disabled during normal operation', async ({ page }) => {
|
||||||
|
const reloadBtn = page.locator('button.reload-btn');
|
||||||
|
await expect(reloadBtn).toBeVisible();
|
||||||
|
await expect(reloadBtn).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Reload handles raw Wails count result without falling into error state', async ({ page }) => {
|
||||||
|
await page.evaluate(() => window.__wailsMock.setReloadResponseMode('raw-count'));
|
||||||
|
|
||||||
|
const reloadBtn = page.locator('button.reload-btn');
|
||||||
|
await reloadBtn.click();
|
||||||
|
|
||||||
|
await expect(page.locator('.error-state')).toHaveCount(0);
|
||||||
|
await expect(page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' })).toBeVisible({ timeout: 10000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* Acceptance Test B: Sidebar opens plugin view by item.view, not item.id
|
||||||
|
*
|
||||||
|
* Data:
|
||||||
|
* - sidebar item id = verstak.platform-test.sidebar
|
||||||
|
* - sidebar item view = verstak.platform-test.diagnostics
|
||||||
|
*
|
||||||
|
* Scenario:
|
||||||
|
* 1. Click sidebar item "Platform Test"
|
||||||
|
* 2. Verify diagnostics view is opened (verstak.platform-test.diagnostics)
|
||||||
|
* 3. Verify NOT opened empty container by sidebar id
|
||||||
|
*/
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
|
||||||
|
|
||||||
|
test.describe('B: Sidebar opens plugin view by item.view', () => {
|
||||||
|
let consoleCollector;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
consoleCollector = setupConsoleCollector(page);
|
||||||
|
await resetMockState(page);
|
||||||
|
await page.goto('/');
|
||||||
|
await waitForAppReady(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async () => {
|
||||||
|
consoleCollector.assertNoErrors();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Sidebar item exists with correct label', async ({ page }) => {
|
||||||
|
await expect(page.locator('.sidebar .nav-item').filter({ hasText: 'Plugin Manager' })).not.toBeVisible();
|
||||||
|
await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Activity' })).toBeVisible();
|
||||||
|
await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser' })).toBeVisible();
|
||||||
|
|
||||||
|
const sidebarItem = page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' });
|
||||||
|
await expect(sidebarItem).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Global Activity and Browser sidebar items open plugin views', async ({ page }) => {
|
||||||
|
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Activity' }).click();
|
||||||
|
await expect(page.locator('.view-container .view-header h2')).toHaveText('Activity', { timeout: 10000 });
|
||||||
|
|
||||||
|
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser' }).click();
|
||||||
|
await expect(page.locator('.view-container .view-header h2')).toHaveText('Browser', { timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selected global tool remains visibly active through navigation and sidebar reloads', async ({ page }) => {
|
||||||
|
const activity = page.locator('.sidebar .plugin-item').filter({ hasText: 'Activity' });
|
||||||
|
const browserInbox = page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser' });
|
||||||
|
|
||||||
|
await activity.click();
|
||||||
|
await expect(activity).toHaveAttribute('aria-current', 'page');
|
||||||
|
await expect(activity).toHaveClass(/is-active/);
|
||||||
|
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.dispatchEvent(new CustomEvent('verstak:plugins-changed'));
|
||||||
|
});
|
||||||
|
await expect(activity).toHaveAttribute('aria-current', 'page');
|
||||||
|
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.dispatchEvent(new CustomEvent('verstak:open-view', {
|
||||||
|
detail: { viewId: 'verstak.browser-inbox.view', pluginId: 'verstak.browser-inbox' },
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
await expect(page.locator('.view-container .view-header h2')).toHaveText('Browser', { timeout: 10000 });
|
||||||
|
await expect(browserInbox).toHaveAttribute('aria-current', 'page');
|
||||||
|
await expect(browserInbox).toHaveClass(/is-active/);
|
||||||
|
await expect(activity).not.toHaveAttribute('aria-current', 'page');
|
||||||
|
|
||||||
|
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
|
||||||
|
await expect(browserInbox).not.toHaveAttribute('aria-current', 'page');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Click sidebar item opens diagnostics view by view ID, not sidebar ID', async ({ page }) => {
|
||||||
|
const sidebarItem = page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' });
|
||||||
|
await expect(sidebarItem).toBeVisible();
|
||||||
|
|
||||||
|
// Click the sidebar item
|
||||||
|
await sidebarItem.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// View container should be visible
|
||||||
|
const viewContainer = page.locator('.view-container');
|
||||||
|
await expect(viewContainer).toBeVisible();
|
||||||
|
|
||||||
|
// The view header should show "Platform Diagnostics" (from view contribution title)
|
||||||
|
// This proves the view was opened by item.view = "verstak.platform-test.diagnostics"
|
||||||
|
// NOT by item.id = "verstak.platform-test.sidebar"
|
||||||
|
const viewHeader = viewContainer.locator('.view-header h2');
|
||||||
|
await expect(viewHeader).toHaveText('Platform Diagnostics', { timeout: 10000 });
|
||||||
|
|
||||||
|
// The view should NOT show "View ... not found" error
|
||||||
|
// (which would happen if it tried to open by sidebar item id)
|
||||||
|
await expect(viewContainer).not.toHaveText(/not found/);
|
||||||
|
|
||||||
|
// The view should NOT show an empty container message
|
||||||
|
const emptyView = viewContainer.locator('.empty');
|
||||||
|
await expect(emptyView).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('View header shows correct title from view contribution', async ({ page }) => {
|
||||||
|
const sidebarItem = page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' });
|
||||||
|
await sidebarItem.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Verify the view title comes from the view contribution (item.view)
|
||||||
|
// NOT from the sidebar item (item.id)
|
||||||
|
const viewHeader = page.locator('.view-container .view-header h2');
|
||||||
|
await expect(viewHeader).toHaveText('Platform Diagnostics', { timeout: 10000 });
|
||||||
|
|
||||||
|
// Should NOT show sidebar item id as view title
|
||||||
|
await expect(viewHeader).not.toHaveText('verstak.platform-test.sidebar');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { waitForAppReady, setupConsoleCollector, resetMockState, openPluginManager } from './helpers.js';
|
||||||
|
|
||||||
|
test.describe('Status Bar host', () => {
|
||||||
|
let consoleCollector;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
consoleCollector = setupConsoleCollector(page);
|
||||||
|
await resetMockState(page);
|
||||||
|
await page.goto('/');
|
||||||
|
await waitForAppReady(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async () => {
|
||||||
|
consoleCollector.assertNoErrors();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders enabled plugin statusBarItems', async ({ page }) => {
|
||||||
|
const statusBar = page.locator('.status-bar');
|
||||||
|
await expect(statusBar).toBeVisible();
|
||||||
|
await expect(statusBar.locator('.vault-status')).toContainText('Vault: open');
|
||||||
|
await expect(statusBar.locator('[data-status-item-id="verstak.platform-test.status"]')).toContainText('All Tests Pass');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('opens settings menu with plugin manager and plugin settings', async ({ page }) => {
|
||||||
|
await page.locator('[data-settings-menu-button]').click();
|
||||||
|
|
||||||
|
await expect(page.locator('[data-settings-action="plugin-manager"]')).toBeVisible();
|
||||||
|
await expect(page.locator('[data-settings-panel-id="verstak.sync.settings"]')).toBeVisible();
|
||||||
|
|
||||||
|
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' }).click();
|
||||||
|
await expect(page.locator('.view-container .view-header h2')).toHaveText('Platform Diagnostics');
|
||||||
|
|
||||||
|
await page.locator('[data-settings-menu-button]').click();
|
||||||
|
await page.locator('[data-settings-action="plugin-manager"]').click();
|
||||||
|
await expect(page.locator('.plugin-manager')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refreshes statusBarItems after disabling plugin', async ({ page }) => {
|
||||||
|
const pluginCard = page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' });
|
||||||
|
await expect(page.locator('[data-status-item-id="verstak.platform-test.status"]')).toBeVisible();
|
||||||
|
await openPluginManager(page);
|
||||||
|
|
||||||
|
await pluginCard.locator('button.btn-disable').click();
|
||||||
|
|
||||||
|
await expect(pluginCard.locator('button.btn-enable')).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.locator('[data-status-item-id="verstak.platform-test.status"]')).not.toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
|
||||||
|
|
||||||
|
test.describe('Todo plugin workflow', () => {
|
||||||
|
let consoleCollector;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
consoleCollector = setupConsoleCollector(page);
|
||||||
|
await resetMockState(page);
|
||||||
|
await page.goto('/');
|
||||||
|
await waitForAppReady(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async () => {
|
||||||
|
consoleCollector.assertNoErrors();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('workspace Todo supports CRUD, reminders, and manual Journal conversion', async ({ page }) => {
|
||||||
|
await page.getByRole('tab', { name: 'Todos' }).click();
|
||||||
|
|
||||||
|
const todos = page.locator('.todo-root');
|
||||||
|
await expect(todos).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(todos).toContainText('Todos · Project');
|
||||||
|
|
||||||
|
await todos.locator('[data-todo-action="add"]').click();
|
||||||
|
await todos.locator('[data-todo-input="title"]').fill('Prepare project review');
|
||||||
|
await todos.locator('[data-todo-input="description"]').fill('Collect factual review notes.');
|
||||||
|
await todos.locator('[data-todo-input="priority"]').selectOption('high');
|
||||||
|
await todos.locator('[data-todo-input="dueAt"]').fill('2000-01-01');
|
||||||
|
await todos.locator('[data-todo-input="reminderDate"]').fill('2000-01-01');
|
||||||
|
const reminderTime = todos.locator('[data-todo-input="reminderTime"]');
|
||||||
|
await expect(reminderTime).toHaveAttribute('type', 'text');
|
||||||
|
await reminderTime.fill('09:30');
|
||||||
|
await todos.locator('[data-todo-action="save"]').click();
|
||||||
|
|
||||||
|
await expect(todos).toContainText('Overdue');
|
||||||
|
await expect(todos).toContainText('Reminder due');
|
||||||
|
await expect.poll(async () => page.evaluate(async () => {
|
||||||
|
const result = await window.go.api.App.ReadPluginSettings('verstak.todo');
|
||||||
|
const settings = Array.isArray(result) ? result[0] : result;
|
||||||
|
const todo = settings['todos:global'].find((item) => item.title === 'Prepare project review');
|
||||||
|
return todo && [todo.workspaceRootPath, todo.priority, todo.dueAt, todo.reminderDate, todo.reminderAt].join('|');
|
||||||
|
})).toBe('Project|high|2000-01-01|2000-01-01|2000-01-01T09:30');
|
||||||
|
|
||||||
|
await todos.locator('[data-todo-action="edit"]').click();
|
||||||
|
await todos.locator('[data-todo-input="title"]').fill('Prepare project review updated');
|
||||||
|
await todos.locator('[data-todo-input="reminderTime"]').fill('not-a-time');
|
||||||
|
await todos.locator('[data-todo-action="save"]').click();
|
||||||
|
await expect(todos).toContainText('Enter a valid reminder time');
|
||||||
|
await todos.locator('[data-todo-input="reminderTime"]').fill('');
|
||||||
|
await todos.locator('[data-todo-action="save"]').click();
|
||||||
|
await expect(todos).toContainText('Prepare project review updated');
|
||||||
|
await expect.poll(async () => page.evaluate(async () => {
|
||||||
|
const result = await window.go.api.App.ReadPluginSettings('verstak.todo');
|
||||||
|
const settings = Array.isArray(result) ? result[0] : result;
|
||||||
|
const todo = settings['todos:global'].find((item) => item.title === 'Prepare project review updated');
|
||||||
|
return todo && [todo.reminderDate, todo.reminderAt].join('|');
|
||||||
|
})).toBe('2000-01-01|');
|
||||||
|
|
||||||
|
await todos.locator('[data-todo-action="mark-done"]').click();
|
||||||
|
await expect(todos.locator('[data-todo-action="create-journal-entry"]')).toBeVisible();
|
||||||
|
await todos.locator('[data-todo-action="create-journal-entry"]').click();
|
||||||
|
|
||||||
|
const journal = page.locator('.journal-root');
|
||||||
|
await expect(page.getByRole('tab', { name: 'Journal' })).toHaveAttribute('aria-selected', 'true');
|
||||||
|
await expect(journal).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(journal).toContainText('Create journal entry from completed todo');
|
||||||
|
await expect(journal.locator('[data-journal-input="title"]')).toHaveValue('Prepare project review updated');
|
||||||
|
await expect(journal.locator('[data-journal-input="summary"]')).toHaveValue('Collect factual review notes.');
|
||||||
|
await expect(journal.locator('[data-journal-input="minutes"]')).toHaveValue('0');
|
||||||
|
|
||||||
|
await journal.locator('[data-journal-input="title"]').fill('Prepare project review handoff');
|
||||||
|
await journal.locator('[data-journal-input="summary"]').fill('Reviewed factual project notes before handoff.');
|
||||||
|
await journal.locator('[data-journal-action="save-entry"]').click();
|
||||||
|
await expect.poll(async () => page.evaluate(async () => {
|
||||||
|
const todoResult = await window.go.api.App.ReadPluginSettings('verstak.todo');
|
||||||
|
const todoSettings = Array.isArray(todoResult) ? todoResult[0] : todoResult;
|
||||||
|
const todo = todoSettings['todos:global'].find((item) => item.title === 'Prepare project review updated');
|
||||||
|
const journalResult = await window.go.api.App.ReadPluginSettings('verstak.journal');
|
||||||
|
const journalSettings = Array.isArray(journalResult) ? journalResult[0] : journalResult;
|
||||||
|
const entry = journalSettings['worklog:workspace:Project'].find((item) => item.sourceTodoId === todo.id);
|
||||||
|
return entry && [entry.title, entry.summary, entry.minutes].join('|');
|
||||||
|
})).toBe('Prepare project review handoff|Reviewed factual project notes before handoff.|0');
|
||||||
|
|
||||||
|
await page.getByRole('tab', { name: 'Todos' }).click();
|
||||||
|
await page.locator('.todo-root [data-todo-action="delete"]').click();
|
||||||
|
await expect.poll(async () => page.evaluate(async () => {
|
||||||
|
const result = await window.go.api.App.ReadPluginSettings('verstak.todo');
|
||||||
|
const settings = Array.isArray(result) ? result[0] : result;
|
||||||
|
return settings['todos:global'].some((item) => item.title === 'Prepare project review updated');
|
||||||
|
})).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('global Todos filter by workspace and Overview only exposes current workspace attention', async ({ page }) => {
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
await window.go.api.App.WritePluginSettings('verstak.todo', {
|
||||||
|
'todos:global': [
|
||||||
|
{
|
||||||
|
id: 'todo-project-open',
|
||||||
|
title: 'Project deadline',
|
||||||
|
workspaceRootPath: 'Project',
|
||||||
|
status: 'open',
|
||||||
|
priority: 'high',
|
||||||
|
dueAt: '2000-01-01',
|
||||||
|
reminderAt: '2000-01-01T09:00',
|
||||||
|
createdAt: '2026-06-30T08:00:00.000Z',
|
||||||
|
updatedAt: '2026-06-30T08:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'todo-project-done',
|
||||||
|
title: 'Project completed item',
|
||||||
|
workspaceRootPath: 'Project',
|
||||||
|
status: 'done',
|
||||||
|
priority: 'normal',
|
||||||
|
createdAt: '2026-06-30T08:01:00.000Z',
|
||||||
|
updatedAt: '2026-06-30T08:01:00.000Z',
|
||||||
|
completedAt: '2026-06-30T08:02:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'todo-test-open',
|
||||||
|
title: 'Test workspace item',
|
||||||
|
workspaceRootPath: 'Test',
|
||||||
|
status: 'open',
|
||||||
|
priority: 'normal',
|
||||||
|
dueAt: '2000-01-01',
|
||||||
|
createdAt: '2026-06-30T08:03:00.000Z',
|
||||||
|
updatedAt: '2026-06-30T08:03:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const overview = page.locator('[data-overview-root]');
|
||||||
|
await overview.locator('[data-overview-action="refresh"]').click();
|
||||||
|
const attention = overview.locator('[data-overview-section="attention"]');
|
||||||
|
await expect(attention).toContainText('Project deadline');
|
||||||
|
await expect(attention).not.toContainText('Test workspace item');
|
||||||
|
await attention.locator('.overview-attention-row', { hasText: 'Project deadline' }).getByRole('button', { name: 'Open Todos' }).click();
|
||||||
|
await expect(page.getByRole('tab', { name: 'Todos' })).toHaveAttribute('aria-selected', 'true');
|
||||||
|
|
||||||
|
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Todos' }).click();
|
||||||
|
const globalTodos = page.locator('.todo-root');
|
||||||
|
await expect(globalTodos).toContainText('Project deadline');
|
||||||
|
await expect(globalTodos).toContainText('Test workspace item');
|
||||||
|
|
||||||
|
await globalTodos.locator('[data-todo-filter="workspace"]').selectOption('Project');
|
||||||
|
await expect(globalTodos).toContainText('Project deadline');
|
||||||
|
await expect(globalTodos).toContainText('Project completed item');
|
||||||
|
await expect(globalTodos).not.toContainText('Test workspace item');
|
||||||
|
|
||||||
|
await globalTodos.locator('[data-todo-filter="status"]').selectOption('done');
|
||||||
|
await expect(globalTodos).toContainText('Project completed item');
|
||||||
|
await expect(globalTodos).not.toContainText('Project deadline');
|
||||||
|
|
||||||
|
await page.locator('.wt-label').filter({ hasText: 'Test' }).click();
|
||||||
|
await page.getByRole('tab', { name: 'Todos' }).click();
|
||||||
|
const workspaceTodos = page.locator('.todo-root');
|
||||||
|
await expect(workspaceTodos).toContainText('Test workspace item');
|
||||||
|
await expect(workspaceTodos).not.toContainText('Project deadline');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
|
||||||
|
|
||||||
|
async function openTrash(page) {
|
||||||
|
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Trash' }).click();
|
||||||
|
await expect(page.locator('.trash-root')).toBeVisible({ timeout: 10000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTrashEntry(page, path, content, deletedAt) {
|
||||||
|
return page.evaluate(async ({ path, content, deletedAt }) => {
|
||||||
|
const api = window.createPluginAPI('verstak.files');
|
||||||
|
await api.files.writeText(path, content, { createIfMissing: true });
|
||||||
|
const entry = await api.files.trash(path);
|
||||||
|
window.__wailsMock.setTrashDeletedAt(entry.trashId, deletedAt);
|
||||||
|
api.dispose();
|
||||||
|
return entry;
|
||||||
|
}, { path, content, deletedAt });
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('L: Global Trash Plugin', () => {
|
||||||
|
let consoleCollector;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
consoleCollector = setupConsoleCollector(page);
|
||||||
|
await resetMockState(page);
|
||||||
|
await page.goto('/');
|
||||||
|
await waitForAppReady(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async () => {
|
||||||
|
consoleCollector.assertNoErrors();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('moves a Files item into global Trash without exposing Trash metadata in Files', async ({ page }) => {
|
||||||
|
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
|
||||||
|
await page.getByRole('tab', { name: 'Files' }).click();
|
||||||
|
await expect(page.locator('.files-root')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
await page.locator('[data-files-action="new-text"]').click();
|
||||||
|
await page.locator('[data-files-create-input]').fill('GlobalTrash.txt');
|
||||||
|
await page.locator('[data-files-create-confirm]').click();
|
||||||
|
await expect(page.locator('[data-file-name="GlobalTrash.txt"]')).toBeVisible();
|
||||||
|
await page.locator('[data-file-name="GlobalTrash.txt"]').click();
|
||||||
|
await page.locator('[data-files-action="trash"]').click();
|
||||||
|
await page.locator('.files-modal-btn.confirm').click();
|
||||||
|
await expect(page.locator('[data-file-name="GlobalTrash.txt"]')).toHaveCount(0);
|
||||||
|
await expect(page.locator('[data-files-action="trash-view"]')).toHaveCount(0);
|
||||||
|
|
||||||
|
await openTrash(page);
|
||||||
|
const row = page.locator('[data-trash-row]').filter({ hasText: 'GlobalTrash.txt' });
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
await expect(row).toContainText('Project');
|
||||||
|
await expect(row).toContainText('Project/GlobalTrash.txt');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filters and sorts global Trash and restores without overwriting a conflict', async ({ page }) => {
|
||||||
|
const older = await createTrashEntry(page, 'Project/Older.txt', 'older original', '2026-06-20T08:00:00.000Z');
|
||||||
|
const newest = await createTrashEntry(page, 'Test/Newest.txt', 'newest original', '2026-06-29T08:00:00.000Z');
|
||||||
|
const restore = await createTrashEntry(page, 'Project/Restore.txt', 'restore original', '2026-06-25T08:00:00.000Z');
|
||||||
|
const conflict = await page.evaluate(async () => {
|
||||||
|
const api = window.createPluginAPI('verstak.files');
|
||||||
|
await api.files.writeText('Project/Conflict.txt', 'trashed content', { createIfMissing: true });
|
||||||
|
const entry = await api.files.trash('Project/Conflict.txt');
|
||||||
|
await api.files.writeText('Project/Conflict.txt', 'existing content', { createIfMissing: true });
|
||||||
|
window.__wailsMock.setTrashDeletedAt(entry.trashId, '2026-06-24T08:00:00.000Z');
|
||||||
|
api.dispose();
|
||||||
|
return entry;
|
||||||
|
});
|
||||||
|
|
||||||
|
await openTrash(page);
|
||||||
|
await page.locator('[data-trash-filter-workspace]').selectOption('Project');
|
||||||
|
await expect(page.locator(`[data-trash-row="${older.trashId}"]`)).toBeVisible();
|
||||||
|
await expect(page.locator(`[data-trash-row="${newest.trashId}"]`)).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.locator('[data-trash-filter-workspace]').selectOption('');
|
||||||
|
await page.locator('[data-trash-filter-search]').fill('Newest');
|
||||||
|
await expect(page.locator(`[data-trash-row="${newest.trashId}"]`)).toBeVisible();
|
||||||
|
await expect(page.locator(`[data-trash-row="${older.trashId}"]`)).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.locator('[data-trash-filter-search]').fill('');
|
||||||
|
await page.locator('[data-trash-sort]').selectOption('date-asc');
|
||||||
|
await expect(page.locator('[data-trash-row]').first()).toHaveAttribute('data-trash-row', older.trashId);
|
||||||
|
|
||||||
|
await page.locator(`[data-trash-restore="${restore.trashId}"]`).click();
|
||||||
|
await expect(page.locator(`[data-trash-row="${restore.trashId}"]`)).toHaveCount(0);
|
||||||
|
await expect.poll(() => page.evaluate(async () => {
|
||||||
|
const api = window.createPluginAPI('verstak.files');
|
||||||
|
const content = await api.files.readText('Project/Restore.txt');
|
||||||
|
api.dispose();
|
||||||
|
return content;
|
||||||
|
})).toBe('restore original');
|
||||||
|
|
||||||
|
await page.locator(`[data-trash-restore="${conflict.trashId}"]`).click();
|
||||||
|
await expect(page.locator('[data-trash-status]')).toContainText('Restore blocked');
|
||||||
|
await expect(page.locator(`[data-trash-row="${conflict.trashId}"]`)).toBeVisible();
|
||||||
|
await expect.poll(() => page.evaluate(async () => {
|
||||||
|
const api = window.createPluginAPI('verstak.files');
|
||||||
|
const content = await api.files.readText('Project/Conflict.txt');
|
||||||
|
api.dispose();
|
||||||
|
return content;
|
||||||
|
})).toBe('existing content');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('requires confirmation before permanently deleting a Trash item', async ({ page }) => {
|
||||||
|
const entry = await createTrashEntry(page, 'Test/Permanent.txt', 'remove forever', '2026-06-26T08:00:00.000Z');
|
||||||
|
await openTrash(page);
|
||||||
|
|
||||||
|
await page.locator(`[data-trash-delete="${entry.trashId}"]`).click();
|
||||||
|
await expect(page.locator(`[data-trash-confirm="${entry.trashId}"]`)).toBeVisible();
|
||||||
|
await expect(page.locator(`[data-trash-row="${entry.trashId}"]`)).toBeVisible();
|
||||||
|
|
||||||
|
await page.locator(`[data-trash-confirm-delete="${entry.trashId}"]`).click();
|
||||||
|
await expect(page.locator(`[data-trash-row="${entry.trashId}"]`)).toHaveCount(0);
|
||||||
|
await expect.poll(() => page.evaluate(async (trashId) => {
|
||||||
|
const api = window.createPluginAPI('verstak.files');
|
||||||
|
const entries = await api.files.listTrash();
|
||||||
|
api.dispose();
|
||||||
|
return entries.some((item) => item.trashId === trashId);
|
||||||
|
}, entry.trashId)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { waitForAppReady, resetMockState, openPluginManager } from './helpers.js';
|
||||||
|
|
||||||
|
test.describe('UX follow-up fixes', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await resetMockState(page);
|
||||||
|
await page.goto('/');
|
||||||
|
await waitForAppReady(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('global search stays available after opening tool sidebar views', async ({ page }) => {
|
||||||
|
const search = page.locator('[data-global-search-input]');
|
||||||
|
await expect(search).toBeVisible();
|
||||||
|
await expect(page.locator('.workspace-header [data-global-search-input]')).toBeVisible();
|
||||||
|
|
||||||
|
await page.locator('.sidebar .nav-item').filter({ hasText: 'Activity' }).click();
|
||||||
|
await expect(page.locator('.activity-root')).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(search).toBeVisible();
|
||||||
|
await expect(page.locator('.sidebar [data-global-search-input]')).toBeVisible();
|
||||||
|
|
||||||
|
await page.locator('.sidebar .nav-item').filter({ hasText: 'Browser' }).click();
|
||||||
|
await expect(page.locator('.browser-inbox-root')).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(search).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('global search types ahead across workspaces and file contents with keyboard layout fallback', async ({ page }) => {
|
||||||
|
const search = page.locator('[data-global-search-input]');
|
||||||
|
|
||||||
|
await search.fill('Зкщоусе');
|
||||||
|
await expect(page.locator('[data-global-search-results]')).toContainText('Project', { timeout: 10000 });
|
||||||
|
|
||||||
|
await search.fill('project file');
|
||||||
|
await expect(page.locator('[data-global-search-results]')).toContainText('project-only.txt', { timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('global search folder results open the workspace Files context', async ({ page }) => {
|
||||||
|
const search = page.locator('[data-global-search-input]');
|
||||||
|
await search.fill('Project/Notes');
|
||||||
|
|
||||||
|
const folderResult = page.locator('[data-global-search-result-type="Folder"][data-global-search-result-path="Project/Notes"]');
|
||||||
|
await expect(folderResult).toBeVisible({ timeout: 10000 });
|
||||||
|
await folderResult.click();
|
||||||
|
|
||||||
|
await expect(page.locator('.workspace-title')).toHaveText('Project', { timeout: 10000 });
|
||||||
|
await expect(page.getByRole('tab', { name: 'Files' })).toHaveAttribute('aria-selected', 'true');
|
||||||
|
await expect(page.locator('.files-root')).toBeVisible();
|
||||||
|
await expect(page.locator('.files-breadcrumb')).toContainText('Notes');
|
||||||
|
await expect(page.locator('.files-item')).toContainText('Overview.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('global search opens indexed browser inbox results', async ({ page }) => {
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
await window.go.api.App.WritePluginSettings('verstak.browser-inbox', {
|
||||||
|
'captures:workspace:Project': [{
|
||||||
|
captureId: 'capture-search-1',
|
||||||
|
capturedAt: '2026-06-30T08:00:00.000Z',
|
||||||
|
kind: 'page',
|
||||||
|
url: 'https://example.com/research',
|
||||||
|
title: 'Research Search Result',
|
||||||
|
domain: 'example.com',
|
||||||
|
text: 'Searchable captured browser text',
|
||||||
|
workspaceRootPath: 'Project',
|
||||||
|
browserName: 'Firefox',
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const search = page.locator('[data-global-search-input]');
|
||||||
|
await search.fill('Research Search Result');
|
||||||
|
const result = page.locator('[data-global-search-result-type="Browser"]').filter({ hasText: 'Research Search Result' });
|
||||||
|
await expect(result).toBeVisible({ timeout: 10000 });
|
||||||
|
await result.click();
|
||||||
|
|
||||||
|
await expect(page.locator('.browser-inbox-root')).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.locator('.browser-inbox-detail-title')).toHaveText('Research Search Result');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('workspace Search input keeps focus while typing', async ({ page }) => {
|
||||||
|
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
|
||||||
|
await page.getByRole('tab', { name: 'Search' }).click();
|
||||||
|
const searchInput = page.locator('[data-search-input="query"]');
|
||||||
|
await expect(searchInput).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
await searchInput.click();
|
||||||
|
await page.keyboard.press('p');
|
||||||
|
await expect(searchInput).toBeFocused();
|
||||||
|
await page.keyboard.press('r');
|
||||||
|
await expect(searchInput).toBeFocused();
|
||||||
|
await expect(searchInput).toHaveValue('pr');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mobile workspace layout gives content full width below the sidebar', async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await page.reload();
|
||||||
|
await waitForAppReady(page);
|
||||||
|
|
||||||
|
const workspaceBox = await page.locator('.workspace-host').boundingBox();
|
||||||
|
const sidebarBox = await page.locator('.sidebar').boundingBox();
|
||||||
|
expect(workspaceBox.width).toBeGreaterThan(340);
|
||||||
|
expect(workspaceBox.y).toBeGreaterThan(sidebarBox.y + sidebarBox.height - 1);
|
||||||
|
await expect(page.locator('.workspace-header [data-global-search-input]')).toBeVisible();
|
||||||
|
await expect(page.getByRole('tab', { name: 'Overview' })).toBeVisible();
|
||||||
|
|
||||||
|
const hasHorizontalOverflow = await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth);
|
||||||
|
expect(hasHorizontalOverflow).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('plugin settings modal gives complex panels enough space', async ({ page }) => {
|
||||||
|
await openPluginManager(page);
|
||||||
|
await page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' }).getByRole('button', { name: 'Settings' }).click();
|
||||||
|
|
||||||
|
const modal = page.locator('.modal[aria-label="Plugin Settings"]');
|
||||||
|
await expect(modal).toBeVisible({ timeout: 10000 });
|
||||||
|
const box = await modal.boundingBox();
|
||||||
|
expect(box.width).toBeGreaterThanOrEqual(760);
|
||||||
|
expect(box.height).toBeGreaterThanOrEqual(560);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { waitForAppReady, setupConsoleCollector, resetMockState, openPluginManager } from './helpers.js';
|
||||||
|
|
||||||
|
test.describe('UX P0 shell flow', () => {
|
||||||
|
let consoleCollector;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
consoleCollector = setupConsoleCollector(page);
|
||||||
|
await resetMockState(page);
|
||||||
|
await page.goto('/');
|
||||||
|
await waitForAppReady(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async () => {
|
||||||
|
consoleCollector.assertNoErrors();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('starts in the first workspace instead of Plugin Manager', async ({ page }) => {
|
||||||
|
await expect(page.locator('.workspace-host')).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.locator('.plugin-manager')).toHaveCount(0);
|
||||||
|
await expect(page.locator('.wt-node.selected .wt-label')).toHaveText('Project');
|
||||||
|
await expect(page.locator('.workspace-title')).toHaveText('Project');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('workspace selection and main content stay in sync across plugin manager round trip', async ({ page }) => {
|
||||||
|
await page.locator('.wt-label').filter({ hasText: 'Test' }).click();
|
||||||
|
|
||||||
|
await expect(page.locator('.workspace-title')).toHaveText('Test', { timeout: 10000 });
|
||||||
|
await expect(page.locator('.wt-node.selected .wt-label')).toHaveText('Test');
|
||||||
|
await expect(page.locator('.plugin-manager')).toHaveCount(0);
|
||||||
|
|
||||||
|
await openPluginManager(page);
|
||||||
|
await expect(page.locator('.plugin-manager')).toBeVisible();
|
||||||
|
await expect(page.locator('.wt-node.selected .wt-label')).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
|
||||||
|
await expect(page.locator('.workspace-title')).toHaveText('Project', { timeout: 10000 });
|
||||||
|
await expect(page.locator('.plugin-manager')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Deal header does not expose the internal workspace type badge', async ({ page }) => {
|
||||||
|
await expect(page.locator('.workspace-host')).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.locator('.workspace-type')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('status bar plugin contribution failures do not render large error panels', async ({ page }) => {
|
||||||
|
await expect(page.locator('.workspace-host')).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.getByText('Plugin View Error')).toHaveCount(0);
|
||||||
|
await expect(page.locator('.status-bar [data-status-item-id]')).toHaveCount(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Plugin Manager remains reachable from the settings menu', async ({ page }) => {
|
||||||
|
await openPluginManager(page);
|
||||||
|
|
||||||
|
await expect(page.locator('.plugin-manager')).toBeVisible();
|
||||||
|
await expect(page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('UX quick wins', () => {
|
||||||
|
test('Files screen uses readable dates and understandable action controls', async ({ page }) => {
|
||||||
|
await resetMockState(page);
|
||||||
|
await page.goto('/');
|
||||||
|
await waitForAppReady(page);
|
||||||
|
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
|
||||||
|
await page.getByRole('tab', { name: 'Files' }).click();
|
||||||
|
|
||||||
|
const files = page.locator('.files-root');
|
||||||
|
await expect(files).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(files.getByText(/T\d{2}:\d{2}:\d{2}/)).toHaveCount(0);
|
||||||
|
|
||||||
|
const actions = [
|
||||||
|
['new-folder', 'New folder'],
|
||||||
|
['new-markdown', 'New markdown file'],
|
||||||
|
['new-text', 'New text file'],
|
||||||
|
];
|
||||||
|
for (const [action, label] of actions) {
|
||||||
|
const button = page.locator(`[data-files-action="${action}"]`).first();
|
||||||
|
await expect(button).toBeVisible();
|
||||||
|
await expect(button).toHaveAttribute('title', label);
|
||||||
|
await expect(button).toHaveAttribute('aria-label', label);
|
||||||
|
await expect(button).toHaveAttribute('data-files-icon', /.+/);
|
||||||
|
await expect(button.locator('svg')).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Vault Selection is localized and has a clear primary action', async ({ browser }) => {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
window.go = { api: { App: {
|
||||||
|
GetAppSettings: async () => ({ currentVaultPath: '', recentVaults: ['/tmp/verstak-recent-vault'] }),
|
||||||
|
GetVaultStatus: async () => ({ status: 'closed', path: '', vaultId: '' }),
|
||||||
|
SelectDirectory: async () => '',
|
||||||
|
SelectVaultForOpen: async () => '',
|
||||||
|
CreateVault: async () => null,
|
||||||
|
OpenVault: async () => null,
|
||||||
|
SetCurrentVault: async () => '',
|
||||||
|
WriteFrontendLog: async () => {},
|
||||||
|
} } };
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
await page.waitForSelector('.vault-selection', { timeout: 10000 });
|
||||||
|
|
||||||
|
await expect(page.getByText('Choose a vault to start working')).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Create vault' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Open existing' })).toBeVisible();
|
||||||
|
await page.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
|
||||||
|
|
||||||
|
test.describe('UX Overview workspace flow', () => {
|
||||||
|
let consoleCollector;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
consoleCollector = setupConsoleCollector(page);
|
||||||
|
await resetMockState(page);
|
||||||
|
await page.goto('/');
|
||||||
|
await waitForAppReady(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async () => {
|
||||||
|
consoleCollector.assertNoErrors();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('workspace opens with Overview before plugin tools', async ({ page }) => {
|
||||||
|
await expect(page.locator('.workspace-host')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
const tabs = page.getByRole('tab');
|
||||||
|
await expect(tabs.nth(0)).toHaveText('Overview');
|
||||||
|
await expect(tabs.nth(1)).toHaveText('Notes');
|
||||||
|
await expect(page.getByRole('tab', { name: 'Overview' })).toHaveAttribute('aria-selected', 'true');
|
||||||
|
await expect(page.getByRole('tab', { name: 'Today' })).toHaveCount(0);
|
||||||
|
|
||||||
|
const overview = page.locator('[data-overview-root]');
|
||||||
|
await expect(overview).toBeVisible();
|
||||||
|
await expect(overview.locator('[data-overview-section="continue"]')).toContainText('Continue working');
|
||||||
|
await expect(overview.locator('[data-overview-section="recent"]')).toContainText('Recent changes');
|
||||||
|
await expect(overview.locator('[data-overview-section="attention"]')).toContainText('Needs attention');
|
||||||
|
await expect(overview.locator('[data-overview-section="quick-actions"]')).toHaveCount(0);
|
||||||
|
|
||||||
|
const summaryCards = overview.locator('button[data-overview-summary]');
|
||||||
|
await expect(summaryCards).toHaveCount(6);
|
||||||
|
await expect(overview.locator('[data-overview-summary="notes"]')).toContainText('1 total');
|
||||||
|
await expect(overview.locator('[data-overview-summary="notes"]')).toContainText('0 recent changes');
|
||||||
|
await expect(overview.locator('[data-overview-summary="captures"]')).toContainText('0 captures to review');
|
||||||
|
await expect(overview.locator('[data-overview-summary="activity"]')).toContainText('0 recorded events');
|
||||||
|
await expect(overview.locator('[data-overview-summary="journal"]')).toContainText('0 journal entries');
|
||||||
|
await expect(overview).toContainText('No clear resume point yet');
|
||||||
|
await expect(overview).toContainText('No meaningful changes for this filter yet');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Overview summary cards navigate to their corresponding workspace tools', async ({ page }) => {
|
||||||
|
const overview = page.locator('[data-overview-root]');
|
||||||
|
await overview.locator('[data-overview-summary="notes"]').click();
|
||||||
|
await expect(page.getByRole('tab', { name: 'Notes' })).toHaveAttribute('aria-selected', 'true');
|
||||||
|
await expect(page.locator('.notes-root')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
await page.getByRole('tab', { name: 'Overview' }).click();
|
||||||
|
await overview.locator('[data-overview-summary="files"]').click();
|
||||||
|
await expect(page.getByRole('tab', { name: 'Files' })).toHaveAttribute('aria-selected', 'true');
|
||||||
|
await expect(page.locator('.files-root')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
await page.getByRole('tab', { name: 'Overview' }).click();
|
||||||
|
await overview.locator('[data-overview-summary="captures"]').click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('tab', { name: 'Browser' })).toHaveAttribute('aria-selected', 'true');
|
||||||
|
await expect(page.locator('.browser-inbox-root')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
await page.getByRole('tab', { name: 'Overview' }).click();
|
||||||
|
await overview.locator('[data-overview-summary="activity"]').click();
|
||||||
|
await expect(page.getByRole('tab', { name: 'Activity' })).toHaveAttribute('aria-selected', 'true');
|
||||||
|
|
||||||
|
await page.getByRole('tab', { name: 'Overview' }).click();
|
||||||
|
await overview.locator('[data-overview-summary="journal"]').click();
|
||||||
|
await expect(page.getByRole('tab', { name: 'Journal' })).toHaveAttribute('aria-selected', 'true');
|
||||||
|
await expect(page.locator('.journal-root')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
await page.getByRole('tab', { name: 'Overview' }).click();
|
||||||
|
await overview.locator('[data-overview-summary="attention"]').click();
|
||||||
|
await expect(page.getByRole('tab', { name: 'Browser' })).toHaveAttribute('aria-selected', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Overview keeps the Notes total factual when the Files plugin is unavailable', async ({ page }) => {
|
||||||
|
await page.evaluate(() => window.__wailsMock.setPluginStatus('verstak.files', 'disabled', false));
|
||||||
|
await page.locator('[data-overview-action="refresh"]').click();
|
||||||
|
|
||||||
|
await expect(page.locator('[data-overview-summary="notes"]')).toContainText('1 total');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Overview hides Browser cards and actions when the current Deal does not include it', async ({ page }) => {
|
||||||
|
await page.locator('button[title="New Deal"]').click();
|
||||||
|
const modal = page.locator('[data-workspace-create-modal]');
|
||||||
|
await modal.locator('[data-workspace-name]').fill('MinimalOverview');
|
||||||
|
await modal.locator('[data-workspace-template]').selectOption('minimal');
|
||||||
|
await modal.getByRole('button', { name: 'Create Deal' }).click();
|
||||||
|
await expect(page.getByRole('tab', { name: 'Browser' })).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
await window.go.api.App.WritePluginSettings('verstak.browser-inbox', {
|
||||||
|
'captures:global': [{
|
||||||
|
captureId: 'hidden-browser-capture',
|
||||||
|
capturedAt: '2026-07-14T08:00:00.000Z',
|
||||||
|
kind: 'page',
|
||||||
|
title: 'Inbox material must stay hidden',
|
||||||
|
workspaceRootPath: 'MinimalOverview',
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const overview = page.locator('[data-overview-root]');
|
||||||
|
await overview.locator('[data-overview-action="refresh"]').click();
|
||||||
|
|
||||||
|
await expect(overview.locator('[data-overview-summary="captures"]')).toHaveCount(0);
|
||||||
|
await expect(overview.locator('[data-overview-action="browser-inbox"]')).toHaveCount(0);
|
||||||
|
await expect(overview).not.toContainText('Inbox material must stay hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Overview refreshes when Browser is disabled through plugin state changes', async ({ page }) => {
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
await window.go.api.App.WritePluginSettings('verstak.browser-inbox', {
|
||||||
|
'captures:global': [{
|
||||||
|
captureId: 'disabled-browser-capture',
|
||||||
|
capturedAt: '2026-07-14T08:00:00.000Z',
|
||||||
|
kind: 'page',
|
||||||
|
title: 'Disabled inbox material',
|
||||||
|
workspaceRootPath: 'Project',
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
window.__wailsMock.setPluginStatus('verstak.browser-inbox', 'disabled', false);
|
||||||
|
window.dispatchEvent(new CustomEvent('verstak:plugins-changed'));
|
||||||
|
});
|
||||||
|
|
||||||
|
const overview = page.locator('[data-overview-root]');
|
||||||
|
await expect(page.getByRole('tab', { name: 'Browser' })).toHaveCount(0);
|
||||||
|
await expect(overview.locator('[data-overview-summary="captures"]')).toHaveCount(0);
|
||||||
|
await expect(overview.locator('[data-overview-action="browser-inbox"]')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Overview prioritizes resume work and filters meaningful recent changes', async ({ page }) => {
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
await window.go.api.App.WritePluginSettings('verstak.browser-inbox', {
|
||||||
|
'captures:global': [
|
||||||
|
{
|
||||||
|
captureId: 'overview-capture-1',
|
||||||
|
capturedAt: '2026-06-30T08:00:00.000Z',
|
||||||
|
kind: 'page',
|
||||||
|
url: 'https://example.com/research',
|
||||||
|
title: 'Research Report',
|
||||||
|
domain: 'example.com',
|
||||||
|
workspaceRootPath: 'Project',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
captureId: 'overview-capture-2',
|
||||||
|
capturedAt: '2026-06-30T08:15:00.000Z',
|
||||||
|
kind: 'selection',
|
||||||
|
title: 'Quote to process',
|
||||||
|
domain: 'example.com',
|
||||||
|
workspaceRootPath: 'Project',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
captureId: 'overview-client-capture',
|
||||||
|
capturedAt: '2026-06-30T08:30:00.000Z',
|
||||||
|
kind: 'page',
|
||||||
|
title: 'Client capture must stay global',
|
||||||
|
workspaceRootPath: 'ClientA',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
captureId: 'overview-unassigned-capture',
|
||||||
|
capturedAt: '2026-06-30T08:35:00.000Z',
|
||||||
|
kind: 'page',
|
||||||
|
title: 'Unassigned capture must stay global',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await window.go.api.App.WritePluginSettings('verstak.activity', {
|
||||||
|
'events:workspace:Project': [
|
||||||
|
{
|
||||||
|
activityId: 'overview-selected-file',
|
||||||
|
occurredAt: '2026-06-30T08:50:00.000Z',
|
||||||
|
type: 'file.selected',
|
||||||
|
title: 'Selected file',
|
||||||
|
summary: 'Project/draft.md',
|
||||||
|
workspaceRootPath: 'Project',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
activityId: 'overview-opened-file',
|
||||||
|
occurredAt: '2026-06-30T08:40:00.000Z',
|
||||||
|
type: 'file.opened',
|
||||||
|
title: 'draft.md',
|
||||||
|
summary: 'Project/draft.md',
|
||||||
|
workspaceRootPath: 'Project',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
activityId: 'overview-note',
|
||||||
|
occurredAt: '2026-06-30T08:25:00.000Z',
|
||||||
|
type: 'note.saved',
|
||||||
|
title: 'Overview',
|
||||||
|
summary: 'Project/Notes/Overview.md',
|
||||||
|
workspaceRootPath: 'Project',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
activityId: 'overview-file',
|
||||||
|
occurredAt: '2026-06-30T08:20:00.000Z',
|
||||||
|
type: 'file.changed',
|
||||||
|
title: 'draft.md',
|
||||||
|
summary: 'Project/draft.md',
|
||||||
|
workspaceRootPath: 'Project',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
activityId: 'overview-workspace-selected',
|
||||||
|
occurredAt: '2026-06-30T08:10:00.000Z',
|
||||||
|
type: 'case.selected',
|
||||||
|
title: 'Workspace selected',
|
||||||
|
workspaceRootPath: 'Project',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'work-session-candidates:workspace:Project': [
|
||||||
|
{
|
||||||
|
candidateId: 'work-session:Project:overview-note:overview-file',
|
||||||
|
workspaceRootPath: 'Project',
|
||||||
|
startedAt: '2026-06-30T08:15:00.000Z',
|
||||||
|
endedAt: '2026-06-30T08:25:00.000Z',
|
||||||
|
estimatedMinutes: 10,
|
||||||
|
activityCount: 2,
|
||||||
|
activityIds: ['overview-file', 'overview-note'],
|
||||||
|
activities: [
|
||||||
|
{ activityId: 'overview-file', type: 'file.changed', occurredAt: '2026-06-30T08:20:00.000Z' },
|
||||||
|
{ activityId: 'overview-note', type: 'note.saved', occurredAt: '2026-06-30T08:25:00.000Z' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await window.go.api.App.WritePluginSettings('verstak.journal', {
|
||||||
|
'worklog:workspace:Project': [
|
||||||
|
{
|
||||||
|
entryId: 'overview-journal-1',
|
||||||
|
date: '2026-06-30',
|
||||||
|
title: 'Write project summary',
|
||||||
|
summary: 'Turn recent captures into a worklog entry',
|
||||||
|
minutes: 35,
|
||||||
|
workspaceRootPath: 'Project',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.locator('[data-overview-action="refresh"]').click();
|
||||||
|
|
||||||
|
const overview = page.locator('[data-overview-root]');
|
||||||
|
await expect(overview.locator('[data-overview-summary="notes"]')).toContainText('1 total');
|
||||||
|
await expect(overview.locator('[data-overview-summary="notes"]')).toContainText('1 recent change');
|
||||||
|
await expect(overview.locator('[data-overview-summary="files"]')).toContainText('1 recent change');
|
||||||
|
await expect(overview.locator('[data-overview-summary="captures"]')).toContainText('2');
|
||||||
|
await expect(overview.locator('[data-overview-summary="activity"]')).toContainText('5 recorded events');
|
||||||
|
await expect(overview.locator('[data-overview-summary="journal"]')).toContainText('1');
|
||||||
|
await expect(overview.locator('[data-overview-summary="attention"]')).toContainText('3');
|
||||||
|
const attention = overview.locator('[data-overview-section="attention"]');
|
||||||
|
await expect(attention).toContainText('Possible journal entry');
|
||||||
|
await expect(attention).toContainText('Deal: Project · 10 min · 2 activities');
|
||||||
|
await attention.locator('.overview-attention-row', { hasText: 'Possible journal entry' }).getByRole('button', { name: 'Review candidate' }).click();
|
||||||
|
await expect(page.getByRole('tab', { name: 'Journal' })).toHaveAttribute('aria-selected', 'true');
|
||||||
|
await expect(page.locator('.journal-root [data-journal-candidate]')).toContainText('Deal: Project');
|
||||||
|
await page.locator('.journal-modal-actions').getByRole('button', { name: 'Cancel' }).click();
|
||||||
|
await page.getByRole('tab', { name: 'Overview' }).click();
|
||||||
|
|
||||||
|
const resume = overview.locator('[data-overview-section="continue"]');
|
||||||
|
const candidates = resume.locator('[data-overview-continue-item]');
|
||||||
|
await expect(candidates).toHaveCount(4);
|
||||||
|
await expect(candidates.nth(0)).toContainText('Quote to process');
|
||||||
|
await expect(candidates.nth(1)).toContainText('Research Report');
|
||||||
|
await expect(candidates.nth(2)).toContainText('Edited note "Overview"');
|
||||||
|
await expect(candidates.nth(3)).toContainText('Changed file "draft.md"');
|
||||||
|
await candidates.nth(0).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('tab', { name: 'Browser' })).toHaveAttribute('aria-selected', 'true');
|
||||||
|
await expect(page.locator('.browser-inbox-root')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
await page.getByRole('tab', { name: 'Overview' }).click();
|
||||||
|
const recent = overview.locator('[data-overview-section="recent"]');
|
||||||
|
await expect(recent).toContainText('Edited note "Overview"');
|
||||||
|
await expect(recent).toContainText('Changed file "draft.md"');
|
||||||
|
await expect(recent).toContainText('Captured page "Research Report"');
|
||||||
|
await expect(recent).toContainText('Added journal entry "Write project summary"');
|
||||||
|
await expect(recent).not.toContainText('Selected file');
|
||||||
|
await expect(recent).not.toContainText('Workspace selected');
|
||||||
|
await expect(recent).not.toContainText('file.opened');
|
||||||
|
await expect(recent).not.toContainText('Client capture must stay global');
|
||||||
|
await expect(recent).not.toContainText('Unassigned capture must stay global');
|
||||||
|
await expect(recent.locator('[data-overview-recent-item]')).toHaveCount(5);
|
||||||
|
await expect(recent.locator('[data-overview-recent-item] button')).toHaveCount(0);
|
||||||
|
|
||||||
|
await overview.locator('[data-overview-filter="notes"]').click();
|
||||||
|
await expect(recent).toContainText('Edited note "Overview"');
|
||||||
|
await expect(recent).not.toContainText('Changed file "draft.md"');
|
||||||
|
await expect(recent).not.toContainText('Research Report');
|
||||||
|
|
||||||
|
await overview.locator('[data-overview-filter="captures"]').click();
|
||||||
|
await expect(recent).toContainText('Captured page "Research Report"');
|
||||||
|
await expect(recent).toContainText('Captured selection "Quote to process"');
|
||||||
|
await expect(recent).not.toContainText('Edited note "Overview"');
|
||||||
|
|
||||||
|
await overview.locator('[data-overview-filter="journal"]').click();
|
||||||
|
await expect(recent).toContainText('Added journal entry "Write project summary"');
|
||||||
|
await expect(recent).not.toContainText('Changed file "draft.md"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Overview localizes activity labels without exposing internal event names', async ({ page }) => {
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
await window.go.api.App.WritePluginSettings('verstak.activity', {
|
||||||
|
'events:workspace:Project': [{
|
||||||
|
activityId: 'overview-russian-note',
|
||||||
|
occurredAt: '2026-06-30T08:25:00.000Z',
|
||||||
|
type: 'note.saved',
|
||||||
|
title: 'Локализация',
|
||||||
|
summary: 'Project/Notes/Localization.md',
|
||||||
|
workspaceRootPath: 'Project',
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.locator('[data-settings-menu-button]').click();
|
||||||
|
await page.locator('[data-settings-language="ru"]').click();
|
||||||
|
|
||||||
|
const overview = page.locator('[data-overview-root]');
|
||||||
|
await overview.locator('[data-overview-action="refresh"]').click();
|
||||||
|
const recent = overview.locator('[data-overview-section="recent"]');
|
||||||
|
await expect(recent).toContainText('Изменена заметка «Локализация»');
|
||||||
|
await expect(recent).not.toContainText('note.saved');
|
||||||
|
await expect(recent).not.toContainText('Edited note');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
|
||||||
|
|
||||||
|
test.describe('Workspace templates', () => {
|
||||||
|
let consoleCollector;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
consoleCollector = setupConsoleCollector(page);
|
||||||
|
await resetMockState(page);
|
||||||
|
await page.goto('/');
|
||||||
|
await waitForAppReady(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async () => {
|
||||||
|
consoleCollector.assertNoErrors();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function openCreateModal(page) {
|
||||||
|
await page.locator('button[title="New Deal"]').click();
|
||||||
|
const modal = page.locator('[data-workspace-create-modal]');
|
||||||
|
await expect(modal).toBeVisible();
|
||||||
|
return modal;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('creation modal validates names, shows template tools, and persists the selected snapshot', async ({ page }) => {
|
||||||
|
const modal = await openCreateModal(page);
|
||||||
|
const templateSelect = modal.locator('[data-workspace-template]');
|
||||||
|
await expect(templateSelect).toHaveValue('default');
|
||||||
|
await expect(templateSelect).toHaveCSS('appearance', 'none');
|
||||||
|
await expect(modal.locator('[data-workspace-template-tools]')).toContainText('Notes');
|
||||||
|
await expect(modal.locator('[data-workspace-template-tools]')).toContainText('Browser');
|
||||||
|
|
||||||
|
await modal.getByRole('button', { name: 'Create Deal' }).click();
|
||||||
|
await expect(modal.locator('[data-workspace-create-error]')).toContainText('Name is required');
|
||||||
|
|
||||||
|
await modal.locator('[data-workspace-name]').fill('bad/name');
|
||||||
|
await modal.getByRole('button', { name: 'Create Deal' }).click();
|
||||||
|
await expect(modal.locator('[data-workspace-create-error]')).toContainText('Could not create the Deal. Please try again.');
|
||||||
|
|
||||||
|
await modal.locator('[data-workspace-name]').fill('ProjectPlan');
|
||||||
|
await modal.locator('[data-workspace-template]').selectOption('project');
|
||||||
|
await expect(modal.locator('[data-workspace-template-description]')).toContainText('Project planning');
|
||||||
|
await expect(modal.locator('[data-workspace-template-tools]')).toContainText('Todos');
|
||||||
|
await modal.getByRole('button', { name: 'Create Deal' }).click();
|
||||||
|
|
||||||
|
await expect(page.locator('.wt-label').filter({ hasText: 'ProjectPlan' })).toBeVisible();
|
||||||
|
await expect.poll(async () => page.evaluate(async () => {
|
||||||
|
const result = await window.go.api.App.GetWorkspaceMetadata('ProjectPlan');
|
||||||
|
const metadata = Array.isArray(result) ? result[0] : result;
|
||||||
|
return {
|
||||||
|
templateId: metadata.createdFromTemplate?.templateId,
|
||||||
|
tools: metadata.workspaceTools,
|
||||||
|
};
|
||||||
|
})).toEqual({
|
||||||
|
templateId: 'project',
|
||||||
|
tools: ['verstak.notes', 'verstak.files', 'verstak.todo', 'verstak.journal', 'verstak.activity', 'verstak.browser-inbox'],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(page.getByRole('tab', { name: 'Todos' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('tab', { name: 'Journal' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('tab', { name: 'Secrets' })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Minimal keeps global tools available while limiting workspace tabs', async ({ page }) => {
|
||||||
|
const modal = await openCreateModal(page);
|
||||||
|
await modal.locator('[data-workspace-name]').fill('MinimalSpace');
|
||||||
|
await modal.locator('[data-workspace-template]').selectOption('minimal');
|
||||||
|
await modal.getByRole('button', { name: 'Create Deal' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('tab', { name: 'Overview' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('tab', { name: 'Notes' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('tab', { name: 'Files' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('tab', { name: 'Todos' })).toHaveCount(0);
|
||||||
|
await expect(page.getByRole('tab', { name: 'Journal' })).toHaveCount(0);
|
||||||
|
await expect(page.getByRole('tab', { name: 'Secrets' })).toHaveCount(0);
|
||||||
|
await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Todos' })).toBeVisible();
|
||||||
|
await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('template explains an unavailable plugin and warns after incomplete creation', async ({ page }) => {
|
||||||
|
await page.evaluate(() => window.__wailsMock.setPluginStatus('verstak.todo', 'disabled', false));
|
||||||
|
const modal = await openCreateModal(page);
|
||||||
|
await modal.locator('[data-workspace-template]').selectOption('project');
|
||||||
|
|
||||||
|
const todo = modal.locator('[data-workspace-template-tool="verstak.todo"]');
|
||||||
|
await expect(todo).toContainText('Todos');
|
||||||
|
await expect(todo).toContainText('Plugin is disabled');
|
||||||
|
await expect(todo).toHaveAttribute('data-template-tool-status', 'unavailable');
|
||||||
|
|
||||||
|
await modal.locator('[data-workspace-name]').fill('ProjectWithWarning');
|
||||||
|
await modal.getByRole('button', { name: 'Create Deal' }).click();
|
||||||
|
|
||||||
|
const warning = page.locator('[data-workspace-template-warning]');
|
||||||
|
await expect(warning).toContainText('ProjectWithWarning');
|
||||||
|
await expect(warning).toContainText('Todos');
|
||||||
|
await expect(warning).toContainText('Plugin is disabled');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Admin shows Secrets when available and missing workspace plugins degrade without breaking tabs', async ({ page }) => {
|
||||||
|
let modal = await openCreateModal(page);
|
||||||
|
await modal.locator('[data-workspace-name]').fill('AdminSpace');
|
||||||
|
await modal.locator('[data-workspace-template]').selectOption('admin');
|
||||||
|
await modal.getByRole('button', { name: 'Create Deal' }).click();
|
||||||
|
await expect(page.getByRole('tab', { name: 'Secrets' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.evaluate(() => window.__wailsMock.setPluginStatus('verstak.todo', 'disabled', false));
|
||||||
|
modal = await openCreateModal(page);
|
||||||
|
await modal.locator('[data-workspace-name]').fill('ProjectWithoutTodo');
|
||||||
|
await modal.locator('[data-workspace-template]').selectOption('project');
|
||||||
|
await modal.getByRole('button', { name: 'Create Deal' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('tab', { name: 'Notes' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('tab', { name: 'Files' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('tab', { name: 'Todos' })).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.locator('.wt-label').filter({ hasText: 'AdminSpace' }).click();
|
||||||
|
await expect(page.getByRole('tab', { name: 'Secrets' })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,6 +16,15 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script type="module" src="/src/main.js"></script>
|
<script type="module">
|
||||||
|
// In test mode (no Wails runtime), load mock bridge first
|
||||||
|
if (!window['go'] || !window['go']['api']) {
|
||||||
|
import('/src/lib/test/wails-mock.js').then(function() {
|
||||||
|
import('/src/main.js');
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
import('/src/main.js');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -8,7 +8,9 @@
|
|||||||
"name": "verstak-desktop-frontend",
|
"name": "verstak-desktop-frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.61.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^3.1.0",
|
"@sveltejs/vite-plugin-svelte": "^3.1.0",
|
||||||
|
"lucide-svelte": "^0.577.0",
|
||||||
"svelte": "^4.2.0",
|
"svelte": "^4.2.0",
|
||||||
"vite": "^5.4.0"
|
"vite": "^5.4.0"
|
||||||
}
|
}
|
||||||
@@ -429,6 +431,21 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.61.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.0.tgz",
|
||||||
|
"integrity": "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.61.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
"version": "4.62.0",
|
"version": "4.62.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.0.tgz",
|
||||||
@@ -966,6 +983,16 @@
|
|||||||
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
|
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/lucide-svelte": {
|
||||||
|
"version": "0.577.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.577.0.tgz",
|
||||||
|
"integrity": "sha512-0i88o57KsaHWnc80J57fY99CWzlZsSdtH5kKjLUJa7z8dum/9/AbINNLzJ7NiRFUdOgMnfAmJt8jFbW2zeC5qQ==",
|
||||||
|
"deprecated": "Package deprecated. Please use @lucide/svelte instead.",
|
||||||
|
"dev": true,
|
||||||
|
"peerDependencies": {
|
||||||
|
"svelte": "^3 || ^4 || ^5.0.0-next.42"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
@@ -1022,6 +1049,50 @@
|
|||||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.61.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz",
|
||||||
|
"integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.61.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.61.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz",
|
||||||
|
"integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==",
|
||||||
|
"dev": true,
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright/node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.15",
|
"version": "8.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||||
|
|||||||
@@ -6,10 +6,15 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test:e2e": "npx playwright test --config playwright.config.js",
|
||||||
|
"test:e2e:ui": "npx playwright test --config playwright.config.js --ui",
|
||||||
|
"test:e2e:headed": "npx playwright test --config playwright.config.js --headed"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.61.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^3.1.0",
|
"@sveltejs/vite-plugin-svelte": "^3.1.0",
|
||||||
|
"lucide-svelte": "^0.577.0",
|
||||||
"svelte": "^4.2.0",
|
"svelte": "^4.2.0",
|
||||||
"vite": "^5.4.0"
|
"vite": "^5.4.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
ef6849d398e9d32c4ae5afdea904eb49
|
c02aa6458981565332c3b0bc49516b42
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { dirname, resolve } from 'path';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
const FRONTEND_PORT = 5174;
|
||||||
|
const FRONTEND_URL = `http://localhost:${FRONTEND_PORT}`;
|
||||||
|
const LOOPBACK_NO_PROXY = 'localhost,127.0.0.1,::1';
|
||||||
|
|
||||||
|
process.env.NO_PROXY = process.env.NO_PROXY
|
||||||
|
? `${process.env.NO_PROXY},${LOOPBACK_NO_PROXY}`
|
||||||
|
: LOOPBACK_NO_PROXY;
|
||||||
|
process.env.no_proxy = process.env.no_proxy
|
||||||
|
? `${process.env.no_proxy},${LOOPBACK_NO_PROXY}`
|
||||||
|
: LOOPBACK_NO_PROXY;
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: resolve(__dirname, 'e2e'),
|
||||||
|
testMatch: '**/*.spec.js',
|
||||||
|
timeout: 30000,
|
||||||
|
expect: { timeout: 10000 },
|
||||||
|
fullyParallel: false,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
workers: 1,
|
||||||
|
reporter: [
|
||||||
|
['list'],
|
||||||
|
['json', { outputFile: resolve(__dirname, 'e2e-results/test-results.json') }],
|
||||||
|
],
|
||||||
|
outputDir: resolve(__dirname, 'e2e-results'),
|
||||||
|
use: {
|
||||||
|
baseURL: FRONTEND_URL,
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
video: 'on-first-retry',
|
||||||
|
headless: true,
|
||||||
|
viewport: { width: 1280, height: 720 },
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: { ...devices['Desktop Chrome'] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
webServer: {
|
||||||
|
command: `npx vite --mode test --port ${FRONTEND_PORT}`,
|
||||||
|
url: FRONTEND_URL,
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
timeout: 60000,
|
||||||
|
env: {
|
||||||
|
NO_PROXY: process.env.NO_PROXY,
|
||||||
|
no_proxy: process.env.no_proxy,
|
||||||
|
},
|
||||||
|
stdout: 'pipe',
|
||||||
|
stderr: 'pipe',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,33 +1,868 @@
|
|||||||
<script>
|
<script>
|
||||||
import PluginManager from './lib/plugin-manager/PluginManager.svelte';
|
import PluginManager from './lib/plugin-manager/PluginManager.svelte';
|
||||||
import Sidebar from './lib/shell/Sidebar.svelte';
|
import Sidebar from './lib/shell/Sidebar.svelte';
|
||||||
|
import CommandPalette from './lib/shell/CommandPalette.svelte';
|
||||||
|
import StatusBar from './lib/shell/StatusBar.svelte';
|
||||||
import ViewContainer from './lib/shell/ViewContainer.svelte';
|
import ViewContainer from './lib/shell/ViewContainer.svelte';
|
||||||
|
import VaultSelection from './lib/shell/VaultSelection.svelte';
|
||||||
|
import WorkbenchHost from './lib/shell/WorkbenchHost.svelte';
|
||||||
|
import WorkspaceHost from './lib/shell/WorkspaceHost.svelte';
|
||||||
|
import * as App from '../wailsjs/go/api/App';
|
||||||
|
import { debug } from './lib/log/debug.js';
|
||||||
|
import { onDestroy, onMount, tick } from 'svelte';
|
||||||
|
import { i18n } from './lib/i18n/index.js';
|
||||||
|
|
||||||
let currentView = 'plugin-manager';
|
let currentView = 'workspace';
|
||||||
|
let vaultStatus = { status: 'unknown', path: '', vaultId: '' };
|
||||||
|
let needsVaultSelection = false;
|
||||||
|
let loading = true;
|
||||||
|
let locale = i18n.getLocale();
|
||||||
|
const unsubscribeLocale = i18n.subscribe((nextLocale) => { locale = nextLocale; });
|
||||||
|
$: tr = ((activeLocale) => (key, params, fallback) => {
|
||||||
|
void activeLocale;
|
||||||
|
return i18n.t(key, params, fallback);
|
||||||
|
})(locale);
|
||||||
|
|
||||||
|
let activeView = null;
|
||||||
|
let activeViewPluginId = '';
|
||||||
|
let activeSettingsPluginId = '';
|
||||||
|
let activeSettingsPanelId = '';
|
||||||
|
let openedResource = null;
|
||||||
|
|
||||||
|
let workspaceNodes = [];
|
||||||
|
let selectedWorkspaceName = '';
|
||||||
|
let activeWorkspaceToolKey = '';
|
||||||
|
let navigationStack = [];
|
||||||
|
let navigationIndex = -1;
|
||||||
|
let applyingNavigation = false;
|
||||||
|
let lastMouseHistoryDirection = '';
|
||||||
|
let lastMouseHistoryAt = 0;
|
||||||
|
|
||||||
|
function flog(msg) {
|
||||||
|
App.WriteFrontendLog('App', msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resultOrError(response, fallbackValue) {
|
||||||
|
return typeof response === 'string' ? [fallbackValue, response] : [response, ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
function workspaceName(workspace) {
|
||||||
|
return String(workspace?.name || workspace?.rootPath || workspace?.id || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function workspaceAsNode(workspace, order) {
|
||||||
|
const name = workspaceName(workspace);
|
||||||
|
return {
|
||||||
|
id: name,
|
||||||
|
workspaceId: workspace?.id || workspace?.workspaceId || '',
|
||||||
|
type: workspace?.type || 'space',
|
||||||
|
title: workspace?.title || name,
|
||||||
|
name,
|
||||||
|
rootPath: workspace?.rootPath || name,
|
||||||
|
status: workspace?.status || 'active',
|
||||||
|
order,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitWorkspaceActive(name) {
|
||||||
|
window.dispatchEvent(new CustomEvent('verstak:workspace-active-changed', {
|
||||||
|
detail: { workspaceName: name || '' }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearWorkspaceSelection() {
|
||||||
|
selectedWorkspaceName = '';
|
||||||
|
emitWorkspaceActive('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDefaultWorkspaceRoute() {
|
||||||
|
try {
|
||||||
|
const [workspaces, err] = resultOrError(await App.ListWorkspaces(), []);
|
||||||
|
if (err || !workspaces || workspaces.length === 0) {
|
||||||
|
workspaceNodes = [];
|
||||||
|
selectedWorkspaceName = '';
|
||||||
|
currentView = 'workspace-empty';
|
||||||
|
emitWorkspaceActive('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
workspaceNodes = workspaces.map(workspaceAsNode);
|
||||||
|
let currentWorkspace = null;
|
||||||
|
try {
|
||||||
|
currentWorkspace = await App.GetCurrentWorkspace();
|
||||||
|
} catch {
|
||||||
|
currentWorkspace = null;
|
||||||
|
}
|
||||||
|
const currentName = workspaceName(currentWorkspace);
|
||||||
|
const selected = workspaces.find((workspace) => workspaceName(workspace) === currentName) || workspaces[0];
|
||||||
|
selectedWorkspaceName = workspaceName(selected);
|
||||||
|
if (selectedWorkspaceName) {
|
||||||
|
try { await App.SetCurrentWorkspace(selectedWorkspaceName); } catch {}
|
||||||
|
currentView = 'workspace';
|
||||||
|
} else {
|
||||||
|
currentView = 'workspace-empty';
|
||||||
|
}
|
||||||
|
emitWorkspaceActive(selectedWorkspaceName);
|
||||||
|
} catch (e) {
|
||||||
|
debug.log('[App] openDefaultWorkspaceRoute ERROR', String(e));
|
||||||
|
workspaceNodes = [];
|
||||||
|
selectedWorkspaceName = '';
|
||||||
|
currentView = 'workspace-empty';
|
||||||
|
emitWorkspaceActive('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentSnapshot() {
|
||||||
|
return {
|
||||||
|
currentView,
|
||||||
|
activeView,
|
||||||
|
activeViewPluginId,
|
||||||
|
activeSettingsPluginId,
|
||||||
|
activeSettingsPanelId,
|
||||||
|
openedResource,
|
||||||
|
selectedWorkspaceName,
|
||||||
|
activeWorkspaceToolKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameSnapshot(a, b) {
|
||||||
|
return JSON.stringify(a) === JSON.stringify(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushNavigation(snapshot = currentSnapshot()) {
|
||||||
|
if (applyingNavigation) return;
|
||||||
|
if (navigationIndex >= 0 && sameSnapshot(navigationStack[navigationIndex], snapshot)) return;
|
||||||
|
if (navigationIndex < navigationStack.length - 1) {
|
||||||
|
navigationStack = navigationStack.slice(0, navigationIndex + 1);
|
||||||
|
}
|
||||||
|
navigationStack = [...navigationStack, snapshot];
|
||||||
|
navigationIndex = navigationStack.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySnapshot(snapshot) {
|
||||||
|
applyingNavigation = true;
|
||||||
|
currentView = snapshot.currentView;
|
||||||
|
activeView = snapshot.activeView;
|
||||||
|
activeViewPluginId = snapshot.activeViewPluginId;
|
||||||
|
activeSettingsPluginId = snapshot.activeSettingsPluginId;
|
||||||
|
activeSettingsPanelId = snapshot.activeSettingsPanelId;
|
||||||
|
openedResource = snapshot.openedResource;
|
||||||
|
selectedWorkspaceName = snapshot.selectedWorkspaceName;
|
||||||
|
activeWorkspaceToolKey = snapshot.activeWorkspaceToolKey || '';
|
||||||
|
emitWorkspaceActive(currentView === 'workspace' ? selectedWorkspaceName : '');
|
||||||
|
applyingNavigation = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateBack() {
|
||||||
|
if (navigationIndex <= 0) return false;
|
||||||
|
navigationIndex -= 1;
|
||||||
|
applySnapshot(navigationStack[navigationIndex]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateForward() {
|
||||||
|
if (navigationIndex >= navigationStack.length - 1) return false;
|
||||||
|
navigationIndex += 1;
|
||||||
|
applySnapshot(navigationStack[navigationIndex]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mouseHistoryDirection(event) {
|
||||||
|
if (currentView === 'workspace') return '';
|
||||||
|
if (event.button === 3 || event.button === 8 || event.buttons === 8 || event.buttons === 128 || event.which === 8) return 'back';
|
||||||
|
if (event.button === 4 || event.button === 9 || event.buttons === 16 || event.buttons === 256 || event.which === 9) return 'forward';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyHistoryDirection(event) {
|
||||||
|
if (currentView === 'workspace' || currentView === 'workbench') return '';
|
||||||
|
const key = event.key || '';
|
||||||
|
if (event.altKey && key === 'ArrowLeft') return 'back';
|
||||||
|
if (event.altKey && key === 'ArrowRight') return 'forward';
|
||||||
|
if (key === 'BrowserBack' || key === 'XF86Back') return 'back';
|
||||||
|
if (key === 'BrowserForward' || key === 'XF86Forward') return 'forward';
|
||||||
|
if (event.keyCode === 166) return 'back';
|
||||||
|
if (event.keyCode === 167) return 'forward';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleHistoryRequest(direction, event) {
|
||||||
|
if (!direction || event?.defaultPrevented) return;
|
||||||
|
if (event?.type === 'mousedown' || event?.type === 'mouseup' || event?.type === 'auxclick' || event?.type === 'pointerdown') {
|
||||||
|
const now = Date.now();
|
||||||
|
if (direction === lastMouseHistoryDirection && now - lastMouseHistoryAt < 120) return;
|
||||||
|
lastMouseHistoryDirection = direction;
|
||||||
|
lastMouseHistoryAt = now;
|
||||||
|
debug.log('[App] mouse history event', {
|
||||||
|
type: event.type,
|
||||||
|
direction,
|
||||||
|
button: event.button,
|
||||||
|
buttons: event.buttons,
|
||||||
|
which: event.which,
|
||||||
|
pointerType: event.pointerType || '',
|
||||||
|
currentView,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const moved = direction === 'back' ? navigateBack() : navigateForward();
|
||||||
|
if (moved && event) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkVault() {
|
||||||
|
debug.log('[App] checkVault: START');
|
||||||
|
flog('checkVault: START');
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
debug.log('[App] checkVault: calling GetAppSettings...');
|
||||||
|
const settings = await App.GetAppSettings();
|
||||||
|
debug.log('[App] checkVault: GetAppSettings returned', settings);
|
||||||
|
flog('checkVault: GetAppSettings returned');
|
||||||
|
if (settings?.debug) debug.enable({ persist: false });
|
||||||
|
|
||||||
|
debug.log('[App] checkVault: calling GetVaultStatus...');
|
||||||
|
vaultStatus = await App.GetVaultStatus() || { status: 'unknown', path: '', vaultId: '' };
|
||||||
|
debug.log('[App] checkVault: GetVaultStatus returned', vaultStatus);
|
||||||
|
flog('checkVault: vaultStatus=' + vaultStatus.status);
|
||||||
|
|
||||||
|
if (!settings.currentVaultPath || vaultStatus.status !== 'open') {
|
||||||
|
debug.log('[App] checkVault: vault not open, needsVaultSelection=true');
|
||||||
|
flog('checkVault: needsVaultSelection=true');
|
||||||
|
needsVaultSelection = true;
|
||||||
|
} else {
|
||||||
|
debug.log('[App] checkVault: vault open, needsVaultSelection=false');
|
||||||
|
flog('checkVault: needsVaultSelection=false');
|
||||||
|
needsVaultSelection = false;
|
||||||
|
await openDefaultWorkspaceRoute();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debug.log('[App] checkVault: ERROR', String(e));
|
||||||
|
flog('checkVault: ERROR: ' + String(e));
|
||||||
|
console.error('[App] startup check failed:', e);
|
||||||
|
needsVaultSelection = true;
|
||||||
|
}
|
||||||
|
loading = false;
|
||||||
|
await tick();
|
||||||
|
debug.log('[App] checkVault: END, loading=false');
|
||||||
|
flog('checkVault: END, loading=false');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onVaultOpened() {
|
||||||
|
debug.log('[App] onVaultOpened');
|
||||||
|
needsVaultSelection = false;
|
||||||
|
vaultStatus = { status: 'open', path: '', vaultId: '' };
|
||||||
|
await openDefaultWorkspaceRoute();
|
||||||
|
pushNavigation();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onNav(e) {
|
||||||
|
debug.log('[App] onNav:', e.detail.viewId);
|
||||||
|
currentView = e.detail.viewId;
|
||||||
|
if (currentView !== 'workspace') clearWorkspaceSelection();
|
||||||
|
pushNavigation();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onOpenView(e) {
|
||||||
|
debug.log('[App] onOpenView:', e.detail.viewId, 'plugin:', e.detail.pluginId);
|
||||||
|
activeView = e.detail.viewId;
|
||||||
|
activeViewPluginId = e.detail.pluginId || '';
|
||||||
|
currentView = 'plugin-view';
|
||||||
|
clearWorkspaceSelection();
|
||||||
|
pushNavigation();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onOpenSettings(e) {
|
||||||
|
debug.log('[App] onOpenSettings:', e.detail.pluginId, e.detail.panelId);
|
||||||
|
activeSettingsPluginId = e.detail.pluginId;
|
||||||
|
activeSettingsPanelId = e.detail.panelId || '';
|
||||||
|
currentView = 'plugin-manager';
|
||||||
|
clearWorkspaceSelection();
|
||||||
|
pushNavigation();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWorkbenchOpened(e) {
|
||||||
|
debug.log('[App] onWorkbenchOpened:', e.detail?.request?.path, e.detail?.providerId);
|
||||||
|
if (currentView === 'workspace') pushNavigation();
|
||||||
|
openedResource = e.detail;
|
||||||
|
currentView = 'workbench';
|
||||||
|
pushNavigation();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWorkspaceToolSelected(e) {
|
||||||
|
activeWorkspaceToolKey = e.detail?.toolKey || '';
|
||||||
|
if (currentView === 'workspace') pushNavigation();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWorkspaceSelected(e) {
|
||||||
|
debug.log('[App] onWorkspaceSelected:', e.detail?.workspaceName);
|
||||||
|
selectedWorkspaceName = e.detail?.workspaceName || '';
|
||||||
|
workspaceNodes = e.detail?.nodes || workspaceNodes;
|
||||||
|
if (selectedWorkspaceName) {
|
||||||
|
activeView = null;
|
||||||
|
activeViewPluginId = '';
|
||||||
|
activeSettingsPluginId = '';
|
||||||
|
activeSettingsPanelId = '';
|
||||||
|
openedResource = null;
|
||||||
|
currentView = 'workspace';
|
||||||
|
emitWorkspaceActive(selectedWorkspaceName);
|
||||||
|
pushNavigation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCloseSettings() {
|
||||||
|
debug.log('[App] onCloseSettings');
|
||||||
|
activeSettingsPluginId = '';
|
||||||
|
activeSettingsPanelId = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function onNavigateBack(e) {
|
||||||
|
if (currentView === 'workbench') return;
|
||||||
|
if (currentView === 'workspace') {
|
||||||
|
const upBtn = document.querySelector('[data-files-action="up"]');
|
||||||
|
if (upBtn && !upBtn.disabled) {
|
||||||
|
upBtn.click();
|
||||||
|
e?.preventDefault?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (navigateBack()) e?.preventDefault?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onNavigateForward(e) {
|
||||||
|
if (currentView === 'workbench') return;
|
||||||
|
if (currentView === 'workspace') {
|
||||||
|
const fwdBtn = document.querySelector('[data-files-action="forward"]');
|
||||||
|
if (fwdBtn && !fwdBtn.disabled) {
|
||||||
|
fwdBtn.click();
|
||||||
|
e?.preventDefault?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (navigateForward()) e?.preventDefault?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCloseWorkbench(e) {
|
||||||
|
if (currentView !== 'workbench') return;
|
||||||
|
if (!navigateBack() && selectedWorkspaceName) {
|
||||||
|
currentView = 'workspace';
|
||||||
|
pushNavigation();
|
||||||
|
}
|
||||||
|
e?.preventDefault?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onGlobalKeydown(e) {
|
||||||
|
handleHistoryRequest(keyHistoryDirection(e), e);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onGlobalMouse(e) {
|
||||||
|
handleHistoryRequest(mouseHistoryDirection(e), e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for events
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.addEventListener('verstak:vault-opened', onVaultOpened);
|
||||||
|
window.addEventListener('verstak:nav', onNav);
|
||||||
|
window.addEventListener('verstak:open-view', onOpenView);
|
||||||
|
window.addEventListener('verstak:open-settings', onOpenSettings);
|
||||||
|
window.addEventListener('verstak:close-settings', onCloseSettings);
|
||||||
|
window.addEventListener('verstak:workbench-opened', onWorkbenchOpened);
|
||||||
|
window.addEventListener('verstak:workspace-selected', onWorkspaceSelected);
|
||||||
|
window.addEventListener('verstak:workspace-tool-selected', onWorkspaceToolSelected);
|
||||||
|
window.addEventListener('verstak:navigate-back', onNavigateBack);
|
||||||
|
window.addEventListener('verstak:navigate-forward', onNavigateForward);
|
||||||
|
window.addEventListener('verstak:close-workbench', onCloseWorkbench);
|
||||||
|
window.addEventListener('keydown', onGlobalKeydown);
|
||||||
|
window.addEventListener('pointerdown', onGlobalMouse, true);
|
||||||
|
window.addEventListener('mousedown', onGlobalMouse, true);
|
||||||
|
window.addEventListener('mouseup', onGlobalMouse, true);
|
||||||
|
window.addEventListener('auxclick', onGlobalMouse, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await checkVault();
|
||||||
|
pushNavigation();
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(unsubscribeLocale);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<main>
|
{#if loading}
|
||||||
<Sidebar />
|
<div class="app-loading">
|
||||||
|
<p>{tr('app.loading')}</p>
|
||||||
|
</div>
|
||||||
|
{:else if needsVaultSelection}
|
||||||
|
<VaultSelection />
|
||||||
|
{:else}
|
||||||
|
<main>
|
||||||
|
<Sidebar
|
||||||
|
showGlobalSearch={currentView !== 'workspace' && currentView !== 'workspace-empty'}
|
||||||
|
{activeView}
|
||||||
|
{activeViewPluginId}
|
||||||
|
/>
|
||||||
|
<CommandPalette />
|
||||||
|
|
||||||
<section class="content">
|
<section class="content-shell">
|
||||||
{#if currentView === 'plugin-manager'}
|
<section class="content scroll-surface">
|
||||||
<PluginManager />
|
{#if currentView === 'plugin-manager'}
|
||||||
{:else}
|
<PluginManager {activeSettingsPluginId} {activeSettingsPanelId} />
|
||||||
<ViewContainer />
|
{:else if currentView === 'workbench'}
|
||||||
{/if}
|
<WorkbenchHost {openedResource} />
|
||||||
</section>
|
{:else if currentView === 'workspace' || currentView === 'workspace-empty'}
|
||||||
</main>
|
<WorkspaceHost
|
||||||
|
selectedWorkspaceName={selectedWorkspaceName}
|
||||||
|
nodes={workspaceNodes}
|
||||||
|
bind:activeToolKey={activeWorkspaceToolKey}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<ViewContainer {activeView} {activeViewPluginId} />
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
<StatusBar />
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
:global(*) {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(:root) {
|
||||||
|
--vt-color-background: #101020;
|
||||||
|
--vt-color-surface: #15152c;
|
||||||
|
--vt-color-surface-muted: #111629;
|
||||||
|
--vt-color-surface-hover: #1b2440;
|
||||||
|
--vt-color-surface-selected: rgba(78, 204, 163, 0.14);
|
||||||
|
--vt-color-border: #202b46;
|
||||||
|
--vt-color-border-strong: #2c456a;
|
||||||
|
--vt-color-text-primary: #f4f7fb;
|
||||||
|
--vt-color-text-secondary: #b7c0d4;
|
||||||
|
--vt-color-text-muted: #7f8aa3;
|
||||||
|
--vt-color-accent: #4ecca3;
|
||||||
|
--vt-color-accent-muted: rgba(78, 204, 163, 0.14);
|
||||||
|
--vt-color-danger: #e94560;
|
||||||
|
--vt-color-danger-muted: rgba(233, 69, 96, 0.14);
|
||||||
|
--vt-color-warning: #ffc857;
|
||||||
|
--vt-color-warning-muted: rgba(255, 200, 87, 0.14);
|
||||||
|
--vt-color-success: #4ecca3;
|
||||||
|
--vt-space-1: 0.25rem;
|
||||||
|
--vt-space-2: 0.5rem;
|
||||||
|
--vt-space-3: 0.75rem;
|
||||||
|
--vt-space-4: 1rem;
|
||||||
|
--vt-space-6: 1.5rem;
|
||||||
|
--vt-space-8: 2rem;
|
||||||
|
--vt-radius-sm: 4px;
|
||||||
|
--vt-radius-md: 6px;
|
||||||
|
--vt-radius-lg: 8px;
|
||||||
|
--vt-font-xs: 0.72rem;
|
||||||
|
--vt-font-sm: 0.8rem;
|
||||||
|
--vt-font-md: 0.88rem;
|
||||||
|
--vt-font-lg: 1rem;
|
||||||
|
--vt-focus-ring: 0 0 0 2px rgba(78, 204, 163, 0.34);
|
||||||
|
--vt-elevation-menu: 0 14px 32px rgba(0, 0, 0, 0.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html),
|
||||||
|
:global(body),
|
||||||
|
:global(#app) {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(body) {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: var(--vt-color-background);
|
||||||
|
color: var(--vt-color-text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(button) {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
min-height: 2rem;
|
||||||
|
padding: 0.4rem 0.85rem;
|
||||||
|
border: 1px solid var(--vt-color-border-strong);
|
||||||
|
border-radius: var(--vt-radius-md);
|
||||||
|
background: var(--vt-color-surface-hover);
|
||||||
|
color: var(--vt-color-text-primary);
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--vt-font-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease, opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(button:hover:not(:disabled)) {
|
||||||
|
background: #203050;
|
||||||
|
border-color: var(--vt-color-accent);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(button:focus-visible) {
|
||||||
|
outline: 0;
|
||||||
|
box-shadow: var(--vt-focus-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(button:disabled) {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.btn-primary) {
|
||||||
|
background: var(--vt-color-accent);
|
||||||
|
border-color: var(--vt-color-accent);
|
||||||
|
color: #101827;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.btn-primary:hover:not(:disabled)) {
|
||||||
|
background: #63d9b3;
|
||||||
|
border-color: #63d9b3;
|
||||||
|
color: #101827;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.btn-secondary) {
|
||||||
|
background: var(--vt-color-surface-hover);
|
||||||
|
border-color: var(--vt-color-border-strong);
|
||||||
|
color: var(--vt-color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.btn-danger) {
|
||||||
|
background: var(--vt-color-danger);
|
||||||
|
border-color: var(--vt-color-danger);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.btn-danger:hover:not(:disabled)) {
|
||||||
|
background: #ff5b73;
|
||||||
|
border-color: #ff5b73;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.btn-ghost) {
|
||||||
|
background: transparent;
|
||||||
|
border-color: transparent;
|
||||||
|
color: var(--vt-color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.btn-ghost:hover:not(:disabled)) {
|
||||||
|
background: var(--vt-color-surface-hover);
|
||||||
|
border-color: var(--vt-color-border);
|
||||||
|
color: var(--vt-color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.btn-icon) {
|
||||||
|
width: 2rem;
|
||||||
|
min-width: 2rem;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.vt-page) {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--vt-color-background);
|
||||||
|
color: var(--vt-color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.vt-page-header),
|
||||||
|
:global(.vt-toolbar) {
|
||||||
|
min-height: 2.75rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--vt-space-2);
|
||||||
|
padding: var(--vt-space-2) var(--vt-space-3);
|
||||||
|
border-bottom: 1px solid var(--vt-color-border);
|
||||||
|
background: var(--vt-color-surface-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.vt-page-title) {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--vt-color-text-primary);
|
||||||
|
font-size: var(--vt-font-lg);
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.vt-page-subtitle) {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--vt-color-text-muted);
|
||||||
|
font-size: var(--vt-font-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.vt-toolbar-group) {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--vt-space-1);
|
||||||
|
padding: 0 var(--vt-space-2) 0 0;
|
||||||
|
border-right: 1px solid var(--vt-color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.vt-toolbar-group:last-child) {
|
||||||
|
border-right: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.vt-button) {
|
||||||
|
min-height: 2rem;
|
||||||
|
border-radius: var(--vt-radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.vt-icon-button) {
|
||||||
|
width: 2rem;
|
||||||
|
min-width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
min-height: 2rem;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.vt-tabbar) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--vt-space-1);
|
||||||
|
padding: var(--vt-space-1) var(--vt-space-3) 0;
|
||||||
|
border-bottom: 1px solid var(--vt-color-border);
|
||||||
|
background: #12162a;
|
||||||
|
overflow-x: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.vt-tab) {
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-height: 2.1rem;
|
||||||
|
padding: 0.38rem 0.78rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-bottom: 0;
|
||||||
|
border-radius: var(--vt-radius-md) var(--vt-radius-md) 0 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--vt-color-text-muted);
|
||||||
|
font-size: var(--vt-font-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.vt-tab:hover:not(:disabled)) {
|
||||||
|
background: var(--vt-color-surface-hover);
|
||||||
|
border-color: transparent;
|
||||||
|
color: var(--vt-color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.vt-tab.is-active),
|
||||||
|
:global(.vt-tab.active) {
|
||||||
|
background: var(--vt-color-background);
|
||||||
|
border-color: var(--vt-color-border);
|
||||||
|
color: var(--vt-color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.vt-list-row) {
|
||||||
|
border-bottom: 1px solid rgba(32, 43, 70, 0.72);
|
||||||
|
color: var(--vt-color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.vt-list-row:hover) {
|
||||||
|
background: var(--vt-color-surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.vt-list-row.selected),
|
||||||
|
:global(.vt-list-row.active),
|
||||||
|
:global(.vt-list-row.is-selected) {
|
||||||
|
background: var(--vt-color-surface-selected);
|
||||||
|
box-shadow: inset 2px 0 0 var(--vt-color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.vt-card) {
|
||||||
|
border: 1px solid var(--vt-color-border);
|
||||||
|
border-radius: var(--vt-radius-lg);
|
||||||
|
background: var(--vt-color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.vt-split-pane) {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(16rem, 22rem) minmax(0, 1fr);
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--vt-color-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.vt-split-list) {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
border-right: 1px solid var(--vt-color-border);
|
||||||
|
background: var(--vt-color-surface-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.vt-split-detail) {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: var(--vt-space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.vt-empty-state) {
|
||||||
|
min-height: 9rem;
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--vt-space-2);
|
||||||
|
padding: var(--vt-space-8);
|
||||||
|
color: var(--vt-color-text-muted);
|
||||||
|
font-size: var(--vt-font-sm);
|
||||||
|
line-height: 1.45;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.vt-empty-title) {
|
||||||
|
color: var(--vt-color-text-secondary);
|
||||||
|
font-size: var(--vt-font-md);
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.vt-inline-alert) {
|
||||||
|
border: 1px solid var(--vt-color-border-strong);
|
||||||
|
border-radius: var(--vt-radius-md);
|
||||||
|
background: var(--vt-color-surface-muted);
|
||||||
|
color: var(--vt-color-text-secondary);
|
||||||
|
padding: var(--vt-space-3);
|
||||||
|
font-size: var(--vt-font-sm);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.vt-inline-alert.error) {
|
||||||
|
border-color: rgba(233, 69, 96, 0.55);
|
||||||
|
background: var(--vt-color-danger-muted);
|
||||||
|
color: #ffc6ce;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.vt-badge) {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 1.25rem;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border: 1px solid var(--vt-color-border);
|
||||||
|
border-radius: var(--vt-radius-sm);
|
||||||
|
background: var(--vt-color-surface-muted);
|
||||||
|
color: var(--vt-color-text-muted);
|
||||||
|
font-size: var(--vt-font-xs);
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.vt-badge.accent) {
|
||||||
|
border-color: rgba(78, 204, 163, 0.4);
|
||||||
|
background: var(--vt-color-accent-muted);
|
||||||
|
color: var(--vt-color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.vt-menu) {
|
||||||
|
border: 1px solid var(--vt-color-border-strong);
|
||||||
|
border-radius: var(--vt-radius-md);
|
||||||
|
background: var(--vt-color-surface);
|
||||||
|
color: var(--vt-color-text-primary);
|
||||||
|
box-shadow: var(--vt-elevation-menu);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.vt-menu-item) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--vt-space-2);
|
||||||
|
padding: 0.42rem 0.7rem;
|
||||||
|
color: var(--vt-color-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.vt-menu-item:hover) {
|
||||||
|
background: var(--vt-color-surface-hover);
|
||||||
|
color: var(--vt-color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.vt-menu-item.danger) {
|
||||||
|
color: #ff9aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.scroll-surface) {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(*) {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--vt-color-border-strong) var(--vt-color-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(*::-webkit-scrollbar) {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(*::-webkit-scrollbar-track) {
|
||||||
|
background: var(--vt-color-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(*::-webkit-scrollbar-thumb) {
|
||||||
|
background: var(--vt-color-border-strong);
|
||||||
|
border-radius: var(--vt-radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(*::-webkit-scrollbar-thumb:hover) {
|
||||||
|
background: #1a4a7a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100vh;
|
||||||
|
background: var(--vt-color-background);
|
||||||
|
color: var(--vt-color-text-muted);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
main {
|
main {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
background: #1a1a2e;
|
width: 100%;
|
||||||
}
|
background: var(--vt-color-background);
|
||||||
.content {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.content-shell {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
main {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,307 @@
|
|||||||
|
export default {
|
||||||
|
'app.loading': 'Loading Verstak...',
|
||||||
|
'settings.title': 'Settings',
|
||||||
|
'settings.language': 'Language',
|
||||||
|
'settings.language.system': 'System / Системный',
|
||||||
|
'settings.language.en': 'English',
|
||||||
|
'settings.language.ru': 'Русский',
|
||||||
|
'settings.pluginManager': 'Plugin Manager',
|
||||||
|
'statusBar.label': 'Status bar',
|
||||||
|
'vault.label': 'Vault: {status}',
|
||||||
|
'vault.status.open': 'open',
|
||||||
|
'vault.status.closed': 'closed',
|
||||||
|
'vault.status.unknown': 'unknown',
|
||||||
|
'sidebar.tools': 'Tools',
|
||||||
|
'sidebar.error.contributions': 'Failed to load plugin contributions',
|
||||||
|
'sidebar.error.load': 'Failed to load sidebar',
|
||||||
|
'sidebar.error.ui': 'Plugin UI error',
|
||||||
|
'workspace.overview': 'Overview',
|
||||||
|
'workspace.search': 'Search in this Deal',
|
||||||
|
'workspace.tools': 'Deal tools',
|
||||||
|
'workspace.tool': 'Deal tool',
|
||||||
|
'workspace.emptyTools': 'No Deal tools available',
|
||||||
|
'workspace.emptyToolsHint': 'Enable plugins with Deal tools or open Plugin Manager from settings.',
|
||||||
|
'workspace.select': 'Select a Deal',
|
||||||
|
'workspace.selectHint': 'Use the + button in Deals to add your first project.',
|
||||||
|
'common.details': 'Details',
|
||||||
|
'common.plugin': 'Plugin',
|
||||||
|
'common.component': 'Component',
|
||||||
|
'pluginView.error': 'Plugin view error',
|
||||||
|
'pluginView.failed': 'Plugin UI failed',
|
||||||
|
'pluginView.noVisual': 'This plugin does not provide a visual view yet.',
|
||||||
|
'pluginView.viewId': 'View ID',
|
||||||
|
'pluginView.bundleUnavailable': 'Frontend bundle unavailable',
|
||||||
|
'pluginView.unavailable': 'View is unavailable.',
|
||||||
|
'pluginView.select': 'Select a plugin view from the sidebar',
|
||||||
|
'pluginView.selectHint': 'Plugin views will appear here',
|
||||||
|
'common.retry': '⟳ Retry',
|
||||||
|
'common.source': 'Source',
|
||||||
|
'common.status': 'Status',
|
||||||
|
'common.capability': 'Capability',
|
||||||
|
'common.provider': 'Provider',
|
||||||
|
'status.loaded': 'loaded',
|
||||||
|
'status.degraded': 'degraded',
|
||||||
|
'status.failed': 'failed',
|
||||||
|
'status.disabled': 'disabled',
|
||||||
|
'status.missing': 'missing',
|
||||||
|
'pluginManager.reloading': '⟳ Reloading...',
|
||||||
|
'pluginManager.reload': '⟳ Reload',
|
||||||
|
'pluginManager.scanning': 'Scanning plugin directories...',
|
||||||
|
'pluginManager.summary.plugins': '{count} plugin(s) discovered',
|
||||||
|
'pluginManager.summary.capabilities': '{count} capabilities registered',
|
||||||
|
'pluginManager.summary.permissions': '{count} permissions known',
|
||||||
|
'pluginManager.scanSummary': 'Plugin scan summary',
|
||||||
|
'pluginManager.health': 'Plugin Health',
|
||||||
|
'pluginManager.permissionRisk': 'Permission Risk',
|
||||||
|
'pluginManager.elevatedPermissions': '{count} plugins request elevated permissions',
|
||||||
|
'pluginManager.filters': 'Filters',
|
||||||
|
'pluginManager.filterResults': 'Showing {visible} of {total} plugins',
|
||||||
|
'pluginManager.filterReset': 'Reset filters',
|
||||||
|
'pluginManager.filterState': 'State',
|
||||||
|
'pluginManager.filterAll': 'All',
|
||||||
|
'pluginManager.filterEnabled': 'Enabled',
|
||||||
|
'pluginManager.filterDisabled': 'Disabled',
|
||||||
|
'pluginManager.filterFailed': 'Problem',
|
||||||
|
'pluginManager.filterDegraded': 'Degraded',
|
||||||
|
'pluginManager.filterPermissions': 'Permissions',
|
||||||
|
'pluginManager.filterCapabilities': 'Capabilities',
|
||||||
|
'pluginManager.filterSettings': 'Settings',
|
||||||
|
'pluginManager.filterWithSettings': 'With settings',
|
||||||
|
'pluginManager.filterWithoutSettings': 'Without settings',
|
||||||
|
'pluginManager.filterSource': 'Source',
|
||||||
|
'pluginManager.filterOfficial': 'Official',
|
||||||
|
'pluginManager.filterThirdParty': 'Third-party or local',
|
||||||
|
'pluginManager.filterEmpty': 'No plugins match the selected filters.',
|
||||||
|
'pluginManager.none': 'No plugins found',
|
||||||
|
'pluginManager.scannedDirs': 'Plugin directories scanned:',
|
||||||
|
'pluginManager.userPlugins': 'user plugins',
|
||||||
|
'pluginManager.bundledPlugins': 'bundled plugins (app directory)',
|
||||||
|
'pluginManager.installHint': 'Place a plugin folder with plugin.json in one of these directories and click Reload.',
|
||||||
|
'pluginManager.missingTitle': 'Missing Installed Plugins',
|
||||||
|
'pluginManager.missingHint': 'These plugins are required by this vault but their packages are not installed locally.',
|
||||||
|
'pluginManager.missingPackage': "This plugin is listed in the vault's desired plugins but the package is not installed.",
|
||||||
|
'pluginManager.capabilityRegistry': 'Capability Registry ({count})',
|
||||||
|
'pluginManager.settingsError': 'Settings Error',
|
||||||
|
'pluginManager.pluginSettings': 'Plugin Settings',
|
||||||
|
'pluginManager.settingsBundleUnavailable': 'Settings panel frontend bundle not available',
|
||||||
|
'common.none': 'none',
|
||||||
|
'common.unknown': 'unknown',
|
||||||
|
'pluginCard.count.views': '{count} view(s)',
|
||||||
|
'pluginCard.count.commands': '{count} command(s)',
|
||||||
|
'pluginCard.count.searchProviders': '{count} search provider(s)',
|
||||||
|
'pluginCard.count.sidebar': '{count} sidebar item(s)',
|
||||||
|
'pluginCard.count.statusbar': '{count} status bar item(s)',
|
||||||
|
'pluginCard.count.openProviders': '{count} openProviders',
|
||||||
|
'pluginCard.count.workspace': '{count} Deal tool(s)',
|
||||||
|
'pluginCard.degraded': 'Plugin is usable, but some optional capabilities are unavailable.',
|
||||||
|
'pluginCard.name': 'Name',
|
||||||
|
'pluginCard.contributions': 'Contributions',
|
||||||
|
'pluginCard.technicalDetails': 'Technical details',
|
||||||
|
'pluginCard.apiVersion': 'API Version',
|
||||||
|
'pluginCard.root': 'Root',
|
||||||
|
'pluginCard.provides': 'Provides',
|
||||||
|
'pluginCard.requires': 'Requires',
|
||||||
|
'pluginCard.missingRequired': 'Missing required capabilities',
|
||||||
|
'pluginCard.optionalRequires': 'Optional Requires',
|
||||||
|
'pluginCard.optionalUnavailable': 'Optional capabilities not available — plugin running in degraded mode',
|
||||||
|
'pluginCard.permissions': 'Permissions',
|
||||||
|
'pluginCard.enabling': '⟳ Enabling...',
|
||||||
|
'pluginCard.enable': '▶ Enable',
|
||||||
|
'pluginCard.disabling': '⟳ Disabling...',
|
||||||
|
'pluginCard.disable': '⏸ Disable',
|
||||||
|
'pluginCard.openVault': 'Open a vault to manage plugin state',
|
||||||
|
'pluginCard.missingUiPermission': 'Plugin has UI contributions but lacks ui.register permission',
|
||||||
|
'common.loading': 'Loading...',
|
||||||
|
'common.browse': 'Browse...',
|
||||||
|
'vaultSelection.chooseNew': 'Choose or enter a folder for the new vault.',
|
||||||
|
'vaultSelection.createError': 'Could not create the vault. Please try again.',
|
||||||
|
'vaultSelection.openError': 'Could not open the vault. Please try again.',
|
||||||
|
'vaultSelection.chooseExisting': 'Choose or enter an existing vault.',
|
||||||
|
'vaultSelection.subtitle': 'Choose a vault to start working',
|
||||||
|
'vaultSelection.createTitle': 'Create a new vault',
|
||||||
|
'vaultSelection.createHint': 'Create a local vault folder for Deals and projects.',
|
||||||
|
'vaultSelection.pathPlaceholder': 'Choose or enter a path...',
|
||||||
|
'vaultSelection.creating': 'Creating...',
|
||||||
|
'vaultSelection.create': 'Create vault',
|
||||||
|
'vaultSelection.openTitle': 'Open an existing vault',
|
||||||
|
'vaultSelection.openHint': 'Use a vault that is already on this computer.',
|
||||||
|
'vaultSelection.opening': 'Opening...',
|
||||||
|
'vaultSelection.open': 'Open existing',
|
||||||
|
'vaultSelection.recent': 'Recent vaults',
|
||||||
|
'common.cancel': 'Cancel',
|
||||||
|
'common.close': 'Close',
|
||||||
|
'workspaceTree.title': 'Deals',
|
||||||
|
'workspaceTree.new': 'New Deal',
|
||||||
|
'workspaceTree.nameRequired': 'Name is required',
|
||||||
|
'workspaceTree.chooseTemplate': 'Choose a Deal template',
|
||||||
|
'workspaceTree.saveRename': 'Save rename',
|
||||||
|
'workspaceTree.rename': 'Rename Deal',
|
||||||
|
'workspaceTree.trash': 'Move Deal to trash',
|
||||||
|
'workspaceTree.create': 'Create Deal',
|
||||||
|
'workspaceTree.namePlaceholder': 'Deal name',
|
||||||
|
'workspaceTree.template': 'Template',
|
||||||
|
'workspaceTree.creating': 'Creating...',
|
||||||
|
'workspaceTree.templateAvailable': 'Available',
|
||||||
|
'workspaceTree.templateLimited': 'Available with limitations',
|
||||||
|
'workspaceTree.templateMissingPlugin': 'Plugin is not installed',
|
||||||
|
'workspaceTree.templatePluginDisabled': 'Plugin is disabled',
|
||||||
|
'workspaceTree.templateCapabilityUnavailable': 'A required capability is unavailable',
|
||||||
|
'workspaceTree.templateIncompatible': 'Plugin is incompatible with this version of Verstak',
|
||||||
|
'workspaceTree.templateLoadFailed': 'Plugin failed to load',
|
||||||
|
'workspaceTree.templateNotReady': 'Plugin is not ready',
|
||||||
|
'workspaceTree.templateToolTabs': 'Creates: {tabs}',
|
||||||
|
'workspaceTree.templateToolNoTabs': 'No Deal tab provided',
|
||||||
|
'workspaceTree.templateWillBeIncomplete': 'This Deal will be created without the unavailable components.',
|
||||||
|
'workspaceTree.templateIncompleteCreated': '«{name}» was created with unavailable components.',
|
||||||
|
'workspaceTree.templatesError': 'Could not load Deal templates. Please try again.',
|
||||||
|
'workspaceTree.loadError': 'Could not load Deals. Please try again.',
|
||||||
|
'workspaceTree.selectError': 'Could not select this Deal. Please try again.',
|
||||||
|
'workspaceTree.createError': 'Could not create the Deal. Please try again.',
|
||||||
|
'workspaceTree.renameError': 'Could not rename the Deal. Please try again.',
|
||||||
|
'workspaceTree.trashError': 'Could not move the Deal to trash. Please try again.',
|
||||||
|
'pluginManager.loadError': 'Could not load plugins. Please try again.',
|
||||||
|
'pluginManager.reloadError': 'Could not reload plugins. Please try again.',
|
||||||
|
'pluginManager.enableError': 'Could not enable the plugin. Please try again.',
|
||||||
|
'pluginManager.disableError': 'Could not disable the plugin. Please try again.',
|
||||||
|
'pluginManager.settingsLoadError': 'Could not load plugin settings. Please try again.',
|
||||||
|
'pluginManager.settingsUnavailable': 'Plugin settings are unavailable.',
|
||||||
|
'command.openOverview': 'Open Overview',
|
||||||
|
'command.openFiles': 'Open Files',
|
||||||
|
'command.openActivity': 'Open Activity',
|
||||||
|
'command.openBrowserInbox': 'Open Browser',
|
||||||
|
'command.createMarkdown': 'Create Markdown File',
|
||||||
|
'command.createText': 'Create Text File',
|
||||||
|
'command.syncNow': 'Sync Now',
|
||||||
|
'command.openSyncSettings': 'Open Sync Settings',
|
||||||
|
'command.openPluginManager': 'Open Plugin Manager',
|
||||||
|
'command.handled': '{title} handled',
|
||||||
|
'command.result': '{title} {status}',
|
||||||
|
'command.failed': 'Could not run {title}. Please try again.',
|
||||||
|
'command.statusHandled': 'handled',
|
||||||
|
'command.palette': 'Command Palette',
|
||||||
|
'command.run': 'Run command',
|
||||||
|
'command.none': 'No commands',
|
||||||
|
'search.indexing': 'Indexing...',
|
||||||
|
'search.placeholder': 'Search',
|
||||||
|
'search.global': 'Global search',
|
||||||
|
'search.noResults': 'No results',
|
||||||
|
'search.type.workspace': 'Deal',
|
||||||
|
'search.type.tool': 'Tool',
|
||||||
|
'search.type.folder': 'Folder',
|
||||||
|
'search.type.file': 'File',
|
||||||
|
'search.type.journal': 'Journal',
|
||||||
|
'search.type.browserInbox': 'Browser',
|
||||||
|
'search.type.activity': 'Activity',
|
||||||
|
'workbench.noProvider': 'No provider',
|
||||||
|
'workbench.noViewer': 'No viewer/editor available',
|
||||||
|
'workbench.noResource': 'No resource opened',
|
||||||
|
'bundle.noFrontend': 'Plugin has no frontend bundle',
|
||||||
|
'bundle.notFound': 'Plugin not found',
|
||||||
|
'bundle.loadFailed': 'Could not load the plugin interface. Please try again.',
|
||||||
|
'bundle.emptyContent': 'empty content',
|
||||||
|
'bundle.executionError': 'Could not start the plugin interface. Please try again.',
|
||||||
|
'bundle.registrationMissing': 'Bundle loaded but no VerstakPluginRegister call detected',
|
||||||
|
'bundle.componentMissing': 'The requested plugin interface is unavailable.',
|
||||||
|
'bundle.mountError': 'Could not open the plugin interface. Please try again.',
|
||||||
|
'bundle.mountUnavailable': 'Mount container not available',
|
||||||
|
'bundle.unexpectedError': 'Could not open the plugin interface. Please try again.',
|
||||||
|
'bundle.unknownError': 'Unknown error',
|
||||||
|
'bundle.frontendEntry': 'Frontend entry',
|
||||||
|
'bundle.availableComponents': 'Available components',
|
||||||
|
'bundle.loading': 'Loading plugin bundle...',
|
||||||
|
'overview.filter.all': 'All',
|
||||||
|
'overview.filter.notes': 'Notes',
|
||||||
|
'overview.filter.files': 'Files',
|
||||||
|
'overview.filter.captures': 'Captures',
|
||||||
|
'overview.filter.journal': 'Journal',
|
||||||
|
'overview.notes': 'Notes',
|
||||||
|
'overview.files': 'Files',
|
||||||
|
'overview.captures': 'Captures',
|
||||||
|
'overview.activity': 'Activity',
|
||||||
|
'overview.journal': 'Journal',
|
||||||
|
'overview.attention': 'Needs attention',
|
||||||
|
'overview.openNotes': 'Open Notes',
|
||||||
|
'overview.openFiles': 'Open Files',
|
||||||
|
'overview.reviewInbox': 'Review Inbox',
|
||||||
|
'overview.viewActivity': 'View Activity',
|
||||||
|
'overview.openJournal': 'Open Journal',
|
||||||
|
'overview.openTodos': 'Open Todos',
|
||||||
|
'overview.reviewCandidate': 'Review candidate',
|
||||||
|
'overview.reviewPending': 'Review pending items',
|
||||||
|
'overview.count.totalRecent.one': '{total} total · {recent} recent change',
|
||||||
|
'overview.count.totalRecent.many': '{total} total · {recent} recent changes',
|
||||||
|
'overview.count.recentChanges.one': '{count} recent change',
|
||||||
|
'overview.count.recentChanges.many': '{count} recent changes',
|
||||||
|
'overview.count.captures.one': '{count} capture to review',
|
||||||
|
'overview.count.captures.many': '{count} captures to review',
|
||||||
|
'overview.count.events.one': '{count} recorded event',
|
||||||
|
'overview.count.events.many': '{count} recorded events',
|
||||||
|
'overview.count.journal.one': '{count} journal entry',
|
||||||
|
'overview.count.journal.many': '{count} journal entries',
|
||||||
|
'overview.count.pending.one': '{count} pending item',
|
||||||
|
'overview.count.pending.many': '{count} pending items',
|
||||||
|
'overview.loadingContext': 'Loading Deal context...',
|
||||||
|
'overview.lastActive': 'Last active {time}',
|
||||||
|
'overview.noRecentActivity': 'No recent Deal activity',
|
||||||
|
'overview.refresh': 'Refresh',
|
||||||
|
'overview.summary': 'Deal overview summary',
|
||||||
|
'overview.continue': 'Continue working',
|
||||||
|
'overview.continueHint': 'Pick up the next useful item in this Deal.',
|
||||||
|
'overview.loadingSignals': 'Loading Deal signals...',
|
||||||
|
'overview.noResume': 'No clear resume point yet',
|
||||||
|
'overview.noResumeHint': 'Recent notes, files, captures, and journal entries will appear here.',
|
||||||
|
'overview.recentChanges': 'Recent changes',
|
||||||
|
'overview.recentChangesHint': 'Latest meaningful activity in this Deal.',
|
||||||
|
'overview.event.workspaceOpened': 'Opened Deal',
|
||||||
|
'overview.event.activity': 'Deal activity',
|
||||||
|
'overview.event.noteEdited': 'Edited note "{name}"',
|
||||||
|
'overview.event.noteOpened': 'Opened note "{name}"',
|
||||||
|
'overview.event.noteCreated': 'Created note "{name}"',
|
||||||
|
'overview.event.fileOpened': 'Opened file "{name}"',
|
||||||
|
'overview.event.fileChanged': 'Changed file "{name}"',
|
||||||
|
'overview.event.fileCreated': 'Created file "{name}"',
|
||||||
|
'overview.event.fileRemoved': 'Removed file "{name}"',
|
||||||
|
'overview.event.capturePage': 'Captured page "{name}"',
|
||||||
|
'overview.event.captureSelection': 'Captured selection "{name}"',
|
||||||
|
'overview.event.captureLink': 'Captured link "{name}"',
|
||||||
|
'overview.event.captureFile': 'Captured file "{name}"',
|
||||||
|
'overview.event.captureConverted': 'Converted capture "{name}"',
|
||||||
|
'overview.event.capture': 'Captured {kind} "{title}"',
|
||||||
|
'overview.event.journalAdded': 'Added journal entry "{name}"',
|
||||||
|
'overview.event.workSessionDetected': 'Work session detected',
|
||||||
|
'overview.event.todo': 'Todo "{title}"',
|
||||||
|
'overview.event.reviewCapture': 'Review capture "{title}"',
|
||||||
|
'overview.event.continueJournal': 'Continue journal entry "{title}"',
|
||||||
|
'overview.event.possibleJournalEntry': 'Possible journal entry',
|
||||||
|
'overview.captureKind.page': 'page',
|
||||||
|
'overview.captureKind.selection': 'selection',
|
||||||
|
'overview.captureKind.link': 'link',
|
||||||
|
'overview.captureKind.file': 'file',
|
||||||
|
'overview.captureKind.item': 'item',
|
||||||
|
'overview.todo.reminderDue': 'Reminder due',
|
||||||
|
'overview.todo.overdue': 'Overdue',
|
||||||
|
'overview.todo.dueSoon': 'Due soon',
|
||||||
|
'overview.todo.due': 'Due {date}',
|
||||||
|
'overview.candidateMeta': 'Deal: {deal} · {minutes} min · {activities} activities',
|
||||||
|
'overview.minutes': '{count} min',
|
||||||
|
'overview.browserCapture': 'Browser capture',
|
||||||
|
'overview.untitledTodo': 'Untitled todo',
|
||||||
|
'overview.untitledCapture': 'Untitled capture',
|
||||||
|
'overview.untitledJournal': 'Journal entry',
|
||||||
|
'overview.unknownDeal': 'Unknown Deal',
|
||||||
|
'overview.item': 'item',
|
||||||
|
'overview.overviewNote': 'Deal overview note',
|
||||||
|
'overview.recentFilter': 'Recent changes filter',
|
||||||
|
'overview.loadingRecent': 'Loading recent changes...',
|
||||||
|
'overview.noChanges': 'No meaningful changes for this filter yet.',
|
||||||
|
'overview.attentionHint': 'Pending captures, urgent todos, and possible journal entries.',
|
||||||
|
'overview.loadingPending': 'Loading pending items...',
|
||||||
|
'overview.noPending': 'No pending captures, urgent todos, or possible journal entries.',
|
||||||
|
'overview.keyResources': 'Key resources',
|
||||||
|
'overview.time.none': 'No timestamp',
|
||||||
|
'overview.time.now': 'Just now',
|
||||||
|
'overview.time.minutes': '{count} min ago',
|
||||||
|
'overview.time.hours': '{count}h ago',
|
||||||
|
'overview.time.yesterday': 'Yesterday',
|
||||||
|
'overview.time.days': '{count} days ago',
|
||||||
|
};
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
export default {
|
||||||
|
'app.loading': 'Загрузка Верстака...',
|
||||||
|
'settings.title': 'Настройки',
|
||||||
|
'settings.language': 'Язык',
|
||||||
|
'settings.language.system': 'System / Системный',
|
||||||
|
'settings.language.en': 'English',
|
||||||
|
'settings.language.ru': 'Русский',
|
||||||
|
'settings.pluginManager': 'Менеджер плагинов',
|
||||||
|
'statusBar.label': 'Строка состояния',
|
||||||
|
'vault.label': 'Хранилище: {status}',
|
||||||
|
'vault.status.open': 'открыто',
|
||||||
|
'vault.status.closed': 'закрыто',
|
||||||
|
'vault.status.unknown': 'неизвестно',
|
||||||
|
'sidebar.tools': 'Инструменты',
|
||||||
|
'sidebar.error.contributions': 'Не удалось загрузить элементы плагинов',
|
||||||
|
'sidebar.error.load': 'Не удалось загрузить боковую панель',
|
||||||
|
'sidebar.error.ui': 'Ошибка интерфейса плагина',
|
||||||
|
'workspace.overview': 'Обзор',
|
||||||
|
'workspace.search': 'Поиск в Деле',
|
||||||
|
'workspace.tools': 'Инструменты Дела',
|
||||||
|
'workspace.tool': 'Инструмент Дела',
|
||||||
|
'workspace.emptyTools': 'Нет доступных инструментов',
|
||||||
|
'workspace.emptyToolsHint': 'Включите плагины с инструментами Дела или откройте Менеджер плагинов в настройках.',
|
||||||
|
'workspace.select': 'Выберите Дело',
|
||||||
|
'workspace.selectHint': 'Используйте кнопку + в разделе Дел, чтобы добавить первый проект.',
|
||||||
|
'common.details': 'Подробнее',
|
||||||
|
'common.plugin': 'Плагин',
|
||||||
|
'common.component': 'Компонент',
|
||||||
|
'pluginView.error': 'Ошибка представления плагина',
|
||||||
|
'pluginView.failed': 'Ошибка интерфейса плагина',
|
||||||
|
'pluginView.noVisual': 'Этот плагин пока не предоставляет визуальное представление.',
|
||||||
|
'pluginView.viewId': 'ID представления',
|
||||||
|
'pluginView.bundleUnavailable': 'Frontend bundle недоступен',
|
||||||
|
'pluginView.unavailable': 'Представление недоступно.',
|
||||||
|
'pluginView.select': 'Выберите представление плагина на боковой панели',
|
||||||
|
'pluginView.selectHint': 'Представления плагинов появятся здесь',
|
||||||
|
'common.retry': '⟳ Повторить',
|
||||||
|
'common.source': 'Источник',
|
||||||
|
'common.status': 'Состояние',
|
||||||
|
'common.capability': 'Возможность',
|
||||||
|
'common.provider': 'Поставщик',
|
||||||
|
'status.loaded': 'загружено',
|
||||||
|
'status.degraded': 'ограничено',
|
||||||
|
'status.failed': 'ошибка',
|
||||||
|
'status.disabled': 'выключено',
|
||||||
|
'status.missing': 'отсутствует',
|
||||||
|
'pluginManager.reloading': '⟳ Обновление...',
|
||||||
|
'pluginManager.reload': '⟳ Обновить',
|
||||||
|
'pluginManager.scanning': 'Сканирование каталогов плагинов...',
|
||||||
|
'pluginManager.summary.plugins': 'Обнаружено плагинов: {count}',
|
||||||
|
'pluginManager.summary.capabilities': 'Зарегистрировано возможностей: {count}',
|
||||||
|
'pluginManager.summary.permissions': 'Известно разрешений: {count}',
|
||||||
|
'pluginManager.scanSummary': 'Сводка проверки плагинов',
|
||||||
|
'pluginManager.health': 'Состояние плагинов',
|
||||||
|
'pluginManager.permissionRisk': 'Риск разрешений',
|
||||||
|
'pluginManager.elevatedPermissions': 'Плагинов с повышенными разрешениями: {count}',
|
||||||
|
'pluginManager.filters': 'Фильтры',
|
||||||
|
'pluginManager.filterResults': 'Показано плагинов: {visible} из {total}',
|
||||||
|
'pluginManager.filterReset': 'Сбросить фильтры',
|
||||||
|
'pluginManager.filterState': 'Состояние',
|
||||||
|
'pluginManager.filterAll': 'Все',
|
||||||
|
'pluginManager.filterEnabled': 'Включённые',
|
||||||
|
'pluginManager.filterDisabled': 'Выключенные',
|
||||||
|
'pluginManager.filterFailed': 'С проблемами',
|
||||||
|
'pluginManager.filterDegraded': 'Ограниченные',
|
||||||
|
'pluginManager.filterPermissions': 'Разрешения',
|
||||||
|
'pluginManager.filterCapabilities': 'Возможности',
|
||||||
|
'pluginManager.filterSettings': 'Настройки',
|
||||||
|
'pluginManager.filterWithSettings': 'С настройками',
|
||||||
|
'pluginManager.filterWithoutSettings': 'Без настроек',
|
||||||
|
'pluginManager.filterSource': 'Источник',
|
||||||
|
'pluginManager.filterOfficial': 'Официальные',
|
||||||
|
'pluginManager.filterThirdParty': 'Сторонние или локальные',
|
||||||
|
'pluginManager.filterEmpty': 'Нет плагинов, соответствующих выбранным фильтрам.',
|
||||||
|
'pluginManager.none': 'Плагины не найдены',
|
||||||
|
'pluginManager.scannedDirs': 'Проверенные каталоги плагинов:',
|
||||||
|
'pluginManager.userPlugins': 'пользовательские плагины',
|
||||||
|
'pluginManager.bundledPlugins': 'встроенные плагины (каталог приложения)',
|
||||||
|
'pluginManager.installHint': 'Поместите папку плагина с plugin.json в один из этих каталогов и нажмите «Обновить».',
|
||||||
|
'pluginManager.missingTitle': 'Отсутствующие установленные плагины',
|
||||||
|
'pluginManager.missingHint': 'Эти плагины требуются хранилищу, но их пакеты не установлены на этом устройстве.',
|
||||||
|
'pluginManager.missingPackage': 'Плагин указан среди желаемых плагинов хранилища, но его пакет не установлен.',
|
||||||
|
'pluginManager.capabilityRegistry': 'Реестр возможностей ({count})',
|
||||||
|
'pluginManager.settingsError': 'Ошибка настроек',
|
||||||
|
'pluginManager.pluginSettings': 'Настройки плагина',
|
||||||
|
'pluginManager.settingsBundleUnavailable': 'Frontend bundle панели настроек недоступен',
|
||||||
|
'common.none': 'нет',
|
||||||
|
'common.unknown': 'неизвестно',
|
||||||
|
'pluginCard.count.views': 'представлений: {count}',
|
||||||
|
'pluginCard.count.commands': 'команд: {count}',
|
||||||
|
'pluginCard.count.searchProviders': 'поставщиков поиска: {count}',
|
||||||
|
'pluginCard.count.sidebar': 'элементов боковой панели: {count}',
|
||||||
|
'pluginCard.count.statusbar': 'элементов строки состояния: {count}',
|
||||||
|
'pluginCard.count.openProviders': 'обработчиков открытия: {count}',
|
||||||
|
'pluginCard.count.workspace': 'инструментов Дела: {count}',
|
||||||
|
'pluginCard.degraded': 'Плагин работает, но некоторые необязательные возможности недоступны.',
|
||||||
|
'pluginCard.name': 'Название',
|
||||||
|
'pluginCard.contributions': 'Элементы интерфейса',
|
||||||
|
'pluginCard.technicalDetails': 'Технические сведения',
|
||||||
|
'pluginCard.apiVersion': 'Версия API',
|
||||||
|
'pluginCard.root': 'Корневой каталог',
|
||||||
|
'pluginCard.provides': 'Предоставляет',
|
||||||
|
'pluginCard.requires': 'Требует',
|
||||||
|
'pluginCard.missingRequired': 'Отсутствуют обязательные возможности',
|
||||||
|
'pluginCard.optionalRequires': 'Необязательные зависимости',
|
||||||
|
'pluginCard.optionalUnavailable': 'Необязательные возможности недоступны — плагин работает в ограниченном режиме',
|
||||||
|
'pluginCard.permissions': 'Разрешения',
|
||||||
|
'pluginCard.enabling': '⟳ Включение...',
|
||||||
|
'pluginCard.enable': '▶ Включить',
|
||||||
|
'pluginCard.disabling': '⟳ Выключение...',
|
||||||
|
'pluginCard.disable': '⏸ Выключить',
|
||||||
|
'pluginCard.openVault': 'Откройте хранилище для управления состоянием плагина',
|
||||||
|
'pluginCard.missingUiPermission': 'Плагин добавляет элементы интерфейса, но не имеет разрешения ui.register',
|
||||||
|
'common.loading': 'Загрузка...',
|
||||||
|
'common.browse': 'Обзор...',
|
||||||
|
'vaultSelection.chooseNew': 'Выберите или введите папку для нового хранилища.',
|
||||||
|
'vaultSelection.createError': 'Не удалось создать хранилище. Повторите попытку.',
|
||||||
|
'vaultSelection.openError': 'Не удалось открыть хранилище. Повторите попытку.',
|
||||||
|
'vaultSelection.chooseExisting': 'Выберите или введите существующее хранилище.',
|
||||||
|
'vaultSelection.subtitle': 'Выберите хранилище, чтобы начать работу',
|
||||||
|
'vaultSelection.createTitle': 'Создать новое хранилище',
|
||||||
|
'vaultSelection.createHint': 'Создайте локальную папку хранилища для Дел и проектов.',
|
||||||
|
'vaultSelection.pathPlaceholder': 'Выберите или введите путь...',
|
||||||
|
'vaultSelection.creating': 'Создание...',
|
||||||
|
'vaultSelection.create': 'Создать хранилище',
|
||||||
|
'vaultSelection.openTitle': 'Открыть существующее хранилище',
|
||||||
|
'vaultSelection.openHint': 'Используйте хранилище, которое уже находится на этом компьютере.',
|
||||||
|
'vaultSelection.opening': 'Открытие...',
|
||||||
|
'vaultSelection.open': 'Открыть',
|
||||||
|
'vaultSelection.recent': 'Недавние хранилища',
|
||||||
|
'common.cancel': 'Отмена',
|
||||||
|
'common.close': 'Закрыть',
|
||||||
|
'workspaceTree.title': 'Дела',
|
||||||
|
'workspaceTree.new': 'Новое Дело',
|
||||||
|
'workspaceTree.nameRequired': 'Введите название',
|
||||||
|
'workspaceTree.chooseTemplate': 'Выберите шаблон Дела',
|
||||||
|
'workspaceTree.saveRename': 'Сохранить новое название',
|
||||||
|
'workspaceTree.rename': 'Переименовать Дело',
|
||||||
|
'workspaceTree.trash': 'Переместить Дело в корзину',
|
||||||
|
'workspaceTree.create': 'Создать Дело',
|
||||||
|
'workspaceTree.namePlaceholder': 'Название Дела',
|
||||||
|
'workspaceTree.template': 'Шаблон',
|
||||||
|
'workspaceTree.creating': 'Создание...',
|
||||||
|
'workspaceTree.templateAvailable': 'Доступен',
|
||||||
|
'workspaceTree.templateLimited': 'Доступен с ограничениями',
|
||||||
|
'workspaceTree.templateMissingPlugin': 'Плагин не установлен',
|
||||||
|
'workspaceTree.templatePluginDisabled': 'Плагин выключен',
|
||||||
|
'workspaceTree.templateCapabilityUnavailable': 'Не хватает обязательной возможности',
|
||||||
|
'workspaceTree.templateIncompatible': 'Плагин несовместим с этой версией Верстака',
|
||||||
|
'workspaceTree.templateLoadFailed': 'Плагин не удалось загрузить',
|
||||||
|
'workspaceTree.templateNotReady': 'Плагин ещё не готов к работе',
|
||||||
|
'workspaceTree.templateToolTabs': 'Создаёт вкладки: {tabs}',
|
||||||
|
'workspaceTree.templateToolNoTabs': 'Не предоставляет вкладку Дела',
|
||||||
|
'workspaceTree.templateWillBeIncomplete': 'Дело будет создано без недоступных компонентов.',
|
||||||
|
'workspaceTree.templateIncompleteCreated': 'Дело «{name}» создано с недоступными компонентами.',
|
||||||
|
'workspaceTree.templatesError': 'Не удалось загрузить шаблоны Дел. Повторите попытку.',
|
||||||
|
'workspaceTree.loadError': 'Не удалось загрузить Дела. Повторите попытку.',
|
||||||
|
'workspaceTree.selectError': 'Не удалось выбрать это Дело. Повторите попытку.',
|
||||||
|
'workspaceTree.createError': 'Не удалось создать Дело. Повторите попытку.',
|
||||||
|
'workspaceTree.renameError': 'Не удалось переименовать Дело. Повторите попытку.',
|
||||||
|
'workspaceTree.trashError': 'Не удалось переместить Дело в корзину. Повторите попытку.',
|
||||||
|
'pluginManager.loadError': 'Не удалось загрузить плагины. Повторите попытку.',
|
||||||
|
'pluginManager.reloadError': 'Не удалось перезагрузить плагины. Повторите попытку.',
|
||||||
|
'pluginManager.enableError': 'Не удалось включить плагин. Повторите попытку.',
|
||||||
|
'pluginManager.disableError': 'Не удалось выключить плагин. Повторите попытку.',
|
||||||
|
'pluginManager.settingsLoadError': 'Не удалось загрузить настройки плагина. Повторите попытку.',
|
||||||
|
'pluginManager.settingsUnavailable': 'Настройки плагина недоступны.',
|
||||||
|
'command.openOverview': 'Открыть обзор',
|
||||||
|
'command.openFiles': 'Открыть файлы',
|
||||||
|
'command.openActivity': 'Открыть активность',
|
||||||
|
'command.openBrowserInbox': 'Открыть Браузер',
|
||||||
|
'command.createMarkdown': 'Создать файл Markdown',
|
||||||
|
'command.createText': 'Создать текстовый файл',
|
||||||
|
'command.syncNow': 'Синхронизировать сейчас',
|
||||||
|
'command.openSyncSettings': 'Открыть настройки синхронизации',
|
||||||
|
'command.openPluginManager': 'Открыть менеджер плагинов',
|
||||||
|
'command.handled': 'Выполнено: {title}',
|
||||||
|
'command.result': '{title}: {status}',
|
||||||
|
'command.failed': 'Не удалось выполнить команду «{title}». Повторите попытку.',
|
||||||
|
'command.statusHandled': 'выполнено',
|
||||||
|
'command.palette': 'Палитра команд',
|
||||||
|
'command.run': 'Выполнить команду',
|
||||||
|
'command.none': 'Команды не найдены',
|
||||||
|
'search.indexing': 'Индексирование...',
|
||||||
|
'search.placeholder': 'Поиск',
|
||||||
|
'search.global': 'Глобальный поиск',
|
||||||
|
'search.noResults': 'Ничего не найдено',
|
||||||
|
'search.type.workspace': 'Дело',
|
||||||
|
'search.type.tool': 'Инструмент',
|
||||||
|
'search.type.folder': 'Папка',
|
||||||
|
'search.type.file': 'Файл',
|
||||||
|
'search.type.journal': 'Журнал',
|
||||||
|
'search.type.browserInbox': 'Браузер',
|
||||||
|
'search.type.activity': 'Активность',
|
||||||
|
'workbench.noProvider': 'Нет обработчика',
|
||||||
|
'workbench.noViewer': 'Нет доступного средства просмотра или редактора',
|
||||||
|
'workbench.noResource': 'Ресурс не открыт',
|
||||||
|
'bundle.noFrontend': 'У плагина нет frontend bundle',
|
||||||
|
'bundle.notFound': 'Плагин не найден',
|
||||||
|
'bundle.loadFailed': 'Не удалось загрузить интерфейс плагина. Повторите попытку.',
|
||||||
|
'bundle.emptyContent': 'пустое содержимое',
|
||||||
|
'bundle.executionError': 'Не удалось запустить интерфейс плагина. Повторите попытку.',
|
||||||
|
'bundle.registrationMissing': 'Bundle загружен, но вызов VerstakPluginRegister не обнаружен',
|
||||||
|
'bundle.componentMissing': 'Запрошенный интерфейс плагина недоступен.',
|
||||||
|
'bundle.mountError': 'Не удалось открыть интерфейс плагина. Повторите попытку.',
|
||||||
|
'bundle.mountUnavailable': 'Контейнер для подключения недоступен',
|
||||||
|
'bundle.unexpectedError': 'Не удалось открыть интерфейс плагина. Повторите попытку.',
|
||||||
|
'bundle.unknownError': 'Неизвестная ошибка',
|
||||||
|
'bundle.frontendEntry': 'Точка входа frontend',
|
||||||
|
'bundle.availableComponents': 'Доступные компоненты',
|
||||||
|
'bundle.loading': 'Загрузка bundle плагина...',
|
||||||
|
'overview.filter.all': 'Все',
|
||||||
|
'overview.filter.notes': 'Заметки',
|
||||||
|
'overview.filter.files': 'Файлы',
|
||||||
|
'overview.filter.captures': 'Сохранённое',
|
||||||
|
'overview.filter.journal': 'Журнал',
|
||||||
|
'overview.notes': 'Заметки',
|
||||||
|
'overview.files': 'Файлы',
|
||||||
|
'overview.captures': 'Сохранённое',
|
||||||
|
'overview.activity': 'Активность',
|
||||||
|
'overview.journal': 'Журнал',
|
||||||
|
'overview.attention': 'Требует внимания',
|
||||||
|
'overview.openNotes': 'Открыть заметки',
|
||||||
|
'overview.openFiles': 'Открыть файлы',
|
||||||
|
'overview.reviewInbox': 'Разобрать входящие',
|
||||||
|
'overview.viewActivity': 'Открыть активность',
|
||||||
|
'overview.openJournal': 'Открыть журнал',
|
||||||
|
'overview.openTodos': 'Открыть задачи',
|
||||||
|
'overview.reviewCandidate': 'Просмотреть запись',
|
||||||
|
'overview.reviewPending': 'Разобрать ожидающее',
|
||||||
|
'overview.count.totalRecent.one': 'Всего: {total} · недавних изменений: {recent}',
|
||||||
|
'overview.count.totalRecent.many': 'Всего: {total} · недавних изменений: {recent}',
|
||||||
|
'overview.count.recentChanges.one': 'Недавних изменений: {count}',
|
||||||
|
'overview.count.recentChanges.many': 'Недавних изменений: {count}',
|
||||||
|
'overview.count.captures.one': 'На разбор: {count}',
|
||||||
|
'overview.count.captures.many': 'На разбор: {count}',
|
||||||
|
'overview.count.events.one': 'Событий: {count}',
|
||||||
|
'overview.count.events.many': 'Событий: {count}',
|
||||||
|
'overview.count.journal.one': 'Записей журнала: {count}',
|
||||||
|
'overview.count.journal.many': 'Записей журнала: {count}',
|
||||||
|
'overview.count.pending.one': 'Ожидают: {count}',
|
||||||
|
'overview.count.pending.many': 'Ожидают: {count}',
|
||||||
|
'overview.loadingContext': 'Загрузка данных Дела...',
|
||||||
|
'overview.lastActive': 'Последняя активность: {time}',
|
||||||
|
'overview.noRecentActivity': 'В Деле пока нет недавней активности',
|
||||||
|
'overview.refresh': 'Обновить',
|
||||||
|
'overview.summary': 'Сводка Дела',
|
||||||
|
'overview.continue': 'Продолжить работу',
|
||||||
|
'overview.continueHint': 'Вернитесь к следующему полезному делу в этом Деле.',
|
||||||
|
'overview.loadingSignals': 'Загрузка данных Дела...',
|
||||||
|
'overview.noResume': 'Пока неясно, с чего продолжить',
|
||||||
|
'overview.noResumeHint': 'Здесь появятся недавние заметки, файлы, сохранённое и записи журнала.',
|
||||||
|
'overview.recentChanges': 'Недавние изменения',
|
||||||
|
'overview.recentChangesHint': 'Последняя важная активность в этом Деле.',
|
||||||
|
'overview.event.workspaceOpened': 'Открыто Дело',
|
||||||
|
'overview.event.activity': 'Активность Дела',
|
||||||
|
'overview.event.noteEdited': 'Изменена заметка «{name}»',
|
||||||
|
'overview.event.noteOpened': 'Открыта заметка «{name}»',
|
||||||
|
'overview.event.noteCreated': 'Создана заметка «{name}»',
|
||||||
|
'overview.event.fileOpened': 'Открыт файл «{name}»',
|
||||||
|
'overview.event.fileChanged': 'Изменён файл «{name}»',
|
||||||
|
'overview.event.fileCreated': 'Создан файл «{name}»',
|
||||||
|
'overview.event.fileRemoved': 'Удалён файл «{name}»',
|
||||||
|
'overview.event.capturePage': 'Сохранена страница «{name}»',
|
||||||
|
'overview.event.captureSelection': 'Сохранено выделение «{name}»',
|
||||||
|
'overview.event.captureLink': 'Сохранена ссылка «{name}»',
|
||||||
|
'overview.event.captureFile': 'Сохранён файл «{name}»',
|
||||||
|
'overview.event.captureConverted': 'Сохранённое преобразовано: «{name}»',
|
||||||
|
'overview.event.capture': 'Сохранено: {kind} «{title}»',
|
||||||
|
'overview.event.journalAdded': 'Добавлена запись в журнал «{name}»',
|
||||||
|
'overview.event.workSessionDetected': 'Обнаружена рабочая сессия',
|
||||||
|
'overview.event.todo': 'Задача «{title}»',
|
||||||
|
'overview.event.reviewCapture': 'Разобрать сохранённое «{title}»',
|
||||||
|
'overview.event.continueJournal': 'Продолжить запись журнала «{title}»',
|
||||||
|
'overview.event.possibleJournalEntry': 'Возможная запись в журнал',
|
||||||
|
'overview.captureKind.page': 'страница',
|
||||||
|
'overview.captureKind.selection': 'выделение',
|
||||||
|
'overview.captureKind.link': 'ссылка',
|
||||||
|
'overview.captureKind.file': 'файл',
|
||||||
|
'overview.captureKind.item': 'элемент',
|
||||||
|
'overview.todo.reminderDue': 'Пора напомнить',
|
||||||
|
'overview.todo.overdue': 'Просрочено',
|
||||||
|
'overview.todo.dueSoon': 'Скоро срок',
|
||||||
|
'overview.todo.due': 'Срок: {date}',
|
||||||
|
'overview.candidateMeta': 'Дело: {deal} · {minutes} мин. · активностей: {activities}',
|
||||||
|
'overview.minutes': '{count} мин.',
|
||||||
|
'overview.browserCapture': 'Сохранённое из браузера',
|
||||||
|
'overview.untitledTodo': 'Задача без названия',
|
||||||
|
'overview.untitledCapture': 'Сохранённое без названия',
|
||||||
|
'overview.untitledJournal': 'Запись журнала',
|
||||||
|
'overview.unknownDeal': 'Неизвестное Дело',
|
||||||
|
'overview.item': 'элемент',
|
||||||
|
'overview.overviewNote': 'Обзорная заметка Дела',
|
||||||
|
'overview.recentFilter': 'Фильтр недавних изменений',
|
||||||
|
'overview.loadingRecent': 'Загрузка недавних изменений...',
|
||||||
|
'overview.noChanges': 'Для этого фильтра пока нет важных изменений.',
|
||||||
|
'overview.attentionHint': 'Неразобранное сохранённое, срочные задачи и возможные записи журнала.',
|
||||||
|
'overview.loadingPending': 'Загрузка ожидающих элементов...',
|
||||||
|
'overview.noPending': 'Нет неразобранного сохранённого, срочных задач или возможных записей журнала.',
|
||||||
|
'overview.keyResources': 'Ключевые ресурсы',
|
||||||
|
'overview.time.none': 'Время не указано',
|
||||||
|
'overview.time.now': 'Только что',
|
||||||
|
'overview.time.minutes': '{count} мин. назад',
|
||||||
|
'overview.time.hours': '{count} ч. назад',
|
||||||
|
'overview.time.yesterday': 'Вчера',
|
||||||
|
'overview.time.days': '{count} дн. назад',
|
||||||
|
};
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import englishShellCatalog from './catalogs/en.js';
|
||||||
|
import russianShellCatalog from './catalogs/ru.js';
|
||||||
|
|
||||||
|
const LANGUAGE_PREFERENCES = new Set(['system', 'en', 'ru']);
|
||||||
|
const CONTRIBUTION_TEXT_FIELDS = {
|
||||||
|
views: 'title',
|
||||||
|
commands: 'title',
|
||||||
|
settingsPanels: 'title',
|
||||||
|
sidebarItems: 'title',
|
||||||
|
fileActions: 'label',
|
||||||
|
noteActions: 'label',
|
||||||
|
contextMenuEntries: 'label',
|
||||||
|
searchProviders: 'label',
|
||||||
|
statusBarItems: 'label',
|
||||||
|
openProviders: 'title',
|
||||||
|
workspaceItems: 'title',
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeSystemLanguages(languages) {
|
||||||
|
const values = Array.isArray(languages) ? languages : [languages];
|
||||||
|
return values.map((value) => String(value || '').trim().toLowerCase()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveLocale(preference, systemLanguages = []) {
|
||||||
|
if (!LANGUAGE_PREFERENCES.has(preference)) {
|
||||||
|
throw new Error(`unsupported language preference: ${preference}`);
|
||||||
|
}
|
||||||
|
if (preference === 'en' || preference === 'ru') return preference;
|
||||||
|
return normalizeSystemLanguages(systemLanguages).some((locale) => locale === 'ru' || locale.startsWith('ru-'))
|
||||||
|
? 'ru'
|
||||||
|
: 'en';
|
||||||
|
}
|
||||||
|
|
||||||
|
function interpolate(message, params) {
|
||||||
|
if (!params) return message;
|
||||||
|
return message.replace(/\{([A-Za-z0-9_.-]+)\}/g, (placeholder, name) => (
|
||||||
|
Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : placeholder
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
function browserLanguages() {
|
||||||
|
if (typeof navigator === 'undefined') return ['en'];
|
||||||
|
if (Array.isArray(navigator.languages) && navigator.languages.length > 0) return navigator.languages;
|
||||||
|
return [navigator.language || 'en'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createI18n(options = {}) {
|
||||||
|
const shellCatalogs = options.shellCatalogs || { en: {}, ru: {} };
|
||||||
|
let systemLanguages = options.systemLanguages || browserLanguages;
|
||||||
|
let catalogLoader = options.loadPluginCatalog || (async () => ({}));
|
||||||
|
let preference = 'system';
|
||||||
|
let locale = 'en';
|
||||||
|
const listeners = new Set();
|
||||||
|
const pluginConfigs = new Map();
|
||||||
|
const pluginCatalogs = new Map();
|
||||||
|
|
||||||
|
function configure(next = {}) {
|
||||||
|
if (next.systemLanguages) systemLanguages = next.systemLanguages;
|
||||||
|
if (next.loadPluginCatalog) catalogLoader = next.loadPluginCatalog;
|
||||||
|
}
|
||||||
|
|
||||||
|
function notify() {
|
||||||
|
listeners.forEach((listener) => listener(locale));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initialize(initialPreference = 'system') {
|
||||||
|
preference = LANGUAGE_PREFERENCES.has(initialPreference) ? initialPreference : 'system';
|
||||||
|
locale = resolveLocale(preference, systemLanguages());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCatalog(pluginId, catalogLocale) {
|
||||||
|
if (!catalogLocale) return;
|
||||||
|
let catalogs = pluginCatalogs.get(pluginId);
|
||||||
|
if (!catalogs) {
|
||||||
|
catalogs = new Map();
|
||||||
|
pluginCatalogs.set(pluginId, catalogs);
|
||||||
|
}
|
||||||
|
if (catalogs.has(catalogLocale)) return;
|
||||||
|
const messages = await catalogLoader(pluginId, catalogLocale);
|
||||||
|
catalogs.set(catalogLocale, { ...(messages || {}) });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPlugin(pluginId, localization) {
|
||||||
|
if (!pluginId || !localization || !localization.defaultLocale || !localization.locales) return;
|
||||||
|
pluginConfigs.set(pluginId, localization);
|
||||||
|
const requested = localization.locales[locale] ? locale : '';
|
||||||
|
const defaultLocale = localization.defaultLocale;
|
||||||
|
await Promise.all(Array.from(new Set([requested, defaultLocale].filter(Boolean))).map((item) => loadCatalog(pluginId, item)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setLanguagePreference(nextPreference) {
|
||||||
|
const nextLocale = resolveLocale(nextPreference, systemLanguages());
|
||||||
|
preference = nextPreference;
|
||||||
|
locale = nextLocale;
|
||||||
|
await Promise.all(Array.from(pluginConfigs.entries()).map(([pluginId, config]) => (
|
||||||
|
loadPlugin(pluginId, config).catch((error) => {
|
||||||
|
console.warn(`[i18n] failed to load ${pluginId} catalog for ${nextLocale}:`, error);
|
||||||
|
})
|
||||||
|
)));
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
function t(key, params, fallback) {
|
||||||
|
const message = shellCatalogs[locale]?.[key]
|
||||||
|
?? shellCatalogs.en?.[key]
|
||||||
|
?? fallback
|
||||||
|
?? key;
|
||||||
|
return interpolate(message, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
function translatePlugin(pluginId, key, params, fallback) {
|
||||||
|
const config = pluginConfigs.get(pluginId);
|
||||||
|
const catalogs = pluginCatalogs.get(pluginId);
|
||||||
|
const message = catalogs?.get(locale)?.[key]
|
||||||
|
?? catalogs?.get(config?.defaultLocale)?.[key]
|
||||||
|
?? fallback
|
||||||
|
?? key;
|
||||||
|
return interpolate(message, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
function localizeContributions(pluginId, contributions) {
|
||||||
|
if (!contributions) return contributions;
|
||||||
|
const localized = { ...contributions };
|
||||||
|
Object.entries(CONTRIBUTION_TEXT_FIELDS).forEach(([point, field]) => {
|
||||||
|
if (!Array.isArray(contributions[point])) return;
|
||||||
|
localized[point] = contributions[point].map((item) => ({
|
||||||
|
...item,
|
||||||
|
[field]: translatePlugin(
|
||||||
|
pluginId,
|
||||||
|
`contributions.${point}.${item.id}.${field}`,
|
||||||
|
undefined,
|
||||||
|
item[field],
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
return localized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function localizePlugin(plugin) {
|
||||||
|
if (!plugin) return plugin;
|
||||||
|
const hasStateWrapper = !!plugin.manifest;
|
||||||
|
const manifest = hasStateWrapper ? plugin.manifest : plugin;
|
||||||
|
const pluginId = manifest.id;
|
||||||
|
const localizedManifest = {
|
||||||
|
...manifest,
|
||||||
|
name: translatePlugin(pluginId, 'manifest.name', undefined, manifest.name),
|
||||||
|
description: translatePlugin(pluginId, 'manifest.description', undefined, manifest.description),
|
||||||
|
contributes: localizeContributions(pluginId, manifest.contributes),
|
||||||
|
};
|
||||||
|
return hasStateWrapper ? { ...plugin, manifest: localizedManifest } : localizedManifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
function localizeContributionSummary(summary) {
|
||||||
|
if (!summary) return summary;
|
||||||
|
const localized = { ...summary };
|
||||||
|
Object.entries(CONTRIBUTION_TEXT_FIELDS).forEach(([point, field]) => {
|
||||||
|
if (!Array.isArray(summary[point])) return;
|
||||||
|
localized[point] = summary[point].map((item) => ({
|
||||||
|
...item,
|
||||||
|
[field]: translatePlugin(
|
||||||
|
item.pluginId,
|
||||||
|
`contributions.${point}.${item.id}.${field}`,
|
||||||
|
undefined,
|
||||||
|
item[field],
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
return localized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribe(listener) {
|
||||||
|
listeners.add(listener);
|
||||||
|
listener(locale);
|
||||||
|
return () => listeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
configure,
|
||||||
|
initialize,
|
||||||
|
getLanguagePreference: () => preference,
|
||||||
|
getLocale: () => locale,
|
||||||
|
setLanguagePreference,
|
||||||
|
t,
|
||||||
|
subscribe,
|
||||||
|
loadPlugin,
|
||||||
|
translatePlugin,
|
||||||
|
localizeContributions,
|
||||||
|
localizePlugin,
|
||||||
|
localizeContributionSummary,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const i18n = createI18n({
|
||||||
|
shellCatalogs: {
|
||||||
|
en: englishShellCatalog,
|
||||||
|
ru: russianShellCatalog,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const t = (...args) => i18n.t(...args);
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
/**
|
||||||
|
* Frontend debug logger.
|
||||||
|
*
|
||||||
|
* Enabled when:
|
||||||
|
* 1. URL has ?debug query param, OR
|
||||||
|
* 2. localStorage has verstak-debug = "true"
|
||||||
|
*
|
||||||
|
* Writes to:
|
||||||
|
* - console (always, with [debug] prefix)
|
||||||
|
* - localStorage buffer (last 1000 entries, key: verstak-debug-log)
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* import { debug } from '../log/debug.js';
|
||||||
|
* debug.log('[ComponentName]', 'message', data);
|
||||||
|
* debug.logf('[ComponentName]', 'format %s', arg);
|
||||||
|
*
|
||||||
|
* To enable: open app with ?debug or run in console:
|
||||||
|
* localStorage.setItem('verstak-debug', 'true')
|
||||||
|
*
|
||||||
|
* To export log: run in console:
|
||||||
|
* copy(JSON.parse(localStorage.getItem('verstak-debug-log')))
|
||||||
|
*/
|
||||||
|
|
||||||
|
var ENABLED = false;
|
||||||
|
var BUFFER_KEY = 'verstak-debug-log';
|
||||||
|
var MAX_ENTRIES = 1000;
|
||||||
|
|
||||||
|
// Check enable conditions
|
||||||
|
function checkEnabled() {
|
||||||
|
try {
|
||||||
|
if (window.location && window.location.search && window.location.search.indexOf('debug') !== -1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (typeof localStorage !== 'undefined' && localStorage.getItem('verstak-debug') === 'true') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// localStorage not available
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ENABLED = checkEnabled();
|
||||||
|
|
||||||
|
function getTimestamp() {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMessage(args) {
|
||||||
|
var parts = [];
|
||||||
|
for (var i = 0; i < args.length; i++) {
|
||||||
|
var a = args[i];
|
||||||
|
if (typeof a === 'object') {
|
||||||
|
try {
|
||||||
|
parts.push(JSON.stringify(a));
|
||||||
|
} catch (e) {
|
||||||
|
parts.push(String(a));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
parts.push(String(a));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeToBuffer(entry) {
|
||||||
|
try {
|
||||||
|
if (typeof localStorage === 'undefined') return;
|
||||||
|
var raw = localStorage.getItem(BUFFER_KEY);
|
||||||
|
var log = raw ? JSON.parse(raw) : [];
|
||||||
|
log.push(entry);
|
||||||
|
if (log.length > MAX_ENTRIES) {
|
||||||
|
log = log.slice(log.length - MAX_ENTRIES);
|
||||||
|
}
|
||||||
|
localStorage.setItem(BUFFER_KEY, JSON.stringify(log));
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore quota errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function log() {
|
||||||
|
if (!ENABLED) return;
|
||||||
|
var msg = formatMessage(Array.prototype.slice.call(arguments));
|
||||||
|
var entry = { ts: getTimestamp(), msg: msg };
|
||||||
|
writeToBuffer(entry);
|
||||||
|
console.log('[debug]', msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
function logf() {
|
||||||
|
if (!ENABLED) return;
|
||||||
|
var args = Array.prototype.slice.call(arguments);
|
||||||
|
var format = args.shift();
|
||||||
|
var i = 0;
|
||||||
|
var msg = format.replace(/%[sdfo]/g, function () {
|
||||||
|
return i < args.length ? String(args[i++]) : '';
|
||||||
|
});
|
||||||
|
var entry = { ts: getTimestamp(), msg: msg };
|
||||||
|
writeToBuffer(entry);
|
||||||
|
console.log('[debug]', msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLog() {
|
||||||
|
try {
|
||||||
|
if (typeof localStorage === 'undefined') return [];
|
||||||
|
var raw = localStorage.getItem(BUFFER_KEY);
|
||||||
|
return raw ? JSON.parse(raw) : [];
|
||||||
|
} catch (e) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearLog() {
|
||||||
|
try {
|
||||||
|
if (typeof localStorage !== 'undefined') {
|
||||||
|
localStorage.removeItem(BUFFER_KEY);
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportLog() {
|
||||||
|
var entries = getLog();
|
||||||
|
return entries.map(function (e) { return e.ts + ' ' + e.msg; }).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Named export for Svelte/Vite
|
||||||
|
export var debug = {
|
||||||
|
log: log,
|
||||||
|
logf: logf,
|
||||||
|
isEnabled: function () { return ENABLED; },
|
||||||
|
enable: function (options) {
|
||||||
|
ENABLED = true;
|
||||||
|
if (!options || options.persist !== false) {
|
||||||
|
try { localStorage.setItem('verstak-debug', 'true'); } catch (e) {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
disable: function () {
|
||||||
|
ENABLED = false;
|
||||||
|
try { localStorage.removeItem('verstak-debug'); } catch (e) {}
|
||||||
|
},
|
||||||
|
getLog: getLog,
|
||||||
|
clearLog: clearLog,
|
||||||
|
exportLog: exportLog
|
||||||
|
};
|
||||||
|
|
||||||
|
// Also expose globally for console access
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.__verstakDebug = debug;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ENABLED) {
|
||||||
|
console.log('[debug] frontend debug logger enabled');
|
||||||
|
}
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import * as App from '../../../wailsjs/go/api/App';
|
||||||
|
import Icon from '../ui/Icon.svelte';
|
||||||
|
import { i18n } from '../i18n/index.js';
|
||||||
|
|
||||||
|
import { createPluginAPI } from './VerstakPluginAPI.js';
|
||||||
|
|
||||||
|
export let pluginId = null;
|
||||||
|
export let componentId = null;
|
||||||
|
export let viewPluginId = null;
|
||||||
|
export let componentProps = {};
|
||||||
|
|
||||||
|
let loadState = 'idle'; // idle | loading | loaded | error
|
||||||
|
let pluginInfo = null;
|
||||||
|
let errorText = '';
|
||||||
|
let mountContainer = null;
|
||||||
|
let currentPluginId = null;
|
||||||
|
let currentComponent = null;
|
||||||
|
let currentAPI = null;
|
||||||
|
let currentPropsKey = '';
|
||||||
|
let locale = i18n.getLocale();
|
||||||
|
let unsubscribeLocale = null;
|
||||||
|
|
||||||
|
$: tr = ((activeLocale) => (key, params, fallback) => {
|
||||||
|
void activeLocale;
|
||||||
|
return i18n.t(key, params, fallback);
|
||||||
|
})(locale);
|
||||||
|
|
||||||
|
$: activePluginId = pluginId || viewPluginId;
|
||||||
|
$: activeComponent = componentId;
|
||||||
|
$: propsKey = JSON.stringify(componentProps || {});
|
||||||
|
|
||||||
|
// React to changes — reload on view change
|
||||||
|
$: if (activePluginId && activeComponent) {
|
||||||
|
loadAndMount(activePluginId, activeComponent, propsKey);
|
||||||
|
} else if (!activePluginId) {
|
||||||
|
cleanup();
|
||||||
|
loadState = 'idle';
|
||||||
|
}
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
unsubscribeLocale?.();
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
unsubscribeLocale = i18n.subscribe((nextLocale) => locale = nextLocale);
|
||||||
|
});
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
if (currentAPI && typeof currentAPI.dispose === 'function') {
|
||||||
|
try {
|
||||||
|
currentAPI.dispose();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[PluginBundleHost] API dispose error:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const reg = window.__VERSTAK_PLUGIN_REGISTRY__;
|
||||||
|
if (currentPluginId && currentComponent && reg && reg[currentPluginId]) {
|
||||||
|
const comp = reg[currentPluginId][currentComponent];
|
||||||
|
if (comp && comp.unmount && mountContainer) {
|
||||||
|
try {
|
||||||
|
comp.unmount(mountContainer);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[PluginBundleHost] unmount error:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (mountContainer) {
|
||||||
|
mountContainer.innerHTML = '';
|
||||||
|
}
|
||||||
|
currentPluginId = null;
|
||||||
|
currentComponent = null;
|
||||||
|
currentAPI = null;
|
||||||
|
currentPropsKey = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function unpackBackendResult(result) {
|
||||||
|
if (Array.isArray(result) && result.length === 2 && (typeof result[1] === 'string' || result[1] == null)) {
|
||||||
|
return { value: result[0], error: result[1] || '' };
|
||||||
|
}
|
||||||
|
return { value: result, error: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportError(key, fallback, details) {
|
||||||
|
console.warn('[PluginBundleHost] ' + key + ':', details);
|
||||||
|
return tr(key, undefined, fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAndMount(pId, compId, nextPropsKey) {
|
||||||
|
// If same plugin+component and already mounted, skip
|
||||||
|
if (currentPluginId === pId && currentComponent === compId && currentPropsKey === nextPropsKey && loadState === 'loaded') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup previous
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
loadState = 'loading';
|
||||||
|
errorText = '';
|
||||||
|
currentPluginId = pId;
|
||||||
|
currentComponent = compId;
|
||||||
|
currentPropsKey = nextPropsKey;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get plugin frontend info
|
||||||
|
const info = await App.GetPluginFrontendInfo(pId);
|
||||||
|
pluginInfo = info;
|
||||||
|
|
||||||
|
if (!info || info.status === 'no-frontend' || info.status === 'not-found') {
|
||||||
|
loadState = 'error';
|
||||||
|
errorText = info.status === 'no-frontend'
|
||||||
|
? tr('bundle.noFrontend')
|
||||||
|
: tr('bundle.notFound');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await i18n.loadPlugin(pId, info.localization);
|
||||||
|
} catch (catalogError) {
|
||||||
|
console.warn(`[PluginBundleHost] localization unavailable for ${pId}:`, catalogError);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if bundle already loaded for this plugin
|
||||||
|
const reg = window.__VERSTAK_PLUGIN_REGISTRY__ || {};
|
||||||
|
if (!reg[pId]) {
|
||||||
|
// Load the bundle JS content via backend API
|
||||||
|
const assetResult = unpackBackendResult(await App.GetPluginAssetContent(pId, info.entry));
|
||||||
|
const content = assetResult.value;
|
||||||
|
if (assetResult.error || !content) {
|
||||||
|
loadState = 'error';
|
||||||
|
errorText = reportError('bundle.loadFailed', 'Could not load the plugin interface. Please try again.', assetResult.error || tr('bundle.emptyContent'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute bundle via Function constructor (safe: no access to outer scope)
|
||||||
|
// This is equivalent to eval but more explicit
|
||||||
|
try {
|
||||||
|
const fn = new Function(content);
|
||||||
|
fn();
|
||||||
|
} catch (e) {
|
||||||
|
loadState = 'error';
|
||||||
|
errorText = reportError('bundle.executionError', 'Could not start the plugin interface. Please try again.', e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify registration happened
|
||||||
|
if (!window.__VERSTAK_PLUGIN_REGISTRY__[pId]) {
|
||||||
|
loadState = 'error';
|
||||||
|
errorText = tr('bundle.registrationMissing');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the component
|
||||||
|
const components = window.__VERSTAK_PLUGIN_REGISTRY__[pId];
|
||||||
|
const comp = components[compId];
|
||||||
|
if (!comp || !comp.mount) {
|
||||||
|
loadState = 'error';
|
||||||
|
errorText = tr('bundle.componentMissing', undefined, 'The requested plugin interface is unavailable.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create API
|
||||||
|
const api = createPluginAPI(pId);
|
||||||
|
currentAPI = api;
|
||||||
|
|
||||||
|
// Mount component
|
||||||
|
if (!mountContainer) {
|
||||||
|
// Container must exist in DOM — wait for next tick
|
||||||
|
await new Promise(r => requestAnimationFrame(r));
|
||||||
|
}
|
||||||
|
if (mountContainer) {
|
||||||
|
try {
|
||||||
|
comp.mount(mountContainer, Object.assign({ componentId: compId }, componentProps || {}), api);
|
||||||
|
loadState = 'loaded';
|
||||||
|
errorText = '';
|
||||||
|
} catch (e) {
|
||||||
|
loadState = 'error';
|
||||||
|
errorText = reportError('bundle.mountError', 'Could not open the plugin interface. Please try again.', e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
loadState = 'error';
|
||||||
|
errorText = tr('bundle.mountUnavailable');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
loadState = 'error';
|
||||||
|
errorText = reportError('bundle.unexpectedError', 'Could not open the plugin interface. Please try again.', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getComponentList() {
|
||||||
|
const reg = window.__VERSTAK_PLUGIN_REGISTRY__;
|
||||||
|
if (!reg || !currentPluginId || !reg[currentPluginId]) return [];
|
||||||
|
return Object.keys(reg[currentPluginId]);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="plugin-bundle-host">
|
||||||
|
{#if loadState === 'idle'}
|
||||||
|
<div class="host-state idle">
|
||||||
|
<p>{tr('pluginView.select')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if loadState === 'error'}
|
||||||
|
<div class="host-state error">
|
||||||
|
<Icon name="warning" size={24} class="error-icon" />
|
||||||
|
<p class="error-title">{tr('pluginView.error')}</p>
|
||||||
|
<p class="error-message">{errorText || tr('bundle.unknownError')}</p>
|
||||||
|
<details class="error-details">
|
||||||
|
<summary>{tr('common.details')}</summary>
|
||||||
|
<p><strong>{tr('common.plugin')}:</strong> {currentPluginId || tr('common.unknown')}</p>
|
||||||
|
<p><strong>{tr('common.component')}:</strong> {currentComponent || tr('common.unknown')}</p>
|
||||||
|
{#if pluginInfo}
|
||||||
|
<p class="error-meta">{tr('bundle.frontendEntry')}: {pluginInfo.entry || tr('common.none')}</p>
|
||||||
|
{/if}
|
||||||
|
{#if getComponentList().length > 0}
|
||||||
|
<p class="error-meta">{tr('bundle.availableComponents')}: {getComponentList().join(', ')}</p>
|
||||||
|
{/if}
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else}
|
||||||
|
{#if loadState === 'loading'}
|
||||||
|
<div class="host-state loading">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>{tr('bundle.loading')}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div
|
||||||
|
class="plugin-mount-container"
|
||||||
|
class:mount-hidden={loadState !== 'loaded'}
|
||||||
|
bind:this={mountContainer}
|
||||||
|
data-plugin-id={currentPluginId}
|
||||||
|
data-component={currentComponent}
|
||||||
|
style="flex:1;min-width:0;min-height:0;height:100%;display:flex;flex-direction:column;position:relative;"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.plugin-bundle-host {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-state {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-state.idle {
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-state.loading {
|
||||||
|
color: #a0a0b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: 2px solid #333;
|
||||||
|
border-top-color: #4ecca3;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-state.error {
|
||||||
|
color: #e94560;
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-state.error :global(.error-icon) {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-details {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #a0a0b8;
|
||||||
|
max-width: 400px;
|
||||||
|
text-align: left;
|
||||||
|
background: #16213e;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #0f3460;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-details[open] {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-details summary {
|
||||||
|
cursor: pointer;
|
||||||
|
color: #e0e0f0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-details p {
|
||||||
|
margin: 0.3rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-details strong {
|
||||||
|
color: #e0e0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
color: #e94560;
|
||||||
|
max-width: 400px;
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-meta {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 0.3rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-mount-container {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-mount-container.mount-hidden {
|
||||||
|
height: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,603 @@
|
|||||||
|
import * as App from '../../../wailsjs/go/api/App';
|
||||||
|
import { i18n } from '../i18n/index.js';
|
||||||
|
|
||||||
|
window.__VERSTAK_PLUGIN_REGISTRY__ = window.__VERSTAK_PLUGIN_REGISTRY__ || {};
|
||||||
|
window.__VERSTAK_EVENT_HANDLERS__ = window.__VERSTAK_EVENT_HANDLERS__ || {};
|
||||||
|
window.__VERSTAK_COMMAND_HANDLERS__ = window.__VERSTAK_COMMAND_HANDLERS__ || {};
|
||||||
|
|
||||||
|
if (!window.VerstakPluginRegister) {
|
||||||
|
window.VerstakPluginRegister = function(pluginId, bundle) {
|
||||||
|
if (!pluginId || !bundle || !bundle.components) {
|
||||||
|
console.error('[VerstakPluginRegister] invalid registration:', pluginId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log('[VerstakPluginRegister] registered:', pluginId, Object.keys(bundle.components));
|
||||||
|
window.__VERSTAK_PLUGIN_REGISTRY__[pluginId] = bundle.components;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function unpack(result) {
|
||||||
|
if (Array.isArray(result) && result.length === 2 && (typeof result[1] === 'string' || result[1] == null)) {
|
||||||
|
return [result[0], result[1] || ''];
|
||||||
|
}
|
||||||
|
return [result, ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callBackend(pluginId, label, fn) {
|
||||||
|
try {
|
||||||
|
const [value, err] = unpack(await fn());
|
||||||
|
if (err) {
|
||||||
|
throw new Error(err);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
} catch (e) {
|
||||||
|
const message = e && e.message ? e.message : String(e);
|
||||||
|
throw new Error('[plugin:' + pluginId + '] ' + label + ' failed: ' + message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callBackendErrorString(pluginId, label, fn) {
|
||||||
|
try {
|
||||||
|
const err = await fn();
|
||||||
|
if (err) {
|
||||||
|
throw new Error(err);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const message = e && e.message ? e.message : String(e);
|
||||||
|
throw new Error('[plugin:' + pluginId + '] ' + label + ' failed: ' + message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEventHandlers(eventName) {
|
||||||
|
if (!window.__VERSTAK_EVENT_HANDLERS__[eventName]) {
|
||||||
|
window.__VERSTAK_EVENT_HANDLERS__[eventName] = [];
|
||||||
|
}
|
||||||
|
return window.__VERSTAK_EVENT_HANDLERS__[eventName];
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchLocalEvent(pluginId, eventName, payload) {
|
||||||
|
const event = {
|
||||||
|
name: eventName,
|
||||||
|
pluginId: pluginId,
|
||||||
|
payload: payload || {},
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
const handlers = getEventHandlers(eventName).slice();
|
||||||
|
handlers.forEach(function(handler) {
|
||||||
|
try {
|
||||||
|
handler(event);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[VerstakPluginAPI] event handler error:', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchBackendEvent(event) {
|
||||||
|
if (!event || !event.name) return;
|
||||||
|
const handlers = getEventHandlers(event.name).slice();
|
||||||
|
handlers.forEach(function(handler) {
|
||||||
|
try {
|
||||||
|
handler(event);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[VerstakPluginAPI] backend event handler error:', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.__VERSTAK_DISPATCH_BACKEND_EVENT__ = dispatchBackendEvent;
|
||||||
|
|
||||||
|
if (!window.__VERSTAK_BACKEND_EVENT_BRIDGE__ && window.runtime && typeof window.runtime.EventsOnMultiple === 'function') {
|
||||||
|
window.__VERSTAK_BACKEND_EVENT_BRIDGE__ = window.runtime.EventsOnMultiple('verstak:plugin-event', dispatchBackendEvent, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandKey(pluginId, commandId) {
|
||||||
|
return pluginId + ':' + commandId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executePluginCommand(pluginId, cmdId, args) {
|
||||||
|
if (!pluginId) {
|
||||||
|
throw new Error('executePluginCommand requires pluginId');
|
||||||
|
}
|
||||||
|
if (!cmdId) {
|
||||||
|
throw new Error('executePluginCommand requires command id');
|
||||||
|
}
|
||||||
|
const declared = await callBackend(pluginId, 'commands.execute(' + cmdId + ')', function() {
|
||||||
|
return App.ExecutePluginCommand(pluginId, cmdId, args || {});
|
||||||
|
});
|
||||||
|
const handler = window.__VERSTAK_COMMAND_HANDLERS__[commandKey(pluginId, cmdId)];
|
||||||
|
if (!handler) {
|
||||||
|
throw new Error('[plugin:' + pluginId + '] commands.execute(' + cmdId + ') failed: declared-but-unhandled');
|
||||||
|
}
|
||||||
|
const result = await handler(args || {}, declared);
|
||||||
|
return {
|
||||||
|
status: 'handled',
|
||||||
|
pluginId: pluginId,
|
||||||
|
commandId: cmdId,
|
||||||
|
result: result
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPluginAPI(pluginId) {
|
||||||
|
if (!pluginId) {
|
||||||
|
throw new Error('createPluginAPI requires pluginId');
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanups = [];
|
||||||
|
let disposed = false;
|
||||||
|
|
||||||
|
function assertActive(label) {
|
||||||
|
if (disposed) {
|
||||||
|
throw new Error('[plugin:' + pluginId + '] ' + label + ' failed: API disposed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function trackCleanup(fn) {
|
||||||
|
cleanups.push(fn);
|
||||||
|
return function untrackAndRun() {
|
||||||
|
const idx = cleanups.indexOf(fn);
|
||||||
|
if (idx !== -1) {
|
||||||
|
cleanups.splice(idx, 1);
|
||||||
|
}
|
||||||
|
fn();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
pluginId: pluginId,
|
||||||
|
|
||||||
|
i18n: {
|
||||||
|
getLocale: function() {
|
||||||
|
assertActive('i18n.getLocale');
|
||||||
|
return i18n.getLocale();
|
||||||
|
},
|
||||||
|
t: function(key, params, fallback) {
|
||||||
|
assertActive('i18n.t(' + key + ')');
|
||||||
|
return i18n.translatePlugin(pluginId, key, params, fallback);
|
||||||
|
},
|
||||||
|
onDidChangeLocale: function(listener) {
|
||||||
|
assertActive('i18n.onDidChangeLocale');
|
||||||
|
if (typeof listener !== 'function') {
|
||||||
|
throw new Error('i18n.onDidChangeLocale requires a listener function');
|
||||||
|
}
|
||||||
|
return trackCleanup(i18n.subscribe(listener));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
ui: {
|
||||||
|
openSettings: function(panelId) {
|
||||||
|
assertActive('ui.openSettings');
|
||||||
|
window.dispatchEvent(new CustomEvent('verstak:open-settings', {
|
||||||
|
detail: { pluginId: pluginId, panelId: panelId || '' }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
capabilities: {
|
||||||
|
has: async function(capId) {
|
||||||
|
const info = await callBackend(pluginId, 'capabilities.has(' + capId + ')', function() {
|
||||||
|
return App.GetPluginCapability(pluginId, capId);
|
||||||
|
});
|
||||||
|
return !!(info && info.available);
|
||||||
|
},
|
||||||
|
get: function(capId) {
|
||||||
|
return callBackend(pluginId, 'capabilities.get(' + capId + ')', function() {
|
||||||
|
return App.GetPluginCapability(pluginId, capId);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
list: function() {
|
||||||
|
return callBackend(pluginId, 'capabilities.list', function() {
|
||||||
|
return App.ListPluginCapabilities(pluginId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
events: {
|
||||||
|
publish: async function(type, payload) {
|
||||||
|
await callBackendErrorString(pluginId, 'events.publish(' + type + ')', function() {
|
||||||
|
return App.PublishPluginEvent(pluginId, type, payload || {});
|
||||||
|
});
|
||||||
|
if (!window.__VERSTAK_BACKEND_EVENT_BRIDGE__) {
|
||||||
|
dispatchLocalEvent(pluginId, type, payload || {});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
subscribe: function(type, handler) {
|
||||||
|
assertActive('events.subscribe(' + type + ')');
|
||||||
|
if (typeof handler !== 'function') {
|
||||||
|
throw new Error('events.subscribe requires a handler function');
|
||||||
|
}
|
||||||
|
return callBackendErrorString(pluginId, 'events.subscribe(' + type + ')', function() {
|
||||||
|
return App.SubscribePluginEvent(pluginId, type);
|
||||||
|
}).then(function() {
|
||||||
|
const handlers = getEventHandlers(type);
|
||||||
|
handlers.push(handler);
|
||||||
|
return trackCleanup(function unsubscribe() {
|
||||||
|
const current = getEventHandlers(type);
|
||||||
|
window.__VERSTAK_EVENT_HANDLERS__[type] = current.filter(function(item) {
|
||||||
|
return item !== handler;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
notifications: {
|
||||||
|
replace: function(items) {
|
||||||
|
assertActive('notifications.replace');
|
||||||
|
if (!Array.isArray(items)) {
|
||||||
|
throw new Error('notifications.replace requires an array');
|
||||||
|
}
|
||||||
|
return callBackendErrorString(pluginId, 'notifications.replace', function() {
|
||||||
|
return App.ReplacePluginNotifications(pluginId, items);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
clear: function() {
|
||||||
|
assertActive('notifications.clear');
|
||||||
|
return callBackendErrorString(pluginId, 'notifications.clear', function() {
|
||||||
|
return App.ClearPluginNotifications(pluginId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
settings: {
|
||||||
|
read: async function(key) {
|
||||||
|
assertActive('settings.read');
|
||||||
|
const settings = await callBackend(pluginId, 'settings.read', function() {
|
||||||
|
return App.ReadPluginSettings(pluginId);
|
||||||
|
});
|
||||||
|
if (!key) {
|
||||||
|
return settings || {};
|
||||||
|
}
|
||||||
|
return settings ? settings[key] : undefined;
|
||||||
|
},
|
||||||
|
write: async function(key, value) {
|
||||||
|
assertActive('settings.write(' + key + ')');
|
||||||
|
if (!key) {
|
||||||
|
throw new Error('settings.write requires a key');
|
||||||
|
}
|
||||||
|
await callBackendErrorString(pluginId, 'settings.write(' + key + ')', function() {
|
||||||
|
return App.WritePluginSetting(pluginId, key, value);
|
||||||
|
});
|
||||||
|
return this.read();
|
||||||
|
},
|
||||||
|
writeAll: function(settings) {
|
||||||
|
assertActive('settings.writeAll');
|
||||||
|
return callBackendErrorString(pluginId, 'settings.writeAll', function() {
|
||||||
|
return App.WritePluginSettings(pluginId, settings || {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
storage: {
|
||||||
|
data: {
|
||||||
|
read: function(name) {
|
||||||
|
assertActive('storage.data.read(' + name + ')');
|
||||||
|
if (!name) {
|
||||||
|
throw new Error('storage.data.read requires a name');
|
||||||
|
}
|
||||||
|
return callBackend(pluginId, 'storage.data.read(' + name + ')', function() {
|
||||||
|
return App.ReadPluginDataJSON(pluginId, name);
|
||||||
|
}).then(function(data) {
|
||||||
|
return data || {};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
readNDJSON: function(name) {
|
||||||
|
assertActive('storage.data.readNDJSON(' + name + ')');
|
||||||
|
if (!name) {
|
||||||
|
throw new Error('storage.data.readNDJSON requires a name');
|
||||||
|
}
|
||||||
|
return callBackend(pluginId, 'storage.data.readNDJSON(' + name + ')', function() {
|
||||||
|
return App.ReadPluginDataNDJSON(pluginId, name);
|
||||||
|
}).then(function(records) {
|
||||||
|
return Array.isArray(records) ? records : [];
|
||||||
|
});
|
||||||
|
},
|
||||||
|
writeNDJSON: function(name, records) {
|
||||||
|
assertActive('storage.data.writeNDJSON(' + name + ')');
|
||||||
|
if (!name) {
|
||||||
|
throw new Error('storage.data.writeNDJSON requires a name');
|
||||||
|
}
|
||||||
|
return callBackendErrorString(pluginId, 'storage.data.writeNDJSON(' + name + ')', function() {
|
||||||
|
return App.WritePluginDataNDJSON(pluginId, name, Array.isArray(records) ? records : []);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
write: function(name, data) {
|
||||||
|
assertActive('storage.data.write(' + name + ')');
|
||||||
|
if (!name) {
|
||||||
|
throw new Error('storage.data.write requires a name');
|
||||||
|
}
|
||||||
|
return callBackendErrorString(pluginId, 'storage.data.write(' + name + ')', function() {
|
||||||
|
return App.WritePluginDataJSON(pluginId, name, data || {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
secrets: {
|
||||||
|
status: function() {
|
||||||
|
assertActive('secrets.status');
|
||||||
|
return callBackend(pluginId, 'secrets.status', function() {
|
||||||
|
return App.PluginSecretsStatus(pluginId);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
unlock: function(masterPassword) {
|
||||||
|
assertActive('secrets.unlock');
|
||||||
|
return callBackendErrorString(pluginId, 'secrets.unlock', function() {
|
||||||
|
return App.PluginSecretsUnlock(pluginId, String(masterPassword == null ? '' : masterPassword));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
list: function() {
|
||||||
|
assertActive('secrets.list');
|
||||||
|
return callBackend(pluginId, 'secrets.list', function() {
|
||||||
|
return App.PluginSecretsList(pluginId);
|
||||||
|
}).then(function(records) {
|
||||||
|
return Array.isArray(records) ? records : [];
|
||||||
|
});
|
||||||
|
},
|
||||||
|
read: function(secretId) {
|
||||||
|
assertActive('secrets.read(' + secretId + ')');
|
||||||
|
if (!secretId) {
|
||||||
|
throw new Error('secrets.read requires a secret id');
|
||||||
|
}
|
||||||
|
return callBackend(pluginId, 'secrets.read(' + secretId + ')', function() {
|
||||||
|
return App.PluginSecretsRead(pluginId, secretId);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
write: function(record) {
|
||||||
|
assertActive('secrets.write');
|
||||||
|
return callBackend(pluginId, 'secrets.write', function() {
|
||||||
|
return App.PluginSecretsWrite(pluginId, record || {});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
delete: function(secretId) {
|
||||||
|
assertActive('secrets.delete(' + secretId + ')');
|
||||||
|
if (!secretId) {
|
||||||
|
throw new Error('secrets.delete requires a secret id');
|
||||||
|
}
|
||||||
|
return callBackendErrorString(pluginId, 'secrets.delete(' + secretId + ')', function() {
|
||||||
|
return App.PluginSecretsDelete(pluginId, secretId);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
copyLink: function(secretId) {
|
||||||
|
assertActive('secrets.copyLink(' + secretId + ')');
|
||||||
|
if (!secretId) {
|
||||||
|
throw new Error('secrets.copyLink requires a secret id');
|
||||||
|
}
|
||||||
|
return callBackend(pluginId, 'secrets.copyLink(' + secretId + ')', function() {
|
||||||
|
return App.PluginSecretsCopyLink(pluginId, secretId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
files: {
|
||||||
|
list: function(relativeDir) {
|
||||||
|
assertActive('files.list');
|
||||||
|
return callBackend(pluginId, 'files.list(' + (relativeDir || '') + ')', function() {
|
||||||
|
return App.ListVaultFiles(pluginId, relativeDir || '');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
metadata: function(relativePath) {
|
||||||
|
assertActive('files.metadata(' + relativePath + ')');
|
||||||
|
return callBackend(pluginId, 'files.metadata(' + relativePath + ')', function() {
|
||||||
|
return App.GetVaultFileMetadata(pluginId, relativePath);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
readText: function(relativePath) {
|
||||||
|
assertActive('files.readText(' + relativePath + ')');
|
||||||
|
return callBackend(pluginId, 'files.readText(' + relativePath + ')', function() {
|
||||||
|
return App.ReadVaultTextFile(pluginId, relativePath);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
readBytes: function(relativePath) {
|
||||||
|
assertActive('files.readBytes(' + relativePath + ')');
|
||||||
|
return callBackend(pluginId, 'files.readBytes(' + relativePath + ')', function() {
|
||||||
|
return App.ReadVaultFileBytes(pluginId, relativePath);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
writeText: function(relativePath, content, options) {
|
||||||
|
assertActive('files.writeText(' + relativePath + ')');
|
||||||
|
return callBackendErrorString(pluginId, 'files.writeText(' + relativePath + ')', function() {
|
||||||
|
return App.WriteVaultTextFile(pluginId, relativePath, String(content == null ? '' : content), options || {});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
writeBytes: function(relativePath, dataBase64, options) {
|
||||||
|
assertActive('files.writeBytes(' + relativePath + ')');
|
||||||
|
return callBackendErrorString(pluginId, 'files.writeBytes(' + relativePath + ')', function() {
|
||||||
|
return App.WriteVaultFileBytes(pluginId, relativePath, String(dataBase64 == null ? '' : dataBase64), options || {});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
createFolder: function(relativePath) {
|
||||||
|
assertActive('files.createFolder(' + relativePath + ')');
|
||||||
|
return callBackendErrorString(pluginId, 'files.createFolder(' + relativePath + ')', function() {
|
||||||
|
return App.CreateVaultFolder(pluginId, relativePath);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
move: function(fromRelativePath, toRelativePath, options) {
|
||||||
|
assertActive('files.move(' + fromRelativePath + ')');
|
||||||
|
return callBackendErrorString(pluginId, 'files.move(' + fromRelativePath + ')', function() {
|
||||||
|
return App.MoveVaultPath(pluginId, fromRelativePath, toRelativePath, options || {});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
trash: function(relativePath) {
|
||||||
|
assertActive('files.trash(' + relativePath + ')');
|
||||||
|
return callBackend(pluginId, 'files.trash(' + relativePath + ')', function() {
|
||||||
|
return App.TrashVaultPath(pluginId, relativePath);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
listTrash: function() {
|
||||||
|
assertActive('files.listTrash');
|
||||||
|
return callBackend(pluginId, 'files.listTrash', function() {
|
||||||
|
return App.ListVaultTrash(pluginId);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
restoreTrash: function(trashId, options) {
|
||||||
|
assertActive('files.restoreTrash(' + trashId + ')');
|
||||||
|
return callBackend(pluginId, 'files.restoreTrash(' + trashId + ')', function() {
|
||||||
|
return App.RestoreVaultTrash(pluginId, trashId, options || {});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
deleteTrash: function(trashId) {
|
||||||
|
assertActive('files.deleteTrash(' + trashId + ')');
|
||||||
|
return callBackendErrorString(pluginId, 'files.deleteTrash(' + trashId + ')', function() {
|
||||||
|
return App.DeleteVaultTrash(pluginId, trashId);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
openExternal: function(relativePath) {
|
||||||
|
assertActive('files.openExternal(' + relativePath + ')');
|
||||||
|
return callBackendErrorString(pluginId, 'files.openExternal(' + relativePath + ')', function() {
|
||||||
|
return App.OpenVaultPathExternal(pluginId, relativePath);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
openURL: function(url) {
|
||||||
|
assertActive('files.openURL');
|
||||||
|
return callBackendErrorString(pluginId, 'files.openURL', function() {
|
||||||
|
return App.OpenExternalURL(pluginId, String(url == null ? '' : url));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
showInFolder: function(relativePath) {
|
||||||
|
assertActive('files.showInFolder(' + relativePath + ')');
|
||||||
|
return callBackendErrorString(pluginId, 'files.showInFolder(' + relativePath + ')', function() {
|
||||||
|
return App.ShowVaultPathInFolder(pluginId, relativePath);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
sync: {
|
||||||
|
status: function() {
|
||||||
|
assertActive('sync.status');
|
||||||
|
return callBackend(pluginId, 'sync.status', function() {
|
||||||
|
return App.PluginSyncStatus(pluginId);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
configure: function(serverURL, username, password) {
|
||||||
|
assertActive('sync.configure');
|
||||||
|
return callBackendErrorString(pluginId, 'sync.configure', function() {
|
||||||
|
return App.PluginSyncConfigure(pluginId, serverURL || '', username || '', password || '');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
disconnect: function() {
|
||||||
|
assertActive('sync.disconnect');
|
||||||
|
return callBackendErrorString(pluginId, 'sync.disconnect', function() {
|
||||||
|
return App.PluginSyncDisconnect(pluginId);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
testConnection: function(serverURL, username, password) {
|
||||||
|
assertActive('sync.testConnection');
|
||||||
|
return callBackendErrorString(pluginId, 'sync.testConnection', function() {
|
||||||
|
return App.PluginSyncTestConnection(pluginId, serverURL || '', username || '', password || '');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
setInterval: function(minutes) {
|
||||||
|
assertActive('sync.setInterval');
|
||||||
|
return callBackendErrorString(pluginId, 'sync.setInterval', function() {
|
||||||
|
return App.PluginSyncSetInterval(pluginId, Number(minutes) || 0);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
resetKey: function() {
|
||||||
|
assertActive('sync.resetKey');
|
||||||
|
return callBackendErrorString(pluginId, 'sync.resetKey', function() {
|
||||||
|
return App.PluginSyncResetKey(pluginId);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
now: function() {
|
||||||
|
assertActive('sync.now');
|
||||||
|
return callBackend(pluginId, 'sync.now', function() {
|
||||||
|
return App.PluginSyncNow(pluginId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
browserReceiver: {
|
||||||
|
pairing: function() {
|
||||||
|
assertActive('browserReceiver.pairing');
|
||||||
|
return callBackend(pluginId, 'browserReceiver.pairing', function() {
|
||||||
|
return App.PluginBrowserReceiverPairing(pluginId);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
rotateToken: function() {
|
||||||
|
assertActive('browserReceiver.rotateToken');
|
||||||
|
return callBackend(pluginId, 'browserReceiver.rotateToken', function() {
|
||||||
|
return App.PluginRotateBrowserReceiverToken(pluginId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
workbench: {
|
||||||
|
openResource: async function(request) {
|
||||||
|
assertActive('workbench.openResource');
|
||||||
|
const result = await callBackend(pluginId, 'workbench.openResource', function() {
|
||||||
|
return App.OpenWorkbenchResource(pluginId, request || {});
|
||||||
|
});
|
||||||
|
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
editResource: async function(request) {
|
||||||
|
assertActive('workbench.editResource');
|
||||||
|
const result = await callBackend(pluginId, 'workbench.editResource', function() {
|
||||||
|
return App.EditWorkbenchResource(pluginId, request || {});
|
||||||
|
});
|
||||||
|
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
contributions: {
|
||||||
|
list: async function(point) {
|
||||||
|
assertActive('contributions.list');
|
||||||
|
const summary = await callBackend(pluginId, 'contributions.list', function() {
|
||||||
|
return App.GetContributions();
|
||||||
|
});
|
||||||
|
if (!point) {
|
||||||
|
return summary || {};
|
||||||
|
}
|
||||||
|
return Array.isArray((summary || {})[point]) ? summary[point] : [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
commands: {
|
||||||
|
register: function(cmdId, handler) {
|
||||||
|
assertActive('commands.register(' + cmdId + ')');
|
||||||
|
if (!cmdId) {
|
||||||
|
throw new Error('commands.register requires a command id');
|
||||||
|
}
|
||||||
|
if (typeof handler !== 'function') {
|
||||||
|
throw new Error('commands.register requires a handler function');
|
||||||
|
}
|
||||||
|
return callBackend(pluginId, 'commands.register(' + cmdId + ')', function() {
|
||||||
|
return App.ExecutePluginCommand(pluginId, cmdId, { validateOnly: true });
|
||||||
|
}).then(function() {
|
||||||
|
const key = commandKey(pluginId, cmdId);
|
||||||
|
window.__VERSTAK_COMMAND_HANDLERS__[key] = handler;
|
||||||
|
return trackCleanup(function unregisterCommand() {
|
||||||
|
if (window.__VERSTAK_COMMAND_HANDLERS__[key] === handler) {
|
||||||
|
delete window.__VERSTAK_COMMAND_HANDLERS__[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
execute: async function(cmdId, args) {
|
||||||
|
assertActive('commands.execute(' + cmdId + ')');
|
||||||
|
return executePluginCommand(pluginId, cmdId, args || {});
|
||||||
|
},
|
||||||
|
executeFor: async function(targetPluginId, cmdId, args) {
|
||||||
|
assertActive('commands.executeFor(' + targetPluginId + ':' + cmdId + ')');
|
||||||
|
return executePluginCommand(targetPluginId, cmdId, args || {});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
dispose: function() {
|
||||||
|
if (disposed) return;
|
||||||
|
disposed = true;
|
||||||
|
while (cleanups.length > 0) {
|
||||||
|
const cleanup = cleanups.pop();
|
||||||
|
try {
|
||||||
|
cleanup();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[VerstakPluginAPI] cleanup error:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
window.createPluginAPI = createPluginAPI;
|
||||||
|
window.VerstakPluginAPI = createPluginAPI;
|
||||||
@@ -1,10 +1,26 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import Icon from '../ui/Icon.svelte';
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
|
import { i18n } from '../i18n/index.js';
|
||||||
export let p = {};
|
export let p = {};
|
||||||
export let capabilities = [];
|
export let capabilities = [];
|
||||||
export let permissions = [];
|
export let permissions = [];
|
||||||
export let onSettings = () => {};
|
export let contributions = {};
|
||||||
|
export let vaultOpen = false;
|
||||||
|
export let settingsPanels = [];
|
||||||
|
export let onEnable = () => {};
|
||||||
|
export let onDisable = () => {};
|
||||||
|
let locale = i18n.getLocale();
|
||||||
|
const unsubscribeLocale = i18n.subscribe((nextLocale) => { locale = nextLocale; });
|
||||||
|
$: tr = ((activeLocale) => (key, params, fallback) => {
|
||||||
|
void activeLocale;
|
||||||
|
return i18n.t(key, params, fallback);
|
||||||
|
})(locale);
|
||||||
|
onDestroy(unsubscribeLocale);
|
||||||
|
|
||||||
$: hasSettingsPanel = (contributions.settingsPanels || []).some(sp => sp.pluginId === pluginId);
|
$: m = p.manifest || {};
|
||||||
|
$: pluginId = m.id || 'unknown';
|
||||||
|
$: hasSettingsPanel = settingsPanels.length > 0;
|
||||||
$: hasUIPermission = (m.permissions || []).includes('ui.register');
|
$: hasUIPermission = (m.permissions || []).includes('ui.register');
|
||||||
$: hasStoragePermission = (m.permissions || []).includes('storage.namespace');
|
$: hasStoragePermission = (m.permissions || []).includes('storage.namespace');
|
||||||
$: hasCommandsPermission = (m.permissions || []).includes('commands.register');
|
$: hasCommandsPermission = (m.permissions || []).includes('commands.register');
|
||||||
@@ -20,22 +36,26 @@
|
|||||||
discovered: '#a0a0b8',
|
discovered: '#a0a0b8',
|
||||||
}[p.status] || '#a0a0b8');
|
}[p.status] || '#a0a0b8');
|
||||||
|
|
||||||
$: pluginId = m.id || 'unknown';
|
|
||||||
|
|
||||||
$: contribCounts = {
|
$: contribCounts = {
|
||||||
views: (contributions.views || []).filter(v => v.pluginId === pluginId).length,
|
views: (contributions.views || []).filter(v => v.pluginId === pluginId).length,
|
||||||
commands: (contributions.commands || []).filter(c => c.pluginId === pluginId).length,
|
commands: (contributions.commands || []).filter(c => c.pluginId === pluginId).length,
|
||||||
|
searchProviders: (contributions.searchProviders || []).filter(s => s.pluginId === pluginId).length,
|
||||||
sidebar: (contributions.sidebarItems || []).filter(s => s.pluginId === pluginId).length,
|
sidebar: (contributions.sidebarItems || []).filter(s => s.pluginId === pluginId).length,
|
||||||
statusbar: (contributions.statusBarItems || []).filter(s => s.pluginId === pluginId).length,
|
statusbar: (contributions.statusBarItems || []).filter(s => s.pluginId === pluginId).length,
|
||||||
|
openProviders: (contributions.openProviders || []).filter(o => o.pluginId === pluginId).length,
|
||||||
|
workspaceItems: (contributions.workspaceItems || []).filter(w => w.pluginId === pluginId).length,
|
||||||
};
|
};
|
||||||
|
|
||||||
$: contribSummary = (() => {
|
$: contribSummary = (() => {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
if (contribCounts.views > 0) parts.push(contribCounts.views + ' view' + (contribCounts.views !== 1 ? 's' : ''));
|
if (contribCounts.views > 0) parts.push(tr('pluginCard.count.views', { count: contribCounts.views }));
|
||||||
if (contribCounts.commands > 0) parts.push(contribCounts.commands + ' command' + (contribCounts.commands !== 1 ? 's' : ''));
|
if (contribCounts.commands > 0) parts.push(tr('pluginCard.count.commands', { count: contribCounts.commands }));
|
||||||
if (contribCounts.sidebar > 0) parts.push(contribCounts.sidebar + ' sidebar' + (contribCounts.sidebar !== 1 ? 's' : ''));
|
if (contribCounts.searchProviders > 0) parts.push(tr('pluginCard.count.searchProviders', { count: contribCounts.searchProviders }));
|
||||||
if (contribCounts.statusbar > 0) parts.push(contribCounts.statusbar + ' statusbar' + (contribCounts.statusbar !== 1 ? 's' : ''));
|
if (contribCounts.sidebar > 0) parts.push(tr('pluginCard.count.sidebar', { count: contribCounts.sidebar }));
|
||||||
return parts.length > 0 ? parts.join(', ') : 'none';
|
if (contribCounts.statusbar > 0) parts.push(tr('pluginCard.count.statusbar', { count: contribCounts.statusbar }));
|
||||||
|
if (contribCounts.openProviders > 0) parts.push(tr('pluginCard.count.openProviders', { count: contribCounts.openProviders }));
|
||||||
|
if (contribCounts.workspaceItems > 0) parts.push(tr('pluginCard.count.workspace', { count: contribCounts.workspaceItems }));
|
||||||
|
return parts.length > 0 ? parts.join(', ') : tr('common.none');
|
||||||
})();
|
})();
|
||||||
|
|
||||||
$: dangerousPermissions = (m.permissions || []).filter(name => {
|
$: dangerousPermissions = (m.permissions || []).filter(name => {
|
||||||
@@ -54,20 +74,27 @@
|
|||||||
$: missingOptional = (m.optionalRequires || []).filter(opt =>
|
$: missingOptional = (m.optionalRequires || []).filter(opt =>
|
||||||
!capabilities.some(c => c.name === opt)
|
!capabilities.some(c => c.name === opt)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export let actionFeedback = {}; // { [pluginId]: 'enabling' | 'disabling' | null }
|
||||||
|
|
||||||
|
$: isDisabled = p.status === 'disabled' || !p.enabled;
|
||||||
|
$: canToggle = p.status !== 'failed' && p.status !== 'incompatible' && p.status !== 'missing-required-capability' && p.status !== 'discovered';
|
||||||
|
$: isBusy = actionFeedback[pluginId] != null;
|
||||||
|
$: busyAction = actionFeedback[pluginId] || null;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="plugin-card" class:disabled={!p.enabled} class:failed={p.status === 'failed'}>
|
<div class="plugin-card" class:disabled={isDisabled} class:failed={p.status === 'failed'}>
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div class="plugin-id">
|
<div class="plugin-id">
|
||||||
<span class="status-dot" style="background: {statusColor}"></span>
|
<span class="status-dot" style="background: {statusColor}"></span>
|
||||||
<strong>{m.id || 'unknown'}</strong>
|
<strong>{pluginId}</strong>
|
||||||
<span class="version">v{m.version || '?'}</span>
|
<span class="version">v{m.version || '?'}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="status-badge" style="color: {statusColor}">{p.status}</span>
|
<span class="status-badge" style="color: {statusColor}">{tr(`status.${p.status}`, undefined, p.status)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if p.status === 'degraded'}
|
{#if p.status === 'degraded'}
|
||||||
<p class="degraded-text">Plugin is usable, but some optional capabilities are unavailable.</p>
|
<p class="degraded-text">{tr('pluginCard.degraded')}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if m.description}
|
{#if m.description}
|
||||||
@@ -76,40 +103,44 @@
|
|||||||
|
|
||||||
<div class="card-meta">
|
<div class="card-meta">
|
||||||
<div class="meta-row">
|
<div class="meta-row">
|
||||||
<span class="label">Name:</span>
|
<span class="label">{tr('pluginCard.name')}:</span>
|
||||||
<span>{m.name || '-'}</span>
|
<span>{m.name || '-'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="meta-row">
|
<div class="meta-row">
|
||||||
<span class="label">API Version:</span>
|
<span class="label">{tr('common.source')}:</span>
|
||||||
<span>{m.apiVersion || '-'}</span>
|
<span>{m.source || tr('common.unknown')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="meta-row">
|
<div class="meta-row">
|
||||||
<span class="label">Source:</span>
|
<span class="label">{tr('pluginCard.contributions')}:</span>
|
||||||
<span>{m.source || 'unknown'}</span>
|
|
||||||
</div>
|
|
||||||
<div class="meta-row">
|
|
||||||
<span class="label">Root:</span>
|
|
||||||
<span class="path">{p.rootPath || '-'}</span>
|
|
||||||
</div>
|
|
||||||
<div class="meta-row">
|
|
||||||
<span class="label">Contributions:</span>
|
|
||||||
<span>{contribSummary}</span>
|
<span>{contribSummary}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Capabilities -->
|
<details class="plugin-details">
|
||||||
<div class="section">
|
<summary>{tr('pluginCard.technicalDetails')}</summary>
|
||||||
<span class="section-title">Provides</span>
|
<div class="card-meta technical-meta">
|
||||||
<div class="tags">
|
<div class="meta-row">
|
||||||
{#each m.provides || [] as cap}
|
<span class="label">{tr('pluginCard.apiVersion')}:</span>
|
||||||
<span class="tag provides">{cap}</span>
|
<span>{m.apiVersion || '-'}</span>
|
||||||
{/each}
|
</div>
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="label">{tr('pluginCard.root')}:</span>
|
||||||
|
<span class="path">{p.rootPath || '-'}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if m.requires && m.requires.length > 0}
|
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<span class="section-title">Requires</span>
|
<span class="section-title">{tr('pluginCard.provides')}</span>
|
||||||
|
<div class="tags">
|
||||||
|
{#each m.provides || [] as cap}
|
||||||
|
<span class="tag provides">{cap}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if m.requires && m.requires.length > 0}
|
||||||
|
<div class="section">
|
||||||
|
<span class="section-title">{tr('pluginCard.requires')}</span>
|
||||||
<div class="tags">
|
<div class="tags">
|
||||||
{#each m.requires as req}
|
{#each m.requires as req}
|
||||||
{@const found = capabilities.some(c => c.name === req)}
|
{@const found = capabilities.some(c => c.name === req)}
|
||||||
@@ -120,14 +151,14 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{#if missingRequired.length > 0}
|
{#if missingRequired.length > 0}
|
||||||
<p class="warning">⚠ Missing required capabilities: {missingRequired.join(', ')}</p>
|
<p class="warning"><Icon name="warning" size={12} /> {tr('pluginCard.missingRequired')}: {missingRequired.join(', ')}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if m.optionalRequires && m.optionalRequires.length > 0}
|
{#if m.optionalRequires && m.optionalRequires.length > 0}
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<span class="section-title">Optional Requires</span>
|
<span class="section-title">{tr('pluginCard.optionalRequires')}</span>
|
||||||
<div class="tags">
|
<div class="tags">
|
||||||
{#each m.optionalRequires as opt}
|
{#each m.optionalRequires as opt}
|
||||||
{@const found = capabilities.some(c => c.name === opt)}
|
{@const found = capabilities.some(c => c.name === opt)}
|
||||||
@@ -138,26 +169,26 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{#if missingOptional.length > 0}
|
{#if missingOptional.length > 0}
|
||||||
<p class="info">ℹ Optional capabilities not available — plugin running in degraded mode</p>
|
<p class="info"><Icon name="warning" size={12} /> {tr('pluginCard.optionalUnavailable')}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Permissions -->
|
{#if m.permissions && m.permissions.length > 0}
|
||||||
{#if m.permissions && m.permissions.length > 0}
|
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<span class="section-title">Permissions</span>
|
<span class="section-title">{tr('pluginCard.permissions')}</span>
|
||||||
<div class="tags">
|
<div class="tags">
|
||||||
{#each m.permissions as perm}
|
{#each m.permissions as perm}
|
||||||
{@const isDangerous = dangerousPermissions.includes(perm)}
|
{@const isDangerous = dangerousPermissions.includes(perm)}
|
||||||
<span class="tag" class:dangerous={isDangerous}>
|
<span class="tag" class:dangerous={isDangerous}>
|
||||||
{perm}
|
{perm}
|
||||||
{#if isDangerous}<span class="danger-icon">⚠</span>{/if}
|
{#if isDangerous}<Icon name="warning" size={12} class="danger-icon" />{/if}
|
||||||
</span>
|
</span>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
</details>
|
||||||
|
|
||||||
<!-- Error -->
|
<!-- Error -->
|
||||||
{#if p.error}
|
{#if p.error}
|
||||||
@@ -167,15 +198,29 @@
|
|||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<div class="card-actions">
|
<div class="card-actions">
|
||||||
{#if hasSettingsPanel}
|
{#if hasSettingsPanel}
|
||||||
<button class="btn-settings" on:click={() => onSettings(m.id)} type="button">
|
<button class="btn-settings" on:click={() => window.dispatchEvent(new CustomEvent('verstak:open-settings', { detail: { pluginId: m.id, panelId: settingsPanels[0]?.id } }))} type="button" disabled={isDisabled || p.status === 'failed'}>
|
||||||
⚙ Settings
|
<Icon name="gear" size={14} /> {tr('settings.title')}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if vaultOpen && canToggle}
|
||||||
|
{#if isDisabled}
|
||||||
|
<button class="btn-enable" on:click={() => onEnable(m.id)} type="button" disabled={isBusy}>
|
||||||
|
{#if busyAction === 'enabling'}{tr('pluginCard.enabling')}{:else}{tr('pluginCard.enable')}{/if}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button class="btn-disable" on:click={() => onDisable(m.id)} type="button" disabled={isBusy}>
|
||||||
|
{#if busyAction === 'disabling'}{tr('pluginCard.disabling')}{:else}{tr('pluginCard.disable')}{/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{#if !vaultOpen && canToggle}
|
||||||
|
<span class="vault-hint">{tr('pluginCard.openVault')}</span>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Permission warnings -->
|
<!-- Permission warnings -->
|
||||||
{#if !hasUIPermission && (m.contributes && (m.contributes.views || m.contributes.sidebarItems || m.contributes.settingsPanels).length > 0)}
|
{#if !hasUIPermission && m.contributes && ((m.contributes.views || []).length > 0 || (m.contributes.sidebarItems || []).length > 0 || (m.contributes.settingsPanels || []).length > 0)}
|
||||||
<p class="warning">⚠ Plugin has UI contributions but lacks ui.register permission</p>
|
<p class="warning"><Icon name="warning" size={12} /> {tr('pluginCard.missingUiPermission')}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -185,6 +230,7 @@
|
|||||||
border: 1px solid #0f3460;
|
border: 1px solid #0f3460;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.plugin-card.disabled {
|
.plugin-card.disabled {
|
||||||
@@ -199,6 +245,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,6 +254,12 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
min-width: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-id strong {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-dot {
|
.status-dot {
|
||||||
@@ -242,15 +296,34 @@
|
|||||||
|
|
||||||
.card-meta {
|
.card-meta {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 0.3rem;
|
gap: 0.3rem;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.technical-meta {
|
||||||
|
margin-top: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-details {
|
||||||
|
margin: 0.6rem 0;
|
||||||
|
padding: 0.45rem 0;
|
||||||
|
border-top: 1px solid rgba(15, 52, 96, 0.75);
|
||||||
|
border-bottom: 1px solid rgba(15, 52, 96, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-details summary {
|
||||||
|
cursor: pointer;
|
||||||
|
color: #8b8ba8;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.meta-row {
|
.meta-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
@@ -262,6 +335,8 @@
|
|||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: #a0a0b8;
|
color: #a0a0b8;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section {
|
.section {
|
||||||
@@ -290,6 +365,8 @@
|
|||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
color: #e0e0e0;
|
color: #e0e0e0;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag.provides {
|
.tag.provides {
|
||||||
@@ -320,7 +397,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.check { color: #4ecca3; margin-left: 2px; }
|
.check { color: #4ecca3; margin-left: 2px; }
|
||||||
.danger-icon { color: #e94560; margin-left: 2px; }
|
:global(.danger-icon) { color: #e94560; margin-left: 2px; vertical-align: middle; }
|
||||||
|
|
||||||
.info {
|
.info {
|
||||||
color: #ffc857;
|
color: #ffc857;
|
||||||
@@ -347,13 +424,18 @@
|
|||||||
|
|
||||||
.card-actions {
|
.card-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
padding-top: 0.5rem;
|
padding-top: 0.5rem;
|
||||||
border-top: 1px solid #0f3460;
|
border-top: 1px solid #0f3460;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-settings {
|
.btn-settings {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
background: #0f3460;
|
background: #0f3460;
|
||||||
border: 1px solid #1a3a5c;
|
border: 1px solid #1a3a5c;
|
||||||
color: #e0e0f0;
|
color: #e0e0f0;
|
||||||
@@ -366,4 +448,55 @@
|
|||||||
.btn-settings:hover {
|
.btn-settings:hover {
|
||||||
background: #1a3a5c;
|
background: #1a3a5c;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-enable {
|
||||||
|
background: #4ecca3;
|
||||||
|
color: #1a1a2e;
|
||||||
|
border: none;
|
||||||
|
padding: 0.3rem 0.75rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-enable:hover {
|
||||||
|
background: #3dbb92;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-disable {
|
||||||
|
background: #533483;
|
||||||
|
color: #e0e0f0;
|
||||||
|
border: none;
|
||||||
|
padding: 0.3rem 0.75rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-disable:hover {
|
||||||
|
background: #6b44a0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-hint {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.card-meta {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-row {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,415 @@
|
|||||||
|
<script>
|
||||||
|
import { onDestroy, onMount, tick } from 'svelte';
|
||||||
|
import * as App from '../../../wailsjs/go/api/App';
|
||||||
|
import { executePluginCommand } from '../plugin-host/VerstakPluginAPI.js';
|
||||||
|
import { i18n } from '../i18n/index.js';
|
||||||
|
import { debug } from '../log/debug.js';
|
||||||
|
|
||||||
|
let open = false;
|
||||||
|
let query = '';
|
||||||
|
let commands = [];
|
||||||
|
let selectedIndex = 0;
|
||||||
|
let inputEl = null;
|
||||||
|
let statusMessage = '';
|
||||||
|
let statusType = '';
|
||||||
|
let locale = i18n.getLocale();
|
||||||
|
let unsubscribeLocale = null;
|
||||||
|
|
||||||
|
$: tr = ((activeLocale) => (key, params, fallback) => {
|
||||||
|
void activeLocale;
|
||||||
|
return i18n.t(key, params, fallback);
|
||||||
|
})(locale);
|
||||||
|
|
||||||
|
const inactiveStatuses = new Set(['disabled', 'failed', 'incompatible', 'missing-required-capability']);
|
||||||
|
$: shellCommands = [
|
||||||
|
{ id: 'verstak.shell.open-overview', title: tr('command.openOverview'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 10, shellAction: 'overview' },
|
||||||
|
{ id: 'verstak.shell.open-files', title: tr('command.openFiles'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 20, shellAction: 'files' },
|
||||||
|
{ id: 'verstak.shell.open-activity', title: tr('command.openActivity'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 30, shellAction: 'activity' },
|
||||||
|
{ id: 'verstak.shell.open-browser-inbox', title: tr('command.openBrowserInbox'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 40, shellAction: 'browser-inbox' },
|
||||||
|
{ id: 'verstak.shell.create-markdown', title: tr('command.createMarkdown'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 50, shellAction: 'create-markdown' },
|
||||||
|
{ id: 'verstak.shell.create-text', title: tr('command.createText'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 60, shellAction: 'create-text' },
|
||||||
|
{ id: 'verstak.shell.sync-now', title: tr('command.syncNow'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 70, shellAction: 'sync-now' },
|
||||||
|
{ id: 'verstak.shell.open-sync-settings', title: tr('command.openSyncSettings'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 80, shellAction: 'sync-settings' },
|
||||||
|
{ id: 'verstak.shell.open-plugin-manager', title: tr('command.openPluginManager'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 90, shellAction: 'plugin-manager' },
|
||||||
|
];
|
||||||
|
|
||||||
|
$: normalizedQuery = query.trim().toLowerCase();
|
||||||
|
$: filteredCommands = commands.filter((command) => {
|
||||||
|
if (!normalizedQuery) return true;
|
||||||
|
return [
|
||||||
|
command.title,
|
||||||
|
command.id,
|
||||||
|
command.pluginId,
|
||||||
|
command.pluginName,
|
||||||
|
].filter(Boolean).join(' ').toLowerCase().includes(normalizedQuery);
|
||||||
|
});
|
||||||
|
$: if (selectedIndex >= filteredCommands.length) {
|
||||||
|
selectedIndex = Math.max(0, filteredCommands.length - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCommands() {
|
||||||
|
const [plugins, contributions] = await Promise.all([
|
||||||
|
App.GetPlugins().catch(() => []),
|
||||||
|
App.GetContributions().catch(() => ({})),
|
||||||
|
]);
|
||||||
|
const pluginById = new Map((plugins || []).map((plugin) => [plugin.manifest?.id, plugin]));
|
||||||
|
await Promise.all((plugins || []).map((plugin) => i18n.loadPlugin(
|
||||||
|
plugin.manifest?.id,
|
||||||
|
plugin.manifest?.localization,
|
||||||
|
).catch(() => {})));
|
||||||
|
const localizedPlugins = (plugins || []).map((plugin) => i18n.localizePlugin(plugin));
|
||||||
|
const localizedPluginById = new Map(localizedPlugins.map((plugin) => [plugin.manifest?.id, plugin]));
|
||||||
|
const localizedContributions = i18n.localizeContributionSummary(contributions || {});
|
||||||
|
const pluginCommands = (localizedContributions.commands || [])
|
||||||
|
.filter((command) => {
|
||||||
|
const plugin = pluginById.get(command.pluginId);
|
||||||
|
if (!plugin) return false;
|
||||||
|
return !inactiveStatuses.has(plugin.status);
|
||||||
|
})
|
||||||
|
.map((command) => {
|
||||||
|
const plugin = localizedPluginById.get(command.pluginId);
|
||||||
|
return {
|
||||||
|
...command,
|
||||||
|
pluginName: plugin?.manifest?.name || command.pluginId,
|
||||||
|
priority: 1000,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
commands = [...shellCommands, ...pluginCommands].sort((a, b) => {
|
||||||
|
const priority = (a.priority || 1000) - (b.priority || 1000);
|
||||||
|
if (priority) return priority;
|
||||||
|
const title = String(a.title || a.id).localeCompare(String(b.title || b.id));
|
||||||
|
if (title) return title;
|
||||||
|
return String(a.pluginId).localeCompare(String(b.pluginId));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openPalette() {
|
||||||
|
await loadCommands();
|
||||||
|
query = '';
|
||||||
|
selectedIndex = 0;
|
||||||
|
open = true;
|
||||||
|
await tick();
|
||||||
|
inputEl?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePalette() {
|
||||||
|
open = false;
|
||||||
|
query = '';
|
||||||
|
selectedIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(type, message) {
|
||||||
|
statusType = type;
|
||||||
|
statusMessage = message;
|
||||||
|
window.clearTimeout(setStatus.timer);
|
||||||
|
setStatus.timer = window.setTimeout(() => {
|
||||||
|
statusType = '';
|
||||||
|
statusMessage = '';
|
||||||
|
}, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clickWorkspaceTool(label) {
|
||||||
|
const tabs = Array.from(document.querySelectorAll('.workspace-tabs [role="tab"]'));
|
||||||
|
const tab = tabs.find((node) => String(node.textContent || '').trim().toLowerCase() === label);
|
||||||
|
if (tab) {
|
||||||
|
tab.click();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openWorkspaceTool(label) {
|
||||||
|
if (clickWorkspaceTool(label)) return;
|
||||||
|
const selectedWorkspace = document.querySelector('.wt-node.selected .wt-label');
|
||||||
|
const firstWorkspace = document.querySelector('.wt-label');
|
||||||
|
const workspaceButton = selectedWorkspace || firstWorkspace;
|
||||||
|
if (workspaceButton) {
|
||||||
|
workspaceButton.click();
|
||||||
|
await tick();
|
||||||
|
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||||
|
}
|
||||||
|
clickWorkspaceTool(label);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startFilesCreate(action) {
|
||||||
|
await openWorkspaceTool('files');
|
||||||
|
await tick();
|
||||||
|
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||||
|
const button = document.querySelector(`[data-files-action="${action}"]`);
|
||||||
|
if (!button) {
|
||||||
|
throw new Error(`Files action not available: ${action}`);
|
||||||
|
}
|
||||||
|
button.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runShellCommand(command) {
|
||||||
|
if (command.shellAction === 'plugin-manager') {
|
||||||
|
window.dispatchEvent(new CustomEvent('verstak:open-settings', { detail: {} }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command.shellAction === 'sync-settings') {
|
||||||
|
window.dispatchEvent(new CustomEvent('verstak:open-settings', {
|
||||||
|
detail: { pluginId: 'verstak.sync', panelId: 'verstak.sync.settings' }
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command.shellAction === 'sync-now') {
|
||||||
|
const result = await App.PluginSyncNow('verstak.sync');
|
||||||
|
if (typeof result === 'string' && result) {
|
||||||
|
throw new Error(result);
|
||||||
|
}
|
||||||
|
if (Array.isArray(result) && result[1]) {
|
||||||
|
throw new Error(result[1]);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command.shellAction === 'create-markdown') {
|
||||||
|
await startFilesCreate('new-markdown');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command.shellAction === 'create-text') {
|
||||||
|
await startFilesCreate('new-text');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const actionToTab = {
|
||||||
|
overview: 'overview',
|
||||||
|
files: 'files',
|
||||||
|
activity: 'activity',
|
||||||
|
'browser-inbox': 'browser inbox',
|
||||||
|
};
|
||||||
|
await openWorkspaceTool(actionToTab[command.shellAction] || command.shellAction);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runCommand(command) {
|
||||||
|
if (!command) return;
|
||||||
|
try {
|
||||||
|
if (command.shellAction) {
|
||||||
|
await runShellCommand(command);
|
||||||
|
closePalette();
|
||||||
|
setStatus('success', tr('command.handled', { title: command.title || command.id }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await executePluginCommand(command.pluginId, command.id, {
|
||||||
|
source: 'command-palette',
|
||||||
|
});
|
||||||
|
closePalette();
|
||||||
|
setStatus('success', tr('command.result', { title: command.title || command.id, status: result.status || tr('command.statusHandled') }));
|
||||||
|
} catch (err) {
|
||||||
|
const details = err?.message || String(err);
|
||||||
|
debug.log('[CommandPalette] command failed:', details);
|
||||||
|
App.WriteFrontendLog('CommandPalette', `command failed: ${details}`).catch(() => {});
|
||||||
|
setStatus('error', tr('command.failed', {
|
||||||
|
title: command.title || command.id,
|
||||||
|
}, `Could not run ${command.title || command.id}. Please try again.`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveSelection(delta) {
|
||||||
|
if (filteredCommands.length === 0) return;
|
||||||
|
selectedIndex = (selectedIndex + delta + filteredCommands.length) % filteredCommands.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWindowKeydown(event) {
|
||||||
|
const key = event.key || '';
|
||||||
|
const comboOpen = (event.ctrlKey || event.metaKey) && (key.toLowerCase() === 'k' || (event.shiftKey && key.toLowerCase() === 'p'));
|
||||||
|
if (!open && comboOpen) {
|
||||||
|
event.preventDefault();
|
||||||
|
openPalette();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
if (key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
closePalette();
|
||||||
|
} else if (key === 'ArrowDown') {
|
||||||
|
event.preventDefault();
|
||||||
|
moveSelection(1);
|
||||||
|
} else if (key === 'ArrowUp') {
|
||||||
|
event.preventDefault();
|
||||||
|
moveSelection(-1);
|
||||||
|
} else if (key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
runCommand(filteredCommands[selectedIndex]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onOverlayMouseDown(event) {
|
||||||
|
if (event.target === event.currentTarget) closePalette();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.addEventListener('keydown', onWindowKeydown);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
unsubscribeLocale = i18n.subscribe((nextLocale) => {
|
||||||
|
const changed = locale !== nextLocale;
|
||||||
|
locale = nextLocale;
|
||||||
|
if (changed && open) loadCommands();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
unsubscribeLocale?.();
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.removeEventListener('keydown', onWindowKeydown);
|
||||||
|
window.clearTimeout(setStatus.timer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if statusMessage}
|
||||||
|
<div
|
||||||
|
class="command-palette-toast"
|
||||||
|
data-command-palette-status={statusType}
|
||||||
|
>
|
||||||
|
{statusMessage}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<div class="command-palette-overlay" role="presentation" on:mousedown={onOverlayMouseDown}>
|
||||||
|
<section class="command-palette" role="dialog" aria-modal="true" aria-label={tr('command.palette')}>
|
||||||
|
<input
|
||||||
|
bind:this={inputEl}
|
||||||
|
bind:value={query}
|
||||||
|
class="command-palette-input"
|
||||||
|
data-command-palette-input
|
||||||
|
placeholder={tr('command.run')}
|
||||||
|
aria-label={tr('command.run')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="command-palette-list" role="listbox">
|
||||||
|
{#if filteredCommands.length === 0}
|
||||||
|
<div class="command-palette-empty">{tr('command.none')}</div>
|
||||||
|
{:else}
|
||||||
|
{#each filteredCommands as command, index}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class:selected={index === selectedIndex}
|
||||||
|
class="command-palette-item"
|
||||||
|
data-command-id={command.id}
|
||||||
|
on:mouseenter={() => selectedIndex = index}
|
||||||
|
on:click={() => runCommand(command)}
|
||||||
|
>
|
||||||
|
<span class="command-palette-title">{command.title || command.id}</span>
|
||||||
|
<span class="command-palette-meta">{command.pluginName} · {command.id}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.command-palette-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 10000;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 10vh 1rem 1rem;
|
||||||
|
background: rgba(0, 0, 0, 0.48);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette {
|
||||||
|
width: min(640px, 100%);
|
||||||
|
max-height: min(560px, 80vh);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #254466;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #101526;
|
||||||
|
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-input {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 3rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid #213650;
|
||||||
|
background: #0b1020;
|
||||||
|
color: #f4f7fb;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-list {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-item {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 3rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.18rem;
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: #e0e0f0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-item:hover,
|
||||||
|
.command-palette-item.selected {
|
||||||
|
border-color: #4ecca3;
|
||||||
|
background: #17243a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-title {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-meta {
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #8da2bd;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-empty {
|
||||||
|
padding: 1.25rem;
|
||||||
|
color: #8da2bd;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-toast {
|
||||||
|
position: fixed;
|
||||||
|
right: 1rem;
|
||||||
|
bottom: 1rem;
|
||||||
|
z-index: 10001;
|
||||||
|
max-width: min(420px, calc(100vw - 2rem));
|
||||||
|
padding: 0.7rem 0.9rem;
|
||||||
|
border: 1px solid #254466;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #101526;
|
||||||
|
color: #e0e0f0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-toast[data-command-palette-status="success"] {
|
||||||
|
border-color: #4ecca3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-toast[data-command-palette-status="error"] {
|
||||||
|
border-color: #e74c3c;
|
||||||
|
color: #ffd6d1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,443 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import * as App from '../../../wailsjs/go/api/App';
|
||||||
|
import Icon from '../ui/Icon.svelte';
|
||||||
|
import { i18n } from '../i18n/index.js';
|
||||||
|
|
||||||
|
const TEXT_EXTENSIONS = new Set(['txt', 'md', 'markdown', 'log', 'json', 'csv', 'yaml', 'yml', 'toml']);
|
||||||
|
const FILE_INDEX_LIMIT = 220;
|
||||||
|
const RESULT_LIMIT = 8;
|
||||||
|
const RU = 'ёйцукенгшщзхъфывапролджэячсмитьбю';
|
||||||
|
const EN = '`qwertyuiop[]asdfghjkl;\\zxcvbnm,.';
|
||||||
|
|
||||||
|
let query = '';
|
||||||
|
let index = [];
|
||||||
|
let results = [];
|
||||||
|
let focused = false;
|
||||||
|
let loading = true;
|
||||||
|
let searchTimer = null;
|
||||||
|
let buildSeq = 0;
|
||||||
|
let locale = i18n.getLocale();
|
||||||
|
|
||||||
|
$: tr = ((activeLocale) => (key, params, fallback) => {
|
||||||
|
void activeLocale;
|
||||||
|
return i18n.t(key, params, fallback);
|
||||||
|
})(locale);
|
||||||
|
|
||||||
|
$: scheduleSearch(query);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const unsubscribeLocale = i18n.subscribe((nextLocale) => {
|
||||||
|
const changed = locale !== nextLocale;
|
||||||
|
locale = nextLocale;
|
||||||
|
if (changed) buildIndex();
|
||||||
|
});
|
||||||
|
buildIndex();
|
||||||
|
return () => {
|
||||||
|
unsubscribeLocale();
|
||||||
|
clearTimeout(searchTimer);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function normalize(value) {
|
||||||
|
return String(value == null ? '' : value).trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function swapLayout(value, from, to) {
|
||||||
|
return String(value || '').split('').map(ch => {
|
||||||
|
const lower = ch.toLowerCase();
|
||||||
|
const idx = from.indexOf(lower);
|
||||||
|
if (idx === -1) return ch;
|
||||||
|
const mapped = to[idx] || ch;
|
||||||
|
return ch === lower ? mapped : mapped.toUpperCase();
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function queryVariants(value) {
|
||||||
|
const base = normalize(value);
|
||||||
|
return [...new Set([
|
||||||
|
base,
|
||||||
|
normalize(swapLayout(base, RU, EN)),
|
||||||
|
normalize(swapLayout(base, EN, RU)),
|
||||||
|
].filter(Boolean))];
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchScore(item, variants) {
|
||||||
|
const haystack = normalize(`${item.title} ${item.subtitle || ''} ${item.keywords || ''}`);
|
||||||
|
for (const variant of variants) {
|
||||||
|
if (!variant) continue;
|
||||||
|
if (normalize(item.title) === variant) return 100;
|
||||||
|
if (normalize(item.title).startsWith(variant)) return 80;
|
||||||
|
if (haystack.includes(variant)) return 50;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleSearch(value) {
|
||||||
|
clearTimeout(searchTimer);
|
||||||
|
searchTimer = setTimeout(() => runSearch(value), 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runSearch(value) {
|
||||||
|
const variants = queryVariants(value);
|
||||||
|
if (!variants.length) {
|
||||||
|
results = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
results = index
|
||||||
|
.map(item => ({ item, score: matchScore(item, variants) }))
|
||||||
|
.filter(row => row.score > 0)
|
||||||
|
.sort((a, b) => b.score - a.score || a.item.rank - b.item.rank || a.item.title.localeCompare(b.item.title))
|
||||||
|
.slice(0, RESULT_LIMIT)
|
||||||
|
.map(row => row.item);
|
||||||
|
}
|
||||||
|
|
||||||
|
function workspaceTitle(node) {
|
||||||
|
return node?.title || node?.name || node?.id || node?.rootPath || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function workspaceName(node) {
|
||||||
|
return node?.name || node?.id || node?.rootPath || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resultOrEmpty(promise, fallback) {
|
||||||
|
try {
|
||||||
|
const response = await promise;
|
||||||
|
if (Array.isArray(response) && response.length === 2) return response[1] ? fallback : response[0];
|
||||||
|
return response || fallback;
|
||||||
|
} catch (_) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listFilesRecursive(dir = '', depth = 0, acc = []) {
|
||||||
|
if (acc.length >= FILE_INDEX_LIMIT || depth > 5) return acc;
|
||||||
|
const entries = await resultOrEmpty(App.ListVaultFiles('verstak.search', dir), []);
|
||||||
|
for (const entry of entries || []) {
|
||||||
|
if (acc.length >= FILE_INDEX_LIMIT) break;
|
||||||
|
const path = entry.relativePath || entry.path || entry.name || '';
|
||||||
|
if (!path) continue;
|
||||||
|
acc.push(entry);
|
||||||
|
if (entry.type === 'folder') await listFilesRecursive(path, depth + 1, acc);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readFileSnippet(path) {
|
||||||
|
const ext = String(path).split('.').pop().toLowerCase();
|
||||||
|
if (!TEXT_EXTENSIONS.has(ext)) return '';
|
||||||
|
const text = await resultOrEmpty(App.ReadVaultTextFile('verstak.search', path), '');
|
||||||
|
return String(text || '').slice(0, 900);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pluginToolKind(pluginId, label) {
|
||||||
|
if (pluginId === 'verstak.browser-inbox') return 'browser-inbox';
|
||||||
|
if (pluginId === 'verstak.activity') return 'activity';
|
||||||
|
if (pluginId === 'verstak.journal') return 'journal';
|
||||||
|
return String(label || pluginId || '').toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function indexPluginSettings(pluginId, label, rank, view, nodes) {
|
||||||
|
const settings = await resultOrEmpty(App.ReadPluginSettings(pluginId), {});
|
||||||
|
const items = [];
|
||||||
|
Object.keys(settings || {}).forEach(key => {
|
||||||
|
const value = settings[key];
|
||||||
|
const rows = Array.isArray(value) ? value : [];
|
||||||
|
rows.forEach(row => {
|
||||||
|
if (!row || typeof row !== 'object') return;
|
||||||
|
const title = row.title || row.summary || row.url || row.captureId || row.activityId || row.entryId || label;
|
||||||
|
const workspaceName = row.workspaceRootPath || row.workspaceName || '';
|
||||||
|
items.push({
|
||||||
|
type: label,
|
||||||
|
title,
|
||||||
|
subtitle: row.url || row.summary || row.workspaceRootPath || key,
|
||||||
|
keywords: JSON.stringify(row),
|
||||||
|
rank,
|
||||||
|
action: workspaceName ? 'workspace-tool' : (view ? 'view' : ''),
|
||||||
|
viewId: view?.id || '',
|
||||||
|
pluginId,
|
||||||
|
workspaceName,
|
||||||
|
toolKind: pluginToolKind(pluginId, label),
|
||||||
|
nodes,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildIndex() {
|
||||||
|
const seq = ++buildSeq;
|
||||||
|
loading = true;
|
||||||
|
const next = [];
|
||||||
|
|
||||||
|
const tree = await resultOrEmpty(App.GetWorkspaceTree(), { nodes: [] });
|
||||||
|
const nodes = Array.isArray(tree.nodes) ? tree.nodes : [];
|
||||||
|
nodes.forEach(node => {
|
||||||
|
next.push({
|
||||||
|
type: 'Workspace',
|
||||||
|
typeLabel: tr('search.type.workspace'),
|
||||||
|
title: workspaceTitle(node),
|
||||||
|
subtitle: tr('search.type.workspace'),
|
||||||
|
keywords: `${node.id || ''} ${node.rootPath || ''}`,
|
||||||
|
rank: 10,
|
||||||
|
action: 'workspace',
|
||||||
|
workspaceName: workspaceName(node),
|
||||||
|
nodes,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const [rawPlugins, rawContributions] = await Promise.all([
|
||||||
|
resultOrEmpty(App.GetPlugins(), []),
|
||||||
|
resultOrEmpty(App.GetContributions(), {}),
|
||||||
|
]);
|
||||||
|
await Promise.all((rawPlugins || []).map((plugin) => i18n.loadPlugin(
|
||||||
|
plugin.manifest?.id,
|
||||||
|
plugin.manifest?.localization,
|
||||||
|
).catch(() => {})));
|
||||||
|
const contributions = i18n.localizeContributionSummary(rawContributions || {});
|
||||||
|
const viewByPluginId = new Map();
|
||||||
|
(contributions.views || []).forEach(view => {
|
||||||
|
if (view.pluginId && !viewByPluginId.has(view.pluginId)) viewByPluginId.set(view.pluginId, view);
|
||||||
|
});
|
||||||
|
(contributions.sidebarItems || []).forEach(item => {
|
||||||
|
next.push({
|
||||||
|
type: 'Tool',
|
||||||
|
typeLabel: tr('search.type.tool'),
|
||||||
|
title: item.title || item.id,
|
||||||
|
subtitle: item.pluginId || '',
|
||||||
|
keywords: `${item.id || ''} ${item.view || ''}`,
|
||||||
|
rank: 20,
|
||||||
|
action: 'view',
|
||||||
|
viewId: item.view || item.id,
|
||||||
|
pluginId: item.pluginId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const files = await listFilesRecursive();
|
||||||
|
for (const entry of files) {
|
||||||
|
const path = entry.relativePath || entry.path || entry.name || '';
|
||||||
|
const snippet = await readFileSnippet(path);
|
||||||
|
next.push({
|
||||||
|
type: entry.type === 'folder' ? 'Folder' : 'File',
|
||||||
|
typeLabel: tr(entry.type === 'folder' ? 'search.type.folder' : 'search.type.file'),
|
||||||
|
title: path.split('/').pop() || path,
|
||||||
|
subtitle: path,
|
||||||
|
keywords: snippet,
|
||||||
|
rank: entry.type === 'folder' ? 30 : 40,
|
||||||
|
action: entry.type === 'folder' ? 'file-folder' : 'file',
|
||||||
|
path,
|
||||||
|
nodes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const pluginItems = await Promise.all([
|
||||||
|
indexPluginSettings('verstak.journal', tr('search.type.journal'), 50, viewByPluginId.get('verstak.journal'), nodes),
|
||||||
|
indexPluginSettings('verstak.browser-inbox', tr('search.type.browserInbox'), 55, viewByPluginId.get('verstak.browser-inbox'), nodes),
|
||||||
|
indexPluginSettings('verstak.activity', tr('search.type.activity'), 60, viewByPluginId.get('verstak.activity'), nodes),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (seq !== buildSeq) return;
|
||||||
|
index = next.concat(pluginItems.flat());
|
||||||
|
loading = false;
|
||||||
|
runSearch(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFocus() {
|
||||||
|
focused = true;
|
||||||
|
buildIndex();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openResult(item) {
|
||||||
|
query = '';
|
||||||
|
results = [];
|
||||||
|
if (item.action === 'workspace') {
|
||||||
|
window.dispatchEvent(new CustomEvent('verstak:workspace-selected', {
|
||||||
|
detail: { workspaceName: item.workspaceName, nodes: item.nodes || [] }
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (item.action === 'view') {
|
||||||
|
window.dispatchEvent(new CustomEvent('verstak:open-view', {
|
||||||
|
detail: { viewId: item.viewId, pluginId: item.pluginId }
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (item.action === 'workspace-tool') {
|
||||||
|
const workspaceName = item.workspaceName || '';
|
||||||
|
if (workspaceName) {
|
||||||
|
window.dispatchEvent(new CustomEvent('verstak:workspace-selected', {
|
||||||
|
detail: { workspaceName, nodes: item.nodes || [] }
|
||||||
|
}));
|
||||||
|
window.dispatchEvent(new CustomEvent('verstak:workspace-open-tool', {
|
||||||
|
detail: { kind: item.toolKind || item.type || '' }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (item.action === 'file-folder') {
|
||||||
|
const parts = String(item.path || '').split('/').filter(Boolean);
|
||||||
|
const workspaceName = parts[0] || '';
|
||||||
|
const localPath = parts.slice(1).join('/');
|
||||||
|
if (workspaceName) {
|
||||||
|
window.__filesHistoryByWorkspace = window.__filesHistoryByWorkspace || {};
|
||||||
|
window.__filesHistoryByWorkspace[workspaceName] = {
|
||||||
|
stack: [localPath],
|
||||||
|
index: 0,
|
||||||
|
currentPath: localPath,
|
||||||
|
};
|
||||||
|
const detail = { workspaceName };
|
||||||
|
if (Array.isArray(item.nodes) && item.nodes.length > 0) detail.nodes = item.nodes;
|
||||||
|
window.dispatchEvent(new CustomEvent('verstak:workspace-selected', {
|
||||||
|
detail
|
||||||
|
}));
|
||||||
|
window.dispatchEvent(new CustomEvent('verstak:workspace-open-tool', {
|
||||||
|
detail: { kind: 'files' }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (item.action === 'file') {
|
||||||
|
const response = await App.OpenWorkbenchResource('verstak.search', {
|
||||||
|
kind: 'vault-file',
|
||||||
|
path: item.path,
|
||||||
|
mode: 'view',
|
||||||
|
context: { sourceView: 'global-search' }
|
||||||
|
});
|
||||||
|
const [result, err] = Array.isArray(response) ? response : [response, ''];
|
||||||
|
if (!err && result) {
|
||||||
|
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="global-search" class:open={focused && (query || results.length)}>
|
||||||
|
<div class="global-search-box">
|
||||||
|
<Icon name="search" size={14} class="global-search-icon" />
|
||||||
|
<input
|
||||||
|
bind:value={query}
|
||||||
|
on:focus={handleFocus}
|
||||||
|
on:blur={() => setTimeout(() => focused = false, 120)}
|
||||||
|
type="search"
|
||||||
|
placeholder={loading ? tr('search.indexing') : tr('search.placeholder')}
|
||||||
|
aria-label={tr('search.global')}
|
||||||
|
data-global-search-input
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{#if focused && query}
|
||||||
|
<div class="global-search-results" data-global-search-results>
|
||||||
|
{#if results.length}
|
||||||
|
{#each results as item}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="global-search-result"
|
||||||
|
data-global-search-result-type={item.type}
|
||||||
|
data-global-search-result-path={item.path || ''}
|
||||||
|
on:mousedown|preventDefault={() => openResult(item)}
|
||||||
|
>
|
||||||
|
<span class="global-search-result-title">{item.title}</span>
|
||||||
|
<span class="global-search-result-meta">{item.typeLabel || item.type} · {item.subtitle}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
<div class="global-search-empty vt-empty-title">{tr('search.noResults')}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.global-search {
|
||||||
|
position: relative;
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--vt-color-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-search-box {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
height: 2rem;
|
||||||
|
padding: 0 0.55rem;
|
||||||
|
border: 1px solid var(--vt-color-border-strong);
|
||||||
|
border-radius: var(--vt-radius-md);
|
||||||
|
background: #0f1424;
|
||||||
|
color: var(--vt-color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.global-search-icon) {
|
||||||
|
color: var(--vt-color-text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-search input {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
border: 0;
|
||||||
|
outline: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--vt-color-text-primary);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-search input::placeholder {
|
||||||
|
color: var(--vt-color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-search-box:focus-within {
|
||||||
|
border-color: var(--vt-color-accent);
|
||||||
|
box-shadow: var(--vt-focus-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-search-results {
|
||||||
|
position: absolute;
|
||||||
|
left: 0.75rem;
|
||||||
|
right: 0.75rem;
|
||||||
|
top: calc(100% - 0.25rem);
|
||||||
|
z-index: 400;
|
||||||
|
max-height: 20rem;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--vt-color-border-strong);
|
||||||
|
border-radius: var(--vt-radius-md);
|
||||||
|
background: var(--vt-color-surface);
|
||||||
|
box-shadow: var(--vt-elevation-menu);
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-search-result {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.12rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.55rem 0.65rem;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid rgba(40, 70, 111, 0.55);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--vt-color-text-primary);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-search-result:hover {
|
||||||
|
background: var(--vt-color-surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-search-result-title {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-search-result-meta,
|
||||||
|
.global-search-empty {
|
||||||
|
color: var(--vt-color-text-muted);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-search-empty {
|
||||||
|
padding: 0.7rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,75 +1,285 @@
|
|||||||
<script>
|
<script>
|
||||||
import { 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 GlobalSearch from './GlobalSearch.svelte';
|
||||||
|
import Icon from '../ui/Icon.svelte';
|
||||||
|
import { debug } from '../log/debug.js';
|
||||||
|
import { i18n } from '../i18n/index.js';
|
||||||
|
|
||||||
|
export let showGlobalSearch = true;
|
||||||
|
export let activeView = null;
|
||||||
|
export let activeViewPluginId = '';
|
||||||
|
|
||||||
|
function flog(msg) {
|
||||||
|
App.WriteFrontendLog('Sidebar', msg);
|
||||||
|
}
|
||||||
|
|
||||||
let sidebarItems = [];
|
|
||||||
let activeView = '';
|
|
||||||
let plugins = [];
|
let plugins = [];
|
||||||
let contributions = { sidebarItems: [], views: [], commands: [], settingsPanels: [] };
|
let vaultStatus = { status: 'unknown', path: '', vaultId: '' };
|
||||||
|
let sidebarItems = [];
|
||||||
|
let errorMessage = '';
|
||||||
|
let locale = i18n.getLocale();
|
||||||
|
let unsubscribeLocale = null;
|
||||||
|
|
||||||
onMount(async () => {
|
$: tr = ((activeLocale) => (key, params, fallback) => {
|
||||||
|
void activeLocale;
|
||||||
|
return i18n.t(key, params, fallback);
|
||||||
|
})(locale);
|
||||||
|
|
||||||
|
$: vaultOpen = vaultStatus.status === 'open';
|
||||||
|
$: activeSidebarKey = activeView ? `${activeViewPluginId}:${activeView}` : '';
|
||||||
|
|
||||||
|
async function loadSidebar() {
|
||||||
|
debug.log('[Sidebar] onMount: START');
|
||||||
|
flog('onMount: START');
|
||||||
|
let contribErr = false;
|
||||||
try {
|
try {
|
||||||
const [contribs, pluginList] = await Promise.all([
|
debug.log('[Sidebar] onMount: loading plugins/vault/contributions...');
|
||||||
App.GetContributions(),
|
flog('onMount: loading plugins/vault/contributions...');
|
||||||
App.GetPlugins(),
|
const [p, v, contribs] = await Promise.all([
|
||||||
|
App.GetPlugins().catch(() => []),
|
||||||
|
App.GetVaultStatus().catch(() => ({ status: 'unknown', path: '', vaultId: '' })),
|
||||||
|
App.GetContributions().catch(() => { contribErr = true; return {}; }),
|
||||||
]);
|
]);
|
||||||
contributions = contribs;
|
await Promise.all((p || []).map((plugin) => (
|
||||||
plugins = pluginList;
|
i18n.loadPlugin(plugin.manifest?.id, plugin.manifest?.localization).catch(() => {})
|
||||||
const pluginMap = new Map(pluginList.map(p => [p.manifest.id, p]));
|
)));
|
||||||
sidebarItems = (contribs.sidebarItems || []).filter(item => {
|
plugins = (p || []).map((plugin) => i18n.localizePlugin(plugin));
|
||||||
const plugin = pluginMap.get(item.pluginId);
|
const localizedContributions = i18n.localizeContributionSummary(contribs || {});
|
||||||
return plugin && plugin.manifest.permissions.includes('ui.register');
|
vaultStatus = v;
|
||||||
|
debug.log('[Sidebar] onMount: plugins=' + plugins.length + ' vault=' + vaultStatus.status);
|
||||||
|
flog('onMount: plugins=' + plugins.length + ' vault=' + vaultStatus.status);
|
||||||
|
if (contribErr) {
|
||||||
|
errorMessage = tr('sidebar.error.contributions');
|
||||||
|
}
|
||||||
|
sidebarItems = (localizedContributions.sidebarItems || []).filter(item => {
|
||||||
|
const plugin = plugins.find(p => p.manifest?.id === item.pluginId);
|
||||||
|
if (!plugin) return false;
|
||||||
|
return plugin.status !== 'disabled' && plugin.status !== 'failed' && plugin.status !== 'incompatible' && plugin.status !== 'missing-required-capability';
|
||||||
});
|
});
|
||||||
|
sidebarItems.sort((a, b) => (a.position || 100) - (b.position || 100));
|
||||||
|
debug.log('[Sidebar] onMount: sidebarItems=' + sidebarItems.length);
|
||||||
|
flog('onMount: sidebarItems=' + sidebarItems.length);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
debug.log('[Sidebar] onMount: ERROR:', String(e));
|
||||||
|
flog('onMount: ERROR: ' + String(e));
|
||||||
console.error('[Sidebar] load error:', e);
|
console.error('[Sidebar] load error:', e);
|
||||||
|
errorMessage = tr('sidebar.error.load');
|
||||||
}
|
}
|
||||||
|
debug.log('[Sidebar] onMount: END');
|
||||||
|
flog('onMount: END');
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
unsubscribeLocale = i18n.subscribe((nextLocale) => {
|
||||||
|
const changed = locale !== nextLocale;
|
||||||
|
locale = nextLocale;
|
||||||
|
if (changed) loadSidebar();
|
||||||
|
});
|
||||||
|
loadSidebar();
|
||||||
|
window.addEventListener('verstak:plugins-changed', loadSidebar);
|
||||||
});
|
});
|
||||||
|
|
||||||
function openView(viewId) {
|
onDestroy(() => {
|
||||||
activeView = viewId;
|
if (unsubscribeLocale) unsubscribeLocale();
|
||||||
window.dispatchEvent(new CustomEvent('verstak:open-view', { detail: { viewId } }));
|
window.removeEventListener('verstak:plugins-changed', loadSidebar);
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleSidebarItem(item) {
|
||||||
|
debug.log('[Sidebar] handleSidebarItem:', item.id, '-> view:', item.view);
|
||||||
|
// Use item.view (the view contribution ID) if available, fall back to item.id
|
||||||
|
const viewId = item.view || item.id;
|
||||||
|
window.dispatchEvent(new CustomEvent('verstak:open-view', { detail: { viewId, pluginId: item.pluginId } }));
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<nav class="sidebar">
|
<aside class="sidebar">
|
||||||
{#each sidebarItems as item}
|
<div class="sidebar-header">
|
||||||
<button
|
<Icon name="logo" size={20} class="sidebar-logo" />
|
||||||
class="sidebar-item"
|
<span class="sidebar-title">Verstak</span>
|
||||||
class:active={activeView === item.item.view}
|
</div>
|
||||||
on:click={() => openView(item.item.view)}
|
|
||||||
type="button"
|
{#if vaultOpen && showGlobalSearch}
|
||||||
>
|
<GlobalSearch />
|
||||||
{#if item.item.icon}<span class="icon">{item.item.icon}</span>{/if}
|
{/if}
|
||||||
<span class="label">{item.item.title}</span>
|
|
||||||
</button>
|
{#if sidebarItems.length > 0}
|
||||||
{/each}
|
<div class="sidebar-section">
|
||||||
</nav>
|
<span class="section-label">{tr('sidebar.tools')}</span>
|
||||||
|
{#each sidebarItems as item}
|
||||||
|
<button
|
||||||
|
class="nav-item plugin-item vt-list-row"
|
||||||
|
class:is-active={activeSidebarKey === `${item.pluginId || ''}:${item.view || item.id}`}
|
||||||
|
aria-current={activeSidebarKey === `${item.pluginId || ''}:${item.view || item.id}` ? 'page' : undefined}
|
||||||
|
on:click={() => handleSidebarItem(item)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name={item.icon || 'plugin'} size={16} class="nav-icon icon-plugin" />
|
||||||
|
<span class="nav-label">{item.title || item.id}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if vaultOpen}
|
||||||
|
<WorkspaceTree />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
{#if errorMessage}
|
||||||
|
<span class="sidebar-error">
|
||||||
|
<Icon name="warning" size={10} class="sidebar-error-icon" />
|
||||||
|
{tr('sidebar.error.ui')}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.sidebar {
|
.sidebar {
|
||||||
width: 200px;
|
width: 220px;
|
||||||
background: #16213e;
|
min-width: 220px;
|
||||||
border-right: 1px solid #0f3460;
|
background: var(--vt-color-surface-muted);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 0.5rem 0;
|
border-right: 1px solid var(--vt-color-border);
|
||||||
overflow-y: auto;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.sidebar-item {
|
|
||||||
|
.sidebar-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.5rem 1rem;
|
padding: 1rem 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--vt-color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.sidebar-logo) {
|
||||||
|
width: 1.2rem;
|
||||||
|
height: 1.2rem;
|
||||||
|
color: var(--vt-color-accent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-title {
|
||||||
|
color: var(--vt-color-text-primary);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0.45rem 0.6rem 0.55rem;
|
||||||
|
gap: 0.15rem;
|
||||||
|
border-bottom: 1px solid var(--vt-color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(workspace-tree) {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-label {
|
||||||
|
color: var(--vt-color-text-muted);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
padding: 0.25rem 0.45rem 0.35rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 0.45rem;
|
||||||
|
min-height: 1.85rem;
|
||||||
|
padding: 0.15rem 0.45rem;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: #a0a0b0;
|
color: var(--vt-color-text-secondary);
|
||||||
|
font-size: 0.78rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
border-radius: var(--vt-radius-sm);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-size: 0.9rem;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:hover {
|
||||||
|
background: var(--vt-color-surface-hover);
|
||||||
|
color: var(--vt-color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.is-active {
|
||||||
|
background: var(--vt-color-surface-selected);
|
||||||
|
color: var(--vt-color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.is-active :global(.nav-icon.icon-plugin) {
|
||||||
|
color: currentColor;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.nav-icon) {
|
||||||
|
width: 0.9rem;
|
||||||
|
height: 0.9rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: currentColor;
|
||||||
|
}
|
||||||
|
:global(.nav-icon.icon-plugin) {
|
||||||
|
color: var(--vt-color-text-muted);
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-label {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer {
|
||||||
|
margin-top: auto;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
border-top: 1px solid var(--vt-color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-error {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--vt-color-danger);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
:global(.sidebar-error-icon) {
|
||||||
|
color: var(--vt-color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.sidebar {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
max-height: 14rem;
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 1px solid var(--vt-color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-section {
|
||||||
|
max-height: 5.5rem;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.sidebar-item:hover { background: #0f3460; color: #e0e0f0; }
|
|
||||||
.sidebar-item.active { background: #0f3460; color: #4ecca3; }
|
|
||||||
.icon { font-size: 1.1rem; }
|
|
||||||
.label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,395 @@
|
|||||||
|
<script>
|
||||||
|
import { onDestroy, onMount } from 'svelte';
|
||||||
|
import * as App from '../../../wailsjs/go/api/App';
|
||||||
|
import Icon from '../ui/Icon.svelte';
|
||||||
|
import { i18n } from '../i18n/index.js';
|
||||||
|
|
||||||
|
let items = [];
|
||||||
|
let settingsPanels = [];
|
||||||
|
let vaultStatus = { status: 'unknown', path: '', vaultId: '' };
|
||||||
|
let settingsOpen = false;
|
||||||
|
let locale = i18n.getLocale();
|
||||||
|
let selectedLanguage = i18n.getLanguagePreference();
|
||||||
|
let unsubscribeLocale = null;
|
||||||
|
|
||||||
|
$: tr = ((activeLocale) => (key, params, fallback) => {
|
||||||
|
void activeLocale;
|
||||||
|
return i18n.t(key, params, fallback);
|
||||||
|
})(locale);
|
||||||
|
|
||||||
|
$: leftItems = items.filter((item) => item.position === 'left');
|
||||||
|
$: centerItems = items.filter((item) => item.position === 'center');
|
||||||
|
$: rightItems = items.filter((item) => item.position === 'right');
|
||||||
|
$: vaultOpen = vaultStatus.status === 'open';
|
||||||
|
$: vaultStatusLabel = tr(`vault.status.${vaultStatus.status || 'unknown'}`, undefined, vaultStatus.status || 'unknown');
|
||||||
|
$: vaultLabel = tr('vault.label', { status: vaultStatusLabel });
|
||||||
|
|
||||||
|
const inactiveStatuses = new Set(['disabled', 'failed', 'incompatible', 'missing-required-capability']);
|
||||||
|
|
||||||
|
async function loadStatusBar() {
|
||||||
|
const [rawPlugins, rawContributions, vault] = await Promise.all([
|
||||||
|
App.GetPlugins().catch(() => []),
|
||||||
|
App.GetContributions().catch(() => ({})),
|
||||||
|
App.GetVaultStatus().catch(() => ({ status: 'unknown', path: '', vaultId: '' })),
|
||||||
|
]);
|
||||||
|
await Promise.all((rawPlugins || []).map((plugin) => (
|
||||||
|
i18n.loadPlugin(plugin.manifest?.id, plugin.manifest?.localization).catch(() => {})
|
||||||
|
)));
|
||||||
|
const plugins = (rawPlugins || []).map((plugin) => i18n.localizePlugin(plugin));
|
||||||
|
const contributions = i18n.localizeContributionSummary(rawContributions || {});
|
||||||
|
const pluginById = new Map((plugins || []).map((plugin) => [plugin.manifest?.id, plugin]));
|
||||||
|
vaultStatus = vault || { status: 'unknown', path: '', vaultId: '' };
|
||||||
|
items = (contributions.statusBarItems || [])
|
||||||
|
.filter((item) => {
|
||||||
|
const plugin = pluginById.get(item.pluginId);
|
||||||
|
if (!plugin) return false;
|
||||||
|
return !inactiveStatuses.has(plugin.status);
|
||||||
|
})
|
||||||
|
.map((item) => ({
|
||||||
|
...item,
|
||||||
|
position: item.position || 'left',
|
||||||
|
}));
|
||||||
|
settingsPanels = (contributions.settingsPanels || [])
|
||||||
|
.filter((panel) => {
|
||||||
|
const plugin = pluginById.get(panel.pluginId);
|
||||||
|
if (!plugin) return false;
|
||||||
|
return !inactiveStatuses.has(plugin.status);
|
||||||
|
})
|
||||||
|
.sort((a, b) => String(a.title || a.id).localeCompare(String(b.title || b.id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPluginManager() {
|
||||||
|
settingsOpen = false;
|
||||||
|
window.dispatchEvent(new CustomEvent('verstak:close-settings'));
|
||||||
|
window.dispatchEvent(new CustomEvent('verstak:nav', { detail: { viewId: 'plugin-manager' } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSettingsPanel(panel) {
|
||||||
|
settingsOpen = false;
|
||||||
|
window.dispatchEvent(new CustomEvent('verstak:open-settings', {
|
||||||
|
detail: { pluginId: panel.pluginId, panelId: panel.id }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectLanguage(language) {
|
||||||
|
const err = await App.UpdateAppSettings({ language });
|
||||||
|
if (err) {
|
||||||
|
throw new Error(err);
|
||||||
|
}
|
||||||
|
await i18n.setLanguagePreference(language);
|
||||||
|
selectedLanguage = language;
|
||||||
|
settingsOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSettings(event) {
|
||||||
|
event.stopPropagation();
|
||||||
|
settingsOpen = !settingsOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSettings() {
|
||||||
|
settingsOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
unsubscribeLocale = i18n.subscribe((nextLocale) => {
|
||||||
|
const changed = locale !== nextLocale;
|
||||||
|
locale = nextLocale;
|
||||||
|
selectedLanguage = i18n.getLanguagePreference();
|
||||||
|
if (changed) loadStatusBar();
|
||||||
|
});
|
||||||
|
loadStatusBar();
|
||||||
|
window.addEventListener('verstak:plugins-changed', loadStatusBar);
|
||||||
|
window.addEventListener('verstak:vault-opened', loadStatusBar);
|
||||||
|
window.addEventListener('click', closeSettings);
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (unsubscribeLocale) unsubscribeLocale();
|
||||||
|
window.removeEventListener('verstak:plugins-changed', loadStatusBar);
|
||||||
|
window.removeEventListener('verstak:vault-opened', loadStatusBar);
|
||||||
|
window.removeEventListener('click', closeSettings);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<footer class="status-bar" aria-label={tr('statusBar.label')}>
|
||||||
|
<div class="status-bar-group status-left">
|
||||||
|
<span
|
||||||
|
class="vault-status"
|
||||||
|
class:vault-open={vaultOpen}
|
||||||
|
class:vault-closed={!vaultOpen}
|
||||||
|
title={vaultStatus.path || vaultStatus.vaultId || vaultLabel}
|
||||||
|
>
|
||||||
|
<Icon name="vault" size={13} class="status-icon" />
|
||||||
|
{vaultLabel}
|
||||||
|
</span>
|
||||||
|
{#each leftItems as item}
|
||||||
|
<span
|
||||||
|
class:status-bar-warning={item.handler}
|
||||||
|
class="status-bar-item"
|
||||||
|
data-status-item-id={item.id}
|
||||||
|
title={item.handler ? `${item.pluginId}: compact status only` : item.pluginId}
|
||||||
|
>
|
||||||
|
{#if item.handler}<Icon name="warning" size={11} class="status-warning-icon" />{/if}
|
||||||
|
{item.label || item.id}
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="status-bar-group status-center">
|
||||||
|
{#each centerItems as item}
|
||||||
|
<span
|
||||||
|
class:status-bar-warning={item.handler}
|
||||||
|
class="status-bar-item"
|
||||||
|
data-status-item-id={item.id}
|
||||||
|
title={item.handler ? `${item.pluginId}: compact status only` : item.pluginId}
|
||||||
|
>
|
||||||
|
{#if item.handler}<Icon name="warning" size={11} class="status-warning-icon" />{/if}
|
||||||
|
{item.label || item.id}
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="status-bar-group status-right">
|
||||||
|
{#each rightItems as item}
|
||||||
|
<span
|
||||||
|
class:status-bar-warning={item.handler}
|
||||||
|
class="status-bar-item"
|
||||||
|
data-status-item-id={item.id}
|
||||||
|
title={item.handler ? `${item.pluginId}: compact status only` : item.pluginId}
|
||||||
|
>
|
||||||
|
{#if item.handler}<Icon name="warning" size={11} class="status-warning-icon" />{/if}
|
||||||
|
{item.label || item.id}
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
<div class="settings-menu-wrap">
|
||||||
|
<button
|
||||||
|
class="settings-button"
|
||||||
|
class:active={settingsOpen}
|
||||||
|
type="button"
|
||||||
|
title={tr('settings.title')}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={settingsOpen}
|
||||||
|
data-settings-menu-button
|
||||||
|
on:click={toggleSettings}
|
||||||
|
>
|
||||||
|
<Icon name="settings" size={14} class="settings-icon" />
|
||||||
|
<Icon name="chevronDown" size={12} class="settings-chevron" />
|
||||||
|
</button>
|
||||||
|
{#if settingsOpen}
|
||||||
|
<div class="settings-menu" role="menu">
|
||||||
|
<div class="settings-menu-heading">{tr('settings.language')}</div>
|
||||||
|
{#each ['system', 'en', 'ru'] as language}
|
||||||
|
<button
|
||||||
|
class="settings-menu-item language-item"
|
||||||
|
class:active-language={selectedLanguage === language}
|
||||||
|
type="button"
|
||||||
|
role="menuitemradio"
|
||||||
|
aria-checked={selectedLanguage === language}
|
||||||
|
data-settings-language={language}
|
||||||
|
on:click={() => selectLanguage(language)}
|
||||||
|
>
|
||||||
|
<span class="language-check" aria-hidden="true">{selectedLanguage === language ? '✓' : ''}</span>
|
||||||
|
<span>{tr(`settings.language.${language}`)}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
<div class="settings-menu-separator"></div>
|
||||||
|
<button
|
||||||
|
class="settings-menu-item"
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
data-settings-action="plugin-manager"
|
||||||
|
on:click={openPluginManager}
|
||||||
|
>
|
||||||
|
<Icon name="puzzle" size={14} class="settings-menu-icon" />
|
||||||
|
<span>{tr('settings.pluginManager')}</span>
|
||||||
|
</button>
|
||||||
|
{#if settingsPanels.length > 0}
|
||||||
|
<div class="settings-menu-separator"></div>
|
||||||
|
{#each settingsPanels as panel}
|
||||||
|
<button
|
||||||
|
class="settings-menu-item"
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
data-settings-panel-id={panel.id}
|
||||||
|
title={panel.pluginId}
|
||||||
|
on:click={() => openSettingsPanel(panel)}
|
||||||
|
>
|
||||||
|
<Icon name={panel.icon || 'settings'} size={14} class="settings-menu-icon" />
|
||||||
|
<span>{panel.title || panel.id}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.status-bar {
|
||||||
|
position: relative;
|
||||||
|
z-index: 100;
|
||||||
|
min-height: 1.7rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.2rem 0.65rem;
|
||||||
|
border-top: 1px solid #16213e;
|
||||||
|
background: #111629;
|
||||||
|
color: #9fb2ca;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar-group {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-center {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-right {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
max-width: 18rem;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0.12rem 0.35rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar-warning {
|
||||||
|
color: #ffc857;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.status-warning-icon) {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
max-width: 24rem;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0.12rem 0.35rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #a0a0b8;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-status.vault-open {
|
||||||
|
color: #4ecca3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-status.vault-closed {
|
||||||
|
color: #9fb2ca;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.status-icon) {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-menu-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-button {
|
||||||
|
min-height: 1.35rem;
|
||||||
|
height: 1.35rem;
|
||||||
|
padding: 0 0.35rem;
|
||||||
|
gap: 0.15rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: #9fb2ca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-button:hover,
|
||||||
|
.settings-button.active {
|
||||||
|
border-color: #1a3a5c;
|
||||||
|
background: #16213e;
|
||||||
|
color: #e0e0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.settings-icon),
|
||||||
|
:global(.settings-chevron),
|
||||||
|
:global(.settings-menu-icon) {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-menu {
|
||||||
|
position: fixed;
|
||||||
|
right: 0.65rem;
|
||||||
|
bottom: 2rem;
|
||||||
|
z-index: 10000;
|
||||||
|
min-width: 13rem;
|
||||||
|
padding: 0.3rem;
|
||||||
|
border: 1px solid #1a3a5c;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #12122a;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-menu-item {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 1.8rem;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 0.45rem;
|
||||||
|
padding: 0.3rem 0.45rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: #cfd8e3;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-menu-item:hover {
|
||||||
|
background: #0f3460;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-menu-heading {
|
||||||
|
padding: 0.25rem 0.45rem 0.2rem;
|
||||||
|
color: #7f8aa3;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-check {
|
||||||
|
width: 0.9rem;
|
||||||
|
color: #4ecca3;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-language {
|
||||||
|
background: rgba(78, 204, 163, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-menu-separator {
|
||||||
|
height: 1px;
|
||||||
|
margin: 0.25rem 0.2rem;
|
||||||
|
background: #1a3a5c;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
<script>
|
||||||
|
import { onDestroy, onMount } from 'svelte';
|
||||||
|
import * as App from '../../../wailsjs/go/api/App';
|
||||||
|
import Icon from '../ui/Icon.svelte';
|
||||||
|
import { i18n } from '../i18n/index.js';
|
||||||
|
|
||||||
|
let appSettings = {};
|
||||||
|
let recentVaults = [];
|
||||||
|
let error = '';
|
||||||
|
let loading = true;
|
||||||
|
let creating = false;
|
||||||
|
let opening = false;
|
||||||
|
let newVaultPath = '';
|
||||||
|
let openVaultPath = '';
|
||||||
|
let locale = i18n.getLocale();
|
||||||
|
const unsubscribeLocale = i18n.subscribe((nextLocale) => { locale = nextLocale; });
|
||||||
|
$: tr = ((activeLocale) => (key, params, fallback) => {
|
||||||
|
void activeLocale;
|
||||||
|
return i18n.t(key, params, fallback);
|
||||||
|
})(locale);
|
||||||
|
|
||||||
|
function reportError(key, fallback, details) {
|
||||||
|
console.warn('[VaultSelection] operation failed:', details);
|
||||||
|
return tr(key, undefined, fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
onDestroy(unsubscribeLocale);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
appSettings = await App.GetAppSettings() || {};
|
||||||
|
recentVaults = appSettings.recentVaults || [];
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[VaultSelection] load settings:', e);
|
||||||
|
}
|
||||||
|
loading = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function browseNewVault() {
|
||||||
|
const path = await App.SelectDirectory();
|
||||||
|
if (path) {
|
||||||
|
newVaultPath = path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function browseOpenVault() {
|
||||||
|
const path = await App.SelectVaultForOpen();
|
||||||
|
if (path) {
|
||||||
|
openVaultPath = path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createVault() {
|
||||||
|
error = '';
|
||||||
|
if (!newVaultPath.trim()) {
|
||||||
|
error = tr('vaultSelection.chooseNew');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
creating = true;
|
||||||
|
try {
|
||||||
|
const createErr = await App.CreateVault(newVaultPath.trim());
|
||||||
|
if (createErr) {
|
||||||
|
error = reportError('vaultSelection.createError', 'Could not create the vault. Please try again.', createErr);
|
||||||
|
creating = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const openErr = await App.OpenVault(newVaultPath.trim());
|
||||||
|
if (openErr) {
|
||||||
|
error = reportError('vaultSelection.openError', 'Could not open the vault. Please try again.', openErr);
|
||||||
|
creating = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const setErr = await App.SetCurrentVault(newVaultPath.trim());
|
||||||
|
if (setErr) {
|
||||||
|
console.warn('[VaultSelection] SetCurrentVault:', setErr);
|
||||||
|
}
|
||||||
|
window.dispatchEvent(new CustomEvent('verstak:vault-opened'));
|
||||||
|
} catch (e) {
|
||||||
|
error = reportError('vaultSelection.createError', 'Could not create the vault. Please try again.', e);
|
||||||
|
creating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openExistingVault() {
|
||||||
|
error = '';
|
||||||
|
if (!openVaultPath.trim()) {
|
||||||
|
error = tr('vaultSelection.chooseExisting');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
opening = true;
|
||||||
|
try {
|
||||||
|
const openErr = await App.OpenVault(openVaultPath.trim());
|
||||||
|
if (openErr) {
|
||||||
|
error = reportError('vaultSelection.openError', 'Could not open the vault. Please try again.', openErr);
|
||||||
|
opening = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const setErr = await App.SetCurrentVault(openVaultPath.trim());
|
||||||
|
if (setErr) {
|
||||||
|
console.warn('[VaultSelection] SetCurrentVault:', setErr);
|
||||||
|
}
|
||||||
|
window.dispatchEvent(new CustomEvent('verstak:vault-opened'));
|
||||||
|
} catch (e) {
|
||||||
|
error = reportError('vaultSelection.openError', 'Could not open the vault. Please try again.', e);
|
||||||
|
opening = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openRecent(path) {
|
||||||
|
error = '';
|
||||||
|
opening = true;
|
||||||
|
try {
|
||||||
|
const openErr = await App.OpenVault(path);
|
||||||
|
if (openErr) {
|
||||||
|
error = reportError('vaultSelection.openError', 'Could not open the vault. Please try again.', openErr);
|
||||||
|
opening = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const setErr = await App.SetCurrentVault(path);
|
||||||
|
if (setErr) {
|
||||||
|
console.warn('[VaultSelection] SetCurrentVault:', setErr);
|
||||||
|
}
|
||||||
|
window.dispatchEvent(new CustomEvent('verstak:vault-opened'));
|
||||||
|
} catch (e) {
|
||||||
|
error = reportError('vaultSelection.openError', 'Could not open the vault. Please try again.', e);
|
||||||
|
opening = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="vault-selection">
|
||||||
|
<div class="vault-selection-inner">
|
||||||
|
<p class="loading-text">{tr('common.loading')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="vault-selection">
|
||||||
|
<div class="vault-selection-inner">
|
||||||
|
<div class="logo">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#4ecca3" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||||
|
<line x1="12" y1="11" x2="12" y2="17"/>
|
||||||
|
<line x1="9" y1="14" x2="15" y2="14"/>
|
||||||
|
</svg>
|
||||||
|
<h1>Verstak</h1>
|
||||||
|
<p class="subtitle">{tr('vaultSelection.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="error-box">
|
||||||
|
<Icon name="warning" size={14} class="error-icon" />
|
||||||
|
<span class="error-text">{error}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<div class="action-card">
|
||||||
|
<h3>{tr('vaultSelection.createTitle')}</h3>
|
||||||
|
<p class="hint">{tr('vaultSelection.createHint')}</p>
|
||||||
|
<div class="input-row">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={newVaultPath}
|
||||||
|
placeholder={tr('vaultSelection.pathPlaceholder')}
|
||||||
|
disabled={creating}
|
||||||
|
/>
|
||||||
|
<button class="btn-secondary" on:click={browseNewVault} type="button" disabled={creating}>
|
||||||
|
{tr('common.browse')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="button-row">
|
||||||
|
<button class="btn-primary" on:click={createVault} type="button" disabled={creating}>
|
||||||
|
{creating ? tr('vaultSelection.creating') : tr('vaultSelection.create')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-card">
|
||||||
|
<h3>{tr('vaultSelection.openTitle')}</h3>
|
||||||
|
<p class="hint">{tr('vaultSelection.openHint')}</p>
|
||||||
|
<div class="input-row">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={openVaultPath}
|
||||||
|
placeholder={tr('vaultSelection.pathPlaceholder')}
|
||||||
|
disabled={opening}
|
||||||
|
/>
|
||||||
|
<button class="btn-secondary" on:click={browseOpenVault} type="button" disabled={opening}>
|
||||||
|
{tr('common.browse')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="button-row">
|
||||||
|
<button class="btn-secondary open-existing-btn" on:click={openExistingVault} type="button" disabled={opening}>
|
||||||
|
{opening ? tr('vaultSelection.opening') : tr('vaultSelection.open')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if recentVaults.length > 0}
|
||||||
|
<div class="recent-section">
|
||||||
|
<h3>{tr('vaultSelection.recent')}</h3>
|
||||||
|
<ul class="recent-list">
|
||||||
|
{#each recentVaults as path}
|
||||||
|
<li>
|
||||||
|
<button class="recent-item" on:click={() => openRecent(path)} type="button" disabled={opening}>
|
||||||
|
<Icon name="vault" size={16} class="recent-icon" />
|
||||||
|
<span class="recent-path">{path}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.vault-selection {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100vh;
|
||||||
|
background: var(--vt-color-background);
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
.vault-selection-inner {
|
||||||
|
max-width: 560px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.loading-text {
|
||||||
|
color: var(--vt-color-text-muted);
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
.logo h1 {
|
||||||
|
color: var(--vt-color-text-primary);
|
||||||
|
font-size: 1.8rem;
|
||||||
|
margin: 0.5rem 0 0.25rem;
|
||||||
|
}
|
||||||
|
.subtitle {
|
||||||
|
color: var(--vt-color-text-muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.error-box {
|
||||||
|
background: var(--vt-color-danger-muted);
|
||||||
|
border: 1px solid rgba(233, 69, 96, 0.5);
|
||||||
|
border-radius: var(--vt-radius-lg);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #ffc6ce;
|
||||||
|
}
|
||||||
|
:global(.error-icon) { flex-shrink: 0; }
|
||||||
|
.error-text { word-break: break-word; }
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.action-card {
|
||||||
|
background: var(--vt-color-surface);
|
||||||
|
border: 1px solid var(--vt-color-border);
|
||||||
|
border-radius: var(--vt-radius-lg);
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
.action-card h3 {
|
||||||
|
color: var(--vt-color-text-primary);
|
||||||
|
font-size: 1rem;
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
color: var(--vt-color-text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
}
|
||||||
|
.input-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.input-row input {
|
||||||
|
flex: 1;
|
||||||
|
background: #0f1424;
|
||||||
|
border: 1px solid var(--vt-color-border-strong);
|
||||||
|
color: var(--vt-color-text-primary);
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.input-row input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--vt-color-accent);
|
||||||
|
box-shadow: var(--vt-focus-ring);
|
||||||
|
}
|
||||||
|
.input-row input::placeholder {
|
||||||
|
color: var(--vt-color-text-muted);
|
||||||
|
}
|
||||||
|
.button-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--vt-color-accent);
|
||||||
|
color: #101827;
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 1.25rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: #3dbb92;
|
||||||
|
}
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--vt-color-surface-hover);
|
||||||
|
color: var(--vt-color-text-secondary);
|
||||||
|
border: 1px solid var(--vt-color-border-strong);
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.btn-secondary:hover:not(:disabled) {
|
||||||
|
background: var(--vt-color-surface-hover);
|
||||||
|
color: var(--vt-color-text-primary);
|
||||||
|
}
|
||||||
|
.btn-secondary:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.recent-section {
|
||||||
|
background: var(--vt-color-surface);
|
||||||
|
border: 1px solid var(--vt-color-border);
|
||||||
|
border-radius: var(--vt-radius-lg);
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
}
|
||||||
|
.recent-section h3 {
|
||||||
|
color: var(--vt-color-text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
.recent-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.recent-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--vt-color-text-primary);
|
||||||
|
padding: 0.4rem 0;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.recent-item:hover {
|
||||||
|
color: var(--vt-color-accent);
|
||||||
|
}
|
||||||
|
.recent-item:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
:global(.recent-icon) { flex-shrink: 0; }
|
||||||
|
.recent-path {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,88 +1,142 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import PluginBundleHost from '../plugin-host/PluginBundleHost.svelte';
|
||||||
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import * as App from '../../../wailsjs/go/api/App';
|
import * as App from '../../../wailsjs/go/api/App';
|
||||||
|
import Icon from '../ui/Icon.svelte';
|
||||||
|
import { i18n } from '../i18n/index.js';
|
||||||
|
|
||||||
|
export let activeView = null;
|
||||||
|
export let activeViewPluginId = null;
|
||||||
|
|
||||||
let views = [];
|
let views = [];
|
||||||
let activeView = '';
|
|
||||||
let pluginStates = {};
|
|
||||||
let plugins = [];
|
let plugins = [];
|
||||||
|
let renderError = null;
|
||||||
|
let locale = i18n.getLocale();
|
||||||
|
let unsubscribeLocale = null;
|
||||||
|
$: tr = ((activeLocale) => (key, params, fallback) => {
|
||||||
|
void activeLocale;
|
||||||
|
return i18n.t(key, params, fallback);
|
||||||
|
})(locale);
|
||||||
|
|
||||||
onMount(async () => {
|
async function loadViews() {
|
||||||
try {
|
try {
|
||||||
const [contribs, pluginList] = await Promise.all([
|
const [contribs, pluginList] = await Promise.all([
|
||||||
App.GetContributions(),
|
App.GetContributions().catch(() => ({ views: [] })),
|
||||||
App.GetPlugins(),
|
App.GetPlugins().catch(() => []),
|
||||||
]);
|
]);
|
||||||
views = contribs.views || [];
|
await Promise.all((pluginList || []).map((plugin) => (
|
||||||
plugins = pluginList;
|
i18n.loadPlugin(plugin.manifest?.id, plugin.manifest?.localization).catch(() => {})
|
||||||
for (const p of pluginList) {
|
)));
|
||||||
pluginStates[p.manifest.id] = p.status;
|
views = i18n.localizeContributionSummary(contribs || {}).views || [];
|
||||||
}
|
plugins = (pluginList || []).map((plugin) => i18n.localizePlugin(plugin));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[ViewContainer] load error:', e);
|
console.error('[ViewContainer] load error:', e);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
window.addEventListener('verstak:open-view', (e) => {
|
onMount(() => {
|
||||||
activeView = e.detail.viewId;
|
unsubscribeLocale = i18n.subscribe((nextLocale) => {
|
||||||
|
const changed = locale !== nextLocale;
|
||||||
|
locale = nextLocale;
|
||||||
|
if (changed) loadViews();
|
||||||
});
|
});
|
||||||
|
loadViews();
|
||||||
});
|
});
|
||||||
|
|
||||||
function getViewStatus(view) {
|
onDestroy(() => {
|
||||||
const status = pluginStates[view.pluginId];
|
if (unsubscribeLocale) unsubscribeLocale();
|
||||||
if (status === 'failed' || status === 'incompatible') return 'error';
|
});
|
||||||
if (status === 'degraded') return 'degraded';
|
|
||||||
return 'ok';
|
$: currentView = views.find(v => v.id === activeView && v.pluginId === activeViewPluginId);
|
||||||
|
$: currentPlugin = currentView
|
||||||
|
? plugins.find(p => p.manifest?.id === currentView.pluginId)
|
||||||
|
: null;
|
||||||
|
$: pluginStatus = currentPlugin ? currentPlugin.status : 'unknown';
|
||||||
|
$: hasFrontend = currentPlugin?.manifest?.frontend?.entry != null;
|
||||||
|
$: hostPluginId = currentView?.pluginId || activeViewPluginId;
|
||||||
|
$: hostComponentId = currentView?.component || null;
|
||||||
|
|
||||||
|
// Reset render error when view changes
|
||||||
|
$: if (activeView) {
|
||||||
|
renderError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onHostError(e) {
|
||||||
|
renderError = e.detail?.message || tr('pluginView.error');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="view-container">
|
{#key `${activeViewPluginId}:${activeView}`}
|
||||||
{#if activeView}
|
{#if renderError}
|
||||||
{#each views.filter(v => v.item.id === activeView) as view}
|
<div class="view-container">
|
||||||
<div class="view" class:degraded={getViewStatus(view) === 'degraded'}>
|
<div class="error-boundary">
|
||||||
<div class="view-header">
|
<div class="error-fallback vt-inline-alert error">
|
||||||
<span class="view-icon">{view.item.icon || '📦'}</span>
|
<Icon name="warning" size={24} class="error-icon" />
|
||||||
<h2>{view.item.title}</h2>
|
<p class="error-title">{tr('pluginView.failed')}</p>
|
||||||
{#if getViewStatus(view) === 'degraded'}
|
<details class="error-details">
|
||||||
<span class="badge degraded">degraded</span>
|
<summary>{tr('common.details')}</summary>
|
||||||
{/if}
|
<p class="error-text">{renderError}</p>
|
||||||
</div>
|
</details>
|
||||||
<div class="view-content">
|
|
||||||
<div class="plugin-view-host" data-view-id={view.item.id} data-component={view.item.component}>
|
|
||||||
<p class="placeholder">
|
|
||||||
Plugin view: <strong>{view.item.component}</strong>
|
|
||||||
<br />
|
|
||||||
<span class="sub">from {view.pluginId}</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
</div>
|
||||||
<div class="empty">View "{activeView}" not found in contributions</div>
|
{:else if currentView}
|
||||||
{/each}
|
<div class="view-container">
|
||||||
|
<div class="view" class:degraded={pluginStatus === 'degraded'}>
|
||||||
|
<div class="view-header">
|
||||||
|
<Icon name={currentView.icon || 'logo'} size={20} class="view-icon" />
|
||||||
|
<h2>{currentView.title}</h2>
|
||||||
|
</div>
|
||||||
|
<div class="view-content">
|
||||||
|
{#if hasFrontend}
|
||||||
|
<PluginBundleHost
|
||||||
|
pluginId={hostPluginId}
|
||||||
|
componentId={hostComponentId}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="placeholder">
|
||||||
|
<p class="placeholder-label">{tr('pluginView.noVisual')}</p>
|
||||||
|
<details class="placeholder-details">
|
||||||
|
<summary>{tr('common.details')}</summary>
|
||||||
|
<p class="placeholder-info"><span class="placeholder-key">{tr('common.plugin')}:</span> <strong>{currentView.pluginId}</strong></p>
|
||||||
|
<p class="placeholder-info"><span class="placeholder-key">{tr('pluginView.viewId')}:</span> <code>{currentView.id}</code></p>
|
||||||
|
<p class="placeholder-info"><span class="placeholder-key">{tr('common.component')}:</span> <code>{currentView.component}</code></p>
|
||||||
|
</details>
|
||||||
|
<p class="placeholder-badge vt-badge">{tr('pluginView.bundleUnavailable')}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if activeView}
|
||||||
|
<div class="view-container empty">
|
||||||
|
<p>{tr('pluginView.unavailable')}</p>
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="empty">
|
<div class="view-container empty">
|
||||||
<p>Select an item from the sidebar</p>
|
<p>{tr('pluginView.select')}</p>
|
||||||
<p class="sub">Plugin views will appear here</p>
|
<p class="sub">{tr('pluginView.selectHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
{/key}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.view-container {
|
.view-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
min-width: 0;
|
||||||
background: #1a1a2e;
|
background: var(--vt-color-background);
|
||||||
}
|
}
|
||||||
.view {
|
.view {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
}
|
}
|
||||||
.view.degraded {
|
.view.degraded {
|
||||||
border-left: 3px solid #ffc857;
|
border-left: 3px solid var(--vt-color-warning);
|
||||||
}
|
}
|
||||||
.view-header {
|
.view-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -90,48 +144,106 @@
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
padding-bottom: 0.75rem;
|
padding-bottom: 0.75rem;
|
||||||
border-bottom: 1px solid #16213e;
|
border-bottom: 1px solid var(--vt-color-border);
|
||||||
}
|
}
|
||||||
.view-header h2 {
|
.view-header h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
color: #e0e0f0;
|
color: var(--vt-color-text-primary);
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
.view-icon { font-size: 1.3rem; }
|
:global(.view-icon) {
|
||||||
|
width: 1.3rem;
|
||||||
|
height: 1.3rem;
|
||||||
|
color: var(--vt-color-text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
.view-content {
|
.view-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: auto;
|
min-width: 0;
|
||||||
}
|
|
||||||
.plugin-view-host {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 200px;
|
|
||||||
}
|
}
|
||||||
.placeholder {
|
.placeholder {
|
||||||
color: #666;
|
color: var(--vt-color-text-muted);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
border: 1px dashed #333;
|
border: 1px dashed var(--vt-color-border-strong);
|
||||||
border-radius: 8px;
|
border-radius: var(--vt-radius-lg);
|
||||||
|
}
|
||||||
|
.placeholder-label {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--vt-color-text-secondary);
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
.placeholder-info {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--vt-color-text-muted);
|
||||||
|
margin: 0.3rem 0;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
.placeholder-key {
|
||||||
|
color: var(--vt-color-text-muted);
|
||||||
|
}
|
||||||
|
.placeholder-info strong { color: var(--vt-color-accent); }
|
||||||
|
.placeholder-info code {
|
||||||
|
color: var(--vt-color-text-primary);
|
||||||
|
background: var(--vt-color-surface-muted);
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.placeholder-badge {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
background: var(--vt-color-surface-muted);
|
||||||
|
color: var(--vt-color-text-secondary);
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
.error-boundary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.error-fallback {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
:global(.error-icon) {
|
||||||
|
color: var(--vt-color-danger);
|
||||||
|
}
|
||||||
|
.error-title {
|
||||||
|
color: var(--vt-color-danger);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
.error-text {
|
||||||
|
color: var(--vt-color-text-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-family: monospace;
|
||||||
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
.placeholder strong { color: #4ecca3; }
|
|
||||||
.placeholder .sub { font-size: 0.85rem; color: #555; }
|
|
||||||
.empty {
|
.empty {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
color: #555;
|
color: var(--vt-color-text-muted);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
.empty .sub { font-size: 0.85rem; color: #444; margin-top: 0.5rem; }
|
.empty .sub { font-size: 0.85rem; color: var(--vt-color-text-muted); margin-top: 0.5rem; }
|
||||||
.badge {
|
.placeholder-details,
|
||||||
padding: 0.15rem 0.5rem;
|
.error-details { margin-top: 0.75rem; color: var(--vt-color-text-muted); }
|
||||||
border-radius: 3px;
|
.placeholder-details summary,
|
||||||
font-size: 0.75rem;
|
.error-details summary { cursor: pointer; color: var(--vt-color-text-secondary); }
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.badge.degraded { background: #ffc857; color: #1a1a2e; }
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
<script>
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
|
import PluginBundleHost from '../plugin-host/PluginBundleHost.svelte';
|
||||||
|
import Icon from '../ui/Icon.svelte';
|
||||||
|
import { i18n } from '../i18n/index.js';
|
||||||
|
import { debug } from '../log/debug.js';
|
||||||
|
|
||||||
|
export let openedResource = null;
|
||||||
|
let locale = i18n.getLocale();
|
||||||
|
const unsubscribeLocale = i18n.subscribe((nextLocale) => locale = nextLocale);
|
||||||
|
|
||||||
|
$: tr = ((activeLocale) => (key, params, fallback) => {
|
||||||
|
void activeLocale;
|
||||||
|
return i18n.t(key, params, fallback);
|
||||||
|
})(locale);
|
||||||
|
|
||||||
|
$: providerPluginId = openedResource?.providerPluginId || '';
|
||||||
|
$: providerComponent = openedResource?.providerComponent || '';
|
||||||
|
$: resourcePath = openedResource?.request?.path || '';
|
||||||
|
$: providerId = openedResource?.providerId || '';
|
||||||
|
$: requestMode = openedResource?.request?.mode || 'view';
|
||||||
|
$: requestContext = openedResource?.request?.context?.notesMode || openedResource?.request?.context?.isInsideNotesFolder
|
||||||
|
? 'notes-markdown'
|
||||||
|
: ((openedResource?.request?.extension === '.md' || openedResource?.request?.extension === '.markdown') ? 'generic-markdown' : 'generic-text');
|
||||||
|
$: resourceTitle = resourceDisplayTitle(resourcePath, requestContext);
|
||||||
|
$: componentProps = openedResource || {};
|
||||||
|
$: mountKey = [
|
||||||
|
providerPluginId,
|
||||||
|
providerComponent,
|
||||||
|
resourcePath,
|
||||||
|
requestMode,
|
||||||
|
requestContext,
|
||||||
|
].join(':');
|
||||||
|
|
||||||
|
function resourceDisplayTitle(path, context) {
|
||||||
|
const fileName = String(path || '').split('/').filter(Boolean).pop() || String(path || '');
|
||||||
|
return context === 'notes-markdown' ? fileName.replace(/\.(md|markdown)$/i, '') : fileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeWorkbench() {
|
||||||
|
window.dispatchEvent(new CustomEvent('verstak:close-workbench', { cancelable: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
onDestroy(unsubscribeLocale);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="workbench-host vt-page">
|
||||||
|
{#if openedResource?.status === 'no-provider'}
|
||||||
|
<div class="workbench-header vt-page-header">
|
||||||
|
<span class="workbench-title vt-page-title">{resourceTitle}</span>
|
||||||
|
<span class="workbench-provider vt-badge">{tr('workbench.noProvider')}</span>
|
||||||
|
<button class="close-btn btn-ghost btn-icon" type="button" title={tr('common.close')} aria-label={tr('common.close')} on:click={closeWorkbench}>
|
||||||
|
<Icon name="x" size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="workbench-empty no-provider vt-empty-state" data-workbench-status="no-provider">
|
||||||
|
<p class="vt-empty-title">{tr('workbench.noViewer')}</p>
|
||||||
|
{#if debug.isEnabled()}
|
||||||
|
<p class="workbench-meta">{requestMode} · {requestContext}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else if openedResource}
|
||||||
|
<div class="workbench-header vt-page-header">
|
||||||
|
<span class="workbench-title vt-page-title">{resourceTitle}</span>
|
||||||
|
{#if debug.isEnabled() && providerId}
|
||||||
|
<span class="workbench-provider vt-badge accent" data-debug-provider>{providerId}</span>
|
||||||
|
{/if}
|
||||||
|
<button class="close-btn btn-ghost btn-icon" type="button" title={tr('common.close')} aria-label={tr('common.close')} on:click={closeWorkbench}>
|
||||||
|
<Icon name="x" size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="workbench-content">
|
||||||
|
{#key mountKey}
|
||||||
|
<PluginBundleHost
|
||||||
|
pluginId={providerPluginId}
|
||||||
|
componentId={providerComponent}
|
||||||
|
{componentProps}
|
||||||
|
/>
|
||||||
|
{/key}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="workbench-empty vt-empty-state">
|
||||||
|
<p class="vt-empty-title">{tr('workbench.noResource')}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.workbench-host {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--vt-color-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workbench-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: var(--vt-space-2) var(--vt-space-4);
|
||||||
|
border-bottom: 1px solid var(--vt-color-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workbench-title {
|
||||||
|
color: var(--vt-color-text-primary);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: var(--vt-radius-md);
|
||||||
|
color: var(--vt-color-text-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
color: var(--vt-color-text-primary);
|
||||||
|
background: var(--vt-color-surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workbench-provider {
|
||||||
|
color: var(--vt-color-accent);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workbench-content {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workbench-empty {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--vt-color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workbench-empty.no-provider {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workbench-meta {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--vt-color-text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
<script>
|
||||||
|
import PluginBundleHost from '../plugin-host/PluginBundleHost.svelte';
|
||||||
|
import GlobalSearch from './GlobalSearch.svelte';
|
||||||
|
import TodaySurface from './TodaySurface.svelte';
|
||||||
|
import * as App from '../../../wailsjs/go/api/App';
|
||||||
|
import { onDestroy, onMount } from 'svelte';
|
||||||
|
import { i18n } from '../i18n/index.js';
|
||||||
|
|
||||||
|
export let selectedWorkspaceName = '';
|
||||||
|
export let nodes = [];
|
||||||
|
export let activeToolKey = '';
|
||||||
|
|
||||||
|
let contributions = {};
|
||||||
|
let plugins = [];
|
||||||
|
let discoveredWorkspaceTools = [];
|
||||||
|
let workspaceTools = [];
|
||||||
|
let workspaceMetadata = null;
|
||||||
|
let metadataWorkspaceRoot = '';
|
||||||
|
let toolsLoaded = false;
|
||||||
|
let requestedToolKind = '';
|
||||||
|
let requestedToolRequest = null;
|
||||||
|
let activeToolRequest = null;
|
||||||
|
let requestedWorkspaceRoot = '';
|
||||||
|
let locale = i18n.getLocale();
|
||||||
|
let unsubscribeLocale = null;
|
||||||
|
$: tr = ((activeLocale) => (key, params, fallback) => {
|
||||||
|
void activeLocale;
|
||||||
|
return i18n.t(key, params, fallback);
|
||||||
|
})(locale);
|
||||||
|
// TODO: Rename TodaySurface.svelte to OverviewSurface.svelte in a refactor-only follow-up.
|
||||||
|
$: overviewTool = { id: '__overview', title: tr('workspace.overview'), pluginId: 'verstak.shell', component: 'TodaySurface', shell: true };
|
||||||
|
|
||||||
|
const toolOrder = new Map([
|
||||||
|
['notes', 10],
|
||||||
|
['files', 20],
|
||||||
|
['todo', 30],
|
||||||
|
['activity', 40],
|
||||||
|
['browser', 50],
|
||||||
|
['inbox', 50],
|
||||||
|
['secrets', 60],
|
||||||
|
['search', 90],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$: selectedWorkspace = nodes.find(n => n.id === selectedWorkspaceName || n.name === selectedWorkspaceName || n.rootPath === selectedWorkspaceName) || null;
|
||||||
|
$: workspaceRootPath = selectedWorkspace?.rootPath || selectedWorkspace?.name || selectedWorkspace?.id || '';
|
||||||
|
$: workspaceId = selectedWorkspace?.workspaceId || '';
|
||||||
|
$: workspaceTitle = selectedWorkspace?.title || selectedWorkspace?.name || selectedWorkspace?.id || selectedWorkspaceName;
|
||||||
|
$: if (workspaceRootPath !== metadataWorkspaceRoot) {
|
||||||
|
metadataWorkspaceRoot = workspaceRootPath;
|
||||||
|
workspaceMetadata = null;
|
||||||
|
if (workspaceRootPath) loadWorkspaceMetadata(workspaceRootPath);
|
||||||
|
}
|
||||||
|
$: workspaceTools = sortWorkspaceTools(filterWorkspaceTools(discoveredWorkspaceTools, workspaceMetadata));
|
||||||
|
$: if (workspaceRootPath !== requestedWorkspaceRoot) {
|
||||||
|
requestedWorkspaceRoot = workspaceRootPath;
|
||||||
|
requestedToolRequest = null;
|
||||||
|
activeToolRequest = null;
|
||||||
|
}
|
||||||
|
$: displayedTools = selectedWorkspace ? [overviewTool, ...workspaceTools] : [];
|
||||||
|
$: activeTool = displayedTools.find(tool => toolKey(tool) === activeToolKey) || displayedTools[0] || null;
|
||||||
|
$: if (displayedTools.length > 0 && (!activeToolKey || (toolsLoaded && !displayedTools.some(tool => toolKey(tool) === activeToolKey)))) {
|
||||||
|
activeToolKey = toolKey(overviewTool);
|
||||||
|
}
|
||||||
|
$: if (requestedToolKind && workspaceTools.length > 0) {
|
||||||
|
const match = findWorkspaceTool(requestedToolKind);
|
||||||
|
if (match) {
|
||||||
|
const toolRequest = requestedToolRequest;
|
||||||
|
requestedToolKind = '';
|
||||||
|
requestedToolRequest = null;
|
||||||
|
selectTool(match, toolRequest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$: if (selectedWorkspaceName) loadTools();
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
unsubscribeLocale = i18n.subscribe((nextLocale) => {
|
||||||
|
const changed = locale !== nextLocale;
|
||||||
|
locale = nextLocale;
|
||||||
|
if (changed && selectedWorkspaceName) loadTools();
|
||||||
|
});
|
||||||
|
window.addEventListener('verstak:workspace-open-tool', handleWorkspaceOpenTool);
|
||||||
|
window.addEventListener('verstak:plugins-changed', handlePluginsChanged);
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (unsubscribeLocale) unsubscribeLocale();
|
||||||
|
window.removeEventListener('verstak:workspace-open-tool', handleWorkspaceOpenTool);
|
||||||
|
window.removeEventListener('verstak:plugins-changed', handlePluginsChanged);
|
||||||
|
});
|
||||||
|
|
||||||
|
function toolKey(tool) {
|
||||||
|
return `${tool?.pluginId || ''}:${tool?.id || ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolRank(tool) {
|
||||||
|
const text = `${tool?.title || ''} ${tool?.id || ''} ${tool?.pluginId || ''}`.toLowerCase();
|
||||||
|
for (const [needle, rank] of toolOrder.entries()) {
|
||||||
|
if (text.includes(needle)) return rank;
|
||||||
|
}
|
||||||
|
return 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortWorkspaceTools(tools) {
|
||||||
|
return [...tools].sort((a, b) => {
|
||||||
|
const rankDiff = toolRank(a) - toolRank(b);
|
||||||
|
if (rankDiff !== 0) return rankDiff;
|
||||||
|
return String(a.title || a.id).localeCompare(String(b.title || b.id));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterWorkspaceTools(tools, metadata) {
|
||||||
|
if (!Array.isArray(metadata?.workspaceTools)) return tools;
|
||||||
|
const allowedPluginIds = new Set(metadata.workspaceTools);
|
||||||
|
return tools.filter(tool => allowedPluginIds.has(tool.pluginId));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resultOrError(response, fallbackValue) {
|
||||||
|
if (Array.isArray(response) && typeof response[1] === 'string') {
|
||||||
|
return [response[0] || fallbackValue, response[1] || ''];
|
||||||
|
}
|
||||||
|
return typeof response === 'string' ? [fallbackValue, response] : [response || fallbackValue, ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadWorkspaceMetadata(rootPath) {
|
||||||
|
try {
|
||||||
|
const [metadata, err] = resultOrError(await App.GetWorkspaceMetadata(rootPath), null);
|
||||||
|
if (rootPath !== workspaceRootPath) return;
|
||||||
|
workspaceMetadata = err ? null : metadata;
|
||||||
|
} catch (_) {
|
||||||
|
if (rootPath === workspaceRootPath) workspaceMetadata = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectTool(tool, toolRequest = null) {
|
||||||
|
activeToolKey = toolKey(tool);
|
||||||
|
activeToolRequest = toolRequest;
|
||||||
|
window.dispatchEvent(new CustomEvent('verstak:workspace-tool-selected', {
|
||||||
|
detail: {
|
||||||
|
toolKey: activeToolKey,
|
||||||
|
toolId: tool?.id || '',
|
||||||
|
pluginId: tool?.pluginId || '',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function findWorkspaceTool(kind) {
|
||||||
|
kind = String(kind || '').toLowerCase();
|
||||||
|
return workspaceTools.find(tool => {
|
||||||
|
const text = `${tool?.title || ''} ${tool?.id || ''} ${tool?.pluginId || ''}`.toLowerCase();
|
||||||
|
if (kind === 'browser-inbox') return text.includes('browser') || text.includes('inbox');
|
||||||
|
return text.includes(kind);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestWorkspaceTool(kind, toolRequest = null) {
|
||||||
|
requestedToolKind = String(kind || '').toLowerCase();
|
||||||
|
requestedToolRequest = toolRequest;
|
||||||
|
const match = findWorkspaceTool(requestedToolKind);
|
||||||
|
if (match) {
|
||||||
|
requestedToolKind = '';
|
||||||
|
requestedToolRequest = null;
|
||||||
|
selectTool(match, toolRequest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openWorkspaceTool(event) {
|
||||||
|
requestWorkspaceTool(event?.detail?.kind, event?.detail?.toolRequest || null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWorkspaceOpenTool(event) {
|
||||||
|
requestWorkspaceTool(event?.detail?.kind, event?.detail?.toolRequest || null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePluginsChanged() {
|
||||||
|
if (selectedWorkspaceName) loadTools();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTools() {
|
||||||
|
try {
|
||||||
|
toolsLoaded = false;
|
||||||
|
const [c, p] = await Promise.all([
|
||||||
|
App.GetContributions().catch(() => ({})),
|
||||||
|
App.GetPlugins().catch(() => []),
|
||||||
|
]);
|
||||||
|
await Promise.all((p || []).map((plugin) => (
|
||||||
|
i18n.loadPlugin(plugin.manifest?.id, plugin.manifest?.localization).catch(() => {})
|
||||||
|
)));
|
||||||
|
contributions = i18n.localizeContributionSummary(c || {});
|
||||||
|
plugins = (p || []).map((plugin) => i18n.localizePlugin(plugin));
|
||||||
|
|
||||||
|
const enabledIds = new Set(
|
||||||
|
plugins.filter(pl => pl.enabled && (pl.status === 'loaded' || pl.status === 'degraded')).map(pl => pl.manifest?.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
discoveredWorkspaceTools = (contributions.workspaceItems || []).filter(tool => enabledIds.has(tool.pluginId));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[WorkspaceHost] loadTools error:', e);
|
||||||
|
} finally {
|
||||||
|
toolsLoaded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="workspace-host vt-page">
|
||||||
|
{#if selectedWorkspace}
|
||||||
|
<div class="workspace-header vt-page-header">
|
||||||
|
<div class="workspace-title-group">
|
||||||
|
<span class="workspace-title vt-page-title">{workspaceTitle}</span>
|
||||||
|
</div>
|
||||||
|
<div class="workspace-search" aria-label={tr('workspace.search')}>
|
||||||
|
<GlobalSearch />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if displayedTools.length > 0}
|
||||||
|
<div class="workspace-tabs vt-tabbar" role="tablist" aria-label={tr('workspace.tools')}>
|
||||||
|
{#each displayedTools as tool (tool.id + tool.pluginId)}
|
||||||
|
<button
|
||||||
|
class="vt-tab"
|
||||||
|
class:is-active={toolKey(tool) === toolKey(activeTool)}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={toolKey(tool) === toolKey(activeTool)}
|
||||||
|
type="button"
|
||||||
|
title={tool.pluginId}
|
||||||
|
on:click={() => selectTool(tool)}
|
||||||
|
>
|
||||||
|
{tool.title || tool.id}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="workspace-tool-content" role="tabpanel" aria-label={activeTool?.title || activeTool?.id || tr('workspace.tool')}>
|
||||||
|
{#if activeTool}
|
||||||
|
{#if activeTool.shell}
|
||||||
|
<TodaySurface
|
||||||
|
{workspaceRootPath}
|
||||||
|
availableTools={displayedTools}
|
||||||
|
on:openTool={openWorkspaceTool}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<PluginBundleHost
|
||||||
|
pluginId={activeTool.pluginId}
|
||||||
|
componentId={activeTool.component}
|
||||||
|
componentProps={{ workspaceName: selectedWorkspaceName, workspaceNodeId: selectedWorkspaceName, workspaceNode: selectedWorkspace, workspaceRootPath, workspaceId, toolRequest: activeToolRequest }}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="workspace-empty vt-empty-state">
|
||||||
|
<p class="vt-empty-title">{tr('workspace.emptyTools')}</p>
|
||||||
|
<p class="workspace-hint">{tr('workspace.emptyToolsHint')}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div class="workspace-empty vt-empty-state">
|
||||||
|
<p class="vt-empty-title">{tr('workspace.select')}</p>
|
||||||
|
<p class="workspace-hint">{tr('workspace.selectHint')}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.workspace-host {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--vt-color-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: var(--vt-space-2) var(--vt-space-4);
|
||||||
|
border-bottom: 1px solid var(--vt-color-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-title-group {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-title {
|
||||||
|
color: var(--vt-color-text-primary);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-search {
|
||||||
|
width: min(27rem, 46vw);
|
||||||
|
min-width: 16rem;
|
||||||
|
flex-shrink: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-search :global(.global-search) {
|
||||||
|
padding: 0;
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-search :global(.global-search-box) {
|
||||||
|
background: #0f1424;
|
||||||
|
border-color: var(--vt-color-border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-search :global(.global-search-results) {
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: calc(100% + 0.35rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.workspace-header {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-search {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-tabs {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: var(--vt-space-1) var(--vt-space-3) 0;
|
||||||
|
background: #12162a;
|
||||||
|
border-bottom: 1px solid var(--vt-color-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-gutter: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-tabs button {
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-height: 2rem;
|
||||||
|
padding: 0.35rem 0.8rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-bottom: none;
|
||||||
|
border-radius: var(--vt-radius-md) var(--vt-radius-md) 0 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--vt-color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-tabs button:hover {
|
||||||
|
color: var(--vt-color-text-primary);
|
||||||
|
background: var(--vt-color-surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-tabs button.is-active {
|
||||||
|
color: var(--vt-color-accent);
|
||||||
|
background: var(--vt-color-background);
|
||||||
|
border-color: var(--vt-color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-tool-content {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-empty {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--vt-color-text-muted);
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-hint {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--vt-color-text-muted);
|
||||||
|
max-width: 300px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,506 @@
|
|||||||
|
<script context="module">
|
||||||
|
import { writable } from 'svelte/store';
|
||||||
|
|
||||||
|
const activeWorkspaceId = writable('');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { onDestroy, onMount } from 'svelte';
|
||||||
|
import * as App from '../../../wailsjs/go/api/App';
|
||||||
|
import Icon from '../ui/Icon.svelte';
|
||||||
|
import { i18n } from '../i18n/index.js';
|
||||||
|
|
||||||
|
let loading = true;
|
||||||
|
let localError = '';
|
||||||
|
let workspaces = [];
|
||||||
|
let currentWorkspaceId = '';
|
||||||
|
let showCreate = false;
|
||||||
|
let newWorkspaceName = '';
|
||||||
|
let workspaceTemplates = [];
|
||||||
|
let templatePluginNames = {};
|
||||||
|
let templatePlugins = {};
|
||||||
|
let templateCapabilities = new Set();
|
||||||
|
let selectedTemplateId = 'default';
|
||||||
|
let createError = '';
|
||||||
|
let templateWarning = null;
|
||||||
|
let templatesLoading = false;
|
||||||
|
let creating = false;
|
||||||
|
let renamingId = '';
|
||||||
|
let renameValue = '';
|
||||||
|
let busyId = '';
|
||||||
|
let locale = i18n.getLocale();
|
||||||
|
let unsubscribeLocale = null;
|
||||||
|
$: tr = ((activeLocale) => (key, params, fallback) => {
|
||||||
|
void activeLocale;
|
||||||
|
return i18n.t(key, params, fallback);
|
||||||
|
})(locale);
|
||||||
|
|
||||||
|
function reportError(key, fallback, details) {
|
||||||
|
console.warn('[WorkspaceTree] operation failed:', details);
|
||||||
|
return tr(key, undefined, fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
unsubscribeLocale = i18n.subscribe((nextLocale) => {
|
||||||
|
const changed = locale !== nextLocale;
|
||||||
|
locale = nextLocale;
|
||||||
|
if (changed) loadWorkspaceTemplates();
|
||||||
|
});
|
||||||
|
loadWorkspaces();
|
||||||
|
loadWorkspaceTemplates();
|
||||||
|
window.addEventListener('verstak:workspace-active-changed', onActiveWorkspaceChanged);
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (unsubscribeLocale) unsubscribeLocale();
|
||||||
|
window.removeEventListener('verstak:workspace-active-changed', onActiveWorkspaceChanged);
|
||||||
|
});
|
||||||
|
|
||||||
|
function onActiveWorkspaceChanged(event) {
|
||||||
|
currentWorkspaceId = event.detail?.workspaceName || '';
|
||||||
|
activeWorkspaceId.set(currentWorkspaceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resultOrError(response, fallbackValue) {
|
||||||
|
if (Array.isArray(response) && typeof response[1] === 'string') {
|
||||||
|
return [response[0] || fallbackValue, response[1] || ''];
|
||||||
|
}
|
||||||
|
return typeof response === 'string' ? [fallbackValue, response] : [response, ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
$: selectedTemplate = workspaceTemplates.find(template => template.id === selectedTemplateId) || workspaceTemplates[0] || null;
|
||||||
|
$: selectedTemplateTools = (selectedTemplate?.workspaceTools || []).map((pluginId) => (
|
||||||
|
templateToolState(pluginId, templatePlugins, templateCapabilities, templatePluginNames, tr)
|
||||||
|
));
|
||||||
|
$: selectedTemplateIssues = selectedTemplateTools.filter(tool => tool.status !== 'available');
|
||||||
|
|
||||||
|
function toolLabel(pluginId, names = templatePluginNames) {
|
||||||
|
return names[pluginId] || String(pluginId || '').replace(/^verstak\./, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function templateToolState(pluginId, plugins, capabilities, names, translate) {
|
||||||
|
const plugin = plugins[pluginId];
|
||||||
|
if (!plugin) {
|
||||||
|
return {
|
||||||
|
pluginId,
|
||||||
|
name: toolLabel(pluginId, names),
|
||||||
|
tabs: [],
|
||||||
|
status: 'unavailable',
|
||||||
|
reason: translate('workspaceTree.templateMissingPlugin'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifest = plugin.manifest || {};
|
||||||
|
const tabs = Array.isArray(manifest.contributes?.workspaceItems)
|
||||||
|
? manifest.contributes.workspaceItems.map(item => item?.title || item?.id).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
const pluginStatus = String(plugin.status || '').toLowerCase();
|
||||||
|
const missingCapability = Array.isArray(manifest.requires)
|
||||||
|
&& manifest.requires.some(capabilityId => !capabilities.has(capabilityId));
|
||||||
|
let status = 'available';
|
||||||
|
let reason = translate('workspaceTree.templateAvailable');
|
||||||
|
|
||||||
|
if (!plugin.enabled || pluginStatus === 'disabled') {
|
||||||
|
status = 'unavailable';
|
||||||
|
reason = translate('workspaceTree.templatePluginDisabled');
|
||||||
|
} else if (pluginStatus === 'missing-required-capability' || missingCapability) {
|
||||||
|
status = 'unavailable';
|
||||||
|
reason = translate('workspaceTree.templateCapabilityUnavailable');
|
||||||
|
} else if (pluginStatus === 'incompatible') {
|
||||||
|
status = 'unavailable';
|
||||||
|
reason = translate('workspaceTree.templateIncompatible');
|
||||||
|
} else if (pluginStatus === 'failed') {
|
||||||
|
status = 'unavailable';
|
||||||
|
reason = translate('workspaceTree.templateLoadFailed');
|
||||||
|
} else if (pluginStatus === 'degraded') {
|
||||||
|
status = 'limited';
|
||||||
|
reason = translate('workspaceTree.templateLimited');
|
||||||
|
} else if (pluginStatus !== 'loaded') {
|
||||||
|
status = 'unavailable';
|
||||||
|
reason = translate('workspaceTree.templateNotReady');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { pluginId, name: manifest.name || toolLabel(pluginId, names), tabs, status, reason };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadWorkspaceTemplates() {
|
||||||
|
templatesLoading = true;
|
||||||
|
try {
|
||||||
|
const [templates, plugins, capabilities] = await Promise.all([
|
||||||
|
App.ListWorkspaceTemplates ? App.ListWorkspaceTemplates() : [],
|
||||||
|
App.GetPlugins ? App.GetPlugins() : [],
|
||||||
|
App.GetCapabilities ? App.GetCapabilities() : [],
|
||||||
|
]);
|
||||||
|
const [list, err] = resultOrError(templates, []);
|
||||||
|
if (err) {
|
||||||
|
createError = reportError('workspaceTree.templatesError', 'Could not load Deal templates. Please try again.', err);
|
||||||
|
workspaceTemplates = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
workspaceTemplates = Array.isArray(list) ? list : [];
|
||||||
|
await Promise.all((Array.isArray(plugins) ? plugins : []).map((plugin) => (
|
||||||
|
i18n.loadPlugin(plugin.manifest?.id, plugin.manifest?.localization).catch(() => {})
|
||||||
|
)));
|
||||||
|
const localizedPlugins = (Array.isArray(plugins) ? plugins : []).map((plugin) => i18n.localizePlugin(plugin));
|
||||||
|
templatePluginNames = localizedPlugins.reduce((names, plugin) => {
|
||||||
|
const id = plugin?.manifest?.id;
|
||||||
|
const name = plugin?.manifest?.name;
|
||||||
|
if (id && name) names[id] = name;
|
||||||
|
return names;
|
||||||
|
}, {});
|
||||||
|
templatePlugins = localizedPlugins.reduce((result, plugin) => {
|
||||||
|
const id = plugin?.manifest?.id;
|
||||||
|
if (id) result[id] = plugin;
|
||||||
|
return result;
|
||||||
|
}, {});
|
||||||
|
const [capabilityList] = resultOrError(capabilities, []);
|
||||||
|
templateCapabilities = new Set((Array.isArray(capabilityList) ? capabilityList : []).map(capability => capability?.name).filter(Boolean));
|
||||||
|
if (!workspaceTemplates.some(template => template.id === selectedTemplateId)) {
|
||||||
|
selectedTemplateId = workspaceTemplates[0]?.id || '';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
createError = reportError('workspaceTree.templatesError', 'Could not load Deal templates. Please try again.', error);
|
||||||
|
workspaceTemplates = [];
|
||||||
|
} finally {
|
||||||
|
templatesLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function wsName(workspace) {
|
||||||
|
return String(workspace?.name || workspace?.rootPath || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function asNode(workspace, order) {
|
||||||
|
const name = wsName(workspace);
|
||||||
|
return {
|
||||||
|
id: name,
|
||||||
|
type: 'space',
|
||||||
|
title: name,
|
||||||
|
name,
|
||||||
|
rootPath: workspace.rootPath || name,
|
||||||
|
status: 'active',
|
||||||
|
order,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodesForEvent() {
|
||||||
|
return workspaces.map(asNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadWorkspaces() {
|
||||||
|
loading = true;
|
||||||
|
localError = '';
|
||||||
|
try {
|
||||||
|
const [list, err] = resultOrError(await App.ListWorkspaces(), []);
|
||||||
|
if (err) {
|
||||||
|
localError = reportError('workspaceTree.loadError', 'Could not load Deals. Please try again.', err);
|
||||||
|
workspaces = [];
|
||||||
|
} else {
|
||||||
|
workspaces = list || [];
|
||||||
|
if (!currentWorkspaceId) {
|
||||||
|
let currentWorkspace = null;
|
||||||
|
try {
|
||||||
|
currentWorkspace = await App.GetCurrentWorkspace();
|
||||||
|
} catch {
|
||||||
|
currentWorkspace = null;
|
||||||
|
}
|
||||||
|
const currentName = wsName(currentWorkspace);
|
||||||
|
if (workspaces.some((ws) => wsName(ws) === currentName)) {
|
||||||
|
currentWorkspaceId = currentName;
|
||||||
|
}
|
||||||
|
} else if (!workspaces.some((ws) => wsName(ws) === currentWorkspaceId)) {
|
||||||
|
currentWorkspaceId = '';
|
||||||
|
}
|
||||||
|
activeWorkspaceId.set(currentWorkspaceId);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
localError = reportError('workspaceTree.loadError', 'Could not load Deals. Please try again.', e);
|
||||||
|
}
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectWorkspace(workspace) {
|
||||||
|
const id = wsName(workspace);
|
||||||
|
const err = await App.SetCurrentWorkspace(id);
|
||||||
|
if (err) {
|
||||||
|
localError = reportError('workspaceTree.selectError', 'Could not select this Deal. Please try again.', err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentWorkspaceId = id;
|
||||||
|
activeWorkspaceId.set(id);
|
||||||
|
window.dispatchEvent(new CustomEvent('verstak:workspace-selected', {
|
||||||
|
detail: { workspaceName: id, nodes: nodesForEvent() }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doCreate() {
|
||||||
|
const name = newWorkspaceName.trim();
|
||||||
|
if (!name) {
|
||||||
|
createError = tr('workspaceTree.nameRequired');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!selectedTemplate) {
|
||||||
|
createError = tr('workspaceTree.chooseTemplate');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const creationIssues = selectedTemplateIssues.map(tool => ({
|
||||||
|
pluginId: tool.pluginId,
|
||||||
|
name: tool.name,
|
||||||
|
reason: tool.reason,
|
||||||
|
}));
|
||||||
|
creating = true;
|
||||||
|
createError = '';
|
||||||
|
const [, err] = resultOrError(await App.CreateWorkspace(name, selectedTemplate.id), null);
|
||||||
|
if (err) {
|
||||||
|
createError = reportError('workspaceTree.createError', 'Could not create the Deal. Please try again.', err);
|
||||||
|
creating = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showCreate = false;
|
||||||
|
newWorkspaceName = '';
|
||||||
|
creating = false;
|
||||||
|
await loadWorkspaces();
|
||||||
|
const created = workspaces.find((ws) => wsName(ws) === name);
|
||||||
|
if (created) await selectWorkspace(created);
|
||||||
|
templateWarning = creationIssues.length > 0 ? { workspaceName: name, issues: creationIssues } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openCreateDialog() {
|
||||||
|
showCreate = true;
|
||||||
|
newWorkspaceName = '';
|
||||||
|
createError = '';
|
||||||
|
await loadWorkspaceTemplates();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCreateDialog() {
|
||||||
|
if (creating) return;
|
||||||
|
showCreate = false;
|
||||||
|
newWorkspaceName = '';
|
||||||
|
createError = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissTemplateWarning() {
|
||||||
|
templateWarning = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startRename(workspace) {
|
||||||
|
renamingId = wsName(workspace);
|
||||||
|
renameValue = renamingId;
|
||||||
|
localError = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelRename() {
|
||||||
|
renamingId = '';
|
||||||
|
renameValue = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commitRename(workspace) {
|
||||||
|
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 = reportError('workspaceTree.renameError', 'Could not rename the Deal. Please try again.', 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) {
|
||||||
|
const name = wsName(workspace);
|
||||||
|
busyId = name;
|
||||||
|
const [, err] = resultOrError(await App.TrashWorkspace(name), null);
|
||||||
|
if (err) {
|
||||||
|
localError = reportError('workspaceTree.trashError', 'Could not move the Deal to trash. Please try again.', err);
|
||||||
|
busyId = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (currentWorkspaceId === name) currentWorkspaceId = '';
|
||||||
|
busyId = '';
|
||||||
|
await loadWorkspaces();
|
||||||
|
if (currentWorkspaceId) {
|
||||||
|
const selected = workspaces.find((ws) => wsName(ws) === currentWorkspaceId);
|
||||||
|
if (selected) await selectWorkspace(selected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="wt">
|
||||||
|
<div class="wt-header">
|
||||||
|
<span class="wt-title">{tr('workspaceTree.title')}</span>
|
||||||
|
<button class="wt-btn" on:click={openCreateDialog} title={tr('workspaceTree.new')} type="button">+</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="wt-loading">{tr('common.loading')}</div>
|
||||||
|
{:else if localError}
|
||||||
|
<div class="wt-error">{localError}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="wt-list">
|
||||||
|
{#each workspaces as workspace (wsName(workspace))}
|
||||||
|
{@const id = wsName(workspace)}
|
||||||
|
<div class="wt-node vt-list-row" class:selected={id === $activeWorkspaceId}>
|
||||||
|
<div class="wt-row">
|
||||||
|
<span class="wt-icon"><Icon name="space" size={13} class="wt-node-icon" /></span>
|
||||||
|
{#if renamingId === id}
|
||||||
|
<input
|
||||||
|
class="wt-rename"
|
||||||
|
bind:value={renameValue}
|
||||||
|
disabled={busyId === id}
|
||||||
|
on:keydown={(e) => {
|
||||||
|
if (e.key === 'Enter') commitRename(workspace);
|
||||||
|
if (e.key === 'Escape') cancelRename();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button class="wt-btn wt-btn-small wt-always" on:click={() => commitRename(workspace)} title={tr('workspaceTree.saveRename')} type="button" disabled={busyId === id}>OK</button>
|
||||||
|
<button class="wt-btn wt-btn-small wt-always" on:click={cancelRename} title={tr('common.cancel')} type="button" disabled={busyId === id}>{tr('common.cancel')}</button>
|
||||||
|
{:else}
|
||||||
|
<button class="wt-label" on:click={() => selectWorkspace(workspace)} type="button">{id}</button>
|
||||||
|
<button class="wt-icon-btn" on:click={() => startRename(workspace)} title={tr('workspaceTree.rename')} type="button" disabled={busyId === id}>
|
||||||
|
<Icon name="edit" size={12} />
|
||||||
|
</button>
|
||||||
|
<button class="wt-icon-btn danger" on:click={() => trashWorkspace(workspace)} title={tr('workspaceTree.trash')} type="button" disabled={busyId === id}>
|
||||||
|
<Icon name="trash" size={12} />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if templateWarning}
|
||||||
|
<section class="workspace-template-warning" data-workspace-template-warning role="alert">
|
||||||
|
<div>
|
||||||
|
<strong>{tr('workspaceTree.templateIncompleteCreated', { name: templateWarning.workspaceName })}</strong>
|
||||||
|
{#each templateWarning.issues as issue (issue.pluginId)}
|
||||||
|
<div class="workspace-template-warning-item">{issue.name}: {issue.reason}</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<button class="wt-btn" type="button" on:click={dismissTemplateWarning} title={tr('common.close')}>{tr('common.close')}</button>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showCreate}
|
||||||
|
<div class="workspace-create-overlay" data-workspace-create-modal role="dialog" aria-modal="true" aria-label={tr('workspaceTree.create')}>
|
||||||
|
<div class="workspace-create-modal">
|
||||||
|
<div class="workspace-create-header">
|
||||||
|
<div>
|
||||||
|
<h2>{tr('workspaceTree.new')}</h2>
|
||||||
|
</div>
|
||||||
|
<button class="wt-btn" on:click={closeCreateDialog} type="button" disabled={creating}>{tr('common.close')}</button>
|
||||||
|
</div>
|
||||||
|
<label class="workspace-create-field">
|
||||||
|
<span>{tr('pluginCard.name')}</span>
|
||||||
|
<input data-workspace-name type="text" bind:value={newWorkspaceName} placeholder={tr('workspaceTree.namePlaceholder')} disabled={creating} on:keydown={(event) => event.key === 'Enter' && doCreate()} />
|
||||||
|
</label>
|
||||||
|
<label class="workspace-create-field">
|
||||||
|
<span>{tr('workspaceTree.template')}</span>
|
||||||
|
<select data-workspace-template bind:value={selectedTemplateId} disabled={creating || templatesLoading || !workspaceTemplates.length}>
|
||||||
|
{#each workspaceTemplates as template (template.id)}
|
||||||
|
<option value={template.id}>{template.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{#if selectedTemplate}
|
||||||
|
<div class="workspace-template-summary">
|
||||||
|
<p data-workspace-template-description>{selectedTemplate.description}</p>
|
||||||
|
<div class="workspace-template-tools" data-workspace-template-tools>
|
||||||
|
{#each selectedTemplateTools as tool (tool.pluginId)}
|
||||||
|
<div
|
||||||
|
class="workspace-template-tool"
|
||||||
|
class:limited={tool.status === 'limited'}
|
||||||
|
class:unavailable={tool.status === 'unavailable'}
|
||||||
|
data-workspace-template-tool={tool.pluginId}
|
||||||
|
data-template-tool-status={tool.status}
|
||||||
|
>
|
||||||
|
<span class="workspace-template-tool-name">{tool.name}</span>
|
||||||
|
<span class="workspace-template-tool-tabs">{tool.tabs.length ? tr('workspaceTree.templateToolTabs', { tabs: tool.tabs.join(', ') }) : tr('workspaceTree.templateToolNoTabs')}</span>
|
||||||
|
<span class="workspace-template-tool-reason">{tool.reason}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if selectedTemplateIssues.length > 0}
|
||||||
|
<p class="workspace-template-incomplete" data-workspace-template-incomplete>{tr('workspaceTree.templateWillBeIncomplete')}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if createError}
|
||||||
|
<p class="workspace-create-error" data-workspace-create-error role="alert">{createError}</p>
|
||||||
|
{/if}
|
||||||
|
<div class="workspace-create-actions">
|
||||||
|
<button class="wt-btn-primary" on:click={doCreate} type="button" disabled={creating || templatesLoading || !selectedTemplate}>{creating ? tr('workspaceTree.creating') : tr('workspaceTree.create')}</button>
|
||||||
|
<button class="wt-btn" on:click={closeCreateDialog} type="button" disabled={creating}>{tr('common.cancel')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.wt { display: flex; flex-direction: column; flex: 1; overflow: hidden; position: relative; }
|
||||||
|
.wt-header { display: flex; align-items: center; justify-content: space-between; padding: 0.7rem 0.6rem 0.35rem; border-bottom: 1px solid var(--vt-color-border); flex-shrink: 0; }
|
||||||
|
.wt-title { color: var(--vt-color-text-muted); font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600; }
|
||||||
|
.wt-list { min-height: 0; overflow-y: auto; padding: 0.2rem 0.6rem; }
|
||||||
|
.wt-btn { min-height: 1.55rem; background: transparent; border: 1px solid transparent; color: var(--vt-color-text-muted); cursor: pointer; font-size: 0.78rem; padding: 0.12rem 0.38rem; border-radius: var(--vt-radius-sm); }
|
||||||
|
.wt-btn:hover:not(:disabled) { color: var(--vt-color-accent); background: var(--vt-color-accent-muted); border-color: rgba(78,204,163,0.25); }
|
||||||
|
.wt-btn-small { font-size: 0.7rem; opacity: 0; }
|
||||||
|
.wt-always { opacity: 1; }
|
||||||
|
.wt-row:hover .wt-btn-small { opacity: 1; }
|
||||||
|
.wt-loading, .wt-error { padding: 0.5rem; font-size: 0.75rem; color: var(--vt-color-text-muted); }
|
||||||
|
.wt-error { color: var(--vt-color-danger); }
|
||||||
|
.wt-row { display: flex; align-items: center; gap: 0.45rem; padding: 0.18rem 0.45rem; min-height: 1.85rem; border-radius: var(--vt-radius-sm); }
|
||||||
|
.wt-row:hover { background: var(--vt-color-surface-hover); }
|
||||||
|
.wt-node.selected > .wt-row { background: var(--vt-color-surface-selected); box-shadow: inset 2px 0 0 var(--vt-color-accent); }
|
||||||
|
.wt-icon { width: 0.95rem; height: 0.95rem; display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; color: var(--vt-color-text-muted); }
|
||||||
|
:global(.wt-node-icon) { display: block; }
|
||||||
|
.wt-label { flex: 1; min-width: 0; min-height: 0; justify-content: flex-start; background: none; border: none; color: var(--vt-color-text-secondary); font-size: 0.78rem; text-align: left; cursor: pointer; padding: 0.1rem 0; border-radius: var(--vt-radius-sm); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.wt-label:hover { color: var(--vt-color-accent); }
|
||||||
|
.wt-icon-btn { width: 1.45rem; height: 1.45rem; min-height: 0; padding: 0; border: 1px solid transparent; background: transparent; color: var(--vt-color-text-muted); opacity: 0.75; flex-shrink: 0; cursor: pointer; border-radius: var(--vt-radius-sm); }
|
||||||
|
.wt-row:hover .wt-icon-btn { opacity: 1; }
|
||||||
|
.wt-icon-btn:hover:not(:disabled) { color: var(--vt-color-accent); background: var(--vt-color-accent-muted); border-color: rgba(78,204,163,0.25); }
|
||||||
|
.wt-icon-btn.danger:hover:not(:disabled) { color: var(--vt-color-danger); background: var(--vt-color-danger-muted); border-color: rgba(233,69,96,0.35); }
|
||||||
|
.wt-rename { flex: 1; min-width: 0; background: #0f1424; border: 1px solid var(--vt-color-border-strong); color: var(--vt-color-text-primary); padding: 0.2rem 0.35rem; border-radius: var(--vt-radius-sm); font-size: 0.78rem; }
|
||||||
|
.wt-rename:focus { outline: none; border-color: var(--vt-color-accent); box-shadow: var(--vt-focus-ring); }
|
||||||
|
.workspace-create-overlay { position: fixed; inset: 0; z-index: 10000; display: flex; align-items: center; justify-content: center; padding: 1rem; background: rgba(4, 8, 18, 0.7); }
|
||||||
|
.workspace-create-modal { width: min(34rem, 100%); display: grid; gap: 0.85rem; padding: 1rem; border: 1px solid var(--vt-color-border-strong); border-radius: var(--vt-radius-lg); background: var(--vt-color-surface); box-shadow: 0 18px 44px rgba(0, 0, 0, 0.38); }
|
||||||
|
.workspace-create-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; }
|
||||||
|
.workspace-create-header h2 { margin: 0; font-size: 1rem; }
|
||||||
|
.workspace-create-field { display: grid; gap: 0.35rem; color: var(--vt-color-text-muted); font-size: 0.75rem; }
|
||||||
|
.workspace-create-field input, .workspace-create-field select { width: 100%; min-height: 2rem; box-sizing: border-box; border: 1px solid var(--vt-color-border-strong); border-radius: var(--vt-radius-sm); background: #0f1424; color: var(--vt-color-text-primary); padding: 0.35rem 0.5rem; font: inherit; font-size: 0.84rem; }
|
||||||
|
.workspace-create-field select { appearance: none; background-color: #0f1424; background-image: linear-gradient(45deg, transparent 50%, var(--vt-color-text-muted) 50%), linear-gradient(135deg, var(--vt-color-text-muted) 50%, transparent 50%); background-position: calc(100% - 14px) 50%, calc(100% - 9px) 50%; background-size: 5px 5px, 5px 5px; background-repeat: no-repeat; padding-right: 1.7rem; }
|
||||||
|
.workspace-create-field select option { background: #0f1424; color: var(--vt-color-text-primary); }
|
||||||
|
.workspace-create-field input:focus, .workspace-create-field select:focus { outline: none; border-color: var(--vt-color-accent); box-shadow: var(--vt-focus-ring); }
|
||||||
|
.workspace-template-summary { display: grid; gap: 0.55rem; padding: 0.75rem; border: 1px solid var(--vt-color-border); border-radius: var(--vt-radius-md); background: var(--vt-color-surface-muted); }
|
||||||
|
.workspace-template-summary p { margin: 0; color: var(--vt-color-text-secondary); font-size: 0.8rem; line-height: 1.45; }
|
||||||
|
.workspace-template-tools { display: grid; gap: 0.4rem; }
|
||||||
|
.workspace-template-tool { display: grid; grid-template-columns: minmax(7rem, 1fr) minmax(0, 1.4fr) minmax(0, 1.2fr); gap: 0.5rem; align-items: center; padding: 0.45rem 0.55rem; border: 1px solid var(--vt-color-border-strong); border-radius: var(--vt-radius-sm); color: var(--vt-color-text-secondary); font-size: 0.72rem; }
|
||||||
|
.workspace-template-tool-name { color: var(--vt-color-text-primary); font-weight: 600; }
|
||||||
|
.workspace-template-tool-tabs { color: var(--vt-color-text-muted); }
|
||||||
|
.workspace-template-tool-reason { color: var(--vt-color-accent); }
|
||||||
|
.workspace-template-tool.limited { border-color: rgba(220, 164, 57, 0.58); background: rgba(220, 164, 57, 0.08); }
|
||||||
|
.workspace-template-tool.limited .workspace-template-tool-reason { color: #e8bc63; }
|
||||||
|
.workspace-template-tool.unavailable { border-color: rgba(233, 69, 96, 0.58); background: var(--vt-color-danger-muted); }
|
||||||
|
.workspace-template-tool.unavailable .workspace-template-tool-reason { color: var(--vt-color-danger); }
|
||||||
|
.workspace-template-incomplete { color: var(--vt-color-danger) !important; }
|
||||||
|
.workspace-template-warning { display: flex; gap: 0.5rem; align-items: flex-start; justify-content: space-between; margin: 0.25rem 0.6rem 0.5rem; padding: 0.6rem; border: 1px solid rgba(233, 69, 96, 0.58); border-radius: var(--vt-radius-md); background: var(--vt-color-danger-muted); color: var(--vt-color-text-secondary); font-size: 0.73rem; line-height: 1.4; }
|
||||||
|
.workspace-template-warning strong { color: var(--vt-color-danger); }
|
||||||
|
.workspace-template-warning-item { margin-top: 0.2rem; }
|
||||||
|
@media (max-width: 700px) { .workspace-template-tool { grid-template-columns: 1fr; gap: 0.18rem; } }
|
||||||
|
.workspace-create-error { margin: 0; color: var(--vt-color-danger); font-size: 0.78rem; line-height: 1.4; }
|
||||||
|
.workspace-create-actions { display: flex; gap: 0.4rem; justify-content: flex-end; }
|
||||||
|
.wt-btn-primary { background: var(--vt-color-accent); color: #101827; border: none; padding: 0.3rem 0.6rem; border-radius: var(--vt-radius-sm); cursor: pointer; font-size: 0.75rem; font-weight: 600; }
|
||||||
|
.wt-btn-primary:hover:not(:disabled) { background: #3dbb92; }
|
||||||
|
.wt-btn-primary:disabled, .wt-btn:disabled, .wt-icon-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<script>
|
||||||
|
/**
|
||||||
|
* Icon.svelte — Renders bundled Lucide SVG icons.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* <Icon name="puzzle" size={18} />
|
||||||
|
* <Icon name="flask" class="my-icon" />
|
||||||
|
*
|
||||||
|
* Rules:
|
||||||
|
* - NO emoji, NO unicode pictographic symbols
|
||||||
|
* - NO dependency on system icon fonts
|
||||||
|
* - If name is not found, renders a default circle icon
|
||||||
|
*/
|
||||||
|
import Activity from 'lucide-svelte/icons/activity';
|
||||||
|
import Briefcase from 'lucide-svelte/icons/briefcase';
|
||||||
|
import ChevronDown from 'lucide-svelte/icons/chevron-down';
|
||||||
|
import ChevronLeft from 'lucide-svelte/icons/chevron-left';
|
||||||
|
import ChevronRight from 'lucide-svelte/icons/chevron-right';
|
||||||
|
import Circle from 'lucide-svelte/icons/circle';
|
||||||
|
import FlaskConical from 'lucide-svelte/icons/flask-conical';
|
||||||
|
import Folder from 'lucide-svelte/icons/folder';
|
||||||
|
import Inbox from 'lucide-svelte/icons/inbox';
|
||||||
|
import LayoutGrid from 'lucide-svelte/icons/layout-grid';
|
||||||
|
import ListTodo from 'lucide-svelte/icons/list-todo';
|
||||||
|
import PanelsTopLeft from 'lucide-svelte/icons/panels-top-left';
|
||||||
|
import Pencil from 'lucide-svelte/icons/pencil';
|
||||||
|
import Plug from 'lucide-svelte/icons/plug';
|
||||||
|
import Puzzle from 'lucide-svelte/icons/puzzle';
|
||||||
|
import Settings from 'lucide-svelte/icons/settings';
|
||||||
|
import Shield from 'lucide-svelte/icons/shield';
|
||||||
|
import TriangleAlert from 'lucide-svelte/icons/triangle-alert';
|
||||||
|
import Trash2 from 'lucide-svelte/icons/trash-2';
|
||||||
|
import X from 'lucide-svelte/icons/x';
|
||||||
|
|
||||||
|
export let name = 'dot';
|
||||||
|
export let size = 16;
|
||||||
|
export let className = '';
|
||||||
|
|
||||||
|
const icons = {
|
||||||
|
activity: Activity,
|
||||||
|
case: Briefcase,
|
||||||
|
chevronDown: ChevronDown,
|
||||||
|
chevronLeft: ChevronLeft,
|
||||||
|
chevronRight: ChevronRight,
|
||||||
|
dot: Circle,
|
||||||
|
edit: Pencil,
|
||||||
|
flask: FlaskConical,
|
||||||
|
folder: Folder,
|
||||||
|
gear: Settings,
|
||||||
|
inbox: Inbox,
|
||||||
|
'list-todo': ListTodo,
|
||||||
|
logo: PanelsTopLeft,
|
||||||
|
plugin: Plug,
|
||||||
|
puzzle: Puzzle,
|
||||||
|
space: LayoutGrid,
|
||||||
|
trash: Trash2,
|
||||||
|
vault: Shield,
|
||||||
|
warning: TriangleAlert,
|
||||||
|
x: X,
|
||||||
|
};
|
||||||
|
|
||||||
|
const aliases = {
|
||||||
|
'🧪': 'flask',
|
||||||
|
danger: 'warning',
|
||||||
|
settings: 'gear',
|
||||||
|
todo: 'list-todo',
|
||||||
|
};
|
||||||
|
|
||||||
|
$: iconKey = aliases[name] || name || 'dot';
|
||||||
|
$: IconComponent = icons[iconKey] || icons.dot;
|
||||||
|
$: iconClass = [className, $$restProps.class].filter(Boolean).join(' ');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:component
|
||||||
|
this={IconComponent}
|
||||||
|
size={size}
|
||||||
|
{...$$restProps}
|
||||||
|
class={iconClass}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
@@ -1,7 +1,33 @@
|
|||||||
import App from './App.svelte';
|
import App from './App.svelte';
|
||||||
|
import * as Backend from '../wailsjs/go/api/App';
|
||||||
|
import { i18n } from './lib/i18n/index.js';
|
||||||
|
|
||||||
const app = new App({
|
function unpack(result) {
|
||||||
target: document.getElementById('app'),
|
if (Array.isArray(result) && result.length === 2 && (typeof result[1] === 'string' || result[1] == null)) {
|
||||||
|
if (result[1]) throw new Error(result[1]);
|
||||||
|
return result[0];
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
i18n.configure({
|
||||||
|
loadPluginCatalog: async (pluginId, locale) => unpack(await Backend.GetPluginLocalization(pluginId, locale)),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
try {
|
||||||
|
const settings = await Backend.GetAppSettings();
|
||||||
|
await i18n.initialize(settings?.language || 'system');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[i18n] initialization failed:', error);
|
||||||
|
await i18n.initialize('system');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new App({
|
||||||
|
target: document.getElementById('app'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = start();
|
||||||
|
|
||||||
export default app;
|
export default app;
|
||||||
|
|||||||
@@ -0,0 +1,303 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* bundle-host-test.cjs — Headless smoke-test for PluginBundleHost contract.
|
||||||
|
*
|
||||||
|
* Tests:
|
||||||
|
* 1. Error boundary: missing frontend entry (no bundle)
|
||||||
|
* 2. Error boundary: bundle JS throws during execution
|
||||||
|
* 3. Error boundary: component id not found in bundle
|
||||||
|
* 4. Error boundary: component mount throws at runtime
|
||||||
|
* 5. Real mount: bundle executes and registers components
|
||||||
|
* 6. Real mount: DiagnosticsPanel writes expected text to container
|
||||||
|
* 7. Real mount: PlatformTestSettings writes expected text to container
|
||||||
|
* 8. Real mount: cleanup (unmount) empties the container
|
||||||
|
* 9. Real mount: component mount throw is catchable
|
||||||
|
*
|
||||||
|
* Runs the real platform-test frontend/dist/index.js in a vm.Sandbox
|
||||||
|
* with a minimal window/document mock.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const vm = require('vm');
|
||||||
|
|
||||||
|
// ── paths ──────────────────────────────────────────────────────────────
|
||||||
|
const BUNDLE_PATH = path.resolve(
|
||||||
|
__dirname, '..', '..', 'plugins', 'platform-test', 'frontend', 'dist', 'index.js'
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function makeMockDocument() {
|
||||||
|
let elCounter = 0;
|
||||||
|
|
||||||
|
function createElement(tag) {
|
||||||
|
elCounter++;
|
||||||
|
const el = {
|
||||||
|
tagName: tag.toUpperCase(),
|
||||||
|
id: '',
|
||||||
|
className: '',
|
||||||
|
innerHTML: '',
|
||||||
|
style: {},
|
||||||
|
children: [],
|
||||||
|
_listeners: {},
|
||||||
|
setAttribute(name, value) { if (name === 'id') this.id = value; },
|
||||||
|
getAttribute(name) { if (name === 'id') return this.id; return null; },
|
||||||
|
addEventListener(type, handler) { this._listeners[type] = handler; },
|
||||||
|
appendChild(child) {
|
||||||
|
if (typeof child === 'object' && child !== null) {
|
||||||
|
this.children.push(child);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
removeChild(child) {
|
||||||
|
const idx = this.children.indexOf(child);
|
||||||
|
if (idx >= 0) this.children.splice(idx, 1);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTextNode(text) {
|
||||||
|
return { nodeType: 3, textContent: String(text), _text: String(text) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const headChildren = [];
|
||||||
|
const bodyChildren = [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
head: { appendChild: (el) => { headChildren.push(el); }, children: headChildren },
|
||||||
|
body: { appendChild: (el) => { bodyChildren.push(el); }, children: bodyChildren },
|
||||||
|
createElement,
|
||||||
|
createTextNode,
|
||||||
|
querySelector: () => null,
|
||||||
|
getElementById: () => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMockWindow() {
|
||||||
|
const doc = makeMockDocument();
|
||||||
|
const registry = {};
|
||||||
|
|
||||||
|
const w = {
|
||||||
|
VerstakPluginRegister(pluginId, bundle) {
|
||||||
|
if (!pluginId || !bundle || !bundle.components) return;
|
||||||
|
registry[pluginId] = bundle.components;
|
||||||
|
},
|
||||||
|
VerstakPluginAPI(pluginId) {
|
||||||
|
return {
|
||||||
|
pluginId,
|
||||||
|
capabilities: { has: () => false },
|
||||||
|
events: { publish: () => {}, subscribe: () => {} },
|
||||||
|
settings: { read: () => null, write: () => {} },
|
||||||
|
commands: { execute: () => {} },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
document: doc,
|
||||||
|
console,
|
||||||
|
__VERSTAK_PLUGIN_REGISTRY__: registry,
|
||||||
|
};
|
||||||
|
|
||||||
|
// window === globalThis in browser — make it self-referential
|
||||||
|
w.window = w;
|
||||||
|
w.globalThis = w;
|
||||||
|
|
||||||
|
return w;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively extract text from mock element tree
|
||||||
|
function findTextContent(el) {
|
||||||
|
if (typeof el === 'string') return el;
|
||||||
|
if (el == null) return '';
|
||||||
|
if (el._text) return el._text;
|
||||||
|
if (el.textContent) return el.textContent;
|
||||||
|
if (Array.isArray(el.children)) {
|
||||||
|
return el.children.map(c => findTextContent(c)).join(' ');
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Runner ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let passed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
function test(name, fn) {
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
passed++;
|
||||||
|
console.log(` ✅ ${name}`);
|
||||||
|
} catch (e) {
|
||||||
|
failed++;
|
||||||
|
const msg = e.message || String(e);
|
||||||
|
errors.push(`${name}: ${msg}`);
|
||||||
|
console.log(` ❌ ${name} — ${msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
console.log('=== bundle-host-test: PluginBundleHost contract smoke ===');
|
||||||
|
console.log(` bundle path: ${BUNDLE_PATH}`);
|
||||||
|
console.log(` bundle exists: ${fs.existsSync(BUNDLE_PATH)}`);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// ── Error Boundary Tests ──
|
||||||
|
|
||||||
|
test('E1. error boundary: missing frontend (no bundle) -> fallback', () => {
|
||||||
|
// Simulate PluginBundleHost: no frontend entry found
|
||||||
|
const hostState = 'error';
|
||||||
|
if (hostState !== 'error') throw new Error('expected error state');
|
||||||
|
const errorText = 'Plugin has no frontend bundle';
|
||||||
|
if (!errorText.includes('no frontend')) throw new Error('error text mismatch');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('E2. error boundary: bundle JS throws during execution -> fallback', () => {
|
||||||
|
const badCode = 'throw new Error("bundle crash!");';
|
||||||
|
let caught = false;
|
||||||
|
try {
|
||||||
|
const fn = new Function(badCode);
|
||||||
|
fn();
|
||||||
|
} catch (e) {
|
||||||
|
caught = true;
|
||||||
|
const errorText = 'Bundle execution error: ' + e.message;
|
||||||
|
if (!errorText.includes('Bundle execution error')) {
|
||||||
|
throw new Error('expected Bundle execution error prefix');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!caught) throw new Error('bundle throw was not caught');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('E3. error boundary: component id not found -> fallback', () => {
|
||||||
|
const registry = { 'test.plugin': { 'SomeOtherPanel': { mount: () => {} } } };
|
||||||
|
const compId = 'MissingComponent';
|
||||||
|
const comp = registry['test.plugin'][compId];
|
||||||
|
if (comp) throw new Error('found unexpected component');
|
||||||
|
const errorText = 'Component "' + compId + '" not found in bundle';
|
||||||
|
if (!errorText.includes('not found')) throw new Error('error text mismatch');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('E4. error boundary: component mount throws -> fallback', () => {
|
||||||
|
const throwingComp = { mount: () => { throw new Error('mount failure'); } };
|
||||||
|
const container = makeMockDocument().createElement('div');
|
||||||
|
let caught = false;
|
||||||
|
try {
|
||||||
|
throwingComp.mount(container, {}, {});
|
||||||
|
} catch (e) {
|
||||||
|
caught = true;
|
||||||
|
}
|
||||||
|
if (!caught) throw new Error('mount throw was not caught');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Real Mount Tests (run the actual platform-test bundle) ──
|
||||||
|
|
||||||
|
if (!fs.existsSync(BUNDLE_PATH)) {
|
||||||
|
console.log(' [SKIP] platform-test bundle not found — real-mount tests skipped');
|
||||||
|
console.log(' [SKIP] run install-dev-plugins.sh first');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bundleSource = fs.readFileSync(BUNDLE_PATH, 'utf-8');
|
||||||
|
|
||||||
|
test('R1. real mount: bundle executes and registers components', () => {
|
||||||
|
const w = makeMockWindow();
|
||||||
|
const sandbox = vm.createContext(w);
|
||||||
|
vm.runInContext(bundleSource, sandbox);
|
||||||
|
|
||||||
|
const reg = sandbox.__VERSTAK_PLUGIN_REGISTRY__;
|
||||||
|
if (!reg) throw new Error('registry not created');
|
||||||
|
if (!reg['verstak.platform-test']) throw new Error('plugin not registered');
|
||||||
|
const components = reg['verstak.platform-test'];
|
||||||
|
if (!components.DiagnosticsPanel) throw new Error('DiagnosticsPanel missing');
|
||||||
|
if (!components.PlatformTestSettings) throw new Error('PlatformTestSettings missing');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('R2. real mount: DiagnosticsPanel.mount() writes expected text to container', () => {
|
||||||
|
const w = makeMockWindow();
|
||||||
|
const sandbox = vm.createContext(w);
|
||||||
|
vm.runInContext(bundleSource, sandbox);
|
||||||
|
|
||||||
|
const components = sandbox.__VERSTAK_PLUGIN_REGISTRY__['verstak.platform-test'];
|
||||||
|
const api = w.VerstakPluginAPI('verstak.platform-test');
|
||||||
|
const container = w.document.createElement('div');
|
||||||
|
container.id = 'test-diagnostics';
|
||||||
|
|
||||||
|
components.DiagnosticsPanel.mount(container, { componentId: 'verstak.platform-test.diagnostics' }, api);
|
||||||
|
|
||||||
|
const text = findTextContent(container);
|
||||||
|
const required = ['Platform Diagnostics', 'verstak.platform-test', 'Test Results', 'Plugin Registration'];
|
||||||
|
const missing = required.filter(chunk => !text.includes(chunk));
|
||||||
|
if (missing.length > 0) throw new Error('missing: ' + missing.join(', '));
|
||||||
|
|
||||||
|
// Verify bundle source contains Frontend Bundle Loaded (badge fix)
|
||||||
|
if (!bundleSource.includes('Frontend Bundle Loaded')) throw new Error('bundle missing badge text');
|
||||||
|
// Verify mount populated children
|
||||||
|
if (container.children.length < 4) throw new Error('mount did not populate container children');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('R3. real mount: PlatformTestSettings.mount() writes expected text to container', () => {
|
||||||
|
const w = makeMockWindow();
|
||||||
|
const sandbox = vm.createContext(w);
|
||||||
|
vm.runInContext(bundleSource, sandbox);
|
||||||
|
|
||||||
|
const components = sandbox.__VERSTAK_PLUGIN_REGISTRY__['verstak.platform-test'];
|
||||||
|
const api = w.VerstakPluginAPI('verstak.platform-test');
|
||||||
|
const container = w.document.createElement('div');
|
||||||
|
container.id = 'test-settings';
|
||||||
|
|
||||||
|
components.PlatformTestSettings.mount(container, { componentId: 'verstak.platform-test.settings' }, api);
|
||||||
|
|
||||||
|
const text = findTextContent(container);
|
||||||
|
const required = ['Platform Test Settings', 'verstak.platform-test', 'Settings panel loaded from plugin frontend bundle', 'Interactive Counter'];
|
||||||
|
const missing = required.filter(chunk => !text.includes(chunk));
|
||||||
|
if (missing.length > 0) throw new Error('missing: ' + missing.join(', '));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('R4. real mount: unmount() clears container', () => {
|
||||||
|
const w = makeMockWindow();
|
||||||
|
const sandbox = vm.createContext(w);
|
||||||
|
vm.runInContext(bundleSource, sandbox);
|
||||||
|
|
||||||
|
const components = sandbox.__VERSTAK_PLUGIN_REGISTRY__['verstak.platform-test'];
|
||||||
|
const api = w.VerstakPluginAPI('verstak.platform-test');
|
||||||
|
const container = w.document.createElement('div');
|
||||||
|
|
||||||
|
// Mount — verify container was modified
|
||||||
|
components.PlatformTestSettings.mount(container, {}, api);
|
||||||
|
if (container.innerHTML === '' && container.className === '') {
|
||||||
|
// mount should have set something
|
||||||
|
if (container.children.length === 0) {
|
||||||
|
throw new Error('mount should have populated container');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unmount — verify cleanup
|
||||||
|
components.PlatformTestSettings.unmount(container);
|
||||||
|
if (container.innerHTML !== '') throw new Error('unmount did not clear innerHTML');
|
||||||
|
if (container.className !== '') throw new Error('unmount did not clear className');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('R5. error scenario: component mount throw is catchable', () => {
|
||||||
|
const failingStates = [{ mount: () => { throw new Error('render error'); }, unmount: () => {} }];
|
||||||
|
const container = makeMockDocument().createElement('div');
|
||||||
|
let caught = false;
|
||||||
|
try {
|
||||||
|
failingStates[0].mount(container, {}, {});
|
||||||
|
} catch (e) {
|
||||||
|
caught = true;
|
||||||
|
if (!e.message.includes('render error')) throw new Error('wrong error message');
|
||||||
|
}
|
||||||
|
if (!caught) throw new Error('mount throw was not caught');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Summary ──
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log(`=== results: ${passed} passed, ${failed} failed ===`);
|
||||||
|
if (failed > 0) {
|
||||||
|
console.log('Errors:');
|
||||||
|
errors.forEach(e => console.log(` - ${e}`));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -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,112 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
createI18n,
|
||||||
|
resolveLocale,
|
||||||
|
} from '../src/lib/i18n/index.js';
|
||||||
|
import englishShellCatalog from '../src/lib/i18n/catalogs/en.js';
|
||||||
|
import russianShellCatalog from '../src/lib/i18n/catalogs/ru.js';
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
Object.keys(russianShellCatalog).sort(),
|
||||||
|
Object.keys(englishShellCatalog).sort(),
|
||||||
|
'English and Russian shell catalogs must have identical keys',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(resolveLocale('system', ['ru-RU']), 'ru');
|
||||||
|
assert.equal(resolveLocale('system', ['uk-UA', 'en-US']), 'en');
|
||||||
|
assert.equal(resolveLocale('ru', ['en-US']), 'ru');
|
||||||
|
assert.equal(resolveLocale('en', ['ru-RU']), 'en');
|
||||||
|
assert.throws(() => resolveLocale('de', ['de-DE']), /unsupported language/);
|
||||||
|
|
||||||
|
const catalogs = {
|
||||||
|
'localized.plugin': {
|
||||||
|
en: { 'manifest.name': 'Localized Plugin', greeting: 'Hello, {name}!' },
|
||||||
|
ru: {
|
||||||
|
'manifest.name': 'Локализованный плагин',
|
||||||
|
'contributions.views.localized.view.title': 'Локализованный экран',
|
||||||
|
greeting: 'Привет, {name}!',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const loads = [];
|
||||||
|
const service = createI18n({
|
||||||
|
shellCatalogs: {
|
||||||
|
en: { loading: 'Loading {name}...', fallbackOnly: 'English fallback' },
|
||||||
|
ru: { loading: 'Загрузка {name}...' },
|
||||||
|
},
|
||||||
|
systemLanguages: () => ['ru-RU'],
|
||||||
|
loadPluginCatalog: async (pluginId, locale) => {
|
||||||
|
loads.push(`${pluginId}:${locale}`);
|
||||||
|
return catalogs[pluginId]?.[locale] || {};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.initialize('system');
|
||||||
|
assert.equal(service.getLanguagePreference(), 'system');
|
||||||
|
assert.equal(service.getLocale(), 'ru');
|
||||||
|
assert.equal(service.t('loading', { name: 'Верстак' }), 'Загрузка Верстак...');
|
||||||
|
assert.equal(service.t('fallbackOnly'), 'English fallback');
|
||||||
|
assert.equal(service.t('missing', undefined, 'Explicit fallback'), 'Explicit fallback');
|
||||||
|
assert.equal(service.t('unknown'), 'unknown');
|
||||||
|
|
||||||
|
await service.loadPlugin('localized.plugin', {
|
||||||
|
defaultLocale: 'en',
|
||||||
|
locales: { en: 'locales/en.json', ru: 'locales/ru.json' },
|
||||||
|
});
|
||||||
|
assert.deepEqual(loads.sort(), ['localized.plugin:en', 'localized.plugin:ru']);
|
||||||
|
assert.equal(service.translatePlugin('localized.plugin', 'greeting', { name: 'Мир' }), 'Привет, Мир!');
|
||||||
|
|
||||||
|
const plugin = {
|
||||||
|
manifest: {
|
||||||
|
id: 'localized.plugin',
|
||||||
|
name: 'Localized Plugin',
|
||||||
|
description: 'Literal description',
|
||||||
|
contributes: {
|
||||||
|
views: [{ id: 'localized.view', title: 'Literal View', component: 'View' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const localized = service.localizePlugin(plugin);
|
||||||
|
assert.notEqual(localized, plugin);
|
||||||
|
assert.equal(localized.manifest.name, 'Локализованный плагин');
|
||||||
|
assert.equal(localized.manifest.description, 'Literal description');
|
||||||
|
assert.equal(localized.manifest.contributes.views[0].title, 'Локализованный экран');
|
||||||
|
assert.equal(plugin.manifest.name, 'Localized Plugin');
|
||||||
|
const summary = service.localizeContributionSummary({
|
||||||
|
views: [{ pluginId: 'localized.plugin', id: 'localized.view', title: 'Literal View', component: 'View' }],
|
||||||
|
});
|
||||||
|
assert.equal(summary.views[0].title, 'Локализованный экран');
|
||||||
|
|
||||||
|
let notifications = 0;
|
||||||
|
const unsubscribe = service.subscribe(() => { notifications += 1; });
|
||||||
|
assert.equal(notifications, 1);
|
||||||
|
await service.setLanguagePreference('en');
|
||||||
|
assert.equal(service.getLocale(), 'en');
|
||||||
|
assert.equal(service.translatePlugin('localized.plugin', 'greeting', { name: 'World' }), 'Hello, World!');
|
||||||
|
assert.equal(notifications, 2);
|
||||||
|
unsubscribe();
|
||||||
|
await service.setLanguagePreference('ru');
|
||||||
|
assert.equal(notifications, 2);
|
||||||
|
|
||||||
|
const resilientService = createI18n({
|
||||||
|
shellCatalogs: { en: { title: 'Title' }, ru: { title: 'Заголовок' } },
|
||||||
|
systemLanguages: () => ['en-US'],
|
||||||
|
loadPluginCatalog: async () => { throw new Error('broken catalog'); },
|
||||||
|
});
|
||||||
|
await resilientService.initialize('en');
|
||||||
|
await assert.rejects(
|
||||||
|
resilientService.loadPlugin('broken.plugin', {
|
||||||
|
defaultLocale: 'en',
|
||||||
|
locales: { en: 'locales/en.json', ru: 'locales/ru.json' },
|
||||||
|
}),
|
||||||
|
/broken catalog/,
|
||||||
|
);
|
||||||
|
let resilientNotification = '';
|
||||||
|
resilientService.subscribe((nextLocale) => { resilientNotification = nextLocale; });
|
||||||
|
await resilientService.setLanguagePreference('ru');
|
||||||
|
assert.equal(resilientService.getLanguagePreference(), 'ru');
|
||||||
|
assert.equal(resilientService.getLocale(), 'ru');
|
||||||
|
assert.equal(resilientNotification, 'ru');
|
||||||
|
assert.equal(resilientService.t('title'), 'Заголовок');
|
||||||
|
|
||||||
|
console.log('i18n service tests passed');
|
||||||
@@ -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');
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { pathToFileURL } from 'node:url';
|
||||||
|
|
||||||
|
const pluginData = {};
|
||||||
|
const scheduledNotifications = [];
|
||||||
|
|
||||||
|
globalThis.window = {
|
||||||
|
__VERSTAK_PLUGIN_REGISTRY__: {},
|
||||||
|
__VERSTAK_EVENT_HANDLERS__: {},
|
||||||
|
__VERSTAK_COMMAND_HANDLERS__: {},
|
||||||
|
go: {
|
||||||
|
api: {
|
||||||
|
App: {
|
||||||
|
GetContributions: () => Promise.resolve({
|
||||||
|
fileActions: [
|
||||||
|
{
|
||||||
|
pluginId: 'provider.plugin',
|
||||||
|
id: 'provider.file.action',
|
||||||
|
label: 'Provider File Action',
|
||||||
|
handler: 'provider.command',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
noteActions: [],
|
||||||
|
contextMenuEntries: [],
|
||||||
|
}),
|
||||||
|
ExecutePluginCommand: (pluginId, commandId, args) => Promise.resolve([{
|
||||||
|
status: 'declared',
|
||||||
|
pluginId,
|
||||||
|
commandId,
|
||||||
|
args,
|
||||||
|
}, '']),
|
||||||
|
ReadPluginDataJSON: (pluginId, name) => Promise.resolve([
|
||||||
|
Object.assign({}, (pluginData[pluginId] && pluginData[pluginId][name]) || {}),
|
||||||
|
'',
|
||||||
|
]),
|
||||||
|
WritePluginDataJSON: (pluginId, name, data) => {
|
||||||
|
pluginData[pluginId] = pluginData[pluginId] || {};
|
||||||
|
pluginData[pluginId][name] = Object.assign({}, data || {});
|
||||||
|
return Promise.resolve('');
|
||||||
|
},
|
||||||
|
ReplacePluginNotifications: (pluginId, items) => {
|
||||||
|
scheduledNotifications.push({ pluginId, items });
|
||||||
|
return Promise.resolve(items[0]?.id === 'rejected' ? 'notification permission denied' : '');
|
||||||
|
},
|
||||||
|
ClearPluginNotifications: (pluginId) => {
|
||||||
|
scheduledNotifications.push({ pluginId, clear: true });
|
||||||
|
return Promise.resolve('');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
globalThis.__mockApp = window.go.api.App;
|
||||||
|
const localeListeners = new Set();
|
||||||
|
globalThis.__mockI18n = {
|
||||||
|
getLocale: () => 'ru',
|
||||||
|
translatePlugin: (_pluginId, key, params, fallback) => {
|
||||||
|
const messages = { greeting: 'Привет, {name}!' };
|
||||||
|
const message = messages[key] || fallback || key;
|
||||||
|
return message.replace(/\{([^}]+)\}/g, (placeholder, name) => (
|
||||||
|
Object.prototype.hasOwnProperty.call(params || {}, name) ? String(params[name]) : placeholder
|
||||||
|
));
|
||||||
|
},
|
||||||
|
subscribe: (listener) => {
|
||||||
|
localeListeners.add(listener);
|
||||||
|
listener('ru');
|
||||||
|
return () => localeListeners.delete(listener);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const sourcePath = path.resolve('frontend/src/lib/plugin-host/VerstakPluginAPI.js');
|
||||||
|
const source = fs.readFileSync(sourcePath, 'utf8')
|
||||||
|
.replace("import * as App from '../../../wailsjs/go/api/App';", 'const App = globalThis.__mockApp;')
|
||||||
|
.replace("import { i18n } from '../i18n/index.js';", 'const i18n = globalThis.__mockI18n;');
|
||||||
|
const tempPath = path.resolve('/tmp/verstak-plugin-api-contributions-test.mjs');
|
||||||
|
fs.writeFileSync(tempPath, source);
|
||||||
|
|
||||||
|
const apiModule = await import(pathToFileURL(tempPath).href + '?t=' + Date.now());
|
||||||
|
const api = apiModule.createPluginAPI('verstak.files');
|
||||||
|
|
||||||
|
if (!api.contributions || typeof api.contributions.list !== 'function') {
|
||||||
|
throw new Error('api.contributions.list is missing');
|
||||||
|
}
|
||||||
|
if (!api.commands || typeof api.commands.executeFor !== 'function') {
|
||||||
|
throw new Error('api.commands.executeFor is missing');
|
||||||
|
}
|
||||||
|
if (!api.i18n || typeof api.i18n.getLocale !== 'function' || typeof api.i18n.t !== 'function' || typeof api.i18n.onDidChangeLocale !== 'function') {
|
||||||
|
throw new Error('api.i18n contract is missing');
|
||||||
|
}
|
||||||
|
if (api.i18n.getLocale() !== 'ru' || api.i18n.t('greeting', { name: 'Мир' }) !== 'Привет, Мир!') {
|
||||||
|
throw new Error('api.i18n locale or translation is incorrect');
|
||||||
|
}
|
||||||
|
let localeNotifications = 0;
|
||||||
|
api.i18n.onDidChangeLocale(() => { localeNotifications += 1; });
|
||||||
|
if (localeNotifications !== 1 || localeListeners.size !== 1) {
|
||||||
|
throw new Error('api.i18n locale subscription was not registered');
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileActions = await api.contributions.list('fileActions');
|
||||||
|
if (fileActions.length !== 1 || fileActions[0].id !== 'provider.file.action') {
|
||||||
|
throw new Error(`unexpected file actions: ${JSON.stringify(fileActions)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.__VERSTAK_COMMAND_HANDLERS__['provider.plugin:provider.command'] = (args, declared) => ({
|
||||||
|
handledPath: args.path,
|
||||||
|
declaredPlugin: declared.pluginId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await api.commands.executeFor('provider.plugin', 'provider.command', {
|
||||||
|
source: 'files',
|
||||||
|
path: 'Project/Docs/readme.md',
|
||||||
|
});
|
||||||
|
if (result.status !== 'handled' || result.result.handledPath !== 'Project/Docs/readme.md') {
|
||||||
|
throw new Error(`unexpected executeFor result: ${JSON.stringify(result)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!api.storage || !api.storage.data || typeof api.storage.data.read !== 'function' || typeof api.storage.data.write !== 'function') {
|
||||||
|
throw new Error('api.storage.data read/write is missing');
|
||||||
|
}
|
||||||
|
|
||||||
|
await api.storage.data.write('search-index', { version: 1, workspaceRootPath: 'Project' });
|
||||||
|
const stored = await api.storage.data.read('search-index');
|
||||||
|
if (stored.version !== 1 || stored.workspaceRootPath !== 'Project') {
|
||||||
|
throw new Error(`unexpected storage data: ${JSON.stringify(stored)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!api.notifications || typeof api.notifications.replace !== 'function' || typeof api.notifications.clear !== 'function') {
|
||||||
|
throw new Error('api.notifications replace/clear contract is missing');
|
||||||
|
}
|
||||||
|
await api.notifications.replace([{ id: 'reminder-1', dueAt: '2026-07-14T10:00:00Z', title: 'Reminder' }]);
|
||||||
|
await api.notifications.clear();
|
||||||
|
if (scheduledNotifications.length !== 2
|
||||||
|
|| scheduledNotifications[0].pluginId !== 'verstak.files'
|
||||||
|
|| scheduledNotifications[0].items[0].id !== 'reminder-1'
|
||||||
|
|| scheduledNotifications[1].pluginId !== 'verstak.files'
|
||||||
|
|| !scheduledNotifications[1].clear) {
|
||||||
|
throw new Error(`unexpected notification calls: ${JSON.stringify(scheduledNotifications)}`);
|
||||||
|
}
|
||||||
|
let rejected = false;
|
||||||
|
try {
|
||||||
|
await api.notifications.replace([{ id: 'rejected', dueAt: '2026-07-14T10:00:00Z', title: 'Reminder' }]);
|
||||||
|
} catch (err) {
|
||||||
|
rejected = String(err.message || err).includes('[plugin:verstak.files] notifications.replace failed: notification permission denied');
|
||||||
|
}
|
||||||
|
if (!rejected) {
|
||||||
|
throw new Error('notification backend errors must be plugin-scoped rejections');
|
||||||
|
}
|
||||||
|
|
||||||
|
api.dispose();
|
||||||
|
if (localeListeners.size !== 0) {
|
||||||
|
throw new Error('api.i18n locale subscription was not disposed');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('plugin api contributions smoke passed');
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { pathToFileURL } from 'node:url';
|
||||||
|
|
||||||
|
const calls = [];
|
||||||
|
|
||||||
|
globalThis.window = {
|
||||||
|
__VERSTAK_PLUGIN_REGISTRY__: {},
|
||||||
|
__VERSTAK_EVENT_HANDLERS__: {},
|
||||||
|
__VERSTAK_COMMAND_HANDLERS__: {},
|
||||||
|
go: {
|
||||||
|
api: {
|
||||||
|
App: {
|
||||||
|
ReadVaultFileBytes: (pluginId, relativePath) => {
|
||||||
|
calls.push({ method: 'ReadVaultFileBytes', pluginId, relativePath });
|
||||||
|
return Promise.resolve([{ relativePath, size: 4, mimeHint: 'image/png', dataBase64: 'iVBORw==' }, '']);
|
||||||
|
},
|
||||||
|
RestoreVaultTrash: (pluginId, trashId, options) => {
|
||||||
|
calls.push({ method: 'RestoreVaultTrash', pluginId, trashId, options });
|
||||||
|
return Promise.resolve(['Docs/restored.txt', '']);
|
||||||
|
},
|
||||||
|
WriteVaultFileBytes: (pluginId, relativePath, dataBase64, options) => {
|
||||||
|
calls.push({ method: 'WriteVaultFileBytes', pluginId, relativePath, dataBase64, options });
|
||||||
|
return Promise.resolve('');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
globalThis.__mockApp = window.go.api.App;
|
||||||
|
|
||||||
|
const sourcePath = path.resolve('frontend/src/lib/plugin-host/VerstakPluginAPI.js');
|
||||||
|
const source = fs.readFileSync(sourcePath, 'utf8')
|
||||||
|
.replace("import * as App from '../../../wailsjs/go/api/App';", 'const App = globalThis.__mockApp;');
|
||||||
|
const tempPath = path.resolve('/tmp/verstak-plugin-api-files-test.mjs');
|
||||||
|
fs.writeFileSync(tempPath, source);
|
||||||
|
|
||||||
|
const apiModule = await import(pathToFileURL(tempPath).href + '?t=' + Date.now());
|
||||||
|
const api = apiModule.createPluginAPI('verstak.files');
|
||||||
|
|
||||||
|
if (!api.files || typeof api.files.restoreTrash !== 'function') {
|
||||||
|
throw new Error('api.files.restoreTrash is missing');
|
||||||
|
}
|
||||||
|
if (typeof api.files.readBytes !== 'function') {
|
||||||
|
throw new Error('api.files.readBytes is missing');
|
||||||
|
}
|
||||||
|
if (typeof api.files.writeBytes !== 'function') {
|
||||||
|
throw new Error('api.files.writeBytes is missing');
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = await api.files.readBytes('Docs/image.png');
|
||||||
|
if (bytes.dataBase64 !== 'iVBORw==' || bytes.mimeHint !== 'image/png') {
|
||||||
|
throw new Error(`unexpected readBytes result: ${JSON.stringify(bytes)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await api.files.writeBytes('Docs/copy.png', 'iVBORw==', { createIfMissing: true });
|
||||||
|
|
||||||
|
const restored = await api.files.restoreTrash('trash-1', { overwrite: true });
|
||||||
|
if (restored !== 'Docs/restored.txt') {
|
||||||
|
throw new Error(`unexpected restore result: ${JSON.stringify(restored)}`);
|
||||||
|
}
|
||||||
|
if (calls.length !== 3 || calls[0].method !== 'ReadVaultFileBytes' || calls[0].pluginId !== 'verstak.files' || calls[0].relativePath !== 'Docs/image.png') {
|
||||||
|
throw new Error(`unexpected ReadVaultFileBytes call: ${JSON.stringify(calls)}`);
|
||||||
|
}
|
||||||
|
if (calls[1].method !== 'WriteVaultFileBytes' || calls[1].pluginId !== 'verstak.files' || calls[1].relativePath !== 'Docs/copy.png' || calls[1].dataBase64 !== 'iVBORw==' || calls[1].options.createIfMissing !== true) {
|
||||||
|
throw new Error(`unexpected WriteVaultFileBytes call: ${JSON.stringify(calls)}`);
|
||||||
|
}
|
||||||
|
if (calls[2].method !== 'RestoreVaultTrash' || calls[2].pluginId !== 'verstak.files' || calls[2].trashId !== 'trash-1' || calls[2].options.overwrite !== true) {
|
||||||
|
throw new Error(`unexpected RestoreVaultTrash call: ${JSON.stringify(calls)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('plugin api files smoke passed');
|
||||||
@@ -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,57 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const root = path.resolve('.');
|
||||||
|
|
||||||
|
function read(relativePath) {
|
||||||
|
return fs.readFileSync(path.join(root, relativePath), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertIncludes(source, needle, message) {
|
||||||
|
if (!source.includes(needle)) {
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaceHost = read('frontend/src/lib/shell/WorkspaceHost.svelte');
|
||||||
|
const statusBar = read('frontend/src/lib/shell/StatusBar.svelte');
|
||||||
|
const pluginManager = read('frontend/src/lib/plugin-manager/PluginManager.svelte');
|
||||||
|
const syncManifest = JSON.parse(read('../verstak-official-plugins/plugins/sync/plugin.json'));
|
||||||
|
|
||||||
|
assertIncludes(
|
||||||
|
workspaceHost,
|
||||||
|
'data-workspace-search',
|
||||||
|
'WorkspaceHost should expose a stable workspace header search slot'
|
||||||
|
);
|
||||||
|
assertIncludes(
|
||||||
|
workspaceHost,
|
||||||
|
'toolOrder',
|
||||||
|
'WorkspaceHost should define usage-based workspace tool ordering'
|
||||||
|
);
|
||||||
|
assertIncludes(
|
||||||
|
workspaceHost,
|
||||||
|
'sortWorkspaceTools',
|
||||||
|
'WorkspaceHost should sort workspace tools by expected usage'
|
||||||
|
);
|
||||||
|
|
||||||
|
assertIncludes(
|
||||||
|
statusBar,
|
||||||
|
"import PluginBundleHost from '../plugin-host/PluginBundleHost.svelte';",
|
||||||
|
'StatusBar should mount plugin-provided status bar components'
|
||||||
|
);
|
||||||
|
assertIncludes(
|
||||||
|
statusBar,
|
||||||
|
'componentId={item.handler}',
|
||||||
|
'StatusBar should use statusBarItems.handler as the component id'
|
||||||
|
);
|
||||||
|
|
||||||
|
const syncStatus = syncManifest.contributes.statusBarItems.find((item) => item.id === 'verstak.sync.status');
|
||||||
|
if (!syncStatus || syncStatus.handler !== 'SyncStatusBar') {
|
||||||
|
throw new Error('Sync statusBarItem should declare handler "SyncStatusBar"');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/lastOpenedKey\s*=\s*key;\s*openSettingsFromProps\(activeSettingsPluginId,\s*activeSettingsPanelId\)/s.test(pluginManager)) {
|
||||||
|
throw new Error('PluginManager should not mark settings panel as opened before resolving contributions');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('shell source contract smoke passed');
|
||||||
@@ -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,14 +1,41 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => {
|
||||||
plugins: [svelte()],
|
const isTest = mode === 'test';
|
||||||
build: {
|
|
||||||
outDir: 'dist',
|
return {
|
||||||
emptyOutDir: true,
|
plugins: [svelte()],
|
||||||
},
|
build: {
|
||||||
server: {
|
outDir: 'dist',
|
||||||
port: 5173,
|
emptyOutDir: true,
|
||||||
strictPort: true,
|
},
|
||||||
},
|
server: {
|
||||||
|
port: isTest ? 5174 : 5173,
|
||||||
|
strictPort: true,
|
||||||
|
fs: {
|
||||||
|
allow: [resolve(__dirname, '..', '..')],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
include: [
|
||||||
|
'lucide-svelte/icons/briefcase',
|
||||||
|
'lucide-svelte/icons/chevron-down',
|
||||||
|
'lucide-svelte/icons/chevron-right',
|
||||||
|
'lucide-svelte/icons/circle',
|
||||||
|
'lucide-svelte/icons/flask-conical',
|
||||||
|
'lucide-svelte/icons/folder',
|
||||||
|
'lucide-svelte/icons/layout-grid',
|
||||||
|
'lucide-svelte/icons/panels-top-left',
|
||||||
|
'lucide-svelte/icons/pencil',
|
||||||
|
'lucide-svelte/icons/plug',
|
||||||
|
'lucide-svelte/icons/puzzle',
|
||||||
|
'lucide-svelte/icons/settings',
|
||||||
|
'lucide-svelte/icons/shield',
|
||||||
|
'lucide-svelte/icons/trash-2',
|
||||||
|
'lucide-svelte/icons/triangle-alert',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,26 +1,207 @@
|
|||||||
// 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 {context} 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 {notifications} from '../models';
|
||||||
|
|
||||||
|
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>;
|
||||||
|
|
||||||
|
export function CreateVaultFolder(arg1:string,arg2:string):Promise<string>;
|
||||||
|
|
||||||
|
export function CreateWorkspace(arg1:string,arg2:string):Promise<workspace.Workspace|string>;
|
||||||
|
|
||||||
|
export function CreateWorkspaceNode(arg1:string,arg2:string,arg3:string):Promise<Record<string, any>>;
|
||||||
|
|
||||||
|
export function DeleteVaultTrash(arg1:string,arg2:string):Promise<string>;
|
||||||
|
|
||||||
|
export function DisablePlugin(arg1:string):Promise<string>;
|
||||||
|
|
||||||
|
export function DomReady(arg1:context.Context):Promise<void>;
|
||||||
|
|
||||||
|
export function EditWorkbenchResource(arg1:string,arg2:Record<string, any>):Promise<workbench.OpenResourceResult|string>;
|
||||||
|
|
||||||
|
export function EnablePlugin(arg1:string):Promise<string>;
|
||||||
|
|
||||||
|
export function ExecutePluginCommand(arg1:string,arg2:string,arg3:Record<string, any>):Promise<Record<string, any>|string>;
|
||||||
|
|
||||||
|
export function GetAppSettings():Promise<Record<string, any>>;
|
||||||
|
|
||||||
export function GetCapabilities():Promise<Array<capability.Entry>>;
|
export function GetCapabilities():Promise<Array<capability.Entry>>;
|
||||||
|
|
||||||
export function GetContributions():Promise<api.ContributionSummary>;
|
export function GetContributions():Promise<api.ContributionSummary>;
|
||||||
|
|
||||||
|
export function GetCurrentWorkspace():Promise<Record<string, any>>;
|
||||||
|
|
||||||
|
export function GetCurrentWorkspaceNode():Promise<Record<string, any>>;
|
||||||
|
|
||||||
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 GetPluginCapability(arg1:string,arg2:string):Promise<Record<string, any>|string>;
|
||||||
|
|
||||||
|
export function GetPluginFrontendInfo(arg1:string):Promise<Record<string, any>>;
|
||||||
|
|
||||||
|
export function GetPluginLocalization(arg1:string,arg2:string):Promise<Record<string, string>|string>;
|
||||||
|
|
||||||
export function GetPlugins():Promise<Array<plugin.Plugin>>;
|
export function GetPlugins():Promise<Array<plugin.Plugin>>;
|
||||||
|
|
||||||
|
export function GetVaultFileMetadata(arg1:string,arg2:string):Promise<files.FileMetadata|string>;
|
||||||
|
|
||||||
|
export function GetVaultPluginState():Promise<Record<string, any>>;
|
||||||
|
|
||||||
export function GetVaultStatus():Promise<Record<string, string>>;
|
export function GetVaultStatus():Promise<Record<string, string>>;
|
||||||
|
|
||||||
|
export function GetWorkbenchOpenedResources():Promise<Array<workbench.OpenedResource>>;
|
||||||
|
|
||||||
|
export function GetWorkbenchPreferences():Promise<workbench.Preferences>;
|
||||||
|
|
||||||
|
export function GetWorkspaceMetadata(arg1:string):Promise<workspace.Metadata|string>;
|
||||||
|
|
||||||
|
export function GetWorkspaceTree():Promise<Record<string, any>>;
|
||||||
|
|
||||||
|
export function ListPluginCapabilities(arg1:string):Promise<Array<capability.Entry>|string>;
|
||||||
|
|
||||||
|
export function ListVaultFiles(arg1:string,arg2:string):Promise<Array<files.FileEntry>|string>;
|
||||||
|
|
||||||
|
export function ListVaultTrash(arg1:string):Promise<Array<files.TrashEntry>|string>;
|
||||||
|
|
||||||
|
export function ListWorkspaceIdentities():Promise<Array<workspace.WorkspaceIdentity>|string>;
|
||||||
|
|
||||||
|
export function ListWorkspaceTemplates():Promise<Array<workspace.WorkspaceTemplate>|string>;
|
||||||
|
|
||||||
|
export function ListWorkspaces():Promise<Array<workspace.Workspace>|string>;
|
||||||
|
|
||||||
|
export function MoveVaultPath(arg1:string,arg2:string,arg3:string,arg4:files.MoveOptions):Promise<string>;
|
||||||
|
|
||||||
|
export function MoveWorkspaceNode(arg1:string,arg2:string):Promise<string>;
|
||||||
|
|
||||||
|
export function OpenExternalURL(arg1:string,arg2:string):Promise<string>;
|
||||||
|
|
||||||
export function OpenVault(arg1:string):Promise<void>;
|
export function OpenVault(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function OpenVaultPathExternal(arg1:string,arg2:string):Promise<string>;
|
||||||
|
|
||||||
|
export function OpenWorkbenchResource(arg1:string,arg2:Record<string, any>):Promise<workbench.OpenResourceResult|string>;
|
||||||
|
|
||||||
|
export function PluginBrowserReceiverPairing(arg1:string):Promise<Record<string, string>|string>;
|
||||||
|
|
||||||
|
export function PluginRotateBrowserReceiverToken(arg1:string):Promise<Record<string, string>|string>;
|
||||||
|
|
||||||
|
export function PluginSecretsCopyLink(arg1:string,arg2:string):Promise<string|string>;
|
||||||
|
|
||||||
|
export function PluginSecretsDelete(arg1:string,arg2:string):Promise<string>;
|
||||||
|
|
||||||
|
export function PluginSecretsList(arg1:string):Promise<Array<Record<string, any>>|string>;
|
||||||
|
|
||||||
|
export function PluginSecretsRead(arg1:string,arg2:string):Promise<Record<string, any>|string>;
|
||||||
|
|
||||||
|
export function PluginSecretsStatus(arg1:string):Promise<Record<string, any>|string>;
|
||||||
|
|
||||||
|
export function PluginSecretsUnlock(arg1:string,arg2:string):Promise<string>;
|
||||||
|
|
||||||
|
export function PluginSecretsWrite(arg1:string,arg2:Record<string, any>):Promise<Record<string, any>|string>;
|
||||||
|
|
||||||
|
export function PluginSyncConfigure(arg1:string,arg2:string,arg3:string,arg4:string):Promise<string>;
|
||||||
|
|
||||||
|
export function PluginSyncDisconnect(arg1:string):Promise<string>;
|
||||||
|
|
||||||
|
export function PluginSyncNow(arg1:string):Promise<Record<string, any>|string>;
|
||||||
|
|
||||||
|
export function PluginSyncResetKey(arg1:string):Promise<string>;
|
||||||
|
|
||||||
|
export function PluginSyncSetInterval(arg1:string,arg2:number):Promise<string>;
|
||||||
|
|
||||||
|
export function PluginSyncStatus(arg1:string):Promise<api.SyncStatusDTO|string>;
|
||||||
|
|
||||||
|
export function PluginSyncTestConnection(arg1:string,arg2:string,arg3:string,arg4:string):Promise<string>;
|
||||||
|
|
||||||
|
export function PublishPluginEvent(arg1:string,arg2:string,arg3:Record<string, any>):Promise<string>;
|
||||||
|
|
||||||
|
export function PurgeWorkspaceTrash(arg1:string):Promise<string>;
|
||||||
|
|
||||||
|
export function Quit():Promise<void>;
|
||||||
|
|
||||||
|
export function ReadPluginDataJSON(arg1:string,arg2:string):Promise<Record<string, any>>;
|
||||||
|
|
||||||
|
export function ReadPluginDataNDJSON(arg1:string,arg2:string):Promise<Array<Record<string, any>>>;
|
||||||
|
|
||||||
|
export function ReadPluginSetting(arg1:string,arg2:string):Promise<any>;
|
||||||
|
|
||||||
|
export function ReadPluginSettings(arg1:string):Promise<Record<string, any>|string>;
|
||||||
|
|
||||||
|
export function ReadVaultFileBytes(arg1:string,arg2:string):Promise<files.FileBytes|string>;
|
||||||
|
|
||||||
|
export function ReadVaultTextFile(arg1:string,arg2:string):Promise<string|string>;
|
||||||
|
|
||||||
|
export function RecordDesiredPlugin(arg1:string,arg2:string,arg3:string):Promise<string>;
|
||||||
|
|
||||||
export function ReloadPlugins():Promise<number|string>;
|
export function ReloadPlugins():Promise<number|string>;
|
||||||
|
|
||||||
export function Startup():Promise<void>;
|
export function RenameWorkspace(arg1:string,arg2:string):Promise<string>;
|
||||||
|
|
||||||
|
export function RenameWorkspaceNode(arg1:string,arg2:string):Promise<string>;
|
||||||
|
|
||||||
|
export function RepairWorkspaceIdentity(arg1:string,arg2:string):Promise<string>;
|
||||||
|
|
||||||
|
export function ReplacePluginNotifications(arg1:string,arg2:Array<notifications.Request>):Promise<string>;
|
||||||
|
|
||||||
|
export function RestoreVaultTrash(arg1:string,arg2:string,arg3:files.RestoreOptions):Promise<string|string>;
|
||||||
|
|
||||||
|
export function RestoreWorkspaceTrash(arg1:string,arg2:string):Promise<workspace.Workspace|string>;
|
||||||
|
|
||||||
|
export function SelectDirectory():Promise<string>;
|
||||||
|
|
||||||
|
export function SelectVaultForOpen():Promise<string>;
|
||||||
|
|
||||||
|
export function SetCurrentVault(arg1:string):Promise<string>;
|
||||||
|
|
||||||
|
export function SetCurrentWorkspace(arg1:string):Promise<string>;
|
||||||
|
|
||||||
|
export function SetCurrentWorkspaceNode(arg1:string):Promise<string>;
|
||||||
|
|
||||||
|
export function SetNotificationService(arg1:api.notificationService):Promise<void>;
|
||||||
|
|
||||||
|
export function ShowVaultPathInFolder(arg1:string,arg2:string):Promise<string>;
|
||||||
|
|
||||||
|
export function ShowWindow():Promise<void>;
|
||||||
|
|
||||||
|
export function Shutdown(arg1:context.Context):Promise<void>;
|
||||||
|
|
||||||
|
export function SubscribePluginEvent(arg1:string,arg2:string):Promise<string>;
|
||||||
|
|
||||||
|
export function TrashVaultPath(arg1:string,arg2:string):Promise<files.TrashResult|string>;
|
||||||
|
|
||||||
|
export function TrashWorkspace(arg1:string):Promise<workspace.TrashResult|string>;
|
||||||
|
|
||||||
|
export function UpdateAppSettings(arg1:Record<string, any>):Promise<string>;
|
||||||
|
|
||||||
|
export function UpdateWorkbenchPreferences(arg1:workbench.Preferences):Promise<string>;
|
||||||
|
|
||||||
|
export function UpdateWorkspaceMetadata(arg1:string,arg2:workspace.MetadataPatch):Promise<workspace.Metadata|string>;
|
||||||
|
|
||||||
|
export function WriteFrontendLog(arg1:string,arg2:string):Promise<void>;
|
||||||
|
|
||||||
|
export function WritePluginDataJSON(arg1:string,arg2:string,arg3:Record<string, any>):Promise<string>;
|
||||||
|
|
||||||
|
export function WritePluginDataNDJSON(arg1:string,arg2:string,arg3:Array<Record<string, any>>):Promise<string>;
|
||||||
|
|
||||||
|
export function WritePluginSetting(arg1:string,arg2:string,arg3:any):Promise<string>;
|
||||||
|
|
||||||
|
export function WritePluginSettings(arg1:string,arg2:Record<string, any>):Promise<string>;
|
||||||
|
|
||||||
|
export function WriteVaultFileBytes(arg1:string,arg2:string,arg3:string,arg4:files.WriteOptions):Promise<string>;
|
||||||
|
|
||||||
|
export function WriteVaultTextFile(arg1:string,arg2:string,arg3:string,arg4:files.WriteOptions):Promise<string>;
|
||||||
|
|||||||