Add open provider contracts to SDK
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import manifestSchema from '../schemas/manifest.json';
|
||||
import type { OpenResourceRequest, PluginManifest } from './types';
|
||||
import { createMockPluginAPI } from './test-utils';
|
||||
|
||||
describe('VerstakPluginAPI contract', () => {
|
||||
test('mock API exposes the bundled runtime shape', async () => {
|
||||
const api = createMockPluginAPI('verstak.platform-test');
|
||||
|
||||
expect(api.pluginId).toBe('verstak.platform-test');
|
||||
expect(typeof api.settings.read).toBe('function');
|
||||
expect(typeof api.settings.write).toBe('function');
|
||||
expect(typeof api.capabilities.list).toBe('function');
|
||||
expect(typeof api.commands.register).toBe('function');
|
||||
expect(typeof api.commands.execute).toBe('function');
|
||||
expect(typeof api.events.publish).toBe('function');
|
||||
expect(typeof api.events.subscribe).toBe('function');
|
||||
expect(typeof api.files.list).toBe('function');
|
||||
expect(typeof api.files.metadata).toBe('function');
|
||||
expect(typeof api.files.readText).toBe('function');
|
||||
expect(typeof api.files.writeText).toBe('function');
|
||||
expect(typeof api.files.createFolder).toBe('function');
|
||||
expect(typeof api.files.move).toBe('function');
|
||||
expect(typeof api.files.trash).toBe('function');
|
||||
expect(typeof api.workbench.openResource).toBe('function');
|
||||
expect(typeof api.workbench.editResource).toBe('function');
|
||||
});
|
||||
|
||||
test('manifest schema accepts files permissions used by platform-test', () => {
|
||||
const permissionEnum = ((manifestSchema as any).properties.permissions.items.enum || []) as string[];
|
||||
|
||||
expect(permissionEnum).toContain('files.read');
|
||||
expect(permissionEnum).toContain('files.write');
|
||||
expect(permissionEnum).toContain('files.delete');
|
||||
expect(permissionEnum).toContain('workbench.open');
|
||||
});
|
||||
|
||||
test('manifest types accept open provider contributions', () => {
|
||||
const manifest: PluginManifest = {
|
||||
schemaVersion: 1,
|
||||
id: 'verstak.default-editor',
|
||||
name: 'Default Editor',
|
||||
version: '0.1.0',
|
||||
apiVersion: '1',
|
||||
provides: ['editor.text', 'editor.text.markdown'],
|
||||
permissions: ['ui.register', 'files.read', 'files.write', 'workbench.open'],
|
||||
contributes: {
|
||||
openProviders: [
|
||||
{
|
||||
id: 'verstak.default-editor.markdown',
|
||||
title: 'Default Markdown Editor',
|
||||
priority: 100,
|
||||
component: 'MarkdownEditor',
|
||||
supports: [
|
||||
{
|
||||
kind: 'vault-file',
|
||||
extensions: ['.md', '.markdown'],
|
||||
contexts: ['generic-markdown', 'notes-markdown'],
|
||||
},
|
||||
{
|
||||
kind: 'vault-file',
|
||||
mime: ['text/plain'],
|
||||
extensions: ['.txt', '.log'],
|
||||
contexts: ['generic-text'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
expect(manifest.contributes?.openProviders?.[0].supports[0].contexts).toContain('notes-markdown');
|
||||
expect(manifest.contributes?.openProviders?.[0].supports[1].contexts).toContain('generic-text');
|
||||
});
|
||||
|
||||
test('OpenResourceRequest and no-provider result shape are typed', () => {
|
||||
const request: OpenResourceRequest = {
|
||||
kind: 'vault-file',
|
||||
path: 'Docs/todo.txt',
|
||||
mode: 'edit',
|
||||
mime: 'text/plain',
|
||||
extension: '.txt',
|
||||
context: {
|
||||
sourcePluginId: 'files.plugin',
|
||||
sourceView: 'files',
|
||||
},
|
||||
};
|
||||
|
||||
const result = {
|
||||
status: 'no-provider' as const,
|
||||
request,
|
||||
message: 'no open provider for resource',
|
||||
};
|
||||
|
||||
expect(result.status).toBe('no-provider');
|
||||
expect(result.request.context?.sourceView).toBe('files');
|
||||
});
|
||||
|
||||
test('workbench mock routes open and edit resources', async () => {
|
||||
const api = createMockPluginAPI('files.plugin');
|
||||
const request: OpenResourceRequest = {
|
||||
kind: 'vault-file',
|
||||
path: 'Notes/Overview.md',
|
||||
mode: 'view',
|
||||
extension: '.md',
|
||||
context: {
|
||||
sourceView: 'notes',
|
||||
isInsideNotesFolder: true,
|
||||
notesMode: true,
|
||||
},
|
||||
};
|
||||
|
||||
await expect(api.workbench.openResource(request)).resolves.toEqual(expect.objectContaining({
|
||||
status: 'opened',
|
||||
providerId: expect.any(String),
|
||||
request: expect.objectContaining({ path: 'Notes/Overview.md', mode: 'view' }),
|
||||
}));
|
||||
await expect(api.workbench.editResource({ ...request, mode: 'edit' })).resolves.toEqual(expect.objectContaining({
|
||||
status: 'opened',
|
||||
request: expect.objectContaining({ mode: 'edit' }),
|
||||
}));
|
||||
});
|
||||
|
||||
test('settings persist in the mock API namespace', async () => {
|
||||
const api = createMockPluginAPI();
|
||||
|
||||
await api.settings.write('savedText', 'hello');
|
||||
|
||||
await expect(api.settings.read('savedText')).resolves.toBe('hello');
|
||||
await expect(api.settings.read()).resolves.toEqual({ savedText: 'hello' });
|
||||
});
|
||||
|
||||
test('commands register, execute, and unregister', async () => {
|
||||
const api = createMockPluginAPI('cmd.plugin');
|
||||
|
||||
const unregister = await api.commands.register('cmd.plugin.echo', async (args) => args.value);
|
||||
await expect(api.commands.execute('cmd.plugin.echo', { value: 'ok' })).resolves.toEqual({
|
||||
status: 'handled',
|
||||
pluginId: 'cmd.plugin',
|
||||
commandId: 'cmd.plugin.echo',
|
||||
result: 'ok',
|
||||
});
|
||||
|
||||
unregister();
|
||||
await expect(api.commands.execute('cmd.plugin.echo', {})).rejects.toThrow('declared-but-unhandled');
|
||||
});
|
||||
|
||||
test('events publish to subscribers and unsubscribe cleanly', async () => {
|
||||
const api = createMockPluginAPI('event.plugin');
|
||||
const received: unknown[] = [];
|
||||
|
||||
const unsubscribe = await api.events.subscribe('event.plugin.echo', (event) => {
|
||||
received.push(event.payload.message);
|
||||
});
|
||||
await api.events.publish('event.plugin.echo', { message: 'first' });
|
||||
unsubscribe();
|
||||
await api.events.publish('event.plugin.echo', { message: 'second' });
|
||||
|
||||
expect(received).toEqual(['first']);
|
||||
});
|
||||
|
||||
test('files mock supports text write, read, list, move, and trash', async () => {
|
||||
const api = createMockPluginAPI('files.plugin');
|
||||
|
||||
await api.files.createFolder('PlatformTest');
|
||||
await api.files.writeText('PlatformTest/one.txt', 'hello', { createIfMissing: true });
|
||||
await expect(api.files.readText('PlatformTest/one.txt')).resolves.toBe('hello');
|
||||
await expect(api.files.list('PlatformTest')).resolves.toEqual([
|
||||
expect.objectContaining({ relativePath: 'PlatformTest/one.txt', type: 'file' }),
|
||||
]);
|
||||
await api.files.move('PlatformTest/one.txt', 'PlatformTest/two.txt');
|
||||
const trash = await api.files.trash('PlatformTest/two.txt');
|
||||
|
||||
expect(trash.originalPath).toBe('PlatformTest/two.txt');
|
||||
expect(trash.trashId).toBeTruthy();
|
||||
expect(trash.trashPath).toMatch(/^\.verstak\/trash\/files\/.+\/two\.txt$/);
|
||||
});
|
||||
|
||||
test('files mock rejects non-canonical and reserved paths', async () => {
|
||||
const api = createMockPluginAPI('files.plugin');
|
||||
|
||||
await expect(api.files.readText(String.raw`PlatformTest\one.txt`)).rejects.toThrow('backslash');
|
||||
await expect(api.files.readText('//server/share')).rejects.toThrow('absolute');
|
||||
await expect(api.files.readText('C:/Windows/system.ini')).rejects.toThrow('absolute');
|
||||
await expect(api.files.readText('../secret')).rejects.toThrow('path-traversal');
|
||||
await expect(api.files.readText('bad\0path')).rejects.toThrow('null-byte');
|
||||
await expect(api.files.readText('.Verstak/vault.json')).rejects.toThrow('reserved-path');
|
||||
});
|
||||
|
||||
test('files mock rejects moving a folder into itself', async () => {
|
||||
const api = createMockPluginAPI('files.plugin');
|
||||
|
||||
await api.files.createFolder('Folder');
|
||||
|
||||
await expect(api.files.move('Folder', 'Folder/Child')).rejects.toThrow('move-into-self');
|
||||
});
|
||||
});
|
||||
+97
-160
@@ -1,166 +1,103 @@
|
||||
// Verstak Plugin SDK — VerstakPluginAPI
|
||||
// The official runtime API available to all plugins in the frontend context.
|
||||
// Verstak Plugin SDK — bundled frontend plugin API contract.
|
||||
//
|
||||
// The desktop host creates the real API with createPluginAPI(pluginId) inside
|
||||
// VerstakPluginAPI.js and passes it to bundled plugin components at mount time.
|
||||
// This SDK file intentionally exposes the TypeScript contract only; it is not
|
||||
// a standalone security boundary or RPC client.
|
||||
|
||||
import type { PluginSettings } from './types';
|
||||
import type {
|
||||
CapabilityEntry,
|
||||
FileEntry,
|
||||
FileMetadata,
|
||||
MovePathOptions,
|
||||
OpenResourceRequest,
|
||||
OpenResourceResult,
|
||||
PluginSettings,
|
||||
TrashResult,
|
||||
WriteTextOptions,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* VerstakPluginAPI — единственный способ для frontend плагина
|
||||
* общаться с core платформы.
|
||||
*
|
||||
* Экземпляр API передаётся плагину при активации через глобальную
|
||||
* переменную `window.__VERSTAK_PLUGIN_API__`.
|
||||
*/
|
||||
export class VerstakPluginAPI {
|
||||
private pluginId: string;
|
||||
private capabilities = new Set<string>();
|
||||
export type PluginCommandArgs = Record<string, unknown>;
|
||||
export type PluginCommandHandler = (
|
||||
args: PluginCommandArgs,
|
||||
declaration: PluginCommandDeclaration
|
||||
) => unknown | Promise<unknown>;
|
||||
export type Unsubscribe = () => void;
|
||||
|
||||
constructor(pluginId: string) {
|
||||
this.pluginId = pluginId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Инициализация API — вызывается core после загрузки frontend bundle.
|
||||
* @internal
|
||||
*/
|
||||
_init(capabilities: string[]): void {
|
||||
this.capabilities = new Set(capabilities);
|
||||
}
|
||||
|
||||
// ─── View Registration ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Зарегистрировать view для отображения в UI Shell.
|
||||
*/
|
||||
registerView(id: string, component: unknown): void {
|
||||
this._postMessage('register.view', { id, component });
|
||||
}
|
||||
|
||||
/**
|
||||
* Зарегистрировать панель настроек плагина.
|
||||
*/
|
||||
registerSettingsPanel(id: string, title: string, component: unknown): void {
|
||||
this._postMessage('register.settingsPanel', { id, title, component });
|
||||
}
|
||||
|
||||
/**
|
||||
* Зарегистрировать команду для command palette.
|
||||
*/
|
||||
registerCommand(id: string, title: string, handler: () => void, keybinding?: string): void {
|
||||
this._postMessage('register.command', { id, title, keybinding, handler: handler.toString() });
|
||||
}
|
||||
|
||||
/**
|
||||
* Зарегистрировать действия для файлов.
|
||||
*/
|
||||
registerFileAction(id: string, label: string, handler: (filePath: string) => void, capability?: string): void {
|
||||
this._postMessage('register.fileAction', { id, label, handler: handler.toString(), capability });
|
||||
}
|
||||
|
||||
/**
|
||||
* Зарегистрировать действия для заметок.
|
||||
*/
|
||||
registerNoteAction(id: string, label: string, handler: (noteId: string) => void, capability?: string): void {
|
||||
this._postMessage('register.noteAction', { id, label, handler: handler.toString(), capability });
|
||||
}
|
||||
|
||||
/**
|
||||
* Зарегистрировать provider поиска.
|
||||
*/
|
||||
registerSearchProvider(id: string, label: string, handler: (query: string) => unknown[]): void {
|
||||
this._postMessage('register.searchProvider', { id, label, handler: handler.toString() });
|
||||
}
|
||||
|
||||
// ─── Capabilities ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Проверить, доступна ли capability.
|
||||
*/
|
||||
hasCapability(name: string): boolean {
|
||||
return this.capabilities.has(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить список всех доступных capabilities.
|
||||
*/
|
||||
getAvailableCapabilities(): string[] {
|
||||
return Array.from(this.capabilities);
|
||||
}
|
||||
|
||||
// ─── Backend Communication ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* Вызвать backend метод плагина через RPC.
|
||||
*/
|
||||
async callBackend(method: string, args: unknown[] = []): Promise<unknown> {
|
||||
return this._rpcCall(method, args);
|
||||
}
|
||||
|
||||
// ─── Settings ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Прочитать настройки плагина.
|
||||
*/
|
||||
async readSettings(): Promise<PluginSettings> {
|
||||
const result = await this._rpcCall('readSettings', []);
|
||||
return result as PluginSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Записать настройки плагина.
|
||||
*/
|
||||
async writeSettings(settings: PluginSettings): Promise<void> {
|
||||
await this._rpcCall('writeSettings', [settings]);
|
||||
}
|
||||
|
||||
// ─── Event Bus ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Подписаться на событие event bus.
|
||||
*/
|
||||
subscribe(event: string, handler: (payload: unknown) => void): void {
|
||||
this._postMessage('subscribe', { event, handler: handler.toString() });
|
||||
}
|
||||
|
||||
/**
|
||||
* Опубликовать событие в event bus.
|
||||
*/
|
||||
publish(event: string, payload: unknown): void {
|
||||
this._postMessage('publish', { event, payload });
|
||||
}
|
||||
|
||||
// ─── Internal ──────────────────────────────────────────────
|
||||
|
||||
private _postMessage(type: string, data: Record<string, unknown>): void {
|
||||
window.dispatchEvent(new CustomEvent('verstak:plugin', {
|
||||
detail: { pluginId: this.pluginId, type, data }
|
||||
}));
|
||||
}
|
||||
|
||||
private async _rpcCall(method: string, args: unknown[]): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const callId = `${this.pluginId}:${Date.now()}:${Math.random()}`;
|
||||
const handler = (event: CustomEvent) => {
|
||||
if (event.detail.callId === callId) {
|
||||
window.removeEventListener('verstak:rpc:response', handler as EventListener);
|
||||
if (event.detail.error) {
|
||||
reject(new Error(event.detail.error));
|
||||
} else {
|
||||
resolve(event.detail.result);
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('verstak:rpc:response', handler as EventListener);
|
||||
this._postMessage('rpc', { callId, method, args });
|
||||
});
|
||||
}
|
||||
export interface PluginCommandDeclaration {
|
||||
status: 'declared';
|
||||
pluginId: string;
|
||||
commandId: string;
|
||||
handler?: string;
|
||||
args?: PluginCommandArgs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Создать экземпляр VerstakPluginAPI.
|
||||
* Core вызывает эту функцию после загрузки frontend bundle,
|
||||
* передавая pluginId и список доступных capabilities.
|
||||
*/
|
||||
export function createPluginAPI(pluginId: string): VerstakPluginAPI {
|
||||
const api = new VerstakPluginAPI(pluginId);
|
||||
return api;
|
||||
export interface PluginCommandResult {
|
||||
status: 'handled';
|
||||
pluginId: string;
|
||||
commandId: string;
|
||||
result: unknown;
|
||||
}
|
||||
|
||||
export interface PluginEvent<TPayload = Record<string, unknown>> {
|
||||
name: string;
|
||||
pluginId: string;
|
||||
payload: TPayload;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface VerstakPluginAPI {
|
||||
readonly pluginId: string;
|
||||
|
||||
settings: {
|
||||
read(): Promise<PluginSettings>;
|
||||
read<T = unknown>(key: string): Promise<T | undefined>;
|
||||
write(key: string, value: unknown): Promise<PluginSettings>;
|
||||
writeAll(settings: PluginSettings): Promise<void>;
|
||||
};
|
||||
|
||||
capabilities: {
|
||||
has(capability: string): Promise<boolean>;
|
||||
get(capability: string): Promise<{ available: boolean; name?: string; pluginId?: string; status?: string }>;
|
||||
list(): Promise<CapabilityEntry[]>;
|
||||
};
|
||||
|
||||
commands: {
|
||||
register(commandId: string, handler: PluginCommandHandler): Promise<Unsubscribe>;
|
||||
execute(commandId: string, args?: PluginCommandArgs): Promise<PluginCommandResult>;
|
||||
};
|
||||
|
||||
events: {
|
||||
publish(eventName: string, payload?: Record<string, unknown>): Promise<void>;
|
||||
subscribe<TPayload = Record<string, unknown>>(
|
||||
eventName: string,
|
||||
handler: (event: PluginEvent<TPayload>) => void
|
||||
): Promise<Unsubscribe>;
|
||||
};
|
||||
|
||||
files: {
|
||||
/**
|
||||
* Files API uses canonical vault-relative slash paths. Backslashes,
|
||||
* Windows/UNC absolute paths, traversal, null bytes, `.verstak` variants,
|
||||
* and symlink read/write/move/trash operations are rejected by the host.
|
||||
*/
|
||||
list(relativeDir?: string): Promise<FileEntry[]>;
|
||||
metadata(relativePath: string): Promise<FileMetadata>;
|
||||
readText(relativePath: string): Promise<string>;
|
||||
writeText(relativePath: string, content: string, options?: WriteTextOptions): Promise<void>;
|
||||
createFolder(relativePath: string): Promise<void>;
|
||||
move(fromRelativePath: string, toRelativePath: string, options?: MovePathOptions): Promise<void>;
|
||||
trash(relativePath: string): Promise<TrashResult>;
|
||||
};
|
||||
|
||||
workbench: {
|
||||
openResource(request: OpenResourceRequest): Promise<OpenResourceResult>;
|
||||
editResource(request: OpenResourceRequest): Promise<OpenResourceResult>;
|
||||
};
|
||||
|
||||
dispose?: () => void;
|
||||
}
|
||||
|
||||
export function createPluginAPI(_pluginId: string): VerstakPluginAPI {
|
||||
throw new Error('createPluginAPI is provided by Verstak Desktop at plugin runtime');
|
||||
}
|
||||
|
||||
+181
-16
@@ -1,6 +1,7 @@
|
||||
// Verstak Plugin SDK — Test Utilities
|
||||
|
||||
import type { PluginManifest, PluginState } from './types';
|
||||
import type { VerstakPluginAPI } from './plugin-api';
|
||||
|
||||
/**
|
||||
* Создать тестовый manifest для unit-тестов.
|
||||
@@ -39,23 +40,187 @@ export function createTestPluginState(overrides?: Partial<PluginState>): PluginS
|
||||
/**
|
||||
* Создать заглушку VerstakPluginAPI для тестов.
|
||||
*/
|
||||
export function createMockPluginAPI(): {
|
||||
registerView: ReturnType<typeof vi.fn>;
|
||||
registerCommand: ReturnType<typeof vi.fn>;
|
||||
registerSettingsPanel: ReturnType<typeof vi.fn>;
|
||||
hasCapability: ReturnType<typeof vi.fn>;
|
||||
callBackend: ReturnType<typeof vi.fn>;
|
||||
subscribe: ReturnType<typeof vi.fn>;
|
||||
publish: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
export function createMockPluginAPI(pluginId = 'test.plugin'): VerstakPluginAPI {
|
||||
const settings: Record<string, unknown> = {};
|
||||
const commands = new Map<string, (args: Record<string, unknown>) => unknown>();
|
||||
const eventHandlers = new Map<string, Array<(event: any) => void>>();
|
||||
const files = new Map<string, { type: 'file' | 'folder'; content?: string; modifiedAt: string }>();
|
||||
files.set('', { type: 'folder', modifiedAt: new Date().toISOString() });
|
||||
|
||||
function normalizePath(path: string, allowRoot = false): string {
|
||||
const raw = String(path || '');
|
||||
if (raw.includes('\0')) throw new Error('invalid-path: null-byte');
|
||||
if (raw.includes('\\')) throw new Error('invalid-path: backslash not allowed');
|
||||
const normalized = raw.replace(/^\.\//, '');
|
||||
const parts = normalized.split('/').filter(Boolean);
|
||||
if (!allowRoot && parts.length === 0) throw new Error('invalid-path: empty path');
|
||||
if (normalized.startsWith('/') || /^[A-Za-z]:/.test(normalized)) throw new Error('invalid-path: absolute path rejected');
|
||||
if (parts.includes('..')) throw new Error('invalid-path: path-traversal');
|
||||
if (parts[0] && parts[0].toLowerCase() === '.verstak') throw new Error('reserved-path: .verstak is internal');
|
||||
return parts.join('/');
|
||||
}
|
||||
|
||||
function parentPath(path: string): string {
|
||||
const idx = path.lastIndexOf('/');
|
||||
return idx === -1 ? '' : path.slice(0, idx);
|
||||
}
|
||||
|
||||
function baseName(path: string): string {
|
||||
const idx = path.lastIndexOf('/');
|
||||
return idx === -1 ? path : path.slice(idx + 1);
|
||||
}
|
||||
|
||||
function entry(path: string, node: { type: 'file' | 'folder'; content?: string; modifiedAt: string }) {
|
||||
const name = baseName(path);
|
||||
const dot = name.lastIndexOf('.');
|
||||
const extension = dot > 0 ? name.slice(dot + 1) : '';
|
||||
return {
|
||||
name,
|
||||
relativePath: path,
|
||||
type: node.type,
|
||||
size: node.type === 'file' ? (node.content || '').length : 0,
|
||||
modifiedAt: node.modifiedAt,
|
||||
extension,
|
||||
isHidden: name.startsWith('.'),
|
||||
isReserved: false,
|
||||
canRead: true,
|
||||
canWrite: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
registerView: vi.fn(),
|
||||
registerCommand: vi.fn(),
|
||||
registerSettingsPanel: vi.fn(),
|
||||
hasCapability: vi.fn().mockReturnValue(false),
|
||||
callBackend: vi.fn().mockResolvedValue(undefined),
|
||||
subscribe: vi.fn(),
|
||||
publish: vi.fn(),
|
||||
pluginId,
|
||||
settings: {
|
||||
read: vi.fn(async (key?: string) => key ? settings[key] : { ...settings }) as VerstakPluginAPI['settings']['read'],
|
||||
write: vi.fn(async (key: string, value: unknown) => {
|
||||
settings[key] = value;
|
||||
return { ...settings };
|
||||
}),
|
||||
writeAll: vi.fn(async (nextSettings: Record<string, unknown>) => {
|
||||
Object.keys(settings).forEach((key) => delete settings[key]);
|
||||
Object.assign(settings, nextSettings);
|
||||
}),
|
||||
},
|
||||
capabilities: {
|
||||
has: vi.fn(async () => false),
|
||||
get: vi.fn(async (name: string) => ({ available: false, name })),
|
||||
list: vi.fn(async () => []),
|
||||
},
|
||||
commands: {
|
||||
register: vi.fn(async (commandId: string, handler: (args: Record<string, unknown>) => unknown) => {
|
||||
commands.set(commandId, handler);
|
||||
return () => { commands.delete(commandId); };
|
||||
}),
|
||||
execute: vi.fn(async (commandId: string, args: Record<string, unknown> = {}) => {
|
||||
const handler = commands.get(commandId);
|
||||
if (!handler) {
|
||||
throw new Error(`declared-but-unhandled: ${commandId}`);
|
||||
}
|
||||
return { status: 'handled' as const, pluginId, commandId, result: await handler(args) };
|
||||
}),
|
||||
},
|
||||
events: {
|
||||
publish: vi.fn(async (eventName: string, payload: Record<string, unknown> = {}) => {
|
||||
const event = { name: eventName, pluginId, payload, timestamp: new Date().toISOString() };
|
||||
(eventHandlers.get(eventName) || []).slice().forEach((handler) => handler(event));
|
||||
}),
|
||||
subscribe: vi.fn(async (eventName: string, handler: (event: any) => void) => {
|
||||
const handlers = eventHandlers.get(eventName) || [];
|
||||
handlers.push(handler);
|
||||
eventHandlers.set(eventName, handlers);
|
||||
return () => {
|
||||
eventHandlers.set(eventName, (eventHandlers.get(eventName) || []).filter((item) => item !== handler));
|
||||
};
|
||||
}),
|
||||
},
|
||||
files: {
|
||||
list: vi.fn(async (relativeDir = '') => {
|
||||
const dir = normalizePath(relativeDir, true);
|
||||
const node = files.get(dir);
|
||||
if (!node || node.type !== 'folder') throw new Error(`not-found: ${dir}`);
|
||||
const prefix = dir ? `${dir}/` : '';
|
||||
return Array.from(files.entries())
|
||||
.filter(([path]) => path !== dir && path.startsWith(prefix) && !path.slice(prefix.length).includes('/'))
|
||||
.map(([path, node]) => entry(path, node));
|
||||
}),
|
||||
metadata: vi.fn(async (relativePath: string) => {
|
||||
const path = normalizePath(relativePath);
|
||||
const node = files.get(path);
|
||||
if (!node) throw new Error(`not-found: ${path}`);
|
||||
return { ...entry(path, node), mimeHint: '', isText: node.type === 'file' };
|
||||
}),
|
||||
readText: vi.fn(async (relativePath: string) => {
|
||||
const path = normalizePath(relativePath);
|
||||
const node = files.get(path);
|
||||
if (!node) throw new Error(`not-found: ${path}`);
|
||||
if (node.type !== 'file') throw new Error(`not-regular-file: ${path}`);
|
||||
return node.content || '';
|
||||
}),
|
||||
writeText: vi.fn(async (relativePath: string, content: string, options = {}) => {
|
||||
const path = normalizePath(relativePath);
|
||||
const node = files.get(path);
|
||||
if (node && node.type !== 'file') throw new Error(`not-regular-file: ${path}`);
|
||||
if (node && !options.overwrite) throw new Error(`conflict: ${path}`);
|
||||
if (!node && !options.createIfMissing) throw new Error(`not-found: ${path}`);
|
||||
const parent = parentPath(path);
|
||||
if (!files.get(parent) || files.get(parent)?.type !== 'folder') throw new Error(`parent-not-found: ${parent}`);
|
||||
files.set(path, { type: 'file', content, modifiedAt: new Date().toISOString() });
|
||||
}),
|
||||
createFolder: vi.fn(async (relativePath: string) => {
|
||||
const path = normalizePath(relativePath);
|
||||
if (files.has(path)) throw new Error(`conflict: ${path}`);
|
||||
const parent = parentPath(path);
|
||||
if (!files.get(parent) || files.get(parent)?.type !== 'folder') throw new Error(`parent-not-found: ${parent}`);
|
||||
files.set(path, { type: 'folder', modifiedAt: new Date().toISOString() });
|
||||
}),
|
||||
move: vi.fn(async (fromRelativePath: string, toRelativePath: string, options = {}) => {
|
||||
const from = normalizePath(fromRelativePath);
|
||||
const to = normalizePath(toRelativePath);
|
||||
const node = files.get(from);
|
||||
if (!node) throw new Error(`not-found: ${from}`);
|
||||
if (node.type === 'folder' && (to === from || to.startsWith(`${from}/`))) {
|
||||
throw new Error(`move-into-self: ${from} -> ${to}`);
|
||||
}
|
||||
if (files.has(to) && !options.overwrite) throw new Error(`conflict: ${to}`);
|
||||
const parent = parentPath(to);
|
||||
if (!files.get(parent) || files.get(parent)?.type !== 'folder') throw new Error(`parent-not-found: ${parent}`);
|
||||
const moving = Array.from(files.entries()).filter(([path]) => path === from || path.startsWith(`${from}/`));
|
||||
moving.forEach(([path, movingNode]) => {
|
||||
const suffix = path.slice(from.length);
|
||||
files.set(`${to}${suffix}`, movingNode);
|
||||
files.delete(path);
|
||||
});
|
||||
}),
|
||||
trash: vi.fn(async (relativePath: string) => {
|
||||
const path = normalizePath(relativePath);
|
||||
if (!files.has(path)) throw new Error(`not-found: ${path}`);
|
||||
files.delete(path);
|
||||
const trashId = `mock-${Date.now()}`;
|
||||
return {
|
||||
originalPath: path,
|
||||
trashPath: `.verstak/trash/files/${trashId}/${baseName(path)}`,
|
||||
trashId,
|
||||
deletedAt: new Date().toISOString(),
|
||||
};
|
||||
}),
|
||||
},
|
||||
workbench: {
|
||||
openResource: vi.fn(async (request) => ({
|
||||
status: 'opened' as const,
|
||||
providerId: request.context?.notesMode ? 'mock.notes-markdown-provider' : 'mock.open-provider',
|
||||
providerPluginId: 'mock.editor',
|
||||
providerComponent: 'MockEditor',
|
||||
request: { ...request, mode: request.mode || 'view' },
|
||||
})),
|
||||
editResource: vi.fn(async (request) => ({
|
||||
status: 'opened' as const,
|
||||
providerId: request.context?.notesMode ? 'mock.notes-markdown-provider' : 'mock.open-provider',
|
||||
providerPluginId: 'mock.editor',
|
||||
providerComponent: 'MockEditor',
|
||||
request: { ...request, mode: 'edit' as const },
|
||||
})),
|
||||
},
|
||||
dispose: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+103
-1
@@ -55,7 +55,8 @@ export type CapabilityName = string;
|
||||
|
||||
export interface CapabilityEntry {
|
||||
name: CapabilityName;
|
||||
description: string;
|
||||
description?: string;
|
||||
pluginId: string;
|
||||
status: 'stable' | 'draft' | 'deprecated';
|
||||
}
|
||||
|
||||
@@ -65,6 +66,10 @@ export type Permission =
|
||||
| 'vault.read'
|
||||
| 'vault.write'
|
||||
| 'vault.watch'
|
||||
| 'files.read'
|
||||
| 'files.write'
|
||||
| 'files.delete'
|
||||
| 'workbench.open'
|
||||
| 'storage.namespace'
|
||||
| 'storage.migrations'
|
||||
| 'events.publish'
|
||||
@@ -84,6 +89,57 @@ export interface PermissionEntry {
|
||||
dangerous: boolean;
|
||||
}
|
||||
|
||||
// ─── Files API ──────────────────────────────────────────────
|
||||
|
||||
export type FileEntryType = 'file' | 'folder' | 'symlink' | 'unknown';
|
||||
|
||||
export interface FileEntry {
|
||||
name: string;
|
||||
relativePath: string;
|
||||
type: FileEntryType;
|
||||
size: number;
|
||||
modifiedAt: string;
|
||||
extension: string;
|
||||
isHidden: boolean;
|
||||
isReserved: boolean;
|
||||
canRead: boolean;
|
||||
canWrite: boolean;
|
||||
}
|
||||
|
||||
export interface FileMetadata {
|
||||
relativePath: string;
|
||||
type: FileEntryType;
|
||||
size: number;
|
||||
modifiedAt: string;
|
||||
createdAt?: string;
|
||||
extension: string;
|
||||
mimeHint: string;
|
||||
isText: boolean;
|
||||
isHidden: boolean;
|
||||
isReserved: boolean;
|
||||
canRead: boolean;
|
||||
canWrite: boolean;
|
||||
}
|
||||
|
||||
export interface WriteTextOptions {
|
||||
/** Create the file when it is missing. Parent folder must already exist. */
|
||||
createIfMissing?: boolean;
|
||||
/** Replace an existing regular file. Existing folders/symlinks are rejected. */
|
||||
overwrite?: boolean;
|
||||
}
|
||||
|
||||
export interface MovePathOptions {
|
||||
/** Replace an existing target path when the host supports it. */
|
||||
overwrite?: boolean;
|
||||
}
|
||||
|
||||
export interface TrashResult {
|
||||
originalPath: string;
|
||||
trashPath: string;
|
||||
trashId: string;
|
||||
deletedAt: string;
|
||||
}
|
||||
|
||||
// ─── Contribution Points ─────────────────────────────────────
|
||||
|
||||
export interface ContributionPoints {
|
||||
@@ -97,6 +153,7 @@ export interface ContributionPoints {
|
||||
searchProviders?: ContributionSearchProvider[];
|
||||
activityProviders?: ContributionActivityProvider[];
|
||||
statusBarItems?: ContributionStatusBarItem[];
|
||||
openProviders?: ContributionOpenProvider[];
|
||||
}
|
||||
|
||||
export interface ContributionView {
|
||||
@@ -165,6 +222,51 @@ export interface ContributionStatusBarItem {
|
||||
handler?: string;
|
||||
}
|
||||
|
||||
export type OpenResourceKind = 'vault-file';
|
||||
export type OpenResourceMode = 'view' | 'edit';
|
||||
export type OpenResourceContextName = 'generic-text' | 'generic-markdown' | 'notes-markdown' | string;
|
||||
|
||||
export interface OpenProviderSupport {
|
||||
kind: OpenResourceKind;
|
||||
extensions?: string[];
|
||||
mime?: string[];
|
||||
contexts?: OpenResourceContextName[];
|
||||
}
|
||||
|
||||
export interface ContributionOpenProvider {
|
||||
id: string;
|
||||
title: string;
|
||||
priority?: number;
|
||||
component: string;
|
||||
supports: OpenProviderSupport[];
|
||||
}
|
||||
|
||||
export interface OpenResourceContext {
|
||||
sourcePluginId?: string;
|
||||
sourceView?: 'files' | 'notes' | string;
|
||||
isInsideNotesFolder?: boolean;
|
||||
notesScopePath?: string;
|
||||
notesMode?: boolean;
|
||||
}
|
||||
|
||||
export interface OpenResourceRequest {
|
||||
kind: OpenResourceKind;
|
||||
path: string;
|
||||
mode?: OpenResourceMode;
|
||||
mime?: string;
|
||||
extension?: string;
|
||||
context?: OpenResourceContext;
|
||||
}
|
||||
|
||||
export interface OpenResourceResult {
|
||||
status: 'opened' | 'no-provider';
|
||||
providerId?: string;
|
||||
providerPluginId?: string;
|
||||
providerComponent?: string;
|
||||
request: OpenResourceRequest;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// ─── Plugin State ────────────────────────────────────────────
|
||||
|
||||
export type PluginStatus =
|
||||
|
||||
Reference in New Issue
Block a user