Implement milestone 6b workbench routing skeleton

This commit is contained in:
2026-06-19 07:51:57 +08:00
parent a100f5a441
commit 6ed6df311a
53 changed files with 7592 additions and 335 deletions
+167 -4
View File
@@ -3,7 +3,11 @@
import Sidebar from './lib/shell/Sidebar.svelte';
import ViewContainer from './lib/shell/ViewContainer.svelte';
import VaultSelection from './lib/shell/VaultSelection.svelte';
import WorkbenchHost from './lib/shell/WorkbenchHost.svelte';
import * as App from '../wailsjs/go/api/App';
import { debug } from './lib/log/debug.js';
import { onMount } from 'svelte';
import { tick } from 'svelte';
let currentView = 'plugin-manager';
let vaultStatus = { status: 'unknown', path: '', vaultId: '' };
@@ -14,47 +18,81 @@
let activeViewPluginId = '';
let activeSettingsPluginId = '';
let activeSettingsPanelId = '';
let openedResource = null;
function flog(msg) {
App.WriteFrontendLog('App', msg);
}
async function checkVault() {
debug.log('[App] checkVault: START');
flog('checkVault: START');
loading = true;
try {
debug.log('[App] checkVault: calling GetAppSettings...');
const settings = await App.GetAppSettings();
debug.log('[App] checkVault: GetAppSettings returned', settings);
flog('checkVault: GetAppSettings returned');
debug.log('[App] checkVault: calling GetVaultStatus...');
vaultStatus = await App.GetVaultStatus() || { status: 'unknown', path: '', vaultId: '' };
debug.log('[App] checkVault: GetVaultStatus returned', vaultStatus);
flog('checkVault: vaultStatus=' + vaultStatus.status);
if (!settings.currentVaultPath || vaultStatus.status !== 'open') {
debug.log('[App] checkVault: vault not open, needsVaultSelection=true');
flog('checkVault: needsVaultSelection=true');
needsVaultSelection = true;
} else {
debug.log('[App] checkVault: vault open, needsVaultSelection=false');
flog('checkVault: needsVaultSelection=false');
needsVaultSelection = false;
}
} catch (e) {
debug.log('[App] checkVault: ERROR', String(e));
flog('checkVault: ERROR: ' + String(e));
console.error('[App] startup check failed:', e);
needsVaultSelection = true;
}
loading = false;
await tick();
debug.log('[App] checkVault: END, loading=false');
flog('checkVault: END, loading=false');
}
function onVaultOpened() {
debug.log('[App] onVaultOpened');
needsVaultSelection = false;
vaultStatus = { status: 'open', path: '', vaultId: '' };
}
function onNav(e) {
debug.log('[App] onNav:', e.detail.viewId);
currentView = e.detail.viewId;
}
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';
}
function onOpenSettings(e) {
debug.log('[App] onOpenSettings:', e.detail.pluginId, e.detail.panelId);
activeSettingsPluginId = e.detail.pluginId;
activeSettingsPanelId = e.detail.panelId || '';
currentView = 'plugin-manager';
}
function onWorkbenchOpened(e) {
debug.log('[App] onWorkbenchOpened:', e.detail?.request?.path, e.detail?.providerId);
openedResource = e.detail;
currentView = 'workbench';
}
function onCloseSettings() {
debug.log('[App] onCloseSettings');
activeSettingsPluginId = '';
activeSettingsPanelId = '';
}
@@ -66,9 +104,10 @@
window.addEventListener('verstak:open-view', onOpenView);
window.addEventListener('verstak:open-settings', onOpenSettings);
window.addEventListener('verstak:close-settings', onCloseSettings);
window.addEventListener('verstak:workbench-opened', onWorkbenchOpened);
}
checkVault();
onMount(() => { checkVault(); });
</script>
{#if loading}
@@ -81,9 +120,11 @@
<main>
<Sidebar />
<section class="content">
<section class="content scroll-surface">
{#if currentView === 'plugin-manager'}
<PluginManager {activeSettingsPluginId} {activeSettingsPanelId} />
{:else if currentView === 'workbench'}
<WorkbenchHost {openedResource} />
{:else}
<ViewContainer {activeView} {activeViewPluginId} />
{/if}
@@ -98,6 +139,13 @@
box-sizing: border-box;
}
:global(html),
:global(body),
:global(#app) {
width: 100%;
height: 100%;
}
:global(body) {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #1a1a2e;
@@ -105,6 +153,118 @@
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 #1a3a5c;
border-radius: 6px;
background: #0f3460;
color: #e0e0f0;
font: inherit;
font-size: 0.85rem;
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: #1a3a5c;
border-color: #4ecca3;
color: #ffffff;
}
:global(button:focus-visible) {
outline: 2px solid #4ecca3;
outline-offset: 2px;
}
:global(button:disabled) {
opacity: 0.55;
cursor: not-allowed;
}
:global(.btn-primary) {
background: #4ecca3;
border-color: #4ecca3;
color: #101827;
}
:global(.btn-primary:hover:not(:disabled)) {
background: #63d9b3;
border-color: #63d9b3;
color: #101827;
}
:global(.btn-secondary) {
background: #0f3460;
border-color: #533483;
color: #e0e0f0;
}
:global(.btn-danger) {
background: #e94560;
border-color: #e94560;
color: #ffffff;
}
:global(.btn-danger:hover:not(:disabled)) {
background: #ff5b73;
border-color: #ff5b73;
}
:global(.btn-ghost) {
background: transparent;
border-color: transparent;
color: #a0a0b8;
}
:global(.btn-ghost:hover:not(:disabled)) {
background: rgba(15, 52, 96, 0.55);
border-color: #0f3460;
color: #e0e0f0;
}
:global(.btn-icon) {
width: 2rem;
min-width: 2rem;
padding: 0;
}
:global(.scroll-surface) {
min-width: 0;
min-height: 0;
overflow: auto;
scrollbar-gutter: stable;
}
:global(*) {
scrollbar-width: thin;
scrollbar-color: #0f3460 #1a1a2e;
}
:global(*::-webkit-scrollbar) {
width: 8px;
height: 8px;
}
:global(*::-webkit-scrollbar-track) {
background: #1a1a2e;
}
:global(*::-webkit-scrollbar-thumb) {
background: #0f3460;
border-radius: 4px;
}
:global(*::-webkit-scrollbar-thumb:hover) {
background: #1a4a7a;
}
.app-loading {
display: flex;
align-items: center;
@@ -118,14 +278,17 @@
main {
display: flex;
height: 100vh;
width: 100%;
background: #1a1a2e;
overflow: hidden;
}
.content {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 1.5rem;
padding: clamp(1rem, 2vw, 1.5rem);
}
</style>
+150
View File
@@ -0,0 +1,150 @@
/**
* 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 () {
ENABLED = true;
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');
}
@@ -3,12 +3,12 @@
import * as App from '../../../wailsjs/go/api/App';
import Icon from '../ui/Icon.svelte';
// Import the VerstakPluginAPI contract
import './VerstakPluginAPI.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;
@@ -16,6 +16,7 @@
let mountContainer = null;
let currentPluginId = null;
let currentComponent = null;
let currentAPI = null;
$: activePluginId = pluginId || viewPluginId;
$: activeComponent = componentId;
@@ -33,6 +34,13 @@
});
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];
@@ -49,6 +57,14 @@
}
currentPluginId = null;
currentComponent = null;
currentAPI = null;
}
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: '' };
}
async function loadAndMount(pId, compId) {
@@ -82,10 +98,11 @@
const reg = window.__VERSTAK_PLUGIN_REGISTRY__ || {};
if (!reg[pId]) {
// Load the bundle JS content via backend API
const [content, err] = await App.GetPluginAssetContent(pId, info.entry);
if (err || !content) {
const assetResult = unpackBackendResult(await App.GetPluginAssetContent(pId, info.entry));
const content = assetResult.value;
if (assetResult.error || !content) {
loadState = 'error';
errorText = 'Failed to load bundle: ' + (err || 'empty content');
errorText = 'Failed to load bundle: ' + (assetResult.error || 'empty content');
return;
}
@@ -120,7 +137,8 @@
}
// Create API
const api = window.VerstakPluginAPI(pId);
const api = createPluginAPI(pId);
currentAPI = api;
// Mount component
if (!mountContainer) {
@@ -129,7 +147,7 @@
}
if (mountContainer) {
try {
comp.mount(mountContainer, { componentId: compId }, api);
comp.mount(mountContainer, Object.assign({ componentId: compId }, componentProps || {}), api);
loadState = 'loaded';
errorText = '';
} catch (e) {
@@ -161,12 +179,6 @@
<p>Select a plugin view from the sidebar</p>
</div>
{:else if loadState === 'loading'}
<div class="host-state loading">
<div class="spinner"></div>
<p>Loading plugin bundle...</p>
</div>
{:else if loadState === 'error'}
<div class="host-state error">
<Icon name="warning" size={24} className="error-icon" />
@@ -184,9 +196,16 @@
</div>
</div>
{:else if loadState === 'loaded'}
{:else}
{#if loadState === 'loading'}
<div class="host-state loading">
<div class="spinner"></div>
<p>Loading plugin bundle...</p>
</div>
{/if}
<div
class="plugin-mount-container"
class:mount-hidden={loadState !== 'loaded'}
bind:this={mountContainer}
data-plugin-id={currentPluginId}
data-component={currentComponent}
@@ -197,10 +216,10 @@
<style>
.plugin-bundle-host {
width: 100%;
height: 100%;
min-height: 200px;
display: flex;
flex-direction: column;
min-width: 0;
}
.host-state {
@@ -286,8 +305,14 @@
}
.plugin-mount-container {
flex: 1;
overflow: auto;
min-width: 0;
position: relative;
}
.plugin-mount-container.mount-hidden {
height: 0;
min-height: 0;
overflow: hidden;
visibility: hidden;
}
</style>
+283 -48
View File
@@ -1,18 +1,10 @@
// VerstakPluginAPI is the restricted API passed to plugin frontend bundles.
// Plugins do NOT get direct access to Wails bridge — only what's exposed here.
// All methods are stubs or limited implementations.
import * as App from '../../../wailsjs/go/api/App';
(function() {
// Store registered components per plugin
window.__VERSTAK_PLUGIN_REGISTRY__ = window.__VERSTAK_PLUGIN_REGISTRY__ || {};
// Original register function
const origRegister = window.VerstakPluginRegister;
if (origRegister) {
// Already defined — don't override
return;
}
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);
@@ -21,48 +13,291 @@
console.log('[VerstakPluginRegister] registered:', pluginId, Object.keys(bundle.components));
window.__VERSTAK_PLUGIN_REGISTRY__[pluginId] = bundle.components;
};
}
// Create the restricted API object for a plugin host context
window.VerstakPluginAPI = function(pluginId) {
return {
pluginId: pluginId,
function unpack(result) {
if (Array.isArray(result) && result.length === 2 && (typeof result[1] === 'string' || result[1] == null)) {
return [result[0], result[1] || ''];
}
return [result, ''];
}
capabilities: {
has: function(capId) {
// planned: query backend cap registry
console.log('[plugin:' + pluginId + '] capabilities.has(' + capId + ') — stub');
return false;
}
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 commandKey(pluginId, commandId) {
return pluginId + ':' + commandId;
}
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,
capabilities: {
has: async function(capId) {
const info = await callBackend(pluginId, 'capabilities.has(' + capId + ')', function() {
return App.GetPluginCapability(pluginId, capId);
});
return !!(info && info.available);
},
events: {
publish: function(type, payload) {
console.log('[plugin:' + pluginId + '] event publish:', type, payload);
// planned: actual event bus bridge
},
subscribe: function(type, handler) {
console.log('[plugin:' + pluginId + '] event subscribe:', type, '(stub)');
// planned: actual event bus bridge
}
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);
});
}
},
settings: {
read: function(key) {
console.log('[plugin:' + pluginId + '] settings.read(' + key + ') — stub');
return null;
},
write: function(key, value) {
console.log('[plugin:' + pluginId + '] settings.write(' + key + ',', value, ') — stub');
// planned: backend storage namespace
}
events: {
publish: async function(type, payload) {
await callBackendErrorString(pluginId, 'events.publish(' + type + ')', function() {
return App.PublishPluginEvent(pluginId, type, payload || {});
});
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;
});
});
});
}
},
commands: {
execute: function(cmdId, args) {
console.log('[plugin:' + pluginId + '] commands.execute(' + cmdId + ') — stub');
// planned: command execution
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');
}
const settings = await this.read();
settings[key] = value;
await callBackendErrorString(pluginId, 'settings.write(' + key + ')', function() {
return App.WritePluginSettings(pluginId, settings);
});
return settings;
},
writeAll: function(settings) {
assertActive('settings.writeAll');
return callBackendErrorString(pluginId, 'settings.writeAll', function() {
return App.WritePluginSettings(pluginId, settings || {});
});
}
},
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);
});
},
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 || {});
});
},
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);
});
}
},
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;
}
},
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 + ')');
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
};
}
},
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;
@@ -211,6 +211,7 @@
border: 1px solid #0f3460;
border-radius: 8px;
padding: 1rem;
min-width: 0;
}
.plugin-card.disabled {
@@ -225,6 +226,8 @@
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
margin-bottom: 0.5rem;
}
@@ -232,6 +235,12 @@
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
flex-wrap: wrap;
}
.plugin-id strong {
overflow-wrap: anywhere;
}
.status-dot {
@@ -268,7 +277,7 @@
.card-meta {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.3rem;
margin-bottom: 0.75rem;
font-size: 0.8rem;
@@ -277,6 +286,7 @@
.meta-row {
display: flex;
gap: 0.5rem;
min-width: 0;
}
.label {
@@ -288,6 +298,8 @@
font-family: monospace;
font-size: 0.75rem;
color: #a0a0b8;
min-width: 0;
overflow-wrap: anywhere;
}
.section {
@@ -316,6 +328,8 @@
font-size: 0.75rem;
font-family: monospace;
color: #e0e0e0;
max-width: 100%;
overflow-wrap: anywhere;
}
.tag.provides {
@@ -375,6 +389,7 @@
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 0.75rem;
padding-top: 0.5rem;
border-top: 1px solid #0f3460;
@@ -432,4 +447,19 @@
font-size: 0.75rem;
font-style: italic;
}
@media (max-width: 760px) {
.card-meta {
grid-template-columns: 1fr;
}
.meta-row {
flex-direction: column;
gap: 0.15rem;
}
.label {
min-width: 0;
}
}
</style>
@@ -2,8 +2,9 @@
import Icon from '../ui/Icon.svelte';
import PluginCard from './PluginCard.svelte';
import PluginBundleHost from '../plugin-host/PluginBundleHost.svelte';
import { onMount } from 'svelte';
import { GetPlugins, GetCapabilities, GetPermissions, GetContributions, ReloadPlugins, GetVaultStatus, GetVaultPluginState, EnablePlugin, DisablePlugin, ReadPluginSettings, WritePluginSettings, GetPluginFrontendInfo } from '../../../wailsjs/go/api/App';
import { onMount, tick } from 'svelte';
import { GetPlugins, GetCapabilities, GetPermissions, GetContributions, ReloadPlugins, GetVaultStatus, GetVaultPluginState, EnablePlugin, DisablePlugin, ReadPluginSettings, WritePluginSettings, GetPluginFrontendInfo, WriteFrontendLog } from '../../../wailsjs/go/api/App';
import { debug } from '../log/debug.js';
let plugins = [];
let capabilities = [];
@@ -45,6 +46,28 @@
}, 4000);
}
function notifyPluginsChanged() {
window.dispatchEvent(new CustomEvent('verstak:plugins-changed'));
}
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 unpackReloadResult(result) {
if (Array.isArray(result)) {
return {
count: Number(result[0] || 0),
summary: result[1] || `Reloaded ${Number(result[0] || 0)} plugin(s).`,
};
}
const count = Number(result || 0);
return { count, summary: `Reloaded ${count} plugin(s).` };
}
async function openSettingsFromProps(pluginId, panelId) {
const panel = (contributions.settingsPanels || []).find(sp => sp.pluginId === pluginId && (!panelId || sp.id === panelId));
if (panel) {
@@ -55,8 +78,14 @@
const info = await GetPluginFrontendInfo(pluginId);
settingsPluginInfo = info;
} catch { settingsPluginInfo = null; }
ReadPluginSettings(pluginId).then(data => {
settingsData = data || {};
ReadPluginSettings(pluginId).then(result => {
const unpacked = unpackBackendResult(result);
if (unpacked.error) {
settingsError = unpacked.error;
settingsData = {};
return;
}
settingsData = unpacked.value || {};
}).catch(() => { settingsData = {}; });
} else {
settingsError = `Settings panel not found for plugin "${pluginId}". Check that the plugin is enabled and has settingsPanels in its manifest.`;
@@ -73,18 +102,27 @@
}
async function loadAll() {
debug.log('[PluginManager] loadAll: START');
error = '';
loading = true;
try {
debug.log('[PluginManager] loadAll: calling GetPlugins...');
const p = await GetPlugins();
plugins = p || [];
debug.log('[PluginManager] loadAll: GetPlugins returned', plugins.length, 'plugins');
for (var i = 0; i < plugins.length; i++) {
debug.log('[PluginManager] loadAll: plugin[' + i + ']:', plugins[i].manifest?.id, 'status:', plugins[i].status, 'enabled:', plugins[i].enabled);
}
} catch (e) {
debug.log('[PluginManager] loadAll: GetPlugins ERROR:', String(e));
WriteFrontendLog('PluginManager', 'loadAll: GetPlugins ERROR: ' + String(e));
error = 'GetPlugins: ' + String(e);
loading = false;
return;
}
// Collect all async loads but await them so loading stays true until all are done
try {
debug.log('[PluginManager] loadAll: loading vault/capabilities/permissions/contributions...');
const [v, caps, perms, contribs] = await Promise.all([
GetVaultStatus().catch(() => ({ status: 'unknown', path: '', vaultId: '' })),
GetCapabilities().catch(() => []),
@@ -95,66 +133,93 @@
capabilities = caps || [];
permissions = perms || [];
contributions = contribs || {};
debug.log('[PluginManager] loadAll: vault=' + vaultStatus.status + ' caps=' + capabilities.length + ' perms=' + permissions.length);
WriteFrontendLog('PluginManager', 'loadAll: vault=' + vaultStatus.status + ' caps=' + capabilities.length + ' perms=' + permissions.length);
} catch (e) {
// Non-critical — log but don't fail
debug.log('[PluginManager] loadAll: non-critical load ERROR:', String(e));
WriteFrontendLog('PluginManager', 'loadAll: non-critical ERROR: ' + String(e));
console.error('[PluginManager] non-critical load error:', e);
}
if (vaultStatus.status === 'open') {
try {
debug.log('[PluginManager] loadAll: calling GetVaultPluginState...');
vaultPluginState = await GetVaultPluginState() || { enabledPlugins: [], disabledPlugins: [], desiredPlugins: [] };
} catch { /* non-critical */ }
WriteFrontendLog('PluginManager', 'loadAll: GetVaultPluginState returned');
} catch (e) {
WriteFrontendLog('PluginManager', 'loadAll: GetVaultPluginState ERROR: ' + String(e));
}
}
loading = false;
await tick();
debug.log('[PluginManager] loadAll: END, loading=false');
WriteFrontendLog('PluginManager', 'loadAll: END, loading=false');
}
onMount(() => { loadAll(); });
async function reload() {
debug.log('[PluginManager] reload: START');
reloading = true;
error = '';
let resultMsg = '';
try {
const [count, summary] = await ReloadPlugins();
debug.log('[PluginManager] reload: calling ReloadPlugins...');
const { count, summary } = unpackReloadResult(await ReloadPlugins());
debug.log('[PluginManager] reload: ReloadPlugins returned count=' + count + ' summary=' + summary);
resultMsg = `Reloaded ${count} plugin(s). ${summary}`;
} catch (e) {
debug.log('[PluginManager] reload: ReloadPlugins ERROR:', String(e));
error = 'Reload: ' + String(e);
reloading = false;
return;
}
debug.log('[PluginManager] reload: calling loadAll after reload...');
await loadAll();
notifyPluginsChanged();
reloading = false;
debug.log('[PluginManager] reload: END');
showToast(resultMsg, 'success');
}
async function enablePlugin(pluginId) {
debug.log('[PluginManager] enablePlugin:', pluginId);
actionFeedback = { ...actionFeedback, [pluginId]: 'enabling' };
error = '';
const err = await EnablePlugin(pluginId);
if (err) {
debug.log('[PluginManager] enablePlugin: ERROR:', err);
actionFeedback = { ...actionFeedback, [pluginId]: null };
error = 'Enable: ' + err;
return;
}
debug.log('[PluginManager] enablePlugin: success, reloading...');
// Reload to get updated state
try { await ReloadPlugins(); } catch (e) { /* ignore */ }
await loadAll();
notifyPluginsChanged();
actionFeedback = { ...actionFeedback, [pluginId]: null };
debug.log('[PluginManager] enablePlugin: done');
showToast(`Plugin "${pluginId}" enabled`, 'success');
}
async function disablePlugin(pluginId) {
debug.log('[PluginManager] disablePlugin:', pluginId);
actionFeedback = { ...actionFeedback, [pluginId]: 'disabling' };
error = '';
const err = await DisablePlugin(pluginId);
if (err) {
debug.log('[PluginManager] disablePlugin: ERROR:', err);
actionFeedback = { ...actionFeedback, [pluginId]: null };
error = 'Disable: ' + err;
return;
}
debug.log('[PluginManager] disablePlugin: success, reloading...');
// Reload to get updated state
try { await ReloadPlugins(); } catch (e) { /* ignore */ }
await loadAll();
notifyPluginsChanged();
actionFeedback = { ...actionFeedback, [pluginId]: null };
debug.log('[PluginManager] disablePlugin: done');
showToast(`Plugin "${pluginId}" disabled`, 'info');
}
@@ -327,14 +392,18 @@
<style>
.plugin-manager {
max-width: 900px;
padding-top: 0.5rem;
flex: 1;
width: min(100%, 1100px);
min-height: 0;
padding: 0.5rem 0.5rem 1.5rem 0;
position: relative;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
margin-bottom: 1.25rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid #0f3460;
@@ -343,14 +412,20 @@
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 0;
flex-wrap: wrap;
}
h2 { color: #e0e0e0; font-size: 1.3rem; margin: 0; }
.vault-badge {
max-width: 100%;
font-size: 0.75rem;
padding: 0.2rem 0.6rem;
border-radius: 12px;
font-weight: 600;
border: 1px solid;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vault-open { background: rgba(78, 204, 163, 0.15); color: #4ecca3; border-color: #4ecca3; }
.vault-not-created { background: rgba(255, 200, 87, 0.15); color: #ffc857; border-color: #ffc857; }
@@ -404,7 +479,7 @@
.hint-list { list-style: none; padding: 0; margin: 0.5rem 0; font-size: 0.8rem; opacity: 0.7; }
.hint-list li { margin: 0.25rem 0; }
.hint code { background: #0f3460; padding: 0.1rem 0.3rem; border-radius: 3px; }
.plugin-list { display: flex; flex-direction: column; gap: 0.75rem; margin-bottom: 1.5rem; }
.plugin-list { display: flex; flex-direction: column; gap: 0.75rem; margin-bottom: 1.5rem; min-width: 0; }
.missing-section { margin-bottom: 1.5rem; }
.missing-section h3 { color: #e94560; font-size: 1rem; margin: 0 0 0.25rem; }
@@ -450,4 +525,24 @@
.modal-body { padding: 1rem; overflow-y: auto; }
.settings-hint { color: #666; font-size: 0.8rem; margin: 0.25rem 0; }
.settings-hint code { color: #4ecca3; }
@media (max-width: 760px) {
.plugin-manager {
width: 100%;
padding-right: 0;
}
header {
align-items: flex-start;
}
.reload-btn {
width: 100%;
}
.modal {
width: min(480px, calc(100vw - 2rem));
max-height: calc(100vh - 2rem);
}
}
</style>
+31 -2
View File
@@ -1,8 +1,13 @@
<script>
import { onMount } from 'svelte';
import { onDestroy, onMount } from 'svelte';
import * as App from '../../../wailsjs/go/api/App';
import WorkspaceTree from './WorkspaceTree.svelte';
import Icon from '../ui/Icon.svelte';
import { debug } from '../log/debug.js';
function flog(msg) {
App.WriteFrontendLog('Sidebar', msg);
}
let plugins = [];
let vaultStatus = { status: 'unknown', path: '', vaultId: '' };
@@ -15,9 +20,13 @@
$: vaultOpen = vaultStatus.status === 'open';
onMount(async () => {
async function loadSidebar() {
debug.log('[Sidebar] onMount: START');
flog('onMount: START');
let contribErr = false;
try {
debug.log('[Sidebar] onMount: loading plugins/vault/contributions...');
flog('onMount: loading plugins/vault/contributions...');
const [p, v, contribs] = await Promise.all([
App.GetPlugins().catch(() => []),
App.GetVaultStatus().catch(() => ({ status: 'unknown', path: '', vaultId: '' })),
@@ -25,6 +34,8 @@
]);
plugins = p || [];
vaultStatus = v;
debug.log('[Sidebar] onMount: plugins=' + plugins.length + ' vault=' + vaultStatus.status);
flog('onMount: plugins=' + plugins.length + ' vault=' + vaultStatus.status);
if (contribErr) {
errorMessage = 'Failed to load plugin contributions';
}
@@ -34,17 +45,34 @@
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) {
debug.log('[Sidebar] onMount: ERROR:', String(e));
flog('onMount: ERROR: ' + String(e));
console.error('[Sidebar] load error:', e);
errorMessage = 'Failed to load sidebar';
}
debug.log('[Sidebar] onMount: END');
flog('onMount: END');
}
onMount(() => {
loadSidebar();
window.addEventListener('verstak:plugins-changed', loadSidebar);
});
onDestroy(() => {
window.removeEventListener('verstak:plugins-changed', loadSidebar);
});
function handleNav(id) {
debug.log('[Sidebar] handleNav:', id);
window.dispatchEvent(new CustomEvent('verstak:nav', { detail: { viewId: id } }));
}
function handleSidebarItem(item) {
debug.log('[Sidebar] handleSidebarItem:', item.id, '-> view:', item.view);
// Use item.view (the view contribution ID) if available, fall back to item.id
const viewId = item.view || item.id;
window.dispatchEvent(new CustomEvent('verstak:open-view', { detail: { viewId, pluginId: item.pluginId } }));
@@ -171,6 +199,7 @@
.nav-item {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0.6rem;
padding: 0.45rem 0.75rem;
background: none;
+3 -2
View File
@@ -101,13 +101,14 @@
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
background: #1a1a2e;
}
.view {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
padding: 1.5rem;
}
.view.degraded {
@@ -155,7 +156,7 @@
}
.view-content {
flex: 1;
overflow: auto;
min-width: 0;
}
.placeholder {
color: #666;
+114
View File
@@ -0,0 +1,114 @@
<script>
import PluginBundleHost from '../plugin-host/PluginBundleHost.svelte';
export let openedResource = null;
$: 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');
$: componentProps = openedResource || {};
$: mountKey = [
providerPluginId,
providerComponent,
resourcePath,
requestMode,
requestContext,
].join(':');
</script>
<div class="workbench-host">
{#if openedResource?.status === 'no-provider'}
<div class="workbench-header">
<span class="workbench-title">{resourcePath}</span>
<span class="workbench-provider">no-provider</span>
</div>
<div class="workbench-empty no-provider" data-workbench-status="no-provider">
<p>No viewer/editor available</p>
<p class="workbench-meta">{requestMode} · {requestContext}</p>
</div>
{:else if openedResource}
<div class="workbench-header">
<span class="workbench-title">{resourcePath}</span>
<span class="workbench-provider">{providerId}</span>
</div>
<div class="workbench-content">
{#key mountKey}
<PluginBundleHost
pluginId={providerPluginId}
componentId={providerComponent}
{componentProps}
/>
{/key}
</div>
{:else}
<div class="workbench-empty">
<p>No resource opened</p>
</div>
{/if}
</div>
<style>
.workbench-host {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
background: #1a1a2e;
}
.workbench-header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid #16213e;
flex-shrink: 0;
}
.workbench-title {
color: #e0e0f0;
font-size: 0.95rem;
font-weight: 600;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.workbench-provider {
color: #4ecca3;
font-size: 0.75rem;
margin-left: auto;
}
.workbench-content {
min-width: 0;
min-height: 0;
flex: 1;
padding: 1rem;
}
.workbench-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: #666;
}
.workbench-empty.no-provider {
flex-direction: column;
gap: 0.35rem;
}
.workbench-meta {
margin: 0;
color: #8b8ba8;
font-size: 0.8rem;
}
</style>
+14 -5
View File
@@ -1,8 +1,15 @@
<script context="module">
import { writable } from 'svelte/store';
const activeWorkspaceNodeId = writable('');
</script>
<script>
import { onMount } from 'svelte';
import * as App from '../../../wailsjs/go/api/App';
export let nodes = [];
export let node = null;
export let currentNodeId = '';
export let expandedNodes = {};
export let depth = 0;
@@ -32,6 +39,7 @@
} else {
nodes = result.nodes || [];
currentNodeId = result.currentNodeId || '';
activeWorkspaceNodeId.set(currentNodeId);
const root = nodes.find(n => !n.parentId);
if (root) expandedNodes[root.id] = true;
}
@@ -69,6 +77,7 @@
const err = await App.SetCurrentWorkspaceNode(id);
if (err) { localError = err; return; }
currentNodeId = id;
activeWorkspaceNodeId.set(id);
}
function openCreate(parentId, type) {
@@ -128,7 +137,7 @@
{/if}
</div>
{:else}
<div class="wt-node" class:selected={node.id === currentNodeId} class:archived={node.status === 'archived'} class:sleeping={node.status === 'sleeping'}>
<div class="wt-node" class:selected={node.id === $activeWorkspaceNodeId} class:archived={node.status === 'archived'} class:sleeping={node.status === 'sleeping'}>
<div class="wt-row" style="padding-left: {depth * 1.0 + 0.4}rem;">
{#if hasKids(node.id)}
<button class="wt-expand" on:click={() => toggle(node.id)} type="button">{expandedNodes[node.id] ? '\u25BE' : '\u25B8'}</button>
@@ -153,7 +162,7 @@
.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.4rem 0.6rem; border-bottom: 1px solid #0f3460; flex-shrink: 0; }
.wt-title { color: #a0a0b8; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600; }
.wt-btn { background: none; border: none; color: #666; cursor: pointer; font-size: 0.85rem; padding: 0.1rem 0.3rem; border-radius: 3px; }
.wt-btn { min-height: 0; background: none; border: none; color: #666; cursor: pointer; font-size: 0.85rem; padding: 0.1rem 0.3rem; border-radius: 3px; }
.wt-btn:hover { color: #4ecca3; background: rgba(78,204,163,0.1); }
.wt-btn-small { font-size: 0.7rem; opacity: 0; }
.wt-row:hover .wt-btn-small { opacity: 1; }
@@ -162,12 +171,12 @@
.wt-node { }
.wt-row { display: flex; align-items: center; gap: 0.2rem; padding: 0.15rem 0; }
.wt-row:hover { background: rgba(15,52,96,0.4); }
.wt-row.selected { background: rgba(78,204,163,0.1); }
.wt-expand { width: 1rem; height: 1rem; display: flex; align-items: center; justify-content: center; font-size: 0.65rem; color: #666; background: none; border: none; cursor: pointer; padding: 0; flex-shrink: 0; }
.wt-node.selected > .wt-row { background: rgba(78,204,163,0.1); }
.wt-expand { width: 1rem; height: 1rem; min-height: 0; display: flex; align-items: center; justify-content: center; font-size: 0.65rem; color: #666; background: none; border: none; cursor: pointer; padding: 0; flex-shrink: 0; }
.wt-expand:hover { color: #e0e0f0; }
.wt-expand-spacer { width: 1rem; flex-shrink: 0; }
.wt-icon { font-size: 0.8rem; flex-shrink: 0; }
.wt-label { flex: 1; background: none; border: none; color: #e0e0f0; font-size: 0.78rem; text-align: left; cursor: pointer; padding: 0.1rem 0.2rem; border-radius: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wt-label { flex: 1; min-height: 0; justify-content: flex-start; background: none; border: none; color: #e0e0f0; font-size: 0.78rem; text-align: left; cursor: pointer; padding: 0.1rem 0.2rem; border-radius: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wt-label:hover { color: #4ecca3; }
.wt-node.archived .wt-label { text-decoration: line-through; opacity: 0.5; }
.wt-node.sleeping .wt-label { opacity: 0.6; }
+756
View File
@@ -0,0 +1,756 @@
/**
* Wails Mock Bridge эмулирует window['go']['api']['App'] для тестового окружения.
*
* Каждый метод возвращает Promise с данными, совместимыми с Wails-контрактом.
* Состояние мутабельно тесты могут менять его между сценариями.
*/
(function () {
if (window.__wailsMockReady) return;
// ── Mutable state ──────────────────────────────────────────────────
var pluginStates = {
'verstak.platform-test': {
status: 'loaded',
enabled: true,
manifest: {
schemaVersion: 1,
id: 'verstak.platform-test',
name: 'Platform Test',
version: '0.1.0',
apiVersion: '0.1.0',
description: 'Runtime test plugin for verifying the Verstak platform.',
source: 'official',
icon: '🧪',
provides: ['verstak/platform-test/v1', 'verstak/diagnostics/v1'],
requires: ['verstak/core/plugin-manager/v1', 'verstak/core/capability-registry/v1'],
optionalRequires: ['verstak/core/vault/v1', 'verstak/core/sync/v1', 'verstak/core/files/v1', 'verstak/core/workbench/v1'],
permissions: ['vault.read', 'events.publish', 'events.subscribe', 'ui.register', 'commands.register', 'storage.namespace', 'files.read', 'files.write', 'files.delete', 'workbench.open'],
frontend: { entry: 'frontend/dist/index.js' },
contributes: {
views: [
{ id: 'verstak.platform-test.diagnostics', title: 'Platform Diagnostics', icon: '🧪', component: 'DiagnosticsPanel' }
],
commands: [
{ id: 'verstak.platform-test.run-tests', title: 'Run Platform Tests', handler: 'runAllTests' },
{ id: 'verstak.platform-test.show-version', title: 'Show Version Info', handler: 'showVersion' }
],
sidebarItems: [
{ id: 'verstak.platform-test.sidebar', title: 'Platform Test', icon: '🧪', view: 'verstak.platform-test.diagnostics', position: 100 }
],
statusBarItems: [
{ id: 'verstak.platform-test.status', label: '🧪 All Tests Pass', position: 'right', handler: 'openDiagnostics' }
],
settingsPanels: [
{ id: 'verstak.platform-test.settings', title: 'Platform Test Settings', icon: '🧪', component: 'PlatformTestSettings' }
],
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', extensions: ['.txt', '.log'], mime: ['text/plain'], contexts: ['generic-text'] }
]
}
]
}
},
rootPath: '/tmp/verstak-test/plugins/platform-test',
error: ''
}
};
var vaultStatus = { status: 'open', path: '/tmp/verstak-test/vault', vaultId: 'test-vault-001' };
var vaultPluginState = { enabledPlugins: ['verstak.platform-test'], disabledPlugins: [], desiredPlugins: [{ id: 'verstak.platform-test', version: '0.1.0', source: 'official' }] };
var appSettings = { currentVaultPath: '/tmp/verstak-test/vault', recentVaults: [] };
var workbenchPreferences = {};
var openedResources = [];
var pluginSettings = {
'verstak.platform-test': { savedText: 'initial value' }
};
var vaultFiles = makeDefaultVaultFiles();
var workspaceTree = makeDefaultWorkspaceTree();
var reloadResponseMode = 'tuple';
// ── Helpers ────────────────────────────────────────────────────────
function makeDefaultWorkspaceTree() {
return {
status: 'initialized',
currentNodeId: 'case-alpha',
nodes: [
{ id: 'space-main', parentId: '', type: 'space', title: 'Main Space', status: 'active', order: 1 },
{ id: 'case-alpha', parentId: 'space-main', type: 'case', title: 'Alpha Case', status: 'active', order: 1 },
{ id: 'case-beta', parentId: 'space-main', type: 'case', title: 'Beta Case', status: 'active', order: 2 }
]
};
}
function cloneWorkspaceTree() {
return {
status: workspaceTree.status,
currentNodeId: workspaceTree.currentNodeId,
nodes: workspaceTree.nodes.map(function (n) { return Object.assign({}, n); })
};
}
function makeDefaultVaultFiles() {
return {
'': { type: 'folder', modifiedAt: new Date().toISOString() }
};
}
function normalizeVaultPath(relativePath, allowRoot) {
var p = String(relativePath || '');
if (p.indexOf('\x00') !== -1) return { error: 'invalid-path: null-byte' };
if (p.indexOf('\\') !== -1) return { error: 'invalid-path: backslash not allowed' };
if (p.indexOf('./') === 0) p = p.slice(2);
if (!allowRoot && !p) return { error: 'invalid-path: empty path' };
if (p.charAt(0) === '/' || /^[A-Za-z]:/.test(p)) return { error: 'invalid-path: absolute path rejected' };
var parts = p.split('/').filter(Boolean);
if (parts.indexOf('..') !== -1) return { error: 'invalid-path: path-traversal' };
if (parts[0] && parts[0].toLowerCase() === '.verstak') return { error: 'reserved-path: .verstak is internal' };
return { path: parts.join('/') };
}
function parentPath(path) {
var idx = path.lastIndexOf('/');
return idx === -1 ? '' : path.slice(0, idx);
}
function baseName(path) {
var idx = path.lastIndexOf('/');
return idx === -1 ? path : path.slice(idx + 1);
}
function fileEntry(path, node) {
var name = path ? baseName(path) : '';
var ext = '';
var dot = name.lastIndexOf('.');
if (dot > 0) ext = name.slice(dot + 1);
return {
name: name,
relativePath: path,
type: node.type,
size: node.type === 'file' ? (node.content || '').length : 0,
modifiedAt: node.modifiedAt || new Date().toISOString(),
extension: ext,
isHidden: name.charAt(0) === '.',
isReserved: false,
canRead: node.type === 'file' || node.type === 'folder',
canWrite: node.type === 'file' || node.type === 'folder'
};
}
function requirePluginPermission(pluginId, permission) {
var s = pluginStates[pluginId];
if (!s || !s.enabled || (s.status !== 'loaded' && s.status !== 'degraded')) {
return 'plugin not enabled and loaded';
}
if (!s.manifest.permissions || s.manifest.permissions.indexOf(permission) === -1) {
return 'plugin lacks required permission ' + permission;
}
if (vaultStatus.status !== 'open') return 'vault-not-open';
return '';
}
function makePlugin(id) {
var s = pluginStates[id];
if (!s) return null;
return {
manifest: s.manifest,
status: s.status,
enabled: s.enabled,
rootPath: s.rootPath,
error: s.error
};
}
function allPlugins() {
return Object.keys(pluginStates).map(makePlugin).filter(Boolean);
}
function allCapabilities() {
var caps = [];
caps.push({ name: 'verstak/core/plugin-manager/v1', description: 'Plugin management', pluginId: 'verstak-desktop', status: 'stable' });
caps.push({ name: 'verstak/core/capability-registry/v1', description: 'Capability registry', pluginId: 'verstak-desktop', status: 'stable' });
caps.push({ name: 'verstak/core/files/v1', description: 'Files API', pluginId: 'verstak-desktop', status: 'stable' });
caps.push({ name: 'verstak/core/workbench/v1', description: 'Workbench routing', pluginId: 'verstak-desktop', status: 'stable' });
for (var id in pluginStates) {
var s = pluginStates[id];
if (s.status === 'loaded' && s.enabled && s.manifest && s.manifest.provides) {
s.manifest.provides.forEach(function (p) {
caps.push({ name: p, description: '', pluginId: id, status: 'stable' });
});
}
}
return caps;
}
function allPermissions() {
return [
{ name: 'vault.read', description: 'Read vault data', dangerous: false },
{ name: 'events.publish', description: 'Publish events', dangerous: false },
{ name: 'events.subscribe', description: 'Subscribe to events', dangerous: false },
{ name: 'ui.register', description: 'Register UI contributions', dangerous: false },
{ name: 'commands.register', description: 'Register commands', dangerous: false },
{ name: 'storage.namespace', description: 'Access plugin storage', dangerous: false },
{ name: 'files.read', description: 'Read vault files', dangerous: false },
{ name: 'files.write', description: 'Write vault files', dangerous: true },
{ name: 'files.delete', description: 'Trash vault files', dangerous: true },
{ name: 'workbench.open', description: 'Request Workbench open/edit routing', dangerous: false }
];
}
function allContributions() {
var views = [], commands = [], sidebarItems = [], statusBarItems = [], settingsPanels = [], openProviders = [];
for (var id in pluginStates) {
var s = pluginStates[id];
var c = (s.manifest && s.manifest.contributes) || {};
if (c.views) c.views.forEach(function (v) { views.push(Object.assign({}, v, { pluginId: id })); });
if (c.commands) c.commands.forEach(function (cmd) { commands.push(Object.assign({}, cmd, { pluginId: id })); });
if (c.sidebarItems) c.sidebarItems.forEach(function (sb) { sidebarItems.push(Object.assign({}, sb, { pluginId: id })); });
if (c.statusBarItems) c.statusBarItems.forEach(function (st) { statusBarItems.push(Object.assign({}, st, { pluginId: id })); });
if (c.settingsPanels) c.settingsPanels.forEach(function (sp) { settingsPanels.push(Object.assign({}, sp, { pluginId: id })); });
if (c.openProviders) c.openProviders.forEach(function (op) { openProviders.push(Object.assign({}, op, { pluginId: id })); });
}
return { views: views, commands: commands, sidebarItems: sidebarItems, statusBarItems: statusBarItems, settingsPanels: settingsPanels, openProviders: openProviders };
}
function requestExtension(request) {
if (request && request.extension) {
var explicit = String(request.extension).toLowerCase();
return explicit.charAt(0) === '.' ? explicit : '.' + explicit;
}
var p = String((request && request.path) || '').toLowerCase();
var slash = p.lastIndexOf('/');
var name = slash === -1 ? p : p.slice(slash + 1);
var dot = name.lastIndexOf('.');
return dot > 0 ? name.slice(dot) : '';
}
function requestContextName(request) {
var ctx = (request && request.context) || {};
if (ctx.notesMode || ctx.isInsideNotesFolder || ctx.sourceView === 'notes') return 'notes-markdown';
var ext = requestExtension(request);
if (ext === '.md' || ext === '.markdown') return 'generic-markdown';
return 'generic-text';
}
function providerSupports(provider, request) {
var ext = requestExtension(request);
var contextName = requestContextName(request);
return (provider.supports || []).some(function (support) {
if (support.kind && support.kind !== request.kind) return false;
if (support.extensions && support.extensions.length && support.extensions.map(function (e) { return String(e).toLowerCase(); }).indexOf(ext) === -1) return false;
if (support.contexts && support.contexts.length && support.contexts.indexOf(contextName) === -1) return false;
return true;
});
}
function selectOpenProvider(request) {
var providers = allContributions().openProviders.filter(function (provider) {
var s = pluginStates[provider.pluginId];
return s && s.enabled && (s.status === 'loaded' || s.status === 'degraded') && providerSupports(provider, request);
});
providers.sort(function (a, b) {
var byPriority = (b.priority || 0) - (a.priority || 0);
if (byPriority) return byPriority;
return String(a.id).localeCompare(String(b.id));
});
return providers[0] || null;
}
function openWorkbenchResource(pluginId, request, forcedMode) {
var s = pluginStates[pluginId];
if (!s || !s.enabled || (s.status !== 'loaded' && s.status !== 'degraded')) {
return Promise.resolve([{}, 'plugin not enabled and loaded']);
}
if (!s.manifest.permissions || s.manifest.permissions.indexOf('workbench.open') === -1) {
return Promise.resolve([{}, 'plugin lacks required permission workbench.open']);
}
var normalized = Object.assign({}, request || {});
normalized.kind = normalized.kind || 'vault-file';
normalized.mode = forcedMode || normalized.mode || 'view';
normalized.extension = requestExtension(normalized);
normalized.context = Object.assign({}, normalized.context || {}, { sourcePluginId: pluginId });
var provider = selectOpenProvider(normalized);
if (!provider) {
return Promise.resolve([{
status: 'no-provider',
request: normalized,
message: 'no open provider for resource'
}, '']);
}
var result = {
status: 'opened',
providerId: provider.id,
providerPluginId: provider.pluginId,
providerComponent: provider.component,
request: normalized
};
openedResources.push(Object.assign({ id: provider.id + ':' + openedResources.length, openedAt: new Date().toISOString() }, result));
return Promise.resolve([result, '']);
}
function platformTestBundle() {
return [
"(function(){",
"var DiagnosticsPanel={",
"mount:function(containerEl,props,api){",
"containerEl.innerHTML='';",
"containerEl.__ptCleanup=[];",
"function track(fn){if(typeof fn==='function')containerEl.__ptCleanup.push(fn);}",
"var root=document.createElement('div');",
"root.className='pt-root';",
"var title=document.createElement('h2');",
"title.className='pt-plugin-name';",
"title.textContent='Platform Diagnostics';",
"var pluginId=document.createElement('p');",
"pluginId.className='pt-plugin-id';",
"pluginId.textContent=api.pluginId;",
"var status=document.createElement('div');",
"status.className='pt-badge pt-badge-success';",
"status.textContent='Frontend Bundle Loaded';",
"var saved=document.createElement('div');",
"saved.className='pt-card pt-saved-setting';",
"saved.textContent='Saved setting: loading...';",
"var cap=document.createElement('div');",
"cap.className='pt-capability-result';",
"cap.textContent='Capabilities: loading...';",
"api.capabilities.list().then(function(caps){cap.textContent='Capabilities: '+caps.length+' available';});",
"api.settings.read('savedText').then(function(value){saved.textContent='Saved setting: '+(value||'');});",
"var input=document.createElement('input');",
"input.className='pt-setting-input';",
"input.setAttribute('aria-label','Saved setting');",
"input.value='changed value';",
"var button=document.createElement('button');",
"button.className='btn btn-primary pt-save-setting';",
"button.textContent='Save Setting';",
"button.addEventListener('click',function(){api.settings.write('savedText',input.value).then(function(){saved.textContent='Saved setting: '+input.value;});});",
"api.capabilities.has('verstak/platform-test/v1').then(function(ok){status.textContent='Frontend Bundle Loaded | capability '+(ok?'available':'missing');});",
"var command=document.createElement('div');",
"command.className='pt-command-result';",
"command.textContent='Command: registering...';",
"api.commands.register('verstak.platform-test.show-version',function(){return {version:'0.1.0',source:'bundled-frontend'};}).then(function(unregister){track(unregister);return api.commands.execute('verstak.platform-test.show-version',{});}).then(function(result){status.setAttribute('data-command-status',result.status||'');command.textContent='Command: '+result.status+' '+result.result.version+' from '+result.result.source;});",
"var eventResult=document.createElement('div');",
"eventResult.className='pt-event-result';",
"eventResult.textContent='Event: subscribing...';",
"api.events.subscribe('verstak.platform-test.echo',function(event){eventResult.textContent='Event: received '+event.payload.message;eventResult.setAttribute('data-event-status','received');}).then(function(unsubscribe){track(unsubscribe);return api.events.publish('verstak.platform-test.echo',{message:'hello-event'});});",
"var filesResult=document.createElement('div');",
"filesResult.className='pt-files-result';",
"filesResult.textContent='Files: running...';",
"var filesError=document.createElement('div');",
"filesError.className='pt-files-error-result';",
"filesError.textContent='Files error path: checking...';",
"var workbenchResult=document.createElement('div');",
"workbenchResult.className='pt-workbench-result';",
"workbenchResult.textContent='Workbench: ready';",
"function makeWorkbenchButton(cls,label,request){var b=document.createElement('button');b.className='btn btn-primary '+cls;b.textContent=label;b.addEventListener('click',function(){workbenchResult.textContent='Workbench: opening...';api.workbench.editResource(request).then(function(result){workbenchResult.textContent='Workbench: opened '+result.request.path+' with '+(result.providerId||'no-provider');workbenchResult.setAttribute('data-workbench-status',result.status==='opened'?'ok':result.status);}).catch(function(err){workbenchResult.textContent='Workbench error: '+(err&&err.message?err.message:String(err));workbenchResult.setAttribute('data-workbench-status','error');});});return b;}",
"var textWorkbenchButton=makeWorkbenchButton('pt-open-workbench-text','Open Text Diagnostic',{kind:'vault-file',path:'Docs/todo.txt',extension:'.txt',mime:'text/plain',context:{sourceView:'files'}});",
"var markdownWorkbenchButton=makeWorkbenchButton('pt-open-workbench-markdown','Open Markdown Diagnostic',{kind:'vault-file',path:'Docs/readme.md',extension:'.md',context:{sourceView:'files'}});",
"var notesWorkbenchButton=makeWorkbenchButton('pt-open-workbench-notes','Open Notes Diagnostic',{kind:'vault-file',path:'Notes/Overview.md',extension:'.md',context:{sourceView:'notes',isInsideNotesFolder:true,notesMode:true}});",
"api.files.createFolder('PlatformTest').catch(function(e){if(String(e).indexOf('conflict')===-1)throw e;}).then(function(){return api.files.writeText('PlatformTest/files-api.txt','hello files',{createIfMissing:true,overwrite:true});}).then(function(){return api.files.readText('PlatformTest/files-api.txt');}).then(function(text){if(text!=='hello files')throw new Error('read mismatch');return api.files.list('PlatformTest');}).then(function(entries){if(!entries.some(function(e){return e.relativePath==='PlatformTest/files-api.txt';}))throw new Error('list missing file');return api.files.move('PlatformTest/files-api.txt','PlatformTest/files-api-moved.txt',{overwrite:true});}).then(function(){return api.files.trash('PlatformTest/files-api-moved.txt');}).then(function(){filesResult.textContent='Files: wrote/read/listed/moved/trashed';filesResult.setAttribute('data-files-status','ok');}).catch(function(err){filesResult.textContent='Files error: '+(err&&err.message?err.message:String(err));filesResult.setAttribute('data-files-status','error');});",
"api.files.readText('.verstak/vault.json').then(function(){filesError.textContent='Files error path: unexpectedly allowed';filesError.setAttribute('data-files-error-status','error');}).catch(function(err){var message=err&&err.message?err.message:String(err);if(message.indexOf('reserved-path')===-1&&message.indexOf('.verstak')===-1){filesError.textContent='Files error path: wrong error '+message;filesError.setAttribute('data-files-error-status','error');return;}filesError.textContent='Files error path: rejected reserved-path';filesError.setAttribute('data-files-error-status','expected');});",
"root.appendChild(title);",
"root.appendChild(pluginId);",
"root.appendChild(status);",
"root.appendChild(saved);",
"root.appendChild(input);",
"root.appendChild(button);",
"root.appendChild(cap);",
"root.appendChild(command);",
"root.appendChild(eventResult);",
"root.appendChild(filesResult);",
"root.appendChild(filesError);",
"root.appendChild(textWorkbenchButton);",
"root.appendChild(markdownWorkbenchButton);",
"root.appendChild(notesWorkbenchButton);",
"root.appendChild(workbenchResult);",
"containerEl.appendChild(root);",
"},",
"unmount:function(containerEl){while(containerEl.__ptCleanup&&containerEl.__ptCleanup.length){containerEl.__ptCleanup.pop()();}containerEl.innerHTML='';}",
"};",
"var MarkdownDiagnosticProvider={",
"mount:function(containerEl,props,api){",
"containerEl.innerHTML='';",
"var root=document.createElement('div');",
"root.className='pt-root pt-workbench-result';",
"root.setAttribute('data-workbench-status','ok');",
"var req=(props&&props.request)||{};",
"var ctx=(req.context&&req.context.notesMode)||false?'notes-markdown':((req.extension==='.md'||req.extension==='.markdown')?'generic-markdown':'generic-text');",
"root.setAttribute('data-resource-path',req.path||'');",
"root.setAttribute('data-resource-mode',req.mode||'');",
"root.setAttribute('data-resource-context',ctx);",
"root.textContent='Workbench: opened '+(req.path||'')+' with '+((props&&props.providerId)||'')+' mode='+(req.mode||'')+' context='+ctx;",
"containerEl.appendChild(root);",
"},",
"unmount:function(containerEl){containerEl.innerHTML='';}",
"};",
"var PlatformTestSettings={",
"mount:function(containerEl,props,api){",
"containerEl.innerHTML='<div class=\"pt-root\"><h2>Platform Test Settings</h2><p>'+api.pluginId+'</p></div>';",
"},",
"unmount:function(containerEl){containerEl.innerHTML='';}",
"};",
"window.VerstakPluginRegister('verstak.platform-test',{components:{DiagnosticsPanel:DiagnosticsPanel,PlatformTestSettings:PlatformTestSettings,MarkdownDiagnosticProvider:MarkdownDiagnosticProvider}});",
"})();"
].join('');
}
// ── Mock API ───────────────────────────────────────────────────────
var mock = {
GetPlugins: function () { return Promise.resolve(allPlugins()); },
GetCapabilities: function () { return Promise.resolve(allCapabilities()); },
GetPermissions: function () { return Promise.resolve(allPermissions()); },
GetContributions: function () { return Promise.resolve(allContributions()); },
GetVaultStatus: function () { return Promise.resolve(vaultStatus); },
GetVaultPluginState: function () { return Promise.resolve(vaultPluginState); },
GetAppSettings: function () { return Promise.resolve(appSettings); },
GetPluginFrontendInfo: function (pluginId) {
var s = pluginStates[pluginId];
if (s && s.manifest && s.manifest.frontend) {
return Promise.resolve({ entry: s.manifest.frontend.entry });
}
return Promise.resolve({});
},
ReadPluginSettings: function (pluginId) {
return Promise.resolve([Object.assign({}, pluginSettings[pluginId] || {}), '']);
},
WritePluginSettings: function (pluginId, settings) {
pluginSettings[pluginId] = Object.assign({}, settings || {});
return Promise.resolve('');
},
ReadPluginSetting: function () { return Promise.resolve(null); },
WritePluginSetting: function () { return Promise.resolve(null); },
ReadPluginDataJSON: function () { return Promise.resolve({}); },
WritePluginDataJSON: function () { return Promise.resolve(null); },
OpenWorkbenchResource: function (pluginId, request) {
return openWorkbenchResource(pluginId, request || {}, '');
},
EditWorkbenchResource: function (pluginId, request) {
return openWorkbenchResource(pluginId, request || {}, 'edit');
},
GetWorkbenchOpenedResources: function () {
return Promise.resolve(openedResources.map(function (resource) {
return Object.assign({}, resource, { request: Object.assign({}, resource.request || {}) });
}));
},
GetWorkbenchPreferences: function () {
return Promise.resolve(Object.assign({}, workbenchPreferences));
},
UpdateWorkbenchPreferences: function (preferences) {
workbenchPreferences = Object.assign({}, workbenchPreferences, preferences || {});
return Promise.resolve('');
},
GetPluginAssetContent: function (pluginId, assetPath) {
if (pluginId === 'verstak.platform-test' && assetPath === 'frontend/dist/index.js') {
return Promise.resolve(platformTestBundle());
}
return Promise.resolve('');
},
GetPluginCapability: function (pluginId, capId) {
var caps = allCapabilities();
var found = caps.find(function (cap) { return cap.name === capId; });
return Promise.resolve([found ? Object.assign({ available: true }, found) : { available: false, name: capId }, '']);
},
ListPluginCapabilities: function () { return Promise.resolve([allCapabilities(), '']); },
ExecutePluginCommand: function (pluginId, commandId, args) {
var s = pluginStates[pluginId];
var commands = ((s && s.manifest && s.manifest.contributes && s.manifest.contributes.commands) || []);
var found = commands.find(function (cmd) { return cmd.id === commandId; });
if (!found) return Promise.resolve([{}, 'command not declared']);
return Promise.resolve([{ status: 'declared', pluginId: pluginId, commandId: commandId, handler: found.handler, args: args || {} }, '']);
},
PublishPluginEvent: function () { return Promise.resolve(''); },
SubscribePluginEvent: function (pluginId, eventName) {
var s = pluginStates[pluginId];
if (!s || !s.enabled || s.status !== 'loaded') return Promise.resolve('plugin not enabled and loaded');
if (!eventName) return Promise.resolve('event name is empty');
if (!s.manifest.permissions || s.manifest.permissions.indexOf('events.subscribe') === -1) {
return Promise.resolve('plugin lacks required permission events.subscribe');
}
return Promise.resolve('');
},
ListVaultFiles: function (pluginId, relativeDir) {
var err = requirePluginPermission(pluginId, 'files.read');
if (err) return Promise.resolve([[], err]);
var norm = normalizeVaultPath(relativeDir, true);
if (norm.error) return Promise.resolve([[], norm.error]);
var dir = norm.path;
if (!vaultFiles[dir] || vaultFiles[dir].type !== 'folder') return Promise.resolve([[], 'not-found: ' + dir]);
var prefix = dir ? dir + '/' : '';
var entries = [];
Object.keys(vaultFiles).forEach(function (path) {
if (path === dir || path.indexOf(prefix) !== 0) return;
var rest = path.slice(prefix.length);
if (!rest || rest.indexOf('/') !== -1) return;
entries.push(fileEntry(path, vaultFiles[path]));
});
return Promise.resolve([entries, '']);
},
GetVaultFileMetadata: function (pluginId, relativePath) {
var err = requirePluginPermission(pluginId, 'files.read');
if (err) return Promise.resolve([{}, err]);
var norm = normalizeVaultPath(relativePath, false);
if (norm.error) return Promise.resolve([{}, norm.error]);
var node = vaultFiles[norm.path];
if (!node) return Promise.resolve([{}, 'not-found: ' + norm.path]);
return Promise.resolve([fileEntry(norm.path, node), '']);
},
ReadVaultTextFile: function (pluginId, relativePath) {
var err = requirePluginPermission(pluginId, 'files.read');
if (err) return Promise.resolve(['', err]);
var norm = normalizeVaultPath(relativePath, false);
if (norm.error) return Promise.resolve(['', norm.error]);
var node = vaultFiles[norm.path];
if (!node) return Promise.resolve(['', 'not-found: ' + norm.path]);
if (node.type !== 'file') return Promise.resolve(['', 'not-regular-file: ' + norm.path]);
return Promise.resolve([node.content || '', '']);
},
WriteVaultTextFile: function (pluginId, relativePath, content, options) {
var err = requirePluginPermission(pluginId, 'files.write');
if (err) return Promise.resolve(err);
var norm = normalizeVaultPath(relativePath, false);
if (norm.error) return Promise.resolve(norm.error);
options = options || {};
var existing = vaultFiles[norm.path];
if (existing && existing.type !== 'file') return Promise.resolve('not-regular-file: ' + norm.path);
if (existing && !options.overwrite) return Promise.resolve('conflict: ' + norm.path);
if (!existing && !options.createIfMissing) return Promise.resolve('not-found: ' + norm.path);
var parent = parentPath(norm.path);
if (!vaultFiles[parent] || vaultFiles[parent].type !== 'folder') return Promise.resolve('parent-not-found: ' + parent);
vaultFiles[norm.path] = { type: 'file', content: String(content == null ? '' : content), modifiedAt: new Date().toISOString() };
return Promise.resolve('');
},
CreateVaultFolder: function (pluginId, relativePath) {
var err = requirePluginPermission(pluginId, 'files.write');
if (err) return Promise.resolve(err);
var norm = normalizeVaultPath(relativePath, false);
if (norm.error) return Promise.resolve(norm.error);
if (vaultFiles[norm.path]) return Promise.resolve('conflict: ' + norm.path);
var parent = parentPath(norm.path);
if (!vaultFiles[parent] || vaultFiles[parent].type !== 'folder') return Promise.resolve('parent-not-found: ' + parent);
vaultFiles[norm.path] = { type: 'folder', modifiedAt: new Date().toISOString() };
return Promise.resolve('');
},
MoveVaultPath: function (pluginId, fromRelativePath, toRelativePath, options) {
var err = requirePluginPermission(pluginId, 'files.write');
if (err) return Promise.resolve(err);
var from = normalizeVaultPath(fromRelativePath, false);
var to = normalizeVaultPath(toRelativePath, false);
if (from.error) return Promise.resolve(from.error);
if (to.error) return Promise.resolve(to.error);
options = options || {};
if (!vaultFiles[from.path]) return Promise.resolve('not-found: ' + from.path);
if (vaultFiles[from.path].type === 'folder' && (to.path === from.path || to.path.indexOf(from.path + '/') === 0)) {
return Promise.resolve('move-into-self: ' + from.path + ' -> ' + to.path);
}
if (vaultFiles[to.path] && !options.overwrite) return Promise.resolve('conflict: ' + to.path);
var parent = parentPath(to.path);
if (!vaultFiles[parent] || vaultFiles[parent].type !== 'folder') return Promise.resolve('parent-not-found: ' + parent);
var moving = Object.keys(vaultFiles).filter(function (path) { return path === from.path || path.indexOf(from.path + '/') === 0; });
moving.forEach(function (path) {
var suffix = path.slice(from.path.length);
vaultFiles[to.path + suffix] = vaultFiles[path];
delete vaultFiles[path];
});
return Promise.resolve('');
},
TrashVaultPath: function (pluginId, relativePath) {
var err = requirePluginPermission(pluginId, 'files.delete');
if (err) return Promise.resolve([{}, err]);
var norm = normalizeVaultPath(relativePath, false);
if (norm.error) return Promise.resolve([{}, norm.error]);
if (!vaultFiles[norm.path]) return Promise.resolve([{}, 'not-found: ' + norm.path]);
var trashId = 'mock-' + Date.now() + '-' + Math.random().toString(16).slice(2);
var trashPath = '.verstak/trash/files/' + trashId + '/' + baseName(norm.path);
var moving = Object.keys(vaultFiles).filter(function (path) { return path === norm.path || path.indexOf(norm.path + '/') === 0; });
moving.forEach(function (path) { delete vaultFiles[path]; });
return Promise.resolve([{ originalPath: norm.path, trashPath: trashPath, trashId: trashId, deletedAt: new Date().toISOString() }, '']);
},
GetCurrentWorkspaceNode: function () { return Promise.resolve(null); },
GetWorkspaceTree: function () { return Promise.resolve(cloneWorkspaceTree()); },
ArchiveWorkspaceNode: function () { return Promise.resolve(''); },
CreateWorkspaceNode: function () { return Promise.resolve({}); },
MoveWorkspaceNode: function () { return Promise.resolve(''); },
RenameWorkspaceNode: function () { return Promise.resolve(''); },
SetCurrentWorkspaceNode: function (id) {
var found = workspaceTree.nodes.some(function (n) { return n.id === id; });
if (!found) return Promise.resolve('workspace node not found: ' + id);
workspaceTree.currentNodeId = id;
return Promise.resolve('');
},
SelectDirectory: function () { return Promise.resolve(''); },
SelectVaultForOpen: function () { return Promise.resolve(''); },
CreateVault: function () { return Promise.resolve(null); },
OpenVault: function () { return Promise.resolve(null); },
CloseVault: function () { return Promise.resolve(null); },
SetCurrentVault: function () { return Promise.resolve(''); },
UpdateAppSettings: function () { return Promise.resolve(''); },
RecordDesiredPlugin: function () { return Promise.resolve(''); },
WriteFrontendLog: function () { return Promise.resolve(); },
EnablePlugin: function (pluginId) {
if (pluginStates[pluginId]) {
pluginStates[pluginId].status = 'loaded';
pluginStates[pluginId].enabled = true;
if (vaultPluginState.disabledPlugins.indexOf(pluginId) !== -1) {
vaultPluginState.disabledPlugins = vaultPluginState.disabledPlugins.filter(function (id) { return id !== pluginId; });
}
if (vaultPluginState.enabledPlugins.indexOf(pluginId) === -1) {
vaultPluginState.enabledPlugins.push(pluginId);
}
}
return Promise.resolve(null);
},
DisablePlugin: function (pluginId) {
if (pluginStates[pluginId]) {
pluginStates[pluginId].status = 'disabled';
pluginStates[pluginId].enabled = false;
if (vaultPluginState.enabledPlugins.indexOf(pluginId) !== -1) {
vaultPluginState.enabledPlugins = vaultPluginState.enabledPlugins.filter(function (id) { return id !== pluginId; });
}
if (vaultPluginState.disabledPlugins.indexOf(pluginId) === -1) {
vaultPluginState.disabledPlugins.push(pluginId);
}
}
return Promise.resolve(null);
},
ReloadPlugins: function () {
if (reloadResponseMode === 'raw-count') {
return Promise.resolve(Object.keys(pluginStates).length);
}
return Promise.resolve([Object.keys(pluginStates).length, 'Reloaded ' + Object.keys(pluginStates).length + ' plugin(s).']);
}
};
// ── Install bridge ─────────────────────────────────────────────────
if (!window['go']) window['go'] = {};
if (!window['go']['api']) window['go']['api'] = {};
window['go']['api']['App'] = mock;
// ── Test helpers (exposed for Playwright) ──────────────────────────
window.__wailsMock = {
reset: function () {
pluginStates = {
'verstak.platform-test': {
status: 'loaded',
enabled: true,
manifest: {
schemaVersion: 1,
id: 'verstak.platform-test',
name: 'Platform Test',
version: '0.1.0',
apiVersion: '0.1.0',
description: 'Runtime test plugin for verifying the Verstak platform.',
source: 'official',
icon: '🧪',
provides: ['verstak/platform-test/v1', 'verstak/diagnostics/v1'],
requires: ['verstak/core/plugin-manager/v1', 'verstak/core/capability-registry/v1'],
optionalRequires: ['verstak/core/vault/v1', 'verstak/core/sync/v1', 'verstak/core/files/v1', 'verstak/core/workbench/v1'],
permissions: ['vault.read', 'events.publish', 'events.subscribe', 'ui.register', 'commands.register', 'storage.namespace', 'files.read', 'files.write', 'files.delete', 'workbench.open'],
frontend: { entry: 'frontend/dist/index.js' },
contributes: {
views: [
{ id: 'verstak.platform-test.diagnostics', title: 'Platform Diagnostics', icon: '🧪', component: 'DiagnosticsPanel' }
],
commands: [
{ id: 'verstak.platform-test.run-tests', title: 'Run Platform Tests', handler: 'runAllTests' },
{ id: 'verstak.platform-test.show-version', title: 'Show Version Info', handler: 'showVersion' }
],
sidebarItems: [
{ id: 'verstak.platform-test.sidebar', title: 'Platform Test', icon: '🧪', view: 'verstak.platform-test.diagnostics', position: 100 }
],
statusBarItems: [
{ id: 'verstak.platform-test.status', label: '🧪 All Tests Pass', position: 'right', handler: 'openDiagnostics' }
],
settingsPanels: [
{ id: 'verstak.platform-test.settings', title: 'Platform Test Settings', icon: '🧪', component: 'PlatformTestSettings' }
],
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', extensions: ['.txt', '.log'], mime: ['text/plain'], contexts: ['generic-text'] }
]
}
]
}
},
rootPath: '/tmp/verstak-test/plugins/platform-test',
error: ''
}
};
vaultStatus = { status: 'open', path: '/tmp/verstak-test/vault', vaultId: 'test-vault-001' };
vaultPluginState = { enabledPlugins: ['verstak.platform-test'], disabledPlugins: [], desiredPlugins: [{ id: 'verstak.platform-test', version: '0.1.0', source: 'official' }] };
appSettings = { currentVaultPath: '/tmp/verstak-test/vault', recentVaults: [] };
workbenchPreferences = {};
openedResources = [];
pluginSettings = { 'verstak.platform-test': { savedText: 'initial value' } };
vaultFiles = makeDefaultVaultFiles();
workspaceTree = makeDefaultWorkspaceTree();
reloadResponseMode = 'tuple';
},
setPluginStatus: function (pluginId, status, enabled) {
if (pluginStates[pluginId]) {
pluginStates[pluginId].status = status;
pluginStates[pluginId].enabled = enabled;
}
},
getPluginState: function (pluginId) {
return pluginStates[pluginId] ? Object.assign({}, pluginStates[pluginId]) : null;
},
addSyntheticPlugins: function (count) {
var total = Number(count || 0);
for (var i = 1; i <= total; i++) {
var id = 'verstak.synthetic-layout-' + String(i).padStart(2, '0');
pluginStates[id] = {
status: 'loaded',
enabled: true,
manifest: {
schemaVersion: 1,
id: id,
name: 'Synthetic Layout Plugin ' + i,
version: '0.0.' + i,
apiVersion: '0.1.0',
description: 'Synthetic plugin used by frontend layout tests.',
source: 'test',
provides: ['verstak/synthetic-layout-' + i + '/v1'],
requires: [],
optionalRequires: [],
permissions: [],
contributes: {
views: [],
commands: [],
sidebarItems: [],
statusBarItems: [],
settingsPanels: []
}
},
rootPath: '/tmp/verstak-test/plugins/synthetic-layout-' + i + '/with/a/long/path/for/responsive-checks',
error: ''
};
if (vaultPluginState.enabledPlugins.indexOf(id) === -1) {
vaultPluginState.enabledPlugins.push(id);
}
if (!vaultPluginState.desiredPlugins.some(function (p) { return p.id === id; })) {
vaultPluginState.desiredPlugins.push({ id: id, version: '0.0.' + i, source: 'test' });
}
}
},
setVaultStatus: function (status) { vaultStatus = status; },
setVaultPluginState: function (state) { vaultPluginState = state; },
setReloadResponseMode: function (mode) { reloadResponseMode = mode || 'tuple'; }
};
window.__wailsMockReady = true;
console.log('[wails-mock] bridge installed');
})();