Add status bar contribution host
This commit is contained in:
parent
7630a31286
commit
6cc37972d1
|
|
@ -201,7 +201,7 @@ Icon fields use shell icon names rendered through the bundled Lucide SVG wrapper
|
||||||
| Контекстное меню | `contextMenuEntries` | Registry готов, UI не реализован |
|
| Контекстное меню | `contextMenuEntries` | Registry готов, UI не реализован |
|
||||||
| Провайдеры поиска | `searchProviders` | Registry готов, UI не реализован |
|
| Провайдеры поиска | `searchProviders` | Registry готов, UI не реализован |
|
||||||
| Провайдеры активности | `activityProviders` | Registry готов, UI не реализован |
|
| Провайдеры активности | `activityProviders` | Registry готов, UI не реализован |
|
||||||
| Элементы status bar | `statusBarItems` | Registry готов, UI не реализован |
|
| Элементы status bar | `statusBarItems` | ✅ StatusBar.svelte host |
|
||||||
|
|
||||||
### Структура contribution points в manifest
|
### Структура contribution points в manifest
|
||||||
|
|
||||||
|
|
@ -393,6 +393,16 @@ contributions summary.
|
||||||
фильтрует по title/id/plugin и вызывает зарегистрированные bundled frontend
|
фильтрует по title/id/plugin и вызывает зарегистрированные bundled frontend
|
||||||
handlers.
|
handlers.
|
||||||
|
|
||||||
|
`statusBarItems`
|
||||||
|
|
||||||
|
- Shell status bar renders enabled plugin `statusBarItems` contributions.
|
||||||
|
- Items support `left`, `center`, and `right` positions. Missing position
|
||||||
|
defaults to `left`.
|
||||||
|
- The host refreshes on plugin reload/enable/disable through
|
||||||
|
`verstak:plugins-changed`.
|
||||||
|
- `handler` is preserved in the contribution summary for future action routing;
|
||||||
|
current host only renders status labels.
|
||||||
|
|
||||||
`events`
|
`events`
|
||||||
|
|
||||||
- `events.subscribe(eventName, handler)` — frontend-local subscription с backend
|
- `events.subscribe(eventName, handler)` — frontend-local subscription с backend
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
|
||||||
|
|
||||||
|
test.describe('Status Bar host', () => {
|
||||||
|
let consoleCollector;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
consoleCollector = setupConsoleCollector(page);
|
||||||
|
await resetMockState(page);
|
||||||
|
await page.goto('/');
|
||||||
|
await waitForAppReady(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async () => {
|
||||||
|
consoleCollector.assertNoErrors();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders enabled plugin statusBarItems', async ({ page }) => {
|
||||||
|
const statusBar = page.locator('.status-bar');
|
||||||
|
await expect(statusBar).toBeVisible();
|
||||||
|
await expect(statusBar.locator('[data-status-item-id="verstak.platform-test.status"]')).toContainText('All Tests Pass');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refreshes statusBarItems after disabling plugin', async ({ page }) => {
|
||||||
|
const pluginCard = page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' });
|
||||||
|
await expect(page.locator('[data-status-item-id="verstak.platform-test.status"]')).toBeVisible();
|
||||||
|
|
||||||
|
await pluginCard.locator('button.btn-disable').click();
|
||||||
|
|
||||||
|
await expect(pluginCard.locator('button.btn-enable')).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.locator('[data-status-item-id="verstak.platform-test.status"]')).not.toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
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 CommandPalette from './lib/shell/CommandPalette.svelte';
|
||||||
|
import StatusBar from './lib/shell/StatusBar.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';
|
||||||
|
|
@ -295,6 +296,7 @@
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<CommandPalette />
|
<CommandPalette />
|
||||||
|
|
||||||
|
<section class="content-shell">
|
||||||
<section class="content scroll-surface">
|
<section class="content scroll-surface">
|
||||||
{#if currentView === 'plugin-manager'}
|
{#if currentView === 'plugin-manager'}
|
||||||
<PluginManager {activeSettingsPluginId} {activeSettingsPanelId} />
|
<PluginManager {activeSettingsPluginId} {activeSettingsPanelId} />
|
||||||
|
|
@ -306,6 +308,8 @@
|
||||||
<ViewContainer {activeView} {activeViewPluginId} />
|
<ViewContainer {activeView} {activeViewPluginId} />
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
<StatusBar />
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|
@ -460,6 +464,14 @@
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.content-shell {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
<script>
|
||||||
|
import { onDestroy, onMount } from 'svelte';
|
||||||
|
import * as App from '../../../wailsjs/go/api/App';
|
||||||
|
|
||||||
|
let items = [];
|
||||||
|
$: leftItems = items.filter((item) => item.position === 'left');
|
||||||
|
$: centerItems = items.filter((item) => item.position === 'center');
|
||||||
|
$: rightItems = items.filter((item) => item.position === 'right');
|
||||||
|
|
||||||
|
const inactiveStatuses = new Set(['disabled', 'failed', 'incompatible', 'missing-required-capability']);
|
||||||
|
|
||||||
|
async function loadStatusBar() {
|
||||||
|
const [plugins, contributions] = await Promise.all([
|
||||||
|
App.GetPlugins().catch(() => []),
|
||||||
|
App.GetContributions().catch(() => ({})),
|
||||||
|
]);
|
||||||
|
const pluginById = new Map((plugins || []).map((plugin) => [plugin.manifest?.id, plugin]));
|
||||||
|
items = (contributions.statusBarItems || [])
|
||||||
|
.filter((item) => {
|
||||||
|
const plugin = pluginById.get(item.pluginId);
|
||||||
|
if (!plugin) return false;
|
||||||
|
return !inactiveStatuses.has(plugin.status);
|
||||||
|
})
|
||||||
|
.map((item) => ({
|
||||||
|
...item,
|
||||||
|
position: item.position || 'left',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
loadStatusBar();
|
||||||
|
window.addEventListener('verstak:plugins-changed', loadStatusBar);
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
window.removeEventListener('verstak:plugins-changed', loadStatusBar);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<footer class="status-bar" aria-label="Status bar">
|
||||||
|
<div class="status-bar-group status-left">
|
||||||
|
{#each leftItems as item}
|
||||||
|
<span class="status-bar-item" data-status-item-id={item.id} title={item.pluginId}>
|
||||||
|
{item.label || item.id}
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="status-bar-group status-center">
|
||||||
|
{#each centerItems as item}
|
||||||
|
<span class="status-bar-item" data-status-item-id={item.id} title={item.pluginId}>
|
||||||
|
{item.label || item.id}
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="status-bar-group status-right">
|
||||||
|
{#each rightItems as item}
|
||||||
|
<span class="status-bar-item" data-status-item-id={item.id} title={item.pluginId}>
|
||||||
|
{item.label || item.id}
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.status-bar {
|
||||||
|
min-height: 1.7rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.2rem 0.65rem;
|
||||||
|
border-top: 1px solid #16213e;
|
||||||
|
background: #111629;
|
||||||
|
color: #9fb2ca;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar-group {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-center {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-right {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar-item {
|
||||||
|
max-width: 18rem;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0.12rem 0.35rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -228,6 +228,14 @@ type FlatCommand struct {
|
||||||
Handler string `json:"handler,omitempty"`
|
Handler string `json:"handler,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FlatStatusBarItem struct {
|
||||||
|
PluginID string `json:"pluginId"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Position string `json:"position,omitempty"`
|
||||||
|
Handler string `json:"handler,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type FlatOpenProviderSupport struct {
|
type FlatOpenProviderSupport struct {
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
Mime []string `json:"mime,omitempty"`
|
Mime []string `json:"mime,omitempty"`
|
||||||
|
|
@ -258,6 +266,7 @@ type ContributionSummary struct {
|
||||||
Commands []FlatCommand `json:"commands"`
|
Commands []FlatCommand `json:"commands"`
|
||||||
SettingsPanels []FlatSettingsPanel `json:"settingsPanels"`
|
SettingsPanels []FlatSettingsPanel `json:"settingsPanels"`
|
||||||
SidebarItems []FlatSidebarItem `json:"sidebarItems"`
|
SidebarItems []FlatSidebarItem `json:"sidebarItems"`
|
||||||
|
StatusBarItems []FlatStatusBarItem `json:"statusBarItems"`
|
||||||
OpenProviders []FlatOpenProvider `json:"openProviders"`
|
OpenProviders []FlatOpenProvider `json:"openProviders"`
|
||||||
WorkspaceItems []FlatWorkspaceItem `json:"workspaceItems"`
|
WorkspaceItems []FlatWorkspaceItem `json:"workspaceItems"`
|
||||||
}
|
}
|
||||||
|
|
@ -271,6 +280,7 @@ func buildContributionSummary(r *contribution.Registry) ContributionSummary {
|
||||||
regCmds := r.Commands()
|
regCmds := r.Commands()
|
||||||
regPanels := r.SettingsPanels()
|
regPanels := r.SettingsPanels()
|
||||||
regSidebar := r.SidebarItems()
|
regSidebar := r.SidebarItems()
|
||||||
|
regStatusBar := r.StatusBarItems()
|
||||||
regOpenProviders := r.OpenProviders()
|
regOpenProviders := r.OpenProviders()
|
||||||
regWorkspaceItems := r.WorkspaceItems()
|
regWorkspaceItems := r.WorkspaceItems()
|
||||||
|
|
||||||
|
|
@ -290,6 +300,10 @@ func buildContributionSummary(r *contribution.Registry) ContributionSummary {
|
||||||
for i, v := range regSidebar {
|
for i, v := range regSidebar {
|
||||||
sidebar[i] = FlatSidebarItem{PluginID: v.PluginID, ID: v.Item.ID, Title: v.Item.Title, Icon: v.Item.Icon, View: v.Item.View, Position: v.Item.Position}
|
sidebar[i] = FlatSidebarItem{PluginID: v.PluginID, ID: v.Item.ID, Title: v.Item.Title, Icon: v.Item.Icon, View: v.Item.View, Position: v.Item.Position}
|
||||||
}
|
}
|
||||||
|
statusBarItems := make([]FlatStatusBarItem, len(regStatusBar))
|
||||||
|
for i, v := range regStatusBar {
|
||||||
|
statusBarItems[i] = FlatStatusBarItem{PluginID: v.PluginID, ID: v.Item.ID, Label: v.Item.Label, Position: v.Item.Position, Handler: v.Item.Handler}
|
||||||
|
}
|
||||||
openProviders := make([]FlatOpenProvider, len(regOpenProviders))
|
openProviders := make([]FlatOpenProvider, len(regOpenProviders))
|
||||||
for i, v := range regOpenProviders {
|
for i, v := range regOpenProviders {
|
||||||
supports := make([]FlatOpenProviderSupport, len(v.Item.Supports))
|
supports := make([]FlatOpenProviderSupport, len(v.Item.Supports))
|
||||||
|
|
@ -309,7 +323,7 @@ func buildContributionSummary(r *contribution.Registry) ContributionSummary {
|
||||||
for i, v := range regWorkspaceItems {
|
for i, v := range regWorkspaceItems {
|
||||||
workspaceItems[i] = FlatWorkspaceItem{PluginID: v.PluginID, ID: v.Item.ID, Title: v.Item.Title, Icon: v.Item.Icon, Component: v.Item.Component}
|
workspaceItems[i] = FlatWorkspaceItem{PluginID: v.PluginID, ID: v.Item.ID, Title: v.Item.Title, Icon: v.Item.Icon, Component: v.Item.Component}
|
||||||
}
|
}
|
||||||
return ContributionSummary{Views: views, Commands: cmds, SettingsPanels: panels, SidebarItems: sidebar, OpenProviders: openProviders, WorkspaceItems: workspaceItems}
|
return ContributionSummary{Views: views, Commands: cmds, SettingsPanels: panels, SidebarItems: sidebar, StatusBarItems: statusBarItems, OpenProviders: openProviders, WorkspaceItems: workspaceItems}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetContributions returns all registered contributions flattened for the frontend.
|
// GetContributions returns all registered contributions flattened for the frontend.
|
||||||
|
|
@ -322,8 +336,8 @@ func (a *App) GetContributions() ContributionSummary {
|
||||||
}
|
}
|
||||||
summary := buildContributionSummary(a.contribRegistry)
|
summary := buildContributionSummary(a.contribRegistry)
|
||||||
if a.debug {
|
if a.debug {
|
||||||
debug.Logf("[api] GetContributions: returning views=%d commands=%d sidebar=%d settings=%d openProviders=%d",
|
debug.Logf("[api] GetContributions: returning views=%d commands=%d sidebar=%d statusBar=%d settings=%d openProviders=%d",
|
||||||
len(summary.Views), len(summary.Commands), len(summary.SidebarItems), len(summary.SettingsPanels), len(summary.OpenProviders))
|
len(summary.Views), len(summary.Commands), len(summary.SidebarItems), len(summary.StatusBarItems), len(summary.SettingsPanels), len(summary.OpenProviders))
|
||||||
}
|
}
|
||||||
return summary
|
return summary
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -924,6 +924,9 @@ func newBridgeTestApp(t *testing.T) *App {
|
||||||
Commands: []plugin.ContributionCommand{
|
Commands: []plugin.ContributionCommand{
|
||||||
{ID: "bridge.command", Title: "Bridge Command", Handler: "runBridgeCommand"},
|
{ID: "bridge.command", Title: "Bridge Command", Handler: "runBridgeCommand"},
|
||||||
},
|
},
|
||||||
|
StatusBarItems: []plugin.ContributionStatusBarItem{
|
||||||
|
{ID: "bridge.status", Label: "Bridge Ready", Position: "right", Handler: "openBridgeStatus"},
|
||||||
|
},
|
||||||
OpenProviders: []plugin.ContributionOpenProvider{
|
OpenProviders: []plugin.ContributionOpenProvider{
|
||||||
{
|
{
|
||||||
ID: "bridge.markdown",
|
ID: "bridge.markdown",
|
||||||
|
|
@ -998,6 +1001,19 @@ func TestContributionSummaryIncludesOpenProviders(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestContributionSummaryIncludesStatusBarItems(t *testing.T) {
|
||||||
|
app := newBridgeTestApp(t)
|
||||||
|
|
||||||
|
summary := app.GetContributions()
|
||||||
|
if len(summary.StatusBarItems) != 1 {
|
||||||
|
t.Fatalf("StatusBarItems count = %d, want 1", len(summary.StatusBarItems))
|
||||||
|
}
|
||||||
|
item := summary.StatusBarItems[0]
|
||||||
|
if item.PluginID != "bridge.plugin" || item.ID != "bridge.status" || item.Label != "Bridge Ready" || item.Position != "right" || item.Handler != "openBridgeStatus" {
|
||||||
|
t.Fatalf("status item = %+v", item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestWorkbenchOpenAndEditResourceRouteToProvider(t *testing.T) {
|
func TestWorkbenchOpenAndEditResourceRouteToProvider(t *testing.T) {
|
||||||
app := newBridgeTestApp(t)
|
app := newBridgeTestApp(t)
|
||||||
app.contribRegistry.Register("disabled.plugin", &plugin.Contributions{
|
app.contribRegistry.Register("disabled.plugin", &plugin.Contributions{
|
||||||
|
|
|
||||||
|
|
@ -280,6 +280,15 @@ func (r *Registry) SidebarItems() []ContributionSidebarItem {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Registry) StatusBarItems() []ContributionStatusBarItem {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
result := make([]ContributionStatusBarItem, len(r.statusBarItems))
|
||||||
|
copy(result, r.statusBarItems)
|
||||||
|
sort.Slice(result, func(i, j int) bool { return result[i].Item.ID < result[j].Item.ID })
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Registry) FileActions() []ContributionAction {
|
func (r *Registry) FileActions() []ContributionAction {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
defer r.mu.RUnlock()
|
defer r.mu.RUnlock()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue