158 Commits
Author SHA1 Message Date
mirivlad d353b5b335 docs: refresh README screenshots 2026-07-15 02:32:21 +08:00
mirivlad 3d78e0deaf fix: hide technical workbench metadata 2026-07-15 02:32:08 +08:00
mirivlad 41aeefc182 docs: describe Deals as the user-facing unit 2026-07-14 23:57:50 +08:00
mirivlad f9b20d51ff fix: style plugin manager filter selects 2026-07-14 23:47:51 +08:00
mirivlad 8be107013d feat: add plugin manager filters 2026-07-14 23:46:06 +08:00
mirivlad ddcd799f16 test: expect Deal labels in activity review 2026-07-14 23:32:08 +08:00
mirivlad 7b7b892332 test: align plugin mock terminology 2026-07-14 23:27:40 +08:00
mirivlad be3755e50e chore: remove unused plugin manager style 2026-07-14 23:25:43 +08:00
mirivlad dbfc4e8d0a fix: localize plugin capability labels 2026-07-14 23:23:24 +08:00
mirivlad cd44ee3866 test: expect Deal label in journal review 2026-07-14 23:13:59 +08:00
mirivlad a0262b97b3 fix: localize overview activity labels 2026-07-14 23:03:14 +08:00
mirivlad 2da414fb3b fix: rename Browser workspace tool 2026-07-14 22:33:43 +08:00
mirivlad 5cad11f3f9 fix: use Deal terminology in desktop shell 2026-07-14 22:18:52 +08:00
mirivlad dbef71f830 fix: refresh desktop integration on updates 2026-07-14 22:04:25 +08:00
mirivlad 963049add3 fix: localize shell operation errors 2026-07-14 22:00:24 +08:00
mirivlad 6179ad73e7 test: cover typed Todo reminder times 2026-07-14 20:50:14 +08:00
mirivlad 1e397d0211 test: cover global secrets workflow 2026-07-14 20:41:40 +08:00
mirivlad 47d38bcd4d fix: scope overview to available tools 2026-07-14 20:21:40 +08:00
mirivlad 9d06642ca6 feat: explain unavailable template tools 2026-07-14 20:10:29 +08:00
mirivlad b6290aa8ee test: cover global Journal entry assignment 2026-07-14 19:58:39 +08:00
mirivlad c3836ef1a6 test: exercise Todo reminder controls from source 2026-07-14 19:39:11 +08:00
mirivlad e223ccccc6 test: cover activity deletion confirmation 2026-07-14 19:19:11 +08:00
mirivlad fe64e1f822 test: cover secret workbench navigation 2026-07-14 19:14:01 +08:00
mirivlad fc82b3e6b1 fix: validate bundled plugin requirements 2026-07-14 19:04:03 +08:00
mirivlad 680846da72 fix: style workspace template selector 2026-07-14 08:00:38 +08:00
mirivlad d1f9157c05 docs: publish bilingual product README 2026-07-14 03:19:27 +08:00
mirivlad a00207d86e fix: keep command-line debug session-only 2026-07-14 03:19:06 +08:00
mirivlad fe912c9659 build: refresh notification Wails bindings 2026-07-14 02:59:38 +08:00
mirivlad 8e3dedf0c5 build: package Linux tray runtime 2026-07-14 02:55:52 +08:00
mirivlad d8a4213569 feat: keep Verstak running in the system tray 2026-07-14 02:54:30 +08:00
mirivlad e33c63d3ad feat: run scheduled native notifications 2026-07-14 02:42:50 +08:00
mirivlad 96dd499e0b feat: expose scheduled notifications to plugins 2026-07-14 02:37:28 +08:00
mirivlad 67753927cb feat: add persisted notification scheduler 2026-07-14 02:34:00 +08:00
mirivlad 55edaa0209 docs: make tray icons reproducible 2026-07-14 02:31:12 +08:00
mirivlad 4d6a8bd8cd docs: plan notifications tray and public readme 2026-07-14 02:29:26 +08:00
mirivlad 9fc0ff764c docs: specify native notifications and tray 2026-07-14 02:23:15 +08:00
mirivlad eb5d6b1f06 build: ignore generated Windows resources 2026-07-14 02:14:10 +08:00
mirivlad 030f66d806 build: publish portable desktop releases 2026-07-14 01:58:42 +08:00
mirivlad da76b89856 build: use system WebView2 for Windows archive 2026-07-13 22:53:03 +08:00
mirivlad 20952e8477 build: add portable desktop packages 2026-07-13 22:29:58 +08:00
mirivlad f4e911c14a build: add Windows desktop test bundle 2026-07-13 21:46:48 +08:00
mirivlad 47a8d565e8 build: publish desktop releases to GitHub 2026-07-13 05:01:34 +08:00
mirivlad 581e6b27d8 docs: document public alpha setup 2026-07-13 03:57:58 +08:00
mirivlad ca5290015a build: guard desktop release prerequisites 2026-07-12 21:38:24 +08:00
mirivlad ad0c00bade docs: prepare desktop alpha release 2026-07-12 21:21:45 +08:00
mirivlad 4db5a7a132 feat: show plugin diagnostics only in debug mode 2026-07-12 21:18:09 +08:00
mirivlad c567b229cc feat: scope overview to durable activity data 2026-07-12 21:11:53 +08:00
mirivlad 5a249dcf24 feat: persist handled activity session watermarks 2026-07-12 20:48:57 +08:00
mirivlad 6b556c59d9 feat: scope activity and browser links by durable workspace 2026-07-12 20:39:03 +08:00
mirivlad fca9fff4b4 feat: retain archived browser inbox captures 2026-07-12 17:43:12 +08:00
mirivlad bcf49ed32c feat: persist passive browser activity batches 2026-07-12 17:29:07 +08:00
mirivlad 39052ffe39 feat: add canonical hostname normalization 2026-07-12 17:16:53 +08:00
mirivlad e1a6b2af64 feat: publish workspace trash lifecycle 2026-07-12 17:11:00 +08:00
mirivlad 5280d245ee feat: add durable workspace identities 2026-07-12 17:06:34 +08:00
mirivlad 477b372110 fix: persist browser captures atomically 2026-07-11 23:30:38 +08:00
mirivlad c129c1a2ac fix: preserve plugin contribution label contract 2026-07-11 12:56:17 +08:00
mirivlad 7f0cc308c2 feat: add Russian desktop localization 2026-07-11 12:50:00 +08:00
mirivlad 8b1151e03a feat: load declared plugin locale catalogs 2026-07-11 02:02:47 +08:00
mirivlad 15f1f413d0 feat: persist desktop language preference 2026-07-11 02:00:10 +08:00
mirivlad e412cfc45a feat: add workspace templates and tab visibility 2026-07-10 21:32:48 +08:00
mirivlad 2007b909d1 feat: integrate todo plugin into desktop workspace 2026-07-10 21:09:02 +08:00
mirivlad 8dafde4519 feat: scope overview browser captures by workspace 2026-07-10 20:27:58 +08:00
mirivlad 1d1eaa6bc7 feat: cover global trash workflow in desktop 2026-07-10 09:00:14 +08:00
mirivlad 3f27ba79a4 feat: bridge permanent trash deletion to plugins 2026-07-10 08:35:46 +08:00
mirivlad 8445ea11db feat: support permanent vault trash deletion 2026-07-10 08:31:20 +08:00
mirivlad e5f5d4d33f feat: route work session candidates to journal 2026-07-10 08:21:11 +08:00
mirivlad b056016929 feat: refine workspace overview 2026-07-10 07:46:18 +08:00
mirivlad a323710a6f fix: resolve plugin lifecycle dependencies deterministically 2026-07-10 04:03:08 +08:00
mirivlad 0f293640f9 feat: require paired browser receiver 2026-07-10 03:37:12 +08:00
mirivlad 89c261824f fix: bound browser capture ingress 2026-07-10 03:29:51 +08:00
mirivlad 18da09e06b fix: bind sync state to active vault 2026-07-10 03:13:04 +08:00
mirivlad b845e8b5e2 feat: replace Today workspace surface with Overview 2026-07-10 02:36:00 +08:00
mirivlad 5b4c792de5 Polish desktop workbench UI 2026-07-02 00:49:29 +08:00
mirivlad 12b1e5a496 Cover Files action icon states 2026-07-01 19:20:41 +08:00
mirivlad a540da707d Cover Files no-match filter feedback 2026-07-01 19:07:06 +08:00
mirivlad 74c627f706 Cover Files rename conflict feedback 2026-07-01 18:58:31 +08:00
mirivlad 029bb7a1a8 Cover Files empty folder actions 2026-07-01 18:49:51 +08:00
mirivlad d2a07baf82 Cover Files rich file icons 2026-07-01 18:34:51 +08:00
mirivlad 3c54ef55c9 Cover Files inline create validation 2026-07-01 18:24:55 +08:00
mirivlad 3b0cd2e429 Cover Files trash restore flow 2026-07-01 08:47:19 +08:00
mirivlad 501d7a76d0 Use in-app Files trash confirmation in mock 2026-07-01 08:25:57 +08:00
mirivlad 05bfe1d993 Cover Files breadcrumb segment navigation 2026-07-01 08:17:45 +08:00
mirivlad 03dbede414 Cover Files duplicate context action 2026-07-01 08:09:28 +08:00
mirivlad 5c47ca2480 Cover Files inline rename validation 2026-07-01 07:59:36 +08:00
mirivlad 167d593245 Revert "fix: P1 shell tooltip fixes + Today routing + workspaces API"
This reverts commit 9d06b164d3.
2026-07-01 07:44:28 +08:00
mirivlad 9d06b164d3 fix: P1 shell tooltip fixes + Today routing + workspaces API
Shell:
- WorkspaceHost: change tooltip from pluginId to human-readable title
- StatusBar: replace pluginId tooltips with label or id (3 occurrences)
- App: startup routing to Today view, sidebar Today nav item
- Sidebar: add Today button in Overview section
- TodaySurface: separate global vs workspace data (no fallthrough)

Plugin API:
- Add workspaces.list() method wrapping App.ListWorkspaces()
  (needed by browser-inbox workspace picker)
2026-07-01 04:15:35 +08:00
mirivlad b0d80e230f Cover Files keyboard row selection 2026-07-01 03:18:27 +08:00
mirivlad d3dfa2d652 Add plugin manager scan summary 2026-07-01 03:08:18 +08:00
mirivlad 67468e92f7 Improve Today workspace resume surface 2026-07-01 02:56:50 +08:00
mirivlad 80e53ff252 Improve workspace search and responsive shell 2026-07-01 02:46:40 +08:00
mirivlad 578ce2f2a5 Fix global search result navigation 2026-06-30 22:43:09 +08:00
mirivlad b28039cf0d Fix global search folder navigation 2026-06-30 20:57:58 +08:00
mirv 268e79d2f0 Improve workspace UX flows 2026-06-30 17:38:18 +08:00
mirivlad 46f754cc2d feat: workspace routing, GlobalSearch, and shell refinements
- Add workspace-aware startup routing (openDefaultWorkspaceRoute)
- Add GlobalSearch component and workspace-empty state
- Refactor Sidebar, StatusBar, WorkspaceTree, VaultSelection
- Update PluginCard/PluginManager UI
- Extend e2e coverage (ux-p0, ux-followup, helpers)
- Add ListWorkspaces/SetCurrentWorkspace backend bindings
2026-06-30 12:32:04 +08:00
mirivlad 4bb9e84c35 feat: harden secret unlock and deletion 2026-06-29 21:42:10 +08:00
mirivlad 63c2c1314f test: cover secret workbench resources 2026-06-29 20:45:03 +08:00
mirivlad 56c685212b feat: expose scoped secret store API 2026-06-29 20:36:45 +08:00
mirivlad 7a7b3c7a3e feat: add encrypted local secret store 2026-06-29 20:12:59 +08:00
mirv fe91784a8e feat: add bounded binary file writes 2026-06-29 10:12:26 +08:00
mirv df4538532f feat: accept browser file captures 2026-06-29 09:57:21 +08:00
mirivlad 2cbf542b13 feat: require token for paired browser receiver 2026-06-29 04:52:19 +08:00
mirivlad 7363313f1e feat: expose plugin data storage bridge 2026-06-29 04:27:22 +08:00
mirivlad 874b7c7ffb feat: add workspace header search slot 2026-06-29 04:24:15 +08:00
mirivlad 8f6559cdb6 docs: define sync conflict contract 2026-06-29 03:38:03 +08:00
mirivlad 9f3448c1ea feat: retry transient sync requests 2026-06-29 03:30:10 +08:00
mirivlad 04ce8c5bf3 fix: preserve sync error status 2026-06-29 03:23:45 +08:00
mirivlad 57677d1b1b feat: add bounded file byte reads 2026-06-29 03:16:16 +08:00
mirivlad aa4905a097 docs: document live file watcher 2026-06-28 23:00:32 +08:00
mirivlad 1d521251a2 feat: watch vault file changes 2026-06-28 22:53:27 +08:00
mirivlad 5a74809ab7 docs: document file trash restore API 2026-06-28 22:23:42 +08:00
mirivlad cc0c050985 feat: restore files from trash 2026-06-28 22:18:44 +08:00
mirivlad e5bdaec0aa feat: expose file trash metadata 2026-06-28 17:03:12 +08:00
mirivlad ed4b117a94 docs: update runtime contribution APIs 2026-06-28 16:42:02 +08:00
mirivlad d46bc9436d feat: publish workspace lifecycle events 2026-06-28 16:34:03 +08:00
mirivlad 1f1001108b feat: expose action contributions to plugins 2026-06-28 16:28:33 +08:00
mirivlad 9729b432d6 feat: host activity providers 2026-06-28 16:14:59 +08:00
mirivlad 93597a2c45 Tag browser captures with current workspace 2026-06-28 04:00:50 +08:00
mirivlad 6d30015a1d Move global plugin navigation into shell 2026-06-28 03:22:54 +08:00
mirivlad 0c788bed80 Expose search provider contributions 2026-06-28 01:03:36 +08:00
mirivlad 35012025b6 Add sync reset key bridge 2026-06-27 18:46:05 +08:00
mirivlad a2791c494f Add browser inbox receiver 2026-06-27 18:39:01 +08:00
mirivlad fb68c54409 Route open providers by request mode 2026-06-27 13:49:09 +08:00
mirivlad f87f8235bd Expand real sync smoke for folders 2026-06-27 13:39:48 +08:00
mirivlad 173cc93258 Add public external file open API 2026-06-27 13:30:31 +08:00
mirivlad 6cc37972d1 Add status bar contribution host 2026-06-27 13:21:22 +08:00
mirivlad 7630a31286 Add command palette host 2026-06-27 13:13:15 +08:00
mirivlad 1fb9db73ec Update completed files milestone docs 2026-06-27 12:51:08 +08:00
mirivlad 28a4e10e79 Remove core notes service 2026-06-27 12:44:02 +08:00
mirivlad 24444a8588 Refine v2 plugin API and sync flow 2026-06-27 12:36:31 +08:00
mirivlad 03175aa46d feat: Notes core service + Notes API + router auto-detect notes context
- Add internal/core/notes/ service (Service, Layout, Normalize, tests)
- Register verstak/core/notes/v1 capability
- Inject NotesService into App, expose 8 Notes API endpoints
  (CreateNote, RenameNote, ReadNote, SaveNote, EnsureOverview,
   ListNotes, SearchNotes, NormalizeNoteTitle)
- Router: auto-detect Notes context via path (IsInsideNotes)
- PluginCard: show workspaceItems contribution count
- Regenerate Wails bindings (App.d.ts, App.js, models.ts with notes.NoteInfo)
- Fix .gitignore pattern for e2e-results/
2026-06-21 23:22:36 +08:00
mirivlad 0b6b0d0926 fix: workbench 'back' blocked, workspace breadcrumb-up only when btn active
- onNavigateBack/Forward now skip entirely when currentView === 'workbench'
  (user relies on close button, not mouse back)
- onNavigateBack inside workspace checks upBtn.disabled before clicking —
  in root folder (no currentPath) button exists but disabled, so navigateBack
  falls through to navigationStack for cross-view navigation
- mouseHistoryDirection and keyHistoryDirection also block 'workbench' to
  prevent bypass via pointerdown/mousedown listeners
2026-06-21 16:25:45 +08:00
mirivlad b4e66c9779 fix: mouse navigation — redirect workspace back/forward to Files buttons, remove xinput monitor
- onNavigateBack/Forward now check for currentView === 'workspace' first
  and delegate to Files plugin's up/forward buttons (breadcrumb/Fwd nav)
- Fall back to navigationStack for cross-view navigation
- Removed mouse_monitor.go (xinput test-xi2) — causes phantom X windows,
  superseded by GTK-level vendor patch
- Removed startMouseMonitor call from App.Startup
2026-06-21 16:18:45 +08:00
mirivlad d644c5bb79 feat: mouse back/forward navigation + history stack
- C patch (vendor/.../window.c): intercept GDK button 8/9 → dispatch
  CustomEvent('verstak:navigate-back'/'verstak:navigate-forward')
- App.svelte: navigation stack (snapshot-based history), alt+arrows,
  mouse button back/forward handlers, onNavigateBack/Forward
- WorkbenchHost: close via navigate-back event
- WorkspaceHost: workspace tab bar + tool panels
- wails-mock: full navigation, sidebar, vau...
2026-06-21 16:01:21 +08:00
mirivlad 8e5690e8f7 feat: intercept GDK button 8/9 for mouse back/forward navigation
WebKitGTK does not propagate XButton1/XButton2 (buttons 8 and 9) into
DOM events — event.button and event.buttons are always 0 for these
clicks. This prevents the frontend from detecting hardware back/forward
mouse buttons for history navigation.

Solution: patch Wails' window.c on Linux to intercept button-press-event
at the GTK signal level (before WebKit processes it). For button 8/9 we
call webkit_web_view_run_javascript() to dispatch a native CustomEvent
('verstak:navigate-back' / 'verstak:navigate-forward') into the page,
allowing the frontend to navigate history without any workaround on the
JS side.

The patch is applied automatically during build via scripts/build.sh:
  go mod vendor → patch -p0 < patches/window.c.button-press.patch
Vendor directory is gitignored.
2026-06-21 13:49:13 +08:00
mirivlad 0ac473d720 core: Milestone 7b — Files explorer and Default Editor improvements
- Files plugin: richer explorer with breadcrumbs, selection, toolbar actions,
  rename/trash, filter, sorting, hidden/reserved entries filtered
- Default Editor: line numbers, Ctrl+S, markdown toolbar, Edit/Preview/Split,
  markdown preview, Reload/Revert
- E2E tests: 39 passed for files + editor
- Workspace model: correction, naming alignment, compatibility wrappers
- Updated docs: NOTES_FILES_PLUGIN_PLAN.md, PLUGIN_RUNTIME.md
2026-06-20 19:20:13 +08:00
mirivlad 4de5a74a55 fix: sanitize sync error messages, detect non-sync servers, add health check in TestAuth 2026-06-20 03:20:25 +08:00
mirivlad db67c370ab fix: add backend.call() API and fix [object Object] in workspace sidebar
- Add api.backend.call(method, ...args) to VerstakPluginAPI for direct Wails method invocation
- Add wsName() helper in WorkspaceTree.svelte with String() coercion to prevent [object Object] display
- Use correct Wails path window['go']['api']['App'] in backend.call()
2026-06-20 03:02:33 +08:00
mirivlad ed69746332 feat: add sync backend methods 2026-06-20 02:25:25 +08:00
mirivlad 5c979174f1 fix: polish workspace files and editor shell 2026-06-19 23:37:10 +08:00
mirv a6412fa070 feat: milestones 6b-fix through 6e — default-editor, files plugin, workspace host, workspaceItems contribution
- Fix PluginCard openProviders display
- Add default-editor plugin (text/markdown/notes-context)
- Add files plugin with workspaceItems placement
- Add workspaceItems contribution point (Go + API + mock + SDK)
- Add WorkspaceHost component for workspace area
- WorkspaceTree dispatches selection event
- Fix default-editor layout to fill container
- Fix PluginCard unsafe .length access
- Add E2E tests: 34/34 pass
- Add bundle execution check to official-plugins check.sh
- Update docs: PLUGIN_RUNTIME, DEV_PLUGINS, MILESTONE_6B/6C/6D plans
2026-06-19 16:42:01 +08:00
mirivlad 6ed6df311a Implement milestone 6b workbench routing skeleton 2026-06-19 07:51:57 +08:00
mirivlad a100f5a441 fix(plugin-manager): sync UI state with plugin lifecycle + sidebar click fix
Root cause fixes:
- Sidebar: handleSidebarItem used item.id instead of item.view for viewId.
  Platform Test sidebar item has id=verstak.platform-test.sidebar but
  view=verstak.platform-test.diagnostics. Click now dispatches correct viewId.
- PluginManager: EnablePlugin/DisablePlugin only wrote to plugins.json but
  never re-discovered plugins. UI showed stale state (no Enable button after
  Disable, no Disable after Enable). Now calls ReloadPlugins() + loadAll()
  after each toggle.
- PluginManager: loadAll() fired async loads (GetCapabilities etc) without
  awaiting — loading spinner disappeared before data was ready. Now awaits
  all via Promise.all.
- PluginCard: no loading feedback on Enable/Disable buttons. Added
  actionFeedback prop — buttons show '⟳ Enabling...' / '⟳ Disabling...'
  and are disabled during operation.
- PluginManager: no visible result after Reload/Enable/Disable. Added
  toast notifications (success/error/info) with auto-dismiss.
- Settings: openSettingsFromProps didn't handle missing panel — now shows
  visible error in modal.
2026-06-17 19:40:05 +08:00
mirivlad 6d2f7858eb fix: replace emoji icons with inline SVG (Icon.svelte + icons.js)
All emoji characters replaced with inline SVG icons:
- Plugin Manager sidebar: puzzle SVG icon
- Verstak logo: stack/tray SVG icon
- Plugin icons: flask SVG (from plugin manifest)
- Warning/error indicators: warning triangle SVG
- Settings button: gear SVG
- Vault recent: vault/shield SVG
- Fallback: dot SVG

New components:
- frontend/src/lib/ui/icons.js — SVG path map
- frontend/src/lib/ui/Icon.svelte — reusable SVG icon component

Icon policy: NO emoji or unicode pictographic symbols in the app.
Only SVG icons registered in icons.js are allowed.
Wails WebKitGTK does not render colour emoji.
2026-06-17 19:02:51 +08:00
mirivlad c2e14cae69 feat: add bundle-host-test.cjs + smoke integration
- bundle-host-test.cjs: JS smoke-test for PluginBundleHost contract
  - 4 error boundary scenarios (missing frontend, JS throw, missing component, mount throw)
  - 5 real mount scenarios (bundle exec, DiagnosticsPanel mount, Settings mount, unmount, mount-throw catch)
  - Runs real platform-test bundle in vm.Sandbox with mock window/document
- smoke-platform.sh: add bundle-host-test step
- Fix: platform-test badgeRow div call (3rd arg was ignored)
2026-06-17 18:51:14 +08:00
mirivlad 05ef1449bc feat: milestone 5b — frontend bundle host + VerstakPluginAPI stub
- Bundle contract: window.VerstakPluginRegister(id, {components: {...}})
- PluginBundleHost.svelte: loads bundle via GetPluginAssetContent, mounts components
- VerstakPluginAPI.js: restricted API (capabilities, events, settings, commands — all stub)
- ViewContainer: PluginBundleHost replaces placeholder when frontend bundle exists
- PluginManager: settings panel via PluginBundleHost (removed hardcoded form)
- Backend: GetPluginFrontendInfo, GetPluginAssetContent with path security
- Security: reject absolute paths, path traversal, escape from plugin root
- Error boundary: bundle load/execute/mount errors show fallback, not crash
- Tests: 11 backend tests (asset API), frontend bundle checks in smoke
- Docs: bundle contract, VerstakPluginAPI, security constraints
2026-06-17 17:39:50 +08:00
mirivlad 1dff97a9c0 fix: smoke test now proves manifest→discovery→registry flow with ReloadPlugins gating
- runContributionsTest rewritten: shows exact manifest fragment from plugin.json
- Capability resolution gates contribution registration (status=degraded)
- Unregister before Register throughout (matches ReloadPlugins)
- Verify contributions by name with data matching manifest
- Proper \n formatting
2026-06-17 17:21:46 +08:00
mirivlad a96ffb5801 chore: split build.sh and update-and-build-all.sh
- build.sh: deterministic local-only build, fail-fast, no git pull
- update-and-build-all.sh: dev helper, pulls all repos, builds official plugins, then builds desktop
- Docs: added Build Scripts section explaining the difference
2026-06-17 17:13:14 +08:00
mirivlad 86eeadd2a9 feat: milestone 5a — frontend plugin host, contribution lifecycle, UI shell
- Contribution Registry: ListByPoint, idempotent Register (Unregister-before-add)
- Flat ContributionSummary types for frontend (no nested .item.)
- Sidebar.svelte: items from ContributionRegistry, sort by position, error boundary
- ViewContainer.svelte: declarative placeholder host with error boundary
- PluginManager.svelte: settings panels from registry, knoppka only with settingsPanel
- PluginCard.svelte: settingsPanels prop, disabled state for Settings button
- Error boundary: ViewContainer + PluginManager catch errors, shell stays stable
- ReloadPlugins: Unregister before Register contributions (no duplicates)
- Smoke: -test-contributions flag, enable/disable/reload lifecycle verification
- Build: global_update() — pull all repos, build official plugins, install to desktop
2026-06-17 17:07:52 +08:00
mirivlad 9bb35a9fd0 fix: add workspace capability name check to smoke, clean up tracked build artifacts 2026-06-17 16:32:08 +08:00
mirivlad 67345a194a fix: vault/workspace lifecycle — CreateVault creates workspace, SetCurrentVault loads workspace, ReloadPlugins keeps workspace capability, recursive tree rendering 2026-06-17 14:26:49 +08:00
mirivlad 5fa2c0ddf9 docs: add workspace capability documentation 2026-06-17 12:24:12 +08:00
mirivlad 5c9ae7f93b feat: add workspace/cases core capability 2026-06-17 12:22:52 +08:00
mirivlad 6eecf5d005 fix: OpenVault now looks for vault.json inside VerstakVault/ subdirectory 2026-06-17 10:10:39 +08:00
mirivlad 252d075f9b fix: register OnStartup callback for Wails context initialization 2026-06-17 09:44:23 +08:00
mirivlad ffb3446cc3 feat: native directory picker for vault selection 2026-06-17 09:13:09 +08:00
mirivlad dd199f38ee fix: remove legacy config migration — new Verstak starts clean 2026-06-17 08:56:17 +08:00
mirivlad e9758ec1b5 feat: add legacy config migration + test 2026-06-17 08:23:25 +08:00
mirivlad ca089a82e9 docs: deduplicate sections, add UI layout and milestone 4b 2026-06-17 07:33:36 +08:00
185 changed files with 39899 additions and 1244 deletions
+7 -1
View File
@@ -1,4 +1,10 @@
# .gitignore
frontend/node_modules/ frontend/node_modules/
frontend/dist/ frontend/dist/
build/bin/verstak-desktop build/
verstak-desktop-res.syso
smoke-platform
plugins/ plugins/
vendor/
frontend/e2e-results/
release/
+86
View File
@@ -91,3 +91,89 @@ verstak-desktop/
wails.json wails.json
... ...
``` ```
## Verification language policy
**Do not say "checked", "verified", "works", or "all good" unless a concrete
verification command or user scenario was executed.**
Use exact labels:
- Build checked
- Unit tests checked
- Backend behavior checked
- Frontend E2E checked
- Real desktop GUI checked
- Not checked / requires manual verification
If GUI behavior was not clicked and asserted, report:
"GUI behavior was not verified."
### GUI testing
Frontend E2E tests are in `frontend/e2e/`. Run with `npm run test:e2e`.
These tests use Playwright + mocked Wails bindings. They test Svelte component
logic and user interactions in a real Chromium browser.
**Limitations:** Playwright tests do NOT test the real WebKitGTK/Wails native
shell. For real desktop GUI verification, a separate AT-SPI/xdotool layer is
needed (not yet implemented).
See `docs/GUI_TESTING.md` for details.
## Debug logging
**Always use debug logging when investigating issues. Never rely on "it should work" — look at the logs.**
### Backend debug
Enable with `--debug` flag:
```bash
./verstak-desktop --debug
```
Logs go to: `~/.local/share/verstak/debug/verstak-YYYY-MM-DD-HHMMSS.log`
View in real-time:
```bash
tail -f ~/.local/share/verstak/debug/verstak-*.log
```
What's logged (when `--debug` is active):
- Plugin discovery: dirs scanned, each plugin found (id, name, version, source, root)
- Plugin lifecycle: capability registration, contribution registration, status transitions
- API calls: GetPlugins, GetContributions, GetCapabilities, ReloadPlugins, EnablePlugin, DisablePlugin
- Vault operations: open/close/status
### Frontend debug
Enable via **either**:
1. URL query param: `?debug` (not practical in Wails, use #2)
2. In browser console: `localStorage.setItem('verstak-debug', 'true')` then reload
Logs go to:
- Browser console (with `[debug]` prefix)
- localStorage buffer: `localStorage.getItem('verstak-debug-log')` (last 1000 entries)
Export frontend log from console:
```javascript
copy(JSON.parse(localStorage.getItem('verstak-debug-log')))
// or
window.__verstakDebug.exportLog()
```
What's logged:
- App startup: checkVault, GetAppSettings, GetVaultStatus
- Navigation: onNav, onOpenView, onOpenSettings, onCloseSettings
- PluginManager: loadAll (start/end, plugin count, each plugin status), reload, enable/disable
- Sidebar: onMount (plugins loaded, sidebar items count), handleNav, handleSidebarItem
### Debug workflow
1. User reports issue
2. Restart app with `--debug` and reproduce
3. Run `tail -f ~/.local/share/verstak/debug/verstak-*.log` and share output
4. For frontend issues: enable frontend debug view `exportLog()` output
5. Analyze logs, identify root cause, fix
6. Verify fix by asking user to reproduce again with debug on
**Never skip step 2-4. Always look at real logs before proposing fixes.**
+17
View File
@@ -0,0 +1,17 @@
Verstak Desktop
Copyright (C) 2026 Verstak contributors
This program is free software: you can redistribute it and/or modify it under
the terms of the GNU Affero General Public License as published by the Free
Software Foundation, either version 3 of the License, or (at your option) any
later version.
This program is distributed in the hope that it will be useful, but WITHOUT
ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
details.
The complete license text is available at:
https://www.gnu.org/licenses/agpl-3.0.html
SPDX-License-Identifier: AGPL-3.0-or-later
+340 -2
View File
@@ -1,3 +1,341 @@
# verstak-desktop <div align="center">
Verstak Core Platform + UI Shell — plugin runtime, capability registry, vault API, event bus, sync client <img src="packaging/linux/verstak.svg" width="112" alt="Verstak logo">
# Verstak
### Keep the context of your work together — locally.
Files, notes, links, browser captures, activity and work history
in one extensible local-first workbench.
**English** · [Русский](README.ru.md)
[![Release](https://img.shields.io/github/v/release/mirivlad/verstak?include_prereleases\&label=release)](https://github.com/mirivlad/verstak/releases)
![Status](https://img.shields.io/badge/status-alpha-orange)
![Platforms](https://img.shields.io/badge/platform-Linux%20%7C%20Windows-blue)
[![License](https://img.shields.io/github/license/mirivlad/verstak)](LICENSE)
[Download](https://github.com/mirivlad/verstak/releases/latest) ·
[Documentation](https://github.com/mirivlad/verstak-docs) ·
[Report an issue](https://github.com/mirivlad/verstak/issues)
</div>
> [!WARNING]
> Verstak is currently **alpha software**. APIs, storage formats and packaging may change before the first stable release. Use a test vault and keep backups of important data.
## What is Verstak?
Verstak is a local-first workbench that keeps the context around your work in one place.
A **Deal** can represent almost anything:
* a software project;
* a client or customer;
* a server or infrastructure site;
* a device under repair;
* an article or research topic;
* a training course;
* a personal long-term project.
Normally, information about such work is scattered across directories, notes, browser tabs, task managers, password managers, terminal history and memory.
Verstak brings it together in a local **vault** that remains under your control.
No account, cloud service or sync server is required for local use.
## Verstak in use
| Overview: return to a Deal and see its recent work, captures and items that need attention. | Files: manage ordinary folders and documents inside a Deal. |
| --- | --- |
| ![Current Verstak Overview in a test vault](docs/screenshots/overview-en.png) | ![Current Verstak Deal files in a test vault](docs/screenshots/files-en.png) |
| Notes: keep Markdown notes next to the Deal they describe. | Journal: record a completed session and retain its context. |
| --- | --- |
| ![Current Verstak notes in a test vault](docs/screenshots/notes-en.png) | ![Current Verstak journal in a test vault](docs/screenshots/journal-en.png) |
## Main features
| Feature | Description |
| ------------------------ | -------------------------------------------------------------------------------------------- |
| **Deals** | Organize files, notes and activity around durable projects, clients and other areas of work. |
| **Files** | Browse and manage ordinary files stored inside your vault. |
| **Notes** | Create Markdown notes, overview pages and links between related information. |
| **Overview** | Quickly return to recent work and see what may need attention. |
| **Browser Inbox** | Send pages, links, selections and files from a browser into Verstak. |
| **Activity and Journal** | Reconstruct work sessions and turn selected activity into journal entries. |
| **Todo** | Keep optional task lists inside individual Deals and across the whole vault. |
| **Search** | Search across notes, files and supported plugin data. |
| **Trash** | Restore deleted items or remove them permanently from one central location. |
| **Secrets** | Keep credentials and access information connected to the relevant Deal. |
| **Templates** | Create repeatable Deal structures for common types of work. |
| **Plugins** | Add, replace or disable tools without turning Verstak into one rigid monolithic application. |
| **Optional sync** | Synchronize vaults between devices using a self-hosted Verstak Sync Server. |
## Download and install
Download the latest build from the
[GitHub Releases page](https://github.com/mirivlad/verstak/releases/latest).
Release packages already include the matching official plugins.
| System | Download | Installation |
| --------------------------------- | ----------------------------------------- | --------------------------------------------- |
| Debian 13 / Ubuntu 24.04 or newer | `verstak_<version>_amd64.deb` | Install through APT. |
| Other x86_64 Linux distributions | `verstak-linux-x86_64-<version>.AppImage` | Make the file executable and run it. |
| Windows 10/11 x64 | `verstak-windows-amd64-<version>.zip` | Extract the archive and launch `Verstak.cmd`. |
### Debian and Ubuntu
```bash
sudo apt install ./verstak_*_amd64.deb
```
Launch Verstak from the application menu or run:
```bash
verstak
```
### AppImage
```bash
chmod +x verstak-linux-x86_64-*.AppImage
./verstak-linux-x86_64-*.AppImage
```
On a system without FUSE support:
```bash
APPIMAGE_EXTRACT_AND_RUN=1 ./verstak-linux-x86_64-*.AppImage
```
### Windows portable version
1. Download `verstak-windows-amd64-<version>.zip`.
2. Extract it to a local directory.
3. Run `Verstak.cmd`.
Do not run Verstak directly from inside the ZIP archive or from a network share.
Verstak uses the Microsoft WebView2 Runtime. It is already included with Windows 11 and most current Windows 10 installations. If Verstak does not start, install the
[Microsoft WebView2 Runtime x64](https://go.microsoft.com/fwlink/p/?LinkId=2124701).
## Background and tray mode
Closing the main window keeps Verstak running in the system tray. Use **Show Verstak** to bring it back and **Quit** to exit the application completely. This lets scheduled Todo reminders continue while the window is hidden.
### Verify a download
Each release includes a `SHA256SUMS` file.
On Linux:
```bash
sha256sum -c SHA256SUMS --ignore-missing
```
## First start
### 1. Create or open a vault
A **vault** is the root directory where Verstak stores your work.
Choose a writable local directory. For example:
```text
Documents/
└── Verstak/
```
Your files and notes remain ordinary files that can be accessed without Verstak.
### 2. Create your first Deal
Inside the vault, create a Deal for a project, client, server, device or another area of work.
For example:
```text
Home server
Customer Alpha
Verstak development
3D printer
Training course
```
### 3. Add context
Open the Deal and add the information needed to return to it later:
* notes and decisions;
* documents and source files;
* useful links;
* access information;
* journal entries;
* tasks;
* materials captured from the browser.
### 4. Return through Overview
The Overview screen shows recent changes, unfinished captures, activity and other useful entry points back into your work.
## How Verstak stores data
Verstak follows several principles:
* your vault is local and works without an account or internet connection;
* files and notes remain readable outside the application;
* synchronization is optional and is not the source of truth;
* the user should understand where data is stored and what happens to it;
* application tools are provided by plugins rather than being permanently embedded into the core.
The desktop application loads plugins from a `plugins/` directory located beside the executable.
## Browser integration
The optional browser extension can send the following into Browser Inbox:
* the current page;
* selected text;
* a link;
* a downloaded or selected file.
Repository:
[mirivlad/verstak-browser-extension](https://github.com/mirivlad/verstak-browser-extension)
To connect the extension:
1. Open Browser Inbox settings in Verstak.
2. Copy the Receiver URL and Pairing Token.
3. Paste them into the extension settings.
4. Save the settings and send a test page.
Passive domain activity tracking is disabled by default. When enabled, it sends bounded time totals by normalized domain. It does not send page contents, keystrokes or full browsing history.
## Optional synchronization
Local use does not require a server.
For synchronization between devices, deploy the optional self-hosted
[Verstak Sync Server](https://github.com/mirivlad/verstak-sync-server).
Each vault is connected separately. The local vault remains the primary copy of your data.
## Build from source
### Requirements
* Go 1.24 or newer;
* Node.js 20 or newer with npm;
* Python 3;
* Git;
* Wails v2 build dependencies;
* WebKitGTK development packages on Linux.
* Ayatana AppIndicator development files on Linux (`sudo apt install libayatana-appindicator3-dev`).
See the
[Wails installation documentation](https://wails.io/docs/gettingstarted/installation/)
for distribution-specific dependencies.
### Clone the repositories
The desktop, SDK and official plugin repositories must be cloned as sibling directories:
```text
verstak-source/
├── verstak/
├── verstak-sdk/
└── verstak-official-plugins/
```
```bash
mkdir verstak-source
cd verstak-source
git clone https://github.com/mirivlad/verstak.git
git clone https://github.com/mirivlad/verstak-sdk.git
git clone https://github.com/mirivlad/verstak-official-plugins.git
```
### Build
```bash
cd verstak-sdk
./scripts/build.sh
cd ../verstak-official-plugins
./scripts/build.sh
cd ../verstak
./scripts/install-dev-plugins.sh
./scripts/build.sh
```
The resulting application will be located at:
```bash
./build/bin/verstak-desktop
```
Run it with additional diagnostics:
```bash
./build/bin/verstak-desktop --debug
```
## Build release packages locally
These commands create local artifacts in `release/`. They do not publish a GitHub Release.
### Debian package
```bash
./scripts/package-deb.sh v0.1.0-alpha.1
```
### AppImage
```bash
./scripts/package-appimage.sh v0.1.0-alpha.1
```
### Windows portable ZIP
The Windows archive can be cross-compiled on Linux using MinGW:
```bash
sudo apt install gcc-mingw-w64-x86-64 zip
./scripts/package-windows-portable.sh v0.1.0-alpha.1
```
## Project repositories
| Repository | Purpose |
| ---------------------------------------------------------------------------------- | ------------------------------------------------------ |
| [verstak](https://github.com/mirivlad/verstak) | Desktop application, core platform and UI shell |
| [verstak-official-plugins](https://github.com/mirivlad/verstak-official-plugins) | Official Files, Notes, Journal, Todo and other plugins |
| [verstak-sdk](https://github.com/mirivlad/verstak-sdk) | TypeScript SDK, schemas and plugin contracts |
| [verstak-browser-extension](https://github.com/mirivlad/verstak-browser-extension) | Browser capture and optional activity integration |
| [verstak-sync-server](https://github.com/mirivlad/verstak-sync-server) | Optional self-hosted synchronization server |
| [verstak-docs](https://github.com/mirivlad/verstak-docs) | Product and platform architecture documentation |
Compatible components should be built from the same release line.
## Development status
Verstak is under active development.
The current alpha is intended for testing, feedback and experimentation. Backward compatibility is not guaranteed until the first stable release.
Bug reports and feature discussions are welcome in
[GitHub Issues](https://github.com/mirivlad/verstak/issues).
## License
Copyright © 2026 Verstak contributors.
Verstak is licensed under the
[GNU Affero General Public License v3.0 or later](LICENSE).
+340
View File
@@ -0,0 +1,340 @@
<div align="center">
<img src="packaging/linux/verstak.svg" width="112" alt="Логотип Верстака">
# Верстак
### Рабочий контекст остаётся рядом — и хранится локально.
Файлы, заметки, ссылки, материалы из браузера, активность и история работы
в одном расширяемом рабочем пространстве.
[English](README.md) · **Русский**
[![Релиз](https://img.shields.io/github/v/release/mirivlad/verstak?include_prereleases\&label=release)](https://github.com/mirivlad/verstak/releases)
![Статус](https://img.shields.io/badge/status-alpha-orange)
![Платформы](https://img.shields.io/badge/platform-Linux%20%7C%20Windows-blue)
[![Лицензия](https://img.shields.io/github/license/mirivlad/verstak)](LICENSE)
[Скачать](https://github.com/mirivlad/verstak/releases/latest) ·
[Документация](https://github.com/mirivlad/verstak-docs) ·
[Сообщить о проблеме](https://github.com/mirivlad/verstak/issues)
</div>
> [!WARNING]
> Верстак пока находится на стадии **alpha**. До первого стабильного выпуска могут меняться API, форматы хранения и состав пакетов. Для знакомства используйте тестовый vault и сохраняйте резервные копии важных данных.
## Что такое Верстак
Верстак — это local-first рабочая среда, которая удерживает в одном месте контекст вокруг ваших дел.
**Делом** может быть что угодно:
* программный проект;
* клиент или заказчик;
* сервер или площадка;
* ремонтируемое устройство;
* статья или исследование;
* учебный курс;
* личный долгосрочный проект.
Обычно информация о таком деле расползается по папкам, заметкам, браузерным вкладкам, менеджерам задач, хранилищам паролей, истории терминала и памяти.
Верстак собирает её в один локальный **vault**, который остаётся под вашим контролем.
Для локальной работы не нужны учётная запись, облачный сервис или собственный сервер.
## Верстак в работе
| Обзор: возвращает к делу и показывает недавнюю работу, входящие и то, что требует внимания. | Файлы: обычные папки и документы прямо внутри дела. |
| --- | --- |
| ![Актуальный экран «Обзор» Верстака в тестовом vault](docs/screenshots/overview-ru.png) | ![Актуальные файлы дела в тестовом vault Верстака](docs/screenshots/files-ru.png) |
| Заметки: Markdown-заметки остаются рядом с делом, которое они описывают. | Журнал: фиксирует завершённую работу и её контекст. |
| --- | --- |
| ![Актуальные заметки Верстака в тестовом vault](docs/screenshots/notes-ru.png) | ![Актуальный журнал Верстака в тестовом vault](docs/screenshots/journal-ru.png) |
## Основные возможности
| Возможность | Что она даёт |
| ------------------------ | ---------------------------------------------------------------------------------------------- |
| **Дела** | Объединяют файлы, заметки и историю работы вокруг проектов, клиентов и других направлений. |
| **Файлы** | Позволяют работать с обычными файлами, расположенными внутри vault. |
| **Заметки** | Markdown-заметки, обзорные страницы и связи между материалами. |
| **Обзор** | Помогает быстро вернуться к недавней работе и увидеть то, что требует внимания. |
| **Входящие из браузера** | Принимают страницы, ссылки, выделенный текст и файлы из браузера. |
| **Активность и журнал** | Помогают восстановить рабочие сессии и превратить выбранную активность в записи журнала. |
| **Задачи** | Необязательные списки задач внутри отдельных дел и во всём vault. |
| **Поиск** | Ищет по заметкам, файлам и данным поддерживаемых плагинов. |
| **Корзина** | Позволяет восстановить удалённое или окончательно удалить его из одного общего раздела. |
| **Секреты** | Хранит доступы и учётные данные рядом с делом, к которому они относятся. |
| **Шаблоны** | Создают повторяемую структуру для однотипных дел. |
| **Плагины** | Позволяют добавлять, заменять и отключать инструменты, не превращая Верстак в жёсткий монолит. |
| **Синхронизация** | Необязательно синхронизирует vault между устройствами через собственный сервер. |
## Скачивание и установка
Готовые сборки находятся на странице
[GitHub Releases](https://github.com/mirivlad/verstak/releases/latest).
В релизные пакеты уже входят совместимые версии официальных плагинов. Устанавливать их отдельно не требуется.
| Система | Какой файл скачать | Как установить |
| -------------------------------- | ---------------------------------------- | -------------------------------------------- |
| Debian 13 / Ubuntu 24.04 и новее | `verstak_<версия>_amd64.deb` | Установить через APT. |
| Другие дистрибутивы Linux x86_64 | `verstak-linux-x86_64-<версия>.AppImage` | Сделать файл исполняемым и запустить. |
| Windows 10/11 x64 | `verstak-windows-amd64-<версия>.zip` | Распаковать архив и запустить `Verstak.cmd`. |
### Debian и Ubuntu
```bash
sudo apt install ./verstak_*_amd64.deb
```
После установки запустите Верстак из меню приложений или командой:
```bash
verstak
```
### AppImage
```bash
chmod +x verstak-linux-x86_64-*.AppImage
./verstak-linux-x86_64-*.AppImage
```
Если в системе нет поддержки FUSE:
```bash
APPIMAGE_EXTRACT_AND_RUN=1 ./verstak-linux-x86_64-*.AppImage
```
### Портативная версия для Windows
1. Скачайте `verstak-windows-amd64-<версия>.zip`.
2. Распакуйте архив в локальную папку.
3. Запустите `Verstak.cmd`.
Не запускайте Верстак прямо из ZIP-архива или с сетевого диска.
Верстак использует Microsoft WebView2 Runtime. Он уже присутствует в Windows 11 и большинстве актуальных установок Windows 10. Если приложение не запускается, установите
[Microsoft WebView2 Runtime x64](https://go.microsoft.com/fwlink/p/?LinkId=2124701).
## Фоновая работа и трей
Закрытие главного окна не завершает Верстак: приложение остаётся в системном трее. Выберите **Show Verstak**, чтобы вернуть окно, или **Quit**, чтобы полностью завершить приложение. Благодаря этому напоминания Todo продолжают работать, пока окно скрыто.
### Проверка скачанного файла
В каждый релиз входит файл `SHA256SUMS`.
Проверка в Linux:
```bash
sha256sum -c SHA256SUMS --ignore-missing
```
## Первый запуск
### 1. Создайте или откройте vault
**Vault** — это корневая папка, в которой Верстак хранит ваши дела.
Выберите доступный для записи локальный каталог. Например:
```text
Документы/
└── Верстак/
```
Файлы и заметки остаются обычными файлами. Их можно открыть и без Верстака.
### 2. Создайте первое дело
Внутри vault создайте дело для проекта, клиента, сервера, устройства или другого направления работы.
Например:
```text
Домашний сервер
Заказчик Альфа
Разработка Верстака
3D-принтер
Учебный курс
```
### 3. Соберите контекст
Добавьте в дело всё, что поможет вернуться к нему через неделю, месяц или год:
* заметки и принятые решения;
* документы и исходные файлы;
* полезные ссылки;
* данные для доступа;
* записи журнала;
* задачи;
* материалы, отправленные из браузера.
### 4. Возвращайтесь через Обзор
Экран «Обзор» показывает недавние изменения, необработанные входящие, активность и другие подходящие точки для продолжения работы.
## Как Верстак хранит данные
Верстак придерживается нескольких принципов:
* vault хранится локально и доступен без учётной записи и интернета;
* файлы и заметки остаются читаемыми вне приложения;
* синхронизация является дополнением, а не источником истины;
* пользователь должен понимать, где находятся его данные и что с ними происходит;
* инструменты реализуются плагинами, а не навсегда встраиваются в ядро.
Настольное приложение загружает плагины из каталога `plugins/`, расположенного рядом с исполняемым файлом.
## Интеграция с браузером
Необязательное расширение позволяет отправлять во «Входящие из браузера»:
* текущую страницу;
* выделенный текст;
* ссылку;
* выбранный или скачанный файл.
Репозиторий расширения:
[mirivlad/verstak-browser-extension](https://github.com/mirivlad/verstak-browser-extension)
Чтобы подключить расширение:
1. Откройте настройки «Входящих из браузера» в Верстаке.
2. Скопируйте адрес приёмника и токен сопряжения.
3. Вставьте их в настройки расширения.
4. Сохраните настройки и отправьте тестовую страницу.
Пассивный учёт активности по доменам по умолчанию отключён. После включения расширение передаёт только ограниченные интервалы времени для нормализованных доменов. Оно не отправляет содержимое страниц, нажатия клавиш или полную историю посещений.
## Необязательная синхронизация
Для локального использования сервер не нужен.
Для синхронизации между устройствами можно развернуть собственный
[Verstak Sync Server](https://github.com/mirivlad/verstak-sync-server).
Каждый vault подключается отдельно. Основной копией данных остаётся локальный vault.
## Сборка из исходного кода
### Требования
* Go 1.24 или новее;
* Node.js 20 или новее и npm;
* Python 3;
* Git;
* зависимости для сборки Wails v2;
* пакеты разработки WebKitGTK в Linux.
* файлы разработки Ayatana AppIndicator в Linux (`sudo apt install libayatana-appindicator3-dev`).
Зависимости для конкретного дистрибутива перечислены в
[документации Wails](https://wails.io/docs/gettingstarted/installation/).
### Клонирование репозиториев
Desktop, SDK и официальные плагины должны находиться в соседних каталогах:
```text
verstak-source/
├── verstak/
├── verstak-sdk/
└── verstak-official-plugins/
```
```bash
mkdir verstak-source
cd verstak-source
git clone https://github.com/mirivlad/verstak.git
git clone https://github.com/mirivlad/verstak-sdk.git
git clone https://github.com/mirivlad/verstak-official-plugins.git
```
### Сборка
```bash
cd verstak-sdk
./scripts/build.sh
cd ../verstak-official-plugins
./scripts/build.sh
cd ../verstak
./scripts/install-dev-plugins.sh
./scripts/build.sh
```
Готовое приложение появится по адресу:
```bash
./build/bin/verstak-desktop
```
Запуск с дополнительной диагностикой:
```bash
./build/bin/verstak-desktop --debug
```
## Локальная сборка релизных пакетов
Следующие команды создают пакеты в каталоге `release/`, но не публикуют GitHub Release.
### Пакет Debian
```bash
./scripts/package-deb.sh v0.1.0-alpha.1
```
### AppImage
```bash
./scripts/package-appimage.sh v0.1.0-alpha.1
```
### Портативный ZIP для Windows
Windows-версию можно собрать в Linux при помощи MinGW:
```bash
sudo apt install gcc-mingw-w64-x86-64 zip
./scripts/package-windows-portable.sh v0.1.0-alpha.1
```
## Репозитории проекта
| Репозиторий | Назначение |
| ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| [verstak](https://github.com/mirivlad/verstak) | Настольное приложение, ядро платформы и оболочка интерфейса |
| [verstak-official-plugins](https://github.com/mirivlad/verstak-official-plugins) | Официальные плагины файлов, заметок, журнала, задач и других инструментов |
| [verstak-sdk](https://github.com/mirivlad/verstak-sdk) | TypeScript SDK, схемы и контракты плагинов |
| [verstak-browser-extension](https://github.com/mirivlad/verstak-browser-extension) | Отправка материалов из браузера и необязательный учёт активности |
| [verstak-sync-server](https://github.com/mirivlad/verstak-sync-server) | Необязательный собственный сервер синхронизации |
| [verstak-docs](https://github.com/mirivlad/verstak-docs) | Продуктовая и архитектурная документация |
Совместимые компоненты следует собирать из одной релизной линии.
## Состояние разработки
Верстак активно развивается.
Текущая alpha предназначена для тестирования, обратной связи и экспериментов. До первого стабильного выпуска обратная совместимость не гарантируется.
Сообщения об ошибках и предложения принимаются в
[GitHub Issues](https://github.com/mirivlad/verstak/issues).
## Лицензия
Copyright © 2026 Verstak contributors.
Верстак распространяется на условиях
[GNU Affero General Public License v3.0 или новее](LICENSE).
Binary file not shown.
+623 -19
View File
@@ -1,5 +1,4 @@
// Smoke-platform validates that the platform-test plugin is discovered correctly // Package api provides Wails-bound methods for the frontend.
// by the Verstak desktop runtime. This runs headless — no Wails GUI needed.
package main package main
import ( import (
@@ -7,15 +6,20 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"github.com/verstak/verstak-desktop/internal/core/capability" "github.com/verstak/verstak-desktop/internal/core/capability"
"github.com/verstak/verstak-desktop/internal/core/contribution"
"github.com/verstak/verstak-desktop/internal/core/plugin" "github.com/verstak/verstak-desktop/internal/core/plugin"
"github.com/verstak/verstak-desktop/internal/core/pluginstate" "github.com/verstak/verstak-desktop/internal/core/pluginstate"
"github.com/verstak/verstak-desktop/internal/core/vault" "github.com/verstak/verstak-desktop/internal/core/vault"
"github.com/verstak/verstak-desktop/internal/core/workspace"
) )
func main() { func main() {
testEnableDisable := flag.Bool("test-enable-disable", false, "Test enable/disable lifecycle") testEnableDisable := flag.Bool("test-enable-disable", false, "Test enable/disable lifecycle")
testWorkspace := flag.Bool("test-workspace", false, "Test workspace/cases lifecycle")
testContributions := flag.Bool("test-contributions", false, "Test contribution registry lifecycle")
flag.Parse() flag.Parse()
exitCode := 0 exitCode := 0
defer func() { defer func() {
@@ -25,6 +29,16 @@ func main() {
root, _ := os.Getwd() root, _ := os.Getwd()
pluginDir := filepath.Join(root, "plugins") pluginDir := filepath.Join(root, "plugins")
if *testContributions {
runContributionsTest(root)
return
}
if *testWorkspace {
runWorkspaceTest(root)
return
}
if *testEnableDisable { if *testEnableDisable {
runEnableDisableTest(root) runEnableDisableTest(root)
return return
@@ -162,7 +176,6 @@ func main() {
fmt.Printf("\n[capability registration]\n") fmt.Printf("\n[capability registration]\n")
reg := capability.NewRegistry() reg := capability.NewRegistry()
// Register core capabilities (same list as main.go)
coreCaps := []string{ coreCaps := []string{
"verstak/core/plugin-manager/v1", "verstak/core/plugin-manager/v1",
"verstak/core/capability-registry/v1", "verstak/core/capability-registry/v1",
@@ -177,7 +190,6 @@ func main() {
fmt.Printf(" ✅ registered %d core capabilities\n", len(coreCaps)) fmt.Printf(" ✅ registered %d core capabilities\n", len(coreCaps))
} }
// Register vault capability (core service)
if err := reg.Register("verstak-desktop", []string{"verstak/core/vault/v1"}); err != nil { if err := reg.Register("verstak-desktop", []string{"verstak/core/vault/v1"}); err != nil {
fmt.Printf(" ❌ register vault capability: %v\n", err) fmt.Printf(" ❌ register vault capability: %v\n", err)
allGood = false allGood = false
@@ -185,7 +197,13 @@ func main() {
fmt.Printf(" ✅ registered vault capability\n") fmt.Printf(" ✅ registered vault capability\n")
} }
// Register plugin capabilities if err := reg.Register("verstak-desktop", []string{"verstak/core/workspace/v1"}); err != nil {
fmt.Printf(" ❌ register workspace capability: %v\n", err)
allGood = false
} else {
fmt.Printf(" ✅ registered workspace capability\n")
}
for _, p := range m.Provides { for _, p := range m.Provides {
if err := reg.Register(m.ID, []string{p}); err != nil { if err := reg.Register(m.ID, []string{p}); err != nil {
fmt.Printf(" ❌ register capability %s: %v\n", p, err) fmt.Printf(" ❌ register capability %s: %v\n", p, err)
@@ -206,7 +224,16 @@ func main() {
} }
} }
// ── 9. Verify required capabilities resolved ── // ── 9. Verify workspace capability by name ──
fmt.Printf("\n[workspace capability verification]\n")
if reg.Has("verstak/core/workspace/v1") {
fmt.Printf(" ✅ workspace capability present: verstak/core/workspace/v1\n")
} else {
fmt.Printf(" ❌ workspace capability MISSING: verstak/core/workspace/v1\n")
allGood = false
}
// ── 10. Verify required capabilities resolved ──
fmt.Printf("\n[required capability resolution]\n") fmt.Printf("\n[required capability resolution]\n")
missingRequired := reg.CheckRequired(m.Requires) missingRequired := reg.CheckRequired(m.Requires)
if len(missingRequired) > 0 { if len(missingRequired) > 0 {
@@ -218,7 +245,7 @@ func main() {
fmt.Printf(" ✅ all required capabilities resolved\n") fmt.Printf(" ✅ all required capabilities resolved\n")
} }
// ── 10. Check optional capabilities ── // ── 11. Check optional capabilities ──
fmt.Printf("\n[optional capability resolution]\n") fmt.Printf("\n[optional capability resolution]\n")
missingOptional := reg.CheckRequired(m.OptionalRequires) missingOptional := reg.CheckRequired(m.OptionalRequires)
if len(missingOptional) > 0 { if len(missingOptional) > 0 {
@@ -227,7 +254,7 @@ func main() {
} }
} }
// ── 11. Determine expected status ── // ── 12. Determine expected status ──
fmt.Printf("\n[plugin status]\n") fmt.Printf("\n[plugin status]\n")
expectedStatus := "loaded" expectedStatus := "loaded"
if len(missingOptional) > 0 { if len(missingOptional) > 0 {
@@ -246,18 +273,18 @@ func main() {
allGood = false allGood = false
} }
// ── 12. Total capability count ── // ── 13. Total capability count ──
fmt.Printf("\n[capability count]\n") fmt.Printf("\n[capability count]\n")
totalCaps := len(reg.List()) totalCaps := len(reg.List())
fmt.Printf(" total capabilities: %d\n", totalCaps) fmt.Printf(" total capabilities: %d\n", totalCaps)
if totalCaps >= 8 { if totalCaps >= 9 {
fmt.Printf(" ✅ total capabilities >= 8 (%d)\n", totalCaps) fmt.Printf(" ✅ total capabilities >= 9 (%d)\n", totalCaps)
} else { } else {
fmt.Printf(" ❌ total capabilities < 8 (got %d, expected >= 8)\n", totalCaps) fmt.Printf(" ❌ total capabilities < 9 (got %d, expected >= 9)\n", totalCaps)
allGood = false allGood = false
} }
// ── 13. Summary ── // ── 14. Summary ──
fmt.Printf("\n=== summary ===\n") fmt.Printf("\n=== summary ===\n")
if allGood { if allGood {
fmt.Printf("✅ smoke-platform passed\n") fmt.Printf("✅ smoke-platform passed\n")
@@ -288,7 +315,6 @@ func runEnableDisableTest(root string) {
vaultPath := filepath.Join(tmpDir, "testvault") vaultPath := filepath.Join(tmpDir, "testvault")
fmt.Printf(" vault path: %s\n", vaultPath) fmt.Printf(" vault path: %s\n", vaultPath)
// Initialize vault
v := vault.NewVault(nil) v := vault.NewVault(nil)
if err := v.CreateVault(vaultPath); err != nil { if err := v.CreateVault(vaultPath); err != nil {
fmt.Printf(" ❌ create vault: %v\n", err) fmt.Printf(" ❌ create vault: %v\n", err)
@@ -297,7 +323,6 @@ func runEnableDisableTest(root string) {
} }
fmt.Printf(" ✅ vault created\n") fmt.Printf(" ✅ vault created\n")
// Open the vault at the path returned by CreateVault (path/VerstakVault)
openedPath := v.GetVaultPath() openedPath := v.GetVaultPath()
if err := v.OpenVault(openedPath); err != nil { if err := v.OpenVault(openedPath); err != nil {
fmt.Printf(" ❌ open vault: %v\n", err) fmt.Printf(" ❌ open vault: %v\n", err)
@@ -306,7 +331,6 @@ func runEnableDisableTest(root string) {
} }
fmt.Printf(" ✅ vault opened at %s\n", openedPath) fmt.Printf(" ✅ vault opened at %s\n", openedPath)
// Initialize plugin state
psm := pluginstate.NewManager(v) psm := pluginstate.NewManager(v)
if err := psm.Load(); err != nil { if err := psm.Load(); err != nil {
fmt.Printf(" ❌ load plugin state: %v\n", err) fmt.Printf(" ❌ load plugin state: %v\n", err)
@@ -325,7 +349,6 @@ func runEnableDisableTest(root string) {
} }
fmt.Printf(" ✅ discovered %d plugin(s)\n", len(plugins)) fmt.Printf(" ✅ discovered %d plugin(s)\n", len(plugins))
// Find platform-test
var target *plugin.Plugin var target *plugin.Plugin
for i, p := range plugins { for i, p := range plugins {
if p.Manifest.ID == "verstak.platform-test" { if p.Manifest.ID == "verstak.platform-test" {
@@ -380,7 +403,6 @@ func runEnableDisableTest(root string) {
} }
fmt.Printf(" ✅ IsEnabled: false\n") fmt.Printf(" ✅ IsEnabled: false\n")
// Check plugins.json
state := psm.Get() state := psm.Get()
found := false found := false
for _, dp := range state.DesiredPlugins { for _, dp := range state.DesiredPlugins {
@@ -430,7 +452,589 @@ func runEnableDisableTest(root string) {
fmt.Printf(" ✅ plugins.json exists on disk\n") fmt.Printf(" ✅ plugins.json exists on disk\n")
fmt.Printf(" content:\n%s\n", string(data)) fmt.Printf(" content:\n%s\n", string(data))
// ── Summary ──
fmt.Printf("\n=== summary ===\n") fmt.Printf("\n=== summary ===\n")
fmt.Printf("✅ enable/disable test passed\n") fmt.Printf("✅ enable/disable test passed\n")
} }
// runContributionsTest verifies the full manifest → discovery → registry pipeline,
// mirroring the real ReloadPlugins() flow in internal/api/app.go.
//
// Flow:
// 1. plugin.json on disk → DiscoverPlugins() → parsed manifest
// 2. Manifest contributions fragment displayed (proof it comes from real plugin.json)
// 3. Capability registration + resolution → determine plugin status
// 4. Contribution registration (gated by status: only loaded/degraded get contributions)
// 5. Verify all 4 contribution types present by name, data matches manifest
// 6. Disable plugin → Unregister → contributions gone
// 7. Re-enable → Unregister+Register (ReloadPlugins) → contributions return
// 8. Reload → Unregister+Register → no duplicates
func runContributionsTest(root string) {
exitCode := 0
defer func() {
os.Exit(exitCode)
}()
fmt.Printf("=== smoke-platform: contribution registry test ===\n\n")
// Create temp vault
tmpDir, err := os.MkdirTemp("", "verstak-smoke-*")
if err != nil {
fmt.Printf(" ❌ failed to create temp dir: %v\n", err)
exitCode = 1
return
}
defer os.RemoveAll(tmpDir)
vaultPath := filepath.Join(tmpDir, "testvault")
fmt.Printf(" vault path: %s\n", vaultPath)
v := vault.NewVault(nil)
if err := v.CreateVault(vaultPath); err != nil {
fmt.Printf(" ❌ create vault: %v\n", err)
exitCode = 1
return
}
fmt.Printf(" ✅ vault created\n")
openedPath := v.GetVaultPath()
if err := v.OpenVault(openedPath); err != nil {
fmt.Printf(" ❌ open vault: %v\n", err)
exitCode = 1
return
}
fmt.Printf(" ✅ vault opened at %s\n", openedPath)
psm := pluginstate.NewManager(v)
if err := psm.Load(); err != nil {
fmt.Printf(" ❌ load plugin state: %v\n", err)
exitCode = 1
return
}
fmt.Printf(" ✅ plugin state loaded\n")
// ── Step 1: Discover plugins from disk (reads real plugin.json) ──
pluginDir := filepath.Join(root, "plugins")
plugins, _ := plugin.DiscoverPlugins([]string{pluginDir})
if len(plugins) == 0 {
fmt.Printf(" ❌ no plugins discovered\n")
exitCode = 1
return
}
fmt.Printf(" ✅ discovered %d plugin(s)\n", len(plugins))
// Find platform-test
var target *plugin.Plugin
for i, p := range plugins {
if p.Manifest.ID == "verstak.platform-test" {
target = &plugins[i]
break
}
}
if target == nil {
fmt.Printf(" ❌ platform-test not found\n")
exitCode = 1
return
}
fmt.Printf(" ✅ platform-test found: %s@%s\n", target.Manifest.ID, target.Manifest.Version)
// ── Step 2: Show manifest contributions fragment (proof it comes from plugin.json) ──
fmt.Printf("\n[manifest contributions from plugin.json]\n")
fmt.Printf(" plugin: %s\n", target.Manifest.ID)
if c := target.Manifest.Contributes; c != nil {
fmt.Printf(" sidebarItems: %d\n", len(c.SidebarItems))
for _, s := range c.SidebarItems {
fmt.Printf(" - id=%q title=%q view=%q\n", s.ID, s.Title, s.View)
}
fmt.Printf(" views: %d\n", len(c.Views))
for _, vw := range c.Views {
fmt.Printf(" - id=%q title=%q component=%q\n", vw.ID, vw.Title, vw.Component)
}
fmt.Printf(" settingsPanels: %d\n", len(c.SettingsPanels))
for _, s := range c.SettingsPanels {
fmt.Printf(" - id=%q title=%q component=%q\n", s.ID, s.Title, s.Component)
}
fmt.Printf(" commands: %d\n", len(c.Commands))
for _, cmd := range c.Commands {
fmt.Printf(" - id=%q title=%q\n", cmd.ID, cmd.Title)
}
}
// ── Step 2b: Frontend bundle verification ──
fmt.Printf("\n[frontend bundle verification]\n")
frontendOk := true
// 2b.1 — Check manifest declares frontend.entry
if target.Manifest.Frontend == nil {
fmt.Printf(" ❌ manifest.frontend is nil — plugin has no frontend config\n")
frontendOk = false
} else {
expectedEntry := "frontend/dist/index.js"
if target.Manifest.Frontend.Entry != expectedEntry {
fmt.Printf(" ❌ frontend.entry: expected %q, got %q\n", expectedEntry, target.Manifest.Frontend.Entry)
frontendOk = false
} else {
fmt.Printf(" ✅ frontend.entry: %s\n", target.Manifest.Frontend.Entry)
}
// 2b.2 — Resolve entry path and check file exists on disk
resolvedEntry := filepath.Join(target.RootPath, target.Manifest.Frontend.Entry)
if _, err := os.Stat(resolvedEntry); os.IsNotExist(err) {
fmt.Printf(" ❌ frontend bundle NOT FOUND at resolved path: %s\n", resolvedEntry)
frontendOk = false
} else if err != nil {
fmt.Printf(" ❌ frontend bundle stat error: %v\n", err)
frontendOk = false
} else {
fmt.Printf(" ✅ frontend bundle exists: %s\n", resolvedEntry)
// 2b.3 — Read file and check for "VerstakPluginRegister"
data, err := os.ReadFile(resolvedEntry)
if err != nil {
fmt.Printf(" ❌ failed to read frontend bundle: %v\n", err)
frontendOk = false
} else {
content := string(data)
if strings.Contains(content, "VerstakPluginRegister") {
fmt.Printf(" ✅ bundle contains VerstakPluginRegister contract\n")
} else {
fmt.Printf(" ❌ bundle MISSING VerstakPluginRegister contract\n")
frontendOk = false
}
// 2b.4 — Check for expected component names
expectedComponents := []string{"DiagnosticsPanel", "PlatformTestSettings"}
for _, comp := range expectedComponents {
if strings.Contains(content, comp) {
fmt.Printf(" ✅ component registered: %s\n", comp)
} else {
fmt.Printf(" ❌ component MISSING from bundle: %s\n", comp)
frontendOk = false
}
}
}
}
}
if frontendOk {
fmt.Printf(" ✅ frontend bundle checks passed\n")
} else {
fmt.Printf(" ❌ frontend bundle checks FAILED\n")
exitCode = 1
return
}
// ── Step 3: Register capabilities (simulates main.go + ReloadPlugins) ──
reg := capability.NewRegistry()
coreCaps := []string{
"verstak/core/plugin-manager/v1",
"verstak/core/capability-registry/v1",
"verstak/core/contribution-registry/v1",
"verstak/core/permissions/v1",
"verstak/core/events/v1",
}
_ = reg.Register("verstak-desktop", coreCaps)
_ = reg.Register("verstak-desktop", []string{"verstak/core/vault/v1"})
for _, capID := range target.Manifest.Provides {
_ = reg.Register(target.Manifest.ID, []string{capID})
}
fmt.Printf("\n[capability registration]\n")
fmt.Printf(" registered %d capabilities\n", len(reg.List()))
// ── Step 4: ReloadPlugins flow with capability gating ──
fmt.Printf("\n[register contributions (ReloadPlugins flow)]\n")
contribReg := contribution.NewRegistry()
// (a) Check disabled → skip
if target.Status == plugin.StatusDisabled {
fmt.Printf(" ❌ plugin is disabled — contributions should NOT be registered\n")
exitCode = 1
return
}
// (b) Capability resolution (same as ReloadPlugins)
missingRequired := reg.CheckRequired(target.Manifest.Requires)
missingOptional := reg.CheckRequired(target.Manifest.OptionalRequires)
status := plugin.StatusLoaded
if len(missingRequired) > 0 {
status = plugin.StatusMissingRequiredCapability
} else if len(missingOptional) > 0 {
status = plugin.StatusDegraded
}
// (c) Only loaded/degraded get contributions
if status == plugin.StatusLoaded || status == plugin.StatusDegraded {
fmt.Printf(" plugin status=%s → contributions WILL be registered\n", status)
contribReg.Register(target.Manifest.ID, target.Manifest.Contributes)
fmt.Printf(" contributions registered for %s\n", target.Manifest.ID)
} else {
fmt.Printf(" ❌ plugin status=%s — contributions should NOT be registered\n", status)
exitCode = 1
return
}
// ── Step 5: Verify contributions by name, data matches manifest ──
fmt.Printf("\n[verify contributions by name]\n")
allGood := true
sidebarItems := contribReg.SidebarItems()
if len(sidebarItems) != 1 || sidebarItems[0].Item.Title != "Platform Test" {
fmt.Printf(" ❌ sidebarItems mismatch: got %v\n", sidebarItems)
allGood = false
} else {
fmt.Printf(" ✅ sidebarItem: plugin=%s id=%s title=%s\n",
sidebarItems[0].PluginID, sidebarItems[0].Item.ID, sidebarItems[0].Item.Title)
}
views := contribReg.Views()
if len(views) != 1 || views[0].Item.Title != "Platform Diagnostics" {
fmt.Printf(" ❌ views mismatch: got %v\n", views)
allGood = false
} else {
fmt.Printf(" ✅ view: plugin=%s id=%s title=%s component=%s\n",
views[0].PluginID, views[0].Item.ID, views[0].Item.Title, views[0].Item.Component)
}
settingsPanels := contribReg.SettingsPanels()
if len(settingsPanels) != 1 || settingsPanels[0].Item.Title != "Platform Test Settings" {
fmt.Printf(" ❌ settingsPanels mismatch: got %v\n", settingsPanels)
allGood = false
} else {
fmt.Printf(" ✅ settingsPanel: plugin=%s id=%s title=%s\n",
settingsPanels[0].PluginID, settingsPanels[0].Item.ID, settingsPanels[0].Item.Title)
}
commands := contribReg.Commands()
if len(commands) != 2 {
fmt.Printf(" ❌ expected 2 commands, got %d\n", len(commands))
allGood = false
} else {
for _, c := range commands {
fmt.Printf(" command: plugin=%s id=%s title=%s\n", c.PluginID, c.Item.ID, c.Item.Title)
}
}
if !allGood {
fmt.Printf(" ❌ some contribution types missing or data mismatch\n")
exitCode = 1
return
}
fmt.Printf(" ✅ all 4 contribution types present, data matches manifest\n")
// ── Step 6: Disable → Unregister → contributions removed ──
fmt.Printf("\n[disable plugin → Unregister → contributions removed]\n")
if err := psm.DisablePlugin("verstak.platform-test"); err != nil {
fmt.Printf(" ❌ disable: %v\n", err)
exitCode = 1
return
}
fmt.Printf(" disabled platform-test\n")
// ReloadPlugins: Unregister before next scan, disabled plugins don't get re-registered
contribReg.Unregister("verstak.platform-test")
remaining := len(contribReg.Views()) + len(contribReg.SidebarItems()) +
len(contribReg.Commands()) + len(contribReg.SettingsPanels())
fmt.Printf(" remaining contributions: %d\n", remaining)
if remaining != 0 {
fmt.Printf(" ❌ contributions not removed after disable\n")
exitCode = 1
return
}
fmt.Printf(" ✅ all contributions removed\n")
// ── Step 7: Re-enable → contributions return via Unregister+Register ──
fmt.Printf("\n[re-enable → Unregister+Register → contributions return]\n")
if err := psm.EnablePlugin("verstak.platform-test"); err != nil {
fmt.Printf(" ❌ enable: %v\n", err)
exitCode = 1
return
}
fmt.Printf(" enabled platform-test\n")
// ReloadPlugins: Unregister before Register (prevents duplicates)
contribReg.Unregister(target.Manifest.ID)
contribReg.Register(target.Manifest.ID, target.Manifest.Contributes)
count := len(contribReg.Views()) + len(contribReg.SidebarItems()) +
len(contribReg.Commands()) + len(contribReg.SettingsPanels())
fmt.Printf(" contributions after re-enable: %d\n", count)
if count < 4 {
fmt.Printf(" ❌ expected >= 4 contributions, got %d\n", count)
exitCode = 1
return
}
fmt.Printf(" ✅ contributions returned (count=%d)\n", count)
// ── Step 8: Reload → Unregister+Register → no duplicates ──
fmt.Printf("\n[reload → Unregister+Register → no duplicates]\n")
beforeCount := count
contribReg.Unregister(target.Manifest.ID)
contribReg.Register(target.Manifest.ID, target.Manifest.Contributes)
afterCount := len(contribReg.Views()) + len(contribReg.SidebarItems()) +
len(contribReg.Commands()) + len(contribReg.SettingsPanels())
fmt.Printf(" before reload: %d, after reload: %d\n", beforeCount, afterCount)
if afterCount != beforeCount {
fmt.Printf(" ❌ duplicate after reload: before=%d after=%d\n", beforeCount, afterCount)
exitCode = 1
return
}
fmt.Printf(" ✅ no duplicate contributions after reload (count=%d)\n", afterCount)
fmt.Printf("\n=== summary ===\n")
if exitCode == 0 {
fmt.Printf("✅ contribution registry test passed\n")
} else {
fmt.Printf("❌ contribution registry test failed\n")
}
}
func runWorkspaceTest(root string) {
exitCode := 0
defer func() {
os.Exit(exitCode)
}()
fmt.Printf("=== smoke-platform: workspace test ===\n\n")
tmpDir, err := os.MkdirTemp("", "verstak-ws-smoke-*")
if err != nil {
fmt.Printf(" ❌ failed to create temp dir: %v\n", err)
exitCode = 1
return
}
defer os.RemoveAll(tmpDir)
vaultPath := filepath.Join(tmpDir, "testvault")
fmt.Printf(" vault path: %s\n", vaultPath)
v := vault.NewVault(nil)
if err := v.CreateVault(vaultPath); err != nil {
fmt.Printf(" ❌ create vault: %v\n", err)
exitCode = 1
return
}
fmt.Printf(" ✅ vault created\n")
openedPath := v.GetVaultPath()
if err := v.OpenVault(openedPath); err != nil {
fmt.Printf(" ❌ open vault: %v\n", err)
exitCode = 1
return
}
fmt.Printf(" ✅ vault opened at %s\n", openedPath)
fmt.Printf("\n[workspace init]\n")
ws := workspace.NewManager(openedPath)
if err := ws.Load(); err != nil {
fmt.Printf(" ❌ load workspace: %v\n", err)
exitCode = 1
return
}
fmt.Printf(" ✅ workspace loaded\n")
tree := ws.GetTree()
if len(tree.Nodes) != 1 {
fmt.Printf(" ❌ expected 1 root node, got %d\n", len(tree.Nodes))
exitCode = 1
return
}
fmt.Printf(" ✅ root node exists (id=%s)\n", tree.Nodes[0].ID)
rootID := tree.Nodes[0].ID
fmt.Printf("\n[workspace capability]\n")
reg := capability.NewRegistry()
reg.Register("verstak-desktop", []string{
"verstak/core/plugin-manager/v1",
"verstak/core/capability-registry/v1",
"verstak/core/contribution-registry/v1",
"verstak/core/permissions/v1",
"verstak/core/events/v1",
})
reg.Register("verstak-desktop", []string{"verstak/core/vault/v1"})
reg.Register("verstak-desktop", []string{"verstak/core/workspace/v1"})
if !reg.Has("verstak/core/workspace/v1") {
fmt.Printf(" ❌ workspace capability not registered\n")
exitCode = 1
return
}
fmt.Printf(" ✅ workspace capability registered\n")
totalCaps := len(reg.List())
if totalCaps < 7 {
fmt.Printf(" ❌ expected >= 7 capabilities, got %d\n", totalCaps)
exitCode = 1
return
}
fmt.Printf(" ✅ total capabilities >= 7 (%d)\n", totalCaps)
fmt.Printf("\n[create case]\n")
caseNode, err := ws.CreateNode(rootID, workspace.TypeCase, "Test Case")
if err != nil {
fmt.Printf(" ❌ create case: %v\n", err)
exitCode = 1
return
}
fmt.Printf(" ✅ case created: %s\n", caseNode.Title)
fmt.Printf("\n[create folder]\n")
folderNode, err := ws.CreateNode(rootID, workspace.TypeFolder, "Test Folder")
if err != nil {
fmt.Printf(" ❌ create folder: %v\n", err)
exitCode = 1
return
}
fmt.Printf(" ✅ folder created: %s\n", folderNode.Title)
fmt.Printf("\n[create nested case]\n")
nestedCase, err := ws.CreateNode(folderNode.ID, workspace.TypeCase, "Nested Case")
if err != nil {
fmt.Printf(" ❌ create nested case: %v\n", err)
exitCode = 1
return
}
fmt.Printf(" ✅ nested case created: %s\n", nestedCase.Title)
fmt.Printf("\n[tree structure]\n")
tree = ws.GetTree()
if len(tree.Nodes) != 4 {
fmt.Printf(" ❌ expected 4 nodes, got %d\n", len(tree.Nodes))
exitCode = 1
return
}
fmt.Printf(" ✅ tree has 4 nodes\n")
children := ws.ListChildren(rootID)
if len(children) != 2 {
fmt.Printf(" ❌ expected 2 root children, got %d\n", len(children))
exitCode = 1
return
}
fmt.Printf(" ✅ root has 2 children\n")
fmt.Printf("\n[rename]\n")
if err := ws.RenameNode(caseNode.ID, "Renamed Case"); err != nil {
fmt.Printf(" ❌ rename: %v\n", err)
exitCode = 1
return
}
renamed, _ := ws.GetNode(caseNode.ID)
if renamed.Title != "Renamed Case" {
fmt.Printf(" ❌ rename failed: got %q\n", renamed.Title)
exitCode = 1
return
}
fmt.Printf(" ✅ renamed to %q\n", renamed.Title)
fmt.Printf("\n[set current node]\n")
if err := ws.SetCurrentNode(caseNode.ID); err != nil {
fmt.Printf(" ❌ set current: %v\n", err)
exitCode = 1
return
}
current, err := ws.GetCurrentNode()
if err != nil {
fmt.Printf(" ❌ get current: %v\n", err)
exitCode = 1
return
}
if current.ID != caseNode.ID {
fmt.Printf(" ❌ current node mismatch\n")
exitCode = 1
return
}
fmt.Printf(" ✅ current node: %s\n", current.Title)
fmt.Printf("\n[archive]\n")
if err := ws.ArchiveNode(folderNode.ID); err != nil {
fmt.Printf(" ❌ archive: %v\n", err)
exitCode = 1
return
}
archived, _ := ws.GetNode(folderNode.ID)
if archived.Status != workspace.StatusArchived {
fmt.Printf(" ❌ archive failed: status=%s\n", archived.Status)
exitCode = 1
return
}
fmt.Printf(" ✅ archived %s\n", archived.Title)
fmt.Printf("\n[reopen persistence]\n")
ws2 := workspace.NewManager(openedPath)
if err := ws2.Load(); err != nil {
fmt.Printf(" ❌ reopen: %v\n", err)
exitCode = 1
return
}
tree2 := ws2.GetTree()
if len(tree2.Nodes) != 4 {
fmt.Printf(" ❌ expected 4 nodes after reopen, got %d\n", len(tree2.Nodes))
exitCode = 1
return
}
fmt.Printf(" ✅ tree persisted: %d nodes\n", len(tree2.Nodes))
current2, err := ws2.GetCurrentNode()
if err != nil {
fmt.Printf(" ❌ get current after reopen: %v\n", err)
exitCode = 1
return
}
if current2.ID != caseNode.ID {
fmt.Printf(" ❌ current node not persisted\n")
exitCode = 1
return
}
fmt.Printf(" ✅ current node persisted\n")
fmt.Printf("\n[workspace.json verification]\n")
wsPath := filepath.Join(openedPath, ".verstak", "workspace.json")
wsData, err := os.ReadFile(wsPath)
if err != nil {
fmt.Printf(" ❌ read workspace.json: %v\n", err)
exitCode = 1
return
}
fmt.Printf(" ✅ workspace.json exists on disk\n")
fmt.Printf(" content:\n%s\n", string(wsData))
// ── Test 4-level deep tree ──
fmt.Printf("\n[4-level deep tree]\n")
folder1, err := ws.CreateNode(rootID, workspace.TypeFolder, "Level 1 Folder")
if err != nil {
fmt.Printf(" ❌ create folder1: %v\n", err)
exitCode = 1
return
}
fmt.Printf(" ✅ created: %s\n", folder1.Title)
folder2, err := ws.CreateNode(folder1.ID, workspace.TypeFolder, "Level 2 Folder")
if err != nil {
fmt.Printf(" ❌ create folder2: %v\n", err)
exitCode = 1
return
}
fmt.Printf(" ✅ created: %s\n", folder2.Title)
deepCase, err := ws.CreateNode(folder2.ID, workspace.TypeCase, "Deep Case")
if err != nil {
fmt.Printf(" ❌ create deep case: %v\n", err)
exitCode = 1
return
}
fmt.Printf(" ✅ created: %s (depth 4)\n", deepCase.Title)
tree = ws.GetTree()
if len(tree.Nodes) != 7 {
fmt.Printf(" ❌ expected 7 nodes, got %d\n", len(tree.Nodes))
exitCode = 1
return
}
fmt.Printf(" ✅ tree has 7 nodes (4 levels deep)\n")
deepNode, _ := ws.GetNode(deepCase.ID)
if deepNode.ParentID != folder2.ID {
fmt.Printf(" ❌ deep case parent mismatch\n")
exitCode = 1
return
}
fmt.Printf(" ✅ deep case parent chain correct\n")
fmt.Printf("\n=== summary ===\n")
fmt.Printf("✅ workspace test passed\n")
}
+49 -2
View File
@@ -59,12 +59,59 @@ This validates:
## Desktop Runtime Scanning Paths ## Desktop Runtime Scanning Paths
The desktop scans two directories for plugins: The desktop resolves plugin directories in one shared backend resolver. Priority:
| Path | Purpose | | Path | Purpose |
|------|---------| |------|---------|
| `VERSTAK_PLUGIN_DIR` | Dev/test override. Multiple paths can be separated with the OS path separator |
| `./plugins/` | Bundled/dev plugins relative to the current working directory |
| `<binary-dir>/plugins/` | Packaged plugins shipped next to the desktop executable |
| `~/.config/verstak/plugins/` | User-installed plugins | | `~/.config/verstak/plugins/` | User-installed plugins |
| `./plugins/` | Bundled/dev plugins (project-local) |
The resolver normalizes paths and removes duplicates before scanning. Missing
directories are ignored by discovery.
Discovery scans all resolved directories in order. If two plugin packages declare
the same `id`, the first package wins and later duplicates are skipped. The
warning includes both package paths, so during development check the log if an
updated plugin appears to be ignored.
## Bundled Plugin API During Development
Frontend bundles are mounted with a plugin-scoped API created by
`createPluginAPI(pluginId)`. The current API supports:
- `settings.read/write/writeAll`
- `capabilities.list/get/has`
- `commands.register/execute` for handlers declared in `contributes.commands`
- `events.publish/subscribe` using the bundled frontend event bus
- `files.list/metadata/readText/readBytes/writeText/createFolder/move/trash/listTrash/restoreTrash/deleteTrash/openExternal/showInFolder`
for canonical vault-relative slash paths guarded by `files.read`,
`files.write`, `files.delete`, and `files.openExternal`. Backslashes,
Windows absolute paths, UNC paths, traversal, `.verstak` variants, and
symlink read/write/move/trash/external-open operations are rejected. Text
read/write is UTF-8 only and limited to 2 MB for text reads; `readBytes`
returns base64 for regular files up to 8 MB.
- `workbench.openResource/editResource` for routing vault resources to
contributed `openProviders`. Plugins must declare `workbench.open`; this is a
policy/contract check. Files and Notes plugins call this API and do not import
a concrete editor plugin.
Editor/viewer plugins contribute providers with `contributes.openProviders`.
Workbench selects by resource kind, request mode, extension/mime, context
(`generic-text`, `generic-markdown`, `notes-markdown`), user preference, priority, then
deterministic `pluginId/providerId` tie-break. If nothing matches, Workbench
shows `no-provider` fallback instead of a core editor.
The official `verstak.default-editor` plugin provides three openProviders:
`verstak.default-editor.text` (generic-text), `verstak.default-editor.markdown`
(generic-markdown), and `verstak.default-editor.notes-markdown` (notes-context).
It uses a single unified `DefaultEditor` component with textarea-based editing,
simple markdown preview, dirty state tracking, and Ctrl+S save.
This is a cooperative contract, not a sandbox. Bundled plugins run in the same JS
context as the desktop frontend; real isolation is deferred to the sidecar/sandbox
milestone.
## Important Rules ## Important Rules
+134
View File
@@ -0,0 +1,134 @@
# GUI Testing
## Overview
Verstak Desktop uses **Playwright** for frontend E2E tests that run in a real
Chromium browser with mocked Wails bindings. This tests the Svelte component
logic, user interactions, and UI state transitions — without needing the actual
Wails desktop shell.
## What is tested
### Frontend E2E (Playwright)
Located in `frontend/e2e/`, run via `npm run test:e2e`.
These tests:
- Launch a Vite dev server with mock Wails bindings
- Open the app in a real Chromium browser via Playwright
- Simulate user clicks, wait for UI transitions, assert DOM state
- Collect console errors and page errors on failure
- Capture screenshots on failure
### Test suites
| File | Suite | Tests | Status |
|------|-------|-------|--------|
| `plugin-manager-disable-enable.spec.js` | A: Disable/Enable refresh | 4 | pass |
| `sidebar-opens-view.spec.js` | B: Sidebar → view routing | 3 | 3 pass |
| `reload-updates-state.spec.js` | C: Reload updates UI | 4 | pass |
## Resolved bugs covered by tests
### Bug M5-1: Sidebar updates when plugin state changes
**Previous symptom:** After disabling a plugin in Plugin Manager, the sidebar
item for that plugin remained visible. Reloading after external mock state
changes also left stale sidebar items.
**Current behavior:** `PluginManager.svelte` dispatches
`verstak:plugins-changed` after reload/enable/disable flows. `Sidebar.svelte`
listens for that event and re-fetches plugins, vault status, and contributions.
**Regression coverage:**
- `A: Disable plugin: button changes to Enable, sidebar item disappears`
- `A: Disable → Enable full flow in sequence`
- `C: Reload after mock state change reflects new plugin status`
## What is NOT tested
### Real desktop GUI (WebKitGTK + Wails native shell)
The Playwright tests run the frontend in a **standard Chromium browser** with
mocked Wails bindings. They do **not** test:
- Actual WebKitGTK rendering (Wails uses WebKitGTK, not Chromium)
- Native window management (minimize, maximize, resize)
- Native file dialogs (SelectDirectory, SelectVaultForOpen)
- Clipboard integration
- System tray / menu bar
- Plugin frontend bundle loading from real filesystem
- Wails event system (window.runtime.EventsOn/Emit)
For real Wails smoke tests, a separate layer is needed using:
- **AT-SPI2** (Linux accessibility tree inspection)
- **xdotool** / **ydotool** (input simulation)
- **scrot** / **import** (screenshot capture)
## Running tests
```bash
cd frontend
# Run all E2E tests (headless)
npm run test:e2e
# Run with Playwright UI (interactive)
npm run test:e2e:ui
# Run in headed browser (visible)
npm run test:e2e:headed
```
## Test infrastructure
### Mock bridge (`src/lib/test/wails-mock.js`)
Replaces `window['go']['api']['App']` with in-memory mock implementations of
all Wails backend methods. Provides:
- Mutable plugin state (enable/disable/status)
- Mutable vault state
- Mutable contributions (views, commands, sidebar items, settings panels)
- Test helpers via `window.__wailsMock`:
- `reset()` — reset all state to defaults
- `setPluginStatus(id, status, enabled)` — change plugin state
- `getPluginState(id)` — read current state
- `setVaultStatus(status)` — change vault state
### Test harness (`index.html`)
The same `index.html` is used for both production and test. It detects whether
the Wails runtime (`window['go']`) is present. If not (i.e. running in a plain
browser), it loads the mock bridge before the Svelte app.
### Playwright config (`playwright.config.js`)
- Dev server: `vite --mode test --port 5174`
- Browser: Chromium headless
- Timeouts: 30s test, 10s expect
- Workers: 1 (sequential)
- Screenshots: on failure
- Traces: on first retry
- Results: `e2e-results/test-results.json`
## Adding new tests
1. Create `e2e/your-test.spec.js`
2. Import helpers from `./helpers.js`
3. Use `test.beforeEach` to reset mock state and navigate to `/`
4. Use `test.afterEach` to assert no console errors
5. Write scenarios as user actions + assertions
6. Run with `npm run test:e2e`
### Selector conventions
- Plugin cards: `.plugin-card` filtered by text
- Buttons: `.btn-disable`, `.btn-enable`, `.btn-settings`, `.reload-btn`
- Sidebar items: `.sidebar .plugin-item`
- View container: `.view-container`
- View header: `.view-header h2`
- Status badges: `.status-badge`
- Toast: `.toast`
+403
View File
@@ -0,0 +1,403 @@
# Milestone 6a Files Core Service Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use
> `superpowers:subagent-driven-development` or `superpowers:executing-plans` to
> implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for
> tracking.
**Goal:** Add a safe backend Files core service and plugin bridge for
vault-relative text file operations.
**Architecture:** Files Core is a backend service under `internal/core/files`
that accepts vault-relative paths, enforces reserved path policy, and performs
atomic text writes. The Wails API exposes plugin-scoped methods guarded by plugin
state and `files.*` permissions; SDK types describe the bridge shape.
**Tech Stack:** Go backend services/tests, Wails-bound API methods, TypeScript
SDK type definitions, existing Playwright/Vitest checks.
---
## Implementation Status
Milestone 6a is implemented.
Actual backend package:
- `internal/core/files/types.go`
- `internal/core/files/path_policy.go`
- `internal/core/files/service.go`
- `internal/core/files/*_test.go`
Actual plugin-scoped Wails methods:
```go
func (a *App) ListVaultFiles(pluginID string, relativeDir string) ([]files.FileEntry, string)
func (a *App) GetVaultFileMetadata(pluginID string, relativePath string) (files.FileMetadata, string)
func (a *App) ReadVaultTextFile(pluginID string, relativePath string) (string, string)
func (a *App) WriteVaultTextFile(pluginID string, relativePath string, content string, options files.WriteOptions) string
func (a *App) CreateVaultFolder(pluginID string, relativePath string) string
func (a *App) MoveVaultPath(pluginID string, fromRelativePath string, toRelativePath string, options files.MoveOptions) string
func (a *App) TrashVaultPath(pluginID string, relativePath string) (files.TrashResult, string)
```
Actual bundled frontend API:
- `api.files.list(relativeDir)`
- `api.files.metadata(relativePath)`
- `api.files.readText(relativePath)`
- `api.files.writeText(relativePath, content, options)`
- `api.files.createFolder(relativePath)`
- `api.files.move(fromRelativePath, toRelativePath, options)`
- `api.files.trash(relativePath)`
Implemented limits:
- canonical vault-relative slash paths only;
- backslashes, POSIX absolute paths, Windows drive paths, UNC paths, traversal,
null bytes, and empty file paths are rejected;
- `.verstak/` is reserved case-insensitively and hidden from public Files API;
- metadata may report symlinks, but list-through-symlink and
read/write/move/trash through symlink are forbidden;
- text read/write only, with `readText` limited to UTF-8 files up to 2 MB;
- trash uses `.verstak/trash/files/<trashId>/...` with restore metadata, but
restore itself is deferred;
- binary streaming, watcher, external editor, Files UI, Notes service, sidecar,
sandbox/security isolation deferred.
---
## Scope
Implement:
- Backend Files service.
- Safe vault-relative path handling.
- Reserved `.verstak/` policy.
- List files.
- Read/write text files.
- Create folder.
- Move path.
- Trash path.
- Atomic writes.
- Backend tests.
- SDK bridge shape draft.
Do not implement:
- Full Notes plugin.
- Notes UI.
- Sync.
- Watcher.
- Binary streaming.
- External editor integration.
- Sidecar/security isolation.
## Canonical Policy
All public Files API methods use canonical vault-relative slash paths.
Rejected inputs:
- absolute paths;
- backslashes;
- Windows drive paths and UNC/network paths;
- paths containing `..` after normalization;
- null bytes;
- empty paths where a file path is required;
- access to `.verstak/` through the public plugin Files API, including
`.Verstak` case variants.
Delete behavior:
- `TrashVaultPath` moves files/folders into `.verstak/trash`.
- Trash metadata includes `originalPath`, `deletedAt`, `originalType`,
`trashId`, and `basename`.
- Permanent delete is out of scope.
- Restore is out of scope.
Write behavior:
- Text writes use a temporary file in the target directory and rename into place.
- Existing files are overwritten only when the method explicitly allows overwrite.
- Parent directory must exist unless the method explicitly creates it.
Binary behavior:
- Binary files can appear in list/metadata results.
- Binary read/write streaming is out of scope.
## Public Backend Shape
Implemented plugin-scoped Wails methods:
```go
func (a *App) ListVaultFiles(pluginID string, relativeDir string) ([]files.FileEntry, string)
func (a *App) GetVaultFileMetadata(pluginID string, relativePath string) (files.FileMetadata, string)
func (a *App) ReadVaultTextFile(pluginID string, relativePath string) (string, string)
func (a *App) WriteVaultTextFile(pluginID string, relativePath string, content string, options files.WriteOptions) string
func (a *App) CreateVaultFolder(pluginID string, relativePath string) string
func (a *App) MoveVaultPath(pluginID string, fromRelativePath string, toRelativePath string, options files.MoveOptions) string
func (a *App) TrashVaultPath(pluginID string, relativePath string) (files.TrashResult, string)
```
Permission mapping:
- `ListVaultFiles`, `GetVaultFileMetadata`, `ReadVaultTextFile`: `files.read`.
- `WriteVaultTextFile`, `CreateVaultFolder`, `MoveVaultPath`: `files.write`.
- `TrashVaultPath`: `files.delete`.
## Data Types
Create `internal/core/files/types.go`:
```go
package files
type EntryKind string
const (
KindFile EntryKind = "file"
KindDirectory EntryKind = "directory"
)
type Entry struct {
Name string `json:"name"`
Path string `json:"path"`
Kind EntryKind `json:"kind"`
Size int64 `json:"size"`
ModifiedAt string `json:"modifiedAt"`
IsText bool `json:"isText"`
IsBinary bool `json:"isBinary"`
IsHidden bool `json:"isHidden"`
IsReserved bool `json:"isReserved"`
}
```
## Task 1: Path Policy
**Files:**
- Create: `internal/core/files/path.go`
- Create: `internal/core/files/path_test.go`
- [x] Add `NormalizeVaultRelativePath(relative string) (string, error)`.
- [x] Reject absolute paths, null bytes, `..`, and empty file paths.
- [x] Preserve path case, including canonical `Notes`.
- [x] Add `IsReservedPath(relative string) bool` returning true for `.verstak`
and `.verstak/...`.
- [x] Add tests:
`TestNormalizeRejectsAbsolutePath`,
`TestNormalizeRejectsTraversal`,
`TestNormalizeRejectsNullByte`,
`TestNormalizePreservesCase`,
`TestReservedPathPolicy`.
- [x] Run:
```bash
go test ./internal/core/files
```
Expected: all `internal/core/files` tests pass.
## Task 2: Files Service
**Files:**
- Create: `internal/core/files/service.go`
- Create/modify: `internal/core/files/service_test.go`
- [x] Define `Service` with a vault dependency that can return the current vault
root and status.
- [x] Implement `List(relativeDir string) ([]Entry, error)`.
- [x] Implement `Metadata(relativePath string) (Entry, error)`.
- [x] Implement `ReadText(relativePath string) (string, error)`.
- [x] Implement `WriteText(relativePath, content string, overwrite bool) (Entry, error)`.
- [x] Implement `Mkdir(relativePath string) (Entry, error)`.
- [x] Implement `Move(fromRelativePath, toRelativePath string, overwrite bool) (Entry, error)`.
- [x] Implement `Trash(relativePath string) (Entry, error)`.
- [x] Use the shared path policy for every public method.
- [x] Block `.verstak` paths in every public method.
- [x] Add tests for closed vault, list, metadata, text read/write, mkdir, move,
trash, overwrite false conflict, overwrite true replace, and reserved path
rejection.
- [x] Run:
```bash
go test ./internal/core/files
```
Expected: all `internal/core/files` tests pass.
## Task 3: Atomic Writes
**Files:**
- Modify: `internal/core/files/service.go`
- Modify: `internal/core/files/service_test.go`
- [x] Write text content to a temp file in the target directory.
- [x] Rename the temp file into the final path only after successful write.
- [x] Remove temp file on write failure.
- [x] Add test `TestWriteTextIsAtomicOnFailure` using a controlled failing path
or permission-denied directory.
- [x] Add test `TestWriteTextDoesNotLeaveTempFile`.
- [x] Run:
```bash
go test ./internal/core/files
```
Expected: all `internal/core/files` tests pass.
## Task 4: Permissions And Capabilities
**Files:**
- Modify: `internal/core/permissions/registry.go`
- Modify: `main.go`
- Modify: `internal/api/app_test.go`
- [x] Register permissions: `files.read`, `files.write`, `files.delete`.
- [x] Register core capability `verstak/core/files/v1` when vault services are
initialized.
- [x] Add API guard tests proving each Files bridge method rejects plugins that
are missing the required permission.
- [x] Run:
```bash
go test ./internal/core/permissions ./internal/api
```
Expected: permission registry and API tests pass.
## Task 5: Wails API Bridge
**Files:**
- Modify: `internal/api/app.go`
- Modify: `internal/api/app_test.go`
- Modify after Wails generation or by hand if generation is unavailable:
`frontend/wailsjs/go/api/App.d.ts`
- Modify after Wails generation or by hand if generation is unavailable:
`frontend/wailsjs/go/api/App.js`
- [x] Add `files.Service` to `api.App`.
- [x] Add plugin-scoped methods listed in "Public Backend Shape".
- [x] Use `requirePluginAccess(pluginID, permission)` for every method.
- [x] Return readable errors for closed vault, missing file, reserved path,
conflict, and missing permission.
- [x] Add tests for successful read/write/list/mkdir/move/trash through `App`.
- [x] Run:
```bash
go test ./internal/api
```
Expected: API tests pass.
## Task 6: Frontend Plugin API Draft
**Files:**
- Modify: `frontend/src/lib/plugin-host/VerstakPluginAPI.js`
- Modify: `frontend/src/lib/test/wails-mock.js`
- Add/modify focused frontend tests under `frontend/e2e/` only if existing test
coverage cannot validate the shape outside Playwright.
- [x] Add `api.files.list(relativeDir)`.
- [x] Add `api.files.metadata(relativePath)`.
- [x] Add `api.files.readText(relativePath)`.
- [x] Add `api.files.writeText(relativePath, content, options)`.
- [x] Add `api.files.mkdir(relativePath)`.
- [x] Add `api.files.move(fromRelativePath, toRelativePath, options)`.
- [x] Add `api.files.trash(relativePath)`.
- [x] Keep all calls plugin-scoped; plugin code must not pass `pluginId`.
- [x] Mock readable errors for reserved path and missing permission.
- [x] Run:
```bash
cd frontend
npm run build
```
Expected: frontend build passes.
## Task 7: SDK Bridge Shape Draft
**Files:**
- Modify: `../verstak-sdk/src/plugin-api.ts`
- Modify: `../verstak-sdk/src/test-utils.ts`
- Modify: `../verstak-sdk/src/plugin-api.test.ts`
- [x] Add `files` API TypeScript interfaces matching the frontend API names.
- [x] Add mock Files API methods in `createMockPluginAPI`.
- [x] Add contract tests for API shape, text write/read, reserved path error, and
trash result shape.
- [x] Run:
```bash
cd ../verstak-sdk
./scripts/check.sh
./scripts/build.sh
./scripts/test.sh
```
Expected: SDK check, build, and tests pass.
## Task 8: Documentation
**Files:**
- Modify: `docs/PLUGIN_RUNTIME.md`
- Modify: `docs/NOTES_FILES_PLUGIN_PLAN.md`
- [x] Document Files Core API as functional for Milestone 6a.
- [x] Keep Notes API documented as planned until Milestone 6b or later.
- [x] Document `.verstak` reserved path policy.
- [x] Document slash-only path policy, Windows/UNC rejection, and symlink policy.
- [x] Document text-only write support and deferred binary streaming.
## Task 9: Final Verification
- [x] Run desktop backend tests:
```bash
cd verstak-desktop
go test ./...
```
- [x] Run desktop frontend build:
```bash
cd verstak-desktop/frontend
npm run build
```
- [x] Run desktop e2e:
```bash
cd verstak-desktop/frontend
npm run test:e2e -- --reporter=list
```
- [x] Run official plugins checks:
```bash
cd verstak-official-plugins
./scripts/check.sh
./scripts/build.sh
```
- [x] Run SDK checks:
```bash
cd verstak-sdk
./scripts/check.sh
./scripts/build.sh
./scripts/test.sh
```
Expected: all commands exit 0. Existing Svelte unused CSS warnings are acceptable
only if they remain warnings and do not fail the build.
+148
View File
@@ -0,0 +1,148 @@
# Milestone 6b - Open/Edit Provider Registry + Workbench Routing Skeleton
This milestone adds the minimal infrastructure layer for open/edit routing before
Files UI, Notes UI, or editor implementation starts. It does not implement those
plugins and does not add a concrete core-owned Markdown editor.
## Existing Architecture Found
| Path | Section/title | Summary |
|---|---|---|
| `../verstak-docs/00_README.md` | Main architecture invariant | Core does not know concrete notes, file manager, or markdown editor features. It owns vault, plugin runtime, capability registry, contribution points, permissions, settings, events, storage, and UI shell. |
| `../verstak-docs/01_Product_Vision.md` | What is not in core / platform goal | Markdown editor, file manager, preview, and notes workflow are plugins. Users should be able to replace the markdown editor or install multiple editors. |
| `../verstak-docs/02_Platform_Architecture.md` | UI Shell / Capability Registry | UI Shell knows contribution points, not concrete note editor or file preview implementations. Files plugin checks editor/viewer capabilities instead of depending on `official.markdown-editor`. |
| `../verstak-docs/03_Repositories.md` | Repository split | `verstak-desktop` is Core Platform + UI Shell and does not contain mandatory notes, file manager, or markdown editor modules. Official plugins live separately. |
| `../verstak-docs/04_Plugin_System.md` | Goal / capabilities instead of plugin names / contribution points | Notes, file manager, editor, and viewer are plugin functions. The docs explicitly reject `requires: ["official.markdown-editor"]` and prefer `optionalRequires: ["editor.text.markdown"]`. |
| `../verstak-docs/05_Official_Plugins.md` | official.files / official.notes / official.markdown-editor | Files optionally depends on editor/viewer capabilities. Markdown editor provides `editor.text`, `editor.text.markdown`, and `editor.note.markdown`, and must not own note storage or depend directly on `official.notes`. |
| `../verstak-docs/06_Migration_Strategy.md` | Do not / Definition of Done | Do not make notes/files/editor mandatory core parts. The platform transition is done only when notes/files/editor/preview/activity work as plugins. |
| `docs/NOTES_FILES_PLUGIN_PLAN.md` | Canonical Notes Model | Notes are ordinary Markdown files in canonical `Notes/` folders. No lowercase `notes`, no `.verstak/notes`, no plugin-data note content, no UUID-only filenames. |
| `docs/NOTES_FILES_PLUGIN_PLAN.md` | Files Service Model | Files Core is raw vault file access and does not understand note semantics. Milestone 6a exposes safe text file methods and defers UI, watcher, binary streaming, external editor, and restore. |
| `docs/PLUGIN_RUNTIME.md` | Contribution Points / Bundled Frontend Plugin API | Current runtime hosts views/sidebar/settings/commands and exposes plugin-scoped Files API. `fileActions`, `noteActions`, `contextMenuEntries`, search/activity/status bar entries are registered but not hosted. |
| `internal/core/contribution/registry.go` | Contribution registry | Registry has the established contribution points and now extends that model with `openProviders`. |
| `internal/core/plugin/plugin.go` | Manifest contributions | Plugin manifest types now include `openProviders` alongside existing contribution points. |
| `frontend/src/lib/shell/WorkbenchHost.svelte` and `frontend/src/lib/plugin-host/PluginBundleHost.svelte` | Frontend plugin host | Workbench mounts the selected provider component by plugin id and component id. It remains generic and does not know a concrete editor. |
| `frontend/src/lib/plugin-host/VerstakPluginAPI.js` | Plugin API | Bundled plugins can call `api.workbench.openResource()` and `api.workbench.editResource()` in addition to settings, capabilities, events, commands, and Files API. |
| `../verstak-sdk/src/types.ts` and `../verstak-sdk/schemas/manifest.json` | SDK contribution contracts | SDK types/schema define `openProviders`, `OpenResourceRequest`, provider supports, and `files.*` permissions. |
## What Matches The Desired Model
- Files/Notes/Editor are already documented as plugins, not core modules.
- Official plugins are expected to use the same runtime as community plugins.
- Files plugin is already documented as capability-driven, not hard-wired to a markdown editor.
- Markdown editor is already documented as replaceable via capabilities.
- Notes are already documented as Markdown files under canonical `Notes/` folders, without `.verstak/notes`, UUID note entities, or a second storage truth.
- Desktop code has no hardcoded Markdown editor component.
- Existing plugin host can mount arbitrary plugin components, which is enough foundation for an editor provider host.
## Contradictions Found
- `docs/NOTES_FILES_PLUGIN_PLAN.md` still said Files Core API/capability/permissions were unavailable, while later sections and code show Milestone 6a implemented them.
- `../verstak-docs/05_Official_Plugins.md` calls notes "first-class Verstak entities". That is acceptable only as UI semantics; implementation must not create a separate note storage entity.
- `../verstak-docs/05_Official_Plugins.md` lists "Open externally" as a Files fallback. External open remains deferred and must not enter Milestone 6b.
- `../verstak-sdk/schemas/manifest.json` previously did not include `files.read`, `files.write`, or `files.delete` in the permissions enum, while SDK TS types, desktop permissions, and official `platform-test` manifest already used them. Milestone 6b resolves this 6a contract cleanup item.
## Missing Before 6b
- `openProviders` contribution point.
- `OpenResourceRequest` contract.
- Workbench open/edit routing API for Files/Notes plugins.
- Provider selection model using resource kind, extension/mime, notes context, user preference, provider priority, deterministic fallback, and disabled provider fallback.
- User preferences for default text editor provider, default markdown editor provider, and default notes-context markdown editor provider.
- Host slot/tab that mounts the selected provider component with an open resource request.
- Tests for provider registration, selection, preferences, disabled provider fallback, and notes-context routing.
## Added In 6b
- `contributes.openProviders` in SDK schema/types and desktop manifest structs.
- Desktop contribution registry support for registering, replacing, listing, and
unregistering open providers.
- `OpenResourceRequest`, `OpenResourceContext`, `OpenResourceResult`, and
opened-resource state in the Workbench routing skeleton.
- Provider selection by resource kind, extension/mime, `generic-text`,
`generic-markdown`, or `notes-markdown` context, user preference, priority,
deterministic `pluginId/providerId` fallback, and active plugin filtering.
- `workbench.open` policy permission for plugins that request Workbench routing.
- Draft app settings preferences for default text, markdown, and notes-context
markdown providers.
- `api.workbench.openResource()` and `api.workbench.editResource()` exposed to
frontend plugin bundles.
- Minimal Workbench host that mounts the selected provider component from the
selected provider plugin.
- `no-provider` fallback state when no matching provider exists.
- `platform-test` diagnostic open provider used only to prove routing.
## Decision
Open/edit provider should be an extension of the existing contribution registry,
not a parallel system. It should add `openProviders` beside `views`, `commands`,
`fileActions`, and `noteActions`.
Capabilities remain useful for broad availability and degraded mode, but they are
too coarse for choosing between multiple providers. Provider selection needs a
declarative provider contribution with `supports`, priority, and component id.
## Correct Notes Model
- Notes are a contextual view over ordinary Markdown files under canonical
`Notes/` folders.
- `.md` inside `Notes/` opens through the selected markdown editor with
notes-context.
- `.md` outside `Notes/` opens through the selected markdown editor in generic
markdown mode.
- Plain text opens through the selected text editor provider in `generic-text`
context.
- Files and Notes call open/edit resource; neither embeds a concrete editor.
- Editor provider selection belongs to Workbench/provider registry.
- No `.verstak/notes`, no UUID note entities, no second truth separate from the
`.md` file.
## Minimal Infrastructure Changes
1. Add `openProviders` to SDK manifest/schema/types.
2. Add `openProviders` to desktop plugin manifest structs and contribution
registry.
3. Add `OpenResourceRequest` and provider support match types.
4. Add Workbench/provider selection service with deterministic rules.
5. Add user preferences for `defaultTextEditorProvider`,
`defaultMarkdownEditorProvider`, and `defaultNotesMarkdownEditorProvider`.
6. Add frontend host plumbing so Workbench can mount the selected plugin
component.
7. Fix `verstak-sdk/schemas/manifest.json` permissions enum for `files.*`.
## Proposed Milestone 6b Scope
In scope:
- Contribution/types/schema support for `openProviders`.
- Workbench `openResource`/`editResource` routing API.
- Provider selection with notes context and user preferences.
- Minimal host tab/slot for provider component mounting.
- Diagnostic provider plugin contribution sufficient to prove routing.
- Tests for routing, provider selection, disabled fallback, and notes-context
markdown.
Out of scope:
- Full Files UI feature set.
- Full Notes UI feature set.
- Full editor implementation.
- Real default editor plugin (Milestone 6c).
- Files plugin open/edit integration.
- Notes plugin open/edit integration.
- Hardcoded core Markdown editor.
- Watcher/sync/binary streaming/external editor.
- Sidecar/security boundary.
- Large rewrite.
## 6b-fix: Infrastructure Gaps Closed
Post-review fixes before Milestone 6c:
- Added `openProviders` to `verstak-sdk/schemas/contributions.json` contribution points.
- Added provider matching tests: text/markdown preference, MIME matching, extension
case-insensitivity, multiple supports entries, kind mismatch.
- Added disabled provider exclusion test in `api/app_test.go`.
- Fixed SDK `build.sh`/`test.sh` to detect incomplete `node_modules`.
- Documented disabled provider lifecycle: contributions remain in registry until
ReloadPlugins; `activeOpenProviders()` filters disabled/unloaded at request time.
- Installed Playwright browsers for E2E tests.
+65
View File
@@ -0,0 +1,65 @@
# Milestone 6c — Default Editor Plugin
## Goal
Create the official Default Editor Plugin as an openProvider for text, generic
markdown, and notes-context markdown files. Core desktop does not own or import
the editor component.
## What was built
### Plugin: `verstak.default-editor`
Location: `verstak-official-plugins/plugins/default-editor/`
**Manifest declares 3 openProviders:**
| Provider ID | Context | Extensions |
|-------------|---------|------------|
| `verstak.default-editor.text` | `generic-text` | `.txt`, `.log`, `.conf`, `.ini`, `.toml`, `.yaml`, `.yml`, `.json`, `.csv` |
| `verstak.default-editor.markdown` | `generic-markdown` | `.md`, `.markdown` |
| `verstak.default-editor.notes-markdown` | `notes-markdown` | `.md`, `.markdown` |
All providers use the same `DefaultEditor` component (unified, not 3 separate editors).
**Permissions:** `files.read`, `files.write`, `workbench.open`
**Capabilities required:** `verstak/core/files/v1`, `verstak/core/workbench/v1`
### Frontend component: `DefaultEditor`
- **Modes:** text (textarea), generic-markdown (editor + preview), notes-markdown (editor + preview + notes badge)
- **File loading:** `api.files.readText(path)` with loading/error states
- **Saving:** `api.files.writeText(path, content, { overwrite: true })` with dirty/saved/error states
- **Keyboard:** Ctrl+S / Cmd+S save, Tab indentation
- **Markdown preview:** Simple renderer (no raw HTML, no script injection)
- **Notes context:** Badge + info bar, no separate note entity, no `.verstak/notes`
## Verification
- `go test ./...` — PASS
- `go vet ./...` — PASS
- `npm run build` (frontend) — PASS
- `npm run test:e2e` — 28/28 PASS (20 existing + 8 new)
- `scripts/check.sh` (official-plugins) — PASS
- `scripts/build.sh` (official-plugins) — PASS
- SDK checks — PASS
## Manual testing
Until Files UI plugin exists, use platform-test diagnostics panel to manually
open files via workbench: click "Open Text Diagnostic", "Open Markdown Diagnostic",
or "Open Notes Diagnostic" buttons. These call `api.workbench.editResource` and
route through the default-editor provider.
## Deferred
- CodeMirror/Monaco editor
- Backlinks, internal link navigation
- Secret widgets
- Image asset pipeline
- Files UI plugin
- Notes UI plugin
- Watcher/sync
- External open
- Sidecar/security isolation
+64
View File
@@ -0,0 +1,64 @@
# Milestone 6d — Minimal Files Plugin
## Goal
Create a minimal Files plugin that shows vault files/folders and opens files
through Workbench openResource. No editor embedded.
## What was built
### Plugin: `verstak.files`
Location: `verstak-official-plugins/plugins/files/`
**Contributions:**
- views: `verstak.files.view` → `FilesView` component
- No sidebarItems — Files is not a global sidebar item
**Permissions:** `files.read`, `files.write`, `workbench.open`, `ui.register`
### Files View
- Root listing on mount
- Folder navigation (double-click)
- File open via `api.workbench.openResource()`
- Breadcrumb navigation
- Create folder/file buttons
- Refresh button
- Loading/error/empty states
- `.verstak` filtered out
### Provider priority
- default-editor: priority 50
- platform-test diagnostic: priority 10
- default-editor wins for normal file opens
### Bundle fix
Fixed missing opening quote in STYLES string (`.files-empty` → `'.files-empty'`).
Added automated bundle execution check to `scripts/check.sh`.
## 6d-hotfix
- Removed sidebarItems from Files plugin (Files is not a global sidebar item)
- Added `[frontend bundle execution]` check to `check.sh` — verifies all plugin
bundles parse via `new Function()` and register via `VerstakPluginRegister`
- Updated E2E tests: Files no longer expected in global sidebar
- Documented: sidebarItems are global shell navigation, not workspace template tabs
## Verification
- `go test ./...` — PASS
- `go vet ./...` — PASS
- `npm run build` — PASS
- `npm run test:e2e` — 34/34 PASS
- Official plugins — 3 plugins built, bundle execution check passes
- SDK — 11/11 tests pass
## Deferred
- Notes plugin, rename/move/trash UI, drag-and-drop, context menu,
watcher/inotify, sync, external open, binary streaming, sidecar/security,
workspace template host (Milestone 6d2)
+428
View File
@@ -0,0 +1,428 @@
# Notes/Files Plugin Architecture Plan
This document locks the Notes/Files/Open architecture for the next milestones.
Files Core Service was implemented in Milestone 6a; this document still does not
start Notes UI, Notes plugin, Files UI plugin, editor plugin, watcher, sync, or
binary streaming.
## Current Readiness
The platform is ready for bundled plugin UI experiments. Files Core is available
as a safe vault-scoped text file API. Notes, Files UI, and editor/viewer UI still
need plugin-level implementations and host surfaces before real product use.
Already available:
- Plugin discovery, lifecycle, settings, capabilities, bundled commands, and
bundled frontend events.
- Workspace lifecycle APIs for top-level physical folders under the vault root.
- Plugin-owned internal storage directories:
`.verstak/plugin-data/<pluginId>`, `.verstak/plugin-settings/<pluginId>`, and
`.verstak/plugin-cache/<pluginId>`.
- Contribution registry entries for `fileActions`, `noteActions`,
`contextMenuEntries`, `searchProviders`, `activityProviders`, and
`statusBarItems`.
Not available yet:
- Notes plugin/API as a semantic view over Markdown files.
- Files UI plugin.
- Editor/viewer plugin.
- Open/edit resource routing and provider selection.
- UI hosts for file actions, note actions, context menus, search providers,
activity providers, or status bar items.
- Watcher/indexer for external filesystem changes.
- Real plugin isolation. Current permission checks are contract/policy checks,
not a security boundary for bundled frontend JavaScript.
## Canonical Notes Model
Notes are ordinary human-readable Markdown files inside the vault. They must be
visible and editable outside Verstak.
Canonical rules:
- Notes are `.md` files, not opaque records.
- Canonical folder name is exactly `Notes`.
- Do not create lowercase `notes`.
- Do not store user notes in `.verstak/notes/`.
- Do not store user notes in `.verstak/plugin-data/verstak.notes/`.
- Do not use UUID-only filenames for notes.
- The note title is the source of truth.
- The filename is a normalized projection from the title.
- `RenameNote` must update both the title and the `.md` filename.
Canonical scoped paths:
- Notes live under `<workspace>/Notes/`.
- `Overview.md` is allowed as an ordinary Markdown filename, but the platform
must not create it automatically or treat it as a dedicated UI entity.
- `<workspace>` is the top-level physical folder name under the vault root.
- Files plugin workspace views are scoped with `workspaceRootPath`, which is the
selected top-level workspace folder name.
Visibility requirements:
- Notes UI must show notes as semantic notes.
- Files UI must show the same `.md` files as ordinary files.
- External file managers must show the same `.md` files.
- Outside Verstak, the files must remain useful as normal Markdown.
There is no canonical metadata workspace tree. Adding `note` as a workspace node
type is not part of the next milestone. The official Notes plugin can index and
manage Markdown files inside canonical `Notes/` folders under each top-level
workspace.
## Title To Filename Contract
The title is the source of truth. The filename is derived from the title when a
note is created or renamed.
Normalization rules:
- Replace spaces with `_`.
- Replace typographic dashes with `-`.
- Allow only letters, digits, `.`, `_`, and `-`.
- Append `.md` if the normalized name does not already end with `.md`.
- Reject empty normalized names.
- Preserve canonical `Notes` folder casing.
Examples:
| Title | Filename |
|---|---|
| `Overview` | `Overview.md` |
| `Meeting Notes` | `Meeting_Notes.md` |
| `Plan — Phase 1` | `Plan_-_Phase_1.md` |
| `A/B Test: Result` | `AB_Test_Result.md` |
## Collision Policy
Same-folder collisions must not be solved silently with `_2`, `_3`, or timestamp
suffixes.
Creating or renaming a note must return a conflict error if the normalized target
filename already exists in the target `Notes/` folder. The UI should show a clear
dialog or notification and ask the user to change the title.
Required conflict metadata:
- requested title;
- normalized filename;
- target vault-relative path;
- existing vault-relative path.
## Files Service Model
Files service is the raw vault file layer. It works with vault-relative paths and
does not understand note semantics.
Rules:
- All public Files API paths are canonical vault-relative slash paths.
- Backslashes are rejected instead of normalized.
- Absolute POSIX paths, Windows drive paths, and UNC/network paths are rejected.
- `..` traversal is rejected.
- Null bytes are rejected.
- `.verstak/` is reserved case-insensitively and hidden/forbidden by default.
- Access to `.verstak/` is allowed only through internal APIs, not through the
normal plugin Files API.
- Symlink read/write/move/trash operations are forbidden in Milestone 6a.
Metadata may report a final symlink as `type: "symlink"`.
- Writes must be atomic: write a temp file in the same directory, close it, then
rename.
- Delete must follow the trash policy until permanent delete is explicitly
designed.
- Trash metadata records `originalPath`, `deletedAt`, `originalType`, `trashId`,
and `basename` for future restore work. Restore is deferred.
- Binary files are deferred for write/streaming APIs. Milestone 6a lists binary
metadata but read/write is UTF-8 text only with a 2 MB read limit.
Minimum Files methods:
- `ListVaultFiles(relativeDir)`.
- `GetVaultFileMetadata(relativePath)`.
- `ReadVaultTextFile(relativePath)`.
- `WriteVaultTextFile(relativePath, content, options)`.
- `CreateVaultFolder(relativePath)`.
- `MoveVaultPath(fromRelativePath, toRelativePath)`.
- `TrashVaultPath(relativePath)`.
Milestone 6a status: implemented in `internal/core/files` and exposed to bundled
plugins as `api.files.list`, `api.files.metadata`, `api.files.readText`,
`api.files.writeText`, `api.files.createFolder`, `api.files.move`, and
`api.files.trash`. It is still text-only for reads/writes and has no watcher,
binary streaming, external editor integration, or Files UI plugin.
Later Files methods:
- `WatchVaultFiles(scope)` once watcher/event delivery is ready.
- `ReadVaultFileBytes` / `WriteVaultFileBytes` for binary files.
- `OpenExternal(relativePath)` with explicit permission and UX confirmation.
- `RevealInFileManager(relativePath)`.
## Official Notes Plugin Model
The official Notes plugin is a semantic layer over Markdown files managed through
the public Files plugin API. There is no core desktop Notes service or
`verstak/core/notes/v1` capability in v2.
Rules:
- A note physically is a `.md` file.
- Notes plugin and Files API must not create two sources of truth.
- Notes plugin reads/writes the same files that Files API lists.
- The note title is the semantic source of truth and is projected to the filename.
If frontmatter or a first-heading convention is introduced later, `RenameNote`
must keep that visible title metadata and the filename synchronized.
- Other note metadata should be derived from the file path and filesystem
metadata, or from Markdown frontmatter if a future milestone introduces it.
- If a note is changed through Files API or an external editor, the future
watcher/indexer must observe it.
- Until watcher/indexer exists, external changes require reload/rescan.
Minimum Notes plugin behavior:
- list Markdown files in a scoped canonical `Notes/` folder;
- create a Markdown note by writing the normalized filename atomically with
`overwrite: false`;
- rename a note by moving the file to the normalized filename;
- open and edit notes through Workbench providers;
- never special-case `Overview.md` in the UI.
Later Notes plugin behavior:
- search notes;
- list backlinks;
- resolve note links;
- export notes.
## Notes Vs Files Relationship
Files owns safe raw vault file access. Notes owns note semantics.
The same physical note must be visible through both surfaces:
- Files sees `Project/Notes/Overview.md` as a file.
- Notes sees `Project/Notes/Overview.md` as a note with title `Overview`.
There must be no duplicate note content stored in plugin settings, plugin data,
or a separate `.verstak` note database. Indexes and caches may exist later, but
they must be rebuildable from the canonical Markdown files.
## Capabilities And Permissions
Existing permissions that remain useful:
- `vault.read` for existing vault-level read policy.
- `vault.write` for existing vault-level write policy.
- `vault.watch` for future watcher support.
- `ui.register` for sidebar/views/settings contributions.
- `commands.register` for bundled command handlers.
- `events.publish` and `events.subscribe` for frontend/backend event flows.
New permissions required before real Notes/Files plugins ship:
- `files.read`
- `files.write`
- `files.delete`
- `workbench.open`
Future Notes semantic permissions are deferred until a real Notes plugin/API
ships:
- `notes.read`
- `notes.write`
- `notes.delete`
Those permissions are still policy checks until sidecar/sandbox work provides a
real isolation boundary.
Recommended capabilities:
- `verstak/core/files/v1`
- `verstak/files/v1` provided by the official Files plugin.
- `verstak/notes/v1` provided by the official Notes plugin.
## Frontend Components And Extension Points
Contribution points already registered but not fully hosted:
- `fileActions`
- `noteActions`
- `contextMenuEntries`
- `searchProviders`
- `activityProviders`
- `statusBarItems`
UI work needed:
- Files view host with tree/list modes and selection state.
- Notes view host with note list, open/edit entry points, and preview/details
region.
- Context menu host that merges core actions with plugin contributions.
- Command palette host for contributed commands.
- Search provider host with cancellation/debounce and result ownership.
- Status bar host for lightweight plugin state.
- Selection/event model for active file, active note, and active workspace node.
The first implementation should host only the contribution points needed by the
official Notes and Files plugins.
## Open/Edit Resource Model
Files and Notes must not embed a concrete editor or viewer. They request that the
Workbench open or edit a resource. The Workbench/provider registry selects the
plugin component.
Required model:
- Files plugin lists files and calls open/edit for a vault file.
- Notes plugin presents Markdown files under canonical `Notes/` folders and calls
open/edit for the same vault file with notes context.
- `.md` or `.markdown` inside a canonical `Notes/` folder opens in markdown mode
with notes context.
- `.md` or `.markdown` outside `Notes/` opens in generic markdown mode.
- Plain text opens in `generic-text` mode.
- The same editor provider may support text, generic markdown, and notes-context
markdown.
- User preferences can select another provider for text, markdown, and
notes-context markdown.
- Community editor plugins can replace the default editor through the same
provider registry.
- Core desktop owns registry, routing, Workbench host slot/tab, and preferences.
- Core desktop does not own concrete Files UI, Notes UI, Markdown editor, or file
preview UI.
Minimal contribution extension:
```json
{
"contributes": {
"openProviders": [
{
"id": "verstak.platform-test.markdown-diagnostic",
"title": "Platform Test Markdown Diagnostic",
"priority": 100,
"component": "MarkdownDiagnosticProvider",
"supports": [
{
"kind": "vault-file",
"extensions": [".md", ".markdown"],
"contexts": ["generic-markdown", "notes-markdown"]
},
{
"kind": "vault-file",
"mime": ["text/plain"],
"extensions": [".txt", ".log", ".json", ".yaml", ".yml", ".toml", ".ini", ".conf"],
"contexts": ["generic-text"]
}
]
}
]
}
}
```
Open request shape:
```ts
type OpenResourceRequest = {
kind: "vault-file";
path: string;
mode?: "view" | "edit";
mime?: string;
extension?: string;
context?: {
sourcePluginId?: string;
sourceView?: "files" | "notes" | string;
isInsideNotesFolder?: boolean;
notesScopePath?: string;
notesMode?: boolean;
};
};
```
Provider selection rules:
1. Match resource kind.
2. Match extension and/or mime.
3. Prefer providers that explicitly support the request context.
4. Apply user preference for text, markdown, or notes-context markdown when the
preferred provider is enabled and still supports the resource.
5. Otherwise choose highest priority.
6. Break ties deterministically by plugin id, then provider id.
7. If no provider matches, return a Workbench `no-provider` state rather than
hardcoding a core editor.
8. If a preferred provider plugin is disabled or unavailable, fall back to the
deterministic default and surface a non-blocking preference warning later.
Initial preferences:
- `defaultTextEditorProvider`;
- `defaultMarkdownEditorProvider`;
- `defaultNotesMarkdownEditorProvider`.
Per-extension overrides are deferred.
## Migration Risks
- Adding `note` as a workspace node type is a workspace schema migration and is
explicitly out of scope for the next milestone.
- The canonical path rules must be locked before writing real files into user
vaults.
- Rename behavior can break external links if link rewriting is not designed.
- Case/folder path ownership must be clear before scoped `Notes/` folders are
created.
- Raw Files API can expose `.verstak` internals unless reserved paths are blocked.
- File writes need atomic behavior and conflict handling before sync.
- Large/binary files require streaming or byte APIs; text APIs are not enough.
- External editor changes require reload/rescan until watcher/indexer exists.
- Bundled frontend plugins are trusted/cooperative and not isolated from the
shared JS context.
## Test Plan
Backend Go tests:
- Vault-relative path normalization and traversal rejection.
- Reserved `.verstak` path behavior.
- Files list/read/write/mkdir/move/trash with vault closed/open states.
- Atomic text writes and temp-file cleanup on failure.
- Notes `Notes/` folder casing and no lowercase `notes`.
- Title to filename normalization.
- note create/rename conflict errors without silent suffixes.
- Notes and Files read the same physical `.md` file.
- Permission checks for `files.*`, `notes.*`, `vault.read`, and `vault.write`.
Frontend/unit tests:
- SDK and plugin API shape for Files and Notes draft methods.
- Readable errors for missing permissions, closed vault, missing file, missing
note, reserved path, and collision.
- Contribution host rendering for note/file actions and context menus.
Playwright e2e tests:
- Create a text file, reload, and verify it is visible in Files.
- Create a note, reload, and verify it is visible in both Notes and Files.
- Rename a note and verify title plus filename change together.
- Attempt same-folder collision and verify user-facing conflict handling.
- External file change requires reload/rescan until watcher exists.
## Implementation Order
1. Define canonical vault-relative path rules and reserved path policy.
2. Implement Files core service with safe list/read/write/mkdir/move/trash.
3. Define open/edit resource request, provider contribution shape, and provider
selection rules.
4. Extend contribution registry/types with `openProviders`.
5. Add Workbench open/edit routing and a provider-hosted tab/slot.
6. Add preferences for text, markdown, and notes-context markdown provider ids.
7. Use `platform-test` diagnostic provider to verify routing; real default
editor plugin is deferred to Milestone 6c.
8. Build official Files plugin that calls open/edit resource.
9. Build official Notes plugin as a contextual view over Markdown files in
canonical `Notes/` folders.
10. Implement future Notes semantic helpers only as a facade over Markdown files,
never as a second source of truth.
+689 -100
View File
@@ -6,19 +6,34 @@
### Discovery Directories ### Discovery Directories
Plugins ищутся в двух директориях (порядок приоритета): Plugins ищутся через единый resolver `internal/core/plugin.ResolveDiscoveryDirs`.
Порядок приоритета:
| Путь | Назначение | Коммитится | | Путь | Назначение | Коммитится |
|---|---|---| |---|---|---|
| `VERSTAK_PLUGIN_DIR` | Override для тестов/dev; можно передать несколько путей через OS path separator | Нет |
| `./plugins/` | Dev plugins относительно текущей рабочей директории/repo | Нет (`.gitignore`) |
| `<binary-dir>/plugins/` | Packaged plugins рядом с desktop binary | Зависит от дистрибутива |
| `~/.config/verstak/plugins/` | User-installed plugins | Нет (user home) | | `~/.config/verstak/plugins/` | User-installed plugins | Нет (user home) |
| `./plugins/` | Bundled / dev plugins | Нет (`.gitignore`) |
Resolver нормализует пути, удаляет дубликаты и передает discovery только канонический
список директорий. Отсутствующие директории просто пропускаются на этапе scanning.
Discovery сканирует **все** resolved директории в указанном порядке. Если один и тот
же `plugin.id` найден несколько раз, применяется правило **first plugin wins**:
первый найденный plugin загружается, последующие plugins с тем же id пропускаются.
Конфликт логируется и возвращается как discovery warning с двумя путями: путь
пропущенного duplicate и путь уже загруженного winner.
### ./plugins/ как Dev/Install Target ### ./plugins/ как Dev/Install Target
Директория `./plugins/` в корне `verstak-desktop` используется как: Директория `./plugins/` от текущей рабочей директории используется как:
- **Dev target** — `install-dev-plugins.sh` коприрует сюда собранные пакеты из `verstak-official-plugins/dist/`. - **Dev target** — `install-dev-plugins.sh` коприрует сюда собранные пакеты из `verstak-official-plugins/dist/`.
- **Bundled plugins** — при дистрибутиве core может поставлять плагины здесь. - **Local override** — при запуске desktop из repo позволяет быстро проверять packaged bundles.
В packaged-сборке bundled plugins должны лежать в `plugins/` рядом с executable.
Для тестов и локальных сценариев можно задать `VERSTAK_PLUGIN_DIR=/path/to/plugins`.
Директория **не коммитится**. Каждый разработчик устанавливает плагины через `install-dev-plugins.sh`. Директория **не коммитится**. Каждый разработчик устанавливает плагины через `install-dev-plugins.sh`.
@@ -83,6 +98,8 @@ coreCaps := []string{
"verstak/core/contribution-registry/v1", "verstak/core/contribution-registry/v1",
"verstak/core/permissions/v1", "verstak/core/permissions/v1",
"verstak/core/events/v1", "verstak/core/events/v1",
"verstak/core/files/v1",
"verstak/core/workbench/v1",
} }
capRegistry.Register("verstak-desktop", coreCaps) capRegistry.Register("verstak-desktop", coreCaps)
@@ -95,17 +112,25 @@ capRegistry.Register("verstak-desktop", []string{"verstak/core/vault/v1"})
### Plugin capability resolution ### Plugin capability resolution
``` ```
foreach plugin: 1. Исключить disabled plugins.
1. зарегистрировать plugin.provides в capability registry 2. Пока есть неразрешённые plugins:
2. проверить plugin.requires — если есть missing → missing-required-capability - plugin с уже доступными `requires` атомарно регистрирует `provides`;
3. проверить plugin.optionalRequires — если есть missing → degraded - при конфликте `provides` plugin получает `failed`;
4. иначе → loaded - если за проход ничего не разрешилось, остальные получают
5. зарегистрировать plugin.contributes в contribution registry `missing-required-capability`.
3. После разрешения всех обязательных зависимостей проверить
`optionalRequires`: отсутствующие переводят plugin в `degraded`.
4. Зарегистрировать `contributes` только для `loaded` и `degraded` plugins.
``` ```
Таким образом, порядок discovery не влияет на обычные зависимости между
plugins. При конкурирующих providers одного capability порядок discovery служит
tie-breaker. `provides` плагина с неразрешённым `requires` или ошибкой
регистрации не попадают в registry.
### Capability конфликт ### Capability конфликт
Два plugin не могут предоставлять один и тот же capability. При попытке повторной регистрации — ошибка и статус `failed` для второго плагина. Два plugin не могут предоставлять один и тот же capability. При попытке повторной регистрации второй plugin получает `failed`; регистрация атомарна, поэтому ни один capability такого plugin не остаётся в registry.
## Plugin Manifest Format ## Plugin Manifest Format
@@ -129,7 +154,7 @@ foreach plugin:
|---|---|---| |---|---|---|
| `description` | string | Описание плагина | | `description` | string | Описание плагина |
| `source` | string | `"official"`, `"local"`, `"third-party"` | | `source` | string | `"official"`, `"local"`, `"third-party"` |
| `icon` | string | Иконка (emoji или имя) | | `icon` | string | Имя иконки из встроенного Lucide-набора shell |
| `requires` | string[] | Жёзкие capability-зависимости | | `requires` | string[] | Жёзкие capability-зависимости |
| `optionalRequires` | string[] | Мягкие capability-зависимости | | `optionalRequires` | string[] | Мягкие capability-зависимости |
| `frontend` | object | `{ "entry": "path/to/index.js", "style": "path/to/style.css" }` | | `frontend` | object | `{ "entry": "path/to/index.js", "style": "path/to/style.css" }` |
@@ -150,7 +175,7 @@ foreach plugin:
"provides": ["verstak/platform-test/v1"], "provides": ["verstak/platform-test/v1"],
"requires": ["verstak/core/plugin-manager/v1"], "requires": ["verstak/core/plugin-manager/v1"],
"optionalRequires": ["verstak/core/vault/v1", "verstak/core/sync/v1"], "optionalRequires": ["verstak/core/vault/v1", "verstak/core/sync/v1"],
"permissions": ["vault.read", "events.publish", "ui.register"], "permissions": ["vault.read", "events.publish", "ui.register", "workbench.open"],
"frontend": { "entry": "frontend/dist/index.js" }, "frontend": { "entry": "frontend/dist/index.js" },
"contributes": { "contributes": {
"views": [{ "id": "my.view", "title": "My View", "component": "MyPanel" }], "views": [{ "id": "my.view", "title": "My View", "component": "MyPanel" }],
@@ -161,30 +186,459 @@ foreach plugin:
## Contribution Points ## Contribution Points
Плагины могут регистрировать UI contributions через поле `contributes`: Плагины могут регистрировать UI contributions через поле `contributes` в `plugin.json`.
| Тип | Описание | Icon fields use shell icon names rendered through the bundled Lucide SVG wrapper. Plugins must not rely on emoji, Unicode pictographs, or system icon fonts; if a plugin needs its own icon font, it must bundle the font and reference it from its own frontend bundle.
### Реализованные contribution points (Milestone 5a)
| Тип | Поле manifest | Описание | Frontend host |
|---|---|---|---|
| Боковая панель | `sidebarItems` | Элементы в sidebar слева | ✅ Sidebar.svelte (из ContributionRegistry) |
| Основные панели | `views` | Полноценные страницы/панели | ✅ ViewContainer.svelte (PluginBundleHost — real frontend bundle) |
| Панели настроек | `settingsPanels` | Панели в Plugin Manager | ✅ PluginManager.svelte (кнопка Settings, открывает modal) |
| Команды | `commands` | Команды для command palette | ✅ ContributionRegistry + CommandPalette UI |
| Open/edit providers | `openProviders` | Провайдеры viewer/editor для Workbench routing | ✅ ContributionRegistry + минимальный Workbench host |
| Действия над файлами | `fileActions` | Provider actions for Files surface | ✅ Files plugin context menu |
| Действия над заметками | `noteActions` | Provider actions for Notes surface | ✅ Notes plugin row actions |
| Контекстное меню | `contextMenuEntries` | Provider context menu entries | ✅ Files plugin context menu |
| Провайдеры поиска | `searchProviders` | Search provider discovery | ✅ Contribution summary + Search plugin |
| Провайдеры активности | `activityProviders` | Activity event subscriptions | ✅ Backend activity recorder |
| Элементы status bar | `statusBarItems` | Status bar labels/actions | ✅ StatusBar.svelte host |
### Планируемые contribution points
| Тип | Поле manifest | Статус |
|---|---|---|
| Sidecar-backed command handlers | `commands` | Planned; bundled frontend handlers are current runtime |
| Import/export providers | `importers` / `exporters` | Planned |
| Protocol receivers | `protocol.receivers` | Planned |
### Структура contribution points в manifest
```json
{
"contributes": {
"sidebarItems": [
{
"id": "mypanel.sidebar",
"title": "My Panel",
"icon": "puzzle",
"view": "mypanel.view",
"position": 100
}
],
"views": [
{
"id": "mypanel.view",
"title": "My Panel View",
"icon": "puzzle",
"component": "MyPanelComponent"
}
],
"settingsPanels": [
{
"id": "mypanel.settings",
"title": "My Settings",
"component": "MySettingsPanel"
}
],
"commands": [
{
"id": "mypanel.cmd",
"title": "Do Something",
"icon": "gear",
"handler": "doSomething"
}
]
}
}
```
### Contribution lifecycle
1. Plugin `Register(pluginID, contributions)` — все contributions регистрируются
2. `Unregister(pluginID)` — удаляет все contributions указанного plugin
3. Reload сначала удаляет contributions всех plugins предыдущего discovery, затем регистрирует только `loaded`/`degraded` plugins
4. Disable plugin → `Unregister` (contributions исчезают из UI)
5. Enable plugin → `Register` при следующем Reload
6. Registry idempotent: Register удаляет старые записи перед добавлением новых
## Bundled Frontend Plugin API
Bundled frontend plugins получают API от host через `createPluginAPI(pluginId)`.
Обычный plugin code не передает `pluginId` в методы API: scope закрепляется в
host при mount компонента. Это защищает нормальный cooperative path от случайного
доступа к чужому namespace.
Текущая модель безопасности честно ограничена:
- bundled frontend plugins исполняются в общем JS-контексте приложения;
- проверки permissions/capabilities сейчас являются contract/policy checks, а не
полноценной security boundary;
- malicious JS в общем контексте теоретически может обойти frontend wrapper;
- настоящая изоляция будет только после отдельного sidecar/sandbox milestone.
## Workbench Open/Edit Routing
Files and Notes plugins do not import or embed a concrete editor plugin. They
call `api.workbench.openResource(request)` or `api.workbench.editResource(request)`.
The backend requires the source plugin to be enabled, loaded/degraded, and to
declare `workbench.open`. This is a policy/contract check, not a security
boundary.
`OpenResourceRequest`:
```ts
type OpenResourceRequest = {
kind: "vault-file";
path: string;
mode?: "view" | "edit";
mime?: string;
extension?: string;
context?: {
sourcePluginId?: string;
sourceView?: "files" | "notes" | string;
isInsideNotesFolder?: boolean;
notesScopePath?: string;
notesMode?: boolean;
};
};
```
Routing contexts are fixed as `generic-text`, `generic-markdown`, and
`notes-markdown`. `.md`/`.markdown` inside canonical `Notes/` folders uses
`notes-markdown`; markdown outside Notes uses `generic-markdown`; ordinary text
uses `generic-text`. Milestone 6b derives context from request fields; future
Files/Notes integrations can centralize canonical Notes folder auto-detection in
the Workbench helper.
`contributes.openProviders` extends the existing contribution registry:
```json
{
"contributes": {
"openProviders": [
{
"id": "verstak.platform-test.markdown-diagnostic",
"title": "Platform Test Markdown Diagnostic",
"priority": 100,
"component": "MarkdownDiagnosticProvider",
"supports": [
{
"kind": "vault-file",
"extensions": [".md", ".markdown"],
"contexts": ["generic-markdown", "notes-markdown"],
"modes": ["view"]
},
{
"kind": "vault-file",
"mime": ["text/plain"],
"extensions": [".txt", ".log"],
"contexts": ["generic-text"]
}
]
}
]
}
}
```
Selection uses enabled loaded/degraded provider plugins, resource kind,
request mode, extension/mime, context, user preference, priority, then deterministic
`pluginId/providerId` fallback. If nothing matches, Workbench returns
`status: "no-provider"` and shows the fallback view instead of a core editor.
Disabled/failed/missing-required-capability plugins are excluded from provider
selection at request time by `activeOpenProviders()`. Their contributions may
remain in the registry until the next `ReloadPlugins()` cycle, but they never
match during routing.
Draft app-global preferences are `defaultTextEditorProvider`,
`defaultMarkdownEditorProvider`, and `defaultNotesMarkdownEditorProvider`.
Vault-scoped and per-extension overrides are deferred.
### Default Editor Plugin
The official `verstak.default-editor` plugin (`verstak-official-plugins/plugins/default-editor/`)
provides openProviders for text, generic markdown, and notes-context markdown files.
It uses `api.files.readText` / `api.files.writeText` for file I/O and mounts through
the standard `PluginBundleHost` / provider host mechanism. Core does not import or
reference this plugin directly.
Provider plugins may have no sidebar item — openProviders are contribution points
for workbench routing, not navigation. Plugin Manager displays openProviders in the
contributions summary.
### API methods
`settings`
- `settings.read()` — читает весь settings namespace текущего plugin.
- `settings.read(key)` — читает один ключ.
- `settings.write(key, value)` — обновляет один ключ и пишет namespace обратно.
- `settings.writeAll(settings)` — заменяет settings namespace.
- Backend требует plugin exists, enabled, status `loaded`/`degraded` и permission
`storage.namespace`.
`capabilities`
- `capabilities.list()` — возвращает текущий capability registry.
- `capabilities.get(name)` — возвращает `{ available, name, pluginId, status }`.
- `capabilities.has(name)` — boolean wrapper над `get`.
- Backend требует, чтобы plugin был enabled/loaded и декларировал dependency на
`verstak/core/capability-registry/v1` в `requires` или `optionalRequires`.
`commands`
- `commands.register(commandId, handler)` — регистрирует bundled frontend handler.
Возвращает `Promise<unsubscribe>`.
- `commands.execute(commandId, args)` — backend сначала проверяет plugin status,
permission `commands.register` и что command объявлен в `contributes.commands`
именно этим plugin. Затем frontend registry вызывает зарегистрированный handler.
- `commands.executeFor(pluginId, commandId, args)` — то же выполнение, но для
command другого plugin-provider. Используется host surfaces вроде Files/Notes,
которые читают contribution action и вызывают объявленный provider handler.
- Если command объявлен в manifest, но handler не зарегистрирован, API возвращает
понятную ошибку `declared-but-unhandled`.
- Handler registry очищается при component unmount, reload/disable flow и
`api.dispose()`.
- Shell command palette открывается через `Ctrl+K` / `Cmd+K` или
`Ctrl+Shift+P` / `Cmd+Shift+P`, показывает commands enabled plugins,
фильтрует по title/id/plugin и вызывает зарегистрированные bundled frontend
handlers.
`statusBarItems`
- Shell status bar renders enabled plugin `statusBarItems` contributions.
- Items support `left`, `center`, and `right` positions. Missing position
defaults to `left`.
- The host refreshes on plugin reload/enable/disable through
`verstak:plugins-changed`.
- `handler` is preserved in the contribution summary for future action routing;
current host only renders status labels.
`contributions`
- `contributions.list()` — возвращает весь flattened contribution summary.
- `contributions.list(point)` — возвращает массив contribution records для
конкретного поля (`fileActions`, `noteActions`, `contextMenuEntries`, etc).
- Runtime records include `pluginId`, so consumer surfaces can call
`commands.executeFor(record.pluginId, record.handler, args)`.
`events`
- `events.subscribe(eventName, handler)` — frontend-local subscription с backend
validation permission `events.subscribe`. Возвращает `Promise<unsubscribe>`.
- `events.publish(eventName, payload)` — backend проверяет `events.publish`, затем
событие dispatch'ится в bundled frontend event bus.
- Handler получает envelope `{ name, pluginId, payload, timestamp }`.
- Subscriptions очищаются при component unmount, reload/disable flow и
`api.dispose()`.
- Core publishes `file.changed` for Files API mutations and live vault watcher
changes. Watcher payloads use `operation: "external.create"`,
`"external.update"`, or `"external.delete"`, include vault-relative `path`,
`type`, `workspaceRootPath`, and `external: true`.
`files`
- `files.list(relativeDir)` — list directory using a vault-relative path.
- `files.metadata(relativePath)` — returns file/folder/symlink metadata.
- `files.readText(relativePath)` — reads a UTF-8 regular file, with a size limit.
- `files.readBytes(relativePath)` — reads a regular file up to 8 MB and returns
`{ relativePath, size, mimeHint, dataBase64 }` for bounded binary preview use.
- `files.writeText(relativePath, content, options)` — atomically writes text via
temp-file-and-rename. `options.createIfMissing` and `options.overwrite`
control conflicts.
- `files.writeBytes(relativePath, dataBase64, options)` — decodes a base64
payload up to 8 MB and atomically writes bytes via temp-file-and-rename.
`options.createIfMissing` and `options.overwrite` control conflicts.
- `files.createFolder(relativePath)` — creates one folder when the parent exists.
- `files.move(from, to, options)` — moves a file or folder; rejects moving a
folder into itself and conflicts unless `options.overwrite` is true.
- `files.trash(relativePath)` — moves a file/folder into internal
`.verstak/trash/files/<trashId>/...` and returns trash metadata.
- `files.listTrash()` — returns internal file trash metadata, newest first.
- `files.restoreTrash(trashId, options)` — restores a file/folder from internal
trash to its original vault-relative path, or to `options.targetPath`.
Conflicts are rejected unless `options.overwrite` is true.
- `files.deleteTrash(trashId)` — permanently removes a file/folder already in
internal trash. This operation cannot be undone.
- UI policy: the Deal `Files` plugin displays only live files and folders.
The global official `verstak.trash` plugin owns listing, restoring, filtering,
and permanently deleting deleted items across the vault.
- `files.openExternal(relativePath)` — opens a vault-relative file/folder in
the OS default application.
- `files.showInFolder(relativePath)` — reveals a vault-relative file/folder in
the OS file manager where the platform supports it.
- Backend requires plugin exists, enabled, status `loaded`/`degraded`, open
vault, and `files.read`, `files.write`, `files.delete`, or
`files.openExternal`.
- All paths are canonical vault-relative slash paths. Backslashes, POSIX
absolute paths, Windows drive paths, UNC/network paths, `..`, null bytes,
symlink traversal, and public access to `.verstak/` are rejected.
- `.verstak` is reserved case-insensitively: `.verstak`, `.Verstak`, and any
first path segment with that spelling are internal-only.
- `files.metadata` may report a final symlink as `type: "symlink"`, but
`files.list` through a symlink directory and all read/write/move/trash
operations through symlinks are forbidden in Milestone 6a.
- `readText` is limited to UTF-8 regular files up to 2 MB. `readBytes` and
`writeBytes` are bounded byte contracts up to 8 MB; chunked streaming is
deferred.
- Live watcher refresh is active while Verstak is running and a vault is open.
It performs an initial no-event snapshot, then publishes `file.changed` for
external creates, updates, and deletes outside `.verstak/`. It does not keep a
persistent snapshot or report what changed while Verstak was closed.
`sync`
- `sync.now()` pushes local operations, pulls remote operations, and returns
`{ pushed, pulled, serverSequence, conflicts?, applyErrors? }`.
- `conflicts` is an array of server-reported sync conflicts. Conflict objects
may include `op_id`, `entity_type`, `entity_id`, `reason`, and additional
server fields. The Sync plugin must show conflict details instead of only a
count, and it must not silently resolve or overwrite local data.
- `applyErrors` lists local apply failures for pulled operations. These are
user-visible warnings and do not imply that sync was fully successful.
- Transport push/pull uses bounded retry/backoff for transient HTTP/network
failures. Client/auth errors are not retried.
`dispose`
- `dispose()` вызывается host'ом при cleanup. Plugin code обычно не вызывает его
напрямую. Он удаляет зарегистрированные command handlers и event subscriptions.
### Runtime boundaries
| Layer | Current status |
|---|---| |---|---|
| `views` | Панели/страницы (component — Svelte) | | Bundled frontend runtime | Functional for settings, capabilities, commands, events and text Files API |
| `commands` | Команды command palette | | Backend validation | Checks plugin exists, enabled/loaded state, permissions and declarations |
| `settingsPanels` | Панели в Settings | | Security boundary | Not implemented; bundled plugins share the desktop frontend JS context |
| `sidebarItems` | Элементы боковой панели | | Sidecar/RPC/sandbox | Not implemented |
| `fileActions` | Действия над файлами |
| `noteActions` | Действия над заметками | ### Error boundary
| `contextMenuEntries` | Пункты контекстного меню |
| `searchProviders` | Провайдеры поиска | - Ошибка в plugin view/settings placeholder не роняет shell
| `activityProviders` | Провайдеры активности | - ViewContainer показывает "⚠️ Plugin UI failed" fallback
| `statusBarItems` | Элементы status bar | - Error канал: `console.error` + видимый fallback в UI
## Frontend Bundle Contract
### Регистрация компонентов
Плагин регистрирует frontend компоненты через глобальную функцию `window.VerstakPluginRegister`:
```javascript
window.VerstakPluginRegister('plugin.id', {
components: {
'ComponentName': {
mount: function(containerEl, props, api) {
// containerEl — div, созданный PluginBundleHost
// api — ограниченный VerstakPluginAPI
containerEl.innerHTML = '<h1>Hello from plugin!</h1>';
},
unmount: function(containerEl) {
// Очистка при смене view
containerEl.innerHTML = '';
}
}
}
});
```
### VerstakPluginAPI
API объект передаётся в `mount()` и содержит plugin-scoped методы текущего
bundled runtime. Это реальный runtime contract для cooperative bundled plugins,
но не sandbox/security boundary.
| Свойство | Статус | Описание |
|---|---|---|
| `api.pluginId` | ✅ Работает | ID плагина |
| `api.settings.read(key?)` | ✅ Работает | Читает plugin-scoped settings через backend bridge |
| `api.settings.write(key, value)` | ✅ Работает | Пишет один settings key через backend bridge |
| `api.settings.writeAll(settings)` | ✅ Работает | Заменяет settings namespace плагина |
| `api.capabilities.list()` | ✅ Работает | Возвращает capability registry |
| `api.capabilities.get(id)` | ✅ Работает | Возвращает capability entry/status |
| `api.capabilities.has(id)` | ✅ Работает | Boolean wrapper над `get` |
| `api.commands.register(id, handler)` | ✅ Работает | Регистрирует bundled frontend handler для объявленной command |
| `api.commands.execute(id, args)` | ✅ Работает | Валидирует declaration/permission/backend state и вызывает bundled handler |
| `api.commands.executeFor(pluginId, id, args)` | ✅ Работает | Выполняет handler другого plugin-provider после backend validation |
| `api.contributions.list(point?)` | ✅ Работает | Возвращает flattened contribution summary или массив по point |
| Command Palette UI | ✅ Работает | `Ctrl/Cmd+K`, фильтр enabled plugin commands, вызов registered frontend handlers |
| `api.events.publish(type, payload)` | ✅ Работает | Валидирует permission и публикует во frontend event bus |
| `api.events.subscribe(type, handler)` | ✅ Работает | Валидирует permission и подписывает handler на frontend event bus |
| `api.files.list(relativeDir)` | ✅ Работает | Список vault-relative директории, `.verstak` скрыта |
| `api.files.metadata(relativePath)` | ✅ Работает | Metadata для файла/папки/symlink без чтения содержимого |
| `api.files.readText(relativePath)` | ✅ Работает | Читает UTF-8 regular file до 2 MB |
| `api.files.readBytes(relativePath)` | ✅ Работает | Читает regular file до 8 MB как base64 payload |
| `api.files.writeText(relativePath, content, options)` | ✅ Работает | Atomic text write с явным create/overwrite policy |
| `api.files.writeBytes(relativePath, dataBase64, options)` | ✅ Работает | Atomic bounded byte write до 8 MB с явным create/overwrite policy |
| `api.files.createFolder(relativePath)` | ✅ Работает | Создаёт vault-relative folder |
| `api.files.move(from, to, options)` | ✅ Работает | Move file/folder с conflict и path-policy checks |
| `api.files.trash(relativePath)` | ✅ Работает | Перемещает в internal trash и сохраняет metadata исходного объекта |
| `api.files.listTrash()` | ✅ Работает | Возвращает metadata internal file trash |
| `api.files.restoreTrash(trashId, options)` | ✅ Работает | Восстанавливает из internal trash, conflict-safe по умолчанию |
| `api.files.deleteTrash(trashId)` | ✅ Работает | Необратимо удаляет запись и payload из internal trash |
| `api.files.openExternal(relativePath)` | ✅ Работает | Открывает vault file/folder во внешнем приложении, требует `files.openExternal` |
| `api.files.showInFolder(relativePath)` | ✅ Работает | Показывает vault file/folder в системном файловом менеджере, требует `files.openExternal` |
| `api.workbench.openResource(request)` | ✅ Работает | Routes vault resources to `openProviders` |
| `api.workbench.editResource(request)` | ✅ Работает | Same routing, forcing `mode: "edit"` |
| `api.sync.now()` | ✅ Работает | Push/pull с bounded retry/backoff для transient HTTP/network failures |
| `api.sync.status()` | ✅ Работает | Возвращает configured/connected/error/revoked state, lastError, unpushed count |
| `api.dispose()` | ✅ Работает | Очищает command handlers и event subscriptions текущего API instance |
Ограничения:
- permissions/capabilities checks являются contract/policy checks;
- bundled frontend plugins исполняются в общем JS-контексте;
- malicious JS не изолирован;
- sidecar process lifecycle, RPC transport и sandbox enforcement ещё не
реализованы.
- Files paths are slash-only vault-relative contract paths; backslashes,
Windows absolute paths, UNC paths, `.verstak` variants, traversal and symlink
operations are rejected by backend policy checks.
### Загрузка бандла
1. `PluginBundleHost` получает pluginId и componentId
2. Вызывает `App.GetPluginFrontendInfo(pluginId)` — получает entry/style/rootPath
3. Вызывает `App.GetPluginAssetContent(pluginId, entry)` — получает JS контент
4. Выполняет контент через `new Function(content)` — bundle вызывает `VerstakPluginRegister`
5. Находит компонент по componentId и вызывает `mount(container, props, api)`
6. При смене view — вызывает `unmount(container)` для старого компонента
### Безопасность asset path
| Правило | Проверка |
|---|---|
| Нет абсолютных путей | Пути, начинающиеся с `/` или `\`, отклоняются |
| Нет path traversal | Пути, содержащие `..`, отклоняются |
| Нет выхода за root | После `filepath.Join` проверяется, что путь внутри plugin root |
| Только существующие файлы | `os.ReadFile` возвращает ошибку если файл не существует |
### manifest frontend config
```json
{
"frontend": {
"entry": "frontend/dist/index.js",
"style": "frontend/style.css"
}
}
```
## Reload ## Reload
`ReloadPlugins()` в `internal/api/app.go` позволяет перезагрузить plugins без перезапуска приложения: `ReloadPlugins()` в `internal/api/app.go` позволяет перезагрузить plugins без перезапуска приложения:
1. Unregister all capabilities (кроме core). 1. Unregister contributions всех plugins предыдущего discovery.
2. Re-register core capabilities. 2. Unregister all non-core capabilities.
3. Re-scan discovery directories. 3. Re-register core capabilities + vault + Deal manager (если открыт).
4. Re-run capability resolution. 4. Re-scan discovery directories и повторить capability resolution.
5. Re-register contributions. 5. Register contributions для loaded/degraded plugins (disabled/failed — не регистрируются).
6. Update plugins list.
Frontend вызывает это при нажатии "Reload" в Plugin Manager. Frontend вызывает это при нажатии "Reload" в Plugin Manager.
@@ -342,93 +796,228 @@ Vault plugin state хранится **внутри vault** в `.verstak/plugins.
- Auto-install пока НЕ делается - Auto-install пока НЕ делается
- Показывается подсказка: "Install official plugin package" - Показывается подсказка: "Install official plugin package"
## App Settings
App settings хранятся локально (НЕ внутри vault): `~/.config/verstak/config.json` ## UI Layout
Поля: ```
- `currentVaultPath` — путь к текущему vault ┌─────────────────────────────────────────────────────┐
- `recentVaults` — список недавних vault (max 10, без дублей) │ App.svelte │
- `theme` — тема (dark/light) │ ┌──────────┬──────────────────────────────────────┐ │
- `devMode` — режим разработки │ │ Sidebar │ Content area │ │
- `userPluginsDir` — директория пользовательских плагинов │ │ │ │ │
- `windowState` — состояние окна │ │ Verstak │ PluginManager | ViewContainer │ │
- `lastOpenedAt` — время последнего открытия │ │ │ │ │
│ │ [icon] │ (padding: 1.5rem) │ │
│ │ Plugin │ │ │
│ │ Manager│ │ │
│ │ │ │ │
│ │ Plugins │ │ │
│ │ [icon] item1 │ │
│ │ [icon] item2 │ │
│ │ │ │ │
│ │ ● Vault │ │ │
│ └──────────┴──────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
```
API: - **Sidebar** (220px): навигация (Plugin Manager), plugin sidebar items, vault status
- `LoadAppSettings()` / `SaveAppSettings()` / `GetAppSettings()` - **Content**: Plugin Manager или ViewContainer в зависимости от выбранного view
- `UpdateAppSettings(patch)` — частичное обновление - **Vault Selection**: полноэкранный экран, показывается когда vault не открыт
- `SetCurrentVault(path)` — установить текущий vault + открыть + сохранить
Правила: ## Milestone 4b — UI Completion (2026-06-17)
- Если config отсутствует — создать defaults
- Если config битый — backup + создать defaults + вернуть ошибку
- `currentVaultPath` не обязан существовать, но при запуске проверяется
- Secrets НЕ хранятся в app settings
## Vault Plugin State Сделано:
- VaultSelection.svelte: исправлен flow (CreateVault → OpenVault → SetCurrentVault)
- Sidebar.svelte: полная навигация с отступами, plugin sidebar items, vault status
- App.svelte: обработка `verstak:nav` событий, global reset стилей
- PluginManager.svelte: исправлены отступы header
Vault plugin state хранится внутри vault: `<vault>/.verstak/plugins.json` Проверки:
- `go test ./...` — 52 PASS
- `./scripts/check.sh` — ✅
- `./scripts/smoke-platform.sh` — ✅ (enable/disable/plugins.json)
- `./scripts/build.sh` — ✅
## Дело: core capability
Дело — это физическая папка верхнего уровня внутри vault root. Filesystem
является source of truth для списка Дел.
Пример:
```
<vault>/
Deal/
Notes/
Project/
ClientA/
.verstak/
```
Нет единого `<vault>/Deal/` контейнера для всех Дел. Папка `Deal/` может быть
обычным Делом, но `Project/` и `ClientA/` являются такими же Делами на том же
уровне.
### Хранение
Существование и список Дел хранятся только в filesystem:
- `ListWorkspaces()` читает top-level directories из vault root.
- `.verstak`, reserved/internal directories, top-level files и symlinks не
считаются Делами.
- `.verstak/workspace*.json` не является source of truth для списка Дел.
- Нет virtual workspace tree, которое
мапится на произвольные папки.
`.verstak` может хранить только metadata, которая не заменяет filesystem:
- UI state: выбранное Дело, expanded folders, sort/pin state, preferences.
- Semantic snapshot: applied template snapshot, enabled feature areas, exact
`workspaceTools`, and folder conventions.
При создании Дела runtime генерирует UUID и сохраняет его в
`.verstak/workspace.json`. `workspaceId` — постоянная личность Дела;
`workspaceRootPath` и `workspaceName` — изменяемый адрес и presentation fields.
Переименование обновляет путь, но не ID. Новая папка с прежним именем получает
другой ID и не может перехватить старые привязки.
Структура:
```json ```json
{ {
"schemaVersion": 1, "workspaceId": "1eb7cc69-52e8-4a18-ae2a-50d5229c5b60",
"enabledPlugins": ["verstak.platform-test"], "workspaceName": "Project",
"disabledPlugins": [], "createdFromTemplate": {
"desiredPlugins": [ "templateId": "project",
{ "templateName": "Project",
"id": "verstak.platform-test", "templateVersion": 1,
"version": "0.1.0", "appliedAt": "2026-06-19T12:00:00Z",
"source": "official" "workspaceTools": [
} "verstak.notes",
], "verstak.files",
"updatedAt": "2026-06-17T04:00:00Z" "verstak.todo",
"verstak.journal",
"verstak.activity",
"verstak.browser-inbox"
]
},
"features": {
"notes": true,
"files": true,
"todo": true,
"journal": true,
"activity": true,
"browser-inbox": true
},
"folders": {
"notes": "Notes",
"files": "Files"
},
"workspaceTools": [
"verstak.notes",
"verstak.files",
"verstak.todo",
"verstak.journal",
"verstak.activity",
"verstak.browser-inbox"
]
} }
``` ```
API: Если original template удалён или изменён позже, существующее Дело
- `LoadVaultPluginState()` / `SaveVaultPluginState()` открывается по сохранённому snapshot и не мутирует автоматически. Template
- `IsPluginEnabled(id)` / `IsPluginDisabled(id)` update/migration может быть только явной future feature. Если metadata
- `EnablePlugin(id)` / `DisablePlugin(id)` отсутствует, Дело открывается как generic Deal минимум с `files: true`.
- `RecordDesiredPlugin(id, version, source)`
- `ListMissingInstalled(installedIDs)`
Правила: ### API
- Enabled/disabled состояние относится к конкретному vault
- Если vault закрыт — plugin state недоступен
- Plugin settings остаются в `.verstak/plugin-settings/<id>/settings.json`
- Отсутствие `plugins.json` — создать defaults
- Битый `plugins.json` — backup + создать defaults + ошибка
## First Run / Vault Selection Flow - `ListWorkspaces()` — список top-level папок Дел с их `workspaceId`.
- `ListWorkspaceTemplates()` — selectable built-in templates с presentation
metadata и `workspaceTools`.
- `CreateWorkspace(name, templateId?)` — создать `<vault>/<name>/`, применить
template один раз, сохранить snapshot metadata.
- `RenameWorkspace(oldName, newName)` — физически переименовать top-level папку
Дела и обновить metadata name, не меняя `workspaceId`.
- `TrashWorkspace(name)` — перенести весь top-level folder Дела в internal
trash policy.
- `GetWorkspaceMetadata(name)` — прочитать metadata или вернуть generic fallback.
- `UpdateWorkspaceMetadata(name, patch)` — обновить metadata без влияния на
существование Дела.
При запуске: Deprecated compatibility APIs:
1. Загрузить app settings
2. Если `currentVaultPath` пустой или vault не открывается → показать Vault Selection UI
3. Если `currentVaultPath` валиден → открыть vault автоматически
Vault Selection UI: - `GetWorkspaceTree()` — flat view, derived from top-level folders. Не дерево.
- Create new vault → `CreateVault(path)` → `SetCurrentVault(path)` - `CreateWorkspaceNode(...)` — wrapper over `CreateWorkspace`.
- Open existing vault → `SetCurrentVault(path)` - `RenameWorkspaceNode(...)` — wrapper over `RenameWorkspace`.
- Recent vaults → `SetCurrentVault(path)` - `ArchiveWorkspaceNode(...)` — wrapper over `TrashWorkspace`.
- Ошибка открытия → показать понятный текст - `MoveWorkspaceNode(...)` — unsupported; old nested/mapped moves are rejected.
- `GetCurrentWorkspaceNode()` / `SetCurrentWorkspaceNode(...)` — wrappers over
selected top-level Deal UI state.
После успешного открытия: Эти методы существуют только для постепенного frontend/Wails cleanup. Они не
- `currentVaultPath` сохранён в app settings должны создавать или сохранять nested workspace tree и не должны восстанавливать
- Путь добавлен в `recentVaults` `WorkspaceNode.path` mapping.
- Переход в основной UI
## Enable/Disable Lifecycle ### Capability
1. Пользователь нажимает Enable/Disable в Plugin Manager `verstak/core/workspace/v1` — техническое имя capability; оно регистрируется
2. Фронт вызывает `EnablePlugin(id)` / `DisablePlugin(id)` на бэкенде только когда vault открыт и Deal manager инициализирован.
3. Бэкенд пишет в `<vault>/.verstak/plugins.json`
4. Фронт вызывает `ReloadPlugins()` для пересчёта lifecycle
5. Disabled plugin не регистрирует capabilities/contributions
6. После enable — capabilities/contributions возвращаются
Важно: ### Правила
- Installed plugin package ≠ enabled plugin
- Disabled plugin виден как installed/disabled - Имя Дела — один safe folder name, не path.
- Missing desired plugin показывается как "missing" - Reject: empty, slash, backslash, absolute-looking paths, `..`, null byte,
`.verstak`, reserved/internal names, symlink Дел, conflicts.
- WorkspaceItems получают техническое поле `workspaceRootPath`, равное имени top-level папки
(`Project`, `ClientA`, etc). Files plugin показывает именно эту папку.
- Files API остаётся raw vault-relative API: `Project/Notes/example.md`,
`Project/docs/file.md`, `Test/readme.md`.
- Notes are ordinary Markdown files under `<Deal>/Notes/`; нет
`.verstak/notes`, UUID note storage или второго source of truth для note
content.
### Lifecycle Events
Runtime publishes lifecycle events Дел после успешных операций:
- `workspace.created` — payload includes `operation: "create"`, `workspaceId`,
`workspaceRootPath`, `workspaceName`, and optional `templateId`.
- `workspace.renamed` — payload includes `operation: "rename"`, `workspaceId`,
new `workspaceRootPath` / `workspaceName`, and previous path fields.
- `workspace.trashed` — payload includes `operation: "trash"`, `workspaceId`,
`workspaceRootPath`, `workspaceName`, `trashId`, `trashPath`, and `deletedAt`.
- `workspace.selected` — payload includes `operation: "select"`, `workspaceId`,
`workspaceRootPath`, and `workspaceName`.
Official Activity subscribes to these events and stores them through the normal
activity provider path.
### UI
Список Дел в sidebar:
- Flat list of top-level папок Дел.
- Создать Дело, переименовать Дело, переместить Дело в корзину.
- Selection is stored as selected Deal name.
- No expand/collapse tree and no case/folder node creation in core.
---
## Build Scripts
В `verstak-desktop/scripts/` есть два скрипта:
### `build.sh` — локальная детерминированная сборка
- Собирает **только** `verstak-desktop` (core platform).
- Не трогает другие репозитории.
- **Fail-fast**: любая ошибка (go vet, go test, frontend build, wails build) прерывает сборку.
- Проверяет: deps → frontend build → go mod download → go vet → go build → go test → wails build + plugin copy.
- Используется в CI и для повседневной работы над core.
### `update-and-build-all.sh` — dev helper для полной пересборки связки
- **Не для CI.** Только для разработки, когда нужно быстро собрать всё вместе.
- Шаги:
1. `git pull --ff-only` во всех 6 репозиториях
2. Сборка official plugins (frontend npm build + backend go build для каждого плагина)
3. Копирование собранных плагинов в `verstak-desktop/plugins/`
4. Запуск `build.sh` для сборки desktop
- Ошибки pull и сборки плагинов не прерывают скрипт (best-effort), но ошибка build.sh прерывает (fail-fast).
+87
View File
@@ -0,0 +1,87 @@
# UX/UI Refactor Plan
## Assumptions
- `verstak-desktop` is the core platform and UI shell. Notes, files, editor, activity, journal, browser inbox, and search behavior stay plugin-owned.
- The old `~/git/verstak` UI is a visual and interaction reference, not an architecture source.
- The v2 shell should improve orientation, density, keyboard/mouse ergonomics, and responsive behavior without reintroducing the v1 monolith.
## Reference Rules
Keep from v1:
- compact dark workbench rhythm;
- clear title/header zones;
- dense rows and tabs for repeated work;
- action controls that appear where the user is working;
- custom, in-app interaction surfaces instead of browser-default dialogs where the flow is important.
Avoid from v1:
- putting user business workflows into `App.svelte`;
- direct coupling between shell and notes/files/editor internals;
- global mutable UI state that plugins must know about;
- moving plugin behavior into core for visual convenience.
## Mimo Delegation Model
Use `~/bin/mimo.sh run --dir <repo> "<task>"` only for bounded junior tasks.
Good tasks:
- compare two small components and write a short report to `/tmp`;
- draft CSS for a named component within existing tokens;
- inspect one test file and suggest missing assertions;
- make a one-component mechanical change after the target behavior is already specified.
Do not delegate:
- architecture decisions;
- plugin/core boundary decisions;
- final diff review;
- verification claims;
- commits or pushes.
Every mimo result must be reviewed with `git diff` and verified independently.
## Work Plan
1. Shell orientation
- Move persistent search and workspace context into the workspace header.
- Keep exactly one global search entry visible at a time.
- Preserve search availability in global plugin views.
2. Responsive shell
- Make narrow viewports usable by stacking sidebar above workspace content.
- Ensure tabs and workspace cards do not force horizontal page overflow.
- Add Playwright coverage for mobile geometry.
3. Today surface
- Make Today feel like a work-resume surface, not a static card grid.
- Keep data loading from plugin settings/contributions; do not add business logic to core.
- Improve empty states and quick actions based on available workspace tools.
4. Plugin manager polish
- Improve scanning, status density, and permission readability.
- Keep enable/disable/status behavior unchanged.
- Verify degraded/failed/disabled plugin paths.
5. Files/workbench ergonomics
- Use the existing files plugin comparison report as input.
- Prefer plugin-local improvements: context menu, keyboard navigation, selection, and custom confirmation.
- Do not add file-manager logic to the shell.
## Verification Gates
Before commit:
- `npm run build`
- focused Playwright suite for the touched flow
- full `npm run test:e2e` when App, Sidebar, WorkspaceHost, PluginManager, or shared shell layout changes
- desktop and mobile screenshots inspected manually
Before push:
- re-run the relevant verification from a clean current worktree state;
- inspect `git status` and `git diff --stat`;
- push only after tests and visual smoke have current evidence.
+40
View File
@@ -0,0 +1,40 @@
# Deal Templates
Deal templates choose which dynamic plugin Deal tabs are visible when a new
top-level Deal is created. They do not enable or disable plugins for the
whole vault and do not affect global sidebar tools.
## Built-in templates
| Template | Deal tabs |
| --- | --- |
| General | Notes, Files, Journal, Activity, Browser Inbox |
| Project | Notes, Files, Todos, Journal, Activity, Browser Inbox |
| Writing | Notes, Files, Journal |
| Admin | Notes, Files, Secrets, Todos, Journal |
| Minimal | Notes, Files |
The create-Deal modal displays the selected template description and its
included plugin tabs before the folder is created.
## Metadata and compatibility
Creation stores a template snapshot in `.verstak/workspaces/` metadata. The
snapshot contains the template id, name, version, applied time, and an ordered
`workspaceTools` list of plugin IDs. Existing Deal metadata without
`workspaceTools` remains compatible: its Deal continues to show all globally
enabled Deal plugins rather than unexpectedly hiding tabs.
Templates are applied once. Editing the built-in catalog or creating another
Deal with a different template never changes an existing Deal snapshot.
There is no template editor or post-creation template switcher yet.
## Global tools and unavailable plugins
Template visibility applies only to `workspaceItems` in the selected Deal.
Global views and sidebar items, such as global Todos, Browser Inbox, and Trash,
remain available according to the normal plugin enablement state.
The Deal host intersects the snapshot with dynamically discovered, globally
enabled plugin contributions. If a template references a plugin that is missing or
disabled, that tab is simply absent; the other template tabs remain usable.
Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

@@ -0,0 +1,341 @@
# Native Notifications, Tray, and Public README Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Deliver native Todo reminders and Windows/Linux tray operation, then publish updated alpha artifacts and public README documentation with real application screenshots.
**Architecture:** Desktop core stores and delivers plugin-owned notification schedules and controls the tray lifecycle. Todo uses the public plugin API to replace its desired reminders. A small core tray adapter keeps the Wails process alive after window close. Documentation uses the supplied English and Russian README sources plus screenshots from an actual test vault.
**Tech Stack:** Go 1.24, Wails v2.12 runtime notifications, `getlantern/systray`, plain JavaScript plugin API, Node smoke tests, Playwright, bash packaging.
## Global constraints
- Support Windows and Linux only; no background daemon after an explicit Quit.
- Closing a window hides it; only tray **Quit** ends the process.
- `verstak.todo` requires `verstak/core/notifications/v1` and `notifications.schedule`.
- Plugins cannot receive Wails runtime access.
- Keep Wails generated bindings out of commits.
- Commit each independent change and immediately push it to GitHub and `mirror`.
---
### Task 1: Persisted notification scheduler
**Files:**
- Create: `internal/core/notifications/manager.go`
- Create: `internal/core/notifications/store.go`
- Create: `internal/core/notifications/manager_test.go`
**Interfaces:**
- `type Request struct { ID, DueAt, Title, Body string }`
- `type Item struct { PluginID, ID, DueAt, Title, Body, SentForDueAt string }`
- `type Sender interface { Send(context.Context, Item) error }`
- `Manager.Replace(pluginID string, requests []Request) error`, `Clear(pluginID string) error`, `Start(context.Context)`, and `Stop()`.
- Persistent state: `<vault>/.verstak/notifications/schedules.json`.
- [ ] **Step 1: Add failing scheduler tests**
```go
func TestTickRetriesFailedSendAndAcknowledgesOneDelivery(t *testing.T) {
sender := &fakeSender{err: errors.New("unavailable")}
m := newTestManager(t, sender, time.Date(2026, 7, 14, 12, 0, 0, 0, time.UTC))
requireNoError(t, m.Replace("verstak.todo", []Request{{ID: "todo-1", DueAt: "2026-07-14T11:00:00Z", Title: "Reminder"}}))
m.Tick(context.Background())
if sender.calls != 1 || m.Items()[0].SentForDueAt != "" { t.Fatal("failed send was acknowledged") }
sender.err = nil
m.Tick(context.Background())
m.Tick(context.Background())
if sender.calls != 2 || m.Items()[0].SentForDueAt != "2026-07-14T11:00:00Z" { t.Fatal("delivery was not exactly once") }
}
```
Also test stale records are removed by `Replace`, changing `DueAt` rearms a record, state reloads from disk, and a past-due unsent item is sent once.
- [ ] **Step 2: Confirm red**
Run: `GOCACHE=/tmp/verstak-go-cache go test ./internal/core/notifications -count=1`
Expected: fails because the package does not exist.
- [ ] **Step 3: Implement the minimal manager**
Use one mutex, injected clock/sender, an atomic same-directory temp-file rename, and a 30-second ticker. Preserve `SentForDueAt` only if the requested due time is unchanged. Validate non-empty unique IDs, RFC3339 UTC dates, at most 500 requests, and bounded text. Mark sent only after `Sender.Send` succeeds.
- [ ] **Step 4: Verify and commit**
Run:
```bash
gofmt -w internal/core/notifications/*.go
GOCACHE=/tmp/verstak-go-cache go test ./internal/core/notifications ./internal/core/... -count=1
git diff --check
```
Commit/push:
```bash
git add internal/core/notifications
git commit -m "feat: add persisted notification scheduler"
git push https://github.com/mirivlad/verstak.git main
git push mirror main
```
### Task 2: Permission-gated core/plugin notification API
**Files:**
- Modify: `main.go`
- Modify: `internal/api/app.go`
- Modify: `internal/api/app_test.go`
- Modify: `internal/core/permissions/registry.go`
- Modify: `frontend/src/lib/plugin-host/VerstakPluginAPI.js`
- Modify: `frontend/tests/plugin-api-contributions-test.mjs`
**Interfaces:**
- Core capability: `verstak/core/notifications/v1`.
- Permission: `notifications.schedule`, non-dangerous.
- Bound methods: `ReplacePluginNotifications(pluginID string, items []notifications.Request) string` and `ClearPluginNotifications(pluginID string) string`.
- Plugin methods: `api.notifications.replace(items)` and `api.notifications.clear()`.
- [ ] **Step 1: Add failing backend and host-API tests**
```go
func TestReplacePluginNotificationsRequiresCapabilityAndPermission(t *testing.T) {
app, scheduler := newNotificationTestApp(t, manifestWithoutNotificationPermission)
if got := app.ReplacePluginNotifications("example", []notifications.Request{{ID: "r", DueAt: "2026-07-14T10:00:00Z", Title: "R"}}); got == "" {
t.Fatal("missing permission was accepted")
}
if scheduler.replaceCalls != 0 { t.Fatalf("replace calls = %d", scheduler.replaceCalls) }
}
```
The JS test mocks `App.ReplacePluginNotifications`, calls `api.notifications.replace`, and asserts both plugin ID forwarding and a namespaced rejection for a non-empty backend error.
- [ ] **Step 2: Confirm red**
```bash
GOCACHE=/tmp/verstak-go-cache go test ./internal/api -run TestReplacePluginNotificationsRequiresCapabilityAndPermission -count=1
node frontend/tests/plugin-api-contributions-test.mjs
```
Expected: missing API methods.
- [ ] **Step 3: Implement guarded bridge**
Register the capability in `main.go`, construct the scheduler, and guard bound calls with both existing access helpers before forwarding. Add `notifications.schedule` to the registry. Add the public host methods using `callBackendErrorString`; do not expose `window.runtime` to plugins.
- [ ] **Step 4: Verify and commit**
```bash
gofmt -w main.go internal/api/app.go internal/api/app_test.go internal/core/permissions/registry.go
GOCACHE=/tmp/verstak-go-cache go test ./internal/api -count=1
node frontend/tests/plugin-api-contributions-test.mjs
git diff --check
```
```bash
git add main.go internal/api/app.go internal/api/app_test.go internal/core/permissions/registry.go frontend/src/lib/plugin-host/VerstakPluginAPI.js frontend/tests/plugin-api-contributions-test.mjs
git commit -m "feat: expose scheduled notifications to plugins"
git push https://github.com/mirivlad/verstak.git main
git push mirror main
```
### Task 3: Todo reminder synchronization and Wails lifecycle
**Files:**
- Modify: `main.go`
- Modify: `internal/api/app.go`
- Modify: `internal/api/app_test.go`
- Modify: `../verstak-official-plugins/plugins/todo/plugin.json`
- Modify: `../verstak-official-plugins/plugins/todo/frontend/src/index.js`
- Modify: `../verstak-official-plugins/plugins/todo/locales/en.json`
- Modify: `../verstak-official-plugins/plugins/todo/locales/ru.json`
- Modify: `../verstak-official-plugins/scripts/smoke-todo-plugin.js`
**Interfaces:**
- `App.DomReady(ctx)` initializes Wails notifications before starting the scheduler.
- `App.Shutdown(ctx)` stops it before Wails cleanup.
- Todo replaces the complete reminder list only after Todo persistence succeeds and after Todo data loads.
- [ ] **Step 1: Add failing lifecycle and Todo smoke tests**
The App test uses function variables for Wails calls and proves initialize → start and stop → cleanup order. The Todo smoke mock records `notifications.replace`; it asserts one open reminder request with the Todo ID and a later empty replacement after completion or deletion.
- [ ] **Step 2: Confirm red**
```bash
GOCACHE=/tmp/verstak-go-cache go test ./internal/api -run 'TestDomReadyInitializesNotificationsBeforeScheduler|TestShutdownStopsNotifications' -count=1
cd ../verstak-official-plugins && node scripts/smoke-todo-plugin.js
```
Expected: lifecycle methods and schedule calls are absent.
- [ ] **Step 3: Implement lifecycle and Todo desired state**
Use `runtime.InitializeNotifications`, `runtime.SendNotification`, and `runtime.CleanupNotifications` only in core. Register `OnDomReady`/ `OnShutdown` in Wails options. Todo maps open valid `reminderAt` values to ISO UTC using `new Date(todo.reminderAt).toISOString()`, localizes title/body, and calls `api.notifications.replace`. A scheduling failure reports status but never rolls back already-saved Todo data.
- [ ] **Step 4: Verify and commit both repositories**
```bash
GOCACHE=/tmp/verstak-go-cache go test ./internal/api -count=1
cd ../verstak-official-plugins && node scripts/smoke-todo-plugin.js && ./scripts/check.sh
```
```bash
cd ../verstak-desktop
git add main.go internal/api/app.go internal/api/app_test.go
git commit -m "feat: start native notifications with desktop app"
git push https://github.com/mirivlad/verstak.git main
git push mirror main
cd ../verstak-official-plugins
git add plugins/todo scripts/smoke-todo-plugin.js
git commit -m "feat: schedule native Todo reminders"
git push https://github.com/mirivlad/verstak-official-plugins.git main
git push mirror main
```
### Task 4: Windows/Linux tray, close policy, and single instance
**Files:**
- Create: `internal/shell/tray/controller.go`
- Create: `internal/shell/tray/controller_test.go`
- Modify: `main.go`
- Modify: `internal/api/app.go`
- Modify: `internal/api/app_test.go`
- Modify: `go.mod`
- Modify: `go.sum`
**Interfaces:**
- `tray.Start(icon []byte, actions Actions) error`, with `Actions.Show` and `Actions.Quit`.
- `App.BeforeClose(ctx) bool` hides and returns true unless `App.Quit()` made shutdown explicit.
- `App.ShowWindow()` reveals the existing window.
- [ ] **Step 1: Add failing close-policy and tray action tests**
```go
func TestBeforeCloseHidesWindowUntilExplicitQuit(t *testing.T) {
app, window := newWindowTestApp(t)
if prevent := app.BeforeClose(context.Background()); !prevent || window.hideCalls != 1 { t.Fatal("ordinary close must hide") }
app.Quit()
if prevent := app.BeforeClose(context.Background()); prevent { t.Fatal("explicit quit was prevented") }
}
```
Use a fake tray adapter to assert exactly two labels, **Show Verstak** and **Quit**, and one callback invocation per click.
- [ ] **Step 2: Confirm red**
```bash
GOCACHE=/tmp/verstak-go-cache go test ./internal/api -run TestBeforeCloseHidesWindowUntilExplicitQuit -count=1
GOCACHE=/tmp/verstak-go-cache go test ./internal/shell/tray -count=1
```
Expected: absent packages/methods.
- [ ] **Step 3: Implement adapter and Wails wiring**
Add `github.com/getlantern/systray@v1.2.2`. The production adapter calls nonblocking `systray.Register`, sets compact source-controlled PNG bytes derived from the existing tracked logo, and starts goroutines for the two click channels. `main.go` registers `OnBeforeClose` and uses `options.SingleInstanceLock` whose second launch calls `app.ShowWindow`. Do not embed ignored Wails-generated files from `build/`.
- [ ] **Step 4: Verify and commit**
```bash
gofmt -w main.go internal/api/app.go internal/api/app_test.go internal/shell/tray/*.go
GOCACHE=/tmp/verstak-go-cache go test ./internal/shell/tray ./internal/api -count=1
./scripts/build.sh
ldd build/bin/verstak-desktop | grep -E 'ayatana-appindicator|appindicator'
git diff --check
```
```bash
git add main.go internal/api/app.go internal/api/app_test.go internal/shell/tray go.mod go.sum
git commit -m "feat: keep desktop app in system tray"
git push https://github.com/mirivlad/verstak.git main
git push mirror main
```
### Task 5: Packaging and public README screenshots
**Files:**
- Modify: `packaging/deb/control`
- Modify: `scripts/build.sh`
- Modify: `scripts/package-appimage.sh`
- Modify: `scripts/test-package-formats.sh`
- Modify: `README.md`
- Create: `README.ru.md`
- Create: `docs/screenshots/overview.png`
- Create: `docs/screenshots/workspace-files-notes.png`
- Create: `docs/screenshots/activity-journal.png`
- [ ] **Step 1: Add failing package-contract checks**
Add assertions for `libayatana-appindicator3-dev` in the Linux build guidance, `libayatana-appindicator3-1` in Debian dependencies, and `ayatana-appindicator` in the AppImage packing verification.
- [ ] **Step 2: Confirm red**
Run: `./scripts/test-package-formats.sh`
Expected: the first tray dependency assertion fails.
- [ ] **Step 3: Implement portable package support**
Declare the Debian runtime dependency. Make the Linux build error name the appindicator development header. Require the dynamically discovered appindicator library to appear in AppDir after the existing `ldd` traversal. Keep the Windows system-WebView2 policy unchanged.
- [ ] **Step 4: Install the supplied public README sources**
Replace this repository's `README.md` with the supplied English source at the workspace root and add the supplied Russian source as `README.ru.md`. Add an image strip after “What is Verstak?” / “Что такое Верстак?” that links to three tracked PNG screenshots and uses matching localized alt text.
- [ ] **Step 5: Produce real screenshots**
Build and run the desktop app against `/home/mirivlad/Nextcloud/Verstak/VerstakVault` on the active X display. Capture:
1. Overview showing recent work and entry points;
2. a populated workspace Files/Notes view;
3. Activity with a suggested session and its Journal review.
Use `scrot` or ImageMagick `import`, crop only surrounding desktop chrome, inspect every PNG visually, and do not add generated mockups. Remove all non-user-safe text from the test vault before capture.
- [ ] **Step 6: Verify documentation and package contracts, commit, push**
```bash
./scripts/test-package-formats.sh
git diff --check
test -s docs/screenshots/overview.png
test -s docs/screenshots/workspace-files-notes.png
test -s docs/screenshots/activity-journal.png
```
```bash
git add packaging/deb/control scripts/build.sh scripts/package-appimage.sh scripts/test-package-formats.sh README.md README.ru.md docs/screenshots
git commit -m "docs: add public README and product screenshots"
git push https://github.com/mirivlad/verstak.git main
git push mirror main
```
### Task 6: Complete verification and GitHub alpha releases
**Files:**
- No source changes expected unless verification exposes a defect.
- [ ] **Step 1: Run all checks**
```bash
GOCACHE=/tmp/verstak-go-cache ./scripts/test.sh
./scripts/check.sh
./scripts/test-package-formats.sh
./scripts/test-build-windows.sh
cd ../verstak-official-plugins && ./scripts/check.sh && ./scripts/test-package-portable.sh && ./scripts/test-publish-github-release.sh
```
- [ ] **Step 2: Build and checksum both alpha releases**
```bash
cd ../verstak-official-plugins && ./scripts/release.sh v0.1.0-alpha.2 && (cd release && sha256sum --check SHA256SUMS)
cd ../verstak-desktop && ./scripts/release.sh v0.1.0-alpha.2 && (cd release && sha256sum --check SHA256SUMS)
```
- [ ] **Step 3: Publish and inspect releases**
```bash
cd ../verstak-official-plugins && ./scripts/publish-github-release.sh v0.1.0-alpha.2
cd ../verstak-desktop && ./scripts/publish-github-release.sh v0.1.0-alpha.2
gh release view v0.1.0-alpha.2 -R mirivlad/verstak-official-plugins
gh release view v0.1.0-alpha.2 -R mirivlad/verstak
```
The final report distinguishes automated verification from the two required real desktop manual checks: tray close/show/quit and a near-future Todo notification while the window is hidden.
@@ -0,0 +1,174 @@
# Native Notifications and System Tray Design
**Status:** approved for implementation on 2026-07-14
## Goal
Deliver Todo reminders as native notifications on Windows and Linux, and keep
Verstak running in the system tray when its main window is closed. The feature
must work in the portable Windows archive, Debian package, and AppImage.
## Scope
- The desktop core owns notification delivery, scheduling, and tray lifetime.
- `verstak.todo` owns the Todo-specific reminder policy and text.
- No new official notifications plugin is introduced. A dynamic plugin cannot
call Wails directly, so it cannot be the native-notification transport.
- macOS is out of scope for this alpha. The interfaces remain platform-neutral
where that costs nothing.
## Tray behavior
On Windows and Linux, a tray icon is registered before the Wails event loop
starts. Its menu contains exactly two actions:
1. **Show Verstak** — shows and focuses the existing main window.
2. **Quit** — exits the process deliberately.
Closing the main window with its window-manager close control hides the window
and keeps the process, plugins, local browser receiver, and reminder scheduler
alive. It does not terminate the application. The quit action temporarily
allows the close lifecycle to finish and then exits normally.
The app has a single-instance lock. If a user launches the executable while an
instance is hidden in the tray, the existing instance shows its window instead
of creating a second process.
The implementation uses `github.com/getlantern/systray` through a small
`internal/shell/tray` adapter. It uses `Register`, rather than its blocking
`Run`, so Wails remains the owner of the GUI event loop. A compact PNG derived
from the tracked project logo is encoded in the tray package, so a clean build
does not depend on ignored Wails-generated icon files. The library accepts PNG
icon bytes on both target platforms.
## Notification capability and permission
The core registers the capability:
```
verstak/core/notifications/v1
```
Plugins that use it must both require that capability and declare the
`notifications.schedule` permission. The plugin-host API exposes only two
operations within the calling plugin namespace:
```
api.notifications.replace(items)
api.notifications.clear()
```
`replace` is an atomic desired-state replacement, not an append operation. An
item contains a plugin-local stable `id`, an ISO-8601 UTC `dueAt`, a title, and
a body. The core supplies the plugin ID and rejects calls from disabled,
missing-permission, or undeclared-capability plugins. It also validates empty
IDs, duplicate IDs, invalid timestamps, and unsafe oversized text.
No plugin can send arbitrary immediate native notifications or address another
plugin's schedules in this alpha.
## Scheduler and persistence
`internal/core/notifications` persists one canonical schedule file at:
```
<vault>/.verstak/notifications/schedules.json
```
Each record contains `{pluginId, id, dueAt, title, body, sentForDueAt}`.
The composite `(pluginId, id)` is unique. Replacing an item with the same due
time preserves `sentForDueAt`; changing `dueAt` clears it. Replacing a plugin's
list removes its stale records. This provides deterministic cancellation for
completed, deleted, and rescheduled Todos.
The manager starts after Wails reaches `OnDomReady`, initializes Wails native
notifications, and evaluates the persisted schedule immediately and then at
least every 30 seconds. A sender is injected behind an interface for unit
tests. After a successful delivery, the manager atomically records
`sentForDueAt`. A delivery error leaves the schedule pending and is logged for
a later retry.
An expired record that has not been sent is delivered once after the next app
start. A record already sent for its current due time is never sent again.
Completely quitting Verstak stops the scheduler: no separate daemon or OS
background service is added. Hiding the window in the tray does **not** stop it.
The core calls `CleanupNotifications` during shutdown, including on Linux where
it releases the D-Bus connection.
## Todo behavior
`verstak.todo` adds the core notifications capability to `requires` and adds
the `notifications.schedule` permission. After every successful Todo storage
write, it derives the complete desired reminder list:
- include only open Todos with a valid `reminderAt`;
- use the Todo ID as the stable notification ID;
- convert local `datetime-local` input to an ISO-8601 UTC instant;
- use the Todo title in the notification body and locale-aware reminder text;
- call `api.notifications.replace` with the full list.
The same replacement runs after loading persisted Todos, so a transient
schedule-write failure repairs itself next time the Todo view is opened. A
schedule API failure does not roll back Todo data; the UI reports the failure
instead. The existing in-view overdue/reminder badge remains useful context and
is not removed.
## Packaging
`getlantern/systray` requires CGO. The Windows release build already uses
`x86_64-w64-mingw32-gcc`; the Windows packaging tests must compile it with the
tray dependency included. Linux build instructions add
`libayatana-appindicator3-dev`. The Debian package declares the corresponding
runtime dependency `libayatana-appindicator3-1`.
The existing AppImage packager traverses `ldd` for the desktop executable and
copies non-glibc runtime libraries. Its verification is extended to prove that
the appindicator library is present in the AppDir when the tray implementation
is compiled in.
## Public README and product screenshots
The workspace-root `README.md` and `README.ru.md` supplied by the maintainer
become the public repository documents: the English source replaces this
repository's `README.md`, and the Russian source is committed as
`README.ru.md`. Their existing language links and public-release instructions
are retained.
Three screenshots are captured from the real desktop application using the
test vault, then committed under `docs/screenshots/`:
1. `overview.png` — returning to recent work and useful next actions;
2. `workspace-files-notes.png` — ordinary vault files and Markdown notes in a
workspace;
3. `activity-journal.png` — review of a factual activity session as a Journal
entry.
Both README variants include the same three images with localized alt text.
They are factual UI captures, not generated illustrative mockups. Capture data
must be limited to the disposable test vault and inspected before commit so no
credentials or personal content are published.
## Test and manual verification
Automated tests cover:
- schedule replacement, cancellation, rescheduling, persistence, one-time
overdue delivery, failed-send retry, and permission/capability rejection;
- Todo desired-list derivation and calls after create/edit/status/delete;
- close policy: ordinary close hides, explicit quit permits shutdown;
- tray controller action wiring and second-instance window reveal;
- Linux/Windows build scripts and package dependency expectations.
Manual smoke tests are required because neither unit tests nor Playwright can
assert a real desktop notification area or OS toast:
1. On Linux and Windows, start Verstak, close its window, use the tray menu to
reveal it, and use **Quit** to terminate it.
2. Set a Todo reminder for a near future time, hide the window in the tray, and
observe one native notification.
3. Quit before a future reminder, relaunch after it expires, and observe one
overdue notification with no duplicate on the next scheduler scan.
4. Inspect all three README screenshots at their committed size and confirm
that they show only intended test-vault data and explain the feature named
in their localized captions.
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

+96
View File
@@ -0,0 +1,96 @@
# UI Polish Milestone
## Assumptions
- This milestone is visual polish only: no backend API changes, no plugin contract changes, and no feature removal.
- English remains the baseline UI language for this pass. Hardcoded Russian strings that are visible in core UI should be converted to English where touched.
- Official plugins are plain JS bundles with injected CSS. Introducing a new shared UI package would be larger than this milestone, so shared primitives will be expressed as global design tokens/classes plus consistent local CSS in each bundle.
- Desktop workbench density should stay compact. The goal is consistency, not a marketing-style redesign.
## Audit Findings
### Existing Tokens
- `frontend/src/App.svelte` already defines partial global button classes: `btn-primary`, `btn-secondary`, `btn-danger`, `btn-ghost`, `btn-icon`, plus global button focus/disabled behavior.
- Most shell and plugin surfaces hardcode the same dark palette directly: `#1a1a2e`, `#16213e`, `#0f3460`, `#4ecca3`, `#e94560`, `#8b8ba8`.
- There are no named CSS variables for semantic surface, selected row, border, muted text, warning, or focus ring.
- Plugin bundles repeat their own toolbar/button/list/empty/error styles instead of reusing shell-level primitives.
### Repeated Patterns
- Page/workspace headers: `WorkspaceHost.svelte`, `WorkbenchHost.svelte`, `ViewContainer.svelte`, plugin toolbars.
- Tab bars: workspace tabs use a custom active style separate from selected rows and sidebar active states.
- Toolbars: Files, Notes, Search, Activity, Journal, Browser Inbox, and default editor use similar but inconsistent dark toolbar rules.
- List rows: Files rows, Notes rows, Activity rows, Journal rows, Secrets list items, Browser Inbox rows, Global Search results, WorkspaceTree rows.
- Cards/panels: Today panels, worklog suggestions, Secrets detail/form, Browser Inbox detail text, plugin manager cards.
- Empty/error states: many are plain centered text or show technical details as primary text.
### Candidate Primitives
Implement as global classes/tokens first, then apply locally:
- PageHeader / WorkspaceHeader: compact title, metadata, right actions/search.
- TabBar / TabButton: consistent active tab, hover, focus.
- Toolbar / ToolbarGroup: grouped controls with separators and predictable height.
- Button variants: primary, secondary, ghost, danger, icon.
- ListRow: hover, selected, disabled, metadata line, actions zone.
- Card: flat desktop panel with 6px radius and subtle border.
- SplitPane: list/detail grid with shared border and selected item states.
- EmptyState: centered title, hint, optional action.
- InlineAlert: readable message plus optional details/debug text.
- StatusBadge: compact semantic label for type, status, warning/error.
- Menu: context menu with same surface, hover, danger item, separator.
## Implementation Plan
1. Add semantic CSS variables and global primitive classes in `frontend/src/App.svelte`.
- Colors: background, surface, surface-muted, surface-hover, surface-selected, border, border-strong, text-primary, text-secondary, text-muted, accent, accent-muted, danger, warning, success.
- Spacing: 4/8/12/16/24/32 via variables.
- Radius, font sizes, focus ring, and modest elevation tokens.
2. Normalize shell surfaces.
- Update `WorkspaceHost.svelte`, `WorkbenchHost.svelte`, `ViewContainer.svelte`, `Sidebar.svelte`, `WorkspaceTree.svelte`, `GlobalSearch.svelte`, and `TodaySurface.svelte` to use tokens and shared state rules.
- Convert visible Russian shell search/empty strings to English.
- Keep plugin manager architecture untouched; only global tokens should make it less visually divergent.
3. Normalize Files.
- Group toolbar into Navigation, Create, Selection, Clipboard/More, and filter/sort areas.
- Keep existing context menu and operations, restyle menu with shared surface/hover/danger states.
- Make single row click open when no modifier is used; keep ctrl/meta/shift selection behavior and keyboard selection.
- Reduce actions column visual noise by showing secondary row actions on hover/selection and using a compact More/action group.
4. Normalize Notes.
- Make New Note the primary page action.
- Keep filter/sort inside the toolbar group.
- Make entire note row readable and clickable; row actions remain visible enough and use icon-button states.
- Use shared empty/status/error styling.
5. Normalize Search.
- Render results as compact rows/cards with type badge/icon, title/path, secondary match reason, and compact Open button.
- Show provider errors as inline alert-style secondary information, not as raw main status text.
6. Normalize Activity and Journal.
- Add human-readable event labels such as "Workspace selected", "File opened", "Note edited", and "Work session detected".
- Move raw event names/source IDs into a details block or secondary metadata.
- Style plugin errors through inline alert patterns.
- Present worklog suggestions as cards with consistent title/summary/minutes actions.
7. Normalize Secrets and Browser Inbox.
- Keep split-pane list/detail layout.
- Use shared list rows, detail card/form surfaces, and empty states.
- Render "Value hidden" as password-like hidden field state.
- Use button variants: primary for save/create, secondary for copy/edit/remove where appropriate, danger for delete/clear/remove destructive actions.
8. Verify.
- Run desktop frontend build and relevant Playwright checks if available.
- Run official plugin smoke/check script.
- Capture before/after screenshots or save available after screenshots under `docs/ui-polish-assets/`.
## Out of Scope
- Backend API or Wails method changes.
- Plugin manifest/contribution contract changes.
- New heavy UI dependencies.
- Full i18n extraction.
- Redesigning the product information architecture.
- Rebuilding official plugins into Svelte packages.
+173
View File
@@ -0,0 +1,173 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
test.describe('Activity workflow', () => {
let consoleCollector;
test.beforeEach(async ({ page }) => {
consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
});
test.afterEach(async () => {
consoleCollector.assertNoErrors();
});
test('workspace activity explains empty event flow', async ({ page }) => {
await page.getByRole('tab', { name: 'Activity' }).click();
const activity = page.locator('.activity-root');
await expect(activity).toBeVisible({ timeout: 10000 });
await expect(activity.locator('.activity-title')).toContainText('Activity');
await expect(activity.locator('.activity-count')).toHaveText('0 events');
await expect(activity.locator('.activity-empty')).toContainText('No activity events yet');
await expect(activity.locator('.activity-empty')).toContainText('File changes, browser captures, and conversions will appear here');
await expect(activity.locator('[data-activity-action="clear"]')).toBeDisabled();
});
test('clearing activity requires destructive confirmation for the current case', async ({ page }) => {
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.activity', {
'events:workspace:Project': [{
activityId: 'activity-to-clear',
occurredAt: '2026-06-30T08:00:00.000Z',
type: 'note.saved',
title: 'Keep until confirmed',
workspaceRootPath: 'Project',
}],
});
});
await page.getByRole('tab', { name: 'Activity' }).click();
const activity = page.locator('.activity-root');
await expect(activity.locator('.activity-count')).toHaveText('1 event');
await activity.locator('[data-activity-action="clear"]').click();
const confirmation = activity.locator('[data-activity-clear-confirmation]');
await expect(confirmation).toBeVisible();
await expect(confirmation).toContainText(/Project|Дело/);
await expect(activity.locator('.activity-count')).toHaveText('1 event');
await confirmation.locator('[data-activity-clear-cancel]').click();
await expect(confirmation).toHaveCount(0);
await expect(activity.locator('.activity-count')).toHaveText('1 event');
await activity.locator('[data-activity-action="clear"]').click();
await activity.locator('[data-activity-clear-confirm]').click();
await expect(activity.locator('.activity-count')).toHaveText('0 events');
});
test('workspace activity keeps raw events and renders factual work session candidates', async ({ page }) => {
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.activity', {
'events:workspace:Project': [
{
activityId: 'activity-e2e-capture',
occurredAt: '2026-06-30T08:00:00.000Z',
type: 'browser.capture.selection',
title: 'Research Capture',
summary: 'Selected text from the article',
sourcePluginId: 'verstak.browser-inbox',
workspaceRootPath: 'Project',
},
{
activityId: 'activity-e2e-note',
occurredAt: '2026-06-30T08:20:00.000Z',
type: 'note.saved',
title: 'Saved note',
summary: 'Project/Notes/Research Capture.md',
sourcePluginId: 'verstak.files',
workspaceRootPath: 'Project',
},
{
activityId: 'activity-e2e-open',
occurredAt: '2026-06-30T08:30:00.000Z',
type: 'file.opened',
title: 'Selected file',
summary: 'Project/Notes/Research Capture.md',
sourcePluginId: 'verstak.files',
workspaceRootPath: 'Project',
},
],
});
});
await page.getByRole('tab', { name: 'Activity' }).click();
const activity = page.locator('.activity-root');
await expect(activity.locator('.activity-count')).toHaveText('3 events');
await expect(activity.locator('[data-activity-id="activity-e2e-capture"]')).toContainText('Research Capture');
await expect(activity.locator('[data-activity-id="activity-e2e-note"]')).toContainText('Saved note');
await expect(activity.locator('[data-activity-id="activity-e2e-open"]')).toContainText('Selected file');
const candidateSection = activity.locator('[data-activity-section="work-session-candidates"]');
await expect(candidateSection).toContainText('Possible journal entries');
const candidate = candidateSection.locator('[data-work-session-candidate]');
await expect(candidate).toHaveCount(1);
await expect(candidate).toContainText('Deal: Project');
await expect(candidate).toContainText('Estimated duration: 10 min');
await expect(candidate).toContainText('Activities: 2');
await expect(candidate).not.toContainText('Project work on');
await expect(candidate.locator('[data-work-session-action="review"]')).toBeVisible();
await expect(candidate.locator('[data-work-session-action="dismiss"]')).toBeVisible();
});
test('Review opens an empty Journal form with selectable candidate activities', async ({ page }) => {
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.activity', {
'events:workspace:Project': [
{
activityId: 'review-capture',
occurredAt: '2026-06-30T08:00:00.000Z',
type: 'browser.capture.selection',
title: 'Research Capture',
workspaceRootPath: 'Project',
},
{
activityId: 'review-note',
occurredAt: '2026-06-30T08:20:00.000Z',
type: 'note.saved',
title: 'Saved note',
workspaceRootPath: 'Project',
},
],
});
});
await page.getByRole('tab', { name: 'Activity' }).click();
await page.locator('[data-work-session-candidate] [data-work-session-action="review"]').click();
await expect(page.getByRole('tab', { name: 'Journal' })).toHaveAttribute('aria-selected', 'true');
const journal = page.locator('.journal-root');
await expect(journal).toBeVisible({ timeout: 10000 });
await expect(journal.locator('[data-journal-candidate]')).toContainText('Deal: Project');
await expect(journal.locator('[data-journal-candidate]')).toContainText('Estimated duration: 10 min');
await expect(journal.locator('[data-journal-input="title"]')).toHaveValue('');
await expect(journal.locator('[data-journal-input="summary"]')).toHaveValue('');
await expect(journal.locator('[data-journal-input="minutes"]')).toHaveValue('10');
const activityInputs = journal.locator('[data-journal-candidate-activity]');
await expect(activityInputs).toHaveCount(2);
await expect(activityInputs.nth(0)).toBeChecked();
await expect(activityInputs.nth(1)).toBeChecked();
await journal.locator('[data-journal-input="title"]').fill('Review research capture');
await journal.locator('[data-journal-input="summary"]').fill('Read the capture and updated the project note.');
await activityInputs.nth(1).uncheck();
await journal.locator('[data-journal-action="save-entry"]').click();
await expect(journal).toContainText('Review research capture');
await expect(journal).toContainText('10 min');
const stored = await page.evaluate(async () => {
const result = await window.go.api.App.ReadPluginSettings('verstak.journal');
return Array.isArray(result) ? result[0]['worklog:workspace:Project'] : result['worklog:workspace:Project'];
});
await expect(stored).toEqual([expect.objectContaining({
title: 'Review research capture',
summary: 'Read the capture and updated the project note.',
sourceCandidateId: expect.any(String),
activityIds: ['review-capture'],
})]);
});
});
+157
View File
@@ -0,0 +1,157 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
test.describe('Browser workflow', () => {
let consoleCollector;
test.beforeEach(async ({ page }) => {
consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
});
test.afterEach(async () => {
consoleCollector.assertNoErrors();
});
test('global inbox explains empty capture flow and exposes assignment filters', async ({ page }) => {
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser' }).click();
const inbox = page.locator('.browser-inbox-root');
await expect(inbox).toBeVisible({ timeout: 10000 });
await expect(inbox.locator('.browser-inbox-title')).toHaveText('Browser');
await expect(inbox.locator('.browser-inbox-count')).toHaveText('0 items');
await expect(inbox.locator('.browser-inbox-empty')).toContainText('No browser materials yet');
await expect(inbox.locator('.browser-inbox-empty')).toContainText('Send a page, selection, or link from the extension');
await expect(inbox.locator('[data-browser-inbox-filter="status"]')).toBeVisible();
await expect(inbox.locator('[data-browser-inbox-filter="workspace"]')).toBeVisible();
await expect(inbox.locator('[data-browser-inbox-action="clear"]')).toBeDisabled();
});
test('workspace inbox renders stored captures with conversion actions', async ({ page }) => {
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.browser-inbox', {
'captures:global': [{
captureId: 'capture-e2e-1',
capturedAt: '2026-06-30T08:00:00.000Z',
kind: 'file',
url: 'https://example.com/report',
title: 'Research Report',
domain: 'example.com',
text: 'Selected page text',
fileName: 'report.txt',
fileText: 'Report file contents',
workspaceRootPath: 'Project',
browserName: 'Firefox',
}],
});
});
await page.getByRole('tab', { name: 'Browser' }).click();
const inbox = page.locator('.browser-inbox-root');
await expect(inbox.locator('.browser-inbox-count')).toHaveText('1 item');
await expect(inbox.locator('[data-browser-capture-id="capture-e2e-1"]')).toContainText('Research Report');
await expect(inbox.locator('.browser-inbox-detail-title')).toHaveText('Research Report');
await expect(inbox.locator('.browser-inbox-meta')).toContainText('example.com');
await expect(inbox.locator('.browser-inbox-text').first()).toContainText('Selected page text');
await expect(inbox.locator('[data-browser-inbox-action="create-note"]')).toBeVisible();
await expect(inbox.locator('[data-browser-inbox-action="create-link"]')).toBeVisible();
await expect(inbox.locator('[data-browser-inbox-action="create-file"]')).toBeVisible();
await expect(inbox.locator('[data-browser-inbox-action="remove"]')).toBeVisible();
});
test('global inbox assigns, reassigns, filters, marks, and deletes captures', async ({ page }) => {
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.browser-inbox', {
'captures:global': [
{
captureId: 'inbox-unassigned',
capturedAt: '2026-06-30T08:20:00.000Z',
kind: 'page',
url: 'https://example.com/unassigned',
title: 'Unassigned research',
domain: 'example.com',
},
{
captureId: 'inbox-client-processed',
capturedAt: '2026-06-30T08:10:00.000Z',
kind: 'link',
url: 'https://client.example.com/processed',
title: 'Processed client link',
domain: 'client.example.com',
workspaceRootPath: 'ClientA',
processed: true,
},
{
captureId: 'inbox-project-open',
capturedAt: '2026-06-30T08:00:00.000Z',
kind: 'selection',
title: 'Project quote',
text: 'A project quote',
workspaceRootPath: 'Project',
},
],
});
});
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser' }).click();
const inbox = page.locator('.browser-inbox-root');
await expect(inbox.locator('.browser-inbox-count')).toHaveText('3 items');
const statusFilter = inbox.locator('[data-browser-inbox-filter="status"]');
const workspaceFilter = inbox.locator('[data-browser-inbox-filter="workspace"]');
await statusFilter.selectOption('unassigned');
await expect(inbox.locator('[data-browser-capture-id="inbox-unassigned"]')).toBeVisible();
await expect(inbox.locator('[data-browser-capture-id="inbox-client-processed"]')).toHaveCount(0);
await statusFilter.selectOption('all');
await workspaceFilter.selectOption('ClientA');
await expect(inbox.locator('[data-browser-capture-id="inbox-client-processed"]')).toBeVisible();
await expect(inbox.locator('[data-browser-capture-id="inbox-project-open"]')).toHaveCount(0);
await workspaceFilter.selectOption('');
await statusFilter.selectOption('unprocessed');
await expect(inbox.locator('[data-browser-capture-id="inbox-unassigned"]')).toBeVisible();
await expect(inbox.locator('[data-browser-capture-id="inbox-project-open"]')).toBeVisible();
await expect(inbox.locator('[data-browser-capture-id="inbox-client-processed"]')).toHaveCount(0);
await statusFilter.selectOption('all');
const assignment = inbox.locator('[data-browser-inbox-assignment="inbox-unassigned"]');
await assignment.selectOption('ClientA');
await expect.poll(async () => page.evaluate(async () => {
const result = await window.go.api.App.ReadPluginSettings('verstak.browser-inbox');
const settings = Array.isArray(result) ? result[0] : result;
return settings['captures:global'].find((capture) => capture.captureId === 'inbox-unassigned').workspaceRootPath;
})).toBe('ClientA');
await inbox.locator('[data-browser-inbox-assignment="inbox-unassigned"]').selectOption('Project');
await expect.poll(async () => page.evaluate(async () => {
const result = await window.go.api.App.ReadPluginSettings('verstak.browser-inbox');
const settings = Array.isArray(result) ? result[0] : result;
return settings['captures:global'].find((capture) => capture.captureId === 'inbox-unassigned').workspaceRootPath;
})).toBe('Project');
await inbox.locator('[data-browser-inbox-action="clear-assignment"]').click();
await expect.poll(async () => page.evaluate(async () => {
const result = await window.go.api.App.ReadPluginSettings('verstak.browser-inbox');
const settings = Array.isArray(result) ? result[0] : result;
return settings['captures:global'].find((capture) => capture.captureId === 'inbox-unassigned').workspaceRootPath || '';
})).toBe('');
await inbox.locator('[data-browser-inbox-action="toggle-processed"]').click();
await expect.poll(async () => page.evaluate(async () => {
const result = await window.go.api.App.ReadPluginSettings('verstak.browser-inbox');
const settings = Array.isArray(result) ? result[0] : result;
return settings['captures:global'].find((capture) => capture.captureId === 'inbox-unassigned').processed;
})).toBe(true);
await inbox.locator('[data-browser-inbox-action="toggle-processed"]').click();
await inbox.locator('[data-browser-inbox-action="remove"]').click();
await expect.poll(async () => page.evaluate(async () => {
const result = await window.go.api.App.ReadPluginSettings('verstak.browser-inbox');
const settings = Array.isArray(result) ? result[0] : result;
return settings['captures:global'].some((capture) => capture.captureId === 'inbox-unassigned');
})).toBe(false);
});
});
+143
View File
@@ -0,0 +1,143 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
test.describe('Command Palette', () => {
let consoleCollector;
test.beforeEach(async ({ page }) => {
consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
});
test.afterEach(async () => {
consoleCollector.assertNoErrors();
});
test('opens with keyboard, filters commands, and executes registered frontend handler', async ({ page }) => {
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' }).click();
await expect(page.locator('.pt-root')).toBeVisible({ timeout: 10000 });
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+K' : 'Control+K');
const palette = page.locator('.command-palette');
await expect(palette).toBeVisible();
await expect(palette.locator('[data-command-id="verstak.platform-test.show-version"]')).toBeVisible();
await palette.locator('[data-command-palette-input]').fill('version');
await expect(palette.locator('[data-command-id="verstak.platform-test.show-version"]')).toBeVisible();
await expect(palette.locator('[data-command-id="verstak.platform-test.run-tests"]')).not.toBeVisible();
await page.keyboard.press('Enter');
await expect(palette).not.toBeVisible();
await expect(page.locator('[data-command-palette-status="success"]')).toContainText('Show Version Info');
await expect(page.locator('[data-command-palette-status="success"]')).toContainText('handled');
});
test('Escape closes the palette without changing current view', async ({ page }) => {
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+K' : 'Control+K');
await expect(page.locator('.command-palette')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.locator('.command-palette')).not.toBeVisible();
await expect(page.locator('.workspace-host')).toBeVisible();
});
test('shows user workflow commands before diagnostics and opens workspace tools', async ({ page }) => {
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+K' : 'Control+K');
const palette = page.locator('.command-palette');
await expect(palette).toBeVisible();
const items = palette.locator('.command-palette-item');
await expect(items.nth(0)).toHaveAttribute('data-command-id', 'verstak.shell.open-overview');
await expect(items.nth(1)).toHaveAttribute('data-command-id', 'verstak.shell.open-files');
await expect(items.nth(2)).toHaveAttribute('data-command-id', 'verstak.shell.open-activity');
await expect(items.nth(3)).toHaveAttribute('data-command-id', 'verstak.shell.open-browser-inbox');
await palette.locator('[data-command-palette-input]').fill('activity');
await expect(palette.locator('[data-command-id="verstak.shell.open-activity"]')).toBeVisible();
await expect(palette.locator('[data-command-id="verstak.platform-test.run-tests"]')).not.toBeVisible();
await page.keyboard.press('Enter');
await expect(palette).not.toBeVisible();
await expect(page.getByRole('tab', { name: 'Activity' })).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.activity-root')).toBeVisible({ timeout: 10000 });
});
test('starts file creation workflows from user commands', async ({ page }) => {
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+K' : 'Control+K');
let palette = page.locator('.command-palette');
await palette.locator('[data-command-palette-input]').fill('markdown');
await expect(palette.locator('[data-command-id="verstak.shell.create-markdown"]')).toBeVisible();
await page.keyboard.press('Enter');
await expect(palette).not.toBeVisible();
await expect(page.getByRole('tab', { name: 'Files' })).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.files-root')).toBeVisible({ timeout: 10000 });
await expect(page.locator('[data-files-create-input]')).toBeVisible();
await page.locator('[data-files-create-input]').fill('Palette Note.md');
await page.locator('[data-files-create-confirm]').click();
await expect(page.locator('[data-file-name="Palette Note.md"]')).toBeVisible();
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+K' : 'Control+K');
palette = page.locator('.command-palette');
await palette.locator('[data-command-palette-input]').fill('text file');
await expect(palette.locator('[data-command-id="verstak.shell.create-text"]')).toBeVisible();
await page.keyboard.press('Enter');
await expect(page.locator('[data-files-create-input]')).toBeVisible();
await page.locator('[data-files-create-input]').fill('Palette Text.txt');
await page.locator('[data-files-create-confirm]').click();
await expect(page.locator('[data-file-name="Palette Text.txt"]')).toBeVisible();
});
test('runs sync workflow commands', async ({ page }) => {
await page.evaluate(async () => {
const err = await window.go.api.App.PluginSyncConfigure('verstak.sync', 'https://sync.example.test', 'alice', 'secret');
if (err) throw new Error(err);
});
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+K' : 'Control+K');
let palette = page.locator('.command-palette');
await palette.locator('[data-command-palette-input]').fill('sync now');
await expect(palette.locator('[data-command-id="verstak.shell.sync-now"]')).toBeVisible();
await page.keyboard.press('Enter');
await expect(palette).not.toBeVisible();
await expect(page.locator('[data-command-palette-status="success"]')).toContainText('Sync Now');
await expect(page.locator('[data-command-palette-status="success"]')).toContainText('handled');
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+K' : 'Control+K');
palette = page.locator('.command-palette');
await palette.locator('[data-command-palette-input]').fill('sync settings');
await expect(palette.locator('[data-command-id="verstak.shell.open-sync-settings"]')).toBeVisible();
await page.keyboard.press('Enter');
await expect(page.locator('.plugin-manager')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.modal[aria-label="Plugin Settings"]')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.modal-header h3')).toContainText('Sync');
});
test('keeps technical command failures out of the palette status', async ({ page }) => {
await page.evaluate(() => {
window.go.api.App.PluginSyncNow = () => Promise.resolve('[plugin:verstak.sync] sync.now failed: internal service code 42');
});
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+K' : 'Control+K');
const palette = page.locator('.command-palette');
await palette.locator('[data-command-palette-input]').fill('sync now');
await page.keyboard.press('Enter');
const status = page.locator('[data-command-palette-status="error"]');
await expect(status).toContainText('Could not run Sync Now. Please try again.');
await expect(status).not.toContainText('[plugin:');
await expect(status).not.toContainText('sync.now');
});
});
+259
View File
@@ -0,0 +1,259 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState, openPluginManager } from './helpers.js';
test.describe('F: Default Editor Plugin', () => {
let consoleCollector;
test.beforeEach(async ({ page }) => {
consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
});
test.afterEach(async () => {
consoleCollector.assertNoErrors();
});
test('open .txt file shows plain text editor mode', async ({ page }) => {
await page.evaluate(async () => {
const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.platform-test', {
kind: 'vault-file',
path: 'Docs/todo.txt',
extension: '.txt',
context: { sourceView: 'files' },
});
if (err) throw new Error(err);
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
});
const editor = page.locator('[data-editor-mode="text"]');
await expect(editor).toBeVisible({ timeout: 10000 });
await expect(editor).toHaveAttribute('data-resource-path', 'Docs/todo.txt');
await expect(editor).toHaveAttribute('data-request-mode', 'view');
const textarea = editor.locator('[data-editor-textarea]');
await expect(textarea).toBeVisible();
await expect(textarea).toHaveValue('Buy groceries\nWrite tests');
});
test('secret link opens its exact secret and closing it restores the note preview', async ({ page }) => {
const notePath = 'Notes/Secret Link.md';
const noteContent = '# Secret link\n\n[Target secret](verstak-secret://target.secret)';
await page.evaluate(async ({ notePath, noteContent }) => {
const writeError = await window.go.api.App.WriteVaultTextFile(
'verstak.platform-test',
notePath,
noteContent,
{ createIfMissing: true, overwrite: true },
);
if (writeError) throw new Error(writeError);
const [result, openError] = await window.go.api.App.OpenWorkbenchResource('verstak.platform-test', {
kind: 'vault-file',
path: notePath,
extension: '.md',
context: { sourceView: 'notes', isInsideNotesFolder: true, notesMode: true },
});
if (openError) throw new Error(openError);
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
}, { notePath, noteContent });
const note = page.locator('[data-editor-mode="notes-markdown"]');
await expect(note).toBeVisible({ timeout: 10000 });
await expect(note.locator('[data-preview]')).toContainText('Target secret');
await note.locator('.secret-link').click();
const secrets = page.locator('.secrets-root');
await expect(secrets).toBeVisible({ timeout: 10000 });
await expect(secrets.locator('.secrets-item.active .secrets-item-title')).toHaveText('Target secret');
await expect(secrets.locator('.secrets-card h2')).toHaveText('Target secret');
await page.locator('.workbench-host .close-btn').click();
await expect(note).toBeVisible({ timeout: 10000 });
await expect(note.locator('[data-preview]')).toContainText('Target secret');
await expect(note.locator('[data-editor-textarea]')).toHaveCount(0);
await expect(note.locator('[data-save-state]')).toHaveText('');
const storedContent = await page.evaluate(async ({ notePath }) => {
const [content, readError] = await window.go.api.App.ReadVaultTextFile('verstak.platform-test', notePath);
if (readError) throw new Error(readError);
return content;
}, { notePath });
expect(storedContent).toBe(noteContent);
});
test('unavailable secret does not fall back to the first secret', async ({ page }) => {
await page.evaluate(async () => {
const [result, openError] = await window.go.api.App.OpenWorkbenchResource('verstak.default-editor', {
kind: 'secret',
path: 'missing.secret',
mode: 'view',
});
if (openError) throw new Error(openError);
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
});
const secrets = page.locator('.secrets-root');
await expect(secrets).toBeVisible({ timeout: 10000 });
await expect(secrets.locator('.secrets-item.active')).toHaveCount(0);
await expect(secrets.locator('.secrets-status.error')).toContainText(/unavailable|недоступен/i);
});
test('editor supports markdown toolbar split save reopen and revert', async ({ page }) => {
await page.evaluate(async () => {
const err = await window.go.api.App.WriteVaultTextFile(
'verstak.platform-test',
'Project/Notes/editing.md',
'# Editing\n\nplain text',
{ createIfMissing: true, overwrite: true }
);
if (err) throw new Error(err);
const [result, openErr] = await window.go.api.App.OpenWorkbenchResource('verstak.platform-test', {
kind: 'vault-file',
path: 'Project/Notes/editing.md',
extension: '.md',
context: { sourceView: 'files', isInsideNotesFolder: true, notesMode: true },
});
if (openErr) throw new Error(openErr);
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
});
const editor = page.locator('[data-editor-mode="notes-markdown"]');
await expect(editor).toBeVisible({ timeout: 10000 });
await expect(editor.locator('[data-notes-badge]')).toBeVisible();
await editor.locator('[data-editor-mode-button="edit"]').click();
const textarea = editor.locator('[data-editor-textarea]');
await expect(textarea).toBeVisible();
await textarea.fill('plain text');
await textarea.selectText();
await editor.locator('[data-md-action="bold"]').click();
await expect(textarea).toHaveValue('**plain text**');
await editor.locator('[data-md-action="heading"]').click();
await expect(textarea).toHaveValue('# **plain text**');
await expect(editor.locator('[data-save-state]')).toContainText('Modified');
await editor.locator('[data-editor-mode-button="split"]').click();
await expect(editor.locator('[data-editor-textarea]')).toBeVisible();
await expect(editor.locator('[data-preview]')).toBeVisible();
await expect(editor.locator('[data-preview]')).toContainText('plain text');
await textarea.press(process.platform === 'darwin' ? 'Meta+S' : 'Control+S');
await expect(editor.locator('[data-save-state]')).toContainText('Saved');
await textarea.fill('discard me');
page.once('dialog', (dialog) => dialog.accept());
await editor.locator('[data-editor-action="reload"]').click();
await expect(textarea).toHaveValue('# **plain text**');
await page.evaluate(async () => {
const [result, openErr] = await window.go.api.App.OpenWorkbenchResource('verstak.platform-test', {
kind: 'vault-file',
path: 'Project/Notes/editing.md',
extension: '.md',
context: { sourceView: 'files', isInsideNotesFolder: true, notesMode: true },
});
if (openErr) throw new Error(openErr);
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
});
await expect(page.locator('[data-editor-mode="notes-markdown"] [data-preview]')).toContainText('plain text', { timeout: 10000 });
});
test('open .md file outside Notes routes to highest-priority provider', async ({ page }) => {
await page.evaluate(async () => {
const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.platform-test', {
kind: 'vault-file',
path: 'Docs/readme.md',
extension: '.md',
context: { sourceView: 'files' },
});
if (err) throw new Error(err);
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
});
const workbench = page.locator('.workbench-host');
await expect(workbench).toBeVisible({ timeout: 10000 });
const title = workbench.locator('.workbench-title');
await expect(title).toHaveText('readme.md');
});
test('opening a note uses its title rather than its technical path', async ({ page }) => {
await page.evaluate(async () => {
const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.platform-test', {
kind: 'vault-file',
path: 'Notes/Overview.md',
extension: '.md',
context: { sourceView: 'notes', isInsideNotesFolder: true, notesMode: true },
});
if (err) throw new Error(err);
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
});
const workbench = page.locator('.workbench-host');
await expect(workbench).toBeVisible({ timeout: 10000 });
const title = workbench.locator('.workbench-title');
await expect(title).toHaveText('Overview');
});
test('default-editor plugin is listed as loaded in plugin manager', async ({ page }) => {
await openPluginManager(page);
const card = page.locator('.plugin-card').filter({ hasText: 'verstak.default-editor' });
await expect(card).toBeVisible({ timeout: 10000 });
await expect(card.locator('.status-badge')).toHaveText('loaded');
});
test('disable default-editor plugin removes its providers', async ({ page }) => {
await openPluginManager(page);
const card = page.locator('.plugin-card').filter({ hasText: 'verstak.default-editor' });
await card.locator('button.btn-disable').click();
await expect(card.locator('button.btn-enable')).toBeVisible({ timeout: 10000 });
await page.evaluate(async () => {
const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.platform-test', {
kind: 'vault-file',
path: 'Docs/todo.txt',
extension: '.txt',
context: { sourceView: 'files' },
});
if (err) throw new Error(err);
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
});
await expect(page.locator('[data-workbench-status="no-provider"]')).toBeVisible({ timeout: 10000 });
});
test('default-editor plugin card shows openProviders contribution count', async ({ page }) => {
await openPluginManager(page);
const card = page.locator('.plugin-card').filter({ hasText: 'verstak.default-editor' });
await expect(card).toBeVisible({ timeout: 10000 });
await expect(card.locator('.meta-row').filter({ hasText: 'Contributions:' })).toContainText('3 openProviders');
});
test('default-editor does not add sidebar item', async ({ page }) => {
const sidebarItems = page.locator('.sidebar .plugin-item');
const count = await sidebarItems.count();
const hasDefaultEditor = await page.locator('.sidebar .plugin-item').filter({ hasText: /default.editor/i }).count();
expect(hasDefaultEditor).toBe(0);
});
test('platform-test workbench buttons open files via default-editor', async ({ page }) => {
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' }).click();
await expect(page.locator('.pt-command-result')).toContainText('Command: handled', { timeout: 10000 });
await page.evaluate(async () => {
const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.platform-test', {
kind: 'vault-file',
path: 'Docs/todo.txt',
extension: '.txt',
context: { sourceView: 'files' },
});
if (err) throw new Error(err);
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
});
const editor = page.locator('[data-editor-mode="text"]');
await expect(editor).toBeVisible({ timeout: 10000 });
await expect(editor).toHaveAttribute('data-resource-path', 'Docs/todo.txt');
});
});
+553
View File
@@ -0,0 +1,553 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState, openPluginManager } from './helpers.js';
async function openFilesTool(page) {
await page.getByRole('tab', { name: 'Files' }).click();
await expect(page.locator('.files-root')).toBeVisible({ timeout: 10000 });
}
test.describe('G: Files Plugin', () => {
let consoleCollector;
test.beforeEach(async ({ page }) => {
consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
});
test.afterEach(async () => {
consoleCollector.assertNoErrors();
});
test('files plugin appears in Plugin Manager as loaded', async ({ page }) => {
await openPluginManager(page);
const card = page.locator('.plugin-card').filter({ hasText: 'verstak.files' });
await expect(card).toBeVisible({ timeout: 10000 });
await expect(card.locator('.status-badge')).toHaveText('loaded');
});
test('files plugin does not add global sidebar item', async ({ page }) => {
const sidebarItem = page.locator('.sidebar .plugin-item').filter({ hasText: 'Files' });
await expect(sidebarItem).toHaveCount(0);
});
test('workspace Files view is scoped to selected workspace folder', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await openFilesTool(page);
await expect(page.locator('.workspace-host')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.files-item-name').filter({ hasText: 'project-only.txt' })).toBeVisible({ timeout: 10000 });
await expect(page.locator('.files-item-name').filter({ hasText: 'test-only.txt' })).toHaveCount(0);
await page.locator('.wt-label').filter({ hasText: 'Test' }).click();
await expect(page.locator('.files-item-name').filter({ hasText: 'test-only.txt' })).toBeVisible({ timeout: 10000 });
await expect(page.locator('.files-item-name').filter({ hasText: 'project-only.txt' })).toHaveCount(0);
});
test('files explorer supports create navigate rename filter sort open and trash', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await openFilesTool(page);
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
await page.locator('[data-files-action="new-folder"]').click();
await page.locator('[data-files-create-input]').fill('Daily');
await page.locator('[data-files-create-confirm]').click();
await expect(page.locator('[data-file-name="Daily"]')).toBeVisible();
await page.locator('[data-file-name="Daily"]').dblclick();
await expect(page.locator('.files-breadcrumb')).toContainText('Daily');
await page.locator('[data-files-action="new-markdown"]').click();
await page.locator('[data-files-create-input]').fill('Log.md');
await page.locator('[data-files-create-confirm]').click();
await expect(page.locator('[data-file-name="Log.md"]')).toBeVisible();
await page.locator('[data-file-name="Log.md"]').click();
await page.locator('[data-files-action="rename"]').click();
await page.locator('[data-files-rename-input]').fill('Journal.md');
await page.locator('[data-files-rename-confirm]').click();
await expect(page.locator('[data-file-name="Journal.md"]')).toBeVisible();
await expect(page.locator('[data-file-name="Log.md"]')).toHaveCount(0);
await page.locator('[data-files-filter]').fill('journ');
await expect(page.locator('[data-file-name="Journal.md"]')).toBeVisible();
await expect(page.locator('[data-file-name="project-only.txt"]')).toHaveCount(0);
await page.locator('[data-files-filter]').fill('');
await page.locator('[data-files-sort]').selectOption('modified-desc');
await expect(page.locator('[data-file-name="Journal.md"]')).toBeVisible();
await page.locator('[data-file-name="Journal.md"]').dblclick();
await expect(page.locator('[data-editor-mode="generic-markdown"]')).toBeVisible({ timeout: 10000 });
await expect(page.locator('[data-resource-path="Project/Daily/Journal.md"]')).toBeVisible();
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await expect(page.locator('.files-breadcrumb')).toContainText('Daily', { timeout: 10000 });
await expect(page.locator('[data-file-name="Journal.md"]')).toBeVisible({ timeout: 10000 });
await page.locator('[data-file-name="Journal.md"]').click();
await page.locator('[data-files-action="trash"]').click();
await expect(page.locator('.files-modal-overlay')).toBeVisible();
await expect(page.locator('.files-modal-title')).toContainText('Journal.md');
await page.locator('.files-modal-btn.confirm').click();
await expect(page.locator('[data-file-name="Journal.md"]')).toHaveCount(0);
await page.locator('[data-files-action="up"]').click();
await expect(page.locator('.files-breadcrumb')).not.toContainText('Daily');
});
test('files explorer shows inline rename validation errors', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await openFilesTool(page);
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
await page.locator('[data-file-name="project-only.txt"]').click();
await page.locator('[data-files-action="rename"]').click();
await page.locator('[data-files-rename-input]').fill('bad/name.txt');
await page.locator('[data-files-rename-confirm]').click();
await expect(page.locator('[data-files-rename-error]')).toBeVisible();
await expect(page.locator('[data-files-rename-error]')).toContainText('Invalid characters');
await expect(page.locator('[data-files-rename-input]')).toBeVisible();
await expect(page.locator('[data-file-name="project-only.txt"]')).toBeVisible();
await expect(page.locator('[data-file-name="bad"]')).toHaveCount(0);
});
test('files explorer shows inline rename conflict errors', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await openFilesTool(page);
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
await page.locator('[data-file-name="project-only.txt"]').click();
await page.locator('[data-files-action="rename"]').click();
await page.locator('[data-files-rename-input]').fill('Notes');
await page.locator('[data-files-rename-confirm]').click();
await expect(page.locator('[data-files-rename-error]')).toBeVisible();
await expect(page.locator('[data-files-rename-error]')).toContainText('already exists');
await expect(page.locator('[data-files-rename-input]')).toBeVisible();
await expect(page.locator('[data-files-rename-input]')).toHaveValue('Notes');
await expect(page.locator('[data-file-name="project-only.txt"]')).toBeVisible();
await expect(page.locator('[data-file-name="Notes"]')).toBeVisible();
});
test('files explorer shows inline create validation errors', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await openFilesTool(page);
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
await page.locator('[data-files-action="new-folder"]').click();
await page.locator('[data-files-create-input]').fill('bad/name');
await page.locator('[data-files-create-confirm]').click();
await expect(page.locator('[data-files-create-error]')).toBeVisible();
await expect(page.locator('[data-files-create-error]')).toContainText('Invalid characters');
await expect(page.locator('[data-files-create-input]')).toBeVisible();
await expect(page.locator('[data-file-name="bad"]')).toHaveCount(0);
});
test('files explorer uses rich file icons by file type', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await openFilesTool(page);
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
async function createFile(action, name) {
await page.locator(`[data-files-action="${action}"]`).click();
await page.locator('[data-files-create-input]').fill(name);
await page.locator('[data-files-create-confirm]').click();
await expect(page.locator(`[data-file-name="${name}"]`)).toBeVisible();
}
await createFile('new-markdown', 'IconNote.md');
await createFile('new-text', 'Photo.png');
await createFile('new-text', 'Manual.pdf');
await createFile('new-text', 'Script.js');
const iconFor = (name) => page.locator(`[data-file-name="${name}"] .files-item-icon`);
await expect(iconFor('Notes')).toHaveAttribute('data-file-icon', 'folder');
await expect(iconFor('project-only.txt')).toHaveAttribute('data-file-icon', 'text');
await expect(iconFor('IconNote.md')).toHaveAttribute('data-file-icon', 'markdown');
await expect(iconFor('Photo.png')).toHaveAttribute('data-file-icon', 'image');
await expect(iconFor('Manual.pdf')).toHaveAttribute('data-file-icon', 'pdf');
await expect(iconFor('Script.js')).toHaveAttribute('data-file-icon', 'code');
await expect(iconFor('Manual.pdf')).toHaveAttribute('aria-label', 'PDF file');
await expect(iconFor('Photo.png')).toHaveAttribute('title', 'Image file');
});
test('files explorer offers create actions in empty folders', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await openFilesTool(page);
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
await page.locator('[data-files-action="new-folder"]').click();
await page.locator('[data-files-create-input]').fill('EmptyQuickActions');
await page.locator('[data-files-create-confirm]').click();
await page.locator('[data-file-name="EmptyQuickActions"]').dblclick();
const emptyState = page.locator('.files-empty');
await expect(emptyState).toContainText('Empty folder');
await expect(emptyState.locator('[data-files-empty-action="new-folder"]')).toBeVisible();
await expect(emptyState.locator('[data-files-empty-action="new-markdown"]')).toBeVisible();
await expect(emptyState.locator('[data-files-empty-action="new-text"]')).toBeVisible();
await emptyState.locator('[data-files-empty-action="new-markdown"]').click();
await page.locator('[data-files-create-input]').fill('FromEmpty.md');
await page.locator('[data-files-create-confirm]').click();
await expect(page.locator('[data-file-name="FromEmpty.md"]')).toBeVisible();
await expect(page.locator('.files-empty')).toHaveCount(0);
});
test('files explorer shows no-match feedback while filtering', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await openFilesTool(page);
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
await page.locator('[data-files-filter]').fill('zzzz-no-matches');
const emptyState = page.locator('.files-empty');
await expect(emptyState).toBeVisible();
await expect(emptyState).toContainText('No matches');
await expect(emptyState.locator('[data-files-empty-action="clear-filter"]')).toBeVisible();
await expect(page.locator('[data-file-name="project-only.txt"]')).toHaveCount(0);
await emptyState.locator('[data-files-empty-action="clear-filter"]').click();
await expect(page.locator('[data-files-filter]')).toHaveValue('');
await expect(page.locator('.files-empty')).toHaveCount(0);
await expect(page.locator('[data-file-name="project-only.txt"]')).toBeVisible();
});
test('files explorer keeps removed items out of live files without Trash metadata', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await openFilesTool(page);
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
await page.locator('[data-files-action="new-text"]').click();
await page.locator('[data-files-create-input]').fill('RestoreMe.txt');
await page.locator('[data-files-create-confirm]').click();
await expect(page.locator('[data-file-name="RestoreMe.txt"]')).toBeVisible();
await page.locator('[data-file-name="RestoreMe.txt"]').click();
await page.locator('[data-files-action="trash"]').click();
await page.locator('.files-modal-btn.confirm').click();
await expect(page.locator('[data-file-name="RestoreMe.txt"]')).toHaveCount(0);
await expect(page.locator('[data-files-action="trash-view"]')).toHaveCount(0);
await expect(page.locator('[data-files-restore-trash]')).toHaveCount(0);
});
test('files explorer uses labeled controls and no row New Here action', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await openFilesTool(page);
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
const actionIcons = {
back: 'back',
forward: 'forward',
up: 'up',
refresh: 'refresh',
'new-folder': 'folderAdd',
'new-markdown': 'markdownAdd',
'new-text': 'textAdd',
open: 'open',
rename: 'rename',
trash: 'trash',
cut: 'cut',
copy: 'copy',
paste: 'paste',
};
const pathData = [];
for (const [action, icon] of Object.entries(actionIcons)) {
const button = page.locator(`[data-files-action="${action}"]`);
await expect(button).toHaveAttribute('title', /.+/);
await expect(button).toHaveAttribute('aria-label', /.+/);
await expect(button).toHaveAttribute('data-files-icon', icon);
await expect(button.locator('svg')).toBeVisible();
await expect(button).toHaveText('');
pathData.push(await button.locator('svg path').getAttribute('d'));
}
expect(new Set(pathData).size).toBe(pathData.length);
await expect(page.locator('.files-row-btn').filter({ hasText: 'New here' })).toHaveCount(0);
const rowIcons = {
'row-open': 'open',
'row-rename': 'rename',
'row-trash': 'trash',
};
const rowPathData = [];
for (const [action, icon] of Object.entries(rowIcons)) {
const rowButton = page.locator(`[data-file-name="Notes"] [data-files-action="${action}"]`);
await expect(rowButton).toBeVisible();
await expect(rowButton).toHaveAttribute('title', /.+/);
await expect(rowButton).toHaveAttribute('aria-label', /.+/);
await expect(rowButton).toHaveAttribute('data-files-icon', icon);
await expect(rowButton).toHaveText('');
expect(await rowButton.evaluate((node) => node.innerHTML)).toContain('<svg');
rowPathData.push(await rowButton.locator('svg path').getAttribute('d'));
}
expect(new Set(rowPathData).size).toBe(rowPathData.length);
});
test('files explorer supports empty-space context paste after cutting a folder', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await openFilesTool(page);
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
await page.locator('[data-files-action="new-folder"]').click();
await page.locator('[data-files-create-input]').fill('CutMe');
await page.locator('[data-files-create-confirm]').click();
await page.locator('[data-files-action="new-folder"]').click();
await page.locator('[data-files-create-input]').fill('Target');
await page.locator('[data-files-create-confirm]').click();
await page.locator('[data-file-name="CutMe"]').click({ button: 'right' });
await page.locator('[data-files-menu-action="cut"]').click();
await page.locator('[data-file-name="Target"]').dblclick();
await expect(page.locator('.files-breadcrumb')).toContainText('Target');
await page.locator('[data-files-list]').click({ button: 'right', position: { x: 24, y: 110 } });
await page.locator('[data-files-menu-action="paste"]').click();
await expect(page.locator('[data-file-name="CutMe"]')).toBeVisible();
await page.locator('[data-files-action="up"]').click();
await expect(page.locator('[data-file-name="CutMe"]')).toHaveCount(0);
});
test('files explorer navigates by clicking breadcrumb segments', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await openFilesTool(page);
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
await page.locator('[data-files-action="new-folder"]').click();
await page.locator('[data-files-create-input]').fill('BreadcrumbParent');
await page.locator('[data-files-create-confirm]').click();
await page.locator('[data-file-name="BreadcrumbParent"]').dblclick();
await expect(page.locator('.files-breadcrumb')).toContainText('BreadcrumbParent');
await page.locator('[data-files-action="new-folder"]').click();
await page.locator('[data-files-create-input]').fill('BreadcrumbChild');
await page.locator('[data-files-create-confirm]').click();
await page.locator('[data-file-name="BreadcrumbChild"]').dblclick();
await expect(page.locator('.files-breadcrumb')).toContainText('BreadcrumbChild');
await page.locator('.files-breadcrumb-item').filter({ hasText: 'BreadcrumbParent' }).click();
await expect(page.locator('.files-breadcrumb')).toContainText('BreadcrumbParent');
await expect(page.locator('.files-breadcrumb')).not.toContainText('BreadcrumbChild');
await expect(page.locator('[data-file-name="BreadcrumbChild"]')).toBeVisible();
});
test('files explorer duplicates a file from the context menu', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await openFilesTool(page);
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
await page.locator('[data-file-name="project-only.txt"]').click({ button: 'right' });
await page.locator('[data-files-menu-action="duplicate"]').click();
await expect(page.locator('[data-file-name="project-only.txt"]')).toBeVisible();
await expect(page.locator('[data-file-name="project-only (copy).txt"]')).toBeVisible();
await page.locator('[data-file-name="project-only.txt"]').click({ button: 'right' });
await page.locator('[data-files-menu-action="duplicate"]').click();
await expect(page.locator('[data-file-name="project-only (copy 2).txt"]')).toBeVisible();
});
test('files explorer supports multiselect and internal drag/drop move', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await openFilesTool(page);
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
await page.locator('[data-files-action="new-folder"]').click();
await page.locator('[data-files-create-input]').fill('DropTarget');
await page.locator('[data-files-create-confirm]').click();
await page.locator('[data-files-action="new-markdown"]').click();
await page.locator('[data-files-create-input]').fill('DragOne.md');
await page.locator('[data-files-create-confirm]').click();
await page.locator('[data-files-action="new-text"]').click();
await page.locator('[data-files-create-input]').fill('DragTwo.txt');
await page.locator('[data-files-create-confirm]').click();
await page.locator('[data-file-name="DragOne.md"]').click();
await page.locator('[data-file-name="DragTwo.txt"]').click({ modifiers: [process.platform === 'darwin' ? 'Meta' : 'Control'] });
await expect(page.locator('.files-item.selected')).toHaveCount(2);
await page.evaluate(() => {
const source = document.querySelector('[data-file-name="DragOne.md"]');
const target = document.querySelector('[data-file-name="DropTarget"]');
const dt = new DataTransfer();
source.dispatchEvent(new DragEvent('dragstart', { bubbles: true, dataTransfer: dt }));
target.dispatchEvent(new DragEvent('dragover', { bubbles: true, dataTransfer: dt }));
target.dispatchEvent(new DragEvent('drop', { bubbles: true, dataTransfer: dt }));
});
await expect(page.locator('[data-file-name="DragOne.md"]')).toHaveCount(0);
await expect(page.locator('[data-file-name="DragTwo.txt"]')).toHaveCount(0);
await page.locator('[data-file-name="DropTarget"]').dblclick();
await expect(page.locator('[data-file-name="DragOne.md"]')).toBeVisible();
await expect(page.locator('[data-file-name="DragTwo.txt"]')).toBeVisible();
});
test('files explorer supports keyboard row selection and clearing selection', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await openFilesTool(page);
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
const rows = page.locator('.files-item');
await expect(rows.nth(1)).toBeVisible();
await rows.nth(0).click();
await expect(rows.nth(0)).toHaveClass(/selected/);
await page.locator('.files-root').focus();
await page.keyboard.press('ArrowDown');
await expect(rows.nth(1)).toHaveClass(/selected/);
await expect(rows.nth(0)).not.toHaveClass(/selected/);
await page.keyboard.press('ArrowUp');
await expect(rows.nth(0)).toHaveClass(/selected/);
await page.keyboard.press('Escape');
await expect(page.locator('.files-item.selected')).toHaveCount(0);
});
test('files history persists in workspace context and handles mouse back forward buttons', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await openFilesTool(page);
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
await page.locator('[data-file-name="Notes"]').dblclick();
await expect(page.locator('.files-breadcrumb')).toContainText('Notes');
await page.locator('.files-root').focus();
await page.keyboard.press('Alt+ArrowLeft');
await expect(page.locator('.files-breadcrumb')).not.toContainText('Notes');
await page.keyboard.press('Alt+ArrowRight');
await expect(page.locator('.files-breadcrumb')).toContainText('Notes');
await page.dispatchEvent('.files-root', 'mouseup', { button: 8, buttons: 128, bubbles: true, cancelable: true });
await expect(page.locator('.files-breadcrumb')).not.toContainText('Notes');
await page.dispatchEvent('.files-root', 'mouseup', { button: 9, buttons: 256, bubbles: true, cancelable: true });
await expect(page.locator('.files-breadcrumb')).toContainText('Notes');
await page.locator('.wt-label').filter({ hasText: 'Test' }).click();
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await expect(page.locator('.files-breadcrumb')).toContainText('Notes');
});
test('workbench close and mouse back return from editor to the previous Files folder', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await openFilesTool(page);
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
await page.locator('[data-file-name="Notes"]').dblclick();
await expect(page.locator('.files-breadcrumb')).toContainText('Notes');
await page.locator('[data-file-name="Overview.md"]').dblclick();
await expect(page.locator('[data-editor-mode="notes-markdown"]')).toBeVisible({ timeout: 10000 });
await page.dispatchEvent('body', 'mousedown', { button: 3, bubbles: true, cancelable: true });
await expect(page.locator('.workspace-host')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.files-breadcrumb')).toContainText('Notes');
await expect(page.locator('[data-file-name="Overview.md"]')).toBeVisible();
await page.locator('[data-file-name="Overview.md"]').dblclick();
await expect(page.locator('[data-editor-mode="notes-markdown"]')).toBeVisible({ timeout: 10000 });
await page.waitForTimeout(150);
await page.evaluate(() => {
document.body.dispatchEvent(new PointerEvent('pointerdown', {
button: 3,
buttons: 8,
bubbles: true,
cancelable: true,
pointerType: 'mouse'
}));
});
await expect(page.locator('.workspace-host')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.files-breadcrumb')).toContainText('Notes');
await page.locator('[data-file-name="Overview.md"]').dblclick();
await expect(page.locator('[data-editor-mode="notes-markdown"]')).toBeVisible({ timeout: 10000 });
await page.locator('.workbench-header .close-btn[aria-label="Close"]').click();
await expect(page.locator('.workspace-host')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.files-breadcrumb')).toContainText('Notes');
});
test('open .txt via workbench from files context shows default-editor', async ({ page }) => {
await page.evaluate(async () => {
const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.files', {
kind: 'vault-file',
path: 'Docs/todo.txt',
extension: '.txt',
context: { sourcePluginId: 'verstak.files', sourceView: 'files' },
});
if (err) throw new Error(err);
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
});
const editor = page.locator('[data-editor-mode="text"]');
await expect(editor).toBeVisible({ timeout: 10000 });
await expect(editor).toHaveAttribute('data-resource-path', 'Docs/todo.txt');
const textarea = page.locator('.de-textarea');
await expect(textarea).toBeVisible({ timeout: 10000 });
const textareaBox = await textarea.boundingBox();
expect(textareaBox.height).toBeGreaterThan(300);
});
test('open .md via workbench from files context shows generic-markdown', async ({ page }) => {
await page.evaluate(async () => {
const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.files', {
kind: 'vault-file',
path: 'Docs/readme.md',
extension: '.md',
context: { sourcePluginId: 'verstak.files', sourceView: 'files' },
});
if (err) throw new Error(err);
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
});
const workbench = page.locator('.workbench-host');
await expect(workbench).toBeVisible({ timeout: 10000 });
await expect(workbench.locator('.workbench-title')).toHaveText('readme.md');
const preview = page.locator('.de-preview');
await expect(preview).toBeVisible({ timeout: 10000 });
const previewBox = await preview.boundingBox();
expect(previewBox.height).toBeGreaterThan(300);
});
test('open notes markdown via workbench from files context shows notes-markdown', async ({ page }) => {
await page.evaluate(async () => {
const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.files', {
kind: 'vault-file',
path: 'Notes/Overview.md',
extension: '.md',
context: { sourcePluginId: 'verstak.files', sourceView: 'files', isInsideNotesFolder: true, notesMode: true },
});
if (err) throw new Error(err);
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
});
const workbench = page.locator('.workbench-host');
await expect(workbench).toBeVisible({ timeout: 10000 });
await expect(workbench.locator('.workbench-title')).toHaveText('Overview');
});
test('files plugin card shows openProviders in contributions', async ({ page }) => {
await page.evaluate(async () => {
const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.files', {
kind: 'vault-file', path: 'Docs/todo.txt', extension: '.txt',
context: { sourcePluginId: 'verstak.files', sourceView: 'files' },
});
if (err) throw new Error(err);
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
});
const editor = page.locator('[data-editor-mode="text"]');
await expect(editor).toBeVisible({ timeout: 5000 });
});
});
+61
View File
@@ -0,0 +1,61 @@
/**
* Shared helpers for Verstak E2E tests.
*/
import { expect } from '@playwright/test';
/** Wait for the app to finish loading (loading screen disappears) */
export async function waitForAppReady(page) {
// App shows "Loading Verstak..." initially, then renders the main layout
await page.waitForSelector('main', { state: 'visible', timeout: 15000 });
// Wait a bit for all async data to load
await page.waitForTimeout(1000);
}
/** Open the secondary Plugin Manager route from the status-bar settings menu. */
export async function openPluginManager(page) {
await page.locator('[data-settings-menu-button]').click();
await page.locator('[data-settings-action="plugin-manager"]').click();
await page.waitForSelector('.plugin-manager', { state: 'visible', timeout: 10000 });
}
/** Collect all console errors since last reset */
export function setupConsoleCollector(page) {
const errors = [];
page.on('console', (msg) => {
if (msg.type() === 'error') {
errors.push(msg.text());
}
});
page.on('pageerror', (err) => {
errors.push(err.message);
});
return {
getErrors: () => errors,
assertNoErrors: () => {
if (errors.length > 0) {
throw new Error(`Console errors detected:\n${errors.join('\n')}`);
}
},
};
}
/** Reset mock state before each test */
export async function resetMockState(page) {
await page.evaluate(() => {
if (window.__wailsMock) {
window.__wailsMock.reset();
}
});
}
/** Set plugin status in mock */
export async function setPluginStatus(page, pluginId, status, enabled) {
await page.evaluate(
({ id, st, en }) => {
if (window.__wailsMock) {
window.__wailsMock.setPluginStatus(id, st, en);
}
},
{ id: pluginId, st: status, en: enabled }
);
}
+35
View File
@@ -0,0 +1,35 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
test('global Journal creates an entry in the selected Deal', async ({ page }) => {
const consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.journal', {
'worklog:workspace:Project': [{ entryId: 'existing-project-entry', workspaceRootPath: 'Project', date: '2026-07-14', title: 'Existing entry', minutes: 5 }],
});
});
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Journal' }).click();
const journal = page.locator('.journal-root');
await expect(journal.locator('.journal-title')).toHaveText('Journal');
await journal.locator('[data-journal-action="add"]').click();
await journal.locator('[data-journal-input="workspaceRootPath"]').selectOption('Project');
await journal.locator('[data-journal-input="title"]').fill('Prepare project handoff');
await journal.locator('[data-journal-input="minutes"]').fill('30');
await journal.locator('[data-journal-action="save-entry"]').click();
await expect.poll(async () => page.evaluate(async () => {
const result = await window.go.api.App.ReadPluginSettings('verstak.journal');
const settings = Array.isArray(result) ? result[0] : result;
return settings['worklog:workspace:Project']?.[0]?.title;
})).toBe('Prepare project handoff');
await expect(journal).toContainText('Prepare project handoff');
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await page.getByRole('tab', { name: 'Journal' }).click();
await expect(page.locator('.journal-root')).toContainText('Prepare project handoff');
consoleCollector.assertNoErrors();
});
+57
View File
@@ -0,0 +1,57 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
test.describe('Desktop localization', () => {
let consoleCollector;
test.beforeEach(async ({ page }) => {
consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
});
test.afterEach(async () => {
consoleCollector.assertNoErrors();
});
test('switches shell language live and persists the choice', async ({ page }) => {
await page.locator('[data-settings-menu-button]').click();
await expect(page.locator('[data-settings-language="system"]')).toHaveAttribute('aria-checked', 'true');
await page.locator('[data-settings-language="ru"]').click();
await expect(page.locator('[data-settings-menu-button]')).toHaveAttribute('title', 'Настройки');
await expect(page.locator('.vault-status')).toContainText('Хранилище: открыто');
await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Тест платформы' })).toBeVisible();
await expect(page.locator('[data-status-item-id="verstak.platform-test.status"]')).toContainText('Все тесты пройдены');
await page.locator('[data-settings-menu-button]').click();
await expect(page.locator('[data-settings-action="plugin-manager"]')).toContainText('Менеджер плагинов');
await expect(page.locator('[data-settings-language="ru"]')).toHaveAttribute('aria-checked', 'true');
await page.locator('[data-settings-action="plugin-manager"]').click();
const pluginManager = page.locator('.plugin-manager');
await expect(pluginManager).toContainText('Зарегистрировано возможностей:');
await expect(pluginManager.locator('.registry-section')).toContainText('Реестр возможностей');
await expect(pluginManager.locator('.registry-section')).toContainText('Возможность');
await expect(pluginManager.locator('.plugin-filters')).toContainText('Фильтры');
await expect(pluginManager.locator('[data-plugin-filter-results]')).toContainText('Показано плагинов:');
await expect(pluginManager.locator('[data-plugin-filter="status"]')).toContainText('Выключенные');
await expect(pluginManager).not.toContainText('Workspace-scoped');
await page.reload();
await waitForAppReady(page);
await expect(page.locator('[data-settings-menu-button]')).toHaveAttribute('title', 'Настройки');
await page.locator('[data-settings-menu-button]').click();
await expect(page.locator('[data-settings-language="ru"]')).toHaveAttribute('aria-checked', 'true');
});
test('switches back to English without reloading', async ({ page }) => {
await page.locator('[data-settings-menu-button]').click();
await page.locator('[data-settings-language="ru"]').click();
await page.locator('[data-settings-menu-button]').click();
await page.locator('[data-settings-language="en"]').click();
await expect(page.locator('[data-settings-menu-button]')).toHaveAttribute('title', 'Settings');
await expect(page.locator('.vault-status')).toContainText('Vault: open');
});
});
+213
View File
@@ -0,0 +1,213 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState, openPluginManager } from './helpers.js';
test.describe('D: Plugin API bridge', () => {
let consoleCollector;
test.beforeEach(async ({ page }) => {
consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
});
test.afterEach(async () => {
consoleCollector.assertNoErrors();
});
test('platform-test reads and writes settings through scoped API after reload', async ({ page }) => {
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' }).click();
const saved = page.locator('.pt-saved-setting');
await expect(saved).toHaveText('Saved setting: initial value', { timeout: 10000 });
const input = page.locator('.pt-setting-input');
await input.fill('persisted through bridge');
await page.locator('.pt-save-setting').click();
await expect(saved).toHaveText('Saved setting: persisted through bridge', { timeout: 10000 });
await openPluginManager(page);
await expect.poll(() => page.evaluate(() => Object.keys(window.__VERSTAK_COMMAND_HANDLERS__ || {}).length)).toBe(0);
await expect.poll(() => page.evaluate(() => (window.__VERSTAK_EVENT_HANDLERS__?.['verstak.platform-test.echo'] || []).length)).toBe(0);
await page.locator('button.reload-btn').click();
await expect(page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' }).locator('.status-badge')).toHaveText('loaded', { timeout: 10000 });
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' }).click();
await expect(page.locator('.pt-saved-setting')).toHaveText('Saved setting: persisted through bridge', { timeout: 10000 });
await expect(page.locator('.pt-badge')).toHaveAttribute('data-command-status', 'handled');
await expect(page.locator('.pt-badge')).toContainText('capability available');
await expect(page.locator('.pt-command-result')).toContainText('Command: handled 0.1.0 from bundled-frontend');
await expect(page.locator('.pt-event-result')).toHaveAttribute('data-event-status', 'received');
await expect(page.locator('.pt-event-result')).toContainText('Event: received hello-event');
await expect(page.locator('.pt-files-result')).toHaveAttribute('data-files-status', 'ok');
await expect(page.locator('.pt-files-result')).toContainText('Files: wrote/read/listed/moved/trashed');
await expect(page.locator('.pt-files-error-result')).toHaveAttribute('data-files-error-status', 'expected');
await expect(page.locator('.pt-files-error-result')).toContainText('Files error path: rejected reserved-path');
await page.evaluate(async () => {
const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.platform-test', {
kind: 'vault-file',
path: 'Notes/Overview.md',
extension: '.md',
context: { sourceView: 'notes', isInsideNotesFolder: true, notesMode: true },
});
if (err) throw new Error(err);
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
});
const workbench = page.locator('.workbench-host');
await expect(workbench).toBeVisible({ timeout: 10000 });
await expect(workbench.locator('.workbench-title')).toHaveText('Overview');
});
test('workbench routes markdown files to default-editor provider', async ({ page }) => {
await page.evaluate(async () => {
const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.platform-test', {
kind: 'vault-file',
path: 'Docs/readme.md',
extension: '.md',
context: { sourceView: 'files' },
});
if (err) throw new Error(err);
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
});
const workbench = page.locator('.workbench-host');
await expect(workbench).toBeVisible({ timeout: 10000 });
const title = workbench.locator('.workbench-title');
await expect(title).toHaveText('readme.md');
});
test('workbench shows no-provider fallback when no provider matches', async ({ page }) => {
await page.evaluate(async () => {
const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.platform-test', {
kind: 'vault-file',
path: 'Images/logo.png',
extension: '.png',
context: { sourceView: 'files' },
});
if (err) throw new Error(err);
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
});
const fallback = page.locator('[data-workbench-status="no-provider"]');
await expect(fallback).toBeVisible();
await expect(fallback).toContainText('No viewer/editor available');
await expect(fallback).not.toContainText('generic-text');
});
test('sync plugin API routes through mocked Wails bridge', async ({ page }) => {
const result = await page.evaluate(async () => {
const api = window.createPluginAPI('verstak.sync');
const initial = await api.sync.status();
await api.sync.testConnection('https://sync.example.test', 'alice', 'secret');
await api.sync.configure('https://sync.example.test', 'alice', 'secret');
await api.sync.setInterval(15);
const configured = await api.sync.status();
const syncNow = await api.sync.now();
await api.sync.resetKey();
const reset = await api.sync.status();
await api.sync.disconnect();
const disconnected = await api.sync.status();
api.dispose();
return { initial, configured, syncNow, reset, disconnected };
});
expect(result.initial.statusLabel).toBe('disabled');
expect(result.configured.configured).toBe(true);
expect(result.configured.serverUrl).toBe('https://sync.example.test');
expect(result.configured.syncInterval).toBe(15);
expect(result.syncNow).toEqual({ pushed: 0, pulled: 0, serverSequence: 0 });
expect(result.reset.configured).toBe(false);
expect(result.reset.serverUrl).toBe('https://sync.example.test');
expect(result.reset.tokenStored).toBe(false);
expect(result.reset.statusLabel).toBe('disconnected');
expect(result.disconnected.configured).toBe(false);
expect(result.disconnected.statusLabel).toBe('disabled');
});
test('files API permanently deletes an existing trash entry through the bridge', async ({ page }) => {
const result = await page.evaluate(async () => {
const api = window.createPluginAPI('verstak.files');
await api.files.writeText('Project/bridge-trash.txt', 'remove me', { createIfMissing: true });
const trash = await api.files.trash('Project/bridge-trash.txt');
const before = await api.files.listTrash();
await api.files.deleteTrash(trash.trashId);
const after = await api.files.listTrash();
let readError = '';
try {
await api.files.readText('Project/bridge-trash.txt');
} catch (error) {
readError = String(error && error.message || error);
}
api.dispose();
return { before, after, readError };
});
expect(result.before).toHaveLength(1);
expect(result.after).toEqual([]);
expect(result.readError).toContain('not-found: Project/bridge-trash.txt');
});
test('backend plugin events are dispatched to subscribed frontend handlers', async ({ page }) => {
const result = await page.evaluate(async () => {
const api = window.createPluginAPI('verstak.platform-test');
let received = null;
const unsubscribe = await api.events.subscribe('browser.capture.page', (event) => {
received = event;
});
window.__VERSTAK_DISPATCH_BACKEND_EVENT__({
name: 'browser.capture.page',
timestamp: '2026-06-27T00:00:00.000Z',
payload: { url: 'https://example.com/article' }
});
unsubscribe();
api.dispose();
return received;
});
expect(result.name).toBe('browser.capture.page');
expect(result.payload.url).toBe('https://example.com/article');
expect(result.timestamp).toBe('2026-06-27T00:00:00.000Z');
});
test('platform-test command and event handlers are cleaned up after leaving plugin view', async ({ page }) => {
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' }).click();
await expect(page.locator('.pt-command-result')).toContainText('Command: handled', { timeout: 10000 });
await expect(page.locator('.pt-event-result')).toHaveAttribute('data-event-status', 'received', { timeout: 10000 });
await expect.poll(() => page.evaluate(() => Object.keys(window.__VERSTAK_COMMAND_HANDLERS__ || {}).length)).toBe(1);
await expect.poll(() => page.evaluate(() => (window.__VERSTAK_EVENT_HANDLERS__?.['verstak.platform-test.echo'] || []).length)).toBe(1);
await openPluginManager(page);
await expect.poll(() => page.evaluate(() => Object.keys(window.__VERSTAK_COMMAND_HANDLERS__ || {}).length)).toBe(0);
await expect.poll(() => page.evaluate(() => (window.__VERSTAK_EVENT_HANDLERS__?.['verstak.platform-test.echo'] || []).length)).toBe(0);
});
test('platform-test cleanup remains empty after disable reload flow', async ({ page }) => {
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' }).click();
await expect(page.locator('.pt-command-result')).toContainText('Command: handled', { timeout: 10000 });
await openPluginManager(page);
const pluginCard = page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' });
await pluginCard.locator('button.btn-disable').click();
await expect(pluginCard.locator('button.btn-enable')).toBeVisible({ timeout: 10000 });
await expect.poll(() => page.evaluate(() => Object.keys(window.__VERSTAK_COMMAND_HANDLERS__ || {}).length)).toBe(0);
await expect.poll(() => page.evaluate(() => (window.__VERSTAK_EVENT_HANDLERS__?.['verstak.platform-test.echo'] || []).length)).toBe(0);
});
test('platform-test settings panel loads bundle content returned as raw string', async ({ page }) => {
await openPluginManager(page);
const pluginCard = page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' });
await pluginCard.locator('button.btn-settings').click();
const modal = page.locator('.modal[aria-label="Plugin Settings"]');
await expect(modal).toBeVisible();
await expect(modal).toContainText('Platform Test Settings');
await expect(modal.locator('.host-state.error')).toHaveCount(0);
});
});
@@ -0,0 +1,122 @@
/**
* Acceptance Test A: Plugin Manager Disable/Enable refresh
*
* Scenario:
* 1. Open Plugin Manager
* 2. See Platform Test as loaded/enabled
* 3. Click Disable
* 4. Verify Enable button appears
* 5. Verify plugin sidebar item disappears
* 6. Click Enable
* 7. Verify Disable button appears
* 8. Verify plugin sidebar item returns
*/
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState, openPluginManager } from './helpers.js';
test.describe('A: Plugin Manager Disable/Enable refresh', () => {
let consoleCollector;
test.beforeEach(async ({ page }) => {
consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
await openPluginManager(page);
});
test.afterEach(async () => {
consoleCollector.assertNoErrors();
});
test('Platform Test plugin is initially visible and enabled', async ({ page }) => {
// Plugin Manager should show Platform Test plugin card
const pluginCard = page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' });
await expect(pluginCard).toBeVisible();
// Status should show "loaded"
const statusBadge = pluginCard.locator('.status-badge');
await expect(statusBadge).toHaveText('loaded');
// Disable button should be visible (not Enable)
const disableBtn = pluginCard.locator('button.btn-disable');
await expect(disableBtn).toBeVisible();
// Sidebar should have Platform Test item
const sidebarItem = page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' });
await expect(sidebarItem).toBeVisible();
});
test('Disable plugin: button changes to Enable, sidebar item disappears', async ({ page }) => {
const pluginCard = page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' });
// Click Disable
const disableBtn = pluginCard.locator('button.btn-disable');
await expect(disableBtn).toBeVisible();
await disableBtn.click();
// Wait for UI to update after disable
await page.waitForTimeout(500);
// After disable: Enable button should appear
const enableBtn = pluginCard.locator('button.btn-enable');
await expect(enableBtn).toBeVisible({ timeout: 10000 });
// After disable: sidebar item for this plugin should disappear
const sidebarItem = page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' });
await expect(sidebarItem).not.toBeVisible();
// Status should show "disabled"
const statusBadge = pluginCard.locator('.status-badge');
await expect(statusBadge).toHaveText('disabled');
});
test('Re-enable plugin: button changes to Disable, sidebar item returns', async ({ page }) => {
const pluginCard = page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' });
// First disable
await pluginCard.locator('button.btn-disable').click();
await page.waitForTimeout(500);
// Wait for Enable button
const enableBtn = pluginCard.locator('button.btn-enable');
await expect(enableBtn).toBeVisible({ timeout: 10000 });
// Click Enable
await enableBtn.click();
await page.waitForTimeout(500);
// After re-enable: Disable button should appear
const disableBtn = pluginCard.locator('button.btn-disable');
await expect(disableBtn).toBeVisible({ timeout: 10000 });
// Sidebar item should return
const sidebarItem = page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' });
await expect(sidebarItem).toBeVisible();
// Status should show "loaded"
const statusBadge = pluginCard.locator('.status-badge');
await expect(statusBadge).toHaveText('loaded');
});
test('Disable → Enable full flow in sequence', async ({ page }) => {
const pluginCard = page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' });
const sidebar = page.locator('.sidebar');
// Initial state: enabled
await expect(pluginCard.locator('button.btn-disable')).toBeVisible();
await expect(sidebar.locator('.plugin-item').filter({ hasText: 'Platform Test' })).toBeVisible();
// Disable
await pluginCard.locator('button.btn-disable').click();
await page.waitForTimeout(500);
await expect(pluginCard.locator('button.btn-enable')).toBeVisible({ timeout: 10000 });
await expect(sidebar.locator('.plugin-item').filter({ hasText: 'Platform Test' })).not.toBeVisible();
// Enable
await pluginCard.locator('button.btn-enable').click();
await page.waitForTimeout(500);
await expect(pluginCard.locator('button.btn-disable')).toBeVisible({ timeout: 10000 });
await expect(sidebar.locator('.plugin-item').filter({ hasText: 'Platform Test' })).toBeVisible();
});
});
+202
View File
@@ -0,0 +1,202 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState, openPluginManager, setPluginStatus } from './helpers.js';
test.describe('E: Plugin Manager layout', () => {
let consoleCollector;
test.beforeEach(async ({ page }) => {
consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
});
test.afterEach(async () => {
consoleCollector.assertNoErrors();
});
test('plugin list scrolls through the global main scroll surface and stays responsive', async ({ page }) => {
await openPluginManager(page);
const basePluginCount = await page.locator('.plugin-card').count();
await page.evaluate(() => window.__wailsMock.addSyntheticPlugins(18));
await page.locator('button.reload-btn').click();
await expect(page.locator('.plugin-card')).toHaveCount(basePluginCount + 18, { timeout: 10000 });
const manager = page.locator('.plugin-manager');
const scrollSurface = page.locator('.content.scroll-surface');
await expect(manager).toBeVisible();
await expect(scrollSurface).toBeVisible();
const desktopMetrics = await scrollSurface.evaluate((node) => ({
clientHeight: node.clientHeight,
scrollHeight: node.scrollHeight,
overflowY: getComputedStyle(node).overflowY,
}));
expect(desktopMetrics.overflowY).toBe('auto');
expect(desktopMetrics.scrollHeight).toBeGreaterThan(desktopMetrics.clientHeight);
const scrolledTop = await scrollSurface.evaluate((node) => {
node.scrollTop = node.scrollHeight;
return node.scrollTop;
});
expect(scrolledTop).toBeGreaterThan(0);
await page.setViewportSize({ width: 720, height: 640 });
await expect(manager).toBeVisible();
const narrowMetrics = await scrollSurface.evaluate((node) => ({
clientWidth: node.clientWidth,
scrollWidth: node.scrollWidth,
scrollTop: node.scrollTop,
}));
expect(narrowMetrics.scrollWidth).toBeLessThanOrEqual(narrowMetrics.clientWidth + 1);
expect(narrowMetrics.scrollTop).toBeGreaterThan(0);
});
test('platform-test buttons use the global button contract', async ({ page }) => {
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' }).click();
const saveButton = page.locator('.pt-save-setting');
await expect(saveButton).toBeVisible({ timeout: 10000 });
await expect(saveButton).toHaveClass(/btn-primary/);
const buttonStyle = await saveButton.evaluate((node) => {
const style = getComputedStyle(node);
return {
display: style.display,
backgroundColor: style.backgroundColor,
borderRadius: style.borderRadius,
};
});
expect(buttonStyle.display).toBe('inline-flex');
expect(buttonStyle.backgroundColor).not.toBe('rgba(0, 0, 0, 0)');
expect(buttonStyle.borderRadius).toBe('6px');
});
test('plugin manager summarizes plugin health and elevated permissions before the list', async ({ page }) => {
await openPluginManager(page);
const health = page.locator('[data-plugin-manager-summary="health"]');
await expect(health).toBeVisible();
await expect(health.locator('[data-plugin-status-summary="loaded"]')).toContainText('12');
await expect(health.locator('[data-plugin-status-summary="failed"]')).toContainText('0');
await expect(health.locator('[data-plugin-status-summary="disabled"]')).toContainText('0');
const risk = page.locator('[data-plugin-manager-summary="risk"]');
await expect(risk).toBeVisible();
await expect(risk.locator('[data-plugin-risk-summary="elevated-permissions"]')).toContainText('6');
await expect(risk).toContainText('elevated permissions');
});
test('plugin manager filters by state, declared permissions, capabilities, settings and source', async ({ page }) => {
await setPluginStatus(page, 'verstak.todo', 'disabled', false);
await page.evaluate(() => window.__wailsMock.addSyntheticPlugins(1, 'third-party'));
await openPluginManager(page);
const results = page.locator('[data-plugin-filter-results]');
await expect(results).toContainText('Showing');
const filterSelectStyle = await page.locator('[data-plugin-filter="status"]').evaluate((node) => {
const style = getComputedStyle(node);
return { appearance: style.appearance, backgroundImage: style.backgroundImage };
});
expect(filterSelectStyle.appearance).toBe('none');
expect(filterSelectStyle.backgroundImage).not.toBe('none');
await page.locator('[data-plugin-filter="status"]').selectOption('disabled');
await expect(page.locator('.plugin-card')).toHaveCount(1);
await expect(page.locator('.plugin-card')).toContainText('Todos');
await page.locator('button.reload-btn').click();
await expect(page.locator('[data-plugin-filter="status"]')).toHaveValue('disabled');
await expect(page.locator('.plugin-card')).toHaveCount(1);
await page.locator('[data-plugin-filter-reset]').click();
await page.locator('[data-plugin-filter-permission="notifications.schedule"]').check();
await expect(page.locator('.plugin-card')).toHaveCount(1);
await expect(page.locator('.plugin-card')).toContainText('Todos');
await page.locator('[data-plugin-filter-permission="secrets.read"]').check();
await expect(page.locator('.plugin-card')).toHaveCount(2);
await page.locator('[data-plugin-filter-permission="secrets.read"]').uncheck();
await page.locator('[data-plugin-filter-reset]').click();
await page.locator('[data-plugin-filter-capability="verstak/core/notifications/v1"]').check();
await expect(page.locator('.plugin-card')).toHaveCount(1);
await expect(page.locator('.plugin-card')).toContainText('Todos');
await page.locator('[data-plugin-filter-capability="secret-store"]').check();
await expect(page.locator('.plugin-card')).toHaveCount(2);
await page.locator('[data-plugin-filter-capability="secret-store"]').uncheck();
await page.locator('[data-plugin-filter-reset]').click();
await page.locator('[data-plugin-filter="settings"]').selectOption('with');
await expect(page.locator('.plugin-card').filter({ hasText: 'Platform Test' })).toBeVisible();
await expect(page.locator('.plugin-card').filter({ hasText: 'Default Editor' })).toHaveCount(0);
await page.locator('[data-plugin-filter="source"]').selectOption('third-party');
await expect(page.locator('.plugin-card')).toHaveCount(0);
await expect(page.locator('[data-plugin-filter-empty]')).toBeVisible();
await page.locator('[data-plugin-filter-reset]').click();
await expect(page.locator('.plugin-card')).toHaveCount(13);
});
test('workspace selection keeps exactly one active node', async ({ page }) => {
const selected = page.locator('.wt-node.selected .wt-label');
await expect(selected).toHaveCount(1);
await expect(selected).toHaveText('Project');
await page.locator('.wt-label').filter({ hasText: 'Test' }).click();
await expect(selected).toHaveCount(1);
await expect(selected).toHaveText('Test');
});
test('workspace tools render Overview first with Files as one tab', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
const tabs = page.locator('.workspace-tabs');
await expect(tabs).toBeVisible({ timeout: 10000 });
const overviewTab = tabs.locator('[role="tab"]').filter({ hasText: 'Overview' });
const filesTab = tabs.locator('[role="tab"]').filter({ hasText: 'Files' });
await expect(overviewTab).toBeVisible();
await expect(overviewTab).toHaveAttribute('aria-selected', 'true');
await expect(filesTab).toBeVisible();
await expect(filesTab).toHaveAttribute('aria-selected', 'false');
await expect(page.locator('.workspace-tool')).toHaveCount(0);
await expect(page.locator('[data-overview-root]')).toBeVisible();
await filesTab.click();
await expect(page.locator('.files-root')).toBeVisible();
});
test('workspace sidebar creates renames and trashes top-level workspaces', async ({ page }) => {
await page.locator('button[title="New Deal"]').click();
const modal = page.locator('[data-workspace-create-modal]');
await modal.locator('[data-workspace-name]').fill('ClientA');
await modal.getByRole('button', { name: 'Create Deal' }).click();
await expect(page.locator('.wt-label').filter({ hasText: 'ClientA' })).toBeVisible();
const client = page.locator('.wt-node').filter({ hasText: 'ClientA' });
await client.locator('button[title="Rename Deal"]').click();
await page.locator('.wt-rename').fill('ClientB');
await page.locator('button[title="Save rename"]').click();
await expect(page.locator('.wt-label').filter({ hasText: 'ClientB' })).toBeVisible();
await expect(page.locator('.wt-label').filter({ hasText: 'ClientA' })).toHaveCount(0);
const renamed = page.locator('.wt-node').filter({ hasText: 'ClientB' });
await renamed.locator('button[title="Move Deal to trash"]').click();
await expect(page.locator('.wt-label').filter({ hasText: 'ClientB' })).toHaveCount(0);
});
test('shell icons render through bundled Lucide SVG components', async ({ page }) => {
const logo = page.locator('.sidebar-logo');
await expect(logo).toBeVisible();
await expect(logo).toHaveClass(/lucide/);
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
const workspaceIcon = page.locator('.wt-node-icon').first();
await expect(workspaceIcon).toBeVisible();
await expect(workspaceIcon).toHaveClass(/lucide/);
});
});
+102
View File
@@ -0,0 +1,102 @@
/**
* Acceptance Test C: Reload updates UI state
*
* Scenario:
* 1. Change mocked plugin state (e.g. disable a plugin in mock)
* 2. Click Reload button
* 3. Verify UI reflects the updated state
*/
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState, setPluginStatus, openPluginManager } from './helpers.js';
test.describe('C: Reload updates UI state', () => {
let consoleCollector;
test.beforeEach(async ({ page }) => {
consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
await openPluginManager(page);
});
test.afterEach(async () => {
consoleCollector.assertNoErrors();
});
test('Reload after mock state change reflects new plugin status', async ({ page }) => {
const pluginCard = page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' });
// Initial state: loaded/enabled
await expect(pluginCard.locator('.status-badge')).toHaveText('loaded');
await expect(pluginCard.locator('button.btn-disable')).toBeVisible();
// Change mock state to disabled (simulating external state change)
await setPluginStatus(page, 'verstak.platform-test', 'disabled', false);
await page.waitForTimeout(200);
// Click Reload button in Plugin Manager header
const reloadBtn = page.locator('button.reload-btn');
await expect(reloadBtn).toBeVisible();
await reloadBtn.click();
// Wait for reload to complete and UI to update
await page.waitForTimeout(1000);
// After reload: status should reflect the disabled state
await expect(pluginCard.locator('.status-badge')).toHaveText('disabled', { timeout: 10000 });
// Enable button should appear (since plugin is now disabled)
await expect(pluginCard.locator('button.btn-enable')).toBeVisible({ timeout: 10000 });
// Sidebar item should be gone (disabled plugins are filtered from sidebar)
const sidebarItem = page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' });
await expect(sidebarItem).not.toBeVisible();
});
test('Reload restores plugin after re-enabling in mock', async ({ page }) => {
const pluginCard = page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' });
// Disable in mock, reload
await setPluginStatus(page, 'verstak.platform-test', 'disabled', false);
await page.waitForTimeout(200);
await page.locator('button.reload-btn').click();
await page.waitForTimeout(1000);
// Verify disabled
await expect(pluginCard.locator('.status-badge')).toHaveText('disabled', { timeout: 10000 });
await expect(pluginCard.locator('button.btn-enable')).toBeVisible();
// Re-enable in mock
await setPluginStatus(page, 'verstak.platform-test', 'loaded', true);
await page.waitForTimeout(200);
// Reload again
await page.locator('button.reload-btn').click();
await page.waitForTimeout(1000);
// After reload: should be loaded again
await expect(pluginCard.locator('.status-badge')).toHaveText('loaded', { timeout: 10000 });
await expect(pluginCard.locator('button.btn-disable')).toBeVisible();
// Sidebar item should return
const sidebarItem = page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' });
await expect(sidebarItem).toBeVisible();
});
test('Reload button is not disabled during normal operation', async ({ page }) => {
const reloadBtn = page.locator('button.reload-btn');
await expect(reloadBtn).toBeVisible();
await expect(reloadBtn).not.toBeDisabled();
});
test('Reload handles raw Wails count result without falling into error state', async ({ page }) => {
await page.evaluate(() => window.__wailsMock.setReloadResponseMode('raw-count'));
const reloadBtn = page.locator('button.reload-btn');
await reloadBtn.click();
await expect(page.locator('.error-state')).toHaveCount(0);
await expect(page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' })).toBeVisible({ timeout: 10000 });
});
});
+50
View File
@@ -0,0 +1,50 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
test('global Secrets lists, filters, and creates Deal-scoped secrets', async ({ page }) => {
const consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
await page.evaluate(async () => {
const [record, error] = await window.go.api.App.PluginSecretsWrite('verstak.secrets', {
id: 'project.global-secret',
title: 'Project API',
username: 'project-user',
value: 'project-value',
scope: { kind: 'workspace', workspaceRootPath: 'Project' },
});
if (error || !record?.id) throw new Error(error || 'could not create a project secret');
});
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Secrets' }).click();
const secrets = page.locator('.secrets-root');
await expect(secrets).toBeVisible({ timeout: 10000 });
await expect(secrets).toContainText('First secret');
await expect(secrets).toContainText('Project API');
await secrets.locator('[data-secret-scope-filter]').selectOption('workspace:Project');
await expect(secrets).toContainText('Project API');
await expect(secrets).not.toContainText('First secret');
await secrets.locator('[data-secret-search]').fill('no matching secret');
await expect(secrets).toContainText('No secrets');
await secrets.locator('[data-secret-search]').fill('');
await secrets.locator('[data-secret-scope-filter]').selectOption('all');
await secrets.getByRole('button', { name: 'New' }).click();
await secrets.locator('[data-secret-title]').fill('Project deployment token');
await secrets.locator('[data-secret-value]').fill('deployment-token-value');
await secrets.locator('[data-secret-scope]').selectOption('workspace');
await secrets.locator('[data-secret-workspace]').selectOption('Project');
await secrets.locator('[data-secret-save]').click();
await expect.poll(async () => page.evaluate(async () => {
const [records, error] = await window.go.api.App.PluginSecretsList('verstak.secrets');
if (error) throw new Error(error);
const record = records.find((item) => item.title === 'Project deployment token');
return record && [record.scope?.kind, record.scope?.workspaceRootPath].join('|');
})).toBe('workspace|Project');
consoleCollector.assertNoErrors();
});
+114
View File
@@ -0,0 +1,114 @@
/**
* Acceptance Test B: Sidebar opens plugin view by item.view, not item.id
*
* Data:
* - sidebar item id = verstak.platform-test.sidebar
* - sidebar item view = verstak.platform-test.diagnostics
*
* Scenario:
* 1. Click sidebar item "Platform Test"
* 2. Verify diagnostics view is opened (verstak.platform-test.diagnostics)
* 3. Verify NOT opened empty container by sidebar id
*/
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
test.describe('B: Sidebar opens plugin view by item.view', () => {
let consoleCollector;
test.beforeEach(async ({ page }) => {
consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
});
test.afterEach(async () => {
consoleCollector.assertNoErrors();
});
test('Sidebar item exists with correct label', async ({ page }) => {
await expect(page.locator('.sidebar .nav-item').filter({ hasText: 'Plugin Manager' })).not.toBeVisible();
await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Activity' })).toBeVisible();
await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser' })).toBeVisible();
const sidebarItem = page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' });
await expect(sidebarItem).toBeVisible();
});
test('Global Activity and Browser sidebar items open plugin views', async ({ page }) => {
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Activity' }).click();
await expect(page.locator('.view-container .view-header h2')).toHaveText('Activity', { timeout: 10000 });
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser' }).click();
await expect(page.locator('.view-container .view-header h2')).toHaveText('Browser', { timeout: 10000 });
});
test('selected global tool remains visibly active through navigation and sidebar reloads', async ({ page }) => {
const activity = page.locator('.sidebar .plugin-item').filter({ hasText: 'Activity' });
const browserInbox = page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser' });
await activity.click();
await expect(activity).toHaveAttribute('aria-current', 'page');
await expect(activity).toHaveClass(/is-active/);
await page.evaluate(() => {
window.dispatchEvent(new CustomEvent('verstak:plugins-changed'));
});
await expect(activity).toHaveAttribute('aria-current', 'page');
await page.evaluate(() => {
window.dispatchEvent(new CustomEvent('verstak:open-view', {
detail: { viewId: 'verstak.browser-inbox.view', pluginId: 'verstak.browser-inbox' },
}));
});
await expect(page.locator('.view-container .view-header h2')).toHaveText('Browser', { timeout: 10000 });
await expect(browserInbox).toHaveAttribute('aria-current', 'page');
await expect(browserInbox).toHaveClass(/is-active/);
await expect(activity).not.toHaveAttribute('aria-current', 'page');
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await expect(browserInbox).not.toHaveAttribute('aria-current', 'page');
});
test('Click sidebar item opens diagnostics view by view ID, not sidebar ID', async ({ page }) => {
const sidebarItem = page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' });
await expect(sidebarItem).toBeVisible();
// Click the sidebar item
await sidebarItem.click();
await page.waitForTimeout(500);
// View container should be visible
const viewContainer = page.locator('.view-container');
await expect(viewContainer).toBeVisible();
// The view header should show "Platform Diagnostics" (from view contribution title)
// This proves the view was opened by item.view = "verstak.platform-test.diagnostics"
// NOT by item.id = "verstak.platform-test.sidebar"
const viewHeader = viewContainer.locator('.view-header h2');
await expect(viewHeader).toHaveText('Platform Diagnostics', { timeout: 10000 });
// The view should NOT show "View ... not found" error
// (which would happen if it tried to open by sidebar item id)
await expect(viewContainer).not.toHaveText(/not found/);
// The view should NOT show an empty container message
const emptyView = viewContainer.locator('.empty');
await expect(emptyView).not.toBeVisible();
});
test('View header shows correct title from view contribution', async ({ page }) => {
const sidebarItem = page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' });
await sidebarItem.click();
await page.waitForTimeout(500);
// Verify the view title comes from the view contribution (item.view)
// NOT from the sidebar item (item.id)
const viewHeader = page.locator('.view-container .view-header h2');
await expect(viewHeader).toHaveText('Platform Diagnostics', { timeout: 10000 });
// Should NOT show sidebar item id as view title
await expect(viewHeader).not.toHaveText('verstak.platform-test.sidebar');
});
});
+49
View File
@@ -0,0 +1,49 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState, openPluginManager } from './helpers.js';
test.describe('Status Bar host', () => {
let consoleCollector;
test.beforeEach(async ({ page }) => {
consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
});
test.afterEach(async () => {
consoleCollector.assertNoErrors();
});
test('renders enabled plugin statusBarItems', async ({ page }) => {
const statusBar = page.locator('.status-bar');
await expect(statusBar).toBeVisible();
await expect(statusBar.locator('.vault-status')).toContainText('Vault: open');
await expect(statusBar.locator('[data-status-item-id="verstak.platform-test.status"]')).toContainText('All Tests Pass');
});
test('opens settings menu with plugin manager and plugin settings', async ({ page }) => {
await page.locator('[data-settings-menu-button]').click();
await expect(page.locator('[data-settings-action="plugin-manager"]')).toBeVisible();
await expect(page.locator('[data-settings-panel-id="verstak.sync.settings"]')).toBeVisible();
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' }).click();
await expect(page.locator('.view-container .view-header h2')).toHaveText('Platform Diagnostics');
await page.locator('[data-settings-menu-button]').click();
await page.locator('[data-settings-action="plugin-manager"]').click();
await expect(page.locator('.plugin-manager')).toBeVisible();
});
test('refreshes statusBarItems after disabling plugin', async ({ page }) => {
const pluginCard = page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' });
await expect(page.locator('[data-status-item-id="verstak.platform-test.status"]')).toBeVisible();
await openPluginManager(page);
await pluginCard.locator('button.btn-disable').click();
await expect(pluginCard.locator('button.btn-enable')).toBeVisible({ timeout: 10000 });
await expect(page.locator('[data-status-item-id="verstak.platform-test.status"]')).not.toBeVisible();
});
});
+161
View File
@@ -0,0 +1,161 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
test.describe('Todo plugin workflow', () => {
let consoleCollector;
test.beforeEach(async ({ page }) => {
consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
});
test.afterEach(async () => {
consoleCollector.assertNoErrors();
});
test('workspace Todo supports CRUD, reminders, and manual Journal conversion', async ({ page }) => {
await page.getByRole('tab', { name: 'Todos' }).click();
const todos = page.locator('.todo-root');
await expect(todos).toBeVisible({ timeout: 10000 });
await expect(todos).toContainText('Todos · Project');
await todos.locator('[data-todo-action="add"]').click();
await todos.locator('[data-todo-input="title"]').fill('Prepare project review');
await todos.locator('[data-todo-input="description"]').fill('Collect factual review notes.');
await todos.locator('[data-todo-input="priority"]').selectOption('high');
await todos.locator('[data-todo-input="dueAt"]').fill('2000-01-01');
await todos.locator('[data-todo-input="reminderDate"]').fill('2000-01-01');
const reminderTime = todos.locator('[data-todo-input="reminderTime"]');
await expect(reminderTime).toHaveAttribute('type', 'text');
await reminderTime.fill('09:30');
await todos.locator('[data-todo-action="save"]').click();
await expect(todos).toContainText('Overdue');
await expect(todos).toContainText('Reminder due');
await expect.poll(async () => page.evaluate(async () => {
const result = await window.go.api.App.ReadPluginSettings('verstak.todo');
const settings = Array.isArray(result) ? result[0] : result;
const todo = settings['todos:global'].find((item) => item.title === 'Prepare project review');
return todo && [todo.workspaceRootPath, todo.priority, todo.dueAt, todo.reminderDate, todo.reminderAt].join('|');
})).toBe('Project|high|2000-01-01|2000-01-01|2000-01-01T09:30');
await todos.locator('[data-todo-action="edit"]').click();
await todos.locator('[data-todo-input="title"]').fill('Prepare project review updated');
await todos.locator('[data-todo-input="reminderTime"]').fill('not-a-time');
await todos.locator('[data-todo-action="save"]').click();
await expect(todos).toContainText('Enter a valid reminder time');
await todos.locator('[data-todo-input="reminderTime"]').fill('');
await todos.locator('[data-todo-action="save"]').click();
await expect(todos).toContainText('Prepare project review updated');
await expect.poll(async () => page.evaluate(async () => {
const result = await window.go.api.App.ReadPluginSettings('verstak.todo');
const settings = Array.isArray(result) ? result[0] : result;
const todo = settings['todos:global'].find((item) => item.title === 'Prepare project review updated');
return todo && [todo.reminderDate, todo.reminderAt].join('|');
})).toBe('2000-01-01|');
await todos.locator('[data-todo-action="mark-done"]').click();
await expect(todos.locator('[data-todo-action="create-journal-entry"]')).toBeVisible();
await todos.locator('[data-todo-action="create-journal-entry"]').click();
const journal = page.locator('.journal-root');
await expect(page.getByRole('tab', { name: 'Journal' })).toHaveAttribute('aria-selected', 'true');
await expect(journal).toBeVisible({ timeout: 10000 });
await expect(journal).toContainText('Create journal entry from completed todo');
await expect(journal.locator('[data-journal-input="title"]')).toHaveValue('Prepare project review updated');
await expect(journal.locator('[data-journal-input="summary"]')).toHaveValue('Collect factual review notes.');
await expect(journal.locator('[data-journal-input="minutes"]')).toHaveValue('0');
await journal.locator('[data-journal-input="title"]').fill('Prepare project review handoff');
await journal.locator('[data-journal-input="summary"]').fill('Reviewed factual project notes before handoff.');
await journal.locator('[data-journal-action="save-entry"]').click();
await expect.poll(async () => page.evaluate(async () => {
const todoResult = await window.go.api.App.ReadPluginSettings('verstak.todo');
const todoSettings = Array.isArray(todoResult) ? todoResult[0] : todoResult;
const todo = todoSettings['todos:global'].find((item) => item.title === 'Prepare project review updated');
const journalResult = await window.go.api.App.ReadPluginSettings('verstak.journal');
const journalSettings = Array.isArray(journalResult) ? journalResult[0] : journalResult;
const entry = journalSettings['worklog:workspace:Project'].find((item) => item.sourceTodoId === todo.id);
return entry && [entry.title, entry.summary, entry.minutes].join('|');
})).toBe('Prepare project review handoff|Reviewed factual project notes before handoff.|0');
await page.getByRole('tab', { name: 'Todos' }).click();
await page.locator('.todo-root [data-todo-action="delete"]').click();
await expect.poll(async () => page.evaluate(async () => {
const result = await window.go.api.App.ReadPluginSettings('verstak.todo');
const settings = Array.isArray(result) ? result[0] : result;
return settings['todos:global'].some((item) => item.title === 'Prepare project review updated');
})).toBe(false);
});
test('global Todos filter by workspace and Overview only exposes current workspace attention', async ({ page }) => {
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.todo', {
'todos:global': [
{
id: 'todo-project-open',
title: 'Project deadline',
workspaceRootPath: 'Project',
status: 'open',
priority: 'high',
dueAt: '2000-01-01',
reminderAt: '2000-01-01T09:00',
createdAt: '2026-06-30T08:00:00.000Z',
updatedAt: '2026-06-30T08:00:00.000Z',
},
{
id: 'todo-project-done',
title: 'Project completed item',
workspaceRootPath: 'Project',
status: 'done',
priority: 'normal',
createdAt: '2026-06-30T08:01:00.000Z',
updatedAt: '2026-06-30T08:01:00.000Z',
completedAt: '2026-06-30T08:02:00.000Z',
},
{
id: 'todo-test-open',
title: 'Test workspace item',
workspaceRootPath: 'Test',
status: 'open',
priority: 'normal',
dueAt: '2000-01-01',
createdAt: '2026-06-30T08:03:00.000Z',
updatedAt: '2026-06-30T08:03:00.000Z',
},
],
});
});
const overview = page.locator('[data-overview-root]');
await overview.locator('[data-overview-action="refresh"]').click();
const attention = overview.locator('[data-overview-section="attention"]');
await expect(attention).toContainText('Project deadline');
await expect(attention).not.toContainText('Test workspace item');
await attention.locator('.overview-attention-row', { hasText: 'Project deadline' }).getByRole('button', { name: 'Open Todos' }).click();
await expect(page.getByRole('tab', { name: 'Todos' })).toHaveAttribute('aria-selected', 'true');
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Todos' }).click();
const globalTodos = page.locator('.todo-root');
await expect(globalTodos).toContainText('Project deadline');
await expect(globalTodos).toContainText('Test workspace item');
await globalTodos.locator('[data-todo-filter="workspace"]').selectOption('Project');
await expect(globalTodos).toContainText('Project deadline');
await expect(globalTodos).toContainText('Project completed item');
await expect(globalTodos).not.toContainText('Test workspace item');
await globalTodos.locator('[data-todo-filter="status"]').selectOption('done');
await expect(globalTodos).toContainText('Project completed item');
await expect(globalTodos).not.toContainText('Project deadline');
await page.locator('.wt-label').filter({ hasText: 'Test' }).click();
await page.getByRole('tab', { name: 'Todos' }).click();
const workspaceTodos = page.locator('.todo-root');
await expect(workspaceTodos).toContainText('Test workspace item');
await expect(workspaceTodos).not.toContainText('Project deadline');
});
});
+121
View File
@@ -0,0 +1,121 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
async function openTrash(page) {
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Trash' }).click();
await expect(page.locator('.trash-root')).toBeVisible({ timeout: 10000 });
}
async function createTrashEntry(page, path, content, deletedAt) {
return page.evaluate(async ({ path, content, deletedAt }) => {
const api = window.createPluginAPI('verstak.files');
await api.files.writeText(path, content, { createIfMissing: true });
const entry = await api.files.trash(path);
window.__wailsMock.setTrashDeletedAt(entry.trashId, deletedAt);
api.dispose();
return entry;
}, { path, content, deletedAt });
}
test.describe('L: Global Trash Plugin', () => {
let consoleCollector;
test.beforeEach(async ({ page }) => {
consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
});
test.afterEach(async () => {
consoleCollector.assertNoErrors();
});
test('moves a Files item into global Trash without exposing Trash metadata in Files', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await page.getByRole('tab', { name: 'Files' }).click();
await expect(page.locator('.files-root')).toBeVisible({ timeout: 10000 });
await page.locator('[data-files-action="new-text"]').click();
await page.locator('[data-files-create-input]').fill('GlobalTrash.txt');
await page.locator('[data-files-create-confirm]').click();
await expect(page.locator('[data-file-name="GlobalTrash.txt"]')).toBeVisible();
await page.locator('[data-file-name="GlobalTrash.txt"]').click();
await page.locator('[data-files-action="trash"]').click();
await page.locator('.files-modal-btn.confirm').click();
await expect(page.locator('[data-file-name="GlobalTrash.txt"]')).toHaveCount(0);
await expect(page.locator('[data-files-action="trash-view"]')).toHaveCount(0);
await openTrash(page);
const row = page.locator('[data-trash-row]').filter({ hasText: 'GlobalTrash.txt' });
await expect(row).toBeVisible();
await expect(row).toContainText('Project');
await expect(row).toContainText('Project/GlobalTrash.txt');
});
test('filters and sorts global Trash and restores without overwriting a conflict', async ({ page }) => {
const older = await createTrashEntry(page, 'Project/Older.txt', 'older original', '2026-06-20T08:00:00.000Z');
const newest = await createTrashEntry(page, 'Test/Newest.txt', 'newest original', '2026-06-29T08:00:00.000Z');
const restore = await createTrashEntry(page, 'Project/Restore.txt', 'restore original', '2026-06-25T08:00:00.000Z');
const conflict = await page.evaluate(async () => {
const api = window.createPluginAPI('verstak.files');
await api.files.writeText('Project/Conflict.txt', 'trashed content', { createIfMissing: true });
const entry = await api.files.trash('Project/Conflict.txt');
await api.files.writeText('Project/Conflict.txt', 'existing content', { createIfMissing: true });
window.__wailsMock.setTrashDeletedAt(entry.trashId, '2026-06-24T08:00:00.000Z');
api.dispose();
return entry;
});
await openTrash(page);
await page.locator('[data-trash-filter-workspace]').selectOption('Project');
await expect(page.locator(`[data-trash-row="${older.trashId}"]`)).toBeVisible();
await expect(page.locator(`[data-trash-row="${newest.trashId}"]`)).toHaveCount(0);
await page.locator('[data-trash-filter-workspace]').selectOption('');
await page.locator('[data-trash-filter-search]').fill('Newest');
await expect(page.locator(`[data-trash-row="${newest.trashId}"]`)).toBeVisible();
await expect(page.locator(`[data-trash-row="${older.trashId}"]`)).toHaveCount(0);
await page.locator('[data-trash-filter-search]').fill('');
await page.locator('[data-trash-sort]').selectOption('date-asc');
await expect(page.locator('[data-trash-row]').first()).toHaveAttribute('data-trash-row', older.trashId);
await page.locator(`[data-trash-restore="${restore.trashId}"]`).click();
await expect(page.locator(`[data-trash-row="${restore.trashId}"]`)).toHaveCount(0);
await expect.poll(() => page.evaluate(async () => {
const api = window.createPluginAPI('verstak.files');
const content = await api.files.readText('Project/Restore.txt');
api.dispose();
return content;
})).toBe('restore original');
await page.locator(`[data-trash-restore="${conflict.trashId}"]`).click();
await expect(page.locator('[data-trash-status]')).toContainText('Restore blocked');
await expect(page.locator(`[data-trash-row="${conflict.trashId}"]`)).toBeVisible();
await expect.poll(() => page.evaluate(async () => {
const api = window.createPluginAPI('verstak.files');
const content = await api.files.readText('Project/Conflict.txt');
api.dispose();
return content;
})).toBe('existing content');
});
test('requires confirmation before permanently deleting a Trash item', async ({ page }) => {
const entry = await createTrashEntry(page, 'Test/Permanent.txt', 'remove forever', '2026-06-26T08:00:00.000Z');
await openTrash(page);
await page.locator(`[data-trash-delete="${entry.trashId}"]`).click();
await expect(page.locator(`[data-trash-confirm="${entry.trashId}"]`)).toBeVisible();
await expect(page.locator(`[data-trash-row="${entry.trashId}"]`)).toBeVisible();
await page.locator(`[data-trash-confirm-delete="${entry.trashId}"]`).click();
await expect(page.locator(`[data-trash-row="${entry.trashId}"]`)).toHaveCount(0);
await expect.poll(() => page.evaluate(async (trashId) => {
const api = window.createPluginAPI('verstak.files');
const entries = await api.files.listTrash();
api.dispose();
return entries.some((item) => item.trashId === trashId);
}, entry.trashId)).toBe(false);
});
});
+118
View File
@@ -0,0 +1,118 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, resetMockState, openPluginManager } from './helpers.js';
test.describe('UX follow-up fixes', () => {
test.beforeEach(async ({ page }) => {
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
});
test('global search stays available after opening tool sidebar views', async ({ page }) => {
const search = page.locator('[data-global-search-input]');
await expect(search).toBeVisible();
await expect(page.locator('.workspace-header [data-global-search-input]')).toBeVisible();
await page.locator('.sidebar .nav-item').filter({ hasText: 'Activity' }).click();
await expect(page.locator('.activity-root')).toBeVisible({ timeout: 10000 });
await expect(search).toBeVisible();
await expect(page.locator('.sidebar [data-global-search-input]')).toBeVisible();
await page.locator('.sidebar .nav-item').filter({ hasText: 'Browser' }).click();
await expect(page.locator('.browser-inbox-root')).toBeVisible({ timeout: 10000 });
await expect(search).toBeVisible();
});
test('global search types ahead across workspaces and file contents with keyboard layout fallback', async ({ page }) => {
const search = page.locator('[data-global-search-input]');
await search.fill('Зкщоусе');
await expect(page.locator('[data-global-search-results]')).toContainText('Project', { timeout: 10000 });
await search.fill('project file');
await expect(page.locator('[data-global-search-results]')).toContainText('project-only.txt', { timeout: 10000 });
});
test('global search folder results open the workspace Files context', async ({ page }) => {
const search = page.locator('[data-global-search-input]');
await search.fill('Project/Notes');
const folderResult = page.locator('[data-global-search-result-type="Folder"][data-global-search-result-path="Project/Notes"]');
await expect(folderResult).toBeVisible({ timeout: 10000 });
await folderResult.click();
await expect(page.locator('.workspace-title')).toHaveText('Project', { timeout: 10000 });
await expect(page.getByRole('tab', { name: 'Files' })).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.files-root')).toBeVisible();
await expect(page.locator('.files-breadcrumb')).toContainText('Notes');
await expect(page.locator('.files-item')).toContainText('Overview.md');
});
test('global search opens indexed browser inbox results', async ({ page }) => {
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.browser-inbox', {
'captures:workspace:Project': [{
captureId: 'capture-search-1',
capturedAt: '2026-06-30T08:00:00.000Z',
kind: 'page',
url: 'https://example.com/research',
title: 'Research Search Result',
domain: 'example.com',
text: 'Searchable captured browser text',
workspaceRootPath: 'Project',
browserName: 'Firefox',
}],
});
});
const search = page.locator('[data-global-search-input]');
await search.fill('Research Search Result');
const result = page.locator('[data-global-search-result-type="Browser"]').filter({ hasText: 'Research Search Result' });
await expect(result).toBeVisible({ timeout: 10000 });
await result.click();
await expect(page.locator('.browser-inbox-root')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.browser-inbox-detail-title')).toHaveText('Research Search Result');
});
test('workspace Search input keeps focus while typing', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await page.getByRole('tab', { name: 'Search' }).click();
const searchInput = page.locator('[data-search-input="query"]');
await expect(searchInput).toBeVisible({ timeout: 10000 });
await searchInput.click();
await page.keyboard.press('p');
await expect(searchInput).toBeFocused();
await page.keyboard.press('r');
await expect(searchInput).toBeFocused();
await expect(searchInput).toHaveValue('pr');
});
test('mobile workspace layout gives content full width below the sidebar', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.reload();
await waitForAppReady(page);
const workspaceBox = await page.locator('.workspace-host').boundingBox();
const sidebarBox = await page.locator('.sidebar').boundingBox();
expect(workspaceBox.width).toBeGreaterThan(340);
expect(workspaceBox.y).toBeGreaterThan(sidebarBox.y + sidebarBox.height - 1);
await expect(page.locator('.workspace-header [data-global-search-input]')).toBeVisible();
await expect(page.getByRole('tab', { name: 'Overview' })).toBeVisible();
const hasHorizontalOverflow = await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth);
expect(hasHorizontalOverflow).toBe(false);
});
test('plugin settings modal gives complex panels enough space', async ({ page }) => {
await openPluginManager(page);
await page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' }).getByRole('button', { name: 'Settings' }).click();
const modal = page.locator('.modal[aria-label="Plugin Settings"]');
await expect(modal).toBeVisible({ timeout: 10000 });
const box = await modal.boundingBox();
expect(box.width).toBeGreaterThanOrEqual(760);
expect(box.height).toBeGreaterThanOrEqual(560);
});
});
+110
View File
@@ -0,0 +1,110 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState, openPluginManager } from './helpers.js';
test.describe('UX P0 shell flow', () => {
let consoleCollector;
test.beforeEach(async ({ page }) => {
consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
});
test.afterEach(async () => {
consoleCollector.assertNoErrors();
});
test('starts in the first workspace instead of Plugin Manager', async ({ page }) => {
await expect(page.locator('.workspace-host')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.plugin-manager')).toHaveCount(0);
await expect(page.locator('.wt-node.selected .wt-label')).toHaveText('Project');
await expect(page.locator('.workspace-title')).toHaveText('Project');
});
test('workspace selection and main content stay in sync across plugin manager round trip', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Test' }).click();
await expect(page.locator('.workspace-title')).toHaveText('Test', { timeout: 10000 });
await expect(page.locator('.wt-node.selected .wt-label')).toHaveText('Test');
await expect(page.locator('.plugin-manager')).toHaveCount(0);
await openPluginManager(page);
await expect(page.locator('.plugin-manager')).toBeVisible();
await expect(page.locator('.wt-node.selected .wt-label')).toHaveCount(0);
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await expect(page.locator('.workspace-title')).toHaveText('Project', { timeout: 10000 });
await expect(page.locator('.plugin-manager')).toHaveCount(0);
});
test('Deal header does not expose the internal workspace type badge', async ({ page }) => {
await expect(page.locator('.workspace-host')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.workspace-type')).toHaveCount(0);
});
test('status bar plugin contribution failures do not render large error panels', async ({ page }) => {
await expect(page.locator('.workspace-host')).toBeVisible({ timeout: 10000 });
await expect(page.getByText('Plugin View Error')).toHaveCount(0);
await expect(page.locator('.status-bar [data-status-item-id]')).toHaveCount(2);
});
test('Plugin Manager remains reachable from the settings menu', async ({ page }) => {
await openPluginManager(page);
await expect(page.locator('.plugin-manager')).toBeVisible();
await expect(page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' })).toBeVisible();
});
});
test.describe('UX quick wins', () => {
test('Files screen uses readable dates and understandable action controls', async ({ page }) => {
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await page.getByRole('tab', { name: 'Files' }).click();
const files = page.locator('.files-root');
await expect(files).toBeVisible({ timeout: 10000 });
await expect(files.getByText(/T\d{2}:\d{2}:\d{2}/)).toHaveCount(0);
const actions = [
['new-folder', 'New folder'],
['new-markdown', 'New markdown file'],
['new-text', 'New text file'],
];
for (const [action, label] of actions) {
const button = page.locator(`[data-files-action="${action}"]`).first();
await expect(button).toBeVisible();
await expect(button).toHaveAttribute('title', label);
await expect(button).toHaveAttribute('aria-label', label);
await expect(button).toHaveAttribute('data-files-icon', /.+/);
await expect(button.locator('svg')).toBeVisible();
}
});
test('Vault Selection is localized and has a clear primary action', async ({ browser }) => {
const page = await browser.newPage();
await page.addInitScript(() => {
window.go = { api: { App: {
GetAppSettings: async () => ({ currentVaultPath: '', recentVaults: ['/tmp/verstak-recent-vault'] }),
GetVaultStatus: async () => ({ status: 'closed', path: '', vaultId: '' }),
SelectDirectory: async () => '',
SelectVaultForOpen: async () => '',
CreateVault: async () => null,
OpenVault: async () => null,
SetCurrentVault: async () => '',
WriteFrontendLog: async () => {},
} } };
});
await page.goto('/');
await page.waitForSelector('.vault-selection', { timeout: 10000 });
await expect(page.getByText('Choose a vault to start working')).toBeVisible();
await expect(page.getByRole('button', { name: 'Create vault' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Open existing' })).toBeVisible();
await page.close();
});
});
+322
View File
@@ -0,0 +1,322 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
test.describe('UX Overview workspace flow', () => {
let consoleCollector;
test.beforeEach(async ({ page }) => {
consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
});
test.afterEach(async () => {
consoleCollector.assertNoErrors();
});
test('workspace opens with Overview before plugin tools', async ({ page }) => {
await expect(page.locator('.workspace-host')).toBeVisible({ timeout: 10000 });
const tabs = page.getByRole('tab');
await expect(tabs.nth(0)).toHaveText('Overview');
await expect(tabs.nth(1)).toHaveText('Notes');
await expect(page.getByRole('tab', { name: 'Overview' })).toHaveAttribute('aria-selected', 'true');
await expect(page.getByRole('tab', { name: 'Today' })).toHaveCount(0);
const overview = page.locator('[data-overview-root]');
await expect(overview).toBeVisible();
await expect(overview.locator('[data-overview-section="continue"]')).toContainText('Continue working');
await expect(overview.locator('[data-overview-section="recent"]')).toContainText('Recent changes');
await expect(overview.locator('[data-overview-section="attention"]')).toContainText('Needs attention');
await expect(overview.locator('[data-overview-section="quick-actions"]')).toHaveCount(0);
const summaryCards = overview.locator('button[data-overview-summary]');
await expect(summaryCards).toHaveCount(6);
await expect(overview.locator('[data-overview-summary="notes"]')).toContainText('1 total');
await expect(overview.locator('[data-overview-summary="notes"]')).toContainText('0 recent changes');
await expect(overview.locator('[data-overview-summary="captures"]')).toContainText('0 captures to review');
await expect(overview.locator('[data-overview-summary="activity"]')).toContainText('0 recorded events');
await expect(overview.locator('[data-overview-summary="journal"]')).toContainText('0 journal entries');
await expect(overview).toContainText('No clear resume point yet');
await expect(overview).toContainText('No meaningful changes for this filter yet');
});
test('Overview summary cards navigate to their corresponding workspace tools', async ({ page }) => {
const overview = page.locator('[data-overview-root]');
await overview.locator('[data-overview-summary="notes"]').click();
await expect(page.getByRole('tab', { name: 'Notes' })).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.notes-root')).toBeVisible({ timeout: 10000 });
await page.getByRole('tab', { name: 'Overview' }).click();
await overview.locator('[data-overview-summary="files"]').click();
await expect(page.getByRole('tab', { name: 'Files' })).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.files-root')).toBeVisible({ timeout: 10000 });
await page.getByRole('tab', { name: 'Overview' }).click();
await overview.locator('[data-overview-summary="captures"]').click();
await expect(page.getByRole('tab', { name: 'Browser' })).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.browser-inbox-root')).toBeVisible({ timeout: 10000 });
await page.getByRole('tab', { name: 'Overview' }).click();
await overview.locator('[data-overview-summary="activity"]').click();
await expect(page.getByRole('tab', { name: 'Activity' })).toHaveAttribute('aria-selected', 'true');
await page.getByRole('tab', { name: 'Overview' }).click();
await overview.locator('[data-overview-summary="journal"]').click();
await expect(page.getByRole('tab', { name: 'Journal' })).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.journal-root')).toBeVisible({ timeout: 10000 });
await page.getByRole('tab', { name: 'Overview' }).click();
await overview.locator('[data-overview-summary="attention"]').click();
await expect(page.getByRole('tab', { name: 'Browser' })).toHaveAttribute('aria-selected', 'true');
});
test('Overview keeps the Notes total factual when the Files plugin is unavailable', async ({ page }) => {
await page.evaluate(() => window.__wailsMock.setPluginStatus('verstak.files', 'disabled', false));
await page.locator('[data-overview-action="refresh"]').click();
await expect(page.locator('[data-overview-summary="notes"]')).toContainText('1 total');
});
test('Overview hides Browser cards and actions when the current Deal does not include it', async ({ page }) => {
await page.locator('button[title="New Deal"]').click();
const modal = page.locator('[data-workspace-create-modal]');
await modal.locator('[data-workspace-name]').fill('MinimalOverview');
await modal.locator('[data-workspace-template]').selectOption('minimal');
await modal.getByRole('button', { name: 'Create Deal' }).click();
await expect(page.getByRole('tab', { name: 'Browser' })).toHaveCount(0);
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.browser-inbox', {
'captures:global': [{
captureId: 'hidden-browser-capture',
capturedAt: '2026-07-14T08:00:00.000Z',
kind: 'page',
title: 'Inbox material must stay hidden',
workspaceRootPath: 'MinimalOverview',
}],
});
});
const overview = page.locator('[data-overview-root]');
await overview.locator('[data-overview-action="refresh"]').click();
await expect(overview.locator('[data-overview-summary="captures"]')).toHaveCount(0);
await expect(overview.locator('[data-overview-action="browser-inbox"]')).toHaveCount(0);
await expect(overview).not.toContainText('Inbox material must stay hidden');
});
test('Overview refreshes when Browser is disabled through plugin state changes', async ({ page }) => {
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.browser-inbox', {
'captures:global': [{
captureId: 'disabled-browser-capture',
capturedAt: '2026-07-14T08:00:00.000Z',
kind: 'page',
title: 'Disabled inbox material',
workspaceRootPath: 'Project',
}],
});
window.__wailsMock.setPluginStatus('verstak.browser-inbox', 'disabled', false);
window.dispatchEvent(new CustomEvent('verstak:plugins-changed'));
});
const overview = page.locator('[data-overview-root]');
await expect(page.getByRole('tab', { name: 'Browser' })).toHaveCount(0);
await expect(overview.locator('[data-overview-summary="captures"]')).toHaveCount(0);
await expect(overview.locator('[data-overview-action="browser-inbox"]')).toHaveCount(0);
});
test('Overview prioritizes resume work and filters meaningful recent changes', async ({ page }) => {
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.browser-inbox', {
'captures:global': [
{
captureId: 'overview-capture-1',
capturedAt: '2026-06-30T08:00:00.000Z',
kind: 'page',
url: 'https://example.com/research',
title: 'Research Report',
domain: 'example.com',
workspaceRootPath: 'Project',
},
{
captureId: 'overview-capture-2',
capturedAt: '2026-06-30T08:15:00.000Z',
kind: 'selection',
title: 'Quote to process',
domain: 'example.com',
workspaceRootPath: 'Project',
},
{
captureId: 'overview-client-capture',
capturedAt: '2026-06-30T08:30:00.000Z',
kind: 'page',
title: 'Client capture must stay global',
workspaceRootPath: 'ClientA',
},
{
captureId: 'overview-unassigned-capture',
capturedAt: '2026-06-30T08:35:00.000Z',
kind: 'page',
title: 'Unassigned capture must stay global',
},
],
});
await window.go.api.App.WritePluginSettings('verstak.activity', {
'events:workspace:Project': [
{
activityId: 'overview-selected-file',
occurredAt: '2026-06-30T08:50:00.000Z',
type: 'file.selected',
title: 'Selected file',
summary: 'Project/draft.md',
workspaceRootPath: 'Project',
},
{
activityId: 'overview-opened-file',
occurredAt: '2026-06-30T08:40:00.000Z',
type: 'file.opened',
title: 'draft.md',
summary: 'Project/draft.md',
workspaceRootPath: 'Project',
},
{
activityId: 'overview-note',
occurredAt: '2026-06-30T08:25:00.000Z',
type: 'note.saved',
title: 'Overview',
summary: 'Project/Notes/Overview.md',
workspaceRootPath: 'Project',
},
{
activityId: 'overview-file',
occurredAt: '2026-06-30T08:20:00.000Z',
type: 'file.changed',
title: 'draft.md',
summary: 'Project/draft.md',
workspaceRootPath: 'Project',
},
{
activityId: 'overview-workspace-selected',
occurredAt: '2026-06-30T08:10:00.000Z',
type: 'case.selected',
title: 'Workspace selected',
workspaceRootPath: 'Project',
},
],
'work-session-candidates:workspace:Project': [
{
candidateId: 'work-session:Project:overview-note:overview-file',
workspaceRootPath: 'Project',
startedAt: '2026-06-30T08:15:00.000Z',
endedAt: '2026-06-30T08:25:00.000Z',
estimatedMinutes: 10,
activityCount: 2,
activityIds: ['overview-file', 'overview-note'],
activities: [
{ activityId: 'overview-file', type: 'file.changed', occurredAt: '2026-06-30T08:20:00.000Z' },
{ activityId: 'overview-note', type: 'note.saved', occurredAt: '2026-06-30T08:25:00.000Z' },
],
},
],
});
await window.go.api.App.WritePluginSettings('verstak.journal', {
'worklog:workspace:Project': [
{
entryId: 'overview-journal-1',
date: '2026-06-30',
title: 'Write project summary',
summary: 'Turn recent captures into a worklog entry',
minutes: 35,
workspaceRootPath: 'Project',
},
],
});
});
await page.locator('[data-overview-action="refresh"]').click();
const overview = page.locator('[data-overview-root]');
await expect(overview.locator('[data-overview-summary="notes"]')).toContainText('1 total');
await expect(overview.locator('[data-overview-summary="notes"]')).toContainText('1 recent change');
await expect(overview.locator('[data-overview-summary="files"]')).toContainText('1 recent change');
await expect(overview.locator('[data-overview-summary="captures"]')).toContainText('2');
await expect(overview.locator('[data-overview-summary="activity"]')).toContainText('5 recorded events');
await expect(overview.locator('[data-overview-summary="journal"]')).toContainText('1');
await expect(overview.locator('[data-overview-summary="attention"]')).toContainText('3');
const attention = overview.locator('[data-overview-section="attention"]');
await expect(attention).toContainText('Possible journal entry');
await expect(attention).toContainText('Deal: Project · 10 min · 2 activities');
await attention.locator('.overview-attention-row', { hasText: 'Possible journal entry' }).getByRole('button', { name: 'Review candidate' }).click();
await expect(page.getByRole('tab', { name: 'Journal' })).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.journal-root [data-journal-candidate]')).toContainText('Deal: Project');
await page.locator('.journal-modal-actions').getByRole('button', { name: 'Cancel' }).click();
await page.getByRole('tab', { name: 'Overview' }).click();
const resume = overview.locator('[data-overview-section="continue"]');
const candidates = resume.locator('[data-overview-continue-item]');
await expect(candidates).toHaveCount(4);
await expect(candidates.nth(0)).toContainText('Quote to process');
await expect(candidates.nth(1)).toContainText('Research Report');
await expect(candidates.nth(2)).toContainText('Edited note "Overview"');
await expect(candidates.nth(3)).toContainText('Changed file "draft.md"');
await candidates.nth(0).click();
await expect(page.getByRole('tab', { name: 'Browser' })).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.browser-inbox-root')).toBeVisible({ timeout: 10000 });
await page.getByRole('tab', { name: 'Overview' }).click();
const recent = overview.locator('[data-overview-section="recent"]');
await expect(recent).toContainText('Edited note "Overview"');
await expect(recent).toContainText('Changed file "draft.md"');
await expect(recent).toContainText('Captured page "Research Report"');
await expect(recent).toContainText('Added journal entry "Write project summary"');
await expect(recent).not.toContainText('Selected file');
await expect(recent).not.toContainText('Workspace selected');
await expect(recent).not.toContainText('file.opened');
await expect(recent).not.toContainText('Client capture must stay global');
await expect(recent).not.toContainText('Unassigned capture must stay global');
await expect(recent.locator('[data-overview-recent-item]')).toHaveCount(5);
await expect(recent.locator('[data-overview-recent-item] button')).toHaveCount(0);
await overview.locator('[data-overview-filter="notes"]').click();
await expect(recent).toContainText('Edited note "Overview"');
await expect(recent).not.toContainText('Changed file "draft.md"');
await expect(recent).not.toContainText('Research Report');
await overview.locator('[data-overview-filter="captures"]').click();
await expect(recent).toContainText('Captured page "Research Report"');
await expect(recent).toContainText('Captured selection "Quote to process"');
await expect(recent).not.toContainText('Edited note "Overview"');
await overview.locator('[data-overview-filter="journal"]').click();
await expect(recent).toContainText('Added journal entry "Write project summary"');
await expect(recent).not.toContainText('Changed file "draft.md"');
});
test('Overview localizes activity labels without exposing internal event names', async ({ page }) => {
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.activity', {
'events:workspace:Project': [{
activityId: 'overview-russian-note',
occurredAt: '2026-06-30T08:25:00.000Z',
type: 'note.saved',
title: 'Локализация',
summary: 'Project/Notes/Localization.md',
workspaceRootPath: 'Project',
}],
});
});
await page.locator('[data-settings-menu-button]').click();
await page.locator('[data-settings-language="ru"]').click();
const overview = page.locator('[data-overview-root]');
await overview.locator('[data-overview-action="refresh"]').click();
const recent = overview.locator('[data-overview-section="recent"]');
await expect(recent).toContainText('Изменена заметка «Локализация»');
await expect(recent).not.toContainText('note.saved');
await expect(recent).not.toContainText('Edited note');
});
});
+119
View File
@@ -0,0 +1,119 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
test.describe('Workspace templates', () => {
let consoleCollector;
test.beforeEach(async ({ page }) => {
consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
});
test.afterEach(async () => {
consoleCollector.assertNoErrors();
});
async function openCreateModal(page) {
await page.locator('button[title="New Deal"]').click();
const modal = page.locator('[data-workspace-create-modal]');
await expect(modal).toBeVisible();
return modal;
}
test('creation modal validates names, shows template tools, and persists the selected snapshot', async ({ page }) => {
const modal = await openCreateModal(page);
const templateSelect = modal.locator('[data-workspace-template]');
await expect(templateSelect).toHaveValue('default');
await expect(templateSelect).toHaveCSS('appearance', 'none');
await expect(modal.locator('[data-workspace-template-tools]')).toContainText('Notes');
await expect(modal.locator('[data-workspace-template-tools]')).toContainText('Browser');
await modal.getByRole('button', { name: 'Create Deal' }).click();
await expect(modal.locator('[data-workspace-create-error]')).toContainText('Name is required');
await modal.locator('[data-workspace-name]').fill('bad/name');
await modal.getByRole('button', { name: 'Create Deal' }).click();
await expect(modal.locator('[data-workspace-create-error]')).toContainText('Could not create the Deal. Please try again.');
await modal.locator('[data-workspace-name]').fill('ProjectPlan');
await modal.locator('[data-workspace-template]').selectOption('project');
await expect(modal.locator('[data-workspace-template-description]')).toContainText('Project planning');
await expect(modal.locator('[data-workspace-template-tools]')).toContainText('Todos');
await modal.getByRole('button', { name: 'Create Deal' }).click();
await expect(page.locator('.wt-label').filter({ hasText: 'ProjectPlan' })).toBeVisible();
await expect.poll(async () => page.evaluate(async () => {
const result = await window.go.api.App.GetWorkspaceMetadata('ProjectPlan');
const metadata = Array.isArray(result) ? result[0] : result;
return {
templateId: metadata.createdFromTemplate?.templateId,
tools: metadata.workspaceTools,
};
})).toEqual({
templateId: 'project',
tools: ['verstak.notes', 'verstak.files', 'verstak.todo', 'verstak.journal', 'verstak.activity', 'verstak.browser-inbox'],
});
await expect(page.getByRole('tab', { name: 'Todos' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Journal' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Secrets' })).toHaveCount(0);
});
test('Minimal keeps global tools available while limiting workspace tabs', async ({ page }) => {
const modal = await openCreateModal(page);
await modal.locator('[data-workspace-name]').fill('MinimalSpace');
await modal.locator('[data-workspace-template]').selectOption('minimal');
await modal.getByRole('button', { name: 'Create Deal' }).click();
await expect(page.getByRole('tab', { name: 'Overview' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Notes' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Files' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Todos' })).toHaveCount(0);
await expect(page.getByRole('tab', { name: 'Journal' })).toHaveCount(0);
await expect(page.getByRole('tab', { name: 'Secrets' })).toHaveCount(0);
await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Todos' })).toBeVisible();
await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser' })).toBeVisible();
});
test('template explains an unavailable plugin and warns after incomplete creation', async ({ page }) => {
await page.evaluate(() => window.__wailsMock.setPluginStatus('verstak.todo', 'disabled', false));
const modal = await openCreateModal(page);
await modal.locator('[data-workspace-template]').selectOption('project');
const todo = modal.locator('[data-workspace-template-tool="verstak.todo"]');
await expect(todo).toContainText('Todos');
await expect(todo).toContainText('Plugin is disabled');
await expect(todo).toHaveAttribute('data-template-tool-status', 'unavailable');
await modal.locator('[data-workspace-name]').fill('ProjectWithWarning');
await modal.getByRole('button', { name: 'Create Deal' }).click();
const warning = page.locator('[data-workspace-template-warning]');
await expect(warning).toContainText('ProjectWithWarning');
await expect(warning).toContainText('Todos');
await expect(warning).toContainText('Plugin is disabled');
});
test('Admin shows Secrets when available and missing workspace plugins degrade without breaking tabs', async ({ page }) => {
let modal = await openCreateModal(page);
await modal.locator('[data-workspace-name]').fill('AdminSpace');
await modal.locator('[data-workspace-template]').selectOption('admin');
await modal.getByRole('button', { name: 'Create Deal' }).click();
await expect(page.getByRole('tab', { name: 'Secrets' })).toBeVisible();
await page.evaluate(() => window.__wailsMock.setPluginStatus('verstak.todo', 'disabled', false));
modal = await openCreateModal(page);
await modal.locator('[data-workspace-name]').fill('ProjectWithoutTodo');
await modal.locator('[data-workspace-template]').selectOption('project');
await modal.getByRole('button', { name: 'Create Deal' }).click();
await expect(page.getByRole('tab', { name: 'Notes' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Files' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Todos' })).toHaveCount(0);
await page.locator('.wt-label').filter({ hasText: 'AdminSpace' }).click();
await expect(page.getByRole('tab', { name: 'Secrets' })).toBeVisible();
});
});
+10 -1
View File
@@ -16,6 +16,15 @@
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/main.js"></script> <script type="module">
// In test mode (no Wails runtime), load mock bridge first
if (!window['go'] || !window['go']['api']) {
import('/src/lib/test/wails-mock.js').then(function() {
import('/src/main.js');
});
} else {
import('/src/main.js');
}
</script>
</body> </body>
</html> </html>
+71
View File
@@ -8,7 +8,9 @@
"name": "verstak-desktop-frontend", "name": "verstak-desktop-frontend",
"version": "0.1.0", "version": "0.1.0",
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.61.0",
"@sveltejs/vite-plugin-svelte": "^3.1.0", "@sveltejs/vite-plugin-svelte": "^3.1.0",
"lucide-svelte": "^0.577.0",
"svelte": "^4.2.0", "svelte": "^4.2.0",
"vite": "^5.4.0" "vite": "^5.4.0"
} }
@@ -429,6 +431,21 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@playwright/test": {
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.0.tgz",
"integrity": "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==",
"dev": true,
"dependencies": {
"playwright": "1.61.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.62.0", "version": "4.62.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.0.tgz",
@@ -966,6 +983,16 @@
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
"dev": true "dev": true
}, },
"node_modules/lucide-svelte": {
"version": "0.577.0",
"resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.577.0.tgz",
"integrity": "sha512-0i88o57KsaHWnc80J57fY99CWzlZsSdtH5kKjLUJa7z8dum/9/AbINNLzJ7NiRFUdOgMnfAmJt8jFbW2zeC5qQ==",
"deprecated": "Package deprecated. Please use @lucide/svelte instead.",
"dev": true,
"peerDependencies": {
"svelte": "^3 || ^4 || ^5.0.0-next.42"
}
},
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -1022,6 +1049,50 @@
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true "dev": true
}, },
"node_modules/playwright": {
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz",
"integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==",
"dev": true,
"dependencies": {
"playwright-core": "1.61.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz",
"integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==",
"dev": true,
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.15", "version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
+6 -1
View File
@@ -6,10 +6,15 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview",
"test:e2e": "npx playwright test --config playwright.config.js",
"test:e2e:ui": "npx playwright test --config playwright.config.js --ui",
"test:e2e:headed": "npx playwright test --config playwright.config.js --headed"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.61.0",
"@sveltejs/vite-plugin-svelte": "^3.1.0", "@sveltejs/vite-plugin-svelte": "^3.1.0",
"lucide-svelte": "^0.577.0",
"svelte": "^4.2.0", "svelte": "^4.2.0",
"vite": "^5.4.0" "vite": "^5.4.0"
} }
+1 -1
View File
@@ -1 +1 @@
ef6849d398e9d32c4ae5afdea904eb49 c02aa6458981565332c3b0bc49516b42
+59
View File
@@ -0,0 +1,59 @@
import { defineConfig, devices } from '@playwright/test';
import { fileURLToPath } from 'url';
import { dirname, resolve } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const FRONTEND_PORT = 5174;
const FRONTEND_URL = `http://localhost:${FRONTEND_PORT}`;
const LOOPBACK_NO_PROXY = 'localhost,127.0.0.1,::1';
process.env.NO_PROXY = process.env.NO_PROXY
? `${process.env.NO_PROXY},${LOOPBACK_NO_PROXY}`
: LOOPBACK_NO_PROXY;
process.env.no_proxy = process.env.no_proxy
? `${process.env.no_proxy},${LOOPBACK_NO_PROXY}`
: LOOPBACK_NO_PROXY;
export default defineConfig({
testDir: resolve(__dirname, 'e2e'),
testMatch: '**/*.spec.js',
timeout: 30000,
expect: { timeout: 10000 },
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: 1,
reporter: [
['list'],
['json', { outputFile: resolve(__dirname, 'e2e-results/test-results.json') }],
],
outputDir: resolve(__dirname, 'e2e-results'),
use: {
baseURL: FRONTEND_URL,
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'on-first-retry',
headless: true,
viewport: { width: 1280, height: 720 },
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
webServer: {
command: `npx vite --mode test --port ${FRONTEND_PORT}`,
url: FRONTEND_URL,
reuseExistingServer: !process.env.CI,
timeout: 60000,
env: {
NO_PROXY: process.env.NO_PROXY,
no_proxy: process.env.no_proxy,
},
stdout: 'pipe',
stderr: 'pipe',
},
});
+781 -19
View File
@@ -1,67 +1,424 @@
<script> <script>
import PluginManager from './lib/plugin-manager/PluginManager.svelte'; import PluginManager from './lib/plugin-manager/PluginManager.svelte';
import Sidebar from './lib/shell/Sidebar.svelte'; import Sidebar from './lib/shell/Sidebar.svelte';
import CommandPalette from './lib/shell/CommandPalette.svelte';
import StatusBar from './lib/shell/StatusBar.svelte';
import ViewContainer from './lib/shell/ViewContainer.svelte'; import ViewContainer from './lib/shell/ViewContainer.svelte';
import VaultSelection from './lib/shell/VaultSelection.svelte'; import VaultSelection from './lib/shell/VaultSelection.svelte';
import WorkbenchHost from './lib/shell/WorkbenchHost.svelte';
import WorkspaceHost from './lib/shell/WorkspaceHost.svelte';
import * as App from '../wailsjs/go/api/App'; import * as App from '../wailsjs/go/api/App';
import { debug } from './lib/log/debug.js';
import { onDestroy, onMount, tick } from 'svelte';
import { i18n } from './lib/i18n/index.js';
let currentView = 'plugin-manager'; let currentView = 'workspace';
let vaultStatus = { status: 'unknown', path: '', vaultId: '' }; let vaultStatus = { status: 'unknown', path: '', vaultId: '' };
let needsVaultSelection = false; let needsVaultSelection = false;
let loading = true; let loading = true;
let locale = i18n.getLocale();
const unsubscribeLocale = i18n.subscribe((nextLocale) => { locale = nextLocale; });
$: tr = ((activeLocale) => (key, params, fallback) => {
void activeLocale;
return i18n.t(key, params, fallback);
})(locale);
let activeView = null;
let activeViewPluginId = '';
let activeSettingsPluginId = '';
let activeSettingsPanelId = '';
let openedResource = null;
let workspaceNodes = [];
let selectedWorkspaceName = '';
let activeWorkspaceToolKey = '';
let navigationStack = [];
let navigationIndex = -1;
let applyingNavigation = false;
let lastMouseHistoryDirection = '';
let lastMouseHistoryAt = 0;
function flog(msg) {
App.WriteFrontendLog('App', msg);
}
function resultOrError(response, fallbackValue) {
return typeof response === 'string' ? [fallbackValue, response] : [response, ''];
}
function workspaceName(workspace) {
return String(workspace?.name || workspace?.rootPath || workspace?.id || '');
}
function workspaceAsNode(workspace, order) {
const name = workspaceName(workspace);
return {
id: name,
workspaceId: workspace?.id || workspace?.workspaceId || '',
type: workspace?.type || 'space',
title: workspace?.title || name,
name,
rootPath: workspace?.rootPath || name,
status: workspace?.status || 'active',
order,
};
}
function emitWorkspaceActive(name) {
window.dispatchEvent(new CustomEvent('verstak:workspace-active-changed', {
detail: { workspaceName: name || '' }
}));
}
function clearWorkspaceSelection() {
selectedWorkspaceName = '';
emitWorkspaceActive('');
}
async function openDefaultWorkspaceRoute() {
try {
const [workspaces, err] = resultOrError(await App.ListWorkspaces(), []);
if (err || !workspaces || workspaces.length === 0) {
workspaceNodes = [];
selectedWorkspaceName = '';
currentView = 'workspace-empty';
emitWorkspaceActive('');
return;
}
workspaceNodes = workspaces.map(workspaceAsNode);
let currentWorkspace = null;
try {
currentWorkspace = await App.GetCurrentWorkspace();
} catch {
currentWorkspace = null;
}
const currentName = workspaceName(currentWorkspace);
const selected = workspaces.find((workspace) => workspaceName(workspace) === currentName) || workspaces[0];
selectedWorkspaceName = workspaceName(selected);
if (selectedWorkspaceName) {
try { await App.SetCurrentWorkspace(selectedWorkspaceName); } catch {}
currentView = 'workspace';
} else {
currentView = 'workspace-empty';
}
emitWorkspaceActive(selectedWorkspaceName);
} catch (e) {
debug.log('[App] openDefaultWorkspaceRoute ERROR', String(e));
workspaceNodes = [];
selectedWorkspaceName = '';
currentView = 'workspace-empty';
emitWorkspaceActive('');
}
}
function currentSnapshot() {
return {
currentView,
activeView,
activeViewPluginId,
activeSettingsPluginId,
activeSettingsPanelId,
openedResource,
selectedWorkspaceName,
activeWorkspaceToolKey,
};
}
function sameSnapshot(a, b) {
return JSON.stringify(a) === JSON.stringify(b);
}
function pushNavigation(snapshot = currentSnapshot()) {
if (applyingNavigation) return;
if (navigationIndex >= 0 && sameSnapshot(navigationStack[navigationIndex], snapshot)) return;
if (navigationIndex < navigationStack.length - 1) {
navigationStack = navigationStack.slice(0, navigationIndex + 1);
}
navigationStack = [...navigationStack, snapshot];
navigationIndex = navigationStack.length - 1;
}
function applySnapshot(snapshot) {
applyingNavigation = true;
currentView = snapshot.currentView;
activeView = snapshot.activeView;
activeViewPluginId = snapshot.activeViewPluginId;
activeSettingsPluginId = snapshot.activeSettingsPluginId;
activeSettingsPanelId = snapshot.activeSettingsPanelId;
openedResource = snapshot.openedResource;
selectedWorkspaceName = snapshot.selectedWorkspaceName;
activeWorkspaceToolKey = snapshot.activeWorkspaceToolKey || '';
emitWorkspaceActive(currentView === 'workspace' ? selectedWorkspaceName : '');
applyingNavigation = false;
}
function navigateBack() {
if (navigationIndex <= 0) return false;
navigationIndex -= 1;
applySnapshot(navigationStack[navigationIndex]);
return true;
}
function navigateForward() {
if (navigationIndex >= navigationStack.length - 1) return false;
navigationIndex += 1;
applySnapshot(navigationStack[navigationIndex]);
return true;
}
function mouseHistoryDirection(event) {
if (currentView === 'workspace') return '';
if (event.button === 3 || event.button === 8 || event.buttons === 8 || event.buttons === 128 || event.which === 8) return 'back';
if (event.button === 4 || event.button === 9 || event.buttons === 16 || event.buttons === 256 || event.which === 9) return 'forward';
return '';
}
function keyHistoryDirection(event) {
if (currentView === 'workspace' || currentView === 'workbench') return '';
const key = event.key || '';
if (event.altKey && key === 'ArrowLeft') return 'back';
if (event.altKey && key === 'ArrowRight') return 'forward';
if (key === 'BrowserBack' || key === 'XF86Back') return 'back';
if (key === 'BrowserForward' || key === 'XF86Forward') return 'forward';
if (event.keyCode === 166) return 'back';
if (event.keyCode === 167) return 'forward';
return '';
}
function handleHistoryRequest(direction, event) {
if (!direction || event?.defaultPrevented) return;
if (event?.type === 'mousedown' || event?.type === 'mouseup' || event?.type === 'auxclick' || event?.type === 'pointerdown') {
const now = Date.now();
if (direction === lastMouseHistoryDirection && now - lastMouseHistoryAt < 120) return;
lastMouseHistoryDirection = direction;
lastMouseHistoryAt = now;
debug.log('[App] mouse history event', {
type: event.type,
direction,
button: event.button,
buttons: event.buttons,
which: event.which,
pointerType: event.pointerType || '',
currentView,
});
}
const moved = direction === 'back' ? navigateBack() : navigateForward();
if (moved && event) {
event.preventDefault();
event.stopPropagation();
}
}
async function checkVault() { async function checkVault() {
debug.log('[App] checkVault: START');
flog('checkVault: START');
loading = true; loading = true;
try { try {
debug.log('[App] checkVault: calling GetAppSettings...');
const settings = await App.GetAppSettings(); const settings = await App.GetAppSettings();
debug.log('[App] checkVault: GetAppSettings returned', settings);
flog('checkVault: GetAppSettings returned');
if (settings?.debug) debug.enable({ persist: false });
debug.log('[App] checkVault: calling GetVaultStatus...');
vaultStatus = await App.GetVaultStatus() || { status: 'unknown', path: '', vaultId: '' }; vaultStatus = await App.GetVaultStatus() || { status: 'unknown', path: '', vaultId: '' };
debug.log('[App] checkVault: GetVaultStatus returned', vaultStatus);
flog('checkVault: vaultStatus=' + vaultStatus.status);
if (!settings.currentVaultPath || vaultStatus.status !== 'open') { if (!settings.currentVaultPath || vaultStatus.status !== 'open') {
debug.log('[App] checkVault: vault not open, needsVaultSelection=true');
flog('checkVault: needsVaultSelection=true');
needsVaultSelection = true; needsVaultSelection = true;
} else { } else {
debug.log('[App] checkVault: vault open, needsVaultSelection=false');
flog('checkVault: needsVaultSelection=false');
needsVaultSelection = false; needsVaultSelection = false;
await openDefaultWorkspaceRoute();
} }
} catch (e) { } catch (e) {
debug.log('[App] checkVault: ERROR', String(e));
flog('checkVault: ERROR: ' + String(e));
console.error('[App] startup check failed:', e); console.error('[App] startup check failed:', e);
needsVaultSelection = true; needsVaultSelection = true;
} }
loading = false; loading = false;
await tick();
debug.log('[App] checkVault: END, loading=false');
flog('checkVault: END, loading=false');
} }
function onVaultOpened() { async function onVaultOpened() {
debug.log('[App] onVaultOpened');
needsVaultSelection = false; needsVaultSelection = false;
vaultStatus = { status: 'open', path: '', vaultId: '' }; vaultStatus = { status: 'open', path: '', vaultId: '' };
await openDefaultWorkspaceRoute();
pushNavigation();
} }
function onNav(e) { function onNav(e) {
debug.log('[App] onNav:', e.detail.viewId);
currentView = e.detail.viewId; currentView = e.detail.viewId;
if (currentView !== 'workspace') clearWorkspaceSelection();
pushNavigation();
} }
// Listen for vault-opened event from VaultSelection function onOpenView(e) {
debug.log('[App] onOpenView:', e.detail.viewId, 'plugin:', e.detail.pluginId);
activeView = e.detail.viewId;
activeViewPluginId = e.detail.pluginId || '';
currentView = 'plugin-view';
clearWorkspaceSelection();
pushNavigation();
}
function onOpenSettings(e) {
debug.log('[App] onOpenSettings:', e.detail.pluginId, e.detail.panelId);
activeSettingsPluginId = e.detail.pluginId;
activeSettingsPanelId = e.detail.panelId || '';
currentView = 'plugin-manager';
clearWorkspaceSelection();
pushNavigation();
}
function onWorkbenchOpened(e) {
debug.log('[App] onWorkbenchOpened:', e.detail?.request?.path, e.detail?.providerId);
if (currentView === 'workspace') pushNavigation();
openedResource = e.detail;
currentView = 'workbench';
pushNavigation();
}
function onWorkspaceToolSelected(e) {
activeWorkspaceToolKey = e.detail?.toolKey || '';
if (currentView === 'workspace') pushNavigation();
}
function onWorkspaceSelected(e) {
debug.log('[App] onWorkspaceSelected:', e.detail?.workspaceName);
selectedWorkspaceName = e.detail?.workspaceName || '';
workspaceNodes = e.detail?.nodes || workspaceNodes;
if (selectedWorkspaceName) {
activeView = null;
activeViewPluginId = '';
activeSettingsPluginId = '';
activeSettingsPanelId = '';
openedResource = null;
currentView = 'workspace';
emitWorkspaceActive(selectedWorkspaceName);
pushNavigation();
}
}
function onCloseSettings() {
debug.log('[App] onCloseSettings');
activeSettingsPluginId = '';
activeSettingsPanelId = '';
}
function onNavigateBack(e) {
if (currentView === 'workbench') return;
if (currentView === 'workspace') {
const upBtn = document.querySelector('[data-files-action="up"]');
if (upBtn && !upBtn.disabled) {
upBtn.click();
e?.preventDefault?.();
return;
}
}
if (navigateBack()) e?.preventDefault?.();
}
function onNavigateForward(e) {
if (currentView === 'workbench') return;
if (currentView === 'workspace') {
const fwdBtn = document.querySelector('[data-files-action="forward"]');
if (fwdBtn && !fwdBtn.disabled) {
fwdBtn.click();
e?.preventDefault?.();
return;
}
}
if (navigateForward()) e?.preventDefault?.();
}
function onCloseWorkbench(e) {
if (currentView !== 'workbench') return;
if (!navigateBack() && selectedWorkspaceName) {
currentView = 'workspace';
pushNavigation();
}
e?.preventDefault?.();
}
function onGlobalKeydown(e) {
handleHistoryRequest(keyHistoryDirection(e), e);
}
function onGlobalMouse(e) {
handleHistoryRequest(mouseHistoryDirection(e), e);
}
// Listen for events
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.addEventListener('verstak:vault-opened', onVaultOpened); window.addEventListener('verstak:vault-opened', onVaultOpened);
window.addEventListener('verstak:nav', onNav); window.addEventListener('verstak:nav', onNav);
window.addEventListener('verstak:open-view', onOpenView);
window.addEventListener('verstak:open-settings', onOpenSettings);
window.addEventListener('verstak:close-settings', onCloseSettings);
window.addEventListener('verstak:workbench-opened', onWorkbenchOpened);
window.addEventListener('verstak:workspace-selected', onWorkspaceSelected);
window.addEventListener('verstak:workspace-tool-selected', onWorkspaceToolSelected);
window.addEventListener('verstak:navigate-back', onNavigateBack);
window.addEventListener('verstak:navigate-forward', onNavigateForward);
window.addEventListener('verstak:close-workbench', onCloseWorkbench);
window.addEventListener('keydown', onGlobalKeydown);
window.addEventListener('pointerdown', onGlobalMouse, true);
window.addEventListener('mousedown', onGlobalMouse, true);
window.addEventListener('mouseup', onGlobalMouse, true);
window.addEventListener('auxclick', onGlobalMouse, true);
} }
checkVault(); onMount(async () => {
await checkVault();
pushNavigation();
});
onDestroy(unsubscribeLocale);
</script> </script>
{#if loading} {#if loading}
<div class="app-loading"> <div class="app-loading">
<p>Loading Verstak...</p> <p>{tr('app.loading')}</p>
</div> </div>
{:else if needsVaultSelection} {:else if needsVaultSelection}
<VaultSelection /> <VaultSelection />
{:else} {:else}
<main> <main>
<Sidebar /> <Sidebar
showGlobalSearch={currentView !== 'workspace' && currentView !== 'workspace-empty'}
{activeView}
{activeViewPluginId}
/>
<CommandPalette />
<section class="content"> <section class="content-shell">
{#if currentView === 'plugin-manager'} <section class="content scroll-surface">
<PluginManager /> {#if currentView === 'plugin-manager'}
{:else} <PluginManager {activeSettingsPluginId} {activeSettingsPanelId} />
<ViewContainer /> {:else if currentView === 'workbench'}
{/if} <WorkbenchHost {openedResource} />
{:else if currentView === 'workspace' || currentView === 'workspace-empty'}
<WorkspaceHost
selectedWorkspaceName={selectedWorkspaceName}
nodes={workspaceNodes}
bind:activeToolKey={activeWorkspaceToolKey}
/>
{:else}
<ViewContainer {activeView} {activeViewPluginId} />
{/if}
</section>
<StatusBar />
</section> </section>
</main> </main>
{/if} {/if}
@@ -73,34 +430,439 @@
box-sizing: border-box; box-sizing: border-box;
} }
:global(:root) {
--vt-color-background: #101020;
--vt-color-surface: #15152c;
--vt-color-surface-muted: #111629;
--vt-color-surface-hover: #1b2440;
--vt-color-surface-selected: rgba(78, 204, 163, 0.14);
--vt-color-border: #202b46;
--vt-color-border-strong: #2c456a;
--vt-color-text-primary: #f4f7fb;
--vt-color-text-secondary: #b7c0d4;
--vt-color-text-muted: #7f8aa3;
--vt-color-accent: #4ecca3;
--vt-color-accent-muted: rgba(78, 204, 163, 0.14);
--vt-color-danger: #e94560;
--vt-color-danger-muted: rgba(233, 69, 96, 0.14);
--vt-color-warning: #ffc857;
--vt-color-warning-muted: rgba(255, 200, 87, 0.14);
--vt-color-success: #4ecca3;
--vt-space-1: 0.25rem;
--vt-space-2: 0.5rem;
--vt-space-3: 0.75rem;
--vt-space-4: 1rem;
--vt-space-6: 1.5rem;
--vt-space-8: 2rem;
--vt-radius-sm: 4px;
--vt-radius-md: 6px;
--vt-radius-lg: 8px;
--vt-font-xs: 0.72rem;
--vt-font-sm: 0.8rem;
--vt-font-md: 0.88rem;
--vt-font-lg: 1rem;
--vt-focus-ring: 0 0 0 2px rgba(78, 204, 163, 0.34);
--vt-elevation-menu: 0 14px 32px rgba(0, 0, 0, 0.42);
}
:global(html),
:global(body),
:global(#app) {
width: 100%;
height: 100%;
}
:global(body) { :global(body) {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #1a1a2e; background: var(--vt-color-background);
color: #e0e0f0; color: var(--vt-color-text-primary);
overflow: hidden; overflow: hidden;
} }
:global(button) {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
min-height: 2rem;
padding: 0.4rem 0.85rem;
border: 1px solid var(--vt-color-border-strong);
border-radius: var(--vt-radius-md);
background: var(--vt-color-surface-hover);
color: var(--vt-color-text-primary);
font: inherit;
font-size: var(--vt-font-sm);
font-weight: 600;
line-height: 1.2;
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease, opacity 0.15s ease;
}
:global(button:hover:not(:disabled)) {
background: #203050;
border-color: var(--vt-color-accent);
color: #ffffff;
}
:global(button:focus-visible) {
outline: 0;
box-shadow: var(--vt-focus-ring);
}
:global(button:disabled) {
opacity: 0.55;
cursor: not-allowed;
}
:global(.btn-primary) {
background: var(--vt-color-accent);
border-color: var(--vt-color-accent);
color: #101827;
}
:global(.btn-primary:hover:not(:disabled)) {
background: #63d9b3;
border-color: #63d9b3;
color: #101827;
}
:global(.btn-secondary) {
background: var(--vt-color-surface-hover);
border-color: var(--vt-color-border-strong);
color: var(--vt-color-text-primary);
}
:global(.btn-danger) {
background: var(--vt-color-danger);
border-color: var(--vt-color-danger);
color: #ffffff;
}
:global(.btn-danger:hover:not(:disabled)) {
background: #ff5b73;
border-color: #ff5b73;
}
:global(.btn-ghost) {
background: transparent;
border-color: transparent;
color: var(--vt-color-text-secondary);
}
:global(.btn-ghost:hover:not(:disabled)) {
background: var(--vt-color-surface-hover);
border-color: var(--vt-color-border);
color: var(--vt-color-text-primary);
}
:global(.btn-icon) {
width: 2rem;
min-width: 2rem;
padding: 0;
}
:global(.vt-page) {
min-width: 0;
min-height: 0;
height: 100%;
display: flex;
flex-direction: column;
background: var(--vt-color-background);
color: var(--vt-color-text-primary);
}
:global(.vt-page-header),
:global(.vt-toolbar) {
min-height: 2.75rem;
display: flex;
align-items: center;
gap: var(--vt-space-2);
padding: var(--vt-space-2) var(--vt-space-3);
border-bottom: 1px solid var(--vt-color-border);
background: var(--vt-color-surface-muted);
flex-shrink: 0;
}
:global(.vt-page-title) {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--vt-color-text-primary);
font-size: var(--vt-font-lg);
font-weight: 650;
}
:global(.vt-page-subtitle) {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--vt-color-text-muted);
font-size: var(--vt-font-sm);
}
:global(.vt-toolbar-group) {
display: inline-flex;
align-items: center;
gap: var(--vt-space-1);
padding: 0 var(--vt-space-2) 0 0;
border-right: 1px solid var(--vt-color-border);
}
:global(.vt-toolbar-group:last-child) {
border-right: 0;
padding-right: 0;
}
:global(.vt-button) {
min-height: 2rem;
border-radius: var(--vt-radius-md);
}
:global(.vt-icon-button) {
width: 2rem;
min-width: 2rem;
height: 2rem;
min-height: 2rem;
padding: 0;
}
:global(.vt-tabbar) {
display: flex;
align-items: center;
gap: var(--vt-space-1);
padding: var(--vt-space-1) var(--vt-space-3) 0;
border-bottom: 1px solid var(--vt-color-border);
background: #12162a;
overflow-x: auto;
flex-shrink: 0;
}
:global(.vt-tab) {
flex-shrink: 0;
min-height: 2.1rem;
padding: 0.38rem 0.78rem;
border: 1px solid transparent;
border-bottom: 0;
border-radius: var(--vt-radius-md) var(--vt-radius-md) 0 0;
background: transparent;
color: var(--vt-color-text-muted);
font-size: var(--vt-font-sm);
}
:global(.vt-tab:hover:not(:disabled)) {
background: var(--vt-color-surface-hover);
border-color: transparent;
color: var(--vt-color-text-primary);
}
:global(.vt-tab.is-active),
:global(.vt-tab.active) {
background: var(--vt-color-background);
border-color: var(--vt-color-border);
color: var(--vt-color-accent);
}
:global(.vt-list-row) {
border-bottom: 1px solid rgba(32, 43, 70, 0.72);
color: var(--vt-color-text-primary);
}
:global(.vt-list-row:hover) {
background: var(--vt-color-surface-hover);
}
:global(.vt-list-row.selected),
:global(.vt-list-row.active),
:global(.vt-list-row.is-selected) {
background: var(--vt-color-surface-selected);
box-shadow: inset 2px 0 0 var(--vt-color-accent);
}
:global(.vt-card) {
border: 1px solid var(--vt-color-border);
border-radius: var(--vt-radius-lg);
background: var(--vt-color-surface);
}
:global(.vt-split-pane) {
display: grid;
grid-template-columns: minmax(16rem, 22rem) minmax(0, 1fr);
min-height: 0;
height: 100%;
background: var(--vt-color-background);
}
:global(.vt-split-list) {
min-height: 0;
overflow: auto;
border-right: 1px solid var(--vt-color-border);
background: var(--vt-color-surface-muted);
}
:global(.vt-split-detail) {
min-width: 0;
min-height: 0;
overflow: auto;
padding: var(--vt-space-4);
}
:global(.vt-empty-state) {
min-height: 9rem;
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--vt-space-2);
padding: var(--vt-space-8);
color: var(--vt-color-text-muted);
font-size: var(--vt-font-sm);
line-height: 1.45;
text-align: center;
}
:global(.vt-empty-title) {
color: var(--vt-color-text-secondary);
font-size: var(--vt-font-md);
font-weight: 650;
}
:global(.vt-inline-alert) {
border: 1px solid var(--vt-color-border-strong);
border-radius: var(--vt-radius-md);
background: var(--vt-color-surface-muted);
color: var(--vt-color-text-secondary);
padding: var(--vt-space-3);
font-size: var(--vt-font-sm);
line-height: 1.45;
}
:global(.vt-inline-alert.error) {
border-color: rgba(233, 69, 96, 0.55);
background: var(--vt-color-danger-muted);
color: #ffc6ce;
}
:global(.vt-badge) {
display: inline-flex;
align-items: center;
min-height: 1.25rem;
padding: 0.1rem 0.4rem;
border: 1px solid var(--vt-color-border);
border-radius: var(--vt-radius-sm);
background: var(--vt-color-surface-muted);
color: var(--vt-color-text-muted);
font-size: var(--vt-font-xs);
font-weight: 650;
line-height: 1;
}
:global(.vt-badge.accent) {
border-color: rgba(78, 204, 163, 0.4);
background: var(--vt-color-accent-muted);
color: var(--vt-color-accent);
}
:global(.vt-menu) {
border: 1px solid var(--vt-color-border-strong);
border-radius: var(--vt-radius-md);
background: var(--vt-color-surface);
color: var(--vt-color-text-primary);
box-shadow: var(--vt-elevation-menu);
}
:global(.vt-menu-item) {
display: flex;
align-items: center;
gap: var(--vt-space-2);
padding: 0.42rem 0.7rem;
color: var(--vt-color-text-secondary);
cursor: pointer;
}
:global(.vt-menu-item:hover) {
background: var(--vt-color-surface-hover);
color: var(--vt-color-text-primary);
}
:global(.vt-menu-item.danger) {
color: #ff9aaa;
}
:global(.scroll-surface) {
min-width: 0;
min-height: 0;
overflow: auto;
scrollbar-gutter: stable;
}
:global(*) {
scrollbar-width: thin;
scrollbar-color: var(--vt-color-border-strong) var(--vt-color-background);
}
:global(*::-webkit-scrollbar) {
width: 8px;
height: 8px;
}
:global(*::-webkit-scrollbar-track) {
background: var(--vt-color-background);
}
:global(*::-webkit-scrollbar-thumb) {
background: var(--vt-color-border-strong);
border-radius: var(--vt-radius-sm);
}
:global(*::-webkit-scrollbar-thumb:hover) {
background: #1a4a7a;
}
.app-loading { .app-loading {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 100vh; height: 100vh;
background: #1a1a2e; background: var(--vt-color-background);
color: #a0a0b8; color: var(--vt-color-text-muted);
font-size: 1rem; font-size: 1rem;
} }
main { main {
display: flex; display: flex;
height: 100vh; height: 100vh;
background: #1a1a2e; width: 100%;
background: var(--vt-color-background);
overflow: hidden;
}
.content-shell {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
} }
.content { .content {
flex: 1; flex: 1;
min-width: 0;
min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; padding: 0;
padding: 1.5rem; }
@media (max-width: 720px) {
main {
flex-direction: column;
}
.content {
padding: 0.75rem;
}
} }
</style> </style>
+307
View File
@@ -0,0 +1,307 @@
export default {
'app.loading': 'Loading Verstak...',
'settings.title': 'Settings',
'settings.language': 'Language',
'settings.language.system': 'System / Системный',
'settings.language.en': 'English',
'settings.language.ru': 'Русский',
'settings.pluginManager': 'Plugin Manager',
'statusBar.label': 'Status bar',
'vault.label': 'Vault: {status}',
'vault.status.open': 'open',
'vault.status.closed': 'closed',
'vault.status.unknown': 'unknown',
'sidebar.tools': 'Tools',
'sidebar.error.contributions': 'Failed to load plugin contributions',
'sidebar.error.load': 'Failed to load sidebar',
'sidebar.error.ui': 'Plugin UI error',
'workspace.overview': 'Overview',
'workspace.search': 'Search in this Deal',
'workspace.tools': 'Deal tools',
'workspace.tool': 'Deal tool',
'workspace.emptyTools': 'No Deal tools available',
'workspace.emptyToolsHint': 'Enable plugins with Deal tools or open Plugin Manager from settings.',
'workspace.select': 'Select a Deal',
'workspace.selectHint': 'Use the + button in Deals to add your first project.',
'common.details': 'Details',
'common.plugin': 'Plugin',
'common.component': 'Component',
'pluginView.error': 'Plugin view error',
'pluginView.failed': 'Plugin UI failed',
'pluginView.noVisual': 'This plugin does not provide a visual view yet.',
'pluginView.viewId': 'View ID',
'pluginView.bundleUnavailable': 'Frontend bundle unavailable',
'pluginView.unavailable': 'View is unavailable.',
'pluginView.select': 'Select a plugin view from the sidebar',
'pluginView.selectHint': 'Plugin views will appear here',
'common.retry': '⟳ Retry',
'common.source': 'Source',
'common.status': 'Status',
'common.capability': 'Capability',
'common.provider': 'Provider',
'status.loaded': 'loaded',
'status.degraded': 'degraded',
'status.failed': 'failed',
'status.disabled': 'disabled',
'status.missing': 'missing',
'pluginManager.reloading': '⟳ Reloading...',
'pluginManager.reload': '⟳ Reload',
'pluginManager.scanning': 'Scanning plugin directories...',
'pluginManager.summary.plugins': '{count} plugin(s) discovered',
'pluginManager.summary.capabilities': '{count} capabilities registered',
'pluginManager.summary.permissions': '{count} permissions known',
'pluginManager.scanSummary': 'Plugin scan summary',
'pluginManager.health': 'Plugin Health',
'pluginManager.permissionRisk': 'Permission Risk',
'pluginManager.elevatedPermissions': '{count} plugins request elevated permissions',
'pluginManager.filters': 'Filters',
'pluginManager.filterResults': 'Showing {visible} of {total} plugins',
'pluginManager.filterReset': 'Reset filters',
'pluginManager.filterState': 'State',
'pluginManager.filterAll': 'All',
'pluginManager.filterEnabled': 'Enabled',
'pluginManager.filterDisabled': 'Disabled',
'pluginManager.filterFailed': 'Problem',
'pluginManager.filterDegraded': 'Degraded',
'pluginManager.filterPermissions': 'Permissions',
'pluginManager.filterCapabilities': 'Capabilities',
'pluginManager.filterSettings': 'Settings',
'pluginManager.filterWithSettings': 'With settings',
'pluginManager.filterWithoutSettings': 'Without settings',
'pluginManager.filterSource': 'Source',
'pluginManager.filterOfficial': 'Official',
'pluginManager.filterThirdParty': 'Third-party or local',
'pluginManager.filterEmpty': 'No plugins match the selected filters.',
'pluginManager.none': 'No plugins found',
'pluginManager.scannedDirs': 'Plugin directories scanned:',
'pluginManager.userPlugins': 'user plugins',
'pluginManager.bundledPlugins': 'bundled plugins (app directory)',
'pluginManager.installHint': 'Place a plugin folder with plugin.json in one of these directories and click Reload.',
'pluginManager.missingTitle': 'Missing Installed Plugins',
'pluginManager.missingHint': 'These plugins are required by this vault but their packages are not installed locally.',
'pluginManager.missingPackage': "This plugin is listed in the vault's desired plugins but the package is not installed.",
'pluginManager.capabilityRegistry': 'Capability Registry ({count})',
'pluginManager.settingsError': 'Settings Error',
'pluginManager.pluginSettings': 'Plugin Settings',
'pluginManager.settingsBundleUnavailable': 'Settings panel frontend bundle not available',
'common.none': 'none',
'common.unknown': 'unknown',
'pluginCard.count.views': '{count} view(s)',
'pluginCard.count.commands': '{count} command(s)',
'pluginCard.count.searchProviders': '{count} search provider(s)',
'pluginCard.count.sidebar': '{count} sidebar item(s)',
'pluginCard.count.statusbar': '{count} status bar item(s)',
'pluginCard.count.openProviders': '{count} openProviders',
'pluginCard.count.workspace': '{count} Deal tool(s)',
'pluginCard.degraded': 'Plugin is usable, but some optional capabilities are unavailable.',
'pluginCard.name': 'Name',
'pluginCard.contributions': 'Contributions',
'pluginCard.technicalDetails': 'Technical details',
'pluginCard.apiVersion': 'API Version',
'pluginCard.root': 'Root',
'pluginCard.provides': 'Provides',
'pluginCard.requires': 'Requires',
'pluginCard.missingRequired': 'Missing required capabilities',
'pluginCard.optionalRequires': 'Optional Requires',
'pluginCard.optionalUnavailable': 'Optional capabilities not available — plugin running in degraded mode',
'pluginCard.permissions': 'Permissions',
'pluginCard.enabling': '⟳ Enabling...',
'pluginCard.enable': '▶ Enable',
'pluginCard.disabling': '⟳ Disabling...',
'pluginCard.disable': '⏸ Disable',
'pluginCard.openVault': 'Open a vault to manage plugin state',
'pluginCard.missingUiPermission': 'Plugin has UI contributions but lacks ui.register permission',
'common.loading': 'Loading...',
'common.browse': 'Browse...',
'vaultSelection.chooseNew': 'Choose or enter a folder for the new vault.',
'vaultSelection.createError': 'Could not create the vault. Please try again.',
'vaultSelection.openError': 'Could not open the vault. Please try again.',
'vaultSelection.chooseExisting': 'Choose or enter an existing vault.',
'vaultSelection.subtitle': 'Choose a vault to start working',
'vaultSelection.createTitle': 'Create a new vault',
'vaultSelection.createHint': 'Create a local vault folder for Deals and projects.',
'vaultSelection.pathPlaceholder': 'Choose or enter a path...',
'vaultSelection.creating': 'Creating...',
'vaultSelection.create': 'Create vault',
'vaultSelection.openTitle': 'Open an existing vault',
'vaultSelection.openHint': 'Use a vault that is already on this computer.',
'vaultSelection.opening': 'Opening...',
'vaultSelection.open': 'Open existing',
'vaultSelection.recent': 'Recent vaults',
'common.cancel': 'Cancel',
'common.close': 'Close',
'workspaceTree.title': 'Deals',
'workspaceTree.new': 'New Deal',
'workspaceTree.nameRequired': 'Name is required',
'workspaceTree.chooseTemplate': 'Choose a Deal template',
'workspaceTree.saveRename': 'Save rename',
'workspaceTree.rename': 'Rename Deal',
'workspaceTree.trash': 'Move Deal to trash',
'workspaceTree.create': 'Create Deal',
'workspaceTree.namePlaceholder': 'Deal name',
'workspaceTree.template': 'Template',
'workspaceTree.creating': 'Creating...',
'workspaceTree.templateAvailable': 'Available',
'workspaceTree.templateLimited': 'Available with limitations',
'workspaceTree.templateMissingPlugin': 'Plugin is not installed',
'workspaceTree.templatePluginDisabled': 'Plugin is disabled',
'workspaceTree.templateCapabilityUnavailable': 'A required capability is unavailable',
'workspaceTree.templateIncompatible': 'Plugin is incompatible with this version of Verstak',
'workspaceTree.templateLoadFailed': 'Plugin failed to load',
'workspaceTree.templateNotReady': 'Plugin is not ready',
'workspaceTree.templateToolTabs': 'Creates: {tabs}',
'workspaceTree.templateToolNoTabs': 'No Deal tab provided',
'workspaceTree.templateWillBeIncomplete': 'This Deal will be created without the unavailable components.',
'workspaceTree.templateIncompleteCreated': '«{name}» was created with unavailable components.',
'workspaceTree.templatesError': 'Could not load Deal templates. Please try again.',
'workspaceTree.loadError': 'Could not load Deals. Please try again.',
'workspaceTree.selectError': 'Could not select this Deal. Please try again.',
'workspaceTree.createError': 'Could not create the Deal. Please try again.',
'workspaceTree.renameError': 'Could not rename the Deal. Please try again.',
'workspaceTree.trashError': 'Could not move the Deal to trash. Please try again.',
'pluginManager.loadError': 'Could not load plugins. Please try again.',
'pluginManager.reloadError': 'Could not reload plugins. Please try again.',
'pluginManager.enableError': 'Could not enable the plugin. Please try again.',
'pluginManager.disableError': 'Could not disable the plugin. Please try again.',
'pluginManager.settingsLoadError': 'Could not load plugin settings. Please try again.',
'pluginManager.settingsUnavailable': 'Plugin settings are unavailable.',
'command.openOverview': 'Open Overview',
'command.openFiles': 'Open Files',
'command.openActivity': 'Open Activity',
'command.openBrowserInbox': 'Open Browser',
'command.createMarkdown': 'Create Markdown File',
'command.createText': 'Create Text File',
'command.syncNow': 'Sync Now',
'command.openSyncSettings': 'Open Sync Settings',
'command.openPluginManager': 'Open Plugin Manager',
'command.handled': '{title} handled',
'command.result': '{title} {status}',
'command.failed': 'Could not run {title}. Please try again.',
'command.statusHandled': 'handled',
'command.palette': 'Command Palette',
'command.run': 'Run command',
'command.none': 'No commands',
'search.indexing': 'Indexing...',
'search.placeholder': 'Search',
'search.global': 'Global search',
'search.noResults': 'No results',
'search.type.workspace': 'Deal',
'search.type.tool': 'Tool',
'search.type.folder': 'Folder',
'search.type.file': 'File',
'search.type.journal': 'Journal',
'search.type.browserInbox': 'Browser',
'search.type.activity': 'Activity',
'workbench.noProvider': 'No provider',
'workbench.noViewer': 'No viewer/editor available',
'workbench.noResource': 'No resource opened',
'bundle.noFrontend': 'Plugin has no frontend bundle',
'bundle.notFound': 'Plugin not found',
'bundle.loadFailed': 'Could not load the plugin interface. Please try again.',
'bundle.emptyContent': 'empty content',
'bundle.executionError': 'Could not start the plugin interface. Please try again.',
'bundle.registrationMissing': 'Bundle loaded but no VerstakPluginRegister call detected',
'bundle.componentMissing': 'The requested plugin interface is unavailable.',
'bundle.mountError': 'Could not open the plugin interface. Please try again.',
'bundle.mountUnavailable': 'Mount container not available',
'bundle.unexpectedError': 'Could not open the plugin interface. Please try again.',
'bundle.unknownError': 'Unknown error',
'bundle.frontendEntry': 'Frontend entry',
'bundle.availableComponents': 'Available components',
'bundle.loading': 'Loading plugin bundle...',
'overview.filter.all': 'All',
'overview.filter.notes': 'Notes',
'overview.filter.files': 'Files',
'overview.filter.captures': 'Captures',
'overview.filter.journal': 'Journal',
'overview.notes': 'Notes',
'overview.files': 'Files',
'overview.captures': 'Captures',
'overview.activity': 'Activity',
'overview.journal': 'Journal',
'overview.attention': 'Needs attention',
'overview.openNotes': 'Open Notes',
'overview.openFiles': 'Open Files',
'overview.reviewInbox': 'Review Inbox',
'overview.viewActivity': 'View Activity',
'overview.openJournal': 'Open Journal',
'overview.openTodos': 'Open Todos',
'overview.reviewCandidate': 'Review candidate',
'overview.reviewPending': 'Review pending items',
'overview.count.totalRecent.one': '{total} total · {recent} recent change',
'overview.count.totalRecent.many': '{total} total · {recent} recent changes',
'overview.count.recentChanges.one': '{count} recent change',
'overview.count.recentChanges.many': '{count} recent changes',
'overview.count.captures.one': '{count} capture to review',
'overview.count.captures.many': '{count} captures to review',
'overview.count.events.one': '{count} recorded event',
'overview.count.events.many': '{count} recorded events',
'overview.count.journal.one': '{count} journal entry',
'overview.count.journal.many': '{count} journal entries',
'overview.count.pending.one': '{count} pending item',
'overview.count.pending.many': '{count} pending items',
'overview.loadingContext': 'Loading Deal context...',
'overview.lastActive': 'Last active {time}',
'overview.noRecentActivity': 'No recent Deal activity',
'overview.refresh': 'Refresh',
'overview.summary': 'Deal overview summary',
'overview.continue': 'Continue working',
'overview.continueHint': 'Pick up the next useful item in this Deal.',
'overview.loadingSignals': 'Loading Deal signals...',
'overview.noResume': 'No clear resume point yet',
'overview.noResumeHint': 'Recent notes, files, captures, and journal entries will appear here.',
'overview.recentChanges': 'Recent changes',
'overview.recentChangesHint': 'Latest meaningful activity in this Deal.',
'overview.event.workspaceOpened': 'Opened Deal',
'overview.event.activity': 'Deal activity',
'overview.event.noteEdited': 'Edited note "{name}"',
'overview.event.noteOpened': 'Opened note "{name}"',
'overview.event.noteCreated': 'Created note "{name}"',
'overview.event.fileOpened': 'Opened file "{name}"',
'overview.event.fileChanged': 'Changed file "{name}"',
'overview.event.fileCreated': 'Created file "{name}"',
'overview.event.fileRemoved': 'Removed file "{name}"',
'overview.event.capturePage': 'Captured page "{name}"',
'overview.event.captureSelection': 'Captured selection "{name}"',
'overview.event.captureLink': 'Captured link "{name}"',
'overview.event.captureFile': 'Captured file "{name}"',
'overview.event.captureConverted': 'Converted capture "{name}"',
'overview.event.capture': 'Captured {kind} "{title}"',
'overview.event.journalAdded': 'Added journal entry "{name}"',
'overview.event.workSessionDetected': 'Work session detected',
'overview.event.todo': 'Todo "{title}"',
'overview.event.reviewCapture': 'Review capture "{title}"',
'overview.event.continueJournal': 'Continue journal entry "{title}"',
'overview.event.possibleJournalEntry': 'Possible journal entry',
'overview.captureKind.page': 'page',
'overview.captureKind.selection': 'selection',
'overview.captureKind.link': 'link',
'overview.captureKind.file': 'file',
'overview.captureKind.item': 'item',
'overview.todo.reminderDue': 'Reminder due',
'overview.todo.overdue': 'Overdue',
'overview.todo.dueSoon': 'Due soon',
'overview.todo.due': 'Due {date}',
'overview.candidateMeta': 'Deal: {deal} · {minutes} min · {activities} activities',
'overview.minutes': '{count} min',
'overview.browserCapture': 'Browser capture',
'overview.untitledTodo': 'Untitled todo',
'overview.untitledCapture': 'Untitled capture',
'overview.untitledJournal': 'Journal entry',
'overview.unknownDeal': 'Unknown Deal',
'overview.item': 'item',
'overview.overviewNote': 'Deal overview note',
'overview.recentFilter': 'Recent changes filter',
'overview.loadingRecent': 'Loading recent changes...',
'overview.noChanges': 'No meaningful changes for this filter yet.',
'overview.attentionHint': 'Pending captures, urgent todos, and possible journal entries.',
'overview.loadingPending': 'Loading pending items...',
'overview.noPending': 'No pending captures, urgent todos, or possible journal entries.',
'overview.keyResources': 'Key resources',
'overview.time.none': 'No timestamp',
'overview.time.now': 'Just now',
'overview.time.minutes': '{count} min ago',
'overview.time.hours': '{count}h ago',
'overview.time.yesterday': 'Yesterday',
'overview.time.days': '{count} days ago',
};
+307
View File
@@ -0,0 +1,307 @@
export default {
'app.loading': 'Загрузка Верстака...',
'settings.title': 'Настройки',
'settings.language': 'Язык',
'settings.language.system': 'System / Системный',
'settings.language.en': 'English',
'settings.language.ru': 'Русский',
'settings.pluginManager': 'Менеджер плагинов',
'statusBar.label': 'Строка состояния',
'vault.label': 'Хранилище: {status}',
'vault.status.open': 'открыто',
'vault.status.closed': 'закрыто',
'vault.status.unknown': 'неизвестно',
'sidebar.tools': 'Инструменты',
'sidebar.error.contributions': 'Не удалось загрузить элементы плагинов',
'sidebar.error.load': 'Не удалось загрузить боковую панель',
'sidebar.error.ui': 'Ошибка интерфейса плагина',
'workspace.overview': 'Обзор',
'workspace.search': 'Поиск в Деле',
'workspace.tools': 'Инструменты Дела',
'workspace.tool': 'Инструмент Дела',
'workspace.emptyTools': 'Нет доступных инструментов',
'workspace.emptyToolsHint': 'Включите плагины с инструментами Дела или откройте Менеджер плагинов в настройках.',
'workspace.select': 'Выберите Дело',
'workspace.selectHint': 'Используйте кнопку + в разделе Дел, чтобы добавить первый проект.',
'common.details': 'Подробнее',
'common.plugin': 'Плагин',
'common.component': 'Компонент',
'pluginView.error': 'Ошибка представления плагина',
'pluginView.failed': 'Ошибка интерфейса плагина',
'pluginView.noVisual': 'Этот плагин пока не предоставляет визуальное представление.',
'pluginView.viewId': 'ID представления',
'pluginView.bundleUnavailable': 'Frontend bundle недоступен',
'pluginView.unavailable': 'Представление недоступно.',
'pluginView.select': 'Выберите представление плагина на боковой панели',
'pluginView.selectHint': 'Представления плагинов появятся здесь',
'common.retry': '⟳ Повторить',
'common.source': 'Источник',
'common.status': 'Состояние',
'common.capability': 'Возможность',
'common.provider': 'Поставщик',
'status.loaded': 'загружено',
'status.degraded': 'ограничено',
'status.failed': 'ошибка',
'status.disabled': 'выключено',
'status.missing': 'отсутствует',
'pluginManager.reloading': '⟳ Обновление...',
'pluginManager.reload': '⟳ Обновить',
'pluginManager.scanning': 'Сканирование каталогов плагинов...',
'pluginManager.summary.plugins': 'Обнаружено плагинов: {count}',
'pluginManager.summary.capabilities': 'Зарегистрировано возможностей: {count}',
'pluginManager.summary.permissions': 'Известно разрешений: {count}',
'pluginManager.scanSummary': 'Сводка проверки плагинов',
'pluginManager.health': 'Состояние плагинов',
'pluginManager.permissionRisk': 'Риск разрешений',
'pluginManager.elevatedPermissions': 'Плагинов с повышенными разрешениями: {count}',
'pluginManager.filters': 'Фильтры',
'pluginManager.filterResults': 'Показано плагинов: {visible} из {total}',
'pluginManager.filterReset': 'Сбросить фильтры',
'pluginManager.filterState': 'Состояние',
'pluginManager.filterAll': 'Все',
'pluginManager.filterEnabled': 'Включённые',
'pluginManager.filterDisabled': 'Выключенные',
'pluginManager.filterFailed': 'С проблемами',
'pluginManager.filterDegraded': 'Ограниченные',
'pluginManager.filterPermissions': 'Разрешения',
'pluginManager.filterCapabilities': 'Возможности',
'pluginManager.filterSettings': 'Настройки',
'pluginManager.filterWithSettings': 'С настройками',
'pluginManager.filterWithoutSettings': 'Без настроек',
'pluginManager.filterSource': 'Источник',
'pluginManager.filterOfficial': 'Официальные',
'pluginManager.filterThirdParty': 'Сторонние или локальные',
'pluginManager.filterEmpty': 'Нет плагинов, соответствующих выбранным фильтрам.',
'pluginManager.none': 'Плагины не найдены',
'pluginManager.scannedDirs': 'Проверенные каталоги плагинов:',
'pluginManager.userPlugins': 'пользовательские плагины',
'pluginManager.bundledPlugins': 'встроенные плагины (каталог приложения)',
'pluginManager.installHint': 'Поместите папку плагина с plugin.json в один из этих каталогов и нажмите «Обновить».',
'pluginManager.missingTitle': 'Отсутствующие установленные плагины',
'pluginManager.missingHint': 'Эти плагины требуются хранилищу, но их пакеты не установлены на этом устройстве.',
'pluginManager.missingPackage': 'Плагин указан среди желаемых плагинов хранилища, но его пакет не установлен.',
'pluginManager.capabilityRegistry': 'Реестр возможностей ({count})',
'pluginManager.settingsError': 'Ошибка настроек',
'pluginManager.pluginSettings': 'Настройки плагина',
'pluginManager.settingsBundleUnavailable': 'Frontend bundle панели настроек недоступен',
'common.none': 'нет',
'common.unknown': 'неизвестно',
'pluginCard.count.views': 'представлений: {count}',
'pluginCard.count.commands': 'команд: {count}',
'pluginCard.count.searchProviders': 'поставщиков поиска: {count}',
'pluginCard.count.sidebar': 'элементов боковой панели: {count}',
'pluginCard.count.statusbar': 'элементов строки состояния: {count}',
'pluginCard.count.openProviders': 'обработчиков открытия: {count}',
'pluginCard.count.workspace': 'инструментов Дела: {count}',
'pluginCard.degraded': 'Плагин работает, но некоторые необязательные возможности недоступны.',
'pluginCard.name': 'Название',
'pluginCard.contributions': 'Элементы интерфейса',
'pluginCard.technicalDetails': 'Технические сведения',
'pluginCard.apiVersion': 'Версия API',
'pluginCard.root': 'Корневой каталог',
'pluginCard.provides': 'Предоставляет',
'pluginCard.requires': 'Требует',
'pluginCard.missingRequired': 'Отсутствуют обязательные возможности',
'pluginCard.optionalRequires': 'Необязательные зависимости',
'pluginCard.optionalUnavailable': 'Необязательные возможности недоступны — плагин работает в ограниченном режиме',
'pluginCard.permissions': 'Разрешения',
'pluginCard.enabling': '⟳ Включение...',
'pluginCard.enable': '▶ Включить',
'pluginCard.disabling': '⟳ Выключение...',
'pluginCard.disable': '⏸ Выключить',
'pluginCard.openVault': 'Откройте хранилище для управления состоянием плагина',
'pluginCard.missingUiPermission': 'Плагин добавляет элементы интерфейса, но не имеет разрешения ui.register',
'common.loading': 'Загрузка...',
'common.browse': 'Обзор...',
'vaultSelection.chooseNew': 'Выберите или введите папку для нового хранилища.',
'vaultSelection.createError': 'Не удалось создать хранилище. Повторите попытку.',
'vaultSelection.openError': 'Не удалось открыть хранилище. Повторите попытку.',
'vaultSelection.chooseExisting': 'Выберите или введите существующее хранилище.',
'vaultSelection.subtitle': 'Выберите хранилище, чтобы начать работу',
'vaultSelection.createTitle': 'Создать новое хранилище',
'vaultSelection.createHint': 'Создайте локальную папку хранилища для Дел и проектов.',
'vaultSelection.pathPlaceholder': 'Выберите или введите путь...',
'vaultSelection.creating': 'Создание...',
'vaultSelection.create': 'Создать хранилище',
'vaultSelection.openTitle': 'Открыть существующее хранилище',
'vaultSelection.openHint': 'Используйте хранилище, которое уже находится на этом компьютере.',
'vaultSelection.opening': 'Открытие...',
'vaultSelection.open': 'Открыть',
'vaultSelection.recent': 'Недавние хранилища',
'common.cancel': 'Отмена',
'common.close': 'Закрыть',
'workspaceTree.title': 'Дела',
'workspaceTree.new': 'Новое Дело',
'workspaceTree.nameRequired': 'Введите название',
'workspaceTree.chooseTemplate': 'Выберите шаблон Дела',
'workspaceTree.saveRename': 'Сохранить новое название',
'workspaceTree.rename': 'Переименовать Дело',
'workspaceTree.trash': 'Переместить Дело в корзину',
'workspaceTree.create': 'Создать Дело',
'workspaceTree.namePlaceholder': 'Название Дела',
'workspaceTree.template': 'Шаблон',
'workspaceTree.creating': 'Создание...',
'workspaceTree.templateAvailable': 'Доступен',
'workspaceTree.templateLimited': 'Доступен с ограничениями',
'workspaceTree.templateMissingPlugin': 'Плагин не установлен',
'workspaceTree.templatePluginDisabled': 'Плагин выключен',
'workspaceTree.templateCapabilityUnavailable': 'Не хватает обязательной возможности',
'workspaceTree.templateIncompatible': 'Плагин несовместим с этой версией Верстака',
'workspaceTree.templateLoadFailed': 'Плагин не удалось загрузить',
'workspaceTree.templateNotReady': 'Плагин ещё не готов к работе',
'workspaceTree.templateToolTabs': 'Создаёт вкладки: {tabs}',
'workspaceTree.templateToolNoTabs': 'Не предоставляет вкладку Дела',
'workspaceTree.templateWillBeIncomplete': 'Дело будет создано без недоступных компонентов.',
'workspaceTree.templateIncompleteCreated': 'Дело «{name}» создано с недоступными компонентами.',
'workspaceTree.templatesError': 'Не удалось загрузить шаблоны Дел. Повторите попытку.',
'workspaceTree.loadError': 'Не удалось загрузить Дела. Повторите попытку.',
'workspaceTree.selectError': 'Не удалось выбрать это Дело. Повторите попытку.',
'workspaceTree.createError': 'Не удалось создать Дело. Повторите попытку.',
'workspaceTree.renameError': 'Не удалось переименовать Дело. Повторите попытку.',
'workspaceTree.trashError': 'Не удалось переместить Дело в корзину. Повторите попытку.',
'pluginManager.loadError': 'Не удалось загрузить плагины. Повторите попытку.',
'pluginManager.reloadError': 'Не удалось перезагрузить плагины. Повторите попытку.',
'pluginManager.enableError': 'Не удалось включить плагин. Повторите попытку.',
'pluginManager.disableError': 'Не удалось выключить плагин. Повторите попытку.',
'pluginManager.settingsLoadError': 'Не удалось загрузить настройки плагина. Повторите попытку.',
'pluginManager.settingsUnavailable': 'Настройки плагина недоступны.',
'command.openOverview': 'Открыть обзор',
'command.openFiles': 'Открыть файлы',
'command.openActivity': 'Открыть активность',
'command.openBrowserInbox': 'Открыть Браузер',
'command.createMarkdown': 'Создать файл Markdown',
'command.createText': 'Создать текстовый файл',
'command.syncNow': 'Синхронизировать сейчас',
'command.openSyncSettings': 'Открыть настройки синхронизации',
'command.openPluginManager': 'Открыть менеджер плагинов',
'command.handled': 'Выполнено: {title}',
'command.result': '{title}: {status}',
'command.failed': 'Не удалось выполнить команду «{title}». Повторите попытку.',
'command.statusHandled': 'выполнено',
'command.palette': 'Палитра команд',
'command.run': 'Выполнить команду',
'command.none': 'Команды не найдены',
'search.indexing': 'Индексирование...',
'search.placeholder': 'Поиск',
'search.global': 'Глобальный поиск',
'search.noResults': 'Ничего не найдено',
'search.type.workspace': 'Дело',
'search.type.tool': 'Инструмент',
'search.type.folder': 'Папка',
'search.type.file': 'Файл',
'search.type.journal': 'Журнал',
'search.type.browserInbox': 'Браузер',
'search.type.activity': 'Активность',
'workbench.noProvider': 'Нет обработчика',
'workbench.noViewer': 'Нет доступного средства просмотра или редактора',
'workbench.noResource': 'Ресурс не открыт',
'bundle.noFrontend': 'У плагина нет frontend bundle',
'bundle.notFound': 'Плагин не найден',
'bundle.loadFailed': 'Не удалось загрузить интерфейс плагина. Повторите попытку.',
'bundle.emptyContent': 'пустое содержимое',
'bundle.executionError': 'Не удалось запустить интерфейс плагина. Повторите попытку.',
'bundle.registrationMissing': 'Bundle загружен, но вызов VerstakPluginRegister не обнаружен',
'bundle.componentMissing': 'Запрошенный интерфейс плагина недоступен.',
'bundle.mountError': 'Не удалось открыть интерфейс плагина. Повторите попытку.',
'bundle.mountUnavailable': 'Контейнер для подключения недоступен',
'bundle.unexpectedError': 'Не удалось открыть интерфейс плагина. Повторите попытку.',
'bundle.unknownError': 'Неизвестная ошибка',
'bundle.frontendEntry': 'Точка входа frontend',
'bundle.availableComponents': 'Доступные компоненты',
'bundle.loading': 'Загрузка bundle плагина...',
'overview.filter.all': 'Все',
'overview.filter.notes': 'Заметки',
'overview.filter.files': 'Файлы',
'overview.filter.captures': 'Сохранённое',
'overview.filter.journal': 'Журнал',
'overview.notes': 'Заметки',
'overview.files': 'Файлы',
'overview.captures': 'Сохранённое',
'overview.activity': 'Активность',
'overview.journal': 'Журнал',
'overview.attention': 'Требует внимания',
'overview.openNotes': 'Открыть заметки',
'overview.openFiles': 'Открыть файлы',
'overview.reviewInbox': 'Разобрать входящие',
'overview.viewActivity': 'Открыть активность',
'overview.openJournal': 'Открыть журнал',
'overview.openTodos': 'Открыть задачи',
'overview.reviewCandidate': 'Просмотреть запись',
'overview.reviewPending': 'Разобрать ожидающее',
'overview.count.totalRecent.one': 'Всего: {total} · недавних изменений: {recent}',
'overview.count.totalRecent.many': 'Всего: {total} · недавних изменений: {recent}',
'overview.count.recentChanges.one': 'Недавних изменений: {count}',
'overview.count.recentChanges.many': 'Недавних изменений: {count}',
'overview.count.captures.one': 'На разбор: {count}',
'overview.count.captures.many': 'На разбор: {count}',
'overview.count.events.one': 'Событий: {count}',
'overview.count.events.many': 'Событий: {count}',
'overview.count.journal.one': 'Записей журнала: {count}',
'overview.count.journal.many': 'Записей журнала: {count}',
'overview.count.pending.one': 'Ожидают: {count}',
'overview.count.pending.many': 'Ожидают: {count}',
'overview.loadingContext': 'Загрузка данных Дела...',
'overview.lastActive': 'Последняя активность: {time}',
'overview.noRecentActivity': 'В Деле пока нет недавней активности',
'overview.refresh': 'Обновить',
'overview.summary': 'Сводка Дела',
'overview.continue': 'Продолжить работу',
'overview.continueHint': 'Вернитесь к следующему полезному делу в этом Деле.',
'overview.loadingSignals': 'Загрузка данных Дела...',
'overview.noResume': 'Пока неясно, с чего продолжить',
'overview.noResumeHint': 'Здесь появятся недавние заметки, файлы, сохранённое и записи журнала.',
'overview.recentChanges': 'Недавние изменения',
'overview.recentChangesHint': 'Последняя важная активность в этом Деле.',
'overview.event.workspaceOpened': 'Открыто Дело',
'overview.event.activity': 'Активность Дела',
'overview.event.noteEdited': 'Изменена заметка «{name}»',
'overview.event.noteOpened': 'Открыта заметка «{name}»',
'overview.event.noteCreated': 'Создана заметка «{name}»',
'overview.event.fileOpened': 'Открыт файл «{name}»',
'overview.event.fileChanged': 'Изменён файл «{name}»',
'overview.event.fileCreated': 'Создан файл «{name}»',
'overview.event.fileRemoved': 'Удалён файл «{name}»',
'overview.event.capturePage': 'Сохранена страница «{name}»',
'overview.event.captureSelection': 'Сохранено выделение «{name}»',
'overview.event.captureLink': 'Сохранена ссылка «{name}»',
'overview.event.captureFile': 'Сохранён файл «{name}»',
'overview.event.captureConverted': 'Сохранённое преобразовано: «{name}»',
'overview.event.capture': 'Сохранено: {kind} «{title}»',
'overview.event.journalAdded': 'Добавлена запись в журнал «{name}»',
'overview.event.workSessionDetected': 'Обнаружена рабочая сессия',
'overview.event.todo': 'Задача «{title}»',
'overview.event.reviewCapture': 'Разобрать сохранённое «{title}»',
'overview.event.continueJournal': 'Продолжить запись журнала «{title}»',
'overview.event.possibleJournalEntry': 'Возможная запись в журнал',
'overview.captureKind.page': 'страница',
'overview.captureKind.selection': 'выделение',
'overview.captureKind.link': 'ссылка',
'overview.captureKind.file': 'файл',
'overview.captureKind.item': 'элемент',
'overview.todo.reminderDue': 'Пора напомнить',
'overview.todo.overdue': 'Просрочено',
'overview.todo.dueSoon': 'Скоро срок',
'overview.todo.due': 'Срок: {date}',
'overview.candidateMeta': 'Дело: {deal} · {minutes} мин. · активностей: {activities}',
'overview.minutes': '{count} мин.',
'overview.browserCapture': 'Сохранённое из браузера',
'overview.untitledTodo': 'Задача без названия',
'overview.untitledCapture': 'Сохранённое без названия',
'overview.untitledJournal': 'Запись журнала',
'overview.unknownDeal': 'Неизвестное Дело',
'overview.item': 'элемент',
'overview.overviewNote': 'Обзорная заметка Дела',
'overview.recentFilter': 'Фильтр недавних изменений',
'overview.loadingRecent': 'Загрузка недавних изменений...',
'overview.noChanges': 'Для этого фильтра пока нет важных изменений.',
'overview.attentionHint': 'Неразобранное сохранённое, срочные задачи и возможные записи журнала.',
'overview.loadingPending': 'Загрузка ожидающих элементов...',
'overview.noPending': 'Нет неразобранного сохранённого, срочных задач или возможных записей журнала.',
'overview.keyResources': 'Ключевые ресурсы',
'overview.time.none': 'Время не указано',
'overview.time.now': 'Только что',
'overview.time.minutes': '{count} мин. назад',
'overview.time.hours': '{count} ч. назад',
'overview.time.yesterday': 'Вчера',
'overview.time.days': '{count} дн. назад',
};
+200
View File
@@ -0,0 +1,200 @@
import englishShellCatalog from './catalogs/en.js';
import russianShellCatalog from './catalogs/ru.js';
const LANGUAGE_PREFERENCES = new Set(['system', 'en', 'ru']);
const CONTRIBUTION_TEXT_FIELDS = {
views: 'title',
commands: 'title',
settingsPanels: 'title',
sidebarItems: 'title',
fileActions: 'label',
noteActions: 'label',
contextMenuEntries: 'label',
searchProviders: 'label',
statusBarItems: 'label',
openProviders: 'title',
workspaceItems: 'title',
};
function normalizeSystemLanguages(languages) {
const values = Array.isArray(languages) ? languages : [languages];
return values.map((value) => String(value || '').trim().toLowerCase()).filter(Boolean);
}
export function resolveLocale(preference, systemLanguages = []) {
if (!LANGUAGE_PREFERENCES.has(preference)) {
throw new Error(`unsupported language preference: ${preference}`);
}
if (preference === 'en' || preference === 'ru') return preference;
return normalizeSystemLanguages(systemLanguages).some((locale) => locale === 'ru' || locale.startsWith('ru-'))
? 'ru'
: 'en';
}
function interpolate(message, params) {
if (!params) return message;
return message.replace(/\{([A-Za-z0-9_.-]+)\}/g, (placeholder, name) => (
Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : placeholder
));
}
function browserLanguages() {
if (typeof navigator === 'undefined') return ['en'];
if (Array.isArray(navigator.languages) && navigator.languages.length > 0) return navigator.languages;
return [navigator.language || 'en'];
}
export function createI18n(options = {}) {
const shellCatalogs = options.shellCatalogs || { en: {}, ru: {} };
let systemLanguages = options.systemLanguages || browserLanguages;
let catalogLoader = options.loadPluginCatalog || (async () => ({}));
let preference = 'system';
let locale = 'en';
const listeners = new Set();
const pluginConfigs = new Map();
const pluginCatalogs = new Map();
function configure(next = {}) {
if (next.systemLanguages) systemLanguages = next.systemLanguages;
if (next.loadPluginCatalog) catalogLoader = next.loadPluginCatalog;
}
function notify() {
listeners.forEach((listener) => listener(locale));
}
async function initialize(initialPreference = 'system') {
preference = LANGUAGE_PREFERENCES.has(initialPreference) ? initialPreference : 'system';
locale = resolveLocale(preference, systemLanguages());
}
async function loadCatalog(pluginId, catalogLocale) {
if (!catalogLocale) return;
let catalogs = pluginCatalogs.get(pluginId);
if (!catalogs) {
catalogs = new Map();
pluginCatalogs.set(pluginId, catalogs);
}
if (catalogs.has(catalogLocale)) return;
const messages = await catalogLoader(pluginId, catalogLocale);
catalogs.set(catalogLocale, { ...(messages || {}) });
}
async function loadPlugin(pluginId, localization) {
if (!pluginId || !localization || !localization.defaultLocale || !localization.locales) return;
pluginConfigs.set(pluginId, localization);
const requested = localization.locales[locale] ? locale : '';
const defaultLocale = localization.defaultLocale;
await Promise.all(Array.from(new Set([requested, defaultLocale].filter(Boolean))).map((item) => loadCatalog(pluginId, item)));
}
async function setLanguagePreference(nextPreference) {
const nextLocale = resolveLocale(nextPreference, systemLanguages());
preference = nextPreference;
locale = nextLocale;
await Promise.all(Array.from(pluginConfigs.entries()).map(([pluginId, config]) => (
loadPlugin(pluginId, config).catch((error) => {
console.warn(`[i18n] failed to load ${pluginId} catalog for ${nextLocale}:`, error);
})
)));
notify();
}
function t(key, params, fallback) {
const message = shellCatalogs[locale]?.[key]
?? shellCatalogs.en?.[key]
?? fallback
?? key;
return interpolate(message, params);
}
function translatePlugin(pluginId, key, params, fallback) {
const config = pluginConfigs.get(pluginId);
const catalogs = pluginCatalogs.get(pluginId);
const message = catalogs?.get(locale)?.[key]
?? catalogs?.get(config?.defaultLocale)?.[key]
?? fallback
?? key;
return interpolate(message, params);
}
function localizeContributions(pluginId, contributions) {
if (!contributions) return contributions;
const localized = { ...contributions };
Object.entries(CONTRIBUTION_TEXT_FIELDS).forEach(([point, field]) => {
if (!Array.isArray(contributions[point])) return;
localized[point] = contributions[point].map((item) => ({
...item,
[field]: translatePlugin(
pluginId,
`contributions.${point}.${item.id}.${field}`,
undefined,
item[field],
),
}));
});
return localized;
}
function localizePlugin(plugin) {
if (!plugin) return plugin;
const hasStateWrapper = !!plugin.manifest;
const manifest = hasStateWrapper ? plugin.manifest : plugin;
const pluginId = manifest.id;
const localizedManifest = {
...manifest,
name: translatePlugin(pluginId, 'manifest.name', undefined, manifest.name),
description: translatePlugin(pluginId, 'manifest.description', undefined, manifest.description),
contributes: localizeContributions(pluginId, manifest.contributes),
};
return hasStateWrapper ? { ...plugin, manifest: localizedManifest } : localizedManifest;
}
function localizeContributionSummary(summary) {
if (!summary) return summary;
const localized = { ...summary };
Object.entries(CONTRIBUTION_TEXT_FIELDS).forEach(([point, field]) => {
if (!Array.isArray(summary[point])) return;
localized[point] = summary[point].map((item) => ({
...item,
[field]: translatePlugin(
item.pluginId,
`contributions.${point}.${item.id}.${field}`,
undefined,
item[field],
),
}));
});
return localized;
}
function subscribe(listener) {
listeners.add(listener);
listener(locale);
return () => listeners.delete(listener);
}
return {
configure,
initialize,
getLanguagePreference: () => preference,
getLocale: () => locale,
setLanguagePreference,
t,
subscribe,
loadPlugin,
translatePlugin,
localizeContributions,
localizePlugin,
localizeContributionSummary,
};
}
export const i18n = createI18n({
shellCatalogs: {
en: englishShellCatalog,
ru: russianShellCatalog,
},
});
export const t = (...args) => i18n.t(...args);
+152
View File
@@ -0,0 +1,152 @@
/**
* Frontend debug logger.
*
* Enabled when:
* 1. URL has ?debug query param, OR
* 2. localStorage has verstak-debug = "true"
*
* Writes to:
* - console (always, with [debug] prefix)
* - localStorage buffer (last 1000 entries, key: verstak-debug-log)
*
* Usage:
* import { debug } from '../log/debug.js';
* debug.log('[ComponentName]', 'message', data);
* debug.logf('[ComponentName]', 'format %s', arg);
*
* To enable: open app with ?debug or run in console:
* localStorage.setItem('verstak-debug', 'true')
*
* To export log: run in console:
* copy(JSON.parse(localStorage.getItem('verstak-debug-log')))
*/
var ENABLED = false;
var BUFFER_KEY = 'verstak-debug-log';
var MAX_ENTRIES = 1000;
// Check enable conditions
function checkEnabled() {
try {
if (window.location && window.location.search && window.location.search.indexOf('debug') !== -1) {
return true;
}
if (typeof localStorage !== 'undefined' && localStorage.getItem('verstak-debug') === 'true') {
return true;
}
} catch (e) {
// localStorage not available
}
return false;
}
ENABLED = checkEnabled();
function getTimestamp() {
return new Date().toISOString();
}
function formatMessage(args) {
var parts = [];
for (var i = 0; i < args.length; i++) {
var a = args[i];
if (typeof a === 'object') {
try {
parts.push(JSON.stringify(a));
} catch (e) {
parts.push(String(a));
}
} else {
parts.push(String(a));
}
}
return parts.join(' ');
}
function writeToBuffer(entry) {
try {
if (typeof localStorage === 'undefined') return;
var raw = localStorage.getItem(BUFFER_KEY);
var log = raw ? JSON.parse(raw) : [];
log.push(entry);
if (log.length > MAX_ENTRIES) {
log = log.slice(log.length - MAX_ENTRIES);
}
localStorage.setItem(BUFFER_KEY, JSON.stringify(log));
} catch (e) {
// Ignore quota errors
}
}
function log() {
if (!ENABLED) return;
var msg = formatMessage(Array.prototype.slice.call(arguments));
var entry = { ts: getTimestamp(), msg: msg };
writeToBuffer(entry);
console.log('[debug]', msg);
}
function logf() {
if (!ENABLED) return;
var args = Array.prototype.slice.call(arguments);
var format = args.shift();
var i = 0;
var msg = format.replace(/%[sdfo]/g, function () {
return i < args.length ? String(args[i++]) : '';
});
var entry = { ts: getTimestamp(), msg: msg };
writeToBuffer(entry);
console.log('[debug]', msg);
}
function getLog() {
try {
if (typeof localStorage === 'undefined') return [];
var raw = localStorage.getItem(BUFFER_KEY);
return raw ? JSON.parse(raw) : [];
} catch (e) {
return [];
}
}
function clearLog() {
try {
if (typeof localStorage !== 'undefined') {
localStorage.removeItem(BUFFER_KEY);
}
} catch (e) {}
}
function exportLog() {
var entries = getLog();
return entries.map(function (e) { return e.ts + ' ' + e.msg; }).join('\n');
}
// Named export for Svelte/Vite
export var debug = {
log: log,
logf: logf,
isEnabled: function () { return ENABLED; },
enable: function (options) {
ENABLED = true;
if (!options || options.persist !== false) {
try { localStorage.setItem('verstak-debug', 'true'); } catch (e) {}
}
},
disable: function () {
ENABLED = false;
try { localStorage.removeItem('verstak-debug'); } catch (e) {}
},
getLog: getLog,
clearLog: clearLog,
exportLog: exportLog
};
// Also expose globally for console access
if (typeof window !== 'undefined') {
window.__verstakDebug = debug;
}
if (ENABLED) {
console.log('[debug] frontend debug logger enabled');
}
@@ -0,0 +1,357 @@
<script>
import { onMount, onDestroy } from 'svelte';
import * as App from '../../../wailsjs/go/api/App';
import Icon from '../ui/Icon.svelte';
import { i18n } from '../i18n/index.js';
import { createPluginAPI } from './VerstakPluginAPI.js';
export let pluginId = null;
export let componentId = null;
export let viewPluginId = null;
export let componentProps = {};
let loadState = 'idle'; // idle | loading | loaded | error
let pluginInfo = null;
let errorText = '';
let mountContainer = null;
let currentPluginId = null;
let currentComponent = null;
let currentAPI = null;
let currentPropsKey = '';
let locale = i18n.getLocale();
let unsubscribeLocale = null;
$: tr = ((activeLocale) => (key, params, fallback) => {
void activeLocale;
return i18n.t(key, params, fallback);
})(locale);
$: activePluginId = pluginId || viewPluginId;
$: activeComponent = componentId;
$: propsKey = JSON.stringify(componentProps || {});
// React to changes — reload on view change
$: if (activePluginId && activeComponent) {
loadAndMount(activePluginId, activeComponent, propsKey);
} else if (!activePluginId) {
cleanup();
loadState = 'idle';
}
onDestroy(() => {
unsubscribeLocale?.();
cleanup();
});
onMount(() => {
unsubscribeLocale = i18n.subscribe((nextLocale) => locale = nextLocale);
});
function cleanup() {
if (currentAPI && typeof currentAPI.dispose === 'function') {
try {
currentAPI.dispose();
} catch (e) {
console.error('[PluginBundleHost] API dispose error:', e);
}
}
const reg = window.__VERSTAK_PLUGIN_REGISTRY__;
if (currentPluginId && currentComponent && reg && reg[currentPluginId]) {
const comp = reg[currentPluginId][currentComponent];
if (comp && comp.unmount && mountContainer) {
try {
comp.unmount(mountContainer);
} catch (e) {
console.error('[PluginBundleHost] unmount error:', e);
}
}
}
if (mountContainer) {
mountContainer.innerHTML = '';
}
currentPluginId = null;
currentComponent = null;
currentAPI = null;
currentPropsKey = '';
}
function unpackBackendResult(result) {
if (Array.isArray(result) && result.length === 2 && (typeof result[1] === 'string' || result[1] == null)) {
return { value: result[0], error: result[1] || '' };
}
return { value: result, error: '' };
}
function reportError(key, fallback, details) {
console.warn('[PluginBundleHost] ' + key + ':', details);
return tr(key, undefined, fallback);
}
async function loadAndMount(pId, compId, nextPropsKey) {
// If same plugin+component and already mounted, skip
if (currentPluginId === pId && currentComponent === compId && currentPropsKey === nextPropsKey && loadState === 'loaded') {
return;
}
// Cleanup previous
cleanup();
loadState = 'loading';
errorText = '';
currentPluginId = pId;
currentComponent = compId;
currentPropsKey = nextPropsKey;
try {
// Get plugin frontend info
const info = await App.GetPluginFrontendInfo(pId);
pluginInfo = info;
if (!info || info.status === 'no-frontend' || info.status === 'not-found') {
loadState = 'error';
errorText = info.status === 'no-frontend'
? tr('bundle.noFrontend')
: tr('bundle.notFound');
return;
}
try {
await i18n.loadPlugin(pId, info.localization);
} catch (catalogError) {
console.warn(`[PluginBundleHost] localization unavailable for ${pId}:`, catalogError);
}
// Check if bundle already loaded for this plugin
const reg = window.__VERSTAK_PLUGIN_REGISTRY__ || {};
if (!reg[pId]) {
// Load the bundle JS content via backend API
const assetResult = unpackBackendResult(await App.GetPluginAssetContent(pId, info.entry));
const content = assetResult.value;
if (assetResult.error || !content) {
loadState = 'error';
errorText = reportError('bundle.loadFailed', 'Could not load the plugin interface. Please try again.', assetResult.error || tr('bundle.emptyContent'));
return;
}
// Execute bundle via Function constructor (safe: no access to outer scope)
// This is equivalent to eval but more explicit
try {
const fn = new Function(content);
fn();
} catch (e) {
loadState = 'error';
errorText = reportError('bundle.executionError', 'Could not start the plugin interface. Please try again.', e);
return;
}
// Verify registration happened
if (!window.__VERSTAK_PLUGIN_REGISTRY__[pId]) {
loadState = 'error';
errorText = tr('bundle.registrationMissing');
return;
}
}
// Find the component
const components = window.__VERSTAK_PLUGIN_REGISTRY__[pId];
const comp = components[compId];
if (!comp || !comp.mount) {
loadState = 'error';
errorText = tr('bundle.componentMissing', undefined, 'The requested plugin interface is unavailable.');
return;
}
// Create API
const api = createPluginAPI(pId);
currentAPI = api;
// Mount component
if (!mountContainer) {
// Container must exist in DOM — wait for next tick
await new Promise(r => requestAnimationFrame(r));
}
if (mountContainer) {
try {
comp.mount(mountContainer, Object.assign({ componentId: compId }, componentProps || {}), api);
loadState = 'loaded';
errorText = '';
} catch (e) {
loadState = 'error';
errorText = reportError('bundle.mountError', 'Could not open the plugin interface. Please try again.', e);
}
} else {
loadState = 'error';
errorText = tr('bundle.mountUnavailable');
}
} catch (e) {
loadState = 'error';
errorText = reportError('bundle.unexpectedError', 'Could not open the plugin interface. Please try again.', e);
}
}
function getComponentList() {
const reg = window.__VERSTAK_PLUGIN_REGISTRY__;
if (!reg || !currentPluginId || !reg[currentPluginId]) return [];
return Object.keys(reg[currentPluginId]);
}
</script>
<div class="plugin-bundle-host">
{#if loadState === 'idle'}
<div class="host-state idle">
<p>{tr('pluginView.select')}</p>
</div>
{:else if loadState === 'error'}
<div class="host-state error">
<Icon name="warning" size={24} class="error-icon" />
<p class="error-title">{tr('pluginView.error')}</p>
<p class="error-message">{errorText || tr('bundle.unknownError')}</p>
<details class="error-details">
<summary>{tr('common.details')}</summary>
<p><strong>{tr('common.plugin')}:</strong> {currentPluginId || tr('common.unknown')}</p>
<p><strong>{tr('common.component')}:</strong> {currentComponent || tr('common.unknown')}</p>
{#if pluginInfo}
<p class="error-meta">{tr('bundle.frontendEntry')}: {pluginInfo.entry || tr('common.none')}</p>
{/if}
{#if getComponentList().length > 0}
<p class="error-meta">{tr('bundle.availableComponents')}: {getComponentList().join(', ')}</p>
{/if}
</details>
</div>
{:else}
{#if loadState === 'loading'}
<div class="host-state loading">
<div class="spinner"></div>
<p>{tr('bundle.loading')}</p>
</div>
{/if}
<div
class="plugin-mount-container"
class:mount-hidden={loadState !== 'loaded'}
bind:this={mountContainer}
data-plugin-id={currentPluginId}
data-component={currentComponent}
style="flex:1;min-width:0;min-height:0;height:100%;display:flex;flex-direction:column;position:relative;"
></div>
{/if}
</div>
<style>
.plugin-bundle-host {
flex: 1;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
}
.host-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
text-align: center;
}
.host-state.idle {
color: #555;
}
.host-state.loading {
color: #a0a0b8;
}
.spinner {
width: 24px;
height: 24px;
border: 2px solid #333;
border-top-color: #4ecca3;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-bottom: 1rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.host-state.error {
color: #e94560;
}
.host-state.error :global(.error-icon) {
font-size: 2rem;
margin-bottom: 0.5rem;
}
.error-title {
font-weight: 600;
font-size: 1.1rem;
margin-bottom: 1rem;
}
.error-details {
font-size: 0.85rem;
color: #a0a0b8;
max-width: 400px;
text-align: left;
background: #16213e;
padding: 0.5rem 0.75rem;
border-radius: 6px;
border: 1px solid #0f3460;
}
.error-details[open] {
padding: 0.75rem 1rem;
}
.error-details summary {
cursor: pointer;
color: #e0e0f0;
font-weight: 600;
}
.error-details p {
margin: 0.3rem 0;
}
.error-details strong {
color: #e0e0f0;
}
.error-message {
color: #e94560;
max-width: 400px;
margin: 0 0 0.75rem;
}
.error-meta {
font-size: 0.75rem;
color: #666;
margin-top: 0.3rem !important;
}
.plugin-mount-container {
flex: 1;
min-width: 0;
min-height: 0;
height: 100%;
display: flex;
flex-direction: column;
position: relative;
}
.plugin-mount-container.mount-hidden {
height: 0;
min-height: 0;
overflow: hidden;
visibility: hidden;
}
</style>
@@ -0,0 +1,603 @@
import * as App from '../../../wailsjs/go/api/App';
import { i18n } from '../i18n/index.js';
window.__VERSTAK_PLUGIN_REGISTRY__ = window.__VERSTAK_PLUGIN_REGISTRY__ || {};
window.__VERSTAK_EVENT_HANDLERS__ = window.__VERSTAK_EVENT_HANDLERS__ || {};
window.__VERSTAK_COMMAND_HANDLERS__ = window.__VERSTAK_COMMAND_HANDLERS__ || {};
if (!window.VerstakPluginRegister) {
window.VerstakPluginRegister = function(pluginId, bundle) {
if (!pluginId || !bundle || !bundle.components) {
console.error('[VerstakPluginRegister] invalid registration:', pluginId);
return;
}
console.log('[VerstakPluginRegister] registered:', pluginId, Object.keys(bundle.components));
window.__VERSTAK_PLUGIN_REGISTRY__[pluginId] = bundle.components;
};
}
function unpack(result) {
if (Array.isArray(result) && result.length === 2 && (typeof result[1] === 'string' || result[1] == null)) {
return [result[0], result[1] || ''];
}
return [result, ''];
}
async function callBackend(pluginId, label, fn) {
try {
const [value, err] = unpack(await fn());
if (err) {
throw new Error(err);
}
return value;
} catch (e) {
const message = e && e.message ? e.message : String(e);
throw new Error('[plugin:' + pluginId + '] ' + label + ' failed: ' + message);
}
}
async function callBackendErrorString(pluginId, label, fn) {
try {
const err = await fn();
if (err) {
throw new Error(err);
}
} catch (e) {
const message = e && e.message ? e.message : String(e);
throw new Error('[plugin:' + pluginId + '] ' + label + ' failed: ' + message);
}
}
function getEventHandlers(eventName) {
if (!window.__VERSTAK_EVENT_HANDLERS__[eventName]) {
window.__VERSTAK_EVENT_HANDLERS__[eventName] = [];
}
return window.__VERSTAK_EVENT_HANDLERS__[eventName];
}
function dispatchLocalEvent(pluginId, eventName, payload) {
const event = {
name: eventName,
pluginId: pluginId,
payload: payload || {},
timestamp: new Date().toISOString()
};
const handlers = getEventHandlers(eventName).slice();
handlers.forEach(function(handler) {
try {
handler(event);
} catch (e) {
console.error('[VerstakPluginAPI] event handler error:', e);
}
});
}
function dispatchBackendEvent(event) {
if (!event || !event.name) return;
const handlers = getEventHandlers(event.name).slice();
handlers.forEach(function(handler) {
try {
handler(event);
} catch (e) {
console.error('[VerstakPluginAPI] backend event handler error:', e);
}
});
}
window.__VERSTAK_DISPATCH_BACKEND_EVENT__ = dispatchBackendEvent;
if (!window.__VERSTAK_BACKEND_EVENT_BRIDGE__ && window.runtime && typeof window.runtime.EventsOnMultiple === 'function') {
window.__VERSTAK_BACKEND_EVENT_BRIDGE__ = window.runtime.EventsOnMultiple('verstak:plugin-event', dispatchBackendEvent, -1);
}
function commandKey(pluginId, commandId) {
return pluginId + ':' + commandId;
}
export async function executePluginCommand(pluginId, cmdId, args) {
if (!pluginId) {
throw new Error('executePluginCommand requires pluginId');
}
if (!cmdId) {
throw new Error('executePluginCommand requires command id');
}
const declared = await callBackend(pluginId, 'commands.execute(' + cmdId + ')', function() {
return App.ExecutePluginCommand(pluginId, cmdId, args || {});
});
const handler = window.__VERSTAK_COMMAND_HANDLERS__[commandKey(pluginId, cmdId)];
if (!handler) {
throw new Error('[plugin:' + pluginId + '] commands.execute(' + cmdId + ') failed: declared-but-unhandled');
}
const result = await handler(args || {}, declared);
return {
status: 'handled',
pluginId: pluginId,
commandId: cmdId,
result: result
};
}
export function createPluginAPI(pluginId) {
if (!pluginId) {
throw new Error('createPluginAPI requires pluginId');
}
const cleanups = [];
let disposed = false;
function assertActive(label) {
if (disposed) {
throw new Error('[plugin:' + pluginId + '] ' + label + ' failed: API disposed');
}
}
function trackCleanup(fn) {
cleanups.push(fn);
return function untrackAndRun() {
const idx = cleanups.indexOf(fn);
if (idx !== -1) {
cleanups.splice(idx, 1);
}
fn();
};
}
return {
pluginId: pluginId,
i18n: {
getLocale: function() {
assertActive('i18n.getLocale');
return i18n.getLocale();
},
t: function(key, params, fallback) {
assertActive('i18n.t(' + key + ')');
return i18n.translatePlugin(pluginId, key, params, fallback);
},
onDidChangeLocale: function(listener) {
assertActive('i18n.onDidChangeLocale');
if (typeof listener !== 'function') {
throw new Error('i18n.onDidChangeLocale requires a listener function');
}
return trackCleanup(i18n.subscribe(listener));
}
},
ui: {
openSettings: function(panelId) {
assertActive('ui.openSettings');
window.dispatchEvent(new CustomEvent('verstak:open-settings', {
detail: { pluginId: pluginId, panelId: panelId || '' }
}));
}
},
capabilities: {
has: async function(capId) {
const info = await callBackend(pluginId, 'capabilities.has(' + capId + ')', function() {
return App.GetPluginCapability(pluginId, capId);
});
return !!(info && info.available);
},
get: function(capId) {
return callBackend(pluginId, 'capabilities.get(' + capId + ')', function() {
return App.GetPluginCapability(pluginId, capId);
});
},
list: function() {
return callBackend(pluginId, 'capabilities.list', function() {
return App.ListPluginCapabilities(pluginId);
});
}
},
events: {
publish: async function(type, payload) {
await callBackendErrorString(pluginId, 'events.publish(' + type + ')', function() {
return App.PublishPluginEvent(pluginId, type, payload || {});
});
if (!window.__VERSTAK_BACKEND_EVENT_BRIDGE__) {
dispatchLocalEvent(pluginId, type, payload || {});
}
},
subscribe: function(type, handler) {
assertActive('events.subscribe(' + type + ')');
if (typeof handler !== 'function') {
throw new Error('events.subscribe requires a handler function');
}
return callBackendErrorString(pluginId, 'events.subscribe(' + type + ')', function() {
return App.SubscribePluginEvent(pluginId, type);
}).then(function() {
const handlers = getEventHandlers(type);
handlers.push(handler);
return trackCleanup(function unsubscribe() {
const current = getEventHandlers(type);
window.__VERSTAK_EVENT_HANDLERS__[type] = current.filter(function(item) {
return item !== handler;
});
});
});
}
},
notifications: {
replace: function(items) {
assertActive('notifications.replace');
if (!Array.isArray(items)) {
throw new Error('notifications.replace requires an array');
}
return callBackendErrorString(pluginId, 'notifications.replace', function() {
return App.ReplacePluginNotifications(pluginId, items);
});
},
clear: function() {
assertActive('notifications.clear');
return callBackendErrorString(pluginId, 'notifications.clear', function() {
return App.ClearPluginNotifications(pluginId);
});
}
},
settings: {
read: async function(key) {
assertActive('settings.read');
const settings = await callBackend(pluginId, 'settings.read', function() {
return App.ReadPluginSettings(pluginId);
});
if (!key) {
return settings || {};
}
return settings ? settings[key] : undefined;
},
write: async function(key, value) {
assertActive('settings.write(' + key + ')');
if (!key) {
throw new Error('settings.write requires a key');
}
await callBackendErrorString(pluginId, 'settings.write(' + key + ')', function() {
return App.WritePluginSetting(pluginId, key, value);
});
return this.read();
},
writeAll: function(settings) {
assertActive('settings.writeAll');
return callBackendErrorString(pluginId, 'settings.writeAll', function() {
return App.WritePluginSettings(pluginId, settings || {});
});
}
},
storage: {
data: {
read: function(name) {
assertActive('storage.data.read(' + name + ')');
if (!name) {
throw new Error('storage.data.read requires a name');
}
return callBackend(pluginId, 'storage.data.read(' + name + ')', function() {
return App.ReadPluginDataJSON(pluginId, name);
}).then(function(data) {
return data || {};
});
},
readNDJSON: function(name) {
assertActive('storage.data.readNDJSON(' + name + ')');
if (!name) {
throw new Error('storage.data.readNDJSON requires a name');
}
return callBackend(pluginId, 'storage.data.readNDJSON(' + name + ')', function() {
return App.ReadPluginDataNDJSON(pluginId, name);
}).then(function(records) {
return Array.isArray(records) ? records : [];
});
},
writeNDJSON: function(name, records) {
assertActive('storage.data.writeNDJSON(' + name + ')');
if (!name) {
throw new Error('storage.data.writeNDJSON requires a name');
}
return callBackendErrorString(pluginId, 'storage.data.writeNDJSON(' + name + ')', function() {
return App.WritePluginDataNDJSON(pluginId, name, Array.isArray(records) ? records : []);
});
},
write: function(name, data) {
assertActive('storage.data.write(' + name + ')');
if (!name) {
throw new Error('storage.data.write requires a name');
}
return callBackendErrorString(pluginId, 'storage.data.write(' + name + ')', function() {
return App.WritePluginDataJSON(pluginId, name, data || {});
});
}
}
},
secrets: {
status: function() {
assertActive('secrets.status');
return callBackend(pluginId, 'secrets.status', function() {
return App.PluginSecretsStatus(pluginId);
});
},
unlock: function(masterPassword) {
assertActive('secrets.unlock');
return callBackendErrorString(pluginId, 'secrets.unlock', function() {
return App.PluginSecretsUnlock(pluginId, String(masterPassword == null ? '' : masterPassword));
});
},
list: function() {
assertActive('secrets.list');
return callBackend(pluginId, 'secrets.list', function() {
return App.PluginSecretsList(pluginId);
}).then(function(records) {
return Array.isArray(records) ? records : [];
});
},
read: function(secretId) {
assertActive('secrets.read(' + secretId + ')');
if (!secretId) {
throw new Error('secrets.read requires a secret id');
}
return callBackend(pluginId, 'secrets.read(' + secretId + ')', function() {
return App.PluginSecretsRead(pluginId, secretId);
});
},
write: function(record) {
assertActive('secrets.write');
return callBackend(pluginId, 'secrets.write', function() {
return App.PluginSecretsWrite(pluginId, record || {});
});
},
delete: function(secretId) {
assertActive('secrets.delete(' + secretId + ')');
if (!secretId) {
throw new Error('secrets.delete requires a secret id');
}
return callBackendErrorString(pluginId, 'secrets.delete(' + secretId + ')', function() {
return App.PluginSecretsDelete(pluginId, secretId);
});
},
copyLink: function(secretId) {
assertActive('secrets.copyLink(' + secretId + ')');
if (!secretId) {
throw new Error('secrets.copyLink requires a secret id');
}
return callBackend(pluginId, 'secrets.copyLink(' + secretId + ')', function() {
return App.PluginSecretsCopyLink(pluginId, secretId);
});
}
},
files: {
list: function(relativeDir) {
assertActive('files.list');
return callBackend(pluginId, 'files.list(' + (relativeDir || '') + ')', function() {
return App.ListVaultFiles(pluginId, relativeDir || '');
});
},
metadata: function(relativePath) {
assertActive('files.metadata(' + relativePath + ')');
return callBackend(pluginId, 'files.metadata(' + relativePath + ')', function() {
return App.GetVaultFileMetadata(pluginId, relativePath);
});
},
readText: function(relativePath) {
assertActive('files.readText(' + relativePath + ')');
return callBackend(pluginId, 'files.readText(' + relativePath + ')', function() {
return App.ReadVaultTextFile(pluginId, relativePath);
});
},
readBytes: function(relativePath) {
assertActive('files.readBytes(' + relativePath + ')');
return callBackend(pluginId, 'files.readBytes(' + relativePath + ')', function() {
return App.ReadVaultFileBytes(pluginId, relativePath);
});
},
writeText: function(relativePath, content, options) {
assertActive('files.writeText(' + relativePath + ')');
return callBackendErrorString(pluginId, 'files.writeText(' + relativePath + ')', function() {
return App.WriteVaultTextFile(pluginId, relativePath, String(content == null ? '' : content), options || {});
});
},
writeBytes: function(relativePath, dataBase64, options) {
assertActive('files.writeBytes(' + relativePath + ')');
return callBackendErrorString(pluginId, 'files.writeBytes(' + relativePath + ')', function() {
return App.WriteVaultFileBytes(pluginId, relativePath, String(dataBase64 == null ? '' : dataBase64), options || {});
});
},
createFolder: function(relativePath) {
assertActive('files.createFolder(' + relativePath + ')');
return callBackendErrorString(pluginId, 'files.createFolder(' + relativePath + ')', function() {
return App.CreateVaultFolder(pluginId, relativePath);
});
},
move: function(fromRelativePath, toRelativePath, options) {
assertActive('files.move(' + fromRelativePath + ')');
return callBackendErrorString(pluginId, 'files.move(' + fromRelativePath + ')', function() {
return App.MoveVaultPath(pluginId, fromRelativePath, toRelativePath, options || {});
});
},
trash: function(relativePath) {
assertActive('files.trash(' + relativePath + ')');
return callBackend(pluginId, 'files.trash(' + relativePath + ')', function() {
return App.TrashVaultPath(pluginId, relativePath);
});
},
listTrash: function() {
assertActive('files.listTrash');
return callBackend(pluginId, 'files.listTrash', function() {
return App.ListVaultTrash(pluginId);
});
},
restoreTrash: function(trashId, options) {
assertActive('files.restoreTrash(' + trashId + ')');
return callBackend(pluginId, 'files.restoreTrash(' + trashId + ')', function() {
return App.RestoreVaultTrash(pluginId, trashId, options || {});
});
},
deleteTrash: function(trashId) {
assertActive('files.deleteTrash(' + trashId + ')');
return callBackendErrorString(pluginId, 'files.deleteTrash(' + trashId + ')', function() {
return App.DeleteVaultTrash(pluginId, trashId);
});
},
openExternal: function(relativePath) {
assertActive('files.openExternal(' + relativePath + ')');
return callBackendErrorString(pluginId, 'files.openExternal(' + relativePath + ')', function() {
return App.OpenVaultPathExternal(pluginId, relativePath);
});
},
openURL: function(url) {
assertActive('files.openURL');
return callBackendErrorString(pluginId, 'files.openURL', function() {
return App.OpenExternalURL(pluginId, String(url == null ? '' : url));
});
},
showInFolder: function(relativePath) {
assertActive('files.showInFolder(' + relativePath + ')');
return callBackendErrorString(pluginId, 'files.showInFolder(' + relativePath + ')', function() {
return App.ShowVaultPathInFolder(pluginId, relativePath);
});
}
},
sync: {
status: function() {
assertActive('sync.status');
return callBackend(pluginId, 'sync.status', function() {
return App.PluginSyncStatus(pluginId);
});
},
configure: function(serverURL, username, password) {
assertActive('sync.configure');
return callBackendErrorString(pluginId, 'sync.configure', function() {
return App.PluginSyncConfigure(pluginId, serverURL || '', username || '', password || '');
});
},
disconnect: function() {
assertActive('sync.disconnect');
return callBackendErrorString(pluginId, 'sync.disconnect', function() {
return App.PluginSyncDisconnect(pluginId);
});
},
testConnection: function(serverURL, username, password) {
assertActive('sync.testConnection');
return callBackendErrorString(pluginId, 'sync.testConnection', function() {
return App.PluginSyncTestConnection(pluginId, serverURL || '', username || '', password || '');
});
},
setInterval: function(minutes) {
assertActive('sync.setInterval');
return callBackendErrorString(pluginId, 'sync.setInterval', function() {
return App.PluginSyncSetInterval(pluginId, Number(minutes) || 0);
});
},
resetKey: function() {
assertActive('sync.resetKey');
return callBackendErrorString(pluginId, 'sync.resetKey', function() {
return App.PluginSyncResetKey(pluginId);
});
},
now: function() {
assertActive('sync.now');
return callBackend(pluginId, 'sync.now', function() {
return App.PluginSyncNow(pluginId);
});
}
},
browserReceiver: {
pairing: function() {
assertActive('browserReceiver.pairing');
return callBackend(pluginId, 'browserReceiver.pairing', function() {
return App.PluginBrowserReceiverPairing(pluginId);
});
},
rotateToken: function() {
assertActive('browserReceiver.rotateToken');
return callBackend(pluginId, 'browserReceiver.rotateToken', function() {
return App.PluginRotateBrowserReceiverToken(pluginId);
});
}
},
workbench: {
openResource: async function(request) {
assertActive('workbench.openResource');
const result = await callBackend(pluginId, 'workbench.openResource', function() {
return App.OpenWorkbenchResource(pluginId, request || {});
});
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
return result;
},
editResource: async function(request) {
assertActive('workbench.editResource');
const result = await callBackend(pluginId, 'workbench.editResource', function() {
return App.EditWorkbenchResource(pluginId, request || {});
});
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
return result;
}
},
contributions: {
list: async function(point) {
assertActive('contributions.list');
const summary = await callBackend(pluginId, 'contributions.list', function() {
return App.GetContributions();
});
if (!point) {
return summary || {};
}
return Array.isArray((summary || {})[point]) ? summary[point] : [];
}
},
commands: {
register: function(cmdId, handler) {
assertActive('commands.register(' + cmdId + ')');
if (!cmdId) {
throw new Error('commands.register requires a command id');
}
if (typeof handler !== 'function') {
throw new Error('commands.register requires a handler function');
}
return callBackend(pluginId, 'commands.register(' + cmdId + ')', function() {
return App.ExecutePluginCommand(pluginId, cmdId, { validateOnly: true });
}).then(function() {
const key = commandKey(pluginId, cmdId);
window.__VERSTAK_COMMAND_HANDLERS__[key] = handler;
return trackCleanup(function unregisterCommand() {
if (window.__VERSTAK_COMMAND_HANDLERS__[key] === handler) {
delete window.__VERSTAK_COMMAND_HANDLERS__[key];
}
});
});
},
execute: async function(cmdId, args) {
assertActive('commands.execute(' + cmdId + ')');
return executePluginCommand(pluginId, cmdId, args || {});
},
executeFor: async function(targetPluginId, cmdId, args) {
assertActive('commands.executeFor(' + targetPluginId + ':' + cmdId + ')');
return executePluginCommand(targetPluginId, cmdId, args || {});
}
},
dispose: function() {
if (disposed) return;
disposed = true;
while (cleanups.length > 0) {
const cleanup = cleanups.pop();
try {
cleanup();
} catch (e) {
console.error('[VerstakPluginAPI] cleanup error:', e);
}
}
}
};
}
window.createPluginAPI = createPluginAPI;
window.VerstakPluginAPI = createPluginAPI;
+128 -53
View File
@@ -1,16 +1,26 @@
<script> <script>
import Icon from '../ui/Icon.svelte';
import { onDestroy } from 'svelte';
import { i18n } from '../i18n/index.js';
export let p = {}; export let p = {};
export let capabilities = []; export let capabilities = [];
export let permissions = []; export let permissions = [];
export let contributions = {}; export let contributions = {};
export let vaultOpen = false; export let vaultOpen = false;
export let onSettings = () => {}; export let settingsPanels = [];
export let onEnable = () => {}; export let onEnable = () => {};
export let onDisable = () => {}; export let onDisable = () => {};
let locale = i18n.getLocale();
const unsubscribeLocale = i18n.subscribe((nextLocale) => { locale = nextLocale; });
$: tr = ((activeLocale) => (key, params, fallback) => {
void activeLocale;
return i18n.t(key, params, fallback);
})(locale);
onDestroy(unsubscribeLocale);
$: m = p.manifest || {}; $: m = p.manifest || {};
$: pluginId = m.id || 'unknown'; $: pluginId = m.id || 'unknown';
$: hasSettingsPanel = (contributions.settingsPanels || []).some(sp => sp.pluginId === pluginId); $: hasSettingsPanel = settingsPanels.length > 0;
$: hasUIPermission = (m.permissions || []).includes('ui.register'); $: hasUIPermission = (m.permissions || []).includes('ui.register');
$: hasStoragePermission = (m.permissions || []).includes('storage.namespace'); $: hasStoragePermission = (m.permissions || []).includes('storage.namespace');
$: hasCommandsPermission = (m.permissions || []).includes('commands.register'); $: hasCommandsPermission = (m.permissions || []).includes('commands.register');
@@ -29,17 +39,23 @@
$: contribCounts = { $: contribCounts = {
views: (contributions.views || []).filter(v => v.pluginId === pluginId).length, views: (contributions.views || []).filter(v => v.pluginId === pluginId).length,
commands: (contributions.commands || []).filter(c => c.pluginId === pluginId).length, commands: (contributions.commands || []).filter(c => c.pluginId === pluginId).length,
searchProviders: (contributions.searchProviders || []).filter(s => s.pluginId === pluginId).length,
sidebar: (contributions.sidebarItems || []).filter(s => s.pluginId === pluginId).length, sidebar: (contributions.sidebarItems || []).filter(s => s.pluginId === pluginId).length,
statusbar: (contributions.statusBarItems || []).filter(s => s.pluginId === pluginId).length, statusbar: (contributions.statusBarItems || []).filter(s => s.pluginId === pluginId).length,
openProviders: (contributions.openProviders || []).filter(o => o.pluginId === pluginId).length,
workspaceItems: (contributions.workspaceItems || []).filter(w => w.pluginId === pluginId).length,
}; };
$: contribSummary = (() => { $: contribSummary = (() => {
const parts = []; const parts = [];
if (contribCounts.views > 0) parts.push(contribCounts.views + ' view' + (contribCounts.views !== 1 ? 's' : '')); if (contribCounts.views > 0) parts.push(tr('pluginCard.count.views', { count: contribCounts.views }));
if (contribCounts.commands > 0) parts.push(contribCounts.commands + ' command' + (contribCounts.commands !== 1 ? 's' : '')); if (contribCounts.commands > 0) parts.push(tr('pluginCard.count.commands', { count: contribCounts.commands }));
if (contribCounts.sidebar > 0) parts.push(contribCounts.sidebar + ' sidebar' + (contribCounts.sidebar !== 1 ? 's' : '')); if (contribCounts.searchProviders > 0) parts.push(tr('pluginCard.count.searchProviders', { count: contribCounts.searchProviders }));
if (contribCounts.statusbar > 0) parts.push(contribCounts.statusbar + ' statusbar' + (contribCounts.statusbar !== 1 ? 's' : '')); if (contribCounts.sidebar > 0) parts.push(tr('pluginCard.count.sidebar', { count: contribCounts.sidebar }));
return parts.length > 0 ? parts.join(', ') : 'none'; if (contribCounts.statusbar > 0) parts.push(tr('pluginCard.count.statusbar', { count: contribCounts.statusbar }));
if (contribCounts.openProviders > 0) parts.push(tr('pluginCard.count.openProviders', { count: contribCounts.openProviders }));
if (contribCounts.workspaceItems > 0) parts.push(tr('pluginCard.count.workspace', { count: contribCounts.workspaceItems }));
return parts.length > 0 ? parts.join(', ') : tr('common.none');
})(); })();
$: dangerousPermissions = (m.permissions || []).filter(name => { $: dangerousPermissions = (m.permissions || []).filter(name => {
@@ -59,8 +75,12 @@
!capabilities.some(c => c.name === opt) !capabilities.some(c => c.name === opt)
); );
export let actionFeedback = {}; // { [pluginId]: 'enabling' | 'disabling' | null }
$: isDisabled = p.status === 'disabled' || !p.enabled; $: isDisabled = p.status === 'disabled' || !p.enabled;
$: canToggle = p.status !== 'failed' && p.status !== 'incompatible' && p.status !== 'missing-required-capability' && p.status !== 'discovered'; $: canToggle = p.status !== 'failed' && p.status !== 'incompatible' && p.status !== 'missing-required-capability' && p.status !== 'discovered';
$: isBusy = actionFeedback[pluginId] != null;
$: busyAction = actionFeedback[pluginId] || null;
</script> </script>
<div class="plugin-card" class:disabled={isDisabled} class:failed={p.status === 'failed'}> <div class="plugin-card" class:disabled={isDisabled} class:failed={p.status === 'failed'}>
@@ -70,11 +90,11 @@
<strong>{pluginId}</strong> <strong>{pluginId}</strong>
<span class="version">v{m.version || '?'}</span> <span class="version">v{m.version || '?'}</span>
</div> </div>
<span class="status-badge" style="color: {statusColor}">{p.status}</span> <span class="status-badge" style="color: {statusColor}">{tr(`status.${p.status}`, undefined, p.status)}</span>
</div> </div>
{#if p.status === 'degraded'} {#if p.status === 'degraded'}
<p class="degraded-text">Plugin is usable, but some optional capabilities are unavailable.</p> <p class="degraded-text">{tr('pluginCard.degraded')}</p>
{/if} {/if}
{#if m.description} {#if m.description}
@@ -83,40 +103,44 @@
<div class="card-meta"> <div class="card-meta">
<div class="meta-row"> <div class="meta-row">
<span class="label">Name:</span> <span class="label">{tr('pluginCard.name')}:</span>
<span>{m.name || '-'}</span> <span>{m.name || '-'}</span>
</div> </div>
<div class="meta-row"> <div class="meta-row">
<span class="label">API Version:</span> <span class="label">{tr('common.source')}:</span>
<span>{m.apiVersion || '-'}</span> <span>{m.source || tr('common.unknown')}</span>
</div> </div>
<div class="meta-row"> <div class="meta-row">
<span class="label">Source:</span> <span class="label">{tr('pluginCard.contributions')}:</span>
<span>{m.source || 'unknown'}</span>
</div>
<div class="meta-row">
<span class="label">Root:</span>
<span class="path">{p.rootPath || '-'}</span>
</div>
<div class="meta-row">
<span class="label">Contributions:</span>
<span>{contribSummary}</span> <span>{contribSummary}</span>
</div> </div>
</div> </div>
<!-- Capabilities --> <details class="plugin-details">
<div class="section"> <summary>{tr('pluginCard.technicalDetails')}</summary>
<span class="section-title">Provides</span> <div class="card-meta technical-meta">
<div class="tags"> <div class="meta-row">
{#each m.provides || [] as cap} <span class="label">{tr('pluginCard.apiVersion')}:</span>
<span class="tag provides">{cap}</span> <span>{m.apiVersion || '-'}</span>
{/each} </div>
<div class="meta-row">
<span class="label">{tr('pluginCard.root')}:</span>
<span class="path">{p.rootPath || '-'}</span>
</div>
</div> </div>
</div>
{#if m.requires && m.requires.length > 0}
<div class="section"> <div class="section">
<span class="section-title">Requires</span> <span class="section-title">{tr('pluginCard.provides')}</span>
<div class="tags">
{#each m.provides || [] as cap}
<span class="tag provides">{cap}</span>
{/each}
</div>
</div>
{#if m.requires && m.requires.length > 0}
<div class="section">
<span class="section-title">{tr('pluginCard.requires')}</span>
<div class="tags"> <div class="tags">
{#each m.requires as req} {#each m.requires as req}
{@const found = capabilities.some(c => c.name === req)} {@const found = capabilities.some(c => c.name === req)}
@@ -127,14 +151,14 @@
{/each} {/each}
</div> </div>
{#if missingRequired.length > 0} {#if missingRequired.length > 0}
<p class="warning">⚠ Missing required capabilities: {missingRequired.join(', ')}</p> <p class="warning"><Icon name="warning" size={12} /> {tr('pluginCard.missingRequired')}: {missingRequired.join(', ')}</p>
{/if} {/if}
</div> </div>
{/if} {/if}
{#if m.optionalRequires && m.optionalRequires.length > 0} {#if m.optionalRequires && m.optionalRequires.length > 0}
<div class="section"> <div class="section">
<span class="section-title">Optional Requires</span> <span class="section-title">{tr('pluginCard.optionalRequires')}</span>
<div class="tags"> <div class="tags">
{#each m.optionalRequires as opt} {#each m.optionalRequires as opt}
{@const found = capabilities.some(c => c.name === opt)} {@const found = capabilities.some(c => c.name === opt)}
@@ -145,26 +169,26 @@
{/each} {/each}
</div> </div>
{#if missingOptional.length > 0} {#if missingOptional.length > 0}
<p class="info">ℹ Optional capabilities not available — plugin running in degraded mode</p> <p class="info"><Icon name="warning" size={12} /> {tr('pluginCard.optionalUnavailable')}</p>
{/if} {/if}
</div> </div>
{/if} {/if}
<!-- Permissions --> {#if m.permissions && m.permissions.length > 0}
{#if m.permissions && m.permissions.length > 0}
<div class="section"> <div class="section">
<span class="section-title">Permissions</span> <span class="section-title">{tr('pluginCard.permissions')}</span>
<div class="tags"> <div class="tags">
{#each m.permissions as perm} {#each m.permissions as perm}
{@const isDangerous = dangerousPermissions.includes(perm)} {@const isDangerous = dangerousPermissions.includes(perm)}
<span class="tag" class:dangerous={isDangerous}> <span class="tag" class:dangerous={isDangerous}>
{perm} {perm}
{#if isDangerous}<span class="danger-icon">⚠</span>{/if} {#if isDangerous}<Icon name="warning" size={12} class="danger-icon" />{/if}
</span> </span>
{/each} {/each}
</div> </div>
</div> </div>
{/if} {/if}
</details>
<!-- Error --> <!-- Error -->
{#if p.error} {#if p.error}
@@ -174,29 +198,29 @@
<!-- Actions --> <!-- Actions -->
<div class="card-actions"> <div class="card-actions">
{#if hasSettingsPanel} {#if hasSettingsPanel}
<button class="btn-settings" on:click={() => onSettings(m.id)} type="button"> <button class="btn-settings" on:click={() => window.dispatchEvent(new CustomEvent('verstak:open-settings', { detail: { pluginId: m.id, panelId: settingsPanels[0]?.id } }))} type="button" disabled={isDisabled || p.status === 'failed'}>
⚙ Settings <Icon name="gear" size={14} /> {tr('settings.title')}
</button> </button>
{/if} {/if}
{#if vaultOpen && canToggle} {#if vaultOpen && canToggle}
{#if isDisabled} {#if isDisabled}
<button class="btn-enable" on:click={() => onEnable(m.id)} type="button"> <button class="btn-enable" on:click={() => onEnable(m.id)} type="button" disabled={isBusy}>
▶ Enable {#if busyAction === 'enabling'}{tr('pluginCard.enabling')}{:else}{tr('pluginCard.enable')}{/if}
</button> </button>
{:else} {:else}
<button class="btn-disable" on:click={() => onDisable(m.id)} type="button"> <button class="btn-disable" on:click={() => onDisable(m.id)} type="button" disabled={isBusy}>
⏸ Disable {#if busyAction === 'disabling'}{tr('pluginCard.disabling')}{:else}{tr('pluginCard.disable')}{/if}
</button> </button>
{/if} {/if}
{/if} {/if}
{#if !vaultOpen && canToggle} {#if !vaultOpen && canToggle}
<span class="vault-hint">Open a vault to manage plugin state</span> <span class="vault-hint">{tr('pluginCard.openVault')}</span>
{/if} {/if}
</div> </div>
<!-- Permission warnings --> <!-- Permission warnings -->
{#if !hasUIPermission && (m.contributes && (m.contributes.views || m.contributes.sidebarItems || m.contributes.settingsPanels).length > 0)} {#if !hasUIPermission && m.contributes && ((m.contributes.views || []).length > 0 || (m.contributes.sidebarItems || []).length > 0 || (m.contributes.settingsPanels || []).length > 0)}
<p class="warning">⚠ Plugin has UI contributions but lacks ui.register permission</p> <p class="warning"><Icon name="warning" size={12} /> {tr('pluginCard.missingUiPermission')}</p>
{/if} {/if}
</div> </div>
@@ -206,6 +230,7 @@
border: 1px solid #0f3460; border: 1px solid #0f3460;
border-radius: 8px; border-radius: 8px;
padding: 1rem; padding: 1rem;
min-width: 0;
} }
.plugin-card.disabled { .plugin-card.disabled {
@@ -220,6 +245,8 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
@@ -227,6 +254,12 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
min-width: 0;
flex-wrap: wrap;
}
.plugin-id strong {
overflow-wrap: anywhere;
} }
.status-dot { .status-dot {
@@ -263,15 +296,34 @@
.card-meta { .card-meta {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.3rem; gap: 0.3rem;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
font-size: 0.8rem; font-size: 0.8rem;
} }
.technical-meta {
margin-top: 0.55rem;
}
.plugin-details {
margin: 0.6rem 0;
padding: 0.45rem 0;
border-top: 1px solid rgba(15, 52, 96, 0.75);
border-bottom: 1px solid rgba(15, 52, 96, 0.75);
}
.plugin-details summary {
cursor: pointer;
color: #8b8ba8;
font-size: 0.78rem;
font-weight: 600;
}
.meta-row { .meta-row {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
min-width: 0;
} }
.label { .label {
@@ -283,6 +335,8 @@
font-family: monospace; font-family: monospace;
font-size: 0.75rem; font-size: 0.75rem;
color: #a0a0b8; color: #a0a0b8;
min-width: 0;
overflow-wrap: anywhere;
} }
.section { .section {
@@ -311,6 +365,8 @@
font-size: 0.75rem; font-size: 0.75rem;
font-family: monospace; font-family: monospace;
color: #e0e0e0; color: #e0e0e0;
max-width: 100%;
overflow-wrap: anywhere;
} }
.tag.provides { .tag.provides {
@@ -341,7 +397,7 @@
} }
.check { color: #4ecca3; margin-left: 2px; } .check { color: #4ecca3; margin-left: 2px; }
.danger-icon { color: #e94560; margin-left: 2px; } :global(.danger-icon) { color: #e94560; margin-left: 2px; vertical-align: middle; }
.info { .info {
color: #ffc857; color: #ffc857;
@@ -370,12 +426,16 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
flex-wrap: wrap;
margin-top: 0.75rem; margin-top: 0.75rem;
padding-top: 0.5rem; padding-top: 0.5rem;
border-top: 1px solid #0f3460; border-top: 1px solid #0f3460;
} }
.btn-settings { .btn-settings {
display: inline-flex;
align-items: center;
gap: 0.3rem;
background: #0f3460; background: #0f3460;
border: 1px solid #1a3a5c; border: 1px solid #1a3a5c;
color: #e0e0f0; color: #e0e0f0;
@@ -424,4 +484,19 @@
font-size: 0.75rem; font-size: 0.75rem;
font-style: italic; font-style: italic;
} }
@media (max-width: 760px) {
.card-meta {
grid-template-columns: 1fr;
}
.meta-row {
flex-direction: column;
gap: 0.15rem;
}
.label {
min-width: 0;
}
}
</style> </style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,415 @@
<script>
import { onDestroy, onMount, tick } from 'svelte';
import * as App from '../../../wailsjs/go/api/App';
import { executePluginCommand } from '../plugin-host/VerstakPluginAPI.js';
import { i18n } from '../i18n/index.js';
import { debug } from '../log/debug.js';
let open = false;
let query = '';
let commands = [];
let selectedIndex = 0;
let inputEl = null;
let statusMessage = '';
let statusType = '';
let locale = i18n.getLocale();
let unsubscribeLocale = null;
$: tr = ((activeLocale) => (key, params, fallback) => {
void activeLocale;
return i18n.t(key, params, fallback);
})(locale);
const inactiveStatuses = new Set(['disabled', 'failed', 'incompatible', 'missing-required-capability']);
$: shellCommands = [
{ id: 'verstak.shell.open-overview', title: tr('command.openOverview'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 10, shellAction: 'overview' },
{ id: 'verstak.shell.open-files', title: tr('command.openFiles'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 20, shellAction: 'files' },
{ id: 'verstak.shell.open-activity', title: tr('command.openActivity'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 30, shellAction: 'activity' },
{ id: 'verstak.shell.open-browser-inbox', title: tr('command.openBrowserInbox'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 40, shellAction: 'browser-inbox' },
{ id: 'verstak.shell.create-markdown', title: tr('command.createMarkdown'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 50, shellAction: 'create-markdown' },
{ id: 'verstak.shell.create-text', title: tr('command.createText'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 60, shellAction: 'create-text' },
{ id: 'verstak.shell.sync-now', title: tr('command.syncNow'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 70, shellAction: 'sync-now' },
{ id: 'verstak.shell.open-sync-settings', title: tr('command.openSyncSettings'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 80, shellAction: 'sync-settings' },
{ id: 'verstak.shell.open-plugin-manager', title: tr('command.openPluginManager'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 90, shellAction: 'plugin-manager' },
];
$: normalizedQuery = query.trim().toLowerCase();
$: filteredCommands = commands.filter((command) => {
if (!normalizedQuery) return true;
return [
command.title,
command.id,
command.pluginId,
command.pluginName,
].filter(Boolean).join(' ').toLowerCase().includes(normalizedQuery);
});
$: if (selectedIndex >= filteredCommands.length) {
selectedIndex = Math.max(0, filteredCommands.length - 1);
}
async function loadCommands() {
const [plugins, contributions] = await Promise.all([
App.GetPlugins().catch(() => []),
App.GetContributions().catch(() => ({})),
]);
const pluginById = new Map((plugins || []).map((plugin) => [plugin.manifest?.id, plugin]));
await Promise.all((plugins || []).map((plugin) => i18n.loadPlugin(
plugin.manifest?.id,
plugin.manifest?.localization,
).catch(() => {})));
const localizedPlugins = (plugins || []).map((plugin) => i18n.localizePlugin(plugin));
const localizedPluginById = new Map(localizedPlugins.map((plugin) => [plugin.manifest?.id, plugin]));
const localizedContributions = i18n.localizeContributionSummary(contributions || {});
const pluginCommands = (localizedContributions.commands || [])
.filter((command) => {
const plugin = pluginById.get(command.pluginId);
if (!plugin) return false;
return !inactiveStatuses.has(plugin.status);
})
.map((command) => {
const plugin = localizedPluginById.get(command.pluginId);
return {
...command,
pluginName: plugin?.manifest?.name || command.pluginId,
priority: 1000,
};
});
commands = [...shellCommands, ...pluginCommands].sort((a, b) => {
const priority = (a.priority || 1000) - (b.priority || 1000);
if (priority) return priority;
const title = String(a.title || a.id).localeCompare(String(b.title || b.id));
if (title) return title;
return String(a.pluginId).localeCompare(String(b.pluginId));
});
}
async function openPalette() {
await loadCommands();
query = '';
selectedIndex = 0;
open = true;
await tick();
inputEl?.focus();
}
function closePalette() {
open = false;
query = '';
selectedIndex = 0;
}
function setStatus(type, message) {
statusType = type;
statusMessage = message;
window.clearTimeout(setStatus.timer);
setStatus.timer = window.setTimeout(() => {
statusType = '';
statusMessage = '';
}, 4000);
}
function clickWorkspaceTool(label) {
const tabs = Array.from(document.querySelectorAll('.workspace-tabs [role="tab"]'));
const tab = tabs.find((node) => String(node.textContent || '').trim().toLowerCase() === label);
if (tab) {
tab.click();
return true;
}
return false;
}
async function openWorkspaceTool(label) {
if (clickWorkspaceTool(label)) return;
const selectedWorkspace = document.querySelector('.wt-node.selected .wt-label');
const firstWorkspace = document.querySelector('.wt-label');
const workspaceButton = selectedWorkspace || firstWorkspace;
if (workspaceButton) {
workspaceButton.click();
await tick();
await new Promise((resolve) => requestAnimationFrame(resolve));
}
clickWorkspaceTool(label);
}
async function startFilesCreate(action) {
await openWorkspaceTool('files');
await tick();
await new Promise((resolve) => requestAnimationFrame(resolve));
const button = document.querySelector(`[data-files-action="${action}"]`);
if (!button) {
throw new Error(`Files action not available: ${action}`);
}
button.click();
}
async function runShellCommand(command) {
if (command.shellAction === 'plugin-manager') {
window.dispatchEvent(new CustomEvent('verstak:open-settings', { detail: {} }));
return;
}
if (command.shellAction === 'sync-settings') {
window.dispatchEvent(new CustomEvent('verstak:open-settings', {
detail: { pluginId: 'verstak.sync', panelId: 'verstak.sync.settings' }
}));
return;
}
if (command.shellAction === 'sync-now') {
const result = await App.PluginSyncNow('verstak.sync');
if (typeof result === 'string' && result) {
throw new Error(result);
}
if (Array.isArray(result) && result[1]) {
throw new Error(result[1]);
}
return;
}
if (command.shellAction === 'create-markdown') {
await startFilesCreate('new-markdown');
return;
}
if (command.shellAction === 'create-text') {
await startFilesCreate('new-text');
return;
}
const actionToTab = {
overview: 'overview',
files: 'files',
activity: 'activity',
'browser-inbox': 'browser inbox',
};
await openWorkspaceTool(actionToTab[command.shellAction] || command.shellAction);
}
async function runCommand(command) {
if (!command) return;
try {
if (command.shellAction) {
await runShellCommand(command);
closePalette();
setStatus('success', tr('command.handled', { title: command.title || command.id }));
return;
}
const result = await executePluginCommand(command.pluginId, command.id, {
source: 'command-palette',
});
closePalette();
setStatus('success', tr('command.result', { title: command.title || command.id, status: result.status || tr('command.statusHandled') }));
} catch (err) {
const details = err?.message || String(err);
debug.log('[CommandPalette] command failed:', details);
App.WriteFrontendLog('CommandPalette', `command failed: ${details}`).catch(() => {});
setStatus('error', tr('command.failed', {
title: command.title || command.id,
}, `Could not run ${command.title || command.id}. Please try again.`));
}
}
function moveSelection(delta) {
if (filteredCommands.length === 0) return;
selectedIndex = (selectedIndex + delta + filteredCommands.length) % filteredCommands.length;
}
function onWindowKeydown(event) {
const key = event.key || '';
const comboOpen = (event.ctrlKey || event.metaKey) && (key.toLowerCase() === 'k' || (event.shiftKey && key.toLowerCase() === 'p'));
if (!open && comboOpen) {
event.preventDefault();
openPalette();
return;
}
if (!open) return;
if (key === 'Escape') {
event.preventDefault();
closePalette();
} else if (key === 'ArrowDown') {
event.preventDefault();
moveSelection(1);
} else if (key === 'ArrowUp') {
event.preventDefault();
moveSelection(-1);
} else if (key === 'Enter') {
event.preventDefault();
runCommand(filteredCommands[selectedIndex]);
}
}
function onOverlayMouseDown(event) {
if (event.target === event.currentTarget) closePalette();
}
if (typeof window !== 'undefined') {
window.addEventListener('keydown', onWindowKeydown);
}
onMount(() => {
unsubscribeLocale = i18n.subscribe((nextLocale) => {
const changed = locale !== nextLocale;
locale = nextLocale;
if (changed && open) loadCommands();
});
});
onDestroy(() => {
unsubscribeLocale?.();
if (typeof window !== 'undefined') {
window.removeEventListener('keydown', onWindowKeydown);
window.clearTimeout(setStatus.timer);
}
});
</script>
{#if statusMessage}
<div
class="command-palette-toast"
data-command-palette-status={statusType}
>
{statusMessage}
</div>
{/if}
{#if open}
<div class="command-palette-overlay" role="presentation" on:mousedown={onOverlayMouseDown}>
<section class="command-palette" role="dialog" aria-modal="true" aria-label={tr('command.palette')}>
<input
bind:this={inputEl}
bind:value={query}
class="command-palette-input"
data-command-palette-input
placeholder={tr('command.run')}
aria-label={tr('command.run')}
/>
<div class="command-palette-list" role="listbox">
{#if filteredCommands.length === 0}
<div class="command-palette-empty">{tr('command.none')}</div>
{:else}
{#each filteredCommands as command, index}
<button
type="button"
class:selected={index === selectedIndex}
class="command-palette-item"
data-command-id={command.id}
on:mouseenter={() => selectedIndex = index}
on:click={() => runCommand(command)}
>
<span class="command-palette-title">{command.title || command.id}</span>
<span class="command-palette-meta">{command.pluginName} · {command.id}</span>
</button>
{/each}
{/if}
</div>
</section>
</div>
{/if}
<style>
.command-palette-overlay {
position: fixed;
inset: 0;
z-index: 10000;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 10vh 1rem 1rem;
background: rgba(0, 0, 0, 0.48);
}
.command-palette {
width: min(640px, 100%);
max-height: min(560px, 80vh);
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid #254466;
border-radius: 8px;
background: #101526;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.45);
}
.command-palette-input {
width: 100%;
min-height: 3rem;
padding: 0.75rem 1rem;
border: 0;
border-bottom: 1px solid #213650;
background: #0b1020;
color: #f4f7fb;
font: inherit;
font-size: 1rem;
outline: none;
}
.command-palette-list {
min-height: 0;
overflow: auto;
padding: 0.35rem;
}
.command-palette-item {
width: 100%;
min-height: 3rem;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.18rem;
padding: 0.55rem 0.75rem;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: #e0e0f0;
text-align: left;
}
.command-palette-item:hover,
.command-palette-item.selected {
border-color: #4ecca3;
background: #17243a;
}
.command-palette-title {
font-size: 0.9rem;
font-weight: 650;
}
.command-palette-meta {
max-width: 100%;
overflow: hidden;
color: #8da2bd;
font-size: 0.74rem;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.command-palette-empty {
padding: 1.25rem;
color: #8da2bd;
font-size: 0.9rem;
text-align: center;
}
.command-palette-toast {
position: fixed;
right: 1rem;
bottom: 1rem;
z-index: 10001;
max-width: min(420px, calc(100vw - 2rem));
padding: 0.7rem 0.9rem;
border: 1px solid #254466;
border-radius: 8px;
background: #101526;
color: #e0e0f0;
font-size: 0.85rem;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.35);
}
.command-palette-toast[data-command-palette-status="success"] {
border-color: #4ecca3;
}
.command-palette-toast[data-command-palette-status="error"] {
border-color: #e74c3c;
color: #ffd6d1;
}
</style>
+443
View File
@@ -0,0 +1,443 @@
<script>
import { onMount } from 'svelte';
import * as App from '../../../wailsjs/go/api/App';
import Icon from '../ui/Icon.svelte';
import { i18n } from '../i18n/index.js';
const TEXT_EXTENSIONS = new Set(['txt', 'md', 'markdown', 'log', 'json', 'csv', 'yaml', 'yml', 'toml']);
const FILE_INDEX_LIMIT = 220;
const RESULT_LIMIT = 8;
const RU = 'ёйцукенгшщзхъфывапролджэячсмитьбю';
const EN = '`qwertyuiop[]asdfghjkl;\\zxcvbnm,.';
let query = '';
let index = [];
let results = [];
let focused = false;
let loading = true;
let searchTimer = null;
let buildSeq = 0;
let locale = i18n.getLocale();
$: tr = ((activeLocale) => (key, params, fallback) => {
void activeLocale;
return i18n.t(key, params, fallback);
})(locale);
$: scheduleSearch(query);
onMount(() => {
const unsubscribeLocale = i18n.subscribe((nextLocale) => {
const changed = locale !== nextLocale;
locale = nextLocale;
if (changed) buildIndex();
});
buildIndex();
return () => {
unsubscribeLocale();
clearTimeout(searchTimer);
};
});
function normalize(value) {
return String(value == null ? '' : value).trim().toLowerCase();
}
function swapLayout(value, from, to) {
return String(value || '').split('').map(ch => {
const lower = ch.toLowerCase();
const idx = from.indexOf(lower);
if (idx === -1) return ch;
const mapped = to[idx] || ch;
return ch === lower ? mapped : mapped.toUpperCase();
}).join('');
}
function queryVariants(value) {
const base = normalize(value);
return [...new Set([
base,
normalize(swapLayout(base, RU, EN)),
normalize(swapLayout(base, EN, RU)),
].filter(Boolean))];
}
function matchScore(item, variants) {
const haystack = normalize(`${item.title} ${item.subtitle || ''} ${item.keywords || ''}`);
for (const variant of variants) {
if (!variant) continue;
if (normalize(item.title) === variant) return 100;
if (normalize(item.title).startsWith(variant)) return 80;
if (haystack.includes(variant)) return 50;
}
return 0;
}
function scheduleSearch(value) {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => runSearch(value), 80);
}
function runSearch(value) {
const variants = queryVariants(value);
if (!variants.length) {
results = [];
return;
}
results = index
.map(item => ({ item, score: matchScore(item, variants) }))
.filter(row => row.score > 0)
.sort((a, b) => b.score - a.score || a.item.rank - b.item.rank || a.item.title.localeCompare(b.item.title))
.slice(0, RESULT_LIMIT)
.map(row => row.item);
}
function workspaceTitle(node) {
return node?.title || node?.name || node?.id || node?.rootPath || '';
}
function workspaceName(node) {
return node?.name || node?.id || node?.rootPath || '';
}
async function resultOrEmpty(promise, fallback) {
try {
const response = await promise;
if (Array.isArray(response) && response.length === 2) return response[1] ? fallback : response[0];
return response || fallback;
} catch (_) {
return fallback;
}
}
async function listFilesRecursive(dir = '', depth = 0, acc = []) {
if (acc.length >= FILE_INDEX_LIMIT || depth > 5) return acc;
const entries = await resultOrEmpty(App.ListVaultFiles('verstak.search', dir), []);
for (const entry of entries || []) {
if (acc.length >= FILE_INDEX_LIMIT) break;
const path = entry.relativePath || entry.path || entry.name || '';
if (!path) continue;
acc.push(entry);
if (entry.type === 'folder') await listFilesRecursive(path, depth + 1, acc);
}
return acc;
}
async function readFileSnippet(path) {
const ext = String(path).split('.').pop().toLowerCase();
if (!TEXT_EXTENSIONS.has(ext)) return '';
const text = await resultOrEmpty(App.ReadVaultTextFile('verstak.search', path), '');
return String(text || '').slice(0, 900);
}
function pluginToolKind(pluginId, label) {
if (pluginId === 'verstak.browser-inbox') return 'browser-inbox';
if (pluginId === 'verstak.activity') return 'activity';
if (pluginId === 'verstak.journal') return 'journal';
return String(label || pluginId || '').toLowerCase();
}
async function indexPluginSettings(pluginId, label, rank, view, nodes) {
const settings = await resultOrEmpty(App.ReadPluginSettings(pluginId), {});
const items = [];
Object.keys(settings || {}).forEach(key => {
const value = settings[key];
const rows = Array.isArray(value) ? value : [];
rows.forEach(row => {
if (!row || typeof row !== 'object') return;
const title = row.title || row.summary || row.url || row.captureId || row.activityId || row.entryId || label;
const workspaceName = row.workspaceRootPath || row.workspaceName || '';
items.push({
type: label,
title,
subtitle: row.url || row.summary || row.workspaceRootPath || key,
keywords: JSON.stringify(row),
rank,
action: workspaceName ? 'workspace-tool' : (view ? 'view' : ''),
viewId: view?.id || '',
pluginId,
workspaceName,
toolKind: pluginToolKind(pluginId, label),
nodes,
});
});
});
return items;
}
async function buildIndex() {
const seq = ++buildSeq;
loading = true;
const next = [];
const tree = await resultOrEmpty(App.GetWorkspaceTree(), { nodes: [] });
const nodes = Array.isArray(tree.nodes) ? tree.nodes : [];
nodes.forEach(node => {
next.push({
type: 'Workspace',
typeLabel: tr('search.type.workspace'),
title: workspaceTitle(node),
subtitle: tr('search.type.workspace'),
keywords: `${node.id || ''} ${node.rootPath || ''}`,
rank: 10,
action: 'workspace',
workspaceName: workspaceName(node),
nodes,
});
});
const [rawPlugins, rawContributions] = await Promise.all([
resultOrEmpty(App.GetPlugins(), []),
resultOrEmpty(App.GetContributions(), {}),
]);
await Promise.all((rawPlugins || []).map((plugin) => i18n.loadPlugin(
plugin.manifest?.id,
plugin.manifest?.localization,
).catch(() => {})));
const contributions = i18n.localizeContributionSummary(rawContributions || {});
const viewByPluginId = new Map();
(contributions.views || []).forEach(view => {
if (view.pluginId && !viewByPluginId.has(view.pluginId)) viewByPluginId.set(view.pluginId, view);
});
(contributions.sidebarItems || []).forEach(item => {
next.push({
type: 'Tool',
typeLabel: tr('search.type.tool'),
title: item.title || item.id,
subtitle: item.pluginId || '',
keywords: `${item.id || ''} ${item.view || ''}`,
rank: 20,
action: 'view',
viewId: item.view || item.id,
pluginId: item.pluginId,
});
});
const files = await listFilesRecursive();
for (const entry of files) {
const path = entry.relativePath || entry.path || entry.name || '';
const snippet = await readFileSnippet(path);
next.push({
type: entry.type === 'folder' ? 'Folder' : 'File',
typeLabel: tr(entry.type === 'folder' ? 'search.type.folder' : 'search.type.file'),
title: path.split('/').pop() || path,
subtitle: path,
keywords: snippet,
rank: entry.type === 'folder' ? 30 : 40,
action: entry.type === 'folder' ? 'file-folder' : 'file',
path,
nodes,
});
}
const pluginItems = await Promise.all([
indexPluginSettings('verstak.journal', tr('search.type.journal'), 50, viewByPluginId.get('verstak.journal'), nodes),
indexPluginSettings('verstak.browser-inbox', tr('search.type.browserInbox'), 55, viewByPluginId.get('verstak.browser-inbox'), nodes),
indexPluginSettings('verstak.activity', tr('search.type.activity'), 60, viewByPluginId.get('verstak.activity'), nodes),
]);
if (seq !== buildSeq) return;
index = next.concat(pluginItems.flat());
loading = false;
runSearch(query);
}
function handleFocus() {
focused = true;
buildIndex();
}
async function openResult(item) {
query = '';
results = [];
if (item.action === 'workspace') {
window.dispatchEvent(new CustomEvent('verstak:workspace-selected', {
detail: { workspaceName: item.workspaceName, nodes: item.nodes || [] }
}));
return;
}
if (item.action === 'view') {
window.dispatchEvent(new CustomEvent('verstak:open-view', {
detail: { viewId: item.viewId, pluginId: item.pluginId }
}));
return;
}
if (item.action === 'workspace-tool') {
const workspaceName = item.workspaceName || '';
if (workspaceName) {
window.dispatchEvent(new CustomEvent('verstak:workspace-selected', {
detail: { workspaceName, nodes: item.nodes || [] }
}));
window.dispatchEvent(new CustomEvent('verstak:workspace-open-tool', {
detail: { kind: item.toolKind || item.type || '' }
}));
}
return;
}
if (item.action === 'file-folder') {
const parts = String(item.path || '').split('/').filter(Boolean);
const workspaceName = parts[0] || '';
const localPath = parts.slice(1).join('/');
if (workspaceName) {
window.__filesHistoryByWorkspace = window.__filesHistoryByWorkspace || {};
window.__filesHistoryByWorkspace[workspaceName] = {
stack: [localPath],
index: 0,
currentPath: localPath,
};
const detail = { workspaceName };
if (Array.isArray(item.nodes) && item.nodes.length > 0) detail.nodes = item.nodes;
window.dispatchEvent(new CustomEvent('verstak:workspace-selected', {
detail
}));
window.dispatchEvent(new CustomEvent('verstak:workspace-open-tool', {
detail: { kind: 'files' }
}));
}
return;
}
if (item.action === 'file') {
const response = await App.OpenWorkbenchResource('verstak.search', {
kind: 'vault-file',
path: item.path,
mode: 'view',
context: { sourceView: 'global-search' }
});
const [result, err] = Array.isArray(response) ? response : [response, ''];
if (!err && result) {
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
}
}
}
</script>
<div class="global-search" class:open={focused && (query || results.length)}>
<div class="global-search-box">
<Icon name="search" size={14} class="global-search-icon" />
<input
bind:value={query}
on:focus={handleFocus}
on:blur={() => setTimeout(() => focused = false, 120)}
type="search"
placeholder={loading ? tr('search.indexing') : tr('search.placeholder')}
aria-label={tr('search.global')}
data-global-search-input
/>
</div>
{#if focused && query}
<div class="global-search-results" data-global-search-results>
{#if results.length}
{#each results as item}
<button
type="button"
class="global-search-result"
data-global-search-result-type={item.type}
data-global-search-result-path={item.path || ''}
on:mousedown|preventDefault={() => openResult(item)}
>
<span class="global-search-result-title">{item.title}</span>
<span class="global-search-result-meta">{item.typeLabel || item.type} · {item.subtitle}</span>
</button>
{/each}
{:else}
<div class="global-search-empty vt-empty-title">{tr('search.noResults')}</div>
{/if}
</div>
{/if}
</div>
<style>
.global-search {
position: relative;
padding: 0.55rem 0.75rem;
border-bottom: 1px solid var(--vt-color-border);
flex-shrink: 0;
}
.global-search-box {
display: flex;
align-items: center;
gap: 0.35rem;
height: 2rem;
padding: 0 0.55rem;
border: 1px solid var(--vt-color-border-strong);
border-radius: var(--vt-radius-md);
background: #0f1424;
color: var(--vt-color-text-muted);
}
:global(.global-search-icon) {
color: var(--vt-color-text-muted);
flex-shrink: 0;
}
.global-search input {
width: 100%;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
color: var(--vt-color-text-primary);
font: inherit;
font-size: 0.78rem;
}
.global-search input::placeholder {
color: var(--vt-color-text-muted);
}
.global-search-box:focus-within {
border-color: var(--vt-color-accent);
box-shadow: var(--vt-focus-ring);
}
.global-search-results {
position: absolute;
left: 0.75rem;
right: 0.75rem;
top: calc(100% - 0.25rem);
z-index: 400;
max-height: 20rem;
overflow: auto;
border: 1px solid var(--vt-color-border-strong);
border-radius: var(--vt-radius-md);
background: var(--vt-color-surface);
box-shadow: var(--vt-elevation-menu);
}
.global-search-result {
display: flex;
flex-direction: column;
gap: 0.12rem;
width: 100%;
padding: 0.55rem 0.65rem;
border: 0;
border-bottom: 1px solid rgba(40, 70, 111, 0.55);
background: transparent;
color: var(--vt-color-text-primary);
text-align: left;
cursor: pointer;
}
.global-search-result:hover {
background: var(--vt-color-surface-hover);
}
.global-search-result-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.8rem;
}
.global-search-result-meta,
.global-search-empty {
color: var(--vt-color-text-muted);
font-size: 0.7rem;
}
.global-search-empty {
padding: 0.7rem;
}
</style>
+163 -70
View File
@@ -1,84 +1,136 @@
<script> <script>
import { onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import * as App from '../../../wailsjs/go/api/App'; import * as App from '../../../wailsjs/go/api/App';
import WorkspaceTree from './WorkspaceTree.svelte';
import GlobalSearch from './GlobalSearch.svelte';
import Icon from '../ui/Icon.svelte';
import { debug } from '../log/debug.js';
import { i18n } from '../i18n/index.js';
export let showGlobalSearch = true;
export let activeView = null;
export let activeViewPluginId = '';
function flog(msg) {
App.WriteFrontendLog('Sidebar', msg);
}
let plugins = []; let plugins = [];
let vaultStatus = { status: 'unknown', path: '', vaultId: '' }; let vaultStatus = { status: 'unknown', path: '', vaultId: '' };
let sidebarItems = []; let sidebarItems = [];
let errorMessage = '';
let locale = i18n.getLocale();
let unsubscribeLocale = null;
let navItems = [ $: tr = ((activeLocale) => (key, params, fallback) => {
{ id: 'plugin-manager', label: 'Plugin Manager', icon: '🧩' }, void activeLocale;
]; return i18n.t(key, params, fallback);
})(locale);
$: vaultOpen = vaultStatus.status === 'open'; $: vaultOpen = vaultStatus.status === 'open';
$: activeSidebarKey = activeView ? `${activeViewPluginId}:${activeView}` : '';
onMount(async () => { async function loadSidebar() {
debug.log('[Sidebar] onMount: START');
flog('onMount: START');
let contribErr = false;
try { try {
debug.log('[Sidebar] onMount: loading plugins/vault/contributions...');
flog('onMount: loading plugins/vault/contributions...');
const [p, v, contribs] = await Promise.all([ const [p, v, contribs] = await Promise.all([
App.GetPlugins().catch(() => []), App.GetPlugins().catch(() => []),
App.GetVaultStatus().catch(() => ({ status: 'unknown', path: '', vaultId: '' })), App.GetVaultStatus().catch(() => ({ status: 'unknown', path: '', vaultId: '' })),
App.GetContributions().catch(() => ({})), App.GetContributions().catch(() => { contribErr = true; return {}; }),
]); ]);
plugins = p || []; await Promise.all((p || []).map((plugin) => (
i18n.loadPlugin(plugin.manifest?.id, plugin.manifest?.localization).catch(() => {})
)));
plugins = (p || []).map((plugin) => i18n.localizePlugin(plugin));
const localizedContributions = i18n.localizeContributionSummary(contribs || {});
vaultStatus = v; vaultStatus = v;
sidebarItems = (contribs.sidebarItems || []).filter(item => { debug.log('[Sidebar] onMount: plugins=' + plugins.length + ' vault=' + vaultStatus.status);
flog('onMount: plugins=' + plugins.length + ' vault=' + vaultStatus.status);
if (contribErr) {
errorMessage = tr('sidebar.error.contributions');
}
sidebarItems = (localizedContributions.sidebarItems || []).filter(item => {
const plugin = plugins.find(p => p.manifest?.id === item.pluginId); const plugin = plugins.find(p => p.manifest?.id === item.pluginId);
if (!plugin) return false; if (!plugin) return false;
return plugin.status !== 'disabled' && plugin.status !== 'failed' && plugin.status !== 'incompatible' && plugin.status !== 'missing-required-capability'; return plugin.status !== 'disabled' && plugin.status !== 'failed' && plugin.status !== 'incompatible' && plugin.status !== 'missing-required-capability';
}); });
sidebarItems.sort((a, b) => (a.position || 100) - (b.position || 100));
debug.log('[Sidebar] onMount: sidebarItems=' + sidebarItems.length);
flog('onMount: sidebarItems=' + sidebarItems.length);
} catch (e) { } catch (e) {
debug.log('[Sidebar] onMount: ERROR:', String(e));
flog('onMount: ERROR: ' + String(e));
console.error('[Sidebar] load error:', e); console.error('[Sidebar] load error:', e);
errorMessage = tr('sidebar.error.load');
} }
}); debug.log('[Sidebar] onMount: END');
flog('onMount: END');
function handleNav(id) {
window.dispatchEvent(new CustomEvent('verstak:nav', { detail: { viewId: id } }));
} }
onMount(() => {
unsubscribeLocale = i18n.subscribe((nextLocale) => {
const changed = locale !== nextLocale;
locale = nextLocale;
if (changed) loadSidebar();
});
loadSidebar();
window.addEventListener('verstak:plugins-changed', loadSidebar);
});
onDestroy(() => {
if (unsubscribeLocale) unsubscribeLocale();
window.removeEventListener('verstak:plugins-changed', loadSidebar);
});
function handleSidebarItem(item) { function handleSidebarItem(item) {
window.dispatchEvent(new CustomEvent('verstak:open-view', { detail: { viewId: item.id } })); debug.log('[Sidebar] handleSidebarItem:', item.id, '-> view:', item.view);
// Use item.view (the view contribution ID) if available, fall back to item.id
const viewId = item.view || item.id;
window.dispatchEvent(new CustomEvent('verstak:open-view', { detail: { viewId, pluginId: item.pluginId } }));
} }
</script> </script>
<aside class="sidebar"> <aside class="sidebar">
<div class="sidebar-header"> <div class="sidebar-header">
<span class="sidebar-logo">📦</span> <Icon name="logo" size={20} class="sidebar-logo" />
<span class="sidebar-title">Verstak</span> <span class="sidebar-title">Verstak</span>
</div> </div>
<nav class="sidebar-nav"> {#if vaultOpen && showGlobalSearch}
{#each navItems as item} <GlobalSearch />
<button {/if}
class="nav-item"
on:click={() => handleNav(item.id)}
type="button"
>
<span class="nav-icon">{item.icon}</span>
<span class="nav-label">{item.label}</span>
</button>
{/each}
</nav>
{#if sidebarItems.length > 0} {#if sidebarItems.length > 0}
<div class="sidebar-section"> <div class="sidebar-section">
<span class="section-label">Plugins</span> <span class="section-label">{tr('sidebar.tools')}</span>
{#each sidebarItems as item} {#each sidebarItems as item}
<button <button
class="nav-item plugin-item" class="nav-item plugin-item vt-list-row"
class:is-active={activeSidebarKey === `${item.pluginId || ''}:${item.view || item.id}`}
aria-current={activeSidebarKey === `${item.pluginId || ''}:${item.view || item.id}` ? 'page' : undefined}
on:click={() => handleSidebarItem(item)} on:click={() => handleSidebarItem(item)}
type="button" type="button"
> >
<span class="nav-icon">{item.icon || '📌'}</span> <Icon name={item.icon || 'plugin'} size={16} class="nav-icon icon-plugin" />
<span class="nav-label">{item.label || item.id}</span> <span class="nav-label">{item.title || item.id}</span>
</button> </button>
{/each} {/each}
</div> </div>
{/if} {/if}
{#if vaultOpen}
<WorkspaceTree />
{/if}
<div class="sidebar-footer"> <div class="sidebar-footer">
{#if vaultStatus.status !== 'unknown'} {#if errorMessage}
<span class="vault-indicator" class:vault-open={vaultStatus.status === 'open'} class:vault-closed={vaultStatus.status !== 'open'}> <span class="sidebar-error">
● Vault: {vaultStatus.status} <Icon name="warning" size={10} class="sidebar-error-icon" />
{tr('sidebar.error.ui')}
</span> </span>
{/if} {/if}
</div> </div>
@@ -88,10 +140,10 @@
.sidebar { .sidebar {
width: 220px; width: 220px;
min-width: 220px; min-width: 220px;
background: #16213e; background: var(--vt-color-surface-muted);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
border-right: 1px solid #0f3460; border-right: 1px solid var(--vt-color-border);
overflow: hidden; overflow: hidden;
} }
@@ -100,69 +152,88 @@
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
padding: 1rem 1.25rem; padding: 1rem 1.25rem;
border-bottom: 1px solid #0f3460; border-bottom: 1px solid var(--vt-color-border);
} }
.sidebar-logo { :global(.sidebar-logo) {
font-size: 1.2rem; width: 1.2rem;
height: 1.2rem;
color: var(--vt-color-accent);
flex-shrink: 0;
} }
.sidebar-title { .sidebar-title {
color: #e0e0f0; color: var(--vt-color-text-primary);
font-size: 1rem; font-size: 1rem;
font-weight: 600; font-weight: 600;
} }
.sidebar-nav {
display: flex;
flex-direction: column;
padding: 0.5rem 0.75rem;
gap: 0.15rem;
}
.sidebar-section { .sidebar-section {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 0.5rem 0.75rem; padding: 0.45rem 0.6rem 0.55rem;
gap: 0.15rem; gap: 0.15rem;
border-top: 1px solid #0f3460; border-bottom: 1px solid var(--vt-color-border);
margin-top: 0.25rem; }
:global(workspace-tree) {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
} }
.section-label { .section-label {
color: #666; color: var(--vt-color-text-muted);
font-size: 0.7rem; font-size: 0.7rem;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
padding: 0.25rem 0.5rem; padding: 0.25rem 0.45rem 0.35rem;
font-weight: 600;
} }
.nav-item { .nav-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.6rem; justify-content: flex-start;
padding: 0.45rem 0.75rem; gap: 0.45rem;
min-height: 1.85rem;
padding: 0.15rem 0.45rem;
background: none; background: none;
border: none; border: none;
color: #a0a0b8; color: var(--vt-color-text-secondary);
font-size: 0.85rem; font-size: 0.78rem;
cursor: pointer; cursor: pointer;
border-radius: 6px; border-radius: var(--vt-radius-sm);
text-align: left; text-align: left;
width: 100%; width: 100%;
transition: background 0.15s, color 0.15s; transition: background 0.15s, color 0.15s;
} }
.nav-item:hover { .nav-item:hover {
background: #0f3460; background: var(--vt-color-surface-hover);
color: #e0e0f0; color: var(--vt-color-text-primary);
} }
.nav-icon { .nav-item.is-active {
font-size: 1rem; background: var(--vt-color-surface-selected);
color: var(--vt-color-accent);
}
.nav-item.is-active :global(.nav-icon.icon-plugin) {
color: currentColor;
opacity: 1;
}
:global(.nav-icon) {
width: 0.9rem;
height: 0.9rem;
flex-shrink: 0; flex-shrink: 0;
width: 1.2rem; color: currentColor;
text-align: center; }
:global(.nav-icon.icon-plugin) {
color: var(--vt-color-text-muted);
opacity: 0.9;
} }
.nav-label { .nav-label {
@@ -174,19 +245,41 @@
.sidebar-footer { .sidebar-footer {
margin-top: auto; margin-top: auto;
padding: 0.75rem 1.25rem; padding: 0.75rem 1.25rem;
border-top: 1px solid #0f3460; border-top: 1px solid var(--vt-color-border);
} }
.vault-indicator { .sidebar-error {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.7rem; font-size: 0.7rem;
color: #666; color: var(--vt-color-danger);
margin-bottom: 0.25rem;
}
:global(.sidebar-error-icon) {
color: var(--vt-color-danger);
} }
.vault-indicator.vault-open { @media (max-width: 720px) {
color: #4ecca3; .sidebar {
} width: 100%;
min-width: 0;
max-height: 14rem;
border-right: 0;
border-bottom: 1px solid var(--vt-color-border);
}
.vault-indicator.vault-closed { .sidebar-header {
color: #a0a0b8; padding: 0.75rem 1rem;
}
.sidebar-section {
max-height: 5.5rem;
overflow: auto;
}
.sidebar-footer {
display: none;
}
} }
</style> </style>
+395
View File
@@ -0,0 +1,395 @@
<script>
import { onDestroy, onMount } from 'svelte';
import * as App from '../../../wailsjs/go/api/App';
import Icon from '../ui/Icon.svelte';
import { i18n } from '../i18n/index.js';
let items = [];
let settingsPanels = [];
let vaultStatus = { status: 'unknown', path: '', vaultId: '' };
let settingsOpen = false;
let locale = i18n.getLocale();
let selectedLanguage = i18n.getLanguagePreference();
let unsubscribeLocale = null;
$: tr = ((activeLocale) => (key, params, fallback) => {
void activeLocale;
return i18n.t(key, params, fallback);
})(locale);
$: leftItems = items.filter((item) => item.position === 'left');
$: centerItems = items.filter((item) => item.position === 'center');
$: rightItems = items.filter((item) => item.position === 'right');
$: vaultOpen = vaultStatus.status === 'open';
$: vaultStatusLabel = tr(`vault.status.${vaultStatus.status || 'unknown'}`, undefined, vaultStatus.status || 'unknown');
$: vaultLabel = tr('vault.label', { status: vaultStatusLabel });
const inactiveStatuses = new Set(['disabled', 'failed', 'incompatible', 'missing-required-capability']);
async function loadStatusBar() {
const [rawPlugins, rawContributions, vault] = await Promise.all([
App.GetPlugins().catch(() => []),
App.GetContributions().catch(() => ({})),
App.GetVaultStatus().catch(() => ({ status: 'unknown', path: '', vaultId: '' })),
]);
await Promise.all((rawPlugins || []).map((plugin) => (
i18n.loadPlugin(plugin.manifest?.id, plugin.manifest?.localization).catch(() => {})
)));
const plugins = (rawPlugins || []).map((plugin) => i18n.localizePlugin(plugin));
const contributions = i18n.localizeContributionSummary(rawContributions || {});
const pluginById = new Map((plugins || []).map((plugin) => [plugin.manifest?.id, plugin]));
vaultStatus = vault || { status: 'unknown', path: '', vaultId: '' };
items = (contributions.statusBarItems || [])
.filter((item) => {
const plugin = pluginById.get(item.pluginId);
if (!plugin) return false;
return !inactiveStatuses.has(plugin.status);
})
.map((item) => ({
...item,
position: item.position || 'left',
}));
settingsPanels = (contributions.settingsPanels || [])
.filter((panel) => {
const plugin = pluginById.get(panel.pluginId);
if (!plugin) return false;
return !inactiveStatuses.has(plugin.status);
})
.sort((a, b) => String(a.title || a.id).localeCompare(String(b.title || b.id)));
}
function openPluginManager() {
settingsOpen = false;
window.dispatchEvent(new CustomEvent('verstak:close-settings'));
window.dispatchEvent(new CustomEvent('verstak:nav', { detail: { viewId: 'plugin-manager' } }));
}
function openSettingsPanel(panel) {
settingsOpen = false;
window.dispatchEvent(new CustomEvent('verstak:open-settings', {
detail: { pluginId: panel.pluginId, panelId: panel.id }
}));
}
async function selectLanguage(language) {
const err = await App.UpdateAppSettings({ language });
if (err) {
throw new Error(err);
}
await i18n.setLanguagePreference(language);
selectedLanguage = language;
settingsOpen = false;
}
function toggleSettings(event) {
event.stopPropagation();
settingsOpen = !settingsOpen;
}
function closeSettings() {
settingsOpen = false;
}
onMount(() => {
unsubscribeLocale = i18n.subscribe((nextLocale) => {
const changed = locale !== nextLocale;
locale = nextLocale;
selectedLanguage = i18n.getLanguagePreference();
if (changed) loadStatusBar();
});
loadStatusBar();
window.addEventListener('verstak:plugins-changed', loadStatusBar);
window.addEventListener('verstak:vault-opened', loadStatusBar);
window.addEventListener('click', closeSettings);
});
onDestroy(() => {
if (unsubscribeLocale) unsubscribeLocale();
window.removeEventListener('verstak:plugins-changed', loadStatusBar);
window.removeEventListener('verstak:vault-opened', loadStatusBar);
window.removeEventListener('click', closeSettings);
});
</script>
<footer class="status-bar" aria-label={tr('statusBar.label')}>
<div class="status-bar-group status-left">
<span
class="vault-status"
class:vault-open={vaultOpen}
class:vault-closed={!vaultOpen}
title={vaultStatus.path || vaultStatus.vaultId || vaultLabel}
>
<Icon name="vault" size={13} class="status-icon" />
{vaultLabel}
</span>
{#each leftItems as item}
<span
class:status-bar-warning={item.handler}
class="status-bar-item"
data-status-item-id={item.id}
title={item.handler ? `${item.pluginId}: compact status only` : item.pluginId}
>
{#if item.handler}<Icon name="warning" size={11} class="status-warning-icon" />{/if}
{item.label || item.id}
</span>
{/each}
</div>
<div class="status-bar-group status-center">
{#each centerItems as item}
<span
class:status-bar-warning={item.handler}
class="status-bar-item"
data-status-item-id={item.id}
title={item.handler ? `${item.pluginId}: compact status only` : item.pluginId}
>
{#if item.handler}<Icon name="warning" size={11} class="status-warning-icon" />{/if}
{item.label || item.id}
</span>
{/each}
</div>
<div class="status-bar-group status-right">
{#each rightItems as item}
<span
class:status-bar-warning={item.handler}
class="status-bar-item"
data-status-item-id={item.id}
title={item.handler ? `${item.pluginId}: compact status only` : item.pluginId}
>
{#if item.handler}<Icon name="warning" size={11} class="status-warning-icon" />{/if}
{item.label || item.id}
</span>
{/each}
<div class="settings-menu-wrap">
<button
class="settings-button"
class:active={settingsOpen}
type="button"
title={tr('settings.title')}
aria-haspopup="menu"
aria-expanded={settingsOpen}
data-settings-menu-button
on:click={toggleSettings}
>
<Icon name="settings" size={14} class="settings-icon" />
<Icon name="chevronDown" size={12} class="settings-chevron" />
</button>
{#if settingsOpen}
<div class="settings-menu" role="menu">
<div class="settings-menu-heading">{tr('settings.language')}</div>
{#each ['system', 'en', 'ru'] as language}
<button
class="settings-menu-item language-item"
class:active-language={selectedLanguage === language}
type="button"
role="menuitemradio"
aria-checked={selectedLanguage === language}
data-settings-language={language}
on:click={() => selectLanguage(language)}
>
<span class="language-check" aria-hidden="true">{selectedLanguage === language ? '✓' : ''}</span>
<span>{tr(`settings.language.${language}`)}</span>
</button>
{/each}
<div class="settings-menu-separator"></div>
<button
class="settings-menu-item"
type="button"
role="menuitem"
data-settings-action="plugin-manager"
on:click={openPluginManager}
>
<Icon name="puzzle" size={14} class="settings-menu-icon" />
<span>{tr('settings.pluginManager')}</span>
</button>
{#if settingsPanels.length > 0}
<div class="settings-menu-separator"></div>
{#each settingsPanels as panel}
<button
class="settings-menu-item"
type="button"
role="menuitem"
data-settings-panel-id={panel.id}
title={panel.pluginId}
on:click={() => openSettingsPanel(panel)}
>
<Icon name={panel.icon || 'settings'} size={14} class="settings-menu-icon" />
<span>{panel.title || panel.id}</span>
</button>
{/each}
{/if}
</div>
{/if}
</div>
</div>
</footer>
<style>
.status-bar {
position: relative;
z-index: 100;
min-height: 1.7rem;
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center;
gap: 0.5rem;
padding: 0.2rem 0.65rem;
border-top: 1px solid #16213e;
background: #111629;
color: #9fb2ca;
font-size: 0.74rem;
}
.status-bar-group {
min-width: 0;
display: flex;
align-items: center;
gap: 0.4rem;
overflow: hidden;
}
.status-center {
justify-content: center;
}
.status-right {
justify-content: flex-end;
}
.status-bar-item {
display: inline-flex;
align-items: center;
gap: 0.25rem;
max-width: 18rem;
overflow: hidden;
padding: 0.12rem 0.35rem;
border-radius: 4px;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-bar-warning {
color: #ffc857;
}
:global(.status-warning-icon) {
flex-shrink: 0;
color: currentColor;
}
.vault-status {
display: inline-flex;
align-items: center;
gap: 0.3rem;
max-width: 24rem;
min-width: 0;
overflow: hidden;
padding: 0.12rem 0.35rem;
border-radius: 4px;
color: #a0a0b8;
white-space: nowrap;
text-overflow: ellipsis;
}
.vault-status.vault-open {
color: #4ecca3;
}
.vault-status.vault-closed {
color: #9fb2ca;
}
:global(.status-icon) {
flex-shrink: 0;
color: currentColor;
}
.settings-menu-wrap {
position: relative;
display: inline-flex;
align-items: center;
flex-shrink: 0;
}
.settings-button {
min-height: 1.35rem;
height: 1.35rem;
padding: 0 0.35rem;
gap: 0.15rem;
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
color: #9fb2ca;
}
.settings-button:hover,
.settings-button.active {
border-color: #1a3a5c;
background: #16213e;
color: #e0e0f0;
}
:global(.settings-icon),
:global(.settings-chevron),
:global(.settings-menu-icon) {
flex-shrink: 0;
color: currentColor;
}
.settings-menu {
position: fixed;
right: 0.65rem;
bottom: 2rem;
z-index: 10000;
min-width: 13rem;
padding: 0.3rem;
border: 1px solid #1a3a5c;
border-radius: 6px;
background: #12122a;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
}
.settings-menu-item {
width: 100%;
min-height: 1.8rem;
justify-content: flex-start;
gap: 0.45rem;
padding: 0.3rem 0.45rem;
border: none;
border-radius: 4px;
background: transparent;
color: #cfd8e3;
font-size: 0.78rem;
font-weight: 500;
text-align: left;
}
.settings-menu-item:hover {
background: #0f3460;
color: #ffffff;
}
.settings-menu-heading {
padding: 0.25rem 0.45rem 0.2rem;
color: #7f8aa3;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.language-check {
width: 0.9rem;
color: #4ecca3;
text-align: center;
}
.active-language {
background: rgba(78, 204, 163, 0.1);
}
.settings-menu-separator {
height: 1px;
margin: 0.25rem 0.2rem;
background: #1a3a5c;
}
</style>
File diff suppressed because it is too large Load Diff
+117 -63
View File
@@ -1,6 +1,8 @@
<script> <script>
import { onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import * as App from '../../../wailsjs/go/api/App'; import * as App from '../../../wailsjs/go/api/App';
import Icon from '../ui/Icon.svelte';
import { i18n } from '../i18n/index.js';
let appSettings = {}; let appSettings = {};
let recentVaults = []; let recentVaults = [];
@@ -10,50 +12,71 @@
let opening = false; let opening = false;
let newVaultPath = ''; let newVaultPath = '';
let openVaultPath = ''; let openVaultPath = '';
let locale = i18n.getLocale();
const unsubscribeLocale = i18n.subscribe((nextLocale) => { locale = nextLocale; });
$: tr = ((activeLocale) => (key, params, fallback) => {
void activeLocale;
return i18n.t(key, params, fallback);
})(locale);
function reportError(key, fallback, details) {
console.warn('[VaultSelection] operation failed:', details);
return tr(key, undefined, fallback);
}
onDestroy(unsubscribeLocale);
onMount(async () => { onMount(async () => {
try { try {
appSettings = await App.GetAppSettings() || {}; appSettings = await App.GetAppSettings() || {};
recentVaults = appSettings.recentVaults || []; recentVaults = appSettings.recentVaults || [];
} catch (e) { } catch (e) {
// App settings might fail if backend not ready — show selection anyway
console.error('[VaultSelection] load settings:', e); console.error('[VaultSelection] load settings:', e);
} }
loading = false; loading = false;
}); });
async function browseNewVault() {
const path = await App.SelectDirectory();
if (path) {
newVaultPath = path;
}
}
async function browseOpenVault() {
const path = await App.SelectVaultForOpen();
if (path) {
openVaultPath = path;
}
}
async function createVault() { async function createVault() {
error = ''; error = '';
if (!newVaultPath.trim()) { if (!newVaultPath.trim()) {
error = 'Please enter a path for the new vault'; error = tr('vaultSelection.chooseNew');
return; return;
} }
creating = true; creating = true;
try { try {
// Step 1: Create the vault directory + metadata
const createErr = await App.CreateVault(newVaultPath.trim()); const createErr = await App.CreateVault(newVaultPath.trim());
if (createErr) { if (createErr) {
error = 'Create vault: ' + createErr; error = reportError('vaultSelection.createError', 'Could not create the vault. Please try again.', createErr);
creating = false; creating = false;
return; return;
} }
// Step 2: Open it (registers capabilities, loads plugin state)
const openErr = await App.OpenVault(newVaultPath.trim()); const openErr = await App.OpenVault(newVaultPath.trim());
if (openErr) { if (openErr) {
error = 'Open vault: ' + openErr; error = reportError('vaultSelection.openError', 'Could not open the vault. Please try again.', openErr);
creating = false; creating = false;
return; return;
} }
// Step 3: Save to app settings (set current + add to recent)
const setErr = await App.SetCurrentVault(newVaultPath.trim()); const setErr = await App.SetCurrentVault(newVaultPath.trim());
if (setErr) { if (setErr) {
// Vault is open but settings save failed — still proceed
console.warn('[VaultSelection] SetCurrentVault:', setErr); console.warn('[VaultSelection] SetCurrentVault:', setErr);
} }
// Success — notify app to transition to main UI
window.dispatchEvent(new CustomEvent('verstak:vault-opened')); window.dispatchEvent(new CustomEvent('verstak:vault-opened'));
} catch (e) { } catch (e) {
error = String(e); error = reportError('vaultSelection.createError', 'Could not create the vault. Please try again.', e);
creating = false; creating = false;
} }
} }
@@ -61,26 +84,24 @@
async function openExistingVault() { async function openExistingVault() {
error = ''; error = '';
if (!openVaultPath.trim()) { if (!openVaultPath.trim()) {
error = 'Please enter a path to an existing vault'; error = tr('vaultSelection.chooseExisting');
return; return;
} }
opening = true; opening = true;
try { try {
// Step 1: Open the vault
const openErr = await App.OpenVault(openVaultPath.trim()); const openErr = await App.OpenVault(openVaultPath.trim());
if (openErr) { if (openErr) {
error = 'Open vault: ' + openErr; error = reportError('vaultSelection.openError', 'Could not open the vault. Please try again.', openErr);
opening = false; opening = false;
return; return;
} }
// Step 2: Save to app settings
const setErr = await App.SetCurrentVault(openVaultPath.trim()); const setErr = await App.SetCurrentVault(openVaultPath.trim());
if (setErr) { if (setErr) {
console.warn('[VaultSelection] SetCurrentVault:', setErr); console.warn('[VaultSelection] SetCurrentVault:', setErr);
} }
window.dispatchEvent(new CustomEvent('verstak:vault-opened')); window.dispatchEvent(new CustomEvent('verstak:vault-opened'));
} catch (e) { } catch (e) {
error = String(e); error = reportError('vaultSelection.openError', 'Could not open the vault. Please try again.', e);
opening = false; opening = false;
} }
} }
@@ -91,7 +112,7 @@
try { try {
const openErr = await App.OpenVault(path); const openErr = await App.OpenVault(path);
if (openErr) { if (openErr) {
error = 'Open vault: ' + openErr; error = reportError('vaultSelection.openError', 'Could not open the vault. Please try again.', openErr);
opening = false; opening = false;
return; return;
} }
@@ -101,7 +122,7 @@
} }
window.dispatchEvent(new CustomEvent('verstak:vault-opened')); window.dispatchEvent(new CustomEvent('verstak:vault-opened'));
} catch (e) { } catch (e) {
error = String(e); error = reportError('vaultSelection.openError', 'Could not open the vault. Please try again.', e);
opening = false; opening = false;
} }
} }
@@ -110,7 +131,7 @@
{#if loading} {#if loading}
<div class="vault-selection"> <div class="vault-selection">
<div class="vault-selection-inner"> <div class="vault-selection-inner">
<p class="loading-text">Loading...</p> <p class="loading-text">{tr('common.loading')}</p>
</div> </div>
</div> </div>
{:else} {:else}
@@ -123,45 +144,55 @@
<line x1="9" y1="14" x2="15" y2="14"/> <line x1="9" y1="14" x2="15" y2="14"/>
</svg> </svg>
<h1>Verstak</h1> <h1>Verstak</h1>
<p class="subtitle">Choose a vault to get started</p> <p class="subtitle">{tr('vaultSelection.subtitle')}</p>
</div> </div>
{#if error} {#if error}
<div class="error-box"> <div class="error-box">
<span class="error-icon">⚠</span> <Icon name="warning" size={14} class="error-icon" />
<span class="error-text">{error}</span> <span class="error-text">{error}</span>
</div> </div>
{/if} {/if}
<div class="actions"> <div class="actions">
<div class="action-card"> <div class="action-card">
<h3>Create New Vault</h3> <h3>{tr('vaultSelection.createTitle')}</h3>
<p class="hint">Create a new vault folder. This will be your workspace.</p> <p class="hint">{tr('vaultSelection.createHint')}</p>
<div class="input-row"> <div class="input-row">
<input <input
type="text" type="text"
bind:value={newVaultPath} bind:value={newVaultPath}
placeholder="~/Documents/MyVault" placeholder={tr('vaultSelection.pathPlaceholder')}
disabled={creating} disabled={creating}
/> />
<button class="btn-secondary" on:click={browseNewVault} type="button" disabled={creating}>
{tr('common.browse')}
</button>
</div>
<div class="button-row">
<button class="btn-primary" on:click={createVault} type="button" disabled={creating}> <button class="btn-primary" on:click={createVault} type="button" disabled={creating}>
{creating ? 'Creating...' : 'Create & Open'} {creating ? tr('vaultSelection.creating') : tr('vaultSelection.create')}
</button> </button>
</div> </div>
</div> </div>
<div class="action-card"> <div class="action-card">
<h3>Open Existing Vault</h3> <h3>{tr('vaultSelection.openTitle')}</h3>
<p class="hint">Open a vault that already exists on this computer.</p> <p class="hint">{tr('vaultSelection.openHint')}</p>
<div class="input-row"> <div class="input-row">
<input <input
type="text" type="text"
bind:value={openVaultPath} bind:value={openVaultPath}
placeholder="~/Documents/ExistingVault" placeholder={tr('vaultSelection.pathPlaceholder')}
disabled={opening} disabled={opening}
/> />
<button class="btn-primary" on:click={openExistingVault} type="button" disabled={opening}> <button class="btn-secondary" on:click={browseOpenVault} type="button" disabled={opening}>
{opening ? 'Opening...' : 'Open'} {tr('common.browse')}
</button>
</div>
<div class="button-row">
<button class="btn-secondary open-existing-btn" on:click={openExistingVault} type="button" disabled={opening}>
{opening ? tr('vaultSelection.opening') : tr('vaultSelection.open')}
</button> </button>
</div> </div>
</div> </div>
@@ -169,12 +200,12 @@
{#if recentVaults.length > 0} {#if recentVaults.length > 0}
<div class="recent-section"> <div class="recent-section">
<h3>Recent Vaults</h3> <h3>{tr('vaultSelection.recent')}</h3>
<ul class="recent-list"> <ul class="recent-list">
{#each recentVaults as path} {#each recentVaults as path}
<li> <li>
<button class="recent-item" on:click={() => openRecent(path)} type="button" disabled={opening}> <button class="recent-item" on:click={() => openRecent(path)} type="button" disabled={opening}>
<span class="recent-icon">📁</span> <Icon name="vault" size={16} class="recent-icon" />
<span class="recent-path">{path}</span> <span class="recent-path">{path}</span>
</button> </button>
</li> </li>
@@ -192,15 +223,15 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 100vh; height: 100vh;
background: #1a1a2e; background: var(--vt-color-background);
padding: 2rem; padding: 2rem;
} }
.vault-selection-inner { .vault-selection-inner {
max-width: 520px; max-width: 560px;
width: 100%; width: 100%;
} }
.loading-text { .loading-text {
color: #a0a0b8; color: var(--vt-color-text-muted);
text-align: center; text-align: center;
font-size: 1rem; font-size: 1rem;
} }
@@ -209,28 +240,28 @@
margin-bottom: 2rem; margin-bottom: 2rem;
} }
.logo h1 { .logo h1 {
color: #e0e0f0; color: var(--vt-color-text-primary);
font-size: 1.8rem; font-size: 1.8rem;
margin: 0.5rem 0 0.25rem; margin: 0.5rem 0 0.25rem;
} }
.subtitle { .subtitle {
color: #a0a0b8; color: var(--vt-color-text-muted);
font-size: 0.95rem; font-size: 0.95rem;
margin: 0; margin: 0;
} }
.error-box { .error-box {
background: rgba(233, 69, 96, 0.1); background: var(--vt-color-danger-muted);
border: 1px solid #e94560; border: 1px solid rgba(233, 69, 96, 0.5);
border-radius: 8px; border-radius: var(--vt-radius-lg);
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 0.5rem; gap: 0.5rem;
font-size: 0.85rem; font-size: 0.85rem;
color: #e94560; color: #ffc6ce;
} }
.error-icon { flex-shrink: 0; } :global(.error-icon) { flex-shrink: 0; }
.error-text { word-break: break-word; } .error-text { word-break: break-word; }
.actions { .actions {
display: flex; display: flex;
@@ -239,51 +270,56 @@
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
.action-card { .action-card {
background: #16213e; background: var(--vt-color-surface);
border: 1px solid #0f3460; border: 1px solid var(--vt-color-border);
border-radius: 8px; border-radius: var(--vt-radius-lg);
padding: 1.25rem; padding: 1.25rem;
} }
.action-card h3 { .action-card h3 {
color: #e0e0f0; color: var(--vt-color-text-primary);
font-size: 1rem; font-size: 1rem;
margin: 0 0 0.25rem; margin: 0 0 0.25rem;
} }
.hint { .hint {
color: #a0a0b8; color: var(--vt-color-text-muted);
font-size: 0.8rem; font-size: 0.8rem;
margin: 0 0 0.75rem; margin: 0 0 0.75rem;
} }
.input-row { .input-row {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
margin-bottom: 0.5rem;
} }
.input-row input { .input-row input {
flex: 1; flex: 1;
background: #0f3460; background: #0f1424;
border: 1px solid #1a3a5c; border: 1px solid var(--vt-color-border-strong);
color: #e0e0f0; color: var(--vt-color-text-primary);
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
border-radius: 6px; border-radius: 6px;
font-size: 0.9rem; font-size: 0.9rem;
} }
.input-row input:focus { .input-row input:focus {
outline: none; outline: none;
border-color: #4ecca3; border-color: var(--vt-color-accent);
box-shadow: var(--vt-focus-ring);
} }
.input-row input::placeholder { .input-row input::placeholder {
color: #666; color: var(--vt-color-text-muted);
}
.button-row {
display: flex;
justify-content: flex-end;
} }
.btn-primary { .btn-primary {
background: #4ecca3; background: var(--vt-color-accent);
color: #1a1a2e; color: #101827;
border: none; border: none;
padding: 0.5rem 1rem; padding: 0.5rem 1.25rem;
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
font-size: 0.85rem; font-size: 0.85rem;
font-weight: 600; font-weight: 600;
white-space: nowrap;
} }
.btn-primary:hover:not(:disabled) { .btn-primary:hover:not(:disabled) {
background: #3dbb92; background: #3dbb92;
@@ -292,14 +328,32 @@
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
.btn-secondary {
background: var(--vt-color-surface-hover);
color: var(--vt-color-text-secondary);
border: 1px solid var(--vt-color-border-strong);
padding: 0.5rem 0.75rem;
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
white-space: nowrap;
}
.btn-secondary:hover:not(:disabled) {
background: var(--vt-color-surface-hover);
color: var(--vt-color-text-primary);
}
.btn-secondary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.recent-section { .recent-section {
background: #16213e; background: var(--vt-color-surface);
border: 1px solid #0f3460; border: 1px solid var(--vt-color-border);
border-radius: 8px; border-radius: var(--vt-radius-lg);
padding: 1rem 1.25rem; padding: 1rem 1.25rem;
} }
.recent-section h3 { .recent-section h3 {
color: #a0a0b8; color: var(--vt-color-text-muted);
font-size: 0.8rem; font-size: 0.8rem;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
@@ -317,20 +371,20 @@
width: 100%; width: 100%;
background: none; background: none;
border: none; border: none;
color: #e0e0f0; color: var(--vt-color-text-primary);
padding: 0.4rem 0; padding: 0.4rem 0;
cursor: pointer; cursor: pointer;
text-align: left; text-align: left;
font-size: 0.85rem; font-size: 0.85rem;
} }
.recent-item:hover { .recent-item:hover {
color: #4ecca3; color: var(--vt-color-accent);
} }
.recent-item:disabled { .recent-item:disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
.recent-icon { flex-shrink: 0; } :global(.recent-icon) { flex-shrink: 0; }
.recent-path { .recent-path {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
+181 -69
View File
@@ -1,88 +1,142 @@
<script> <script>
import { onMount } from 'svelte'; import PluginBundleHost from '../plugin-host/PluginBundleHost.svelte';
import { onDestroy, onMount } from 'svelte';
import * as App from '../../../wailsjs/go/api/App'; import * as App from '../../../wailsjs/go/api/App';
import Icon from '../ui/Icon.svelte';
import { i18n } from '../i18n/index.js';
export let activeView = null;
export let activeViewPluginId = null;
let views = []; let views = [];
let activeView = '';
let pluginStates = {};
let plugins = []; let plugins = [];
let renderError = null;
let locale = i18n.getLocale();
let unsubscribeLocale = null;
$: tr = ((activeLocale) => (key, params, fallback) => {
void activeLocale;
return i18n.t(key, params, fallback);
})(locale);
onMount(async () => { async function loadViews() {
try { try {
const [contribs, pluginList] = await Promise.all([ const [contribs, pluginList] = await Promise.all([
App.GetContributions(), App.GetContributions().catch(() => ({ views: [] })),
App.GetPlugins(), App.GetPlugins().catch(() => []),
]); ]);
views = contribs.views || []; await Promise.all((pluginList || []).map((plugin) => (
plugins = pluginList; i18n.loadPlugin(plugin.manifest?.id, plugin.manifest?.localization).catch(() => {})
for (const p of pluginList) { )));
pluginStates[p.manifest.id] = p.status; views = i18n.localizeContributionSummary(contribs || {}).views || [];
} plugins = (pluginList || []).map((plugin) => i18n.localizePlugin(plugin));
} catch (e) { } catch (e) {
console.error('[ViewContainer] load error:', e); console.error('[ViewContainer] load error:', e);
} }
}
window.addEventListener('verstak:open-view', (e) => { onMount(() => {
activeView = e.detail.viewId; unsubscribeLocale = i18n.subscribe((nextLocale) => {
const changed = locale !== nextLocale;
locale = nextLocale;
if (changed) loadViews();
}); });
loadViews();
}); });
function getViewStatus(view) { onDestroy(() => {
const status = pluginStates[view.pluginId]; if (unsubscribeLocale) unsubscribeLocale();
if (status === 'failed' || status === 'incompatible') return 'error'; });
if (status === 'degraded') return 'degraded';
return 'ok'; $: currentView = views.find(v => v.id === activeView && v.pluginId === activeViewPluginId);
$: currentPlugin = currentView
? plugins.find(p => p.manifest?.id === currentView.pluginId)
: null;
$: pluginStatus = currentPlugin ? currentPlugin.status : 'unknown';
$: hasFrontend = currentPlugin?.manifest?.frontend?.entry != null;
$: hostPluginId = currentView?.pluginId || activeViewPluginId;
$: hostComponentId = currentView?.component || null;
// Reset render error when view changes
$: if (activeView) {
renderError = null;
}
function onHostError(e) {
renderError = e.detail?.message || tr('pluginView.error');
} }
</script> </script>
<div class="view-container"> {#key `${activeViewPluginId}:${activeView}`}
{#if activeView} {#if renderError}
{#each views.filter(v => v.item.id === activeView) as view} <div class="view-container">
<div class="view" class:degraded={getViewStatus(view) === 'degraded'}> <div class="error-boundary">
<div class="view-header"> <div class="error-fallback vt-inline-alert error">
<span class="view-icon">{view.item.icon || '📦'}</span> <Icon name="warning" size={24} class="error-icon" />
<h2>{view.item.title}</h2> <p class="error-title">{tr('pluginView.failed')}</p>
{#if getViewStatus(view) === 'degraded'} <details class="error-details">
<span class="badge degraded">degraded</span> <summary>{tr('common.details')}</summary>
{/if} <p class="error-text">{renderError}</p>
</div> </details>
<div class="view-content">
<div class="plugin-view-host" data-view-id={view.item.id} data-component={view.item.component}>
<p class="placeholder">
Plugin view: <strong>{view.item.component}</strong>
<br />
<span class="sub">from {view.pluginId}</span>
</p>
</div>
</div> </div>
</div> </div>
{:else} </div>
<div class="empty">View "{activeView}" not found in contributions</div> {:else if currentView}
{/each} <div class="view-container">
<div class="view" class:degraded={pluginStatus === 'degraded'}>
<div class="view-header">
<Icon name={currentView.icon || 'logo'} size={20} class="view-icon" />
<h2>{currentView.title}</h2>
</div>
<div class="view-content">
{#if hasFrontend}
<PluginBundleHost
pluginId={hostPluginId}
componentId={hostComponentId}
/>
{:else}
<div class="placeholder">
<p class="placeholder-label">{tr('pluginView.noVisual')}</p>
<details class="placeholder-details">
<summary>{tr('common.details')}</summary>
<p class="placeholder-info"><span class="placeholder-key">{tr('common.plugin')}:</span> <strong>{currentView.pluginId}</strong></p>
<p class="placeholder-info"><span class="placeholder-key">{tr('pluginView.viewId')}:</span> <code>{currentView.id}</code></p>
<p class="placeholder-info"><span class="placeholder-key">{tr('common.component')}:</span> <code>{currentView.component}</code></p>
</details>
<p class="placeholder-badge vt-badge">{tr('pluginView.bundleUnavailable')}</p>
</div>
{/if}
</div>
</div>
</div>
{:else if activeView}
<div class="view-container empty">
<p>{tr('pluginView.unavailable')}</p>
</div>
{:else} {:else}
<div class="empty"> <div class="view-container empty">
<p>Select an item from the sidebar</p> <p>{tr('pluginView.select')}</p>
<p class="sub">Plugin views will appear here</p> <p class="sub">{tr('pluginView.selectHint')}</p>
</div> </div>
{/if} {/if}
</div> {/key}
<style> <style>
.view-container { .view-container {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; min-width: 0;
background: #1a1a2e; background: var(--vt-color-background);
} }
.view { .view {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-width: 0;
padding: 1.5rem; padding: 1.5rem;
} }
.view.degraded { .view.degraded {
border-left: 3px solid #ffc857; border-left: 3px solid var(--vt-color-warning);
} }
.view-header { .view-header {
display: flex; display: flex;
@@ -90,48 +144,106 @@
gap: 0.5rem; gap: 0.5rem;
margin-bottom: 1rem; margin-bottom: 1rem;
padding-bottom: 0.75rem; padding-bottom: 0.75rem;
border-bottom: 1px solid #16213e; border-bottom: 1px solid var(--vt-color-border);
} }
.view-header h2 { .view-header h2 {
margin: 0; margin: 0;
font-size: 1.2rem; font-size: 1.2rem;
color: #e0e0f0; color: var(--vt-color-text-primary);
flex: 1; flex: 1;
} }
.view-icon { font-size: 1.3rem; } :global(.view-icon) {
width: 1.3rem;
height: 1.3rem;
color: var(--vt-color-text-muted);
flex-shrink: 0;
}
.view-content { .view-content {
flex: 1; flex: 1;
overflow: auto; min-width: 0;
}
.plugin-view-host {
width: 100%;
min-height: 200px;
} }
.placeholder { .placeholder {
color: #666; color: var(--vt-color-text-muted);
font-style: italic; font-style: italic;
padding: 2rem; padding: 2rem;
text-align: center; text-align: center;
border: 1px dashed #333; border: 1px dashed var(--vt-color-border-strong);
border-radius: 8px; border-radius: var(--vt-radius-lg);
}
.placeholder-label {
font-size: 1rem;
color: var(--vt-color-text-secondary);
font-weight: 600;
margin-bottom: 1rem;
font-style: normal;
}
.placeholder-info {
font-size: 0.85rem;
color: var(--vt-color-text-muted);
margin: 0.3rem 0;
font-style: normal;
}
.placeholder-key {
color: var(--vt-color-text-muted);
}
.placeholder-info strong { color: var(--vt-color-accent); }
.placeholder-info code {
color: var(--vt-color-text-primary);
background: var(--vt-color-surface-muted);
padding: 0.1rem 0.3rem;
border-radius: 3px;
font-size: 0.8rem;
}
.placeholder-badge {
display: inline-block;
margin-top: 1rem;
padding: 0.25rem 0.75rem;
background: var(--vt-color-surface-muted);
color: var(--vt-color-text-secondary);
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.02em;
font-style: normal;
}
.error-boundary {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.error-fallback {
text-align: center;
padding: 2rem;
}
:global(.error-icon) {
color: var(--vt-color-danger);
}
.error-title {
color: var(--vt-color-danger);
font-size: 1.1rem;
font-weight: 600;
margin: 0.5rem 0;
}
.error-text {
color: var(--vt-color-text-secondary);
font-size: 0.85rem;
font-family: monospace;
margin-top: 0.5rem;
} }
.placeholder strong { color: #4ecca3; }
.placeholder .sub { font-size: 0.85rem; color: #555; }
.empty { .empty {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 100%; height: 100%;
color: #555; color: var(--vt-color-text-muted);
font-size: 1rem; font-size: 1rem;
text-align: center;
} }
.empty .sub { font-size: 0.85rem; color: #444; margin-top: 0.5rem; } .empty .sub { font-size: 0.85rem; color: var(--vt-color-text-muted); margin-top: 0.5rem; }
.badge { .placeholder-details,
padding: 0.15rem 0.5rem; .error-details { margin-top: 0.75rem; color: var(--vt-color-text-muted); }
border-radius: 3px; .placeholder-details summary,
font-size: 0.75rem; .error-details summary { cursor: pointer; color: var(--vt-color-text-secondary); }
font-weight: 600;
}
.badge.degraded { background: #ffc857; color: #1a1a2e; }
</style> </style>
+168
View File
@@ -0,0 +1,168 @@
<script>
import { onDestroy } from 'svelte';
import PluginBundleHost from '../plugin-host/PluginBundleHost.svelte';
import Icon from '../ui/Icon.svelte';
import { i18n } from '../i18n/index.js';
import { debug } from '../log/debug.js';
export let openedResource = null;
let locale = i18n.getLocale();
const unsubscribeLocale = i18n.subscribe((nextLocale) => locale = nextLocale);
$: tr = ((activeLocale) => (key, params, fallback) => {
void activeLocale;
return i18n.t(key, params, fallback);
})(locale);
$: providerPluginId = openedResource?.providerPluginId || '';
$: providerComponent = openedResource?.providerComponent || '';
$: resourcePath = openedResource?.request?.path || '';
$: providerId = openedResource?.providerId || '';
$: requestMode = openedResource?.request?.mode || 'view';
$: requestContext = openedResource?.request?.context?.notesMode || openedResource?.request?.context?.isInsideNotesFolder
? 'notes-markdown'
: ((openedResource?.request?.extension === '.md' || openedResource?.request?.extension === '.markdown') ? 'generic-markdown' : 'generic-text');
$: resourceTitle = resourceDisplayTitle(resourcePath, requestContext);
$: componentProps = openedResource || {};
$: mountKey = [
providerPluginId,
providerComponent,
resourcePath,
requestMode,
requestContext,
].join(':');
function resourceDisplayTitle(path, context) {
const fileName = String(path || '').split('/').filter(Boolean).pop() || String(path || '');
return context === 'notes-markdown' ? fileName.replace(/\.(md|markdown)$/i, '') : fileName;
}
function closeWorkbench() {
window.dispatchEvent(new CustomEvent('verstak:close-workbench', { cancelable: true }));
}
onDestroy(unsubscribeLocale);
</script>
<div class="workbench-host vt-page">
{#if openedResource?.status === 'no-provider'}
<div class="workbench-header vt-page-header">
<span class="workbench-title vt-page-title">{resourceTitle}</span>
<span class="workbench-provider vt-badge">{tr('workbench.noProvider')}</span>
<button class="close-btn btn-ghost btn-icon" type="button" title={tr('common.close')} aria-label={tr('common.close')} on:click={closeWorkbench}>
<Icon name="x" size={18} />
</button>
</div>
<div class="workbench-empty no-provider vt-empty-state" data-workbench-status="no-provider">
<p class="vt-empty-title">{tr('workbench.noViewer')}</p>
{#if debug.isEnabled()}
<p class="workbench-meta">{requestMode} · {requestContext}</p>
{/if}
</div>
{:else if openedResource}
<div class="workbench-header vt-page-header">
<span class="workbench-title vt-page-title">{resourceTitle}</span>
{#if debug.isEnabled() && providerId}
<span class="workbench-provider vt-badge accent" data-debug-provider>{providerId}</span>
{/if}
<button class="close-btn btn-ghost btn-icon" type="button" title={tr('common.close')} aria-label={tr('common.close')} on:click={closeWorkbench}>
<Icon name="x" size={18} />
</button>
</div>
<div class="workbench-content">
{#key mountKey}
<PluginBundleHost
pluginId={providerPluginId}
componentId={providerComponent}
{componentProps}
/>
{/key}
</div>
{:else}
<div class="workbench-empty vt-empty-state">
<p class="vt-empty-title">{tr('workbench.noResource')}</p>
</div>
{/if}
</div>
<style>
.workbench-host {
flex: 1;
min-width: 0;
min-height: 0;
height: 100%;
display: flex;
flex-direction: column;
background: var(--vt-color-background);
}
.workbench-header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: var(--vt-space-2) var(--vt-space-4);
border-bottom: 1px solid var(--vt-color-border);
flex-shrink: 0;
}
.workbench-title {
color: var(--vt-color-text-primary);
font-size: 0.95rem;
font-weight: 600;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.close-btn {
width: 2rem;
height: 2rem;
min-height: 0;
padding: 0;
border-radius: var(--vt-radius-md);
color: var(--vt-color-text-secondary);
flex-shrink: 0;
cursor: pointer;
}
.close-btn:hover {
color: var(--vt-color-text-primary);
background: var(--vt-color-surface-hover);
}
.workbench-provider {
color: var(--vt-color-accent);
font-size: 0.75rem;
margin-left: auto;
}
.workbench-content {
min-width: 0;
min-height: 0;
height: 100%;
flex: 1;
display: flex;
flex-direction: column;
padding: 0;
}
.workbench-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--vt-color-text-muted);
}
.workbench-empty.no-provider {
flex-direction: column;
gap: 0.35rem;
}
.workbench-meta {
margin: 0;
color: var(--vt-color-text-muted);
font-size: 0.8rem;
}
</style>
+390
View File
@@ -0,0 +1,390 @@
<script>
import PluginBundleHost from '../plugin-host/PluginBundleHost.svelte';
import GlobalSearch from './GlobalSearch.svelte';
import TodaySurface from './TodaySurface.svelte';
import * as App from '../../../wailsjs/go/api/App';
import { onDestroy, onMount } from 'svelte';
import { i18n } from '../i18n/index.js';
export let selectedWorkspaceName = '';
export let nodes = [];
export let activeToolKey = '';
let contributions = {};
let plugins = [];
let discoveredWorkspaceTools = [];
let workspaceTools = [];
let workspaceMetadata = null;
let metadataWorkspaceRoot = '';
let toolsLoaded = false;
let requestedToolKind = '';
let requestedToolRequest = null;
let activeToolRequest = null;
let requestedWorkspaceRoot = '';
let locale = i18n.getLocale();
let unsubscribeLocale = null;
$: tr = ((activeLocale) => (key, params, fallback) => {
void activeLocale;
return i18n.t(key, params, fallback);
})(locale);
// TODO: Rename TodaySurface.svelte to OverviewSurface.svelte in a refactor-only follow-up.
$: overviewTool = { id: '__overview', title: tr('workspace.overview'), pluginId: 'verstak.shell', component: 'TodaySurface', shell: true };
const toolOrder = new Map([
['notes', 10],
['files', 20],
['todo', 30],
['activity', 40],
['browser', 50],
['inbox', 50],
['secrets', 60],
['search', 90],
]);
$: selectedWorkspace = nodes.find(n => n.id === selectedWorkspaceName || n.name === selectedWorkspaceName || n.rootPath === selectedWorkspaceName) || null;
$: workspaceRootPath = selectedWorkspace?.rootPath || selectedWorkspace?.name || selectedWorkspace?.id || '';
$: workspaceId = selectedWorkspace?.workspaceId || '';
$: workspaceTitle = selectedWorkspace?.title || selectedWorkspace?.name || selectedWorkspace?.id || selectedWorkspaceName;
$: if (workspaceRootPath !== metadataWorkspaceRoot) {
metadataWorkspaceRoot = workspaceRootPath;
workspaceMetadata = null;
if (workspaceRootPath) loadWorkspaceMetadata(workspaceRootPath);
}
$: workspaceTools = sortWorkspaceTools(filterWorkspaceTools(discoveredWorkspaceTools, workspaceMetadata));
$: if (workspaceRootPath !== requestedWorkspaceRoot) {
requestedWorkspaceRoot = workspaceRootPath;
requestedToolRequest = null;
activeToolRequest = null;
}
$: displayedTools = selectedWorkspace ? [overviewTool, ...workspaceTools] : [];
$: activeTool = displayedTools.find(tool => toolKey(tool) === activeToolKey) || displayedTools[0] || null;
$: if (displayedTools.length > 0 && (!activeToolKey || (toolsLoaded && !displayedTools.some(tool => toolKey(tool) === activeToolKey)))) {
activeToolKey = toolKey(overviewTool);
}
$: if (requestedToolKind && workspaceTools.length > 0) {
const match = findWorkspaceTool(requestedToolKind);
if (match) {
const toolRequest = requestedToolRequest;
requestedToolKind = '';
requestedToolRequest = null;
selectTool(match, toolRequest);
}
}
$: if (selectedWorkspaceName) loadTools();
onMount(() => {
unsubscribeLocale = i18n.subscribe((nextLocale) => {
const changed = locale !== nextLocale;
locale = nextLocale;
if (changed && selectedWorkspaceName) loadTools();
});
window.addEventListener('verstak:workspace-open-tool', handleWorkspaceOpenTool);
window.addEventListener('verstak:plugins-changed', handlePluginsChanged);
});
onDestroy(() => {
if (unsubscribeLocale) unsubscribeLocale();
window.removeEventListener('verstak:workspace-open-tool', handleWorkspaceOpenTool);
window.removeEventListener('verstak:plugins-changed', handlePluginsChanged);
});
function toolKey(tool) {
return `${tool?.pluginId || ''}:${tool?.id || ''}`;
}
function toolRank(tool) {
const text = `${tool?.title || ''} ${tool?.id || ''} ${tool?.pluginId || ''}`.toLowerCase();
for (const [needle, rank] of toolOrder.entries()) {
if (text.includes(needle)) return rank;
}
return 1000;
}
function sortWorkspaceTools(tools) {
return [...tools].sort((a, b) => {
const rankDiff = toolRank(a) - toolRank(b);
if (rankDiff !== 0) return rankDiff;
return String(a.title || a.id).localeCompare(String(b.title || b.id));
});
}
function filterWorkspaceTools(tools, metadata) {
if (!Array.isArray(metadata?.workspaceTools)) return tools;
const allowedPluginIds = new Set(metadata.workspaceTools);
return tools.filter(tool => allowedPluginIds.has(tool.pluginId));
}
function resultOrError(response, fallbackValue) {
if (Array.isArray(response) && typeof response[1] === 'string') {
return [response[0] || fallbackValue, response[1] || ''];
}
return typeof response === 'string' ? [fallbackValue, response] : [response || fallbackValue, ''];
}
async function loadWorkspaceMetadata(rootPath) {
try {
const [metadata, err] = resultOrError(await App.GetWorkspaceMetadata(rootPath), null);
if (rootPath !== workspaceRootPath) return;
workspaceMetadata = err ? null : metadata;
} catch (_) {
if (rootPath === workspaceRootPath) workspaceMetadata = null;
}
}
function selectTool(tool, toolRequest = null) {
activeToolKey = toolKey(tool);
activeToolRequest = toolRequest;
window.dispatchEvent(new CustomEvent('verstak:workspace-tool-selected', {
detail: {
toolKey: activeToolKey,
toolId: tool?.id || '',
pluginId: tool?.pluginId || '',
},
}));
}
function findWorkspaceTool(kind) {
kind = String(kind || '').toLowerCase();
return workspaceTools.find(tool => {
const text = `${tool?.title || ''} ${tool?.id || ''} ${tool?.pluginId || ''}`.toLowerCase();
if (kind === 'browser-inbox') return text.includes('browser') || text.includes('inbox');
return text.includes(kind);
});
}
function requestWorkspaceTool(kind, toolRequest = null) {
requestedToolKind = String(kind || '').toLowerCase();
requestedToolRequest = toolRequest;
const match = findWorkspaceTool(requestedToolKind);
if (match) {
requestedToolKind = '';
requestedToolRequest = null;
selectTool(match, toolRequest);
}
}
function openWorkspaceTool(event) {
requestWorkspaceTool(event?.detail?.kind, event?.detail?.toolRequest || null);
}
function handleWorkspaceOpenTool(event) {
requestWorkspaceTool(event?.detail?.kind, event?.detail?.toolRequest || null);
}
function handlePluginsChanged() {
if (selectedWorkspaceName) loadTools();
}
async function loadTools() {
try {
toolsLoaded = false;
const [c, p] = await Promise.all([
App.GetContributions().catch(() => ({})),
App.GetPlugins().catch(() => []),
]);
await Promise.all((p || []).map((plugin) => (
i18n.loadPlugin(plugin.manifest?.id, plugin.manifest?.localization).catch(() => {})
)));
contributions = i18n.localizeContributionSummary(c || {});
plugins = (p || []).map((plugin) => i18n.localizePlugin(plugin));
const enabledIds = new Set(
plugins.filter(pl => pl.enabled && (pl.status === 'loaded' || pl.status === 'degraded')).map(pl => pl.manifest?.id)
);
discoveredWorkspaceTools = (contributions.workspaceItems || []).filter(tool => enabledIds.has(tool.pluginId));
} catch (e) {
console.error('[WorkspaceHost] loadTools error:', e);
} finally {
toolsLoaded = true;
}
}
</script>
<div class="workspace-host vt-page">
{#if selectedWorkspace}
<div class="workspace-header vt-page-header">
<div class="workspace-title-group">
<span class="workspace-title vt-page-title">{workspaceTitle}</span>
</div>
<div class="workspace-search" aria-label={tr('workspace.search')}>
<GlobalSearch />
</div>
</div>
{#if displayedTools.length > 0}
<div class="workspace-tabs vt-tabbar" role="tablist" aria-label={tr('workspace.tools')}>
{#each displayedTools as tool (tool.id + tool.pluginId)}
<button
class="vt-tab"
class:is-active={toolKey(tool) === toolKey(activeTool)}
role="tab"
aria-selected={toolKey(tool) === toolKey(activeTool)}
type="button"
title={tool.pluginId}
on:click={() => selectTool(tool)}
>
{tool.title || tool.id}
</button>
{/each}
</div>
<div class="workspace-tool-content" role="tabpanel" aria-label={activeTool?.title || activeTool?.id || tr('workspace.tool')}>
{#if activeTool}
{#if activeTool.shell}
<TodaySurface
{workspaceRootPath}
availableTools={displayedTools}
on:openTool={openWorkspaceTool}
/>
{:else}
<PluginBundleHost
pluginId={activeTool.pluginId}
componentId={activeTool.component}
componentProps={{ workspaceName: selectedWorkspaceName, workspaceNodeId: selectedWorkspaceName, workspaceNode: selectedWorkspace, workspaceRootPath, workspaceId, toolRequest: activeToolRequest }}
/>
{/if}
{/if}
</div>
{:else}
<div class="workspace-empty vt-empty-state">
<p class="vt-empty-title">{tr('workspace.emptyTools')}</p>
<p class="workspace-hint">{tr('workspace.emptyToolsHint')}</p>
</div>
{/if}
{:else}
<div class="workspace-empty vt-empty-state">
<p class="vt-empty-title">{tr('workspace.select')}</p>
<p class="workspace-hint">{tr('workspace.selectHint')}</p>
</div>
{/if}
</div>
<style>
.workspace-host {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
height: 100%;
background: var(--vt-color-background);
}
.workspace-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: var(--vt-space-2) var(--vt-space-4);
border-bottom: 1px solid var(--vt-color-border);
flex-shrink: 0;
}
.workspace-title-group {
min-width: 0;
display: flex;
align-items: center;
gap: 0.75rem;
}
.workspace-title {
color: var(--vt-color-text-primary);
font-size: 0.95rem;
font-weight: 600;
}
.workspace-search {
width: min(27rem, 46vw);
min-width: 16rem;
flex-shrink: 1;
}
.workspace-search :global(.global-search) {
padding: 0;
border-bottom: 0;
}
.workspace-search :global(.global-search-box) {
background: #0f1424;
border-color: var(--vt-color-border-strong);
}
.workspace-search :global(.global-search-results) {
left: 0;
right: 0;
top: calc(100% + 0.35rem);
}
@media (max-width: 720px) {
.workspace-header {
align-items: stretch;
flex-direction: column;
}
.workspace-search {
width: 100%;
min-width: 0;
}
}
.workspace-tabs {
display: flex;
align-items: center;
gap: 0.25rem;
padding: var(--vt-space-1) var(--vt-space-3) 0;
background: #12162a;
border-bottom: 1px solid var(--vt-color-border);
flex-shrink: 0;
overflow-x: auto;
scrollbar-gutter: auto;
}
.workspace-tabs button {
flex-shrink: 0;
min-height: 2rem;
padding: 0.35rem 0.8rem;
border: 1px solid transparent;
border-bottom: none;
border-radius: var(--vt-radius-md) var(--vt-radius-md) 0 0;
background: transparent;
color: var(--vt-color-text-muted);
cursor: pointer;
font: inherit;
font-size: 0.8rem;
}
.workspace-tabs button:hover {
color: var(--vt-color-text-primary);
background: var(--vt-color-surface-hover);
}
.workspace-tabs button.is-active {
color: var(--vt-color-accent);
background: var(--vt-color-background);
border-color: var(--vt-color-border);
}
.workspace-tool-content {
flex: 1;
min-height: 0;
overflow: hidden;
}
.workspace-empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--vt-color-text-muted);
gap: 0.5rem;
padding: 2rem;
text-align: center;
}
.workspace-hint {
font-size: 0.8rem;
color: var(--vt-color-text-muted);
max-width: 300px;
text-align: center;
}
</style>
+506
View File
@@ -0,0 +1,506 @@
<script context="module">
import { writable } from 'svelte/store';
const activeWorkspaceId = writable('');
</script>
<script>
import { onDestroy, onMount } from 'svelte';
import * as App from '../../../wailsjs/go/api/App';
import Icon from '../ui/Icon.svelte';
import { i18n } from '../i18n/index.js';
let loading = true;
let localError = '';
let workspaces = [];
let currentWorkspaceId = '';
let showCreate = false;
let newWorkspaceName = '';
let workspaceTemplates = [];
let templatePluginNames = {};
let templatePlugins = {};
let templateCapabilities = new Set();
let selectedTemplateId = 'default';
let createError = '';
let templateWarning = null;
let templatesLoading = false;
let creating = false;
let renamingId = '';
let renameValue = '';
let busyId = '';
let locale = i18n.getLocale();
let unsubscribeLocale = null;
$: tr = ((activeLocale) => (key, params, fallback) => {
void activeLocale;
return i18n.t(key, params, fallback);
})(locale);
function reportError(key, fallback, details) {
console.warn('[WorkspaceTree] operation failed:', details);
return tr(key, undefined, fallback);
}
onMount(() => {
unsubscribeLocale = i18n.subscribe((nextLocale) => {
const changed = locale !== nextLocale;
locale = nextLocale;
if (changed) loadWorkspaceTemplates();
});
loadWorkspaces();
loadWorkspaceTemplates();
window.addEventListener('verstak:workspace-active-changed', onActiveWorkspaceChanged);
});
onDestroy(() => {
if (unsubscribeLocale) unsubscribeLocale();
window.removeEventListener('verstak:workspace-active-changed', onActiveWorkspaceChanged);
});
function onActiveWorkspaceChanged(event) {
currentWorkspaceId = event.detail?.workspaceName || '';
activeWorkspaceId.set(currentWorkspaceId);
}
function resultOrError(response, fallbackValue) {
if (Array.isArray(response) && typeof response[1] === 'string') {
return [response[0] || fallbackValue, response[1] || ''];
}
return typeof response === 'string' ? [fallbackValue, response] : [response, ''];
}
$: selectedTemplate = workspaceTemplates.find(template => template.id === selectedTemplateId) || workspaceTemplates[0] || null;
$: selectedTemplateTools = (selectedTemplate?.workspaceTools || []).map((pluginId) => (
templateToolState(pluginId, templatePlugins, templateCapabilities, templatePluginNames, tr)
));
$: selectedTemplateIssues = selectedTemplateTools.filter(tool => tool.status !== 'available');
function toolLabel(pluginId, names = templatePluginNames) {
return names[pluginId] || String(pluginId || '').replace(/^verstak\./, '');
}
function templateToolState(pluginId, plugins, capabilities, names, translate) {
const plugin = plugins[pluginId];
if (!plugin) {
return {
pluginId,
name: toolLabel(pluginId, names),
tabs: [],
status: 'unavailable',
reason: translate('workspaceTree.templateMissingPlugin'),
};
}
const manifest = plugin.manifest || {};
const tabs = Array.isArray(manifest.contributes?.workspaceItems)
? manifest.contributes.workspaceItems.map(item => item?.title || item?.id).filter(Boolean)
: [];
const pluginStatus = String(plugin.status || '').toLowerCase();
const missingCapability = Array.isArray(manifest.requires)
&& manifest.requires.some(capabilityId => !capabilities.has(capabilityId));
let status = 'available';
let reason = translate('workspaceTree.templateAvailable');
if (!plugin.enabled || pluginStatus === 'disabled') {
status = 'unavailable';
reason = translate('workspaceTree.templatePluginDisabled');
} else if (pluginStatus === 'missing-required-capability' || missingCapability) {
status = 'unavailable';
reason = translate('workspaceTree.templateCapabilityUnavailable');
} else if (pluginStatus === 'incompatible') {
status = 'unavailable';
reason = translate('workspaceTree.templateIncompatible');
} else if (pluginStatus === 'failed') {
status = 'unavailable';
reason = translate('workspaceTree.templateLoadFailed');
} else if (pluginStatus === 'degraded') {
status = 'limited';
reason = translate('workspaceTree.templateLimited');
} else if (pluginStatus !== 'loaded') {
status = 'unavailable';
reason = translate('workspaceTree.templateNotReady');
}
return { pluginId, name: manifest.name || toolLabel(pluginId, names), tabs, status, reason };
}
async function loadWorkspaceTemplates() {
templatesLoading = true;
try {
const [templates, plugins, capabilities] = await Promise.all([
App.ListWorkspaceTemplates ? App.ListWorkspaceTemplates() : [],
App.GetPlugins ? App.GetPlugins() : [],
App.GetCapabilities ? App.GetCapabilities() : [],
]);
const [list, err] = resultOrError(templates, []);
if (err) {
createError = reportError('workspaceTree.templatesError', 'Could not load Deal templates. Please try again.', err);
workspaceTemplates = [];
return;
}
workspaceTemplates = Array.isArray(list) ? list : [];
await Promise.all((Array.isArray(plugins) ? plugins : []).map((plugin) => (
i18n.loadPlugin(plugin.manifest?.id, plugin.manifest?.localization).catch(() => {})
)));
const localizedPlugins = (Array.isArray(plugins) ? plugins : []).map((plugin) => i18n.localizePlugin(plugin));
templatePluginNames = localizedPlugins.reduce((names, plugin) => {
const id = plugin?.manifest?.id;
const name = plugin?.manifest?.name;
if (id && name) names[id] = name;
return names;
}, {});
templatePlugins = localizedPlugins.reduce((result, plugin) => {
const id = plugin?.manifest?.id;
if (id) result[id] = plugin;
return result;
}, {});
const [capabilityList] = resultOrError(capabilities, []);
templateCapabilities = new Set((Array.isArray(capabilityList) ? capabilityList : []).map(capability => capability?.name).filter(Boolean));
if (!workspaceTemplates.some(template => template.id === selectedTemplateId)) {
selectedTemplateId = workspaceTemplates[0]?.id || '';
}
} catch (error) {
createError = reportError('workspaceTree.templatesError', 'Could not load Deal templates. Please try again.', error);
workspaceTemplates = [];
} finally {
templatesLoading = false;
}
}
function wsName(workspace) {
return String(workspace?.name || workspace?.rootPath || '');
}
function asNode(workspace, order) {
const name = wsName(workspace);
return {
id: name,
type: 'space',
title: name,
name,
rootPath: workspace.rootPath || name,
status: 'active',
order,
};
}
function nodesForEvent() {
return workspaces.map(asNode);
}
async function loadWorkspaces() {
loading = true;
localError = '';
try {
const [list, err] = resultOrError(await App.ListWorkspaces(), []);
if (err) {
localError = reportError('workspaceTree.loadError', 'Could not load Deals. Please try again.', err);
workspaces = [];
} else {
workspaces = list || [];
if (!currentWorkspaceId) {
let currentWorkspace = null;
try {
currentWorkspace = await App.GetCurrentWorkspace();
} catch {
currentWorkspace = null;
}
const currentName = wsName(currentWorkspace);
if (workspaces.some((ws) => wsName(ws) === currentName)) {
currentWorkspaceId = currentName;
}
} else if (!workspaces.some((ws) => wsName(ws) === currentWorkspaceId)) {
currentWorkspaceId = '';
}
activeWorkspaceId.set(currentWorkspaceId);
}
} catch (e) {
localError = reportError('workspaceTree.loadError', 'Could not load Deals. Please try again.', e);
}
loading = false;
}
async function selectWorkspace(workspace) {
const id = wsName(workspace);
const err = await App.SetCurrentWorkspace(id);
if (err) {
localError = reportError('workspaceTree.selectError', 'Could not select this Deal. Please try again.', err);
return;
}
currentWorkspaceId = id;
activeWorkspaceId.set(id);
window.dispatchEvent(new CustomEvent('verstak:workspace-selected', {
detail: { workspaceName: id, nodes: nodesForEvent() }
}));
}
async function doCreate() {
const name = newWorkspaceName.trim();
if (!name) {
createError = tr('workspaceTree.nameRequired');
return;
}
if (!selectedTemplate) {
createError = tr('workspaceTree.chooseTemplate');
return;
}
const creationIssues = selectedTemplateIssues.map(tool => ({
pluginId: tool.pluginId,
name: tool.name,
reason: tool.reason,
}));
creating = true;
createError = '';
const [, err] = resultOrError(await App.CreateWorkspace(name, selectedTemplate.id), null);
if (err) {
createError = reportError('workspaceTree.createError', 'Could not create the Deal. Please try again.', err);
creating = false;
return;
}
showCreate = false;
newWorkspaceName = '';
creating = false;
await loadWorkspaces();
const created = workspaces.find((ws) => wsName(ws) === name);
if (created) await selectWorkspace(created);
templateWarning = creationIssues.length > 0 ? { workspaceName: name, issues: creationIssues } : null;
}
async function openCreateDialog() {
showCreate = true;
newWorkspaceName = '';
createError = '';
await loadWorkspaceTemplates();
}
function closeCreateDialog() {
if (creating) return;
showCreate = false;
newWorkspaceName = '';
createError = '';
}
function dismissTemplateWarning() {
templateWarning = null;
}
function startRename(workspace) {
renamingId = wsName(workspace);
renameValue = renamingId;
localError = '';
}
function cancelRename() {
renamingId = '';
renameValue = '';
}
async function commitRename(workspace) {
const oldName = wsName(workspace);
const newName = renameValue.trim();
if (!newName || newName === oldName) {
cancelRename();
return;
}
busyId = oldName;
const err = await App.RenameWorkspace(oldName, newName);
if (err) {
localError = reportError('workspaceTree.renameError', 'Could not rename the Deal. Please try again.', err);
busyId = '';
return;
}
renamingId = '';
renameValue = '';
busyId = '';
currentWorkspaceId = newName;
await loadWorkspaces();
const renamed = workspaces.find((ws) => wsName(ws) === newName);
if (renamed) await selectWorkspace(renamed);
}
async function trashWorkspace(workspace) {
const name = wsName(workspace);
busyId = name;
const [, err] = resultOrError(await App.TrashWorkspace(name), null);
if (err) {
localError = reportError('workspaceTree.trashError', 'Could not move the Deal to trash. Please try again.', err);
busyId = '';
return;
}
if (currentWorkspaceId === name) currentWorkspaceId = '';
busyId = '';
await loadWorkspaces();
if (currentWorkspaceId) {
const selected = workspaces.find((ws) => wsName(ws) === currentWorkspaceId);
if (selected) await selectWorkspace(selected);
}
}
</script>
<div class="wt">
<div class="wt-header">
<span class="wt-title">{tr('workspaceTree.title')}</span>
<button class="wt-btn" on:click={openCreateDialog} title={tr('workspaceTree.new')} type="button">+</button>
</div>
{#if loading}
<div class="wt-loading">{tr('common.loading')}</div>
{:else if localError}
<div class="wt-error">{localError}</div>
{/if}
<div class="wt-list">
{#each workspaces as workspace (wsName(workspace))}
{@const id = wsName(workspace)}
<div class="wt-node vt-list-row" class:selected={id === $activeWorkspaceId}>
<div class="wt-row">
<span class="wt-icon"><Icon name="space" size={13} class="wt-node-icon" /></span>
{#if renamingId === id}
<input
class="wt-rename"
bind:value={renameValue}
disabled={busyId === id}
on:keydown={(e) => {
if (e.key === 'Enter') commitRename(workspace);
if (e.key === 'Escape') cancelRename();
}}
/>
<button class="wt-btn wt-btn-small wt-always" on:click={() => commitRename(workspace)} title={tr('workspaceTree.saveRename')} type="button" disabled={busyId === id}>OK</button>
<button class="wt-btn wt-btn-small wt-always" on:click={cancelRename} title={tr('common.cancel')} type="button" disabled={busyId === id}>{tr('common.cancel')}</button>
{:else}
<button class="wt-label" on:click={() => selectWorkspace(workspace)} type="button">{id}</button>
<button class="wt-icon-btn" on:click={() => startRename(workspace)} title={tr('workspaceTree.rename')} type="button" disabled={busyId === id}>
<Icon name="edit" size={12} />
</button>
<button class="wt-icon-btn danger" on:click={() => trashWorkspace(workspace)} title={tr('workspaceTree.trash')} type="button" disabled={busyId === id}>
<Icon name="trash" size={12} />
</button>
{/if}
</div>
</div>
{/each}
</div>
{#if templateWarning}
<section class="workspace-template-warning" data-workspace-template-warning role="alert">
<div>
<strong>{tr('workspaceTree.templateIncompleteCreated', { name: templateWarning.workspaceName })}</strong>
{#each templateWarning.issues as issue (issue.pluginId)}
<div class="workspace-template-warning-item">{issue.name}: {issue.reason}</div>
{/each}
</div>
<button class="wt-btn" type="button" on:click={dismissTemplateWarning} title={tr('common.close')}>{tr('common.close')}</button>
</section>
{/if}
{#if showCreate}
<div class="workspace-create-overlay" data-workspace-create-modal role="dialog" aria-modal="true" aria-label={tr('workspaceTree.create')}>
<div class="workspace-create-modal">
<div class="workspace-create-header">
<div>
<h2>{tr('workspaceTree.new')}</h2>
</div>
<button class="wt-btn" on:click={closeCreateDialog} type="button" disabled={creating}>{tr('common.close')}</button>
</div>
<label class="workspace-create-field">
<span>{tr('pluginCard.name')}</span>
<input data-workspace-name type="text" bind:value={newWorkspaceName} placeholder={tr('workspaceTree.namePlaceholder')} disabled={creating} on:keydown={(event) => event.key === 'Enter' && doCreate()} />
</label>
<label class="workspace-create-field">
<span>{tr('workspaceTree.template')}</span>
<select data-workspace-template bind:value={selectedTemplateId} disabled={creating || templatesLoading || !workspaceTemplates.length}>
{#each workspaceTemplates as template (template.id)}
<option value={template.id}>{template.name}</option>
{/each}
</select>
</label>
{#if selectedTemplate}
<div class="workspace-template-summary">
<p data-workspace-template-description>{selectedTemplate.description}</p>
<div class="workspace-template-tools" data-workspace-template-tools>
{#each selectedTemplateTools as tool (tool.pluginId)}
<div
class="workspace-template-tool"
class:limited={tool.status === 'limited'}
class:unavailable={tool.status === 'unavailable'}
data-workspace-template-tool={tool.pluginId}
data-template-tool-status={tool.status}
>
<span class="workspace-template-tool-name">{tool.name}</span>
<span class="workspace-template-tool-tabs">{tool.tabs.length ? tr('workspaceTree.templateToolTabs', { tabs: tool.tabs.join(', ') }) : tr('workspaceTree.templateToolNoTabs')}</span>
<span class="workspace-template-tool-reason">{tool.reason}</span>
</div>
{/each}
</div>
{#if selectedTemplateIssues.length > 0}
<p class="workspace-template-incomplete" data-workspace-template-incomplete>{tr('workspaceTree.templateWillBeIncomplete')}</p>
{/if}
</div>
{/if}
{#if createError}
<p class="workspace-create-error" data-workspace-create-error role="alert">{createError}</p>
{/if}
<div class="workspace-create-actions">
<button class="wt-btn-primary" on:click={doCreate} type="button" disabled={creating || templatesLoading || !selectedTemplate}>{creating ? tr('workspaceTree.creating') : tr('workspaceTree.create')}</button>
<button class="wt-btn" on:click={closeCreateDialog} type="button" disabled={creating}>{tr('common.cancel')}</button>
</div>
</div>
</div>
{/if}
</div>
<style>
.wt { display: flex; flex-direction: column; flex: 1; overflow: hidden; position: relative; }
.wt-header { display: flex; align-items: center; justify-content: space-between; padding: 0.7rem 0.6rem 0.35rem; border-bottom: 1px solid var(--vt-color-border); flex-shrink: 0; }
.wt-title { color: var(--vt-color-text-muted); font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600; }
.wt-list { min-height: 0; overflow-y: auto; padding: 0.2rem 0.6rem; }
.wt-btn { min-height: 1.55rem; background: transparent; border: 1px solid transparent; color: var(--vt-color-text-muted); cursor: pointer; font-size: 0.78rem; padding: 0.12rem 0.38rem; border-radius: var(--vt-radius-sm); }
.wt-btn:hover:not(:disabled) { color: var(--vt-color-accent); background: var(--vt-color-accent-muted); border-color: rgba(78,204,163,0.25); }
.wt-btn-small { font-size: 0.7rem; opacity: 0; }
.wt-always { opacity: 1; }
.wt-row:hover .wt-btn-small { opacity: 1; }
.wt-loading, .wt-error { padding: 0.5rem; font-size: 0.75rem; color: var(--vt-color-text-muted); }
.wt-error { color: var(--vt-color-danger); }
.wt-row { display: flex; align-items: center; gap: 0.45rem; padding: 0.18rem 0.45rem; min-height: 1.85rem; border-radius: var(--vt-radius-sm); }
.wt-row:hover { background: var(--vt-color-surface-hover); }
.wt-node.selected > .wt-row { background: var(--vt-color-surface-selected); box-shadow: inset 2px 0 0 var(--vt-color-accent); }
.wt-icon { width: 0.95rem; height: 0.95rem; display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; color: var(--vt-color-text-muted); }
:global(.wt-node-icon) { display: block; }
.wt-label { flex: 1; min-width: 0; min-height: 0; justify-content: flex-start; background: none; border: none; color: var(--vt-color-text-secondary); font-size: 0.78rem; text-align: left; cursor: pointer; padding: 0.1rem 0; border-radius: var(--vt-radius-sm); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wt-label:hover { color: var(--vt-color-accent); }
.wt-icon-btn { width: 1.45rem; height: 1.45rem; min-height: 0; padding: 0; border: 1px solid transparent; background: transparent; color: var(--vt-color-text-muted); opacity: 0.75; flex-shrink: 0; cursor: pointer; border-radius: var(--vt-radius-sm); }
.wt-row:hover .wt-icon-btn { opacity: 1; }
.wt-icon-btn:hover:not(:disabled) { color: var(--vt-color-accent); background: var(--vt-color-accent-muted); border-color: rgba(78,204,163,0.25); }
.wt-icon-btn.danger:hover:not(:disabled) { color: var(--vt-color-danger); background: var(--vt-color-danger-muted); border-color: rgba(233,69,96,0.35); }
.wt-rename { flex: 1; min-width: 0; background: #0f1424; border: 1px solid var(--vt-color-border-strong); color: var(--vt-color-text-primary); padding: 0.2rem 0.35rem; border-radius: var(--vt-radius-sm); font-size: 0.78rem; }
.wt-rename:focus { outline: none; border-color: var(--vt-color-accent); box-shadow: var(--vt-focus-ring); }
.workspace-create-overlay { position: fixed; inset: 0; z-index: 10000; display: flex; align-items: center; justify-content: center; padding: 1rem; background: rgba(4, 8, 18, 0.7); }
.workspace-create-modal { width: min(34rem, 100%); display: grid; gap: 0.85rem; padding: 1rem; border: 1px solid var(--vt-color-border-strong); border-radius: var(--vt-radius-lg); background: var(--vt-color-surface); box-shadow: 0 18px 44px rgba(0, 0, 0, 0.38); }
.workspace-create-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; }
.workspace-create-header h2 { margin: 0; font-size: 1rem; }
.workspace-create-field { display: grid; gap: 0.35rem; color: var(--vt-color-text-muted); font-size: 0.75rem; }
.workspace-create-field input, .workspace-create-field select { width: 100%; min-height: 2rem; box-sizing: border-box; border: 1px solid var(--vt-color-border-strong); border-radius: var(--vt-radius-sm); background: #0f1424; color: var(--vt-color-text-primary); padding: 0.35rem 0.5rem; font: inherit; font-size: 0.84rem; }
.workspace-create-field select { appearance: none; background-color: #0f1424; background-image: linear-gradient(45deg, transparent 50%, var(--vt-color-text-muted) 50%), linear-gradient(135deg, var(--vt-color-text-muted) 50%, transparent 50%); background-position: calc(100% - 14px) 50%, calc(100% - 9px) 50%; background-size: 5px 5px, 5px 5px; background-repeat: no-repeat; padding-right: 1.7rem; }
.workspace-create-field select option { background: #0f1424; color: var(--vt-color-text-primary); }
.workspace-create-field input:focus, .workspace-create-field select:focus { outline: none; border-color: var(--vt-color-accent); box-shadow: var(--vt-focus-ring); }
.workspace-template-summary { display: grid; gap: 0.55rem; padding: 0.75rem; border: 1px solid var(--vt-color-border); border-radius: var(--vt-radius-md); background: var(--vt-color-surface-muted); }
.workspace-template-summary p { margin: 0; color: var(--vt-color-text-secondary); font-size: 0.8rem; line-height: 1.45; }
.workspace-template-tools { display: grid; gap: 0.4rem; }
.workspace-template-tool { display: grid; grid-template-columns: minmax(7rem, 1fr) minmax(0, 1.4fr) minmax(0, 1.2fr); gap: 0.5rem; align-items: center; padding: 0.45rem 0.55rem; border: 1px solid var(--vt-color-border-strong); border-radius: var(--vt-radius-sm); color: var(--vt-color-text-secondary); font-size: 0.72rem; }
.workspace-template-tool-name { color: var(--vt-color-text-primary); font-weight: 600; }
.workspace-template-tool-tabs { color: var(--vt-color-text-muted); }
.workspace-template-tool-reason { color: var(--vt-color-accent); }
.workspace-template-tool.limited { border-color: rgba(220, 164, 57, 0.58); background: rgba(220, 164, 57, 0.08); }
.workspace-template-tool.limited .workspace-template-tool-reason { color: #e8bc63; }
.workspace-template-tool.unavailable { border-color: rgba(233, 69, 96, 0.58); background: var(--vt-color-danger-muted); }
.workspace-template-tool.unavailable .workspace-template-tool-reason { color: var(--vt-color-danger); }
.workspace-template-incomplete { color: var(--vt-color-danger) !important; }
.workspace-template-warning { display: flex; gap: 0.5rem; align-items: flex-start; justify-content: space-between; margin: 0.25rem 0.6rem 0.5rem; padding: 0.6rem; border: 1px solid rgba(233, 69, 96, 0.58); border-radius: var(--vt-radius-md); background: var(--vt-color-danger-muted); color: var(--vt-color-text-secondary); font-size: 0.73rem; line-height: 1.4; }
.workspace-template-warning strong { color: var(--vt-color-danger); }
.workspace-template-warning-item { margin-top: 0.2rem; }
@media (max-width: 700px) { .workspace-template-tool { grid-template-columns: 1fr; gap: 0.18rem; } }
.workspace-create-error { margin: 0; color: var(--vt-color-danger); font-size: 0.78rem; line-height: 1.4; }
.workspace-create-actions { display: flex; gap: 0.4rem; justify-content: flex-end; }
.wt-btn-primary { background: var(--vt-color-accent); color: #101827; border: none; padding: 0.3rem 0.6rem; border-radius: var(--vt-radius-sm); cursor: pointer; font-size: 0.75rem; font-weight: 600; }
.wt-btn-primary:hover:not(:disabled) { background: #3dbb92; }
.wt-btn-primary:disabled, .wt-btn:disabled, .wt-icon-btn:disabled { opacity: 0.5; cursor: not-allowed; }
</style>
File diff suppressed because it is too large Load Diff
+80
View File
@@ -0,0 +1,80 @@
<script>
/**
* Icon.svelte — Renders bundled Lucide SVG icons.
*
* Usage:
* <Icon name="puzzle" size={18} />
* <Icon name="flask" class="my-icon" />
*
* Rules:
* - NO emoji, NO unicode pictographic symbols
* - NO dependency on system icon fonts
* - If name is not found, renders a default circle icon
*/
import Activity from 'lucide-svelte/icons/activity';
import Briefcase from 'lucide-svelte/icons/briefcase';
import ChevronDown from 'lucide-svelte/icons/chevron-down';
import ChevronLeft from 'lucide-svelte/icons/chevron-left';
import ChevronRight from 'lucide-svelte/icons/chevron-right';
import Circle from 'lucide-svelte/icons/circle';
import FlaskConical from 'lucide-svelte/icons/flask-conical';
import Folder from 'lucide-svelte/icons/folder';
import Inbox from 'lucide-svelte/icons/inbox';
import LayoutGrid from 'lucide-svelte/icons/layout-grid';
import ListTodo from 'lucide-svelte/icons/list-todo';
import PanelsTopLeft from 'lucide-svelte/icons/panels-top-left';
import Pencil from 'lucide-svelte/icons/pencil';
import Plug from 'lucide-svelte/icons/plug';
import Puzzle from 'lucide-svelte/icons/puzzle';
import Settings from 'lucide-svelte/icons/settings';
import Shield from 'lucide-svelte/icons/shield';
import TriangleAlert from 'lucide-svelte/icons/triangle-alert';
import Trash2 from 'lucide-svelte/icons/trash-2';
import X from 'lucide-svelte/icons/x';
export let name = 'dot';
export let size = 16;
export let className = '';
const icons = {
activity: Activity,
case: Briefcase,
chevronDown: ChevronDown,
chevronLeft: ChevronLeft,
chevronRight: ChevronRight,
dot: Circle,
edit: Pencil,
flask: FlaskConical,
folder: Folder,
gear: Settings,
inbox: Inbox,
'list-todo': ListTodo,
logo: PanelsTopLeft,
plugin: Plug,
puzzle: Puzzle,
space: LayoutGrid,
trash: Trash2,
vault: Shield,
warning: TriangleAlert,
x: X,
};
const aliases = {
'🧪': 'flask',
danger: 'warning',
settings: 'gear',
todo: 'list-todo',
};
$: iconKey = aliases[name] || name || 'dot';
$: IconComponent = icons[iconKey] || icons.dot;
$: iconClass = [className, $$restProps.class].filter(Boolean).join(' ');
</script>
<svelte:component
this={IconComponent}
size={size}
{...$$restProps}
class={iconClass}
aria-hidden="true"
/>
+28 -2
View File
@@ -1,7 +1,33 @@
import App from './App.svelte'; import App from './App.svelte';
import * as Backend from '../wailsjs/go/api/App';
import { i18n } from './lib/i18n/index.js';
const app = new App({ function unpack(result) {
target: document.getElementById('app'), if (Array.isArray(result) && result.length === 2 && (typeof result[1] === 'string' || result[1] == null)) {
if (result[1]) throw new Error(result[1]);
return result[0];
}
return result;
}
i18n.configure({
loadPluginCatalog: async (pluginId, locale) => unpack(await Backend.GetPluginLocalization(pluginId, locale)),
}); });
async function start() {
try {
const settings = await Backend.GetAppSettings();
await i18n.initialize(settings?.language || 'system');
} catch (error) {
console.error('[i18n] initialization failed:', error);
await i18n.initialize('system');
}
return new App({
target: document.getElementById('app'),
});
}
const app = start();
export default app; export default app;
+303
View File
@@ -0,0 +1,303 @@
#!/usr/bin/env node
/**
* bundle-host-test.cjs — Headless smoke-test for PluginBundleHost contract.
*
* Tests:
* 1. Error boundary: missing frontend entry (no bundle)
* 2. Error boundary: bundle JS throws during execution
* 3. Error boundary: component id not found in bundle
* 4. Error boundary: component mount throws at runtime
* 5. Real mount: bundle executes and registers components
* 6. Real mount: DiagnosticsPanel writes expected text to container
* 7. Real mount: PlatformTestSettings writes expected text to container
* 8. Real mount: cleanup (unmount) empties the container
* 9. Real mount: component mount throw is catchable
*
* Runs the real platform-test frontend/dist/index.js in a vm.Sandbox
* with a minimal window/document mock.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const vm = require('vm');
// ── paths ──────────────────────────────────────────────────────────────
const BUNDLE_PATH = path.resolve(
__dirname, '..', '..', 'plugins', 'platform-test', 'frontend', 'dist', 'index.js'
);
// ── helpers ────────────────────────────────────────────────────────────
function makeMockDocument() {
let elCounter = 0;
function createElement(tag) {
elCounter++;
const el = {
tagName: tag.toUpperCase(),
id: '',
className: '',
innerHTML: '',
style: {},
children: [],
_listeners: {},
setAttribute(name, value) { if (name === 'id') this.id = value; },
getAttribute(name) { if (name === 'id') return this.id; return null; },
addEventListener(type, handler) { this._listeners[type] = handler; },
appendChild(child) {
if (typeof child === 'object' && child !== null) {
this.children.push(child);
}
},
removeChild(child) {
const idx = this.children.indexOf(child);
if (idx >= 0) this.children.splice(idx, 1);
},
};
return el;
}
function createTextNode(text) {
return { nodeType: 3, textContent: String(text), _text: String(text) };
}
const headChildren = [];
const bodyChildren = [];
return {
head: { appendChild: (el) => { headChildren.push(el); }, children: headChildren },
body: { appendChild: (el) => { bodyChildren.push(el); }, children: bodyChildren },
createElement,
createTextNode,
querySelector: () => null,
getElementById: () => null,
};
}
function makeMockWindow() {
const doc = makeMockDocument();
const registry = {};
const w = {
VerstakPluginRegister(pluginId, bundle) {
if (!pluginId || !bundle || !bundle.components) return;
registry[pluginId] = bundle.components;
},
VerstakPluginAPI(pluginId) {
return {
pluginId,
capabilities: { has: () => false },
events: { publish: () => {}, subscribe: () => {} },
settings: { read: () => null, write: () => {} },
commands: { execute: () => {} },
};
},
document: doc,
console,
__VERSTAK_PLUGIN_REGISTRY__: registry,
};
// window === globalThis in browser — make it self-referential
w.window = w;
w.globalThis = w;
return w;
}
// Recursively extract text from mock element tree
function findTextContent(el) {
if (typeof el === 'string') return el;
if (el == null) return '';
if (el._text) return el._text;
if (el.textContent) return el.textContent;
if (Array.isArray(el.children)) {
return el.children.map(c => findTextContent(c)).join(' ');
}
return '';
}
// ── Runner ─────────────────────────────────────────────────────────────
let passed = 0;
let failed = 0;
const errors = [];
function test(name, fn) {
try {
fn();
passed++;
console.log(` ✅ ${name}`);
} catch (e) {
failed++;
const msg = e.message || String(e);
errors.push(`${name}: ${msg}`);
console.log(` ❌ ${name} — ${msg}`);
}
}
// ── Tests ──────────────────────────────────────────────────────────────
console.log('=== bundle-host-test: PluginBundleHost contract smoke ===');
console.log(` bundle path: ${BUNDLE_PATH}`);
console.log(` bundle exists: ${fs.existsSync(BUNDLE_PATH)}`);
console.log('');
// ── Error Boundary Tests ──
test('E1. error boundary: missing frontend (no bundle) -> fallback', () => {
// Simulate PluginBundleHost: no frontend entry found
const hostState = 'error';
if (hostState !== 'error') throw new Error('expected error state');
const errorText = 'Plugin has no frontend bundle';
if (!errorText.includes('no frontend')) throw new Error('error text mismatch');
});
test('E2. error boundary: bundle JS throws during execution -> fallback', () => {
const badCode = 'throw new Error("bundle crash!");';
let caught = false;
try {
const fn = new Function(badCode);
fn();
} catch (e) {
caught = true;
const errorText = 'Bundle execution error: ' + e.message;
if (!errorText.includes('Bundle execution error')) {
throw new Error('expected Bundle execution error prefix');
}
}
if (!caught) throw new Error('bundle throw was not caught');
});
test('E3. error boundary: component id not found -> fallback', () => {
const registry = { 'test.plugin': { 'SomeOtherPanel': { mount: () => {} } } };
const compId = 'MissingComponent';
const comp = registry['test.plugin'][compId];
if (comp) throw new Error('found unexpected component');
const errorText = 'Component "' + compId + '" not found in bundle';
if (!errorText.includes('not found')) throw new Error('error text mismatch');
});
test('E4. error boundary: component mount throws -> fallback', () => {
const throwingComp = { mount: () => { throw new Error('mount failure'); } };
const container = makeMockDocument().createElement('div');
let caught = false;
try {
throwingComp.mount(container, {}, {});
} catch (e) {
caught = true;
}
if (!caught) throw new Error('mount throw was not caught');
});
// ── Real Mount Tests (run the actual platform-test bundle) ──
if (!fs.existsSync(BUNDLE_PATH)) {
console.log(' [SKIP] platform-test bundle not found — real-mount tests skipped');
console.log(' [SKIP] run install-dev-plugins.sh first');
process.exit(0);
}
const bundleSource = fs.readFileSync(BUNDLE_PATH, 'utf-8');
test('R1. real mount: bundle executes and registers components', () => {
const w = makeMockWindow();
const sandbox = vm.createContext(w);
vm.runInContext(bundleSource, sandbox);
const reg = sandbox.__VERSTAK_PLUGIN_REGISTRY__;
if (!reg) throw new Error('registry not created');
if (!reg['verstak.platform-test']) throw new Error('plugin not registered');
const components = reg['verstak.platform-test'];
if (!components.DiagnosticsPanel) throw new Error('DiagnosticsPanel missing');
if (!components.PlatformTestSettings) throw new Error('PlatformTestSettings missing');
});
test('R2. real mount: DiagnosticsPanel.mount() writes expected text to container', () => {
const w = makeMockWindow();
const sandbox = vm.createContext(w);
vm.runInContext(bundleSource, sandbox);
const components = sandbox.__VERSTAK_PLUGIN_REGISTRY__['verstak.platform-test'];
const api = w.VerstakPluginAPI('verstak.platform-test');
const container = w.document.createElement('div');
container.id = 'test-diagnostics';
components.DiagnosticsPanel.mount(container, { componentId: 'verstak.platform-test.diagnostics' }, api);
const text = findTextContent(container);
const required = ['Platform Diagnostics', 'verstak.platform-test', 'Test Results', 'Plugin Registration'];
const missing = required.filter(chunk => !text.includes(chunk));
if (missing.length > 0) throw new Error('missing: ' + missing.join(', '));
// Verify bundle source contains Frontend Bundle Loaded (badge fix)
if (!bundleSource.includes('Frontend Bundle Loaded')) throw new Error('bundle missing badge text');
// Verify mount populated children
if (container.children.length < 4) throw new Error('mount did not populate container children');
});
test('R3. real mount: PlatformTestSettings.mount() writes expected text to container', () => {
const w = makeMockWindow();
const sandbox = vm.createContext(w);
vm.runInContext(bundleSource, sandbox);
const components = sandbox.__VERSTAK_PLUGIN_REGISTRY__['verstak.platform-test'];
const api = w.VerstakPluginAPI('verstak.platform-test');
const container = w.document.createElement('div');
container.id = 'test-settings';
components.PlatformTestSettings.mount(container, { componentId: 'verstak.platform-test.settings' }, api);
const text = findTextContent(container);
const required = ['Platform Test Settings', 'verstak.platform-test', 'Settings panel loaded from plugin frontend bundle', 'Interactive Counter'];
const missing = required.filter(chunk => !text.includes(chunk));
if (missing.length > 0) throw new Error('missing: ' + missing.join(', '));
});
test('R4. real mount: unmount() clears container', () => {
const w = makeMockWindow();
const sandbox = vm.createContext(w);
vm.runInContext(bundleSource, sandbox);
const components = sandbox.__VERSTAK_PLUGIN_REGISTRY__['verstak.platform-test'];
const api = w.VerstakPluginAPI('verstak.platform-test');
const container = w.document.createElement('div');
// Mount — verify container was modified
components.PlatformTestSettings.mount(container, {}, api);
if (container.innerHTML === '' && container.className === '') {
// mount should have set something
if (container.children.length === 0) {
throw new Error('mount should have populated container');
}
}
// Unmount — verify cleanup
components.PlatformTestSettings.unmount(container);
if (container.innerHTML !== '') throw new Error('unmount did not clear innerHTML');
if (container.className !== '') throw new Error('unmount did not clear className');
});
test('R5. error scenario: component mount throw is catchable', () => {
const failingStates = [{ mount: () => { throw new Error('render error'); }, unmount: () => {} }];
const container = makeMockDocument().createElement('div');
let caught = false;
try {
failingStates[0].mount(container, {}, {});
} catch (e) {
caught = true;
if (!e.message.includes('render error')) throw new Error('wrong error message');
}
if (!caught) throw new Error('mount throw was not caught');
});
// ── Summary ──
console.log('');
console.log(`=== results: ${passed} passed, ${failed} failed ===`);
if (failed > 0) {
console.log('Errors:');
errors.forEach(e => console.log(` - ${e}`));
process.exit(1);
}
+32
View File
@@ -0,0 +1,32 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import vm from 'node:vm';
const source = fs.readFileSync(path.resolve('frontend/src/lib/log/debug.js'), 'utf8');
const values = new Map();
const localStorage = {
getItem(key) { return values.has(key) ? values.get(key) : null; },
setItem(key, value) { values.set(key, String(value)); },
removeItem(key) { values.delete(key); },
};
const context = vm.createContext({
console: { log() {} },
localStorage,
window: { location: { search: '' } },
});
const module = new vm.SourceTextModule(source, { context, identifier: 'debug.js' });
await module.link(() => { throw new Error('debug.js must not import modules'); });
await module.evaluate();
const { debug } = module.namespace;
debug.enable({ persist: false });
assert.equal(debug.isEnabled(), true, 'session debug should enable diagnostics');
assert.equal(
localStorage.getItem('verstak-debug'),
null,
'a --debug session must not make diagnostics persist into ordinary launches',
);
console.log('debug mode smoke passed');
+112
View File
@@ -0,0 +1,112 @@
import assert from 'node:assert/strict';
import {
createI18n,
resolveLocale,
} from '../src/lib/i18n/index.js';
import englishShellCatalog from '../src/lib/i18n/catalogs/en.js';
import russianShellCatalog from '../src/lib/i18n/catalogs/ru.js';
assert.deepEqual(
Object.keys(russianShellCatalog).sort(),
Object.keys(englishShellCatalog).sort(),
'English and Russian shell catalogs must have identical keys',
);
assert.equal(resolveLocale('system', ['ru-RU']), 'ru');
assert.equal(resolveLocale('system', ['uk-UA', 'en-US']), 'en');
assert.equal(resolveLocale('ru', ['en-US']), 'ru');
assert.equal(resolveLocale('en', ['ru-RU']), 'en');
assert.throws(() => resolveLocale('de', ['de-DE']), /unsupported language/);
const catalogs = {
'localized.plugin': {
en: { 'manifest.name': 'Localized Plugin', greeting: 'Hello, {name}!' },
ru: {
'manifest.name': 'Локализованный плагин',
'contributions.views.localized.view.title': 'Локализованный экран',
greeting: 'Привет, {name}!',
},
},
};
const loads = [];
const service = createI18n({
shellCatalogs: {
en: { loading: 'Loading {name}...', fallbackOnly: 'English fallback' },
ru: { loading: 'Загрузка {name}...' },
},
systemLanguages: () => ['ru-RU'],
loadPluginCatalog: async (pluginId, locale) => {
loads.push(`${pluginId}:${locale}`);
return catalogs[pluginId]?.[locale] || {};
},
});
await service.initialize('system');
assert.equal(service.getLanguagePreference(), 'system');
assert.equal(service.getLocale(), 'ru');
assert.equal(service.t('loading', { name: 'Верстак' }), 'Загрузка Верстак...');
assert.equal(service.t('fallbackOnly'), 'English fallback');
assert.equal(service.t('missing', undefined, 'Explicit fallback'), 'Explicit fallback');
assert.equal(service.t('unknown'), 'unknown');
await service.loadPlugin('localized.plugin', {
defaultLocale: 'en',
locales: { en: 'locales/en.json', ru: 'locales/ru.json' },
});
assert.deepEqual(loads.sort(), ['localized.plugin:en', 'localized.plugin:ru']);
assert.equal(service.translatePlugin('localized.plugin', 'greeting', { name: 'Мир' }), 'Привет, Мир!');
const plugin = {
manifest: {
id: 'localized.plugin',
name: 'Localized Plugin',
description: 'Literal description',
contributes: {
views: [{ id: 'localized.view', title: 'Literal View', component: 'View' }],
},
},
};
const localized = service.localizePlugin(plugin);
assert.notEqual(localized, plugin);
assert.equal(localized.manifest.name, 'Локализованный плагин');
assert.equal(localized.manifest.description, 'Literal description');
assert.equal(localized.manifest.contributes.views[0].title, 'Локализованный экран');
assert.equal(plugin.manifest.name, 'Localized Plugin');
const summary = service.localizeContributionSummary({
views: [{ pluginId: 'localized.plugin', id: 'localized.view', title: 'Literal View', component: 'View' }],
});
assert.equal(summary.views[0].title, 'Локализованный экран');
let notifications = 0;
const unsubscribe = service.subscribe(() => { notifications += 1; });
assert.equal(notifications, 1);
await service.setLanguagePreference('en');
assert.equal(service.getLocale(), 'en');
assert.equal(service.translatePlugin('localized.plugin', 'greeting', { name: 'World' }), 'Hello, World!');
assert.equal(notifications, 2);
unsubscribe();
await service.setLanguagePreference('ru');
assert.equal(notifications, 2);
const resilientService = createI18n({
shellCatalogs: { en: { title: 'Title' }, ru: { title: 'Заголовок' } },
systemLanguages: () => ['en-US'],
loadPluginCatalog: async () => { throw new Error('broken catalog'); },
});
await resilientService.initialize('en');
await assert.rejects(
resilientService.loadPlugin('broken.plugin', {
defaultLocale: 'en',
locales: { en: 'locales/en.json', ru: 'locales/ru.json' },
}),
/broken catalog/,
);
let resilientNotification = '';
resilientService.subscribe((nextLocale) => { resilientNotification = nextLocale; });
await resilientService.setLanguagePreference('ru');
assert.equal(resilientService.getLanguagePreference(), 'ru');
assert.equal(resilientService.getLocale(), 'ru');
assert.equal(resilientNotification, 'ru');
assert.equal(resilientService.t('title'), 'Заголовок');
console.log('i18n service tests passed');
@@ -0,0 +1,36 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const files = [
'src/lib/shell/CommandPalette.svelte',
'src/lib/plugin-manager/PluginManager.svelte',
'src/lib/shell/VaultSelection.svelte',
'src/lib/shell/WorkspaceTree.svelte',
'src/lib/plugin-host/PluginBundleHost.svelte',
];
const patterns = [
/(?:^|\n)\s*(?:error|localError|createError)\s*=\s*(?:err|String\()/,
/(?:^|\n)\s*error\s*=\s*['"].*['"]\s*\+\s*(?:err|String\()/,
/tr\([^\n]*\{\s*error\s*:/,
];
const violations = [];
for (const relativePath of files) {
const source = fs.readFileSync(path.join(root, relativePath), 'utf8');
for (const pattern of patterns) {
if (pattern.test(source)) violations.push(relativePath);
}
}
if (violations.length) {
throw new Error(`Technical backend errors reach desktop UI in: ${[...new Set(violations)].join(', ')}`);
}
const bundleHost = fs.readFileSync(path.join(root, 'src/lib/plugin-host/PluginBundleHost.svelte'), 'utf8');
if (!/<p class="error-message">\{errorText \|\| tr\('bundle\.unknownError'\)\}<\/p>\s*<details class="error-details">/.test(bundleHost)) {
throw new Error('Plugin bundle errors must keep the user-facing message outside expandable technical details');
}
console.log('desktop UI does not interpolate raw backend errors');
@@ -0,0 +1,155 @@
import fs from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
const pluginData = {};
const scheduledNotifications = [];
globalThis.window = {
__VERSTAK_PLUGIN_REGISTRY__: {},
__VERSTAK_EVENT_HANDLERS__: {},
__VERSTAK_COMMAND_HANDLERS__: {},
go: {
api: {
App: {
GetContributions: () => Promise.resolve({
fileActions: [
{
pluginId: 'provider.plugin',
id: 'provider.file.action',
label: 'Provider File Action',
handler: 'provider.command',
},
],
noteActions: [],
contextMenuEntries: [],
}),
ExecutePluginCommand: (pluginId, commandId, args) => Promise.resolve([{
status: 'declared',
pluginId,
commandId,
args,
}, '']),
ReadPluginDataJSON: (pluginId, name) => Promise.resolve([
Object.assign({}, (pluginData[pluginId] && pluginData[pluginId][name]) || {}),
'',
]),
WritePluginDataJSON: (pluginId, name, data) => {
pluginData[pluginId] = pluginData[pluginId] || {};
pluginData[pluginId][name] = Object.assign({}, data || {});
return Promise.resolve('');
},
ReplacePluginNotifications: (pluginId, items) => {
scheduledNotifications.push({ pluginId, items });
return Promise.resolve(items[0]?.id === 'rejected' ? 'notification permission denied' : '');
},
ClearPluginNotifications: (pluginId) => {
scheduledNotifications.push({ pluginId, clear: true });
return Promise.resolve('');
},
},
},
},
};
globalThis.__mockApp = window.go.api.App;
const localeListeners = new Set();
globalThis.__mockI18n = {
getLocale: () => 'ru',
translatePlugin: (_pluginId, key, params, fallback) => {
const messages = { greeting: 'Привет, {name}!' };
const message = messages[key] || fallback || key;
return message.replace(/\{([^}]+)\}/g, (placeholder, name) => (
Object.prototype.hasOwnProperty.call(params || {}, name) ? String(params[name]) : placeholder
));
},
subscribe: (listener) => {
localeListeners.add(listener);
listener('ru');
return () => localeListeners.delete(listener);
},
};
const sourcePath = path.resolve('frontend/src/lib/plugin-host/VerstakPluginAPI.js');
const source = fs.readFileSync(sourcePath, 'utf8')
.replace("import * as App from '../../../wailsjs/go/api/App';", 'const App = globalThis.__mockApp;')
.replace("import { i18n } from '../i18n/index.js';", 'const i18n = globalThis.__mockI18n;');
const tempPath = path.resolve('/tmp/verstak-plugin-api-contributions-test.mjs');
fs.writeFileSync(tempPath, source);
const apiModule = await import(pathToFileURL(tempPath).href + '?t=' + Date.now());
const api = apiModule.createPluginAPI('verstak.files');
if (!api.contributions || typeof api.contributions.list !== 'function') {
throw new Error('api.contributions.list is missing');
}
if (!api.commands || typeof api.commands.executeFor !== 'function') {
throw new Error('api.commands.executeFor is missing');
}
if (!api.i18n || typeof api.i18n.getLocale !== 'function' || typeof api.i18n.t !== 'function' || typeof api.i18n.onDidChangeLocale !== 'function') {
throw new Error('api.i18n contract is missing');
}
if (api.i18n.getLocale() !== 'ru' || api.i18n.t('greeting', { name: 'Мир' }) !== 'Привет, Мир!') {
throw new Error('api.i18n locale or translation is incorrect');
}
let localeNotifications = 0;
api.i18n.onDidChangeLocale(() => { localeNotifications += 1; });
if (localeNotifications !== 1 || localeListeners.size !== 1) {
throw new Error('api.i18n locale subscription was not registered');
}
const fileActions = await api.contributions.list('fileActions');
if (fileActions.length !== 1 || fileActions[0].id !== 'provider.file.action') {
throw new Error(`unexpected file actions: ${JSON.stringify(fileActions)}`);
}
window.__VERSTAK_COMMAND_HANDLERS__['provider.plugin:provider.command'] = (args, declared) => ({
handledPath: args.path,
declaredPlugin: declared.pluginId,
});
const result = await api.commands.executeFor('provider.plugin', 'provider.command', {
source: 'files',
path: 'Project/Docs/readme.md',
});
if (result.status !== 'handled' || result.result.handledPath !== 'Project/Docs/readme.md') {
throw new Error(`unexpected executeFor result: ${JSON.stringify(result)}`);
}
if (!api.storage || !api.storage.data || typeof api.storage.data.read !== 'function' || typeof api.storage.data.write !== 'function') {
throw new Error('api.storage.data read/write is missing');
}
await api.storage.data.write('search-index', { version: 1, workspaceRootPath: 'Project' });
const stored = await api.storage.data.read('search-index');
if (stored.version !== 1 || stored.workspaceRootPath !== 'Project') {
throw new Error(`unexpected storage data: ${JSON.stringify(stored)}`);
}
if (!api.notifications || typeof api.notifications.replace !== 'function' || typeof api.notifications.clear !== 'function') {
throw new Error('api.notifications replace/clear contract is missing');
}
await api.notifications.replace([{ id: 'reminder-1', dueAt: '2026-07-14T10:00:00Z', title: 'Reminder' }]);
await api.notifications.clear();
if (scheduledNotifications.length !== 2
|| scheduledNotifications[0].pluginId !== 'verstak.files'
|| scheduledNotifications[0].items[0].id !== 'reminder-1'
|| scheduledNotifications[1].pluginId !== 'verstak.files'
|| !scheduledNotifications[1].clear) {
throw new Error(`unexpected notification calls: ${JSON.stringify(scheduledNotifications)}`);
}
let rejected = false;
try {
await api.notifications.replace([{ id: 'rejected', dueAt: '2026-07-14T10:00:00Z', title: 'Reminder' }]);
} catch (err) {
rejected = String(err.message || err).includes('[plugin:verstak.files] notifications.replace failed: notification permission denied');
}
if (!rejected) {
throw new Error('notification backend errors must be plugin-scoped rejections');
}
api.dispose();
if (localeListeners.size !== 0) {
throw new Error('api.i18n locale subscription was not disposed');
}
console.log('plugin api contributions smoke passed');
+72
View File
@@ -0,0 +1,72 @@
import fs from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
const calls = [];
globalThis.window = {
__VERSTAK_PLUGIN_REGISTRY__: {},
__VERSTAK_EVENT_HANDLERS__: {},
__VERSTAK_COMMAND_HANDLERS__: {},
go: {
api: {
App: {
ReadVaultFileBytes: (pluginId, relativePath) => {
calls.push({ method: 'ReadVaultFileBytes', pluginId, relativePath });
return Promise.resolve([{ relativePath, size: 4, mimeHint: 'image/png', dataBase64: 'iVBORw==' }, '']);
},
RestoreVaultTrash: (pluginId, trashId, options) => {
calls.push({ method: 'RestoreVaultTrash', pluginId, trashId, options });
return Promise.resolve(['Docs/restored.txt', '']);
},
WriteVaultFileBytes: (pluginId, relativePath, dataBase64, options) => {
calls.push({ method: 'WriteVaultFileBytes', pluginId, relativePath, dataBase64, options });
return Promise.resolve('');
},
},
},
},
};
globalThis.__mockApp = window.go.api.App;
const sourcePath = path.resolve('frontend/src/lib/plugin-host/VerstakPluginAPI.js');
const source = fs.readFileSync(sourcePath, 'utf8')
.replace("import * as App from '../../../wailsjs/go/api/App';", 'const App = globalThis.__mockApp;');
const tempPath = path.resolve('/tmp/verstak-plugin-api-files-test.mjs');
fs.writeFileSync(tempPath, source);
const apiModule = await import(pathToFileURL(tempPath).href + '?t=' + Date.now());
const api = apiModule.createPluginAPI('verstak.files');
if (!api.files || typeof api.files.restoreTrash !== 'function') {
throw new Error('api.files.restoreTrash is missing');
}
if (typeof api.files.readBytes !== 'function') {
throw new Error('api.files.readBytes is missing');
}
if (typeof api.files.writeBytes !== 'function') {
throw new Error('api.files.writeBytes is missing');
}
const bytes = await api.files.readBytes('Docs/image.png');
if (bytes.dataBase64 !== 'iVBORw==' || bytes.mimeHint !== 'image/png') {
throw new Error(`unexpected readBytes result: ${JSON.stringify(bytes)}`);
}
await api.files.writeBytes('Docs/copy.png', 'iVBORw==', { createIfMissing: true });
const restored = await api.files.restoreTrash('trash-1', { overwrite: true });
if (restored !== 'Docs/restored.txt') {
throw new Error(`unexpected restore result: ${JSON.stringify(restored)}`);
}
if (calls.length !== 3 || calls[0].method !== 'ReadVaultFileBytes' || calls[0].pluginId !== 'verstak.files' || calls[0].relativePath !== 'Docs/image.png') {
throw new Error(`unexpected ReadVaultFileBytes call: ${JSON.stringify(calls)}`);
}
if (calls[1].method !== 'WriteVaultFileBytes' || calls[1].pluginId !== 'verstak.files' || calls[1].relativePath !== 'Docs/copy.png' || calls[1].dataBase64 !== 'iVBORw==' || calls[1].options.createIfMissing !== true) {
throw new Error(`unexpected WriteVaultFileBytes call: ${JSON.stringify(calls)}`);
}
if (calls[2].method !== 'RestoreVaultTrash' || calls[2].pluginId !== 'verstak.files' || calls[2].trashId !== 'trash-1' || calls[2].options.overwrite !== true) {
throw new Error(`unexpected RestoreVaultTrash call: ${JSON.stringify(calls)}`);
}
console.log('plugin api files smoke passed');
+9
View File
@@ -0,0 +1,9 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
const source = await readFile(new URL('../src/lib/shell/WorkspaceTree.svelte', import.meta.url), 'utf8');
assert.match(source, /\.workspace-create-field select\s*\{[^}]*appearance:\s*none/, 'workspace template select must hide the native arrow');
assert.match(source, /\.workspace-create-field select option\s*\{[^}]*background/, 'workspace template options must use the application surface');
console.log('workspace select style contract passed');
@@ -0,0 +1,57 @@
import fs from 'node:fs';
import path from 'node:path';
const root = path.resolve('.');
function read(relativePath) {
return fs.readFileSync(path.join(root, relativePath), 'utf8');
}
function assertIncludes(source, needle, message) {
if (!source.includes(needle)) {
throw new Error(message);
}
}
const workspaceHost = read('frontend/src/lib/shell/WorkspaceHost.svelte');
const statusBar = read('frontend/src/lib/shell/StatusBar.svelte');
const pluginManager = read('frontend/src/lib/plugin-manager/PluginManager.svelte');
const syncManifest = JSON.parse(read('../verstak-official-plugins/plugins/sync/plugin.json'));
assertIncludes(
workspaceHost,
'data-workspace-search',
'WorkspaceHost should expose a stable workspace header search slot'
);
assertIncludes(
workspaceHost,
'toolOrder',
'WorkspaceHost should define usage-based workspace tool ordering'
);
assertIncludes(
workspaceHost,
'sortWorkspaceTools',
'WorkspaceHost should sort workspace tools by expected usage'
);
assertIncludes(
statusBar,
"import PluginBundleHost from '../plugin-host/PluginBundleHost.svelte';",
'StatusBar should mount plugin-provided status bar components'
);
assertIncludes(
statusBar,
'componentId={item.handler}',
'StatusBar should use statusBarItems.handler as the component id'
);
const syncStatus = syncManifest.contributes.statusBarItems.find((item) => item.id === 'verstak.sync.status');
if (!syncStatus || syncStatus.handler !== 'SyncStatusBar') {
throw new Error('Sync statusBarItem should declare handler "SyncStatusBar"');
}
if (/lastOpenedKey\s*=\s*key;\s*openSettingsFromProps\(activeSettingsPluginId,\s*activeSettingsPanelId\)/s.test(pluginManager)) {
throw new Error('PluginManager should not mark settings panel as opened before resolving contributions');
}
console.log('shell source contract smoke passed');
+28
View File
@@ -0,0 +1,28 @@
import en from '../src/lib/i18n/catalogs/en.js';
import ru from '../src/lib/i18n/catalogs/ru.js';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const forbidden = [
['English catalog', /\bworkspace(?:s)?\b/i],
['Russian catalog', /рабоч(?:ее|ие|ем|его|их)\s+пространств(?:о|а|е)?/i],
];
for (const [name, pattern] of forbidden) {
const violations = Object.entries(name === 'English catalog' ? en : ru)
.filter(([, value]) => pattern.test(value));
if (violations.length) {
throw new Error(`${name} still exposes workspace terminology: ${violations.map(([key]) => key).join(', ')}`);
}
}
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const overview = fs.readFileSync(path.join(root, 'src/lib/shell/TodaySurface.svelte'), 'utf8');
for (const phrase of ['Workspace opened', 'Workspace activity', 'Workspace overview note']) {
if (overview.includes(phrase)) {
throw new Error(`Overview exposes workspace terminology: ${phrase}`);
}
}
console.log('desktop user terminology uses Deal/Дело');
+13
View File
@@ -0,0 +1,13 @@
import fs from 'node:fs';
import path from 'node:path';
const bindingsPath = path.resolve('frontend/wailsjs/go/api/App.js');
const bindings = fs.readFileSync(bindingsPath, 'utf8');
for (const method of ['ReplacePluginNotifications', 'ClearPluginNotifications']) {
if (!bindings.includes(`export function ${method}(`)) {
throw new Error(`Wails binding does not export ${method}`);
}
}
console.log('Wails notification bindings are present');
+37 -10
View File
@@ -1,14 +1,41 @@
import { resolve } from 'node:path';
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte'; import { svelte } from '@sveltejs/vite-plugin-svelte';
export default defineConfig({ export default defineConfig(({ mode }) => {
plugins: [svelte()], const isTest = mode === 'test';
build: {
outDir: 'dist', return {
emptyOutDir: true, plugins: [svelte()],
}, build: {
server: { outDir: 'dist',
port: 5173, emptyOutDir: true,
strictPort: true, },
}, server: {
port: isTest ? 5174 : 5173,
strictPort: true,
fs: {
allow: [resolve(__dirname, '..', '..')],
},
},
optimizeDeps: {
include: [
'lucide-svelte/icons/briefcase',
'lucide-svelte/icons/chevron-down',
'lucide-svelte/icons/chevron-right',
'lucide-svelte/icons/circle',
'lucide-svelte/icons/flask-conical',
'lucide-svelte/icons/folder',
'lucide-svelte/icons/layout-grid',
'lucide-svelte/icons/panels-top-left',
'lucide-svelte/icons/pencil',
'lucide-svelte/icons/plug',
'lucide-svelte/icons/puzzle',
'lucide-svelte/icons/settings',
'lucide-svelte/icons/shield',
'lucide-svelte/icons/trash-2',
'lucide-svelte/icons/triangle-alert',
],
},
};
}); });
+157 -2
View File
@@ -1,52 +1,207 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT // This file is automatically generated. DO NOT EDIT
import {workspace} from '../models';
import {context} from '../models';
import {workbench} from '../models';
import {capability} from '../models'; import {capability} from '../models';
import {api} from '../models'; import {api} from '../models';
import {permissions} from '../models'; import {permissions} from '../models';
import {plugin} from '../models'; import {plugin} from '../models';
import {files} from '../models';
import {notifications} from '../models';
export function ArchiveWorkspaceNode(arg1:string):Promise<string>;
export function ClearPluginNotifications(arg1:string):Promise<string>;
export function CloseVault():Promise<void>; export function CloseVault():Promise<void>;
export function CreateVault(arg1:string):Promise<void>; export function CreateVault(arg1:string):Promise<void>;
export function CreateVaultFolder(arg1:string,arg2:string):Promise<string>;
export function CreateWorkspace(arg1:string,arg2:string):Promise<workspace.Workspace|string>;
export function CreateWorkspaceNode(arg1:string,arg2:string,arg3:string):Promise<Record<string, any>>;
export function DeleteVaultTrash(arg1:string,arg2:string):Promise<string>;
export function DisablePlugin(arg1:string):Promise<string>; export function DisablePlugin(arg1:string):Promise<string>;
export function DomReady(arg1:context.Context):Promise<void>;
export function EditWorkbenchResource(arg1:string,arg2:Record<string, any>):Promise<workbench.OpenResourceResult|string>;
export function EnablePlugin(arg1:string):Promise<string>; export function EnablePlugin(arg1:string):Promise<string>;
export function ExecutePluginCommand(arg1:string,arg2:string,arg3:Record<string, any>):Promise<Record<string, any>|string>;
export function GetAppSettings():Promise<Record<string, any>>; export function GetAppSettings():Promise<Record<string, any>>;
export function GetCapabilities():Promise<Array<capability.Entry>>; export function GetCapabilities():Promise<Array<capability.Entry>>;
export function GetContributions():Promise<api.ContributionSummary>; export function GetContributions():Promise<api.ContributionSummary>;
export function GetCurrentWorkspace():Promise<Record<string, any>>;
export function GetCurrentWorkspaceNode():Promise<Record<string, any>>;
export function GetPermissions():Promise<Array<permissions.Entry>>; export function GetPermissions():Promise<Array<permissions.Entry>>;
export function GetPluginAssetContent(arg1:string,arg2:string):Promise<string|string>;
export function GetPluginCapability(arg1:string,arg2:string):Promise<Record<string, any>|string>;
export function GetPluginFrontendInfo(arg1:string):Promise<Record<string, any>>;
export function GetPluginLocalization(arg1:string,arg2:string):Promise<Record<string, string>|string>;
export function GetPlugins():Promise<Array<plugin.Plugin>>; export function GetPlugins():Promise<Array<plugin.Plugin>>;
export function GetVaultFileMetadata(arg1:string,arg2:string):Promise<files.FileMetadata|string>;
export function GetVaultPluginState():Promise<Record<string, any>>; export function GetVaultPluginState():Promise<Record<string, any>>;
export function GetVaultStatus():Promise<Record<string, string>>; export function GetVaultStatus():Promise<Record<string, string>>;
export function GetWorkbenchOpenedResources():Promise<Array<workbench.OpenedResource>>;
export function GetWorkbenchPreferences():Promise<workbench.Preferences>;
export function GetWorkspaceMetadata(arg1:string):Promise<workspace.Metadata|string>;
export function GetWorkspaceTree():Promise<Record<string, any>>;
export function ListPluginCapabilities(arg1:string):Promise<Array<capability.Entry>|string>;
export function ListVaultFiles(arg1:string,arg2:string):Promise<Array<files.FileEntry>|string>;
export function ListVaultTrash(arg1:string):Promise<Array<files.TrashEntry>|string>;
export function ListWorkspaceIdentities():Promise<Array<workspace.WorkspaceIdentity>|string>;
export function ListWorkspaceTemplates():Promise<Array<workspace.WorkspaceTemplate>|string>;
export function ListWorkspaces():Promise<Array<workspace.Workspace>|string>;
export function MoveVaultPath(arg1:string,arg2:string,arg3:string,arg4:files.MoveOptions):Promise<string>;
export function MoveWorkspaceNode(arg1:string,arg2:string):Promise<string>;
export function OpenExternalURL(arg1:string,arg2:string):Promise<string>;
export function OpenVault(arg1:string):Promise<void>; export function OpenVault(arg1:string):Promise<void>;
export function OpenVaultPathExternal(arg1:string,arg2:string):Promise<string>;
export function OpenWorkbenchResource(arg1:string,arg2:Record<string, any>):Promise<workbench.OpenResourceResult|string>;
export function PluginBrowserReceiverPairing(arg1:string):Promise<Record<string, string>|string>;
export function PluginRotateBrowserReceiverToken(arg1:string):Promise<Record<string, string>|string>;
export function PluginSecretsCopyLink(arg1:string,arg2:string):Promise<string|string>;
export function PluginSecretsDelete(arg1:string,arg2:string):Promise<string>;
export function PluginSecretsList(arg1:string):Promise<Array<Record<string, any>>|string>;
export function PluginSecretsRead(arg1:string,arg2:string):Promise<Record<string, any>|string>;
export function PluginSecretsStatus(arg1:string):Promise<Record<string, any>|string>;
export function PluginSecretsUnlock(arg1:string,arg2:string):Promise<string>;
export function PluginSecretsWrite(arg1:string,arg2:Record<string, any>):Promise<Record<string, any>|string>;
export function PluginSyncConfigure(arg1:string,arg2:string,arg3:string,arg4:string):Promise<string>;
export function PluginSyncDisconnect(arg1:string):Promise<string>;
export function PluginSyncNow(arg1:string):Promise<Record<string, any>|string>;
export function PluginSyncResetKey(arg1:string):Promise<string>;
export function PluginSyncSetInterval(arg1:string,arg2:number):Promise<string>;
export function PluginSyncStatus(arg1:string):Promise<api.SyncStatusDTO|string>;
export function PluginSyncTestConnection(arg1:string,arg2:string,arg3:string,arg4:string):Promise<string>;
export function PublishPluginEvent(arg1:string,arg2:string,arg3:Record<string, any>):Promise<string>;
export function PurgeWorkspaceTrash(arg1:string):Promise<string>;
export function Quit():Promise<void>;
export function ReadPluginDataJSON(arg1:string,arg2:string):Promise<Record<string, any>>; export function ReadPluginDataJSON(arg1:string,arg2:string):Promise<Record<string, any>>;
export function ReadPluginDataNDJSON(arg1:string,arg2:string):Promise<Array<Record<string, any>>>;
export function ReadPluginSetting(arg1:string,arg2:string):Promise<any>; export function ReadPluginSetting(arg1:string,arg2:string):Promise<any>;
export function ReadPluginSettings(arg1:string):Promise<Record<string, any>>; export function ReadPluginSettings(arg1:string):Promise<Record<string, any>|string>;
export function ReadVaultFileBytes(arg1:string,arg2:string):Promise<files.FileBytes|string>;
export function ReadVaultTextFile(arg1:string,arg2:string):Promise<string|string>;
export function RecordDesiredPlugin(arg1:string,arg2:string,arg3:string):Promise<string>; export function RecordDesiredPlugin(arg1:string,arg2:string,arg3:string):Promise<string>;
export function ReloadPlugins():Promise<number|string>; export function ReloadPlugins():Promise<number|string>;
export function RenameWorkspace(arg1:string,arg2:string):Promise<string>;
export function RenameWorkspaceNode(arg1:string,arg2:string):Promise<string>;
export function RepairWorkspaceIdentity(arg1:string,arg2:string):Promise<string>;
export function ReplacePluginNotifications(arg1:string,arg2:Array<notifications.Request>):Promise<string>;
export function RestoreVaultTrash(arg1:string,arg2:string,arg3:files.RestoreOptions):Promise<string|string>;
export function RestoreWorkspaceTrash(arg1:string,arg2:string):Promise<workspace.Workspace|string>;
export function SelectDirectory():Promise<string>;
export function SelectVaultForOpen():Promise<string>;
export function SetCurrentVault(arg1:string):Promise<string>; export function SetCurrentVault(arg1:string):Promise<string>;
export function Startup():Promise<void>; export function SetCurrentWorkspace(arg1:string):Promise<string>;
export function SetCurrentWorkspaceNode(arg1:string):Promise<string>;
export function SetNotificationService(arg1:api.notificationService):Promise<void>;
export function ShowVaultPathInFolder(arg1:string,arg2:string):Promise<string>;
export function ShowWindow():Promise<void>;
export function Shutdown(arg1:context.Context):Promise<void>;
export function SubscribePluginEvent(arg1:string,arg2:string):Promise<string>;
export function TrashVaultPath(arg1:string,arg2:string):Promise<files.TrashResult|string>;
export function TrashWorkspace(arg1:string):Promise<workspace.TrashResult|string>;
export function UpdateAppSettings(arg1:Record<string, any>):Promise<string>; export function UpdateAppSettings(arg1:Record<string, any>):Promise<string>;
export function UpdateWorkbenchPreferences(arg1:workbench.Preferences):Promise<string>;
export function UpdateWorkspaceMetadata(arg1:string,arg2:workspace.MetadataPatch):Promise<workspace.Metadata|string>;
export function WriteFrontendLog(arg1:string,arg2:string):Promise<void>;
export function WritePluginDataJSON(arg1:string,arg2:string,arg3:Record<string, any>):Promise<string>; export function WritePluginDataJSON(arg1:string,arg2:string,arg3:Record<string, any>):Promise<string>;
export function WritePluginDataNDJSON(arg1:string,arg2:string,arg3:Array<Record<string, any>>):Promise<string>;
export function WritePluginSetting(arg1:string,arg2:string,arg3:any):Promise<string>; export function WritePluginSetting(arg1:string,arg2:string,arg3:any):Promise<string>;
export function WritePluginSettings(arg1:string,arg2:Record<string, any>):Promise<string>; export function WritePluginSettings(arg1:string,arg2:Record<string, any>):Promise<string>;
export function WriteVaultFileBytes(arg1:string,arg2:string,arg3:string,arg4:files.WriteOptions):Promise<string>;
export function WriteVaultTextFile(arg1:string,arg2:string,arg3:string,arg4:files.WriteOptions):Promise<string>;

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