Skip to content

Commit a05a1dc

Browse files
committed
Native context menus
1 parent 57e5f2f commit a05a1dc

7 files changed

Lines changed: 396 additions & 351 deletions

File tree

apps/lite/electron/src/ipc.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,27 @@ export interface WatcherSubscribeResult {
180180
eventChannel: string;
181181
}
182182

183+
export interface NativeMenuPosition {
184+
x: number;
185+
y: number;
186+
}
187+
188+
type NativeMenuPopupItemData = {
189+
label: string;
190+
enabled?: boolean;
191+
itemId?: string;
192+
submenu?: Array<NativeMenuPopupItem>;
193+
};
194+
195+
export type NativeMenuPopupItem =
196+
| { _tag: "Separator" }
197+
| ({ _tag: "Item" } & NativeMenuPopupItemData);
198+
199+
export interface ShowNativeMenuParams {
200+
items: Array<NativeMenuPopupItem>;
201+
position: NativeMenuPosition;
202+
}
203+
183204
export interface LiteElectronApi {
184205
absorptionPlan: (params: AbsorptionPlanParams) => Promise<Array<CommitAbsorption>>;
185206
absorb: (params: AbsorbParams) => Promise<number>;
@@ -210,6 +231,7 @@ export interface LiteElectronApi {
210231
tearOffBranch: (params: TearOffBranchParams) => Promise<MoveBranchResult>;
211232
ping: (input: string) => Promise<string>;
212233
pushStackLegacy: (params: PushStackLegacyParams) => Promise<PushResult>;
234+
showNativeMenu: (params: ShowNativeMenuParams) => Promise<string | null>;
213235
treeChangeDiffs: (params: TreeChangeDiffParams) => Promise<UnifiedPatch | null>;
214236
unapplyStack: (params: UnapplyStackParams) => Promise<void>;
215237
watcherSubscribe: (projectId: string, callback: (event: WatcherEvent) => void) => Promise<string>;
@@ -244,6 +266,7 @@ export const liteIpcChannels = {
244266
tearOffBranch: "workspace:tear-off-branch",
245267
ping: "lite:ping",
246268
pushStackLegacy: "workspace:push-stack-legacy",
269+
showNativeMenu: "lite:show-native-menu",
247270
treeChangeDiffs: "workspace:tree-change-diffs",
248271
unapplyStack: "workspace:unapply-stack",
249272
watcherSubscribe: "workspace:watcher-subscribe",

apps/lite/electron/src/main.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,11 @@ import {
2222
type TreeChangeDiffParams,
2323
type UpdateBranchNameParams,
2424
type ApplyParams,
25+
type ShowNativeMenuParams,
2526
type UnapplyStackParams,
2627
WatcherSubscribeParams,
2728
WatcherUnsubscribeParams,
29+
NativeMenuPopupItem,
2830
} from "./ipc.js";
2931
import {
3032
absorb,
@@ -55,14 +57,30 @@ import {
5557
updateBranchName,
5658
BranchListingFilter,
5759
} from "@gitbutler/but-sdk";
58-
import { app, BrowserWindow, ipcMain } from "electron";
60+
import { app, BrowserWindow, ipcMain, Menu, type MenuItemConstructorOptions } from "electron";
5961
import { REACT_DEVELOPER_TOOLS, installExtension } from "electron-devtools-installer";
6062
import path from "node:path";
6163
import { fileURLToPath } from "node:url";
6264

6365
const currentFilePath = fileURLToPath(import.meta.url);
6466
const currentDirPath = path.dirname(currentFilePath);
6567

68+
const buildNativeMenuTemplate = (
69+
items: Array<NativeMenuPopupItem>,
70+
onItem: (itemId: string) => void,
71+
): Array<MenuItemConstructorOptions> =>
72+
items.map((item): MenuItemConstructorOptions => {
73+
if (item._tag === "Separator") return { type: "separator" };
74+
const itemId = item.itemId;
75+
76+
return {
77+
label: item.label,
78+
enabled: item.enabled,
79+
click: itemId !== undefined ? () => onItem(itemId) : undefined,
80+
submenu: item.submenu ? buildNativeMenuTemplate(item.submenu, onItem) : undefined,
81+
};
82+
});
83+
6684
function registerIpcHandlers(): void {
6785
ipcMain.handle(
6886
liteIpcChannels.absorptionPlan,
@@ -176,6 +194,31 @@ function registerIpcHandlers(): void {
176194
(_e, { projectId, stackId, branch }: PushStackLegacyParams) =>
177195
pushStackLegacy(projectId, stackId, false, false, branch, true),
178196
);
197+
ipcMain.handle(
198+
liteIpcChannels.showNativeMenu,
199+
async (event, { items, position }: ShowNativeMenuParams) => {
200+
const window = BrowserWindow.fromWebContents(event.sender);
201+
if (!window) return null;
202+
203+
let selectedItemId: string | null = null;
204+
const menu = Menu.buildFromTemplate(
205+
buildNativeMenuTemplate(items, (itemId) => {
206+
selectedItemId = itemId;
207+
}),
208+
);
209+
210+
await new Promise<void>((resolve) => {
211+
menu.popup({
212+
window,
213+
x: Math.round(position.x),
214+
y: Math.round(position.y),
215+
callback: () => resolve(),
216+
});
217+
});
218+
219+
return selectedItemId;
220+
},
221+
);
179222
ipcMain.handle(
180223
liteIpcChannels.treeChangeDiffs,
181224
(_e, { projectId, change }: TreeChangeDiffParams) => treeChangeDiffs(projectId, change),

apps/lite/electron/src/preload.cts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,8 @@ const api: LiteElectronApi = {
9191
ping: (input) => ipcRenderer.invoke("lite:ping", input) as Promise<string>,
9292
pushStackLegacy: (params) =>
9393
ipcRenderer.invoke("workspace:push-stack-legacy", params) as Promise<PushResult>,
94+
showNativeMenu: (params) =>
95+
ipcRenderer.invoke("lite:show-native-menu", params) as Promise<string | null>,
9496
treeChangeDiffs: (params) =>
9597
ipcRenderer.invoke("workspace:tree-change-diffs", params) as Promise<UnifiedPatch | null>,
9698
unapplyStack: (params) => ipcRenderer.invoke("workspace:unapply-stack", params) as Promise<void>,

apps/lite/ui/src/native-menu.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import type { NativeMenuPopupItem, NativeMenuPosition } from "#electron/ipc.ts";
2+
import { MouseEvent } from "react";
3+
4+
type NativeMenuAction = () => void | Promise<void>;
5+
6+
type NativeMenuItemData = {
7+
label: string;
8+
enabled?: boolean;
9+
onSelect?: NativeMenuAction;
10+
submenu?: Array<NativeMenuItem>;
11+
};
12+
13+
export type NativeMenuItem = { _tag: "Separator" } | ({ _tag: "Item" } & NativeMenuItemData);
14+
15+
const serializeNativeMenuItems = (
16+
items: Array<NativeMenuItem>,
17+
handlers: Map<string, NativeMenuAction | undefined>,
18+
nextActionId: { value: number },
19+
): Array<NativeMenuPopupItem> =>
20+
items.map((item): NativeMenuPopupItem => {
21+
if (item._tag === "Separator") return { _tag: "Separator" };
22+
23+
if (item.submenu)
24+
return {
25+
_tag: "Item",
26+
label: item.label,
27+
enabled: item.enabled,
28+
submenu: serializeNativeMenuItems(item.submenu, handlers, nextActionId),
29+
};
30+
31+
const itemId = `native-menu:${nextActionId.value++}`;
32+
handlers.set(itemId, item.onSelect);
33+
34+
return {
35+
_tag: "Item",
36+
label: item.label,
37+
enabled: item.enabled,
38+
itemId,
39+
};
40+
});
41+
42+
const showNativeMenu = async (
43+
items: Array<NativeMenuItem>,
44+
position: NativeMenuPosition,
45+
): Promise<void> => {
46+
if (items.length === 0) return;
47+
48+
const handlers = new Map<string, NativeMenuAction | undefined>();
49+
const serializedItems = serializeNativeMenuItems(items, handlers, { value: 0 });
50+
51+
const selectedItemId = await window.lite.showNativeMenu({ items: serializedItems, position });
52+
if (selectedItemId === null) return;
53+
await handlers.get(selectedItemId)?.();
54+
};
55+
56+
const getBottomLeft = (element: HTMLElement): NativeMenuPosition => {
57+
const rect = element.getBoundingClientRect();
58+
return {
59+
x: Math.round(rect.left),
60+
y: Math.round(rect.bottom),
61+
};
62+
};
63+
64+
export const showNativeContextMenu = async (
65+
event: MouseEvent<HTMLButtonElement>,
66+
items: Array<NativeMenuItem>,
67+
): Promise<void> => {
68+
event.preventDefault();
69+
70+
const position =
71+
event.clientX === 0 && event.clientY === 0
72+
? getBottomLeft(event.currentTarget)
73+
: {
74+
x: Math.round(event.clientX),
75+
y: Math.round(event.clientY),
76+
};
77+
78+
await showNativeMenu(items, position);
79+
};
80+
81+
export const showNativeMenuFromTrigger = async (
82+
trigger: HTMLElement,
83+
items: Array<NativeMenuItem>,
84+
): Promise<void> => showNativeMenu(items, getBottomLeft(trigger));

apps/lite/ui/src/routes/project/$id/workspace/route.module.css

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,6 @@
7373

7474
.itemRow:hover &,
7575
.itemRow:focus-within &,
76-
.itemRow:has(.itemRowAction[aria-expanded="true"]) &,
7776
.itemRowSelected & {
7877
visibility: visible;
7978
}

0 commit comments

Comments
 (0)