@openvcs/sdk 0.2.10 → 0.2.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/runtime/menu.d.ts +17 -0
- package/lib/runtime/menu.js +250 -27
- package/lib/runtime/registration.js +28 -1
- package/package.json +1 -1
- package/src/lib/runtime/menu.ts +306 -36
- package/src/lib/runtime/registration.ts +32 -1
package/lib/runtime/menu.d.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import type { MenubarItem } from '../types/menubar.js';
|
|
2
|
+
import type { PluginDelegates } from '../types/plugin.js';
|
|
3
|
+
import type { PluginRuntimeContext } from './contracts.js';
|
|
2
4
|
type MenubarMenuOptions = {
|
|
3
5
|
before?: string;
|
|
4
6
|
after?: string;
|
|
5
7
|
};
|
|
8
|
+
/** Runs a registered action handler by id. */
|
|
9
|
+
export declare function runRegisteredAction(actionId: string, ...args: unknown[]): Promise<boolean>;
|
|
6
10
|
export interface MenuHandle {
|
|
7
11
|
id: string;
|
|
8
12
|
addItem(item: MenubarItem): void;
|
|
@@ -11,15 +15,28 @@ export interface MenuHandle {
|
|
|
11
15
|
hideItem(actionId: string): void;
|
|
12
16
|
showItem(actionId: string): void;
|
|
13
17
|
}
|
|
18
|
+
/** Returns a menu by id, or null when it does not exist. */
|
|
14
19
|
export declare function getMenu(menuId: string): MenuHandle | null;
|
|
20
|
+
/** Returns a menu by id, creating it if needed. */
|
|
15
21
|
export declare function getOrCreateMenu(menuId: string, label: string): MenuHandle | null;
|
|
22
|
+
/** Creates a menu at a specific position. */
|
|
16
23
|
export declare function createMenu(menuId: string, label: string, options?: MenubarMenuOptions): MenuHandle | null;
|
|
24
|
+
/** Adds one item to a menu. */
|
|
17
25
|
export declare function addMenuItem(menuId: string, item: MenubarItem): void;
|
|
26
|
+
/** Adds one separator to a menu. */
|
|
18
27
|
export declare function addMenuSeparator(menuId: string, beforeAction?: string): void;
|
|
28
|
+
/** Removes one menu from the registry. */
|
|
19
29
|
export declare function removeMenu(menuId: string): void;
|
|
30
|
+
/** Hides one menu from the registry. */
|
|
20
31
|
export declare function hideMenu(menuId: string): void;
|
|
32
|
+
/** Shows one menu from the registry. */
|
|
21
33
|
export declare function showMenu(menuId: string): void;
|
|
34
|
+
/** Registers an action handler by id. */
|
|
22
35
|
export declare function registerAction(id: string, handler: (...args: unknown[]) => unknown): void;
|
|
36
|
+
/** Invokes a host command when a host helper is available. */
|
|
23
37
|
export declare function invoke<T = unknown>(cmd: string, args?: unknown): Promise<T>;
|
|
38
|
+
/** Emits a notification when the host helper is available. */
|
|
24
39
|
export declare function notify(msg: string): void;
|
|
40
|
+
/** Builds SDK delegates from the local menu/action registries. */
|
|
41
|
+
export declare function createMenuPluginDelegates(): PluginDelegates<PluginRuntimeContext>;
|
|
25
42
|
export {};
|
package/lib/runtime/menu.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Copyright © 2025-2026 OpenVCS Contributors
|
|
3
3
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
4
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
5
|
+
exports.runRegisteredAction = runRegisteredAction;
|
|
5
6
|
exports.getMenu = getMenu;
|
|
6
7
|
exports.getOrCreateMenu = getOrCreateMenu;
|
|
7
8
|
exports.createMenu = createMenu;
|
|
@@ -13,64 +14,286 @@ exports.showMenu = showMenu;
|
|
|
13
14
|
exports.registerAction = registerAction;
|
|
14
15
|
exports.invoke = invoke;
|
|
15
16
|
exports.notify = notify;
|
|
17
|
+
exports.createMenuPluginDelegates = createMenuPluginDelegates;
|
|
18
|
+
const menus = new Map();
|
|
19
|
+
const menuOrder = [];
|
|
20
|
+
const actionHandlers = new Map();
|
|
21
|
+
let syntheticId = 0;
|
|
22
|
+
/** Returns the host-side OpenVCS helper, when the environment provides one. */
|
|
16
23
|
function getOpenVCS() {
|
|
17
24
|
return globalThis.OpenVCS;
|
|
18
25
|
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
26
|
+
/** Normalizes a menu id for stable map lookup. */
|
|
27
|
+
function normalizeMenuId(menuId) {
|
|
28
|
+
return String(menuId || '').trim();
|
|
29
|
+
}
|
|
30
|
+
/** Allocates a stable synthetic id for generated menu entries. */
|
|
31
|
+
function allocateSyntheticId(prefix) {
|
|
32
|
+
syntheticId += 1;
|
|
33
|
+
return `${prefix}-${syntheticId}`;
|
|
34
|
+
}
|
|
35
|
+
/** Returns the stored menu state for one id, if present. */
|
|
36
|
+
function getStoredMenu(menuId) {
|
|
37
|
+
return menus.get(normalizeMenuId(menuId)) || null;
|
|
38
|
+
}
|
|
39
|
+
/** Removes one menu id from the ordering list. */
|
|
40
|
+
function removeMenuId(menuId) {
|
|
41
|
+
const id = normalizeMenuId(menuId);
|
|
42
|
+
const index = menuOrder.indexOf(id);
|
|
43
|
+
if (index >= 0)
|
|
44
|
+
menuOrder.splice(index, 1);
|
|
45
|
+
}
|
|
46
|
+
/** Inserts one menu id into the ordering list. */
|
|
47
|
+
function placeMenuId(menuId, options) {
|
|
48
|
+
const id = normalizeMenuId(menuId);
|
|
49
|
+
removeMenuId(id);
|
|
50
|
+
const beforeId = normalizeMenuId(options?.before || '');
|
|
51
|
+
const afterId = normalizeMenuId(options?.after || '');
|
|
52
|
+
if (afterId) {
|
|
53
|
+
const afterIndex = menuOrder.indexOf(afterId);
|
|
54
|
+
if (afterIndex >= 0) {
|
|
55
|
+
menuOrder.splice(afterIndex + 1, 0, id);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (beforeId) {
|
|
60
|
+
const beforeIndex = menuOrder.indexOf(beforeId);
|
|
61
|
+
if (beforeIndex >= 0) {
|
|
62
|
+
menuOrder.splice(beforeIndex, 0, id);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
menuOrder.push(id);
|
|
67
|
+
}
|
|
68
|
+
/** Ensures a menu record exists for one id. */
|
|
69
|
+
function ensureStoredMenu(menuId, label, options) {
|
|
70
|
+
const id = normalizeMenuId(menuId);
|
|
71
|
+
const safeLabel = String(label || '').trim() || id;
|
|
72
|
+
let menu = menus.get(id);
|
|
73
|
+
if (!menu) {
|
|
74
|
+
menu = { id, label: safeLabel, items: [] };
|
|
75
|
+
menus.set(id, menu);
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
menu.label = safeLabel;
|
|
79
|
+
}
|
|
80
|
+
placeMenuId(id, options);
|
|
22
81
|
return menu;
|
|
23
82
|
}
|
|
83
|
+
/** Finds a stored item by action id. */
|
|
84
|
+
function findStoredItem(menu, actionId) {
|
|
85
|
+
const id = normalizeMenuId(actionId);
|
|
86
|
+
return menu.items.find((item) => item.action === id) || null;
|
|
87
|
+
}
|
|
88
|
+
/** Inserts a menu item at the requested position. */
|
|
89
|
+
function insertMenuItem(menu, item, before, after) {
|
|
90
|
+
const beforeId = normalizeMenuId(before || '');
|
|
91
|
+
const afterId = normalizeMenuId(after || '');
|
|
92
|
+
const removeExisting = () => {
|
|
93
|
+
const index = menu.items.findIndex((entry) => entry.id === item.id || entry.action === item.action);
|
|
94
|
+
if (index >= 0)
|
|
95
|
+
menu.items.splice(index, 1);
|
|
96
|
+
};
|
|
97
|
+
if (beforeId) {
|
|
98
|
+
const beforeIndex = menu.items.findIndex((entry) => entry.action === beforeId);
|
|
99
|
+
if (beforeIndex >= 0) {
|
|
100
|
+
removeExisting();
|
|
101
|
+
menu.items.splice(beforeIndex, 0, item);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (afterId) {
|
|
106
|
+
const afterIndex = menu.items.findIndex((entry) => entry.action === afterId);
|
|
107
|
+
if (afterIndex >= 0) {
|
|
108
|
+
removeExisting();
|
|
109
|
+
menu.items.splice(afterIndex + 1, 0, item);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
removeExisting();
|
|
114
|
+
menu.items.push(item);
|
|
115
|
+
}
|
|
116
|
+
/** Converts one stored item into a serializable menu payload element. */
|
|
117
|
+
function serializeMenuItem(item) {
|
|
118
|
+
if (item.hidden)
|
|
119
|
+
return null;
|
|
120
|
+
if (item.kind === 'separator') {
|
|
121
|
+
return {
|
|
122
|
+
type: 'text',
|
|
123
|
+
id: item.id,
|
|
124
|
+
content: item.content || '—',
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
if (item.kind === 'text') {
|
|
128
|
+
return {
|
|
129
|
+
type: 'text',
|
|
130
|
+
id: item.id,
|
|
131
|
+
content: item.content || item.label,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
type: 'button',
|
|
136
|
+
id: item.action || item.id,
|
|
137
|
+
label: item.label,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
/** Serializes the local registry into plugin menu payloads. */
|
|
141
|
+
function serializeMenus() {
|
|
142
|
+
return menuOrder
|
|
143
|
+
.map((menuId, index) => {
|
|
144
|
+
const menu = menus.get(menuId);
|
|
145
|
+
if (!menu || menu.hidden)
|
|
146
|
+
return null;
|
|
147
|
+
return {
|
|
148
|
+
id: menu.id,
|
|
149
|
+
label: menu.label,
|
|
150
|
+
order: index + 1,
|
|
151
|
+
elements: menu.items
|
|
152
|
+
.map((item) => serializeMenuItem(item))
|
|
153
|
+
.filter((item) => Boolean(item)),
|
|
154
|
+
};
|
|
155
|
+
})
|
|
156
|
+
.filter((menu) => Boolean(menu));
|
|
157
|
+
}
|
|
158
|
+
/** Runs a registered action handler by id. */
|
|
159
|
+
async function runRegisteredAction(actionId, ...args) {
|
|
160
|
+
const id = String(actionId || '').trim();
|
|
161
|
+
if (!id)
|
|
162
|
+
return false;
|
|
163
|
+
const handler = actionHandlers.get(id);
|
|
164
|
+
if (!handler)
|
|
165
|
+
return false;
|
|
166
|
+
await handler(...args);
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
/** Creates a stable handle for one stored menu. */
|
|
170
|
+
function createMenuHandle(menuId) {
|
|
171
|
+
const id = normalizeMenuId(menuId);
|
|
172
|
+
return {
|
|
173
|
+
id,
|
|
174
|
+
addItem(item) {
|
|
175
|
+
const label = String(item?.label || '').trim();
|
|
176
|
+
const action = String(item?.action || '').trim();
|
|
177
|
+
if (!label || !action)
|
|
178
|
+
return;
|
|
179
|
+
const menu = getStoredMenu(this.id) || ensureStoredMenu(this.id, this.id);
|
|
180
|
+
insertMenuItem(menu, {
|
|
181
|
+
kind: 'button',
|
|
182
|
+
id: action,
|
|
183
|
+
label,
|
|
184
|
+
title: item.title,
|
|
185
|
+
action,
|
|
186
|
+
}, item.before, item.after);
|
|
187
|
+
},
|
|
188
|
+
addSeparator(beforeAction) {
|
|
189
|
+
const menu = getStoredMenu(this.id) || ensureStoredMenu(this.id, this.id);
|
|
190
|
+
insertMenuItem(menu, {
|
|
191
|
+
kind: 'separator',
|
|
192
|
+
id: allocateSyntheticId(`${menu.id}-separator`),
|
|
193
|
+
label: 'Separator',
|
|
194
|
+
content: '—',
|
|
195
|
+
}, beforeAction);
|
|
196
|
+
},
|
|
197
|
+
removeItem(actionId) {
|
|
198
|
+
const menu = getStoredMenu(this.id);
|
|
199
|
+
if (!menu)
|
|
200
|
+
return;
|
|
201
|
+
const idToRemove = normalizeMenuId(actionId);
|
|
202
|
+
menu.items = menu.items.filter((item) => item.action !== idToRemove);
|
|
203
|
+
},
|
|
204
|
+
hideItem(actionId) {
|
|
205
|
+
const menu = getStoredMenu(this.id);
|
|
206
|
+
if (!menu)
|
|
207
|
+
return;
|
|
208
|
+
const item = findStoredItem(menu, actionId);
|
|
209
|
+
if (item)
|
|
210
|
+
item.hidden = true;
|
|
211
|
+
},
|
|
212
|
+
showItem(actionId) {
|
|
213
|
+
const menu = getStoredMenu(this.id);
|
|
214
|
+
if (!menu)
|
|
215
|
+
return;
|
|
216
|
+
const item = findStoredItem(menu, actionId);
|
|
217
|
+
if (item)
|
|
218
|
+
item.hidden = false;
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
/** Returns a menu by id, or null when it does not exist. */
|
|
24
223
|
function getMenu(menuId) {
|
|
25
|
-
const
|
|
26
|
-
if (!
|
|
224
|
+
const stored = getStoredMenu(menuId);
|
|
225
|
+
if (!stored)
|
|
27
226
|
return null;
|
|
28
|
-
return
|
|
227
|
+
return createMenuHandle(stored.id);
|
|
29
228
|
}
|
|
229
|
+
/** Returns a menu by id, creating it if needed. */
|
|
30
230
|
function getOrCreateMenu(menuId, label) {
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
return null;
|
|
34
|
-
return castMenuHandle(openvcs.menus.getOrCreate(menuId, label));
|
|
231
|
+
const stored = ensureStoredMenu(menuId, label);
|
|
232
|
+
return createMenuHandle(stored.id);
|
|
35
233
|
}
|
|
234
|
+
/** Creates a menu at a specific position. */
|
|
36
235
|
function createMenu(menuId, label, options) {
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
return null;
|
|
40
|
-
return castMenuHandle(openvcs.menus.create(menuId, label, options));
|
|
236
|
+
const stored = ensureStoredMenu(menuId, label, options);
|
|
237
|
+
return createMenuHandle(stored.id);
|
|
41
238
|
}
|
|
239
|
+
/** Adds one item to a menu. */
|
|
42
240
|
function addMenuItem(menuId, item) {
|
|
43
|
-
|
|
44
|
-
openvcs?.menus?.addMenuItem(menuId, item);
|
|
241
|
+
createMenuHandle(menuId).addItem(item);
|
|
45
242
|
}
|
|
243
|
+
/** Adds one separator to a menu. */
|
|
46
244
|
function addMenuSeparator(menuId, beforeAction) {
|
|
47
|
-
|
|
48
|
-
openvcs?.menus?.addMenuSeparator(menuId, beforeAction);
|
|
245
|
+
createMenuHandle(menuId).addSeparator(beforeAction);
|
|
49
246
|
}
|
|
247
|
+
/** Removes one menu from the registry. */
|
|
50
248
|
function removeMenu(menuId) {
|
|
51
|
-
const
|
|
52
|
-
|
|
249
|
+
const id = normalizeMenuId(menuId);
|
|
250
|
+
menus.delete(id);
|
|
251
|
+
removeMenuId(id);
|
|
53
252
|
}
|
|
253
|
+
/** Hides one menu from the registry. */
|
|
54
254
|
function hideMenu(menuId) {
|
|
55
|
-
const
|
|
56
|
-
|
|
255
|
+
const menu = getStoredMenu(menuId);
|
|
256
|
+
if (menu)
|
|
257
|
+
menu.hidden = true;
|
|
57
258
|
}
|
|
259
|
+
/** Shows one menu from the registry. */
|
|
58
260
|
function showMenu(menuId) {
|
|
59
|
-
const
|
|
60
|
-
|
|
261
|
+
const menu = getStoredMenu(menuId);
|
|
262
|
+
if (menu)
|
|
263
|
+
menu.hidden = false;
|
|
61
264
|
}
|
|
265
|
+
/** Registers an action handler by id. */
|
|
62
266
|
function registerAction(id, handler) {
|
|
63
|
-
const
|
|
64
|
-
|
|
267
|
+
const key = String(id || '').trim();
|
|
268
|
+
if (!key)
|
|
269
|
+
return;
|
|
270
|
+
actionHandlers.set(key, handler);
|
|
65
271
|
}
|
|
272
|
+
/** Invokes a host command when a host helper is available. */
|
|
66
273
|
function invoke(cmd, args) {
|
|
67
274
|
const openvcs = getOpenVCS();
|
|
68
275
|
if (!openvcs) {
|
|
69
|
-
return Promise.reject(new Error('OpenVCS not available'));
|
|
276
|
+
return Promise.reject(new Error('OpenVCS host is not available in this runtime'));
|
|
70
277
|
}
|
|
71
278
|
return openvcs.invoke(cmd, args);
|
|
72
279
|
}
|
|
280
|
+
/** Emits a notification when the host helper is available. */
|
|
73
281
|
function notify(msg) {
|
|
74
282
|
const openvcs = getOpenVCS();
|
|
75
283
|
openvcs?.notify(msg);
|
|
76
284
|
}
|
|
285
|
+
/** Builds SDK delegates from the local menu/action registries. */
|
|
286
|
+
function createMenuPluginDelegates() {
|
|
287
|
+
return {
|
|
288
|
+
async 'plugin.get_menus'() {
|
|
289
|
+
return serializeMenus();
|
|
290
|
+
},
|
|
291
|
+
async 'plugin.handle_action'(params) {
|
|
292
|
+
const actionId = String(params?.action_id || '').trim();
|
|
293
|
+
if (actionId) {
|
|
294
|
+
await runRegisteredAction(actionId);
|
|
295
|
+
}
|
|
296
|
+
return null;
|
|
297
|
+
},
|
|
298
|
+
};
|
|
299
|
+
}
|
|
@@ -5,10 +5,37 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
5
5
|
exports.createRegisteredPluginRuntime = createRegisteredPluginRuntime;
|
|
6
6
|
exports.bootstrapPluginModule = bootstrapPluginModule;
|
|
7
7
|
const factory_1 = require("./factory");
|
|
8
|
+
const menu_1 = require("./menu");
|
|
8
9
|
/** Creates a runtime from one declarative plugin module definition. */
|
|
9
10
|
function createRegisteredPluginRuntime(definition = {}) {
|
|
11
|
+
const menuDelegates = (0, menu_1.createMenuPluginDelegates)();
|
|
12
|
+
const explicitPluginDelegates = definition.plugin ?? {};
|
|
13
|
+
const explicitGetMenus = explicitPluginDelegates['plugin.get_menus'];
|
|
14
|
+
const explicitHandleAction = explicitPluginDelegates['plugin.handle_action'];
|
|
10
15
|
const options = {
|
|
11
|
-
plugin:
|
|
16
|
+
plugin: {
|
|
17
|
+
...explicitPluginDelegates,
|
|
18
|
+
'plugin.get_menus': async (params, ctx) => {
|
|
19
|
+
const menus = await menuDelegates['plugin.get_menus']?.(params, ctx);
|
|
20
|
+
const explicitMenus = explicitGetMenus
|
|
21
|
+
? await explicitGetMenus(params, ctx)
|
|
22
|
+
: [];
|
|
23
|
+
return [
|
|
24
|
+
...(Array.isArray(explicitMenus) ? explicitMenus : []),
|
|
25
|
+
...(Array.isArray(menus) ? menus : []),
|
|
26
|
+
];
|
|
27
|
+
},
|
|
28
|
+
'plugin.handle_action': async (params, ctx) => {
|
|
29
|
+
const actionId = String(params?.action_id || '').trim();
|
|
30
|
+
if (actionId && (await (0, menu_1.runRegisteredAction)(actionId))) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
if (explicitHandleAction) {
|
|
34
|
+
return explicitHandleAction(params, ctx);
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
},
|
|
38
|
+
},
|
|
12
39
|
vcs: definition.vcs,
|
|
13
40
|
implements: definition.implements,
|
|
14
41
|
logTarget: definition.logTarget,
|
package/package.json
CHANGED
package/src/lib/runtime/menu.ts
CHANGED
|
@@ -2,31 +2,231 @@
|
|
|
2
2
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
3
3
|
|
|
4
4
|
import type { MenubarItem } from '../types/menubar.js';
|
|
5
|
+
import type {
|
|
6
|
+
PluginDelegates,
|
|
7
|
+
PluginHandleActionParams,
|
|
8
|
+
PluginMenuDefinition,
|
|
9
|
+
} from '../types/plugin.js';
|
|
10
|
+
|
|
11
|
+
import type { PluginRuntimeContext } from './contracts.js';
|
|
5
12
|
|
|
6
13
|
type MenubarMenuOptions = { before?: string; after?: string };
|
|
14
|
+
type MenuEntryKind = 'button' | 'text' | 'separator';
|
|
7
15
|
|
|
8
16
|
type OpenVCSGlobal = typeof globalThis & {
|
|
9
17
|
OpenVCS?: {
|
|
10
|
-
menus?: {
|
|
11
|
-
get(menuId: string): unknown;
|
|
12
|
-
getOrCreate(menuId: string, label: string): unknown;
|
|
13
|
-
create(menuId: string, label: string, options?: MenubarMenuOptions): unknown;
|
|
14
|
-
addMenuItem(menuId: string, item: MenubarItem): void;
|
|
15
|
-
addMenuSeparator(menuId: string, beforeAction?: string): void;
|
|
16
|
-
remove(menuId: string): void;
|
|
17
|
-
hide(menuId: string): void;
|
|
18
|
-
show(menuId: string): void;
|
|
19
|
-
};
|
|
20
|
-
registerAction(id: string, handler: (...args: unknown[]) => unknown): void;
|
|
21
18
|
invoke<T = unknown>(cmd: string, args?: unknown): Promise<T>;
|
|
22
19
|
notify(msg: string): void;
|
|
23
20
|
};
|
|
24
21
|
};
|
|
25
22
|
|
|
23
|
+
interface StoredMenuItem {
|
|
24
|
+
kind: MenuEntryKind;
|
|
25
|
+
id: string;
|
|
26
|
+
label: string;
|
|
27
|
+
title?: string;
|
|
28
|
+
content?: string;
|
|
29
|
+
action?: string;
|
|
30
|
+
hidden?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface StoredMenuState {
|
|
34
|
+
id: string;
|
|
35
|
+
label: string;
|
|
36
|
+
hidden?: boolean;
|
|
37
|
+
items: StoredMenuItem[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface SerializedMenuItem {
|
|
41
|
+
type: 'button' | 'text';
|
|
42
|
+
id: string;
|
|
43
|
+
label?: string;
|
|
44
|
+
content?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface SerializedMenuDefinition {
|
|
48
|
+
id: string;
|
|
49
|
+
label: string;
|
|
50
|
+
order: number;
|
|
51
|
+
elements: SerializedMenuItem[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const menus = new Map<string, StoredMenuState>();
|
|
55
|
+
const menuOrder: string[] = [];
|
|
56
|
+
const actionHandlers = new Map<string, (...args: unknown[]) => unknown>();
|
|
57
|
+
let syntheticId = 0;
|
|
58
|
+
|
|
59
|
+
/** Returns the host-side OpenVCS helper, when the environment provides one. */
|
|
26
60
|
function getOpenVCS() {
|
|
27
61
|
return (globalThis as OpenVCSGlobal).OpenVCS;
|
|
28
62
|
}
|
|
29
63
|
|
|
64
|
+
/** Normalizes a menu id for stable map lookup. */
|
|
65
|
+
function normalizeMenuId(menuId: string): string {
|
|
66
|
+
return String(menuId || '').trim();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Allocates a stable synthetic id for generated menu entries. */
|
|
70
|
+
function allocateSyntheticId(prefix: string): string {
|
|
71
|
+
syntheticId += 1;
|
|
72
|
+
return `${prefix}-${syntheticId}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Returns the stored menu state for one id, if present. */
|
|
76
|
+
function getStoredMenu(menuId: string): StoredMenuState | null {
|
|
77
|
+
return menus.get(normalizeMenuId(menuId)) || null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Removes one menu id from the ordering list. */
|
|
81
|
+
function removeMenuId(menuId: string): void {
|
|
82
|
+
const id = normalizeMenuId(menuId);
|
|
83
|
+
const index = menuOrder.indexOf(id);
|
|
84
|
+
if (index >= 0) menuOrder.splice(index, 1);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Inserts one menu id into the ordering list. */
|
|
88
|
+
function placeMenuId(menuId: string, options?: MenubarMenuOptions): void {
|
|
89
|
+
const id = normalizeMenuId(menuId);
|
|
90
|
+
removeMenuId(id);
|
|
91
|
+
|
|
92
|
+
const beforeId = normalizeMenuId(options?.before || '');
|
|
93
|
+
const afterId = normalizeMenuId(options?.after || '');
|
|
94
|
+
|
|
95
|
+
if (afterId) {
|
|
96
|
+
const afterIndex = menuOrder.indexOf(afterId);
|
|
97
|
+
if (afterIndex >= 0) {
|
|
98
|
+
menuOrder.splice(afterIndex + 1, 0, id);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (beforeId) {
|
|
104
|
+
const beforeIndex = menuOrder.indexOf(beforeId);
|
|
105
|
+
if (beforeIndex >= 0) {
|
|
106
|
+
menuOrder.splice(beforeIndex, 0, id);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
menuOrder.push(id);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Ensures a menu record exists for one id. */
|
|
115
|
+
function ensureStoredMenu(
|
|
116
|
+
menuId: string,
|
|
117
|
+
label: string,
|
|
118
|
+
options?: MenubarMenuOptions,
|
|
119
|
+
): StoredMenuState {
|
|
120
|
+
const id = normalizeMenuId(menuId);
|
|
121
|
+
const safeLabel = String(label || '').trim() || id;
|
|
122
|
+
let menu = menus.get(id);
|
|
123
|
+
|
|
124
|
+
if (!menu) {
|
|
125
|
+
menu = { id, label: safeLabel, items: [] };
|
|
126
|
+
menus.set(id, menu);
|
|
127
|
+
} else {
|
|
128
|
+
menu.label = safeLabel;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
placeMenuId(id, options);
|
|
132
|
+
return menu;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Finds a stored item by action id. */
|
|
136
|
+
function findStoredItem(menu: StoredMenuState, actionId: string): StoredMenuItem | null {
|
|
137
|
+
const id = normalizeMenuId(actionId);
|
|
138
|
+
return menu.items.find((item) => item.action === id) || null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Inserts a menu item at the requested position. */
|
|
142
|
+
function insertMenuItem(menu: StoredMenuState, item: StoredMenuItem, before?: string, after?: string): void {
|
|
143
|
+
const beforeId = normalizeMenuId(before || '');
|
|
144
|
+
const afterId = normalizeMenuId(after || '');
|
|
145
|
+
|
|
146
|
+
const removeExisting = () => {
|
|
147
|
+
const index = menu.items.findIndex((entry) => entry.id === item.id || entry.action === item.action);
|
|
148
|
+
if (index >= 0) menu.items.splice(index, 1);
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
if (beforeId) {
|
|
152
|
+
const beforeIndex = menu.items.findIndex((entry) => entry.action === beforeId);
|
|
153
|
+
if (beforeIndex >= 0) {
|
|
154
|
+
removeExisting();
|
|
155
|
+
menu.items.splice(beforeIndex, 0, item);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (afterId) {
|
|
161
|
+
const afterIndex = menu.items.findIndex((entry) => entry.action === afterId);
|
|
162
|
+
if (afterIndex >= 0) {
|
|
163
|
+
removeExisting();
|
|
164
|
+
menu.items.splice(afterIndex + 1, 0, item);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
removeExisting();
|
|
170
|
+
menu.items.push(item);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Converts one stored item into a serializable menu payload element. */
|
|
174
|
+
function serializeMenuItem(item: StoredMenuItem): SerializedMenuItem | null {
|
|
175
|
+
if (item.hidden) return null;
|
|
176
|
+
|
|
177
|
+
if (item.kind === 'separator') {
|
|
178
|
+
return {
|
|
179
|
+
type: 'text',
|
|
180
|
+
id: item.id,
|
|
181
|
+
content: item.content || '—',
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (item.kind === 'text') {
|
|
186
|
+
return {
|
|
187
|
+
type: 'text',
|
|
188
|
+
id: item.id,
|
|
189
|
+
content: item.content || item.label,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
type: 'button',
|
|
195
|
+
id: item.action || item.id,
|
|
196
|
+
label: item.label,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Serializes the local registry into plugin menu payloads. */
|
|
201
|
+
function serializeMenus(): SerializedMenuDefinition[] {
|
|
202
|
+
return menuOrder
|
|
203
|
+
.map((menuId, index) => {
|
|
204
|
+
const menu = menus.get(menuId);
|
|
205
|
+
if (!menu || menu.hidden) return null;
|
|
206
|
+
return {
|
|
207
|
+
id: menu.id,
|
|
208
|
+
label: menu.label,
|
|
209
|
+
order: index + 1,
|
|
210
|
+
elements: menu.items
|
|
211
|
+
.map((item) => serializeMenuItem(item))
|
|
212
|
+
.filter((item): item is SerializedMenuItem => Boolean(item)),
|
|
213
|
+
};
|
|
214
|
+
})
|
|
215
|
+
.filter((menu): menu is SerializedMenuDefinition => Boolean(menu));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Runs a registered action handler by id. */
|
|
219
|
+
export async function runRegisteredAction(actionId: string, ...args: unknown[]): Promise<boolean> {
|
|
220
|
+
const id = String(actionId || '').trim();
|
|
221
|
+
if (!id) return false;
|
|
222
|
+
|
|
223
|
+
const handler = actionHandlers.get(id);
|
|
224
|
+
if (!handler) return false;
|
|
225
|
+
|
|
226
|
+
await handler(...args);
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
|
|
30
230
|
export interface MenuHandle {
|
|
31
231
|
id: string;
|
|
32
232
|
addItem(item: MenubarItem): void;
|
|
@@ -36,68 +236,138 @@ export interface MenuHandle {
|
|
|
36
236
|
showItem(actionId: string): void;
|
|
37
237
|
}
|
|
38
238
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
239
|
+
/** Creates a stable handle for one stored menu. */
|
|
240
|
+
function createMenuHandle(menuId: string): MenuHandle {
|
|
241
|
+
const id = normalizeMenuId(menuId);
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
id,
|
|
245
|
+
addItem(item: MenubarItem) {
|
|
246
|
+
const label = String(item?.label || '').trim();
|
|
247
|
+
const action = String(item?.action || '').trim();
|
|
248
|
+
if (!label || !action) return;
|
|
249
|
+
|
|
250
|
+
const menu = getStoredMenu(this.id) || ensureStoredMenu(this.id, this.id);
|
|
251
|
+
insertMenuItem(menu, {
|
|
252
|
+
kind: 'button',
|
|
253
|
+
id: action,
|
|
254
|
+
label,
|
|
255
|
+
title: item.title,
|
|
256
|
+
action,
|
|
257
|
+
}, item.before, item.after);
|
|
258
|
+
},
|
|
259
|
+
addSeparator(beforeAction?: string) {
|
|
260
|
+
const menu = getStoredMenu(this.id) || ensureStoredMenu(this.id, this.id);
|
|
261
|
+
insertMenuItem(menu, {
|
|
262
|
+
kind: 'separator',
|
|
263
|
+
id: allocateSyntheticId(`${menu.id}-separator`),
|
|
264
|
+
label: 'Separator',
|
|
265
|
+
content: '—',
|
|
266
|
+
}, beforeAction);
|
|
267
|
+
},
|
|
268
|
+
removeItem(actionId: string) {
|
|
269
|
+
const menu = getStoredMenu(this.id);
|
|
270
|
+
if (!menu) return;
|
|
271
|
+
const idToRemove = normalizeMenuId(actionId);
|
|
272
|
+
menu.items = menu.items.filter((item) => item.action !== idToRemove);
|
|
273
|
+
},
|
|
274
|
+
hideItem(actionId: string) {
|
|
275
|
+
const menu = getStoredMenu(this.id);
|
|
276
|
+
if (!menu) return;
|
|
277
|
+
const item = findStoredItem(menu, actionId);
|
|
278
|
+
if (item) item.hidden = true;
|
|
279
|
+
},
|
|
280
|
+
showItem(actionId: string) {
|
|
281
|
+
const menu = getStoredMenu(this.id);
|
|
282
|
+
if (!menu) return;
|
|
283
|
+
const item = findStoredItem(menu, actionId);
|
|
284
|
+
if (item) item.hidden = false;
|
|
285
|
+
},
|
|
286
|
+
};
|
|
42
287
|
}
|
|
43
288
|
|
|
289
|
+
/** Returns a menu by id, or null when it does not exist. */
|
|
44
290
|
export function getMenu(menuId: string): MenuHandle | null {
|
|
45
|
-
const
|
|
46
|
-
if (!
|
|
47
|
-
return
|
|
291
|
+
const stored = getStoredMenu(menuId);
|
|
292
|
+
if (!stored) return null;
|
|
293
|
+
return createMenuHandle(stored.id);
|
|
48
294
|
}
|
|
49
295
|
|
|
296
|
+
/** Returns a menu by id, creating it if needed. */
|
|
50
297
|
export function getOrCreateMenu(menuId: string, label: string): MenuHandle | null {
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
return castMenuHandle(openvcs.menus.getOrCreate(menuId, label));
|
|
298
|
+
const stored = ensureStoredMenu(menuId, label);
|
|
299
|
+
return createMenuHandle(stored.id);
|
|
54
300
|
}
|
|
55
301
|
|
|
302
|
+
/** Creates a menu at a specific position. */
|
|
56
303
|
export function createMenu(menuId: string, label: string, options?: MenubarMenuOptions): MenuHandle | null {
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
return castMenuHandle(openvcs.menus.create(menuId, label, options));
|
|
304
|
+
const stored = ensureStoredMenu(menuId, label, options);
|
|
305
|
+
return createMenuHandle(stored.id);
|
|
60
306
|
}
|
|
61
307
|
|
|
308
|
+
/** Adds one item to a menu. */
|
|
62
309
|
export function addMenuItem(menuId: string, item: MenubarItem): void {
|
|
63
|
-
|
|
64
|
-
openvcs?.menus?.addMenuItem(menuId, item);
|
|
310
|
+
createMenuHandle(menuId).addItem(item);
|
|
65
311
|
}
|
|
66
312
|
|
|
313
|
+
/** Adds one separator to a menu. */
|
|
67
314
|
export function addMenuSeparator(menuId: string, beforeAction?: string): void {
|
|
68
|
-
|
|
69
|
-
openvcs?.menus?.addMenuSeparator(menuId, beforeAction);
|
|
315
|
+
createMenuHandle(menuId).addSeparator(beforeAction);
|
|
70
316
|
}
|
|
71
317
|
|
|
318
|
+
/** Removes one menu from the registry. */
|
|
72
319
|
export function removeMenu(menuId: string): void {
|
|
73
|
-
const
|
|
74
|
-
|
|
320
|
+
const id = normalizeMenuId(menuId);
|
|
321
|
+
menus.delete(id);
|
|
322
|
+
removeMenuId(id);
|
|
75
323
|
}
|
|
76
324
|
|
|
325
|
+
/** Hides one menu from the registry. */
|
|
77
326
|
export function hideMenu(menuId: string): void {
|
|
78
|
-
const
|
|
79
|
-
|
|
327
|
+
const menu = getStoredMenu(menuId);
|
|
328
|
+
if (menu) menu.hidden = true;
|
|
80
329
|
}
|
|
81
330
|
|
|
331
|
+
/** Shows one menu from the registry. */
|
|
82
332
|
export function showMenu(menuId: string): void {
|
|
83
|
-
const
|
|
84
|
-
|
|
333
|
+
const menu = getStoredMenu(menuId);
|
|
334
|
+
if (menu) menu.hidden = false;
|
|
85
335
|
}
|
|
86
336
|
|
|
337
|
+
/** Registers an action handler by id. */
|
|
87
338
|
export function registerAction(id: string, handler: (...args: unknown[]) => unknown): void {
|
|
88
|
-
const
|
|
89
|
-
|
|
339
|
+
const key = String(id || '').trim();
|
|
340
|
+
if (!key) return;
|
|
341
|
+
actionHandlers.set(key, handler);
|
|
90
342
|
}
|
|
91
343
|
|
|
344
|
+
/** Invokes a host command when a host helper is available. */
|
|
92
345
|
export function invoke<T = unknown>(cmd: string, args?: unknown): Promise<T> {
|
|
93
346
|
const openvcs = getOpenVCS();
|
|
94
347
|
if (!openvcs) {
|
|
95
|
-
return Promise.reject(new Error('OpenVCS not available'));
|
|
348
|
+
return Promise.reject(new Error('OpenVCS host is not available in this runtime'));
|
|
96
349
|
}
|
|
97
350
|
return openvcs.invoke(cmd, args);
|
|
98
351
|
}
|
|
99
352
|
|
|
353
|
+
/** Emits a notification when the host helper is available. */
|
|
100
354
|
export function notify(msg: string): void {
|
|
101
355
|
const openvcs = getOpenVCS();
|
|
102
356
|
openvcs?.notify(msg);
|
|
103
357
|
}
|
|
358
|
+
|
|
359
|
+
/** Builds SDK delegates from the local menu/action registries. */
|
|
360
|
+
export function createMenuPluginDelegates(): PluginDelegates<PluginRuntimeContext> {
|
|
361
|
+
return {
|
|
362
|
+
async 'plugin.get_menus'(): Promise<PluginMenuDefinition[]> {
|
|
363
|
+
return serializeMenus() as unknown as PluginMenuDefinition[];
|
|
364
|
+
},
|
|
365
|
+
async 'plugin.handle_action'(params: PluginHandleActionParams): Promise<null> {
|
|
366
|
+
const actionId = String(params?.action_id || '').trim();
|
|
367
|
+
if (actionId) {
|
|
368
|
+
await runRegisteredAction(actionId);
|
|
369
|
+
}
|
|
370
|
+
return null;
|
|
371
|
+
},
|
|
372
|
+
};
|
|
373
|
+
}
|
|
@@ -14,6 +14,10 @@ import type {
|
|
|
14
14
|
PluginRuntimeTransport,
|
|
15
15
|
} from './contracts';
|
|
16
16
|
import { createPluginRuntime } from './factory';
|
|
17
|
+
import {
|
|
18
|
+
createMenuPluginDelegates,
|
|
19
|
+
runRegisteredAction,
|
|
20
|
+
} from './menu';
|
|
17
21
|
|
|
18
22
|
/** Describes the plugin module startup hook invoked by the generated bootstrap. */
|
|
19
23
|
export type OnPluginStartHandler = () => void | Promise<void>;
|
|
@@ -56,8 +60,35 @@ export interface BootstrapPluginModuleOptions {
|
|
|
56
60
|
export function createRegisteredPluginRuntime(
|
|
57
61
|
definition: PluginModuleDefinition = {},
|
|
58
62
|
): PluginRuntime {
|
|
63
|
+
const menuDelegates = createMenuPluginDelegates();
|
|
64
|
+
const explicitPluginDelegates = definition.plugin ?? {};
|
|
65
|
+
const explicitGetMenus = explicitPluginDelegates['plugin.get_menus'];
|
|
66
|
+
const explicitHandleAction = explicitPluginDelegates['plugin.handle_action'];
|
|
67
|
+
|
|
59
68
|
const options: CreatePluginRuntimeOptions = {
|
|
60
|
-
plugin:
|
|
69
|
+
plugin: {
|
|
70
|
+
...explicitPluginDelegates,
|
|
71
|
+
'plugin.get_menus': async (params, ctx) => {
|
|
72
|
+
const menus = await menuDelegates['plugin.get_menus']?.(params, ctx);
|
|
73
|
+
const explicitMenus = explicitGetMenus
|
|
74
|
+
? await explicitGetMenus(params, ctx)
|
|
75
|
+
: [];
|
|
76
|
+
return [
|
|
77
|
+
...(Array.isArray(explicitMenus) ? explicitMenus : []),
|
|
78
|
+
...(Array.isArray(menus) ? menus : []),
|
|
79
|
+
];
|
|
80
|
+
},
|
|
81
|
+
'plugin.handle_action': async (params, ctx) => {
|
|
82
|
+
const actionId = String(params?.action_id || '').trim();
|
|
83
|
+
if (actionId && (await runRegisteredAction(actionId))) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
if (explicitHandleAction) {
|
|
87
|
+
return explicitHandleAction(params, ctx);
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
},
|
|
91
|
+
},
|
|
61
92
|
vcs: definition.vcs,
|
|
62
93
|
implements: definition.implements,
|
|
63
94
|
logTarget: definition.logTarget,
|