125 Commits
Author SHA1 Message Date
mirivlad c9c26faf56 fix: localize file type labels 2026-07-15 03:13:48 +08:00
mirivlad 484d599a9f docs: use Deal terminology in official plugins 2026-07-14 23:50:59 +08:00
mirivlad 5ddc63bb8d docs: describe search as Deal scoped 2026-07-14 23:20:08 +08:00
mirivlad f456287c2e fix: use Deal terminology in trash 2026-07-14 23:19:11 +08:00
mirivlad c8b8045da1 fix: use Deal terminology in todos 2026-07-14 23:17:08 +08:00
mirivlad 3a1920d104 fix: localize journal candidate review 2026-07-14 23:12:29 +08:00
mirivlad 713b95b5a8 fix: localize activity event labels 2026-07-14 23:08:00 +08:00
mirivlad b449e9c616 fix: simplify markdown editor chrome 2026-07-14 22:56:40 +08:00
mirivlad 90ad8e8467 fix: protect secrets values and deletion 2026-07-14 22:53:37 +08:00
mirivlad aa7956dec4 fix: localize files context menu 2026-07-14 22:49:59 +08:00
mirivlad 44b39ca051 fix: use modal forms for files 2026-07-14 22:46:14 +08:00
mirivlad c83c15bced fix: use modal forms for notes 2026-07-14 22:41:17 +08:00
mirivlad 99172944a1 fix: localize Browser materials workflow 2026-07-14 22:28:53 +08:00
mirivlad a1b3c31d0d fix: localize plugin operation errors 2026-07-14 22:00:24 +08:00
mirivlad 1f03226378 fix: localize Secrets operation errors 2026-07-14 20:56:25 +08:00
mirivlad bee365ad22 fix: support typed Todo reminder times 2026-07-14 20:50:14 +08:00
mirivlad dfc53f1a5f feat: add global secrets view 2026-07-14 20:41:40 +08:00
mirivlad dd75335727 fix: add entries from global journal 2026-07-14 19:43:21 +08:00
mirivlad f23fb6e993 fix: make Todo reminder time selectable 2026-07-14 19:38:00 +08:00
mirivlad 0cd2c2a6ab test: cover activity deletion confirmation 2026-07-14 19:38:00 +08:00
mirivlad 304620952f fix: confirm activity deletion 2026-07-14 19:19:06 +08:00
mirivlad 112fb4cbec fix: apply markdown blocks to full lines 2026-07-14 19:13:56 +08:00
mirivlad 78a9dd31a3 fix: open requested secrets from workbench 2026-07-14 19:13:56 +08:00
mirivlad eb0f468463 fix: make Todo reminders and selects usable 2026-07-14 08:00:33 +08:00
mirivlad 7687f410c4 feat: schedule Todo native reminders 2026-07-14 02:47:05 +08:00
mirivlad 7e34a73835 build: publish portable plugin releases 2026-07-14 01:58:30 +08:00
mirivlad 47c1db9979 build: add portable plugin archives 2026-07-13 22:29:46 +08:00
mirivlad 22c842d5e3 build: add Windows plugin packages 2026-07-13 21:46:40 +08:00
mirivlad 1a15d57033 build: publish plugin releases to GitHub 2026-07-13 05:01:42 +08:00
mirivlad 483b6ba46e build: ignore generated plugin releases 2026-07-12 21:34:57 +08:00
mirivlad 226464f082 docs: prepare official plugins alpha release 2026-07-12 21:22:24 +08:00
mirivlad e93584c37c feat: stabilize activity sessions for journal review 2026-07-12 20:49:13 +08:00
mirivlad 50c4fc5e68 feat: open browser links and resolve filename collisions 2026-07-12 20:39:09 +08:00
mirivlad b45a56b09d feat: add browser inbox archive flow 2026-07-12 17:43:17 +08:00
mirivlad 0db9f896ce feat: read activity from append-only storage 2026-07-12 17:29:13 +08:00
mirivlad 69a1938ff3 fix: make browser inbox mutations durable 2026-07-11 23:30:43 +08:00
mirivlad fe97c5ddcc fix: fully localize platform diagnostics 2026-07-11 12:56:40 +08:00
mirivlad 1bc5ecfd16 fix: keep plugin localization live and complete 2026-07-11 12:48:15 +08:00
mirivlad 3dd0ec9f1f feat: localize official plugins in Russian 2026-07-11 12:42:37 +08:00
mirivlad 077de94e61 docs: document todo plugin behavior 2026-07-10 21:08:50 +08:00
mirivlad 16e1407440 feat: convert completed todos into journal entries 2026-07-10 20:49:15 +08:00
mirivlad 31c93bdf5b feat: add global and workspace todo plugin 2026-07-10 20:43:57 +08:00
mirivlad 92f596647f docs: clarify browser inbox domain bindings 2026-07-10 20:27:24 +08:00
mirivlad eb19cce2a4 feat: make browser inbox globally assignable 2026-07-10 20:19:08 +08:00
mirivlad c31ee7e0ce feat: add global trash plugin 2026-07-10 08:43:22 +08:00
mirivlad a76500418e feat: add deterministic work session candidates 2026-07-10 08:03:46 +08:00
mirivlad 396b77cf26 feat: add browser receiver pairing settings 2026-07-10 03:44:42 +08:00
mirivlad d6b8ab17f1 Polish official plugin workbench UI 2026-07-02 00:49:29 +08:00
mirivlad 0e079e8290 Give Files actions distinct icons 2026-07-01 19:20:41 +08:00
mirivlad 6a9d685d48 Add Files no-match clear action 2026-07-01 19:07:00 +08:00
mirivlad ddc8ffc711 Improve Files rename conflict message 2026-07-01 18:58:26 +08:00
mirivlad 45aac0d3b5 Add Files empty folder actions 2026-07-01 18:49:45 +08:00
mirivlad 32f2c1d6ab Expose Files icon categories 2026-07-01 18:34:47 +08:00
mirivlad 06ffbf9a6e Show Files create validation inline 2026-07-01 18:24:50 +08:00
mirivlad e333be9b9c Show Files rename validation inline 2026-07-01 07:59:27 +08:00
mirivlad dc74472f75 Revert "fix: P1 plugin contributions cleanup + P0 inbox workspace picker"
This reverts commit ba9386d535.
2026-07-01 07:44:22 +08:00
mirivlad ba9386d535 fix: P1 plugin contributions cleanup + P0 inbox workspace picker
Plugin contributions:
- Remove sidebarItems from Activity, Journal (only workspace tabs)
- Remove workspaceItems from Browser Inbox (only sidebar/global)
- Remove workspaceItems from Search (only Ctrl+K command)
- Remove sidebarItems from Platform Test (developer tool, not in sidebar)

Browser Inbox:
- Add workspace picker for captures without workspaceRootPath
- Add assignToWorkspace() with proper storage key migration
- Add loadWorkspacesList() via plugin API
- Add picker CSS styles
- Selections now get same workspace assignment + conversion flow

Fixes P0 issues: captures and selections without workspace assignment
now have clear path to conversion (assign → convert)
2026-07-01 04:15:28 +08:00
mirivlad f34b8a77fc Add Files escape selection clearing 2026-07-01 03:18:23 +08:00
mirivlad 9a59cd29e4 Keep search input focused while typing 2026-06-30 22:42:42 +08:00
mirivlad 9aa837e14d fix: style secrets scope select 2026-06-29 21:46:29 +08:00
mirivlad 962a345ddc feat: improve secrets management UI 2026-06-29 21:42:38 +08:00
mirivlad d32c2ceec6 feat: open secret links from editor 2026-06-29 20:48:57 +08:00
mirivlad 81c09a2df0 feat: add secrets plugin 2026-06-29 20:42:25 +08:00
mirv 5f2926545a feat: convert browser binary captures 2026-06-29 10:12:26 +08:00
mirv 95d16092d3 feat: convert browser file captures 2026-06-29 09:57:21 +08:00
mirv b46a973fd3 feat: convert browser captures to link files 2026-06-29 09:47:10 +08:00
mirv 6fa0dd350d feat: record browser conversion activity 2026-06-29 09:41:25 +08:00
mirv 8cadc957cf feat: convert browser captures to notes 2026-06-29 09:36:53 +08:00
mirv fdc5bd0f46 feat: add browser inbox domain binding 2026-06-29 09:30:43 +08:00
mirivlad 607535358c feat: add journal worklog plugin 2026-06-29 04:46:25 +08:00
mirivlad 018dacc805 feat: suggest worklogs from activity 2026-06-29 04:40:29 +08:00
mirivlad 5011ddf89c feat: persist search index and run providers 2026-06-29 04:31:21 +08:00
mirivlad 60909d3e46 feat: mount sync status bar component 2026-06-29 04:24:24 +08:00
mirivlad efaeed7bcb feat: show sync conflict details 2026-06-29 03:38:03 +08:00
mirivlad a4d4e5ed0e feat: show persisted sync errors 2026-06-29 03:23:45 +08:00
mirivlad f0c7b31c9e feat: render inline image previews 2026-06-29 03:16:16 +08:00
mirivlad 7b249fcf48 feat: refresh files and notes on file events 2026-06-28 22:57:09 +08:00
mirivlad 914c8a7204 feat: restore files from trash view 2026-06-28 22:19:41 +08:00
mirivlad 4e6188e7cf fix: style notes sort dropdown 2026-06-28 22:09:08 +08:00
mirivlad 67ac99c24d feat: show files trash metadata 2026-06-28 17:03:50 +08:00
mirivlad 10bc28cef7 fix: improve notes rename conflicts 2026-06-28 16:49:08 +08:00
mirivlad f2e837a776 feat: add notes list filtering 2026-06-28 16:45:43 +08:00
mirivlad 7a0964892b feat: record workspace lifecycle activity 2026-06-28 16:34:35 +08:00
mirivlad 48fe942028 feat: host file and note actions 2026-06-28 16:29:04 +08:00
mirivlad 3b7258ed3e fix: make activity view passive 2026-06-28 16:14:59 +08:00
mirivlad 8010d6c601 Show tagged global records in workspace views 2026-06-28 04:00:50 +08:00
mirivlad 4a1550acf6 Read legacy inbox and activity storage 2026-06-28 03:47:58 +08:00
mirivlad b4ef3b38b0 Scope inbox and activity by workspace 2026-06-28 03:35:35 +08:00
mirivlad 2c19dd2132 Expose inbox and activity as global views 2026-06-28 03:23:19 +08:00
mirivlad 47ae318094 Add baseline activity plugin 2026-06-28 03:04:00 +08:00
mirivlad 59e48d7ea4 Enhance vault search behavior 2026-06-28 02:50:19 +08:00
mirivlad e465658be7 Add official search plugin 2026-06-28 01:04:21 +08:00
mirivlad 20d1059edb Clean plugin dist before packaging 2026-06-28 00:06:40 +08:00
mirivlad a7aa176e67 Remove redundant Markdown preview provider 2026-06-27 23:58:30 +08:00
mirivlad ff805c28ad Add File preview plugin 2026-06-27 23:49:53 +08:00
mirivlad 950a7f3d8c Add Markdown preview plugin 2026-06-27 23:45:22 +08:00
mirivlad 9928094b77 Add browser inbox plugin 2026-06-27 19:07:37 +08:00
mirivlad 18b9dc0bca Add sync key reset action 2026-06-27 18:46:05 +08:00
mirivlad 5349973f04 Add Notes trash action 2026-06-27 13:37:11 +08:00
mirivlad 084580d3fd Use external open API in Files plugin 2026-06-27 13:34:16 +08:00
mirivlad bf21df14f1 Clarify notes plugin manifest 2026-06-27 12:56:27 +08:00
mirivlad 5f48c003db Add files external open fallback 2026-06-27 12:55:43 +08:00
mirivlad 92ac1f730b Avoid overview defaults in plugin demos 2026-06-27 12:46:18 +08:00
mirivlad fca7716e75 Keep files plugin note-neutral 2026-06-27 12:39:51 +08:00
mirivlad 39d8df339b Align official plugins with v2 runtime 2026-06-27 12:36:31 +08:00
mirivlad 321c8e58d1 feat: Notes plugin + Files improvements + Default editor wiki links
plugins/files:
- SVG icons for toolbar actions (iconButton helper)
- Multi-select cut/copy/paste (Ctrl+X/C/V)
- Mouse back/forward button navigation (history stack)
- Navigation history persisted per workspace (window.__filesHistoryByWorkspace)
- Context menu: icons, notes integration (Create Note, Open Overview)
- Drag-and-drop folder target fix

plugins/default-editor:
- Wiki links [[Title]] in notes context (renderInline with isNotesContext)
- Save/Read via Notes API when editorMode === 'notes-markdown'
- Internal link click handler (open note via api.request.open)

plugins/platform-test:
- Mouse Event Inspector card for debugging back/forward buttons

plugins/notes (new):
- Basic notes plugin scaffold (plugin.json + frontend entry)
- Contributes workspace item 'Notes'
2026-06-21 23:23:07 +08:00
mirivlad d3079be007 fix: Cut/copy/paste path consistency and folder cut support
- pasteEntry: source path now scoped for readText, relative for trash
- cutEntry: removed folder guard — folder cut via api.files.move
- copyEntry: hidden from context menu for folders (not supported)
- Clipboard object now carries isFolder flag
2026-06-20 20:23:02 +08:00
mirivlad 8f8f431d4d plugins(files): Feature 11 — Rename validation
- Invalid chars: \ / : * ? " < > | and control chars (0x00-0x1f)
- Invalid names: '.', '..', leading/trailing spaces, trailing dot
- Collision check via api.files.metadata before rename
- Special case for case-only difference
- Errors shown in renameInput.placeholder
2026-06-20 20:02:32 +08:00
mirivlad c3a14411ab plugins(files): Feature 10 — Navigation history
- History stack with Back/Forward (goBack/goForward)
- historyIndex + navigatingHistory flag for correct stack management
- Toolbar buttons: ← (back) and → (forward) before Up button
- Alt+Left / Alt+Right and Ctrl+[ / Ctrl+] keyboard shortcuts
- Future path trimmed on new navigation (forward state discarded)
- Duplicate paths not pushed to stack
- Refresh and direct loadEntries don't pollute history
2026-06-20 20:01:20 +08:00
mirivlad 4a0656cd09 plugins(files): Feature 9 — Rich file type icons
- Added FILE_ICONS lookup with SVG paths for 15 categories
- Added EXT_MAP with 70+ extension-to-category mappings
- Images, video, audio, archives, PDFs get distinct icons
- Documents, spreadsheets, presentations get office-style icons
- Code files (JS/Python/Go/Rust/C/etc.) get a dedicated code icon
- Database, font, config files also get proper icons
- Existing behavior preserved: folder, markdown, json->{ }, generic
- fileIcon simplified to use lookup tables
2026-06-20 19:59:12 +08:00
mirivlad 194b68890b plugins(files): Feature 8 — Keyboard navigation
- ArrowUp/Down: move selection by one item
- Shift+ArrowUp/Down: extend selection range
- Ctrl+ArrowUp/Down: scroll viewport without changing selection
- Home/End: jump to first/last item (Shift to extend)
- PageUp/PageDown: jump by one viewport height
- All navigation keys get preventDefault()
- Existing Enter/Delete/F2/Ctrl+A preserved
2026-06-20 19:56:40 +08:00
mirivlad e5c63576d0 plugins(files): Feature 7 — Drag-and-Drop
- Items are draggable (draggable='true')
- Multi-file drag from selection (selectedPaths)
- Drop on folder moves files via api.files.move()
- Visual feedback: .files-dragging (opacity) + .files-drag-over (dashed outline)
- No-op when source === target folder
2026-06-20 19:55:20 +08:00
mirivlad e74499a329 plugins(files): Feature 6 — Multi-select
- Ctrl+click: toggle single file selection
- Shift+click: range select from last clicked to current
- Ctrl+A: select all visible entries
- Click on empty space: deselect all
- Bulk trash: delete all selected files via Promise.allSettled
- Open/Rename disabled when multiple selected; Trash enabled for bulk
- Refactored: selectedPath (string) → selectedPaths (hash map)
2026-06-20 19:51:13 +08:00
mirivlad 281614e1b4 plugins(files): Feature 5 — Cut/Copy/Paste
- pasteEntry: reads clipboard, copies file with collision resolution,
  handles 'cut' action (trashe source + clear clipboard)
- Paste toolbar button: disabled when clipboard empty, enabled after cut/copy
- Paste in context menu: shown on empty-space right-click when clipboard
  has content
- Folder guard: cut/copy for folders logs not-supported and skips
- updateButtons() toggles paste button state on cut/copy
2026-06-20 19:48:53 +08:00
mirivlad b5d6694afa plugins(files): fix document event listener leak in context menu cleanup 2026-06-20 19:47:09 +08:00
mirivlad 2ff75c4dca plugins(files): context menu, confirm modal, duplicate with collision handling
- Feature 1: Right-click context menu with actions (Open, Rename, Duplicate,
  Cut, Copy, Trash) and empty-space shortcut for new files
- Feature 3: Custom confirmModal instead of window.confirm(), styled overlay
  + modal with danger mode for trash operations
- Feature 4: Duplicate with smart collision resolution — finds first free
  name (copy), (copy 2)... up to 100 attempts using api.files.metadata()
2026-06-20 19:43:58 +08:00
mirivlad 7567e38c4f plugins: files explorer and default editor improvements
- Files plugin: richer file list, breadcrumbs, toolbar, sorting,
  filter, rename/trash, row actions
- Default Editor: line numbers, markdown toolbar, Edit/Preview/Split,
  reload/revert
- Files plugin.json: update manifest
2026-06-20 19:21:24 +08:00
mirivlad 695c1c83d0 fix: rewrite sync settings UI with dark theme inputs, add auto-sync and interval settings 2026-06-20 03:39:04 +08:00
mirivlad 95aa600704 fix: sanitize sync error messages in frontend, limit displayed length 2026-06-20 03:20:26 +08:00
mirivlad ef2c8c76c4 fix: use api.backend.call() instead of broken wailsCall, fix styling
- Replace direct window['go']['main']['App'] access with api.backend.call()
- Use hardcoded colors matching PluginManager modal theme (#16213e, #0f3460, etc.)
- Labels above inputs, full-width inputs, proper form layout
- Add explicit btn/modal styles instead of relying on CSS variables
2026-06-20 03:02:34 +08:00
mirivlad bb0811a60c test: add sync plugin E2E tests 2026-06-20 02:39:36 +08:00
mirivlad 589f98011d feat: build sync plugin frontend 2026-06-20 02:37:32 +08:00
mirivlad 9dd93f3685 feat: implement SyncStatusBar component 2026-06-20 02:35:18 +08:00
mirivlad 931bc6bf29 feat: implement SyncSettings component 2026-06-20 02:32:23 +08:00
mirivlad 6fb16442cb feat: create sync plugin structure 2026-06-20 02:28:15 +08:00
92 changed files with 18179 additions and 617 deletions
+2
View File
@@ -1,4 +1,6 @@
dist/
dist-windows/
release/
node_modules/
# compiled backend binaries (built from source)
plugins/*/backend/platform-test
+1 -2
View File
@@ -14,7 +14,6 @@ verstak-official-plugins/
files/
notes/
markdown-editor/
markdown-preview/
file-preview/
activity/
journal/
@@ -45,7 +44,7 @@ verstak-official-plugins/
- `official.files` — файловый менеджер
- `official.notes` — заметки
- `official.markdown-editor` — редактор
- `official.markdown-preview` — предпросмотр
- `official.file-preview` — предпросмотр изображений/metadata
- `official.activity` — активность
- `official.browser-inbox` — браузерный inbox
+17
View File
@@ -0,0 +1,17 @@
Verstak Official Plugins
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
+73 -2
View File
@@ -1,3 +1,74 @@
# verstak-official-plugins
# Verstak Official Plugins
Official Verstak plugins monorepo — files, notes, markdown-editor, preview, activity, journal, browser-inbox, search, secrets, templates + shared packages
The first-party plugin set for Verstak Desktop: Files, Trash, Notes, editor and
preview providers, Browser Inbox, Activity, Journal, Todo, Search, Secrets and
Templates. Plugin bundles are built into independent directories that the
desktop host loads from its `plugins/` folder.
> **Alpha software.** Build this repository together with compatible
> `verstak-desktop` and `verstak-sdk` checkouts.
## Build
Requirements: Node.js with npm, Go and Python 3. Keep this repository next to
`verstak-sdk` so manifest validation can use the SDK schema.
```bash
npm --version
go version
./scripts/check.sh
./scripts/build.sh
```
The build creates `dist/<plugin-id>/` packages. To install them into a sibling
desktop checkout:
```bash
cd ../verstak-desktop
./scripts/install-dev-plugins.sh
```
## Portable plugin archives
Build target-specific archives on Linux:
```bash
sudo apt install gcc-mingw-w64-x86-64
./scripts/package-portable.sh v0.1.0-alpha.1
```
The command creates `release/verstak-official-plugins-linux-amd64-<version>.tar.gz`
and `release/verstak-official-plugins-windows-amd64-<version>.zip`, plus
`SHA256SUMS`. Each archive expands directly into the desktop application's
`plugins/` directory. Frontends and manifests are shared; native Go sidecars
are built for the target OS. This is a local package operation and does not
create a GitHub Release.
## Alpha behaviour
- Browser Inbox archives processed captures and can restore them; permanent
deletion is a separate action.
- Saving a browser link creates a collision-safe `.url` file. Opening a link is
handled by Verstak, so it does not depend on a Linux `.url` file association.
- Activity sessions can be scoped to a durable Deal identity or to an
explicit unassigned scope. Journal creation is always a user action.
- Todo is optional: Overview uses it only when the Todo capability is present.
## Publish a GitHub Release
```bash
./scripts/publish-github-release.sh v0.1.0-alpha.1
```
The publisher requires an authenticated [`gh`](https://cli.github.com/) CLI
and a clean, up-to-date `main`. It runs the local release command, creates and
pushes an annotated tag if necessary, then creates or updates the GitHub
Release with the Linux archive, Windows archive and `SHA256SUMS`. Alpha, beta
and release-candidate tags are published as prereleases; stable tags are
marked latest. Re-running it for a tag that points at the current commit
replaces the release assets.
## License
Copyright © 2026 Verstak contributors. Licensed under
[GNU AGPLv3 or later](LICENSE).
+25
View File
@@ -0,0 +1,25 @@
# Browser Inbox
Browser Inbox is one global queue of browser captures. New and changed records are
stored in `captures:global`; previous `captures` and `captures:workspace:*` keys
remain readable for migration compatibility.
Each capture contains a durable `workspaceId`, its current or historical
`workspaceRootPath`, and `processed`. An empty `workspaceRootPath` means the
capture is **Unassigned**. The global view can filter all captures by assignment,
Deal, processed state, and text search. A capture
can be assigned, reassigned, made unassigned, marked processed or unprocessed, and
deleted.
The local browser receiver adds the currently active Deal before publishing a
capture event. When it has no active Deal, the capture remains unassigned
unless an explicit existing domain binding matches it. The frontend never assigns an
untagged capture merely because a Deal view happens to be open.
Deal assignment uses the immutable `workspaceId` stored in Deal metadata;
`workspaceRootPath` is an address and display value. A rename updates the latter,
while a newly created folder with the old name cannot take over existing captures.
Deal Inbox and Overview show only captures whose `workspaceId` matches the selected
Deal. Unassigned captures remain visible only in the
global Browser Inbox.
+27 -18
View File
@@ -10,11 +10,11 @@
plugin-name/
plugin.json — обязательный manifest
frontend/
package.json — npm проект (Svelte)
package.json — опционально, npm проект (Svelte/Vite)
src/
index.js — entry point
index.js — source entry point для plain JS plugins
...
dist/ — сборка frontend (npm run build)
dist/ — ignored build output для npm frontend plugins
index.js
style.css
backend/ — опционально (Go sidecar)
@@ -33,7 +33,7 @@ plugin-name/
```
dist/
plugin-name/
plugin.json — копируется из корня исходного плагина
plugin.json — manifest для runtime package
frontend/
dist/ — только собранные файлы (копируется содержимое)
index.js
@@ -46,7 +46,11 @@ dist/
### plugin.json
Копируется как из корня исходного плагина. Содержит:
Исходный `plugin.json` копируется в package. Если plain JS plugin в исходниках
указывает `frontend.entry` на `frontend/src/index.js`, `build.sh` переписывает
это поле в packaged manifest на `frontend/dist/index.js`.
Содержит:
| Поле | Назначение в dist |
|---|---|
@@ -59,7 +63,14 @@ dist/
### frontend/dist
Содержимое `frontend/dist/` копируется в `dist/<plugin-name>/frontend/dist/`. Это результат `npm run build` — скомпилированные JS/CSS без map-файлов и dev-зависимостей.
Для plugins с `frontend/package.json` содержимое `frontend/dist/` копируется в
`dist/<plugin-name>/frontend/dist/`. Это результат `npm run build` —
скомпилированные JS/CSS без map-файлов и dev-зависимостей.
Для plain JS plugins без `frontend/package.json` `build.sh` всегда копирует
tracked `frontend/src/index.js` в `dist/<plugin-name>/frontend/dist/index.js`.
Ignored source-side `frontend/dist/` не используется для таких plugins, чтобы
локальные stale build artifacts не попадали в package.
### backend binary
@@ -95,25 +106,23 @@ cd ~/git/verstak2/verstak-official-plugins
`build.sh` для каждого плагина в `plugins/`:
1. Проверяет `plugin.json` (JSON validation).
2. Собирает frontend: `npm install && npm run build`.
2. Собирает frontend для plugins с `frontend/package.json`: `npm install && npm run build`.
3. Собирает backend: `go build -o <plugin-name> .`.
4. Упаковывает в `dist/<plugin-name>/` через `package_plugin()`.
4. Упаковывает в `dist/<plugin-name>/` через `package_plugin()`. Plain JS
frontend берётся из `frontend/src/index.js`, packaged manifest получает
`frontend.entry = "frontend/dist/index.js"`.
### Сборка конкретного плагина
```bash
cd ~/git/verstak2/verstak-official-plugins/plugins/platform-test
# Frontend
cd frontend && npm install && npm run build
# Backend (если есть)
cd ../backend && go build -o platform-test .
# Package
mkdir -p ../../dist/platform-test
cp plugin.json ../../dist/platform-test/
cp -r frontend/dist/. ../../dist/platform-test/frontend/dist/
cp backend/platform-test ../../dist/platform-test/backend/
cd ~/git/verstak2/verstak-official-plugins
./scripts/build.sh
```
`build.sh` intentionally packages all official plugins because packaging includes
manifest rewriting for plain JS plugins. Do not hand-copy `plugin.json` for a
plain JS plugin without applying the same `frontend.entry` rewrite.
### Backend build details
```bash
+45
View File
@@ -0,0 +1,45 @@
# Todos
Todos are stored by the official `verstak.todo` plugin under the canonical
`todos:global` settings key. A todo is global when `workspaceRootPath` is empty;
otherwise that field identifies the top-level Deal folder that owns it.
The global Todos view aggregates all records and supports Deal, status, text,
and due/reminder/updated sorting filters. The Deal Todos view shows only records
whose `workspaceRootPath` exactly matches the current Deal. This keeps unassigned
and other-Deal records out of a Deal tab and its
Overview signals.
## Stored fields
Each record has a stable `id`, `title`, optional `description`, optional
`workspaceRootPath` and `workspaceName`, `status` (`open`, `done`, or
`cancelled`), `priority`, optional `dueAt` and `reminderAt`, `createdAt`,
`updatedAt`, and optional `completedAt`. The model also reserves `sourceUrl` and
`linkedJournalEntryId` for future source/link integrations.
## Reminders
The plugin stores reminder metadata, renders clear indicators for overdue,
due-soon, and reminder-due todos, and schedules native desktop notifications when
the Desktop notification capability is available. Without that capability, the
reminder remains visible in Todos but is not treated as an error.
## Completed Todo to Journal
From a Deal Todo tab, a completed todo exposes **Create Journal Entry**. It opens
the current Deal Journal with a normal, editable form. The form copies
only factual data from the todo: title, description, completion date, and zero
minutes. It never generates a summary, duration, or billable status.
The user can edit the Journal form before saving. The saved Journal entry records
`sourceTodoId`, which prevents a second Journal entry from being created from the
same todo. The Journal plugin owns that link; Todo does not write into Journal
storage and therefore does not currently populate `linkedJournalEntryId` on its
own record.
## Visibility
The Plugin Manager can globally enable or disable the Todo plugin. A Deal template
controls whether its Todos tab is available; disabling the plugin hides the tab
without affecting stored todo records.
+970
View File
@@ -0,0 +1,970 @@
/* ===========================================================
Activity Plugin - Verstak v2 Frontend Bundle
Contract: window.VerstakPluginRegister(id, { components })
=========================================================== */
(function () {
'use strict';
var PLUGIN_ID = 'verstak.activity';
var MAX_EVENTS = 250;
var RAW_DATA_NAME = 'activity-events';
var MAX_CANDIDATES = 12;
var LEGACY_KEY = 'events';
var GLOBAL_KEY = 'events:global';
var WORKSPACE_PREFIX = 'events:workspace:';
var CANDIDATE_PREFIX = 'work-session-candidates:workspace:';
var DISMISSAL_PREFIX = 'work-session-dismissals:workspace:';
var SESSION_REGISTRY_KEY = 'activity-session-registry-v2';
var WORKLOG_COMMAND_ID = 'verstak.activity.suggestWorklog';
var MIN_SESSION_DURATION_MINUTES = 10;
var MIN_SESSION_ACTIVITY_COUNT = 2;
var MAX_IDLE_GAP_MINUTES = 20;
var MAX_SESSION_DURATION_MINUTES = 120;
var ACTIVITY_EVENTS = [
'file.opened',
'file.changed',
'note.saved',
'action.started',
'browser.capture.received',
'case.selected',
'browser.capture.page',
'browser.capture.selection',
'browser.capture.link',
'browser.capture.file',
'browser.capture.converted',
'browser.activity.batch',
'activity.session.handled'
];
var EVENT_LABELS = {
'workspace.selected': { key: 'ui.event.workspaceSelected', fallback: 'Deal selected' },
'case.selected': { key: 'ui.event.workspaceSelected', fallback: 'Deal selected' },
'file.opened': { key: 'ui.event.fileOpened', fallback: 'File opened' },
'file.changed': { key: 'ui.event.fileChanged', fallback: 'File changed' },
'note.saved': { key: 'ui.event.noteEdited', fallback: 'Note edited' },
'action.started': { key: 'ui.event.workSessionDetected', fallback: 'Work session detected' },
'browser.capture.received': { key: 'ui.event.browserCaptureReceived', fallback: 'Browser capture received' },
'browser.capture.page': { key: 'ui.event.pageCaptured', fallback: 'Page captured' },
'browser.capture.selection': { key: 'ui.event.selectionCaptured', fallback: 'Selection captured' },
'browser.capture.link': { key: 'ui.event.linkCaptured', fallback: 'Link captured' },
'browser.capture.file': { key: 'ui.event.fileCaptured', fallback: 'File captured' },
'browser.capture.converted': { key: 'ui.event.captureConverted', fallback: 'Capture converted' },
'browser.activity.domain': { key: 'ui.event.browserDomainActivity', fallback: 'Browser domain activity' }
};
var LOW_VALUE_EVENT_TYPES = {
'workspace.selected': true,
'case.selected': true,
'file.selected': true,
'file.opened': true,
'note.opened': true
};
function injectStyles() {
if (document.getElementById('activity-style-injected')) return;
var style = document.createElement('style');
style.id = 'activity-style-injected';
style.textContent = STYLES;
document.head.appendChild(style);
}
var STYLES = [
'.activity-root{display:flex;flex-direction:column;height:100%;min-height:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;color:var(--vt-color-text-primary,#f4f7fb);background:var(--vt-color-background,#101020)}',
'.activity-toolbar{display:flex;align-items:center;gap:.5rem;min-height:2.75rem;padding:.5rem .75rem;border-bottom:1px solid var(--vt-color-border,#202b46);background:var(--vt-color-surface-muted,#111629);flex-shrink:0;flex-wrap:wrap}',
'.activity-title{font-size:.84rem;font-weight:600;color:var(--vt-color-text-primary,#f4f7fb)}',
'.activity-count{font-size:.72rem;color:var(--vt-color-text-muted,#7f8aa3)}',
'.activity-spacer{flex:1}',
'.activity-btn{font-size:.78rem;padding:.32rem .65rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-md,6px);background:var(--vt-color-surface-hover,#1b2440);color:var(--vt-color-text-secondary,#b7c0d4);cursor:pointer}',
'.activity-btn:hover{background:var(--vt-color-surface-hover,#1b2440);border-color:var(--vt-color-accent,#4ecca3);color:var(--vt-color-text-primary,#f4f7fb)}',
'.activity-btn:disabled{opacity:.45;cursor:default}',
'.activity-btn.danger{border-color:rgba(233,69,96,.42);color:#ff9a9a}',
'.activity-status{font-size:.72rem;color:var(--vt-color-text-muted,#7f8aa3);white-space:nowrap}',
'.activity-status.error{display:inline-flex;border:1px solid rgba(233,69,96,.45);border-radius:var(--vt-radius-sm,4px);background:var(--vt-color-danger-muted,rgba(233,69,96,.14));color:#ffc6ce;padding:.18rem .4rem}',
'.activity-modal-host[hidden]{display:none}.activity-modal-overlay{position:fixed;inset:0;z-index:10000;display:flex;align-items:center;justify-content:center;padding:1rem;background:rgba(0,0,0,.58)}.activity-modal{width:440px;max-width:96vw;display:grid;gap:.75rem;padding:1rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-lg,8px);background:var(--vt-color-surface,#15152c);box-shadow:0 18px 44px rgba(0,0,0,.38)}.activity-modal-title{font-size:.95rem;font-weight:650}.activity-modal-copy{color:var(--vt-color-text-secondary,#b7c0d4);font-size:.84rem;line-height:1.45}.activity-modal-actions{display:flex;justify-content:flex-end;gap:.5rem}.activity-btn.destructive{background:var(--vt-color-danger,#e94560);border-color:var(--vt-color-danger,#e94560);color:#fff}',
'.activity-candidates{border-bottom:1px solid rgba(32,43,70,.72);background:var(--vt-color-surface-muted,#111629);padding:.65rem .75rem;display:grid;gap:.5rem}',
'.activity-candidates-title{font-size:.76rem;font-weight:600;color:var(--vt-color-text-muted,#7f8aa3);text-transform:uppercase;letter-spacing:.04em}',
'.activity-candidate{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.65rem;align-items:start;padding:.65rem .75rem;border:1px solid rgba(78,204,163,.34);border-radius:var(--vt-radius-lg,8px);background:var(--vt-color-surface,#15152c)}',
'.activity-candidate-title{font-size:.84rem;color:var(--vt-color-text-primary,#f4f7fb);font-weight:600}',
'.activity-candidate-facts{margin-top:.28rem;display:grid;gap:.16rem;font-size:.76rem;color:var(--vt-color-text-secondary,#b7c0d4)}',
'.activity-candidate-activities{margin-top:.38rem;font-size:.72rem;color:var(--vt-color-text-muted,#7f8aa3)}.activity-candidate-activities summary{cursor:pointer}.activity-candidate-activity{margin-top:.2rem;overflow-wrap:anywhere}',
'.activity-candidate-actions{display:flex;gap:.35rem;align-items:center;flex-wrap:wrap;justify-content:flex-end}',
'.activity-candidate-duration{font-size:.76rem;color:var(--vt-color-accent,#4ecca3);white-space:nowrap}',
'.activity-list{flex:1;min-height:0;overflow:auto;background:var(--vt-color-background,#101020)}',
'.activity-empty{height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.35rem;color:var(--vt-color-text-muted,#7f8aa3);font-size:.86rem;padding:2rem;text-align:center}',
'.activity-empty-title{color:var(--vt-color-text-secondary,#b7c0d4);font-weight:650}',
'.activity-row{display:grid;grid-template-columns:9.5rem minmax(0,1fr);gap:.75rem;padding:.72rem .85rem;border-bottom:1px solid rgba(32,43,70,.72)}',
'.activity-row:hover{background:var(--vt-color-surface-hover,#1b2440)}',
'.activity-time{font-size:.72rem;color:var(--vt-color-text-muted,#7f8aa3);white-space:nowrap}',
'.activity-main{min-width:0}',
'.activity-row-head{display:flex;align-items:center;gap:.45rem;min-width:0}',
'.activity-type{font-size:.68rem;color:var(--vt-color-accent,#4ecca3);text-transform:uppercase;letter-spacing:.04em;flex-shrink:0}',
'.activity-title-text{font-size:.86rem;color:var(--vt-color-text-primary,#f4f7fb);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
'.activity-summary{margin-top:.25rem;font-size:.78rem;line-height:1.4;color:var(--vt-color-text-secondary,#b7c0d4);white-space:pre-wrap;overflow-wrap:anywhere}',
'.activity-source{margin-top:.25rem;font-size:.72rem;color:var(--vt-color-text-muted,#7f8aa3)}',
'.activity-details{margin-top:.25rem;font-size:.72rem;color:var(--vt-color-text-muted,#7f8aa3)}.activity-details summary{cursor:pointer}',
'@media(max-width:760px){.activity-row,.activity-candidate{grid-template-columns:1fr;gap:.25rem}.activity-candidate-actions{justify-content:flex-start}.activity-toolbar{align-items:stretch}.activity-status{width:100%}}'
].join('\n');
function el(tag, attrs, children) {
var elem = document.createElement(tag);
if (attrs) {
Object.keys(attrs).forEach(function (k) {
if (attrs[k] == null) return;
if (k === 'className') elem.className = attrs[k];
else if (k.slice(0, 2) === 'on') elem.addEventListener(k.slice(2).toLowerCase(), attrs[k]);
else if (k === 'textContent') elem.textContent = attrs[k];
else elem.setAttribute(k, attrs[k]);
});
}
if (children) {
(Array.isArray(children) ? children : [children]).forEach(function (child) {
if (child == null) return;
elem.appendChild(typeof child === 'string' ? document.createTextNode(child) : child);
});
}
return elem;
}
function text(value) {
return String(value == null ? '' : value);
}
function encodeKey(value) {
return encodeURIComponent(text(value).trim());
}
function cleanWorkspace(value) {
return text(value).trim().replace(/^\/+|\/+$/g, '');
}
function workspaceFromProps(props) {
var node = props && props.workspaceNode;
return cleanWorkspace((props && (props.workspaceRootPath || props.workspaceName || props.workspaceNodeId))
|| (node && (node.rootPath || node.name || node.id)));
}
function workspaceFromPayload(payload) {
var explicit = cleanWorkspace(payload && (payload.workspaceRootPath || payload.workspaceName || payload.workspaceNodeId));
if (explicit) return explicit;
var path = cleanWorkspace(payload && payload.path);
if (!path || path.indexOf('/') === -1) return '';
return cleanWorkspace(path.split('/')[0]);
}
function scopeFromProps(props) {
var workspaceRoot = workspaceFromProps(props);
var workspaceId = text(props && (props.workspaceId || (props.workspaceNode && props.workspaceNode.workspaceId))).trim();
if (!workspaceRoot) {
return { mode: 'global', key: GLOBAL_KEY, label: 'All Deals', workspaceRoot: '', workspaceId: '' };
}
return {
mode: 'workspace',
key: WORKSPACE_PREFIX + encodeKey(workspaceRoot),
label: workspaceRoot,
workspaceRoot: workspaceRoot,
workspaceId: workspaceId
};
}
function eventPayload(event) {
return event && event.payload && typeof event.payload === 'object' ? event.payload : {};
}
function normalizeStoredEvents(value, storageKey) {
if (!Array.isArray(value)) return [];
return value.filter(function (item) {
return item && typeof item === 'object' && item.activityId;
}).map(function (item) {
return {
activityId: text(item.activityId),
type: text(item.type || item.name || 'activity.event'),
title: text(item.title || item.type || 'Activity event'),
summary: text(item.summary),
occurredAt: text(item.occurredAt || item.timestamp || item.receivedAt),
receivedAt: text(item.receivedAt),
sourcePluginId: text(item.sourcePluginId || item.pluginId),
workspaceRootPath: cleanWorkspace(item.workspaceRootPath || workspaceFromPayload(item.payload || {})),
workspaceId: text(item.workspaceId || (item.sessionScope && item.sessionScope.workspaceId) || (item.payload && item.payload.workspaceId)),
sessionScope: item.sessionScope && typeof item.sessionScope === 'object' ? item.sessionScope : {},
durationSeconds: Math.max(0, Number(item.durationSeconds || (item.payload && item.payload.durationSeconds) || 0)),
_storageKey: storageKey || '',
payload: item.payload && typeof item.payload === 'object' ? item.payload : {}
};
}).slice(0, MAX_EVENTS);
}
function storageEvents(activityList) {
return activityList.map(function (item) {
return {
activityId: item.activityId,
type: item.type,
title: item.title,
summary: item.summary,
occurredAt: item.occurredAt,
receivedAt: item.receivedAt,
sourcePluginId: item.sourcePluginId,
workspaceRootPath: item.workspaceRootPath,
workspaceId: item.workspaceId,
sessionScope: item.sessionScope || {},
durationSeconds: item.durationSeconds || 0,
payload: item.payload || {}
};
});
}
function sortEvents(activityList) {
var seen = {};
return activityList.filter(function (item) {
var key = item && item.activityId;
if (!key) return false;
if (seen[key]) return false;
seen[key] = true;
return true;
}).slice().sort(function (a, b) {
return text(b.occurredAt || b.receivedAt).localeCompare(text(a.occurredAt || a.receivedAt));
}).slice(0, MAX_EVENTS);
}
function isMeaningfulActivity(activity) {
var type = text(activity && activity.type).toLowerCase();
return !LOW_VALUE_EVENT_TYPES[type];
}
function eventTimeMs(activity) {
var value = activity && (activity.occurredAt || activity.receivedAt);
var date = value ? new Date(value) : null;
return date && !isNaN(date.getTime()) ? date.getTime() : 0;
}
function candidateWorkspace(activity) {
return cleanWorkspace(activity && (activity.workspaceRootPath || workspaceFromPayload(activity.payload || {})));
}
function candidateScope(activity) {
var workspaceId = text(activity && (activity.workspaceId || (activity.sessionScope && activity.sessionScope.workspaceId) || (activity.payload && activity.payload.workspaceId))).trim();
if (workspaceId) return 'workspace:' + workspaceId;
var workspaceRoot = candidateWorkspace(activity);
if (workspaceRoot) return 'legacy-workspace:' + workspaceRoot;
return 'unassigned';
}
function toISOTime(time) {
var date = new Date(time);
return isNaN(date.getTime()) ? '' : date.toISOString();
}
function newSessionId() {
if (typeof crypto !== 'undefined' && crypto && typeof crypto.randomUUID === 'function') return crypto.randomUUID();
return 'session-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
}
function candidateActivity(activity) {
return {
activityId: text(activity.activityId),
type: text(activity.type),
occurredAt: toISOTime(eventTimeMs(activity)),
sourcePluginId: text(activity.sourcePluginId),
workspaceRootPath: candidateWorkspace(activity),
workspaceId: text(activity.workspaceId)
};
}
function normalizeSessionRegistry(value) {
value = value && typeof value === 'object' ? value : {};
return {
sessions: value.sessions && typeof value.sessions === 'object' ? value.sessions : {},
eventSessionIds: value.eventSessionIds && typeof value.eventSessionIds === 'object' ? value.eventSessionIds : {}
};
}
function pruneSessionRegistry(registry, activityList) {
var present = {};
(activityList || []).forEach(function (activity) { present[text(activity && activity.activityId)] = true; });
Object.keys(registry.eventSessionIds).forEach(function (activityId) {
if (!present[activityId]) delete registry.eventSessionIds[activityId];
});
var referenced = {};
Object.keys(registry.eventSessionIds).forEach(function (activityId) {
referenced[registry.eventSessionIds[activityId]] = true;
});
Object.keys(registry.sessions).forEach(function (sessionId) {
if (!referenced[sessionId]) delete registry.sessions[sessionId];
});
return registry;
}
function sessionDurationMinutes(session) {
var events = session.activities.slice().sort(function (a, b) { return eventTimeMs(a) - eventTimeMs(b); });
var durationMs = 0;
for (var index = 0; index < events.length; index += 1) {
var explicit = Math.max(0, Number(events[index].durationSeconds || 0)) * 1000;
durationMs += explicit;
if (index === 0 || explicit > 0 || Number(events[index - 1].durationSeconds || 0) > 0) continue;
var gap = eventTimeMs(events[index]) - eventTimeMs(events[index - 1]);
if (gap > 0 && gap <= MAX_IDLE_GAP_MINUTES * 60 * 1000) durationMs += Math.min(gap, 10 * 60 * 1000);
}
return Math.min(MAX_SESSION_DURATION_MINUTES, Math.floor(durationMs / 60000));
}
function findCompatibleSession(sessions, scope, time) {
var nearest = null;
sessions.forEach(function (session) {
if (session.scope !== scope) return;
var before = session.firstTime - time;
var after = time - session.lastTime;
var distance = time < session.firstTime ? before : after;
if (distance < 0 || distance > MAX_IDLE_GAP_MINUTES * 60 * 1000) return;
if (Math.max(session.lastTime, time) - Math.min(session.firstTime, time) > MAX_SESSION_DURATION_MINUTES * 60 * 1000) return;
if (!nearest || distance < nearest.distance) nearest = { session: session, distance: distance };
});
return nearest && nearest.session;
}
function logicalSessions(activityList, registry) {
var sessionsById = {};
var sessions = [];
var lastScope = '';
var ordered = sortEvents(activityList || []).filter(function (activity) {
return isMeaningfulActivity(activity) && eventTimeMs(activity);
}).slice().sort(function (a, b) { return eventTimeMs(a) - eventTimeMs(b); });
ordered.forEach(function (activity) {
var scope = candidateScope(activity);
var activityId = text(activity.activityId);
var sessionId = text(registry.eventSessionIds[activityId]);
var session = sessionId && sessionsById[sessionId];
if (!session && sessionId && registry.sessions[sessionId]) {
var persisted = registry.sessions[sessionId];
session = {
sessionId: sessionId,
scope: text(persisted.scope || scope),
workspaceRootPath: cleanWorkspace(persisted.workspaceRootPath || candidateWorkspace(activity)),
workspaceId: text(persisted.workspaceId || activity.workspaceId),
anchor: text(persisted.anchor),
firstTime: eventTimeMs(activity),
lastTime: eventTimeMs(activity),
activities: []
};
sessionsById[sessionId] = session;
sessions.push(session);
}
if (!session && (!lastScope || lastScope === scope)) {
session = findCompatibleSession(sessions, scope, eventTimeMs(activity));
}
if (!session) {
sessionId = newSessionId();
session = {
sessionId: sessionId,
scope: scope,
workspaceRootPath: candidateWorkspace(activity),
workspaceId: text(activity.workspaceId),
anchor: new Date().toISOString(),
firstTime: eventTimeMs(activity),
lastTime: eventTimeMs(activity),
activities: []
};
sessionsById[sessionId] = session;
sessions.push(session);
registry.sessions[sessionId] = {
scope: session.scope,
workspaceRootPath: session.workspaceRootPath,
workspaceId: session.workspaceId,
anchor: session.anchor
};
}
registry.eventSessionIds[activityId] = session.sessionId;
session.activities.push(activity);
session.firstTime = Math.min(session.firstTime, eventTimeMs(activity));
session.lastTime = Math.max(session.lastTime, eventTimeMs(activity));
if (candidateWorkspace(activity)) session.workspaceRootPath = candidateWorkspace(activity);
if (activity.workspaceId) session.workspaceId = text(activity.workspaceId);
lastScope = scope;
});
return sessions;
}
function buildCandidate(session) {
var activities = session.activities.slice().sort(function (a, b) { return eventTimeMs(a) - eventTimeMs(b); });
var first = activities[0];
var last = activities[activities.length - 1];
var duration = sessionDurationMinutes(session);
if (session.scope === 'unassigned' || !session.workspaceRootPath || activities.length < MIN_SESSION_ACTIVITY_COUNT || duration < MIN_SESSION_DURATION_MINUTES) return null;
return {
candidateId: 'work-session:' + encodeKey(session.sessionId) + ':' + encodeKey(last.activityId),
sessionId: session.sessionId,
handledThrough: toISOTime(eventTimeMs(last)),
workspaceRootPath: session.workspaceRootPath,
workspaceId: session.workspaceId,
startedAt: toISOTime(eventTimeMs(first)),
endedAt: toISOTime(eventTimeMs(last)),
estimatedMinutes: duration,
activityCount: activities.length,
activityIds: activities.map(function (activity) { return activity.activityId; }).filter(Boolean),
activities: activities.map(candidateActivity)
};
}
function buildWorkSessionCandidates(activityList, workspaceFilter, registry) {
var filter = cleanWorkspace(workspaceFilter);
registry = normalizeSessionRegistry(registry);
return logicalSessions(activityList, registry).map(buildCandidate).filter(function (candidate) {
return candidate && (!filter || candidate.workspaceRootPath === filter);
}).sort(function (a, b) {
return b.endedAt.localeCompare(a.endedAt) || a.workspaceRootPath.localeCompare(b.workspaceRootPath);
}).slice(0, MAX_CANDIDATES);
}
function humanEventType(type, translate) {
var label = EVENT_LABELS[text(type).toLowerCase()];
return label ? translate(label.key, null, label.fallback) : '';
}
function humanEventTitle(activity, translate) {
var explicit = text(activity && activity.title).trim();
var type = text(activity && activity.type).trim();
var label = humanEventType(type, translate);
if (explicit && explicit.toLowerCase() !== type.toLowerCase()) return label ? label + ' — ' + explicit : explicit;
return label || text(activity && (activity.summary || activity.activityId)).trim() || translate('ui.event.activity', null, 'Activity event');
}
function eventKind(activity, translate) {
var type = text(activity && activity.type).toLowerCase();
if (type.indexOf('browser.capture') === 0) return translate('ui.kind.capture', null, 'Capture');
if (type.indexOf('file.') === 0) return translate('ui.kind.file', null, 'File');
if (type.indexOf('note.') === 0) return translate('ui.kind.note', null, 'Note');
if (type.indexOf('workspace') !== -1 || type.indexOf('case.') === 0) return translate('ui.kind.deal', null, 'Deal');
if (type.indexOf('action.') === 0) return translate('ui.kind.work', null, 'Work');
return translate('ui.kind.activity', null, 'Activity');
}
function globalEventKeys(settings) {
var keys = [LEGACY_KEY, GLOBAL_KEY];
Object.keys(settings || {}).forEach(function (key) {
if (key.indexOf(WORKSPACE_PREFIX) === 0 && keys.indexOf(key) === -1) keys.push(key);
});
return keys;
}
function eventsFromSettings(settings, workspaceRoot) {
settings = settings || {};
var workspace = cleanWorkspace(workspaceRoot);
if (!workspace) {
var all = [];
globalEventKeys(settings).forEach(function (key) {
all = all.concat(normalizeStoredEvents(settings[key], key));
});
return sortEvents(all);
}
var workspaceKey = WORKSPACE_PREFIX + encodeKey(workspace);
var scopedEvents = normalizeStoredEvents(settings[workspaceKey], workspaceKey);
var globalEvents = normalizeStoredEvents(settings[GLOBAL_KEY], GLOBAL_KEY).filter(function (item) {
return item.workspaceRootPath === workspace;
});
var legacyEvents = normalizeStoredEvents(settings[LEGACY_KEY], LEGACY_KEY).filter(function (item) {
return item.workspaceRootPath === workspace;
});
return sortEvents(scopedEvents.concat(globalEvents, legacyEvents));
}
function eventsFromRecords(records, workspaceRoot) {
var normalized = normalizeStoredEvents(records, RAW_DATA_NAME);
var workspace = cleanWorkspace(workspaceRoot);
if (!workspace) return sortEvents(normalized);
return sortEvents(normalized.filter(function (item) {
return item.workspaceRootPath === workspace;
}));
}
function candidateStorageKey(workspaceRoot) {
return CANDIDATE_PREFIX + encodeKey(workspaceRoot);
}
function dismissalStorageKey(workspaceRoot) {
return DISMISSAL_PREFIX + encodeKey(workspaceRoot);
}
function decodeStoredWorkspace(key, prefix) {
if (key.indexOf(prefix) !== 0) return '';
try {
return cleanWorkspace(decodeURIComponent(key.slice(prefix.length)));
} catch (err) {
return cleanWorkspace(key.slice(prefix.length));
}
}
function dismissedCandidatesFromSettings(settings) {
var dismissed = {};
Object.keys(settings || {}).forEach(function (key) {
if (key.indexOf(DISMISSAL_PREFIX) !== 0) return;
var workspace = decodeStoredWorkspace(key, DISMISSAL_PREFIX);
if (!workspace) return;
dismissed[workspace] = {};
if (Array.isArray(settings[key])) {
settings[key].forEach(function (candidateId) {
candidateId = text(candidateId).trim();
if (candidateId) dismissed[workspace][candidateId] = true;
});
return;
}
if (settings[key] && typeof settings[key] === 'object') dismissed[workspace] = settings[key];
});
return dismissed;
}
function candidateAfterWatermark(candidate, watermark) {
if (!watermark || watermark === true) return watermark === true ? null : candidate;
var handledTime = Date.parse(watermark.handledThrough || '');
if (!Number.isFinite(handledTime)) return candidate;
var additions = candidate.activities.filter(function (activity) { return eventTimeMs(activity) > handledTime; });
if (eventTimeMs({ occurredAt: candidate.endedAt }) <= handledTime || additions.length < MIN_SESSION_ACTIVITY_COUNT || eventTimeMs(additions[additions.length - 1]) - handledTime < MIN_SESSION_DURATION_MINUTES * 60 * 1000) return null;
var duration = sessionDurationMinutes({ activities: additions });
if (duration < MIN_SESSION_DURATION_MINUTES) return null;
return Object.assign({}, candidate, {
startedAt: toISOTime(eventTimeMs(additions[0])),
estimatedMinutes: duration,
activityCount: additions.length,
activityIds: additions.map(function (activity) { return activity.activityId; }),
activities: additions.map(candidateActivity)
});
}
function visibleCandidates(activityList, workspaceFilter, sessionRegistry, dismissedByWorkspace, handledSessions) {
return buildWorkSessionCandidates(activityList, workspaceFilter, sessionRegistry).map(function (candidate) {
var dismissed = dismissedByWorkspace && dismissedByWorkspace[candidate.workspaceRootPath];
var watermark = handledSessions && handledSessions[candidate.sessionId];
return candidateAfterWatermark(candidate, watermark || (dismissed && (dismissed[candidate.sessionId] || dismissed[candidate.candidateId])));
}).filter(Boolean);
}
function formatDate(value) {
if (!value) return '';
var date = new Date(value);
if (isNaN(date.getTime())) return text(value);
return date.toLocaleString(undefined, { year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' });
}
function ActivityView() {}
ActivityView.mount = function (containerEl, props, api) {
injectStyles();
containerEl.innerHTML = '';
containerEl.className = 'activity-root';
containerEl.setAttribute('data-plugin-id', PLUGIN_ID);
var scope = scopeFromProps(props || {});
var events = [];
var candidateSourceEvents = [];
var candidates = [];
var dismissedByWorkspace = {};
var handledSessions = {};
var sessionRegistry = normalizeSessionRegistry({});
function tr(key, params, fallback) {
if (api && api.i18n && typeof api.i18n.t === 'function') return api.i18n.t(key, params, fallback);
return fallback || key;
}
var statusText = tr('ui.loading', null, 'Loading activity...');
var statusClass = '';
var disposed = false;
var unsubscribers = [];
function reportError(key, fallback, err) {
if (typeof console !== 'undefined' && typeof console.warn === 'function') {
console.warn('[verstak.activity] ' + key, err);
}
statusText = tr(key, null, fallback);
statusClass = 'error';
}
var toolbar = el('div', { className: 'activity-toolbar' });
var titleEl = el('span', { className: 'activity-title', textContent: scope.mode === 'global' ? tr('ui.title', null, 'Activity') : tr('ui.workspaceTitle', { workspace: scope.label }, 'Activity · ' + scope.label) });
var countEl = el('span', { className: 'activity-count' });
var statusEl = el('span', { className: 'activity-status' });
var clearBtn = el('button', {
className: 'activity-btn danger',
'data-activity-action': 'clear',
textContent: tr('ui.clear', null, 'Clear'),
onClick: showClearConfirmation
});
toolbar.appendChild(titleEl);
toolbar.appendChild(countEl);
toolbar.appendChild(el('span', { className: 'activity-spacer' }));
toolbar.appendChild(statusEl);
toolbar.appendChild(clearBtn);
var candidatesEl = el('div', {
className: 'activity-candidates',
'data-activity-section': 'work-session-candidates'
});
var listEl = el('div', { className: 'activity-list' });
var modalHost = el('div', { className: 'activity-modal-host', hidden: 'hidden' });
containerEl.appendChild(toolbar);
containerEl.appendChild(candidatesEl);
containerEl.appendChild(listEl);
containerEl.appendChild(modalHost);
function candidatesForWorkspace(workspaceRoot) {
return visibleCandidates(candidateSourceEvents, workspaceRoot, sessionRegistry, dismissedByWorkspace, handledSessions);
}
function updateCandidates() {
candidates = candidatesForWorkspace(scope.mode === 'workspace' ? scope.workspaceRoot : '');
}
function candidateWorkspaces() {
var workspaces = {};
candidateSourceEvents.forEach(function (activity) {
var workspace = candidateWorkspace(activity);
if (workspace) workspaces[workspace] = true;
});
if (scope.mode === 'workspace' && scope.workspaceRoot) workspaces[scope.workspaceRoot] = true;
return Object.keys(workspaces);
}
function persistCandidateCache(workspaceRoot) {
if (!workspaceRoot || !api || !api.settings || typeof api.settings.write !== 'function') return Promise.resolve();
return api.settings.write(candidateStorageKey(workspaceRoot), candidatesForWorkspace(workspaceRoot));
}
function persistCandidateCaches() {
return Promise.all(candidateWorkspaces().map(persistCandidateCache));
}
function persistDismissals(workspaceRoot) {
if (!workspaceRoot || !api || !api.settings || typeof api.settings.write !== 'function') return Promise.resolve();
var dismissed = dismissedByWorkspace[workspaceRoot] || {};
return api.settings.write(dismissalStorageKey(workspaceRoot), dismissed);
}
function persistSessionRegistry() {
if (!api || !api.settings || typeof api.settings.write !== 'function') return Promise.resolve();
return api.settings.write(SESSION_REGISTRY_KEY, sessionRegistry);
}
function persist() {
if (!api || !api.settings || typeof api.settings.write !== 'function') return Promise.resolve();
var toStore = scope.mode === 'global'
? events.filter(function (item) { return !item._storageKey || item._storageKey === GLOBAL_KEY; })
: events;
return api.settings.write(scope.key, storageEvents(toStore)).then(persistSessionRegistry).then(persistCandidateCaches).catch(function (err) {
reportError('ui.saveError', 'Could not save activity. Please try again.', err);
});
}
function clearGlobal() {
if (!api || !api.settings || typeof api.settings.read !== 'function' || typeof api.settings.write !== 'function') {
events = [];
return Promise.resolve();
}
var clearRaw = api.storage && api.storage.data && typeof api.storage.data.writeNDJSON === 'function'
? api.storage.data.writeNDJSON(RAW_DATA_NAME, [])
: Promise.resolve();
return clearRaw.then(function () {
return api.settings.read();
}).then(function (settings) {
var keys = globalEventKeys(settings || {}).concat(Object.keys(settings || {}).filter(function (key) {
return key.indexOf(CANDIDATE_PREFIX) === 0 || key.indexOf(DISMISSAL_PREFIX) === 0;
}));
events = [];
candidateSourceEvents = [];
candidates = [];
dismissedByWorkspace = {};
handledSessions = {};
sessionRegistry = normalizeSessionRegistry({});
return Promise.all(keys.filter(function (key, index, all) {
return all.indexOf(key) === index;
}).map(function (key) {
return api.settings.write(key, []);
}));
}).then(function () {
statusText = tr('ui.cleared', null, 'Activity cleared');
statusClass = '';
}).catch(function (err) {
reportError('ui.clearError', 'Could not clear activity. Please try again.', err);
});
}
function closeClearConfirmation() {
modalHost.innerHTML = '';
modalHost.setAttribute('hidden', 'hidden');
}
function clearCurrentScope() {
if (scope.mode === 'global') {
return clearGlobal().then(render);
}
events = [];
candidateSourceEvents = candidateSourceEvents.filter(function (activity) {
return candidateWorkspace(activity) !== scope.workspaceRoot;
});
updateCandidates();
return clearWorkspaceRaw(scope.workspaceRoot).then(persist).then(render);
}
function showClearConfirmation() {
var scopeMessage = scope.mode === 'global'
? tr('ui.clearGlobalWarning', null, 'This permanently deletes all recorded activity and journal suggestions in every case.')
: tr('ui.clearWorkspaceWarning', { workspace: scope.label }, 'This permanently deletes recorded activity for ' + scope.label + '. Activity in other cases remains.');
var confirmBtn = el('button', {
className: 'activity-btn danger destructive',
type: 'button',
'data-activity-clear-confirm': '',
textContent: tr('ui.confirmClear', null, 'Clear activity'),
onClick: function () {
confirmBtn.disabled = true;
clearCurrentScope().then(closeClearConfirmation);
}
});
modalHost.innerHTML = '';
if (typeof modalHost.removeAttribute === 'function') modalHost.removeAttribute('hidden');
else delete modalHost.attributes.hidden;
modalHost.appendChild(el('div', {
className: 'activity-modal-overlay',
onClick: function (event) {
if (event.target === event.currentTarget) closeClearConfirmation();
}
}, [
el('div', { className: 'activity-modal', role: 'dialog', 'aria-modal': 'true', 'data-activity-clear-confirmation': '' }, [
el('div', { className: 'activity-modal-title', textContent: tr('ui.clearConfirmTitle', null, 'Clear activity?') }),
el('div', { className: 'activity-modal-copy', textContent: scopeMessage }),
el('div', { className: 'activity-modal-actions' }, [
el('button', { className: 'activity-btn', type: 'button', 'data-activity-clear-cancel': '', textContent: tr('ui.cancel', null, 'Cancel'), onClick: closeClearConfirmation }),
confirmBtn
])
])
]));
}
function clearWorkspaceRaw(workspaceRoot) {
if (!api || !api.storage || !api.storage.data || typeof api.storage.data.readNDJSON !== 'function' || typeof api.storage.data.writeNDJSON !== 'function') {
return Promise.resolve();
}
return api.storage.data.readNDJSON(RAW_DATA_NAME).then(function (records) {
var workspace = cleanWorkspace(workspaceRoot);
var kept = (Array.isArray(records) ? records : []).filter(function (record) {
return cleanWorkspace(record && (record.workspaceRootPath || workspaceFromPayload(record.payload || {}))) !== workspace;
});
return api.storage.data.writeNDJSON(RAW_DATA_NAME, kept);
});
}
function renderList() {
listEl.innerHTML = '';
if (events.length === 0) {
listEl.appendChild(el('div', { className: 'activity-empty' }, [
el('div', { className: 'activity-empty-title', textContent: tr('ui.empty', null, 'No activity events yet') }),
el('div', { textContent: tr('ui.emptyHint', null, 'File changes, browser captures, and conversions will appear here.') })
]));
return;
}
events.forEach(function (activity) {
listEl.appendChild(el('div', {
className: 'activity-row',
'data-activity-id': activity.activityId
}, [
el('div', { className: 'activity-time', textContent: formatDate(activity.occurredAt) || '-' }),
el('div', { className: 'activity-main' }, [
el('div', { className: 'activity-row-head' }, [
el('span', { className: 'activity-type', textContent: eventKind(activity, tr) }),
el('span', { className: 'activity-title-text', textContent: humanEventTitle(activity, tr) })
]),
activity.summary ? el('div', { className: 'activity-summary', textContent: activity.summary }) : null
])
]));
});
}
function candidateTimeRange(candidate) {
return formatDate(candidate.startedAt) + ' - ' + formatDate(candidate.endedAt);
}
function reviewCandidate(candidate) {
if (typeof window === 'undefined' || typeof window.dispatchEvent !== 'function' || typeof CustomEvent !== 'function') return;
window.dispatchEvent(new CustomEvent('verstak:workspace-open-tool', {
detail: {
kind: 'journal',
toolRequest: { type: 'work-session-candidate', candidate: candidate }
}
}));
}
function dismissCandidate(candidate) {
if (!candidate || !candidate.workspaceRootPath || !candidate.candidateId) return;
dismissedByWorkspace[candidate.workspaceRootPath] = dismissedByWorkspace[candidate.workspaceRootPath] || {};
dismissedByWorkspace[candidate.workspaceRootPath][candidate.sessionId || candidate.candidateId] = {
handledThrough: candidate.handledThrough || candidate.endedAt,
handledAt: new Date().toISOString(),
status: 'dismissed'
};
updateCandidates();
Promise.all([
persistDismissals(candidate.workspaceRootPath),
persistCandidateCache(candidate.workspaceRootPath)
]).then(function () {
statusText = tr('ui.dismissed', null, 'Candidate dismissed');
statusClass = '';
}).catch(function (err) {
reportError('ui.dismissError', 'Could not dismiss the suggestion. Please try again.', err);
}).then(render);
}
function renderCandidates() {
candidatesEl.innerHTML = '';
if (!candidates.length) {
candidatesEl.setAttribute('hidden', 'hidden');
return;
}
if (typeof candidatesEl.removeAttribute === 'function') candidatesEl.removeAttribute('hidden');
else delete candidatesEl.attributes.hidden;
candidatesEl.appendChild(el('div', { className: 'activity-candidates-title', textContent: tr('ui.candidates', null, 'Possible journal entries') }));
candidates.forEach(function (candidate) {
candidatesEl.appendChild(el('div', {
className: 'activity-candidate',
'data-work-session-candidate': candidate.candidateId
}, [
el('div', {}, [
el('div', { className: 'activity-candidate-title', textContent: tr('ui.candidate', null, 'Possible journal entry') }),
el('div', { className: 'activity-candidate-facts' }, [
el('div', { textContent: tr('ui.candidateDeal', { deal: candidate.workspaceRootPath }, 'Deal: ' + candidate.workspaceRootPath) }),
el('div', { textContent: tr('ui.candidateTime', { time: candidateTimeRange(candidate) }, 'Time: ' + candidateTimeRange(candidate)) }),
el('div', { textContent: tr('ui.candidateDuration', { minutes: candidate.estimatedMinutes }, 'Estimated duration: ' + candidate.estimatedMinutes + ' min') }),
el('div', { textContent: tr('ui.candidateActivities', { count: candidate.activityCount }, 'Activities: ' + candidate.activityCount) })
]),
el('details', { className: 'activity-candidate-activities' }, [
el('summary', { textContent: tr('ui.includedActivities', { count: candidate.activityCount }, 'Included activities (' + candidate.activityCount + ')') })
].concat(candidate.activities.map(function (activity) {
return el('div', { className: 'activity-candidate-activity', textContent: activity.occurredAt + ' · ' + humanEventTitle(activity, tr) });
})))
]),
el('div', { className: 'activity-candidate-actions' }, [
el('div', { className: 'activity-candidate-duration', textContent: tr('ui.minutes', { count: candidate.estimatedMinutes }, candidate.estimatedMinutes + ' min') }),
el('button', { className: 'activity-btn', type: 'button', 'data-work-session-action': 'review', textContent: tr('ui.review', null, 'Review'), onClick: function () { reviewCandidate(candidate); } }),
el('button', { className: 'activity-btn', type: 'button', 'data-work-session-action': 'dismiss', textContent: tr('ui.dismiss', null, 'Dismiss'), onClick: function () { dismissCandidate(candidate); } })
])
]));
});
}
function render() {
countEl.textContent = tr(events.length === 1 ? 'ui.eventCount.one' : 'ui.eventCount.many', { count: events.length }, events.length + ' event' + (events.length === 1 ? '' : 's'));
clearBtn.disabled = events.length === 0;
statusEl.textContent = statusText;
statusEl.className = 'activity-status' + (statusClass ? ' ' + statusClass : '');
renderCandidates();
renderList();
}
function loadStored() {
if (!api || !api.settings || typeof api.settings.read !== 'function') return Promise.resolve();
var readRaw = api.storage && api.storage.data && typeof api.storage.data.readNDJSON === 'function'
? api.storage.data.readNDJSON(RAW_DATA_NAME)
: Promise.resolve([]);
return Promise.all([api.settings.read(), readRaw]).then(function (results) {
var settings = results[0] || {};
var rawRecords = Array.isArray(results[1]) ? results[1] : [];
settings = settings || {};
sessionRegistry = normalizeSessionRegistry(settings[SESSION_REGISTRY_KEY]);
handledSessions = settings["activity-session-handling-v2"] && typeof settings["activity-session-handling-v2"] === 'object'
? settings["activity-session-handling-v2"]
: {};
candidateSourceEvents = rawRecords.length ? eventsFromRecords(rawRecords, '') : eventsFromSettings(settings, '');
sessionRegistry = pruneSessionRegistry(sessionRegistry, candidateSourceEvents);
events = rawRecords.length
? eventsFromRecords(rawRecords, scope.mode === 'workspace' ? scope.workspaceRoot : '')
: eventsFromSettings(settings, scope.mode === 'workspace' ? scope.workspaceRoot : '');
dismissedByWorkspace = dismissedCandidatesFromSettings(settings);
updateCandidates();
return persistSessionRegistry().then(persistCandidateCaches);
}).catch(function (err) {
reportError('ui.loadError', 'Could not load activity. Please try again.', err);
});
}
function listWorkSessionCandidates(args) {
var workspace = cleanWorkspace(args && args.workspaceRootPath);
if (!api || !api.settings || typeof api.settings.read !== 'function') {
return Promise.resolve({ candidates: visibleCandidates(candidateSourceEvents, workspace || (scope.mode === 'workspace' ? scope.workspaceRoot : ''), sessionRegistry, dismissedByWorkspace, handledSessions) });
}
var readRaw = api.storage && api.storage.data && typeof api.storage.data.readNDJSON === 'function'
? api.storage.data.readNDJSON(RAW_DATA_NAME)
: Promise.resolve([]);
return Promise.all([api.settings.read(), readRaw]).then(function (results) {
var settings = results[0] || {};
var rawRecords = Array.isArray(results[1]) ? results[1] : [];
var source = rawRecords.length ? eventsFromRecords(rawRecords, '') : eventsFromSettings(settings, '');
return { candidates: visibleCandidates(source, workspace, sessionRegistry, dismissedCandidatesFromSettings(settings), settings["activity-session-handling-v2"] || {}) };
}).catch(function () {
return { candidates: [] };
});
}
function registerCommands() {
if (!api || !api.commands || typeof api.commands.register !== 'function') return Promise.resolve();
return api.commands.register(WORKLOG_COMMAND_ID, listWorkSessionCandidates).then(function (unregister) {
if (typeof unregister === 'function') unsubscribers.push(unregister);
}).catch(function (err) {
reportError('ui.commandsUnavailable', 'Activity actions are unavailable. Please try again.', err);
});
}
function subscribeEvents() {
if (!api || !api.events || typeof api.events.subscribe !== 'function') return Promise.resolve();
return Promise.all(ACTIVITY_EVENTS.map(function (eventName) {
return api.events.subscribe(eventName, function (event) {
var eventWorkspace = workspaceFromPayload(eventPayload(event));
if (scope.mode === 'workspace' && eventWorkspace && eventWorkspace !== scope.workspaceRoot) return Promise.resolve();
return loadStored().then(render);
}).then(function (unsubscribe) {
if (typeof unsubscribe === 'function') unsubscribers.push(unsubscribe);
});
})).then(function () {
statusText = scope.mode === 'global'
? tr('ui.listeningGlobal', null, 'Listening for all activity')
: tr('ui.listeningDeal', null, 'Listening for Deal activity');
statusClass = '';
}).catch(function (err) {
reportError('ui.subscriptionsUnavailable', 'Activity updates are unavailable. Please try again.', err);
});
}
render();
loadStored().then(function () {
if (disposed) return;
render();
return registerCommands();
}).then(function () {
if (disposed) return;
return subscribeEvents();
}).then(function () {
if (!disposed) render();
});
if (api && api.i18n && typeof api.i18n.onDidChangeLocale === 'function') {
api.i18n.onDidChangeLocale(function () {
titleEl.textContent = scope.mode === 'global' ? tr('ui.title', null, 'Activity') : tr('ui.workspaceTitle', { workspace: scope.label }, 'Activity · ' + scope.label);
clearBtn.textContent = tr('ui.clear', null, 'Clear');
render();
});
}
containerEl.__activityUnmount = function () {
disposed = true;
while (unsubscribers.length > 0) {
try {
unsubscribers.pop()();
} catch (err) {
console.error('[Activity] unsubscribe error:', err);
}
}
};
};
ActivityView.unmount = function (containerEl) {
if (containerEl && typeof containerEl.__activityUnmount === 'function') {
containerEl.__activityUnmount();
delete containerEl.__activityUnmount;
}
};
window.VerstakPluginRegister(PLUGIN_ID, {
components: {
ActivityView: ActivityView
}
});
})();
+62
View File
@@ -0,0 +1,62 @@
{
"manifest.name": "Activity",
"manifest.description": "Deal-scoped activity log for public plugin events.",
"contributions.views.verstak.activity.view.title": "Activity",
"contributions.sidebarItems.verstak.activity.sidebar.title": "Activity",
"contributions.workspaceItems.verstak.activity.workspace.title": "Activity",
"contributions.commands.verstak.activity.suggestWorklog.title": "List Possible Journal Entries",
"ui.loading": "Loading activity...",
"ui.title": "Activity",
"ui.workspaceTitle": "Activity · {workspace}",
"ui.clear": "Clear",
"ui.clearConfirmTitle": "Clear activity?",
"ui.clearGlobalWarning": "This permanently deletes all recorded activity and journal suggestions in every Deal.",
"ui.clearWorkspaceWarning": "This permanently deletes recorded activity for {workspace}. Activity in other Deals remains.",
"ui.confirmClear": "Clear activity",
"ui.cancel": "Cancel",
"ui.saveError": "Could not save activity. Please try again.",
"ui.cleared": "Activity cleared",
"ui.clearError": "Could not clear activity. Please try again.",
"ui.dismissError": "Could not dismiss the suggestion. Please try again.",
"ui.loadError": "Could not load activity. Please try again.",
"ui.commandsUnavailable": "Activity actions are unavailable. Please try again.",
"ui.subscriptionsUnavailable": "Activity updates are unavailable. Please try again.",
"ui.empty": "No activity events yet",
"ui.emptyHint": "File changes, browser captures, and conversions will appear here.",
"ui.details": "Details",
"ui.eventSource": "Event: {event}{source}",
"ui.dismissed": "Candidate dismissed",
"ui.candidates": "Possible journal entries",
"ui.candidate": "Possible journal entry",
"ui.review": "Review",
"ui.dismiss": "Dismiss",
"ui.event.workspaceSelected": "Deal selected",
"ui.event.fileOpened": "File opened",
"ui.event.fileChanged": "File changed",
"ui.event.noteEdited": "Note edited",
"ui.event.workSessionDetected": "Work session detected",
"ui.event.browserCaptureReceived": "Browser capture received",
"ui.event.pageCaptured": "Page captured",
"ui.event.selectionCaptured": "Selection captured",
"ui.event.linkCaptured": "Link captured",
"ui.event.fileCaptured": "File captured",
"ui.event.captureConverted": "Capture converted",
"ui.event.browserDomainActivity": "Browser domain activity",
"ui.event.activity": "Activity event",
"ui.kind.capture": "Capture",
"ui.kind.file": "File",
"ui.kind.note": "Note",
"ui.kind.deal": "Deal",
"ui.kind.work": "Work",
"ui.kind.activity": "Activity",
"ui.candidateDeal": "Deal: {deal}",
"ui.candidateTime": "Time: {time}",
"ui.candidateDuration": "Estimated duration: {minutes} min",
"ui.candidateActivities": "Activities: {count}",
"ui.includedActivities": "Included activities ({count})",
"ui.minutes": "{count} min",
"ui.eventCount.one": "{count} event",
"ui.eventCount.many": "{count} events",
"ui.listeningGlobal": "Listening for all activity",
"ui.listeningDeal": "Listening for Deal activity"
}
+62
View File
@@ -0,0 +1,62 @@
{
"manifest.name": "Активность",
"manifest.description": "Журнал публичных событий плагинов в Деле.",
"contributions.views.verstak.activity.view.title": "Активность",
"contributions.sidebarItems.verstak.activity.sidebar.title": "Активность",
"contributions.workspaceItems.verstak.activity.workspace.title": "Активность",
"contributions.commands.verstak.activity.suggestWorklog.title": "Показать возможные записи журнала",
"ui.loading": "Загрузка активности...",
"ui.title": "Активность",
"ui.workspaceTitle": "Активность · {workspace}",
"ui.clear": "Очистить",
"ui.clearConfirmTitle": "Очистить активность?",
"ui.clearGlobalWarning": "Будут безвозвратно удалены вся записанная активность и предложения для журнала во всех Делах.",
"ui.clearWorkspaceWarning": "Будет безвозвратно удалена записанная активность Дела «{workspace}». Активность других Дел останется.",
"ui.confirmClear": "Очистить активность",
"ui.cancel": "Отмена",
"ui.saveError": "Не удалось сохранить активность. Повторите попытку.",
"ui.cleared": "Активность очищена",
"ui.clearError": "Не удалось очистить активность. Повторите попытку.",
"ui.dismissError": "Не удалось отклонить предложение. Повторите попытку.",
"ui.loadError": "Не удалось загрузить активность. Повторите попытку.",
"ui.commandsUnavailable": "Действия с активностью недоступны. Повторите попытку.",
"ui.subscriptionsUnavailable": "Обновления активности недоступны. Повторите попытку.",
"ui.empty": "Событий активности пока нет",
"ui.emptyHint": "Здесь появятся изменения файлов, материалы из браузера и преобразования.",
"ui.details": "Подробнее",
"ui.eventSource": "Событие: {event}{source}",
"ui.dismissed": "Кандидат скрыт",
"ui.candidates": "Возможные записи журнала",
"ui.candidate": "Возможная запись журнала",
"ui.review": "Проверить",
"ui.dismiss": "Скрыть",
"ui.event.workspaceSelected": "Выбрано Дело",
"ui.event.fileOpened": "Открыт файл",
"ui.event.fileChanged": "Изменён файл",
"ui.event.noteEdited": "Изменена заметка",
"ui.event.workSessionDetected": "Обнаружена рабочая сессия",
"ui.event.browserCaptureReceived": "Получено сохранённое из браузера",
"ui.event.pageCaptured": "Сохранена страница",
"ui.event.selectionCaptured": "Сохранено выделение",
"ui.event.linkCaptured": "Сохранена ссылка",
"ui.event.fileCaptured": "Сохранён файл",
"ui.event.captureConverted": "Сохранённое преобразовано",
"ui.event.browserDomainActivity": "Активность на домене в браузере",
"ui.event.activity": "Событие активности",
"ui.kind.capture": "Сохранённое",
"ui.kind.file": "Файл",
"ui.kind.note": "Заметка",
"ui.kind.deal": "Дело",
"ui.kind.work": "Работа",
"ui.kind.activity": "Активность",
"ui.candidateDeal": "Дело: {deal}",
"ui.candidateTime": "Время: {time}",
"ui.candidateDuration": "Оценка длительности: {minutes} мин.",
"ui.candidateActivities": "Активностей: {count}",
"ui.includedActivities": "Учтённые активности ({count})",
"ui.minutes": "{count} мин.",
"ui.eventCount.one": "Событий: {count}",
"ui.eventCount.many": "Событий: {count}",
"ui.listeningGlobal": "Учёт всей активности включён",
"ui.listeningDeal": "Учёт активности Дела включён"
}
+82
View File
@@ -0,0 +1,82 @@
{
"schemaVersion": 1,
"id": "verstak.activity",
"name": "Activity",
"version": "0.1.0",
"apiVersion": "0.1.0",
"description": "Deal-scoped activity log for public plugin events.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official",
"icon": "activity",
"provides": [
"activity.log",
"activity.provider",
"activity.reconstruction"
],
"permissions": [
"events.subscribe",
"storage.namespace",
"ui.register",
"commands.register"
],
"frontend": {
"entry": "frontend/src/index.js"
},
"contributes": {
"views": [
{
"id": "verstak.activity.view",
"title": "Activity",
"icon": "activity",
"component": "ActivityView"
}
],
"sidebarItems": [
{
"id": "verstak.activity.sidebar",
"title": "Activity",
"icon": "activity",
"view": "verstak.activity.view",
"position": 20
}
],
"workspaceItems": [
{
"id": "verstak.activity.workspace",
"title": "Activity",
"icon": "activity",
"component": "ActivityView"
}
],
"commands": [
{
"id": "verstak.activity.suggestWorklog",
"title": "List Possible Journal Entries",
"handler": "verstak.activity.suggestWorklog"
}
],
"activityProviders": [
{
"id": "verstak.activity.log",
"events": [
"file.opened",
"file.changed",
"note.saved",
"action.started",
"browser.capture.received",
"case.selected",
"workspace.created",
"workspace.renamed",
"workspace.trashed",
"workspace.selected",
"browser.capture.page",
"browser.capture.selection",
"browser.capture.link",
"browser.capture.file",
"browser.capture.converted"
],
"handler": "recordActivityEvent"
}
]
}
}
File diff suppressed because it is too large Load Diff
+90
View File
@@ -0,0 +1,90 @@
{
"manifest.name": "Browser",
"manifest.description": "Global browser materials with explicit Deal assignment delivered through the local receiver event protocol.",
"contributions.views.verstak.browser-inbox.view.title": "Browser",
"contributions.sidebarItems.verstak.browser-inbox.sidebar.title": "Browser",
"contributions.workspaceItems.verstak.browser-inbox.workspace.title": "Browser",
"contributions.settingsPanels.verstak.browser-inbox.settings.title": "Browser",
"ui.connecting": "Connecting to receiver events...",
"ui.title": "Browser",
"ui.workspaceTitle": "Browser · {workspace}",
"ui.allCaptures": "All captures",
"ui.allDeals": "All Deals",
"ui.unassigned": "Unassigned",
"ui.unprocessed": "Unprocessed",
"ui.processed": "Processed",
"ui.search": "Search captures",
"ui.clear": "Clear",
"ui.assignedHere": "Assigned to this Deal",
"ui.selectCapture": "Select a capture to inspect it.",
"ui.kind": "Kind",
"ui.domain": "Domain",
"ui.captured": "Captured",
"ui.workspace": "Deal",
"ui.status": "Status",
"ui.createNote": "Create Note",
"ui.createLink": "Create Link",
"ui.createFile": "Create File",
"ui.delete": "Delete",
"ui.copyUrl": "Copy URL",
"ui.copyToken": "Copy Token",
"ui.rotateToken": "Rotate Token",
"ui.receiverUrl": "Receiver URL",
"ui.pairingToken": "Pairing Token",
"ui.loading": "Loading...",
"ui.clipboardUnavailable": "Clipboard unavailable",
"ui.copied": "{label} copied",
"ui.rotateConfirm": "Rotate pairing token?",
"ui.rotating": "Rotating...",
"ui.tokenRotated": "Token rotated",
"ui.saveError": "Could not update browser materials. Please try again.",
"ui.createNoteError": "Could not create the note. Please try again.",
"ui.createLinkError": "Could not create the link. Please try again.",
"ui.openLinkError": "Could not open the link. Please try again.",
"ui.createFileError": "Could not create the file. Please try again.",
"ui.loadError": "Could not load browser materials. Please try again.",
"ui.receiverError": "The browser receiver is unavailable. Please try again.",
"ui.pairingLoadError": "Could not load browser connection settings. Please try again.",
"ui.clipboardError": "Could not copy to the clipboard. Please try again.",
"ui.tokenRotateError": "Could not rotate the pairing token. Please try again.",
"ui.statusFilter": "Material status filter",
"ui.workspaceFilter": "Deal filter",
"ui.assignment": "Assign to Deal",
"ui.clearAssignment": "Clear assignment",
"ui.empty": "No browser materials yet. Send a page, selection, or link from the extension.",
"ui.emptyFiltered": "No materials match the current filters.",
"ui.untitledCapture": "Untitled material",
"ui.url": "URL",
"ui.browser": "Browser",
"ui.assignBeforeCreation": "Assign a Deal before creating a note, link, or file.",
"ui.markProcessed": "Mark processed",
"ui.markUnprocessed": "Mark unprocessed",
"ui.openLink": "Open link",
"ui.restore": "Restore to Inbox",
"ui.archive": "Archive",
"ui.deletePermanently": "Delete permanently",
"ui.captureReceived": "Material received",
"ui.captureLoadError": "Could not load the received material.",
"ui.inboxArchived": "Inbox archived",
"ui.workspaceCapturesArchived": "Deal materials archived",
"ui.assignedToWorkspace": "Material assigned to {workspace}",
"ui.captureUnassigned": "Material is unassigned",
"ui.captureArchived": "Material archived",
"ui.captureRestored": "Material restored to Inbox",
"ui.captureDeleted": "Material permanently deleted",
"ui.captureProcessed": "Material marked processed",
"ui.captureUnprocessed": "Material marked unprocessed",
"ui.creatingNote": "Creating note...",
"ui.noteCreated": "Note created: {path}",
"ui.creatingLink": "Creating link...",
"ui.linkCreated": "Link created",
"ui.creatingFile": "Creating file...",
"ui.fileCreated": "File created: {path}",
"ui.receiverReadyAll": "Receiver ready for all Deals",
"ui.receiverReadyWorkspace": "Receiver ready for this Deal",
"ui.items.one": "{count} item",
"ui.items.few": "{count} items",
"ui.items.many": "{count} items",
"ui.items.other": "{count} items",
"ui.items.filtered": "{visible} of {total}"
}
+90
View File
@@ -0,0 +1,90 @@
{
"manifest.name": "Браузер",
"manifest.description": "Общие материалы из браузера с явным назначением Дела через локальный протокол приёма.",
"contributions.views.verstak.browser-inbox.view.title": "Браузер",
"contributions.sidebarItems.verstak.browser-inbox.sidebar.title": "Браузер",
"contributions.workspaceItems.verstak.browser-inbox.workspace.title": "Браузер",
"contributions.settingsPanels.verstak.browser-inbox.settings.title": "Браузер",
"ui.connecting": "Подключение к событиям приёмника...",
"ui.title": "Браузер",
"ui.workspaceTitle": "Браузер · {workspace}",
"ui.allCaptures": "Все материалы",
"ui.allDeals": "Все Дела",
"ui.unassigned": "Не назначено",
"ui.unprocessed": "Не обработано",
"ui.processed": "Обработано",
"ui.search": "Поиск материалов",
"ui.clear": "Очистить",
"ui.assignedHere": "Назначено этому Делу",
"ui.selectCapture": "Выберите материал для просмотра.",
"ui.kind": "Тип",
"ui.domain": "Домен",
"ui.captured": "Получено",
"ui.workspace": "Дело",
"ui.status": "Состояние",
"ui.createNote": "Создать заметку",
"ui.createLink": "Создать ссылку",
"ui.createFile": "Создать файл",
"ui.delete": "Удалить",
"ui.copyUrl": "Копировать URL",
"ui.copyToken": "Копировать токен",
"ui.rotateToken": "Сменить токен",
"ui.receiverUrl": "URL приёмника",
"ui.pairingToken": "Токен сопряжения",
"ui.loading": "Загрузка...",
"ui.clipboardUnavailable": "Буфер обмена недоступен",
"ui.copied": "{label} скопирован",
"ui.rotateConfirm": "Сменить токен сопряжения?",
"ui.rotating": "Смена токена...",
"ui.tokenRotated": "Токен изменён",
"ui.saveError": "Не удалось обновить материалы браузера. Повторите попытку.",
"ui.createNoteError": "Не удалось создать заметку. Повторите попытку.",
"ui.createLinkError": "Не удалось создать ссылку. Повторите попытку.",
"ui.openLinkError": "Не удалось открыть ссылку. Повторите попытку.",
"ui.createFileError": "Не удалось создать файл. Повторите попытку.",
"ui.loadError": "Не удалось загрузить материалы браузера. Повторите попытку.",
"ui.receiverError": "Приёмник браузера недоступен. Повторите попытку.",
"ui.pairingLoadError": "Не удалось загрузить параметры подключения браузера. Повторите попытку.",
"ui.clipboardError": "Не удалось скопировать в буфер обмена. Повторите попытку.",
"ui.tokenRotateError": "Не удалось сменить токен сопряжения. Повторите попытку.",
"ui.statusFilter": "Фильтр состояния материала",
"ui.workspaceFilter": "Фильтр Дела",
"ui.assignment": "Назначить Делу",
"ui.clearAssignment": "Снять назначение",
"ui.empty": "Пока нет материалов из браузера. Отправьте страницу, выделенный текст или ссылку с помощью расширения.",
"ui.emptyFiltered": "Нет материалов по текущим фильтрам.",
"ui.untitledCapture": "Материал без названия",
"ui.url": "URL",
"ui.browser": "Браузер",
"ui.assignBeforeCreation": "Назначьте Дело перед созданием заметки, ссылки или файла.",
"ui.markProcessed": "Отметить обработанным",
"ui.markUnprocessed": "Отметить необработанным",
"ui.openLink": "Открыть ссылку",
"ui.restore": "Вернуть во входящие",
"ui.archive": "Архивировать",
"ui.deletePermanently": "Удалить навсегда",
"ui.captureReceived": "Материал получен",
"ui.captureLoadError": "Не удалось загрузить полученный материал.",
"ui.inboxArchived": "Входящие архивированы",
"ui.workspaceCapturesArchived": "Материалы Дела архивированы",
"ui.assignedToWorkspace": "Материал назначен Делу «{workspace}»",
"ui.captureUnassigned": "Материал не назначен",
"ui.captureArchived": "Материал архивирован",
"ui.captureRestored": "Материал возвращён во входящие",
"ui.captureDeleted": "Материал удалён навсегда",
"ui.captureProcessed": "Материал отмечен обработанным",
"ui.captureUnprocessed": "Материал отмечен необработанным",
"ui.creatingNote": "Создание заметки...",
"ui.noteCreated": "Заметка создана: {path}",
"ui.creatingLink": "Создание ссылки...",
"ui.linkCreated": "Ссылка создана",
"ui.creatingFile": "Создание файла...",
"ui.fileCreated": "Файл создан: {path}",
"ui.receiverReadyAll": "Приёмник готов для всех Дел",
"ui.receiverReadyWorkspace": "Приёмник готов для этого Дела",
"ui.items.one": "{count} материал",
"ui.items.few": "{count} материала",
"ui.items.many": "{count} материалов",
"ui.items.other": "{count} материала",
"ui.items.filtered": "{visible} из {total}"
}
+62
View File
@@ -0,0 +1,62 @@
{
"schemaVersion": 1,
"id": "verstak.browser-inbox",
"name": "Browser",
"version": "0.1.0",
"apiVersion": "0.1.0",
"description": "Global browser materials with explicit Deal assignment delivered through the local receiver event protocol.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official",
"icon": "inbox",
"provides": [
"capture.browser",
"browser.inbox",
"domain.binding"
],
"permissions": [
"events.subscribe",
"events.publish",
"browser.receiver.manage",
"files.read",
"files.write",
"storage.namespace",
"ui.register"
],
"frontend": {
"entry": "frontend/src/index.js"
},
"contributes": {
"views": [
{
"id": "verstak.browser-inbox.view",
"title": "Browser",
"icon": "inbox",
"component": "BrowserInboxView"
}
],
"sidebarItems": [
{
"id": "verstak.browser-inbox.sidebar",
"title": "Browser",
"icon": "inbox",
"view": "verstak.browser-inbox.view",
"position": 30
}
],
"workspaceItems": [
{
"id": "verstak.browser-inbox.workspace",
"title": "Browser",
"icon": "inbox",
"component": "BrowserInboxView"
}
],
"settingsPanels": [
{
"id": "verstak.browser-inbox.settings",
"title": "Browser",
"component": "BrowserInboxSettings"
}
]
}
}
+490 -304
View File
@@ -6,7 +6,6 @@
(function () {
'use strict';
/* ── Style injection ─────────────────────────────────────── */
function injectStyles() {
if (document.getElementById('de-style-injected')) return;
var style = document.createElement('style');
@@ -16,114 +15,39 @@
}
var STYLES = [
'.de-root { display:flex; flex-direction:column; height:100%; min-height:0; overflow:hidden; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif; color:#e0e0e0; }',
'.de-toolbar { display:flex; align-items:center; gap:0.5rem; padding:0.5rem 0.75rem; border-bottom:1px solid #16213e; flex-shrink:0; background:#12122a; }',
'.de-toolbar-mode { font-size:0.75rem; color:#4ecca3; padding:0.15rem 0.5rem; border-radius:3px; background:#1a2a3a; }',
'.de-toolbar-context { font-size:0.7rem; color:#8b8ba8; margin-left:0.25rem; }',
'.de-toolbar-spacer { flex:1; }',
'.de-toolbar-btn { font-size:0.75rem; padding:0.25rem 0.6rem; border:1px solid #333; border-radius:4px; background:#1a1a2e; color:#ccc; cursor:pointer; }',
'.de-toolbar-btn:hover { background:#2a2a4e; border-color:#4ecca3; }',
'.de-toolbar-btn.active { background:#1a3a2a; border-color:#4ecca3; color:#4ecca3; }',
'.de-toolbar-btn:disabled { opacity:0.4; cursor:default; }',
'.de-status { font-size:0.7rem; color:#8b8ba8; padding:0.15rem 0.5rem; }',
'.de-status.saved { color:#4ecca3; }',
'.de-status.error { color:#e74c3c; }',
'.de-status.dirty { color:#f39c12; }',
'.de-editor-wrap { flex:1; display:flex; min-height:0; overflow:hidden; }',
'.de-textarea { flex:1; width:100%; height:100%; resize:none; border:none; outline:none; padding:0.75rem; font-family:"SF Mono","Fira Code","Cascadia Code",monospace; font-size:0.85rem; line-height:1.6; background:#0d0d1a; color:#e0e0e0; tab-size:2; }',
'.de-textarea:focus { outline:none; }',
'.de-preview { flex:1; height:100%; padding:0.75rem 1rem; overflow-y:auto; background:#0d0d1a; line-height:1.7; font-size:0.9rem; }',
'.de-preview h1,.de-preview h2,.de-preview h3,.de-preview h4,.de-preview h5,.de-preview h6 { color:#e0e0f0; margin:1rem 0 0.5rem; }',
'.de-preview h1 { font-size:1.5rem; border-bottom:1px solid #16213e; padding-bottom:0.3rem; }',
'.de-preview h2 { font-size:1.25rem; border-bottom:1px solid #16213e; padding-bottom:0.25rem; }',
'.de-preview h3 { font-size:1.1rem; }',
'.de-preview p { margin:0.5rem 0; }',
'.de-preview code { background:#1a1a2e; padding:0.15rem 0.35rem; border-radius:3px; font-size:0.85em; color:#4ecca3; }',
'.de-preview pre { background:#1a1a2e; padding:0.75rem; border-radius:4px; overflow-x:auto; margin:0.75rem 0; }',
'.de-preview pre code { background:none; padding:0; }',
'.de-preview ul,.de-preview ol { padding-left:1.5rem; margin:0.5rem 0; }',
'.de-preview li { margin:0.25rem 0; }',
'.de-preview blockquote { border-left:3px solid #4ecca3; margin:0.5rem 0; padding:0.25rem 0.75rem; color:#aaa; }',
'.de-preview a { color:#4ecca3; text-decoration:none; }',
'.de-preview a:hover { text-decoration:underline; }',
'.de-preview hr { border:none; border-top:1px solid #16213e; margin:1rem 0; }',
'.de-preview table { border-collapse:collapse; margin:0.75rem 0; }',
'.de-preview th,.de-preview td { border:1px solid #333; padding:0.35rem 0.6rem; text-align:left; }',
'.de-preview th { background:#1a1a2e; }',
'.de-notes-badge { font-size:0.65rem; padding:0.1rem 0.4rem; border-radius:3px; background:#2a1a3a; color:#b388ff; margin-left:0.5rem; }',
'.de-notes-info { padding:0.5rem 0.75rem; background:#1a1a2a; border-top:1px solid #16213e; font-size:0.75rem; color:#8b8ba8; flex-shrink:0; }',
'.de-loading { flex:1; display:flex; align-items:center; justify-content:center; color:#666; }',
'.de-error { flex:1; display:flex; flex-direction:column; align-items:center; justify-content:center; color:#e74c3c; gap:0.5rem; }',
'.de-error-msg { font-size:0.85rem; color:#aaa; max-width:400px; text-align:center; }',
'.de-root{display:flex;flex-direction:column;height:100%;min-height:0;overflow:hidden;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;color:#e0e0e0;background:#0d0d1a}',
'.de-toolbar,.de-md-toolbar{display:flex;align-items:center;gap:.45rem;padding:.45rem .75rem;border-bottom:1px solid #16213e;flex-shrink:0;background:#12122a;flex-wrap:wrap}',
'.de-md-toolbar{background:#101028;padding:.38rem .75rem}',
'.de-toolbar-context{font-size:.75rem;color:#a0a0bb;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
'.de-toolbar-spacer{flex:1}',
'.de-toolbar-btn,.de-md-btn{font-size:.75rem;padding:.28rem .58rem;border:1px solid #333;border-radius:4px;background:#1a1a2e;color:#ccc;cursor:pointer}',
'.de-md-btn{min-width:2rem;font-family:inherit}',
'.de-toolbar-btn:hover,.de-md-btn:hover{background:#2a2a4e;border-color:#4ecca3}',
'.de-toolbar-btn.active{background:#1a3a2a;border-color:#4ecca3;color:#4ecca3}',
'.de-toolbar-btn:disabled,.de-md-btn:disabled{opacity:.45;cursor:default}',
'.de-status{font-size:.72rem;color:#8b8ba8;padding:.15rem .5rem;white-space:nowrap}',
'.de-status.saved{color:#4ecca3}.de-status.error{color:#e74c3c}.de-status.dirty{color:#f39c12}.de-status.saving{color:#79c0ff}',
'.de-editor-wrap{flex:1;display:flex;min-height:0;overflow:hidden;background:#0d0d1a}',
'.de-pane{flex:1;min-width:0;min-height:0;display:flex;overflow:hidden}',
'.de-pane+.de-pane{border-left:1px solid #16213e}',
'.de-editor-shell{flex:1;display:flex;min-width:0;min-height:0;overflow:hidden;background:#0d0d1a}',
'.de-lines{flex:0 0 auto;min-width:3rem;padding:.75rem .45rem;text-align:right;background:#0a0a15;color:#555;font-family:"SF Mono","Fira Code","Cascadia Code",Consolas,monospace;font-size:.82rem;line-height:1.6;user-select:none;overflow:hidden;white-space:pre}',
'.de-textarea{flex:1;width:100%;height:100%;resize:none;border:0;outline:0;padding:.75rem;font-family:"SF Mono","Fira Code","Cascadia Code",Consolas,monospace;font-size:.86rem;line-height:1.6;background:#0d0d1a;color:#e0e0e0;tab-size:2;white-space:pre;overflow:auto}',
'.de-preview{flex:1;height:100%;padding:1rem 1.15rem;overflow:auto;background:#0d0d1a;line-height:1.7;font-size:.92rem;color:#d8d8e8}',
'.de-preview h1,.de-preview h2,.de-preview h3,.de-preview h4,.de-preview h5,.de-preview h6{color:#f0f0ff;margin:1rem 0 .5rem}',
'.de-preview h1{font-size:1.55rem;border-bottom:1px solid #16213e;padding-bottom:.35rem}.de-preview h2{font-size:1.3rem;border-bottom:1px solid #16213e;padding-bottom:.25rem}.de-preview h3{font-size:1.12rem}',
'.de-preview p{margin:.55rem 0}.de-preview code{background:#1a1a2e;padding:.15rem .35rem;border-radius:3px;font-size:.87em;color:#4ecca3}',
'.de-preview pre{background:#1a1a2e;padding:.85rem;border-radius:4px;overflow:auto;margin:.8rem 0}.de-preview pre code{background:none;padding:0;color:#d8d8e8}',
'.de-preview ul,.de-preview ol{padding-left:1.5rem;margin:.55rem 0}.de-preview li{margin:.25rem 0}',
'.de-preview blockquote{border-left:3px solid #4ecca3;margin:.6rem 0;padding:.25rem .85rem;color:#aaa;background:#101028}',
'.de-preview a{color:#4ecca3;text-decoration:none}.de-preview a:hover{text-decoration:underline}',
'.de-preview table{border-collapse:collapse;margin:.8rem 0;max-width:100%;display:block;overflow:auto}.de-preview th,.de-preview td{border:1px solid #333;padding:.35rem .6rem;text-align:left}.de-preview th{background:#1a1a2e}',
'.de-preview img{max-width:100%;height:auto;border-radius:4px}.de-preview .task{margin-right:.4rem}',
'.de-notes-badge{font-size:.65rem;padding:.1rem .4rem;border-radius:3px;background:#2a1a3a;color:#b388ff}',
'.de-loading,.de-error{flex:1;display:flex;align-items:center;justify-content:center;color:#777;padding:2rem}.de-error{color:#e74c3c;flex-direction:column;gap:.5rem}.de-error-msg{font-size:.85rem;color:#aaa;max-width:420px;text-align:center}',
'@media(max-width:780px){.de-editor-wrap{flex-direction:column}.de-pane+.de-pane{border-left:0;border-top:1px solid #16213e}.de-toolbar-context{max-width:100%}}'
].join('\n');
/* ── Simple markdown renderer (no raw HTML) ──────────────── */
function renderMarkdown(text) {
if (!text) return '';
var html = text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
// Code blocks (``` ... ```)
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, function (_, lang, code) {
return '<pre><code class="lang-' + (lang || 'text') + '">' + code.trimEnd() + '</code></pre>';
});
// Inline code
html = html.replace(/`([^`\n]+)`/g, '<code>$1</code>');
// Headings
html = html.replace(/^######\s+(.+)$/gm, '<h6>$1</h6>');
html = html.replace(/^#####\s+(.+)$/gm, '<h5>$1</h5>');
html = html.replace(/^####\s+(.+)$/gm, '<h4>$1</h4>');
html = html.replace(/^###\s+(.+)$/gm, '<h3>$1</h3>');
html = html.replace(/^##\s+(.+)$/gm, '<h2>$1</h2>');
html = html.replace(/^#\s+(.+)$/gm, '<h1>$1</h1>');
// Horizontal rule
html = html.replace(/^---+$/gm, '<hr>');
html = html.replace(/^\*\*\*+$/gm, '<hr>');
// Bold and italic
html = html.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
// Strikethrough
html = html.replace(/~~(.+?)~~/g, '<del>$1</del>');
// Blockquote
html = html.replace(/^&gt;\s+(.+)$/gm, '<blockquote>$1</blockquote>');
// Unordered list items
html = html.replace(/^[\-\*]\s+(.+)$/gm, '<li>$1</li>');
// Ordered list items
html = html.replace(/^\d+\.\s+(.+)$/gm, '<li>$1</li>');
// Wrap consecutive <li> in <ul>
html = html.replace(/((?:<li>.*<\/li>\n?)+)/g, '<ul>$1</ul>');
// Links [text](url) — render as text since we can't navigate internally
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a title="Link: $2">$1</a>');
// Images ![alt](src) — render as placeholder
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<span title="Image: $2" style="color:#666">[image: $1]</span>');
// Paragraphs: double newlines
html = html.replace(/\n\n+/g, '</p><p>');
html = '<p>' + html + '</p>';
// Clean up empty paragraphs
html = html.replace(/<p>\s*<\/p>/g, '');
html = html.replace(/<p>\s*(<(?:h[1-6]|ul|ol|pre|blockquote|hr))/g, '$1');
html = html.replace(/(<\/(?:h[1-6]|ul|ol|pre|blockquote|hr)>)\s*<\/p>/g, '$1');
return html;
}
/* ── Utilities ──────────────────────────────────────────── */
function el(tag, attrs, children) {
var elem = document.createElement(tag);
if (attrs) {
@@ -132,6 +56,7 @@
else if (k === 'style' && typeof attrs[k] === 'object') Object.assign(elem.style, attrs[k]);
else if (k.slice(0, 2) === 'on') elem.addEventListener(k.slice(2).toLowerCase(), attrs[k]);
else if (k === 'innerHTML') elem.innerHTML = attrs[k];
else if (k === 'textContent') elem.textContent = attrs[k];
else elem.setAttribute(k, attrs[k]);
});
}
@@ -144,6 +69,165 @@
return elem;
}
function escapeHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function escapeAttr(s) {
return escapeHtml(s).replace(/"/g, '&quot;');
}
function cleanPath(path) {
return String(path || '').split('/').filter(Boolean).join('/');
}
function normalizeNoteFilename(title) {
var value = String(title == null ? '' : title).trim();
if (/\.markdown$/i.test(value) && value.length > 9) value = value.slice(0, -9);
else if (/\.md$/i.test(value) && value.length > 3) value = value.slice(0, -3);
if (!value) throw new Error('note title must not be empty');
value = value.replace(/\s+/g, '_');
value = value.replace(/[\u2012\u2013\u2014\u2015\u2212]/g, '-');
value = value.replace(/[<>:"/\\|?*\x00-\x1f\x7f]/g, '');
var out = '';
for (var i = 0; i < value.length; i++) {
var ch = value.charAt(i);
if (/[A-Za-z0-9._-]/.test(ch) || /[\p{L}\p{N}]/u.test(ch)) out += ch;
else if (/\S/.test(ch)) out += '_';
}
out = out.replace(/[_.-]+/g, '_').replace(/^[._\-\s]+|[._\-\s]+$/g, '');
if (!out) throw new Error('note title normalizes to an empty filename');
return out + '.md';
}
function renderInline(text, isNotesContext, secretLinksAvailable) {
var html = escapeHtml(text);
// Internal wiki links [[Title]] — only render in notes context
if (isNotesContext) {
html = html.replace(/\[\[([^\]]+)\]\]/g, '<a href="#" class="internal-link" data-note-link="$1">$1</a>');
}
if (secretLinksAvailable) {
html = html.replace(/\[([^\]]+)\]\(verstak-secret:\/\/([^)]+)\)/g, '<a href="#" class="secret-link" data-secret-id="$2">$1</a>');
}
html = html.replace(/`([^`\n]+)`/g, '<code>$1</code>');
html = html.replace(/!\[([^\]]*)\]\((https?:\/\/[^)]+)\)/g, '<img alt="$1" src="$2">');
html = html.replace(/\[([^\]]+)\]\((https?:\/\/[^)]+|mailto:[^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
html = html.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
return html;
}
function renderMarkdown(text, isNotesContext, secretLinksAvailable) {
var lines = String(text || '').split(/\r?\n/);
var out = [];
var inCode = false;
var codeLang = '';
var code = [];
var listType = '';
var table = [];
function closeList() {
if (listType) {
out.push('</' + listType + '>');
listType = '';
}
}
function closeTable() {
if (!table.length) return;
out.push('<table><tbody>' + table.map(function (row) {
return '<tr>' + row.map(function (cell) { return '<td>' + renderInline(cell.trim(), isNotesContext, secretLinksAvailable) + '</td>'; }).join('') + '</tr>';
}).join('') + '</tbody></table>');
table = [];
}
function pushParagraph(line) {
closeList();
closeTable();
if (line.trim()) out.push('<p>' + renderInline(line, isNotesContext, secretLinksAvailable) + '</p>');
}
lines.forEach(function (line) {
var fence = line.match(/^```(\w*)\s*$/);
if (fence) {
if (inCode) {
out.push('<pre><code class="language-' + escapeAttr(codeLang || 'text') + '">' + escapeHtml(code.join('\n')) + '</code></pre>');
inCode = false;
code = [];
codeLang = '';
} else {
closeList();
closeTable();
inCode = true;
codeLang = fence[1] || 'text';
}
return;
}
if (inCode) {
code.push(line);
return;
}
if (!line.trim()) {
closeList();
closeTable();
return;
}
var heading = line.match(/^(#{1,6})\s+(.+)$/);
if (heading) {
closeList();
closeTable();
out.push('<h' + heading[1].length + '>' + renderInline(heading[2], isNotesContext, secretLinksAvailable) + '</h' + heading[1].length + '>');
return;
}
if (/^\|.+\|$/.test(line) && !/^\|\s*-+/.test(line)) {
closeList();
table.push(line.replace(/^\||\|$/g, '').split('|'));
return;
}
if (/^\|\s*-+/.test(line)) return;
var quote = line.match(/^>\s+(.+)$/);
if (quote) {
closeList();
closeTable();
out.push('<blockquote>' + renderInline(quote[1], isNotesContext, secretLinksAvailable) + '</blockquote>');
return;
}
var task = line.match(/^[-*]\s+\[([ xX])\]\s+(.+)$/);
var unordered = line.match(/^[-*]\s+(.+)$/);
var ordered = line.match(/^\d+\.\s+(.+)$/);
if (task || unordered || ordered) {
closeTable();
var desired = ordered ? 'ol' : 'ul';
if (listType !== desired) {
closeList();
out.push('<' + desired + '>');
listType = desired;
}
if (task) {
out.push('<li><input class="task" type="checkbox" disabled ' + (task[1].toLowerCase() === 'x' ? 'checked' : '') + '> ' + renderInline(task[2], isNotesContext, secretLinksAvailable) + '</li>');
} else {
out.push('<li>' + renderInline((ordered || unordered)[1], isNotesContext, secretLinksAvailable) + '</li>');
}
return;
}
pushParagraph(line);
});
if (inCode) out.push('<pre><code>' + escapeHtml(code.join('\n')) + '</code></pre>');
closeList();
closeTable();
return out.join('\n');
}
function detectMode(props) {
var ctx = props.request && props.request.context;
if (ctx && (ctx.notesMode || ctx.isInsideNotesFolder)) return 'notes-markdown';
@@ -152,19 +236,39 @@
return 'text';
}
function detectContextLabel(mode) {
if (mode === 'notes-markdown') return 'notes';
if (mode === 'generic-markdown') return 'markdown';
return 'text';
}
function fileName(path) {
if (!path) return '';
var parts = path.split('/');
return parts[parts.length - 1];
var parts = String(path || '').split('/');
return parts[parts.length - 1] || '';
}
function insertAround(textarea, before, after, placeholder) {
var start = textarea.selectionStart;
var end = textarea.selectionEnd;
var value = textarea.value;
var selected = value.slice(start, end) || placeholder || '';
textarea.value = value.slice(0, start) + before + selected + after + value.slice(end);
textarea.selectionStart = start + before.length;
textarea.selectionEnd = start + before.length + selected.length;
textarea.dispatchEvent(new Event('input', { bubbles: true }));
textarea.focus();
}
function prefixLines(textarea, prefix, placeholder) {
var start = textarea.selectionStart;
var end = textarea.selectionEnd;
var value = textarea.value;
var lineStart = value.lastIndexOf('\n', Math.max(0, start - 1)) + 1;
var lineEnd = value.indexOf('\n', end);
if (lineEnd === -1) lineEnd = value.length;
var selected = value.slice(lineStart, lineEnd) || placeholder || '';
var replacement = selected.split('\n').map(function (line) { return prefix + line; }).join('\n');
textarea.value = value.slice(0, lineStart) + replacement + value.slice(lineEnd);
textarea.selectionStart = lineStart;
textarea.selectionEnd = lineStart + replacement.length;
textarea.dispatchEvent(new Event('input', { bubbles: true }));
textarea.focus();
}
/* ── DefaultEditor component ─────────────────────────────── */
var DefaultEditor = {
mount: function (containerEl, props, api) {
injectStyles();
@@ -173,145 +277,195 @@
var request = props.request || {};
var resourcePath = request.path || '';
var mode = request.mode || 'view';
var requestedMode = request.mode || 'view';
var editorMode = detectMode(props);
var isMarkdown = editorMode === 'generic-markdown' || editorMode === 'notes-markdown';
var previewVisible = isMarkdown && mode === 'view';
var viewMode = isMarkdown ? (requestedMode === 'edit' ? 'edit' : 'preview') : 'edit';
var currentContent = '';
var savedContent = '';
var dirty = false;
var saveState = ''; // '' | 'saved' | 'error'
var saveState = '';
var lastSavedAt = '';
var saveTimer = null;
var disposed = false;
// ── Toolbar ──────────────────────────────────────────
var modeLabel = el('span', { className: 'de-toolbar-mode' }, [editorMode]);
var contextLabel = el('span', { className: 'de-toolbar-context' }, [fileName(resourcePath)]);
var notesBadge = null;
if (editorMode === 'notes-markdown') {
notesBadge = el('span', { className: 'de-notes-badge' }, ['notes context']);
var textarea = null;
var linesEl = null;
var previewEl = null;
var secretLinksAvailable = false;
function tr(key, params, fallback) {
if (api && api.i18n && typeof api.i18n.t === 'function') return api.i18n.t(key, params, fallback);
return fallback || key;
}
containerEl.setAttribute('data-editor-mode', editorMode);
containerEl.setAttribute('data-resource-path', resourcePath);
containerEl.setAttribute('data-request-mode', requestedMode);
var contextLabel = el('span', { className: 'de-toolbar-context', title: resourcePath }, [resourcePath || fileName(resourcePath)]);
var notesBadge = editorMode === 'notes-markdown' ? el('span', { className: 'de-notes-badge', 'data-notes-badge': '' }, [tr('ui.note', null, 'Note')]) : null;
var spacer = el('span', { className: 'de-toolbar-spacer' });
var editBtn = null;
var previewBtn = null;
var saveBtn = el('button', { className: 'de-toolbar-btn' }, ['Save']);
saveBtn.disabled = true;
if (isMarkdown) {
editBtn = el('button', { className: 'de-toolbar-btn' + (mode === 'edit' ? ' active' : '') }, ['Edit']);
previewBtn = el('button', { className: 'de-toolbar-btn' + (previewVisible ? ' active' : '') }, ['Preview']);
if (mode === 'edit') {
previewBtn.classList.remove('active');
}
}
var statusEl = el('span', { className: 'de-status' });
var toolbarChildren = [modeLabel, contextLabel];
var editBtn = isMarkdown ? el('button', { className: 'de-toolbar-btn', 'data-editor-mode-button': 'edit' }, [tr('ui.edit', null, 'Edit')]) : null;
var previewBtn = isMarkdown ? el('button', { className: 'de-toolbar-btn', 'data-editor-mode-button': 'preview' }, [tr('ui.preview', null, 'Preview')]) : null;
var splitBtn = isMarkdown ? el('button', { className: 'de-toolbar-btn', 'data-editor-mode-button': 'split' }, [tr('ui.split', null, 'Split')]) : null;
var reloadBtn = el('button', { className: 'de-toolbar-btn', 'data-editor-action': 'reload' }, [tr('ui.reload', null, 'Reload')]);
var saveBtn = el('button', { className: 'de-toolbar-btn', 'data-editor-action': 'save' }, [tr('ui.save', null, 'Save')]);
var statusEl = el('span', { className: 'de-status', 'data-save-state': '' });
var toolbarChildren = [contextLabel];
if (notesBadge) toolbarChildren.push(notesBadge);
toolbarChildren.push(spacer);
if (editBtn) toolbarChildren.push(editBtn);
if (previewBtn) toolbarChildren.push(previewBtn);
toolbarChildren.push(saveBtn);
toolbarChildren.push(statusEl);
[editBtn, previewBtn, splitBtn, reloadBtn, saveBtn, statusEl].forEach(function (node) { if (node) toolbarChildren.push(node); });
containerEl.appendChild(el('div', { className: 'de-toolbar' }, toolbarChildren));
var toolbar = el('div', { className: 'de-toolbar' }, toolbarChildren);
containerEl.appendChild(toolbar);
var mdToolbar = null;
if (isMarkdown) {
mdToolbar = el('div', { className: 'de-md-toolbar' });
[
['heading', 'H', 'ui.md.heading', 'Heading'],
['bold', 'B', 'ui.md.bold', 'Bold'],
['italic', 'I', 'ui.md.italic', 'Italic'],
['link', 'Link', 'ui.md.link', 'Link'],
['code', 'Code', 'ui.md.inlineCode', 'Inline code'],
['code-block', '```', 'ui.md.codeBlock', 'Code block'],
['bullet', '• List', 'ui.md.bulletList', 'Bullet list'],
['numbered', '1. List', 'ui.md.numberedList', 'Numbered list'],
['quote', 'Quote', 'ui.md.quote', 'Quote'],
['task', 'Task', 'ui.md.taskItem', 'Task item']
].forEach(function (item) {
mdToolbar.appendChild(el('button', { className: 'de-md-btn', type: 'button', 'data-md-action': item[0], title: tr(item[2], null, item[3]), 'aria-label': tr(item[2], null, item[3]) }, [item[1]]));
});
containerEl.appendChild(mdToolbar);
}
// ── Editor area ──────────────────────────────────────
var editorWrap = el('div', { className: 'de-editor-wrap' });
containerEl.appendChild(editorWrap);
var textarea = null;
var previewEl = null;
if (mode === 'edit' || !isMarkdown) {
textarea = el('textarea', { className: 'de-textarea', spellcheck: 'false' });
textarea.value = '';
editorWrap.appendChild(textarea);
textarea.addEventListener('input', function () {
currentContent = textarea.value;
dirty = currentContent !== savedContent;
updateStatus();
});
textarea.addEventListener('keydown', function (e) {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
save();
}
// Tab support
if (e.key === 'Tab') {
e.preventDefault();
var start = textarea.selectionStart;
var end = textarea.selectionEnd;
textarea.value = textarea.value.substring(0, start) + ' ' + textarea.value.substring(end);
textarea.selectionStart = textarea.selectionEnd = start + 2;
textarea.dispatchEvent(new Event('input'));
}
});
function updateLineNumbers() {
if (!linesEl || !textarea) return;
var count = textarea.value.split('\n').length;
var numbers = [];
for (var i = 1; i <= count; i += 1) numbers.push(String(i));
linesEl.textContent = numbers.join('\n');
}
if (isMarkdown && previewVisible) {
previewEl = el('div', { className: 'de-preview' });
editorWrap.appendChild(previewEl);
}
// ── Notes info bar ───────────────────────────────────
if (editorMode === 'notes-markdown') {
var notesInfo = el('div', { className: 'de-notes-info' }, [
'Notes context active — internal links, backlinks, and widgets deferred.'
]);
containerEl.appendChild(notesInfo);
}
// ── Status helpers ───────────────────────────────────
function updateStatus() {
if (saveState === 'error') {
statusEl.textContent = 'Error saving';
if (saveState === 'saving') {
statusEl.textContent = tr('ui.saving', null, 'Saving...');
statusEl.className = 'de-status saving';
} else if (saveState === 'error') {
statusEl.textContent = tr('ui.saveError', null, 'Error saving');
statusEl.className = 'de-status error';
} else if (saveState === 'saved') {
statusEl.textContent = 'Saved';
statusEl.className = 'de-status saved';
} else if (dirty) {
statusEl.textContent = 'Modified';
statusEl.textContent = tr('ui.modified', null, 'Modified');
statusEl.className = 'de-status dirty';
} else if (lastSavedAt) {
statusEl.textContent = saveState === 'saved' ? tr('ui.savedAt', { time: lastSavedAt }, 'Saved ' + lastSavedAt) : tr('ui.saved', null, 'Saved');
statusEl.className = 'de-status saved';
} else {
statusEl.textContent = '';
statusEl.className = 'de-status';
}
saveBtn.disabled = !dirty;
saveBtn.disabled = !dirty || saveState === 'saving';
}
function updatePreview() {
if (previewEl) {
previewEl.innerHTML = renderMarkdown(currentContent);
}
if (previewEl) previewEl.innerHTML = isMarkdown ? renderMarkdown(currentContent, editorMode === 'notes-markdown', secretLinksAvailable) : '<pre>' + escapeHtml(currentContent) + '</pre>';
}
// ── Save ─────────────────────────────────────────────
function save() {
if (!dirty || disposed) return;
function loadSecretProviderAvailability() {
if (!isMarkdown || !api.contributions || typeof api.contributions.list !== 'function') return;
api.contributions.list('openProviders').then(function (providers) {
if (disposed) return;
secretLinksAvailable = (providers || []).some(function (provider) {
return (provider.supports || []).some(function (support) {
return support.kind === 'secret';
});
});
updatePreview();
}).catch(function () {
secretLinksAvailable = false;
updatePreview();
});
}
function syncFromTextarea() {
if (!textarea) return;
currentContent = textarea.value;
dirty = currentContent !== savedContent;
saveState = '';
updateLineNumbers();
updateStatus();
api.files.writeText(resourcePath, currentContent, {
createIfMissing: false,
overwrite: true
}).then(function () {
updatePreview();
}
function makeEditorPane() {
var pane = el('div', { className: 'de-pane' });
var shell = el('div', { className: 'de-editor-shell' });
linesEl = el('div', { className: 'de-lines' });
textarea = el('textarea', { className: 'de-textarea', spellcheck: 'false', 'data-editor-textarea': '' });
textarea.value = currentContent;
textarea.addEventListener('input', syncFromTextarea);
textarea.addEventListener('scroll', function () { if (linesEl) linesEl.scrollTop = textarea.scrollTop; });
textarea.addEventListener('keydown', function (event) {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
event.preventDefault();
save();
}
if (event.key === 'Tab') {
event.preventDefault();
insertAround(textarea, ' ', '', '');
}
});
shell.appendChild(linesEl);
shell.appendChild(textarea);
pane.appendChild(shell);
updateLineNumbers();
return pane;
}
function makePreviewPane() {
var pane = el('div', { className: 'de-pane' });
previewEl = el('div', { className: 'de-preview', 'data-preview': '' });
pane.appendChild(previewEl);
updatePreview();
return pane;
}
function rebuildEditorArea() {
editorWrap.innerHTML = '';
textarea = null;
linesEl = null;
previewEl = null;
if (!isMarkdown || viewMode === 'edit') editorWrap.appendChild(makeEditorPane());
if (isMarkdown && viewMode === 'preview') editorWrap.appendChild(makePreviewPane());
if (isMarkdown && viewMode === 'split') {
editorWrap.appendChild(makeEditorPane());
editorWrap.appendChild(makePreviewPane());
}
if (editBtn) editBtn.className = 'de-toolbar-btn' + (viewMode === 'edit' ? ' active' : '');
if (previewBtn) previewBtn.className = 'de-toolbar-btn' + (viewMode === 'preview' ? ' active' : '');
if (splitBtn) splitBtn.className = 'de-toolbar-btn' + (viewMode === 'split' ? ' active' : '');
updateStatus();
}
function save() {
if (!dirty || disposed) return Promise.resolve();
saveState = 'saving';
updateStatus();
var savePromise = api.files.writeText(resourcePath, currentContent, { createIfMissing: false, overwrite: true });
return savePromise.then(function () {
if (disposed) return;
savedContent = currentContent;
dirty = false;
saveState = 'saved';
lastSavedAt = new Date().toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
updateStatus();
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(function () {
if (!disposed) { saveState = ''; updateStatus(); }
}, 2000);
if (!disposed) {
saveState = '';
updateStatus();
}
}, 2500);
}).catch(function (err) {
if (disposed) return;
saveState = 'error';
@@ -320,105 +474,141 @@
});
}
// ── Toolbar events ───────────────────────────────────
saveBtn.addEventListener('click', save);
if (editBtn) {
editBtn.addEventListener('click', function () {
if (mode === 'edit') return;
mode = 'edit';
previewVisible = false;
rebuildEditorArea();
});
}
if (previewBtn) {
previewBtn.addEventListener('click', function () {
if (previewVisible) return;
previewVisible = true;
mode = isMarkdown ? 'view' : mode;
rebuildEditorArea();
});
}
function rebuildEditorArea() {
function reloadFromDisk() {
if (dirty && !window.confirm(tr('ui.discardConfirm', null, 'Discard unsaved changes and reload from disk?'))) return;
editorWrap.innerHTML = '';
textarea = null;
previewEl = null;
if (mode === 'edit' || !isMarkdown) {
textarea = el('textarea', { className: 'de-textarea', spellcheck: 'false' });
textarea.value = currentContent;
editorWrap.appendChild(textarea);
textarea.addEventListener('input', function () {
currentContent = textarea.value;
dirty = currentContent !== savedContent;
updateStatus();
});
textarea.addEventListener('keydown', function (e) {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
save();
}
if (e.key === 'Tab') {
e.preventDefault();
var start = textarea.selectionStart;
var end = textarea.selectionEnd;
textarea.value = textarea.value.substring(0, start) + ' ' + textarea.value.substring(end);
textarea.selectionStart = textarea.selectionEnd = start + 2;
textarea.dispatchEvent(new Event('input'));
}
});
}
if (isMarkdown && previewVisible) {
previewEl = el('div', { className: 'de-preview' });
editorWrap.appendChild(previewEl);
updatePreview();
}
// Update toolbar button states
if (editBtn) {
editBtn.className = 'de-toolbar-btn' + (mode === 'edit' ? ' active' : '');
}
if (previewBtn) {
previewBtn.className = 'de-toolbar-btn' + (previewVisible ? ' active' : '');
}
updateStatus();
}
// ── Load file ────────────────────────────────────────
editorWrap.appendChild(el('div', { className: 'de-loading' }, ['Loading...']));
api.files.readText(resourcePath).then(function (content) {
editorWrap.appendChild(el('div', { className: 'de-loading' }, [tr('ui.loading', null, 'Loading...')]));
var readPromise = api.files.readText(resourcePath);
readPromise.then(function (content) {
if (disposed) return;
editorWrap.innerHTML = '';
currentContent = content;
savedContent = content;
currentContent = String(content == null ? '' : content);
savedContent = currentContent;
dirty = false;
saveState = '';
rebuildEditorArea();
}).catch(function (err) {
if (disposed) return;
console.warn('[default-editor] load error:', err);
editorWrap.innerHTML = '';
var msg = (err && err.message) ? err.message : String(err);
editorWrap.appendChild(el('div', { className: 'de-error' }, [
el('div', {}, ['Failed to load file']),
el('div', { className: 'de-error-msg' }, [msg])
el('div', {}, [tr('ui.loadFailed', null, 'Could not load the file. Please try again.')])
]));
});
}
function setMode(nextMode) {
if (!isMarkdown || viewMode === nextMode) return;
viewMode = nextMode;
rebuildEditorArea();
}
function applyMarkdownAction(action) {
if (!textarea && viewMode === 'preview') {
setMode('edit');
}
if (!textarea) return;
if (action === 'heading') prefixLines(textarea, '# ', '');
else if (action === 'bold') insertAround(textarea, '**', '**', 'bold text');
else if (action === 'italic') insertAround(textarea, '*', '*', 'italic text');
else if (action === 'link') insertAround(textarea, '[', '](https://)', 'link text');
else if (action === 'code') insertAround(textarea, '`', '`', 'code');
else if (action === 'code-block') insertAround(textarea, '```\n', '\n```', 'code');
else if (action === 'bullet') prefixLines(textarea, '- ', 'item');
else if (action === 'numbered') prefixLines(textarea, '1. ', 'item');
else if (action === 'quote') prefixLines(textarea, '> ', 'quote');
else if (action === 'task') prefixLines(textarea, '- [ ] ', 'task');
}
saveBtn.addEventListener('click', save);
reloadBtn.addEventListener('click', reloadFromDisk);
if (editBtn) editBtn.addEventListener('click', function () { setMode('edit'); });
if (previewBtn) previewBtn.addEventListener('click', function () { setMode('preview'); });
if (splitBtn) splitBtn.addEventListener('click', function () { setMode('split'); });
if (mdToolbar) {
mdToolbar.addEventListener('click', function (event) {
var button = event.target.closest('[data-md-action]');
if (!button) return;
applyMarkdownAction(button.getAttribute('data-md-action'));
});
}
loadSecretProviderAvailability();
reloadFromDisk();
var localeUnsubscribe = api.i18n && typeof api.i18n.onDidChangeLocale === 'function'
? api.i18n.onDidChangeLocale(function () {
if (notesBadge) notesBadge.textContent = tr('ui.note', null, 'Note');
if (editBtn) editBtn.textContent = tr('ui.edit', null, 'Edit');
if (previewBtn) previewBtn.textContent = tr('ui.preview', null, 'Preview');
if (splitBtn) splitBtn.textContent = tr('ui.split', null, 'Split');
reloadBtn.textContent = tr('ui.reload', null, 'Reload');
saveBtn.textContent = tr('ui.save', null, 'Save');
if (mdToolbar) {
[
['heading', 'ui.md.heading', 'Heading'], ['bold', 'ui.md.bold', 'Bold'], ['italic', 'ui.md.italic', 'Italic'],
['link', 'ui.md.link', 'Link'], ['code', 'ui.md.inlineCode', 'Inline code'], ['code-block', 'ui.md.codeBlock', 'Code block'],
['bullet', 'ui.md.bulletList', 'Bullet list'], ['numbered', 'ui.md.numberedList', 'Numbered list'], ['quote', 'ui.md.quote', 'Quote'], ['task', 'ui.md.taskItem', 'Task item']
].forEach(function (item) {
var button = mdToolbar.querySelector('[data-md-action="' + item[0] + '"]');
if (!button) return;
var label = tr(item[1], null, item[2]);
button.setAttribute('title', label);
button.setAttribute('aria-label', label);
});
}
updateStatus();
})
: null;
containerEl.addEventListener('click', function (event) {
var secretLink = event.target.closest('.secret-link');
if (secretLink) {
event.preventDefault();
if (!secretLinksAvailable) return;
var secretID = secretLink.getAttribute('data-secret-id');
if (!secretID) return;
api.workbench.openResource({
kind: 'secret',
path: decodeURIComponent(secretID),
mode: 'view',
context: {
sourcePluginId: 'verstak.default-editor',
sourceView: 'editor'
}
}).catch(function (err) {
console.error('[default-editor] open secret link:', err);
});
return;
}
var link = event.target.closest('.internal-link');
if (!link) return;
event.preventDefault();
var noteTitle = link.getAttribute('data-note-link');
if (!noteTitle) return;
var currentPath = cleanPath(resourcePath);
var notesIdx = currentPath.indexOf('/Notes/');
var notesRoot = notesIdx === -1 ? 'Notes' : currentPath.slice(0, notesIdx) + '/Notes';
var targetPath = cleanPath(notesRoot + '/' + normalizeNoteFilename(noteTitle));
api.workbench.openResource({
kind: 'vault-file',
path: targetPath,
mode: 'view',
extension: '.md',
context: {
sourcePluginId: 'verstak.default-editor',
sourceView: 'editor',
isInsideNotesFolder: true,
notesMode: true
}
}).catch(function (err) {
console.error('[default-editor] open internal link:', err);
});
});
// ── Cleanup ──────────────────────────────────────────
containerEl.__deCleanup = function () {
disposed = true;
if (typeof localeUnsubscribe === 'function') localeUnsubscribe();
if (saveTimer) clearTimeout(saveTimer);
};
// Set data attributes for testability
containerEl.setAttribute('data-editor-mode', editorMode);
containerEl.setAttribute('data-resource-path', resourcePath);
containerEl.setAttribute('data-request-mode', mode);
},
unmount: function (containerEl) {
@@ -430,11 +620,7 @@
}
};
/* ── Register ────────────────────────────────────────────── */
window.VerstakPluginRegister('verstak.default-editor', {
components: {
DefaultEditor: DefaultEditor
}
components: { DefaultEditor: DefaultEditor }
});
})();
+33
View File
@@ -0,0 +1,33 @@
{
"manifest.name": "Default Editor",
"manifest.description": "Built-in text and markdown editor/viewer for Verstak. Provides openProviders for generic text, generic markdown, and notes-context markdown files.",
"contributions.openProviders.verstak.default-editor.text.title": "Default Text Editor",
"contributions.openProviders.verstak.default-editor.markdown.title": "Default Markdown Editor",
"contributions.openProviders.verstak.default-editor.notes-markdown.title": "Default Notes Markdown Editor",
"ui.notesContext": "notes context",
"ui.note": "Note",
"ui.edit": "Edit",
"ui.preview": "Preview",
"ui.split": "Split",
"ui.reload": "Reload",
"ui.save": "Save",
"ui.notesInfo": "Notes context active. Note actions, backlinks, and graph tools are reserved for the future Notes plugin.",
"ui.saving": "Saving...",
"ui.saveError": "Error saving",
"ui.modified": "Modified",
"ui.savedAt": "Saved {time}",
"ui.saved": "Saved",
"ui.discardConfirm": "Discard unsaved changes and reload from disk?",
"ui.loading": "Loading...",
"ui.loadFailed": "Could not load the file. Please try again.",
"ui.md.heading": "Heading",
"ui.md.bold": "Bold",
"ui.md.italic": "Italic",
"ui.md.link": "Link",
"ui.md.inlineCode": "Inline code",
"ui.md.codeBlock": "Code block",
"ui.md.bulletList": "Bullet list",
"ui.md.numberedList": "Numbered list",
"ui.md.quote": "Quote",
"ui.md.taskItem": "Task item"
}
+33
View File
@@ -0,0 +1,33 @@
{
"manifest.name": "Стандартный редактор",
"manifest.description": "Встроенный редактор и просмотрщик текста и Markdown для Верстака.",
"contributions.openProviders.verstak.default-editor.text.title": "Стандартный текстовый редактор",
"contributions.openProviders.verstak.default-editor.markdown.title": "Стандартный редактор Markdown",
"contributions.openProviders.verstak.default-editor.notes-markdown.title": "Стандартный редактор Markdown-заметок",
"ui.notesContext": "контекст заметки",
"ui.note": "Заметка",
"ui.edit": "Редактировать",
"ui.preview": "Просмотр",
"ui.split": "Разделить",
"ui.reload": "Перезагрузить",
"ui.save": "Сохранить",
"ui.notesInfo": "Активен контекст заметки. Действия, обратные ссылки и граф будут развиваться в плагине заметок.",
"ui.saving": "Сохранение...",
"ui.saveError": "Ошибка сохранения",
"ui.modified": "Изменён",
"ui.savedAt": "Сохранено {time}",
"ui.saved": "Сохранено",
"ui.discardConfirm": "Отменить несохранённые изменения и перечитать файл с диска?",
"ui.loading": "Загрузка...",
"ui.loadFailed": "Не удалось загрузить файл. Повторите попытку.",
"ui.md.heading": "Заголовок",
"ui.md.bold": "Жирный текст",
"ui.md.italic": "Курсив",
"ui.md.link": "Ссылка",
"ui.md.inlineCode": "Встроенный код",
"ui.md.codeBlock": "Блок кода",
"ui.md.bulletList": "Маркированный список",
"ui.md.numberedList": "Нумерованный список",
"ui.md.quote": "Цитата",
"ui.md.taskItem": "Пункт задачи"
}
+2 -1
View File
@@ -5,6 +5,7 @@
"version": "0.1.0",
"apiVersion": "0.1.0",
"description": "Built-in text and markdown editor/viewer for Verstak. Provides openProviders for generic text, generic markdown, and notes-context markdown files.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official",
"icon": "edit",
"provides": [
@@ -20,7 +21,7 @@
"workbench.open"
],
"frontend": {
"entry": "frontend/dist/index.js"
"entry": "frontend/src/index.js"
},
"contributes": {
"openProviders": [
+164
View File
@@ -0,0 +1,164 @@
/* ===========================================================
File Preview Plugin — Verstak v2 Frontend Bundle
Contract: window.VerstakPluginRegister(id, { components })
=========================================================== */
(function () {
'use strict';
var IMAGE_EXTS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico'];
function injectStyles() {
if (document.getElementById('file-preview-style-injected')) return;
var style = document.createElement('style');
style.id = 'file-preview-style-injected';
style.textContent = STYLES;
document.head.appendChild(style);
}
var STYLES = [
'.fp-root{height:100%;min-height:0;display:flex;flex-direction:column;background:#0d0d1a;color:#d8d8e8;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif}',
'.fp-toolbar{display:flex;align-items:center;gap:.5rem;padding:.45rem .75rem;border-bottom:1px solid #16213e;background:#12122a;flex-shrink:0}',
'.fp-mode{font-size:.72rem;color:#4ecca3;background:#1a2a3a;border-radius:3px;padding:.14rem .45rem}',
'.fp-path{font-size:.75rem;color:#a0a0bb;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
'.fp-spacer{flex:1}',
'.fp-btn{font-size:.75rem;padding:.28rem .58rem;border:1px solid #333;border-radius:4px;background:#1a1a2e;color:#ccc;cursor:pointer}',
'.fp-btn:hover{background:#2a2a4e;border-color:#4ecca3;color:#4ecca3}',
'.fp-body{flex:1;min-height:0;overflow:auto;padding:1rem 1.2rem}',
'.fp-image-wrap{display:flex;align-items:center;justify-content:center;min-height:220px;margin:0 0 1rem;padding:1rem;background:#090914;border:1px solid #16213e;border-radius:6px}',
'.fp-image{display:block;max-width:100%;max-height:62vh;object-fit:contain}',
'.fp-meta{display:grid;grid-template-columns:max-content 1fr;gap:.45rem .8rem;max-width:760px;font-size:.86rem}',
'.fp-meta dt{color:#8b8ba8}.fp-meta dd{margin:0;color:#e0e0e0;word-break:break-word}',
'.fp-kind{margin:0 0 1rem;color:#f0f0ff;font-size:1.1rem}',
'.fp-loading,.fp-error{flex:1;display:flex;align-items:center;justify-content:center;color:#777;padding:2rem}.fp-error{color:#e74c3c;flex-direction:column;gap:.5rem}'
].join('\n');
function el(tag, attrs, children) {
var elem = document.createElement(tag);
if (attrs) {
Object.keys(attrs).forEach(function (k) {
if (k === 'className') elem.className = attrs[k];
else if (k === 'style' && typeof attrs[k] === 'object') Object.assign(elem.style, attrs[k]);
else if (k.slice(0, 2) === 'on') elem.addEventListener(k.slice(2).toLowerCase(), attrs[k]);
else if (k === 'innerHTML') elem.innerHTML = attrs[k];
else if (k === 'textContent') elem.textContent = attrs[k];
else elem.setAttribute(k, attrs[k]);
});
}
if (children) {
(Array.isArray(children) ? children : [children]).forEach(function (c) {
if (c == null) return;
elem.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
});
}
return elem;
}
function extension(path, explicit) {
if (explicit) return String(explicit).replace(/^\./, '').toLowerCase();
var name = String(path || '').split('/').pop() || '';
var idx = name.lastIndexOf('.');
return idx === -1 ? '' : name.slice(idx + 1).toLowerCase();
}
function formatSize(size) {
size = Number(size || 0);
if (size < 1024) return size + ' B';
if (size < 1024 * 1024) return (size / 1024).toFixed(1) + ' KB';
return (size / (1024 * 1024)).toFixed(1) + ' MB';
}
function imageMime(ext, hint) {
if (hint && String(hint).indexOf('image/') === 0) return String(hint);
if (ext === 'jpg') return 'image/jpeg';
if (ext === 'svg') return 'image/svg+xml';
if (IMAGE_EXTS.indexOf(ext) !== -1) return 'image/' + ext;
return 'application/octet-stream';
}
function renderImage(body, path, meta, ext, bytes) {
var src = 'data:' + imageMime(ext, bytes && bytes.mimeHint || meta && meta.mimeHint) + ';base64,' + (bytes && bytes.dataBase64 || '');
var img = el('img', {
className: 'fp-image',
src: src,
alt: path,
'data-preview-image': 'true'
});
var wrap = el('div', { className: 'fp-image-wrap' }, [img]);
body.appendChild(wrap);
}
function renderMeta(body, path, meta, ext) {
body.className = 'fp-body';
body.innerHTML = '';
body.appendChild(el('h2', { className: 'fp-kind' }, [IMAGE_EXTS.indexOf(ext) !== -1 ? 'Image Preview' : 'File Preview']));
var dl = el('dl', { className: 'fp-meta' }, [
el('dt', {}, ['Path']), el('dd', {}, [path]),
el('dt', {}, ['Type']), el('dd', {}, [meta.type || 'file']),
el('dt', {}, ['Extension']), el('dd', {}, [ext || '-']),
el('dt', {}, ['Size']), el('dd', {}, [formatSize(meta.size)]),
el('dt', {}, ['Modified']), el('dd', {}, [meta.modifiedAt || '-'])
]);
body.appendChild(dl);
}
var FilePreview = {
mount: function (containerEl, props, api) {
injectStyles();
var request = props && props.request || {};
var path = request.path || '';
var ext = extension(path, request.extension);
function tr(key, params, fallback) {
if (api && api.i18n && typeof api.i18n.t === 'function') return api.i18n.t(key, params, fallback);
return fallback || key;
}
containerEl.innerHTML = '';
containerEl.className = 'fp-root';
containerEl.setAttribute('data-plugin-id', 'verstak.file-preview');
containerEl.setAttribute('data-preview-path', path);
var openButton = el('button', {
className: 'fp-btn',
'data-action': 'open-external',
textContent: tr('ui.openExternal', null, 'Open External'),
onClick: function () {
if (api.files.openExternal) api.files.openExternal(path).catch(function (err) { console.error('[file-preview] openExternal:', err); });
}
});
containerEl.appendChild(el('div', { className: 'fp-toolbar' }, [
el('span', { className: 'fp-mode' }, [tr('ui.preview', null, 'Preview')]),
el('span', { className: 'fp-path' }, [path]),
el('span', { className: 'fp-spacer' }),
openButton
]));
var body = el('div', { className: 'fp-loading' }, [tr('ui.loading', null, 'Loading...')]);
containerEl.appendChild(body);
api.files.metadata(path).then(function (meta) {
renderMeta(body, path, meta || {}, ext);
if (IMAGE_EXTS.indexOf(ext) === -1 || !api.files.readBytes) return null;
return api.files.readBytes(path).then(function (bytes) {
if (bytes && bytes.dataBase64) renderImage(body, path, meta || {}, ext, bytes);
return null;
});
}).catch(function (err) {
console.warn('[file-preview] preview error:', err);
body.className = 'fp-error';
body.textContent = tr('ui.error', null, 'Could not preview this file. Please try again.');
});
if (api.i18n && typeof api.i18n.onDidChangeLocale === 'function') {
api.i18n.onDidChangeLocale(function () {
openButton.textContent = tr('ui.openExternal', null, 'Open External');
});
}
},
unmount: function (containerEl) {
containerEl.innerHTML = '';
}
};
window.VerstakPluginRegister('verstak.file-preview', {
components: { FilePreview: FilePreview }
});
})();
+9
View File
@@ -0,0 +1,9 @@
{
"manifest.name": "File Preview",
"manifest.description": "Read-only inline image preview provider for vault files.",
"contributions.openProviders.verstak.file-preview.image.title": "Image Preview",
"ui.openExternal": "Open External",
"ui.preview": "Preview",
"ui.loading": "Loading...",
"ui.error": "Could not preview this file. Please try again."
}
+9
View File
@@ -0,0 +1,9 @@
{
"manifest.name": "Просмотр файлов",
"manifest.description": "Встроенный просмотр изображений из хранилища без редактирования.",
"contributions.openProviders.verstak.file-preview.image.title": "Просмотр изображения",
"ui.openExternal": "Открыть во внешнем приложении",
"ui.preview": "Просмотр",
"ui.loading": "Загрузка...",
"ui.error": "Не удалось открыть предварительный просмотр файла. Повторите попытку."
}
+42
View File
@@ -0,0 +1,42 @@
{
"schemaVersion": 1,
"id": "verstak.file-preview",
"name": "File Preview",
"version": "0.1.0",
"apiVersion": "0.1.0",
"description": "Read-only inline image preview provider for vault files.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official",
"icon": "eye",
"provides": [
"verstak/file-preview/v1"
],
"requires": [
"verstak/core/files/v1",
"verstak/core/workbench/v1"
],
"permissions": [
"files.read",
"files.openExternal"
],
"frontend": {
"entry": "frontend/src/index.js"
},
"contributes": {
"openProviders": [
{
"id": "verstak.file-preview.image",
"title": "Image Preview",
"priority": 80,
"component": "FilePreview",
"supports": [
{
"kind": "vault-file",
"extensions": [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".ico"],
"modes": ["view"]
}
]
}
]
}
}
+162
View File
@@ -0,0 +1,162 @@
# План улучшений файлового менеджера Verstak2
> **Архитектура:** Плагин `verstak.files` в монорепозитории `verstak-official-plugins`.
> Код: `plugins/files/frontend/src/index.js` (vanilla JS, VerstakPluginRegister).
> API: `api.files.*`, `api.workbench.openResource()`, `api.settings`, `api.events`, `api.commands`.
## Порядок реализации
| # | Feature | Приоритет | Зависит от |
|---|---------|-----------|------------|
| 1 | Контекстное меню (правый клик) | 🔥 High | — |
| 2 | Open External / Show in Explorer | ✅ Done | `files.openExternal` |
| 3 | Кастомный ConfirmModal | 🔥 High | — |
| 4 | Duplicate | 🔥 High | — |
| 5 | Cut/Copy/Paste | 🔥 High | — |
| 6 | Множественное выделение | 🔥 High | — |
| 7 | Drag-and-Drop | 🔥 High | — |
| 8 | Клавиатурная навигация (Ctrl+A, Escape, стрелки) | Medium | #6 |
| 9 | Богатые иконки (как в v1) | Medium | — |
| 10 | История навигации | Medium | — |
| 11 | Rename с валидацией | Medium | — |
---
### Feature 1: Контекстное меню (правый клик)
**Описание:** При правом клике на файле/папке показывать меню с действиями.
**Пункты меню:** Open, Open External, Show in Explorer, Rename, Duplicate, Cut, Copy, Delete — с разделителями.
**Архитектура:**
- Событие `contextmenu` на `.files-item`
- Позиционирование у курсора (event.clientX/Y)
- Кастомный `<div>` с пунктами, закрывается по клику вне или Escape
- Класс `.files-context-menu` для стилей
**Файлы:** `plugins/files/frontend/src/index.js`
---
### Feature 2: Open External / Show in Explorer
**Описание:** Пункты меню для открытия файла во внешнем приложении и показа
файла/папки в системном файловом менеджере.
**Текущий v2 статус:** плагин использует публичные методы
`api.files.openExternal(relativePath)` и `api.files.showInFolder(relativePath)`,
guarded by `files.openExternal`. Fallback modal с vault-relative path и кнопкой
`Copy Path` показывается только если API недоступен или вернул ошибку.
**Файлы:** `plugins/files/frontend/src/index.js`
---
### Feature 3: Кастомный ConfirmModal
**Описание:** Вместо `window.confirm()` — стилизованный modal с кнопками Отмена/Удалить, опасным стилем (красная кнопка).
**Архитектура:**
- Функция `confirmModal(message, options)` возвращает Promise
- Создаёт overlay + modal в DOM
- Закрывается по Escape или кнопке Отмена
**Файлы:** `plugins/files/frontend/src/index.js`
---
### Feature 4: Duplicate
**Описание:** Копирование файла/папки в той же папке с добавлением суффикса (напр. "filename_copy.ext", "filename_2.ext").
**Архитектура:**
- Для файла: `api.files.readText()` + `api.files.writeText()` с новым именем
- Для папки: рекурсивное копирование (сложнее, можно defer)
- Пункт в контекстном меню: "Duplicate"
**Файлы:** `plugins/files/frontend/src/index.js`
---
### Feature 5: Cut/Copy/Paste
**Описание:** Буфер обмена внутри файлового менеджера. Cut → перенос. Copy → дублирование. Paste в текущую папку.
**Архитектура:**
- `clipboard: { items: [...], mode: 'cut'|'copy' }`
- Клавиши Ctrl+X/C/V
- Пункты в контекстном меню
**Файлы:** `plugins/files/frontend/src/index.js`
---
### Feature 6: Множественное выделение
**Описание:** Ctrl+click (toggle), Shift+click (range), Ctrl+A (select all). Массовые операции (Trash, Copy, Paste).
**Архитектура:**
- `selectedPaths: Set<string>` вместо `selectedPath: string`
- Ctrl+click → toggle entry в Set
- Shift+click → range select от последнего клика
- Действия применяются ко всем выделенным
**Файлы:** `plugins/files/frontend/src/index.js`
---
### Feature 7: Drag-and-Drop
**Описание:** Перемещение файлов/папок между папками через drag-and-drop.
**Архитектура:**
- HTML5 DnD API: `dragstart`, `dragover`, `drop` на `.files-item`
- При drop на папку → `api.files.move()`
- Визуальная индикация (подсветка папки при наведении)
- Отмена по Escape
**Файлы:** `plugins/files/frontend/src/index.js`
---
### Feature 8: Клавиатурная навигация
**Описание:** Escape (снять выделение), Ctrl+A (select all), стрелки (навигация по строкам).
**Зависит от:** Feature 6 (множественное выделение)
**Файлы:** `plugins/files/frontend/src/index.js`
---
### Feature 9: Богатые иконки
**Описание:** Как в v1 — отдельные SVG иконки для image, video, audio, pdf, document, spreadsheet, presentation, archive, code, text.
**Архитектура:** Расширить функцию `fileIcon()`.
**Файлы:** `plugins/files/frontend/src/index.js`
---
### Feature 10: История навигации
**Описание:** Кнопки Back/Forward в тулбаре, как в браузере. Хранение стека посещённых папок.
**Архитектура:**
- `historyStack: string[]` с индексом
- back() / forward() / push(path)
**Файлы:** `plugins/files/frontend/src/index.js`
---
### Feature 11: Rename с валидацией
**Описание:** Проверять имя через API до применения. Показывать ошибку под полем, а не в placeholder.
**Архитектура:**
- Проверка: не пустое, нет `/`, нет null byte, не `.verstak`
- API коллизии: `api.files.metadata(newPath)` — если существует, ошибка
**Файлы:** `plugins/files/frontend/src/index.js`
File diff suppressed because it is too large Load Diff
+77
View File
@@ -0,0 +1,77 @@
{
"manifest.name": "Files",
"manifest.description": "Deal-scoped vault file explorer with create, rename, trash, filtering, sorting, and Workbench openResource integration.",
"contributions.workspaceItems.verstak.files.workspace.title": "Files",
"ui.back": "Back",
"ui.forward": "Forward",
"ui.up": "Up",
"ui.refresh": "Refresh",
"ui.newFolder": "New folder",
"ui.newMarkdown": "New markdown file",
"ui.newText": "New text file",
"ui.open": "Open",
"ui.openFolder": "Open folder",
"ui.openExternal": "Open externally",
"ui.showInFolder": "Show in folder",
"ui.rename": "Rename",
"ui.duplicate": "Duplicate",
"ui.trash": "Move to trash",
"ui.cut": "Cut",
"ui.copy": "Copy",
"ui.paste": "Paste",
"ui.filter": "Filter current folder",
"ui.sort.foldersName": "Folders + name",
"ui.column.name": "Name",
"ui.column.type": "Type",
"ui.column.modified": "Modified",
"ui.column.size": "Size",
"ui.column.actions": "Actions",
"ui.folderType": "Folder",
"ui.icon.folder": "Folder",
"ui.icon.markdown": "Markdown file",
"ui.icon.image": "Image file",
"ui.icon.video": "Video file",
"ui.icon.audio": "Audio file",
"ui.icon.archive": "Archive file",
"ui.icon.pdf": "PDF file",
"ui.icon.text": "Text file",
"ui.icon.document": "Document file",
"ui.icon.spreadsheet": "Spreadsheet file",
"ui.icon.presentation": "Presentation file",
"ui.icon.code": "Code file",
"ui.icon.database": "Database file",
"ui.icon.font": "Font file",
"ui.icon.config": "Config file",
"ui.icon.json": "JSON file",
"ui.icon.generic": "File",
"ui.create": "Create",
"ui.cancel": "Cancel",
"ui.createFolderTitle": "Create folder",
"ui.createMarkdownTitle": "Create Markdown file",
"ui.createTextTitle": "Create text file",
"ui.renameTitle": "Rename {name}",
"ui.folderName": "Folder name",
"ui.markdownFileName": "Markdown file name",
"ui.textFileName": "Text file name",
"ui.fileName": "File or folder name",
"ui.nameRequired": "Enter a name.",
"ui.invalidCharacters": "The name contains invalid characters.",
"ui.invalidName": "Enter a valid name.",
"ui.nameDiffersOnlyByCase": "The name differs only by letter case.",
"ui.nameConflict": "An item with that name already exists.",
"ui.externalFailed": "{action} failed.",
"ui.vaultRelativePath": "Vault-relative path:",
"ui.copyPath": "Copy path",
"ui.close": "Close",
"ui.trashConfirm": "Move \"{name}\" to trash?",
"ui.trashManyConfirm": "Move {count} items to trash?",
"ui.clipboardDifferentDeal": "Clipboard items belong to another Deal.",
"ui.emptyFolder": "Empty folder",
"ui.noMatches": "No matches",
"ui.clearFilter": "Clear filter",
"ui.loading": "Loading...",
"ui.loadFailed": "Could not load files. Please try again.",
"ui.createError": "Could not create this item. Please try again.",
"ui.renameError": "Could not rename this item. Please try again.",
"ui.trashError": "Could not move this item to trash. Please try again."
}
+77
View File
@@ -0,0 +1,77 @@
{
"manifest.name": "Файлы",
"manifest.description": "Файловый менеджер Дела с созданием, переименованием, корзиной, фильтрацией и сортировкой.",
"contributions.workspaceItems.verstak.files.workspace.title": "Файлы",
"ui.back": "Назад",
"ui.forward": "Вперёд",
"ui.up": "На уровень выше",
"ui.refresh": "Обновить",
"ui.newFolder": "Новая папка",
"ui.newMarkdown": "Новый Markdown-файл",
"ui.newText": "Новый текстовый файл",
"ui.open": "Открыть",
"ui.openFolder": "Открыть папку",
"ui.openExternal": "Открыть во внешнем приложении",
"ui.showInFolder": "Показать в папке",
"ui.rename": "Переименовать",
"ui.duplicate": "Создать копию",
"ui.trash": "Переместить в корзину",
"ui.cut": "Вырезать",
"ui.copy": "Копировать",
"ui.paste": "Вставить",
"ui.filter": "Фильтр текущей папки",
"ui.sort.foldersName": "Сначала папки, затем по имени",
"ui.column.name": "Имя",
"ui.column.type": "Тип",
"ui.column.modified": "Изменён",
"ui.column.size": "Размер",
"ui.column.actions": "Действия",
"ui.folderType": "Папка",
"ui.icon.folder": "Папка",
"ui.icon.markdown": "Файл Markdown",
"ui.icon.image": "Изображение",
"ui.icon.video": "Видео",
"ui.icon.audio": "Аудиофайл",
"ui.icon.archive": "Архив",
"ui.icon.pdf": "PDF-файл",
"ui.icon.text": "Текстовый файл",
"ui.icon.document": "Документ",
"ui.icon.spreadsheet": "Таблица",
"ui.icon.presentation": "Презентация",
"ui.icon.code": "Исходный код",
"ui.icon.database": "База данных",
"ui.icon.font": "Шрифт",
"ui.icon.config": "Файл конфигурации",
"ui.icon.json": "JSON-файл",
"ui.icon.generic": "Файл",
"ui.create": "Создать",
"ui.cancel": "Отмена",
"ui.createFolderTitle": "Создать папку",
"ui.createMarkdownTitle": "Создать Markdown-файл",
"ui.createTextTitle": "Создать текстовый файл",
"ui.renameTitle": "Переименовать {name}",
"ui.folderName": "Название папки",
"ui.markdownFileName": "Название Markdown-файла",
"ui.textFileName": "Название текстового файла",
"ui.fileName": "Название файла или папки",
"ui.nameRequired": "Введите название.",
"ui.invalidCharacters": "В названии есть недопустимые символы.",
"ui.invalidName": "Введите корректное название.",
"ui.nameDiffersOnlyByCase": "Название отличается только регистром букв.",
"ui.nameConflict": "Элемент с таким названием уже существует.",
"ui.externalFailed": "Не удалось выполнить действие «{action}».",
"ui.vaultRelativePath": "Путь относительно хранилища:",
"ui.copyPath": "Скопировать путь",
"ui.close": "Закрыть",
"ui.trashConfirm": "Переместить «{name}» в корзину?",
"ui.trashManyConfirm": "Переместить в корзину {count} элементов?",
"ui.clipboardDifferentDeal": "Элементы буфера обмена принадлежат другому Делу.",
"ui.emptyFolder": "Папка пуста",
"ui.noMatches": "Совпадений нет",
"ui.clearFilter": "Сбросить фильтр",
"ui.loading": "Загрузка...",
"ui.loadFailed": "Не удалось загрузить файлы. Повторите попытку.",
"ui.createError": "Не удалось создать этот элемент. Повторите попытку.",
"ui.renameError": "Не удалось переименовать этот элемент. Повторите попытку.",
"ui.trashError": "Не удалось переместить элемент в корзину. Повторите попытку."
}
+6 -2
View File
@@ -4,7 +4,8 @@
"name": "Files",
"version": "0.1.0",
"apiVersion": "0.1.0",
"description": "Minimal vault file navigator. Shows folders and files, opens files through Workbench openResource.",
"description": "Deal-scoped vault file explorer with create, rename, trash, filtering, sorting, and Workbench openResource integration.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official",
"icon": "folder",
"provides": [
@@ -17,11 +18,14 @@
"permissions": [
"files.read",
"files.write",
"files.delete",
"files.openExternal",
"events.subscribe",
"workbench.open",
"ui.register"
],
"frontend": {
"entry": "frontend/dist/index.js"
"entry": "frontend/src/index.js"
},
"contributes": {
"workspaceItems": [
+613
View File
@@ -0,0 +1,613 @@
/* ===========================================================
Journal Plugin - Verstak v2 Frontend Bundle
Contract: window.VerstakPluginRegister(id, { components })
=========================================================== */
(function () {
'use strict';
var PLUGIN_ID = 'verstak.journal';
var WORKLOG_PREFIX = 'worklog:workspace:';
function injectStyles() {
if (document.getElementById('journal-style-injected')) return;
var style = document.createElement('style');
style.id = 'journal-style-injected';
style.textContent = STYLES;
document.head.appendChild(style);
}
var STYLES = [
'.journal-root{display:flex;flex-direction:column;height:100%;min-height:0;background:var(--vt-color-background,#101020);color:var(--vt-color-text-primary,#f4f7fb);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif}',
'.journal-toolbar{display:flex;align-items:center;gap:.5rem;min-height:2.75rem;padding:.55rem .75rem;border-bottom:1px solid var(--vt-color-border,#202b46);background:var(--vt-color-surface-muted,#111629);flex-shrink:0;flex-wrap:wrap}',
'.journal-title{font-size:.86rem;font-weight:600}',
'.journal-count{font-size:.72rem;color:var(--vt-color-text-muted,#7f8aa3)}',
'.journal-spacer{flex:1}',
'.journal-btn{font-size:.78rem;padding:.36rem .65rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-md,6px);background:var(--vt-color-surface-hover,#1b2440);color:var(--vt-color-text-secondary,#b7c0d4);cursor:pointer;display:inline-flex;align-items:center;justify-content:center;gap:.35rem}',
'.journal-btn svg{width:14px;height:14px;display:block;fill:currentColor}',
'.journal-btn:hover{border-color:var(--vt-color-accent,#4ecca3);background:var(--vt-color-surface-hover,#1b2440);color:var(--vt-color-text-primary,#f4f7fb)}',
'.journal-btn:disabled{opacity:.45;cursor:default}',
'.journal-status{font-size:.72rem;color:var(--vt-color-text-muted,#7f8aa3)}',
'.journal-status.error{display:inline-flex;border:1px solid rgba(233,69,96,.45);border-radius:var(--vt-radius-sm,4px);background:var(--vt-color-danger-muted,rgba(233,69,96,.14));color:#ffc6ce;padding:.18rem .4rem}',
'.journal-input{font-size:.8rem;padding:.38rem .5rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:#0f1424;color:var(--vt-color-text-primary,#f4f7fb);min-width:0;font-family:inherit}',
'.journal-input.textarea{min-height:7rem;resize:vertical;line-height:1.4}',
'.journal-input.journal-select{appearance:none;background-color:#0f1424;background-image:linear-gradient(45deg,transparent 50%,var(--vt-color-text-muted,#7f8aa3) 50%),linear-gradient(135deg,var(--vt-color-text-muted,#7f8aa3) 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}.journal-input.journal-select option{background:#0f1424;color:var(--vt-color-text-primary,#f4f7fb)}',
'.journal-input:focus{outline:none;border-color:var(--vt-color-accent,#4ecca3);box-shadow:var(--vt-focus-ring,0 0 0 2px rgba(78,204,163,.34))}',
'.journal-billable{display:flex;align-items:center;gap:.25rem;font-size:.74rem;color:var(--vt-color-text-secondary,#b7c0d4);white-space:nowrap}',
'.journal-list{flex:1;min-height:0;overflow:auto;background:var(--vt-color-background,#101020)}',
'.journal-empty{height:100%;display:flex;align-items:center;justify-content:center;color:var(--vt-color-text-muted,#7f8aa3);font-size:.86rem;padding:2rem;text-align:center}',
'.journal-row{display:grid;grid-template-columns:8rem minmax(0,1fr) auto auto;gap:.7rem;margin:.5rem .75rem 0;padding:.75rem .85rem;border:1px solid var(--vt-color-border,#202b46);border-radius:var(--vt-radius-lg,8px);background:var(--vt-color-surface,#15152c);align-items:start}',
'.journal-row:hover{background:var(--vt-color-surface-hover,#1b2440)}',
'.journal-date{font-size:.75rem;color:var(--vt-color-text-muted,#7f8aa3);white-space:nowrap}',
'.journal-main{min-width:0}',
'.journal-entry-title{font-size:.88rem;color:var(--vt-color-text-primary,#f4f7fb);font-weight:600;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
'.journal-summary{margin-top:.25rem;font-size:.78rem;line-height:1.4;color:var(--vt-color-text-secondary,#b7c0d4);white-space:pre-wrap;overflow-wrap:anywhere}',
'.journal-meta{margin-top:.25rem;font-size:.72rem;color:var(--vt-color-text-muted,#7f8aa3)}',
'.journal-minutes{font-size:.78rem;color:var(--vt-color-accent,#4ecca3);white-space:nowrap}',
'.journal-row-actions{display:flex;gap:.25rem}',
'.journal-icon-btn{width:1.65rem;height:1.65rem;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--vt-color-border,#202b46);border-radius:var(--vt-radius-sm,4px);background:transparent;color:var(--vt-color-text-muted,#7f8aa3);cursor:pointer;padding:0}',
'.journal-icon-btn:hover{background:var(--vt-color-surface-hover,#1b2440);border-color:var(--vt-color-accent,#4ecca3);color:var(--vt-color-accent,#4ecca3)}',
'.journal-icon-btn.danger:hover{border-color:rgba(233,69,96,.65);color:var(--vt-color-danger,#e94560)}',
'.journal-icon-btn svg{width:14px;height:14px;display:block;fill:currentColor}',
'.journal-modal-host[hidden]{display:none}',
'.journal-modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.58);z-index:10000;display:flex;align-items:center;justify-content:center;padding:1rem}',
'.journal-modal{width:520px;max-width:96vw;display:grid;gap:.75rem;padding:1rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-lg,8px);background:var(--vt-color-surface,#15152c);box-shadow:0 18px 44px rgba(0,0,0,.38)}',
'.journal-modal-title{font-size:.95rem;font-weight:650;color:var(--vt-color-text-primary,#f4f7fb)}',
'.journal-modal-grid{display:grid;grid-template-columns:1fr 8rem;gap:.6rem}',
'.journal-field{display:grid;gap:.3rem;font-size:.72rem;color:var(--vt-color-text-muted,#7f8aa3)}',
'.journal-field.wide{grid-column:1/-1}',
'.journal-candidate-context{display:grid;gap:.22rem;padding:.65rem .7rem;border:1px solid rgba(78,204,163,.34);border-radius:var(--vt-radius-md,6px);background:var(--vt-color-surface-muted,#111629);font-size:.76rem;color:var(--vt-color-text-secondary,#b7c0d4)}',
'.journal-candidate-context strong{color:var(--vt-color-text-primary,#f4f7fb)}',
'.journal-candidate-activities{display:grid;gap:.35rem;margin:0;padding:.65rem .7rem;border:1px solid var(--vt-color-border,#202b46);border-radius:var(--vt-radius-md,6px);font-size:.74rem;color:var(--vt-color-text-secondary,#b7c0d4)}',
'.journal-candidate-activities legend{padding:0 .2rem;color:var(--vt-color-text-muted,#7f8aa3)}',
'.journal-candidate-activity{display:flex;align-items:flex-start;gap:.45rem;line-height:1.35;overflow-wrap:anywhere}',
'.journal-modal-actions{display:flex;justify-content:flex-end;gap:.5rem}',
'.journal-btn.primary{background:var(--vt-color-accent,#4ecca3);border-color:var(--vt-color-accent,#4ecca3);color:#101827}',
'.journal-btn.ghost{background:transparent}',
'@media(max-width:820px){.journal-row{grid-template-columns:1fr}.journal-btn{width:100%}.journal-toolbar{align-items:stretch}.journal-status{width:100%}.journal-modal-grid{grid-template-columns:1fr}}'
].join('\n');
function el(tag, attrs, children) {
var elem = document.createElement(tag);
if (attrs) {
Object.keys(attrs).forEach(function (key) {
if (attrs[key] == null) return;
if (key === 'className') elem.className = attrs[key];
else if (key === 'innerHTML') elem.innerHTML = attrs[key];
else if (key === 'textContent') elem.textContent = attrs[key];
else if (key.slice(0, 2) === 'on') elem.addEventListener(key.slice(2).toLowerCase(), attrs[key]);
else elem.setAttribute(key, attrs[key]);
});
}
if (children) {
(Array.isArray(children) ? children : [children]).forEach(function (child) {
if (child == null) return;
elem.appendChild(typeof child === 'string' ? document.createTextNode(child) : child);
});
}
return elem;
}
function text(value) {
return String(value == null ? '' : value);
}
function cleanWorkspace(value) {
return text(value).trim().replace(/^\/+|\/+$/g, '');
}
function encodeKey(value) {
return encodeURIComponent(text(value).trim());
}
function workspaceFromProps(props) {
var node = props && props.workspaceNode;
return cleanWorkspace((props && (props.workspaceRootPath || props.workspaceName || props.workspaceNodeId))
|| (node && (node.rootPath || node.name || node.id)));
}
function scopeFromProps(props) {
var workspaceRoot = workspaceFromProps(props || {});
if (!workspaceRoot) return { mode: 'global', key: '', label: 'All Deals', workspaceRoot: '' };
return {
mode: 'workspace',
key: WORKLOG_PREFIX + encodeKey(workspaceRoot),
label: workspaceRoot,
workspaceRoot: workspaceRoot
};
}
function today() {
return new Date().toISOString().slice(0, 10);
}
function normalizeEntry(value, storageKey) {
value = value || {};
return {
entryId: text(value.entryId || ('entry:' + Date.now())),
workspaceRootPath: cleanWorkspace(value.workspaceRootPath || decodeWorkspaceKey(storageKey)),
date: text(value.date || today()).slice(0, 10),
title: text(value.title || 'Worklog entry'),
summary: text(value.summary),
minutes: Math.max(0, Number(value.minutes || 0)),
billable: value.billable === true,
sourceCandidateId: text(value.sourceCandidateId || value.sourceSuggestionId),
sourceTodoId: text(value.sourceTodoId),
activityIds: Array.isArray(value.activityIds)
? value.activityIds.map(text)
: (Array.isArray(value.eventIds) ? value.eventIds.map(text) : [])
};
}
function decodeWorkspaceKey(key) {
if (!key || key.indexOf(WORKLOG_PREFIX) !== 0) return '';
try {
return decodeURIComponent(key.slice(WORKLOG_PREFIX.length));
} catch (err) {
return key.slice(WORKLOG_PREFIX.length);
}
}
function normalizeEntries(value, storageKey) {
if (!Array.isArray(value)) return [];
return value.map(function (item) { return normalizeEntry(item, storageKey); });
}
function storageEntries(entryList) {
return entryList.map(function (entry) {
return {
entryId: entry.entryId,
workspaceRootPath: entry.workspaceRootPath,
date: entry.date,
title: entry.title,
summary: entry.summary,
minutes: entry.minutes,
billable: entry.billable,
sourceCandidateId: entry.sourceCandidateId,
sourceTodoId: entry.sourceTodoId,
activityIds: entry.activityIds || []
};
});
}
function sortEntries(entryList) {
var seen = {};
return entryList.filter(function (entry) {
var key = entry.sourceCandidateId
? 'candidate:' + entry.sourceCandidateId
: (entry.sourceTodoId ? 'todo:' + entry.sourceTodoId : 'entry:' + entry.entryId);
if (!key || seen[key]) return false;
seen[key] = true;
return true;
}).slice().sort(function (a, b) {
return text(b.date).localeCompare(text(a.date)) || text(b.entryId).localeCompare(text(a.entryId));
});
}
function worklogKeys(settings) {
return Object.keys(settings || {}).filter(function (key) {
return key.indexOf(WORKLOG_PREFIX) === 0;
});
}
function entryId(workspaceRoot, date, title) {
return 'journal:' + cleanWorkspace(workspaceRoot || 'Global') + ':' + text(date) + ':' + encodeKey(title).slice(0, 48) + ':' + Date.now();
}
function candidateDate(value) {
var date = new Date(value || '');
return isNaN(date.getTime()) ? today() : date.toISOString().slice(0, 10);
}
function candidateTime(value) {
var date = new Date(value || '');
if (isNaN(date.getTime())) return text(value);
return date.toLocaleString(undefined, { year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' });
}
function candidateFromRequest(request, workspaceRoot) {
var value = request && request.type === 'work-session-candidate' ? request.candidate : null;
if (!value || typeof value !== 'object') return null;
var workspace = cleanWorkspace(value.workspaceRootPath);
if (!workspace || workspace !== cleanWorkspace(workspaceRoot) || !text(value.candidateId).trim()) return null;
var activities = Array.isArray(value.activities) ? value.activities.filter(function (activity) {
return activity && text(activity.activityId).trim();
}).map(function (activity) {
return {
activityId: text(activity.activityId),
type: text(activity.type || 'activity.event'),
occurredAt: text(activity.occurredAt),
sourcePluginId: text(activity.sourcePluginId)
};
}) : [];
var activityIds = Array.isArray(value.activityIds) ? value.activityIds.map(text).filter(Boolean) : activities.map(function (activity) { return activity.activityId; });
if (!activities.length) {
activities = activityIds.map(function (activityId) {
return { activityId: activityId, type: 'activity.event', occurredAt: '', sourcePluginId: '' };
});
}
return {
candidateId: text(value.candidateId),
sessionId: text(value.sessionId),
handledThrough: text(value.handledThrough || value.endedAt),
workspaceRootPath: workspace,
startedAt: text(value.startedAt),
endedAt: text(value.endedAt),
estimatedMinutes: Math.max(0, Number(value.estimatedMinutes || 0)),
activityCount: Math.max(0, Number(value.activityCount || activities.length)),
activityIds: activityIds,
activities: activities
};
}
function completedTodoFromRequest(request, workspaceRoot) {
var value = request && request.type === 'completed-todo' ? request.todo : null;
if (!value || typeof value !== 'object') return null;
var workspace = cleanWorkspace(value.workspaceRootPath);
var todoId = text(value.id).trim();
var title = text(value.title).trim();
if (!todoId || !title || !workspace || workspace !== cleanWorkspace(workspaceRoot)) return null;
return {
id: todoId,
title: title,
description: text(value.description || value.body),
workspaceRootPath: workspace,
completedAt: text(value.completedAt)
};
}
var ICONS = {
add: 'M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z',
edit: 'M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34a.9959.9959 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z',
trash: 'M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM8 9h8v10H8V9zm7.5-5-1-1h-5l-1 1H5v2h14V4z'
};
function iconSvg(name) {
return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><path d="' + (ICONS[name] || ICONS.edit) + '" fill="currentColor"/></svg>';
}
function JournalView() {}
JournalView.mount = function (containerEl, props, api) {
injectStyles();
containerEl.innerHTML = '';
containerEl.className = 'journal-root';
containerEl.setAttribute('data-plugin-id', PLUGIN_ID);
var scope = scopeFromProps(props || {});
var entries = [];
var workspaceOptions = [];
function tr(key, params, fallback) {
if (api && api.i18n && typeof api.i18n.t === 'function') return api.i18n.t(key, params, fallback);
return fallback || key;
}
var statusText = tr('ui.loading', null, 'Loading journal...');
var statusClass = '';
var modalHost = el('div', { className: 'journal-modal-host', hidden: 'hidden' });
function reportError(key, fallback, err) {
if (typeof console !== 'undefined' && typeof console.warn === 'function') {
console.warn('[verstak.journal] ' + key, err);
}
statusText = tr(key, null, fallback);
statusClass = 'error';
}
var toolbar = el('div', { className: 'journal-toolbar' });
var titleEl = el('span', { className: 'journal-title', textContent: scope.mode === 'global' ? tr('ui.title', null, 'Journal') : tr('ui.workspaceTitle', { workspace: scope.label }, 'Journal · ' + scope.label) });
var countEl = el('span', { className: 'journal-count' });
var statusEl = el('span', { className: 'journal-status' });
var addBtn = el('button', {
className: 'journal-btn primary',
'data-journal-action': 'add',
innerHTML: iconSvg('add') + '<span>' + tr('ui.add', null, 'Add') + '</span>',
onClick: function () { showEntryModal(); }
});
toolbar.appendChild(titleEl);
toolbar.appendChild(countEl);
toolbar.appendChild(el('span', { className: 'journal-spacer' }));
toolbar.appendChild(statusEl);
toolbar.appendChild(addBtn);
var listEl = el('div', { className: 'journal-list' });
containerEl.appendChild(toolbar);
containerEl.appendChild(listEl);
containerEl.appendChild(modalHost);
function persist(workspaceRoot, values) {
if (!api || !api.settings || typeof api.settings.write !== 'function') return Promise.resolve();
var target = cleanWorkspace(workspaceRoot || scope.workspaceRoot);
if (!target) return Promise.resolve();
return api.settings.write(WORKLOG_PREFIX + encodeKey(target), storageEntries(values || entries)).catch(function (err) {
reportError('ui.saveError', 'Could not save journal. Please try again.', err);
});
}
function loadWorkspaceOptions() {
if (!api || !api.files || typeof api.files.list !== 'function') return Promise.resolve();
return api.files.list('').then(function (items) {
workspaceOptions = (Array.isArray(items) ? items : []).filter(function (item) {
return text(item && item.type).toLowerCase() === 'folder';
}).map(function (item) { return cleanWorkspace(item.relativePath || item.name); }).filter(function (value) {
return value && value.indexOf('/') === -1;
});
});
}
function loadStored() {
if (!api || !api.settings || typeof api.settings.read !== 'function') return Promise.resolve();
if (scope.mode === 'global') {
return api.settings.read().then(function (settings) {
var all = [];
worklogKeys(settings || {}).forEach(function (key) {
all = all.concat(normalizeEntries((settings || {})[key], key));
});
entries = sortEntries(all);
statusText = tr('ui.aggregating', null, 'Aggregating worklogs');
statusClass = '';
}).catch(function (err) {
reportError('ui.loadError', 'Could not load journal. Please try again.', err);
});
}
return api.settings.read(scope.key).then(function (stored) {
entries = sortEntries(normalizeEntries(stored, scope.key));
statusText = tr('ui.ready', null, 'Ready');
statusClass = '';
}).catch(function (err) {
reportError('ui.loadError', 'Could not load journal. Please try again.', err);
});
}
function closeEntryModal() {
modalHost.innerHTML = '';
modalHost.setAttribute('hidden', 'hidden');
}
function activityLabel(activity) {
var type = text(activity && activity.type);
var labels = {
'browser.capture.link': ['ui.activity.captureLink', 'Link captured'],
'browser.capture.selection': ['ui.activity.captureSelection', 'Selection captured'],
'browser.capture.page': ['ui.activity.capturePage', 'Page captured'],
'file.created': ['ui.activity.fileCreated', 'File created'],
'file.updated': ['ui.activity.fileUpdated', 'File updated'],
'file.deleted': ['ui.activity.fileDeleted', 'File deleted'],
'note.saved': ['ui.activity.noteSaved', 'Note saved'],
'workspace.created': ['ui.activity.dealCreated', 'Deal created'],
'workspace.renamed': ['ui.activity.dealRenamed', 'Deal renamed']
};
var label = labels[type] || ['ui.activity.generic', 'Activity'];
return tr(label[0], null, label[1]);
}
function entryMeta(entry) {
var facts = [entry.workspaceRootPath, entry.billable ? tr('ui.meta.billable', null, 'billable') : tr('ui.meta.nonBillable', null, 'non-billable')];
if (entry.activityIds.length) {
facts.push(tr(entry.activityIds.length === 1 ? 'ui.meta.activities.one' : 'ui.meta.activities.many', { count: entry.activityIds.length }, entry.activityIds.length + ' linked activities'));
}
if (entry.sourceTodoId) facts.push(tr('ui.meta.todo', null, 'linked todo'));
return facts.join(' · ');
}
function showEntryModal(existingEntry, candidate, completedTodo) {
var editing = !!existingEntry;
var reviewingCandidate = !editing && !!candidate;
var reviewingTodo = !editing && !!completedTodo;
var dateInput = el('input', { className: 'journal-input', type: 'date', value: editing ? existingEntry.date : (reviewingCandidate ? candidateDate(candidate.startedAt) : (reviewingTodo ? candidateDate(completedTodo.completedAt) : today())), 'data-journal-input': 'date' });
var titleInput = el('input', { className: 'journal-input', type: 'text', placeholder: tr('ui.workItem', null, 'Work item'), value: editing ? existingEntry.title : (reviewingTodo ? completedTodo.title : ''), 'data-journal-input': 'title' });
var summaryInput = el('textarea', { className: 'journal-input textarea', placeholder: tr('ui.body', null, 'Body'), 'data-journal-input': 'summary' });
summaryInput.value = editing ? existingEntry.summary : (reviewingTodo ? completedTodo.description : '');
var minutesInput = el('input', { className: 'journal-input', type: 'number', min: '0', step: '1', value: editing ? existingEntry.minutes : (reviewingCandidate ? candidate.estimatedMinutes : (reviewingTodo ? '0' : '30')), 'data-journal-input': 'minutes' });
var billableInput = el('input', { type: 'checkbox', 'data-journal-input': 'billable' });
billableInput.checked = editing ? existingEntry.billable === true : false;
var workspaceInput = null;
if (scope.mode === 'global') {
workspaceInput = el('select', { className: 'journal-input journal-select', 'data-journal-input': 'workspaceRootPath' });
workspaceOptions.forEach(function (workspace) {
workspaceInput.appendChild(el('option', { value: workspace, textContent: workspace }));
});
}
var activityInputs = reviewingCandidate ? candidate.activities.map(function (activity) {
var input = el('input', { type: 'checkbox', value: activity.activityId, checked: 'checked', 'data-journal-candidate-activity': activity.activityId });
input.checked = true;
return { input: input, activity: activity };
}) : [];
function saveEntry() {
addOrUpdateEntry(existingEntry, {
date: dateInput.value || today(),
title: titleInput.value,
summary: summaryInput.value,
minutes: minutesInput.value,
billable: billableInput.checked === true,
workspaceRootPath: workspaceInput ? workspaceInput.value : scope.workspaceRoot,
sourceCandidateId: reviewingCandidate ? candidate.candidateId : (existingEntry ? existingEntry.sourceCandidateId : ''),
sessionId: reviewingCandidate ? candidate.sessionId : '',
handledThrough: reviewingCandidate ? candidate.handledThrough : '',
sourceTodoId: reviewingTodo ? completedTodo.id : (existingEntry ? existingEntry.sourceTodoId : ''),
activityIds: reviewingCandidate
? activityInputs.filter(function (item) { return item.input.checked === true; }).map(function (item) { return item.activity.activityId; })
: (existingEntry ? existingEntry.activityIds : [])
});
}
var candidateContext = reviewingCandidate ? el('div', { className: 'journal-candidate-context', 'data-journal-candidate': candidate.candidateId }, [
el('strong', { textContent: tr('ui.candidate.title', null, 'Possible journal entry') }),
el('div', { textContent: tr('ui.candidate.deal', { deal: candidate.workspaceRootPath }, 'Deal: ' + candidate.workspaceRootPath) }),
el('div', { textContent: tr('ui.candidate.time', { start: candidateTime(candidate.startedAt), end: candidateTime(candidate.endedAt) }, 'Time: ' + candidateTime(candidate.startedAt) + ' – ' + candidateTime(candidate.endedAt)) }),
el('div', { textContent: tr('ui.candidate.duration', { minutes: candidate.estimatedMinutes }, 'Estimated duration: ' + candidate.estimatedMinutes + ' min') }),
el('div', { textContent: tr('ui.candidate.activities', { count: candidate.activityCount }, 'Activities: ' + candidate.activityCount) })
]) : null;
var candidateActivities = reviewingCandidate ? el('fieldset', { className: 'journal-candidate-activities' }, [
el('legend', { textContent: tr('ui.candidate.linkedActivities', null, 'Linked activities') })
].concat(activityInputs.map(function (item) {
var detail = (item.activity.occurredAt ? candidateTime(item.activity.occurredAt) + ' · ' : '') + activityLabel(item.activity);
return el('label', { className: 'journal-candidate-activity' }, [item.input, detail]);
}))) : null;
var todoContext = reviewingTodo ? el('div', { className: 'journal-candidate-context', 'data-journal-todo': completedTodo.id }, [
el('strong', { textContent: tr('ui.todo.title', null, 'Completed todo') }),
el('div', { textContent: tr('ui.candidate.deal', { deal: completedTodo.workspaceRootPath }, 'Deal: ' + completedTodo.workspaceRootPath) }),
completedTodo.completedAt ? el('div', { textContent: tr('ui.todo.completed', { time: candidateTime(completedTodo.completedAt) }, 'Completed: ' + candidateTime(completedTodo.completedAt)) }) : null
]) : null;
modalHost.innerHTML = '';
if (typeof modalHost.removeAttribute === 'function') modalHost.removeAttribute('hidden');
else delete modalHost.attributes.hidden;
modalHost.appendChild(el('div', { className: 'journal-modal-overlay', onClick: function (event) {
if (event.target === event.currentTarget) closeEntryModal();
} }, [
el('div', { className: 'journal-modal' }, [
el('div', { className: 'journal-modal-title', textContent: editing ? tr('ui.editEntry', null, 'Edit journal entry') : (reviewingCandidate ? tr('ui.reviewCandidate', null, 'Review possible journal entry') : (reviewingTodo ? tr('ui.fromTodo', null, 'Create journal entry from completed todo') : tr('ui.addEntry', null, 'Add journal entry'))) }),
candidateContext,
todoContext,
el('div', { className: 'journal-modal-grid' }, [
el('label', { className: 'journal-field' }, [tr('ui.date', null, 'Date'), dateInput]),
el('label', { className: 'journal-field' }, [tr('ui.minutes', null, 'Minutes'), minutesInput]),
workspaceInput ? el('label', { className: 'journal-field wide' }, [tr('ui.workspace', null, 'Deal'), workspaceInput]) : null,
el('label', { className: 'journal-field wide' }, [tr('ui.fieldTitle', null, 'Title'), titleInput]),
el('label', { className: 'journal-field wide' }, [tr('ui.body', null, 'Body'), summaryInput]),
el('label', { className: 'journal-billable' }, [billableInput, tr('ui.billable', null, 'Billable')])
]),
candidateActivities,
el('div', { className: 'journal-modal-actions' }, [
el('button', { className: 'journal-btn ghost', type: 'button', textContent: tr('ui.cancel', null, 'Cancel'), onClick: closeEntryModal }),
el('button', { className: 'journal-btn primary', type: 'button', 'data-journal-action': 'save-entry', textContent: editing ? tr('ui.saveChanges', null, 'Save changes') : tr('ui.addEntryShort', null, 'Add entry'), onClick: saveEntry })
])
])
]));
titleInput.focus && titleInput.focus();
}
function addOrUpdateEntry(existingEntry, formValue) {
var title = text(formValue && formValue.title).trim();
if (!title) {
statusText = tr('ui.titleRequired', null, 'Title is required');
statusClass = 'error';
render();
return;
}
var workspaceRoot = cleanWorkspace(formValue && formValue.workspaceRootPath || scope.workspaceRoot);
if (!workspaceRoot) return;
var sourceCandidateId = text(formValue && formValue.sourceCandidateId || (existingEntry && existingEntry.sourceCandidateId)).trim();
var sessionID = text(formValue && formValue.sessionId).trim();
var handledThrough = text(formValue && formValue.handledThrough).trim();
var sourceTodoId = text(formValue && formValue.sourceTodoId || (existingEntry && existingEntry.sourceTodoId)).trim();
if (!existingEntry && sourceCandidateId && entries.some(function (entry) { return entry.sourceCandidateId === sourceCandidateId; })) {
statusText = tr('ui.candidate.duplicate', null, 'A journal entry already references this candidate');
statusClass = 'error';
render();
return;
}
if (!existingEntry && sourceTodoId && entries.some(function (entry) { return entry.sourceTodoId === sourceTodoId; })) {
statusText = tr('ui.todo.duplicate', null, 'A journal entry already references this todo');
statusClass = 'error';
render();
return;
}
var entry = normalizeEntry({
entryId: existingEntry ? existingEntry.entryId : entryId(workspaceRoot, formValue.date || today(), title),
workspaceRootPath: workspaceRoot,
date: formValue.date || today(),
title: title,
summary: formValue.summary,
minutes: Number(formValue.minutes || 0),
billable: formValue.billable === true,
sourceCandidateId: sourceCandidateId,
sourceTodoId: sourceTodoId,
activityIds: Array.isArray(formValue.activityIds) ? formValue.activityIds : (existingEntry ? existingEntry.activityIds : [])
}, scope.key);
if (existingEntry) {
entries = entries.map(function (item) {
return item.entryId === existingEntry.entryId ? entry : item;
});
} else {
entries = [entry].concat(entries);
}
entries = sortEntries(entries);
var targetEntries = entries.filter(function (item) { return item.workspaceRootPath === workspaceRoot; });
closeEntryModal();
statusText = existingEntry ? tr('ui.updated', null, 'Entry updated') : tr('ui.added', null, 'Entry added');
statusClass = '';
persist(workspaceRoot, targetEntries).then(function () {
if (!sessionID || !handledThrough || !api || !api.events || typeof api.events.publish !== 'function') return undefined;
return api.events.publish('activity.session.handled', {
sessionId: sessionID,
handledThrough: handledThrough,
status: 'accepted'
});
}).then(render);
}
function deleteEntry(entry) {
if (scope.mode !== 'workspace' || !entry) return;
entries = entries.filter(function (item) { return item.entryId !== entry.entryId; });
statusText = tr('ui.deleted', null, 'Entry deleted');
statusClass = '';
persist().then(render);
}
function renderList() {
listEl.innerHTML = '';
if (!entries.length) {
listEl.appendChild(el('div', { className: 'journal-empty', textContent: tr('ui.empty', null, 'No worklog entries yet.') }));
return;
}
entries.forEach(function (entry) {
listEl.appendChild(el('div', {
className: 'journal-row',
'data-journal-entry': entry.entryId
}, [
el('div', { className: 'journal-date', textContent: entry.date }),
el('div', { className: 'journal-main' }, [
el('div', { className: 'journal-entry-title', textContent: entry.title }),
entry.summary ? el('div', { className: 'journal-summary', textContent: entry.summary }) : null,
el('div', { className: 'journal-meta', textContent: entryMeta(entry) })
]),
el('div', { className: 'journal-minutes', textContent: tr('ui.minutesValue', { minutes: entry.minutes }, entry.minutes + ' min') }),
scope.mode === 'workspace' ? el('div', { className: 'journal-row-actions' }, [
el('button', { className: 'journal-icon-btn', type: 'button', title: tr('ui.edit', null, 'Edit'), 'aria-label': tr('ui.edit', null, 'Edit'), 'data-journal-action': 'edit', innerHTML: iconSvg('edit'), onClick: function () { showEntryModal(entry); } }),
el('button', { className: 'journal-icon-btn danger', type: 'button', title: tr('ui.delete', null, 'Delete'), 'aria-label': tr('ui.delete', null, 'Delete'), 'data-journal-action': 'delete', innerHTML: iconSvg('trash'), onClick: function () { deleteEntry(entry); } })
]) : null
]));
});
}
function render() {
countEl.textContent = tr(
entries.length === 1 ? 'ui.entryCount.one' : 'ui.entryCount.many',
{ count: entries.length },
entries.length === 1 ? '{count} entry' : '{count} entries'
);
statusEl.textContent = statusText;
statusEl.className = 'journal-status' + (statusClass ? ' ' + statusClass : '');
addBtn.disabled = false;
renderList();
}
render();
Promise.all([loadStored(), loadWorkspaceOptions()]).then(function () {
render();
var candidate = candidateFromRequest(props && props.toolRequest, scope.workspaceRoot);
var completedTodo = completedTodoFromRequest(props && props.toolRequest, scope.workspaceRoot);
if (candidate) showEntryModal(null, candidate);
else if (completedTodo) showEntryModal(null, null, completedTodo);
});
if (api && api.i18n && typeof api.i18n.onDidChangeLocale === 'function') {
api.i18n.onDidChangeLocale(function () {
titleEl.textContent = scope.mode === 'global' ? tr('ui.title', null, 'Journal') : tr('ui.workspaceTitle', { workspace: scope.label }, 'Journal · ' + scope.label);
addBtn.innerHTML = iconSvg('add') + '<span>' + tr('ui.add', null, 'Add') + '</span>';
render();
});
}
};
JournalView.unmount = function (containerEl) {
if (containerEl) containerEl.innerHTML = '';
};
window.VerstakPluginRegister(PLUGIN_ID, {
components: {
JournalView: JournalView
}
});
})();
+63
View File
@@ -0,0 +1,63 @@
{
"manifest.name": "Journal",
"manifest.description": "Deal-scoped journal with user-authored entries and optional Activity links.",
"contributions.views.verstak.journal.view.title": "Journal",
"contributions.sidebarItems.verstak.journal.sidebar.title": "Journal",
"contributions.workspaceItems.verstak.journal.workspace.title": "Journal",
"ui.loading": "Loading journal...",
"ui.title": "Journal",
"ui.workspaceTitle": "Journal · {workspace}",
"ui.add": "Add",
"ui.saveError": "Could not save journal. Please try again.",
"ui.aggregating": "Aggregating worklogs",
"ui.loadError": "Could not load journal. Please try again.",
"ui.ready": "Ready",
"ui.workItem": "Work item",
"ui.body": "Body",
"ui.editEntry": "Edit journal entry",
"ui.reviewCandidate": "Review possible journal entry",
"ui.fromTodo": "Create journal entry from completed todo",
"ui.addEntry": "Add journal entry",
"ui.date": "Date",
"ui.minutes": "Minutes",
"ui.fieldTitle": "Title",
"ui.billable": "Billable",
"ui.cancel": "Cancel",
"ui.saveChanges": "Save changes",
"ui.addEntryShort": "Add entry",
"ui.titleRequired": "Title is required",
"ui.deleted": "Entry deleted",
"ui.empty": "No worklog entries yet.",
"ui.edit": "Edit",
"ui.delete": "Delete",
"ui.entryCount.one": "{count} entry",
"ui.entryCount.many": "{count} entries",
"ui.candidate.title": "Possible journal entry",
"ui.candidate.deal": "Deal: {deal}",
"ui.candidate.time": "Time: {start} – {end}",
"ui.candidate.duration": "Estimated duration: {minutes} min",
"ui.candidate.activities": "Activities: {count}",
"ui.candidate.linkedActivities": "Linked activities",
"ui.candidate.duplicate": "A journal entry already references this candidate",
"ui.todo.title": "Completed todo",
"ui.todo.completed": "Completed: {time}",
"ui.todo.duplicate": "A journal entry already references this todo",
"ui.activity.captureLink": "Link captured",
"ui.activity.captureSelection": "Selection captured",
"ui.activity.capturePage": "Page captured",
"ui.activity.fileCreated": "File created",
"ui.activity.fileUpdated": "File updated",
"ui.activity.fileDeleted": "File deleted",
"ui.activity.noteSaved": "Note saved",
"ui.activity.dealCreated": "Deal created",
"ui.activity.dealRenamed": "Deal renamed",
"ui.activity.generic": "Activity",
"ui.updated": "Entry updated",
"ui.added": "Entry added",
"ui.minutesValue": "{minutes} min",
"ui.meta.billable": "billable",
"ui.meta.nonBillable": "non-billable",
"ui.meta.activities.one": "{count} linked activity",
"ui.meta.activities.many": "{count} linked activities",
"ui.meta.todo": "linked todo"
}
+63
View File
@@ -0,0 +1,63 @@
{
"manifest.name": "Журнал",
"manifest.description": "Журнал Дела с ручными записями и необязательными связями с активностью.",
"contributions.views.verstak.journal.view.title": "Журнал",
"contributions.sidebarItems.verstak.journal.sidebar.title": "Журнал",
"contributions.workspaceItems.verstak.journal.workspace.title": "Журнал",
"ui.loading": "Загрузка журнала...",
"ui.title": "Журнал",
"ui.workspaceTitle": "Журнал · {workspace}",
"ui.add": "Добавить",
"ui.saveError": "Не удалось сохранить журнал. Повторите попытку.",
"ui.aggregating": "Сбор записей о работе",
"ui.loadError": "Не удалось загрузить журнал. Повторите попытку.",
"ui.ready": "Готово",
"ui.workItem": "Выполненная работа",
"ui.body": "Описание",
"ui.editEntry": "Изменить запись журнала",
"ui.reviewCandidate": "Проверить возможную запись журнала",
"ui.fromTodo": "Создать запись журнала из выполненной задачи",
"ui.addEntry": "Добавить запись журнала",
"ui.date": "Дата",
"ui.minutes": "Минуты",
"ui.fieldTitle": "Название",
"ui.billable": "Оплачиваемая работа",
"ui.cancel": "Отмена",
"ui.saveChanges": "Сохранить изменения",
"ui.addEntryShort": "Добавить запись",
"ui.titleRequired": "Введите название",
"ui.deleted": "Запись удалена",
"ui.empty": "Записей о работе пока нет.",
"ui.edit": "Изменить",
"ui.delete": "Удалить",
"ui.entryCount.one": "{count} запись",
"ui.entryCount.many": "Записей: {count}",
"ui.candidate.title": "Возможная запись журнала",
"ui.candidate.deal": "Дело: {deal}",
"ui.candidate.time": "Время: {start} – {end}",
"ui.candidate.duration": "Расчётная длительность: {minutes} мин",
"ui.candidate.activities": "Активностей: {count}",
"ui.candidate.linkedActivities": "Связанные активности",
"ui.candidate.duplicate": "Запись журнала уже ссылается на этого кандидата",
"ui.todo.title": "Выполненная задача",
"ui.todo.completed": "Выполнена: {time}",
"ui.todo.duplicate": "Запись журнала уже ссылается на эту задачу",
"ui.activity.captureLink": "Сохранена ссылка",
"ui.activity.captureSelection": "Захвачено выделение",
"ui.activity.capturePage": "Сохранена страница",
"ui.activity.fileCreated": "Создан файл",
"ui.activity.fileUpdated": "Изменён файл",
"ui.activity.fileDeleted": "Удалён файл",
"ui.activity.noteSaved": "Сохранена заметка",
"ui.activity.dealCreated": "Создано Дело",
"ui.activity.dealRenamed": "Переименовано Дело",
"ui.activity.generic": "Активность",
"ui.updated": "Запись обновлена",
"ui.added": "Запись добавлена",
"ui.minutesValue": "{minutes} мин",
"ui.meta.billable": "оплачиваемая",
"ui.meta.nonBillable": "неоплачиваемая",
"ui.meta.activities.one": "{count} связанная активность",
"ui.meta.activities.many": "Связанных активностей: {count}",
"ui.meta.todo": "связанная задача"
}
+52
View File
@@ -0,0 +1,52 @@
{
"schemaVersion": 1,
"id": "verstak.journal",
"name": "Journal",
"version": "0.1.0",
"apiVersion": "0.1.0",
"description": "Deal-scoped journal with user-authored entries and optional Activity links.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official",
"icon": "book-open",
"provides": [
"worklog",
"journal",
"report.worklog"
],
"permissions": [
"events.publish",
"files.read",
"storage.namespace",
"ui.register"
],
"frontend": {
"entry": "frontend/src/index.js"
},
"contributes": {
"views": [
{
"id": "verstak.journal.view",
"title": "Journal",
"icon": "book-open",
"component": "JournalView"
}
],
"sidebarItems": [
{
"id": "verstak.journal.sidebar",
"title": "Journal",
"icon": "book-open",
"view": "verstak.journal.view",
"position": 30
}
],
"workspaceItems": [
{
"id": "verstak.journal.workspace",
"title": "Journal",
"icon": "book-open",
"component": "JournalView"
}
]
}
}
+791
View File
@@ -0,0 +1,791 @@
/* ===========================================================
Notes Plugin — Verstak v2 Frontend Bundle
Contract: window.VerstakPluginRegister(id, { components })
=========================================================== */
(function () {
'use strict';
function injectStyles() {
if (document.getElementById('notes-style-injected')) return;
var style = document.createElement('style');
style.id = 'notes-style-injected';
style.textContent = STYLES;
document.head.appendChild(style);
}
var STYLES = [
'.notes-root{display:flex;flex-direction:column;height:100%;min-height:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;color:var(--vt-color-text-primary,#f4f7fb);background:var(--vt-color-background,#101020);outline:none}',
'.notes-toolbar{display:flex;align-items:center;gap:.5rem;min-height:2.75rem;padding:.5rem .75rem;border-bottom:1px solid var(--vt-color-border,#202b46);flex-shrink:0;background:var(--vt-color-surface-muted,#111629);flex-wrap:wrap}',
'.notes-btn{font-size:.78rem;padding:.36rem .7rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-md,6px);background:var(--vt-color-surface-hover,#1b2440);color:var(--vt-color-text-secondary,#b7c0d4);cursor:pointer;display:inline-flex;align-items:center;gap:.35rem}',
'.notes-btn:hover{background:var(--vt-color-surface-hover,#1b2440);border-color:var(--vt-color-accent,#4ecca3);color:var(--vt-color-text-primary,#f4f7fb)}',
'.notes-btn:focus-visible{outline:0;box-shadow:var(--vt-focus-ring,0 0 0 2px rgba(78,204,163,.34))}',
'.notes-btn:disabled{opacity:.45;cursor:default}',
'.notes-btn.primary{background:var(--vt-color-accent,#4ecca3);border-color:var(--vt-color-accent,#4ecca3);color:#101827}',
'.notes-btn.primary:hover{background:#63d9b3;color:#101827}',
'.notes-filter,.notes-sort{font-size:.78rem;padding:.32rem .5rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:#0f1424;color:var(--vt-color-text-primary,#f4f7fb);outline:none}',
'.notes-filter{width:11rem}',
'.notes-sort{width:8rem;appearance:none;background-color:#0f1424;background-image:linear-gradient(45deg,transparent 50%,#8b8ba8 50%),linear-gradient(135deg,#8b8ba8 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.6rem}.notes-sort option{background:#0f1424;color:var(--vt-color-text-primary,#f4f7fb)}',
'.notes-filter:focus,.notes-sort:focus{border-color:var(--vt-color-accent,#4ecca3);box-shadow:var(--vt-focus-ring,0 0 0 2px rgba(78,204,163,.34))}',
'.notes-list{flex:1;overflow:auto;min-height:0}',
'.notes-item{display:flex;align-items:center;gap:.5rem;padding:.5rem .75rem;border-bottom:1px solid rgba(32,43,70,.72);cursor:pointer;font-size:.85rem}',
'.notes-item:hover{background:var(--vt-color-surface-hover,#1b2440)}',
'.notes-item.selected{background:var(--vt-color-surface-selected,rgba(78,204,163,.14));box-shadow:inset 2px 0 0 var(--vt-color-accent,#4ecca3)}',
'.notes-item-icon{width:1.25rem;height:1.25rem;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;color:var(--vt-color-text-muted,#7f8aa3)}',
'.notes-item-icon svg{width:16px;height:16px;display:block;fill:currentColor}',
'.notes-item-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
'.notes-item-actions{display:flex;gap:.25rem;opacity:.78;transition:opacity .15s}',
'.notes-item:hover .notes-item-actions{opacity:1}',
'.notes-item-btn{width:1.65rem;height:1.65rem;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--vt-color-border,#202b46);border-radius:var(--vt-radius-sm,4px);background:transparent;color:var(--vt-color-text-muted,#7f8aa3);cursor:pointer;padding:0;font-size:.7rem}',
'.notes-item-btn:hover{background:var(--vt-color-surface-hover,#1b2440);border-color:var(--vt-color-accent,#4ecca3);color:var(--vt-color-accent,#4ecca3)}',
'.notes-item-btn:focus-visible{outline:0;box-shadow:var(--vt-focus-ring,0 0 0 2px rgba(78,204,163,.34))}',
'.notes-item-btn svg{width:14px;height:14px;display:block;fill:currentColor}',
'.notes-empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;color:var(--vt-color-text-muted,#7f8aa3);font-size:.85rem;padding:2rem;gap:.5rem;text-align:center}',
'.notes-empty-hint{font-size:.75rem;color:var(--vt-color-text-muted,#7f8aa3)}',
'.notes-error{flex:1;display:flex;align-items:center;justify-content:center;color:var(--vt-color-danger,#e94560);padding:1rem;font-size:.85rem}',
'.notes-title-bar{padding:.4rem .75rem;font-size:.72rem;color:var(--vt-color-text-muted,#7f8aa3);background:var(--vt-color-surface-muted,#111629);border-bottom:1px solid var(--vt-color-border,#202b46);text-transform:uppercase;letter-spacing:.04em}',
'.notes-modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:10000;display:flex;align-items:center;justify-content:center}',
'.notes-modal{width:380px;max-width:90vw;padding:20px;background:#1a1a2e;border:1px solid #333;border-radius:10px;color:#e0e0e0;font-family:inherit;box-shadow:0 12px 40px rgba(0,0,0,.5)}',
'.notes-modal-title{font-size:.9rem;font-weight:600;margin-bottom:12px}',
'.notes-modal-msg{font-size:.82rem;color:#aaa;margin-bottom:16px;word-wrap:break-word}',
'.notes-modal-form{display:grid;gap:.55rem}',
'.notes-modal-input{width:100%;font:inherit;font-size:.84rem;padding:.5rem .6rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:#0f1424;color:var(--vt-color-text-primary,#f4f7fb);outline:none}',
'.notes-modal-input:focus{border-color:var(--vt-color-accent,#4ecca3);box-shadow:var(--vt-focus-ring,0 0 0 2px rgba(78,204,163,.34))}',
'.notes-modal-input[aria-invalid="true"]{border-color:var(--vt-color-danger,#e94560)}',
'.notes-modal-error{display:none;font-size:.78rem;line-height:1.35;color:#ffc6ce}',
'.notes-modal-actions{display:flex;justify-content:flex-end;gap:8px}',
'.notes-modal-btn{font-size:.8rem;padding:.35rem .9rem;border:1px solid #333;border-radius:5px;cursor:pointer;font-family:inherit}',
'.notes-modal-btn.cancel{background:#2a2a4e;color:#ccc}',
'.notes-modal-btn.cancel:hover{background:#3a3a5e}',
'.notes-modal-btn.confirm{background:#4ecca3;color:#111;border-color:#4ecca3}',
'.notes-modal-btn.confirm:hover{background:#3dbb92}',
'.notes-modal-btn.danger{background:#7a2a35;color:#fff;border-color:#b84a5a}',
'.notes-modal-btn.danger:hover{background:#963545}',
'.notes-status{font-size:.72rem;color:#8b8ba8;padding:.15rem .5rem;white-space:nowrap}',
'.notes-status.success{color:#4ecca3}',
'.notes-status.error{color:#e74c3c}',
'.notes-status.loading{color:#79c0ff}',
'@media(max-width:600px){.notes-toolbar{flex-direction:column;align-items:stretch}}'
].join('\n');
function el(tag, attrs, children) {
var elem = document.createElement(tag);
if (attrs) {
Object.keys(attrs).forEach(function (k) {
if (k === 'className') elem.className = attrs[k];
else if (k === 'style' && typeof attrs[k] === 'object') Object.assign(elem.style, attrs[k]);
else if (k.slice(0, 2) === 'on') elem.addEventListener(k.slice(2).toLowerCase(), attrs[k]);
else if (k === 'innerHTML') elem.innerHTML = attrs[k];
else if (k === 'textContent') elem.textContent = attrs[k];
else elem.setAttribute(k, attrs[k]);
});
}
if (children) {
(Array.isArray(children) ? children : [children]).forEach(function (c) {
if (c == null) return;
elem.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
});
}
return elem;
}
function svgIcon(path) {
return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><path d="' + path + '" fill="currentColor"/></svg>';
}
var ICONS = {
note: 'M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm-1 9h-1v1c0 .83-.67 1.5-1.5 1.5S9 12.83 9 12V9.5c0-.83.67-1.5 1.5-1.5S12 8.67 12 9.5v1h1v-1c0-.83.67-1.5 1.5-1.5s1.5.67 1.5 1.5v2.5c0 .83-.67 1.5-1.5 1.5s-1.5-.67-1.5-1.5v-1z',
overview: 'M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-1 9h-4v4h-2v-4H9V9h4V5h2v4h4v2z',
add: 'M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z',
rename: 'M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34a.9959.9959 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z',
open: 'M14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7zM5 5h6V3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2v-6h-2v6H5V5z',
search: 'M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z',
trash: 'M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM8 9h8v10H8V9zm7.5-5-1-1h-5l-1 1H5v2h14V4z'
};
function iconSvg(name) {
return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><path d="' + (ICONS[name] || ICONS.note) + '" fill="currentColor"/></svg>';
}
function fileName(path) {
var parts = String(path || '').split('/');
return parts[parts.length - 1] || '';
}
function cleanPath(path) {
return String(path || '').split('/').filter(Boolean).join('/');
}
function parentPath(path) {
path = cleanPath(path);
var idx = path.lastIndexOf('/');
return idx === -1 ? '' : path.slice(0, idx);
}
function notesFolderPath(parent) {
parent = cleanPath(parent);
return parent ? parent + '/Notes' : 'Notes';
}
function titleFromFilename(filename) {
filename = String(filename || '').trim();
if (/\.markdown$/i.test(filename)) filename = filename.slice(0, -9);
else if (/\.md$/i.test(filename)) filename = filename.slice(0, -3);
return filename.replace(/_/g, ' ').trim();
}
function normalizeNoteFilename(title) {
var original = String(title == null ? '' : title);
var value = original.trim();
if (/\.markdown$/i.test(value) && value.length > 9) value = value.slice(0, -9);
else if (/\.md$/i.test(value) && value.length > 3) value = value.slice(0, -3);
if (!value) throw new Error('note title must not be empty');
value = value.replace(/\s+/g, '_');
value = value.replace(/[\u2012\u2013\u2014\u2015\u2212]/g, '-');
value = value.replace(/[<>:"/\\|?*\x00-\x1f\x7f]/g, '');
var out = '';
for (var i = 0; i < value.length; i++) {
var ch = value.charAt(i);
if (/[A-Za-z0-9._-]/.test(ch) || /[\p{L}\p{N}]/u.test(ch)) out += ch;
else if (/\S/.test(ch)) out += '_';
}
out = out.replace(/[_.-]+/g, '_').replace(/^[._\-\s]+|[._\-\s]+$/g, '');
if (!out) throw new Error('note title normalizes to an empty filename');
return out + '.md';
}
function isConflictError(err) {
var msg = (err && err.message) ? err.message : String(err || '');
return /conflict|already exists|exists/i.test(msg);
}
function isNotFoundError(err) {
var msg = (err && err.message) ? err.message : String(err || '');
return /not.?found|does not exist|no such/i.test(msg);
}
var NotesView = {
mount: function (containerEl, props, api) {
injectStyles();
containerEl.innerHTML = '';
containerEl.className = 'notes-root';
containerEl.setAttribute('data-plugin-id', 'verstak.notes');
var workspaceNode = props && props.workspaceNode;
var workspaceRoot = (workspaceNode && (workspaceNode.rootPath || workspaceNode.name || workspaceNode.id)) || '';
var workspaceName = workspaceRoot || (workspaceNode && (workspaceNode.name || workspaceNode.title)) || '';
var notes = [];
var selectedPath = '';
var statusText = '';
var statusClass = '';
var disposed = false;
var noteActions = [];
var filterText = '';
var sortMode = 'title-asc';
var renameTarget = null;
function tr(key, params, fallback) {
if (api && api.i18n && typeof api.i18n.t === 'function') return api.i18n.t(key, params, fallback);
return fallback || key;
}
function notesParent() {
return workspaceRoot || '';
}
function noteFromEntry(parent, entry) {
return {
title: titleFromFilename(entry.name),
filename: entry.name,
path: entry.relativePath,
parentPath: cleanPath(parent),
modifiedAt: entry.modifiedAt || ''
};
}
function sortNotes(list, mode) {
return list.sort(function (a, b) {
var byTitle = String(a.title || '').toLowerCase().localeCompare(String(b.title || '').toLowerCase());
if (mode === 'title-desc') return -byTitle;
return byTitle;
});
}
function listNotes(parent) {
return api.files.list(notesFolderPath(parent)).then(function (entries) {
return (entries || []).filter(function (entry) {
return entry.type === 'file' && /\.(md|markdown)$/i.test(entry.name || '');
}).map(function (entry) {
return noteFromEntry(parent, entry);
});
}).catch(function (err) {
if (isNotFoundError(err)) return [];
throw err;
});
}
function ensureNotesFolder(parent) {
return api.files.createFolder(notesFolderPath(parent)).catch(function (err) {
if (!isConflictError(err)) throw err;
});
}
function createNote(parent, title) {
var trimmedTitle = String(title || '').trim();
if (!trimmedTitle) return Promise.reject(new Error('note title must not be empty'));
var path = notesFolderPath(parent) + '/' + normalizeNoteFilename(trimmedTitle);
return ensureNotesFolder(parent).then(function () {
return api.files.writeText(path, '# ' + trimmedTitle + '\n', {
createIfMissing: true,
overwrite: false
}).then(function () {
return { path: path };
}).catch(function (writeErr) {
if (isConflictError(writeErr)) return { path: path, conflict: true };
throw writeErr;
});
});
}
function renameNote(notePath, newTitle) {
var trimmedTitle = String(newTitle || '').trim();
if (!trimmedTitle) return Promise.reject(new Error('note title must not be empty'));
var newPath = parentPath(notePath) + '/' + normalizeNoteFilename(trimmedTitle);
if (newPath === notePath) return Promise.resolve({ path: notePath });
return api.files.metadata(newPath).then(function () {
return { path: newPath, conflict: true };
}).catch(function (err) {
if (!isNotFoundError(err)) throw err;
return api.files.move(notePath, newPath, { overwrite: false }).then(function () {
return { path: newPath };
}).catch(function (moveErr) {
if (isConflictError(moveErr)) return { path: newPath, conflict: true };
throw moveErr;
});
});
}
// ─── UI Elements ────────────────────────────────────────
var toolbar = el('div', { className: 'notes-toolbar' });
var createBtn = el('button', { className: 'notes-btn primary', 'data-action': 'create', innerHTML: iconSvg('add') + ' ' + tr('ui.newNote', null, 'New Note') });
var filterInput = el('input', { className: 'notes-filter', 'data-notes-filter': '', placeholder: tr('ui.filter', null, 'Filter notes') });
var sortSelect = el('select', { className: 'notes-sort', 'data-notes-sort': '' }, [
el('option', { value: 'title-asc' }, ['A-Z']),
el('option', { value: 'title-desc' }, ['Z-A'])
]);
var statusEl = el('span', { className: 'notes-status' });
toolbar.appendChild(createBtn);
toolbar.appendChild(filterInput);
toolbar.appendChild(sortSelect);
toolbar.appendChild(el('span', { style: { flex: '1' } }));
toolbar.appendChild(statusEl);
containerEl.appendChild(toolbar);
var titleBar = el('div', { className: 'notes-title-bar' }, [tr('ui.title', { workspace: workspaceName }, 'Notes in ' + workspaceName)]);
containerEl.appendChild(titleBar);
var listContainer = el('div', { className: 'notes-list', 'data-notes-list': '' });
containerEl.appendChild(listContainer);
var createModal = null;
var createInput = null;
var createError = null;
var renameModal = null;
var renameInput = null;
var renameError = null;
// ─── Core Functions ─────────────────────────────────────
function setStatus(text, cls) {
statusText = text;
statusClass = cls || '';
if (statusEl) {
statusEl.textContent = text;
statusEl.className = 'notes-status' + (cls ? ' ' + cls : '');
}
if (text) {
setTimeout(function () {
if (!disposed && statusEl && statusEl.textContent === text) {
statusEl.textContent = '';
statusEl.className = 'notes-status';
}
}, 4000);
}
}
function userFacingError(key, fallback, err) {
if (typeof console !== 'undefined' && typeof console.warn === 'function') {
console.warn('[verstak.notes] ' + key, err);
}
return tr(key, null, fallback);
}
function loadNotes() {
listContainer.innerHTML = '';
listContainer.appendChild(el('div', { className: 'notes-empty' }, [tr('ui.loading', null, 'Loading...')]));
var parent = notesParent();
listNotes(parent).then(function (result) {
if (disposed) return;
notes = result || [];
renderList();
}).catch(function (err) {
if (disposed) return;
renderEmpty(userFacingError('ui.loadError', 'Could not load notes. Please try again.', err));
});
}
function loadContributionActions() {
var contributions = api && api.contributions;
if (!contributions || typeof contributions.list !== 'function') return;
contributions.list('noteActions').then(function (result) {
if (disposed) return;
noteActions = (result || []).filter(function (item) {
return item && item.pluginId && item.handler && item.label;
});
renderList();
}).catch(function (err) {
console.error('[notes] contribution actions:', err);
});
}
function isNotesEvent(event) {
var payload = (event && event.payload) || {};
var changedPath = cleanPath(payload.path || '');
if (!changedPath) return true;
var parent = notesParent();
return changedPath === parent || changedPath.indexOf(parent + '/') === 0;
}
function actionInitial(action) {
var label = String(action && action.label || '').trim();
return label ? label.charAt(0).toUpperCase() : '+';
}
function executeContributionAction(action, note) {
if (!action || !note || !api.commands || typeof api.commands.executeFor !== 'function') return;
setStatus(action.label + '...', 'loading');
api.commands.executeFor(action.pluginId, action.handler, {
source: 'notes',
actionId: action.id,
path: note.path,
note: note,
notesScopePath: notesParent(),
workspaceRootPath: workspaceRoot
}).then(function () {
if (!disposed) setStatus(action.label + ' complete', 'success');
}).catch(function (err) {
if (!disposed) setStatus(userFacingError('ui.actionError', 'Could not complete this note action. Please try again.', err), 'error');
});
}
function visibleNotes() {
var q = filterText.trim().toLowerCase();
var filtered = (notes || []).filter(function (note) {
if (!q) return true;
return String(note.title || '').toLowerCase().indexOf(q) !== -1 ||
String(note.path || '').toLowerCase().indexOf(q) !== -1;
});
return sortNotes(filtered, sortMode);
}
function renderList() {
listContainer.innerHTML = '';
if (!notes || notes.length === 0) {
renderEmpty(tr('ui.empty', null, 'No notes yet'));
return;
}
var shown = visibleNotes();
if (shown.length === 0) {
renderEmpty(tr('ui.noMatches', null, 'No matching notes'), tr('ui.clearFilterHint', null, 'Clear the filter to show all notes'));
return;
}
shown.forEach(function (note) {
var actionButtons = [
el('button', {
className: 'notes-item-btn',
title: tr('ui.open', null, 'Open'),
'aria-label': tr('ui.open', null, 'Open'),
'data-note-action': 'open',
innerHTML: iconSvg('open'),
onClick: function (e) { e.stopPropagation(); openNote(note); }
}),
el('button', {
className: 'notes-item-btn',
title: tr('ui.rename', null, 'Rename'),
'aria-label': tr('ui.rename', null, 'Rename'),
'data-note-action': 'rename',
innerHTML: iconSvg('rename'),
onClick: function (e) { e.stopPropagation(); beginRename(note); }
}),
el('button', {
className: 'notes-item-btn',
title: tr('ui.trash', null, 'Move to Trash'),
'aria-label': tr('ui.trash', null, 'Move to Trash'),
'data-note-action': 'trash',
innerHTML: iconSvg('trash'),
onClick: function (e) { e.stopPropagation(); confirmTrashNote(note); }
})
];
noteActions.forEach(function (action) {
actionButtons.push(el('button', {
className: 'notes-item-btn',
title: action.label,
'aria-label': action.label,
'data-note-contribution-action': action.id,
textContent: actionInitial(action),
onClick: function (e) { e.stopPropagation(); executeContributionAction(action, note); }
}));
});
var row = el('div', {
className: 'notes-item' + (note.path === selectedPath ? ' selected' : ''),
'data-note-path': note.path,
'data-note-title': note.title,
onClick: function () { selectNote(note); },
onDblclick: function () { openNote(note); }
}, [
el('span', { className: 'notes-item-icon', innerHTML: iconSvg('note') }),
el('span', { className: 'notes-item-name', textContent: note.title || fileName(note.path), title: note.title || note.path }),
el('span', { className: 'notes-item-actions' }, actionButtons)
]);
listContainer.appendChild(row);
});
}
function renderEmpty(msg, hint) {
listContainer.innerHTML = '';
listContainer.appendChild(el('div', { className: 'notes-empty' }, [
el('div', { innerHTML: iconSvg('note') }),
el('div', {}, [msg]),
el('div', { className: 'notes-empty-hint' }, [hint || tr('ui.emptyHint', null, 'Click "New Note" to create one')])
]));
}
function selectNote(note) {
selectedPath = note.path;
renderList();
}
function openNote(note) {
if (!note) return;
var ext = '.md';
api.workbench.openResource({
kind: 'vault-file',
path: note.path,
mode: 'view',
extension: ext,
context: {
sourcePluginId: 'verstak.notes',
sourceView: 'notes',
isInsideNotesFolder: true,
notesMode: true,
notesScopePath: notesParent()
}
}).catch(function (err) { console.error('[notes] openResource:', err); });
}
function conflictMessage(title, existingPath) {
return [
tr('ui.conflictMessage', { title: title }, 'A note with the title "' + title + '" already exists.'),
existingPath ? tr('ui.existingFile', { path: existingPath }, ' Existing file: ' + existingPath + '.') : '',
tr('ui.chooseDifferent', null, ' Please choose a different title.')
].join('');
}
function showNoteFormModal(mode, note) {
var isRename = mode === 'rename';
var modalTitle = isRename
? tr('ui.renameTitle', null, 'Rename note')
: tr('ui.createTitle', null, 'Create note');
var overlay = el('div', {
className: 'notes-modal-overlay',
role: 'presentation'
});
overlay.setAttribute('data-notes-' + mode + '-modal', '');
var input = el('input', {
className: 'notes-modal-input',
type: 'text',
value: isRename ? (note.title || fileName(note.path)) : '',
placeholder: isRename
? tr('ui.newTitle', null, 'New title')
: tr('ui.noteTitle', null, 'Note title'),
autocomplete: 'off',
'aria-invalid': 'false'
});
input.setAttribute('data-notes-' + mode + '-input', '');
input.value = isRename ? (note.title || fileName(note.path)) : '';
var error = el('div', {
className: 'notes-modal-error',
role: 'alert'
});
error.setAttribute('data-notes-' + mode + '-error', '');
var confirm = el('button', {
className: 'notes-modal-btn confirm',
type: 'button',
textContent: isRename ? tr('ui.rename', null, 'Rename') : tr('ui.create', null, 'Create'),
onClick: function () {
if (isRename) confirmRename();
else confirmCreate();
}
});
var cancel = el('button', {
className: 'notes-modal-btn cancel',
type: 'button',
textContent: tr('ui.cancel', null, 'Cancel'),
onClick: function () {
if (isRename) hideRename();
else hideCreate();
}
});
function handleFormKeydown(event) {
if (event.key === 'Enter') {
if (event.preventDefault) event.preventDefault();
if (isRename) confirmRename();
else confirmCreate();
}
if (event.key === 'Escape') {
if (event.preventDefault) event.preventDefault();
if (isRename) hideRename();
else hideCreate();
}
}
input.addEventListener('keydown', handleFormKeydown);
var modal = el('div', {
className: 'notes-modal notes-modal-form',
role: 'dialog',
'aria-modal': 'true',
'aria-label': modalTitle
}, [
el('div', { className: 'notes-modal-title', textContent: modalTitle }),
input,
error,
el('div', { className: 'notes-modal-actions' }, [cancel, confirm])
]);
overlay.appendChild(modal);
document.body.appendChild(overlay);
if (isRename) {
renameInput = input;
renameError = error;
} else {
createInput = input;
createError = error;
}
input.focus();
if (isRename && input.select) input.select();
return overlay;
}
// ─── Create ────────────────────────────────────────────
function showCreate() {
hideCreate();
createModal = showNoteFormModal('create');
}
function hideCreate() {
if (createModal) createModal.remove();
createModal = null;
createInput = null;
createError = null;
}
function setCreateError(message) {
if (!createError || !createInput) return;
createError.textContent = message || '';
createError.style.display = message ? 'block' : 'none';
createInput.setAttribute('aria-invalid', message ? 'true' : 'false');
}
function confirmCreate() {
if (!createInput) return;
var title = createInput.value.trim();
if (!title) {
setCreateError(tr('ui.titleRequired', null, 'Enter a note title.'));
createInput.focus();
return;
}
setStatus(tr('ui.creating', null, 'Creating note...'), 'loading');
var parent = notesParent();
createNote(parent, title).then(function (data) {
if (disposed) return;
data = data || {};
if (data.conflict) {
setCreateError(conflictMessage(title, data.path));
createInput.focus();
return;
}
hideCreate();
setStatus(tr('ui.created', null, 'Note created'), 'success');
loadNotes();
// Open the newly created note
if (data.path) {
api.workbench.openResource({
kind: 'vault-file',
path: data.path,
mode: 'edit',
extension: '.md',
context: {
sourcePluginId: 'verstak.notes',
sourceView: 'notes',
isInsideNotesFolder: true,
notesMode: true,
notesScopePath: parent
}
}).catch(function () {});
}
}).catch(function (err) {
setCreateError(userFacingError('ui.createError', 'Could not create the note. Please try again.', err));
});
}
// ─── Rename ─────────────────────────────────────────────
function beginRename(note) {
hideRename();
renameTarget = note;
renameModal = showNoteFormModal('rename', note);
}
function hideRename() {
renameTarget = null;
if (renameModal) renameModal.remove();
renameModal = null;
renameInput = null;
renameError = null;
}
function setRenameError(message) {
if (!renameError || !renameInput) return;
renameError.textContent = message || '';
renameError.style.display = message ? 'block' : 'none';
renameInput.setAttribute('aria-invalid', message ? 'true' : 'false');
}
function confirmRename() {
if (!renameTarget || !renameInput) return;
var newTitle = renameInput.value.trim();
if (!newTitle) {
setRenameError(tr('ui.titleRequired', null, 'Enter a note title.'));
renameInput.focus();
return;
}
setStatus(tr('ui.renaming', null, 'Renaming...'), 'loading');
renameNote(renameTarget.path, newTitle).then(function (data) {
if (disposed) return;
data = data || {};
if (data.conflict) {
setRenameError(conflictMessage(newTitle, data.path));
renameInput.focus();
return;
}
hideRename();
setStatus(tr('ui.renamed', null, 'Note renamed'), 'success');
loadNotes();
}).catch(function (err) {
setRenameError(userFacingError('ui.renameError', 'Could not rename the note. Please try again.', err));
});
}
// ─── Trash ──────────────────────────────────────────────
function confirmTrashNote(note) {
if (!note) return;
showTrashModal(note).then(function (confirmed) {
if (!confirmed || disposed) return;
setStatus(tr('ui.trashing', null, 'Moving note to trash...'), 'loading');
api.files.trash(note.path).then(function () {
if (disposed) return;
if (selectedPath === note.path) selectedPath = '';
setStatus(tr('ui.trashed', null, 'Note moved to trash'), 'success');
loadNotes();
}).catch(function (err) {
setStatus(userFacingError('ui.trashError', 'Could not move the note to trash. Please try again.', err), 'error');
});
});
}
function showTrashModal(note) {
return new Promise(function (resolve) {
var overlay = el('div', { className: 'notes-modal-overlay' });
function close(value) {
overlay.remove();
resolve(value);
}
var modal = el('div', { className: 'notes-modal' }, [
el('div', { className: 'notes-modal-title' }, [tr('ui.trashTitle', null, 'Move Note to Trash')]),
el('div', { className: 'notes-modal-msg' }, [
tr('ui.trashConfirm', { title: note.title || fileName(note.path) }, 'Move "' + (note.title || fileName(note.path)) + '" to trash?')
]),
el('div', { className: 'notes-modal-actions' }, [
el('button', { className: 'notes-modal-btn cancel', textContent: tr('ui.cancel', null, 'Cancel'), onClick: function () { close(false); } }),
el('button', { className: 'notes-modal-btn danger', 'data-notes-confirm-trash': '', textContent: tr('ui.trash', null, 'Move to Trash'), onClick: function () { close(true); } })
])
]);
overlay.appendChild(modal);
document.body.appendChild(overlay);
});
}
// ─── Event Wiring ───────────────────────────────────────
createBtn.addEventListener('click', showCreate);
filterInput.addEventListener('input', function () {
filterText = filterInput.value;
renderList();
});
sortSelect.addEventListener('change', function () {
sortMode = sortSelect.value || 'title-asc';
renderList();
});
// ─── Init ───────────────────────────────────────────────
loadContributionActions();
loadNotes();
var localeUnsubscribe = null;
if (api.i18n && typeof api.i18n.onDidChangeLocale === 'function') {
localeUnsubscribe = api.i18n.onDidChangeLocale(function () {
createBtn.innerHTML = iconSvg('add') + ' ' + tr('ui.newNote', null, 'New Note');
filterInput.setAttribute('placeholder', tr('ui.filter', null, 'Filter notes'));
titleBar.textContent = tr('ui.title', { workspace: workspaceName }, 'Notes in ' + workspaceName);
renderList();
});
}
var fileChangedUnsubscribe = null;
if (api.events && typeof api.events.subscribe === 'function') {
api.events.subscribe('file.changed', function (event) {
if (disposed || !isNotesEvent(event)) return;
loadNotes();
}).then(function (unsubscribe) {
fileChangedUnsubscribe = unsubscribe;
}).catch(function (err) {
console.error('[notes] file.changed subscription:', err);
});
}
containerEl.__notesCleanup = function () {
disposed = true;
hideCreate();
hideRename();
if (typeof localeUnsubscribe === 'function') localeUnsubscribe();
if (typeof fileChangedUnsubscribe === 'function') fileChangedUnsubscribe();
};
},
unmount: function (containerEl) {
if (containerEl.__notesCleanup) {
containerEl.__notesCleanup();
containerEl.__notesCleanup = null;
}
containerEl.innerHTML = '';
}
};
window.VerstakPluginRegister('verstak.notes', {
components: { NotesView: NotesView }
});
})();
+40
View File
@@ -0,0 +1,40 @@
{
"manifest.name": "Notes",
"manifest.description": "Deal-scoped notes manager for Markdown files in canonical Notes folders with create, rename, trash, and Workbench integration.",
"contributions.workspaceItems.verstak.notes.workspace.title": "Notes",
"ui.newNote": "New Note",
"ui.filter": "Filter notes",
"ui.title": "Notes in {workspace}",
"ui.noteTitle": "Note title",
"ui.createTitle": "Create note",
"ui.renameTitle": "Rename note",
"ui.titleRequired": "Enter a note title.",
"ui.create": "Create",
"ui.cancel": "Cancel",
"ui.newTitle": "New title",
"ui.rename": "Rename",
"ui.loading": "Loading...",
"ui.empty": "No notes yet",
"ui.noMatches": "No matching notes",
"ui.clearFilterHint": "Clear the filter to show all notes",
"ui.open": "Open",
"ui.trash": "Move to Trash",
"ui.emptyHint": "Click \"New Note\" to create one",
"ui.creating": "Creating note...",
"ui.created": "Note created",
"ui.renaming": "Renaming...",
"ui.renamed": "Note renamed",
"ui.trashing": "Moving note to trash...",
"ui.trashed": "Note moved to trash",
"ui.conflictTitle": "Name Conflict",
"ui.conflictMessage": "A note with the title \"{title}\" already exists.",
"ui.existingFile": " Existing file: {path}.",
"ui.chooseDifferent": " Please choose a different title.",
"ui.trashTitle": "Move Note to Trash",
"ui.trashConfirm": "Move \"{title}\" to trash?",
"ui.loadError": "Could not load notes. Please try again.",
"ui.actionError": "Could not complete this note action. Please try again.",
"ui.createError": "Could not create the note. Please try again.",
"ui.renameError": "Could not rename the note. Please try again.",
"ui.trashError": "Could not move the note to trash. Please try again."
}
+40
View File
@@ -0,0 +1,40 @@
{
"manifest.name": "Заметки",
"manifest.description": "Менеджер Markdown-заметок Дела с созданием, переименованием и корзиной.",
"contributions.workspaceItems.verstak.notes.workspace.title": "Заметки",
"ui.newNote": "Новая заметка",
"ui.filter": "Фильтр заметок",
"ui.title": "Заметки · {workspace}",
"ui.noteTitle": "Название заметки",
"ui.createTitle": "Создать заметку",
"ui.renameTitle": "Переименовать заметку",
"ui.titleRequired": "Введите название заметки.",
"ui.create": "Создать",
"ui.cancel": "Отмена",
"ui.newTitle": "Новое название",
"ui.rename": "Переименовать",
"ui.loading": "Загрузка...",
"ui.empty": "Заметок пока нет",
"ui.noMatches": "Подходящих заметок нет",
"ui.clearFilterHint": "Сбросьте фильтр, чтобы показать все заметки",
"ui.open": "Открыть",
"ui.trash": "Переместить в корзину",
"ui.emptyHint": "Нажмите «Новая заметка», чтобы создать первую",
"ui.creating": "Создание заметки...",
"ui.created": "Заметка создана",
"ui.renaming": "Переименование...",
"ui.renamed": "Заметка переименована",
"ui.trashing": "Перемещение заметки в корзину...",
"ui.trashed": "Заметка перемещена в корзину",
"ui.conflictTitle": "Конфликт названия",
"ui.conflictMessage": "Заметка с названием «{title}» уже существует.",
"ui.existingFile": " Существующий файл: {path}.",
"ui.chooseDifferent": " Выберите другое название.",
"ui.trashTitle": "Переместить заметку в корзину",
"ui.trashConfirm": "Переместить «{title}» в корзину?",
"ui.loadError": "Не удалось загрузить заметки. Повторите попытку.",
"ui.actionError": "Не удалось выполнить действие с заметкой. Повторите попытку.",
"ui.createError": "Не удалось создать заметку. Повторите попытку.",
"ui.renameError": "Не удалось переименовать заметку. Повторите попытку.",
"ui.trashError": "Не удалось переместить заметку в корзину. Повторите попытку."
}
+39
View File
@@ -0,0 +1,39 @@
{
"schemaVersion": 1,
"id": "verstak.notes",
"name": "Notes",
"version": "0.1.0",
"apiVersion": "0.1.0",
"description": "Deal-scoped notes manager for Markdown files in canonical Notes folders with create, rename, trash, and Workbench integration.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official",
"icon": "edit",
"provides": [
"verstak/notes/v1"
],
"requires": [
"verstak/core/files/v1",
"verstak/core/workbench/v1"
],
"permissions": [
"files.read",
"files.write",
"files.delete",
"events.subscribe",
"workbench.open",
"ui.register"
],
"frontend": {
"entry": "frontend/src/index.js"
},
"contributes": {
"workspaceItems": [
{
"id": "verstak.notes.workspace",
"title": "Notes",
"icon": "edit",
"component": "NotesView"
}
]
}
}
+139
View File
@@ -458,12 +458,151 @@
apiStatusList,
]);
/* ── Mouse Event Inspector ──────────────────────────────────── */
var mouseLog = [];
var mouseCapturing = false;
var mouseHandlers = [];
var mouseEventTypes = ['pointerdown', 'pointerup', 'mousedown', 'mouseup', 'auxclick'];
var mouseLogContainer = el('pre', {
className: 'pt-mouse-log',
style: {
background: '#0d0d1a',
color: '#4ecca3',
padding: '0.75rem',
borderRadius: '6px',
fontSize: '0.75rem',
fontFamily: 'monospace',
maxHeight: '300px',
overflow: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
margin: '0.5rem 0',
lineHeight: '1.5',
},
}, ['']);
var mouseCountSpan = span('pt-list-value', '0');
function renderMouseLog() {
if (mouseLog.length === 0) {
mouseLogContainer.textContent = '(no events captured)';
mouseCountSpan.textContent = '0';
return;
}
var lines = mouseLog.map(function (e, i) {
return '[' + (i + 1) + '] ' + e.type + ' button=' + e.button + ' buttons=' + e.buttons +
' which=' + e.which + ' pointerType=' + e.pointerType +
' defaultPrevented=' + e.defaultPrevented +
' target=' + e.targetTag + (e.targetClass ? '.' + e.targetClass : '');
});
mouseLogContainer.textContent = lines.join('\n');
mouseCountSpan.textContent = String(mouseLog.length);
mouseLogContainer.scrollTop = mouseLogContainer.scrollHeight;
}
function onMouseEvent(e) {
mouseLog.push({
type: e.type,
button: e.button,
buttons: e.buttons,
which: e.which,
pointerType: e.pointerType || '',
defaultPrevented: e.defaultPrevented,
targetTag: e.target ? e.target.tagName : '',
targetClass: e.target ? (e.target.className || '') : '',
time: Date.now(),
});
if (mouseLog.length > 200) mouseLog.shift();
renderMouseLog();
}
function startMouseCapture() {
if (mouseCapturing) return;
mouseCapturing = true;
mouseEventTypes.forEach(function (type) {
var handler = function (e) { onMouseEvent(e); };
window.addEventListener(type, handler, true);
mouseHandlers.push({ type: type, handler: handler });
});
startStopBtn.textContent = '■ Stop Capture';
startStopBtn.setAttribute('data-mouse-capturing', 'true');
renderMouseLog();
}
function stopMouseCapture() {
if (!mouseCapturing) return;
mouseCapturing = false;
mouseHandlers.forEach(function (h) {
window.removeEventListener(h.type, h.handler, true);
});
mouseHandlers = [];
startStopBtn.textContent = '▶ Start Capture';
startStopBtn.setAttribute('data-mouse-capturing', 'false');
}
var startStopBtn = el('button', {
className: 'btn btn-primary',
style: { marginRight: '0.5rem' },
onClick: function () {
if (mouseCapturing) { stopMouseCapture(); } else { startMouseCapture(); }
},
}, ['▶ Start Capture']);
var clearBtn = el('button', {
className: 'btn btn-secondary',
style: { marginRight: '0.5rem' },
onClick: function () {
mouseLog = [];
renderMouseLog();
},
}, ['Clear']);
var copyBtn = el('button', {
className: 'btn btn-secondary',
onClick: function () {
var json = JSON.stringify(mouseLog, null, 2);
if (navigator.clipboard) {
navigator.clipboard.writeText(json).then(function () {
copyBtn.textContent = '✓ Copied!';
setTimeout(function () { copyBtn.textContent = 'Copy JSON'; }, 1500);
});
} else {
var ta = document.createElement('textarea');
ta.value = json;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
copyBtn.textContent = '✓ Copied!';
setTimeout(function () { copyBtn.textContent = 'Copy JSON'; }, 1500);
}
},
}, ['Copy JSON']);
trackCleanup(function () { stopMouseCapture(); });
var mouseCard = div('pt-card', [
el('h3', { className: 'pt-card-title' }, ['Mouse Event Inspector']),
el('p', { style: { margin: '0 0 0.5rem', color: '#a0a0b8', fontSize: '0.8rem' } }, [
'Captures ALL mouse/pointer events on window. Press back/forward buttons to see what WebKitGTK reports.',
]),
el('div', { style: { display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.5rem' } }, [
startStopBtn,
clearBtn,
copyBtn,
span('pt-list-label', ' Events:'),
mouseCountSpan,
]),
mouseLogContainer,
]);
/* ── Assemble ──────────────────────────────────────────────── */
containerEl.appendChild(header);
containerEl.appendChild(badgeRow);
containerEl.appendChild(bridgeCard);
containerEl.appendChild(testsCard);
containerEl.appendChild(capsCard);
containerEl.appendChild(mouseCard);
containerEl.appendChild(infoCard);
containerEl.appendChild(apiCard);
},
+288 -98
View File
@@ -63,7 +63,34 @@
containerEl.innerHTML = '';
containerEl.className = 'pt-root';
function tr(key, params, fallback) {
if (api && api.i18n && typeof api.i18n.t === 'function') return api.i18n.t(key, params, fallback);
return fallback || key;
}
containerEl.__ptCleanup = [];
var localizedNodes = [];
function localized(tag, attrs, key, fallback) {
var node = el(tag, attrs, [tr(key, null, fallback)]);
var item = { node: node, key: key, params: null, fallback: fallback };
node.__ptLocalized = item;
localizedNodes.push(item);
return node;
}
function setLocalized(node, key, params, fallback) {
var item = node.__ptLocalized;
if (!item) {
item = { node: node };
node.__ptLocalized = item;
localizedNodes.push(item);
}
item.key = key;
item.params = params || null;
item.fallback = fallback;
node.textContent = tr(key, params, fallback);
}
function trackCleanup(fn) {
if (typeof fn === 'function') {
@@ -74,12 +101,19 @@
containerEl.__ptCleanup.push(fn);
}
}
if (api.i18n && typeof api.i18n.onDidChangeLocale === 'function') {
trackCleanup(api.i18n.onDidChangeLocale(function () {
localizedNodes.forEach(function (item) {
if (item.key) item.node.textContent = tr(item.key, item.params, item.fallback);
});
}));
}
/* ── Header ─────────────────────────────────────────────────── */
var header = div('pt-header', [
span('pt-icon', '◉'),
div('pt-title-group', [
el('h2', { className: 'pt-plugin-name' }, ['Platform Diagnostics']),
localized('h2', { className: 'pt-plugin-name' }, 'ui.diagnostics', 'Platform Diagnostics'),
el('p', { className: 'pt-plugin-id' }, [api.pluginId]),
]),
span('pt-version', 'v' + (props && props.version ? props.version : '0.1.0')),
@@ -88,51 +122,51 @@
/* ── Status badge ──────────────────────────────────────────── */
var badge = div('pt-badge pt-badge-success', [
el('span', {}, ['✅']),
el('span', {}, ['Frontend Bundle Loaded']),
localized('span', {}, 'ui.bundleLoaded', 'Frontend Bundle Loaded'),
]);
var badgeRow = div('', [badge]);
/* ── Real Plugin API bridge checks ─────────────────────────── */
var savedValue = span('pt-list-value pt-saved-setting', 'Saved setting: loading...');
var capabilityValue = span('pt-list-value pt-capability-result', 'Capabilities: loading...');
var commandValue = span('pt-list-value pt-command-result', 'Command: registering...');
var eventValue = span('pt-list-value pt-event-result', 'Event: subscribing...');
var filesValue = span('pt-list-value pt-files-result', 'Files: running...');
var filesErrorValue = span('pt-list-value pt-files-error-result', 'Files error path: checking...');
var workbenchValue = span('pt-list-value pt-workbench-result', 'Workbench: ready');
function makeWorkbenchButton(className, label, request) {
var savedValue = localized('span', { className: 'pt-list-value pt-saved-setting' }, 'ui.savedLoading', 'Saved setting: loading...');
var capabilityValue = localized('span', { className: 'pt-list-value pt-capability-result' }, 'ui.capabilitiesLoading', 'Capabilities: loading...');
var commandValue = localized('span', { className: 'pt-list-value pt-command-result' }, 'ui.commandRegistering', 'Command: registering...');
var eventValue = localized('span', { className: 'pt-list-value pt-event-result' }, 'ui.eventSubscribing', 'Event: subscribing...');
var filesValue = localized('span', { className: 'pt-list-value pt-files-result' }, 'ui.filesRunning', 'Files: running...');
var filesErrorValue = localized('span', { className: 'pt-list-value pt-files-error-result' }, 'ui.filesPathChecking', 'Files error path: checking...');
var workbenchValue = localized('span', { className: 'pt-list-value pt-workbench-result' }, 'ui.workbenchReady', 'Workbench: ready');
function makeWorkbenchButton(className, key, label, request) {
return el('button', {
className: 'btn btn-primary ' + className,
onClick: function () {
workbenchValue.textContent = 'Workbench: opening...';
setLocalized(workbenchValue, 'ui.workbenchOpening', null, 'Workbench: opening...');
api.workbench.editResource(request)
.then(function (result) {
workbenchValue.textContent = 'Workbench: opened ' + result.request.path + ' with ' + (result.providerId || 'no-provider');
setLocalized(workbenchValue, 'ui.workbenchOpened', { path: result.request.path, provider: result.providerId || 'no-provider' }, 'Workbench: opened {path} with {provider}');
workbenchValue.setAttribute('data-workbench-status', result.status === 'opened' ? 'ok' : result.status);
})
.catch(function (err) {
workbenchValue.textContent = 'Workbench error: ' + (err && err.message ? err.message : String(err));
setLocalized(workbenchValue, 'ui.workbenchError', { error: err && err.message ? err.message : String(err) }, 'Workbench error: {error}');
workbenchValue.setAttribute('data-workbench-status', 'error');
});
},
}, [label]);
}, [localized('span', {}, key, label)]);
}
var openTextWorkbenchButton = makeWorkbenchButton('pt-open-workbench-text', 'Open Text Diagnostic', {
var openTextWorkbenchButton = makeWorkbenchButton('pt-open-workbench-text', 'ui.openTextDiagnostic', 'Open Text Diagnostic', {
kind: 'vault-file',
path: 'Docs/todo.txt',
extension: '.txt',
mime: 'text/plain',
context: { sourceView: 'files' },
});
var openMarkdownWorkbenchButton = makeWorkbenchButton('pt-open-workbench-markdown', 'Open Markdown Diagnostic', {
var openMarkdownWorkbenchButton = makeWorkbenchButton('pt-open-workbench-markdown', 'ui.openMarkdownDiagnostic', 'Open Markdown Diagnostic', {
kind: 'vault-file',
path: 'Docs/readme.md',
extension: '.md',
context: { sourceView: 'files' },
});
var openNotesWorkbenchButton = makeWorkbenchButton('pt-open-workbench-notes', 'Open Notes Diagnostic', {
var openNotesWorkbenchButton = makeWorkbenchButton('pt-open-workbench-notes', 'ui.openNotesDiagnostic', 'Open Notes Diagnostic', {
kind: 'vault-file',
path: 'Notes/Overview.md',
path: 'Notes/example.md',
extension: '.md',
context: {
sourceView: 'notes',
@@ -143,51 +177,51 @@
var settingInput = el('input', {
className: 'pt-setting-input',
type: 'text',
'aria-label': 'Saved setting',
'aria-label': tr('ui.savedSetting', null, 'Saved setting'),
value: 'changed value',
});
var saveStatus = span('pt-list-value', '');
var saveButton = el('button', {
className: 'btn btn-primary pt-save-setting',
onClick: function () {
saveStatus.textContent = 'Saving...';
setLocalized(saveStatus, 'ui.saving', null, 'Saving...');
api.settings.write('savedText', settingInput.value)
.then(function () {
savedValue.textContent = 'Saved setting: ' + settingInput.value;
saveStatus.textContent = 'Saved';
setLocalized(savedValue, 'ui.savedSettingValue', { value: settingInput.value }, 'Saved setting: {value}');
setLocalized(saveStatus, 'ui.saved', null, 'Saved');
})
.catch(function (err) {
saveStatus.textContent = 'Error: ' + (err && err.message ? err.message : String(err));
setLocalized(saveStatus, 'ui.errorValue', { error: err && err.message ? err.message : String(err) }, 'Error: {error}');
});
},
}, ['Save Setting']);
}, [localized('span', {}, 'ui.saveSetting', 'Save Setting')]);
api.settings.read('savedText')
.then(function (value) {
var text = value || '';
settingInput.value = text || 'changed value';
savedValue.textContent = 'Saved setting: ' + text;
setLocalized(savedValue, 'ui.savedSettingValue', { value: text }, 'Saved setting: {value}');
})
.catch(function (err) {
savedValue.textContent = 'Settings error: ' + (err && err.message ? err.message : String(err));
setLocalized(savedValue, 'ui.settingsError', { error: err && err.message ? err.message : String(err) }, 'Settings error: {error}');
});
api.capabilities.list()
.then(function (caps) {
capabilityValue.textContent = 'Capabilities: ' + caps.length + ' available';
setLocalized(capabilityValue, 'ui.capabilitiesAvailable', { count: caps.length }, 'Capabilities: {count} available');
})
.catch(function (err) {
capabilityValue.textContent = 'Capabilities error: ' + (err && err.message ? err.message : String(err));
setLocalized(capabilityValue, 'ui.capabilitiesError', { error: err && err.message ? err.message : String(err) }, 'Capabilities error: {error}');
});
api.capabilities.has('verstak/platform-test/v1')
.then(function (available) {
badge.setAttribute('data-capability-status', available ? 'available' : 'missing');
badge.lastChild.textContent = 'Frontend Bundle Loaded | capability ' + (available ? 'available' : 'missing');
setLocalized(badge.lastChild, available ? 'ui.bundleCapabilityAvailable' : 'ui.bundleCapabilityMissing', null, available ? 'Frontend Bundle Loaded | capability available' : 'Frontend Bundle Loaded | capability missing');
})
.catch(function (err) {
badge.setAttribute('data-capability-status', 'error');
badge.lastChild.textContent = 'Capability error: ' + (err && err.message ? err.message : String(err));
setLocalized(badge.lastChild, 'ui.capabilityError', { error: err && err.message ? err.message : String(err) }, 'Capability error: {error}');
});
api.commands.register('verstak.platform-test.show-version', function () {
@@ -202,17 +236,17 @@
})
.then(function (result) {
badge.setAttribute('data-command-status', result.status || '');
commandValue.textContent = 'Command: ' + result.status + ' ' + result.result.version + ' from ' + result.result.source;
setLocalized(commandValue, 'ui.commandResult', { status: result.status, version: result.result.version, source: result.result.source }, 'Command: {status} {version} from {source}');
})
.catch(function (err) {
badge.setAttribute('data-command-status', 'error');
commandValue.textContent = 'Command error: ' + (err && err.message ? err.message : String(err));
setLocalized(commandValue, 'ui.commandError', { error: err && err.message ? err.message : String(err) }, 'Command error: {error}');
console.error('[platform-test] command bridge error:', err);
});
api.events.subscribe('verstak.platform-test.echo', function (event) {
var message = event && event.payload ? event.payload.message : '';
eventValue.textContent = 'Event: received ' + message;
setLocalized(eventValue, 'ui.eventReceived', { message: message }, 'Event: received {message}');
eventValue.setAttribute('data-event-status', 'received');
})
.then(function (unsubscribe) {
@@ -220,7 +254,7 @@
return api.events.publish('verstak.platform-test.echo', { message: 'hello-event' });
})
.catch(function (err) {
eventValue.textContent = 'Event error: ' + (err && err.message ? err.message : String(err));
setLocalized(eventValue, 'ui.eventError', { error: err && err.message ? err.message : String(err) }, 'Event error: {error}');
eventValue.setAttribute('data-event-status', 'error');
});
@@ -249,39 +283,39 @@
return api.files.trash('PlatformTest/files-api-moved.txt');
})
.then(function () {
filesValue.textContent = 'Files: wrote/read/listed/moved/trashed';
setLocalized(filesValue, 'ui.filesPassed', null, 'Files: wrote/read/listed/moved/trashed');
filesValue.setAttribute('data-files-status', 'ok');
})
.catch(function (err) {
filesValue.textContent = 'Files error: ' + (err && err.message ? err.message : String(err));
setLocalized(filesValue, 'ui.filesError', { error: err && err.message ? err.message : String(err) }, 'Files error: {error}');
filesValue.setAttribute('data-files-status', 'error');
});
api.files.readText('.verstak/vault.json')
.then(function () {
filesErrorValue.textContent = 'Files error path: unexpectedly allowed';
setLocalized(filesErrorValue, 'ui.filesPathAllowed', null, 'Files error path: unexpectedly allowed');
filesErrorValue.setAttribute('data-files-error-status', 'error');
})
.catch(function (err) {
var message = err && err.message ? err.message : String(err);
if (message.indexOf('reserved-path') === -1 && message.indexOf('.verstak') === -1) {
filesErrorValue.textContent = 'Files error path: wrong error ' + message;
setLocalized(filesErrorValue, 'ui.filesPathWrongError', { error: message }, 'Files error path: wrong error {error}');
filesErrorValue.setAttribute('data-files-error-status', 'error');
return;
}
filesErrorValue.textContent = 'Files error path: rejected reserved-path';
setLocalized(filesErrorValue, 'ui.filesPathRejected', null, 'Files error path: rejected reserved-path');
filesErrorValue.setAttribute('data-files-error-status', 'expected');
});
var bridgeCard = div('pt-card', [
el('h3', { className: 'pt-card-title' }, ['Real Plugin API Bridge']),
localized('h3', { className: 'pt-card-title' }, 'ui.apiBridge', 'Real Plugin API Bridge'),
el('ul', { className: 'pt-list' }, [
el('li', { className: 'pt-list-item' }, [
span('pt-list-label', 'Persisted setting'),
localized('span', { className: 'pt-list-label' }, 'ui.persistedSetting', 'Persisted setting'),
savedValue,
]),
el('li', { className: 'pt-list-item' }, [
span('pt-list-label', 'New value'),
localized('span', { className: 'pt-list-label' }, 'ui.newValue', 'New value'),
settingInput,
]),
el('li', { className: 'pt-list-item' }, [
@@ -289,27 +323,27 @@
saveStatus,
]),
el('li', { className: 'pt-list-item' }, [
span('pt-list-label', 'Capabilities'),
localized('span', { className: 'pt-list-label' }, 'ui.capabilitiesLabel', 'Capabilities'),
capabilityValue,
]),
el('li', { className: 'pt-list-item' }, [
span('pt-list-label', 'Command runtime'),
localized('span', { className: 'pt-list-label' }, 'ui.commandRuntime', 'Command runtime'),
commandValue,
]),
el('li', { className: 'pt-list-item' }, [
span('pt-list-label', 'Event runtime'),
localized('span', { className: 'pt-list-label' }, 'ui.eventRuntime', 'Event runtime'),
eventValue,
]),
el('li', { className: 'pt-list-item' }, [
span('pt-list-label', 'Files runtime'),
localized('span', { className: 'pt-list-label' }, 'ui.filesRuntime', 'Files runtime'),
filesValue,
]),
el('li', { className: 'pt-list-item' }, [
span('pt-list-label', 'Files reserved path'),
localized('span', { className: 'pt-list-label' }, 'ui.filesReservedPath', 'Files reserved path'),
filesErrorValue,
]),
el('li', { className: 'pt-list-item' }, [
span('pt-list-label', 'Workbench routing'),
localized('span', { className: 'pt-list-label' }, 'ui.workbenchRouting', 'Workbench routing'),
openTextWorkbenchButton,
openMarkdownWorkbenchButton,
openNotesWorkbenchButton,
@@ -320,12 +354,12 @@
/* ── Test results summary ──────────────────────────────────── */
var testsData = [
{ label: 'Plugin Registration', status: 'pass' },
{ label: 'Capability: verstak/platform-test/v1', status: 'pass' },
{ label: 'Capability: verstak/diagnostics/v1', status: 'pass' },
{ label: 'Capability: verstak/core/files/v1', status: 'pass' },
{ label: 'Capability: verstak/core/workbench/v1', status: 'pass' },
{ label: 'API Contract Compliance', status: 'pass' },
{ key: 'registration', label: 'Plugin Registration', status: 'pass' },
{ key: 'platformCapability', label: 'Capability: verstak/platform-test/v1', status: 'pass' },
{ key: 'diagnosticsCapability', label: 'Capability: verstak/diagnostics/v1', status: 'pass' },
{ key: 'filesCapability', label: 'Capability: verstak/core/files/v1', status: 'pass' },
{ key: 'workbenchCapability', label: 'Capability: verstak/core/workbench/v1', status: 'pass' },
{ key: 'apiContract', label: 'API Contract Compliance', status: 'pass' },
];
var totalTests = testsData.length;
@@ -334,19 +368,19 @@
var summaryRow = div('pt-test-summary', [
div('pt-test-stat', [
span('pt-test-stat-value pt-pass', String(passedTests)),
span('pt-test-stat-label', 'Passed'),
localized('span', { className: 'pt-test-stat-label' }, 'ui.passed', 'Passed'),
]),
div('pt-test-stat', [
span('pt-test-stat-value pt-fail', String(totalTests - passedTests)),
span('pt-test-stat-label', 'Failed'),
localized('span', { className: 'pt-test-stat-label' }, 'ui.failed', 'Failed'),
]),
div('pt-test-stat', [
span('pt-test-stat-value', String(totalTests)),
span('pt-test-stat-label', 'Total'),
localized('span', { className: 'pt-test-stat-label' }, 'ui.total', 'Total'),
]),
div('pt-test-stat', [
span('pt-test-stat-value pt-pass', '100%'),
span('pt-test-stat-label', 'Success Rate'),
localized('span', { className: 'pt-test-stat-label' }, 'ui.successRate', 'Success Rate'),
]),
]);
@@ -354,14 +388,14 @@
testsData.forEach(function (t) {
var dot = el('span', { className: 'pt-cap-dot pt-cap-dot-ok' });
var item = el('li', { className: 'pt-list-item' }, [
el('span', { className: 'pt-list-label' }, [dot, ' ', t.label]),
span('pt-list-value pt-pass', t.status === 'pass' ? '✓ PASS' : '✗ FAIL'),
el('span', { className: 'pt-list-label' }, [dot, ' ', localized('span', {}, 'ui.test.' + t.key, t.label)]),
localized('span', { className: 'pt-list-value pt-pass' }, t.status === 'pass' ? 'ui.pass' : 'ui.fail', t.status === 'pass' ? '✓ PASS' : '✗ FAIL'),
]);
testsList.appendChild(item);
});
var testsCard = div('pt-card', [
el('h3', { className: 'pt-card-title' }, ['Test Results']),
localized('h3', { className: 'pt-card-title' }, 'ui.testResults', 'Test Results'),
summaryRow,
testsList,
]);
@@ -380,7 +414,7 @@
var dot = el('span', {
className: 'pt-cap-dot pt-cap-dot-missing',
});
var statusText = span('pt-list-value', 'Checking...');
var statusText = localized('span', { className: 'pt-list-value' }, 'ui.checking', 'Checking...');
var item = el('li', { className: 'pt-list-item' }, [
el('span', { className: 'pt-list-label' }, [dot, ' ', cap.label]),
statusText,
@@ -389,39 +423,39 @@
api.capabilities.has(cap.id)
.then(function (available) {
dot.className = 'pt-cap-dot ' + (available ? 'pt-cap-dot-ok' : 'pt-cap-dot-missing');
statusText.textContent = available ? '✓ Available' : '— Unavailable';
setLocalized(statusText, available ? 'ui.available' : 'ui.unavailable', null, available ? '✓ Available' : '— Unavailable');
})
.catch(function () {
dot.className = 'pt-cap-dot pt-cap-dot-missing';
statusText.textContent = 'Error';
setLocalized(statusText, 'ui.error', null, 'Error');
});
});
var capsCard = div('pt-card', [
el('h3', { className: 'pt-card-title' }, ['Registered Capabilities']),
localized('h3', { className: 'pt-card-title' }, 'ui.capabilities', 'Registered Capabilities'),
capList,
]);
/* ── Plugin info ───────────────────────────────────────────── */
var infoList = el('ul', { className: 'pt-list' });
var infoItems = [
{ label: 'Plugin ID', value: api.pluginId },
{ label: 'Bundle Status', value: 'Loaded ✓' },
{ label: 'Registration Scheme', value: 'VerstakPluginRegister' },
{ label: 'Components', value: 'DiagnosticsPanel, PlatformTestSettings' },
{ label: 'Container', value: containerEl.tagName.toLowerCase() + (containerEl.id ? '#' + containerEl.id : '') },
{ key: 'pluginId', label: 'Plugin ID', value: api.pluginId },
{ key: 'bundleStatus', label: 'Bundle Status', value: tr('ui.loaded', null, 'Loaded ✓') },
{ key: 'registration', label: 'Registration Scheme', value: 'VerstakPluginRegister' },
{ key: 'components', label: 'Components', value: 'DiagnosticsPanel, PlatformTestSettings' },
{ key: 'container', label: 'Container', value: containerEl.tagName.toLowerCase() + (containerEl.id ? '#' + containerEl.id : '') },
];
infoItems.forEach(function (item) {
infoList.appendChild(
el('li', { className: 'pt-list-item' }, [
span('pt-list-label', item.label),
localized('span', { className: 'pt-list-label' }, 'ui.info.' + item.key, item.label),
span('pt-list-value', item.value),
])
);
});
var infoCard = div('pt-card', [
el('h3', { className: 'pt-card-title' }, ['Plugin Info']),
localized('h3', { className: 'pt-card-title' }, 'ui.pluginInfo', 'Plugin Info'),
infoList,
]);
@@ -436,7 +470,9 @@
{ label: 'capabilities.has', ok: typeof api.capabilities.has === 'function' },
{ label: 'files.list', ok: typeof api.files.list === 'function' },
{ label: 'files.readText', ok: typeof api.files.readText === 'function' },
{ label: 'files.readBytes', ok: typeof api.files.readBytes === 'function' },
{ label: 'files.writeText', ok: typeof api.files.writeText === 'function' },
{ label: 'files.writeBytes', ok: typeof api.files.writeBytes === 'function' },
{ label: 'files.trash', ok: typeof api.files.trash === 'function' },
{ label: 'workbench.openResource', ok: typeof api.workbench.openResource === 'function' },
{ label: 'workbench.editResource', ok: typeof api.workbench.editResource === 'function' },
@@ -448,22 +484,160 @@
apiStatusList.appendChild(
el('li', { className: 'pt-list-item' }, [
el('span', { className: 'pt-list-label' }, [dot, ' ', chk.label]),
span('pt-list-value', chk.ok ? '✓ Ready' : '✗ Missing'),
localized('span', { className: 'pt-list-value' }, chk.ok ? 'ui.ready' : 'ui.missing', chk.ok ? '✓ Ready' : '✗ Missing'),
])
);
});
var apiCard = div('pt-card', [
el('h3', { className: 'pt-card-title' }, ['Host API Methods']),
localized('h3', { className: 'pt-card-title' }, 'ui.hostMethods', 'Host API Methods'),
apiStatusList,
]);
/* ── Mouse Event Inspector ──────────────────────────────────── */
var mouseLog = [];
var mouseCapturing = false;
var mouseHandlers = [];
var mouseEventTypes = ['pointerdown', 'pointerup', 'mousedown', 'mouseup', 'auxclick'];
var mouseLogContainer = el('pre', {
className: 'pt-mouse-log',
style: {
background: '#0d0d1a',
color: '#4ecca3',
padding: '0.75rem',
borderRadius: '6px',
fontSize: '0.75rem',
fontFamily: 'monospace',
maxHeight: '300px',
overflow: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
margin: '0.5rem 0',
lineHeight: '1.5',
},
}, ['']);
var mouseCountSpan = span('pt-list-value', '0');
function renderMouseLog() {
if (mouseLog.length === 0) {
setLocalized(mouseLogContainer, 'ui.noMouseEvents', null, '(no events captured)');
mouseCountSpan.textContent = '0';
return;
}
var lines = mouseLog.map(function (e, i) {
return '[' + (i + 1) + '] ' + e.type + ' button=' + e.button + ' buttons=' + e.buttons +
' which=' + e.which + ' pointerType=' + e.pointerType +
' defaultPrevented=' + e.defaultPrevented +
' target=' + e.targetTag + (e.targetClass ? '.' + e.targetClass : '');
});
if (mouseLogContainer.__ptLocalized) mouseLogContainer.__ptLocalized.key = '';
mouseLogContainer.textContent = lines.join('\n');
mouseCountSpan.textContent = String(mouseLog.length);
mouseLogContainer.scrollTop = mouseLogContainer.scrollHeight;
}
function onMouseEvent(e) {
mouseLog.push({
type: e.type,
button: e.button,
buttons: e.buttons,
which: e.which,
pointerType: e.pointerType || '',
defaultPrevented: e.defaultPrevented,
targetTag: e.target ? e.target.tagName : '',
targetClass: e.target ? (e.target.className || '') : '',
time: Date.now(),
});
if (mouseLog.length > 200) mouseLog.shift();
renderMouseLog();
}
function startMouseCapture() {
if (mouseCapturing) return;
mouseCapturing = true;
mouseEventTypes.forEach(function (type) {
var handler = function (e) { onMouseEvent(e); };
window.addEventListener(type, handler, true);
mouseHandlers.push({ type: type, handler: handler });
});
setLocalized(startStopBtn, 'ui.stopCapture', null, '■ Stop Capture');
startStopBtn.setAttribute('data-mouse-capturing', 'true');
renderMouseLog();
}
function stopMouseCapture() {
if (!mouseCapturing) return;
mouseCapturing = false;
mouseHandlers.forEach(function (h) {
window.removeEventListener(h.type, h.handler, true);
});
mouseHandlers = [];
setLocalized(startStopBtn, 'ui.startCapture', null, '▶ Start Capture');
startStopBtn.setAttribute('data-mouse-capturing', 'false');
}
var startStopBtn = localized('button', {
className: 'btn btn-primary',
style: { marginRight: '0.5rem' },
onClick: function () {
if (mouseCapturing) { stopMouseCapture(); } else { startMouseCapture(); }
},
}, 'ui.startCapture', '▶ Start Capture');
var clearBtn = localized('button', {
className: 'btn btn-secondary',
style: { marginRight: '0.5rem' },
onClick: function () {
mouseLog = [];
renderMouseLog();
},
}, 'ui.clear', 'Clear');
var copyBtn = localized('button', {
className: 'btn btn-secondary',
onClick: function () {
var json = JSON.stringify(mouseLog, null, 2);
if (navigator.clipboard) {
navigator.clipboard.writeText(json).then(function () {
setLocalized(copyBtn, 'ui.copied', null, '✓ Copied!');
setTimeout(function () { setLocalized(copyBtn, 'ui.copyJson', null, 'Copy JSON'); }, 1500);
});
} else {
var ta = document.createElement('textarea');
ta.value = json;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
setLocalized(copyBtn, 'ui.copied', null, '✓ Copied!');
setTimeout(function () { setLocalized(copyBtn, 'ui.copyJson', null, 'Copy JSON'); }, 1500);
}
},
}, 'ui.copyJson', 'Copy JSON');
trackCleanup(function () { stopMouseCapture(); });
var mouseCard = div('pt-card', [
localized('h3', { className: 'pt-card-title' }, 'ui.mouseInspector', 'Mouse Event Inspector'),
localized('p', { style: { margin: '0 0 0.5rem', color: '#a0a0b8', fontSize: '0.8rem' } }, 'ui.mouseHint', 'Captures ALL mouse/pointer events on window. Press back/forward buttons to see what WebKitGTK reports.'),
el('div', { style: { display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.5rem' } }, [
startStopBtn,
clearBtn,
copyBtn,
localized('span', { className: 'pt-list-label' }, 'ui.eventsCount', 'Events:'),
mouseCountSpan,
]),
mouseLogContainer,
]);
/* ── Assemble ──────────────────────────────────────────────── */
containerEl.appendChild(header);
containerEl.appendChild(badgeRow);
containerEl.appendChild(bridgeCard);
containerEl.appendChild(testsCard);
containerEl.appendChild(capsCard);
containerEl.appendChild(mouseCard);
containerEl.appendChild(infoCard);
containerEl.appendChild(apiCard);
},
@@ -526,6 +700,28 @@
containerEl.innerHTML = '';
containerEl.className = 'pt-root';
function tr(key, params, fallback) {
if (api && api.i18n && typeof api.i18n.t === 'function') return api.i18n.t(key, params, fallback);
return fallback || key;
}
var localizedNodes = [];
var unsubscribeLocale = null;
function localized(tag, attrs, key, fallback) {
var node = el(tag, attrs, [tr(key, null, fallback)]);
localizedNodes.push({ node: node, key: key, fallback: fallback });
return node;
}
if (api.i18n && typeof api.i18n.onDidChangeLocale === 'function') {
unsubscribeLocale = api.i18n.onDidChangeLocale(function () {
localizedNodes.forEach(function (item) {
item.node.textContent = tr(item.key, null, item.fallback);
});
});
containerEl.__ptSettingsCleanup = unsubscribeLocale;
}
/* ── Counter state (local, not persisted) ──────────────────── */
var counterState = { value: 0 };
@@ -533,77 +729,69 @@
var header = div('pt-header', [
span('pt-icon', '⚙️'),
div('pt-title-group', [
el('h2', { className: 'pt-plugin-name' }, ['Platform Test Settings']),
localized('h2', { className: 'pt-plugin-name' }, 'ui.settings', 'Platform Test Settings'),
el('p', { className: 'pt-plugin-id' }, [api.pluginId]),
]),
]);
/* ── Info card ──────────────────────────────────────────────── */
var infoCard = div('pt-card', [
el('p', { style: { margin: '0', color: '#a0a0b8', fontSize: '0.85rem' } }, [
'Settings panel loaded from plugin frontend bundle via ',
el('code', { style: { background: '#1a1a2e', padding: '0.1rem 0.3rem', borderRadius: '3px', color: '#4ecca3' } }, ['VerstakPluginRegister']),
' contract.',
]),
localized('p', { style: { margin: '0', color: '#a0a0b8', fontSize: '0.85rem' } }, 'ui.settingsInfo', 'Settings panel loaded from the plugin frontend bundle through the VerstakPluginRegister contract.'),
]);
/* ── Counter section ────────────────────────────────────────── */
var counterDisplay = div('pt-counter', [
el('span', { className: 'pt-counter-value' }, [String(counterState.value)]),
span('pt-counter-label', 'clicks (session only, no persistence)'),
localized('span', { className: 'pt-counter-label' }, 'ui.counterClicks', 'clicks (session only, no persistence)'),
]);
var incrementBtn = el('button', { className: 'btn btn-primary', onClick: function () {
counterState.value += 1;
counterDisplay.firstChild.textContent = String(counterState.value);
}}, ['+ Increment']);
}}, [localized('span', {}, 'ui.increment', '+ Increment')]);
var decrementBtn = el('button', { className: 'btn btn-secondary', onClick: function () {
counterState.value = Math.max(0, counterState.value - 1);
counterDisplay.firstChild.textContent = String(counterState.value);
}}, ['− Decrement']);
}}, [localized('span', {}, 'ui.decrement', '− Decrement')]);
var resetBtn = el('button', { className: 'btn btn-secondary', onClick: function () {
counterState.value = 0;
counterDisplay.firstChild.textContent = '0';
}}, ['↺ Reset']);
}}, [localized('span', {}, 'ui.reset', '↺ Reset')]);
var btnGroup = el('div', { style: { display: 'flex', gap: '0.5rem' } }, [
incrementBtn, decrementBtn, resetBtn,
]);
var counterCard = div('pt-card', [
el('h3', { className: 'pt-card-title' }, ['Interactive Counter (Local State)']),
localized('h3', { className: 'pt-card-title' }, 'ui.counter', 'Interactive Counter (Local State)'),
counterDisplay,
btnGroup,
el('p', { style: { marginTop: '0.75rem', color: '#6c6c8a', fontSize: '0.7rem' } }, [
'This counter is a local demo. State is not persisted — refreshing resets it.',
]),
localized('p', { style: { marginTop: '0.75rem', color: '#6c6c8a', fontSize: '0.7rem' } }, 'ui.counterHint', 'This counter is a local demo. State is not persisted — refreshing resets it.'),
]);
/* ── Settings stub ─────────────────────────────────────────── */
var settingsDemoList = el('ul', { className: 'pt-list' });
var settingsItems = [
{ label: 'Auto-run on load', value: 'true' },
{ label: 'Verbose logging', value: 'false' },
{ label: 'Theme override', value: 'inherit' },
{ label: 'Notifications', value: 'enabled' },
{ key: 'autoRun', label: 'Auto-run on load', valueKey: 'true', value: 'true' },
{ key: 'verbose', label: 'Verbose logging', valueKey: 'false', value: 'false' },
{ key: 'theme', label: 'Theme override', valueKey: 'inherit', value: 'inherit' },
{ key: 'notifications', label: 'Notifications', valueKey: 'enabled', value: 'enabled' },
];
settingsItems.forEach(function (s) {
settingsDemoList.appendChild(
el('li', { className: 'pt-list-item' }, [
span('pt-list-label', s.label),
span('pt-list-value', s.value),
localized('span', { className: 'pt-list-label' }, 'ui.setting.' + s.key, s.label),
localized('span', { className: 'pt-list-value' }, 'ui.value.' + s.valueKey, s.value),
])
);
});
var settingsCard = div('pt-card', [
el('h3', { className: 'pt-card-title' }, ['Plugin Settings (Demo)']),
localized('h3', { className: 'pt-card-title' }, 'ui.demoSettings', 'Plugin Settings (Demo)'),
settingsDemoList,
el('p', { style: { marginTop: '0.5rem', color: '#6c6c8a', fontSize: '0.7rem' } }, [
'Use api.settings.read() / api.settings.write() for persisted settings.',
]),
localized('p', { style: { marginTop: '0.5rem', color: '#6c6c8a', fontSize: '0.7rem' } }, 'ui.settingsHint', 'Use api.settings.read() / api.settings.write() for persisted settings.'),
]);
/* ── Assemble ──────────────────────────────────────────────── */
@@ -614,8 +802,10 @@
},
unmount: function (containerEl) {
if (typeof containerEl.__ptSettingsCleanup === 'function') containerEl.__ptSettingsCleanup();
containerEl.innerHTML = '';
containerEl.className = '';
delete containerEl.__ptSettingsCleanup;
},
};
+111
View File
@@ -0,0 +1,111 @@
{
"manifest.name": "Platform Test",
"manifest.description": "Runtime test plugin for verifying the Verstak platform: manifest loading, capability registration, contribution injection, event bus, and UI rendering.",
"contributions.views.verstak.platform-test.diagnostics.title": "Platform Diagnostics",
"contributions.commands.verstak.platform-test.run-tests.title": "Run Platform Tests",
"contributions.commands.verstak.platform-test.show-version.title": "Show Version Info",
"contributions.sidebarItems.verstak.platform-test.sidebar.title": "Platform Test",
"contributions.statusBarItems.verstak.platform-test.status.label": "[OK] All Tests Pass",
"contributions.settingsPanels.verstak.platform-test.settings.title": "Platform Test Settings",
"contributions.openProviders.verstak.platform-test.markdown-diagnostic.title": "Platform Test Markdown Diagnostic",
"ui.diagnostics": "Platform Diagnostics",
"ui.bundleLoaded": "Plugin Bundle Loaded",
"ui.apiBridge": "API Bridge",
"ui.testResults": "Test Results",
"ui.capabilities": "Granted Capabilities",
"ui.pluginInfo": "Plugin Info",
"ui.hostMethods": "Host Methods",
"ui.mouseInspector": "Mouse Event Inspector",
"ui.settings": "Platform Test Settings",
"ui.increment": "+ Increment",
"ui.decrement": "− Decrement",
"ui.reset": "Reset",
"ui.counter": "Interactive Counter",
"ui.demoSettings": "Demo Settings",
"ui.savedLoading": "Saved setting: loading...",
"ui.capabilitiesLoading": "Capabilities: loading...",
"ui.commandRegistering": "Command: registering...",
"ui.eventSubscribing": "Event: subscribing...",
"ui.filesRunning": "Files: running...",
"ui.filesPathChecking": "Files error path: checking...",
"ui.workbenchReady": "Workbench: ready",
"ui.workbenchOpening": "Workbench: opening...",
"ui.workbenchOpened": "Workbench: opened {path} with {provider}",
"ui.workbenchError": "Workbench error: {error}",
"ui.openTextDiagnostic": "Open Text Diagnostic",
"ui.openMarkdownDiagnostic": "Open Markdown Diagnostic",
"ui.openNotesDiagnostic": "Open Notes Diagnostic",
"ui.savedSetting": "Saved setting",
"ui.saving": "Saving...",
"ui.savedSettingValue": "Saved setting: {value}",
"ui.saved": "Saved",
"ui.errorValue": "Error: {error}",
"ui.saveSetting": "Save Setting",
"ui.settingsError": "Settings error: {error}",
"ui.capabilitiesAvailable": "Capabilities: {count} available",
"ui.capabilitiesError": "Capabilities error: {error}",
"ui.bundleCapabilityAvailable": "Frontend Bundle Loaded | capability available",
"ui.bundleCapabilityMissing": "Frontend Bundle Loaded | capability missing",
"ui.capabilityError": "Capability error: {error}",
"ui.commandResult": "Command: {status} {version} from {source}",
"ui.commandError": "Command error: {error}",
"ui.eventReceived": "Event: received {message}",
"ui.eventError": "Event error: {error}",
"ui.filesPassed": "Files: wrote/read/listed/moved/trashed",
"ui.filesError": "Files error: {error}",
"ui.filesPathAllowed": "Files error path: unexpectedly allowed",
"ui.filesPathWrongError": "Files error path: wrong error {error}",
"ui.filesPathRejected": "Files error path: rejected reserved-path",
"ui.persistedSetting": "Persisted setting",
"ui.newValue": "New value",
"ui.capabilitiesLabel": "Capabilities",
"ui.commandRuntime": "Command runtime",
"ui.eventRuntime": "Event runtime",
"ui.filesRuntime": "Files runtime",
"ui.filesReservedPath": "Files reserved path",
"ui.workbenchRouting": "Workbench routing",
"ui.passed": "Passed",
"ui.failed": "Failed",
"ui.total": "Total",
"ui.successRate": "Success Rate",
"ui.checking": "Checking...",
"ui.available": "✓ Available",
"ui.unavailable": "— Unavailable",
"ui.error": "Error",
"ui.ready": "✓ Ready",
"ui.missing": "✗ Missing",
"ui.loaded": "Loaded ✓",
"ui.info.pluginId": "Plugin ID",
"ui.info.bundleStatus": "Bundle Status",
"ui.info.registration": "Registration Scheme",
"ui.info.components": "Components",
"ui.info.container": "Container",
"ui.test.registration": "Plugin Registration",
"ui.test.platformCapability": "Capability: verstak/platform-test/v1",
"ui.test.diagnosticsCapability": "Capability: verstak/diagnostics/v1",
"ui.test.filesCapability": "Capability: verstak/core/files/v1",
"ui.test.workbenchCapability": "Capability: verstak/core/workbench/v1",
"ui.test.apiContract": "API Contract Compliance",
"ui.pass": "✓ PASS",
"ui.fail": "✗ FAIL",
"ui.noMouseEvents": "(no events captured)",
"ui.stopCapture": "■ Stop Capture",
"ui.startCapture": "▶ Start Capture",
"ui.clear": "Clear",
"ui.copyJson": "Copy JSON",
"ui.copied": "✓ Copied!",
"ui.mouseHint": "Captures ALL mouse/pointer events on window. Press back/forward buttons to see what WebKitGTK reports.",
"ui.eventsCount": "Events:",
"ui.settingsInfo": "Settings panel loaded from the plugin frontend bundle through the VerstakPluginRegister contract.",
"ui.counterClicks": "clicks (session only, no persistence)",
"ui.counterHint": "This counter is a local demo. State is not persisted — refreshing resets it.",
"ui.setting.autoRun": "Auto-run on load",
"ui.setting.verbose": "Verbose logging",
"ui.setting.theme": "Theme override",
"ui.setting.notifications": "Notifications",
"ui.value.true": "true",
"ui.value.false": "false",
"ui.value.inherit": "inherit",
"ui.value.enabled": "enabled",
"ui.settingsHint": "Use api.settings.read() / api.settings.write() for persisted settings."
}
+111
View File
@@ -0,0 +1,111 @@
{
"manifest.name": "Тест платформы",
"manifest.description": "Диагностический плагин для проверки загрузки manifest, capabilities, contributions, шины событий и интерфейса Верстака.",
"contributions.views.verstak.platform-test.diagnostics.title": "Диагностика платформы",
"contributions.commands.verstak.platform-test.run-tests.title": "Запустить тесты платформы",
"contributions.commands.verstak.platform-test.show-version.title": "Показать сведения о версии",
"contributions.sidebarItems.verstak.platform-test.sidebar.title": "Тест платформы",
"contributions.statusBarItems.verstak.platform-test.status.label": "[OK] Все тесты пройдены",
"contributions.settingsPanels.verstak.platform-test.settings.title": "Настройки теста платформы",
"contributions.openProviders.verstak.platform-test.markdown-diagnostic.title": "Диагностика Markdown платформы",
"ui.diagnostics": "Диагностика платформы",
"ui.bundleLoaded": "Пакет плагина загружен",
"ui.apiBridge": "Мост API",
"ui.testResults": "Результаты тестов",
"ui.capabilities": "Предоставленные возможности",
"ui.pluginInfo": "Сведения о плагине",
"ui.hostMethods": "Методы хоста",
"ui.mouseInspector": "Инспектор событий мыши",
"ui.settings": "Настройки теста платформы",
"ui.increment": "+ Увеличить",
"ui.decrement": "− Уменьшить",
"ui.reset": "Сбросить",
"ui.counter": "Интерактивный счётчик",
"ui.demoSettings": "Демонстрационные настройки",
"ui.savedLoading": "Загрузка сохранённой настройки...",
"ui.capabilitiesLoading": "Загрузка возможностей...",
"ui.commandRegistering": "Регистрация команды...",
"ui.eventSubscribing": "Подписка на событие...",
"ui.filesRunning": "Проверка файлов...",
"ui.filesPathChecking": "Проверка запрещённого пути...",
"ui.workbenchReady": "Workbench готов",
"ui.workbenchOpening": "Открытие Workbench...",
"ui.workbenchOpened": "Workbench открыл {path} через {provider}",
"ui.workbenchError": "Ошибка Workbench: {error}",
"ui.openTextDiagnostic": "Открыть текстовую диагностику",
"ui.openMarkdownDiagnostic": "Открыть диагностику Markdown",
"ui.openNotesDiagnostic": "Открыть диагностику заметок",
"ui.savedSetting": "Сохранённая настройка",
"ui.saving": "Сохранение...",
"ui.savedSettingValue": "Сохранённая настройка: {value}",
"ui.saved": "Сохранено",
"ui.errorValue": "Ошибка: {error}",
"ui.saveSetting": "Сохранить настройку",
"ui.settingsError": "Ошибка настроек: {error}",
"ui.capabilitiesAvailable": "Доступно возможностей: {count}",
"ui.capabilitiesError": "Ошибка возможностей: {error}",
"ui.bundleCapabilityAvailable": "Frontend bundle загружен | capability доступна",
"ui.bundleCapabilityMissing": "Frontend bundle загружен | capability отсутствует",
"ui.capabilityError": "Ошибка capability: {error}",
"ui.commandResult": "Команда: {status} {version}, источник: {source}",
"ui.commandError": "Ошибка команды: {error}",
"ui.eventReceived": "Получено событие: {message}",
"ui.eventError": "Ошибка события: {error}",
"ui.filesPassed": "Файлы: запись/чтение/список/перемещение/удаление выполнены",
"ui.filesError": "Ошибка файлов: {error}",
"ui.filesPathAllowed": "Ошибка: запрещённый путь неожиданно разрешён",
"ui.filesPathWrongError": "Неверная ошибка запрещённого пути: {error}",
"ui.filesPathRejected": "Зарезервированный путь отклонён",
"ui.persistedSetting": "Сохранённая настройка",
"ui.newValue": "Новое значение",
"ui.capabilitiesLabel": "Возможности",
"ui.commandRuntime": "Выполнение команд",
"ui.eventRuntime": "Обработка событий",
"ui.filesRuntime": "Работа с файлами",
"ui.filesReservedPath": "Зарезервированный путь",
"ui.workbenchRouting": "Маршрутизация Workbench",
"ui.passed": "Пройдено",
"ui.failed": "Ошибок",
"ui.total": "Всего",
"ui.successRate": "Успешность",
"ui.checking": "Проверка...",
"ui.available": "✓ Доступно",
"ui.unavailable": "— Недоступно",
"ui.error": "Ошибка",
"ui.ready": "✓ Готово",
"ui.missing": "✗ Отсутствует",
"ui.loaded": "Загружено ✓",
"ui.info.pluginId": "ID плагина",
"ui.info.bundleStatus": "Состояние bundle",
"ui.info.registration": "Схема регистрации",
"ui.info.components": "Компоненты",
"ui.info.container": "Контейнер",
"ui.test.registration": "Регистрация плагина",
"ui.test.platformCapability": "Capability: verstak/platform-test/v1",
"ui.test.diagnosticsCapability": "Capability: verstak/diagnostics/v1",
"ui.test.filesCapability": "Capability: verstak/core/files/v1",
"ui.test.workbenchCapability": "Capability: verstak/core/workbench/v1",
"ui.test.apiContract": "Соответствие контракту API",
"ui.pass": "✓ ПРОЙДЕНО",
"ui.fail": "✗ ОШИБКА",
"ui.noMouseEvents": "(события не зафиксированы)",
"ui.stopCapture": "■ Остановить запись",
"ui.startCapture": "▶ Начать запись",
"ui.clear": "Очистить",
"ui.copyJson": "Копировать JSON",
"ui.copied": "✓ Скопировано!",
"ui.mouseHint": "Записывает все события мыши и указателя в окне. Нажмите кнопки назад/вперёд, чтобы увидеть события WebKitGTK.",
"ui.eventsCount": "События:",
"ui.settingsInfo": "Панель настроек загружена из frontend bundle плагина через контракт VerstakPluginRegister.",
"ui.counterClicks": "нажатий (только в текущем сеансе)",
"ui.counterHint": "Это локальный демонстрационный счётчик. После обновления его значение сбрасывается.",
"ui.setting.autoRun": "Автозапуск при загрузке",
"ui.setting.verbose": "Подробный журнал",
"ui.setting.theme": "Переопределение темы",
"ui.setting.notifications": "Уведомления",
"ui.value.true": "да",
"ui.value.false": "нет",
"ui.value.inherit": "наследовать",
"ui.value.enabled": "включены",
"ui.settingsHint": "Для постоянных настроек используйте api.settings.read() / api.settings.write()."
}
+2 -1
View File
@@ -5,6 +5,7 @@
"version": "0.1.0",
"apiVersion": "0.1.0",
"description": "Runtime test plugin for verifying the Verstak platform: manifest loading, capability registration, contribution injection, event bus, and UI rendering.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official",
"icon": "flask",
"provides": [
@@ -34,7 +35,7 @@
"workbench.open"
],
"frontend": {
"entry": "frontend/dist/index.js"
"entry": "frontend/src/index.js"
},
"contributes": {
"views": [
+548
View File
@@ -0,0 +1,548 @@
/* ===========================================================
Search Plugin — Verstak v2 Frontend Bundle
Contract: window.VerstakPluginRegister(id, { components })
=========================================================== */
(function () {
'use strict';
var TEXT_EXTS = [
'md', 'markdown', 'txt', 'log', 'conf', 'ini', 'toml', 'yaml', 'yml',
'json', 'csv', 'tsv', 'xml', 'html', 'htm', 'css', 'scss', 'sass', 'less',
'js', 'jsx', 'mjs', 'cjs', 'ts', 'tsx', 'py', 'go', 'rs', 'java', 'kt',
'swift', 'rb', 'php', 'c', 'cpp', 'h', 'hpp', 'sh', 'bash', 'zsh', 'sql'
];
var SEARCH_DEBOUNCE_MS = 300;
var MAX_FILES = 500;
var MAX_RESULTS = 100;
var INDEX_STORAGE_KEY = 'search-index';
var INDEX_VERSION = 1;
var SEARCH_COMMAND_ID = 'verstak.search.searchVaultText';
function injectStyles() {
if (document.getElementById('search-style-injected')) return;
var style = document.createElement('style');
style.id = 'search-style-injected';
style.textContent = STYLES;
document.head.appendChild(style);
}
var STYLES = [
'.search-root{height:100%;min-height:0;display:flex;flex-direction:column;background:var(--vt-color-background,#101020);color:var(--vt-color-text-primary,#f4f7fb);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif}',
'.search-toolbar{display:flex;align-items:center;gap:.5rem;min-height:2.75rem;padding:.55rem .75rem;border-bottom:1px solid var(--vt-color-border,#202b46);background:var(--vt-color-surface-muted,#111629);flex-shrink:0;flex-wrap:wrap}',
'.search-input{flex:1;min-width:180px;font-size:.86rem;padding:.42rem .55rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:#0f1424;color:var(--vt-color-text-primary,#f4f7fb);outline:none}',
'.search-input:focus{border-color:var(--vt-color-accent,#4ecca3);box-shadow:var(--vt-focus-ring,0 0 0 2px rgba(78,204,163,.34))}',
'.search-btn{font-size:.8rem;min-height:2rem;padding:.42rem .7rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-md,6px);background:var(--vt-color-surface-hover,#1b2440);color:var(--vt-color-text-secondary,#b7c0d4);cursor:pointer}',
'.search-btn:hover{border-color:var(--vt-color-accent,#4ecca3);background:var(--vt-color-surface-hover,#1b2440);color:var(--vt-color-text-primary,#f4f7fb)}',
'.search-btn:disabled{opacity:.45;cursor:default}',
'.search-scope{font-size:.72rem;color:var(--vt-color-text-muted,#7f8aa3);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:18rem}',
'.search-status{font-size:.78rem;color:var(--vt-color-text-muted,#7f8aa3);padding:.45rem .75rem;border-bottom:1px solid rgba(32,43,70,.72);flex-shrink:0}',
'.search-status.error{color:var(--vt-color-danger,#e94560)}',
'.search-alert{margin:.65rem .75rem 0;border:1px solid rgba(233,69,96,.45);border-radius:var(--vt-radius-md,6px);background:var(--vt-color-danger-muted,rgba(233,69,96,.14));color:#ffc6ce;padding:.55rem .65rem;font-size:.78rem}',
'.search-alert[hidden]{display:none}',
'.search-alert summary{cursor:pointer;color:#fff}',
'.search-results{flex:1;min-height:0;overflow:auto}',
'.search-empty{height:100%;display:flex;align-items:center;justify-content:center;color:var(--vt-color-text-muted,#7f8aa3);font-size:.9rem;padding:2rem;text-align:center}',
'.search-result{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.75rem;align-items:center;margin:.5rem .75rem 0;padding:.7rem .8rem;border:1px solid var(--vt-color-border,#202b46);border-radius:var(--vt-radius-lg,8px);background:var(--vt-color-surface,#15152c)}',
'.search-result:hover{background:var(--vt-color-surface-hover,#1b2440)}',
'.search-result-head{display:flex;align-items:center;gap:.5rem;min-width:0}',
'.search-type{display:inline-flex;align-items:center;min-height:1.25rem;padding:.1rem .4rem;border:1px solid rgba(78,204,163,.4);border-radius:var(--vt-radius-sm,4px);background:var(--vt-color-accent-muted,rgba(78,204,163,.14));color:var(--vt-color-accent,#4ecca3);font-size:.68rem;font-weight:650;text-transform:uppercase;letter-spacing:.03em}',
'.search-title{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:.86rem;color:var(--vt-color-text-primary,#f4f7fb);font-weight:650}',
'.search-path{margin-top:.2rem;font-size:.76rem;color:var(--vt-color-text-muted,#7f8aa3);word-break:break-word}',
'.search-snippet{margin-top:.25rem;font-size:.8rem;line-height:1.45;color:var(--vt-color-text-secondary,#b7c0d4);white-space:pre-wrap;overflow-wrap:anywhere}',
'.search-meta{margin-top:.28rem;font-size:.72rem;color:var(--vt-color-text-muted,#7f8aa3)}',
'.search-open-btn{align-self:center;white-space:nowrap;min-width:4.5rem;padding:.35rem .65rem}',
'@media(max-width:700px){.search-result{grid-template-columns:1fr}.search-toolbar{align-items:stretch}.search-btn{width:100%}.search-scope{max-width:none}}'
].join('\n');
function el(tag, attrs, children) {
var elem = document.createElement(tag);
if (attrs) {
Object.keys(attrs).forEach(function (k) {
if (attrs[k] == null) return;
if (k === 'className') elem.className = attrs[k];
else if (k === 'style' && typeof attrs[k] === 'object') {
if (elem.style) Object.assign(elem.style, attrs[k]);
}
else if (k.slice(0, 2) === 'on') elem.addEventListener(k.slice(2).toLowerCase(), attrs[k]);
else if (k === 'textContent') elem.textContent = attrs[k];
else if (k === 'innerHTML') elem.innerHTML = attrs[k];
else elem.setAttribute(k, attrs[k]);
});
}
if (children) {
(Array.isArray(children) ? children : [children]).forEach(function (c) {
if (c == null) return;
elem.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
});
}
return elem;
}
function cleanPath(path) {
return String(path || '').split('/').filter(Boolean).join('/');
}
function baseName(path) {
path = cleanPath(path);
var parts = path.split('/');
return parts[parts.length - 1] || path || 'Untitled';
}
function resultTypeLabel(result) {
var type = String(result && result.type || 'result').toLowerCase();
if (type === 'folder') return 'Folder';
if (type === 'file') return 'File';
if (type === 'external') return 'Plugin';
return type.charAt(0).toUpperCase() + type.slice(1);
}
function extension(entry) {
var explicit = String((entry && entry.extension) || '').replace(/^\./, '').toLowerCase();
if (explicit) return explicit;
var name = String((entry && (entry.name || entry.relativePath)) || '');
var idx = name.lastIndexOf('.');
return idx > 0 ? name.slice(idx + 1).toLowerCase() : '';
}
function isTextFile(entry) {
return entry && entry.type === 'file' && TEXT_EXTS.indexOf(extension(entry)) !== -1;
}
function pathMatches(entry, query) {
var needle = String(query || '').toLowerCase();
var path = String((entry && entry.relativePath) || '').toLowerCase();
var name = String((entry && entry.name) || '').toLowerCase();
return path.indexOf(needle) !== -1 || name.indexOf(needle) !== -1;
}
function lineNumber(text, index) {
return text.slice(0, index).split('\n').length;
}
function snippet(text, index, needleLength) {
var start = Math.max(0, index - 60);
var end = Math.min(text.length, index + needleLength + 90);
return (start > 0 ? '...' : '') + text.slice(start, end).replace(/\s+/g, ' ').trim() + (end < text.length ? '...' : '');
}
function scanText(path, text, query) {
var lower = text.toLowerCase();
var needle = query.toLowerCase();
var idx = lower.indexOf(needle);
if (idx === -1) return null;
return {
path: path,
type: 'file',
matchType: 'Content match',
openable: true,
line: lineNumber(text, idx),
snippet: snippet(text, idx, query.length)
};
}
function scanPath(entry) {
var isFolder = entry.type === 'folder';
return {
path: entry.relativePath,
type: entry.type,
matchType: isFolder ? 'Folder name' : 'File name',
openable: !isFolder,
line: 0,
snippet: isFolder ? 'Folder name/path match' : 'File name/path match'
};
}
async function collectEntries(api, rootPath) {
var found = [];
var folders = [cleanPath(rootPath)];
var visited = 0;
while (folders.length && visited < MAX_FILES) {
var current = folders.shift();
var entries = await api.files.list(current);
entries = Array.isArray(entries) ? entries : [];
for (var i = 0; i < entries.length; i++) {
var entry = entries[i];
if (!entry || !entry.relativePath) continue;
found.push(entry);
if (entry.type === 'folder') {
folders.push(entry.relativePath);
}
visited += 1;
if (visited >= MAX_FILES) break;
}
}
return found;
}
async function buildIndex(api, rootPath) {
var entries = await collectEntries(api, rootPath);
var files = [];
for (var i = 0; i < entries.length; i++) {
var entry = entries[i];
if (!isTextFile(entry)) continue;
try {
files.push({
path: entry.relativePath,
text: String(await api.files.readText(entry.relativePath) || '')
});
} catch (err) {
// Ignore unreadable files; search should remain usable on mixed vaults.
}
}
return {
version: INDEX_VERSION,
workspaceRootPath: cleanPath(rootPath),
updatedAt: new Date().toISOString(),
entries: entries,
files: files
};
}
function normalizeIndex(value, rootPath) {
if (!value || value.version !== INDEX_VERSION) return null;
if (cleanPath(value.workspaceRootPath) !== cleanPath(rootPath)) return null;
if (!Array.isArray(value.entries) || !Array.isArray(value.files)) return null;
return value;
}
async function readStoredIndex(api, rootPath) {
if (!api.storage || !api.storage.data || typeof api.storage.data.read !== 'function') return null;
try {
return normalizeIndex(await api.storage.data.read(INDEX_STORAGE_KEY), rootPath);
} catch (err) {
return null;
}
}
async function writeStoredIndex(api, index) {
if (!api.storage || !api.storage.data || typeof api.storage.data.write !== 'function') return;
try {
await api.storage.data.write(INDEX_STORAGE_KEY, index);
} catch (err) {
// Storage is an optimization for fast reloads; search can still work in memory.
}
}
async function loadOrBuildIndex(api, rootPath, currentIndex) {
var normalized = normalizeIndex(currentIndex, rootPath);
if (normalized) return normalized;
var stored = await readStoredIndex(api, rootPath);
if (stored) return stored;
var index = await buildIndex(api, rootPath);
await writeStoredIndex(api, index);
return index;
}
function runLocalSearch(index, query) {
query = String(query || '').trim();
if (query.length < 2) return [];
index = index || { entries: [], files: [] };
var results = [];
var entries = Array.isArray(index.entries) ? index.entries : [];
var files = Array.isArray(index.files) ? index.files : [];
for (var i = 0; i < entries.length && results.length < MAX_RESULTS; i++) {
var entry = entries[i];
if (pathMatches(entry, query)) results.push(scanPath(entry));
}
for (var j = 0; j < files.length && results.length < MAX_RESULTS; j++) {
var file = files[j];
var match = scanText(file.path, String(file.text || ''), query);
if (match) results.push(match);
}
return results;
}
function normalizeProviderResults(provider, value) {
var list = Array.isArray(value) ? value : (value && Array.isArray(value.results) ? value.results : []);
return list.map(function (item) {
return {
path: cleanPath(item.path || item.relativePath || item.title || provider.label || provider.id),
type: item.type || 'external',
matchType: item.matchType || provider.label || 'External match',
sourceLabel: provider.label || provider.id || provider.pluginId,
openable: item.openable === true,
line: item.line || 0,
snippet: item.snippet || item.preview || ''
};
}).filter(function (item) { return item.path; });
}
async function runExternalProviders(api, rootPath, query, remaining) {
var output = { results: [], errors: [] };
if (remaining <= 0) return output;
if (!api.contributions || typeof api.contributions.list !== 'function') return output;
if (!api.commands || typeof api.commands.executeFor !== 'function') return output;
var providers = [];
try {
providers = await api.contributions.list('searchProviders');
} catch (err) {
console.warn('[verstak.search] list search providers:', err);
output.errors.push(true);
return output;
}
providers = Array.isArray(providers) ? providers : [];
for (var i = 0; i < providers.length && output.results.length < remaining; i++) {
var provider = providers[i];
if (!provider || !provider.handler) continue;
if (provider.pluginId === 'verstak.search' || provider.handler === SEARCH_COMMAND_ID) continue;
try {
var response = await api.commands.executeFor(provider.pluginId, provider.handler, {
query: query,
workspaceRootPath: rootPath,
limit: remaining - output.results.length
});
var normalized = normalizeProviderResults(provider, response && response.result);
output.results = output.results.concat(normalized.slice(0, remaining - output.results.length));
} catch (err) {
console.warn('[verstak.search] provider search:', err);
output.errors.push(true);
}
}
return output;
}
var SearchView = {
mount: function (containerEl, props, api) {
injectStyles();
var rootPath = cleanPath(props && (props.workspaceRootPath || props.workspaceName));
var state = { query: '', searching: false, results: [], status: 'Enter at least 2 characters.', error: '', providerErrors: [] };
var searchTimer = null;
var searchSeq = 0;
var index = null;
var cleanupFns = [];
var indexRefresh = Promise.resolve();
var input = null;
var button = null;
var statusEl = null;
var alertEl = null;
var resultsEl = null;
function tr(key, params, fallback) {
if (api && api.i18n && typeof api.i18n.t === 'function') return api.i18n.t(key, params, fallback);
return fallback || key;
}
state.status = tr('ui.minChars', null, 'Enter at least 2 characters.');
function ensureLayout() {
if (input) return;
containerEl.innerHTML = '';
containerEl.className = 'search-root';
containerEl.setAttribute('data-plugin-id', 'verstak.search');
input = el('input', {
className: 'search-input',
type: 'search',
placeholder: tr('ui.placeholder', null, 'Search files, folders, text'),
value: state.query,
'data-search-input': 'query',
onInput: function (event) {
state.query = event.target.value;
scheduleSearch();
}
});
button = el('button', {
className: 'search-btn',
'data-search-action': 'run',
onClick: search
});
containerEl.appendChild(el('div', { className: 'search-toolbar' }, [
input,
button,
el('span', { className: 'search-scope', title: rootPath || tr('ui.vault', null, 'Vault') }, [rootPath || tr('ui.vault', null, 'Vault')])
]));
statusEl = el('div', { className: 'search-status' });
containerEl.appendChild(statusEl);
alertEl = el('div', { className: 'search-alert', hidden: 'hidden' });
containerEl.appendChild(alertEl);
resultsEl = el('div', { className: 'search-results' });
containerEl.appendChild(resultsEl);
}
function render() {
ensureLayout();
if (document.activeElement !== input && input.value !== state.query) {
input.value = state.query;
}
button.textContent = state.searching ? tr('ui.searching', null, 'Searching...') : tr('ui.search', null, 'Search');
button.disabled = !!state.searching;
statusEl.className = 'search-status' + (state.error ? ' error' : '');
statusEl.textContent = state.error || state.status;
alertEl.innerHTML = '';
if (state.providerErrors && state.providerErrors.length) {
if (typeof alertEl.removeAttribute === 'function') alertEl.removeAttribute('hidden');
alertEl.appendChild(el('details', {}, [
el('summary', {}, [tr('ui.providersFailed', null, 'Some plugin search providers did not respond')]),
el('div', {}, [state.providerErrors.map(function () {
return tr('ui.providerUnavailable', null, 'A search provider is unavailable.');
}).join(' ')])
]));
} else if (typeof alertEl.setAttribute === 'function') {
alertEl.setAttribute('hidden', 'hidden');
}
resultsEl.innerHTML = '';
if (!state.results.length) {
resultsEl.appendChild(el('div', { className: 'search-empty' }, [state.searching ? tr('ui.searching', null, 'Searching...') : tr('ui.noResults', null, 'No results')]));
return;
}
state.results.forEach(function (result) {
resultsEl.appendChild(el('div', { className: 'search-result' }, [
el('div', {}, [
el('div', { className: 'search-result-head' }, [
el('span', { className: 'search-type' }, [resultTypeLabel(result)]),
el('span', { className: 'search-title', title: result.path }, [baseName(result.path)])
]),
el('div', { className: 'search-path' }, [result.path]),
el('div', { className: 'search-snippet' }, [result.snippet]),
el('div', { className: 'search-meta' }, [
(result.sourceLabel ? result.sourceLabel + ' - ' : '') + result.matchType + (result.line ? ' - Line ' + result.line : '')
])
]),
result.openable ? el('button', {
className: 'search-btn search-open-btn',
textContent: tr('ui.open', null, 'Open'),
'data-search-open': result.path,
onClick: function () {
api.workbench.openResource({
kind: 'vault-file',
path: result.path,
mode: 'view'
}).catch(function (err) { console.error('[search] openResource:', err); });
}
}) : null
]));
});
}
function scheduleSearch() {
if (searchTimer) {
clearTimeout(searchTimer);
searchTimer = null;
}
searchSeq += 1;
var query = String(state.query || '').trim();
if (query.length < 2) {
state.searching = false;
state.results = [];
state.status = tr('ui.minChars', null, 'Enter at least 2 characters.');
state.error = '';
state.providerErrors = [];
render();
return;
}
searchTimer = setTimeout(search, SEARCH_DEBOUNCE_MS);
}
async function search() {
if (searchTimer) {
clearTimeout(searchTimer);
searchTimer = null;
}
state.query = String(state.query || '').trim();
if (state.query.length < 2) {
state.results = [];
state.status = tr('ui.minChars', null, 'Enter at least 2 characters.');
state.error = '';
state.providerErrors = [];
render();
return;
}
state.searching = true;
state.error = '';
state.providerErrors = [];
state.status = tr('ui.searching', null, 'Searching...');
var seq = searchSeq + 1;
searchSeq = seq;
render();
try {
await indexRefresh;
var results = await searchVaultText({ query: state.query, limit: MAX_RESULTS });
var external = await runExternalProviders(api, rootPath, state.query, MAX_RESULTS - results.length);
if (seq !== searchSeq) return;
state.results = results.concat(external.results);
state.status = tr('ui.count', { count: state.results.length }, state.results.length + ' result' + (state.results.length === 1 ? '' : 's'));
state.providerErrors = external.errors;
} catch (err) {
if (seq !== searchSeq) return;
console.warn('[verstak.search] search:', err);
state.results = [];
state.error = tr('ui.searchError', null, 'Could not search the vault. Please try again.');
state.providerErrors = [];
} finally {
if (seq !== searchSeq) return;
state.searching = false;
render();
}
}
async function searchVaultText(args) {
var query = args && args.query != null ? args.query : state.query;
var limit = args && args.limit ? Number(args.limit) : MAX_RESULTS;
index = await loadOrBuildIndex(api, rootPath, index);
return runLocalSearch(index, query).slice(0, limit > 0 ? limit : MAX_RESULTS);
}
async function refreshIndex() {
index = await buildIndex(api, rootPath);
await writeStoredIndex(api, index);
return index;
}
function handleFileChanged(event) {
var payload = (event && event.payload) || event || {};
var changedPath = cleanPath(payload.relativePath || payload.path || '');
if (changedPath && rootPath && changedPath !== rootPath && changedPath.indexOf(rootPath + '/') !== 0) return;
indexRefresh = refreshIndex().catch(function (err) {
console.error('[search] refresh index:', err);
});
return indexRefresh;
}
function setupIntegrations() {
if (api.commands && typeof api.commands.register === 'function') {
api.commands.register(SEARCH_COMMAND_ID, searchVaultText).then(function (unregister) {
if (typeof unregister === 'function') cleanupFns.push(unregister);
}).catch(function (err) {
console.error('[search] register command:', err);
});
}
if (api.events && typeof api.events.subscribe === 'function') {
api.events.subscribe('file.changed', handleFileChanged).then(function (unsubscribe) {
if (typeof unsubscribe === 'function') cleanupFns.push(unsubscribe);
}).catch(function (err) {
console.error('[search] subscribe file.changed:', err);
});
}
}
setupIntegrations();
render();
if (api.i18n && typeof api.i18n.onDidChangeLocale === 'function') {
cleanupFns.push(api.i18n.onDidChangeLocale(function () {
if (input) input.setAttribute('placeholder', tr('ui.placeholder', null, 'Search files, folders, text'));
render();
}));
}
containerEl.__verstakSearchCleanup = function () {
if (searchTimer) clearTimeout(searchTimer);
searchSeq += 1;
while (cleanupFns.length) {
try {
cleanupFns.pop()();
} catch (err) {
console.error('[search] cleanup:', err);
}
}
};
},
unmount: function (containerEl) {
if (containerEl.__verstakSearchCleanup) containerEl.__verstakSearchCleanup();
delete containerEl.__verstakSearchCleanup;
containerEl.innerHTML = '';
}
};
window.VerstakPluginRegister('verstak.search', {
components: { SearchView: SearchView }
});
})();
+18
View File
@@ -0,0 +1,18 @@
{
"manifest.name": "Search",
"manifest.description": "Deal-scoped vault text search provider.",
"contributions.workspaceItems.verstak.search.workspace.title": "Search",
"contributions.commands.verstak.search.searchVaultText.title": "Search Vault Text",
"contributions.searchProviders.verstak.search.vault-text.label": "Vault Text Search",
"ui.minChars": "Enter at least 2 characters.",
"ui.placeholder": "Search files, folders, text",
"ui.vault": "Vault",
"ui.searching": "Searching...",
"ui.search": "Search",
"ui.providersFailed": "Some plugin search providers did not respond",
"ui.providerUnavailable": "A search provider is unavailable.",
"ui.searchError": "Could not search the vault. Please try again.",
"ui.noResults": "No results",
"ui.open": "Open",
"ui.count": "{count} result(s)"
}
+18
View File
@@ -0,0 +1,18 @@
{
"manifest.name": "Поиск",
"manifest.description": "Поиск текста в хранилище в пределах Дела.",
"contributions.workspaceItems.verstak.search.workspace.title": "Поиск",
"contributions.commands.verstak.search.searchVaultText.title": "Искать текст в хранилище",
"contributions.searchProviders.verstak.search.vault-text.label": "Поиск по тексту хранилища",
"ui.minChars": "Введите не менее 2 символов.",
"ui.placeholder": "Поиск файлов, папок и текста",
"ui.vault": "Хранилище",
"ui.searching": "Поиск...",
"ui.search": "Искать",
"ui.providersFailed": "Некоторые поставщики поиска не ответили",
"ui.providerUnavailable": "Один из источников поиска недоступен.",
"ui.searchError": "Не удалось выполнить поиск в хранилище. Повторите попытку.",
"ui.noResults": "Ничего не найдено",
"ui.open": "Открыть",
"ui.count": "Результатов: {count}"
}
+54
View File
@@ -0,0 +1,54 @@
{
"schemaVersion": 1,
"id": "verstak.search",
"name": "Search",
"version": "0.1.0",
"apiVersion": "0.1.0",
"description": "Deal-scoped vault text search provider.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official",
"icon": "search",
"provides": [
"verstak/search/v1",
"search.provider"
],
"requires": [
"verstak/core/files/v1",
"verstak/core/workbench/v1"
],
"permissions": [
"files.read",
"workbench.open",
"ui.register",
"storage.namespace",
"events.subscribe",
"commands.register"
],
"frontend": {
"entry": "frontend/src/index.js"
},
"contributes": {
"workspaceItems": [
{
"id": "verstak.search.workspace",
"title": "Search",
"icon": "search",
"component": "SearchView"
}
],
"commands": [
{
"id": "verstak.search.searchVaultText",
"title": "Search Vault Text",
"handler": "verstak.search.searchVaultText"
}
],
"searchProviders": [
{
"id": "verstak.search.vault-text",
"label": "Vault Text Search",
"handler": "verstak.search.searchVaultText"
}
]
}
}
+717
View File
@@ -0,0 +1,717 @@
/* ===========================================================
Secrets Plugin - Verstak v2 Frontend Bundle
Contract: window.VerstakPluginRegister(id, { components })
=========================================================== */
(function () {
'use strict';
var PLUGIN_ID = 'verstak.secrets';
var ScopeGlobal = 'global';
var ScopeWorkspace = 'workspace';
function injectStyles() {
if (document.getElementById('secrets-style-injected')) return;
var style = document.createElement('style');
style.id = 'secrets-style-injected';
style.textContent = STYLES;
document.head.appendChild(style);
}
var STYLES = [
'.secrets-root{display:grid;grid-template-columns:minmax(17rem,20rem) minmax(0,1fr);height:100%;min-height:0;background:var(--vt-color-background,#101020);color:var(--vt-color-text-primary,#f4f7fb);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif}',
'.secrets-panel{min-height:0;overflow:auto;border-right:1px solid var(--vt-color-border,#202b46);background:var(--vt-color-surface-muted,#111629)}',
'.secrets-main{min-width:0;min-height:0;overflow:auto;padding:1rem;background:var(--vt-color-background,#101020)}',
'.secrets-toolbar{display:flex;align-items:center;gap:.5rem;min-height:2.75rem;padding:.65rem .75rem;border-bottom:1px solid var(--vt-color-border,#202b46)}',
'.secrets-filters{display:grid;grid-template-columns:minmax(0,1fr) minmax(8rem,.8fr);gap:.4rem;padding:.5rem .55rem;border-bottom:1px solid var(--vt-color-border,#202b46)}',
'.secrets-title{font-weight:600;font-size:.88rem}',
'.secrets-count{color:var(--vt-color-text-muted,#7f8aa3);font-size:.76rem}',
'.secrets-spacer{flex:1}',
'.secrets-btn{height:2rem;padding:0 .65rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-md,6px);background:var(--vt-color-surface-hover,#1b2440);color:var(--vt-color-text-secondary,#b7c0d4);font:inherit;font-size:.78rem;cursor:pointer}',
'.secrets-btn:hover{border-color:var(--vt-color-accent,#4ecca3);background:var(--vt-color-surface-hover,#1b2440);color:var(--vt-color-text-primary,#f4f7fb)}',
'.secrets-btn.primary{background:var(--vt-color-accent,#4ecca3);border-color:var(--vt-color-accent,#4ecca3);color:#101827}',
'.secrets-btn.danger{border-color:rgba(233,69,96,.5);color:#ff9aaa;background:var(--vt-color-danger-muted,rgba(233,69,96,.14))}',
'.secrets-btn:disabled{opacity:.45;cursor:default}',
'.secrets-group{padding:.6rem .55rem .25rem;color:var(--vt-color-text-muted,#7f8aa3);font-size:.72rem;text-transform:uppercase;letter-spacing:.04em}',
'.secrets-list{display:grid;gap:.25rem;padding:.2rem .45rem .75rem}',
'.secrets-item{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.45rem;align-items:center;text-align:left;border:1px solid transparent;border-radius:var(--vt-radius-md,6px);background:transparent;color:var(--vt-color-text-secondary,#b7c0d4);padding:.5rem .55rem;cursor:pointer}',
'.secrets-item:hover{background:var(--vt-color-surface-hover,#1b2440);border-color:var(--vt-color-border,#202b46)}',
'.secrets-item.active{background:var(--vt-color-surface-selected,rgba(78,204,163,.14));border-color:rgba(78,204,163,.45);box-shadow:inset 2px 0 0 var(--vt-color-accent,#4ecca3)}',
'.secrets-item-title{font-size:.82rem;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
'.secrets-item-meta{font-size:.72rem;color:var(--vt-color-text-muted,#7f8aa3);margin-top:.12rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
'.secrets-empty{padding:1.5rem;color:var(--vt-color-text-muted,#7f8aa3);font-size:.84rem;text-align:center}',
'.secrets-card{max-width:46rem;display:grid;gap:.8rem}',
'.secrets-card h2{font-size:1rem;margin:0;color:var(--vt-color-text-primary,#f4f7fb)}',
'.secrets-form{display:grid;gap:.65rem;border:1px solid var(--vt-color-border,#202b46);border-radius:var(--vt-radius-lg,8px);padding:.9rem;background:var(--vt-color-surface,#15152c)}',
'.secrets-row{display:grid;grid-template-columns:8rem minmax(0,1fr);gap:.65rem;align-items:center}',
'.secrets-label{font-size:.78rem;color:var(--vt-color-text-muted,#7f8aa3)}',
'.secrets-input,.secrets-textarea,.secrets-select,.secrets-search{width:100%;box-sizing:border-box;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:#0f1424;color:var(--vt-color-text-primary,#f4f7fb);font:inherit;font-size:.84rem;padding:.45rem .55rem;outline:none}',
'.secrets-search{font-size:.76rem}',
'.secrets-select{appearance:none;background-color:#0d1117;background-image:linear-gradient(45deg,transparent 50%,#8b949e 50%),linear-gradient(135deg,#8b949e 50%,transparent 50%);background-position:calc(100% - 16px) 50%,calc(100% - 11px) 50%;background-size:5px 5px,5px 5px;background-repeat:no-repeat;padding-right:2rem}.secrets-select option{background:#0d1117;color:var(--vt-color-text-primary,#f4f7fb)}',
'.secrets-textarea{min-height:6rem;resize:vertical;font-family:ui-monospace,SFMono-Regular,Consolas,monospace}',
'.secrets-input:focus,.secrets-textarea:focus,.secrets-select:focus,.secrets-search:focus{border-color:var(--vt-color-accent,#4ecca3);box-shadow:var(--vt-focus-ring,0 0 0 2px rgba(78,204,163,.34))}',
'.secrets-actions{display:flex;gap:.5rem;flex-wrap:wrap}',
'.secrets-status{font-size:.78rem;color:var(--vt-color-text-muted,#7f8aa3);min-height:1rem}',
'.secrets-status.error{color:#ffc6ce}',
'.secrets-secret-value{white-space:pre-wrap;overflow-wrap:anywhere;border:1px solid var(--vt-color-border-strong,#2c456a);background:#0f1424;border-radius:var(--vt-radius-sm,4px);padding:.7rem;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:.82rem}',
'.secrets-hidden-value{display:inline-flex;align-items:center;min-height:2rem;width:100%;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:#0f1424;color:var(--vt-color-text-muted,#7f8aa3);padding:.35rem .55rem;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;letter-spacing:.08em}',
'.secrets-table{width:100%;border-collapse:collapse;border:1px solid var(--vt-color-border,#202b46);background:var(--vt-color-surface,#15152c);border-radius:var(--vt-radius-lg,8px);overflow:hidden}',
'.secrets-table th,.secrets-table td{border-bottom:1px solid var(--vt-color-border,#202b46);padding:.55rem .65rem;text-align:left;vertical-align:top;font-size:.84rem}',
'.secrets-table th{width:9rem;color:var(--vt-color-text-muted,#7f8aa3);font-weight:500;background:var(--vt-color-surface-muted,#111629)}',
'.secrets-table td{color:var(--vt-color-text-primary,#f4f7fb);overflow-wrap:anywhere}',
'.secrets-table tr:last-child th,.secrets-table tr:last-child td{border-bottom:0}',
'.secrets-modal-overlay{position:fixed;inset:0;z-index:10000;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.6)}',
'.secrets-modal{width:24rem;max-width:90vw;display:grid;gap:.8rem;padding:1rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-lg,8px);background:var(--vt-color-surface,#15152c);box-shadow:var(--vt-elevation-menu,0 14px 32px rgba(0,0,0,.42))}',
'.secrets-modal-title{margin:0;font-size:.96rem}',
'.secrets-modal-message{margin:0;color:var(--vt-color-text-secondary,#b7c0d4);font-size:.84rem;line-height:1.45}',
'.secrets-modal-actions{display:flex;justify-content:flex-end;gap:.5rem}',
'@media(max-width:780px){.secrets-root{grid-template-columns:1fr}.secrets-panel{border-right:0;border-bottom:1px solid #252b36;max-height:45vh}.secrets-row,.secrets-filters{grid-template-columns:1fr}}'
].join('\n');
function el(tag, attrs, children) {
var elem = document.createElement(tag);
if (attrs) {
Object.keys(attrs).forEach(function (key) {
if (attrs[key] == null) return;
if (key === 'className') elem.className = attrs[key];
else if (key.slice(0, 2) === 'on') elem.addEventListener(key.slice(2).toLowerCase(), attrs[key]);
else if (key === 'textContent') elem.textContent = attrs[key];
else elem.setAttribute(key, attrs[key]);
});
}
if (children) {
(Array.isArray(children) ? children : [children]).forEach(function (child) {
if (child == null) return;
elem.appendChild(typeof child === 'string' ? document.createTextNode(child) : child);
});
}
return elem;
}
function text(value) {
return String(value == null ? '' : value);
}
function cleanWorkspace(value) {
return text(value).trim().replace(/^\/+|\/+$/g, '');
}
function workspaceFromProps(props) {
var node = props && props.workspaceNode;
return cleanWorkspace((props && (props.workspaceRootPath || props.workspaceName || props.workspaceNodeId))
|| (node && (node.rootPath || node.name || node.id)));
}
function scopeLabel(record, translate) {
var scope = record && record.scope || {};
if (scope.kind === ScopeWorkspace) return cleanWorkspace(scope.workspaceRootPath) || translate('ui.deal', null, 'Deal');
return translate('ui.global', null, 'Global');
}
function selectedIDFromProps(props) {
var resource = props && props.resource || {};
var request = props && props.request || {};
var path = text(resource.path || request.path || props && props.secretId);
if (path.indexOf('verstak-secret://') === 0) return decodeURIComponent(path.slice('verstak-secret://'.length));
return decodeURIComponent(path.replace(/^\/+/, ''));
}
function groupRecords(records, translate) {
var groups = {};
records.forEach(function (record) {
var label = scopeLabel(record, translate);
groups[label] = groups[label] || [];
groups[label].push(record);
});
return Object.keys(groups).sort(function (a, b) {
if (a === translate('ui.global', null, 'Global')) return -1;
if (b === translate('ui.global', null, 'Global')) return 1;
return a.localeCompare(b);
}).map(function (label) {
groups[label].sort(function (a, b) {
return text(a.title || a.id).localeCompare(text(b.title || b.id));
});
return { label: label, records: groups[label] };
});
}
function writeClipboard(api, value) {
if (api && api.clipboard && typeof api.clipboard.writeText === 'function') return api.clipboard.writeText(value);
if (window.navigator && window.navigator.clipboard && typeof window.navigator.clipboard.writeText === 'function') {
return window.navigator.clipboard.writeText(value);
}
return Promise.reject(new Error('clipboard API unavailable'));
}
var SecretsView = {
mount: function (containerEl, props, api) {
injectStyles();
containerEl.innerHTML = '';
var disposed = false;
var workspaceRoot = workspaceFromProps(props || {});
var selectedID = selectedIDFromProps(props || {});
var records = [];
var workspaceOptions = [];
var scopeFilter = 'all';
var searchQuery = '';
var selectedRecord = null;
var selectedValue = '';
var isValueVisible = false;
var deleteModal = null;
var initialized = false;
var unlocked = false;
var statusText = '';
var statusError = false;
function tr(key, params, fallback) {
if (api && api.i18n && typeof api.i18n.t === 'function') return api.i18n.t(key, params, fallback);
return fallback || key;
}
function setStatus(message, isError) {
statusText = message || '';
statusError = !!isError;
render();
}
function userFacingError(action, err) {
var raw = (err && err.message) ? err.message : String(err || '');
if (typeof console !== 'undefined' && typeof console.warn === 'function') {
console.warn('[verstak.secrets] ' + action + ' failed', err);
}
if (action === 'unlock') {
var minimumLength = raw.match(/master password must be at least (\d+) characters/i);
if (minimumLength) {
return tr('ui.masterPasswordMinLength', { count: minimumLength[1] }, 'Master password must be at least ' + minimumLength[1] + ' characters.');
}
if (/master password is empty/i.test(raw)) {
return tr('ui.masterPasswordRequired', null, 'Enter a master password.');
}
if (/invalid master password/i.test(raw)) {
return tr('ui.masterPasswordInvalid', null, 'The master password is incorrect.');
}
}
return tr('ui.' + action + 'Error', null, {
status: 'Could not check the secret storage. Please try again.',
load: 'Could not load secrets. Please try again.',
read: 'Could not open this secret. Please try again.',
save: 'Could not save the secret. Please try again.',
delete: 'Could not delete the secret. Please try again.',
copyValue: 'Could not copy the secret value. Please try again.',
copyLink: 'Could not copy the secret link. Please try again.',
unlock: 'Could not unlock secrets. Check the master password and try again.'
}[action] || 'Could not complete this action. Please try again.');
}
function setUserFacingError(action, err) {
setStatus(userFacingError(action, err), true);
}
function recordWorkspaceRoot(record) {
var scope = record && record.scope || {};
return scope.kind === ScopeWorkspace ? cleanWorkspace(scope.workspaceRootPath) : '';
}
function workspaceRoots() {
var seen = {};
var values = [];
workspaceOptions.concat(records.map(recordWorkspaceRoot)).forEach(function (value) {
value = cleanWorkspace(value);
if (!value || seen[value]) return;
seen[value] = true;
values.push(value);
});
return values.sort(function (a, b) { return a.localeCompare(b); });
}
function filteredRecords() {
var query = text(searchQuery).trim().toLowerCase();
return records.filter(function (record) {
var recordWorkspace = recordWorkspaceRoot(record);
if (scopeFilter === ScopeGlobal && recordWorkspace) return false;
if (scopeFilter.indexOf(ScopeWorkspace + ':') === 0 && recordWorkspace !== scopeFilter.slice((ScopeWorkspace + ':').length)) return false;
if (!query) return true;
return [record.title, record.id, record.username, recordWorkspace].some(function (value) {
return text(value).toLowerCase().indexOf(query) !== -1;
});
});
}
function clearHiddenSelection() {
if (!selectedRecord) return;
var visible = filteredRecords();
if (visible.some(function (record) { return record.id === selectedRecord.id; })) return;
selectedRecord = null;
selectedValue = '';
}
function loadWorkspaceOptions() {
if (!api || !api.files || typeof api.files.list !== 'function') return Promise.resolve();
return api.files.list('').then(function (items) {
workspaceOptions = (Array.isArray(items) ? items : []).filter(function (item) {
return text(item && item.type).toLowerCase() === 'folder';
}).map(function (item) {
return cleanWorkspace(item.relativePath || item.name);
}).filter(function (value) {
return value && value.indexOf('/') === -1;
});
}).catch(function () {
workspaceOptions = [];
});
}
function renderLocked() {
var passwordInput = el('input', {
className: 'secrets-input',
type: 'password',
'data-secret-master-password': '',
placeholder: tr('ui.masterPassword', null, 'Master password')
});
var confirmInput = initialized ? null : el('input', {
className: 'secrets-input',
type: 'password',
'data-secret-master-password-confirm': '',
placeholder: tr('ui.repeatPassword', null, 'Repeat master password')
});
var unlockBtn = el('button', {
className: 'secrets-btn primary',
type: 'button',
'data-secret-unlock': '',
onClick: function () {
if (!initialized && passwordInput.value !== confirmInput.value) {
setStatus(tr('ui.passwordMismatch', null, 'Master passwords do not match'), true);
return;
}
unlockBtn.disabled = true;
api.secrets.unlock(passwordInput.value).then(function () {
initialized = true;
unlocked = true;
return loadRecords();
}).catch(function (err) {
unlockBtn.disabled = false;
setUserFacingError('unlock', err);
});
}
}, [tr('ui.unlock', null, 'Unlock')]);
if (!initialized) unlockBtn.textContent = tr('ui.createMaster', null, 'Create master password');
var rows = [
el('div', { className: 'secrets-row' }, [
el('label', { className: 'secrets-label' }, [tr('ui.password', null, 'Password')]),
passwordInput
])
];
if (confirmInput) {
rows.push(el('div', { className: 'secrets-row' }, [
el('label', { className: 'secrets-label' }, [tr('ui.repeat', null, 'Repeat')]),
confirmInput
]));
}
rows.push(el('div', { className: 'secrets-actions' }, [unlockBtn]));
rows.push(el('div', { className: statusError ? 'secrets-status error' : 'secrets-status' }, [statusText]));
containerEl.innerHTML = '';
containerEl.appendChild(el('div', { className: 'secrets-root' }, [
el('div', { className: 'secrets-panel' }, [
el('div', { className: 'secrets-toolbar' }, [
el('span', { className: 'secrets-title' }, [tr('ui.title', null, 'Secrets')])
])
]),
el('div', { className: 'secrets-main' }, [
el('div', { className: 'secrets-card' }, [
el('h2', {}, [initialized ? tr('ui.unlockSecrets', null, 'Unlock secrets') : tr('ui.createMaster', null, 'Create master password')]),
el('div', { className: 'secrets-form' }, rows)
])
])
]));
}
function renderList() {
var visibleRecords = filteredRecords();
var scopeSelect = el('select', {
className: 'secrets-select',
'data-secret-scope-filter': '',
onChange: function (event) {
scopeFilter = text(event.target.value) || 'all';
clearHiddenSelection();
render();
}
}, [
el('option', { value: 'all' }, [tr('ui.scopeAll', null, 'All scopes')]),
el('option', { value: ScopeGlobal }, [tr('ui.global', null, 'Global')])
].concat(workspaceRoots().map(function (workspace) {
return el('option', { value: ScopeWorkspace + ':' + workspace }, [workspace]);
})));
scopeSelect.value = scopeFilter;
var searchInput = el('input', {
className: 'secrets-search',
type: 'search',
'data-secret-search': '',
placeholder: tr('ui.search', null, 'Search secrets'),
value: searchQuery,
onInput: function (event) {
searchQuery = text(event.target.value);
clearHiddenSelection();
render();
}
});
var children = [
el('div', { className: 'secrets-toolbar' }, [
el('span', { className: 'secrets-title' }, [tr('ui.title', null, 'Secrets')]),
el('span', { className: 'secrets-count' }, [String(visibleRecords.length)]),
el('span', { className: 'secrets-spacer' }),
el('button', { className: 'secrets-btn', type: 'button', onClick: showNewSecret }, [tr('ui.new', null, 'New')])
]),
el('div', { className: 'secrets-filters' }, [searchInput, scopeSelect])
];
if (!visibleRecords.length) {
children.push(el('div', { className: 'secrets-empty' }, [tr('ui.empty', null, 'No secrets')]));
return children;
}
groupRecords(visibleRecords, tr).forEach(function (group) {
children.push(el('div', { className: 'secrets-group' }, [group.label]));
children.push(el('div', { className: 'secrets-list' }, group.records.map(function (record) {
var active = selectedRecord && selectedRecord.id === record.id;
return el('button', {
className: active ? 'secrets-item active' : 'secrets-item',
type: 'button',
onClick: function () { selectRecord(record.id); }
}, [
el('span', {}, [
el('span', { className: 'secrets-item-title' }, [record.title || record.id]),
el('span', { className: 'secrets-item-meta' }, [record.username ? record.username + ' · ' + record.id : record.id])
])
]);
})));
});
return children;
}
function renderSelected() {
if (!selectedRecord) return el('div', { className: 'secrets-card' }, [
el('h2', {}, [tr('ui.select', null, 'Select a secret')]),
el('div', { className: statusError ? 'secrets-status error' : 'secrets-status' }, [statusText])
]);
var valueIsAvailable = !!selectedValue;
var valueDisplay = valueIsAvailable && isValueVisible ? selectedValue : '••••••••••••';
return el('div', { className: 'secrets-card' }, [
el('h2', {}, [selectedRecord.title || selectedRecord.id]),
el('table', { className: 'secrets-table' }, [
el('tbody', {}, [
fieldRow(tr('ui.group', null, 'Group'), scopeLabel(selectedRecord, tr)),
fieldRow('ID', selectedRecord.id),
fieldRow(tr('ui.username', null, 'Username'), selectedRecord.username || ''),
fieldRow(tr('ui.password', null, 'Password'), valueDisplay, isValueVisible && valueIsAvailable ? 'secrets-secret-value' : 'secrets-hidden-value'),
fieldRow(tr('ui.updated', null, 'Updated'), selectedRecord.updatedAt || '')
])
]),
el('div', { className: 'secrets-actions' }, [
el('button', {
className: 'secrets-btn',
type: 'button',
'data-secret-copy-link': selectedRecord.id,
onClick: function () { copySecretLink(selectedRecord.id); }
}, [tr('ui.copyLink', null, 'Copy secret link')]),
el('button', {
className: 'secrets-btn',
type: 'button',
disabled: !valueIsAvailable,
'data-secret-copy-value': selectedRecord.id,
onClick: function () { copySecretValue(selectedValue); }
}, [tr('ui.copyValue', null, 'Copy value')]),
el('button', {
className: 'secrets-btn',
type: 'button',
disabled: !valueIsAvailable,
'data-secret-toggle-value': selectedRecord.id,
onClick: function () {
if (!selectedValue) return;
isValueVisible = !isValueVisible;
render();
}
}, [isValueVisible ? tr('ui.hideValue', null, 'Hide value') : tr('ui.showValue', null, 'Show value')]),
el('button', {
className: 'secrets-btn',
type: 'button',
'data-secret-edit': selectedRecord.id,
onClick: function () { showEditSecret(); }
}, [tr('ui.edit', null, 'Edit')]),
el('button', {
className: 'secrets-btn danger',
type: 'button',
'data-secret-delete': selectedRecord.id,
onClick: function () { deleteSecret(selectedRecord.id); }
}, [tr('ui.delete', null, 'Delete')])
]),
el('div', { className: statusError ? 'secrets-status error' : 'secrets-status' }, [statusText])
]);
}
function fieldRow(label, value, valueClass) {
return el('tr', {}, [
el('th', {}, [label]),
el('td', {}, [valueClass ? el('span', { className: valueClass }, [value || '']) : (value || '')])
]);
}
function renderSecretForm(existing) {
var isEdit = !!existing;
var title = el('input', { className: 'secrets-input', type: 'text', 'data-secret-title': '', placeholder: tr('ui.fieldTitle', null, 'Title') });
title.value = existing ? text(existing.title) : '';
var id = el('input', { className: 'secrets-input', type: 'text', placeholder: 'stable.id' });
id.value = existing ? text(existing.id) : '';
id.disabled = isEdit;
var username = el('input', { className: 'secrets-input', type: 'text', placeholder: tr('ui.optionalUsername', null, 'optional username') });
username.value = existing ? text(existing.username) : '';
var value = el('textarea', { className: 'secrets-textarea', 'data-secret-value': '', placeholder: tr('ui.secretValue', null, 'Secret value') });
value.value = isEdit ? selectedValue : '';
var scope = el('select', { className: 'secrets-select' }, [
el('option', { value: ScopeGlobal }, [tr('ui.global', null, 'Global')]),
el('option', { value: ScopeWorkspace }, [tr('ui.deal', null, 'Deal')])
]);
scope.setAttribute('data-secret-scope', '');
var existingWorkspace = existing ? recordWorkspaceRoot(existing) : '';
var defaultWorkspace = existingWorkspace || workspaceRoot || workspaceRoots()[0] || '';
var workspace = el('select', { className: 'secrets-select', 'data-secret-workspace': '' }, [
el('option', { value: '' }, [tr('ui.chooseWorkspace', null, 'Choose a Deal')])
].concat(workspaceRoots().map(function (item) {
return el('option', { value: item }, [item]);
})));
workspace.value = defaultWorkspace;
var workspaceRow = el('div', { className: 'secrets-row' }, [
el('label', { className: 'secrets-label' }, [tr('ui.deal', null, 'Deal')]), workspace
]);
function updateWorkspaceVisibility() {
workspaceRow.hidden = scope.value !== ScopeWorkspace;
}
scope.addEventListener('change', updateWorkspaceVisibility);
scope.value = existing && existing.scope && existing.scope.kind ? existing.scope.kind : (workspaceRoot ? ScopeWorkspace : ScopeGlobal);
updateWorkspaceVisibility();
return el('div', { className: 'secrets-card' }, [
el('h2', {}, [isEdit ? tr('ui.editSecret', null, 'Edit secret') : tr('ui.newSecret', null, 'New secret')]),
el('div', { className: 'secrets-form' }, [
el('div', { className: 'secrets-row' }, [el('label', { className: 'secrets-label' }, [tr('ui.fieldTitle', null, 'Title')]), title]),
el('div', { className: 'secrets-row' }, [el('label', { className: 'secrets-label' }, ['ID']), id]),
el('div', { className: 'secrets-row' }, [el('label', { className: 'secrets-label' }, [tr('ui.username', null, 'Username')]), username]),
el('div', { className: 'secrets-row' }, [el('label', { className: 'secrets-label' }, [tr('ui.scope', null, 'Scope')]), scope]),
workspaceRow,
el('div', { className: 'secrets-row' }, [el('label', { className: 'secrets-label' }, [tr('ui.value', null, 'Value')]), value]),
el('div', { className: 'secrets-actions' }, [
el('button', {
className: 'secrets-btn primary',
type: 'button',
'data-secret-save': '',
onClick: function () {
var nextID = text(id.value).trim() || text(title.value).trim().toLowerCase().replace(/[^a-z0-9._-]+/g, '.').replace(/^\.+|\.+$/g, '');
var targetWorkspace = cleanWorkspace(workspace.value);
if (scope.value === ScopeWorkspace && !targetWorkspace) {
setStatus(tr('ui.workspaceRequired', null, 'Choose a Deal for this secret.'), true);
return;
}
api.secrets.write({
id: nextID,
title: text(title.value).trim() || nextID,
username: text(username.value).trim(),
value: text(value.value),
scope: scope.value === ScopeWorkspace ? { kind: ScopeWorkspace, workspaceRootPath: targetWorkspace } : { kind: ScopeGlobal }
}).then(function (record) {
selectedID = record.id;
selectedRecord = record;
selectedValue = '';
return loadRecords();
}).catch(function (err) {
setUserFacingError('save', err);
});
}
}, [tr('ui.save', null, 'Save')]),
el('button', { className: 'secrets-btn', type: 'button', onClick: function () { mode = 'selected'; render(); } }, [tr('ui.cancel', null, 'Cancel')])
]),
el('div', { className: statusError ? 'secrets-status error' : 'secrets-status' }, [statusText])
])
]);
}
var mode = 'selected';
function render() {
if (disposed) return;
if (!unlocked) {
renderLocked();
return;
}
containerEl.innerHTML = '';
containerEl.appendChild(el('div', { className: 'secrets-root' }, [
el('div', { className: 'secrets-panel' }, renderList()),
el('div', { className: 'secrets-main' }, [
mode === 'new' ? renderSecretForm(null) : mode === 'edit' ? renderSecretForm(selectedRecord) : renderSelected()
])
]));
}
function loadRecords() {
return api.secrets.list().then(function (items) {
records = Array.isArray(items) ? items : [];
var wanted = selectedID || (selectedRecord && selectedRecord.id) || '';
if (wanted) {
var found = records.find(function (record) { return record.id === wanted; });
if (found) return selectRecord(found.id);
selectedRecord = null;
selectedValue = '';
mode = 'selected';
statusText = tr('ui.requestedUnavailable', null, 'The requested secret is unavailable.');
statusError = true;
render();
return;
}
selectedRecord = records[0] || null;
selectedValue = '';
mode = 'selected';
render();
}).catch(function (err) {
setUserFacingError('load', err);
});
}
function selectRecord(id) {
mode = 'selected';
selectedID = id;
selectedRecord = records.find(function (record) { return record.id === id; }) || null;
selectedValue = '';
isValueVisible = false;
render();
if (!id) return Promise.resolve();
return api.secrets.read(id).then(function (record) {
if (disposed) return;
selectedRecord = record;
selectedValue = text(record.value);
render();
}).catch(function (err) {
if (disposed) return;
setUserFacingError('read', err);
});
}
function showNewSecret() {
mode = 'new';
statusText = '';
statusError = false;
render();
}
function showEditSecret() {
if (!selectedRecord) return;
mode = 'edit';
statusText = '';
statusError = false;
render();
}
function deleteSecret(id) {
if (!id) return;
removeDeleteModal();
var title = selectedRecord && selectedRecord.id === id ? selectedRecord.title || selectedRecord.id : id;
var overlay = el('div', {
className: 'secrets-modal-overlay',
'data-secret-delete-modal': '',
role: 'presentation'
});
var cancel = el('button', {
className: 'secrets-btn',
type: 'button',
'data-secret-delete-cancel': '',
onClick: removeDeleteModal
}, [tr('ui.cancel', null, 'Cancel')]);
var confirm = el('button', {
className: 'secrets-btn danger',
type: 'button',
'data-secret-delete-confirm': '',
onClick: function () {
removeDeleteModal();
confirmDeleteSecret(id);
}
}, [tr('ui.delete', null, 'Delete')]);
overlay.appendChild(el('div', {
className: 'secrets-modal',
role: 'dialog',
'aria-modal': 'true',
'aria-label': tr('ui.deleteSecretTitle', null, 'Delete secret')
}, [
el('h2', { className: 'secrets-modal-title' }, [tr('ui.deleteSecretTitle', null, 'Delete secret')]),
el('p', { className: 'secrets-modal-message' }, [tr('ui.deleteConfirm', { title: title }, 'Delete "' + title + '"?')]),
el('div', { className: 'secrets-modal-actions' }, [cancel, confirm])
]));
document.body.appendChild(overlay);
deleteModal = overlay;
if (cancel.focus) cancel.focus();
}
function removeDeleteModal() {
if (deleteModal && typeof deleteModal.remove === 'function') deleteModal.remove();
else if (deleteModal && deleteModal.parentNode) deleteModal.parentNode.removeChild(deleteModal);
deleteModal = null;
}
function confirmDeleteSecret(id) {
api.secrets.delete(id).then(function () {
selectedID = '';
selectedRecord = null;
selectedValue = '';
isValueVisible = false;
return loadRecords();
}).catch(function (err) {
setUserFacingError('delete', err);
});
}
function copySecretValue(value) {
if (!value) return;
writeClipboard(api, value).then(function () {
setStatus(tr('ui.valueCopied', null, 'Secret value copied'), false);
}).catch(function (err) {
setUserFacingError('copyValue', err);
});
}
function copySecretLink(id) {
api.secrets.copyLink(id).then(function (link) {
return writeClipboard(api, link).then(function () {
setStatus(tr('ui.linkCopied', null, 'Secret link copied'), false);
});
}).catch(function (err) {
setUserFacingError('copyLink', err);
});
}
api.secrets.status().then(function (status) {
initialized = !!(status && status.initialized);
unlocked = !!(status && status.unlocked);
if (unlocked) return loadWorkspaceOptions().then(loadRecords);
render();
}).catch(function (err) {
statusText = userFacingError('status', err);
statusError = true;
renderLocked();
});
var localeUnsubscribe = api.i18n && typeof api.i18n.onDidChangeLocale === 'function'
? api.i18n.onDidChangeLocale(render)
: null;
containerEl.__secretsCleanup = function () {
disposed = true;
removeDeleteModal();
if (typeof localeUnsubscribe === 'function') localeUnsubscribe();
};
},
unmount: function (containerEl) {
if (containerEl.__secretsCleanup) {
containerEl.__secretsCleanup();
containerEl.__secretsCleanup = null;
}
containerEl.innerHTML = '';
}
};
window.VerstakPluginRegister(PLUGIN_ID, {
components: { SecretsView: SecretsView }
});
})();
+62
View File
@@ -0,0 +1,62 @@
{
"manifest.name": "Secrets",
"manifest.description": "Encrypted global and Deal-scoped secret manager.",
"contributions.views.verstak.secrets.view.title": "Secrets",
"contributions.sidebarItems.verstak.secrets.sidebar.title": "Secrets",
"contributions.openProviders.verstak.secrets.secret.title": "Secrets",
"contributions.settingsPanels.verstak.secrets.settings.title": "Secrets",
"contributions.workspaceItems.verstak.secrets.workspace.title": "Secrets",
"ui.masterPassword": "Master password",
"ui.repeatPassword": "Repeat master password",
"ui.passwordMismatch": "Master passwords do not match",
"ui.masterPasswordMinLength": "Master password must be at least {count} characters.",
"ui.masterPasswordRequired": "Enter a master password.",
"ui.masterPasswordInvalid": "The master password is incorrect.",
"ui.unlock": "Unlock",
"ui.createMaster": "Create master password",
"ui.password": "Password",
"ui.repeat": "Repeat",
"ui.title": "Secrets",
"ui.unlockSecrets": "Unlock secrets",
"ui.new": "New",
"ui.empty": "No secrets",
"ui.select": "Select a secret",
"ui.requestedUnavailable": "The requested secret is unavailable.",
"ui.group": "Group",
"ui.username": "Username",
"ui.updated": "Updated",
"ui.copyLink": "Copy secret link",
"ui.edit": "Edit",
"ui.delete": "Delete",
"ui.copyValue": "Copy value",
"ui.showValue": "Show value",
"ui.hideValue": "Hide value",
"ui.fieldTitle": "Title",
"ui.optionalUsername": "optional username",
"ui.secretValue": "Secret value",
"ui.global": "Global",
"ui.workspace": "Deal",
"ui.editSecret": "Edit secret",
"ui.newSecret": "New secret",
"ui.scope": "Scope",
"ui.deal": "Deal",
"ui.scopeAll": "All scopes",
"ui.search": "Search secrets",
"ui.chooseWorkspace": "Choose a Deal",
"ui.workspaceRequired": "Choose a Deal for this secret.",
"ui.statusError": "Could not check the secret storage. Please try again.",
"ui.loadError": "Could not load secrets. Please try again.",
"ui.readError": "Could not open this secret. Please try again.",
"ui.saveError": "Could not save the secret. Please try again.",
"ui.deleteError": "Could not delete the secret. Please try again.",
"ui.copyValueError": "Could not copy the secret value. Please try again.",
"ui.copyLinkError": "Could not copy the secret link. Please try again.",
"ui.unlockError": "Could not unlock secrets. Check the master password and try again.",
"ui.value": "Value",
"ui.save": "Save",
"ui.cancel": "Cancel",
"ui.deleteSecretTitle": "Delete secret",
"ui.deleteConfirm": "Delete \"{title}\"? This cannot be undone.",
"ui.linkCopied": "Secret link copied",
"ui.valueCopied": "Secret value copied"
}
+62
View File
@@ -0,0 +1,62 @@
{
"manifest.name": "Секреты",
"manifest.description": "Зашифрованное хранилище общих секретов и секретов Дел.",
"contributions.views.verstak.secrets.view.title": "Секреты",
"contributions.sidebarItems.verstak.secrets.sidebar.title": "Секреты",
"contributions.openProviders.verstak.secrets.secret.title": "Секреты",
"contributions.settingsPanels.verstak.secrets.settings.title": "Секреты",
"contributions.workspaceItems.verstak.secrets.workspace.title": "Секреты",
"ui.masterPassword": "Мастер-пароль",
"ui.repeatPassword": "Повторите мастер-пароль",
"ui.passwordMismatch": "Мастер-пароли не совпадают",
"ui.masterPasswordMinLength": "Мастер-пароль должен содержать не менее {count} символов.",
"ui.masterPasswordRequired": "Введите мастер-пароль.",
"ui.masterPasswordInvalid": "Мастер-пароль указан неверно.",
"ui.unlock": "Разблокировать",
"ui.createMaster": "Создать мастер-пароль",
"ui.password": "Пароль",
"ui.repeat": "Повтор",
"ui.title": "Секреты",
"ui.unlockSecrets": "Разблокировать секреты",
"ui.new": "Новый",
"ui.empty": "Секретов нет",
"ui.select": "Выберите секрет",
"ui.requestedUnavailable": "Запрошенный секрет недоступен.",
"ui.group": "Группа",
"ui.username": "Имя пользователя",
"ui.updated": "Обновлён",
"ui.copyLink": "Копировать ссылку на секрет",
"ui.edit": "Изменить",
"ui.delete": "Удалить",
"ui.copyValue": "Копировать значение",
"ui.showValue": "Показать значение",
"ui.hideValue": "Скрыть значение",
"ui.fieldTitle": "Название",
"ui.optionalUsername": "необязательное имя пользователя",
"ui.secretValue": "Значение секрета",
"ui.global": "Общий",
"ui.workspace": "Дело",
"ui.editSecret": "Изменить секрет",
"ui.newSecret": "Новый секрет",
"ui.scope": "Область",
"ui.deal": "Дело",
"ui.scopeAll": "Все области",
"ui.search": "Поиск секретов",
"ui.chooseWorkspace": "Выберите дело",
"ui.workspaceRequired": "Выберите дело для этого секрета.",
"ui.statusError": "Не удалось проверить хранилище секретов. Повторите попытку.",
"ui.loadError": "Не удалось загрузить секреты. Повторите попытку.",
"ui.readError": "Не удалось открыть секрет. Повторите попытку.",
"ui.saveError": "Не удалось сохранить секрет. Повторите попытку.",
"ui.deleteError": "Не удалось удалить секрет. Повторите попытку.",
"ui.copyValueError": "Не удалось скопировать значение секрета. Повторите попытку.",
"ui.copyLinkError": "Не удалось скопировать ссылку на секрет. Повторите попытку.",
"ui.unlockError": "Не удалось разблокировать секреты. Проверьте мастер-пароль и повторите попытку.",
"ui.value": "Значение",
"ui.save": "Сохранить",
"ui.cancel": "Отмена",
"ui.deleteSecretTitle": "Удалить секрет",
"ui.deleteConfirm": "Удалить «{title}»? Это действие нельзя отменить.",
"ui.linkCopied": "Ссылка на секрет скопирована",
"ui.valueCopied": "Значение секрета скопировано"
}
+74
View File
@@ -0,0 +1,74 @@
{
"schemaVersion": 1,
"id": "verstak.secrets",
"name": "Secrets",
"version": "0.1.0",
"apiVersion": "0.1.0",
"description": "Encrypted global and Deal-scoped secret manager.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official",
"icon": "key-round",
"provides": [
"secret-store",
"secrets.read-ui",
"secrets.write-ui"
],
"permissions": [
"files.read",
"secrets.read",
"secrets.write",
"ui.register"
],
"frontend": {
"entry": "frontend/src/index.js"
},
"contributes": {
"views": [
{
"id": "verstak.secrets.view",
"title": "Secrets",
"icon": "key-round",
"component": "SecretsView"
}
],
"sidebarItems": [
{
"id": "verstak.secrets.sidebar",
"title": "Secrets",
"icon": "key-round",
"view": "verstak.secrets.view",
"position": 45
}
],
"openProviders": [
{
"id": "verstak.secrets.secret",
"title": "Secrets",
"priority": 100,
"component": "SecretsView",
"supports": [
{
"kind": "secret",
"modes": ["view"]
}
]
}
],
"settingsPanels": [
{
"id": "verstak.secrets.settings",
"title": "Secrets",
"icon": "key-round",
"component": "SecretsView"
}
],
"workspaceItems": [
{
"id": "verstak.secrets.workspace",
"title": "Secrets",
"icon": "key-round",
"component": "SecretsView"
}
]
}
}
+29
View File
@@ -0,0 +1,29 @@
import { test, expect } from '@playwright/test';
test.describe('Sync Plugin Settings', () => {
test('shows setup form when not configured', async ({ page }) => {
await page.goto('/');
await page.click('[data-plugin="verstak.sync"]');
await page.click('[data-action="settings"]');
await expect(page.locator('.sync-setup')).toBeVisible();
await expect(page.locator('input[placeholder="https://example.com"]')).toBeVisible();
});
test('shows status when configured', async ({ page }) => {
await page.goto('/');
await page.evaluate(() => {
localStorage.setItem('verstak-sync-settings', JSON.stringify({
serverUrl: 'https://sync.example.com',
lastStatus: 'connected',
deviceName: 'test-device'
}));
});
await page.click('[data-plugin="verstak.sync"]');
await page.click('[data-action="settings"]');
await expect(page.locator('.sync-info')).toBeVisible();
await expect(page.locator('.status-ok')).toBeVisible();
});
});
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
{
"name": "verstak-sync-plugin",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "vite build",
"dev": "vite build --watch"
},
"devDependencies": {
"vite": "^5.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"svelte": "^4.0.0"
}
}
@@ -0,0 +1,298 @@
<script>
import { onDestroy, onMount } from 'svelte'
export let api = null
let settings = null
let loading = false
let errorMsg = ''
let resultMsg = ''
let resultKind = ''
let conflictDetails = []
let connectionResult = ''
let connectionOk = null
let serverUrl = ''
let username = ''
let password = ''
let syncInterval = 5
let autoSync = false
let locale = api?.i18n?.getLocale?.() || 'en'
let unsubscribeLocale
function tr(key, params, fallback) {
locale
return api?.i18n?.t?.(key, params, fallback) || fallback || key
}
const INPUT_STYLE = 'width:100%;background:#0f3460;border:1px solid #1a3a5c;color:#e0e0f0;padding:8px 10px;border-radius:4px;font-size:0.85rem;box-sizing:border-box;height:36px;'
const INPUT_FOCUS_STYLE = INPUT_STYLE + 'outline:none;border-color:#4ecca3;'
function reportError(key, fallback, error) {
console.warn('[verstak.sync] operation failed:', error)
return tr(key, null, fallback)
}
function syncAPI() {
if (!api?.sync) throw new Error(tr('ui.apiUnavailable', null, 'Plugin API sync namespace not available'))
return api.sync
}
async function load() {
try {
if (api?.settings?.read) {
const saved = await api.settings.read()
if (saved) {
serverUrl = saved.serverUrl || ''
username = saved.username || ''
autoSync = !!saved.autoSync
syncInterval = saved.syncInterval || 5
}
}
} catch (error) {
console.warn('[verstak.sync] settings load failed:', error)
}
try {
settings = await syncAPI().status()
if (settings) {
if (settings.serverUrl) serverUrl = settings.serverUrl
if (settings.syncInterval != null) syncInterval = settings.syncInterval
if (settings.syncInterval > 0) autoSync = true
if (settings.lastError) console.warn('[verstak.sync] last sync failed:', settings.lastError)
}
} catch (error) {
console.warn('[verstak.sync] status load failed:', error)
settings = null
}
}
load()
async function saveSettings() {
if (syncInterval < 1 || syncInterval > 1440) {
errorMsg = tr('ui.intervalError', null, 'Sync interval must be between 1 and 1440 minutes.')
return
}
loading = true
errorMsg = ''
resultMsg = ''
try {
if (api?.settings?.writeAll) {
await api.settings.writeAll({ serverUrl, username, autoSync, syncInterval })
}
await syncAPI().setInterval(autoSync ? syncInterval : 0)
resultMsg = tr('ui.settingsSaved', null, 'Settings saved.')
resultKind = ''
} catch (e) {
errorMsg = reportError('ui.saveFailed', 'Could not save sync settings. Please try again.', e)
}
loading = false
}
async function testConnection() {
if (!serverUrl) { errorMsg = tr('ui.serverRequired', null, 'Server URL is required.'); return }
loading = true
connectionResult = ''
connectionOk = null
errorMsg = ''
try {
await syncAPI().testConnection(serverUrl, username, password)
connectionOk = true
connectionResult = tr('ui.connectionSuccessful', null, 'Connection successful.')
} catch (e) {
connectionOk = false
connectionResult = reportError('ui.connectionFailed', 'Could not connect. Check the server address and credentials.', e)
}
loading = false
}
async function configureSync() {
if (!serverUrl) { errorMsg = tr('ui.serverRequired', null, 'Server URL is required.'); return }
loading = true
errorMsg = ''
connectionResult = ''
try {
await syncAPI().configure(serverUrl, username, password)
connectionResult = tr('ui.connectedSuccessfully', null, 'Connected successfully.')
connectionOk = true
username = ''
password = ''
await load()
} catch (e) {
errorMsg = reportError('ui.connectFailed', 'Could not connect this device. Please try again.', e)
}
loading = false
}
function syncResultWarning(result) {
const conflicts = Array.isArray(result?.conflicts) ? result.conflicts : []
const applyErrors = Array.isArray(result?.applyErrors) ? result.applyErrors : []
const parts = []
if (conflicts.length > 0) parts.push(tr('ui.conflictsCount', { count: conflicts.length }, '{count} conflict(s)'))
if (applyErrors.length > 0) parts.push(tr('ui.errorsCount', { count: applyErrors.length }, '{count} error(s)'))
return parts.join(' · ')
}
function formatSyncConflict() {
return tr('ui.syncConflictItem', null, 'A synchronization conflict needs attention.')
}
async function runSyncNow() {
loading = true
errorMsg = ''
resultMsg = ''
conflictDetails = []
try {
const r = await syncAPI().now()
const summary = tr('ui.syncSummary', { pushed: r?.pushed || 0, pulled: r?.pulled || 0 }, 'Pushed {pushed}, pulled {pulled}')
const warning = syncResultWarning(r)
const conflicts = Array.isArray(r?.conflicts) ? r.conflicts : []
conflictDetails = conflicts.slice(0, 5).map(formatSyncConflict)
resultMsg = warning ? summary + ' · ' + warning : summary
resultKind = warning ? 'warning' : ''
await load()
} catch (e) {
errorMsg = reportError('ui.syncFailed', 'Could not synchronize. Please try again.', e)
}
loading = false
}
function toggleAutoSync() {
autoSync = !autoSync
if (autoSync && syncInterval < 1) syncInterval = 5
saveSettings()
}
function saveInterval() {
if (syncInterval < 1 || syncInterval > 1440) {
errorMsg = tr('ui.intervalError', null, 'Sync interval must be between 1 and 1440 minutes.')
return
}
autoSync = syncInterval > 0
saveSettings()
}
async function doDisconnect() {
loading = true
errorMsg = ''
resultMsg = ''
try {
await syncAPI().disconnect()
resultMsg = tr('ui.disconnected', null, 'Disconnected from server.')
resultKind = ''
settings = null
await load()
} catch (e) {
errorMsg = reportError('ui.disconnectFailed', 'Could not disconnect from the server. Please try again.', e)
}
loading = false
}
async function resetKey() {
loading = true
errorMsg = ''
resultMsg = ''
try {
await syncAPI().resetKey()
resultMsg = tr('ui.keyReset', null, 'Sync key reset. Connect again to pair this device.')
resultKind = ''
await load()
} catch (e) {
errorMsg = reportError('ui.resetKeyFailed', 'Could not reset the sync key. Please try again.', e)
}
loading = false
}
onMount(() => {
unsubscribeLocale = api?.i18n?.onDidChangeLocale?.((nextLocale) => {
locale = nextLocale
})
})
onDestroy(() => unsubscribeLocale?.())
</script>
<div style="padding:1.5rem;max-width:500px;">
<h2 style="margin:0 0 0.25rem;color:#e0e0f0;font-size:1.2rem;">{tr('ui.title', null, 'Sync')}</h2>
<p style="color:#a0a0b8;font-size:0.85rem;margin-bottom:1.25rem;">{tr('ui.description', null, 'Synchronize your vault across devices.')}</p>
{#if errorMsg}
<div style="padding:0.5rem 0.75rem;margin-bottom:0.75rem;background:rgba(255,107,107,0.1);border:1px solid rgba(255,107,107,0.3);border-radius:6px;color:#ff6b6b;font-size:0.85rem;">{errorMsg}</div>
{/if}
{#if resultMsg && !errorMsg}
<div style="padding:0.5rem 0.75rem;margin-bottom:0.75rem;border-radius:6px;font-size:0.85rem;{resultKind === 'warning' ? 'background:rgba(245,158,11,0.1);border:1px solid rgba(245,158,11,0.3);color:#f59e0b;' : 'background:rgba(52,211,153,0.1);border:1px solid rgba(52,211,153,0.3);color:#34d399;'}">{resultMsg}</div>
{/if}
{#if conflictDetails.length > 0 && !errorMsg}
<div style="padding:0.5rem 0.75rem;margin-bottom:0.75rem;background:rgba(245,158,11,0.1);border:1px solid rgba(245,158,11,0.3);border-radius:6px;color:#f59e0b;font-size:0.85rem;">
<div style="font-weight:600;margin-bottom:0.35rem;">{tr('ui.syncConflicts', null, 'Sync conflicts')}</div>
{#each conflictDetails as detail}
<div>{detail}</div>
{/each}
</div>
{/if}
{#if settings && settings.lastError && !errorMsg}
<div style="padding:0.5rem 0.75rem;margin-bottom:0.75rem;background:rgba(255,107,107,0.1);border:1px solid rgba(255,107,107,0.3);border-radius:6px;color:#ff6b6b;font-size:0.85rem;">
{tr('ui.lastSyncError', null, 'The last synchronization did not finish. Try again.')}
</div>
{/if}
<div style="background:#16213e;border:1px solid #0f3460;border-radius:8px;padding:1rem 1.25rem;margin-bottom:1rem;">
<h3 style="margin:0 0 0.75rem;color:#e0e0f0;font-size:0.95rem;">{tr('ui.server', null, 'Server')}</h3>
<div style="margin-bottom:0.75rem;">
<label for="sync-server-url" style="display:block;color:#a0a0b8;font-size:0.85rem;margin-bottom:0.35rem;">{tr('ui.serverUrl', null, 'Server URL')}</label>
<input id="sync-server-url" type="text" style={INPUT_STYLE} on:focus={e => e.target.style.cssText = INPUT_FOCUS_STYLE} on:blur={e => e.target.style.cssText = INPUT_STYLE} bind:value={serverUrl} placeholder="https://example.com" />
</div>
<div style="margin-bottom:0.75rem;">
<label for="sync-username" style="display:block;color:#a0a0b8;font-size:0.85rem;margin-bottom:0.35rem;">{tr('ui.username', null, 'Username')}</label>
<input id="sync-username" type="text" style={INPUT_STYLE} on:focus={e => e.target.style.cssText = INPUT_FOCUS_STYLE} on:blur={e => e.target.style.cssText = INPUT_STYLE} bind:value={username} />
</div>
<div style="margin-bottom:0.75rem;">
<label for="sync-password" style="display:block;color:#a0a0b8;font-size:0.85rem;margin-bottom:0.35rem;">{tr('ui.password', null, 'Password')}</label>
<input id="sync-password" type="password" style={INPUT_STYLE} on:focus={e => e.target.style.cssText = INPUT_FOCUS_STYLE} on:blur={e => e.target.style.cssText = INPUT_STYLE} bind:value={password} />
</div>
<div style="display:flex;gap:0.5rem;margin-top:1rem;">
<button style="background:#1a1a2e;color:#e0e0f0;border:1px solid #1a3a5c;padding:0.4rem 0.75rem;border-radius:4px;cursor:pointer;font-size:0.85rem;" on:click={testConnection} disabled={loading || !serverUrl}>{tr('ui.testConnection', null, 'Test Connection')}</button>
<button style="background:#4ecca3;color:#1a1a2e;border:1px solid #4ecca3;padding:0.4rem 0.75rem;border-radius:4px;cursor:pointer;font-size:0.85rem;font-weight:600;" on:click={configureSync} disabled={loading || !serverUrl}>{tr('ui.connect', null, 'Connect')}</button>
</div>
{#if connectionResult}
<div style="margin-top:0.75rem;padding:0.5rem 0.75rem;border-radius:6px;font-size:0.85rem;{connectionOk ? 'background:rgba(52,211,153,0.1);border:1px solid rgba(52,211,153,0.3);color:#34d399;' : 'background:rgba(255,107,107,0.1);border:1px solid rgba(255,107,107,0.3);color:#ff6b6b;'}">{connectionResult}</div>
{/if}
</div>
<div style="background:#16213e;border:1px solid #0f3460;border-radius:8px;padding:1rem 1.25rem;margin-bottom:1rem;">
<h3 style="margin:0 0 0.75rem;color:#e0e0f0;font-size:0.95rem;">{tr('ui.behavior', null, 'Sync Behavior')}</h3>
<div style="margin-bottom:0.75rem;display:flex;align-items:center;gap:0.5rem;">
<input type="checkbox" id="auto-sync" bind:checked={autoSync} on:change={toggleAutoSync} style="width:16px;height:16px;accent-color:#4ecca3;" />
<label for="auto-sync" style="color:#e0e0f0;font-size:0.9rem;cursor:pointer;">{tr('ui.enableAutoSync', null, 'Enable auto-sync')}</label>
</div>
<div style="margin-bottom:0.75rem;">
<label for="sync-interval" style="display:block;color:#a0a0b8;font-size:0.85rem;margin-bottom:0.35rem;">{tr('ui.interval', null, 'Sync interval')}</label>
<div style="display:flex;align-items:center;gap:0.5rem;">
<input id="sync-interval" type="number" min="1" max="1440" bind:value={syncInterval} on:change={saveInterval} style="width:100px;background:#0f3460;border:1px solid #1a3a5c;color:#e0e0f0;padding:8px 10px;border-radius:4px;font-size:0.85rem;height:36px;" />
<span style="color:#a0a0b8;font-size:0.85rem;">{tr('ui.minutes', null, 'minutes')}</span>
</div>
</div>
{#if settings && settings.lastSyncAt}
<div style="color:#a0a0b8;font-size:0.85rem;">
{tr('ui.lastSync', { date: settings.lastSyncAt }, 'Last sync: {date}')}
</div>
{/if}
</div>
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;">
<button style="background:#4ecca3;color:#1a1a2e;border:1px solid #4ecca3;padding:0.4rem 0.75rem;border-radius:4px;cursor:pointer;font-size:0.85rem;font-weight:600;" on:click={saveSettings} disabled={loading}>{tr('ui.save', null, 'Save')}</button>
{#if settings && settings.configured}
<button style="background:#1a1a2e;color:#e0e0f0;border:1px solid #1a3a5c;padding:0.4rem 0.75rem;border-radius:4px;cursor:pointer;font-size:0.85rem;" on:click={runSyncNow} disabled={loading}>{tr('ui.syncNow', null, 'Sync Now')}</button>
<button style="background:#1a1a2e;color:#e0e0f0;border:1px solid #1a3a5c;padding:0.4rem 0.75rem;border-radius:4px;cursor:pointer;font-size:0.85rem;" on:click={resetKey} disabled={loading}>{tr('ui.resetKey', null, 'Reset Key')}</button>
<button style="background:#e94560;color:#fff;border:1px solid #e94560;padding:0.4rem 0.75rem;border-radius:4px;cursor:pointer;font-size:0.85rem;" on:click={doDisconnect} disabled={loading}>{tr('ui.disconnect', null, 'Disconnect')}</button>
{/if}
</div>
</div>
@@ -0,0 +1,102 @@
<script>
import { onMount, onDestroy } from 'svelte'
export let api = null
let status = 'disabled'
let interval
let locale = api?.i18n?.getLocale?.() || 'en'
let unsubscribeLocale
function tr(key, params, fallback) {
locale
return api?.i18n?.t?.(key, params, fallback) || fallback || key
}
async function loadStatus() {
try {
const s = await api?.settings?.read?.()
if (s?.syncStatus) {
status = s.syncStatus
return
}
} catch (_) {}
try {
const s = await api?.sync?.status?.()
if (s?.statusLabel) {
status = s.statusLabel
} else if (s?.configured === false) {
status = 'disabled'
}
} catch (_) {}
}
function statusColor() {
if (status === 'connected') return '#34d399'
if (status === 'error' || status === 'revoked') return '#ff6b6b'
return '#888'
}
function statusText() {
const labels = {
connected: tr('ui.status.connected', null, 'Connected'),
disconnected: tr('ui.status.disconnected', null, 'Disconnected'),
disabled: tr('ui.status.disabled', null, 'Not configured'),
error: tr('ui.status.error', null, 'Error'),
revoked: tr('ui.status.revoked', null, 'Revoked'),
}
return labels[status] || status
}
function openSettings() {
if (api?.ui?.openSettings) {
api.ui.openSettings()
}
}
onMount(() => {
loadStatus()
interval = setInterval(loadStatus, 15000)
unsubscribeLocale = api?.i18n?.onDidChangeLocale?.((nextLocale) => {
locale = nextLocale
})
})
onDestroy(() => {
if (interval) clearInterval(interval)
unsubscribeLocale?.()
})
</script>
<button class="sync-status-bar" on:click={openSettings} title="{tr('ui.title', null, 'Sync')}: {statusText()}">
<span class="status-dot" style="background: {statusColor()}"></span>
<span class="status-label">{statusText()}</span>
</button>
<style>
.sync-status-bar {
display: inline-flex;
align-items: center;
gap: 6px;
background: transparent;
border: none;
padding: 4px 10px;
cursor: pointer;
font-size: 12px;
color: var(--text-dim, #888);
border-radius: 4px;
transition: background 0.15s;
}
.sync-status-bar:hover {
background: var(--surface-alt, rgba(255,255,255,0.06));
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.status-label {
white-space: nowrap;
}
</style>
+25
View File
@@ -0,0 +1,25 @@
import SyncSettings from './SyncSettings.svelte';
import SyncStatusBar from './SyncStatusBar.svelte';
window.VerstakPluginRegister('verstak.sync', {
components: {
SyncSettings: {
mount(container, props, api) {
return new SyncSettings({
target: container,
props: { ...props, api }
});
},
unmount(container) {}
},
SyncStatusBar: {
mount(container, props, api) {
return new SyncStatusBar({
target: container,
props: { ...props, api }
});
},
unmount(container) {}
}
}
});
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
export default defineConfig({
plugins: [svelte()],
build: {
outDir: 'dist',
lib: {
entry: 'src/index.js',
formats: ['iife'],
name: 'VerstakSyncPlugin'
},
rollupOptions: {
output: {
entryFileNames: 'index.js',
assetFileNames: '[name][extname]'
}
}
}
});
+48
View File
@@ -0,0 +1,48 @@
{
"manifest.name": "Sync",
"contributions.settingsPanels.verstak.sync.settings.title": "Sync",
"contributions.statusBarItems.verstak.sync.status.label": "Sync",
"ui.title": "Sync",
"ui.description": "Synchronize your vault across devices.",
"ui.unknownError": "Unknown error",
"ui.apiUnavailable": "Synchronization is unavailable right now.",
"ui.intervalError": "Sync interval must be between 1 and 1440 minutes.",
"ui.settingsSaved": "Settings saved.",
"ui.serverRequired": "Server URL is required.",
"ui.connectionSuccessful": "Connection successful.",
"ui.connectionFailed": "Could not connect. Check the server address and credentials.",
"ui.saveFailed": "Could not save sync settings. Please try again.",
"ui.connectFailed": "Could not connect this device. Please try again.",
"ui.syncFailed": "Could not synchronize. Please try again.",
"ui.disconnectFailed": "Could not disconnect from the server. Please try again.",
"ui.resetKeyFailed": "Could not reset the sync key. Please try again.",
"ui.connectedSuccessfully": "Connected successfully.",
"ui.conflictsCount": "{count} conflict(s)",
"ui.errorsCount": "{count} error(s)",
"ui.syncSummary": "Pushed {pushed}, pulled {pulled}",
"ui.disconnected": "Disconnected from server.",
"ui.keyReset": "Sync key reset. Connect again to pair this device.",
"ui.syncConflicts": "Sync conflicts",
"ui.lastSyncError": "The last synchronization did not finish. Try again.",
"ui.syncConflictItem": "A synchronization conflict needs attention.",
"ui.server": "Server",
"ui.serverUrl": "Server URL",
"ui.username": "Username",
"ui.password": "Password",
"ui.testConnection": "Test Connection",
"ui.connect": "Connect",
"ui.behavior": "Sync Behavior",
"ui.enableAutoSync": "Enable auto-sync",
"ui.interval": "Sync interval",
"ui.minutes": "minutes",
"ui.lastSync": "Last sync: {date}",
"ui.save": "Save",
"ui.syncNow": "Sync Now",
"ui.resetKey": "Reset Key",
"ui.disconnect": "Disconnect",
"ui.status.connected": "Connected",
"ui.status.disconnected": "Disconnected",
"ui.status.disabled": "Not configured",
"ui.status.error": "Error",
"ui.status.revoked": "Revoked"
}
+48
View File
@@ -0,0 +1,48 @@
{
"manifest.name": "Синхронизация",
"contributions.settingsPanels.verstak.sync.settings.title": "Синхронизация",
"contributions.statusBarItems.verstak.sync.status.label": "Синхронизация",
"ui.title": "Синхронизация",
"ui.description": "Синхронизируйте хранилище между устройствами.",
"ui.unknownError": "Неизвестная ошибка",
"ui.apiUnavailable": "Синхронизация сейчас недоступна.",
"ui.intervalError": "Интервал синхронизации должен быть от 1 до 1440 минут.",
"ui.settingsSaved": "Настройки сохранены.",
"ui.serverRequired": "Укажите URL сервера.",
"ui.connectionSuccessful": "Подключение успешно.",
"ui.connectionFailed": "Не удалось подключиться. Проверьте адрес сервера и учётные данные.",
"ui.saveFailed": "Не удалось сохранить настройки синхронизации. Повторите попытку.",
"ui.connectFailed": "Не удалось подключить это устройство. Повторите попытку.",
"ui.syncFailed": "Не удалось синхронизировать данные. Повторите попытку.",
"ui.disconnectFailed": "Не удалось отключиться от сервера. Повторите попытку.",
"ui.resetKeyFailed": "Не удалось сбросить ключ синхронизации. Повторите попытку.",
"ui.connectedSuccessfully": "Подключение установлено.",
"ui.conflictsCount": "Конфликтов: {count}",
"ui.errorsCount": "Ошибок: {count}",
"ui.syncSummary": "Отправлено: {pushed}, получено: {pulled}",
"ui.disconnected": "Сервер отключён.",
"ui.keyReset": "Ключ синхронизации сброшен. Подключитесь снова, чтобы связать устройство.",
"ui.syncConflicts": "Конфликты синхронизации",
"ui.lastSyncError": "Последняя синхронизация не завершилась. Повторите попытку.",
"ui.syncConflictItem": "Конфликт синхронизации требует внимания.",
"ui.server": "Сервер",
"ui.serverUrl": "URL сервера",
"ui.username": "Имя пользователя",
"ui.password": "Пароль",
"ui.testConnection": "Проверить подключение",
"ui.connect": "Подключить",
"ui.behavior": "Параметры синхронизации",
"ui.enableAutoSync": "Включить автосинхронизацию",
"ui.interval": "Интервал синхронизации",
"ui.minutes": "минут",
"ui.lastSync": "Последняя синхронизация: {date}",
"ui.save": "Сохранить",
"ui.syncNow": "Синхронизировать",
"ui.resetKey": "Сбросить ключ",
"ui.disconnect": "Отключить",
"ui.status.connected": "Подключено",
"ui.status.disconnected": "Отключено",
"ui.status.disabled": "Не настроено",
"ui.status.error": "Ошибка",
"ui.status.revoked": "Доступ отозван"
}
+17
View File
@@ -0,0 +1,17 @@
{
"schemaVersion": 1,
"id": "verstak.sync",
"name": "Sync",
"version": "0.1.0",
"apiVersion": "0.1.0",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official",
"provides": ["verstak/sync/v1", "verstak/sync.status/v1"],
"requires": ["verstak/core/files/v1"],
"permissions": ["files.read", "files.write", "network.remote", "storage.namespace", "sync.participate", "ui.register"],
"frontend": { "entry": "frontend/dist/index.js" },
"contributes": {
"settingsPanels": [{ "id": "verstak.sync.settings", "title": "Sync", "component": "SyncSettings" }],
"statusBarItems": [{ "id": "verstak.sync.status", "label": "Sync", "position": "right", "handler": "SyncStatusBar" }]
}
}
+703
View File
@@ -0,0 +1,703 @@
/* ===========================================================
Todo Plugin - Verstak v2 Frontend Bundle
Contract: window.VerstakPluginRegister(id, { components })
=========================================================== */
(function () {
'use strict';
var PLUGIN_ID = 'verstak.todo';
var GLOBAL_KEY = 'todos:global';
var MAX_TODOS = 500;
var STATUS_VALUES = ['open', 'done', 'cancelled'];
var PRIORITY_VALUES = ['low', 'normal', 'high'];
var STYLES = [
'.todo-root{display:flex;flex-direction:column;height:100%;min-height:0;background:var(--vt-color-background,#101020);color:var(--vt-color-text-primary,#f4f7fb);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif}',
'.todo-toolbar{display:flex;align-items:center;gap:.5rem;min-height:2.75rem;padding:.5rem .75rem;border-bottom:1px solid var(--vt-color-border,#202b46);background:var(--vt-color-surface-muted,#111629);flex-wrap:wrap;flex-shrink:0}',
'.todo-title{font-size:.86rem;font-weight:600}.todo-count,.todo-status,.todo-scope{font-size:.72rem;color:var(--vt-color-text-muted,#7f8aa3)}.todo-spacer{flex:1}',
'.todo-filters{display:flex;align-items:center;gap:.35rem;min-width:0;flex:1;flex-wrap:wrap}',
'.todo-input,.todo-select{box-sizing:border-box;min-height:1.9rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:var(--vt-color-surface,#15152c);color:var(--vt-color-text-primary,#f4f7fb);color-scheme:dark;font:inherit;font-size:.78rem;padding:.32rem .45rem}',
'.todo-input.search{width:min(15rem,100%)}.todo-input.textarea{min-height:6.5rem;resize:vertical;line-height:1.4}.todo-input.todo-time-input{width:100%;font-variant-numeric:tabular-nums}.todo-select{max-width:12rem;appearance:none;background-color:var(--vt-color-surface,#15152c);background-image:linear-gradient(45deg,transparent 50%,var(--vt-color-text-muted,#7f8aa3) 50%),linear-gradient(135deg,var(--vt-color-text-muted,#7f8aa3) 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}.todo-select option{background:var(--vt-color-surface,#15152c);color:var(--vt-color-text-primary,#f4f7fb)}',
'.todo-btn{min-height:1.9rem;padding:.32rem .62rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-md,6px);background:var(--vt-color-surface-hover,#1b2440);color:var(--vt-color-text-secondary,#b7c0d4);font-size:.78rem;cursor:pointer}.todo-btn:hover{border-color:var(--vt-color-accent,#4ecca3);color:var(--vt-color-text-primary,#f4f7fb)}.todo-btn.primary{background:var(--vt-color-accent,#4ecca3);border-color:var(--vt-color-accent,#4ecca3);color:#101827}.todo-btn.danger{border-color:rgba(233,69,96,.5);color:#ff9a9a}.todo-btn:disabled{opacity:.45;cursor:default}',
'.todo-list{flex:1;min-height:0;overflow:auto;padding:.5rem .75rem .85rem}.todo-empty{height:100%;display:flex;align-items:center;justify-content:center;padding:2rem;text-align:center;color:var(--vt-color-text-muted,#7f8aa3);font-size:.86rem}',
'.todo-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.75rem;align-items:start;margin-top:.5rem;padding:.75rem .85rem;border:1px solid var(--vt-color-border,#202b46);border-radius:var(--vt-radius-lg,8px);background:var(--vt-color-surface,#15152c)}.todo-row:hover{background:var(--vt-color-surface-hover,#1b2440)}.todo-row.done .todo-row-title{text-decoration:line-through;color:var(--vt-color-text-muted,#7f8aa3)}',
'.todo-row-main{min-width:0;display:grid;gap:.3rem}.todo-row-head{display:flex;align-items:center;gap:.45rem;min-width:0}.todo-row-title{font-size:.88rem;font-weight:600;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.todo-row-description{font-size:.78rem;line-height:1.4;color:var(--vt-color-text-secondary,#b7c0d4);white-space:pre-wrap;overflow-wrap:anywhere}.todo-row-meta{display:flex;align-items:center;gap:.35rem;flex-wrap:wrap;font-size:.71rem;color:var(--vt-color-text-muted,#7f8aa3)}',
'.todo-badge{display:inline-flex;align-items:center;min-height:1.2rem;padding:0 .32rem;border:1px solid var(--vt-color-border,#202b46);border-radius:var(--vt-radius-sm,4px);white-space:nowrap}.todo-badge.high{border-color:rgba(233,69,96,.55);color:#ffadb8}.todo-badge.low{color:#9fb0c8}.todo-badge.overdue,.todo-badge.reminder-due{border-color:rgba(233,69,96,.55);color:#ffadb8}.todo-badge.due-soon{border-color:rgba(255,200,87,.55);color:#ffd77f}',
'.todo-row-actions{display:flex;gap:.35rem;flex-wrap:wrap;justify-content:flex-end}.todo-row-actions .todo-btn{padding:.25rem .45rem;font-size:.72rem}',
'.todo-modal-host[hidden]{display:none}.todo-modal-overlay{position:fixed;inset:0;z-index:10000;display:flex;align-items:center;justify-content:center;padding:1rem;background:rgba(0,0,0,.58)}.todo-modal{width:560px;max-width:96vw;display:grid;gap:.75rem;padding:1rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-lg,8px);background:var(--vt-color-surface,#15152c);box-shadow:0 18px 44px rgba(0,0,0,.38)}.todo-modal-title{font-size:.95rem;font-weight:650}.todo-form-grid{display:grid;grid-template-columns:1fr 1fr;gap:.65rem}.todo-field{display:grid;gap:.3rem;font-size:.72rem;color:var(--vt-color-text-muted,#7f8aa3)}.todo-field.wide{grid-column:1/-1}.todo-modal-actions{display:flex;justify-content:flex-end;gap:.5rem}',
'@media(max-width:820px){.todo-toolbar{align-items:stretch}.todo-filters{width:100%}.todo-input.search{flex:1}.todo-row{grid-template-columns:1fr}.todo-row-actions{justify-content:flex-start}.todo-form-grid{grid-template-columns:1fr}.todo-field.wide{grid-column:auto}}'
].join('\n');
function injectStyles() {
if (document.getElementById('todo-style-injected')) return;
var style = document.createElement('style');
style.id = 'todo-style-injected';
style.textContent = STYLES;
document.head.appendChild(style);
}
function el(tag, attrs, children) {
var elem = document.createElement(tag);
if (attrs) {
Object.keys(attrs).forEach(function (key) {
if (attrs[key] == null) return;
if (key === 'className') elem.className = attrs[key];
else if (key === 'textContent') elem.textContent = attrs[key];
else if (key === 'value') elem.value = attrs[key];
else if (key === 'checked') elem.checked = attrs[key] === true;
else if (key === 'disabled') elem.disabled = attrs[key] === true;
else if (key.slice(0, 2) === 'on') elem.addEventListener(key.slice(2).toLowerCase(), attrs[key]);
else elem.setAttribute(key, attrs[key]);
});
}
if (children) {
(Array.isArray(children) ? children : [children]).forEach(function (child) {
if (child == null) return;
elem.appendChild(typeof child === 'string' ? document.createTextNode(child) : child);
});
}
return elem;
}
function text(value) {
return String(value == null ? '' : value);
}
function cleanWorkspace(value) {
return text(value).trim().replace(/^\/+|\/+$/g, '');
}
function workspaceFromProps(props) {
var node = props && props.workspaceNode;
return cleanWorkspace((props && (props.workspaceRootPath || props.workspaceName || props.workspaceNodeId))
|| (node && (node.rootPath || node.name || node.id)));
}
function scopeFromProps(props) {
var workspaceRoot = workspaceFromProps(props || {});
return workspaceRoot
? { mode: 'workspace', workspaceRoot: workspaceRoot, label: workspaceRoot }
: { mode: 'global', workspaceRoot: '', label: 'All Deals' };
}
function now() {
return new Date().toISOString();
}
function cleanStatus(value) {
value = text(value).trim().toLowerCase();
return STATUS_VALUES.indexOf(value) === -1 ? 'open' : value;
}
function cleanPriority(value) {
value = text(value).trim().toLowerCase();
return PRIORITY_VALUES.indexOf(value) === -1 ? 'normal' : value;
}
function cleanDate(value) {
value = text(value).trim();
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return value;
var match = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/.exec(value);
var day;
var month;
var year;
if (match) {
month = Number(match[1]);
day = Number(match[2]);
year = Number(match[3]);
} else {
match = /^(\d{1,2})\.(\d{1,2})\.(\d{4})$/.exec(value);
if (!match) return '';
day = Number(match[1]);
month = Number(match[2]);
year = Number(match[3]);
}
var date = new Date(Date.UTC(year, month - 1, day));
if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) return '';
return String(year).padStart(4, '0') + '-' + String(month).padStart(2, '0') + '-' + String(day).padStart(2, '0');
}
function cleanDateTime(value) {
value = text(value).trim();
if (!value) return '';
var date = new Date(value);
return isNaN(date.getTime()) ? '' : value;
}
function splitReminderDateTime(value) {
var match = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})/.exec(cleanDateTime(value));
return match ? { date: match[1], time: match[2] } : { date: '', time: '' };
}
function cleanReminderTime(value) {
var match = /^(\d{1,2}):(\d{2})$/.exec(text(value).trim());
if (!match) return '';
var hour = Number(match[1]);
var minute = Number(match[2]);
if (hour > 23 || minute > 59) return '';
return String(hour).padStart(2, '0') + ':' + String(minute).padStart(2, '0');
}
function joinReminderDateTime(date, time) {
date = cleanDate(date);
time = cleanReminderTime(time);
if (!date || !time) return '';
return cleanDateTime(date + 'T' + time);
}
function todoId(workspaceRoot, title) {
return 'todo:' + (cleanWorkspace(workspaceRoot) || 'global') + ':' + Date.now() + ':' + Math.random().toString(36).slice(2, 8) + ':' + text(title).trim().slice(0, 20).replace(/\s+/g, '-');
}
function normalizeTodo(value) {
value = value || {};
var status = cleanStatus(value.status);
var reminderAt = cleanDateTime(value.reminderAt || value.reminderDateTime);
var reminder = splitReminderDateTime(reminderAt);
var createdAt = text(value.createdAt).trim() || now();
var completedAt = status === 'done' ? (text(value.completedAt).trim() || createdAt) : '';
return {
id: text(value.id || todoId(value.workspaceRootPath, value.title)).trim(),
title: text(value.title).trim(),
description: text(value.description || value.body),
workspaceRootPath: cleanWorkspace(value.workspaceRootPath || value.workspaceName),
workspaceName: cleanWorkspace(value.workspaceName || value.workspaceRootPath),
status: status,
priority: cleanPriority(value.priority),
dueAt: cleanDate(value.dueAt || value.dueDate),
reminderDate: cleanDate(value.reminderDate) || reminder.date,
reminderAt: reminderAt,
createdAt: createdAt,
updatedAt: text(value.updatedAt).trim() || createdAt,
completedAt: completedAt,
sourceUrl: text(value.sourceUrl),
linkedJournalEntryId: text(value.linkedJournalEntryId)
};
}
function normalizeTodos(value) {
if (!Array.isArray(value)) return [];
return value.filter(function (item) {
return item && typeof item === 'object';
}).map(normalizeTodo).filter(function (todo) {
return todo.id;
}).slice(0, MAX_TODOS);
}
function storageTodos(todoList) {
return todoList.map(function (todo) {
return {
id: todo.id,
title: todo.title,
description: todo.description,
workspaceRootPath: todo.workspaceRootPath,
workspaceName: todo.workspaceName || todo.workspaceRootPath || '',
status: todo.status,
priority: todo.priority,
dueAt: todo.dueAt,
reminderDate: todo.reminderDate,
reminderAt: todo.reminderAt,
createdAt: todo.createdAt,
updatedAt: todo.updatedAt,
completedAt: todo.completedAt,
sourceUrl: todo.sourceUrl,
linkedJournalEntryId: todo.linkedJournalEntryId
};
});
}
function sortTodos(todoList) {
var seen = {};
return todoList.filter(function (todo) {
if (!todo || !todo.id || seen[todo.id]) return false;
seen[todo.id] = true;
return true;
}).slice(0, MAX_TODOS);
}
function dateTimeValue(value, endOfDay) {
if (!value) return 0;
var date = new Date(endOfDay && /^\d{4}-\d{2}-\d{2}$/.test(value) ? value + 'T23:59:59' : value);
return isNaN(date.getTime()) ? 0 : date.getTime();
}
function formatDate(value) {
if (!value) return '';
var date = new Date(/^\d{4}-\d{2}-\d{2}$/.test(value) ? value + 'T00:00:00' : value);
return isNaN(date.getTime()) ? value : date.toLocaleString(undefined, { year: 'numeric', month: 'short', day: '2-digit', hour: /^\d{4}-\d{2}-\d{2}$/.test(value) ? undefined : '2-digit', minute: /^\d{4}-\d{2}-\d{2}$/.test(value) ? undefined : '2-digit' });
}
function dueState(todo) {
if (!todo || todo.status !== 'open') return '';
var current = Date.now();
var due = dateTimeValue(todo.dueAt, true);
if (due && due < current) return 'overdue';
if (due && due - current <= 3 * 24 * 60 * 60 * 1000) return 'due-soon';
return '';
}
function reminderIsDue(todo) {
return !!(todo && todo.status === 'open' && todo.reminderAt && dateTimeValue(todo.reminderAt, false) <= Date.now());
}
function TodoView() {}
TodoView.mount = function (containerEl, props, api) {
injectStyles();
containerEl.innerHTML = '';
containerEl.className = 'todo-root';
containerEl.setAttribute('data-plugin-id', PLUGIN_ID);
var scope = scopeFromProps(props || {});
var todos = [];
var workspaceOptions = [];
var statusFilter = 'all';
var workspaceFilter = '';
var sortMode = 'due';
var searchQuery = '';
var statusText = '';
var statusClass = '';
function tr(key, params, fallback) {
if (api && api.i18n && typeof api.i18n.t === 'function') return api.i18n.t(key, params, fallback);
return fallback || key;
}
var toolbar = el('div', { className: 'todo-toolbar' });
var titleEl = el('span', { className: 'todo-title', textContent: scope.mode === 'global' ? tr('ui.title', null, 'Todos') : tr('ui.workspaceTitle', { workspace: scope.label }, 'Todos · ' + scope.label) });
var countEl = el('span', { className: 'todo-count' });
var statusEl = el('span', { className: 'todo-status' });
var filtersEl = el('div', { className: 'todo-filters' });
var statusFilterEl = el('select', {
className: 'todo-select',
'data-todo-filter': 'status',
onChange: function (event) {
statusFilter = cleanStatusFilter(event.target.value);
render();
}
}, [
option('all', tr('ui.allStatuses', null, 'All statuses')),
option('open', tr('ui.status.open', null, 'Open')),
option('done', tr('ui.status.done', null, 'Done')),
option('cancelled', tr('ui.status.cancelled', null, 'Cancelled'))
]);
var workspaceFilterEl = el('select', {
className: 'todo-select',
'data-todo-filter': 'workspace',
onChange: function (event) {
workspaceFilter = cleanWorkspace(event.target.value);
render();
}
});
var sortEl = el('select', {
className: 'todo-select',
'data-todo-filter': 'sort',
onChange: function (event) {
sortMode = text(event.target.value) || 'due';
render();
}
}, [
option('due', tr('ui.sort.due', null, 'Sort by due date')),
option('reminder', tr('ui.sort.reminder', null, 'Sort by reminder')),
option('updated', tr('ui.sort.updated', null, 'Sort by updated'))
]);
var searchInput = el('input', {
className: 'todo-input search',
type: 'search',
placeholder: tr('ui.search', null, 'Search todos'),
'data-todo-filter': 'search',
onInput: function (event) {
searchQuery = text(event.target.value).trim().toLowerCase();
render();
}
});
var addBtn = el('button', {
className: 'todo-btn primary',
type: 'button',
'data-todo-action': 'add',
textContent: tr('ui.add', null, 'Add Todo'),
onClick: function () { showTodoModal(null); }
});
var listEl = el('div', { className: 'todo-list' });
var modalHost = el('div', { className: 'todo-modal-host', hidden: 'hidden' });
toolbar.appendChild(titleEl);
toolbar.appendChild(countEl);
filtersEl.appendChild(statusFilterEl);
if (scope.mode === 'global') filtersEl.appendChild(workspaceFilterEl);
filtersEl.appendChild(sortEl);
filtersEl.appendChild(searchInput);
toolbar.appendChild(filtersEl);
toolbar.appendChild(el('span', { className: 'todo-spacer' }));
toolbar.appendChild(statusEl);
toolbar.appendChild(addBtn);
containerEl.appendChild(toolbar);
containerEl.appendChild(listEl);
containerEl.appendChild(modalHost);
function option(value, label) {
return el('option', { value: value, textContent: label });
}
function cleanStatusFilter(value) {
value = text(value).trim().toLowerCase();
return value === 'all' || STATUS_VALUES.indexOf(value) !== -1 ? value : 'all';
}
function workspaceRoots() {
var roots = workspaceOptions.slice();
todos.forEach(function (todo) {
var workspace = cleanWorkspace(todo.workspaceRootPath);
if (workspace && roots.indexOf(workspace) === -1) roots.push(workspace);
});
if (scope.workspaceRoot && roots.indexOf(scope.workspaceRoot) === -1) roots.push(scope.workspaceRoot);
return roots.sort(function (a, b) { return a.localeCompare(b, undefined, { sensitivity: 'base' }); });
}
function renderWorkspaceFilterOptions() {
if (scope.mode !== 'global') return;
workspaceFilterEl.innerHTML = '';
workspaceFilterEl.appendChild(option('', tr('ui.allWorkspaces', null, 'All Deals')));
workspaceFilterEl.appendChild(option('__unassigned__', tr('ui.unassigned', null, 'Unassigned')));
workspaceRoots().forEach(function (workspace) {
workspaceFilterEl.appendChild(option(workspace, workspace));
});
workspaceFilterEl.value = workspaceFilter;
}
function visibleTodos() {
var filtered = todos.filter(function (todo) {
var workspace = cleanWorkspace(todo.workspaceRootPath);
if (scope.mode === 'workspace' && workspace !== scope.workspaceRoot) return false;
if (scope.mode === 'global' && workspaceFilter === '__unassigned__' && workspace) return false;
if (scope.mode === 'global' && workspaceFilter && workspaceFilter !== '__unassigned__' && workspace !== workspaceFilter) return false;
if (statusFilter !== 'all' && todo.status !== statusFilter) return false;
if (!searchQuery) return true;
return [todo.title, todo.description, todo.workspaceRootPath].join('\n').toLowerCase().indexOf(searchQuery) !== -1;
});
return filtered.sort(function (a, b) {
if (sortMode === 'updated') return text(b.updatedAt).localeCompare(text(a.updatedAt));
var aValue = dateTimeValue(sortMode === 'reminder' ? a.reminderAt : a.dueAt, sortMode !== 'reminder') || Number.MAX_SAFE_INTEGER;
var bValue = dateTimeValue(sortMode === 'reminder' ? b.reminderAt : b.dueAt, sortMode !== 'reminder') || Number.MAX_SAFE_INTEGER;
return aValue - bValue || text(b.updatedAt).localeCompare(text(a.updatedAt));
});
}
function notificationRequests() {
return sortTodos(todos).filter(function (todo) {
return todo.status === 'open' && todo.reminderAt;
}).map(function (todo) {
var dueAt = new Date(todo.reminderAt);
if (isNaN(dueAt.getTime())) return null;
var title = todo.title || tr('ui.untitled', null, 'Untitled todo');
return {
id: todo.id,
dueAt: dueAt.toISOString(),
title: tr('ui.notificationTitle', null, 'Todo reminder'),
body: tr('ui.notificationBody', { title: title }, title)
};
}).filter(function (item) { return item !== null; });
}
function reportError(key, fallback, err) {
if (typeof console !== 'undefined' && typeof console.warn === 'function') {
console.warn('[verstak.todo] ' + key, err);
}
statusText = tr(key, null, fallback);
statusClass = 'error';
}
function syncNotifications() {
if (!api || !api.notifications || typeof api.notifications.replace !== 'function') return Promise.resolve();
return api.notifications.replace(notificationRequests()).catch(function (err) {
reportError('ui.notificationError', 'Could not schedule reminders. Please try again.', err);
});
}
function persist() {
if (!api || !api.settings || typeof api.settings.write !== 'function') return syncNotifications();
return api.settings.write(GLOBAL_KEY, storageTodos(sortTodos(todos))).then(function () {
return syncNotifications();
}).catch(function (err) {
reportError('ui.saveError', 'Could not save tasks. Please try again.', err);
});
}
function loadStored() {
if (!api || !api.settings || typeof api.settings.read !== 'function') return syncNotifications();
return api.settings.read().then(function (settings) {
todos = sortTodos(normalizeTodos((settings || {})[GLOBAL_KEY]));
return syncNotifications();
}).catch(function (err) {
reportError('ui.loadError', 'Could not load tasks. Please try again.', err);
});
}
function loadWorkspaceOptions() {
if (!api || !api.files || typeof api.files.list !== 'function') return Promise.resolve();
return api.files.list('').then(function (entries) {
workspaceOptions = (Array.isArray(entries) ? entries : []).filter(function (entry) {
return text(entry && entry.type).toLowerCase() === 'folder';
}).map(function (entry) {
return cleanWorkspace(entry.relativePath || entry.name);
}).filter(function (workspace) {
return workspace && workspace.indexOf('/') === -1;
});
}).catch(function () {
workspaceOptions = [];
});
}
function closeTodoModal() {
modalHost.innerHTML = '';
modalHost.setAttribute('hidden', 'hidden');
}
function showTodoModal(existingTodo) {
var editing = !!existingTodo;
var titleInput = el('input', { className: 'todo-input', type: 'text', value: editing ? existingTodo.title : '', placeholder: tr('ui.titlePlaceholder', null, 'Todo title'), 'data-todo-input': 'title' });
var descriptionInput = el('textarea', { className: 'todo-input textarea', placeholder: tr('ui.descriptionPlaceholder', null, 'Optional description'), 'data-todo-input': 'description' });
descriptionInput.value = editing ? existingTodo.description : '';
var priorityInput = el('select', { className: 'todo-select', 'data-todo-input': 'priority' }, [option('low', tr('ui.priority.low', null, 'Low')), option('normal', tr('ui.priority.normal', null, 'Normal')), option('high', tr('ui.priority.high', null, 'High'))]);
priorityInput.value = editing ? existingTodo.priority : 'normal';
var dueInput = el('input', { className: 'todo-input', type: 'date', value: editing ? existingTodo.dueAt : '', 'data-todo-input': 'dueAt' });
var reminder = splitReminderDateTime(editing ? existingTodo.reminderAt : '');
var reminderDateInput = el('input', { className: 'todo-input', type: 'date', value: editing ? existingTodo.reminderDate || reminder.date : '', 'data-todo-input': 'reminderDate' });
var reminderTimeInput = el('input', { className: 'todo-input todo-time-input', type: 'text', inputmode: 'numeric', maxlength: '5', placeholder: tr('ui.reminderTimePlaceholder', null, '14:30'), value: reminder.time, 'data-todo-input': 'reminderTime', 'aria-label': tr('ui.field.reminderTime', null, 'Reminder time') });
var workspaceInput = null;
var workspace = editing ? existingTodo.workspaceRootPath : scope.workspaceRoot;
if (scope.mode === 'global') {
workspaceInput = el('select', { className: 'todo-select', 'data-todo-input': 'workspaceRootPath' });
workspaceInput.appendChild(option('', tr('ui.unassigned', null, 'Unassigned')));
workspaceRoots().forEach(function (workspaceRoot) {
workspaceInput.appendChild(option(workspaceRoot, workspaceRoot));
});
workspaceInput.value = workspace || '';
}
function saveTodo() {
var title = text(titleInput.value).trim();
if (!title) {
statusText = tr('ui.titleRequired', null, 'Title is required');
statusClass = 'error';
render();
return;
}
var workspaceRoot = scope.mode === 'workspace' ? scope.workspaceRoot : cleanWorkspace(workspaceInput && workspaceInput.value);
var reminderDate = cleanDate(reminderDateInput.value);
var reminderTime = cleanReminderTime(reminderTimeInput.value);
if (text(reminderTimeInput.value).trim() && !reminderTime) {
statusText = tr('ui.reminderTimeInvalid', null, 'Enter a valid reminder time (HH:MM)');
statusClass = 'error';
render();
return;
}
if (reminderTime && !reminderDate) {
statusText = tr('ui.reminderDateRequired', null, 'Choose a reminder date before setting its time');
statusClass = 'error';
render();
return;
}
var timestamp = now();
var next = normalizeTodo({
id: editing ? existingTodo.id : todoId(workspaceRoot, title),
title: title,
description: descriptionInput.value,
workspaceRootPath: workspaceRoot,
workspaceName: workspaceRoot,
status: editing ? existingTodo.status : 'open',
priority: priorityInput.value,
dueAt: dueInput.value,
reminderDate: reminderDate,
reminderAt: joinReminderDateTime(reminderDate, reminderTime),
createdAt: editing ? existingTodo.createdAt : timestamp,
updatedAt: timestamp,
completedAt: editing ? existingTodo.completedAt : '',
sourceUrl: editing ? existingTodo.sourceUrl : '',
linkedJournalEntryId: editing ? existingTodo.linkedJournalEntryId : ''
});
if (editing) {
todos = todos.map(function (todo) { return todo.id === existingTodo.id ? next : todo; });
} else {
todos = [next].concat(todos);
}
todos = sortTodos(todos);
if (workspaceRoot && workspaceOptions.indexOf(workspaceRoot) === -1) workspaceOptions.push(workspaceRoot);
closeTodoModal();
statusText = editing ? tr('ui.updated', null, 'Todo updated') : tr('ui.added', null, 'Todo added');
statusClass = '';
persist().then(render);
}
var fields = [
el('label', { className: 'todo-field wide' }, [tr('ui.field.title', null, 'Title'), titleInput]),
el('label', { className: 'todo-field wide' }, [tr('ui.field.description', null, 'Description'), descriptionInput]),
el('label', { className: 'todo-field' }, [tr('ui.field.priority', null, 'Priority'), priorityInput]),
el('label', { className: 'todo-field' }, [tr('ui.field.due', null, 'Due date'), dueInput]),
el('label', { className: 'todo-field' }, [tr('ui.field.reminderDate', null, 'Reminder date'), reminderDateInput]),
el('label', { className: 'todo-field' }, [tr('ui.field.reminderTime', null, 'Reminder time'), reminderTimeInput])
];
if (workspaceInput) fields.push(el('label', { className: 'todo-field' }, [tr('ui.field.workspace', null, 'Deal'), workspaceInput]));
else fields.push(el('div', { className: 'todo-field', textContent: tr('ui.workspaceValue', { workspace: scope.workspaceRoot }, 'Deal: ' + scope.workspaceRoot) }));
modalHost.innerHTML = '';
if (typeof modalHost.removeAttribute === 'function') modalHost.removeAttribute('hidden');
modalHost.appendChild(el('div', { className: 'todo-modal-overlay', onClick: function (event) {
if (event.target === event.currentTarget) closeTodoModal();
} }, [
el('div', { className: 'todo-modal' }, [
el('div', { className: 'todo-modal-title', textContent: editing ? tr('ui.edit', null, 'Edit Todo') : tr('ui.add', null, 'Add Todo') }),
el('div', { className: 'todo-form-grid' }, fields),
el('div', { className: 'todo-modal-actions' }, [
el('button', { className: 'todo-btn', type: 'button', textContent: tr('ui.cancel', null, 'Cancel'), onClick: closeTodoModal }),
el('button', { className: 'todo-btn primary', type: 'button', 'data-todo-action': 'save', textContent: editing ? tr('ui.saveChanges', null, 'Save changes') : tr('ui.add', null, 'Add Todo'), onClick: saveTodo })
])
])
]));
titleInput.focus && titleInput.focus();
}
function setTodoStatus(todo, nextStatus) {
var timestamp = now();
todos = todos.map(function (item) {
if (item.id !== todo.id) return item;
return Object.assign({}, item, {
status: nextStatus,
completedAt: nextStatus === 'done' ? timestamp : '',
updatedAt: timestamp
});
});
statusText = nextStatus === 'done' ? tr('ui.markedDone', null, 'Todo marked done') : (nextStatus === 'cancelled' ? tr('ui.cancelled', null, 'Todo cancelled') : tr('ui.reopened', null, 'Todo reopened'));
statusClass = '';
persist().then(render);
}
function deleteTodo(todo) {
todos = todos.filter(function (item) { return item.id !== todo.id; });
statusText = tr('ui.deleted', null, 'Todo deleted');
statusClass = '';
persist().then(render);
}
function openWorkspace(todo) {
var workspaceRoot = cleanWorkspace(todo && todo.workspaceRootPath);
if (!workspaceRoot || typeof window === 'undefined' || typeof window.dispatchEvent !== 'function' || typeof CustomEvent === 'undefined') return;
window.dispatchEvent(new CustomEvent('verstak:workspace-selected', { detail: { workspaceName: workspaceRoot } }));
window.dispatchEvent(new CustomEvent('verstak:workspace-open-tool', { detail: { kind: 'todo' } }));
}
function createJournalEntry(todo) {
if (!todo || todo.status !== 'done' || scope.mode !== 'workspace') return;
if (typeof window === 'undefined' || typeof window.dispatchEvent !== 'function' || typeof CustomEvent === 'undefined') return;
window.dispatchEvent(new CustomEvent('verstak:workspace-open-tool', {
detail: {
kind: 'journal',
toolRequest: {
type: 'completed-todo',
todo: {
id: todo.id,
title: todo.title,
description: todo.description,
workspaceRootPath: scope.workspaceRoot,
completedAt: todo.completedAt
}
}
}
}));
}
function renderTodoMeta(todo) {
var meta = [];
var workspace = cleanWorkspace(todo.workspaceRootPath);
var due = dueState(todo);
var reminderDue = reminderIsDue(todo);
if (scope.mode === 'global') meta.push(el('span', { className: 'todo-badge', textContent: workspace || tr('ui.unassigned', null, 'Unassigned') }));
meta.push(el('span', { className: 'todo-badge ' + todo.priority, textContent: tr('ui.priorityValue', { priority: tr('ui.priority.' + todo.priority, null, todo.priority) }, todo.priority + ' priority') }));
meta.push(el('span', { className: 'todo-badge', textContent: tr('ui.status.' + todo.status, null, todo.status) }));
if (todo.dueAt) meta.push(el('span', { className: 'todo-badge ' + due, textContent: tr('ui.dueValue', { prefix: due === 'overdue' ? tr('ui.overduePrefix', null, 'Overdue · ') : (due === 'due-soon' ? tr('ui.dueSoonPrefix', null, 'Due soon · ') : ''), date: formatDate(todo.dueAt) }, (due === 'overdue' ? 'Overdue · ' : (due === 'due-soon' ? 'Due soon · ' : '')) + 'Due ' + formatDate(todo.dueAt)) }));
if (todo.reminderAt) meta.push(el('span', { className: 'todo-badge ' + (reminderDue ? 'reminder-due' : ''), textContent: tr(reminderDue ? 'ui.reminderDueValue' : 'ui.reminderValue', { date: formatDate(todo.reminderAt) }, (reminderDue ? 'Reminder due ' : 'Reminder ') + formatDate(todo.reminderAt)) }));
else if (todo.reminderDate) meta.push(el('span', { className: 'todo-badge', textContent: tr('ui.reminderDateValue', { date: formatDate(todo.reminderDate) }, 'Reminder date ' + formatDate(todo.reminderDate)) }));
return el('div', { className: 'todo-row-meta' }, meta);
}
function renderList() {
listEl.innerHTML = '';
var visible = visibleTodos();
if (!visible.length) {
listEl.appendChild(el('div', {
className: 'todo-empty',
textContent: todos.length ? tr('ui.noMatches', null, 'No todos match the current filters.') : tr('ui.empty', null, 'No todos yet.')
}));
return;
}
visible.forEach(function (todo) {
var actionButtons = [];
if (scope.mode === 'global' && todo.workspaceRootPath) {
actionButtons.push(el('button', { className: 'todo-btn', type: 'button', 'data-todo-action': 'open-workspace', textContent: tr('ui.openWorkspace', null, 'Open Deal'), onClick: function () { openWorkspace(todo); } }));
}
if (todo.status === 'open') {
actionButtons.push(el('button', { className: 'todo-btn', type: 'button', 'data-todo-action': 'mark-done', textContent: tr('ui.status.done', null, 'Done'), onClick: function () { setTodoStatus(todo, 'done'); } }));
actionButtons.push(el('button', { className: 'todo-btn', type: 'button', 'data-todo-action': 'cancel', textContent: tr('ui.cancel', null, 'Cancel'), onClick: function () { setTodoStatus(todo, 'cancelled'); } }));
} else {
actionButtons.push(el('button', { className: 'todo-btn', type: 'button', 'data-todo-action': 'reopen', textContent: tr('ui.reopen', null, 'Reopen'), onClick: function () { setTodoStatus(todo, 'open'); } }));
}
if (scope.mode === 'workspace' && todo.status === 'done') {
actionButtons.push(el('button', { className: 'todo-btn', type: 'button', 'data-todo-action': 'create-journal-entry', textContent: tr('ui.createJournal', null, 'Create Journal Entry'), onClick: function () { createJournalEntry(todo); } }));
}
actionButtons.push(el('button', { className: 'todo-btn', type: 'button', 'data-todo-action': 'edit', textContent: tr('ui.editAction', null, 'Edit'), onClick: function () { showTodoModal(todo); } }));
actionButtons.push(el('button', { className: 'todo-btn danger', type: 'button', 'data-todo-action': 'delete', textContent: tr('ui.delete', null, 'Delete'), onClick: function () { deleteTodo(todo); } }));
listEl.appendChild(el('div', { className: 'todo-row' + (todo.status === 'done' ? ' done' : ''), 'data-todo-id': todo.id }, [
el('div', { className: 'todo-row-main' }, [
el('div', { className: 'todo-row-head' }, [
el('div', { className: 'todo-row-title', textContent: todo.title || tr('ui.untitled', null, 'Untitled todo') })
]),
todo.description ? el('div', { className: 'todo-row-description', textContent: todo.description }) : null,
renderTodoMeta(todo)
]),
el('div', { className: 'todo-row-actions' }, actionButtons)
]));
});
}
function render() {
var visible = visibleTodos();
countEl.textContent = visible.length === todos.length
? tr('ui.count', { count: todos.length }, todos.length + ' todo' + (todos.length === 1 ? '' : 's'))
: tr('ui.filteredCount', { visible: visible.length, count: todos.length }, visible.length + ' of ' + todos.length + ' todos');
statusFilterEl.value = statusFilter;
sortEl.value = sortMode;
searchInput.value = searchQuery;
renderWorkspaceFilterOptions();
statusEl.textContent = statusText;
statusEl.className = 'todo-status' + (statusClass ? ' ' + statusClass : '');
renderList();
}
render();
Promise.all([loadStored(), loadWorkspaceOptions()]).then(function () {
render();
});
if (api && api.i18n && typeof api.i18n.onDidChangeLocale === 'function') {
api.i18n.onDidChangeLocale(function () {
titleEl.textContent = scope.mode === 'global' ? tr('ui.title', null, 'Todos') : tr('ui.workspaceTitle', { workspace: scope.label }, 'Todos · ' + scope.label);
searchInput.setAttribute('placeholder', tr('ui.search', null, 'Search todos'));
addBtn.textContent = tr('ui.add', null, 'Add Todo');
render();
syncNotifications().then(render);
});
}
};
TodoView.unmount = function (containerEl) {
if (containerEl) containerEl.innerHTML = '';
};
window.VerstakPluginRegister(PLUGIN_ID, {
components: {
TodoView: TodoView
}
});
})();
+69
View File
@@ -0,0 +1,69 @@
{
"manifest.name": "Todos",
"manifest.description": "Global and Deal-scoped todo tracking with due and reminder metadata.",
"contributions.views.verstak.todo.view.title": "Todos",
"contributions.sidebarItems.verstak.todo.sidebar.title": "Todos",
"contributions.workspaceItems.verstak.todo.workspace.title": "Todos",
"ui.title": "Todos",
"ui.workspaceTitle": "Todos · {workspace}",
"ui.allStatuses": "All statuses",
"ui.status.open": "Open",
"ui.status.done": "Done",
"ui.status.cancelled": "Cancelled",
"ui.sort.due": "Sort by due date",
"ui.sort.reminder": "Sort by reminder",
"ui.sort.updated": "Sort by updated",
"ui.search": "Search todos",
"ui.add": "Add Todo",
"ui.allWorkspaces": "All Deals",
"ui.unassigned": "Unassigned",
"ui.saveError": "Could not save tasks. Please try again.",
"ui.loadError": "Could not load tasks. Please try again.",
"ui.notificationError": "Could not schedule reminders. Please try again.",
"ui.notificationTitle": "Todo reminder",
"ui.notificationBody": "{title}",
"ui.titlePlaceholder": "Todo title",
"ui.descriptionPlaceholder": "Optional description",
"ui.priority.low": "Low",
"ui.priority.normal": "Normal",
"ui.priority.high": "High",
"ui.titleRequired": "Title is required",
"ui.updated": "Todo updated",
"ui.added": "Todo added",
"ui.field.title": "Title",
"ui.field.description": "Description",
"ui.field.priority": "Priority",
"ui.field.due": "Due date",
"ui.field.reminder": "Reminder",
"ui.field.reminderDate": "Reminder date",
"ui.field.reminderTime": "Reminder time",
"ui.reminderTimePlaceholder": "14:30",
"ui.reminderTimeInvalid": "Enter a valid reminder time (HH:MM)",
"ui.reminderDateRequired": "Choose a reminder date before setting its time",
"ui.field.workspace": "Deal",
"ui.workspaceValue": "Deal: {workspace}",
"ui.edit": "Edit Todo",
"ui.cancel": "Cancel",
"ui.saveChanges": "Save changes",
"ui.markedDone": "Todo marked done",
"ui.cancelled": "Todo cancelled",
"ui.reopened": "Todo reopened",
"ui.deleted": "Todo deleted",
"ui.priorityValue": "{priority} priority",
"ui.overduePrefix": "Overdue · ",
"ui.dueSoonPrefix": "Due soon · ",
"ui.dueValue": "{prefix}Due {date}",
"ui.reminderDueValue": "Reminder due {date}",
"ui.reminderValue": "Reminder {date}",
"ui.reminderDateValue": "Reminder date {date}",
"ui.noMatches": "No todos match the current filters.",
"ui.empty": "No todos yet.",
"ui.openWorkspace": "Open Deal",
"ui.reopen": "Reopen",
"ui.createJournal": "Create Journal Entry",
"ui.editAction": "Edit",
"ui.delete": "Delete",
"ui.untitled": "Untitled todo",
"ui.count": "{count} todo(s)",
"ui.filteredCount": "{visible} of {count} todos"
}
+69
View File
@@ -0,0 +1,69 @@
{
"manifest.name": "Задачи",
"manifest.description": "Общие задачи и задачи Дел со сроками и напоминаниями.",
"contributions.views.verstak.todo.view.title": "Задачи",
"contributions.sidebarItems.verstak.todo.sidebar.title": "Задачи",
"contributions.workspaceItems.verstak.todo.workspace.title": "Задачи",
"ui.title": "Задачи",
"ui.workspaceTitle": "Задачи · {workspace}",
"ui.allStatuses": "Все состояния",
"ui.status.open": "Открыта",
"ui.status.done": "Выполнена",
"ui.status.cancelled": "Отменена",
"ui.sort.due": "Сортировать по сроку",
"ui.sort.reminder": "Сортировать по напоминанию",
"ui.sort.updated": "Сортировать по обновлению",
"ui.search": "Поиск задач",
"ui.add": "Добавить задачу",
"ui.allWorkspaces": "Все Дела",
"ui.unassigned": "Не назначено",
"ui.saveError": "Не удалось сохранить задачи. Повторите попытку.",
"ui.loadError": "Не удалось загрузить задачи. Повторите попытку.",
"ui.notificationError": "Не удалось запланировать напоминания. Повторите попытку.",
"ui.notificationTitle": "Напоминание о задаче",
"ui.notificationBody": "{title}",
"ui.titlePlaceholder": "Название задачи",
"ui.descriptionPlaceholder": "Необязательное описание",
"ui.priority.low": "Низкий",
"ui.priority.normal": "Обычный",
"ui.priority.high": "Высокий",
"ui.titleRequired": "Введите название",
"ui.updated": "Задача обновлена",
"ui.added": "Задача добавлена",
"ui.field.title": "Название",
"ui.field.description": "Описание",
"ui.field.priority": "Приоритет",
"ui.field.due": "Срок",
"ui.field.reminder": "Напоминание",
"ui.field.reminderDate": "Дата напоминания",
"ui.field.reminderTime": "Время напоминания",
"ui.reminderTimePlaceholder": "14:30",
"ui.reminderTimeInvalid": "Введите корректное время напоминания (ЧЧ:ММ)",
"ui.reminderDateRequired": "Сначала выберите дату напоминания",
"ui.field.workspace": "Дело",
"ui.workspaceValue": "Дело: {workspace}",
"ui.edit": "Изменить задачу",
"ui.cancel": "Отмена",
"ui.saveChanges": "Сохранить изменения",
"ui.markedDone": "Задача выполнена",
"ui.cancelled": "Задача отменена",
"ui.reopened": "Задача открыта снова",
"ui.deleted": "Задача удалена",
"ui.priorityValue": "Приоритет: {priority}",
"ui.overduePrefix": "Просрочено · ",
"ui.dueSoonPrefix": "Скоро срок · ",
"ui.dueValue": "{prefix}Срок: {date}",
"ui.reminderDueValue": "Пора напомнить: {date}",
"ui.reminderValue": "Напоминание: {date}",
"ui.reminderDateValue": "Дата напоминания: {date}",
"ui.noMatches": "Нет задач, соответствующих фильтрам.",
"ui.empty": "Задач пока нет.",
"ui.openWorkspace": "Открыть Дело",
"ui.reopen": "Открыть снова",
"ui.createJournal": "Создать запись журнала",
"ui.editAction": "Изменить",
"ui.delete": "Удалить",
"ui.untitled": "Задача без названия",
"ui.count": "Задач: {count}",
"ui.filteredCount": "Показано {visible} из {count}"
}
+54
View File
@@ -0,0 +1,54 @@
{
"schemaVersion": 1,
"id": "verstak.todo",
"name": "Todos",
"version": "0.1.0",
"apiVersion": "0.1.0",
"description": "Global and Deal-scoped todo tracking with due and reminder metadata.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official",
"icon": "list-todo",
"provides": [
"todo.list",
"todo.workspace"
],
"requires": [
"verstak/core/notifications/v1"
],
"permissions": [
"files.read",
"storage.namespace",
"ui.register",
"notifications.schedule"
],
"frontend": {
"entry": "frontend/src/index.js"
},
"contributes": {
"views": [
{
"id": "verstak.todo.view",
"title": "Todos",
"icon": "list-todo",
"component": "TodoView"
}
],
"sidebarItems": [
{
"id": "verstak.todo.sidebar",
"title": "Todos",
"icon": "list-todo",
"view": "verstak.todo.view",
"position": 35
}
],
"workspaceItems": [
{
"id": "verstak.todo.workspace",
"title": "Todos",
"icon": "list-todo",
"component": "TodoView"
}
]
}
}
+408
View File
@@ -0,0 +1,408 @@
/* ===========================================================
Trash Plugin - Verstak v2 Frontend Bundle
Contract: window.VerstakPluginRegister(id, { components })
=========================================================== */
(function () {
'use strict';
var PLUGIN_ID = 'verstak.trash';
var STYLES = [
'.trash-root{display:flex;flex-direction:column;height:100%;min-height:0;background:var(--vt-color-background,#101020);color:var(--vt-color-text-primary,#f4f7fb);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif}',
'.trash-toolbar{display:flex;align-items:center;gap:.5rem;min-height:2.75rem;padding:.5rem .75rem;border-bottom:1px solid var(--vt-color-border,#202b46);background:var(--vt-color-surface-muted,#111629);flex-shrink:0;flex-wrap:wrap}',
'.trash-title{font-size:.88rem;font-weight:650;color:var(--vt-color-text-primary,#f4f7fb)}',
'.trash-count{font-size:.74rem;color:var(--vt-color-text-muted,#7f8aa3)}',
'.trash-spacer{flex:1}',
'.trash-control{min-height:2rem;box-sizing:border-box;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:var(--vt-color-surface,#15152c);color:var(--vt-color-text-primary,#f4f7fb);font:inherit;font-size:.78rem;padding:.35rem .5rem}',
'.trash-search{min-width:12rem;flex:1 1 14rem}',
'.trash-select{max-width:11rem;appearance:none;background-color:var(--vt-color-surface,#15152c);background-image:linear-gradient(45deg,transparent 50%,var(--vt-color-text-muted,#7f8aa3) 50%),linear-gradient(135deg,var(--vt-color-text-muted,#7f8aa3) 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}.trash-select option{background:var(--vt-color-surface,#15152c);color:var(--vt-color-text-primary,#f4f7fb)}.trash-control:focus{outline:none;border-color:var(--vt-color-accent,#4ecca3);box-shadow:var(--vt-focus-ring,0 0 0 2px rgba(78,204,163,.34))}',
'.trash-btn{min-height:2rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:var(--vt-color-surface-hover,#1b2440);color:var(--vt-color-text-secondary,#b7c0d4);font:inherit;font-size:.78rem;padding:.35rem .6rem;cursor:pointer;white-space:nowrap}',
'.trash-btn:hover:not(:disabled){border-color:var(--vt-color-accent,#4ecca3);color:var(--vt-color-text-primary,#f4f7fb)}',
'.trash-btn:disabled{opacity:.48;cursor:default}',
'.trash-btn-danger{border-color:rgba(233,69,96,.52);color:#ffb4bf;background:var(--vt-color-danger-muted,rgba(233,69,96,.14))}',
'.trash-status{min-height:1.25rem;padding:.4rem .75rem;border-bottom:1px solid rgba(32,43,70,.7);font-size:.76rem;color:var(--vt-color-text-muted,#7f8aa3);flex-shrink:0}',
'.trash-status.error{color:#ffc6ce;background:rgba(233,69,96,.08)}',
'.trash-list{flex:1;min-height:0;overflow:auto}',
'.trash-header,.trash-row{display:grid;grid-template-columns:minmax(12rem,1.4fr) minmax(7rem,.75fr) minmax(14rem,1.6fr) minmax(8rem,.8fr) minmax(7rem,.65fr) auto;gap:.65rem;align-items:center;padding:.5rem .75rem;border-bottom:1px solid rgba(32,43,70,.72)}',
'.trash-header{position:sticky;top:0;z-index:1;background:var(--vt-color-surface-muted,#111629);color:var(--vt-color-text-muted,#7f8aa3);font-size:.68rem;font-weight:650;letter-spacing:.02em;text-transform:uppercase}',
'.trash-row:hover{background:var(--vt-color-surface-hover,#1b2440)}',
'.trash-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:.84rem;font-weight:600}',
'.trash-meta{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--vt-color-text-secondary,#b7c0d4);font-size:.76rem}',
'.trash-path{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--vt-color-text-muted,#7f8aa3)}',
'.trash-type{display:inline-flex;align-items:center;gap:.3rem;font-size:.72rem;color:var(--vt-color-text-secondary,#b7c0d4)}',
'.trash-actions{display:flex;justify-content:flex-end;gap:.35rem}',
'.trash-empty{height:100%;min-height:12rem;display:flex;align-items:center;justify-content:center;padding:2rem;color:var(--vt-color-text-muted,#7f8aa3);font-size:.88rem;text-align:center}',
'.trash-confirm-backdrop{position:absolute;inset:0;z-index:20;display:flex;align-items:center;justify-content:center;padding:1rem;background:rgba(5,7,16,.7)}',
'.trash-confirm{width:min(31rem,100%);border:1px solid rgba(233,69,96,.5);border-radius:var(--vt-radius-md,6px);background:var(--vt-color-surface,#15152c);box-shadow:var(--vt-elevation-modal,0 16px 40px rgba(0,0,0,.45));padding:1rem}',
'.trash-confirm h3{margin:0;font-size:1rem;font-weight:650}',
'.trash-confirm p{margin:.55rem 0;color:var(--vt-color-text-secondary,#b7c0d4);font-size:.82rem;line-height:1.45;overflow-wrap:anywhere}',
'.trash-confirm-path{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--vt-color-text-muted,#7f8aa3)}',
'.trash-confirm-actions{display:flex;justify-content:flex-end;gap:.5rem;margin-top:1rem}',
'@media(max-width:900px){.trash-header{display:none}.trash-row{grid-template-columns:minmax(0,1fr) auto;gap:.45rem .75rem;padding:.7rem .75rem}.trash-row>span:nth-child(2),.trash-row>span:nth-child(3),.trash-row>span:nth-child(4),.trash-row>span:nth-child(5){grid-column:1}.trash-actions{grid-column:2;grid-row:1 / span 5;align-self:center;flex-direction:column}.trash-search{order:5;flex-basis:100%}.trash-select{max-width:none;flex:1 1 8rem}}'
].join('\n');
function injectStyles() {
if (document.getElementById('trash-style-injected')) return;
var style = document.createElement('style');
style.id = 'trash-style-injected';
style.textContent = STYLES;
document.head.appendChild(style);
}
function el(tag, attrs, children) {
var node = document.createElement(tag);
attrs = attrs || {};
Object.keys(attrs).forEach(function (key) {
var value = attrs[key];
if (value == null) return;
if (key === 'className') node.className = value;
else if (key === 'textContent') node.textContent = value;
else if (key === 'style' && typeof value === 'object') Object.assign(node.style, value);
else if (key.slice(0, 2) === 'on') node.addEventListener(key.slice(2).toLowerCase(), value);
else if (key === 'value') node.value = value;
else if (key === 'disabled') node.disabled = !!value;
else node.setAttribute(key, value);
});
(Array.isArray(children) ? children : [children]).forEach(function (child) {
if (child == null) return;
node.appendChild(typeof child === 'string' ? document.createTextNode(child) : child);
});
return node;
}
function text(value) {
return String(value == null ? '' : value);
}
function cleanPath(value) {
return text(value).split('/').filter(Boolean).join('/');
}
function nameFor(entry) {
var path = cleanPath(entry && entry.originalPath);
return text(entry && entry.basename).trim() || path.split('/').pop() || 'Untitled item';
}
function workspaceFor(entry) {
return cleanPath(entry && entry.originalPath).split('/')[0] || 'Vault root';
}
function directoryFor(entry) {
var path = cleanPath(entry && entry.originalPath);
var parts = path.split('/');
parts.pop();
return parts.join('/') || '/';
}
function typeLabel(entry) {
return entry && entry.originalType === 'folder' ? 'Folder' : 'File';
}
function formatSize(value) {
var size = Number(value);
if (!Number.isFinite(size) || size <= 0) return '';
var units = ['B', 'KB', 'MB', 'GB'];
var unit = 0;
while (size >= 1024 && unit < units.length - 1) {
size /= 1024;
unit += 1;
}
return (unit === 0 ? String(Math.round(size)) : size.toFixed(size >= 10 ? 0 : 1)) + ' ' + units[unit];
}
function formatDate(value) {
var date = new Date(value);
if (Number.isNaN(date.getTime())) return text(value);
return new Intl.DateTimeFormat('en-GB', {
year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit'
}).format(date);
}
function errorText(error) {
return error && error.message ? error.message : text(error);
}
function isConflict(error) {
return /conflict:/i.test(errorText(error));
}
function sortEntries(entries, mode) {
return entries.slice().sort(function (left, right) {
if (mode === 'date-asc') return text(left.deletedAt).localeCompare(text(right.deletedAt)) || text(left.trashId).localeCompare(text(right.trashId));
if (mode === 'name-asc') return nameFor(left).localeCompare(nameFor(right), undefined, { sensitivity: 'base' });
if (mode === 'type-asc') return typeLabel(left).localeCompare(typeLabel(right)) || nameFor(left).localeCompare(nameFor(right));
return text(right.deletedAt).localeCompare(text(left.deletedAt)) || text(right.trashId).localeCompare(text(left.trashId));
});
}
var TrashView = {
mount: function (containerEl, props, api) {
injectStyles();
var state = {
entries: [],
workspace: '',
query: '',
type: '',
sort: 'date-desc',
loading: true,
busyId: '',
confirmingId: '',
status: '',
statusError: false,
disposed: false
};
function tr(key, params, fallback) {
if (api && api.i18n && typeof api.i18n.t === 'function') return api.i18n.t(key, params, fallback);
return fallback || key;
}
function workspaceOptions() {
var values = {};
state.entries.forEach(function (entry) { values[workspaceFor(entry)] = true; });
return Object.keys(values).sort(function (left, right) { return left.localeCompare(right); });
}
function visibleEntries() {
var query = state.query.trim().toLowerCase();
return sortEntries(state.entries.filter(function (entry) {
if (state.workspace && workspaceFor(entry) !== state.workspace) return false;
if (state.type && text(entry.originalType) !== state.type) return false;
if (!query) return true;
return [nameFor(entry), entry.originalPath, directoryFor(entry), workspaceFor(entry)].join(' ').toLowerCase().indexOf(query) !== -1;
}), state.sort);
}
function statusText(entries) {
if (state.loading) return tr('ui.loading', null, 'Loading deleted items...');
if (state.status) return state.status;
return tr('ui.count', { count: entries.length }, entries.length === 1 ? '1 deleted item' : entries.length + ' deleted items');
}
function renderConfirmation() {
var entry = state.entries.find(function (item) { return item.trashId === state.confirmingId; });
if (!entry) return null;
return el('div', { className: 'trash-confirm-backdrop', 'data-trash-confirm': entry.trashId }, [
el('section', { className: 'trash-confirm', role: 'dialog', 'aria-modal': 'true', 'aria-label': tr('ui.deletePermanently', null, 'Delete permanently') }, [
el('h3', {}, [tr('ui.deleteQuestion', null, 'Delete permanently?')]),
el('p', {}, [tr('ui.cannotUndo', null, 'This cannot be undone.')]),
el('p', { className: 'trash-confirm-path' }, [entry.originalPath || nameFor(entry)]),
el('div', { className: 'trash-confirm-actions' }, [
el('button', {
className: 'trash-btn',
type: 'button',
'data-trash-confirm-cancel': entry.trashId,
onClick: function () { state.confirmingId = ''; render(); }
}, [tr('ui.cancel', null, 'Cancel')]),
el('button', {
className: 'trash-btn trash-btn-danger',
type: 'button',
disabled: state.busyId === entry.trashId,
'data-trash-confirm-delete': entry.trashId,
onClick: function () { deletePermanently(entry); }
}, [state.busyId === entry.trashId ? tr('ui.deleting', null, 'Deleting...') : tr('ui.deletePermanently', null, 'Delete permanently')])
])
])
]);
}
function render() {
var rows = visibleEntries();
containerEl.innerHTML = '';
containerEl.className = 'trash-root';
containerEl.setAttribute('data-plugin-id', PLUGIN_ID);
var workspaceSelect = el('select', {
className: 'trash-control trash-select',
value: state.workspace,
'data-trash-filter-workspace': '',
onChange: function (event) { state.workspace = event.target.value; render(); }
}, [el('option', { value: '' }, [tr('ui.allWorkspaces', null, 'All Deals')])]);
workspaceOptions().forEach(function (workspace) {
workspaceSelect.appendChild(el('option', { value: workspace }, [workspace]));
});
var typeSelect = el('select', {
className: 'trash-control trash-select',
value: state.type,
'data-trash-filter-type': '',
onChange: function (event) { state.type = event.target.value; render(); }
}, [
el('option', { value: '' }, [tr('ui.allTypes', null, 'All types')]),
el('option', { value: 'file' }, [tr('ui.files', null, 'Files')]),
el('option', { value: 'folder' }, [tr('ui.folders', null, 'Folders')])
]);
var sortSelect = el('select', {
className: 'trash-control trash-select',
value: state.sort,
'data-trash-sort': '',
onChange: function (event) { state.sort = event.target.value; render(); }
}, [
el('option', { value: 'date-desc' }, [tr('ui.newest', null, 'Deleted: newest')]),
el('option', { value: 'date-asc' }, [tr('ui.oldest', null, 'Deleted: oldest')]),
el('option', { value: 'name-asc' }, [tr('ui.name', null, 'Name')]),
el('option', { value: 'type-asc' }, [tr('ui.type', null, 'Type')])
]);
containerEl.appendChild(el('div', { className: 'trash-toolbar' }, [
el('span', { className: 'trash-title' }, [tr('ui.title', null, 'Trash')]),
el('span', { className: 'trash-count' }, [tr('ui.total', { count: state.entries.length }, state.entries.length + ' total')]),
el('span', { className: 'trash-spacer' }),
el('input', {
className: 'trash-control trash-search',
type: 'search',
value: state.query,
placeholder: tr('ui.filter', null, 'Filter name or path'),
'data-trash-filter-search': '',
onInput: function (event) { state.query = event.target.value; render(); }
}),
workspaceSelect,
typeSelect,
sortSelect,
el('button', { className: 'trash-btn', type: 'button', onClick: loadEntries }, [tr('ui.refresh', null, 'Refresh')])
]));
containerEl.appendChild(el('div', {
className: 'trash-status' + (state.statusError ? ' error' : ''),
'data-trash-status': ''
}, [statusText(rows)]));
var list = el('div', { className: 'trash-list', 'data-trash-list': '' });
list.appendChild(el('div', { className: 'trash-header' }, [
el('span', {}, [tr('ui.name', null, 'Name')]),
el('span', {}, [tr('ui.workspace', null, 'Deal')]),
el('span', {}, [tr('ui.originalPath', null, 'Original path')]),
el('span', {}, [tr('ui.deleted', null, 'Deleted')]),
el('span', {}, [tr('ui.typeSize', null, 'Type / size')]),
el('span', {}, [tr('ui.actions', null, 'Actions')])
]));
if (state.loading) {
list.appendChild(el('div', { className: 'trash-empty' }, [tr('ui.loading', null, 'Loading deleted items...')]));
} else if (!rows.length) {
list.appendChild(el('div', { className: 'trash-empty' }, [state.entries.length ? tr('ui.noMatches', null, 'No deleted items match the current filters.') : tr('ui.empty', null, 'Trash is empty.')]));
} else {
rows.forEach(function (entry) {
var size = formatSize(entry.size);
list.appendChild(el('div', {
className: 'trash-row',
'data-trash-row': entry.trashId,
'data-trash-workspace': workspaceFor(entry)
}, [
el('span', { className: 'trash-name', title: nameFor(entry) }, [nameFor(entry)]),
el('span', { className: 'trash-meta', title: workspaceFor(entry) }, [workspaceFor(entry)]),
el('span', { className: 'trash-meta trash-path', title: entry.originalPath || '' }, [entry.originalPath || directoryFor(entry)]),
el('span', { className: 'trash-meta' }, [formatDate(entry.deletedAt)]),
el('span', { className: 'trash-type' }, [typeLabel(entry) + (size ? ' - ' + size : '')]),
el('div', { className: 'trash-actions' }, [
el('button', {
className: 'trash-btn',
type: 'button',
disabled: state.busyId === entry.trashId,
'data-trash-restore': entry.trashId,
onClick: function () { restoreEntry(entry); }
}, [state.busyId === entry.trashId ? tr('ui.restoring', null, 'Restoring...') : tr('ui.restore', null, 'Restore')]),
el('button', {
className: 'trash-btn trash-btn-danger',
type: 'button',
disabled: state.busyId === entry.trashId,
'data-trash-delete': entry.trashId,
onClick: function () { state.confirmingId = entry.trashId; render(); }
}, [tr('ui.deletePermanently', null, 'Delete permanently')])
])
]));
});
}
containerEl.appendChild(list);
var confirmation = renderConfirmation();
if (confirmation) containerEl.appendChild(confirmation);
}
function restoreEntry(entry) {
if (!entry || !entry.trashId || !api.files || typeof api.files.restoreTrash !== 'function') return;
state.busyId = entry.trashId;
state.status = '';
state.statusError = false;
render();
api.files.restoreTrash(entry.trashId, { overwrite: false }).then(function () {
if (state.disposed) return;
state.entries = state.entries.filter(function (item) { return item.trashId !== entry.trashId; });
state.busyId = '';
state.status = 'Restored ' + nameFor(entry) + '.';
render();
}).catch(function (error) {
if (state.disposed) return;
state.busyId = '';
state.statusError = true;
state.status = isConflict(error)
? 'Restore blocked: an item already exists at the original path. Nothing was overwritten.'
: 'Restore failed: ' + errorText(error);
render();
});
}
function deletePermanently(entry) {
if (!entry || !entry.trashId || !api.files || typeof api.files.deleteTrash !== 'function') return;
state.busyId = entry.trashId;
state.status = '';
state.statusError = false;
render();
api.files.deleteTrash(entry.trashId).then(function () {
if (state.disposed) return;
state.entries = state.entries.filter(function (item) { return item.trashId !== entry.trashId; });
state.busyId = '';
state.confirmingId = '';
state.status = 'Permanently deleted ' + nameFor(entry) + '.';
render();
}).catch(function (error) {
if (state.disposed) return;
state.busyId = '';
state.statusError = true;
state.status = 'Permanent delete failed: ' + errorText(error);
render();
});
}
function loadEntries() {
if (!api.files || typeof api.files.listTrash !== 'function') {
state.loading = false;
state.statusError = true;
state.status = 'Trash is unavailable for this plugin configuration.';
render();
return;
}
state.loading = true;
state.status = '';
state.statusError = false;
render();
api.files.listTrash().then(function (entries) {
if (state.disposed) return;
state.entries = Array.isArray(entries) ? entries : [];
state.loading = false;
render();
}).catch(function (error) {
if (state.disposed) return;
state.entries = [];
state.loading = false;
state.statusError = true;
state.status = 'Could not load Trash: ' + errorText(error);
render();
});
}
loadEntries();
var localeUnsubscribe = api.i18n && typeof api.i18n.onDidChangeLocale === 'function'
? api.i18n.onDidChangeLocale(render)
: null;
containerEl.__trashCleanup = function () {
state.disposed = true;
if (typeof localeUnsubscribe === 'function') localeUnsubscribe();
containerEl.innerHTML = '';
};
},
unmount: function (containerEl) {
if (containerEl && typeof containerEl.__trashCleanup === 'function') containerEl.__trashCleanup();
}
};
window.VerstakPluginRegister(PLUGIN_ID, { components: { TrashView: TrashView } });
})();
+34
View File
@@ -0,0 +1,34 @@
{
"manifest.name": "Trash",
"manifest.description": "Global vault trash manager for restoring or permanently deleting deleted files and folders.",
"contributions.views.verstak.trash.view.title": "Trash",
"contributions.sidebarItems.verstak.trash.sidebar.title": "Trash",
"ui.loading": "Loading deleted items...",
"ui.count": "{count} deleted item(s)",
"ui.deletePermanently": "Delete permanently",
"ui.deleteQuestion": "Delete permanently?",
"ui.cannotUndo": "This cannot be undone.",
"ui.cancel": "Cancel",
"ui.deleting": "Deleting...",
"ui.allWorkspaces": "All Deals",
"ui.allTypes": "All types",
"ui.files": "Files",
"ui.folders": "Folders",
"ui.newest": "Deleted: newest",
"ui.oldest": "Deleted: oldest",
"ui.name": "Name",
"ui.type": "Type",
"ui.title": "Trash",
"ui.total": "{count} total",
"ui.filter": "Filter name or path",
"ui.refresh": "Refresh",
"ui.workspace": "Deal",
"ui.originalPath": "Original path",
"ui.deleted": "Deleted",
"ui.typeSize": "Type / size",
"ui.actions": "Actions",
"ui.noMatches": "No deleted items match the current filters.",
"ui.empty": "Trash is empty.",
"ui.restoring": "Restoring...",
"ui.restore": "Restore"
}
+34
View File
@@ -0,0 +1,34 @@
{
"manifest.name": "Корзина",
"manifest.description": "Общая корзина хранилища для восстановления или окончательного удаления файлов и папок.",
"contributions.views.verstak.trash.view.title": "Корзина",
"contributions.sidebarItems.verstak.trash.sidebar.title": "Корзина",
"ui.loading": "Загрузка удалённых элементов...",
"ui.count": "Удалённых элементов: {count}",
"ui.deletePermanently": "Удалить навсегда",
"ui.deleteQuestion": "Удалить навсегда?",
"ui.cannotUndo": "Это действие нельзя отменить.",
"ui.cancel": "Отмена",
"ui.deleting": "Удаление...",
"ui.allWorkspaces": "Все Дела",
"ui.allTypes": "Все типы",
"ui.files": "Файлы",
"ui.folders": "Папки",
"ui.newest": "Сначала недавно удалённые",
"ui.oldest": "Сначала давно удалённые",
"ui.name": "Имя",
"ui.type": "Тип",
"ui.title": "Корзина",
"ui.total": "Всего: {count}",
"ui.filter": "Фильтр по имени или пути",
"ui.refresh": "Обновить",
"ui.workspace": "Дело",
"ui.originalPath": "Исходный путь",
"ui.deleted": "Удалён",
"ui.typeSize": "Тип / размер",
"ui.actions": "Действия",
"ui.noMatches": "Нет удалённых элементов, соответствующих фильтрам.",
"ui.empty": "Корзина пуста.",
"ui.restoring": "Восстановление...",
"ui.restore": "Восстановить"
}
+44
View File
@@ -0,0 +1,44 @@
{
"schemaVersion": 1,
"id": "verstak.trash",
"name": "Trash",
"version": "0.1.0",
"apiVersion": "0.1.0",
"description": "Global vault trash manager for restoring or permanently deleting deleted files and folders.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official",
"icon": "trash",
"provides": [
"trash.management"
],
"requires": [
"verstak/core/files/v1"
],
"permissions": [
"files.delete",
"files.write",
"ui.register"
],
"frontend": {
"entry": "frontend/src/index.js"
},
"contributes": {
"views": [
{
"id": "verstak.trash.view",
"title": "Trash",
"icon": "trash",
"component": "TrashView"
}
],
"sidebarItems": [
{
"id": "verstak.trash.sidebar",
"title": "Trash",
"icon": "trash",
"view": "verstak.trash.view",
"position": 40
}
]
}
}
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
WINDOWS_DIST="${VERSTAK_WINDOWS_PLUGIN_DIST:-$ROOT/dist-windows}"
WINDOWS_CC="${VERSTAK_WINDOWS_CC:-x86_64-w64-mingw32-gcc}"
GO_BIN="${GO_BIN:-go}"
if ! command -v "$WINDOWS_CC" >/dev/null; then
echo "Windows cross-compiler not found: $WINDOWS_CC" >&2
echo "Install x86_64-w64-mingw32-gcc (for example: sudo apt install gcc-mingw-w64-x86-64)." >&2
exit 1
fi
if ! command -v "$GO_BIN" >/dev/null; then
echo "go is required to build Windows plugin backends" >&2
exit 1
fi
cd "$ROOT"
echo "=== verstak official plugins Windows amd64 build ==="
# Reuse the canonical frontend and manifest packager, then replace every Go
# sidecar with its Windows executable in a separate dist-windows tree.
./scripts/build.sh
rm -rf "$WINDOWS_DIST"
mkdir -p "$WINDOWS_DIST"
cp -R "$ROOT/dist/." "$WINDOWS_DIST/"
backend_count=0
for plugin_dir in "$ROOT"/plugins/*/; do
[[ -d "$plugin_dir" ]] || continue
plugin_name="$(basename "$plugin_dir")"
backend_dir="$plugin_dir/backend"
[[ -d "$backend_dir" ]] || continue
if [[ ! -f "$backend_dir/go.mod" && ! -f "$backend_dir/main.go" ]]; then
continue
fi
package_backend="$WINDOWS_DIST/$plugin_name/backend"
rm -rf "$package_backend"
mkdir -p "$package_backend"
echo " → Windows backend: $plugin_name.exe"
(
cd "$backend_dir"
GOOS=windows GOARCH=amd64 CGO_ENABLED=1 CC="$WINDOWS_CC" \
"$GO_BIN" build -o "$package_backend/$plugin_name.exe" .
)
backend_count=$((backend_count + 1))
done
echo "Windows plugin packages: $WINDOWS_DIST"
echo "Windows backends built: $backend_count"
+40 -7
View File
@@ -36,6 +36,7 @@ package_plugin() {
local plugin_dir="$1"
local plugin_name="$2"
local dist_dir="$ROOT/dist/$plugin_name"
local copied_plain_source=0
echo " → packaging dist/$plugin_name"
rm -rf "$dist_dir"
@@ -47,17 +48,45 @@ package_plugin() {
fi
# 2. frontend/dist/
if [ -d "$plugin_dir/frontend/dist" ]; then
if [ ! -f "$plugin_dir/frontend/package.json" ] && [ -f "$plugin_dir/frontend/src/index.js" ]; then
mkdir -p "$dist_dir/frontend/dist"
cp "$plugin_dir/frontend/src/index.js" "$dist_dir/frontend/dist/index.js"
copied_plain_source=1
echo " └─ frontend/dist/index.js (from frontend/src/index.js)"
elif [ -d "$plugin_dir/frontend/dist" ]; then
mkdir -p "$dist_dir/frontend/dist"
cp -r "$plugin_dir/frontend/dist/." "$dist_dir/frontend/dist/"
echo " └─ frontend/dist ($(find "$dist_dir/frontend/dist" -type f | wc -l) file(s))"
elif [ -f "$plugin_dir/frontend/src/index.js" ]; then
mkdir -p "$dist_dir/frontend/dist"
cp "$plugin_dir/frontend/src/index.js" "$dist_dir/frontend/dist/index.js"
echo " └─ frontend/dist/index.js (from frontend/src/index.js)"
fi
# 3. backend binary
if [ "$copied_plain_source" -eq 1 ] && [ -f "$dist_dir/plugin.json" ]; then
if command -v python3 &>/dev/null; then
python3 -c "
import json
path = '$dist_dir/plugin.json'
with open(path, encoding='utf-8') as f:
manifest = json.load(f)
frontend = manifest.setdefault('frontend', {})
frontend['entry'] = 'frontend/dist/index.js'
with open(path, 'w', encoding='utf-8') as f:
json.dump(manifest, f, ensure_ascii=False, indent=2)
f.write('\n')
"
echo " └─ plugin.json frontend.entry -> frontend/dist/index.js"
else
echo " ❌ python3 required to rewrite packaged plugin.json for plain JS frontend"
return 1
fi
fi
# 3. locale catalogs declared by plugin.json
if [ -d "$plugin_dir/locales" ]; then
mkdir -p "$dist_dir/locales"
cp -r "$plugin_dir/locales/." "$dist_dir/locales/"
echo " └─ locales ($(find "$dist_dir/locales" -type f | wc -l) file(s))"
fi
# 4. backend binary
if [ -d "$plugin_dir/backend" ]; then
# Find the compiled binary (same name as plugin directory)
local bin_name="$plugin_name"
@@ -69,7 +98,7 @@ package_plugin() {
fi
fi
# 4. Verify dist package has at least plugin.json
# 5. Verify dist package has at least plugin.json
if [ ! -f "$dist_dir/plugin.json" ]; then
echo " ❌ dist package missing plugin.json"
return 1
@@ -88,10 +117,14 @@ HAS_DEPS=1
if ! command -v node &>/dev/null; then echo " ❌ node: not found"; HAS_DEPS=0; else echo " ✅ node $(node --version)"; fi
if ! command -v npm &>/dev/null; then echo " ❌ npm: not found"; HAS_DEPS=0; fi
if ! command -v go &>/dev/null; then echo " ❌ go: not found"; HAS_DEPS=0; else echo " ✅ go $(go version | grep -oP 'go\S+')"; fi
if ! command -v python3 &>/dev/null; then echo " ❌ python3: not found"; HAS_DEPS=0; else echo " ✅ python3 $(python3 --version | awk '{print $2}')"; fi
if [ "$HAS_DEPS" -eq 0 ]; then
echo " ⚠️ some deps missing — will skip matching plugin parts"
fi
rm -rf "$ROOT/dist"
mkdir -p "$ROOT/dist"
for plugin_dir in "$ROOT"/plugins/*/; do
[ -d "$plugin_dir" ] || continue
plugin_name="$(basename "$plugin_dir")"
+81
View File
@@ -0,0 +1,81 @@
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 pluginsRoot = path.join(root, 'plugins');
const contributionFields = {
views: 'title',
commands: 'title',
settingsPanels: 'title',
sidebarItems: 'title',
fileActions: 'label',
noteActions: 'label',
contextMenuEntries: 'label',
searchProviders: 'label',
statusBarItems: 'label',
openProviders: 'title',
workspaceItems: 'title',
};
const problems = [];
for (const entry of fs.readdirSync(pluginsRoot, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const pluginRoot = path.join(pluginsRoot, entry.name);
const manifestPath = path.join(pluginRoot, 'plugin.json');
if (!fs.existsSync(manifestPath)) continue;
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const localization = manifest.localization;
if (!localization || localization.defaultLocale !== 'en') {
problems.push(`${manifest.id}: localization.defaultLocale must be en`);
continue;
}
if (!localization.locales || !localization.locales.en || !localization.locales.ru) {
problems.push(`${manifest.id}: en and ru catalogs must be declared`);
continue;
}
const catalogs = {};
for (const locale of ['en', 'ru']) {
const relative = localization.locales[locale];
const catalogPath = path.resolve(pluginRoot, relative);
if (!catalogPath.startsWith(pluginRoot + path.sep) || !fs.existsSync(catalogPath)) {
problems.push(`${manifest.id}: missing safe ${locale} catalog at ${relative}`);
continue;
}
try {
catalogs[locale] = JSON.parse(fs.readFileSync(catalogPath, 'utf8'));
} catch (error) {
problems.push(`${manifest.id}: invalid ${locale} catalog: ${error.message}`);
continue;
}
for (const [key, value] of Object.entries(catalogs[locale])) {
if (typeof value !== 'string') problems.push(`${manifest.id}: ${locale}.${key} must be a string`);
}
}
if (!catalogs.en || !catalogs.ru) continue;
const enKeys = Object.keys(catalogs.en).sort();
const ruKeys = Object.keys(catalogs.ru).sort();
if (JSON.stringify(enKeys) !== JSON.stringify(ruKeys)) {
problems.push(`${manifest.id}: en/ru catalog keys differ`);
}
const required = ['manifest.name'];
if (manifest.description) required.push('manifest.description');
for (const [point, field] of Object.entries(contributionFields)) {
for (const item of manifest.contributes?.[point] || []) {
required.push(`contributions.${point}.${item.id}.${field}`);
}
}
for (const key of required) {
if (!Object.prototype.hasOwnProperty.call(catalogs.en, key)) problems.push(`${manifest.id}: en catalog missing ${key}`);
if (!Object.prototype.hasOwnProperty.call(catalogs.ru, key)) problems.push(`${manifest.id}: ru catalog missing ${key}`);
}
}
if (problems.length > 0) {
problems.forEach((problem) => console.error(` FAIL ${problem}`));
process.exit(1);
}
console.log(' OK official plugin locale catalogs are complete and aligned');
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const root = path.resolve(__dirname, '..');
const checks = [
['plugins/todo/frontend/src/index.js', '.todo-select'],
['plugins/files/frontend/src/index.js', '.files-sort'],
['plugins/notes/frontend/src/index.js', '.notes-sort'],
['plugins/browser-inbox/frontend/src/index.js', '.browser-inbox-select'],
['plugins/journal/frontend/src/index.js', '.journal-input.journal-select'],
['plugins/trash/frontend/src/index.js', '.trash-select'],
['plugins/secrets/frontend/src/index.js', '.secrets-select'],
];
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
let failed = false;
for (const [relativePath, selector] of checks) {
const source = fs.readFileSync(path.join(root, relativePath), 'utf8');
const escaped = escapeRegExp(selector);
if (!new RegExp(`${escaped}\\{[^}]*appearance:none`).test(source)) {
console.error(`${relativePath}: ${selector} must hide the native select arrow`);
failed = true;
}
if (!new RegExp(`${escaped} option\\{[^}]*background`).test(source)) {
console.error(`${relativePath}: ${selector} options must use the application surface`);
failed = true;
}
}
if (failed) process.exit(1);
console.log('official plugin select styles are complete');
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const root = path.resolve(__dirname, '..');
function frontendSources(dir) {
if (!fs.existsSync(dir)) return [];
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const target = path.join(dir, entry.name);
if (entry.isDirectory()) return frontendSources(target);
return /\.(?:js|svelte)$/.test(entry.name) ? [target] : [];
});
}
const files = fs.readdirSync(path.join(root, 'plugins'))
.flatMap((plugin) => frontendSources(path.join(root, 'plugins', plugin, 'frontend', 'src')));
const renderPatterns = [
/statusText\s*=/,
/state\.error\s*=/,
/errorMsg\s*=/,
/setStatus\(/,
/setCreateError\(/,
/setRenameError\(/,
/renderEmpty\(/,
/output\.errors\.push\(/,
/window\.alert\(/,
/showExternalFallback\(/,
/de-error-msg/,
/files-error-msg/,
/body\.textContent\s*=/
];
const violations = [];
for (const file of files) {
const lines = fs.readFileSync(file, 'utf8').split('\n');
lines.forEach((line, index) => {
if (!/(?:err(?:or)?\.message|String\(err(?:or)?\b)/.test(line)) return;
if (!renderPatterns.some((pattern) => pattern.test(line))) return;
violations.push(`${path.relative(root, file)}:${index + 1}: technical error text reaches a user-facing UI sink`);
});
}
if (violations.length) {
console.error('User-facing UI must not render raw backend/plugin errors:');
violations.forEach((violation) => console.error(` ${violation}`));
process.exit(1);
}
console.log('user-facing error messages do not expose raw backend details');
+205 -20
View File
@@ -24,44 +24,51 @@ if [ "$HAS_PYTHON" -eq 1 ]; then
echo "[manifest validation]"
SDK_SCHEMA="$ROOT/../verstak-sdk/schemas/manifest.json"
if [ -f "$SDK_SCHEMA" ]; then
set +e
python3 -c "
import json, glob
import json, glob, os, sys
from jsonschema import Draft202012Validator
skipped = []
problems = []
with open('$SDK_SCHEMA') as f:
schema = json.load(f)
validator = Draft202012Validator(schema)
for plugin_dir in glob.glob('$ROOT/plugins/*/'):
manifest_path = plugin_dir + 'plugin.json'
plugin_name = os.path.basename(os.path.dirname(manifest_path))
try:
with open(manifest_path) as f:
manifest = json.load(f)
except FileNotFoundError:
skipped.append(plugin_dir.split('/')[-2])
skipped.append(plugin_name)
continue
except json.JSONDecodeError as e:
problems.append(plugin_dir.split('/')[-2] + ': invalid JSON — ' + str(e))
problems.append(plugin_name + ': invalid JSON - ' + str(e))
continue
checks = {
'id': isinstance(manifest.get('id'), str) and '.' in manifest['id'],
'version': isinstance(manifest.get('version'), str),
'schemaVersion': manifest.get('schemaVersion') == 1,
'provides': isinstance(manifest.get('provides'), list),
'requires': isinstance(manifest.get('requires'), list),
}
for check, ok in checks.items():
if not ok:
problems.append(manifest.get('id', plugin_dir.split('/')[-2]) + ': missing/empty \"' + check + '\"')
for err in sorted(validator.iter_errors(manifest), key=lambda e: list(e.path)):
where = '.'.join(str(part) for part in err.path) or '<root>'
problems.append(manifest.get('id', plugin_name) + ': ' + where + ': ' + err.message)
for field in ('requires', 'optionalRequires'):
if 'verstak/core/notes/v1' in manifest.get(field, []):
problems.append(manifest.get('id', plugin_name) + ': ' + field + ': core notes capability is not part of v2 platform contract')
if skipped:
print(' \u26a0\ufe0f skipped (no plugin.json): ' + ', '.join(skipped))
print(' warning: skipped (no plugin.json): ' + ', '.join(skipped))
if problems:
for p in problems:
print(' \u274c ' + p)
print(' FAIL ' + p)
sys.exit(1)
else:
print(' \u2705 all manifests valid')
print(' OK all manifests valid')
"
report "manifests valid" $?
STATUS=$?
set -e
report "manifests valid" "$STATUS"
else
echo " ℹ️ SDK schema not found at $SDK_SCHEMA — run build.sh in verstak-sdk first"
fi
@@ -69,6 +76,159 @@ else
echo " ℹ️ python3 not available — skipping manifest validation"
fi
echo ""
# Verify plugin-owned English and Russian catalogs.
echo "[localization catalogs]"
if command -v node &>/dev/null; then
set +e
node "$ROOT/scripts/check-locales.mjs"
STATUS=$?
set -e
report "localization catalogs" "$STATUS"
else
echo " ⚠️ node not available — skipping localization catalog validation"
fi
echo ""
echo "[select styles]"
if command -v node &>/dev/null; then
set +e
node "$ROOT/scripts/check-select-styles.js"
STATUS=$?
set -e
report "selects use application styles" "$STATUS"
else
echo " ⚠️ node not available — skipping select style validation"
fi
echo ""
echo "[user-facing errors]"
if command -v node &>/dev/null; then
set +e
node "$ROOT/scripts/check-user-facing-errors.js"
STATUS=$?
set -e
report "user-facing errors" "$STATUS"
else
echo " ⚠️ node not available — skipping user-facing error validation"
fi
echo ""
# Guard official plugins against bypassing the v2 plugin API for note features.
echo "[frontend API boundary]"
if [ "$HAS_PYTHON" -eq 1 ]; then
set +e
python3 -c "
import os, re, sys
root = '$ROOT/plugins'
forbidden = re.compile(r\"api\\.backend\\.call|api\\.request\\.open|window(?:\\.go|\\[['\\\"]go['\\\"]\\])|Notes/Overview\\.md\")
problems = []
for dirpath, _, filenames in os.walk(root):
normalized_dir = dirpath.replace(os.sep, '/')
if '/node_modules' in normalized_dir or '/frontend/dist' in normalized_dir:
continue
for filename in filenames:
if not filename.endswith(('.js', '.svelte', '.ts')):
continue
path = os.path.join(dirpath, filename)
with open(path, encoding='utf-8') as f:
for lineno, line in enumerate(f, 1):
if forbidden.search(line):
problems.append(f'{os.path.relpath(path, \"$ROOT\")}:{lineno}: {line.strip()}')
if problems:
for p in problems:
print(' FAIL ' + p)
sys.exit(1)
print(' OK official plugins use public VerstakPluginAPI only')
"
STATUS=$?
set -e
report "frontend API boundary" "$STATUS"
else
echo " ⚠️ python3 not available — skipping frontend API boundary"
fi
echo ""
# Keep Files plugin as a raw file explorer; note workflows belong to Notes.
echo "[files plugin note boundaries]"
if [ "$HAS_PYTHON" -eq 1 ]; then
set +e
python3 -c "
import os, re, sys
path = '$ROOT/plugins/files/frontend/src/index.js'
forbidden = re.compile(r'createNoteInFolder|ensureOverviewInFolder|Create Note|Open Overview')
problems = []
with open(path, encoding='utf-8') as f:
for lineno, line in enumerate(f, 1):
if forbidden.search(line):
problems.append(f'{os.path.relpath(path, \"$ROOT\")}:{lineno}: {line.strip()}')
if problems:
for p in problems:
print(' FAIL ' + p)
sys.exit(1)
print(' OK files plugin has no note creation or overview actions')
"
STATUS=$?
set -e
report "files plugin note boundaries" "$STATUS"
else
echo " ⚠️ python3 not available — skipping files plugin note boundaries"
fi
echo ""
# Ensure source manifests do not require ignored dist files for plain JS plugins.
echo "[frontend entry source contract]"
if [ "$HAS_PYTHON" -eq 1 ]; then
set +e
python3 -c "
import json, os, sys
root = '$ROOT/plugins'
problems = []
for plugin_name in sorted(os.listdir(root)):
plugin_dir = os.path.join(root, plugin_name)
manifest_path = os.path.join(plugin_dir, 'plugin.json')
if not os.path.isfile(manifest_path):
continue
with open(manifest_path, encoding='utf-8') as f:
manifest = json.load(f)
frontend = manifest.get('frontend') or {}
entry = frontend.get('entry')
if not entry:
continue
entry_path = os.path.join(plugin_dir, entry)
has_build_step = os.path.isfile(os.path.join(plugin_dir, 'frontend', 'package.json'))
has_plain_source = os.path.isfile(os.path.join(plugin_dir, 'frontend', 'src', 'index.js'))
if has_build_step:
if entry != 'frontend/dist/index.js':
problems.append(f'{plugin_name}: build frontend entry must be frontend/dist/index.js, got {entry}')
continue
if has_plain_source and entry != 'frontend/src/index.js':
problems.append(f'{plugin_name}: plain JS frontend entry must be frontend/src/index.js, got {entry}')
continue
if not os.path.isfile(entry_path):
problems.append(f'{plugin_name}: frontend entry does not exist: {entry}')
if problems:
for p in problems:
print(' FAIL ' + p)
sys.exit(1)
print(' OK source manifests reference tracked frontend entries')
"
STATUS=$?
set -e
report "frontend entry source contract" "$STATUS"
else
echo " ⚠️ python3 not available — skipping frontend entry source contract"
fi
echo ""
# Check all scripts in plugins are executable
echo "[script permissions]"
@@ -88,6 +248,30 @@ echo "[frontend smoke]"
if command -v node &>/dev/null; then
node "$ROOT/scripts/smoke-platform-frontend.js"
report "platform-test frontend components mount" $?
node "$ROOT/scripts/smoke-default-editor-plugin.js"
report "default editor frontend behavior" $?
node "$ROOT/scripts/smoke-notes-plugin.js"
report "notes frontend behavior" $?
node "$ROOT/scripts/smoke-file-preview-plugin.js"
report "file-preview frontend behavior" $?
node "$ROOT/scripts/smoke-files-plugin.js"
report "files frontend behavior" $?
node "$ROOT/scripts/smoke-trash-plugin.js"
report "trash frontend behavior" $?
node "$ROOT/scripts/smoke-activity-plugin.js"
report "activity frontend behavior" $?
node "$ROOT/scripts/smoke-journal-plugin.js"
report "journal frontend behavior" $?
node "$ROOT/scripts/smoke-browser-inbox-plugin.js"
report "browser inbox frontend behavior" $?
node "$ROOT/scripts/smoke-todo-plugin.js"
report "todo frontend behavior" $?
node "$ROOT/scripts/smoke-search-plugin.js"
report "search frontend behavior" $?
node "$ROOT/scripts/smoke-secrets-plugin.js"
report "secrets frontend behavior" $?
node "$ROOT/scripts/smoke-sync-plugin.js"
report "sync frontend behavior" $?
else
echo " ⚠️ node not available — skipping frontend smoke"
fi
@@ -104,10 +288,11 @@ if command -v node &>/dev/null; then
entry=$(node -e "const m=require('$manifest');console.log(m.frontend&&m.frontend.entry||'')" 2>/dev/null)
if [ -z "$entry" ]; then continue; fi
bundle="$plugin_dir$entry"
if [ ! -f "$bundle" ] && [ "$entry" = "frontend/dist/index.js" ] && [ -f "$plugin_dir/frontend/src/index.js" ]; then
bundle="$plugin_dir/frontend/src/index.js"
fi
if [ ! -f "$bundle" ]; then
if [ -f "$plugin_dir/frontend/package.json" ]; then
echo " ⚠️ $plugin_id: bundle not built at $entry — run scripts/build.sh for packaged frontend smoke"
continue
fi
echo " ❌ $plugin_id: bundle not found at $entry"
BUNDLE_FAILED=1
continue
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
VERSION="${1:-}"
if [[ -z "$VERSION" || ! "$VERSION" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then
echo "usage: $0 <version>" >&2
echo "example: $0 v0.1.0-alpha.1" >&2
exit 2
fi
if ! command -v zip >/dev/null; then
echo "zip is required to create the Windows plugin archive" >&2
exit 1
fi
echo "=== verstak official portable plugin packages $VERSION ==="
"$ROOT/scripts/build.sh"
"$ROOT/scripts/build-windows.sh"
RELEASE_ROOT="$ROOT/release"
LINUX_ARCHIVE="$RELEASE_ROOT/verstak-official-plugins-linux-amd64-$VERSION.tar.gz"
WINDOWS_ARCHIVE="$RELEASE_ROOT/verstak-official-plugins-windows-amd64-$VERSION.zip"
rm -rf "$RELEASE_ROOT"
mkdir -p "$RELEASE_ROOT"
# Each archive expands directly into the desktop application's plugins/
# directory. Frontends and manifests are shared; platform-test's native
# sidecar is built for the target operating system.
tar -C "$ROOT/dist" -czf "$LINUX_ARCHIVE" .
(cd "$ROOT/dist-windows" && zip -qr "$WINDOWS_ARCHIVE" .)
(cd "$RELEASE_ROOT" && sha256sum "$(basename "$LINUX_ARCHIVE")" "$(basename "$WINDOWS_ARCHIVE")" > SHA256SUMS)
echo "Linux plugins: $LINUX_ARCHIVE"
echo "Windows plugins: $WINDOWS_ARCHIVE"
echo "checksums: $RELEASE_ROOT/SHA256SUMS"
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
VERSION="${1:-}"
REPOSITORY="mirivlad/verstak-official-plugins"
RELEASE_SCRIPT="${VERSTAK_RELEASE_SCRIPT:-$ROOT/scripts/release.sh}"
RELEASE_DIR="${VERSTAK_RELEASE_DIR:-$ROOT/release}"
GIT_BIN="${GIT_BIN:-git}"
GH_BIN="${GH_BIN:-gh}"
cd "$ROOT"
if [[ -z "$VERSION" || ! "$VERSION" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then
echo "usage: $0 <version>" >&2
echo "example: $0 v0.1.0-alpha.1" >&2
exit 2
fi
if ! command -v "$GH_BIN" >/dev/null; then
echo "gh CLI is required to publish a GitHub Release" >&2
exit 1
fi
if [[ "$("$GIT_BIN" branch --show-current)" != "main" ]]; then
echo "GitHub releases must be published from main" >&2
exit 1
fi
if [[ -n "$("$GIT_BIN" status --porcelain)" ]]; then
echo "working tree must be clean before publishing a release" >&2
exit 1
fi
"$GH_BIN" auth status
"$GIT_BIN" fetch origin main --tags
HEAD="$("$GIT_BIN" rev-parse HEAD)"
if [[ "$HEAD" != "$("$GIT_BIN" rev-parse origin/main)" ]]; then
echo "local main must match origin/main before publishing a release" >&2
exit 1
fi
"$RELEASE_SCRIPT" "$VERSION"
ASSETS=(
"$RELEASE_DIR/verstak-official-plugins-linux-amd64-$VERSION.tar.gz"
"$RELEASE_DIR/verstak-official-plugins-windows-amd64-$VERSION.zip"
"$RELEASE_DIR/SHA256SUMS"
)
for asset in "${ASSETS[@]}"; do
if [[ ! -s "$asset" ]]; then
echo "required release asset is missing or empty: $asset" >&2
exit 1
fi
done
if "$GIT_BIN" rev-parse -q --verify "refs/tags/$VERSION" >/dev/null; then
if [[ "$("$GIT_BIN" rev-parse "${VERSION}^{commit}")" != "$HEAD" ]]; then
echo "existing tag $VERSION does not point at HEAD" >&2
exit 1
fi
else
"$GIT_BIN" tag -a "$VERSION" -m "Release $VERSION"
"$GIT_BIN" push origin "refs/tags/$VERSION"
fi
if "$GH_BIN" release view "$VERSION" --repo "$REPOSITORY" >/dev/null 2>&1; then
"$GH_BIN" release upload "$VERSION" "${ASSETS[@]}" --repo "$REPOSITORY" --clobber
else
RELEASE_OPTIONS=(--generate-notes --verify-tag)
if [[ "$VERSION" == *-* ]]; then
RELEASE_OPTIONS+=(--prerelease)
else
RELEASE_OPTIONS+=(--latest)
fi
"$GH_BIN" release create "$VERSION" "${ASSETS[@]}" \
--repo "$REPOSITORY" \
--title "Verstak Official Plugins $VERSION" \
"${RELEASE_OPTIONS[@]}"
fi
echo "GitHub release:"
"$GH_BIN" release view "$VERSION" --repo "$REPOSITORY" --json url --jq .url
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
VERSION="${1:-}"
if [[ -z "$VERSION" || ! "$VERSION" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then
echo "usage: $0 <version>" >&2
echo "example: $0 v0.1.0-alpha.1" >&2
exit 2
fi
echo "=== verstak official plugins release $VERSION ==="
"$ROOT/scripts/check.sh"
"$ROOT/scripts/package-portable.sh" "$VERSION"
+558
View File
@@ -0,0 +1,558 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const root = path.resolve(__dirname, '..');
const sourcePath = path.join(root, 'plugins', 'activity', 'frontend', 'src', 'index.js');
const manifestPath = path.join(root, 'plugins', 'activity', 'plugin.json');
const source = fs.readFileSync(sourcePath, 'utf8');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const WORKLOG_COMMAND_ID = 'verstak.activity.suggestWorklog';
class FakeNode {
constructor(tagName) {
this.tagName = String(tagName || '').toUpperCase();
this.children = [];
this.attributes = {};
this.listeners = {};
this.className = '';
this.value = '';
this.disabled = false;
this.parentNode = null;
this._textContent = '';
this._innerHTML = '';
}
appendChild(node) {
this.children.push(node);
node.parentNode = this;
return node;
}
setAttribute(name, value) {
this.attributes[name] = String(value);
}
getAttribute(name) {
return this.attributes[name];
}
addEventListener(type, handler) {
this.listeners[type] = this.listeners[type] || [];
this.listeners[type].push(handler);
}
dispatchEvent(type, event = {}) {
(this.listeners[type] || []).forEach((handler) => handler({ target: this, preventDefault() {}, stopPropagation() {}, ...event }));
}
click() {
this.dispatchEvent('click');
}
set innerHTML(value) {
this._innerHTML = String(value || '');
this.children = [];
}
get innerHTML() {
return this._innerHTML + this.children.map((child) => child.innerHTML).join('');
}
set textContent(value) {
this._textContent = String(value || '');
this.children = [];
}
get textContent() {
if (this.tagName === '#TEXT') return this._textContent;
return this._textContent + this.children.map((child) => child.textContent).join('');
}
}
function walk(node, fn) {
if (fn(node)) return node;
for (const child of node.children) {
const found = walk(child, fn);
if (found) return found;
}
return null;
}
function makeDocument() {
return {
body: new FakeNode('body'),
head: new FakeNode('head'),
createElement(tagName) {
return new FakeNode(tagName);
},
createTextNode(text) {
const node = new FakeNode('#text');
node.textContent = text;
return node;
},
getElementById() {
return null;
},
};
}
function loadComponent(document) {
const registry = {};
vm.runInNewContext(source, {
console,
Date,
Math,
document,
window: {
VerstakPluginRegister(pluginId, bundle) {
registry[pluginId] = bundle.components || {};
},
},
}, { filename: sourcePath });
const component = registry['verstak.activity'] && registry['verstak.activity'].ActivityView;
if (!component) throw new Error('ActivityView was not registered');
return component;
}
function makeApi(initialSettings = {}, initialData = {}) {
const settings = { ...initialSettings };
const data = { ...initialData };
const handlers = {};
const commandHandlers = new Map();
const unsubscribed = [];
return {
handlers,
commandHandlers,
unsubscribed,
settings: {
read: async (key) => (key ? settings[key] : { ...settings }),
write: async (key, value) => {
settings[key] = value;
return { ...settings };
},
},
storage: {
data: {
readNDJSON: async (name) => Array.isArray(data[name]) ? data[name].slice() : [],
writeNDJSON: async (name, records) => {
data[name] = Array.isArray(records) ? records.slice() : [];
},
},
},
events: {
subscribe: async (name, handler) => {
handlers[name] = handler;
return () => {
unsubscribed.push(name);
delete handlers[name];
};
},
},
commands: {
register: async (commandId, handler) => {
commandHandlers.set(commandId, handler);
return () => commandHandlers.delete(commandId);
},
},
storedEvents(key = 'events') {
return settings[key] || [];
},
storedData(name) {
return Array.isArray(data[name]) ? data[name] : [];
},
};
}
async function flush() {
for (let i = 0; i < 20; i += 1) await Promise.resolve();
}
async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, workspaceRootPath: 'Project' }, document = makeDocument()) {
const component = loadComponent(document);
const container = new FakeNode('div');
component.mount(container, props, api);
await flush();
return { component, container, document };
}
(async () => {
const api = makeApi();
const { component, container } = await mountWithApi(api);
const projectKey = 'events:workspace:Project';
const clientKey = 'events:workspace:ClientA';
const globalKey = 'events:global';
if (!manifest.permissions.includes('commands.register')) throw new Error('activity manifest must request commands.register');
const worklogCommand = (manifest.contributes.commands || []).find((item) => item.id === WORKLOG_COMMAND_ID);
if (!worklogCommand || worklogCommand.handler !== WORKLOG_COMMAND_ID) throw new Error('activity worklog suggestion command contribution is missing');
if (typeof api.commandHandlers.get(WORKLOG_COMMAND_ID) !== 'function') throw new Error('activity worklog suggestion command was not registered');
const activityProvider = (manifest.contributes.activityProviders || []).find((item) => item.id === 'verstak.activity.log');
if (!activityProvider || !activityProvider.events.includes('browser.capture.file')) throw new Error('activity provider must include browser.capture.file');
if (!activityProvider || !activityProvider.events.includes('browser.capture.converted')) throw new Error('activity provider must include browser.capture.converted');
for (const name of ['file.opened', 'file.changed', 'note.saved', 'action.started', 'browser.capture.received', 'case.selected', 'browser.capture.selection', 'browser.capture.file', 'browser.capture.converted']) {
if (typeof api.handlers[name] !== 'function') throw new Error(`${name} subscription missing`);
}
await api.settings.write(projectKey, [{
activityId: 'capture-1',
type: 'browser.capture.selection',
title: 'Example Article',
summary: 'Selected text',
occurredAt: '2026-06-27T00:00:00Z',
sourcePluginId: 'verstak.browser-inbox',
workspaceRootPath: 'Project',
payload: {
captureId: 'capture-1',
kind: 'selection',
title: 'Example Article',
url: 'https://example.com/article',
text: 'Selected text',
workspaceRootPath: 'Project',
},
}, {
activityId: 'note-1',
type: 'note.saved',
title: 'Saved note',
summary: 'Project/Notes/Case.md',
occurredAt: '2026-06-27T00:20:00Z',
sourcePluginId: 'verstak.notes',
workspaceRootPath: 'Project',
payload: {
title: 'Saved note',
path: 'Project/Notes/Case.md',
workspaceRootPath: 'Project',
},
}, {
activityId: 'capture-1:browser.capture.converted',
type: 'browser.capture.converted',
title: 'Example Article converted',
summary: 'Project/Notes/Example_Article.md',
occurredAt: '2026-06-27T00:30:00Z',
sourcePluginId: 'verstak.browser-inbox',
workspaceRootPath: 'Project',
payload: {
captureId: 'capture-1',
conversionType: 'note',
notePath: 'Project/Notes/Example_Article.md',
workspaceRootPath: 'Project',
title: 'Example Article converted',
url: 'https://example.com/article',
sourcePluginId: 'verstak.browser-inbox',
},
}]);
await api.handlers['browser.capture.selection']({
name: 'browser.capture.selection',
pluginId: 'verstak.browser-inbox',
timestamp: '2026-06-27T00:00:00Z',
payload: {
captureId: 'capture-1',
kind: 'selection',
title: 'Example Article',
url: 'https://example.com/article',
text: 'Selected text',
},
});
await api.handlers['browser.capture.converted']({
name: 'browser.capture.converted',
pluginId: 'verstak.browser-inbox',
timestamp: '2026-06-27T00:30:00Z',
payload: {
captureId: 'capture-1',
conversionType: 'note',
notePath: 'Project/Notes/Example_Article.md',
workspaceRootPath: 'Project',
title: 'Example Article converted',
url: 'https://example.com/article',
sourcePluginId: 'verstak.browser-inbox',
},
});
await flush();
const stored = api.storedEvents(projectKey);
if (stored.length !== 3) throw new Error(`expected three stored activity events, got ${stored.length}`);
if (stored[0].type !== 'browser.capture.selection') throw new Error('stored event type mismatch');
if (stored[0].sourcePluginId !== 'verstak.browser-inbox') throw new Error('stored event source plugin mismatch');
if (!stored.some((event) => event.type === 'browser.capture.converted' && event.title === 'Example Article converted')) {
throw new Error('conversion activity event was not stored');
}
if (api.storedEvents(globalKey).length !== 0) throw new Error('workspace activity leaked into global storage');
if (!container.textContent.includes('Example Article')) throw new Error('browser capture title was not rendered');
if (!container.textContent.includes('Selection captured')) throw new Error('browser capture label was not rendered');
if (!container.textContent.includes('Capture converted')) throw new Error('conversion label was not rendered');
if (container.textContent.includes('browser.capture.selection') || container.textContent.includes('browser.capture.converted') || container.textContent.includes('verstak.browser-inbox')) {
throw new Error('Activity must not expose technical event or plugin identifiers in its UI');
}
if (!container.textContent.includes('Possible journal entries')) throw new Error('work session candidate section was not rendered');
if (container.textContent.includes('Project work on 2026-06-27')) throw new Error('candidate must not invent a worklog title');
const candidateNode = walk(container, (node) => node.getAttribute && node.getAttribute('data-work-session-candidate'));
if (!candidateNode) throw new Error('work session candidate data attribute was not rendered');
if (!candidateNode.textContent.includes('Deal: Project')) throw new Error('candidate Deal was not rendered');
if (!candidateNode.textContent.includes('Estimated duration: 20 min')) throw new Error('candidate duration was not rendered');
if (!candidateNode.textContent.includes('Activities: 3')) throw new Error('candidate activity count was not rendered');
if (!walk(candidateNode, (node) => node.getAttribute && node.getAttribute('data-work-session-action') === 'review')) throw new Error('candidate review action was not rendered');
if (!walk(candidateNode, (node) => node.getAttribute && node.getAttribute('data-work-session-action') === 'dismiss')) throw new Error('candidate dismiss action was not rendered');
const commandResult = await api.commandHandlers.get(WORKLOG_COMMAND_ID)({ workspaceRootPath: 'Project' });
const candidates = commandResult && commandResult.candidates;
if (!Array.isArray(candidates) || candidates.length !== 1) throw new Error('work session command returned unexpected candidates');
const candidate = candidates[0];
if (!candidate.candidateId) throw new Error('candidate id is missing');
if (candidate.workspaceRootPath !== 'Project') throw new Error('candidate workspace mismatch');
if (candidate.startedAt !== '2026-06-27T00:00:00.000Z' || candidate.endedAt !== '2026-06-27T00:30:00.000Z') throw new Error('candidate range mismatch');
if (candidate.estimatedMinutes !== 20) throw new Error(`expected 20 candidate minutes, got ${candidate.estimatedMinutes}`);
if (candidate.activityCount !== 3) throw new Error(`expected three candidate activities, got ${candidate.activityCount}`);
if (candidate.activityIds.join(',') !== 'capture-1,note-1,capture-1:browser.capture.converted') throw new Error('candidate activity ids mismatch');
if (!Array.isArray(candidate.activities) || candidate.activities.length !== 3) throw new Error('candidate activity list is missing');
if ('title' in candidate || 'summary' in candidate || 'description' in candidate) throw new Error('candidate contains non-factual journal fields');
const cachedCandidates = api.storedEvents('work-session-candidates:workspace:Project');
if (!Array.isArray(cachedCandidates) || cachedCandidates.length !== 1 || cachedCandidates[0].candidateId !== candidate.candidateId) {
throw new Error('candidate cache was not persisted for Overview');
}
const clientView = await mountWithApi(api, { workspaceNode: { name: 'ClientA' }, workspaceRootPath: 'ClientA' });
if (clientView.container.textContent.includes('Example Article')) throw new Error('Project activity leaked into ClientA workspace view');
await api.settings.write(clientKey, [{
activityId: 'client-note',
type: 'note.saved',
title: 'Client note',
summary: 'ClientA/Notes/Client.md',
occurredAt: '2026-06-27T00:10:00Z',
sourcePluginId: 'verstak.notes',
workspaceRootPath: 'ClientA',
payload: {
title: 'Client note',
path: 'ClientA/Notes/Client.md',
workspaceRootPath: 'ClientA',
},
}]);
await api.handlers['note.saved']({
name: 'note.saved',
pluginId: 'verstak.notes',
timestamp: '2026-06-27T00:10:00Z',
payload: {
title: 'Client note',
path: 'ClientA/Notes/Client.md',
workspaceRootPath: 'ClientA',
},
});
await flush();
if (api.storedEvents(clientKey).length !== 1) throw new Error('ClientA activity was not stored under ClientA workspace key');
if (!clientView.container.textContent.includes('Client note')) throw new Error('ClientA activity was not rendered');
if (clientView.container.textContent.includes('Possible journal entries')) throw new Error('a single activity must not create a work session candidate');
component.unmount && component.unmount(clientView.container);
const globalView = await mountWithApi(api, {});
if (!globalView.container.textContent.includes('Example Article')) throw new Error('global activity did not aggregate Project activity');
if (!globalView.container.textContent.includes('Client note')) throw new Error('global activity did not aggregate ClientA activity');
if (!globalView.container.textContent.includes('Possible journal entries')) throw new Error('global activity did not render work session candidates');
component.unmount && component.unmount(globalView.container);
const dismissButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-work-session-action') === 'dismiss');
if (!dismissButton) throw new Error('candidate dismiss action was not available');
dismissButton.click();
await flush();
if (container.textContent.includes('Possible journal entries')) throw new Error('dismiss action did not remove the candidate from Activity');
if (api.storedEvents('work-session-candidates:workspace:Project').length !== 0) throw new Error('dismiss action did not update the candidate cache');
const manualButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-activity-action') === 'manual');
if (manualButton) throw new Error('manual activity button should not be rendered');
const clearButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-activity-action') === 'clear');
if (!clearButton) throw new Error('clear activity button not found');
clearButton.click();
await flush();
if (api.storedEvents(projectKey).length === 0) throw new Error('clear action removed activity before confirmation');
const clearConfirmation = walk(container, (node) => node.getAttribute && node.getAttribute('data-activity-clear-confirmation') === '');
if (!clearConfirmation) throw new Error('clear activity confirmation was not rendered');
const confirmClear = walk(clearConfirmation, (node) => node.getAttribute && node.getAttribute('data-activity-clear-confirm') === '');
if (!confirmClear) throw new Error('clear activity confirmation button was not rendered');
confirmClear.click();
await flush();
if (api.storedEvents(projectKey).length !== 0) throw new Error('clear action did not remove activity events');
if (api.storedEvents('work-session-candidates:workspace:Project').length !== 0) throw new Error('clear action did not remove cached candidates');
component.unmount && component.unmount(container);
if (api.unsubscribed.length !== 39) throw new Error(`expected 39 unsubscribers, got ${api.unsubscribed.length}`);
const persistedApi = makeApi({
'events:workspace:Project': [{
activityId: 'persisted-1',
type: 'note.saved',
title: 'Saved note',
summary: 'Notes/Case.md',
occurredAt: '2026-06-27T01:00:00Z',
sourcePluginId: 'verstak.notes',
}, {
activityId: 'persisted-open-1',
type: 'file.opened',
title: 'Selected file',
summary: 'Project/Notes/Case.md',
occurredAt: '2026-06-27T01:05:00Z',
sourcePluginId: 'verstak.files',
}],
});
const persisted = await mountWithApi(persistedApi);
if (!persisted.container.textContent.includes('Saved note')) throw new Error('persisted activity was not rendered');
if (!persisted.container.textContent.includes('Selected file')) throw new Error('raw Activity log must retain low-value technical events');
const legacyApi = makeApi({
events: [
{
activityId: 'legacy-global',
type: 'browser.capture.page',
title: 'Legacy global capture',
occurredAt: '2026-06-27T02:00:00Z',
sourcePluginId: 'verstak.browser-inbox',
},
{
activityId: 'legacy-project',
type: 'note.saved',
title: 'Legacy project note',
occurredAt: '2026-06-27T02:10:00Z',
sourcePluginId: 'verstak.notes',
payload: { path: 'Project/Notes/Legacy.md' },
},
],
});
const legacyGlobal = await mountWithApi(legacyApi, {});
if (!legacyGlobal.container.textContent.includes('Legacy global capture')) throw new Error('legacy global activity was not rendered in global view');
if (!legacyGlobal.container.textContent.includes('Legacy project note')) throw new Error('legacy workspace activity was not rendered in global view');
component.unmount && component.unmount(legacyGlobal.container);
const legacyProject = await mountWithApi(legacyApi, { workspaceNode: { name: 'Project' }, workspaceRootPath: 'Project' });
if (!legacyProject.container.textContent.includes('Legacy project note')) throw new Error('legacy workspace activity was not rendered in matching workspace');
if (legacyProject.container.textContent.includes('Legacy global capture')) throw new Error('legacy global activity leaked into workspace view');
const taggedGlobalApi = makeApi({
'events:global': [
{
activityId: 'global-project',
type: 'browser.capture.page',
title: 'Global project capture',
occurredAt: '2026-06-27T03:00:00Z',
sourcePluginId: 'verstak.browser-inbox',
workspaceRootPath: 'Project',
},
],
});
const taggedGlobalProject = await mountWithApi(taggedGlobalApi, { workspaceNode: { name: 'Project' }, workspaceRootPath: 'Project' });
if (!taggedGlobalProject.container.textContent.includes('Global project capture')) throw new Error('workspace did not render workspace-tagged global activity');
const taggedGlobalClient = await mountWithApi(taggedGlobalApi, { workspaceNode: { name: 'ClientA' }, workspaceRootPath: 'ClientA' });
if (taggedGlobalClient.container.textContent.includes('Global project capture')) throw new Error('workspace-tagged global activity leaked into another workspace');
const sessionsApi = makeApi({
'events:workspace:Project': [
{ activityId: 'project-first', type: 'note.saved', occurredAt: '2026-06-27T00:00:00Z', workspaceRootPath: 'Project' },
{ activityId: 'project-second', type: 'file.changed', occurredAt: '2026-06-27T00:10:00Z', workspaceRootPath: 'Project' },
{ activityId: 'project-after-switch-first', type: 'note.saved', occurredAt: '2026-06-27T00:24:00Z', workspaceRootPath: 'Project' },
{ activityId: 'project-after-switch-second', type: 'file.changed', occurredAt: '2026-06-27T00:34:00Z', workspaceRootPath: 'Project' },
{ activityId: 'project-after-idle-first', type: 'note.saved', occurredAt: '2026-06-27T01:10:00Z', workspaceRootPath: 'Project' },
{ activityId: 'project-after-idle-second', type: 'file.changed', occurredAt: '2026-06-27T01:22:00Z', workspaceRootPath: 'Project' },
],
'events:workspace:ClientA': [
{ activityId: 'client-first', type: 'note.saved', occurredAt: '2026-06-27T00:12:00Z', workspaceRootPath: 'ClientA' },
{ activityId: 'client-second', type: 'file.changed', occurredAt: '2026-06-27T00:22:00Z', workspaceRootPath: 'ClientA' },
],
});
const sessionView = await mountWithApi(sessionsApi, {});
const sessionResult = await sessionsApi.commandHandlers.get(WORKLOG_COMMAND_ID)({});
const sessionCandidates = sessionResult && sessionResult.candidates;
if (!Array.isArray(sessionCandidates) || sessionCandidates.length !== 4) throw new Error('workspace switches and idle gaps must split candidates');
const projectCandidates = sessionCandidates.filter((item) => item.workspaceRootPath === 'Project');
if (projectCandidates.length !== 3) throw new Error('workspace switch must close the prior Project candidate');
if (!projectCandidates.some((item) => item.activityIds.join(',') === 'project-after-idle-first,project-after-idle-second')) {
throw new Error('idle gap must start a separate Project candidate');
}
if (sessionCandidates.some((item) => 'title' in item || 'summary' in item || 'description' in item)) {
throw new Error('session candidates must contain only factual fields');
}
component.unmount && component.unmount(sessionView.container);
const lateApi = makeApi({
'events:workspace:Project': [
{ activityId: 'late-a', type: 'note.saved', occurredAt: '2026-07-12T10:00:00Z', workspaceRootPath: 'Project' },
{ activityId: 'late-b', type: 'file.changed', occurredAt: '2026-07-12T10:10:00Z', workspaceRootPath: 'Project' },
],
});
const lateView = await mountWithApi(lateApi);
const lateCommand = lateApi.commandHandlers.get(WORKLOG_COMMAND_ID);
const firstLateCandidate = (await lateCommand({ workspaceRootPath: 'Project' })).candidates[0];
if (!firstLateCandidate || !firstLateCandidate.sessionId || firstLateCandidate.estimatedMinutes !== 10) {
throw new Error('file activity duration must use the capped adjacent-event algorithm');
}
await lateApi.settings.write('events:workspace:Project', [
{ activityId: 'late-before', type: 'note.saved', occurredAt: '2026-07-12T09:55:00Z', workspaceRootPath: 'Project' },
{ activityId: 'late-a', type: 'note.saved', occurredAt: '2026-07-12T10:00:00Z', workspaceRootPath: 'Project' },
{ activityId: 'late-b', type: 'file.changed', occurredAt: '2026-07-12T10:10:00Z', workspaceRootPath: 'Project' },
]);
await lateApi.handlers['note.saved']({ name: 'note.saved', payload: {} });
await flush();
const afterLateCandidate = (await lateCommand({ workspaceRootPath: 'Project' })).candidates[0];
if (!afterLateCandidate || afterLateCandidate.sessionId !== firstLateCandidate.sessionId) {
throw new Error('late events changed an immutable session identity');
}
const dismissLate = walk(lateView.container, (node) => node.getAttribute && node.getAttribute('data-work-session-action') === 'dismiss');
dismissLate.click();
await flush();
await lateApi.settings.write('events:workspace:Project', [
{ activityId: 'late-before', type: 'note.saved', occurredAt: '2026-07-12T09:55:00Z', workspaceRootPath: 'Project' },
{ activityId: 'late-a', type: 'note.saved', occurredAt: '2026-07-12T10:00:00Z', workspaceRootPath: 'Project' },
{ activityId: 'late-b', type: 'file.changed', occurredAt: '2026-07-12T10:10:00Z', workspaceRootPath: 'Project' },
{ activityId: 'late-one', type: 'note.saved', occurredAt: '2026-07-12T10:15:00Z', workspaceRootPath: 'Project' },
]);
await lateApi.handlers['note.saved']({ name: 'note.saved', payload: {} });
await flush();
if ((await lateCommand({ workspaceRootPath: 'Project' })).candidates.length !== 0) {
throw new Error('dismissed session was re-offered without substantial new activity');
}
await lateApi.settings.write('events:workspace:Project', [
{ activityId: 'late-before', type: 'note.saved', occurredAt: '2026-07-12T09:55:00Z', workspaceRootPath: 'Project' },
{ activityId: 'late-a', type: 'note.saved', occurredAt: '2026-07-12T10:00:00Z', workspaceRootPath: 'Project' },
{ activityId: 'late-b', type: 'file.changed', occurredAt: '2026-07-12T10:10:00Z', workspaceRootPath: 'Project' },
{ activityId: 'late-one', type: 'note.saved', occurredAt: '2026-07-12T10:15:00Z', workspaceRootPath: 'Project' },
{ activityId: 'late-two', type: 'file.changed', occurredAt: '2026-07-12T10:25:00Z', workspaceRootPath: 'Project' },
]);
await lateApi.handlers['file.changed']({ name: 'file.changed', payload: {} });
await flush();
if ((await lateCommand({ workspaceRootPath: 'Project' })).candidates.length !== 1) {
throw new Error('dismissed session was not re-offered after substantial new activity');
}
component.unmount && component.unmount(lateView.container);
const rawApi = makeApi({}, {
'activity-events': [{
activityId: 'browser-domain:batch-1:0',
type: 'browser.activity.domain',
title: 'example.com',
summary: '5 min browser activity',
occurredAt: '2026-07-12T10:05:00Z',
sourcePluginId: 'verstak-browser-extension',
sourceBatchId: 'batch-1',
hostname: 'example.com',
durationSeconds: 300,
payload: { hostname: 'example.com', durationSeconds: 300 },
}],
});
const rawView = await mountWithApi(rawApi, {});
if (!rawView.container.textContent.includes('example.com')) throw new Error('append-only browser activity was not rendered');
const rawClear = walk(rawView.container, (node) => node.getAttribute && node.getAttribute('data-activity-action') === 'clear');
rawClear.click();
await flush();
if (rawApi.storedData('activity-events').length === 0) throw new Error('append-only activity was removed before confirmation');
const rawClearConfirmation = walk(rawView.container, (node) => node.getAttribute && node.getAttribute('data-activity-clear-confirmation') === '');
if (!rawClearConfirmation) throw new Error('append-only activity confirmation was not rendered');
const rawConfirmClear = walk(rawClearConfirmation, (node) => node.getAttribute && node.getAttribute('data-activity-clear-confirm') === '');
if (!rawConfirmClear) throw new Error('append-only activity confirmation button was not rendered');
rawConfirmClear.click();
await flush();
if (rawApi.storedData('activity-events').length !== 0) throw new Error('clear activity did not replace append-only data');
component.unmount && component.unmount(rawView.container);
console.log('activity plugin smoke passed');
})().catch((err) => {
console.error(err);
process.exit(1);
});
+993
View File
@@ -0,0 +1,993 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const root = path.resolve(__dirname, '..');
const sourcePath = path.join(root, 'plugins', 'browser-inbox', 'frontend', 'src', 'index.js');
const source = fs.readFileSync(sourcePath, 'utf8');
const catalogs = {
en: JSON.parse(fs.readFileSync(path.join(root, 'plugins', 'browser-inbox', 'locales', 'en.json'), 'utf8')),
ru: JSON.parse(fs.readFileSync(path.join(root, 'plugins', 'browser-inbox', 'locales', 'ru.json'), 'utf8')),
};
const technicalErrors = [];
class FakeNode {
constructor(tagName) {
this.tagName = String(tagName || '').toUpperCase();
this.children = [];
this.attributes = {};
this.listeners = {};
this.style = {};
this.className = '';
this.id = '';
this.value = '';
this.parentNode = null;
this._innerHTML = '';
this._textContent = '';
}
appendChild(node) {
if (!(node instanceof FakeNode)) throw new TypeError('appendChild expects FakeNode');
this.children.push(node);
node.parentNode = this;
return node;
}
removeChild(node) {
this.children = this.children.filter((child) => child !== node);
node.parentNode = null;
return node;
}
setAttribute(name, value) {
this.attributes[name] = String(value);
if (name === 'id') this.id = String(value);
}
getAttribute(name) {
return this.attributes[name];
}
addEventListener(type, handler) {
this.listeners[type] = this.listeners[type] || [];
this.listeners[type].push(handler);
}
dispatchEvent(type, event = {}) {
const handlers = this.listeners[type] || [];
handlers.forEach((handler) => handler({
stopPropagation() {},
preventDefault() {},
target: this,
...event,
}));
}
click() {
this.dispatchEvent('click');
}
set innerHTML(value) {
this._innerHTML = String(value || '');
this.children = [];
}
get innerHTML() {
return this._innerHTML;
}
set textContent(value) {
this._textContent = String(value || '');
this.children = [];
}
get textContent() {
if (this.tagName === '#TEXT') return this._textContent;
return this._textContent + this.children.map((child) => child.textContent).join('');
}
}
function walk(node, fn) {
if (fn(node)) return node;
for (const child of node.children) {
const found = walk(child, fn);
if (found) return found;
}
return null;
}
function makeDocument() {
return {
body: new FakeNode('body'),
head: new FakeNode('head'),
createElement(tagName) {
return new FakeNode(tagName);
},
createTextNode(text) {
const node = new FakeNode('#text');
node.textContent = text;
return node;
},
getElementById() {
return null;
},
};
}
function loadComponents(document) {
const registry = {};
const sandbox = {
console: {
...console,
warn(...args) {
technicalErrors.push(args.map((value) => String(value)).join(' '));
},
error(...args) {
technicalErrors.push(args.map((value) => String(value)).join(' '));
},
},
Date,
document,
window: {
VerstakPluginRegister(pluginId, bundle) {
registry[pluginId] = bundle.components || {};
},
},
};
sandbox.window.window = sandbox.window;
sandbox.window.document = document;
vm.runInNewContext(source, sandbox, { filename: sourcePath });
const components = registry['verstak.browser-inbox'];
if (!components) throw new Error('Browser Inbox components were not registered');
return components;
}
function loadComponent(document) {
const component = loadComponents(document).BrowserInboxView;
if (!component) throw new Error('BrowserInboxView was not registered');
return component;
}
function makeApi(initialSettings = {}, locale = 'en') {
const settings = { ...initialSettings };
const handlers = {};
const unsubscribed = [];
const fileWrites = [];
const fileByteWrites = [];
const openedURLs = [];
const publishedEvents = [];
const workspaceEntries = [
{ name: 'ClientA', relativePath: 'ClientA', type: 'folder' },
{ name: 'Project', relativePath: 'Project', type: 'folder' },
];
const receiverPairing = {
receiverUrl: 'http://127.0.0.1:47731/api/browser-inbox/v1/captures',
receiverToken: 'initial-browser-token',
};
let nextWriteError = null;
function translate(key, params, fallback) {
let value = catalogs[locale]?.[key] || catalogs.en[key] || fallback || key;
Object.entries(params || {}).forEach(([name, replacement]) => {
value = value.replace(new RegExp(`\\{${name}\\}`, 'g'), String(replacement));
});
return value;
}
function backendCaptures() {
const keys = ['captures:global', 'captures', ...Object.keys(settings).filter((key) => key.startsWith('captures:workspace:'))];
const seen = new Set();
const captures = [];
for (const key of keys) {
for (const original of Array.isArray(settings[key]) ? settings[key] : []) {
if (!original || !original.captureId || seen.has(original.captureId)) continue;
seen.add(original.captureId);
const capture = { ...original };
if (!capture.workspaceRootPath && key.startsWith('captures:workspace:')) {
capture.workspaceRootPath = decodeURIComponent(key.slice('captures:workspace:'.length));
capture.workspaceName = capture.workspaceRootPath;
}
captures.push(capture);
}
}
return { keys, captures };
}
function backendWriteCaptures(captures, keys) {
settings['captures:global'] = captures.slice(0, 100);
keys.forEach((key) => {
if (key !== 'captures:global') settings[key] = [];
});
}
function backendMutate(payload) {
const { keys, captures } = backendCaptures();
const ids = new Set([payload.captureId, ...(payload.captureIds || [])].filter(Boolean));
const next = captures.flatMap((capture) => {
if (!ids.has(capture.captureId)) return [capture];
if (payload.action === 'delete') return [];
if (payload.action === 'archive') return [{ ...capture, globalState: 'archived' }];
if (payload.action === 'restore') return [{ ...capture, globalState: 'inbox' }];
if (payload.action === 'assign') return [{ ...capture, workspaceRootPath: payload.workspaceRootPath || '', workspaceName: payload.workspaceRootPath || '' }];
if (payload.action === 'processed') return [{ ...capture, processed: payload.processed === true }];
return [capture];
});
backendWriteCaptures(next, keys);
}
function backendAppend(event) {
const { keys, captures } = backendCaptures();
const payload = { ...(event.payload || {}) };
const next = [payload, ...captures.filter((capture) => capture.captureId !== payload.captureId)];
backendWriteCaptures(next, keys);
}
return {
settings,
handlers,
unsubscribed,
fileWrites,
fileByteWrites,
openedURLs,
publishedEvents,
i18n: {
getLocale: () => locale,
t: translate,
onDidChangeLocale: () => () => {},
},
failNextWrite(message) {
nextWriteError = new Error(message || 'write failed');
},
events: {
publish: async (name, payload) => {
publishedEvents.push({ name, payload });
if (name === 'browser-inbox.storage.mutate') backendMutate(payload || {});
},
subscribe: async (name, handler) => {
handlers[name] = async (event) => {
if (name.startsWith('browser.capture.')) backendAppend(event);
return handler(event);
};
return () => {
unsubscribed.push(name);
delete handlers[name];
};
},
},
settings: {
read: async (key) => (key ? settings[key] : { ...settings }),
write: async (key, value) => {
settings[key] = value;
return { ...settings };
},
},
files: {
list: async () => workspaceEntries.map((entry) => ({ ...entry })),
writeText: async (relativePath, content, options = {}) => {
if (nextWriteError) {
const err = nextWriteError;
nextWriteError = null;
throw err;
}
fileWrites.push({ relativePath, content, options });
},
writeBytes: async (relativePath, dataBase64, options = {}) => {
if (nextWriteError) {
const err = nextWriteError;
nextWriteError = null;
throw err;
}
fileByteWrites.push({ relativePath, dataBase64, options });
},
openURL: async (url) => {
openedURLs.push(url);
},
},
browserReceiver: {
pairing: async () => ({ ...receiverPairing }),
rotateToken: async () => {
receiverPairing.receiverToken = 'rotated-browser-token';
return { ...receiverPairing };
},
},
getStoredCaptures(key = 'captures') {
return settings[key] || [];
},
};
}
async function flush() {
for (let i = 0; i < 16; i += 1) await Promise.resolve();
}
async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, workspaceRootPath: 'Project' }, document = makeDocument()) {
const component = loadComponent(document);
const container = new FakeNode('div');
component.mount(container, props, api);
await flush();
return { component, container, document };
}
async function mountSettingsWithApi(api, document = makeDocument()) {
const component = loadComponents(document).BrowserInboxSettings;
const container = new FakeNode('div');
component.mount(container, {}, api);
await flush();
return { component, container, document };
}
(async () => {
const settingsComponents = loadComponents(makeDocument());
if (!settingsComponents.BrowserInboxSettings) throw new Error('BrowserInboxSettings was not registered');
const styleDocument = makeDocument();
const styledView = await mountWithApi(makeApi(), {}, styleDocument);
const injectedStyles = styleDocument.head.children.map((node) => node.textContent).join('\n');
if (!injectedStyles.includes('.browser-inbox-select{') || !injectedStyles.includes('appearance:none')) {
throw new Error('Browser Inbox selects do not use the application select styling');
}
styledView.component.unmount && styledView.component.unmount(styledView.container);
const russianView = await mountWithApi(makeApi({}, 'ru'), {});
if (!russianView.container.textContent.includes('Браузер')) {
throw new Error('Browser Inbox does not use the localized Browser title');
}
if (!russianView.container.textContent.includes('Все Дела')) {
throw new Error('Browser Inbox does not use the localized Deal filter');
}
if (!russianView.container.textContent.includes('Пока нет материалов из браузера')) {
throw new Error('Browser Inbox empty state is not localized');
}
russianView.component.unmount && russianView.component.unmount(russianView.container);
const api = makeApi();
const settingsView = await mountSettingsWithApi(makeApi());
const receiverURLInput = walk(settingsView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-pairing-url') === '');
const receiverTokenInput = walk(settingsView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-pairing-token') === '');
if (!receiverURLInput || receiverURLInput.value !== 'http://127.0.0.1:47731/api/browser-inbox/v1/captures') {
throw new Error('Browser Inbox settings did not render receiver URL');
}
if (!receiverTokenInput || receiverTokenInput.value !== 'initial-browser-token') {
throw new Error('Browser Inbox settings did not render pairing token');
}
const rotateTokenButton = walk(settingsView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-settings-action') === 'rotate-token');
if (!rotateTokenButton) throw new Error('Browser Inbox settings rotate token action missing');
rotateTokenButton.click();
await flush();
if (receiverTokenInput.value !== 'rotated-browser-token') {
throw new Error('Browser Inbox settings did not update token after rotation');
}
settingsView.component.unmount && settingsView.component.unmount(settingsView.container);
const { component, container } = await mountWithApi(api);
for (const name of ['browser.capture.page', 'browser.capture.selection', 'browser.capture.link', 'browser.capture.file']) {
if (typeof api.handlers[name] !== 'function') throw new Error(`${name} subscription missing`);
}
await api.handlers['browser.capture.selection']({
name: 'browser.capture.selection',
timestamp: '2026-06-27T00:00:00Z',
payload: {
captureId: 'capture-1',
capturedAt: '2026-06-27T00:00:00.000Z',
kind: 'selection',
url: 'https://example.com/article',
title: 'Example Article',
domain: 'example.com',
text: 'Selected text from the page',
browserName: 'Firefox',
workspaceRootPath: 'Project',
},
});
await flush();
const projectKey = 'captures:workspace:Project';
const clientKey = 'captures:workspace:ClientA';
const globalKey = 'captures:global';
const captures = api.getStoredCaptures(globalKey);
if (captures.length !== 1) throw new Error(`expected one stored capture, got ${captures.length}`);
if (captures[0].captureId !== 'capture-1') throw new Error('stored capture id mismatch');
if (api.getStoredCaptures(projectKey).length !== 0) throw new Error('workspace capture was stored in a legacy workspace key');
const row = walk(container, (node) => node.getAttribute && node.getAttribute('data-browser-capture-id') === 'capture-1');
if (!row) throw new Error('capture row was not rendered');
if (!container.textContent.includes('Selected text from the page')) {
throw new Error('selection text was not rendered');
}
await api.handlers['browser.capture.selection']({
name: 'browser.capture.selection',
timestamp: '2026-06-27T00:00:00Z',
payload: {
captureId: 'capture-1',
capturedAt: '2026-06-27T00:00:00.000Z',
kind: 'selection',
url: 'https://example.com/article',
title: 'Example Article',
domain: 'example.com',
text: 'Duplicate selected text',
workspaceRootPath: 'Project',
},
});
await flush();
if (api.getStoredCaptures(globalKey).length !== 1) throw new Error('duplicate capture was stored');
const clientView = await mountWithApi(api, { workspaceNode: { name: 'ClientA' }, workspaceRootPath: 'ClientA' });
if (walk(clientView.container, (node) => node.getAttribute && node.getAttribute('data-browser-capture-id') === 'capture-1')) {
throw new Error('Project capture leaked into ClientA workspace view');
}
await api.handlers['browser.capture.page']({
name: 'browser.capture.page',
timestamp: '2026-06-27T00:10:00Z',
payload: {
captureId: 'capture-2',
capturedAt: '2026-06-27T00:10:00.000Z',
kind: 'page',
url: 'https://client.example.com/',
title: 'Client Page',
domain: 'client.example.com',
workspaceRootPath: 'ClientA',
},
});
await flush();
if (!api.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'capture-2')) {
throw new Error('ClientA capture was not stored in the global queue');
}
if (!walk(clientView.container, (node) => node.getAttribute && node.getAttribute('data-browser-capture-id') === 'capture-2')) {
throw new Error('ClientA capture was not rendered');
}
component.unmount && component.unmount(clientView.container);
const globalView = await mountWithApi(api, {});
if (!walk(globalView.container, (node) => node.getAttribute && node.getAttribute('data-browser-capture-id') === 'capture-1')) {
throw new Error('global browser inbox did not aggregate Project capture');
}
if (!walk(globalView.container, (node) => node.getAttribute && node.getAttribute('data-browser-capture-id') === 'capture-2')) {
throw new Error('global browser inbox did not aggregate ClientA capture');
}
await api.handlers['browser.capture.page']({
name: 'browser.capture.page',
timestamp: '2026-06-27T00:20:00Z',
payload: {
captureId: 'capture-unassigned',
capturedAt: '2026-06-27T00:20:00.000Z',
kind: 'page',
url: 'https://inbox.example.com/unassigned',
title: 'Unassigned Page',
domain: 'inbox.example.com',
},
});
await flush();
if (!api.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'capture-unassigned' && !capture.workspaceRootPath)) {
throw new Error('untagged receiver event was not retained as unassigned');
}
const unassignedWorkspace = await mountWithApi(api, { workspaceNode: { name: 'Project' }, workspaceRootPath: 'Project' });
if (walk(unassignedWorkspace.container, (node) => node.getAttribute && node.getAttribute('data-browser-capture-id') === 'capture-unassigned')) {
throw new Error('unassigned capture leaked into a workspace inbox');
}
component.unmount && component.unmount(unassignedWorkspace.container);
component.unmount && component.unmount(globalView.container);
const clearButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-action') === 'clear');
if (!clearButton) throw new Error('clear button not found');
clearButton.click();
await flush();
if (!api.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'capture-1' && capture.globalState === 'archived')) {
throw new Error('workspace clear action did not archive the Project capture');
}
if (!api.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'capture-2')) {
throw new Error('workspace clear action removed a capture from another workspace');
}
component.unmount && component.unmount(container);
if (api.unsubscribed.length !== 16) throw new Error('component did not unsubscribe all capture handlers');
const persistedApi = makeApi({ 'captures:workspace:Project': [captures[0]] });
const persisted = await mountWithApi(persistedApi);
if (!walk(persisted.container, (node) => node.getAttribute && node.getAttribute('data-browser-capture-id') === 'capture-1')) {
throw new Error('persisted capture was not rendered on mount');
}
const legacyApi = makeApi({
captures: [
{
captureId: 'legacy-global-capture',
capturedAt: '2026-06-27T02:00:00.000Z',
kind: 'page',
url: 'https://legacy.example.com/',
title: 'Legacy Global Capture',
domain: 'legacy.example.com',
},
{
captureId: 'legacy-project-capture',
capturedAt: '2026-06-27T02:10:00.000Z',
kind: 'page',
url: 'https://project.example.com/',
title: 'Legacy Project Capture',
domain: 'project.example.com',
workspaceRootPath: 'Project',
},
],
});
const legacyGlobal = await mountWithApi(legacyApi, {});
if (!walk(legacyGlobal.container, (node) => node.getAttribute && node.getAttribute('data-browser-capture-id') === 'legacy-global-capture')) {
throw new Error('legacy global capture was not rendered in global view');
}
if (!walk(legacyGlobal.container, (node) => node.getAttribute && node.getAttribute('data-browser-capture-id') === 'legacy-project-capture')) {
throw new Error('legacy workspace capture was not rendered in global view');
}
if (legacyApi.getStoredCaptures('captures').length !== 0) {
throw new Error('legacy captures were not removed after canonical migration');
}
component.unmount && component.unmount(legacyGlobal.container);
const legacyProject = await mountWithApi(legacyApi, { workspaceNode: { name: 'Project' }, workspaceRootPath: 'Project' });
if (!walk(legacyProject.container, (node) => node.getAttribute && node.getAttribute('data-browser-capture-id') === 'legacy-project-capture')) {
throw new Error('legacy workspace capture was not rendered in matching workspace');
}
if (walk(legacyProject.container, (node) => node.getAttribute && node.getAttribute('data-browser-capture-id') === 'legacy-global-capture')) {
throw new Error('legacy global capture leaked into workspace view');
}
const taggedGlobalApi = makeApi({
'captures:global': [
{
captureId: 'global-project-capture',
capturedAt: '2026-06-27T03:00:00.000Z',
kind: 'page',
url: 'https://project.example.com/global',
title: 'Global Project Capture',
domain: 'project.example.com',
workspaceRootPath: 'Project',
},
],
});
const taggedGlobalProject = await mountWithApi(taggedGlobalApi, { workspaceNode: { name: 'Project' }, workspaceRootPath: 'Project' });
if (!walk(taggedGlobalProject.container, (node) => node.getAttribute && node.getAttribute('data-browser-capture-id') === 'global-project-capture')) {
throw new Error('workspace did not render workspace-tagged global capture');
}
const taggedGlobalClient = await mountWithApi(taggedGlobalApi, { workspaceNode: { name: 'ClientA' }, workspaceRootPath: 'ClientA' });
if (walk(taggedGlobalClient.container, (node) => node.getAttribute && node.getAttribute('data-browser-capture-id') === 'global-project-capture')) {
throw new Error('workspace-tagged global capture leaked into another workspace');
}
const bindingApi = makeApi({
domainBindings: {
'client.example.com': 'ClientA',
'project.example.com': 'Project',
},
});
const bindingGlobal = await mountWithApi(bindingApi, {});
await bindingApi.handlers['browser.capture.page']({
name: 'browser.capture.page',
timestamp: '2026-06-29T00:00:00Z',
payload: {
captureId: 'bound-client-capture',
capturedAt: '2026-06-29T00:00:00.000Z',
kind: 'page',
url: 'https://client.example.com/page',
title: 'Bound Client Page',
domain: 'client.example.com',
},
});
await flush();
if (!bindingApi.getStoredCaptures('captures:global').some((capture) => capture.captureId === 'bound-client-capture' && capture.workspaceRootPath === 'ClientA')) {
throw new Error('domain-bound capture was not stored with its ClientA assignment');
}
const bindingClient = await mountWithApi(bindingApi, { workspaceNode: { name: 'ClientA' }, workspaceRootPath: 'ClientA' });
if (!walk(bindingClient.container, (node) => node.getAttribute && node.getAttribute('data-browser-capture-id') === 'bound-client-capture')) {
throw new Error('domain-bound capture was not rendered in bound workspace');
}
const bindingAggregate = await mountWithApi(bindingApi, {});
if (!walk(bindingAggregate.container, (node) => node.getAttribute && node.getAttribute('data-browser-capture-id') === 'bound-client-capture')) {
throw new Error('global browser inbox did not aggregate domain-bound capture');
}
await bindingApi.handlers['browser.capture.page']({
name: 'browser.capture.page',
timestamp: '2026-06-29T00:10:00Z',
payload: {
captureId: 'explicit-project-capture',
capturedAt: '2026-06-29T00:10:00.000Z',
kind: 'page',
url: 'https://client.example.com/explicit',
title: 'Explicit Project Page',
domain: 'client.example.com',
workspaceRootPath: 'Project',
},
});
await flush();
if (!bindingApi.getStoredCaptures('captures:global').some((capture) => capture.captureId === 'explicit-project-capture' && capture.workspaceRootPath === 'Project')) {
throw new Error('explicit workspace capture was not stored with its payload workspace');
}
if (bindingApi.getStoredCaptures('captures:global').some((capture) => capture.captureId === 'explicit-project-capture' && capture.workspaceRootPath === 'ClientA')) {
throw new Error('domain binding overrode explicit workspaceRootPath');
}
component.unmount && component.unmount(bindingGlobal.container);
component.unmount && component.unmount(bindingClient.container);
component.unmount && component.unmount(bindingAggregate.container);
const assignmentApi = makeApi({
'captures:global': [
{
captureId: 'assignment-unassigned',
capturedAt: '2026-06-29T00:20:00.000Z',
kind: 'page',
url: 'https://inbox.example.com/unassigned',
title: 'Unassigned capture',
domain: 'inbox.example.com',
},
{
captureId: 'assignment-client-processed',
capturedAt: '2026-06-29T00:10:00.000Z',
kind: 'page',
url: 'https://inbox.example.com/client',
title: 'Processed client capture',
domain: 'inbox.example.com',
workspaceRootPath: 'ClientA',
processed: true,
},
],
});
const assignmentView = await mountWithApi(assignmentApi, {});
const statusFilter = walk(assignmentView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-filter') === 'status');
if (!statusFilter) throw new Error('global status filter was not rendered');
statusFilter.value = 'unassigned';
statusFilter.dispatchEvent('change');
await flush();
if (!walk(assignmentView.container, (node) => node.getAttribute && node.getAttribute('data-browser-capture-id') === 'assignment-unassigned')) {
throw new Error('unassigned filter did not render the unassigned capture');
}
if (walk(assignmentView.container, (node) => node.getAttribute && node.getAttribute('data-browser-capture-id') === 'assignment-client-processed')) {
throw new Error('unassigned filter leaked an assigned capture');
}
statusFilter.value = 'all';
statusFilter.dispatchEvent('change');
await flush();
const assignmentSelect = walk(assignmentView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-assignment') === 'assignment-unassigned');
if (!assignmentSelect) throw new Error('workspace assignment control was not rendered');
assignmentSelect.value = 'ClientA';
assignmentSelect.dispatchEvent('change');
await flush();
if (!assignmentApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'assignment-unassigned' && capture.workspaceRootPath === 'ClientA')) {
throw new Error('assignment did not persist ClientA workspace root path');
}
const reassignmentSelect = walk(assignmentView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-assignment') === 'assignment-unassigned');
reassignmentSelect.value = 'Project';
reassignmentSelect.dispatchEvent('change');
await flush();
if (!assignmentApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'assignment-unassigned' && capture.workspaceRootPath === 'Project')) {
throw new Error('reassignment did not persist Project workspace root path');
}
const clearAssignmentButton = walk(assignmentView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-action') === 'clear-assignment');
if (!clearAssignmentButton) throw new Error('clear assignment action was not rendered');
clearAssignmentButton.click();
await flush();
if (!assignmentApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'assignment-unassigned' && !capture.workspaceRootPath)) {
throw new Error('clear assignment did not make the capture unassigned');
}
const processedButton = walk(assignmentView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-action') === 'toggle-processed');
if (!processedButton) throw new Error('processed state action was not rendered');
processedButton.click();
await flush();
if (!assignmentApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'assignment-unassigned' && capture.processed === true)) {
throw new Error('mark processed did not persist state');
}
const unprocessedButton = walk(assignmentView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-action') === 'toggle-processed');
unprocessedButton.click();
await flush();
if (!assignmentApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'assignment-unassigned' && capture.processed === false)) {
throw new Error('mark unprocessed did not persist state');
}
const deleteButton = walk(assignmentView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-action') === 'delete-permanently');
if (!deleteButton) throw new Error('permanent delete action was not rendered');
deleteButton.click();
await flush();
if (assignmentApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'assignment-unassigned')) {
throw new Error('delete did not remove the capture from global storage');
}
component.unmount && component.unmount(assignmentView.container);
const archiveApi = makeApi({
'captures:global': [{
captureId: 'archived-capture',
capturedAt: '2026-07-12T10:00:00.000Z',
kind: 'page',
url: 'https://example.com/archive',
title: 'Archived capture',
workspaceRootPath: 'Project',
globalState: 'archived',
}],
});
const archiveView = await mountWithApi(archiveApi, {});
if (walk(archiveView.container, (node) => node.getAttribute && node.getAttribute('data-browser-capture-id') === 'archived-capture')) {
throw new Error('archived capture leaked into the active inbox');
}
const archiveFilter = walk(archiveView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-filter') === 'status');
archiveFilter.value = 'archived';
archiveFilter.dispatchEvent('change');
await flush();
if (!walk(archiveView.container, (node) => node.getAttribute && node.getAttribute('data-browser-capture-id') === 'archived-capture')) {
throw new Error('archive filter did not reveal archived capture');
}
const restoreButton = walk(archiveView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-action') === 'restore');
if (!restoreButton) throw new Error('restore archived capture action was not rendered');
restoreButton.click();
await flush();
if (!archiveApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'archived-capture' && capture.globalState === 'inbox')) {
throw new Error('restore archived capture did not return it to Inbox');
}
component.unmount && component.unmount(archiveView.container);
const conversionApi = makeApi({
'captures:workspace:Project': [{
captureId: 'convert-selection',
capturedAt: '2026-06-29T01:00:00.000Z',
kind: 'selection',
url: 'https://example.com/article',
title: 'Example Article',
domain: 'example.com',
text: 'Selected text from the page',
workspaceRootPath: 'Project',
workspaceName: 'Project',
}],
});
const conversionView = await mountWithApi(conversionApi);
const createNoteButton = walk(conversionView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-action') === 'create-note');
if (!createNoteButton) throw new Error('create note button was not rendered');
createNoteButton.click();
await flush();
if (conversionApi.fileWrites.length !== 1) throw new Error(`expected one note write, got ${conversionApi.fileWrites.length}`);
const noteWrite = conversionApi.fileWrites[0];
if (noteWrite.relativePath !== 'Project/Notes/Example_Article.md') {
throw new Error(`note path mismatch: ${noteWrite.relativePath}`);
}
if (noteWrite.options.createIfMissing !== true || noteWrite.options.overwrite !== false) {
throw new Error(`note write options mismatch: ${JSON.stringify(noteWrite.options)}`);
}
if (!noteWrite.content.includes('# Example Article')) throw new Error('note content missing heading');
if (!noteWrite.content.includes('Source: https://example.com/article')) throw new Error('note content missing source URL');
if (!noteWrite.content.includes('Selected text from the page')) throw new Error('note content missing selected text');
if (!conversionApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'convert-selection' && capture.globalState === 'archived')) {
throw new Error('converted capture was not archived');
}
const convertedEvent = conversionApi.publishedEvents.find((event) => event.name === 'browser.capture.converted');
if (!convertedEvent) throw new Error('browser.capture.converted event was not published');
if (convertedEvent.payload.conversionType !== 'note') throw new Error('converted event conversionType mismatch');
if (convertedEvent.payload.notePath !== 'Project/Notes/Example_Article.md') throw new Error('converted event notePath mismatch');
component.unmount && component.unmount(conversionView.container);
const failedConversionApi = makeApi({
'captures:workspace:Project': [{
captureId: 'convert-conflict',
capturedAt: '2026-06-29T01:10:00.000Z',
kind: 'page',
url: 'https://example.com/existing',
title: 'Existing Article',
domain: 'example.com',
workspaceRootPath: 'Project',
workspaceName: 'Project',
}],
});
const failedConversionView = await mountWithApi(failedConversionApi);
const failedCreateNoteButton = walk(failedConversionView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-action') === 'create-note');
if (!failedCreateNoteButton) throw new Error('create note button for failed conversion was not rendered');
failedConversionApi.failNextWrite('file already exists');
failedCreateNoteButton.click();
await flush();
if (!failedConversionApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'convert-conflict')) {
throw new Error('failed conversion removed capture from queue');
}
if (!failedConversionView.container.textContent.includes('Could not create the note. Please try again.')) {
throw new Error('failed conversion did not render an error status');
}
if (failedConversionView.container.textContent.includes('file already exists')) {
throw new Error('failed conversion exposed a raw backend error');
}
if (failedConversionApi.publishedEvents.some((event) => event.name === 'browser.capture.converted')) {
throw new Error('failed conversion published converted event');
}
component.unmount && component.unmount(failedConversionView.container);
const linkConversionApi = makeApi({
'captures:workspace:Project': [{
captureId: 'convert-link',
capturedAt: '2026-06-29T01:20:00.000Z',
kind: 'link',
url: 'https://example.com/article',
title: 'Example Article',
domain: 'example.com',
workspaceRootPath: 'Project',
workspaceName: 'Project',
}],
});
const linkConversionView = await mountWithApi(linkConversionApi);
const createLinkButton = walk(linkConversionView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-action') === 'create-link');
if (!createLinkButton) throw new Error('create link button was not rendered');
const openLinkButton = walk(linkConversionView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-action') === 'open-link');
if (!openLinkButton) throw new Error('open link action was not rendered');
openLinkButton.click();
await flush();
if (linkConversionApi.openedURLs.join(',') !== 'https://example.com/article') throw new Error('open link did not use browser URL capability');
createLinkButton.click();
await flush();
if (linkConversionApi.fileWrites.length !== 1) throw new Error(`expected one link write, got ${linkConversionApi.fileWrites.length}`);
const linkWrite = linkConversionApi.fileWrites[0];
if (linkWrite.relativePath !== 'Project/Links/Example_Article.url') {
throw new Error(`link path mismatch: ${linkWrite.relativePath}`);
}
if (linkWrite.options.createIfMissing !== true || linkWrite.options.overwrite !== false) {
throw new Error(`link write options mismatch: ${JSON.stringify(linkWrite.options)}`);
}
if (!linkWrite.content.includes('[InternetShortcut]')) throw new Error('link content missing InternetShortcut header');
if (!linkWrite.content.includes('URL=https://example.com/article')) throw new Error('link content missing URL');
if (!linkConversionApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'convert-link' && capture.globalState === 'archived')) {
throw new Error('converted link capture was not archived');
}
const convertedLinkEvent = linkConversionApi.publishedEvents.find((event) => event.name === 'browser.capture.converted');
if (!convertedLinkEvent) throw new Error('browser.capture.converted link event was not published');
if (convertedLinkEvent.payload.conversionType !== 'link') throw new Error('converted link event conversionType mismatch');
if (convertedLinkEvent.payload.linkPath !== 'Project/Links/Example_Article.url') throw new Error('converted link event linkPath mismatch');
component.unmount && component.unmount(linkConversionView.container);
const collisionLinkApi = makeApi({
'captures:workspace:Project': [{
captureId: 'convert-link-collision',
capturedAt: '2026-06-29T01:25:00.000Z',
kind: 'link',
url: 'https://example.com/collision',
title: 'Example Article',
workspaceRootPath: 'Project',
}],
});
collisionLinkApi.failNextWrite('conflict: Project/Links/Example_Article.url');
const collisionLinkView = await mountWithApi(collisionLinkApi);
walk(collisionLinkView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-action') === 'create-link').click();
await flush();
if (collisionLinkApi.fileWrites.length !== 1 || collisionLinkApi.fileWrites[0].relativePath !== 'Project/Links/Example_Article (2).url') {
throw new Error('link filename collision did not use the numbered suffix');
}
component.unmount && component.unmount(collisionLinkView.container);
const failedLinkApi = makeApi({
'captures:workspace:Project': [{
captureId: 'convert-link-conflict',
capturedAt: '2026-06-29T01:30:00.000Z',
kind: 'link',
url: 'https://example.com/existing-link',
title: 'Existing Link',
domain: 'example.com',
workspaceRootPath: 'Project',
workspaceName: 'Project',
}],
});
const failedLinkView = await mountWithApi(failedLinkApi);
const failedCreateLinkButton = walk(failedLinkView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-action') === 'create-link');
if (!failedCreateLinkButton) throw new Error('create link button for failed conversion was not rendered');
failedLinkApi.failNextWrite('link already exists');
failedCreateLinkButton.click();
await flush();
if (!failedLinkApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'convert-link-conflict')) {
throw new Error('failed link conversion removed capture from queue');
}
if (!failedLinkView.container.textContent.includes('Could not create the link. Please try again.')) {
throw new Error('failed link conversion did not render an error status');
}
if (failedLinkApi.publishedEvents.some((event) => event.name === 'browser.capture.converted')) {
throw new Error('failed link conversion published converted event');
}
component.unmount && component.unmount(failedLinkView.container);
const fileConversionApi = makeApi({
'captures:workspace:Project': [{
captureId: 'convert-file',
capturedAt: '2026-06-29T02:20:00.000Z',
kind: 'file',
url: 'https://example.com/files',
title: 'Example Files',
domain: 'example.com',
fileName: 'notes.txt',
fileMime: 'text/plain',
fileSize: 11,
fileText: 'hello file',
workspaceRootPath: 'Project',
workspaceName: 'Project',
}],
});
const fileConversionView = await mountWithApi(fileConversionApi);
const createFileButton = walk(fileConversionView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-action') === 'create-file');
if (!createFileButton) throw new Error('create file button was not rendered');
createFileButton.click();
await flush();
if (fileConversionApi.fileWrites.length !== 1) throw new Error(`expected one file write, got ${fileConversionApi.fileWrites.length}`);
const fileWrite = fileConversionApi.fileWrites[0];
if (fileWrite.relativePath !== 'Project/Files/notes.txt') {
throw new Error(`file path mismatch: ${fileWrite.relativePath}`);
}
if (fileWrite.content !== 'hello file') throw new Error(`file content mismatch: ${fileWrite.content}`);
if (fileWrite.options.createIfMissing !== true || fileWrite.options.overwrite !== false) {
throw new Error(`file write options mismatch: ${JSON.stringify(fileWrite.options)}`);
}
if (!fileConversionApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'convert-file' && capture.globalState === 'archived')) {
throw new Error('converted file capture was not archived');
}
const convertedFileEvent = fileConversionApi.publishedEvents.find((event) => event.name === 'browser.capture.converted');
if (!convertedFileEvent) throw new Error('browser.capture.converted file event was not published');
if (convertedFileEvent.payload.conversionType !== 'file') throw new Error('converted file event conversionType mismatch');
if (convertedFileEvent.payload.filePath !== 'Project/Files/notes.txt') throw new Error('converted file event filePath mismatch');
component.unmount && component.unmount(fileConversionView.container);
const binaryFileApi = makeApi({
'captures:workspace:Project': [{
captureId: 'convert-binary-file',
capturedAt: '2026-06-29T02:25:00.000Z',
kind: 'file',
url: 'https://example.com/files',
title: 'Example Files',
domain: 'example.com',
fileName: 'logo.png',
fileMime: 'image/png',
fileSize: 4,
fileDataBase64: 'iVBORw==',
workspaceRootPath: 'Project',
workspaceName: 'Project',
}],
});
const binaryFileView = await mountWithApi(binaryFileApi);
const createBinaryFileButton = walk(binaryFileView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-action') === 'create-file');
if (!createBinaryFileButton) throw new Error('create file button for binary capture was not rendered');
createBinaryFileButton.click();
await flush();
if (binaryFileApi.fileWrites.length !== 0) throw new Error('binary file conversion used writeText');
if (binaryFileApi.fileByteWrites.length !== 1) throw new Error(`expected one byte write, got ${binaryFileApi.fileByteWrites.length}`);
const byteWrite = binaryFileApi.fileByteWrites[0];
if (byteWrite.relativePath !== 'Project/Files/logo.png') throw new Error(`byte write path mismatch: ${byteWrite.relativePath}`);
if (byteWrite.dataBase64 !== 'iVBORw==') throw new Error(`byte write data mismatch: ${byteWrite.dataBase64}`);
if (byteWrite.options.createIfMissing !== true || byteWrite.options.overwrite !== false) {
throw new Error(`byte write options mismatch: ${JSON.stringify(byteWrite.options)}`);
}
const convertedBinaryFileEvent = binaryFileApi.publishedEvents.find((event) => event.name === 'browser.capture.converted');
if (!convertedBinaryFileEvent || convertedBinaryFileEvent.payload.filePath !== 'Project/Files/logo.png') {
throw new Error('binary file conversion event mismatch');
}
component.unmount && component.unmount(binaryFileView.container);
const failedFileApi = makeApi({
'captures:workspace:Project': [{
captureId: 'convert-file-conflict',
capturedAt: '2026-06-29T02:30:00.000Z',
kind: 'file',
url: 'https://example.com/files',
title: 'Example Files',
domain: 'example.com',
fileName: 'existing.txt',
fileMime: 'text/plain',
fileSize: 12,
fileText: 'existing file',
workspaceRootPath: 'Project',
workspaceName: 'Project',
}],
});
const failedFileView = await mountWithApi(failedFileApi);
const failedCreateFileButton = walk(failedFileView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-action') === 'create-file');
if (!failedCreateFileButton) throw new Error('create file button for failed conversion was not rendered');
failedFileApi.failNextWrite('file already exists');
failedCreateFileButton.click();
await flush();
if (!failedFileApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'convert-file-conflict')) {
throw new Error('failed file conversion removed capture from queue');
}
if (!failedFileView.container.textContent.includes('Could not create the file. Please try again.')) {
throw new Error('failed file conversion did not render an error status');
}
if (failedFileApi.publishedEvents.some((event) => event.name === 'browser.capture.converted')) {
throw new Error('failed file conversion published converted event');
}
component.unmount && component.unmount(failedFileView.container);
if (!technicalErrors.some((entry) => entry.includes('file already exists'))) {
throw new Error('failed conversion did not retain its technical details in the console log');
}
console.log('browser inbox plugin smoke passed');
})().catch((err) => {
console.error(err);
process.exit(1);
});
+210
View File
@@ -0,0 +1,210 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const root = path.resolve(__dirname, '..');
const sourcePath = path.join(root, 'plugins', 'default-editor', 'frontend', 'src', 'index.js');
const source = fs.readFileSync(sourcePath, 'utf8');
class FakeNode {
constructor(tagName) {
this.tagName = String(tagName || '').toUpperCase();
this.children = [];
this.attributes = {};
this.listeners = {};
this.className = '';
this.value = '';
this.disabled = false;
this.parentNode = null;
this._textContent = '';
this._innerHTML = '';
}
appendChild(node) {
this.children.push(node);
node.parentNode = this;
return node;
}
setAttribute(name, value) {
this.attributes[name] = String(value);
}
getAttribute(name) {
return this.attributes[name];
}
addEventListener(type, handler) {
this.listeners[type] = this.listeners[type] || [];
this.listeners[type].push(handler);
}
dispatchEvent(type, event = {}) {
(this.listeners[type] || []).forEach((handler) => handler({ target: this, preventDefault() {}, stopPropagation() {}, ...event }));
}
set textContent(value) {
this._textContent = String(value || '');
this._innerHTML = '';
this.children = [];
}
get textContent() {
if (this.tagName === '#TEXT') return this._textContent;
return this._textContent + this._innerHTML.replace(/<[^>]*>/g, '') + this.children.map((child) => child.textContent).join('');
}
set innerHTML(value) {
this._innerHTML = String(value || '');
this._textContent = '';
this.children = [];
}
get innerHTML() {
return this._innerHTML + this.children.map((child) => child.innerHTML).join('');
}
}
function walk(node, fn) {
if (fn(node)) return node;
for (const child of node.children) {
const found = walk(child, fn);
if (found) return found;
}
return null;
}
function makeDocument() {
return {
head: new FakeNode('head'),
body: new FakeNode('body'),
createElement(tagName) {
return new FakeNode(tagName);
},
createTextNode(text) {
const node = new FakeNode('#text');
node.textContent = text;
return node;
},
getElementById() {
return null;
},
};
}
function loadComponent(document) {
const registry = {};
vm.runInNewContext(source, {
console,
document,
window: {
confirm: () => true,
VerstakPluginRegister(pluginId, bundle) {
registry[pluginId] = bundle.components || {};
},
},
Event: function Event() {},
setTimeout,
clearTimeout,
}, { filename: sourcePath });
const component = registry['verstak.default-editor'] && registry['verstak.default-editor'].DefaultEditor;
if (!component) throw new Error('DefaultEditor was not registered');
return component;
}
async function flush() {
for (let i = 0; i < 12; i++) {
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
async function mountEditor(secretProviderEnabled, translations) {
const document = makeDocument();
const component = loadComponent(document);
const opened = [];
const api = {
files: {
readText: async () => '[DB password](verstak-secret://client-a.db)\n',
writeText: async () => undefined,
},
contributions: {
list: async (point) => {
if (point !== 'openProviders' || !secretProviderEnabled) return [];
return [{
pluginId: 'verstak.secrets',
id: 'verstak.secrets.secret',
component: 'SecretsView',
supports: [{ kind: 'secret', modes: ['view'] }],
}];
},
},
workbench: {
openResource: async (request) => {
opened.push(request);
return { status: 'opened', request };
},
},
i18n: {
t: (key, params, fallback) => {
let value = translations && translations[key] ? translations[key] : (fallback || key);
Object.entries(params || {}).forEach(([name, replacement]) => {
value = value.replace(`{${name}}`, String(replacement));
});
return value;
},
},
};
const container = document.createElement('div');
component.mount(container, {
request: { kind: 'vault-file', path: 'Project/Notes/Secret.md', extension: '.md', mode: 'view' },
}, api);
await flush();
return { container, opened };
}
(async () => {
const disabled = await mountEditor(false);
const disabledPreview = walk(disabled.container, (node) => node.className === 'de-preview');
if (!disabledPreview) throw new Error('disabled preview missing');
if (disabledPreview.innerHTML.includes('data-secret-id')) throw new Error('secret link rendered without secrets provider');
const enabled = await mountEditor(true);
if (enabled.container.textContent.includes('notes-markdown') || enabled.container.textContent.includes('generic-markdown')) {
throw new Error('editor must not expose the technical editor mode in its toolbar');
}
if (enabled.container.textContent.includes('Notes context active')) {
throw new Error('editor must not show an implementation roadmap in the notes UI');
}
const preview = walk(enabled.container, (node) => node.className === 'de-preview');
if (!preview) throw new Error('enabled preview missing');
if (!preview.innerHTML.includes('data-secret-id="client-a.db"')) throw new Error('secret link did not render with provider');
enabled.container.dispatchEvent('click', {
target: {
closest(selector) {
if (selector === '.secret-link') {
return { getAttribute: (name) => name === 'data-secret-id' ? 'client-a.db' : '' };
}
return null;
},
},
});
await flush();
if (!enabled.opened.some((request) => request.kind === 'secret' && request.path === 'client-a.db')) {
throw new Error('secret link did not open through workbench');
}
const russian = await mountEditor(true, { 'ui.md.heading': 'Заголовок' });
const headingButton = walk(russian.container, (node) => node.getAttribute && node.getAttribute('data-md-action') === 'heading');
if (!headingButton || headingButton.getAttribute('title') !== 'Заголовок') {
throw new Error('Markdown toolbar titles must use the locale catalog');
}
console.log('default editor smoke passed');
})().catch((err) => {
console.error(err);
process.exit(1);
});
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const root = path.resolve(__dirname, '..');
const sourcePath = path.join(root, 'plugins', 'file-preview', 'frontend', 'src', 'index.js');
const source = fs.readFileSync(sourcePath, 'utf8');
class FakeNode {
constructor(tagName) {
this.tagName = String(tagName || '').toUpperCase();
this.children = [];
this.attributes = {};
this.listeners = {};
this.style = {};
this.className = '';
this.id = '';
this.parentNode = null;
this._innerHTML = '';
this._textContent = '';
}
appendChild(node) {
if (!(node instanceof FakeNode)) throw new TypeError('appendChild expects FakeNode');
this.children.push(node);
node.parentNode = this;
return node;
}
setAttribute(name, value) {
this.attributes[name] = String(value);
if (name === 'id') this.id = String(value);
}
getAttribute(name) {
return this.attributes[name];
}
addEventListener(type, handler) {
this.listeners[type] = this.listeners[type] || [];
this.listeners[type].push(handler);
}
dispatchEvent(type, event = {}) {
(this.listeners[type] || []).forEach((handler) => handler({ stopPropagation() {}, ...event }));
}
click() {
this.dispatchEvent('click');
}
set innerHTML(value) {
this._innerHTML = String(value || '');
this.children = [];
}
get innerHTML() {
return this._innerHTML + this.children.map((child) => child.innerHTML).join('');
}
set textContent(value) {
this._textContent = String(value || '');
this.children = [];
}
get textContent() {
if (this.tagName === '#TEXT') return this._textContent;
return this._textContent + this.children.map((child) => child.textContent).join('');
}
}
function walk(node, fn) {
if (fn(node)) return node;
for (const child of node.children) {
const found = walk(child, fn);
if (found) return found;
}
return null;
}
function makeDocument() {
return {
body: new FakeNode('body'),
head: new FakeNode('head'),
createElement(tagName) {
return new FakeNode(tagName);
},
createTextNode(text) {
const node = new FakeNode('#text');
node.textContent = text;
return node;
},
getElementById() {
return null;
},
};
}
function loadComponent(document) {
const registry = {};
vm.runInNewContext(source, {
console,
document,
window: {
VerstakPluginRegister(pluginId, bundle) {
registry[pluginId] = bundle.components || {};
},
},
}, { filename: sourcePath });
const component = registry['verstak.file-preview'] && registry['verstak.file-preview'].FilePreview;
if (!component) throw new Error('FilePreview was not registered');
return component;
}
async function flush() {
for (let i = 0; i < 8; i++) await Promise.resolve();
}
(async () => {
const document = makeDocument();
const component = loadComponent(document);
const imageContainer = new FakeNode('div');
const opened = [];
const byteReads = [];
component.mount(imageContainer, {
request: { path: 'Images/logo.png', extension: '.png', mode: 'view' },
}, {
files: {
metadata: async () => ({ type: 'file', size: 2048, isText: false, modifiedAt: '2026-06-27T00:00:00Z' }),
readBytes: async (relativePath) => {
byteReads.push(relativePath);
return { relativePath, size: 4, mimeHint: 'image/png', dataBase64: 'iVBORw==' };
},
readText: async () => { throw new Error('file preview should not read text content'); },
openExternal: async (relativePath) => { opened.push(relativePath); },
},
});
await flush();
if (!imageContainer.textContent.includes('Image Preview')) throw new Error('image preview metadata was not rendered');
if (byteReads[0] !== 'Images/logo.png') throw new Error(`expected readBytes path Images/logo.png, got ${byteReads[0] || '<none>'}`);
const previewImage = walk(imageContainer, (node) => node.tagName === 'IMG' && node.getAttribute('data-preview-image') === 'true');
if (!previewImage) throw new Error('preview image not rendered');
if (previewImage.getAttribute('src') !== 'data:image/png;base64,iVBORw==') {
throw new Error(`unexpected preview image src: ${previewImage.getAttribute('src') || '<none>'}`);
}
const openButton = walk(imageContainer, (node) => node.getAttribute && node.getAttribute('data-action') === 'open-external');
if (!openButton) throw new Error('open external button not found');
openButton.click();
await flush();
if (opened[0] !== 'Images/logo.png') throw new Error(`expected external open path Images/logo.png, got ${opened[0] || '<none>'}`);
console.log('file-preview smoke passed');
})().catch((err) => {
console.error(err);
process.exit(1);
});
+504
View File
@@ -0,0 +1,504 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const root = path.resolve(__dirname, '..');
const sourcePath = path.join(root, 'plugins', 'files', 'frontend', 'src', 'index.js');
const source = fs.readFileSync(sourcePath, 'utf8');
class FakeNode {
constructor(tagName) {
this.tagName = String(tagName || '').toUpperCase();
this.children = [];
this.attributes = {};
this.listeners = {};
this.style = {};
this.className = '';
this.id = '';
this.value = '';
this.parentNode = null;
this._innerHTML = '';
this._textContent = '';
this.classList = {
add: (cls) => {
if (!this.className.split(/\s+/).includes(cls)) this.className = (this.className + ' ' + cls).trim();
},
remove: (cls) => {
this.className = this.className.split(/\s+/).filter((name) => name && name !== cls).join(' ');
},
contains: (cls) => this.className.split(/\s+/).includes(cls),
};
}
appendChild(node) {
if (!(node instanceof FakeNode)) throw new TypeError('appendChild expects FakeNode');
this.children.push(node);
node.parentNode = this;
return node;
}
removeChild(node) {
this.children = this.children.filter((child) => child !== node);
node.parentNode = null;
return node;
}
setAttribute(name, value) {
this.attributes[name] = String(value);
if (name === 'id') this.id = String(value);
}
getAttribute(name) {
return this.attributes[name];
}
addEventListener(type, handler) {
this.listeners[type] = this.listeners[type] || [];
this.listeners[type].push(handler);
}
removeEventListener(type, handler) {
this.listeners[type] = (this.listeners[type] || []).filter((candidate) => candidate !== handler);
}
dispatchEvent(type, event = {}) {
const handlers = this.listeners[type] || [];
handlers.forEach((handler) => handler({
preventDefault() {},
stopPropagation() {},
target: this,
clientX: 10,
clientY: 10,
...event,
}));
}
click() {
this.dispatchEvent('click');
}
contains(node) {
if (node === this) return true;
return this.children.some((child) => child.contains(node));
}
closest(selector) {
if (selector.startsWith('.') && this.classList.contains(selector.slice(1))) return this;
return this.parentNode ? this.parentNode.closest(selector) : null;
}
querySelector(selector) {
return walk(this, (node) => {
if (selector.startsWith('.')) return node.classList && node.classList.contains(selector.slice(1));
return false;
});
}
focus() {}
select() {}
set innerHTML(value) {
this._innerHTML = String(value || '');
this.children = [];
}
get innerHTML() {
return this._innerHTML;
}
set textContent(value) {
this._textContent = String(value || '');
this.children = [];
}
get textContent() {
if (this.tagName === '#TEXT') return this._textContent;
return this._textContent + this.children.map((child) => child.textContent).join('');
}
get offsetWidth() { return 200; }
get offsetHeight() { return 120; }
}
function walk(node, fn) {
if (fn(node)) return node;
for (const child of node.children) {
const found = walk(child, fn);
if (found) return found;
}
return null;
}
function makeDocument() {
const body = new FakeNode('body');
return {
body,
head: new FakeNode('head'),
listeners: {},
createElement(tagName) {
return new FakeNode(tagName);
},
createTextNode(text) {
const node = new FakeNode('#text');
node.textContent = text;
return node;
},
getElementById() {
return null;
},
addEventListener(type, handler) {
this.listeners[type] = this.listeners[type] || [];
this.listeners[type].push(handler);
},
removeEventListener(type, handler) {
this.listeners[type] = (this.listeners[type] || []).filter((candidate) => candidate !== handler);
},
};
}
function loadFilesComponent(document) {
const registry = {};
const sandbox = {
console,
document,
window: {
innerWidth: 1024,
innerHeight: 768,
listeners: {},
addEventListener(type, handler) {
this.listeners[type] = this.listeners[type] || [];
this.listeners[type].push(handler);
},
removeEventListener(type, handler) {
this.listeners[type] = (this.listeners[type] || []).filter((candidate) => candidate !== handler);
},
VerstakPluginRegister(pluginId, bundle) {
registry[pluginId] = bundle.components || {};
},
},
navigator: {
clipboard: {
written: [],
writeText: async (text) => {
sandbox.navigator.clipboard.written.push(String(text));
},
},
},
};
sandbox.window.window = sandbox.window;
sandbox.window.document = document;
sandbox.window.navigator = sandbox.navigator;
vm.runInNewContext(source, sandbox, { filename: sourcePath });
const component = registry['verstak.files'] && registry['verstak.files'].FilesView;
if (!component) throw new Error('FilesView was not registered');
return { component, clipboard: sandbox.navigator.clipboard };
}
function makeApi(options = {}) {
const externalCalls = [];
const contributionCalls = [];
const created = [];
const moved = [];
const eventHandlers = {};
let restored = false;
let externalVisible = false;
let trashEntries = [{
originalPath: 'Docs/deleted.md',
trashPath: '.verstak/trash/files/mock/deleted.md',
trashId: 'mock-trash',
deletedAt: '2026-06-27T01:02:03Z',
originalType: 'file',
basename: 'deleted.md',
}];
return {
externalCalls,
contributionCalls,
created,
moved,
emitFileChanged(payload) {
(eventHandlers['file.changed'] || []).forEach((handler) => handler({
name: 'file.changed',
payload,
timestamp: new Date().toISOString(),
}));
},
files: {
list: async () => {
const entries = [{
name: 'readme.md',
relativePath: 'Docs/readme.md',
type: 'file',
extension: 'md',
size: 12,
modifiedAt: '2026-06-27T00:00:00Z',
}];
if (options.includeFolder) {
entries.push({
name: 'Archive',
relativePath: 'Docs/Archive',
type: 'folder',
size: 0,
modifiedAt: '2026-06-27T00:00:00Z',
});
}
if (restored) {
entries.push({
name: 'deleted.md',
relativePath: 'Docs/deleted.md',
type: 'file',
extension: 'md',
size: 8,
modifiedAt: '2026-06-27T01:03:00Z',
});
}
if (externalVisible) {
entries.push({
name: 'external.md',
relativePath: 'Docs/external.md',
type: 'file',
extension: 'md',
size: 9,
modifiedAt: '2026-06-27T01:04:00Z',
});
}
return entries;
},
metadata: async () => { throw new Error('not-found'); },
readText: async () => '# Readme\n',
writeText: async (relativePath, content, options) => {
created.push({ type: 'file', relativePath, content, options });
},
createFolder: async (relativePath) => {
created.push({ type: 'folder', relativePath });
},
move: async (fromRelativePath, toRelativePath) => {
moved.push({ fromRelativePath, toRelativePath });
},
trash: async () => undefined,
listTrash: async () => trashEntries.slice(),
restoreTrash: async (trashId) => {
if (trashId !== 'mock-trash') throw new Error(`unexpected restore trash id: ${trashId}`);
restored = true;
trashEntries = [];
return 'Docs/deleted.md';
},
openExternal: async (relativePath) => { externalCalls.push({ action: 'open', path: relativePath }); },
showInFolder: async (relativePath) => { externalCalls.push({ action: 'show', path: relativePath }); },
},
workbench: {
openResource: async () => ({ status: 'opened' }),
},
contributions: {
list: async (point) => {
if (point === 'fileActions') {
return [{
pluginId: 'provider.plugin',
id: 'provider.file.action',
label: 'Provider File Action',
handler: 'provider.command',
}];
}
if (point === 'contextMenuEntries') {
return [{
pluginId: 'provider.plugin',
id: 'provider.file.context',
label: 'Provider Context Action',
context: 'file',
handler: 'provider.context',
}];
}
return [];
},
},
commands: {
executeFor: async (pluginId, commandId, args) => {
contributionCalls.push({ pluginId, commandId, args });
return { status: 'handled' };
},
},
events: {
subscribe: async (eventName, handler) => {
eventHandlers[eventName] = eventHandlers[eventName] || [];
eventHandlers[eventName].push(handler);
return () => {
eventHandlers[eventName] = (eventHandlers[eventName] || []).filter((candidate) => candidate !== handler);
};
},
},
i18n: {
t: (key, params, fallback) => {
let value = options.translations && options.translations[key] ? options.translations[key] : (fallback || key);
Object.entries(params || {}).forEach(([name, replacement]) => {
value = value.replace(`{${name}}`, String(replacement));
});
return value;
},
},
showExternalFile() {
externalVisible = true;
},
};
}
async function flush() {
for (let i = 0; i < 8; i += 1) await Promise.resolve();
}
(async () => {
const document = makeDocument();
const { component, clipboard } = loadFilesComponent(document);
const container = new FakeNode('div');
const api = makeApi();
component.mount(container, {}, api);
await flush();
const row = walk(container, (node) => node.getAttribute && node.getAttribute('data-file-path') === 'Docs/readme.md');
if (!row) throw new Error('file row not rendered');
const newFolder = walk(container, (node) => node.getAttribute && node.getAttribute('data-files-action') === 'new-folder');
if (!newFolder) throw new Error('new folder button not found');
newFolder.click();
const createModal = walk(document.body, (node) => node.getAttribute && node.getAttribute('data-files-create-modal') !== undefined);
if (!createModal) throw new Error('create file modal not found');
const createInput = walk(createModal, (node) => node.getAttribute && node.getAttribute('data-files-create-input') !== undefined);
if (!createInput) throw new Error('create file input not found');
const createConfirm = walk(createModal, (node) => node.tagName === 'BUTTON' && node.textContent === 'Create');
if (!createConfirm) throw new Error('create file confirm button not found');
createConfirm.click();
const createError = walk(createModal, (node) => node.getAttribute && node.getAttribute('data-files-create-error') !== undefined);
if (!createError || !createError.textContent.includes('Enter a name') || createInput.getAttribute('aria-invalid') !== 'true') {
throw new Error('create file modal does not show a validation error for an empty name');
}
createInput.value = 'New folder';
createInput.dispatchEvent('keydown', { key: 'Enter' });
await flush();
if (!api.created.some((entry) => entry.type === 'folder' && entry.relativePath === 'New folder')) {
throw new Error(`new folder was not created: ${JSON.stringify(api.created)}`);
}
if (walk(document.body, (node) => node.getAttribute && node.getAttribute('data-files-create-modal') !== undefined)) {
throw new Error('create file modal should close after a successful create');
}
const newMarkdown = walk(container, (node) => node.getAttribute && node.getAttribute('data-files-action') === 'new-markdown');
if (!newMarkdown) throw new Error('new markdown button not found');
newMarkdown.click();
const markdownModal = walk(document.body, (node) => node.getAttribute && node.getAttribute('data-files-create-modal') !== undefined);
const markdownInput = walk(markdownModal, (node) => node.getAttribute && node.getAttribute('data-files-create-input') !== undefined);
markdownInput.value = 'Readme';
markdownInput.dispatchEvent('keydown', { key: 'Enter' });
await flush();
if (!api.created.some((entry) => entry.type === 'file' && entry.relativePath === 'Readme.md')) {
throw new Error(`new markdown file was not created: ${JSON.stringify(api.created)}`);
}
const rename = walk(container, (node) => node.getAttribute && node.getAttribute('data-files-action') === 'row-rename');
if (!rename) throw new Error('rename file button not found');
rename.click();
const renameModal = walk(document.body, (node) => node.getAttribute && node.getAttribute('data-files-rename-modal') !== undefined);
if (!renameModal) throw new Error('rename file modal not found');
const renameInput = walk(renameModal, (node) => node.getAttribute && node.getAttribute('data-files-rename-input') !== undefined);
if (!renameInput || renameInput.value !== 'readme.md') throw new Error('rename file input should keep the current filename and extension');
renameInput.value = '';
renameInput.dispatchEvent('keydown', { key: 'Enter' });
const renameError = walk(renameModal, (node) => node.getAttribute && node.getAttribute('data-files-rename-error') !== undefined);
if (!renameError || !renameError.textContent.includes('Enter a name') || renameInput.getAttribute('aria-invalid') !== 'true') {
throw new Error('rename file modal does not show a validation error for an empty name');
}
renameInput.value = 'renamed.md';
renameInput.dispatchEvent('keydown', { key: 'Enter' });
await flush();
if (!api.moved.some((entry) => entry.fromRelativePath === 'Docs/readme.md' && entry.toRelativePath === 'Docs/renamed.md')) {
throw new Error(`rename did not preserve the extension: ${JSON.stringify(api.moved)}`);
}
const list = walk(container, (node) => node.getAttribute && node.getAttribute('data-files-list') !== undefined);
if (!list) throw new Error('files list not rendered');
list.dispatchEvent('contextmenu', { target: row, clientX: 20, clientY: 20 });
const openExternal = walk(document.body, (node) => node.getAttribute && node.getAttribute('data-files-menu-action') === 'open-external');
if (!openExternal) throw new Error('Open External menu item not found');
const showInExplorer = walk(document.body, (node) => node.getAttribute && node.getAttribute('data-files-menu-action') === 'show-in-explorer');
if (!showInExplorer) throw new Error('Show in Explorer menu item not found');
openExternal.click();
await flush();
if (!api.externalCalls.some((call) => call.action === 'open' && call.path === 'Docs/readme.md')) {
throw new Error(`expected openExternal call for Docs/readme.md, got ${JSON.stringify(api.externalCalls)}`);
}
if (walk(document.body, (node) => node.tagName === 'BUTTON' && node.textContent === 'Copy Path')) {
throw new Error('external fallback should not show Copy Path after successful API call');
}
showInExplorer.click();
await flush();
if (!api.externalCalls.some((call) => call.action === 'show' && call.path === 'Docs/readme.md')) {
throw new Error(`expected showInFolder call for Docs/readme.md, got ${JSON.stringify(api.externalCalls)}`);
}
if (clipboard.written.length !== 0) {
throw new Error(`expected no copied path after successful external API calls, got ${clipboard.written.join(', ')}`);
}
list.dispatchEvent('contextmenu', { target: row, clientX: 20, clientY: 20 });
const providerAction = walk(document.body, (node) => node.getAttribute && node.getAttribute('data-files-menu-action') === 'contribution-provider.file.action');
if (!providerAction) throw new Error('provider file action menu item not found');
const providerContext = walk(document.body, (node) => node.getAttribute && node.getAttribute('data-files-menu-action') === 'contribution-provider.file.context');
if (!providerContext) throw new Error('provider context menu item not found');
providerAction.click();
await flush();
if (!api.contributionCalls.some((call) => call.pluginId === 'provider.plugin' && call.commandId === 'provider.command' && call.args.path === 'Docs/readme.md')) {
throw new Error(`expected provider file action call, got ${JSON.stringify(api.contributionCalls)}`);
}
const trashViewButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-files-action') === 'trash-view');
if (trashViewButton) throw new Error('Files must not expose a trash metadata toolbar button');
const restoreTrash = walk(container, (node) => node.getAttribute && node.getAttribute('data-files-restore-trash'));
if (restoreTrash) throw new Error('Files must not render trash restore controls');
api.showExternalFile();
api.emitFileChanged({ path: 'Docs/external.md', operation: 'external.create', type: 'file' });
await flush();
const externalRow = walk(container, (node) => node.getAttribute && node.getAttribute('data-file-path') === 'Docs/external.md');
if (!externalRow) {
throw new Error(`external file row not rendered after file.changed: ${container.textContent}`);
}
const russianDocument = makeDocument();
const { component: russianComponent } = loadFilesComponent(russianDocument);
const russianApi = makeApi({
includeFolder: true,
translations: {
'ui.open': 'Открыть',
'ui.openExternal': 'Открыть во внешнем приложении',
'ui.showInFolder': 'Показать в папке',
'ui.rename': 'Переименовать',
'ui.duplicate': 'Создать копию',
'ui.cut': 'Вырезать',
'ui.copy': 'Копировать',
'ui.trash': 'Переместить в корзину',
'ui.folderType': 'Папка',
'ui.icon.markdown': 'Файл Markdown',
},
});
const russianContainer = new FakeNode('div');
russianComponent.mount(russianContainer, {}, russianApi);
await flush();
if (!russianContainer.textContent.includes('Папка') || russianContainer.textContent.includes('folder')) {
throw new Error(`folder type was not localized: ${russianContainer.textContent}`);
}
const russianList = walk(russianContainer, (node) => node.getAttribute && node.getAttribute('data-files-list') !== undefined);
const russianRow = walk(russianContainer, (node) => node.getAttribute && node.getAttribute('data-file-path') === 'Docs/readme.md');
const russianIcon = walk(russianRow, (node) => node.getAttribute && node.getAttribute('data-file-icon') === 'markdown');
if (!russianIcon || russianIcon.getAttribute('title') !== 'Файл Markdown' || russianIcon.getAttribute('aria-label') !== 'Файл Markdown') {
throw new Error(`file icon label was not localized: ${russianIcon && russianIcon.getAttribute('title')}`);
}
russianList.dispatchEvent('contextmenu', { target: russianRow, clientX: 20, clientY: 20 });
const russianMenuText = russianDocument.body.textContent;
['Открыть во внешнем приложении', 'Показать в папке', 'Переименовать', 'Создать копию', 'Вырезать', 'Копировать', 'Переместить в корзину'].forEach((label) => {
if (!russianMenuText.includes(label)) throw new Error(`context menu label was not localized: ${label}`);
});
console.log('files frontend smoke passed');
})().catch((err) => {
console.error(err);
process.exit(1);
});
+381
View File
@@ -0,0 +1,381 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const root = path.resolve(__dirname, '..');
const manifestPath = path.join(root, 'plugins', 'journal', 'plugin.json');
const sourcePath = path.join(root, 'plugins', 'journal', 'frontend', 'src', 'index.js');
const russianLocalePath = path.join(root, 'plugins', 'journal', 'locales', 'ru.json');
if (!fs.existsSync(manifestPath)) throw new Error('journal plugin manifest missing');
if (!fs.existsSync(sourcePath)) throw new Error('journal frontend entry missing');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const source = fs.readFileSync(sourcePath, 'utf8');
const russianLocale = JSON.parse(fs.readFileSync(russianLocalePath, 'utf8'));
class FakeNode {
constructor(tagName) {
this.tagName = String(tagName || '').toUpperCase();
this.children = [];
this.attributes = {};
this.listeners = {};
this.className = '';
this.value = '';
this.checked = false;
this.disabled = false;
this.parentNode = null;
this._textContent = '';
this._innerHTML = '';
}
appendChild(node) {
this.children.push(node);
node.parentNode = this;
return node;
}
setAttribute(name, value) {
this.attributes[name] = String(value);
if (name === 'value') this.value = String(value);
if (name === 'checked') this.checked = true;
}
getAttribute(name) {
return this.attributes[name];
}
removeAttribute(name) {
delete this.attributes[name];
}
addEventListener(type, handler) {
this.listeners[type] = this.listeners[type] || [];
this.listeners[type].push(handler);
}
dispatchEvent(type, event = {}) {
(this.listeners[type] || []).forEach((handler) => handler({ target: this, preventDefault() {}, stopPropagation() {}, ...event }));
}
click() {
this.dispatchEvent('click');
}
set innerHTML(value) {
this._innerHTML = String(value || '');
this.children = [];
}
get innerHTML() {
return this._innerHTML + this.children.map((child) => child.innerHTML).join('');
}
set textContent(value) {
this._textContent = String(value || '');
this.children = [];
}
get textContent() {
if (this.tagName === '#TEXT') return this._textContent;
return this._textContent + this.children.map((child) => child.textContent).join('');
}
}
function walk(node, fn) {
if (fn(node)) return node;
for (const child of node.children) {
const found = walk(child, fn);
if (found) return found;
}
return null;
}
function walkAll(node, fn, matches = []) {
if (fn(node)) matches.push(node);
for (const child of node.children) walkAll(child, fn, matches);
return matches;
}
function makeDocument() {
return {
body: new FakeNode('body'),
head: new FakeNode('head'),
createElement(tagName) {
return new FakeNode(tagName);
},
createTextNode(text) {
const node = new FakeNode('#text');
node.textContent = text;
return node;
},
getElementById() {
return null;
},
};
}
function loadComponent(document) {
const registry = {};
vm.runInNewContext(source, {
console,
Date,
document,
window: {
VerstakPluginRegister(pluginId, bundle) {
registry[pluginId] = bundle.components || {};
},
},
}, { filename: sourcePath });
const component = registry['verstak.journal'] && registry['verstak.journal'].JournalView;
if (!component) throw new Error('JournalView was not registered');
return component;
}
function makeApi(initialSettings = {}, locale = null) {
const settings = { ...initialSettings };
const publishedEvents = [];
return {
publishedEvents,
settings: {
read: async (key) => (key ? settings[key] : { ...settings }),
write: async (key, value) => {
settings[key] = value;
return { ...settings };
},
},
events: {
publish: async (name, payload) => {
publishedEvents.push({ name, payload });
},
},
files: {
list: async () => [
{ type: 'folder', relativePath: 'Project', name: 'Project' },
{ type: 'folder', relativePath: 'Client', name: 'Client' },
],
},
i18n: locale ? {
t(key, params, fallback) {
return String(locale[key] || fallback || key).replace(/\{(\w+)\}/g, (_match, name) => String((params || {})[name] ?? ''));
},
} : null,
storedEntries(key) {
return settings[key] || [];
},
};
}
async function flush() {
for (let i = 0; i < 12; i += 1) await Promise.resolve();
}
async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, workspaceRootPath: 'Project' }, document = makeDocument()) {
const component = loadComponent(document);
const container = new FakeNode('div');
component.mount(container, props, api);
await flush();
return { component, container, document };
}
function byData(container, attr, value) {
const node = walk(container, (candidate) => candidate.getAttribute && candidate.getAttribute(attr) === value);
if (!node) throw new Error(`${attr}=${value} not found`);
return node;
}
(async () => {
if (manifest.id !== 'verstak.journal') throw new Error('journal manifest id mismatch');
for (const capability of ['worklog', 'journal', 'report.worklog']) {
if (!manifest.provides.includes(capability)) throw new Error(`journal manifest missing capability ${capability}`);
}
if ((manifest.optionalRequires || []).includes('activity.reconstruction')) throw new Error('Journal must remain available without Activity');
if (!manifest.permissions.includes('storage.namespace')) throw new Error('journal manifest must request storage.namespace');
if (!manifest.permissions.includes('events.publish')) throw new Error('journal manifest must request events.publish');
if (!manifest.permissions.includes('ui.register')) throw new Error('journal manifest must request ui.register');
if (!(manifest.contributes.workspaceItems || []).some((item) => item.component === 'JournalView')) throw new Error('journal workspace item missing');
if (!(manifest.contributes.sidebarItems || []).some((item) => item.view === 'verstak.journal.view')) throw new Error('journal sidebar item missing');
const api = makeApi();
const { component, container } = await mountWithApi(api);
const projectKey = 'worklog:workspace:Project';
if (walk(container, (node) => node.getAttribute && node.getAttribute('data-journal-action') === 'import-activity')) {
throw new Error('Journal must not provide direct Activity import');
}
byData(container, 'data-journal-action', 'add').click();
await flush();
byData(container, 'data-journal-input', 'date').value = '2026-06-27';
byData(container, 'data-journal-input', 'title').value = 'Draft brief';
byData(container, 'data-journal-input', 'summary').value = 'Reviewed docs';
byData(container, 'data-journal-input', 'minutes').value = '45';
byData(container, 'data-journal-action', 'save-entry').click();
await flush();
if (api.storedEntries(projectKey).length !== 1) throw new Error('manual journal entry was not stored');
if (api.storedEntries(projectKey)[0].activityIds.length !== 0) throw new Error('manual journal entry must not require activity links');
if (!container.textContent.includes('Draft brief')) throw new Error('manual journal entry was not rendered');
if (!container.textContent.includes('45 min')) throw new Error('manual journal entry minutes were not rendered');
byData(container, 'data-journal-action', 'edit').click();
await flush();
byData(container, 'data-journal-input', 'title').value = 'Draft brief updated';
byData(container, 'data-journal-input', 'summary').value = 'Reviewed docs and drafted notes';
byData(container, 'data-journal-input', 'minutes').value = '60';
byData(container, 'data-journal-action', 'save-entry').click();
await flush();
if (api.storedEntries(projectKey).length !== 1) throw new Error('editing journal entry created a duplicate');
if (api.storedEntries(projectKey)[0].title !== 'Draft brief updated') throw new Error('journal entry title was not updated');
if (!container.textContent.includes('60 min')) throw new Error('edited journal entry minutes were not rendered');
const candidate = {
candidateId: 'work-session:Project:capture-1:note-1',
sessionId: 'session-journal-1',
handledThrough: '2026-06-27T11:03:00.000Z',
workspaceRootPath: 'Project',
startedAt: '2026-06-27T10:12:00.000Z',
endedAt: '2026-06-27T11:03:00.000Z',
estimatedMinutes: 51,
activityCount: 2,
activityIds: ['capture-1', 'note-1'],
activities: [
{ activityId: 'capture-1', type: 'browser.capture.selection', occurredAt: '2026-06-27T10:12:00.000Z', sourcePluginId: 'verstak.browser-inbox' },
{ activityId: 'note-1', type: 'note.saved', occurredAt: '2026-06-27T11:03:00.000Z', sourcePluginId: 'verstak.notes' },
],
};
const candidateView = await mountWithApi(api, {
workspaceNode: { name: 'Project' },
workspaceRootPath: 'Project',
toolRequest: { type: 'work-session-candidate', candidate },
});
if (!candidateView.container.textContent.includes('Review possible journal entry')) throw new Error('candidate review modal was not opened');
if (!candidateView.container.textContent.includes('Deal: Project')) throw new Error('candidate Deal was not shown for review');
if (!candidateView.container.textContent.includes('Estimated duration: 51 min')) throw new Error('candidate duration was not shown for review');
if (candidateView.container.textContent.includes('browser.capture.selection') || candidateView.container.textContent.includes('verstak.browser-inbox') || candidateView.container.textContent.includes('capture-1')) {
throw new Error('candidate review exposed technical Activity identifiers');
}
if (byData(candidateView.container, 'data-journal-input', 'title').value !== '') throw new Error('candidate review must start with an empty title');
if (byData(candidateView.container, 'data-journal-input', 'summary').value !== '') throw new Error('candidate review must start with an empty body');
if (byData(candidateView.container, 'data-journal-input', 'minutes').value !== '51') throw new Error('candidate review must prefill the factual duration');
const linkedActivityInputs = walkAll(candidateView.container, (node) => node.getAttribute && node.getAttribute('data-journal-candidate-activity'));
if (linkedActivityInputs.length !== 2 || linkedActivityInputs.some((node) => node.checked !== true)) throw new Error('candidate activities were not available for review');
byData(candidateView.container, 'data-journal-input', 'title').value = 'Review research capture';
byData(candidateView.container, 'data-journal-input', 'summary').value = 'Read the capture and updated the project note.';
linkedActivityInputs[1].checked = false;
byData(candidateView.container, 'data-journal-action', 'save-entry').click();
await flush();
if (api.storedEntries(projectKey).length !== 2) throw new Error('reviewed candidate was not saved as a journal entry');
const linkedEntry = api.storedEntries(projectKey).find((entry) => entry.sourceCandidateId === candidate.candidateId);
if (!linkedEntry) throw new Error('candidate reference was not stored on the journal entry');
if (linkedEntry.title !== 'Review research capture' || linkedEntry.summary !== 'Read the capture and updated the project note.') {
throw new Error('candidate review did not keep the user-authored entry fields');
}
if (linkedEntry.activityIds.join(',') !== 'capture-1') throw new Error('candidate review did not persist selected activity ids');
const handledEvent = api.publishedEvents.find((event) => event.name === 'activity.session.handled');
if (!handledEvent || handledEvent.payload.sessionId !== 'session-journal-1' || handledEvent.payload.handledThrough !== '2026-06-27T11:03:00.000Z' || handledEvent.payload.status !== 'accepted') {
throw new Error('accepted Journal candidate did not persist a session watermark');
}
if (walk(candidateView.container, (node) => node.getAttribute && node.getAttribute('data-journal-action') === 'view-activity')) {
throw new Error('journal rows must not navigate to Activity by default');
}
const russianCandidateView = await mountWithApi(makeApi({}, russianLocale), {
workspaceNode: { name: 'Project' },
workspaceRootPath: 'Project',
toolRequest: { type: 'work-session-candidate', candidate },
});
if (!russianCandidateView.container.textContent.includes('Дело: Project') || !russianCandidateView.container.textContent.includes('Захвачено выделение')) {
throw new Error('candidate review was not localized');
}
byData(candidateView.container, 'data-journal-action', 'delete').click();
await flush();
if (api.storedEntries(projectKey).length !== 1) throw new Error('journal entry was not deleted');
const completedTodo = {
id: 'todo:Project:project-review',
title: 'Prepare project review',
description: 'Collect factual review notes.',
workspaceRootPath: 'Project',
completedAt: '2026-06-27T11:15:00.000Z',
};
const todoView = await mountWithApi(api, {
workspaceNode: { name: 'Project' },
workspaceRootPath: 'Project',
toolRequest: { type: 'completed-todo', todo: completedTodo },
});
if (!todoView.container.textContent.includes('Create journal entry from completed todo')) {
throw new Error('completed Todo did not open the Journal conversion form');
}
if (!byData(todoView.container, 'data-journal-todo', completedTodo.id)) {
throw new Error('completed Todo context was not shown in the Journal form');
}
if (byData(todoView.container, 'data-journal-input', 'title').value !== completedTodo.title) {
throw new Error('completed Todo Journal form did not prefill the exact title');
}
if (byData(todoView.container, 'data-journal-input', 'summary').value !== completedTodo.description) {
throw new Error('completed Todo Journal form did not prefill the exact description');
}
if (byData(todoView.container, 'data-journal-input', 'minutes').value !== '0') {
throw new Error('completed Todo Journal form must not invent a duration');
}
byData(todoView.container, 'data-journal-input', 'title').value = 'Prepare project review for handoff';
byData(todoView.container, 'data-journal-input', 'summary').value = 'Reviewed factual project notes before handoff.';
byData(todoView.container, 'data-journal-action', 'save-entry').click();
await flush();
if (api.storedEntries(projectKey).length !== 2) throw new Error('completed Todo Journal entry was not saved');
const todoEntry = api.storedEntries(projectKey).find((entry) => entry.sourceTodoId === completedTodo.id);
if (!todoEntry) throw new Error('completed Todo reference was not stored on the Journal entry');
if (todoEntry.title !== 'Prepare project review for handoff' || todoEntry.summary !== 'Reviewed factual project notes before handoff.') {
throw new Error('completed Todo Journal form did not preserve the user-edited fields');
}
const duplicateTodoView = await mountWithApi(api, {
workspaceNode: { name: 'Project' },
workspaceRootPath: 'Project',
toolRequest: { type: 'completed-todo', todo: completedTodo },
});
byData(duplicateTodoView.container, 'data-journal-action', 'save-entry').click();
await flush();
if (api.storedEntries(projectKey).filter((entry) => entry.sourceTodoId === completedTodo.id).length !== 1) {
throw new Error('completed Todo Journal conversion created a duplicate entry');
}
const globalView = await mountWithApi(api, {});
if (!globalView.container.textContent.includes('Review research capture') && !globalView.container.textContent.includes('Draft brief updated')) {
throw new Error('global journal did not aggregate remaining entries');
}
const globalAdd = byData(globalView.container, 'data-journal-action', 'add');
if (globalAdd.disabled) throw new Error('global Journal Add must be available');
globalAdd.click();
await flush();
const globalWorkspace = byData(globalView.container, 'data-journal-input', 'workspaceRootPath');
if (!globalWorkspace || globalWorkspace.tagName !== 'SELECT') throw new Error('global Journal form did not render the Deal selector');
globalWorkspace.value = 'Client';
byData(globalView.container, 'data-journal-input', 'title').value = 'Prepare client summary';
byData(globalView.container, 'data-journal-input', 'minutes').value = '30';
byData(globalView.container, 'data-journal-action', 'save-entry').click();
await flush();
const clientKey = 'worklog:workspace:Client';
if (api.storedEntries(clientKey).length !== 1 || api.storedEntries(clientKey)[0].title !== 'Prepare client summary') {
throw new Error('global Journal entry was not stored under the selected Deal');
}
if (!globalView.container.textContent.includes('Prepare client summary')) throw new Error('global Journal did not render the created entry');
component.unmount && component.unmount(container);
component.unmount && component.unmount(candidateView.container);
component.unmount && component.unmount(russianCandidateView.container);
component.unmount && component.unmount(todoView.container);
component.unmount && component.unmount(duplicateTodoView.container);
component.unmount && component.unmount(globalView.container);
console.log('journal plugin smoke passed');
})().catch((err) => {
console.error(err);
process.exit(1);
});
+373
View File
@@ -0,0 +1,373 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const root = path.resolve(__dirname, '..');
const sourcePath = path.join(root, 'plugins', 'notes', 'frontend', 'src', 'index.js');
const source = fs.readFileSync(sourcePath, 'utf8');
if (!source.includes('.notes-sort{width:8rem;appearance:none;')) {
throw new Error('notes sort select must use the shared custom dropdown style');
}
class FakeNode {
constructor(tagName) {
this.tagName = String(tagName || '').toUpperCase();
this.children = [];
this.attributes = {};
this.listeners = {};
this.style = {};
this.className = '';
this.id = '';
this.value = '';
this._innerHTML = '';
this._textContent = '';
}
appendChild(node) {
if (!(node instanceof FakeNode)) throw new TypeError('appendChild expects FakeNode');
this.children.push(node);
node.parentNode = this;
return node;
}
remove() {
if (!this.parentNode) return;
this.parentNode.children = this.parentNode.children.filter((child) => child !== this);
this.parentNode = null;
}
setAttribute(name, value) {
this.attributes[name] = String(value);
if (name === 'id') this.id = String(value);
}
getAttribute(name) {
return this.attributes[name];
}
addEventListener(type, handler) {
this.listeners[type] = this.listeners[type] || [];
this.listeners[type].push(handler);
}
dispatchEvent(type, event = {}) {
const handlers = this.listeners[type] || [];
handlers.forEach((handler) => handler({ stopPropagation() {}, ...event }));
}
click() {
this.dispatchEvent('click');
}
focus() {}
select() {}
set innerHTML(value) {
this._innerHTML = String(value || '');
this.children = [];
}
get innerHTML() {
return this._innerHTML;
}
set textContent(value) {
this._textContent = String(value || '');
this.children = [];
}
get textContent() {
if (this.tagName === '#TEXT') return this._textContent;
return this._textContent + this.children.map((child) => child.textContent).join('');
}
}
function walk(node, fn) {
if (fn(node)) return node;
for (const child of node.children) {
const found = walk(child, fn);
if (found) return found;
}
return null;
}
function makeDocument() {
const body = new FakeNode('body');
return {
body,
head: new FakeNode('head'),
createElement(tagName) {
return new FakeNode(tagName);
},
createTextNode(text) {
const node = new FakeNode('#text');
node.textContent = text;
return node;
},
getElementById() {
return null;
},
};
}
function loadNotesComponent(document) {
const registry = {};
const sandbox = {
console,
setTimeout,
clearTimeout,
document,
window: {
VerstakPluginRegister(pluginId, bundle) {
registry[pluginId] = bundle.components || {};
},
},
};
sandbox.window.window = sandbox.window;
sandbox.window.document = document;
vm.runInNewContext(source, sandbox, { filename: sourcePath });
const component = registry['verstak.notes'] && registry['verstak.notes'].NotesView;
if (!component) throw new Error('NotesView was not registered');
return component;
}
function makeApi(options = {}) {
const entries = new Map();
const opened = [];
const contributionCalls = [];
const eventHandlers = {};
return {
entries,
opened,
contributionCalls,
emitFileChanged(payload) {
(eventHandlers['file.changed'] || []).forEach((handler) => handler({
name: 'file.changed',
payload,
timestamp: new Date().toISOString(),
}));
},
files: {
list: async (relativeDir) => {
const prefix = relativeDir ? `${relativeDir}/` : '';
if (!entries.has(relativeDir)) throw new Error(`not-found: ${relativeDir}`);
return Array.from(entries.entries())
.filter(([entryPath]) => entryPath.startsWith(prefix) && entryPath !== relativeDir && !entryPath.slice(prefix.length).includes('/'))
.map(([entryPath, entry]) => ({
name: path.basename(entryPath),
relativePath: entryPath,
type: entry.type,
}));
},
metadata: async (relativePath) => {
if (options.metadataAlwaysExists) return { relativePath, type: 'file' };
const entry = entries.get(relativePath);
if (!entry) throw new Error(`not-found: ${relativePath}`);
return { relativePath, type: entry.type };
},
createFolder: async (relativePath) => {
if (entries.has(relativePath)) throw new Error(`conflict: ${relativePath}`);
entries.set(relativePath, { type: 'folder' });
},
writeText: async (relativePath, content, writeOptions = {}) => {
if (entries.has(relativePath) && !writeOptions.overwrite) throw new Error(`conflict: ${relativePath}`);
const parent = relativePath.split('/').slice(0, -1).join('/');
if (parent && !entries.has(parent)) throw new Error(`parent-not-found: ${parent}`);
entries.set(relativePath, { type: 'file', content });
},
move: async (fromRelativePath, toRelativePath) => {
const entry = entries.get(fromRelativePath);
if (!entry) throw new Error(`not-found: ${fromRelativePath}`);
if (entries.has(toRelativePath)) throw new Error(`conflict: ${toRelativePath}`);
entries.set(toRelativePath, entry);
entries.delete(fromRelativePath);
},
trash: async (relativePath) => {
if (!entries.has(relativePath)) throw new Error(`not-found: ${relativePath}`);
entries.delete(relativePath);
return {
originalPath: relativePath,
trashPath: `.verstak/trash/files/mock/${path.basename(relativePath)}`,
trashId: 'mock',
deletedAt: new Date().toISOString(),
};
},
},
workbench: {
openResource: async (request) => {
opened.push(request);
return { status: 'opened' };
},
},
contributions: {
list: async (point) => {
if (point === 'noteActions') {
return [{
pluginId: 'provider.plugin',
id: 'provider.note.action',
label: 'Provider Note Action',
handler: 'provider.noteCommand',
}];
}
return [];
},
},
commands: {
executeFor: async (pluginId, commandId, args) => {
contributionCalls.push({ pluginId, commandId, args });
return { status: 'handled' };
},
},
events: {
subscribe: async (eventName, handler) => {
eventHandlers[eventName] = eventHandlers[eventName] || [];
eventHandlers[eventName].push(handler);
return () => {
eventHandlers[eventName] = (eventHandlers[eventName] || []).filter((candidate) => candidate !== handler);
};
},
},
};
}
async function flush() {
for (let i = 0; i < 8; i++) {
await Promise.resolve();
}
}
async function mountNotes(api) {
const document = makeDocument();
const component = loadNotesComponent(document);
const container = new FakeNode('div');
component.mount(container, { workspaceNode: { name: 'Project' } }, api);
await flush();
return { container, document, component };
}
(async () => {
const emptyApi = makeApi();
const emptyMounted = await mountNotes(emptyApi);
if (walk(emptyMounted.container, (node) => node.getAttribute && node.getAttribute('data-action') === 'overview')) {
throw new Error('NotesView must not render a persistent Overview button');
}
if (emptyMounted.container.textContent.includes('<svg')) {
throw new Error('NotesView empty state renders raw SVG text');
}
const createApi = makeApi({ metadataAlwaysExists: true });
createApi.entries.set('Project/Notes', { type: 'folder' });
createApi.entries.set('Project/Notes/Second_Note.md', { type: 'file', content: '# Second Note\n' });
const { container, document } = await mountNotes(createApi);
const createButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-action') === 'create');
if (!createButton) throw new Error('create button not found');
createButton.click();
const createModal = walk(document.body, (node) => node.getAttribute && node.getAttribute('data-notes-create-modal') !== undefined);
if (!createModal) throw new Error('create modal not found');
const input = walk(createModal, (node) => node.getAttribute && node.getAttribute('data-notes-create-input') !== undefined);
if (!input) throw new Error('create input not found');
const confirm = walk(createModal, (node) => node.tagName === 'BUTTON' && node.textContent === 'Create');
if (!confirm) throw new Error('create confirm button not found');
confirm.click();
const createError = walk(createModal, (node) => node.getAttribute && node.getAttribute('data-notes-create-error') !== undefined);
if (!createError || !createError.textContent.includes('Enter a note title') || input.getAttribute('aria-invalid') !== 'true') {
throw new Error('create modal does not show a validation error for an empty title');
}
input.value = 'First Note';
input.dispatchEvent('keydown', { key: 'Enter' });
await flush();
const created = createApi.entries.get('Project/Notes/First_Note.md');
if (!created || created.content !== '# First Note\n') {
throw new Error('create note did not write the markdown file');
}
if (document.body.children.some((node) => node.className === 'notes-modal-overlay')) {
throw new Error('create note showed a conflict modal for a new file');
}
if (!createApi.opened.some((request) => request.path === 'Project/Notes/First_Note.md')) {
throw new Error('create note did not open the newly created file');
}
const filterInput = walk(container, (node) => node.getAttribute && node.getAttribute('data-notes-filter') !== undefined);
if (!filterInput) throw new Error('notes filter input not found');
filterInput.value = 'second';
filterInput.dispatchEvent('input');
if (!container.textContent.includes('Second Note') || container.textContent.includes('First Note')) {
throw new Error(`notes filter did not narrow the rendered list: ${container.textContent}`);
}
filterInput.value = 'missing';
filterInput.dispatchEvent('input');
if (!container.textContent.includes('No matching notes')) {
throw new Error('notes filter empty state not shown');
}
filterInput.value = '';
filterInput.dispatchEvent('input');
const sortSelect = walk(container, (node) => node.getAttribute && node.getAttribute('data-notes-sort') !== undefined);
if (!sortSelect) throw new Error('notes sort select not found');
sortSelect.value = 'title-desc';
sortSelect.dispatchEvent('change');
const renderedText = container.textContent;
if (renderedText.indexOf('Second Note') === -1 || renderedText.indexOf('First Note') === -1 || renderedText.indexOf('Second Note') > renderedText.indexOf('First Note')) {
throw new Error(`notes descending sort order is wrong: ${renderedText}`);
}
sortSelect.value = 'title-asc';
sortSelect.dispatchEvent('change');
createApi.entries.set('Project/Notes/Third_Note.md', { type: 'file', content: '# Third Note\n' });
createApi.emitFileChanged({ path: 'Project/Notes/Third_Note.md', operation: 'external.create', type: 'file' });
await flush();
if (!container.textContent.includes('Third Note')) {
throw new Error(`notes list did not refresh after file.changed: ${container.textContent}`);
}
const renameButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-note-action') === 'rename');
if (!renameButton) throw new Error('rename note button not found');
renameButton.click();
const renameModal = walk(document.body, (node) => node.getAttribute && node.getAttribute('data-notes-rename-modal') !== undefined);
if (!renameModal) throw new Error('rename modal not found');
const renameInput = walk(renameModal, (node) => node.getAttribute && node.getAttribute('data-notes-rename-input') !== undefined);
if (!renameInput) throw new Error('rename input not found');
renameInput.value = 'Second Note';
renameInput.dispatchEvent('keydown', { key: 'Enter' });
await flush();
const renameError = walk(renameModal, (node) => node.getAttribute && node.getAttribute('data-notes-rename-error') !== undefined);
if (!renameError || !renameError.textContent.includes('Project/Notes/Second_Note.md')) {
throw new Error(`rename conflict should stay in the form modal, got ${renameError && renameError.textContent}`);
}
if (walk(document.body, (node) => node.className === 'notes-modal-msg')) {
throw new Error('rename conflict opened a second modal instead of reporting in the form');
}
renameInput.dispatchEvent('keydown', { key: 'Escape' });
await flush();
if (walk(document.body, (node) => node.getAttribute && node.getAttribute('data-notes-rename-modal') !== undefined)) {
throw new Error('Escape did not close the rename modal');
}
const providerAction = walk(container, (node) => node.getAttribute && node.getAttribute('data-note-contribution-action') === 'provider.note.action');
if (!providerAction) throw new Error('provider note action button not found');
providerAction.click();
await flush();
if (!createApi.contributionCalls.some((call) => call.pluginId === 'provider.plugin' && call.commandId === 'provider.noteCommand' && call.args.path === 'Project/Notes/First_Note.md')) {
throw new Error(`expected provider note action call, got ${JSON.stringify(createApi.contributionCalls)}`);
}
const trashButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-note-action') === 'trash');
if (!trashButton) throw new Error('trash note button not found');
trashButton.click();
await flush();
const trashConfirm = walk(document.body, (node) => node.getAttribute && node.getAttribute('data-notes-confirm-trash') !== undefined);
if (!trashConfirm) throw new Error('trash confirmation button not found');
trashConfirm.click();
await flush();
if (createApi.entries.has('Project/Notes/First_Note.md')) {
throw new Error('trash note did not remove the markdown file from active entries');
}
console.log('notes plugin smoke passed');
})().catch((err) => {
console.error(err);
process.exit(1);
});
+68
View File
@@ -6,6 +6,10 @@ const vm = require('vm');
const root = path.resolve(__dirname, '..');
const bundlePath = path.join(root, 'plugins', 'platform-test', 'frontend', 'src', 'index.js');
const source = fs.readFileSync(bundlePath, 'utf8');
const platformCatalogs = {
en: JSON.parse(fs.readFileSync(path.join(root, 'plugins', 'platform-test', 'locales', 'en.json'), 'utf8')),
ru: JSON.parse(fs.readFileSync(path.join(root, 'plugins', 'platform-test', 'locales', 'ru.json'), 'utf8')),
};
class FakeNode {
constructor(tagName) {
@@ -37,6 +41,19 @@ class FakeNode {
addEventListener() {}
}
function findByClass(node, className) {
if (String(node.className || '').split(/\s+/).includes(className)) return node;
for (const child of node.children || []) {
const found = findByClass(child, className);
if (found) return found;
}
return null;
}
function allText(node) {
return [String(node.textContent || '')].concat((node.children || []).map(allText)).join(' ');
}
function makeDocument() {
return {
head: new FakeNode('head'),
@@ -76,6 +93,22 @@ if (!components) {
const api = {
pluginId: 'verstak.platform-test',
i18n: {
locale: 'en',
listeners: new Set(),
messages: platformCatalogs,
getLocale() { return this.locale; },
t(key, params, fallback) { return this.messages[this.locale][key] || fallback || key; },
onDidChangeLocale(listener) {
this.listeners.add(listener);
listener(this.locale);
return () => this.listeners.delete(listener);
},
setLocale(locale) {
this.locale = locale;
this.listeners.forEach((listener) => listener(locale));
},
},
settings: {
read: async () => 'initial value',
write: async () => undefined,
@@ -109,6 +142,15 @@ const api = {
writeText: async (relativePath, content) => {
api.files._entries.set(relativePath, { type: 'file', content });
},
readBytes: async (relativePath) => {
const entry = api.files._entries.get(relativePath);
if (!entry) throw new Error(`not-found: ${relativePath}`);
const content = entry.content || '';
return { relativePath, size: content.length, mimeHint: '', dataBase64: Buffer.from(content, 'binary').toString('base64') };
},
writeBytes: async (relativePath, dataBase64) => {
api.files._entries.set(relativePath, { type: 'file', content: Buffer.from(dataBase64, 'base64').toString('binary') });
},
readText: async (relativePath) => {
if (String(relativePath).split('/')[0].toLowerCase() === '.verstak') {
throw new Error('reserved-path: .verstak is internal');
@@ -170,9 +212,35 @@ const api = {
if (container.children.length === 0) {
throw new Error(`${name} mounted no DOM nodes`);
}
if (name === 'DiagnosticsPanel') {
api.i18n.setLocale('ru');
if (findByClass(container, 'pt-plugin-name')?.textContent !== 'Диагностика платформы') {
throw new Error('DiagnosticsPanel did not update after locale change');
}
const diagnosticsText = allText(container);
if (!diagnosticsText.includes('Сохранённая настройка') || !diagnosticsText.includes('Пройдено')) {
throw new Error('DiagnosticsPanel left primary diagnostic chrome untranslated');
}
}
if (name === 'PlatformTestSettings') {
const counter = findByClass(container, 'pt-counter-value');
counter.textContent = '3';
api.i18n.setLocale('ru');
if (findByClass(container, 'pt-plugin-name')?.textContent !== 'Настройки теста платформы') {
throw new Error('PlatformTestSettings did not update after locale change');
}
if (counter.textContent !== '3') {
throw new Error('PlatformTestSettings lost counter state after locale change');
}
const settingsText = allText(container);
if (!settingsText.includes('Панель настроек загружена') || !settingsText.includes('нажатий')) {
throw new Error('PlatformTestSettings left primary settings copy untranslated');
}
}
if (typeof component.unmount === 'function') {
component.unmount(container);
}
api.i18n.setLocale('en');
}
console.log('platform-test frontend smoke passed');
+326
View File
@@ -0,0 +1,326 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const root = path.resolve(__dirname, '..');
const sourcePath = path.join(root, 'plugins', 'search', 'frontend', 'src', 'index.js');
const manifestPath = path.join(root, 'plugins', 'search', 'plugin.json');
const source = fs.readFileSync(sourcePath, 'utf8');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const technicalErrors = [];
class FakeNode {
constructor(tagName) {
this.tagName = String(tagName || '').toUpperCase();
this.children = [];
this.attributes = {};
this.listeners = {};
this.className = '';
this.value = '';
this.disabled = false;
this.parentNode = null;
this._textContent = '';
this._innerHTML = '';
}
appendChild(node) {
this.children.push(node);
node.parentNode = this;
return node;
}
setAttribute(name, value) {
this.attributes[name] = String(value);
}
getAttribute(name) {
return this.attributes[name];
}
addEventListener(type, handler) {
this.listeners[type] = this.listeners[type] || [];
this.listeners[type].push(handler);
}
dispatchEvent(type, event = {}) {
(this.listeners[type] || []).forEach((handler) => handler({ target: this, preventDefault() {}, stopPropagation() {}, ...event }));
}
click() {
this.dispatchEvent('click');
}
set innerHTML(value) {
this._innerHTML = String(value || '');
this.children = [];
}
get innerHTML() {
return this._innerHTML + this.children.map((child) => child.innerHTML).join('');
}
set textContent(value) {
this._textContent = String(value || '');
this.children = [];
}
get textContent() {
if (this.tagName === '#TEXT') return this._textContent;
return this._textContent + this.children.map((child) => child.textContent).join('');
}
}
function walk(node, fn) {
if (fn(node)) return node;
for (const child of node.children) {
const found = walk(child, fn);
if (found) return found;
}
return null;
}
function makeDocument() {
return {
body: new FakeNode('body'),
head: new FakeNode('head'),
createElement(tagName) {
return new FakeNode(tagName);
},
createTextNode(text) {
const node = new FakeNode('#text');
node.textContent = text;
return node;
},
getElementById() {
return null;
},
};
}
function loadComponent(document) {
const registry = {};
vm.runInNewContext(source, {
console: {
...console,
warn(...args) {
technicalErrors.push(args.map((value) => String(value)).join(' '));
},
error(...args) {
technicalErrors.push(args.map((value) => String(value)).join(' '));
},
},
document,
window: {
VerstakPluginRegister(pluginId, bundle) {
registry[pluginId] = bundle.components || {};
},
},
setTimeout,
clearTimeout,
}, { filename: sourcePath });
const component = registry['verstak.search'] && registry['verstak.search'].SearchView;
if (!component) throw new Error('SearchView was not registered');
return component;
}
async function flush() {
for (let i = 0; i < 12; i++) {
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
async function wait(ms) {
await new Promise((resolve) => setTimeout(resolve, ms));
await flush();
}
(async () => {
const document = makeDocument();
const component = loadComponent(document);
const opened = [];
const fileContents = {
'Project/Docs/case.md': '# Case\nTarget phrase is here.\n',
'Project/Docs/notes.txt': 'No match here.\n',
};
const pluginData = {};
const commandHandlers = new Map();
const eventHandlers = {};
const providerCalls = [];
const api = {
storage: {
data: {
read: async (name) => pluginData[name] || {},
write: async (name, data) => {
pluginData[name] = JSON.parse(JSON.stringify(data || {}));
},
},
},
commands: {
register: async (commandId, handler) => {
commandHandlers.set(commandId, handler);
return () => commandHandlers.delete(commandId);
},
executeFor: async (pluginId, commandId, args) => {
providerCalls.push({ pluginId, commandId, args });
if (pluginId === 'external.notes' && commandId === 'external.notes.search') {
return {
status: 'handled',
pluginId,
commandId,
result: [{
path: 'Project/External/target.note',
type: 'note',
matchType: 'External note',
snippet: 'External provider target result',
openable: false,
}],
};
}
if (pluginId === 'broken.provider') {
throw new Error('provider unavailable');
}
throw new Error(`unexpected provider call ${pluginId}:${commandId}`);
},
},
contributions: {
list: async (point) => {
if (point !== 'searchProviders') return [];
return [
{ pluginId: 'verstak.search', id: 'verstak.search.vault-text', label: 'Vault Text Search', handler: 'verstak.search.searchVaultText' },
{ pluginId: 'external.notes', id: 'external.notes.search', label: 'External Notes', handler: 'external.notes.search' },
{ pluginId: 'broken.provider', id: 'broken.provider.search', label: 'Broken Provider', handler: 'broken.provider.search' },
];
},
},
events: {
subscribe: async (eventName, handler) => {
eventHandlers[eventName] = eventHandlers[eventName] || [];
eventHandlers[eventName].push(handler);
return () => {
eventHandlers[eventName] = (eventHandlers[eventName] || []).filter((candidate) => candidate !== handler);
};
},
},
files: {
list: async (relativeDir) => {
if (relativeDir === 'Project') {
return [
{ name: 'Docs', relativePath: 'Project/Docs', type: 'folder' },
{ name: 'Target Assets', relativePath: 'Project/Target Assets', type: 'folder' },
{ name: 'image.png', relativePath: 'Project/image.png', type: 'file', extension: 'png' },
];
}
if (relativeDir === 'Project/Docs') {
return [
{ name: 'case.md', relativePath: 'Project/Docs/case.md', type: 'file', extension: 'md' },
{ name: 'notes.txt', relativePath: 'Project/Docs/notes.txt', type: 'file', extension: 'txt' },
];
}
return [];
},
readText: async (relativePath) => {
if (Object.prototype.hasOwnProperty.call(fileContents, relativePath)) return fileContents[relativePath];
throw new Error('unexpected readText path ' + relativePath);
},
},
workbench: {
openResource: async (request) => {
opened.push(request);
},
},
};
const container = new FakeNode('div');
component.mount(container, { workspaceRootPath: 'Project' }, api);
await flush();
if (!commandHandlers.has('verstak.search.searchVaultText')) throw new Error('search provider command was not registered');
if (!eventHandlers['file.changed'] || eventHandlers['file.changed'].length !== 1) throw new Error('file.changed subscription was not registered');
if (!manifest.permissions.includes('storage.namespace')) throw new Error('search manifest must request storage.namespace');
if (!manifest.permissions.includes('events.subscribe')) throw new Error('search manifest must request events.subscribe');
if (!manifest.permissions.includes('commands.register')) throw new Error('search manifest must request commands.register');
const command = (manifest.contributes.commands || []).find((item) => item.id === 'verstak.search.searchVaultText');
if (!command || command.handler !== 'verstak.search.searchVaultText') throw new Error('search command contribution is missing');
const provider = (manifest.contributes.searchProviders || []).find((item) => item.id === 'verstak.search.vault-text');
if (!provider || provider.handler !== 'verstak.search.searchVaultText') throw new Error('search provider must point at the command handler');
function queryInput() {
const input = walk(container, (node) => node.getAttribute && node.getAttribute('data-search-input') === 'query');
if (!input) throw new Error('query input not found');
return input;
}
let input = queryInput();
input.value = 'target';
input.dispatchEvent('input');
await wait(360);
if (!container.textContent.includes('Project/Docs/case.md')) throw new Error('typing should search file contents');
if (!container.textContent.includes('Target phrase is here')) throw new Error('typing should render content snippet');
if (!container.textContent.includes('Project/Target Assets')) throw new Error('typing should search folder paths');
if (!container.textContent.includes('Project/External/target.note')) throw new Error('external provider result should be rendered');
if (!container.textContent.includes('External Notes')) throw new Error('external provider label should be rendered');
if (!container.textContent.includes('A search provider is unavailable.')) throw new Error('provider failure should be reported without failing search');
if (container.textContent.includes('provider unavailable')) throw new Error('provider failure leaked a raw backend error');
if (!container.textContent.includes('Content match')) throw new Error('content result type was not rendered');
if (!container.textContent.includes('Folder name')) throw new Error('folder result type was not rendered');
if (!pluginData['search-index'] || !Array.isArray(pluginData['search-index'].files)) throw new Error('search index was not written to plugin data storage');
if (providerCalls.some((call) => call.pluginId === 'verstak.search')) throw new Error('search must not call itself as an external provider');
if (!technicalErrors.some((entry) => entry.includes('provider unavailable'))) throw new Error('provider failure was not retained in the console log');
input = queryInput();
input.value = 'image';
input.dispatchEvent('input');
await wait(360);
if (!container.textContent.includes('Project/image.png')) throw new Error('binary file path match should be rendered');
if (!container.textContent.includes('File name')) throw new Error('file name result type was not rendered');
const button = walk(container, (node) => node.getAttribute && node.getAttribute('data-search-action') === 'run');
if (!button) throw new Error('search button not found');
input = queryInput();
input.value = 'target';
input.dispatchEvent('input');
button.click();
await flush();
if (!container.textContent.includes('Project/Docs/case.md')) throw new Error('matching file path was not rendered');
if (!container.textContent.includes('Target phrase is here')) throw new Error('matching snippet was not rendered');
if (container.textContent.includes('image.png')) throw new Error('binary image file should not be rendered as a result');
fileContents['Project/Docs/notes.txt'] = 'Edited target appears after file change.\n';
eventHandlers['file.changed'].forEach((handler) => handler({
type: 'file.changed',
payload: { relativePath: 'Project/Docs/notes.txt', changeType: 'write' },
}));
await flush();
input = queryInput();
input.value = 'edited target';
input.dispatchEvent('input');
button.click();
await flush();
if (!container.textContent.includes('Project/Docs/notes.txt')) throw new Error('file.changed should refresh the persisted search index');
input = queryInput();
input.value = 'target';
input.dispatchEvent('input');
button.click();
await flush();
const openButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-search-open') === 'Project/Docs/case.md');
if (!openButton) throw new Error('result open button not found');
openButton.click();
await flush();
if (!opened[0] || opened[0].path !== 'Project/Docs/case.md' || opened[0].mode !== 'view') {
throw new Error('result did not open through workbench');
}
console.log('search plugin smoke passed');
})().catch((err) => {
console.error(err);
process.exit(1);
});
+351
View File
@@ -0,0 +1,351 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const root = path.resolve(__dirname, '..');
const sourcePath = path.join(root, 'plugins', 'secrets', 'frontend', 'src', 'index.js');
const manifestPath = path.join(root, 'plugins', 'secrets', 'plugin.json');
const source = fs.readFileSync(sourcePath, 'utf8');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
class FakeNode {
constructor(tagName) {
this.tagName = String(tagName || '').toUpperCase();
this.children = [];
this.attributes = {};
this.listeners = {};
this.className = '';
this.value = '';
this.disabled = false;
this.parentNode = null;
this._textContent = '';
}
appendChild(node) {
this.children.push(node);
node.parentNode = this;
return node;
}
removeChild(node) {
this.children = this.children.filter((child) => child !== node);
node.parentNode = null;
return node;
}
remove() {
if (this.parentNode) this.parentNode.removeChild(this);
}
setAttribute(name, value) {
this.attributes[name] = String(value);
}
getAttribute(name) {
return this.attributes[name];
}
addEventListener(type, handler) {
this.listeners[type] = this.listeners[type] || [];
this.listeners[type].push(handler);
}
dispatchEvent(type, event = {}) {
(this.listeners[type] || []).forEach((handler) => handler({ target: this, preventDefault() {}, stopPropagation() {}, ...event }));
}
click() {
this.dispatchEvent('click');
}
set textContent(value) {
this._textContent = String(value || '');
this.children = [];
}
get textContent() {
if (this.tagName === '#TEXT') return this._textContent;
return this._textContent + this.children.map((child) => child.textContent).join('');
}
set innerHTML(value) {
this._textContent = String(value || '');
this.children = [];
}
get innerHTML() {
return this.textContent;
}
}
function walk(node, fn) {
if (fn(node)) return node;
for (const child of node.children) {
const found = walk(child, fn);
if (found) return found;
}
return null;
}
function makeDocument() {
return {
head: new FakeNode('head'),
body: new FakeNode('body'),
createElement(tagName) {
return new FakeNode(tagName);
},
createTextNode(text) {
const node = new FakeNode('#text');
node.textContent = text;
return node;
},
getElementById() {
return null;
},
};
}
function loadComponent(document, errorLog) {
const registry = {};
vm.runInNewContext(source, {
console: {
...console,
warn(...args) {
errorLog.push(args.map((value) => String(value)).join(' '));
},
error(...args) {
errorLog.push(args.map((value) => String(value)).join(' '));
},
},
document,
window: {
VerstakPluginRegister(pluginId, bundle) {
registry[pluginId] = bundle.components || {};
},
confirm: () => true,
navigator: { clipboard: { writeText: async () => undefined } },
},
setTimeout,
clearTimeout,
}, { filename: sourcePath });
const component = registry['verstak.secrets'] && registry['verstak.secrets'].SecretsView;
if (!component) throw new Error('SecretsView was not registered');
return component;
}
async function flush() {
for (let i = 0; i < 12; i++) {
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
(async () => {
if (!manifest.provides.includes('secret-store')) throw new Error('secrets manifest must provide secret-store');
if (!manifest.provides.includes('secrets.read-ui')) throw new Error('secrets manifest must provide secrets.read-ui');
if (!manifest.provides.includes('secrets.write-ui')) throw new Error('secrets manifest must provide secrets.write-ui');
if (!manifest.permissions.includes('secrets.read')) throw new Error('secrets manifest must request secrets.read');
if (!manifest.permissions.includes('secrets.write')) throw new Error('secrets manifest must request secrets.write');
if (!manifest.permissions.includes('files.read')) throw new Error('secrets manifest must request files.read for the Deal selector');
if (!manifest.permissions.includes('ui.register')) throw new Error('secrets manifest must request ui.register');
if (!(manifest.contributes.openProviders || []).some((item) => (item.supports || []).some((support) => support.kind === 'secret'))) throw new Error('secrets secret open provider missing');
if (!(manifest.contributes.views || []).some((item) => item.component === 'SecretsView')) throw new Error('secrets global view missing');
if (!(manifest.contributes.sidebarItems || []).some((item) => item.view === 'verstak.secrets.view')) throw new Error('secrets global sidebar item missing');
if (!(manifest.contributes.workspaceItems || []).some((item) => item.component === 'SecretsView')) throw new Error('secrets workspace item missing');
if (!(manifest.contributes.settingsPanels || []).some((item) => item.component === 'SecretsView')) throw new Error('secrets settings panel missing');
const document = makeDocument();
const errorLog = [];
const component = loadComponent(document, errorLog);
const records = [
{ id: 'global.server', title: 'Global Server', username: 'root', scope: { kind: 'global' }, updatedAt: '2026-06-29T00:00:00Z' },
{ id: 'client-a.db', title: 'Client A DB', username: 'app', scope: { kind: 'workspace', workspaceRootPath: 'ClientA' }, updatedAt: '2026-06-29T00:00:00Z' },
];
let initialized = false;
let unlocked = false;
let unlockError = '';
const readCalls = [];
const copied = [];
const deleted = [];
const api = {
secrets: {
status: async () => ({ initialized, unlocked }),
unlock: async (password) => {
if (unlockError) throw new Error(unlockError);
if (password !== 'master-password') throw new Error('bad password');
initialized = true;
unlocked = true;
},
list: async () => records,
read: async (id) => {
readCalls.push(id);
return { ...records.find((record) => record.id === id), value: 'secret-value' };
},
write: async (record) => {
const next = { ...record, id: record.id || 'generated.id', updatedAt: '2026-06-29T00:00:00Z' };
const idx = records.findIndex((item) => item.id === next.id);
const listRecord = { ...next };
delete listRecord.value;
if (idx >= 0) records[idx] = listRecord;
else records.push(listRecord);
return listRecord;
},
delete: async (id) => {
deleted.push(id);
const idx = records.findIndex((record) => record.id === id);
if (idx >= 0) records.splice(idx, 1);
},
copyLink: async (id) => `[${records.find((record) => record.id === id).title}](verstak-secret://${id})`,
},
clipboard: {
writeText: async (text) => copied.push(text),
},
files: {
list: async () => [
{ type: 'folder', relativePath: 'ClientA' },
{ type: 'folder', relativePath: 'ClientB' },
],
},
};
const container = document.createElement('div');
component.mount(container, { workspaceRootPath: 'ClientA', resource: { path: 'client-a.db' } }, api);
await flush();
if (!container.textContent.includes('Create master password')) throw new Error('setup screen did not render');
const passwordInput = walk(container, (node) => node.getAttribute && node.getAttribute('data-secret-master-password') === '');
const confirmInput = walk(container, (node) => node.getAttribute && node.getAttribute('data-secret-master-password-confirm') === '');
const unlockButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-secret-unlock') === '');
if (!passwordInput || !confirmInput || !unlockButton) throw new Error('setup controls missing');
unlockError = '[plugin:verstak.secrets] secrets.unlock failed: master password must be at least 8 characters';
passwordInput.value = 'short';
confirmInput.value = 'short';
unlockButton.click();
await flush();
if (!container.textContent.includes('Master password must be at least 8 characters')) {
throw new Error('weak master password error was not explained clearly');
}
if (container.textContent.includes('[plugin:') || container.textContent.includes('secrets.unlock')) {
throw new Error('technical unlock details leaked into the Secrets UI');
}
if (!errorLog.some((entry) => entry.includes('[plugin:verstak.secrets] secrets.unlock failed'))) {
throw new Error('technical unlock details were not retained in the console log');
}
unlockError = '';
passwordInput.value = 'master-password';
confirmInput.value = 'master-password';
unlockButton.click();
await flush();
if (!container.textContent.includes('Global')) throw new Error('global group missing');
if (!container.textContent.includes('ClientA')) throw new Error('workspace group missing');
if (!container.textContent.includes('Client A DB')) throw new Error('workspace secret missing');
if (!readCalls.includes('client-a.db')) throw new Error('deep-linked secret was not selected/read');
if (!container.textContent.includes('Group')) throw new Error('secret field table missing Group row');
if (!container.textContent.includes('Username')) throw new Error('secret field table missing Username row');
if (!container.textContent.includes('Password')) throw new Error('secret field table missing Password row');
if (container.textContent.includes('secret-value')) throw new Error('secret value must stay hidden until the user explicitly reveals it');
const copyValueButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-secret-copy-value') === 'client-a.db');
if (!copyValueButton) throw new Error('copy value button missing');
copyValueButton.click();
await flush();
if (!copied.includes('secret-value')) throw new Error('secret value was not copied');
const showValueButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-secret-toggle-value') === 'client-a.db');
if (!showValueButton) throw new Error('show value button missing');
showValueButton.click();
await flush();
if (!container.textContent.includes('secret-value')) throw new Error('secret value was not revealed after explicit request');
const copyButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-secret-copy-link') === 'client-a.db');
if (!copyButton) throw new Error('copy link button missing');
copyButton.click();
await flush();
if (!copied.includes('[Client A DB](verstak-secret://client-a.db)')) throw new Error('secret link was not copied');
const editButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-secret-edit') === 'client-a.db');
if (!editButton) throw new Error('edit button missing');
editButton.click();
await flush();
const titleInput = walk(container, (node) => node.getAttribute && node.getAttribute('data-secret-title') === '');
const valueInput = walk(container, (node) => node.getAttribute && node.getAttribute('data-secret-value') === '');
const saveButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-secret-save') === '');
if (!titleInput || !valueInput || !saveButton) throw new Error('edit form controls missing');
titleInput.value = 'Client A DB Updated';
valueInput.value = 'updated-secret-value';
saveButton.click();
await flush();
if (!records.some((record) => record.id === 'client-a.db' && record.title === 'Client A DB Updated')) throw new Error('secret edit did not persist');
const deleteButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-secret-delete') === 'client-a.db');
if (!deleteButton) throw new Error('delete button missing');
deleteButton.click();
await flush();
if (deleted.includes('client-a.db')) throw new Error('secret delete must wait for confirmation');
const deleteConfirmModal = walk(document.body, (node) => node.getAttribute && node.getAttribute('data-secret-delete-modal') === '');
if (!deleteConfirmModal) throw new Error('secret delete confirmation modal missing');
const deleteCancelButton = walk(deleteConfirmModal, (node) => node.getAttribute && node.getAttribute('data-secret-delete-cancel') === '');
if (!deleteCancelButton) throw new Error('secret delete cancel button missing');
deleteCancelButton.click();
await flush();
if (deleted.includes('client-a.db')) throw new Error('secret delete ran after cancelling the confirmation');
deleteButton.click();
await flush();
const deleteConfirmButton = walk(document.body, (node) => node.getAttribute && node.getAttribute('data-secret-delete-confirm') === '');
if (!deleteConfirmButton) throw new Error('secret delete confirm button missing');
deleteConfirmButton.click();
await flush();
if (!deleted.includes('client-a.db')) throw new Error('secret delete was not called');
records.push({ id: 'client-a.global-view', title: 'Client A Global View', username: 'app', scope: { kind: 'workspace', workspaceRootPath: 'ClientA' }, updatedAt: '2026-06-29T00:00:00Z' });
const globalContainer = document.createElement('div');
component.mount(globalContainer, {}, api);
await flush();
if (!globalContainer.textContent.includes('Global Server') || !globalContainer.textContent.includes('Client A Global View')) {
throw new Error('global secrets view did not show global and Deal-scoped records');
}
const scopeFilter = walk(globalContainer, (node) => node.getAttribute && node.getAttribute('data-secret-scope-filter') === '');
if (!scopeFilter) throw new Error('global secrets scope filter missing');
scopeFilter.value = 'workspace:ClientA';
scopeFilter.dispatchEvent('change');
await flush();
if (!globalContainer.textContent.includes('Client A Global View') || globalContainer.textContent.includes('Global Server')) {
throw new Error('global secrets scope filter did not isolate the selected Deal');
}
const searchInput = walk(globalContainer, (node) => node.getAttribute && node.getAttribute('data-secret-search') === '');
if (!searchInput) throw new Error('global secrets search missing');
searchInput.value = 'no-match';
searchInput.dispatchEvent('input');
await flush();
if (!globalContainer.textContent.includes('No secrets')) throw new Error('global secrets search did not filter records');
scopeFilter.value = 'all';
scopeFilter.dispatchEvent('change');
await flush();
const newButton = walk(globalContainer, (node) => node.tagName === 'BUTTON' && node.textContent === 'New');
if (!newButton) throw new Error('global secrets new button missing');
newButton.click();
await flush();
const globalTitleInput = walk(globalContainer, (node) => node.getAttribute && node.getAttribute('data-secret-title') === '');
const globalValueInput = walk(globalContainer, (node) => node.getAttribute && node.getAttribute('data-secret-value') === '');
const scopeInput = walk(globalContainer, (node) => node.getAttribute && node.getAttribute('data-secret-scope') === '');
const workspaceInput = walk(globalContainer, (node) => node.getAttribute && node.getAttribute('data-secret-workspace') === '');
const globalSaveButton = walk(globalContainer, (node) => node.getAttribute && node.getAttribute('data-secret-save') === '');
if (!globalTitleInput || !globalValueInput || !scopeInput || !workspaceInput || !globalSaveButton) throw new Error('global Deal-scoped secret form controls missing');
globalTitleInput.value = 'Client B API';
globalValueInput.value = 'client-b-api-value';
scopeInput.value = 'workspace';
scopeInput.dispatchEvent('change');
workspaceInput.value = 'ClientB';
globalSaveButton.click();
await flush();
if (!records.some((record) => record.title === 'Client B API' && record.scope && record.scope.workspaceRootPath === 'ClientB')) {
throw new Error('global secrets form did not create a Deal-scoped secret');
}
console.log('secrets plugin smoke passed');
})().catch((err) => {
console.error(err);
process.exit(1);
});
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const root = path.resolve(__dirname, '..');
const sourcePath = path.join(root, 'plugins', 'sync', 'frontend', 'src', 'SyncSettings.svelte');
const source = fs.readFileSync(sourcePath, 'utf8');
if (!source.includes('settings.lastError')) {
throw new Error('SyncSettings must render persisted settings.lastError');
}
if (!source.includes('function reportError')) {
throw new Error('SyncSettings must map technical failures to localized action-specific errors');
}
if (/sanitizeError\(/.test(source) || /\{ error: sanitizeError/.test(source)) {
throw new Error('SyncSettings must not interpolate raw technical errors into user-facing messages');
}
if (!source.includes("tr('ui.lastSyncError'")) {
throw new Error('SyncSettings must label the persisted sync error');
}
if (!source.includes("tr('ui.syncConflictItem'")) {
throw new Error('SyncSettings must hide technical conflict identifiers behind a user-facing summary');
}
if (!source.includes('conflictDetails')) {
throw new Error('SyncSettings must store sync conflict details after Sync Now');
}
if (!source.includes('Sync conflicts')) {
throw new Error('SyncSettings must label the conflict details section');
}
console.log('sync plugin smoke passed');
+371
View File
@@ -0,0 +1,371 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const root = path.resolve(__dirname, '..');
const sourcePath = path.join(root, 'plugins', 'todo', 'frontend', 'src', 'index.js');
const manifestPath = path.join(root, 'plugins', 'todo', 'plugin.json');
const source = fs.readFileSync(sourcePath, 'utf8');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const localeCatalogs = {
en: JSON.parse(fs.readFileSync(path.join(root, 'plugins', 'todo', 'locales', 'en.json'), 'utf8')),
ru: JSON.parse(fs.readFileSync(path.join(root, 'plugins', 'todo', 'locales', 'ru.json'), 'utf8')),
};
class FakeNode {
constructor(tagName) {
this.tagName = String(tagName || '').toUpperCase();
this.children = [];
this.attributes = {};
this.listeners = {};
this.style = {};
this.className = '';
this.value = '';
this.checked = false;
this.disabled = false;
this.parentNode = null;
this._textContent = '';
this._innerHTML = '';
}
appendChild(node) {
if (!(node instanceof FakeNode)) throw new TypeError('appendChild expects FakeNode');
this.children.push(node);
node.parentNode = this;
return node;
}
setAttribute(name, value) {
this.attributes[name] = String(value);
}
getAttribute(name) {
return this.attributes[name];
}
removeAttribute(name) {
delete this.attributes[name];
}
addEventListener(type, handler) {
this.listeners[type] = this.listeners[type] || [];
this.listeners[type].push(handler);
}
dispatchEvent(type, event = {}) {
(this.listeners[type] || []).forEach((handler) => handler({
target: this,
currentTarget: this,
preventDefault() {},
stopPropagation() {},
...event,
}));
}
click() {
this.dispatchEvent('click');
}
focus() {}
set innerHTML(value) {
this._innerHTML = String(value || '');
this.children = [];
}
get innerHTML() {
return this._innerHTML;
}
set textContent(value) {
this._textContent = String(value || '');
this.children = [];
}
get textContent() {
if (this.tagName === '#TEXT') return this._textContent;
return this._textContent + this.children.map((child) => child.textContent).join('');
}
}
function walk(node, predicate) {
if (predicate(node)) return node;
for (const child of node.children) {
const found = walk(child, predicate);
if (found) return found;
}
return null;
}
function byData(node, name, value) {
return walk(node, (item) => item.getAttribute && item.getAttribute(name) === value);
}
function makeDocument() {
return {
head: new FakeNode('head'),
body: new FakeNode('body'),
createElement(tagName) {
return new FakeNode(tagName);
},
createTextNode(value) {
const node = new FakeNode('#text');
node.textContent = value;
return node;
},
getElementById() {
return null;
},
};
}
function FakeCustomEvent(type, options = {}) {
this.type = type;
this.detail = options.detail;
}
function loadComponent(document, emittedEvents) {
const registry = {};
const window = {
VerstakPluginRegister(pluginId, bundle) {
registry[pluginId] = bundle.components || {};
},
dispatchEvent(event) {
emittedEvents.push(event);
},
};
window.window = window;
window.document = document;
window.CustomEvent = FakeCustomEvent;
vm.runInNewContext(source, { console, Date, Math, CustomEvent: FakeCustomEvent, document, window }, { filename: sourcePath });
const component = registry['verstak.todo'] && registry['verstak.todo'].TodoView;
if (!component) throw new Error('TodoView was not registered');
return component;
}
function makeApi(initialSettings = {}, initialLocale = 'en') {
const settings = { ...initialSettings };
const notificationCalls = [];
let locale = initialLocale;
const localeListeners = [];
return {
settings,
notificationCalls,
settingsApi: {
read: async (key) => (key ? settings[key] : { ...settings }),
write: async (key, value) => {
settings[key] = value;
return { ...settings };
},
},
files: {
list: async () => [
{ name: 'ClientA', relativePath: 'ClientA', type: 'folder' },
{ name: 'Project', relativePath: 'Project', type: 'folder' },
],
},
notifications: {
replace: async (items) => {
notificationCalls.push((Array.isArray(items) ? items : []).map((item) => ({ ...item })));
},
},
setLocale(nextLocale) {
locale = nextLocale;
localeListeners.slice().forEach((listener) => listener(locale));
},
get api() {
return {
settings: this.settingsApi,
files: this.files,
notifications: this.notifications,
i18n: {
getLocale: () => locale,
t: (key, params, fallback) => {
const message = localeCatalogs[locale]?.[key] || localeCatalogs.en[key] || fallback || key;
return message.replace(/\{([^}]+)\}/g, (placeholder, name) => (
Object.prototype.hasOwnProperty.call(params || {}, name) ? String(params[name]) : placeholder
));
},
onDidChangeLocale: (listener) => {
localeListeners.push(listener);
return () => {
const index = localeListeners.indexOf(listener);
if (index !== -1) localeListeners.splice(index, 1);
};
},
},
};
},
};
}
async function flush() {
for (let index = 0; index < 10; index += 1) await Promise.resolve();
}
async function mountWithApi(apiState, props, emittedEvents = [], document = makeDocument()) {
const component = loadComponent(document, emittedEvents);
const container = new FakeNode('div');
component.mount(container, props, apiState.api);
await flush();
return { component, container, document, emittedEvents };
}
(async () => {
if (manifest.id !== 'verstak.todo') throw new Error('todo manifest id mismatch');
if (!manifest.permissions.includes('storage.namespace')) throw new Error('todo manifest must request storage.namespace');
if (!manifest.permissions.includes('files.read')) throw new Error('todo manifest must request files.read');
if (!manifest.permissions.includes('notifications.schedule')) throw new Error('todo manifest must request notifications.schedule');
if (!manifest.requires.includes('verstak/core/notifications/v1')) throw new Error('todo manifest must require native notifications');
if (!(manifest.contributes.views || []).length) throw new Error('todo manifest must contribute a global view');
if (!(manifest.contributes.workspaceItems || []).length) throw new Error('todo manifest must contribute a workspace item');
const apiState = makeApi();
const workspaceView = await mountWithApi(apiState, { workspaceRootPath: 'Project', workspaceNode: { name: 'Project' } });
const { container } = workspaceView;
if (!container.textContent.includes('Todos · Project')) throw new Error('workspace Todo title was not rendered');
if (!byData(container, 'data-todo-action', 'add')) throw new Error('workspace add action was not rendered');
byData(container, 'data-todo-action', 'add').click();
byData(container, 'data-todo-input', 'title').value = 'Prepare project review';
byData(container, 'data-todo-input', 'description').value = 'Collect factual review notes.';
byData(container, 'data-todo-input', 'priority').value = 'high';
byData(container, 'data-todo-input', 'dueAt').value = '01/02/2000';
const reminderDate = byData(container, 'data-todo-input', 'reminderDate');
const reminderTime = byData(container, 'data-todo-input', 'reminderTime');
if (!reminderDate || reminderDate.getAttribute('type') !== 'date') throw new Error('Todo reminder date input was not rendered');
if (!reminderTime || reminderTime.getAttribute('type') !== 'text') throw new Error('Todo reminder time must be a keyboard-editable text input');
reminderDate.value = '01/02/2000';
reminderTime.value = '09:30';
byData(container, 'data-todo-action', 'save').click();
await flush();
const storedAfterCreate = apiState.settings['todos:global'] || [];
if (storedAfterCreate.length !== 1) throw new Error(`expected one stored todo, got ${storedAfterCreate.length}`);
const createdTodo = storedAfterCreate[0];
if (createdTodo.workspaceRootPath !== 'Project') throw new Error('workspace Todo did not keep the Project root path');
if (createdTodo.status !== 'open' || createdTodo.priority !== 'high') throw new Error('Todo status or priority was not stored');
if (createdTodo.dueAt !== '2000-01-02' || createdTodo.reminderDate !== '2000-01-02' || createdTodo.reminderAt !== '2000-01-02T09:30') throw new Error('Todo due/reminder metadata was not stored');
if (!container.textContent.includes('Overdue') || !container.textContent.includes('Reminder due')) throw new Error('due/reminder indicators were not rendered');
const scheduledAfterCreate = apiState.notificationCalls.at(-1) || [];
if (scheduledAfterCreate.length !== 1
|| scheduledAfterCreate[0].id !== createdTodo.id
|| scheduledAfterCreate[0].dueAt !== new Date(createdTodo.reminderAt).toISOString()
|| scheduledAfterCreate[0].title !== 'Todo reminder'
|| scheduledAfterCreate[0].body !== 'Prepare project review') {
throw new Error('open Todo reminder was not scheduled as a native notification');
}
apiState.setLocale('ru');
if (!container.textContent.includes('Задачи · Project') || !container.textContent.includes('Просрочено')) {
throw new Error('Todo view did not update to Russian without remounting');
}
byData(container, 'data-todo-action', 'add').click();
if (!container.textContent.includes('Дело: Project')) {
throw new Error('Todo editor did not use Deal terminology in Russian');
}
const cancelModal = walk(container, (node) => node.tagName === 'BUTTON' && node.textContent === 'Отмена' && !node.getAttribute('data-todo-action'));
if (!cancelModal) throw new Error('Todo editor cancel action was not rendered in Russian');
cancelModal.click();
if ((apiState.settings['todos:global'] || []).length !== 1) throw new Error('locale change lost Todo state');
apiState.setLocale('en');
byData(container, 'data-todo-action', 'edit').click();
byData(container, 'data-todo-input', 'title').value = 'Prepare project review updated';
byData(container, 'data-todo-input', 'reminderTime').value = '29:30';
byData(container, 'data-todo-action', 'save').click();
await flush();
if (apiState.settings['todos:global'][0].title !== 'Prepare project review' || !container.textContent.includes('Enter a valid reminder time')) {
throw new Error('invalid reminder time was saved instead of showing a human-readable error');
}
byData(container, 'data-todo-input', 'reminderTime').value = '';
byData(container, 'data-todo-action', 'save').click();
await flush();
if (apiState.settings['todos:global'][0].title !== 'Prepare project review updated') throw new Error('Todo edit was not persisted');
if (apiState.settings['todos:global'][0].reminderDate !== '2000-01-02' || apiState.settings['todos:global'][0].reminderAt !== '') {
throw new Error('clearing reminder time did not preserve its date while cancelling the reminder');
}
if ((apiState.notificationCalls.at(-1) || []).length !== 0) throw new Error('clearing reminder time did not remove the native notification');
byData(container, 'data-todo-action', 'mark-done').click();
await flush();
if (apiState.settings['todos:global'][0].status !== 'done' || !apiState.settings['todos:global'][0].completedAt) {
throw new Error('mark done did not persist completed state');
}
if ((apiState.notificationCalls.at(-1) || []).length !== 0) {
throw new Error('completed Todo reminder was not removed from native notification schedules');
}
if (!byData(container, 'data-todo-action', 'reopen')) throw new Error('done Todo did not expose reopen action');
if (!byData(container, 'data-todo-action', 'create-journal-entry')) throw new Error('done workspace Todo did not expose Journal conversion');
byData(container, 'data-todo-action', 'create-journal-entry').click();
const journalEvent = workspaceView.emittedEvents.find((event) => event.type === 'verstak:workspace-open-tool');
if (!journalEvent || !journalEvent.detail || journalEvent.detail.kind !== 'journal') {
throw new Error('Todo Journal conversion did not request the Journal workspace tool');
}
const journalRequest = journalEvent.detail.toolRequest;
if (!journalRequest || journalRequest.type !== 'completed-todo' || !journalRequest.todo) {
throw new Error('Todo Journal conversion did not send a completed-todo request');
}
if (journalRequest.todo.id !== createdTodo.id
|| journalRequest.todo.title !== 'Prepare project review updated'
|| journalRequest.todo.description !== 'Collect factual review notes.'
|| journalRequest.todo.workspaceRootPath !== 'Project'
|| !journalRequest.todo.completedAt) {
throw new Error('Todo Journal conversion did not preserve factual completed Todo fields');
}
apiState.settings['todos:global'].push({
id: 'todo-client',
title: 'Client follow-up',
workspaceRootPath: 'ClientA',
status: 'open',
priority: 'normal',
createdAt: '2026-06-30T08:00:00.000Z',
updatedAt: '2026-06-30T08:00:00.000Z',
});
const globalView = await mountWithApi(apiState, {});
if (!globalView.container.textContent.includes('Prepare project review updated') || !globalView.container.textContent.includes('Client follow-up')) {
throw new Error('global Todo view did not aggregate workspace todos');
}
if (!globalView.container.textContent.includes('All Deals')) {
throw new Error('global Todo filter did not use Deal terminology');
}
const workspaceFilter = byData(globalView.container, 'data-todo-filter', 'workspace');
workspaceFilter.value = 'ClientA';
workspaceFilter.dispatchEvent('change');
await flush();
if (!globalView.container.textContent.includes('Client follow-up') || globalView.container.textContent.includes('Prepare project review updated')) {
throw new Error('global Todo workspace filter was not applied');
}
const statusFilter = byData(globalView.container, 'data-todo-filter', 'status');
workspaceFilter.value = '';
workspaceFilter.dispatchEvent('change');
statusFilter.value = 'done';
statusFilter.dispatchEvent('change');
await flush();
if (!globalView.container.textContent.includes('Prepare project review updated') || globalView.container.textContent.includes('Client follow-up')) {
throw new Error('global Todo status filter was not applied');
}
const clientView = await mountWithApi(apiState, { workspaceRootPath: 'ClientA', workspaceNode: { name: 'ClientA' } });
if (!clientView.container.textContent.includes('Client follow-up') || clientView.container.textContent.includes('Prepare project review updated')) {
throw new Error('workspace Todo view leaked another workspace todo');
}
statusFilter.value = 'all';
statusFilter.dispatchEvent('change');
await flush();
byData(container, 'data-todo-action', 'delete').click();
await flush();
if ((apiState.settings['todos:global'] || []).some((todo) => todo.id === createdTodo.id)) throw new Error('Todo delete was not persisted');
workspaceView.component.unmount && workspaceView.component.unmount(container);
globalView.component.unmount && globalView.component.unmount(globalView.container);
clientView.component.unmount && clientView.component.unmount(clientView.container);
console.log('todo plugin smoke passed');
})().catch((error) => {
console.error(error);
process.exit(1);
});
+324
View File
@@ -0,0 +1,324 @@
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const root = path.resolve(__dirname, '..');
const sourcePath = path.join(root, 'plugins', 'trash', 'frontend', 'src', 'index.js');
const manifestPath = path.join(root, 'plugins', 'trash', 'plugin.json');
const russianLocalePath = path.join(root, 'plugins', 'trash', 'locales', 'ru.json');
const source = fs.readFileSync(sourcePath, 'utf8');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const russianLocale = JSON.parse(fs.readFileSync(russianLocalePath, 'utf8'));
class FakeClassList {
constructor(node) {
this.node = node;
}
add(name) {
if (!this.contains(name)) this.node.className = `${this.node.className} ${name}`.trim();
}
remove(name) {
this.node.className = this.node.className.split(/\s+/).filter((item) => item && item !== name).join(' ');
}
contains(name) {
return this.node.className.split(/\s+/).includes(name);
}
}
class FakeNode {
constructor(tagName, text = '') {
this.tagName = String(tagName || '').toUpperCase();
this.children = [];
this.parentNode = null;
this.attributes = {};
this.listeners = {};
this.style = {};
this.className = '';
this.classList = new FakeClassList(this);
this.value = '';
this.disabled = false;
this._text = text;
}
appendChild(child) {
if (child == null) return child;
child.parentNode = this;
this.children.push(child);
return child;
}
removeChild(child) {
this.children = this.children.filter((candidate) => candidate !== child);
child.parentNode = null;
}
setAttribute(name, value) {
this.attributes[name] = String(value);
if (name === 'class') this.className = String(value);
if (name === 'value') this.value = String(value);
}
getAttribute(name) {
return Object.prototype.hasOwnProperty.call(this.attributes, name) ? this.attributes[name] : null;
}
removeAttribute(name) {
delete this.attributes[name];
}
addEventListener(type, handler) {
this.listeners[type] = this.listeners[type] || [];
this.listeners[type].push(handler);
}
dispatchEvent(event) {
const payload = typeof event === 'string' ? { type: event, target: this } : Object.assign({ target: this }, event);
(this.listeners[payload.type] || []).slice().forEach((handler) => handler(payload));
}
click() {
this.dispatchEvent({ type: 'click', target: this, preventDefault() {}, stopPropagation() {} });
}
focus() {}
get textContent() {
return `${this._text || ''}${this.children.map((child) => child.textContent || '').join('')}`;
}
set textContent(value) {
this._text = String(value == null ? '' : value);
this.children = [];
}
get innerHTML() {
return this.textContent;
}
set innerHTML(value) {
this._text = String(value || '');
this.children = [];
}
}
function walk(node, predicate) {
if (predicate(node)) return node;
for (const child of node.children || []) {
const found = walk(child, predicate);
if (found) return found;
}
return null;
}
function walkAll(node, predicate, output = []) {
if (predicate(node)) output.push(node);
for (const child of node.children || []) walkAll(child, predicate, output);
return output;
}
function makeDocument() {
const document = {
head: new FakeNode('head'),
body: new FakeNode('body'),
activeElement: null,
createElement(tagName) {
return new FakeNode(tagName);
},
createTextNode(value) {
return new FakeNode('#text', String(value));
},
getElementById(id) {
return walk(this.head, (node) => node.getAttribute && node.getAttribute('id') === id)
|| walk(this.body, (node) => node.getAttribute && node.getAttribute('id') === id);
},
};
return document;
}
function loadTrashComponent(document) {
const registry = {};
const sandbox = {
console,
Promise,
Date,
Intl,
setTimeout,
clearTimeout,
document,
window: {
VerstakPluginRegister(pluginId, bundle) {
registry[pluginId] = bundle.components || {};
},
},
};
sandbox.window.document = document;
vm.runInNewContext(source, sandbox, { filename: sourcePath });
const component = registry['verstak.trash'] && registry['verstak.trash'].TrashView;
if (!component) throw new Error('TrashView was not registered');
return component;
}
function makeApi(locale = null) {
let entries = [
{
trashId: 'old-project-file',
originalPath: 'Project/Docs/old.txt',
trashPath: '.verstak/trash/files/old-project-file/old.txt',
deletedAt: '2026-06-20T08:00:00.000Z',
originalType: 'file',
basename: 'old.txt',
size: 12,
},
{
trashId: 'latest-client-report',
originalPath: 'ClientA/Archive/report.pdf',
trashPath: '.verstak/trash/files/latest-client-report/report.pdf',
deletedAt: '2026-06-29T09:30:00.000Z',
originalType: 'file',
basename: 'report.pdf',
size: 2048,
},
{
trashId: 'conflict-project-folder',
originalPath: 'Project/Assets',
trashPath: '.verstak/trash/files/conflict-project-folder/Assets',
deletedAt: '2026-06-28T09:30:00.000Z',
originalType: 'folder',
basename: 'Assets',
size: 0,
},
];
const restoreCalls = [];
const deleteCalls = [];
return {
restoreCalls,
deleteCalls,
i18n: locale ? {
t(key, params, fallback) {
return String(locale[key] || fallback || key).replace(/\{(\w+)\}/g, (_match, name) => String((params || {})[name] ?? ''));
},
} : null,
files: {
listTrash: async () => entries.slice(),
restoreTrash: async (trashId, options) => {
restoreCalls.push({ trashId, options });
if (trashId === 'conflict-project-folder') throw new Error('conflict: Project/Assets');
const entry = entries.find((item) => item.trashId === trashId);
if (!entry) throw new Error(`not-found: trash entry ${trashId}`);
entries = entries.filter((item) => item.trashId !== trashId);
return entry.originalPath;
},
deleteTrash: async (trashId) => {
deleteCalls.push(trashId);
if (!entries.some((item) => item.trashId === trashId)) throw new Error(`not-found: trash entry ${trashId}`);
entries = entries.filter((item) => item.trashId !== trashId);
},
},
};
}
async function flush() {
for (let i = 0; i < 10; i += 1) await Promise.resolve();
}
function findByData(rootNode, name, value) {
return walk(rootNode, (node) => node.getAttribute && node.getAttribute(name) === value);
}
(async () => {
if (!manifest.contributes || !Array.isArray(manifest.contributes.sidebarItems) || !manifest.contributes.sidebarItems.some((item) => item.view === 'verstak.trash.view')) {
throw new Error('Trash manifest must provide a global sidebar item');
}
if (manifest.contributes.workspaceItems) throw new Error('Trash must not be a workspace tab');
if (!manifest.permissions.includes('files.delete') || !manifest.permissions.includes('files.write')) {
throw new Error('Trash manifest must request delete and write permissions');
}
const document = makeDocument();
const component = loadTrashComponent(document);
const container = new FakeNode('div');
const api = makeApi();
component.mount(container, {}, api);
await flush();
if (!container.textContent.includes('All Deals') || !container.textContent.includes('Deal')) {
throw new Error('global Trash did not use Deal terminology');
}
const latestRow = findByData(container, 'data-trash-row', 'latest-client-report');
if (!latestRow || !latestRow.textContent.includes('ClientA') || !latestRow.textContent.includes('ClientA/Archive/report.pdf')) {
throw new Error(`global Trash must render workspace and original path: ${container.textContent}`);
}
const workspaceFilter = findByData(container, 'data-trash-filter-workspace', '');
workspaceFilter.value = 'Project';
workspaceFilter.dispatchEvent({ type: 'change', target: workspaceFilter });
if (walkAll(container, (node) => node.getAttribute && node.getAttribute('data-trash-row') !== null).length !== 2) {
throw new Error('workspace filter must keep only Project trash entries');
}
workspaceFilter.value = '';
workspaceFilter.dispatchEvent({ type: 'change', target: workspaceFilter });
const searchFilter = findByData(container, 'data-trash-filter-search', '');
searchFilter.value = 'report';
searchFilter.dispatchEvent({ type: 'input', target: searchFilter });
if (!findByData(container, 'data-trash-row', 'latest-client-report') || findByData(container, 'data-trash-row', 'old-project-file')) {
throw new Error('name/path search must filter trash entries');
}
searchFilter.value = '';
searchFilter.dispatchEvent({ type: 'input', target: searchFilter });
const sortSelect = findByData(container, 'data-trash-sort', '');
sortSelect.value = 'date-asc';
sortSelect.dispatchEvent({ type: 'change', target: sortSelect });
const sortedRows = walkAll(container, (node) => node.getAttribute && node.getAttribute('data-trash-row') !== null);
if (!sortedRows.length || sortedRows[0].getAttribute('data-trash-row') !== 'old-project-file') {
throw new Error('deleted-date ascending sort must place the oldest entry first');
}
const restore = findByData(container, 'data-trash-restore', 'old-project-file');
restore.click();
await flush();
if (!api.restoreCalls.some((call) => call.trashId === 'old-project-file' && call.options && call.options.overwrite === false)) {
throw new Error(`restore must explicitly reject overwrites: ${JSON.stringify(api.restoreCalls)}`);
}
if (findByData(container, 'data-trash-row', 'old-project-file')) throw new Error('restored entry must leave Trash');
const conflictRestore = findByData(container, 'data-trash-restore', 'conflict-project-folder');
conflictRestore.click();
await flush();
const status = findByData(container, 'data-trash-status', '');
if (!status || !/Restore blocked/i.test(status.textContent) || !findByData(container, 'data-trash-row', 'conflict-project-folder')) {
throw new Error('restore conflict must stay visible and keep the entry in Trash');
}
const permanentDelete = findByData(container, 'data-trash-delete', 'latest-client-report');
permanentDelete.click();
await flush();
if (api.deleteCalls.length !== 0 || !findByData(container, 'data-trash-confirm', 'latest-client-report')) {
throw new Error('permanent delete must wait for explicit confirmation');
}
const confirmDelete = findByData(container, 'data-trash-confirm-delete', 'latest-client-report');
confirmDelete.click();
await flush();
if (!api.deleteCalls.includes('latest-client-report') || findByData(container, 'data-trash-row', 'latest-client-report')) {
throw new Error('confirmed permanent delete must remove the entry from Trash');
}
component.unmount(container);
const russianContainer = new FakeNode('div');
component.mount(russianContainer, {}, makeApi(russianLocale));
await flush();
if (!russianContainer.textContent.includes('Все Дела') || !russianContainer.textContent.includes('Дело')) {
throw new Error('global Trash did not localize Deal terminology');
}
component.unmount(russianContainer);
console.log('trash frontend smoke passed');
})().catch((err) => {
console.error(err);
process.exit(1);
});
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
BUILDER="$ROOT/scripts/build-windows.sh"
if [[ ! -x "$BUILDER" ]]; then
echo "Windows plugin builder is missing or not executable: $BUILDER" >&2
exit 1
fi
bash -n "$BUILDER"
grep -Fq 'GOOS=windows' "$BUILDER"
grep -Fq 'GOARCH=amd64' "$BUILDER"
grep -Fq 'dist-windows' "$BUILDER"
grep -Fq '.exe' "$BUILDER"
grep -Fq 'x86_64-w64-mingw32-gcc' "$BUILDER"
echo "Windows plugin build script contract passed"
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
PACKAGER="$ROOT/scripts/package-portable.sh"
if [[ ! -x "$PACKAGER" ]]; then
echo "portable plugin packager is missing or not executable: $PACKAGER" >&2
exit 1
fi
bash -n "$PACKAGER"
grep -Fq 'scripts/build.sh' "$PACKAGER"
grep -Fq 'scripts/build-windows.sh' "$PACKAGER"
grep -Fq 'verstak-official-plugins-linux-amd64-' "$PACKAGER"
grep -Fq 'verstak-official-plugins-windows-amd64-' "$PACKAGER"
grep -Fq 'tar -C' "$PACKAGER"
grep -Fq 'zip -qr' "$PACKAGER"
grep -Fq 'SHA256SUMS' "$PACKAGER"
grep -Fq 'package-portable.sh' "$ROOT/scripts/release.sh"
echo "portable plugin package script contract passed"
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
PUBLISHER="$ROOT/scripts/publish-github-release.sh"
VERSION="v0.0.0-test"
REPOSITORY="mirivlad/verstak-official-plugins"
ASSET_NAMES=(
"verstak-official-plugins-linux-amd64-v0.0.0-test.tar.gz"
"verstak-official-plugins-windows-amd64-v0.0.0-test.zip"
)
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
if [[ ! -x "$PUBLISHER" ]]; then
echo "publisher is missing or not executable: $PUBLISHER" >&2
exit 1
fi
mkdir -p "$WORK/bin" "$WORK/release" "$WORK/state"
LOG="$WORK/log"
export LOG
cat > "$WORK/release.sh" <<'SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
printf 'release:%s\n' "$1" >> "$LOG"
printf 'linux\n' > "$VERSTAK_RELEASE_DIR/verstak-official-plugins-linux-amd64-$1.tar.gz"
printf 'windows\n' > "$VERSTAK_RELEASE_DIR/verstak-official-plugins-windows-amd64-$1.zip"
printf 'checksum\n' > "$VERSTAK_RELEASE_DIR/SHA256SUMS"
SCRIPT
chmod +x "$WORK/release.sh"
cat > "$WORK/bin/git" <<'SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
if [[ "$PWD" != "${EXPECTED_ROOT:?}" ]]; then
echo "publisher did not enter repository root: $PWD" >&2
exit 1
fi
case "${1:-}" in
status) exit 0 ;;
branch) echo main ;;
fetch) printf 'fetch\n' >> "$LOG" ;;
rev-parse)
if [[ "${2:-}" == "-q" ]]; then
if [[ -f "$TEST_STATE/tag" ]]; then echo test-commit; exit 0; fi
exit 1
fi
echo test-commit
;;
tag) touch "$TEST_STATE/tag"; printf 'tag:%s\n' "${3:-}" >> "$LOG" ;;
push) printf 'push:%s:%s\n' "${2:-}" "${3:-}" >> "$LOG" ;;
*) echo "unexpected git invocation: $*" >&2; exit 1 ;;
esac
SCRIPT
chmod +x "$WORK/bin/git"
cat > "$WORK/bin/gh" <<'SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
case "${1:-}:${2:-}" in
auth:status) printf 'auth\n' >> "$LOG" ;;
release:view)
if [[ -f "$TEST_STATE/release" ]]; then echo https://github.example/release; else exit 1; fi
;;
release:create) touch "$TEST_STATE/release"; printf 'create:%s\n' "$*" >> "$LOG" ;;
release:upload) printf 'upload:%s\n' "$*" >> "$LOG" ;;
*) echo "unexpected gh invocation: $*" >&2; exit 1 ;;
esac
SCRIPT
chmod +x "$WORK/bin/gh"
run_publisher() {
VERSTAK_RELEASE_SCRIPT="$WORK/release.sh" \
VERSTAK_RELEASE_DIR="$WORK/release" \
GIT_BIN="$WORK/bin/git" \
GH_BIN="$WORK/bin/gh" \
EXPECTED_ROOT="$ROOT" \
TEST_STATE="$WORK/state" \
"$PUBLISHER" "$VERSION"
}
run_publisher
grep -Fqx "release:$VERSION" "$LOG"
grep -Fqx "tag:$VERSION" "$LOG"
grep -Fqx "push:origin:refs/tags/$VERSION" "$LOG"
grep -F "release create $VERSION" "$LOG" >/dev/null
for asset in "${ASSET_NAMES[@]}"; do
grep -F "$asset" "$LOG" >/dev/null
done
grep -F "SHA256SUMS" "$LOG" >/dev/null
grep -F -- "--prerelease" "$LOG" >/dev/null
run_publisher
grep -F "release upload $VERSION" "$LOG" >/dev/null
echo "GitHub release publisher test passed"