Add command palette host
This commit is contained in:
parent
1fb9db73ec
commit
7630a31286
|
|
@ -189,7 +189,7 @@ Icon fields use shell icon names rendered through the bundled Lucide SVG wrapper
|
||||||
| Боковая панель | `sidebarItems` | Элементы в sidebar слева | ✅ Sidebar.svelte (из ContributionRegistry) |
|
| Боковая панель | `sidebarItems` | Элементы в sidebar слева | ✅ Sidebar.svelte (из ContributionRegistry) |
|
||||||
| Основные панели | `views` | Полноценные страницы/панели | ✅ ViewContainer.svelte (PluginBundleHost — real frontend bundle) |
|
| Основные панели | `views` | Полноценные страницы/панели | ✅ ViewContainer.svelte (PluginBundleHost — real frontend bundle) |
|
||||||
| Панели настроек | `settingsPanels` | Панели в Plugin Manager | ✅ PluginManager.svelte (кнопка Settings, открывает modal) |
|
| Панели настроек | `settingsPanels` | Панели в Plugin Manager | ✅ PluginManager.svelte (кнопка Settings, открывает modal) |
|
||||||
| Команды | `commands` | Команды для command palette | ✅ ContributionRegistry (UI command palette не реализован) |
|
| Команды | `commands` | Команды для command palette | ✅ ContributionRegistry + CommandPalette UI |
|
||||||
| Open/edit providers | `openProviders` | Провайдеры viewer/editor для Workbench routing | ✅ ContributionRegistry + минимальный Workbench host |
|
| Open/edit providers | `openProviders` | Провайдеры viewer/editor для Workbench routing | ✅ ContributionRegistry + минимальный Workbench host |
|
||||||
|
|
||||||
### Планируемые contribution points
|
### Планируемые contribution points
|
||||||
|
|
@ -388,6 +388,10 @@ contributions summary.
|
||||||
понятную ошибку `declared-but-unhandled`.
|
понятную ошибку `declared-but-unhandled`.
|
||||||
- Handler registry очищается при component unmount, reload/disable flow и
|
- Handler registry очищается при component unmount, reload/disable flow и
|
||||||
`api.dispose()`.
|
`api.dispose()`.
|
||||||
|
- Shell command palette открывается через `Ctrl+K` / `Cmd+K` или
|
||||||
|
`Ctrl+Shift+P` / `Cmd+Shift+P`, показывает commands enabled plugins,
|
||||||
|
фильтрует по title/id/plugin и вызывает зарегистрированные bundled frontend
|
||||||
|
handlers.
|
||||||
|
|
||||||
`events`
|
`events`
|
||||||
|
|
||||||
|
|
@ -487,6 +491,7 @@ bundled runtime. Это реальный runtime contract для cooperative bun
|
||||||
| `api.capabilities.has(id)` | ✅ Работает | Boolean wrapper над `get` |
|
| `api.capabilities.has(id)` | ✅ Работает | Boolean wrapper над `get` |
|
||||||
| `api.commands.register(id, handler)` | ✅ Работает | Регистрирует bundled frontend handler для объявленной command |
|
| `api.commands.register(id, handler)` | ✅ Работает | Регистрирует bundled frontend handler для объявленной command |
|
||||||
| `api.commands.execute(id, args)` | ✅ Работает | Валидирует declaration/permission/backend state и вызывает bundled handler |
|
| `api.commands.execute(id, args)` | ✅ Работает | Валидирует declaration/permission/backend state и вызывает bundled handler |
|
||||||
|
| Command Palette UI | ✅ Работает | `Ctrl/Cmd+K`, фильтр enabled plugin commands, вызов registered frontend handlers |
|
||||||
| `api.events.publish(type, payload)` | ✅ Работает | Валидирует permission и публикует во frontend event bus |
|
| `api.events.publish(type, payload)` | ✅ Работает | Валидирует permission и публикует во frontend event bus |
|
||||||
| `api.events.subscribe(type, handler)` | ✅ Работает | Валидирует permission и подписывает handler на frontend event bus |
|
| `api.events.subscribe(type, handler)` | ✅ Работает | Валидирует permission и подписывает handler на frontend event bus |
|
||||||
| `api.files.list(relativeDir)` | ✅ Работает | Список vault-relative директории, `.verstak` скрыта |
|
| `api.files.list(relativeDir)` | ✅ Работает | Список vault-relative директории, `.verstak` скрыта |
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
|
||||||
|
|
||||||
|
test.describe('Command Palette', () => {
|
||||||
|
let consoleCollector;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
consoleCollector = setupConsoleCollector(page);
|
||||||
|
await resetMockState(page);
|
||||||
|
await page.goto('/');
|
||||||
|
await waitForAppReady(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async () => {
|
||||||
|
consoleCollector.assertNoErrors();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('opens with keyboard, filters commands, and executes registered frontend handler', async ({ page }) => {
|
||||||
|
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' }).click();
|
||||||
|
await expect(page.locator('.pt-root')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+K' : 'Control+K');
|
||||||
|
|
||||||
|
const palette = page.locator('.command-palette');
|
||||||
|
await expect(palette).toBeVisible();
|
||||||
|
await expect(palette.locator('[data-command-id="verstak.platform-test.show-version"]')).toBeVisible();
|
||||||
|
|
||||||
|
await palette.locator('[data-command-palette-input]').fill('version');
|
||||||
|
await expect(palette.locator('[data-command-id="verstak.platform-test.show-version"]')).toBeVisible();
|
||||||
|
await expect(palette.locator('[data-command-id="verstak.platform-test.run-tests"]')).not.toBeVisible();
|
||||||
|
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
|
||||||
|
await expect(palette).not.toBeVisible();
|
||||||
|
await expect(page.locator('[data-command-palette-status="success"]')).toContainText('Show Version Info');
|
||||||
|
await expect(page.locator('[data-command-palette-status="success"]')).toContainText('handled');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Escape closes the palette without changing current view', async ({ page }) => {
|
||||||
|
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+K' : 'Control+K');
|
||||||
|
await expect(page.locator('.command-palette')).toBeVisible();
|
||||||
|
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
|
await expect(page.locator('.command-palette')).not.toBeVisible();
|
||||||
|
await expect(page.locator('.plugin-manager')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<script>
|
<script>
|
||||||
import PluginManager from './lib/plugin-manager/PluginManager.svelte';
|
import PluginManager from './lib/plugin-manager/PluginManager.svelte';
|
||||||
import Sidebar from './lib/shell/Sidebar.svelte';
|
import Sidebar from './lib/shell/Sidebar.svelte';
|
||||||
|
import CommandPalette from './lib/shell/CommandPalette.svelte';
|
||||||
import ViewContainer from './lib/shell/ViewContainer.svelte';
|
import ViewContainer from './lib/shell/ViewContainer.svelte';
|
||||||
import VaultSelection from './lib/shell/VaultSelection.svelte';
|
import VaultSelection from './lib/shell/VaultSelection.svelte';
|
||||||
import WorkbenchHost from './lib/shell/WorkbenchHost.svelte';
|
import WorkbenchHost from './lib/shell/WorkbenchHost.svelte';
|
||||||
|
|
@ -292,6 +293,7 @@
|
||||||
{:else}
|
{:else}
|
||||||
<main>
|
<main>
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
|
<CommandPalette />
|
||||||
|
|
||||||
<section class="content scroll-surface">
|
<section class="content scroll-surface">
|
||||||
{#if currentView === 'plugin-manager'}
|
{#if currentView === 'plugin-manager'}
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,29 @@ function commandKey(pluginId, commandId) {
|
||||||
return pluginId + ':' + commandId;
|
return pluginId + ':' + commandId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function executePluginCommand(pluginId, cmdId, args) {
|
||||||
|
if (!pluginId) {
|
||||||
|
throw new Error('executePluginCommand requires pluginId');
|
||||||
|
}
|
||||||
|
if (!cmdId) {
|
||||||
|
throw new Error('executePluginCommand requires command id');
|
||||||
|
}
|
||||||
|
const declared = await callBackend(pluginId, 'commands.execute(' + cmdId + ')', function() {
|
||||||
|
return App.ExecutePluginCommand(pluginId, cmdId, args || {});
|
||||||
|
});
|
||||||
|
const handler = window.__VERSTAK_COMMAND_HANDLERS__[commandKey(pluginId, cmdId)];
|
||||||
|
if (!handler) {
|
||||||
|
throw new Error('[plugin:' + pluginId + '] commands.execute(' + cmdId + ') failed: declared-but-unhandled');
|
||||||
|
}
|
||||||
|
const result = await handler(args || {}, declared);
|
||||||
|
return {
|
||||||
|
status: 'handled',
|
||||||
|
pluginId: pluginId,
|
||||||
|
commandId: cmdId,
|
||||||
|
result: result
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function createPluginAPI(pluginId) {
|
export function createPluginAPI(pluginId) {
|
||||||
if (!pluginId) {
|
if (!pluginId) {
|
||||||
throw new Error('createPluginAPI requires pluginId');
|
throw new Error('createPluginAPI requires pluginId');
|
||||||
|
|
@ -306,20 +329,7 @@ export function createPluginAPI(pluginId) {
|
||||||
},
|
},
|
||||||
execute: async function(cmdId, args) {
|
execute: async function(cmdId, args) {
|
||||||
assertActive('commands.execute(' + cmdId + ')');
|
assertActive('commands.execute(' + cmdId + ')');
|
||||||
const declared = await callBackend(pluginId, 'commands.execute(' + cmdId + ')', function() {
|
return executePluginCommand(pluginId, cmdId, args || {});
|
||||||
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
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,293 @@
|
||||||
|
<script>
|
||||||
|
import { onDestroy, tick } from 'svelte';
|
||||||
|
import * as App from '../../../wailsjs/go/api/App';
|
||||||
|
import { executePluginCommand } from '../plugin-host/VerstakPluginAPI.js';
|
||||||
|
|
||||||
|
let open = false;
|
||||||
|
let query = '';
|
||||||
|
let commands = [];
|
||||||
|
let selectedIndex = 0;
|
||||||
|
let inputEl = null;
|
||||||
|
let statusMessage = '';
|
||||||
|
let statusType = '';
|
||||||
|
|
||||||
|
const inactiveStatuses = new Set(['disabled', 'failed', 'incompatible', 'missing-required-capability']);
|
||||||
|
|
||||||
|
$: normalizedQuery = query.trim().toLowerCase();
|
||||||
|
$: filteredCommands = commands.filter((command) => {
|
||||||
|
if (!normalizedQuery) return true;
|
||||||
|
return [
|
||||||
|
command.title,
|
||||||
|
command.id,
|
||||||
|
command.pluginId,
|
||||||
|
command.pluginName,
|
||||||
|
].filter(Boolean).join(' ').toLowerCase().includes(normalizedQuery);
|
||||||
|
});
|
||||||
|
$: if (selectedIndex >= filteredCommands.length) {
|
||||||
|
selectedIndex = Math.max(0, filteredCommands.length - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCommands() {
|
||||||
|
const [plugins, contributions] = await Promise.all([
|
||||||
|
App.GetPlugins().catch(() => []),
|
||||||
|
App.GetContributions().catch(() => ({})),
|
||||||
|
]);
|
||||||
|
const pluginById = new Map((plugins || []).map((plugin) => [plugin.manifest?.id, plugin]));
|
||||||
|
commands = (contributions.commands || [])
|
||||||
|
.filter((command) => {
|
||||||
|
const plugin = pluginById.get(command.pluginId);
|
||||||
|
if (!plugin) return false;
|
||||||
|
return !inactiveStatuses.has(plugin.status);
|
||||||
|
})
|
||||||
|
.map((command) => {
|
||||||
|
const plugin = pluginById.get(command.pluginId);
|
||||||
|
return {
|
||||||
|
...command,
|
||||||
|
pluginName: plugin?.manifest?.name || command.pluginId,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
const title = String(a.title || a.id).localeCompare(String(b.title || b.id));
|
||||||
|
if (title) return title;
|
||||||
|
return String(a.pluginId).localeCompare(String(b.pluginId));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openPalette() {
|
||||||
|
await loadCommands();
|
||||||
|
query = '';
|
||||||
|
selectedIndex = 0;
|
||||||
|
open = true;
|
||||||
|
await tick();
|
||||||
|
inputEl?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePalette() {
|
||||||
|
open = false;
|
||||||
|
query = '';
|
||||||
|
selectedIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(type, message) {
|
||||||
|
statusType = type;
|
||||||
|
statusMessage = message;
|
||||||
|
window.clearTimeout(setStatus.timer);
|
||||||
|
setStatus.timer = window.setTimeout(() => {
|
||||||
|
statusType = '';
|
||||||
|
statusMessage = '';
|
||||||
|
}, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runCommand(command) {
|
||||||
|
if (!command) return;
|
||||||
|
try {
|
||||||
|
const result = await executePluginCommand(command.pluginId, command.id, {
|
||||||
|
source: 'command-palette',
|
||||||
|
});
|
||||||
|
closePalette();
|
||||||
|
setStatus('success', `${command.title || command.id} ${result.status || 'handled'}`);
|
||||||
|
} catch (err) {
|
||||||
|
setStatus('error', `${command.title || command.id}: ${err?.message || String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveSelection(delta) {
|
||||||
|
if (filteredCommands.length === 0) return;
|
||||||
|
selectedIndex = (selectedIndex + delta + filteredCommands.length) % filteredCommands.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWindowKeydown(event) {
|
||||||
|
const key = event.key || '';
|
||||||
|
const comboOpen = (event.ctrlKey || event.metaKey) && (key.toLowerCase() === 'k' || (event.shiftKey && key.toLowerCase() === 'p'));
|
||||||
|
if (!open && comboOpen) {
|
||||||
|
event.preventDefault();
|
||||||
|
openPalette();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
if (key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
closePalette();
|
||||||
|
} else if (key === 'ArrowDown') {
|
||||||
|
event.preventDefault();
|
||||||
|
moveSelection(1);
|
||||||
|
} else if (key === 'ArrowUp') {
|
||||||
|
event.preventDefault();
|
||||||
|
moveSelection(-1);
|
||||||
|
} else if (key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
runCommand(filteredCommands[selectedIndex]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onOverlayMouseDown(event) {
|
||||||
|
if (event.target === event.currentTarget) closePalette();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.addEventListener('keydown', onWindowKeydown);
|
||||||
|
}
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.removeEventListener('keydown', onWindowKeydown);
|
||||||
|
window.clearTimeout(setStatus.timer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if statusMessage}
|
||||||
|
<div
|
||||||
|
class="command-palette-toast"
|
||||||
|
data-command-palette-status={statusType}
|
||||||
|
>
|
||||||
|
{statusMessage}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<div class="command-palette-overlay" role="presentation" on:mousedown={onOverlayMouseDown}>
|
||||||
|
<section class="command-palette" role="dialog" aria-modal="true" aria-label="Command Palette">
|
||||||
|
<input
|
||||||
|
bind:this={inputEl}
|
||||||
|
bind:value={query}
|
||||||
|
class="command-palette-input"
|
||||||
|
data-command-palette-input
|
||||||
|
placeholder="Run command"
|
||||||
|
aria-label="Run command"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="command-palette-list" role="listbox">
|
||||||
|
{#if filteredCommands.length === 0}
|
||||||
|
<div class="command-palette-empty">No commands</div>
|
||||||
|
{:else}
|
||||||
|
{#each filteredCommands as command, index}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class:selected={index === selectedIndex}
|
||||||
|
class="command-palette-item"
|
||||||
|
data-command-id={command.id}
|
||||||
|
on:mouseenter={() => selectedIndex = index}
|
||||||
|
on:click={() => runCommand(command)}
|
||||||
|
>
|
||||||
|
<span class="command-palette-title">{command.title || command.id}</span>
|
||||||
|
<span class="command-palette-meta">{command.pluginName} · {command.id}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.command-palette-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 10000;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 10vh 1rem 1rem;
|
||||||
|
background: rgba(0, 0, 0, 0.48);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette {
|
||||||
|
width: min(640px, 100%);
|
||||||
|
max-height: min(560px, 80vh);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #254466;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #101526;
|
||||||
|
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-input {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 3rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid #213650;
|
||||||
|
background: #0b1020;
|
||||||
|
color: #f4f7fb;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-list {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-item {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 3rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.18rem;
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: #e0e0f0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-item:hover,
|
||||||
|
.command-palette-item.selected {
|
||||||
|
border-color: #4ecca3;
|
||||||
|
background: #17243a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-title {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-meta {
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #8da2bd;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-empty {
|
||||||
|
padding: 1.25rem;
|
||||||
|
color: #8da2bd;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-toast {
|
||||||
|
position: fixed;
|
||||||
|
right: 1rem;
|
||||||
|
bottom: 1rem;
|
||||||
|
z-index: 10001;
|
||||||
|
max-width: min(420px, calc(100vw - 2rem));
|
||||||
|
padding: 0.7rem 0.9rem;
|
||||||
|
border: 1px solid #254466;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #101526;
|
||||||
|
color: #e0e0f0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-toast[data-command-palette-status="success"] {
|
||||||
|
border-color: #4ecca3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-toast[data-command-palette-status="error"] {
|
||||||
|
border-color: #e74c3c;
|
||||||
|
color: #ffd6d1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Loading…
Reference in New Issue