feat: expose action contributions to plugins
This commit is contained in:
parent
9729b432d6
commit
1f1001108b
|
|
@ -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: {
|
commands: {
|
||||||
register: function(cmdId, handler) {
|
register: function(cmdId, handler) {
|
||||||
assertActive('commands.register(' + cmdId + ')');
|
assertActive('commands.register(' + cmdId + ')');
|
||||||
|
|
@ -368,6 +381,10 @@ export function createPluginAPI(pluginId) {
|
||||||
execute: async function(cmdId, args) {
|
execute: async function(cmdId, args) {
|
||||||
assertActive('commands.execute(' + cmdId + ')');
|
assertActive('commands.execute(' + cmdId + ')');
|
||||||
return executePluginCommand(pluginId, cmdId, args || {});
|
return executePluginCommand(pluginId, cmdId, args || {});
|
||||||
|
},
|
||||||
|
executeFor: async function(targetPluginId, cmdId, args) {
|
||||||
|
assertActive('commands.executeFor(' + targetPluginId + ':' + cmdId + ')');
|
||||||
|
return executePluginCommand(targetPluginId, cmdId, args || {});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
@ -419,6 +419,25 @@ type FlatWorkspaceItem struct {
|
||||||
Component string `json:"component"`
|
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.
|
// ContributionSummary aggregates all contribution types for the frontend.
|
||||||
type ContributionSummary struct {
|
type ContributionSummary struct {
|
||||||
Views []FlatView `json:"views"`
|
Views []FlatView `json:"views"`
|
||||||
|
|
@ -429,6 +448,9 @@ type ContributionSummary struct {
|
||||||
StatusBarItems []FlatStatusBarItem `json:"statusBarItems"`
|
StatusBarItems []FlatStatusBarItem `json:"statusBarItems"`
|
||||||
OpenProviders []FlatOpenProvider `json:"openProviders"`
|
OpenProviders []FlatOpenProvider `json:"openProviders"`
|
||||||
WorkspaceItems []FlatWorkspaceItem `json:"workspaceItems"`
|
WorkspaceItems []FlatWorkspaceItem `json:"workspaceItems"`
|
||||||
|
FileActions []FlatAction `json:"fileActions"`
|
||||||
|
NoteActions []FlatAction `json:"noteActions"`
|
||||||
|
ContextMenuEntries []FlatContextMenuEntry `json:"contextMenuEntries"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildContributionSummary creates a ContributionSummary from the registry.
|
// buildContributionSummary creates a ContributionSummary from the registry.
|
||||||
|
|
@ -444,6 +466,9 @@ func buildContributionSummary(r *contribution.Registry) ContributionSummary {
|
||||||
regStatusBar := r.StatusBarItems()
|
regStatusBar := r.StatusBarItems()
|
||||||
regOpenProviders := r.OpenProviders()
|
regOpenProviders := r.OpenProviders()
|
||||||
regWorkspaceItems := r.WorkspaceItems()
|
regWorkspaceItems := r.WorkspaceItems()
|
||||||
|
regFileActions := r.FileActions()
|
||||||
|
regNoteActions := r.NoteActions()
|
||||||
|
regContextMenus := r.ContextMenus()
|
||||||
|
|
||||||
views := make([]FlatView, len(regViews))
|
views := make([]FlatView, len(regViews))
|
||||||
for i, v := range regViews {
|
for i, v := range regViews {
|
||||||
|
|
@ -488,7 +513,19 @@ 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, 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.
|
// GetContributions returns all registered contributions flattened for the frontend.
|
||||||
|
|
@ -501,8 +538,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 searchProviders=%d sidebar=%d statusBar=%d settings=%d openProviders=%d",
|
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.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
|
return summary
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1128,6 +1128,15 @@ 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"},
|
||||||
},
|
},
|
||||||
|
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{
|
StatusBarItems: []plugin.ContributionStatusBarItem{
|
||||||
{ID: "bridge.status", Label: "Bridge Ready", Position: "right", Handler: "openBridgeStatus"},
|
{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) {
|
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{
|
||||||
|
|
|
||||||
|
|
@ -307,6 +307,15 @@ func (r *Registry) NoteActions() []ContributionAction {
|
||||||
return result
|
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 {
|
func (r *Registry) SearchProviders() []ContributionSearchProvider {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
defer r.mu.RUnlock()
|
defer r.mu.RUnlock()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue