Add command palette host

This commit is contained in:
mirivlad 2026-06-27 13:13:15 +08:00
parent 1fb9db73ec
commit 7630a31286
5 changed files with 373 additions and 15 deletions

View File

@ -189,7 +189,7 @@ Icon fields use shell icon names rendered through the bundled Lucide SVG wrapper
| Боковая панель | `sidebarItems` | Элементы в sidebar слева | ✅ Sidebar.svelte (из ContributionRegistry) |
| Основные панели | `views` | Полноценные страницы/панели | ✅ ViewContainer.svelte (PluginBundleHost — real frontend bundle) |
| Панели настроек | `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 |
### Планируемые contribution points
@ -388,6 +388,10 @@ contributions summary.
понятную ошибку `declared-but-unhandled`.
- Handler registry очищается при component unmount, reload/disable flow и
`api.dispose()`.
- Shell command palette открывается через `Ctrl+K` / `Cmd+K` или
`Ctrl+Shift+P` / `Cmd+Shift+P`, показывает commands enabled plugins,
фильтрует по title/id/plugin и вызывает зарегистрированные bundled frontend
handlers.
`events`
@ -487,6 +491,7 @@ bundled runtime. Это реальный runtime contract для cooperative bun
| `api.capabilities.has(id)` | ✅ Работает | Boolean wrapper над `get` |
| `api.commands.register(id, handler)` | ✅ Работает | Регистрирует bundled frontend handler для объявленной command |
| `api.commands.execute(id, args)` | ✅ Работает | Валидирует declaration/permission/backend state и вызывает bundled handler |
| Command Palette UI | ✅ Работает | `Ctrl/Cmd+K`, фильтр enabled plugin commands, вызов registered frontend handlers |
| `api.events.publish(type, payload)` | ✅ Работает | Валидирует permission и публикует во frontend event bus |
| `api.events.subscribe(type, handler)` | ✅ Работает | Валидирует permission и подписывает handler на frontend event bus |
| `api.files.list(relativeDir)` | ✅ Работает | Список vault-relative директории, `.verstak` скрыта |

View File

@ -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();
});
});

View File

@ -1,6 +1,7 @@
<script>
import PluginManager from './lib/plugin-manager/PluginManager.svelte';
import Sidebar from './lib/shell/Sidebar.svelte';
import CommandPalette from './lib/shell/CommandPalette.svelte';
import ViewContainer from './lib/shell/ViewContainer.svelte';
import VaultSelection from './lib/shell/VaultSelection.svelte';
import WorkbenchHost from './lib/shell/WorkbenchHost.svelte';
@ -292,6 +293,7 @@
{:else}
<main>
<Sidebar />
<CommandPalette />
<section class="content scroll-surface">
{#if currentView === 'plugin-manager'}

View File

@ -75,6 +75,29 @@ function commandKey(pluginId, commandId) {
return pluginId + ':' + commandId;
}
export async function executePluginCommand(pluginId, cmdId, args) {
if (!pluginId) {
throw new Error('executePluginCommand requires pluginId');
}
if (!cmdId) {
throw new Error('executePluginCommand requires command id');
}
const declared = await callBackend(pluginId, 'commands.execute(' + cmdId + ')', function() {
return App.ExecutePluginCommand(pluginId, cmdId, args || {});
});
const handler = window.__VERSTAK_COMMAND_HANDLERS__[commandKey(pluginId, cmdId)];
if (!handler) {
throw new Error('[plugin:' + pluginId + '] commands.execute(' + cmdId + ') failed: declared-but-unhandled');
}
const result = await handler(args || {}, declared);
return {
status: 'handled',
pluginId: pluginId,
commandId: cmdId,
result: result
};
}
export function createPluginAPI(pluginId) {
if (!pluginId) {
throw new Error('createPluginAPI requires pluginId');
@ -306,20 +329,7 @@ export function createPluginAPI(pluginId) {
},
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
};
return executePluginCommand(pluginId, cmdId, args || {});
}
},

View File

@ -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>