@openvcs/sdk 0.2.18-nightly.20260421.22 → 0.3.0-beta.34
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/index.d.ts +1 -1
- package/lib/runtime/index.js +2 -1
- package/lib/runtime/menu.d.ts +26 -9
- package/lib/runtime/menu.js +71 -26
- package/lib/runtime/registration.js +5 -2
- package/lib/runtime/vcs-delegate-base.d.ts +2 -0
- package/lib/runtime/vcs-delegate-base.js +4 -0
- package/lib/runtime/vcs-delegate-metadata.d.ts +2 -0
- package/lib/runtime/vcs-delegate-metadata.js +1 -0
- package/lib/types/menubar.d.ts +2 -2
- package/lib/types/plugin.d.ts +35 -4
- package/lib/types/vcs.d.ts +7 -0
- package/package.json +2 -2
- package/src/lib/runtime/index.ts +1 -0
- package/src/lib/runtime/menu.ts +87 -45
- package/src/lib/runtime/registration.ts +9 -2
- package/src/lib/runtime/vcs-delegate-base.ts +8 -0
- package/src/lib/runtime/vcs-delegate-metadata.ts +2 -0
- package/src/lib/types/menubar.ts +2 -2
- package/src/lib/types/plugin.ts +39 -4
- package/src/lib/types/vcs.ts +8 -0
- package/test/runtime.test.js +321 -2
package/lib/runtime/index.d.ts
CHANGED
|
@@ -9,7 +9,7 @@ export { ModalBuilder } from './modal';
|
|
|
9
9
|
export { bootstrapPluginModule, createRegisteredPluginRuntime, } from './registration';
|
|
10
10
|
export { VcsDelegateBase } from './vcs-delegate-base';
|
|
11
11
|
export type { VcsDelegateAssignments } from './vcs-delegate-metadata';
|
|
12
|
-
export { getMenu, getOrCreateMenu, createMenu, addMenuItem, addMenuSeparator, removeMenu, hideMenu, showMenu, registerAction, invoke, notify, } from './menu';
|
|
12
|
+
export { getMenu, getOrCreateMenu, createMenu, addMenuItem, addMenuSeparator, removeMenu, hideMenu, showMenu, registerAction, resetMenuRegistry, invoke, notify, } from './menu';
|
|
13
13
|
export type { MenuHandle } from './menu';
|
|
14
14
|
/** Starts a previously created plugin runtime on process stdio. */
|
|
15
15
|
export declare function startPluginRuntime(runtime: PluginRuntime, transport?: PluginRuntimeTransport): void;
|
package/lib/runtime/index.js
CHANGED
|
@@ -2,7 +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.notify = exports.invoke = exports.registerAction = exports.showMenu = exports.hideMenu = exports.removeMenu = exports.addMenuSeparator = exports.addMenuItem = exports.createMenu = exports.getOrCreateMenu = exports.getMenu = exports.VcsDelegateBase = exports.createRegisteredPluginRuntime = exports.bootstrapPluginModule = exports.ModalBuilder = exports.createHost = exports.createPluginRuntime = exports.pluginError = exports.isPluginFailure = exports.createRuntimeDispatcher = exports.createDefaultPluginDelegates = void 0;
|
|
5
|
+
exports.notify = exports.invoke = exports.resetMenuRegistry = exports.registerAction = exports.showMenu = exports.hideMenu = exports.removeMenu = exports.addMenuSeparator = exports.addMenuItem = exports.createMenu = exports.getOrCreateMenu = exports.getMenu = exports.VcsDelegateBase = exports.createRegisteredPluginRuntime = exports.bootstrapPluginModule = exports.ModalBuilder = exports.createHost = exports.createPluginRuntime = exports.pluginError = exports.isPluginFailure = exports.createRuntimeDispatcher = exports.createDefaultPluginDelegates = void 0;
|
|
6
6
|
exports.startPluginRuntime = startPluginRuntime;
|
|
7
7
|
var dispatcher_1 = require("./dispatcher");
|
|
8
8
|
Object.defineProperty(exports, "createDefaultPluginDelegates", { enumerable: true, get: function () { return dispatcher_1.createDefaultPluginDelegates; } });
|
|
@@ -31,6 +31,7 @@ Object.defineProperty(exports, "removeMenu", { enumerable: true, get: function (
|
|
|
31
31
|
Object.defineProperty(exports, "hideMenu", { enumerable: true, get: function () { return menu_1.hideMenu; } });
|
|
32
32
|
Object.defineProperty(exports, "showMenu", { enumerable: true, get: function () { return menu_1.showMenu; } });
|
|
33
33
|
Object.defineProperty(exports, "registerAction", { enumerable: true, get: function () { return menu_1.registerAction; } });
|
|
34
|
+
Object.defineProperty(exports, "resetMenuRegistry", { enumerable: true, get: function () { return menu_1.resetMenuRegistry; } });
|
|
34
35
|
Object.defineProperty(exports, "invoke", { enumerable: true, get: function () { return menu_1.invoke; } });
|
|
35
36
|
Object.defineProperty(exports, "notify", { enumerable: true, get: function () { return menu_1.notify; } });
|
|
36
37
|
/** Starts a previously created plugin runtime on process stdio. */
|
package/lib/runtime/menu.d.ts
CHANGED
|
@@ -1,30 +1,47 @@
|
|
|
1
1
|
import type { MenubarItem } from '../types/menubar.js';
|
|
2
|
-
import type { PluginDelegates } from '../types/plugin.js';
|
|
2
|
+
import type { PluginDelegates, PluginHandleActionParams } from '../types/plugin.js';
|
|
3
3
|
import type { PluginRuntimeContext } from './contracts.js';
|
|
4
4
|
type MenubarMenuOptions = {
|
|
5
5
|
before?: string;
|
|
6
6
|
after?: string;
|
|
7
7
|
};
|
|
8
|
+
/** UI surface targeted by a menu: 'menubar' (top-level bar) or 'settings' (preferences panel). */
|
|
9
|
+
type MenuSurface = 'menubar' | 'settings';
|
|
10
|
+
/** Validates and returns the incoming plugin action id. */
|
|
11
|
+
export declare function requireActionId(params: PluginHandleActionParams): string;
|
|
12
|
+
/** Clears all registered menus and action handlers.
|
|
13
|
+
* @internal
|
|
14
|
+
* Called by bootstrap to prevent state leaking between in-process plugin setup runs.
|
|
15
|
+
*/
|
|
16
|
+
export declare function resetMenuRegistry(): void;
|
|
17
|
+
/** Returns whether one registered action handler exists. */
|
|
18
|
+
export declare function hasRegisteredAction(actionId: string): boolean;
|
|
8
19
|
/** Runs a registered action handler by id. */
|
|
9
20
|
export declare function runRegisteredAction(actionId: string, ...args: unknown[]): Promise<unknown>;
|
|
10
21
|
export interface MenuHandle {
|
|
11
22
|
id: string;
|
|
12
23
|
addItem(item: MenubarItem): void;
|
|
13
|
-
addSeparator(beforeAction?: string): void;
|
|
24
|
+
addSeparator(beforeAction?: string, afterAction?: string): void;
|
|
14
25
|
removeItem(actionId: string): void;
|
|
15
26
|
hideItem(actionId: string): void;
|
|
16
27
|
showItem(actionId: string): void;
|
|
17
28
|
}
|
|
18
29
|
/** Returns a menu by id, or null when it does not exist. */
|
|
19
30
|
export declare function getMenu(menuId: string): MenuHandle | null;
|
|
20
|
-
/** Returns a menu by id, creating it if needed.
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
31
|
+
/** Returns a menu by id, creating it if needed.
|
|
32
|
+
* @param menuId - Menu identifier.
|
|
33
|
+
* @param label - User-visible label.
|
|
34
|
+
* @param options - Placement options, including the required `surface` so the runtime can serialize the menu for the correct host UI surface.
|
|
35
|
+
*/
|
|
36
|
+
export declare function getOrCreateMenu(menuId: string, label: string, options: MenubarMenuOptions & {
|
|
37
|
+
surface: MenuSurface;
|
|
38
|
+
}): MenuHandle | null;
|
|
39
|
+
/** Creates a menu at a specific position (alias for getOrCreateMenu). */
|
|
40
|
+
export declare const createMenu: typeof getOrCreateMenu;
|
|
41
|
+
/** Adds one item to a menu, silently no-op if the menu does not exist. */
|
|
25
42
|
export declare function addMenuItem(menuId: string, item: MenubarItem): void;
|
|
26
|
-
/** Adds one separator to a menu. */
|
|
27
|
-
export declare function addMenuSeparator(menuId: string, beforeAction?: string): void;
|
|
43
|
+
/** Adds one separator to a menu, silently no-op if the menu does not exist. */
|
|
44
|
+
export declare function addMenuSeparator(menuId: string, beforeAction?: string, afterAction?: string): void;
|
|
28
45
|
/** Removes one menu from the registry. */
|
|
29
46
|
export declare function removeMenu(menuId: string): void;
|
|
30
47
|
/** Hides one menu from the registry. */
|
package/lib/runtime/menu.js
CHANGED
|
@@ -2,10 +2,13 @@
|
|
|
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.createMenu = void 0;
|
|
6
|
+
exports.requireActionId = requireActionId;
|
|
7
|
+
exports.resetMenuRegistry = resetMenuRegistry;
|
|
8
|
+
exports.hasRegisteredAction = hasRegisteredAction;
|
|
5
9
|
exports.runRegisteredAction = runRegisteredAction;
|
|
6
10
|
exports.getMenu = getMenu;
|
|
7
11
|
exports.getOrCreateMenu = getOrCreateMenu;
|
|
8
|
-
exports.createMenu = createMenu;
|
|
9
12
|
exports.addMenuItem = addMenuItem;
|
|
10
13
|
exports.addMenuSeparator = addMenuSeparator;
|
|
11
14
|
exports.removeMenu = removeMenu;
|
|
@@ -15,10 +18,29 @@ exports.registerAction = registerAction;
|
|
|
15
18
|
exports.invoke = invoke;
|
|
16
19
|
exports.notify = notify;
|
|
17
20
|
exports.createMenuPluginDelegates = createMenuPluginDelegates;
|
|
21
|
+
const errors_js_1 = require("./errors.js");
|
|
18
22
|
const menus = new Map();
|
|
19
23
|
const menuOrder = [];
|
|
20
24
|
const actionHandlers = new Map();
|
|
21
25
|
let syntheticId = 0;
|
|
26
|
+
/** Validates and returns the incoming plugin action id. */
|
|
27
|
+
function requireActionId(params) {
|
|
28
|
+
const actionId = typeof params?.action_id === 'string' ? params.action_id.trim() : '';
|
|
29
|
+
if (!actionId) {
|
|
30
|
+
throw (0, errors_js_1.pluginError)('plugin-invalid-action-id', 'plugin.handle_action requires params.action_id to be a non-empty string');
|
|
31
|
+
}
|
|
32
|
+
return actionId;
|
|
33
|
+
}
|
|
34
|
+
/** Clears all registered menus and action handlers.
|
|
35
|
+
* @internal
|
|
36
|
+
* Called by bootstrap to prevent state leaking between in-process plugin setup runs.
|
|
37
|
+
*/
|
|
38
|
+
function resetMenuRegistry() {
|
|
39
|
+
menus.clear();
|
|
40
|
+
menuOrder.splice(0, menuOrder.length);
|
|
41
|
+
actionHandlers.clear();
|
|
42
|
+
syntheticId = 0;
|
|
43
|
+
}
|
|
22
44
|
/** Returns the host-side OpenVCS helper, when the environment provides one. */
|
|
23
45
|
function getOpenVCS() {
|
|
24
46
|
return globalThis.OpenVCS;
|
|
@@ -66,16 +88,17 @@ function placeMenuId(menuId, options) {
|
|
|
66
88
|
menuOrder.push(id);
|
|
67
89
|
}
|
|
68
90
|
/** Ensures a menu record exists for one id. */
|
|
69
|
-
function ensureStoredMenu(menuId, label, options) {
|
|
91
|
+
function ensureStoredMenu(menuId, label, options, surface) {
|
|
70
92
|
const id = normalizeMenuId(menuId);
|
|
71
93
|
const safeLabel = String(label || '').trim() || id;
|
|
72
94
|
let menu = menus.get(id);
|
|
73
95
|
if (!menu) {
|
|
74
|
-
menu = { id, label: safeLabel, items: [] };
|
|
96
|
+
menu = { id, label: safeLabel, surface, items: [] };
|
|
75
97
|
menus.set(id, menu);
|
|
76
98
|
}
|
|
77
99
|
else {
|
|
78
100
|
menu.label = safeLabel;
|
|
101
|
+
menu.surface = surface;
|
|
79
102
|
}
|
|
80
103
|
placeMenuId(id, options);
|
|
81
104
|
return menu;
|
|
@@ -148,6 +171,7 @@ function serializeMenus() {
|
|
|
148
171
|
id: menu.id,
|
|
149
172
|
label: menu.label,
|
|
150
173
|
order: index + 1,
|
|
174
|
+
surface: menu.surface,
|
|
151
175
|
elements: menu.items
|
|
152
176
|
.map((item) => serializeMenuItem(item))
|
|
153
177
|
.filter((item) => Boolean(item)),
|
|
@@ -155,6 +179,13 @@ function serializeMenus() {
|
|
|
155
179
|
})
|
|
156
180
|
.filter((menu) => Boolean(menu));
|
|
157
181
|
}
|
|
182
|
+
/** Returns whether one registered action handler exists. */
|
|
183
|
+
function hasRegisteredAction(actionId) {
|
|
184
|
+
const id = String(actionId || '').trim();
|
|
185
|
+
if (!id)
|
|
186
|
+
return false;
|
|
187
|
+
return actionHandlers.has(id);
|
|
188
|
+
}
|
|
158
189
|
/** Runs a registered action handler by id. */
|
|
159
190
|
async function runRegisteredAction(actionId, ...args) {
|
|
160
191
|
const id = String(actionId || '').trim();
|
|
@@ -175,7 +206,11 @@ function createMenuHandle(menuId) {
|
|
|
175
206
|
const action = String(item?.action || '').trim();
|
|
176
207
|
if (!label || !action)
|
|
177
208
|
return;
|
|
178
|
-
|
|
209
|
+
let menu = getStoredMenu(this.id);
|
|
210
|
+
if (!menu) {
|
|
211
|
+
// Default to menubar for internal menu handle operations.
|
|
212
|
+
menu = ensureStoredMenu(this.id, this.id, {}, 'menubar');
|
|
213
|
+
}
|
|
179
214
|
insertMenuItem(menu, {
|
|
180
215
|
kind: 'button',
|
|
181
216
|
id: action,
|
|
@@ -184,14 +219,18 @@ function createMenuHandle(menuId) {
|
|
|
184
219
|
action,
|
|
185
220
|
}, item.before, item.after);
|
|
186
221
|
},
|
|
187
|
-
addSeparator(beforeAction) {
|
|
188
|
-
|
|
222
|
+
addSeparator(beforeAction, afterAction) {
|
|
223
|
+
let menu = getStoredMenu(this.id);
|
|
224
|
+
if (!menu) {
|
|
225
|
+
// Default to menubar for internal menu handle operations.
|
|
226
|
+
menu = ensureStoredMenu(this.id, this.id, {}, 'menubar');
|
|
227
|
+
}
|
|
189
228
|
insertMenuItem(menu, {
|
|
190
229
|
kind: 'separator',
|
|
191
230
|
id: allocateSyntheticId(`${menu.id}-separator`),
|
|
192
231
|
label: 'Separator',
|
|
193
232
|
content: '—',
|
|
194
|
-
}, beforeAction);
|
|
233
|
+
}, beforeAction, afterAction);
|
|
195
234
|
},
|
|
196
235
|
removeItem(actionId) {
|
|
197
236
|
const menu = getStoredMenu(this.id);
|
|
@@ -225,23 +264,30 @@ function getMenu(menuId) {
|
|
|
225
264
|
return null;
|
|
226
265
|
return createMenuHandle(stored.id);
|
|
227
266
|
}
|
|
228
|
-
/** Returns a menu by id, creating it if needed.
|
|
229
|
-
|
|
230
|
-
|
|
267
|
+
/** Returns a menu by id, creating it if needed.
|
|
268
|
+
* @param menuId - Menu identifier.
|
|
269
|
+
* @param label - User-visible label.
|
|
270
|
+
* @param options - Placement options, including the required `surface` so the runtime can serialize the menu for the correct host UI surface.
|
|
271
|
+
*/
|
|
272
|
+
function getOrCreateMenu(menuId, label, options) {
|
|
273
|
+
const { surface, ...restOptions } = options;
|
|
274
|
+
const stored = ensureStoredMenu(menuId, label, restOptions, surface);
|
|
231
275
|
return createMenuHandle(stored.id);
|
|
232
276
|
}
|
|
233
|
-
/** Creates a menu at a specific position. */
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
return createMenuHandle(stored.id);
|
|
237
|
-
}
|
|
238
|
-
/** Adds one item to a menu. */
|
|
277
|
+
/** Creates a menu at a specific position (alias for getOrCreateMenu). */
|
|
278
|
+
exports.createMenu = getOrCreateMenu;
|
|
279
|
+
/** Adds one item to a menu, silently no-op if the menu does not exist. */
|
|
239
280
|
function addMenuItem(menuId, item) {
|
|
240
|
-
createMenuHandle(menuId)
|
|
281
|
+
const handle = createMenuHandle(menuId);
|
|
282
|
+
if (!getStoredMenu(menuId))
|
|
283
|
+
return;
|
|
284
|
+
handle.addItem(item);
|
|
241
285
|
}
|
|
242
|
-
/** Adds one separator to a menu. */
|
|
243
|
-
function addMenuSeparator(menuId, beforeAction) {
|
|
244
|
-
|
|
286
|
+
/** Adds one separator to a menu, silently no-op if the menu does not exist. */
|
|
287
|
+
function addMenuSeparator(menuId, beforeAction, afterAction) {
|
|
288
|
+
if (!getStoredMenu(menuId))
|
|
289
|
+
return;
|
|
290
|
+
createMenuHandle(menuId).addSeparator(beforeAction, afterAction);
|
|
245
291
|
}
|
|
246
292
|
/** Removes one menu from the registry. */
|
|
247
293
|
function removeMenu(menuId) {
|
|
@@ -279,7 +325,10 @@ function invoke(cmd, args) {
|
|
|
279
325
|
/** Emits a notification when the host helper is available. */
|
|
280
326
|
function notify(msg) {
|
|
281
327
|
const openvcs = getOpenVCS();
|
|
282
|
-
openvcs
|
|
328
|
+
if (!openvcs) {
|
|
329
|
+
throw new Error('OpenVCS host is not available in this runtime');
|
|
330
|
+
}
|
|
331
|
+
openvcs.notify(msg);
|
|
283
332
|
}
|
|
284
333
|
/** Builds SDK delegates from the local menu/action registries. */
|
|
285
334
|
function createMenuPluginDelegates() {
|
|
@@ -288,11 +337,7 @@ function createMenuPluginDelegates() {
|
|
|
288
337
|
return serializeMenus();
|
|
289
338
|
},
|
|
290
339
|
async 'plugin.handle_action'(params) {
|
|
291
|
-
|
|
292
|
-
if (actionId) {
|
|
293
|
-
return await runRegisteredAction(actionId, params?.payload);
|
|
294
|
-
}
|
|
295
|
-
return null;
|
|
340
|
+
return await runRegisteredAction(requireActionId(params), params?.payload);
|
|
296
341
|
},
|
|
297
342
|
};
|
|
298
343
|
}
|
|
@@ -26,8 +26,8 @@ function createRegisteredPluginRuntime(definition = {}) {
|
|
|
26
26
|
];
|
|
27
27
|
},
|
|
28
28
|
'plugin.handle_action': async (params, ctx) => {
|
|
29
|
-
const actionId =
|
|
30
|
-
if (actionId) {
|
|
29
|
+
const actionId = (0, menu_1.requireActionId)(params);
|
|
30
|
+
if ((0, menu_1.hasRegisteredAction)(actionId)) {
|
|
31
31
|
const result = await (0, menu_1.runRegisteredAction)(actionId, params?.payload);
|
|
32
32
|
if (result !== null && result !== undefined) {
|
|
33
33
|
return result;
|
|
@@ -36,6 +36,7 @@ function createRegisteredPluginRuntime(definition = {}) {
|
|
|
36
36
|
if (explicitHandleAction) {
|
|
37
37
|
return explicitHandleAction(params, ctx);
|
|
38
38
|
}
|
|
39
|
+
ctx.host.info(`plugin.handle_action ignored unhandled action_id '${actionId}'`);
|
|
39
40
|
return null;
|
|
40
41
|
},
|
|
41
42
|
},
|
|
@@ -54,6 +55,8 @@ async function bootstrapPluginModule(options) {
|
|
|
54
55
|
if (typeof onPluginStart !== 'function') {
|
|
55
56
|
throw new Error(`plugin module '${options.modulePath}' must export OnPluginStart()`);
|
|
56
57
|
}
|
|
58
|
+
// Reset internal state so repeated in-process setups do not leak menu or action state.
|
|
59
|
+
(0, menu_1.resetMenuRegistry)();
|
|
57
60
|
try {
|
|
58
61
|
await onPluginStart();
|
|
59
62
|
}
|
|
@@ -77,6 +77,8 @@ export declare abstract class VcsDelegateBase<TDeps, TContext = PluginRuntimeCon
|
|
|
77
77
|
writeMergeResult(_params: VcsTypes.VcsWriteMergeResultParams, _context: TContext): VcsHandlerResult<null>;
|
|
78
78
|
/** Handles `vcs.stage_patch`. */
|
|
79
79
|
stagePatch(_params: VcsTypes.VcsStagePatchParams, _context: TContext): VcsHandlerResult<null>;
|
|
80
|
+
/** Handles `vcs.stage_paths`. */
|
|
81
|
+
stagePaths(_params: VcsTypes.VcsStagePathsParams, _context: TContext): VcsHandlerResult<null>;
|
|
80
82
|
/** Handles `vcs.discard_paths`. */
|
|
81
83
|
discardPaths(_params: VcsTypes.VcsDiscardPathsParams, _context: TContext): VcsHandlerResult<null>;
|
|
82
84
|
/** Handles `vcs.apply_reverse_patch`. */
|
|
@@ -157,6 +157,10 @@ class VcsDelegateBase {
|
|
|
157
157
|
stagePatch(_params, _context) {
|
|
158
158
|
return this.unimplemented('stagePatch');
|
|
159
159
|
}
|
|
160
|
+
/** Handles `vcs.stage_paths`. */
|
|
161
|
+
stagePaths(_params, _context) {
|
|
162
|
+
return this.unimplemented('stagePaths');
|
|
163
|
+
}
|
|
160
164
|
/** Handles `vcs.discard_paths`. */
|
|
161
165
|
discardPaths(_params, _context) {
|
|
162
166
|
return this.unimplemented('discardPaths');
|
|
@@ -30,6 +30,7 @@ export type VcsDelegateBindings<TContext> = {
|
|
|
30
30
|
checkoutConflictSide: NonNullable<VcsTypes.VcsDelegates<TContext>['vcs.checkout_conflict_side']>;
|
|
31
31
|
writeMergeResult: NonNullable<VcsTypes.VcsDelegates<TContext>['vcs.write_merge_result']>;
|
|
32
32
|
stagePatch: NonNullable<VcsTypes.VcsDelegates<TContext>['vcs.stage_patch']>;
|
|
33
|
+
stagePaths: NonNullable<VcsTypes.VcsDelegates<TContext>['vcs.stage_paths']>;
|
|
33
34
|
discardPaths: NonNullable<VcsTypes.VcsDelegates<TContext>['vcs.discard_paths']>;
|
|
34
35
|
applyReversePatch: NonNullable<VcsTypes.VcsDelegates<TContext>['vcs.apply_reverse_patch']>;
|
|
35
36
|
deleteBranch: NonNullable<VcsTypes.VcsDelegates<TContext>['vcs.delete_branch']>;
|
|
@@ -85,6 +86,7 @@ export declare const VCS_DELEGATE_METHOD_MAPPINGS: {
|
|
|
85
86
|
readonly checkoutConflictSide: "vcs.checkout_conflict_side";
|
|
86
87
|
readonly writeMergeResult: "vcs.write_merge_result";
|
|
87
88
|
readonly stagePatch: "vcs.stage_patch";
|
|
89
|
+
readonly stagePaths: "vcs.stage_paths";
|
|
88
90
|
readonly discardPaths: "vcs.discard_paths";
|
|
89
91
|
readonly applyReversePatch: "vcs.apply_reverse_patch";
|
|
90
92
|
readonly deleteBranch: "vcs.delete_branch";
|
|
@@ -33,6 +33,7 @@ exports.VCS_DELEGATE_METHOD_MAPPINGS = {
|
|
|
33
33
|
checkoutConflictSide: 'vcs.checkout_conflict_side',
|
|
34
34
|
writeMergeResult: 'vcs.write_merge_result',
|
|
35
35
|
stagePatch: 'vcs.stage_patch',
|
|
36
|
+
stagePaths: 'vcs.stage_paths',
|
|
36
37
|
discardPaths: 'vcs.discard_paths',
|
|
37
38
|
applyReversePatch: 'vcs.apply_reverse_patch',
|
|
38
39
|
deleteBranch: 'vcs.delete_branch',
|
package/lib/types/menubar.d.ts
CHANGED
|
@@ -22,8 +22,8 @@ export interface MenubarMenu {
|
|
|
22
22
|
id: string;
|
|
23
23
|
/** Adds an item to this menu. */
|
|
24
24
|
addItem(item: MenubarItem): void;
|
|
25
|
-
/** Adds a separator to this menu. */
|
|
26
|
-
addSeparator(beforeAction?: string): void;
|
|
25
|
+
/** Adds a separator to this menu before or after an existing action. */
|
|
26
|
+
addSeparator(beforeAction?: string, afterAction?: string): void;
|
|
27
27
|
/** Removes an item by action ID. */
|
|
28
28
|
removeItem(actionId: string): void;
|
|
29
29
|
/** Hides an item by action ID. */
|
package/lib/types/plugin.d.ts
CHANGED
|
@@ -15,15 +15,46 @@ export interface PluginInitializeResult {
|
|
|
15
15
|
}
|
|
16
16
|
/** Describes one optional override returned by a custom initialize handler. */
|
|
17
17
|
export type PluginInitializeOverride = Partial<PluginInitializeResult>;
|
|
18
|
+
/** Describes the UI surface targeted by one plugin menu definition. */
|
|
19
|
+
export type PluginMenuSurface = 'menubar' | 'settings';
|
|
20
|
+
/** Describes one static text element contributed to a plugin menu. */
|
|
21
|
+
export interface PluginMenuTextElement {
|
|
22
|
+
/** Stores the serialized element kind. */
|
|
23
|
+
type: 'text';
|
|
24
|
+
/** Stores the stable plugin-local element id. */
|
|
25
|
+
id: string;
|
|
26
|
+
/** Stores the rendered text content. */
|
|
27
|
+
content: string;
|
|
28
|
+
}
|
|
29
|
+
/** Describes one button element contributed to a plugin menu. */
|
|
30
|
+
export interface PluginMenuButtonElement {
|
|
31
|
+
/** Stores the serialized element kind. */
|
|
32
|
+
type: 'button';
|
|
33
|
+
/** Stores the stable action id dispatched back to the plugin. */
|
|
34
|
+
id: string;
|
|
35
|
+
/** Stores the user-visible button label. */
|
|
36
|
+
label: string;
|
|
37
|
+
}
|
|
38
|
+
/** Describes one renderable plugin menu element. */
|
|
39
|
+
export type PluginMenuElement = PluginMenuTextElement | PluginMenuButtonElement;
|
|
18
40
|
/** Describes one plugin-contributed menu definition. */
|
|
19
|
-
export
|
|
41
|
+
export interface PluginMenuDefinition {
|
|
42
|
+
/** Stores the stable plugin-local menu id. */
|
|
43
|
+
id: string;
|
|
44
|
+
/** Stores the user-visible menu label. */
|
|
45
|
+
label: string;
|
|
46
|
+
/** Stores the display ordering hint assigned by the runtime. */
|
|
47
|
+
order: number;
|
|
48
|
+
/** Stores the target UI surface for the menu. */
|
|
49
|
+
surface: PluginMenuSurface;
|
|
50
|
+
/** Stores the renderable elements contributed under the menu. */
|
|
51
|
+
elements: PluginMenuElement[];
|
|
52
|
+
}
|
|
20
53
|
/** Describes one plugin settings value payload. */
|
|
21
54
|
export type PluginSettingsValue = Record<string, unknown>;
|
|
22
55
|
/** Describes the params shape for plugin action handling. */
|
|
23
56
|
export interface PluginHandleActionParams extends RequestParams {
|
|
24
|
-
/** Stores the action id selected by the user
|
|
25
|
-
id?: string;
|
|
26
|
-
/** Stores the action id selected by the user. */
|
|
57
|
+
/** Stores the action id selected by the user. Runtime handlers require a non-empty string. */
|
|
27
58
|
action_id?: string;
|
|
28
59
|
/** Stores an optional payload supplied by the triggering UI. */
|
|
29
60
|
payload?: Record<string, unknown>;
|
package/lib/types/vcs.d.ts
CHANGED
|
@@ -261,6 +261,11 @@ export interface VcsStagePatchParams extends VcsSessionParams {
|
|
|
261
261
|
/** Stores the textual patch content. */
|
|
262
262
|
patch: string;
|
|
263
263
|
}
|
|
264
|
+
/** Describes params for staging repository-relative paths into the index. */
|
|
265
|
+
export interface VcsStagePathsParams extends VcsSessionParams {
|
|
266
|
+
/** Stores the paths to stage. */
|
|
267
|
+
paths?: string[];
|
|
268
|
+
}
|
|
264
269
|
/** Describes params for discarding path changes. */
|
|
265
270
|
export interface VcsDiscardPathsParams extends VcsSessionParams {
|
|
266
271
|
/** Stores the paths to discard. */
|
|
@@ -424,6 +429,8 @@ export interface VcsDelegates<TContext = unknown> {
|
|
|
424
429
|
'vcs.write_merge_result'?: RpcMethodHandler<VcsWriteMergeResultParams, null, TContext>;
|
|
425
430
|
/** Handles `vcs.stage_patch`. */
|
|
426
431
|
'vcs.stage_patch'?: RpcMethodHandler<VcsStagePatchParams, null, TContext>;
|
|
432
|
+
/** Handles `vcs.stage_paths`. */
|
|
433
|
+
'vcs.stage_paths'?: RpcMethodHandler<VcsStagePathsParams, null, TContext>;
|
|
427
434
|
/** Handles `vcs.discard_paths`. */
|
|
428
435
|
'vcs.discard_paths'?: RpcMethodHandler<VcsDiscardPathsParams, null, TContext>;
|
|
429
436
|
/** Handles `vcs.apply_reverse_patch`. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openvcs/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0-beta.34",
|
|
4
4
|
"description": "OpenVCS SDK CLI for plugin scaffolding and runtime asset builds",
|
|
5
5
|
"license": "GPL-3.0-or-later",
|
|
6
6
|
"homepage": "https://openvcs.app/",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"@types/node": "^25.3.3",
|
|
43
|
-
"typescript": "^6.0.
|
|
43
|
+
"typescript": "^6.0.3"
|
|
44
44
|
},
|
|
45
45
|
"files": [
|
|
46
46
|
"src/",
|
package/src/lib/runtime/index.ts
CHANGED
package/src/lib/runtime/menu.ts
CHANGED
|
@@ -5,13 +5,17 @@ import type { MenubarItem } from '../types/menubar.js';
|
|
|
5
5
|
import type {
|
|
6
6
|
PluginDelegates,
|
|
7
7
|
PluginHandleActionParams,
|
|
8
|
+
PluginMenuElement,
|
|
8
9
|
PluginMenuDefinition,
|
|
9
10
|
} from '../types/plugin.js';
|
|
10
11
|
|
|
11
12
|
import type { PluginRuntimeContext } from './contracts.js';
|
|
13
|
+
import { pluginError } from './errors.js';
|
|
12
14
|
|
|
13
15
|
type MenubarMenuOptions = { before?: string; after?: string };
|
|
14
16
|
type MenuEntryKind = 'button' | 'text' | 'separator';
|
|
17
|
+
/** UI surface targeted by a menu: 'menubar' (top-level bar) or 'settings' (preferences panel). */
|
|
18
|
+
type MenuSurface = 'menubar' | 'settings';
|
|
15
19
|
|
|
16
20
|
type OpenVCSGlobal = typeof globalThis & {
|
|
17
21
|
OpenVCS?: {
|
|
@@ -26,6 +30,7 @@ interface StoredMenuItem {
|
|
|
26
30
|
label: string;
|
|
27
31
|
title?: string;
|
|
28
32
|
content?: string;
|
|
33
|
+
/** Action id dispatched back to the plugin when the user activates this item. */
|
|
29
34
|
action?: string;
|
|
30
35
|
hidden?: boolean;
|
|
31
36
|
}
|
|
@@ -34,28 +39,39 @@ interface StoredMenuState {
|
|
|
34
39
|
id: string;
|
|
35
40
|
label: string;
|
|
36
41
|
hidden?: boolean;
|
|
42
|
+
/** Surface target for rendering ('menubar' or 'settings'), must be explicitly provided. */
|
|
43
|
+
surface: MenuSurface;
|
|
37
44
|
items: StoredMenuItem[];
|
|
38
45
|
}
|
|
39
46
|
|
|
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
47
|
const menus = new Map<string, StoredMenuState>();
|
|
55
48
|
const menuOrder: string[] = [];
|
|
56
49
|
const actionHandlers = new Map<string, (...args: unknown[]) => unknown>();
|
|
57
50
|
let syntheticId = 0;
|
|
58
51
|
|
|
52
|
+
/** Validates and returns the incoming plugin action id. */
|
|
53
|
+
export function requireActionId(params: PluginHandleActionParams): string {
|
|
54
|
+
const actionId = typeof params?.action_id === 'string' ? params.action_id.trim() : '';
|
|
55
|
+
if (!actionId) {
|
|
56
|
+
throw pluginError(
|
|
57
|
+
'plugin-invalid-action-id',
|
|
58
|
+
'plugin.handle_action requires params.action_id to be a non-empty string',
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
return actionId;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Clears all registered menus and action handlers.
|
|
65
|
+
* @internal
|
|
66
|
+
* Called by bootstrap to prevent state leaking between in-process plugin setup runs.
|
|
67
|
+
*/
|
|
68
|
+
export function resetMenuRegistry(): void {
|
|
69
|
+
menus.clear();
|
|
70
|
+
menuOrder.splice(0, menuOrder.length);
|
|
71
|
+
actionHandlers.clear();
|
|
72
|
+
syntheticId = 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
59
75
|
/** Returns the host-side OpenVCS helper, when the environment provides one. */
|
|
60
76
|
function getOpenVCS() {
|
|
61
77
|
return (globalThis as OpenVCSGlobal).OpenVCS;
|
|
@@ -115,17 +131,19 @@ function placeMenuId(menuId: string, options?: MenubarMenuOptions): void {
|
|
|
115
131
|
function ensureStoredMenu(
|
|
116
132
|
menuId: string,
|
|
117
133
|
label: string,
|
|
118
|
-
options
|
|
134
|
+
options: MenubarMenuOptions,
|
|
135
|
+
surface: MenuSurface,
|
|
119
136
|
): StoredMenuState {
|
|
120
137
|
const id = normalizeMenuId(menuId);
|
|
121
138
|
const safeLabel = String(label || '').trim() || id;
|
|
122
139
|
let menu = menus.get(id);
|
|
123
140
|
|
|
124
141
|
if (!menu) {
|
|
125
|
-
menu = { id, label: safeLabel, items: [] };
|
|
142
|
+
menu = { id, label: safeLabel, surface, items: [] };
|
|
126
143
|
menus.set(id, menu);
|
|
127
144
|
} else {
|
|
128
145
|
menu.label = safeLabel;
|
|
146
|
+
menu.surface = surface;
|
|
129
147
|
}
|
|
130
148
|
|
|
131
149
|
placeMenuId(id, options);
|
|
@@ -171,7 +189,7 @@ function insertMenuItem(menu: StoredMenuState, item: StoredMenuItem, before?: st
|
|
|
171
189
|
}
|
|
172
190
|
|
|
173
191
|
/** Converts one stored item into a serializable menu payload element. */
|
|
174
|
-
function serializeMenuItem(item: StoredMenuItem):
|
|
192
|
+
function serializeMenuItem(item: StoredMenuItem): PluginMenuElement | null {
|
|
175
193
|
if (item.hidden) return null;
|
|
176
194
|
|
|
177
195
|
if (item.kind === 'separator') {
|
|
@@ -198,7 +216,7 @@ function serializeMenuItem(item: StoredMenuItem): SerializedMenuItem | null {
|
|
|
198
216
|
}
|
|
199
217
|
|
|
200
218
|
/** Serializes the local registry into plugin menu payloads. */
|
|
201
|
-
function serializeMenus():
|
|
219
|
+
function serializeMenus(): PluginMenuDefinition[] {
|
|
202
220
|
return menuOrder
|
|
203
221
|
.map((menuId, index) => {
|
|
204
222
|
const menu = menus.get(menuId);
|
|
@@ -207,12 +225,20 @@ function serializeMenus(): SerializedMenuDefinition[] {
|
|
|
207
225
|
id: menu.id,
|
|
208
226
|
label: menu.label,
|
|
209
227
|
order: index + 1,
|
|
228
|
+
surface: menu.surface,
|
|
210
229
|
elements: menu.items
|
|
211
230
|
.map((item) => serializeMenuItem(item))
|
|
212
|
-
.filter((item): item is
|
|
231
|
+
.filter((item): item is PluginMenuElement => Boolean(item)),
|
|
213
232
|
};
|
|
214
233
|
})
|
|
215
|
-
.filter((menu): menu is
|
|
234
|
+
.filter((menu): menu is PluginMenuDefinition => Boolean(menu));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Returns whether one registered action handler exists. */
|
|
238
|
+
export function hasRegisteredAction(actionId: string): boolean {
|
|
239
|
+
const id = String(actionId || '').trim();
|
|
240
|
+
if (!id) return false;
|
|
241
|
+
return actionHandlers.has(id);
|
|
216
242
|
}
|
|
217
243
|
|
|
218
244
|
/** Runs a registered action handler by id. */
|
|
@@ -229,7 +255,7 @@ export async function runRegisteredAction(actionId: string, ...args: unknown[]):
|
|
|
229
255
|
export interface MenuHandle {
|
|
230
256
|
id: string;
|
|
231
257
|
addItem(item: MenubarItem): void;
|
|
232
|
-
addSeparator(beforeAction?: string): void;
|
|
258
|
+
addSeparator(beforeAction?: string, afterAction?: string): void;
|
|
233
259
|
removeItem(actionId: string): void;
|
|
234
260
|
hideItem(actionId: string): void;
|
|
235
261
|
showItem(actionId: string): void;
|
|
@@ -246,7 +272,11 @@ function createMenuHandle(menuId: string): MenuHandle {
|
|
|
246
272
|
const action = String(item?.action || '').trim();
|
|
247
273
|
if (!label || !action) return;
|
|
248
274
|
|
|
249
|
-
|
|
275
|
+
let menu = getStoredMenu(this.id);
|
|
276
|
+
if (!menu) {
|
|
277
|
+
// Default to menubar for internal menu handle operations.
|
|
278
|
+
menu = ensureStoredMenu(this.id, this.id, {}, 'menubar');
|
|
279
|
+
}
|
|
250
280
|
insertMenuItem(menu, {
|
|
251
281
|
kind: 'button',
|
|
252
282
|
id: action,
|
|
@@ -255,14 +285,18 @@ function createMenuHandle(menuId: string): MenuHandle {
|
|
|
255
285
|
action,
|
|
256
286
|
}, item.before, item.after);
|
|
257
287
|
},
|
|
258
|
-
addSeparator(beforeAction?: string) {
|
|
259
|
-
|
|
288
|
+
addSeparator(beforeAction?: string, afterAction?: string) {
|
|
289
|
+
let menu = getStoredMenu(this.id);
|
|
290
|
+
if (!menu) {
|
|
291
|
+
// Default to menubar for internal menu handle operations.
|
|
292
|
+
menu = ensureStoredMenu(this.id, this.id, {}, 'menubar');
|
|
293
|
+
}
|
|
260
294
|
insertMenuItem(menu, {
|
|
261
295
|
kind: 'separator',
|
|
262
296
|
id: allocateSyntheticId(`${menu.id}-separator`),
|
|
263
297
|
label: 'Separator',
|
|
264
298
|
content: '—',
|
|
265
|
-
}, beforeAction);
|
|
299
|
+
}, beforeAction, afterAction);
|
|
266
300
|
},
|
|
267
301
|
removeItem(actionId: string) {
|
|
268
302
|
const menu = getStoredMenu(this.id);
|
|
@@ -292,26 +326,35 @@ export function getMenu(menuId: string): MenuHandle | null {
|
|
|
292
326
|
return createMenuHandle(stored.id);
|
|
293
327
|
}
|
|
294
328
|
|
|
295
|
-
/** Returns a menu by id, creating it if needed.
|
|
296
|
-
|
|
297
|
-
|
|
329
|
+
/** Returns a menu by id, creating it if needed.
|
|
330
|
+
* @param menuId - Menu identifier.
|
|
331
|
+
* @param label - User-visible label.
|
|
332
|
+
* @param options - Placement options, including the required `surface` so the runtime can serialize the menu for the correct host UI surface.
|
|
333
|
+
*/
|
|
334
|
+
export function getOrCreateMenu(
|
|
335
|
+
menuId: string,
|
|
336
|
+
label: string,
|
|
337
|
+
options: MenubarMenuOptions & { surface: MenuSurface },
|
|
338
|
+
): MenuHandle | null {
|
|
339
|
+
const { surface, ...restOptions } = options;
|
|
340
|
+
const stored = ensureStoredMenu(menuId, label, restOptions, surface);
|
|
298
341
|
return createMenuHandle(stored.id);
|
|
299
342
|
}
|
|
300
343
|
|
|
301
|
-
/** Creates a menu at a specific position. */
|
|
302
|
-
export
|
|
303
|
-
const stored = ensureStoredMenu(menuId, label, options);
|
|
304
|
-
return createMenuHandle(stored.id);
|
|
305
|
-
}
|
|
344
|
+
/** Creates a menu at a specific position (alias for getOrCreateMenu). */
|
|
345
|
+
export const createMenu = getOrCreateMenu;
|
|
306
346
|
|
|
307
|
-
/** Adds one item to a menu. */
|
|
347
|
+
/** Adds one item to a menu, silently no-op if the menu does not exist. */
|
|
308
348
|
export function addMenuItem(menuId: string, item: MenubarItem): void {
|
|
309
|
-
createMenuHandle(menuId)
|
|
349
|
+
const handle = createMenuHandle(menuId);
|
|
350
|
+
if (!getStoredMenu(menuId)) return;
|
|
351
|
+
handle.addItem(item);
|
|
310
352
|
}
|
|
311
353
|
|
|
312
|
-
/** Adds one separator to a menu. */
|
|
313
|
-
export function addMenuSeparator(menuId: string, beforeAction?: string): void {
|
|
314
|
-
|
|
354
|
+
/** Adds one separator to a menu, silently no-op if the menu does not exist. */
|
|
355
|
+
export function addMenuSeparator(menuId: string, beforeAction?: string, afterAction?: string): void {
|
|
356
|
+
if (!getStoredMenu(menuId)) return;
|
|
357
|
+
createMenuHandle(menuId).addSeparator(beforeAction, afterAction);
|
|
315
358
|
}
|
|
316
359
|
|
|
317
360
|
/** Removes one menu from the registry. */
|
|
@@ -352,21 +395,20 @@ export function invoke<T = unknown>(cmd: string, args?: unknown): Promise<T> {
|
|
|
352
395
|
/** Emits a notification when the host helper is available. */
|
|
353
396
|
export function notify(msg: string): void {
|
|
354
397
|
const openvcs = getOpenVCS();
|
|
355
|
-
openvcs
|
|
398
|
+
if (!openvcs) {
|
|
399
|
+
throw new Error('OpenVCS host is not available in this runtime');
|
|
400
|
+
}
|
|
401
|
+
openvcs.notify(msg);
|
|
356
402
|
}
|
|
357
403
|
|
|
358
404
|
/** Builds SDK delegates from the local menu/action registries. */
|
|
359
405
|
export function createMenuPluginDelegates(): PluginDelegates<PluginRuntimeContext> {
|
|
360
406
|
return {
|
|
361
407
|
async 'plugin.get_menus'(): Promise<PluginMenuDefinition[]> {
|
|
362
|
-
return serializeMenus()
|
|
408
|
+
return serializeMenus();
|
|
363
409
|
},
|
|
364
410
|
async 'plugin.handle_action'(params: PluginHandleActionParams): Promise<unknown> {
|
|
365
|
-
|
|
366
|
-
if (actionId) {
|
|
367
|
-
return await runRegisteredAction(actionId, params?.payload);
|
|
368
|
-
}
|
|
369
|
-
return null;
|
|
411
|
+
return await runRegisteredAction(requireActionId(params), params?.payload);
|
|
370
412
|
},
|
|
371
413
|
};
|
|
372
414
|
}
|
|
@@ -16,6 +16,9 @@ import type {
|
|
|
16
16
|
import { createPluginRuntime } from './factory';
|
|
17
17
|
import {
|
|
18
18
|
createMenuPluginDelegates,
|
|
19
|
+
hasRegisteredAction,
|
|
20
|
+
requireActionId,
|
|
21
|
+
resetMenuRegistry,
|
|
19
22
|
runRegisteredAction,
|
|
20
23
|
} from './menu';
|
|
21
24
|
|
|
@@ -79,8 +82,8 @@ export function createRegisteredPluginRuntime(
|
|
|
79
82
|
];
|
|
80
83
|
},
|
|
81
84
|
'plugin.handle_action': async (params, ctx) => {
|
|
82
|
-
const actionId =
|
|
83
|
-
if (actionId) {
|
|
85
|
+
const actionId = requireActionId(params);
|
|
86
|
+
if (hasRegisteredAction(actionId)) {
|
|
84
87
|
const result = await runRegisteredAction(actionId, params?.payload);
|
|
85
88
|
if (result !== null && result !== undefined) {
|
|
86
89
|
return result;
|
|
@@ -89,6 +92,7 @@ export function createRegisteredPluginRuntime(
|
|
|
89
92
|
if (explicitHandleAction) {
|
|
90
93
|
return explicitHandleAction(params, ctx);
|
|
91
94
|
}
|
|
95
|
+
ctx.host.info(`plugin.handle_action ignored unhandled action_id '${actionId}'`);
|
|
92
96
|
return null;
|
|
93
97
|
},
|
|
94
98
|
},
|
|
@@ -114,6 +118,9 @@ export async function bootstrapPluginModule(
|
|
|
114
118
|
);
|
|
115
119
|
}
|
|
116
120
|
|
|
121
|
+
// Reset internal state so repeated in-process setups do not leak menu or action state.
|
|
122
|
+
resetMenuRegistry();
|
|
123
|
+
|
|
117
124
|
try {
|
|
118
125
|
await onPluginStart();
|
|
119
126
|
} catch (error) {
|
|
@@ -303,6 +303,14 @@ export abstract class VcsDelegateBase<
|
|
|
303
303
|
return this.unimplemented('stagePatch');
|
|
304
304
|
}
|
|
305
305
|
|
|
306
|
+
/** Handles `vcs.stage_paths`. */
|
|
307
|
+
stagePaths(
|
|
308
|
+
_params: VcsTypes.VcsStagePathsParams,
|
|
309
|
+
_context: TContext,
|
|
310
|
+
): VcsHandlerResult<null> {
|
|
311
|
+
return this.unimplemented('stagePaths');
|
|
312
|
+
}
|
|
313
|
+
|
|
306
314
|
/** Handles `vcs.discard_paths`. */
|
|
307
315
|
discardPaths(
|
|
308
316
|
_params: VcsTypes.VcsDiscardPathsParams,
|
|
@@ -53,6 +53,7 @@ export type VcsDelegateBindings<TContext> = {
|
|
|
53
53
|
VcsTypes.VcsDelegates<TContext>['vcs.write_merge_result']
|
|
54
54
|
>;
|
|
55
55
|
stagePatch: NonNullable<VcsTypes.VcsDelegates<TContext>['vcs.stage_patch']>;
|
|
56
|
+
stagePaths: NonNullable<VcsTypes.VcsDelegates<TContext>['vcs.stage_paths']>;
|
|
56
57
|
discardPaths: NonNullable<VcsTypes.VcsDelegates<TContext>['vcs.discard_paths']>;
|
|
57
58
|
applyReversePatch: NonNullable<
|
|
58
59
|
VcsTypes.VcsDelegates<TContext>['vcs.apply_reverse_patch']
|
|
@@ -122,6 +123,7 @@ export const VCS_DELEGATE_METHOD_MAPPINGS = {
|
|
|
122
123
|
checkoutConflictSide: 'vcs.checkout_conflict_side',
|
|
123
124
|
writeMergeResult: 'vcs.write_merge_result',
|
|
124
125
|
stagePatch: 'vcs.stage_patch',
|
|
126
|
+
stagePaths: 'vcs.stage_paths',
|
|
125
127
|
discardPaths: 'vcs.discard_paths',
|
|
126
128
|
applyReversePatch: 'vcs.apply_reverse_patch',
|
|
127
129
|
deleteBranch: 'vcs.delete_branch',
|
package/src/lib/types/menubar.ts
CHANGED
|
@@ -23,8 +23,8 @@ export interface MenubarMenu {
|
|
|
23
23
|
id: string;
|
|
24
24
|
/** Adds an item to this menu. */
|
|
25
25
|
addItem(item: MenubarItem): void;
|
|
26
|
-
/** Adds a separator to this menu. */
|
|
27
|
-
addSeparator(beforeAction?: string): void;
|
|
26
|
+
/** Adds a separator to this menu before or after an existing action. */
|
|
27
|
+
addSeparator(beforeAction?: string, afterAction?: string): void;
|
|
28
28
|
/** Removes an item by action ID. */
|
|
29
29
|
removeItem(actionId: string): void;
|
|
30
30
|
/** Hides an item by action ID. */
|
package/src/lib/types/plugin.ts
CHANGED
|
@@ -22,17 +22,52 @@ export interface PluginInitializeResult {
|
|
|
22
22
|
/** Describes one optional override returned by a custom initialize handler. */
|
|
23
23
|
export type PluginInitializeOverride = Partial<PluginInitializeResult>;
|
|
24
24
|
|
|
25
|
+
/** Describes the UI surface targeted by one plugin menu definition. */
|
|
26
|
+
export type PluginMenuSurface = 'menubar' | 'settings';
|
|
27
|
+
|
|
28
|
+
/** Describes one static text element contributed to a plugin menu. */
|
|
29
|
+
export interface PluginMenuTextElement {
|
|
30
|
+
/** Stores the serialized element kind. */
|
|
31
|
+
type: 'text';
|
|
32
|
+
/** Stores the stable plugin-local element id. */
|
|
33
|
+
id: string;
|
|
34
|
+
/** Stores the rendered text content. */
|
|
35
|
+
content: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Describes one button element contributed to a plugin menu. */
|
|
39
|
+
export interface PluginMenuButtonElement {
|
|
40
|
+
/** Stores the serialized element kind. */
|
|
41
|
+
type: 'button';
|
|
42
|
+
/** Stores the stable action id dispatched back to the plugin. */
|
|
43
|
+
id: string;
|
|
44
|
+
/** Stores the user-visible button label. */
|
|
45
|
+
label: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Describes one renderable plugin menu element. */
|
|
49
|
+
export type PluginMenuElement = PluginMenuTextElement | PluginMenuButtonElement;
|
|
50
|
+
|
|
25
51
|
/** Describes one plugin-contributed menu definition. */
|
|
26
|
-
export
|
|
52
|
+
export interface PluginMenuDefinition {
|
|
53
|
+
/** Stores the stable plugin-local menu id. */
|
|
54
|
+
id: string;
|
|
55
|
+
/** Stores the user-visible menu label. */
|
|
56
|
+
label: string;
|
|
57
|
+
/** Stores the display ordering hint assigned by the runtime. */
|
|
58
|
+
order: number;
|
|
59
|
+
/** Stores the target UI surface for the menu. */
|
|
60
|
+
surface: PluginMenuSurface;
|
|
61
|
+
/** Stores the renderable elements contributed under the menu. */
|
|
62
|
+
elements: PluginMenuElement[];
|
|
63
|
+
}
|
|
27
64
|
|
|
28
65
|
/** Describes one plugin settings value payload. */
|
|
29
66
|
export type PluginSettingsValue = Record<string, unknown>;
|
|
30
67
|
|
|
31
68
|
/** Describes the params shape for plugin action handling. */
|
|
32
69
|
export interface PluginHandleActionParams extends RequestParams {
|
|
33
|
-
/** Stores the action id selected by the user
|
|
34
|
-
id?: string;
|
|
35
|
-
/** Stores the action id selected by the user. */
|
|
70
|
+
/** Stores the action id selected by the user. Runtime handlers require a non-empty string. */
|
|
36
71
|
action_id?: string;
|
|
37
72
|
/** Stores an optional payload supplied by the triggering UI. */
|
|
38
73
|
payload?: Record<string, unknown>;
|
package/src/lib/types/vcs.ts
CHANGED
|
@@ -298,6 +298,12 @@ export interface VcsStagePatchParams extends VcsSessionParams {
|
|
|
298
298
|
patch: string;
|
|
299
299
|
}
|
|
300
300
|
|
|
301
|
+
/** Describes params for staging repository-relative paths into the index. */
|
|
302
|
+
export interface VcsStagePathsParams extends VcsSessionParams {
|
|
303
|
+
/** Stores the paths to stage. */
|
|
304
|
+
paths?: string[];
|
|
305
|
+
}
|
|
306
|
+
|
|
301
307
|
/** Describes params for discarding path changes. */
|
|
302
308
|
export interface VcsDiscardPathsParams extends VcsSessionParams {
|
|
303
309
|
/** Stores the paths to discard. */
|
|
@@ -518,6 +524,8 @@ export interface VcsDelegates<TContext = unknown> {
|
|
|
518
524
|
>;
|
|
519
525
|
/** Handles `vcs.stage_patch`. */
|
|
520
526
|
'vcs.stage_patch'?: RpcMethodHandler<VcsStagePatchParams, null, TContext>;
|
|
527
|
+
/** Handles `vcs.stage_paths`. */
|
|
528
|
+
'vcs.stage_paths'?: RpcMethodHandler<VcsStagePathsParams, null, TContext>;
|
|
521
529
|
/** Handles `vcs.discard_paths`. */
|
|
522
530
|
'vcs.discard_paths'?: RpcMethodHandler<VcsDiscardPathsParams, null, TContext>;
|
|
523
531
|
/** Handles `vcs.apply_reverse_patch`. */
|
package/test/runtime.test.js
CHANGED
|
@@ -4,7 +4,21 @@ const test = require("node:test");
|
|
|
4
4
|
|
|
5
5
|
const {
|
|
6
6
|
bootstrapPluginModule,
|
|
7
|
-
|
|
7
|
+
createRegisteredPluginRuntime,
|
|
8
|
+
resetMenuRegistry,
|
|
9
|
+
} = require("../lib/runtime");
|
|
10
|
+
const {
|
|
11
|
+
addMenuItem,
|
|
12
|
+
addMenuSeparator,
|
|
13
|
+
createMenu,
|
|
14
|
+
getMenu,
|
|
15
|
+
registerAction,
|
|
16
|
+
removeMenu,
|
|
17
|
+
hideMenu,
|
|
18
|
+
showMenu,
|
|
19
|
+
} = require("../lib/runtime/menu");
|
|
20
|
+
const {
|
|
21
|
+
createMenu: createMenuFromRoot,
|
|
8
22
|
} = require("../lib/runtime");
|
|
9
23
|
const {
|
|
10
24
|
parseFramedMessages,
|
|
@@ -20,7 +34,7 @@ function createRuntimeHarness(options) {
|
|
|
20
34
|
return true;
|
|
21
35
|
},
|
|
22
36
|
};
|
|
23
|
-
const runtime =
|
|
37
|
+
const runtime = createRegisteredPluginRuntime(options);
|
|
24
38
|
runtime.start({ stdin, stdout });
|
|
25
39
|
|
|
26
40
|
return {
|
|
@@ -233,3 +247,308 @@ test("bootstrapPluginModule rejects when OnPluginStart throws", async () => {
|
|
|
233
247
|
/plugin startup failed/
|
|
234
248
|
);
|
|
235
249
|
});
|
|
250
|
+
|
|
251
|
+
test("resetMenuRegistry clears all menus and action handlers", () => {
|
|
252
|
+
createMenu("reset-test", "Reset Test", { surface: "menubar" });
|
|
253
|
+
registerAction("reset-test-action", () => "ok");
|
|
254
|
+
assert.notEqual(getMenu("reset-test"), null);
|
|
255
|
+
|
|
256
|
+
resetMenuRegistry();
|
|
257
|
+
|
|
258
|
+
assert.equal(getMenu("reset-test"), null);
|
|
259
|
+
// After reset, addMenuItem/addMenuSeparator must silently no-op for unknown menus.
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("addMenuItem silently ignores when menu does not exist", () => {
|
|
263
|
+
resetMenuRegistry();
|
|
264
|
+
// Must not throw.
|
|
265
|
+
addMenuItem("nonexistent-menu", {
|
|
266
|
+
label: "Irrelevant",
|
|
267
|
+
action: "test-action",
|
|
268
|
+
});
|
|
269
|
+
assert.equal(getMenu("nonexistent-menu"), null);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("addMenuSeparator silently ignores when menu does not exist", () => {
|
|
273
|
+
resetMenuRegistry();
|
|
274
|
+
// Must not throw.
|
|
275
|
+
addMenuSeparator("nonexistent-menu");
|
|
276
|
+
assert.equal(getMenu("nonexistent-menu"), null);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("addMenuItem and addMenuSeparator do not implicitly create menus", () => {
|
|
280
|
+
resetMenuRegistry();
|
|
281
|
+
createMenu("existing-menu", "Existing Menu", { surface: "menubar" });
|
|
282
|
+
addMenuItem("existing-menu", {
|
|
283
|
+
label: "Test Item",
|
|
284
|
+
action: "test-action",
|
|
285
|
+
});
|
|
286
|
+
addMenuSeparator("existing-menu");
|
|
287
|
+
assert.notEqual(getMenu("existing-menu"), null);
|
|
288
|
+
// Verify state was not leaked from prior tests.
|
|
289
|
+
assert.equal(getMenu("reset-test"), null);
|
|
290
|
+
removeMenu("existing-menu");
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("hideMenu and showMenu toggle menu visibility", () => {
|
|
294
|
+
resetMenuRegistry();
|
|
295
|
+
createMenu("visible-menu", "Visible Menu", { surface: "menubar" });
|
|
296
|
+
addMenuItem("visible-menu", {
|
|
297
|
+
label: "Test Item",
|
|
298
|
+
action: "visibility-action",
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
const menu = getMenu("visible-menu");
|
|
302
|
+
assert.notEqual(menu, null);
|
|
303
|
+
hideMenu("visible-menu");
|
|
304
|
+
assert.notEqual(getMenu("visible-menu"), null);
|
|
305
|
+
|
|
306
|
+
showMenu("visible-menu");
|
|
307
|
+
assert.notEqual(getMenu("visible-menu"), null);
|
|
308
|
+
|
|
309
|
+
removeMenu("visible-menu");
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test("hideMenu and showMenu affect serialized menus", async () => {
|
|
313
|
+
resetMenuRegistry();
|
|
314
|
+
createMenu("serial-menu", "Serial Menu", { surface: "menubar" });
|
|
315
|
+
addMenuItem("serial-menu", {
|
|
316
|
+
label: "Serial Item",
|
|
317
|
+
action: "serial-action",
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
const harness = createRuntimeHarness({ plugin: {} });
|
|
321
|
+
|
|
322
|
+
hideMenu("serial-menu");
|
|
323
|
+
let hidden = await harness.request({
|
|
324
|
+
jsonrpc: "2.0",
|
|
325
|
+
id: 30,
|
|
326
|
+
method: "plugin.get_menus",
|
|
327
|
+
params: {},
|
|
328
|
+
});
|
|
329
|
+
assert.deepEqual(hidden, [{ jsonrpc: "2.0", id: 30, result: [] }]);
|
|
330
|
+
|
|
331
|
+
showMenu("serial-menu");
|
|
332
|
+
const visible = await harness.request({
|
|
333
|
+
jsonrpc: "2.0",
|
|
334
|
+
id: 31,
|
|
335
|
+
method: "plugin.get_menus",
|
|
336
|
+
params: {},
|
|
337
|
+
});
|
|
338
|
+
assert.deepEqual(visible, [{
|
|
339
|
+
jsonrpc: "2.0",
|
|
340
|
+
id: 31,
|
|
341
|
+
result: [{
|
|
342
|
+
id: "serial-menu",
|
|
343
|
+
label: "Serial Menu",
|
|
344
|
+
order: 1,
|
|
345
|
+
surface: "menubar",
|
|
346
|
+
elements: [{ type: "button", id: "serial-action", label: "Serial Item" }],
|
|
347
|
+
}],
|
|
348
|
+
}]);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("addMenuSeparator supports afterAction positioning", async () => {
|
|
352
|
+
resetMenuRegistry();
|
|
353
|
+
createMenu("separator-menu", "Separator Menu", { surface: "menubar" });
|
|
354
|
+
addMenuItem("separator-menu", {
|
|
355
|
+
label: "First Item",
|
|
356
|
+
action: "first-action",
|
|
357
|
+
});
|
|
358
|
+
addMenuItem("separator-menu", {
|
|
359
|
+
label: "Second Item",
|
|
360
|
+
action: "second-action",
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
addMenuSeparator("separator-menu", undefined, "first-action");
|
|
364
|
+
|
|
365
|
+
const harness = createRuntimeHarness({ plugin: {} });
|
|
366
|
+
const messages = await harness.request({
|
|
367
|
+
jsonrpc: "2.0",
|
|
368
|
+
id: 32,
|
|
369
|
+
method: "plugin.get_menus",
|
|
370
|
+
params: {},
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
assert.deepEqual(messages, [{
|
|
374
|
+
jsonrpc: "2.0",
|
|
375
|
+
id: 32,
|
|
376
|
+
result: [{
|
|
377
|
+
id: "separator-menu",
|
|
378
|
+
label: "Separator Menu",
|
|
379
|
+
order: 1,
|
|
380
|
+
surface: "menubar",
|
|
381
|
+
elements: [
|
|
382
|
+
{ type: "button", id: "first-action", label: "First Item" },
|
|
383
|
+
{ type: "text", id: "separator-menu-separator-1", content: "—" },
|
|
384
|
+
{ type: "button", id: "second-action", label: "Second Item" },
|
|
385
|
+
],
|
|
386
|
+
}],
|
|
387
|
+
}]);
|
|
388
|
+
|
|
389
|
+
removeMenu("separator-menu");
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
test("plugin.handle_action rejects missing action_id", async () => {
|
|
393
|
+
resetMenuRegistry();
|
|
394
|
+
const harness = createRuntimeHarness({ plugin: {} });
|
|
395
|
+
|
|
396
|
+
const missing = await harness.request({
|
|
397
|
+
jsonrpc: "2.0",
|
|
398
|
+
id: 20,
|
|
399
|
+
method: "plugin.handle_action",
|
|
400
|
+
params: {},
|
|
401
|
+
});
|
|
402
|
+
assert.deepEqual(missing, [
|
|
403
|
+
{
|
|
404
|
+
jsonrpc: "2.0",
|
|
405
|
+
id: 20,
|
|
406
|
+
error: {
|
|
407
|
+
code: -32001,
|
|
408
|
+
message: "plugin.handle_action requires params.action_id to be a non-empty string",
|
|
409
|
+
data: {
|
|
410
|
+
code: "plugin-invalid-action-id",
|
|
411
|
+
message: "plugin.handle_action requires params.action_id to be a non-empty string",
|
|
412
|
+
},
|
|
413
|
+
},
|
|
414
|
+
},
|
|
415
|
+
]);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test("plugin.handle_action rejects empty action_id", async () => {
|
|
419
|
+
resetMenuRegistry();
|
|
420
|
+
const harness = createRuntimeHarness({ plugin: {} });
|
|
421
|
+
|
|
422
|
+
const empty = await harness.request({
|
|
423
|
+
jsonrpc: "2.0",
|
|
424
|
+
id: 21,
|
|
425
|
+
method: "plugin.handle_action",
|
|
426
|
+
params: { action_id: "" },
|
|
427
|
+
});
|
|
428
|
+
assert.deepEqual(empty, [
|
|
429
|
+
{
|
|
430
|
+
jsonrpc: "2.0",
|
|
431
|
+
id: 21,
|
|
432
|
+
error: {
|
|
433
|
+
code: -32001,
|
|
434
|
+
message: "plugin.handle_action requires params.action_id to be a non-empty string",
|
|
435
|
+
data: {
|
|
436
|
+
code: "plugin-invalid-action-id",
|
|
437
|
+
message: "plugin.handle_action requires params.action_id to be a non-empty string",
|
|
438
|
+
},
|
|
439
|
+
},
|
|
440
|
+
},
|
|
441
|
+
]);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
test("createMenu is available from runtime root export", () => {
|
|
445
|
+
resetMenuRegistry();
|
|
446
|
+
const menu = createMenuFromRoot("root-export-menu", "Root Export Menu", { surface: "menubar" });
|
|
447
|
+
assert.notEqual(menu, null);
|
|
448
|
+
assert.equal(menu.id, "root-export-menu");
|
|
449
|
+
|
|
450
|
+
resetMenuRegistry();
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
test("plugin.handle_action dispatches only on action_id (not id)", async () => {
|
|
454
|
+
resetMenuRegistry();
|
|
455
|
+
// Register after reset so the handler is present.
|
|
456
|
+
registerAction("test-action-id", (payload) => {
|
|
457
|
+
if (typeof payload === "object" && payload != null) {
|
|
458
|
+
return (payload).message;
|
|
459
|
+
}
|
|
460
|
+
return null;
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
const harness = createRuntimeHarness({
|
|
464
|
+
plugin: {},
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
// Valid dispatch via action_id.
|
|
468
|
+
const valid = await harness.request({
|
|
469
|
+
jsonrpc: "2.0",
|
|
470
|
+
id: 10,
|
|
471
|
+
method: "plugin.handle_action",
|
|
472
|
+
params: { action_id: "test-action-id", payload: { message: "hello" } },
|
|
473
|
+
});
|
|
474
|
+
assert.deepEqual(valid, [
|
|
475
|
+
{ jsonrpc: "2.0", id: 10, result: "hello" },
|
|
476
|
+
]);
|
|
477
|
+
|
|
478
|
+
// `id` is ignored; only `action_id` is valid.
|
|
479
|
+
const wrongField = await harness.request({
|
|
480
|
+
jsonrpc: "2.0",
|
|
481
|
+
id: 12,
|
|
482
|
+
method: "plugin.handle_action",
|
|
483
|
+
params: { id: "test-action-id" },
|
|
484
|
+
});
|
|
485
|
+
assert.deepEqual(wrongField, [
|
|
486
|
+
{
|
|
487
|
+
jsonrpc: "2.0",
|
|
488
|
+
id: 12,
|
|
489
|
+
error: {
|
|
490
|
+
code: -32001,
|
|
491
|
+
message: "plugin.handle_action requires params.action_id to be a non-empty string",
|
|
492
|
+
data: {
|
|
493
|
+
code: "plugin-invalid-action-id",
|
|
494
|
+
message: "plugin.handle_action requires params.action_id to be a non-empty string",
|
|
495
|
+
},
|
|
496
|
+
},
|
|
497
|
+
},
|
|
498
|
+
]);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
test("plugin.handle_action logs unhandled actions without explicit handler", async () => {
|
|
502
|
+
resetMenuRegistry();
|
|
503
|
+
const harness = createRuntimeHarness({ plugin: {} });
|
|
504
|
+
|
|
505
|
+
const messages = await harness.request({
|
|
506
|
+
jsonrpc: "2.0",
|
|
507
|
+
id: 13,
|
|
508
|
+
method: "plugin.handle_action",
|
|
509
|
+
params: { action_id: "missing-action" },
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
assert.deepEqual(messages, [
|
|
513
|
+
{
|
|
514
|
+
jsonrpc: "2.0",
|
|
515
|
+
method: "host.log",
|
|
516
|
+
params: {
|
|
517
|
+
level: "info",
|
|
518
|
+
target: "openvcs.plugin",
|
|
519
|
+
message: "plugin.handle_action ignored unhandled action_id 'missing-action'",
|
|
520
|
+
},
|
|
521
|
+
},
|
|
522
|
+
{ jsonrpc: "2.0", id: 13, result: null },
|
|
523
|
+
]);
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
test("bootstrapPluginModule resets menu registry before OnPluginStart", async () => {
|
|
527
|
+
resetMenuRegistry();
|
|
528
|
+
// Pre-populate state that should be cleared.
|
|
529
|
+
createMenu("leak-test", "Leak Test", { surface: "menubar" });
|
|
530
|
+
registerAction("leak-action", () => "leaked");
|
|
531
|
+
|
|
532
|
+
const stdin = new EventEmitter();
|
|
533
|
+
const chunks = [];
|
|
534
|
+
const stdout = {
|
|
535
|
+
write(chunk) {
|
|
536
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
537
|
+
return true;
|
|
538
|
+
},
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
await bootstrapPluginModule({
|
|
542
|
+
modulePath: "./plugin.js",
|
|
543
|
+
transport: { stdin, stdout },
|
|
544
|
+
async importPluginModule() {
|
|
545
|
+
return {
|
|
546
|
+
PluginDefinition: { plugin: {} },
|
|
547
|
+
OnPluginStart() {},
|
|
548
|
+
};
|
|
549
|
+
},
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
// Menu from before bootstrap must not be present.
|
|
553
|
+
assert.equal(getMenu("leak-test"), null);
|
|
554
|
+
});
|