From 1f1001108baa0a48cf54be5ccdec5d034fb6ac0b Mon Sep 17 00:00:00 2001 From: mirivlad Date: Sun, 28 Jun 2026 16:28:33 +0800 Subject: [PATCH] feat: expose action contributions to plugins --- .../src/lib/plugin-host/VerstakPluginAPI.js | 17 +++++ .../tests/plugin-api-contributions-test.mjs | 70 +++++++++++++++++++ internal/api/app.go | 59 +++++++++++++--- internal/api/app_test.go | 36 ++++++++++ internal/core/contribution/registry.go | 9 +++ 5 files changed, 180 insertions(+), 11 deletions(-) create mode 100644 frontend/tests/plugin-api-contributions-test.mjs diff --git a/frontend/src/lib/plugin-host/VerstakPluginAPI.js b/frontend/src/lib/plugin-host/VerstakPluginAPI.js index f71baf5..d2dc7c0 100644 --- a/frontend/src/lib/plugin-host/VerstakPluginAPI.js +++ b/frontend/src/lib/plugin-host/VerstakPluginAPI.js @@ -344,6 +344,19 @@ export function createPluginAPI(pluginId) { } }, + contributions: { + list: async function(point) { + assertActive('contributions.list'); + const summary = await callBackend(pluginId, 'contributions.list', function() { + return App.GetContributions(); + }); + if (!point) { + return summary || {}; + } + return Array.isArray((summary || {})[point]) ? summary[point] : []; + } + }, + commands: { register: function(cmdId, handler) { assertActive('commands.register(' + cmdId + ')'); @@ -368,6 +381,10 @@ export function createPluginAPI(pluginId) { execute: async function(cmdId, args) { assertActive('commands.execute(' + cmdId + ')'); return executePluginCommand(pluginId, cmdId, args || {}); + }, + executeFor: async function(targetPluginId, cmdId, args) { + assertActive('commands.executeFor(' + targetPluginId + ':' + cmdId + ')'); + return executePluginCommand(targetPluginId, cmdId, args || {}); } }, diff --git a/frontend/tests/plugin-api-contributions-test.mjs b/frontend/tests/plugin-api-contributions-test.mjs new file mode 100644 index 0000000..9ff31c1 --- /dev/null +++ b/frontend/tests/plugin-api-contributions-test.mjs @@ -0,0 +1,70 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +globalThis.window = { + __VERSTAK_PLUGIN_REGISTRY__: {}, + __VERSTAK_EVENT_HANDLERS__: {}, + __VERSTAK_COMMAND_HANDLERS__: {}, + go: { + api: { + App: { + GetContributions: () => Promise.resolve({ + fileActions: [ + { + pluginId: 'provider.plugin', + id: 'provider.file.action', + label: 'Provider File Action', + handler: 'provider.command', + }, + ], + noteActions: [], + contextMenuEntries: [], + }), + ExecutePluginCommand: (pluginId, commandId, args) => Promise.resolve([{ + status: 'declared', + pluginId, + commandId, + args, + }, '']), + }, + }, + }, +}; +globalThis.__mockApp = window.go.api.App; + +const sourcePath = path.resolve('frontend/src/lib/plugin-host/VerstakPluginAPI.js'); +const source = fs.readFileSync(sourcePath, 'utf8') + .replace("import * as App from '../../../wailsjs/go/api/App';", 'const App = globalThis.__mockApp;'); +const tempPath = path.resolve('/tmp/verstak-plugin-api-contributions-test.mjs'); +fs.writeFileSync(tempPath, source); + +const apiModule = await import(pathToFileURL(tempPath).href + '?t=' + Date.now()); +const api = apiModule.createPluginAPI('verstak.files'); + +if (!api.contributions || typeof api.contributions.list !== 'function') { + throw new Error('api.contributions.list is missing'); +} +if (!api.commands || typeof api.commands.executeFor !== 'function') { + throw new Error('api.commands.executeFor is missing'); +} + +const fileActions = await api.contributions.list('fileActions'); +if (fileActions.length !== 1 || fileActions[0].id !== 'provider.file.action') { + throw new Error(`unexpected file actions: ${JSON.stringify(fileActions)}`); +} + +window.__VERSTAK_COMMAND_HANDLERS__['provider.plugin:provider.command'] = (args, declared) => ({ + handledPath: args.path, + declaredPlugin: declared.pluginId, +}); + +const result = await api.commands.executeFor('provider.plugin', 'provider.command', { + source: 'files', + path: 'Project/Docs/readme.md', +}); +if (result.status !== 'handled' || result.result.handledPath !== 'Project/Docs/readme.md') { + throw new Error(`unexpected executeFor result: ${JSON.stringify(result)}`); +} + +console.log('plugin api contributions smoke passed'); diff --git a/internal/api/app.go b/internal/api/app.go index 2d86096..daf2fec 100644 --- a/internal/api/app.go +++ b/internal/api/app.go @@ -419,16 +419,38 @@ type FlatWorkspaceItem struct { Component string `json:"component"` } +type FlatAction struct { + PluginID string `json:"pluginId"` + ID string `json:"id"` + Label string `json:"label"` + Icon string `json:"icon,omitempty"` + Capability string `json:"capability,omitempty"` + Handler string `json:"handler,omitempty"` +} + +type FlatContextMenuEntry struct { + PluginID string `json:"pluginId"` + ID string `json:"id"` + Label string `json:"label"` + Context string `json:"context"` + Group string `json:"group,omitempty"` + Capability string `json:"capability,omitempty"` + Handler string `json:"handler,omitempty"` +} + // ContributionSummary aggregates all contribution types for the frontend. type ContributionSummary struct { - Views []FlatView `json:"views"` - Commands []FlatCommand `json:"commands"` - SearchProviders []FlatSearchProvider `json:"searchProviders"` - SettingsPanels []FlatSettingsPanel `json:"settingsPanels"` - SidebarItems []FlatSidebarItem `json:"sidebarItems"` - StatusBarItems []FlatStatusBarItem `json:"statusBarItems"` - OpenProviders []FlatOpenProvider `json:"openProviders"` - WorkspaceItems []FlatWorkspaceItem `json:"workspaceItems"` + Views []FlatView `json:"views"` + Commands []FlatCommand `json:"commands"` + SearchProviders []FlatSearchProvider `json:"searchProviders"` + SettingsPanels []FlatSettingsPanel `json:"settingsPanels"` + SidebarItems []FlatSidebarItem `json:"sidebarItems"` + StatusBarItems []FlatStatusBarItem `json:"statusBarItems"` + OpenProviders []FlatOpenProvider `json:"openProviders"` + WorkspaceItems []FlatWorkspaceItem `json:"workspaceItems"` + FileActions []FlatAction `json:"fileActions"` + NoteActions []FlatAction `json:"noteActions"` + ContextMenuEntries []FlatContextMenuEntry `json:"contextMenuEntries"` } // buildContributionSummary creates a ContributionSummary from the registry. @@ -444,6 +466,9 @@ func buildContributionSummary(r *contribution.Registry) ContributionSummary { regStatusBar := r.StatusBarItems() regOpenProviders := r.OpenProviders() regWorkspaceItems := r.WorkspaceItems() + regFileActions := r.FileActions() + regNoteActions := r.NoteActions() + regContextMenus := r.ContextMenus() views := make([]FlatView, len(regViews)) for i, v := range regViews { @@ -488,7 +513,19 @@ 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, SearchProviders: searchProviders, SettingsPanels: panels, SidebarItems: sidebar, StatusBarItems: statusBarItems, OpenProviders: openProviders, WorkspaceItems: workspaceItems} + fileActions := make([]FlatAction, len(regFileActions)) + for i, v := range regFileActions { + fileActions[i] = FlatAction{PluginID: v.PluginID, ID: v.Item.ID, Label: v.Item.Label, Icon: v.Item.Icon, Capability: v.Item.Capability, Handler: v.Item.Handler} + } + noteActions := make([]FlatAction, len(regNoteActions)) + for i, v := range regNoteActions { + noteActions[i] = FlatAction{PluginID: v.PluginID, ID: v.Item.ID, Label: v.Item.Label, Icon: v.Item.Icon, Capability: v.Item.Capability, Handler: v.Item.Handler} + } + contextMenus := make([]FlatContextMenuEntry, len(regContextMenus)) + for i, v := range regContextMenus { + contextMenus[i] = FlatContextMenuEntry{PluginID: v.PluginID, ID: v.Item.ID, Label: v.Item.Label, Context: v.Item.Context, Group: v.Item.Group, Capability: v.Item.Capability, Handler: v.Item.Handler} + } + return ContributionSummary{Views: views, Commands: cmds, SearchProviders: searchProviders, SettingsPanels: panels, SidebarItems: sidebar, StatusBarItems: statusBarItems, OpenProviders: openProviders, WorkspaceItems: workspaceItems, FileActions: fileActions, NoteActions: noteActions, ContextMenuEntries: contextMenus} } // GetContributions returns all registered contributions flattened for the frontend. @@ -501,8 +538,8 @@ func (a *App) GetContributions() ContributionSummary { } summary := buildContributionSummary(a.contribRegistry) if a.debug { - debug.Logf("[api] GetContributions: returning views=%d commands=%d searchProviders=%d sidebar=%d statusBar=%d settings=%d openProviders=%d", - len(summary.Views), len(summary.Commands), len(summary.SearchProviders), len(summary.SidebarItems), len(summary.StatusBarItems), len(summary.SettingsPanels), len(summary.OpenProviders)) + debug.Logf("[api] GetContributions: returning views=%d commands=%d searchProviders=%d sidebar=%d statusBar=%d settings=%d openProviders=%d fileActions=%d noteActions=%d contextMenuEntries=%d", + len(summary.Views), len(summary.Commands), len(summary.SearchProviders), len(summary.SidebarItems), len(summary.StatusBarItems), len(summary.SettingsPanels), len(summary.OpenProviders), len(summary.FileActions), len(summary.NoteActions), len(summary.ContextMenuEntries)) } return summary } diff --git a/internal/api/app_test.go b/internal/api/app_test.go index 303ee47..4bed333 100644 --- a/internal/api/app_test.go +++ b/internal/api/app_test.go @@ -1128,6 +1128,15 @@ func newBridgeTestApp(t *testing.T) *App { Commands: []plugin.ContributionCommand{ {ID: "bridge.command", Title: "Bridge Command", Handler: "runBridgeCommand"}, }, + FileActions: []plugin.ContributionAction{ + {ID: "bridge.file.action", Label: "Bridge File Action", Icon: "zap", Handler: "bridge.command"}, + }, + NoteActions: []plugin.ContributionAction{ + {ID: "bridge.note.action", Label: "Bridge Note Action", Icon: "zap", Handler: "bridge.command"}, + }, + ContextMenuEntries: []plugin.ContributionContextMenuEntry{ + {ID: "bridge.file.context", Label: "Bridge File Context", Context: "file", Group: "open", Handler: "bridge.command"}, + }, StatusBarItems: []plugin.ContributionStatusBarItem{ {ID: "bridge.status", Label: "Bridge Ready", Position: "right", Handler: "openBridgeStatus"}, }, @@ -1234,6 +1243,33 @@ func TestContributionSummaryIncludesSearchProviders(t *testing.T) { } } +func TestContributionSummaryIncludesActionsAndContextMenus(t *testing.T) { + app := newBridgeTestApp(t) + + summary := app.GetContributions() + if len(summary.FileActions) != 1 { + t.Fatalf("FileActions count = %d, want 1", len(summary.FileActions)) + } + fileAction := summary.FileActions[0] + if fileAction.PluginID != "bridge.plugin" || fileAction.ID != "bridge.file.action" || fileAction.Label != "Bridge File Action" || fileAction.Handler != "bridge.command" { + t.Fatalf("file action = %+v", fileAction) + } + if len(summary.NoteActions) != 1 { + t.Fatalf("NoteActions count = %d, want 1", len(summary.NoteActions)) + } + noteAction := summary.NoteActions[0] + if noteAction.PluginID != "bridge.plugin" || noteAction.ID != "bridge.note.action" || noteAction.Label != "Bridge Note Action" || noteAction.Handler != "bridge.command" { + t.Fatalf("note action = %+v", noteAction) + } + if len(summary.ContextMenuEntries) != 1 { + t.Fatalf("ContextMenuEntries count = %d, want 1", len(summary.ContextMenuEntries)) + } + menuEntry := summary.ContextMenuEntries[0] + if menuEntry.PluginID != "bridge.plugin" || menuEntry.ID != "bridge.file.context" || menuEntry.Context != "file" || menuEntry.Handler != "bridge.command" { + t.Fatalf("context menu entry = %+v", menuEntry) + } +} + 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 625b7a7..12fb8f9 100644 --- a/internal/core/contribution/registry.go +++ b/internal/core/contribution/registry.go @@ -307,6 +307,15 @@ func (r *Registry) NoteActions() []ContributionAction { return result } +func (r *Registry) ContextMenus() []ContributionContextMenuEntry { + r.mu.RLock() + defer r.mu.RUnlock() + result := make([]ContributionContextMenuEntry, len(r.contextMenus)) + copy(result, r.contextMenus) + sort.Slice(result, func(i, j int) bool { return result[i].Item.ID < result[j].Item.ID }) + return result +} + func (r *Registry) SearchProviders() []ContributionSearchProvider { r.mu.RLock() defer r.mu.RUnlock()