mirror of
https://github.com/wavetermdev/waveterm
synced 2026-05-24 09:18:27 +00:00
Context-menu opens previously produced renderer callbacks only on
selection (0 or 1 events). This change makes the lifecycle
deterministic: every menu open now emits exactly one completion signal,
with `null` on cancel and item id on selection.
- **Main process: single terminal callback per popup**
- Updated `emain/emain-menu.ts` to use `menu.popup({ callback })`.
- Tracks whether a menu item click occurred during the popup.
- Emits `contextmenu-click: null` only when the menu closes without
selection.
- Suppresses duplicate close events when a click already fired.
- **Preload + API typing: nullable context-menu callback payload**
- Updated preload bridge and `frontend/types/custom.d.ts` so:
- `onContextMenuClick` now accepts `(id: string | null) => void`.
- Keeps existing channel semantics while allowing explicit cancel
signal.
- **Renderer context menu model: close/select/cancel hooks**
- Extended `showContextMenu` with optional `opts`:
- `onSelect?: (item) => void`
- `onCancel?: () => void`
- `onClose?: (item: MenuItem | null) => void`
- Execution order on selection:
1. original item `click`
2. `onSelect`
3. `onClose`
- Execution order on cancel:
1. `onCancel`
2. `onClose(null)`
- **Targeted behavior tests**
- Expanded `frontend/app/store/contextmenu.test.ts` to verify:
- singleton wiring still initializes once,
- selection path ordering (`click -> onSelect -> onClose`),
- cancel path ordering (`onCancel -> onClose(null)`).
```ts
ContextMenuModel.getInstance().showContextMenu(menu, e, {
onSelect: (item) => { /* after item.click */ },
onCancel: () => { /* close without selection */ },
onClose: (itemOrNull) => { /* always called */ },
});
```
<screenshot>
Not applicable — this is a behavioral/API flow change in native context
menu lifecycle rather than a visual UI update.
</screenshot>
<!-- START COPILOT CODING AGENT TIPS -->
---
💬 We'd love your input! Share your thoughts on Copilot coding agent in
our [2 minute survey](https://gh.io/copilot-coding-agent-survey).
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: sawka <2722291+sawka@users.noreply.github.com>
Co-authored-by: sawka <mike@commandline.dev>
90 lines
2.8 KiB
TypeScript
90 lines
2.8 KiB
TypeScript
// Copyright 2025, Command Line Inc.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
import { atoms, getApi, globalStore } from "./global";
|
|
|
|
type ShowContextMenuOpts = {
|
|
onSelect?: (item: ContextMenuItem) => void;
|
|
onCancel?: () => void;
|
|
onClose?: (item: ContextMenuItem | null) => void;
|
|
};
|
|
|
|
class ContextMenuModel {
|
|
private static instance: ContextMenuModel;
|
|
handlers: Map<string, ContextMenuItem> = new Map(); // id -> item
|
|
activeOpts: ShowContextMenuOpts | null = null;
|
|
|
|
private constructor() {
|
|
getApi().onContextMenuClick(this.handleContextMenuClick.bind(this));
|
|
}
|
|
|
|
static getInstance(): ContextMenuModel {
|
|
if (ContextMenuModel.instance == null) {
|
|
ContextMenuModel.instance = new ContextMenuModel();
|
|
}
|
|
return ContextMenuModel.instance;
|
|
}
|
|
|
|
handleContextMenuClick(id: string | null): void {
|
|
const opts = this.activeOpts;
|
|
this.activeOpts = null;
|
|
const item = id != null ? this.handlers.get(id) : null;
|
|
this.handlers.clear();
|
|
if (item == null) {
|
|
opts?.onCancel?.();
|
|
opts?.onClose?.(null);
|
|
return;
|
|
}
|
|
item.click?.();
|
|
opts?.onSelect?.(item);
|
|
opts?.onClose?.(item);
|
|
}
|
|
|
|
_convertAndRegisterMenu(menu: ContextMenuItem[]): ElectronContextMenuItem[] {
|
|
const electronMenuItems: ElectronContextMenuItem[] = [];
|
|
for (const item of menu) {
|
|
const electronItem: ElectronContextMenuItem = {
|
|
role: item.role,
|
|
type: item.type,
|
|
label: item.label,
|
|
sublabel: item.sublabel,
|
|
id: crypto.randomUUID(),
|
|
checked: item.checked,
|
|
};
|
|
if (item.visible === false) {
|
|
electronItem.visible = false;
|
|
}
|
|
if (item.enabled === false) {
|
|
electronItem.enabled = false;
|
|
}
|
|
if (item.click) {
|
|
this.handlers.set(electronItem.id, item);
|
|
}
|
|
if (item.submenu) {
|
|
electronItem.submenu = this._convertAndRegisterMenu(item.submenu);
|
|
}
|
|
electronMenuItems.push(electronItem);
|
|
}
|
|
return electronMenuItems;
|
|
}
|
|
|
|
showContextMenu(menu: ContextMenuItem[], ev: React.MouseEvent<any>, opts?: ShowContextMenuOpts): void {
|
|
ev.stopPropagation();
|
|
this.handlers.clear();
|
|
this.activeOpts = opts;
|
|
const electronMenuItems = this._convertAndRegisterMenu(menu);
|
|
|
|
const workspace = globalStore.get(atoms.workspace);
|
|
let oid: string;
|
|
|
|
if (workspace != null) {
|
|
oid = workspace.oid;
|
|
} else {
|
|
oid = globalStore.get(atoms.builderId);
|
|
}
|
|
|
|
getApi().showContextMenu(oid, electronMenuItems);
|
|
}
|
|
}
|
|
|
|
export { ContextMenuModel };
|