Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions apps/lite/electron/src/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,27 @@ export interface WatcherSubscribeResult {
eventChannel: string;
}

export interface NativeMenuPosition {
x: number;
y: number;
}

type NativeMenuPopupItemData = {
label: string;
enabled?: boolean;
itemId?: string;
submenu?: Array<NativeMenuPopupItem>;
};

export type NativeMenuPopupItem =
| { _tag: "Separator" }
| ({ _tag: "Item" } & NativeMenuPopupItemData);

export interface ShowNativeMenuParams {
items: Array<NativeMenuPopupItem>;
position: NativeMenuPosition;
}

export interface LiteElectronApi {
absorptionPlan: (params: AbsorptionPlanParams) => Promise<Array<CommitAbsorption>>;
absorb: (params: AbsorbParams) => Promise<number>;
Expand Down Expand Up @@ -210,6 +231,7 @@ export interface LiteElectronApi {
tearOffBranch: (params: TearOffBranchParams) => Promise<MoveBranchResult>;
ping: (input: string) => Promise<string>;
pushStackLegacy: (params: PushStackLegacyParams) => Promise<PushResult>;
showNativeMenu: (params: ShowNativeMenuParams) => Promise<string | null>;
treeChangeDiffs: (params: TreeChangeDiffParams) => Promise<UnifiedPatch | null>;
unapplyStack: (params: UnapplyStackParams) => Promise<void>;
watcherSubscribe: (projectId: string, callback: (event: WatcherEvent) => void) => Promise<string>;
Expand Down Expand Up @@ -244,6 +266,7 @@ export const liteIpcChannels = {
tearOffBranch: "workspace:tear-off-branch",
ping: "lite:ping",
pushStackLegacy: "workspace:push-stack-legacy",
showNativeMenu: "lite:show-native-menu",
treeChangeDiffs: "workspace:tree-change-diffs",
unapplyStack: "workspace:unapply-stack",
watcherSubscribe: "workspace:watcher-subscribe",
Expand Down
45 changes: 44 additions & 1 deletion apps/lite/electron/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,11 @@ import {
type TreeChangeDiffParams,
type UpdateBranchNameParams,
type ApplyParams,
type ShowNativeMenuParams,
type UnapplyStackParams,
WatcherSubscribeParams,
WatcherUnsubscribeParams,
NativeMenuPopupItem,
Comment thread
OliverJAsh marked this conversation as resolved.
} from "./ipc.js";
import {
absorb,
Expand Down Expand Up @@ -55,14 +57,30 @@ import {
updateBranchName,
BranchListingFilter,
} from "@gitbutler/but-sdk";
import { app, BrowserWindow, ipcMain } from "electron";
import { app, BrowserWindow, ipcMain, Menu, type MenuItemConstructorOptions } from "electron";
import { REACT_DEVELOPER_TOOLS, installExtension } from "electron-devtools-installer";
import path from "node:path";
import { fileURLToPath } from "node:url";

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

const buildNativeMenuTemplate = (
items: Array<NativeMenuPopupItem>,
onItem: (itemId: string) => void,
): Array<MenuItemConstructorOptions> =>
items.map((item): MenuItemConstructorOptions => {
if (item._tag === "Separator") return { type: "separator" };
const itemId = item.itemId;

return {
label: item.label,
enabled: item.enabled,
click: itemId !== undefined ? () => onItem(itemId) : undefined,
submenu: item.submenu ? buildNativeMenuTemplate(item.submenu, onItem) : undefined,
};
});

function registerIpcHandlers(): void {
ipcMain.handle(
liteIpcChannels.absorptionPlan,
Expand Down Expand Up @@ -176,6 +194,31 @@ function registerIpcHandlers(): void {
(_e, { projectId, stackId, branch }: PushStackLegacyParams) =>
pushStackLegacy(projectId, stackId, false, false, branch, true),
);
ipcMain.handle(
liteIpcChannels.showNativeMenu,
async (event, { items, position }: ShowNativeMenuParams) => {
const window = BrowserWindow.fromWebContents(event.sender);
if (!window) return null;

let selectedItemId: string | null = null;
const menu = Menu.buildFromTemplate(
buildNativeMenuTemplate(items, (itemId) => {
selectedItemId = itemId;
}),
);

await new Promise<void>((resolve) => {
menu.popup({
window,
x: Math.round(position.x),
y: Math.round(position.y),
callback: () => resolve(),
});
});

return selectedItemId;
},
);
ipcMain.handle(
liteIpcChannels.treeChangeDiffs,
(_e, { projectId, change }: TreeChangeDiffParams) => treeChangeDiffs(projectId, change),
Expand Down
2 changes: 2 additions & 0 deletions apps/lite/electron/src/preload.cts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ const api: LiteElectronApi = {
ping: (input) => ipcRenderer.invoke("lite:ping", input) as Promise<string>,
pushStackLegacy: (params) =>
ipcRenderer.invoke("workspace:push-stack-legacy", params) as Promise<PushResult>,
showNativeMenu: (params) =>
ipcRenderer.invoke("lite:show-native-menu", params) as Promise<string | null>,
treeChangeDiffs: (params) =>
ipcRenderer.invoke("workspace:tree-change-diffs", params) as Promise<UnifiedPatch | null>,
unapplyStack: (params) => ipcRenderer.invoke("workspace:unapply-stack", params) as Promise<void>,
Expand Down
84 changes: 84 additions & 0 deletions apps/lite/ui/src/native-menu.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import type { NativeMenuPopupItem, NativeMenuPosition } from "#electron/ipc.ts";
import { MouseEvent } from "react";
Comment thread
OliverJAsh marked this conversation as resolved.

type NativeMenuAction = () => void | Promise<void>;

type NativeMenuItemData = {
label: string;
enabled?: boolean;
onSelect?: NativeMenuAction;
submenu?: Array<NativeMenuItem>;
};

export type NativeMenuItem = { _tag: "Separator" } | ({ _tag: "Item" } & NativeMenuItemData);

const serializeNativeMenuItems = (
items: Array<NativeMenuItem>,
handlers: Map<string, NativeMenuAction | undefined>,
nextActionId: { value: number },
): Array<NativeMenuPopupItem> =>
items.map((item): NativeMenuPopupItem => {
if (item._tag === "Separator") return { _tag: "Separator" };

if (item.submenu)
return {
_tag: "Item",
label: item.label,
enabled: item.enabled,
submenu: serializeNativeMenuItems(item.submenu, handlers, nextActionId),
};

const itemId = `native-menu:${nextActionId.value++}`;
handlers.set(itemId, item.onSelect);

return {
_tag: "Item",
label: item.label,
enabled: item.enabled,
itemId,
};
});

const showNativeMenu = async (
items: Array<NativeMenuItem>,
position: NativeMenuPosition,
): Promise<void> => {
if (items.length === 0) return;

const handlers = new Map<string, NativeMenuAction | undefined>();
const serializedItems = serializeNativeMenuItems(items, handlers, { value: 0 });

Comment on lines +48 to +50
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

serializeNativeMenuItems expects a Map<string, NativeMenuAction | undefined>, but showNativeMenu constructs handlers as new Map<string, NativeMenuAction>() and passes it in. Because Map is invariant in its value type, this is a TypeScript type error (and it’s also inconsistent with handlers.set(itemId, item.onSelect) where onSelect can be undefined). Make handlers a Map<string, NativeMenuAction | undefined> (or only allocate itemId/store handlers for items that actually have an onSelect).

Copilot uses AI. Check for mistakes.
const selectedItemId = await window.lite.showNativeMenu({ items: serializedItems, position });
if (selectedItemId === null) return;
await handlers.get(selectedItemId)?.();
};

const getBottomLeft = (element: HTMLElement): NativeMenuPosition => {
const rect = element.getBoundingClientRect();
return {
x: Math.round(rect.left),
y: Math.round(rect.bottom),
};
};

export const showNativeContextMenu = async (
event: MouseEvent<HTMLButtonElement>,
items: Array<NativeMenuItem>,
): Promise<void> => {
event.preventDefault();

const position =
event.clientX === 0 && event.clientY === 0
? getBottomLeft(event.currentTarget)
: {
x: Math.round(event.clientX),
y: Math.round(event.clientY),
};

await showNativeMenu(items, position);
};

export const showNativeMenuFromTrigger = async (
trigger: HTMLElement,
items: Array<NativeMenuItem>,
): Promise<void> => showNativeMenu(items, getBottomLeft(trigger));
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,6 @@

.itemRow:hover &,
.itemRow:focus-within &,
.itemRow:has(.itemRowAction[aria-expanded="true"]) &,
.itemRowSelected & {
visibility: visible;
}
Expand Down
Loading
Loading