diff --git a/docs/PLUGIN_RUNTIME.md b/docs/PLUGIN_RUNTIME.md index db7f194..1ed58cb 100644 --- a/docs/PLUGIN_RUNTIME.md +++ b/docs/PLUGIN_RUNTIME.md @@ -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 diff --git a/frontend/e2e/status-bar.spec.js b/frontend/e2e/status-bar.spec.js new file mode 100644 index 0000000..b75195e --- /dev/null +++ b/frontend/e2e/status-bar.spec.js @@ -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(); + }); +}); diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index da96eae..764a9c2 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -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,16 +296,19 @@ -
- {#if currentView === 'plugin-manager'} - - {:else if currentView === 'workbench'} - - {:else if currentView === 'workspace'} - - {:else} - - {/if} +
+
+ {#if currentView === 'plugin-manager'} + + {:else if currentView === 'workbench'} + + {:else if currentView === 'workspace'} + + {:else} + + {/if} +
+
{/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; diff --git a/frontend/src/lib/shell/StatusBar.svelte b/frontend/src/lib/shell/StatusBar.svelte new file mode 100644 index 0000000..527123c --- /dev/null +++ b/frontend/src/lib/shell/StatusBar.svelte @@ -0,0 +1,102 @@ + + +
+
+ {#each leftItems as item} + + {item.label || item.id} + + {/each} +
+
+ {#each centerItems as item} + + {item.label || item.id} + + {/each} +
+
+ {#each rightItems as item} + + {item.label || item.id} + + {/each} +
+
+ + diff --git a/internal/api/app.go b/internal/api/app.go index 6ef2312..37755bd 100644 --- a/internal/api/app.go +++ b/internal/api/app.go @@ -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 } diff --git a/internal/api/app_test.go b/internal/api/app_test.go index eee8910..5cd30ea 100644 --- a/internal/api/app_test.go +++ b/internal/api/app_test.go @@ -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{ diff --git a/internal/core/contribution/registry.go b/internal/core/contribution/registry.go index d90340c..e22a419 100644 --- a/internal/core/contribution/registry.go +++ b/internal/core/contribution/registry.go @@ -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()