mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-08-26 09:02:23 +00:00
206 lines
6.3 KiB
TypeScript
206 lines
6.3 KiB
TypeScript
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
|
|
|
export type DesktopMenuPlatform = 'darwin' | 'win32' | 'linux';
|
|
|
|
export interface InstalledAccelerator {
|
|
accelerator: string;
|
|
command: string;
|
|
normalized: string;
|
|
}
|
|
|
|
const MODIFIER_ORDER = ['Command', 'Control', 'Alt', 'Shift', 'Super'] as const;
|
|
|
|
const RENDERER_RESERVED_KEYS = ['K', 'S'] as const;
|
|
|
|
/**
|
|
* Keys the embedded xterm terminal must receive verbatim on Windows and Linux,
|
|
* where the app modifier is Ctrl and these are standard readline bindings
|
|
* (Ctrl+N next-history, Ctrl+B backward-char, Ctrl+W backward-kill-word).
|
|
* Their menu items keep the conventional label but must not register the
|
|
* accelerator, exactly like the Edit roles; the renderer dispatches them
|
|
* instead and lets the terminal win.
|
|
* macOS is unaffected: there the app modifier is Command, which the shell
|
|
* never claims.
|
|
*/
|
|
const TERMINAL_OWNED_NON_MAC_KEYS = ['N', 'B', 'W'] as const;
|
|
|
|
/** True when the renderer, not the native menu, must own this accelerator. */
|
|
export function isTerminalOwnedAccelerator(
|
|
key: string,
|
|
platform: DesktopMenuPlatform
|
|
): boolean {
|
|
if (platform === 'darwin') return false;
|
|
return (TERMINAL_OWNED_NON_MAC_KEYS as readonly string[]).includes(
|
|
key.toUpperCase()
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Renderer-owned shortcuts that a native menu must not register. Copy and the
|
|
* other edit accelerators are handled separately: Windows/Linux display them
|
|
* in the menu with `registerAccelerator: false`, while macOS requires native
|
|
* Edit accelerators for standard text editing.
|
|
*/
|
|
export function getReservedRendererAccelerators(
|
|
platform: DesktopMenuPlatform
|
|
): string[] {
|
|
const modifier = platform === 'darwin' ? 'Command' : 'Control';
|
|
return RENDERER_RESERVED_KEYS.map((key) => `${modifier}+${key}`);
|
|
}
|
|
|
|
function normalizeModifier(
|
|
token: string,
|
|
platform: DesktopMenuPlatform
|
|
): (typeof MODIFIER_ORDER)[number] | null {
|
|
switch (token.toLowerCase()) {
|
|
case 'cmd':
|
|
case 'command':
|
|
case 'meta':
|
|
return 'Command';
|
|
case 'ctrl':
|
|
case 'control':
|
|
return 'Control';
|
|
case 'cmdorctrl':
|
|
case 'commandorcontrol':
|
|
return platform === 'darwin' ? 'Command' : 'Control';
|
|
case 'alt':
|
|
case 'option':
|
|
return 'Alt';
|
|
case 'shift':
|
|
return 'Shift';
|
|
case 'super':
|
|
return 'Super';
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function normalizeKey(token: string): string {
|
|
if (token.length === 1 && /[a-z]/i.test(token)) return token.toUpperCase();
|
|
if (/^f\d+$/i.test(token)) return token.toUpperCase();
|
|
|
|
switch (token.toLowerCase()) {
|
|
case 'plus':
|
|
return 'Plus';
|
|
case 'space':
|
|
return 'Space';
|
|
case 'tab':
|
|
return 'Tab';
|
|
case 'enter':
|
|
case 'return':
|
|
return 'Enter';
|
|
case 'escape':
|
|
case 'esc':
|
|
return 'Escape';
|
|
default:
|
|
return token;
|
|
}
|
|
}
|
|
|
|
/** Resolve aliases and modifier ordering so equivalent accelerators compare. */
|
|
export function normalizeAccelerator(
|
|
accelerator: string,
|
|
platform: DesktopMenuPlatform
|
|
): string {
|
|
const modifiers = new Set<(typeof MODIFIER_ORDER)[number]>();
|
|
const keys: string[] = [];
|
|
|
|
for (const rawToken of accelerator.split('+')) {
|
|
const token = rawToken.trim();
|
|
if (!token) continue;
|
|
const modifier = normalizeModifier(token, platform);
|
|
if (modifier) modifiers.add(modifier);
|
|
else keys.push(normalizeKey(token));
|
|
}
|
|
|
|
return [
|
|
...MODIFIER_ORDER.filter((modifier) => modifiers.has(modifier)),
|
|
...keys,
|
|
].join('+');
|
|
}
|
|
|
|
function submenuItems(
|
|
item: Electron.MenuItemConstructorOptions
|
|
): Electron.MenuItemConstructorOptions[] {
|
|
return Array.isArray(item.submenu) ? item.submenu : [];
|
|
}
|
|
|
|
/** Collect accelerators that Electron will actually register for this menu. */
|
|
export function collectInstalledAccelerators(
|
|
template: Electron.MenuItemConstructorOptions[],
|
|
platform: DesktopMenuPlatform
|
|
): InstalledAccelerator[] {
|
|
const installed: InstalledAccelerator[] = [];
|
|
|
|
const visit = (
|
|
items: Electron.MenuItemConstructorOptions[],
|
|
parentPath: string[]
|
|
) => {
|
|
for (const item of items) {
|
|
const command =
|
|
item.id ||
|
|
[...parentPath, item.label || item.role || 'menu-item'].join('/');
|
|
|
|
if (
|
|
item.accelerator &&
|
|
!(platform !== 'darwin' && item.registerAccelerator === false)
|
|
) {
|
|
installed.push({
|
|
accelerator: item.accelerator,
|
|
command,
|
|
normalized: normalizeAccelerator(item.accelerator, platform),
|
|
});
|
|
}
|
|
|
|
const children = submenuItems(item);
|
|
if (children.length > 0) {
|
|
visit(children, [...parentPath, item.label || item.role || 'menu']);
|
|
}
|
|
}
|
|
};
|
|
|
|
visit(template, []);
|
|
return installed;
|
|
}
|
|
|
|
/** Fail fast before Electron installs an ambiguous or renderer-owned shortcut. */
|
|
export function assertUniqueInstalledAccelerators(
|
|
template: Electron.MenuItemConstructorOptions[],
|
|
platform: DesktopMenuPlatform,
|
|
reservedAccelerators = getReservedRendererAccelerators(platform)
|
|
): void {
|
|
const reserved = new Set(
|
|
reservedAccelerators.map((accelerator) =>
|
|
normalizeAccelerator(accelerator, platform)
|
|
)
|
|
);
|
|
const seen = new Map<string, InstalledAccelerator>();
|
|
|
|
for (const installed of collectInstalledAccelerators(template, platform)) {
|
|
if (reserved.has(installed.normalized)) {
|
|
throw new Error(
|
|
`Native menu command "${installed.command}" conflicts with renderer-reserved accelerator ${installed.accelerator}`
|
|
);
|
|
}
|
|
|
|
const existing = seen.get(installed.normalized);
|
|
if (existing) {
|
|
throw new Error(
|
|
`Native menu accelerator ${installed.accelerator} is assigned to both "${existing.command}" and "${installed.command}"`
|
|
);
|
|
}
|
|
seen.set(installed.normalized, installed);
|
|
}
|
|
}
|