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) |
|
||||
| Основные панели | `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` скрыта |
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
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'}
|
||||
|
|
|
|||
|
|
@ -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 || {});
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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