Add status bar contribution host

This commit is contained in:
mirivlad 2026-06-27 13:21:22 +08:00
parent 7630a31286
commit 6cc37972d1
7 changed files with 210 additions and 14 deletions

View File

@ -201,7 +201,7 @@ Icon fields use shell icon names rendered through the bundled Lucide SVG wrapper
| Контекстное меню | `contextMenuEntries` | Registry готов, UI не реализован |
| Провайдеры поиска | `searchProviders` | Registry готов, UI не реализован |
| Провайдеры активности | `activityProviders` | Registry готов, UI не реализован |
| Элементы status bar | `statusBarItems` | Registry готов, UI не реализован |
| Элементы status bar | `statusBarItems` | ✅ StatusBar.svelte host |
### Структура contribution points в manifest
@ -393,6 +393,16 @@ contributions summary.
фильтрует по title/id/plugin и вызывает зарегистрированные bundled frontend
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.subscribe(eventName, handler)` — frontend-local subscription с backend

View File

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

View File

@ -2,6 +2,7 @@
import PluginManager from './lib/plugin-manager/PluginManager.svelte';
import Sidebar from './lib/shell/Sidebar.svelte';
import CommandPalette from './lib/shell/CommandPalette.svelte';
import StatusBar from './lib/shell/StatusBar.svelte';
import ViewContainer from './lib/shell/ViewContainer.svelte';
import VaultSelection from './lib/shell/VaultSelection.svelte';
import WorkbenchHost from './lib/shell/WorkbenchHost.svelte';
@ -295,6 +296,7 @@
<Sidebar />
<CommandPalette />
<section class="content-shell">
<section class="content scroll-surface">
{#if currentView === 'plugin-manager'}
<PluginManager {activeSettingsPluginId} {activeSettingsPanelId} />
@ -306,6 +308,8 @@
<ViewContainer {activeView} {activeViewPluginId} />
{/if}
</section>
<StatusBar />
</section>
</main>
{/if}
@ -460,6 +464,14 @@
overflow: hidden;
}
.content-shell {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
}
.content {
flex: 1;
min-width: 0;

View File

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

View File

@ -228,6 +228,14 @@ type FlatCommand struct {
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 {
Kind string `json:"kind"`
Mime []string `json:"mime,omitempty"`
@ -258,6 +266,7 @@ type ContributionSummary struct {
Commands []FlatCommand `json:"commands"`
SettingsPanels []FlatSettingsPanel `json:"settingsPanels"`
SidebarItems []FlatSidebarItem `json:"sidebarItems"`
StatusBarItems []FlatStatusBarItem `json:"statusBarItems"`
OpenProviders []FlatOpenProvider `json:"openProviders"`
WorkspaceItems []FlatWorkspaceItem `json:"workspaceItems"`
}
@ -271,6 +280,7 @@ func buildContributionSummary(r *contribution.Registry) ContributionSummary {
regCmds := r.Commands()
regPanels := r.SettingsPanels()
regSidebar := r.SidebarItems()
regStatusBar := r.StatusBarItems()
regOpenProviders := r.OpenProviders()
regWorkspaceItems := r.WorkspaceItems()
@ -290,6 +300,10 @@ func buildContributionSummary(r *contribution.Registry) ContributionSummary {
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}
}
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))
for i, v := range regOpenProviders {
supports := make([]FlatOpenProviderSupport, len(v.Item.Supports))
@ -309,7 +323,7 @@ func buildContributionSummary(r *contribution.Registry) ContributionSummary {
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}
}
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.
@ -322,8 +336,8 @@ func (a *App) GetContributions() ContributionSummary {
}
summary := buildContributionSummary(a.contribRegistry)
if a.debug {
debug.Logf("[api] GetContributions: returning views=%d commands=%d sidebar=%d settings=%d openProviders=%d",
len(summary.Views), len(summary.Commands), len(summary.SidebarItems), len(summary.SettingsPanels), len(summary.OpenProviders))
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.StatusBarItems), len(summary.SettingsPanels), len(summary.OpenProviders))
}
return summary
}

View File

@ -924,6 +924,9 @@ func newBridgeTestApp(t *testing.T) *App {
Commands: []plugin.ContributionCommand{
{ID: "bridge.command", Title: "Bridge Command", Handler: "runBridgeCommand"},
},
StatusBarItems: []plugin.ContributionStatusBarItem{
{ID: "bridge.status", Label: "Bridge Ready", Position: "right", Handler: "openBridgeStatus"},
},
OpenProviders: []plugin.ContributionOpenProvider{
{
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) {
app := newBridgeTestApp(t)
app.contribRegistry.Register("disabled.plugin", &plugin.Contributions{

View File

@ -280,6 +280,15 @@ func (r *Registry) SidebarItems() []ContributionSidebarItem {
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 {
r.mu.RLock()
defer r.mu.RUnlock()