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>
134 lines
4.5 KiB
TypeScript
134 lines
4.5 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
|
|
describe("ContextMenuModel", () => {
|
|
it("initializes only when getInstance is called", async () => {
|
|
let contextMenuCallback: (id: string | null) => void;
|
|
const onContextMenuClick = vi.fn();
|
|
onContextMenuClick.mockImplementation((callback) => {
|
|
contextMenuCallback = callback;
|
|
});
|
|
const getApi = vi.fn(() => ({
|
|
onContextMenuClick,
|
|
showContextMenu: vi.fn(),
|
|
}));
|
|
|
|
vi.resetModules();
|
|
vi.doMock("./global", () => ({
|
|
atoms: {},
|
|
getApi,
|
|
globalStore: { get: vi.fn() },
|
|
}));
|
|
|
|
const { ContextMenuModel } = await import("./contextmenu");
|
|
expect(getApi).not.toHaveBeenCalled();
|
|
|
|
const firstInstance = ContextMenuModel.getInstance();
|
|
const secondInstance = ContextMenuModel.getInstance();
|
|
|
|
expect(firstInstance).toBe(secondInstance);
|
|
expect(getApi).toHaveBeenCalledTimes(1);
|
|
expect(onContextMenuClick).toHaveBeenCalledTimes(1);
|
|
expect(contextMenuCallback).toBeTypeOf("function");
|
|
});
|
|
|
|
it("runs select and close callbacks after item handler", async () => {
|
|
let contextMenuCallback: (id: string | null) => void;
|
|
const showContextMenu = vi.fn();
|
|
const onContextMenuClick = vi.fn((callback) => {
|
|
contextMenuCallback = callback;
|
|
});
|
|
const getApi = vi.fn(() => ({
|
|
onContextMenuClick,
|
|
showContextMenu,
|
|
}));
|
|
const workspace = { oid: "workspace-1" };
|
|
|
|
vi.resetModules();
|
|
vi.doMock("./global", () => ({
|
|
atoms: { workspace: "workspace", builderId: "builderId" },
|
|
getApi,
|
|
globalStore: {
|
|
get: vi.fn((atom) => {
|
|
if (atom === "workspace") {
|
|
return workspace;
|
|
}
|
|
return "builder-1";
|
|
}),
|
|
},
|
|
}));
|
|
|
|
const { ContextMenuModel } = await import("./contextmenu");
|
|
const model = ContextMenuModel.getInstance();
|
|
const order: string[] = [];
|
|
const itemClick = vi.fn(() => {
|
|
order.push("item");
|
|
});
|
|
const onSelect = vi.fn((item) => {
|
|
order.push(`select:${item.label}`);
|
|
});
|
|
const onClose = vi.fn((item) => {
|
|
order.push(`close:${item?.label ?? "null"}`);
|
|
});
|
|
|
|
model.showContextMenu(
|
|
[{ label: "Open", click: itemClick }],
|
|
{ stopPropagation: vi.fn() } as any,
|
|
{ onSelect, onClose }
|
|
);
|
|
const menuId = showContextMenu.mock.calls[0][1][0].id;
|
|
contextMenuCallback(menuId);
|
|
|
|
expect(order).toEqual(["item", "select:Open", "close:Open"]);
|
|
expect(itemClick).toHaveBeenCalledTimes(1);
|
|
expect(onSelect).toHaveBeenCalledTimes(1);
|
|
expect(onClose).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("runs cancel and close callbacks when no item is selected", async () => {
|
|
let contextMenuCallback: (id: string | null) => void;
|
|
const showContextMenu = vi.fn();
|
|
const onContextMenuClick = vi.fn((callback) => {
|
|
contextMenuCallback = callback;
|
|
});
|
|
const getApi = vi.fn(() => ({
|
|
onContextMenuClick,
|
|
showContextMenu,
|
|
}));
|
|
const workspace = { oid: "workspace-1" };
|
|
|
|
vi.resetModules();
|
|
vi.doMock("./global", () => ({
|
|
atoms: { workspace: "workspace", builderId: "builderId" },
|
|
getApi,
|
|
globalStore: {
|
|
get: vi.fn((atom) => {
|
|
if (atom === "workspace") {
|
|
return workspace;
|
|
}
|
|
return "builder-1";
|
|
}),
|
|
},
|
|
}));
|
|
|
|
const { ContextMenuModel } = await import("./contextmenu");
|
|
const model = ContextMenuModel.getInstance();
|
|
const order: string[] = [];
|
|
const onCancel = vi.fn(() => {
|
|
order.push("cancel");
|
|
});
|
|
const onClose = vi.fn((item) => {
|
|
order.push(`close:${item == null ? "null" : item.label}`);
|
|
});
|
|
|
|
model.showContextMenu(
|
|
[{ label: "Open", click: vi.fn() }],
|
|
{ stopPropagation: vi.fn() } as any,
|
|
{ onCancel, onClose }
|
|
);
|
|
contextMenuCallback(null);
|
|
|
|
expect(order).toEqual(["cancel", "close:null"]);
|
|
expect(onCancel).toHaveBeenCalledTimes(1);
|
|
expect(onClose).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|