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 не реализован |
|
||||
| Провайдеры поиска | `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
|
||||
|
|
|
|||
|
|
@ -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 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 @@
|
|||
<Sidebar />
|
||||
<CommandPalette />
|
||||
|
||||
<section class="content scroll-surface">
|
||||
{#if currentView === 'plugin-manager'}
|
||||
<PluginManager {activeSettingsPluginId} {activeSettingsPanelId} />
|
||||
{:else if currentView === 'workbench'}
|
||||
<WorkbenchHost {openedResource} />
|
||||
{:else if currentView === 'workspace'}
|
||||
<WorkspaceHost selectedWorkspaceName={selectedWorkspaceName} nodes={workspaceNodes} />
|
||||
{:else}
|
||||
<ViewContainer {activeView} {activeViewPluginId} />
|
||||
{/if}
|
||||
<section class="content-shell">
|
||||
<section class="content scroll-surface">
|
||||
{#if currentView === 'plugin-manager'}
|
||||
<PluginManager {activeSettingsPluginId} {activeSettingsPanelId} />
|
||||
{:else if currentView === 'workbench'}
|
||||
<WorkbenchHost {openedResource} />
|
||||
{:else if currentView === 'workspace'}
|
||||
<WorkspaceHost selectedWorkspaceName={selectedWorkspaceName} nodes={workspaceNodes} />
|
||||
{:else}
|
||||
<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;
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue