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
99 changes: 99 additions & 0 deletions src/web-ui/src/infrastructure/services/ShortcutManager.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/**
* @vitest-environment jsdom
*/

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { EDITOR_SHORTCUTS } from '@/shared/constants/shortcuts';
import { shortcutManager } from './ShortcutManager';

function setPlatform(platform: string): void {
Object.defineProperty(window.navigator, 'platform', {
value: platform,
configurable: true,
});
}

function dispatchScopedKey(scope: string, init: KeyboardEventInit): void {
const target = document.createElement('div');
target.setAttribute('data-shortcut-scope', scope);
document.body.appendChild(target);
target.dispatchEvent(new KeyboardEvent('keydown', {
key: init.key,
code: init.code,
ctrlKey: init.ctrlKey,
metaKey: init.metaKey,
shiftKey: init.shiftKey,
altKey: init.altKey,
bubbles: true,
cancelable: true,
}));
target.remove();
}

describe('ShortcutManager platform primary modifier', () => {
beforeEach(() => {
shortcutManager.clear();
shortcutManager.setEnabled(true);
shortcutManager.loadUserOverrides({});
document.body.innerHTML = '';
});

afterEach(() => {
shortcutManager.clear();
vi.restoreAllMocks();
});

it('maps logical Ctrl shortcuts to Command on macOS', () => {
setPlatform('MacIntel');
const callback = vi.fn();
shortcutManager.register(
'editor.findInFile',
{ key: 'f', ctrl: true, scope: 'editor', allowInInput: true },
callback
);

dispatchScopedKey('editor', { key: 'f', metaKey: true });

expect(callback).toHaveBeenCalledTimes(1);
});

it('does not treat physical Control as the macOS primary modifier', () => {
setPlatform('MacIntel');
const callback = vi.fn();
shortcutManager.register(
'editor.findInFile',
{ key: 'f', ctrl: true, scope: 'editor', allowInInput: true },
callback
);

dispatchScopedKey('editor', { key: 'f', ctrlKey: true });

expect(callback).not.toHaveBeenCalled();
});

it('keeps shortcut catalog defaults platform-neutral', () => {
const findInFile = EDITOR_SHORTCUTS.find((shortcut) => shortcut.id === 'editor.findInFile');

expect(findInFile?.config).toMatchObject({ key: 'f', ctrl: true });
expect(findInFile?.config.meta).toBeUndefined();
});

it('detects app-scope conflicts against scoped shortcuts', () => {
setPlatform('Win32');
shortcutManager.register('app.search', { key: 'k', ctrl: true, scope: 'app' }, vi.fn());
shortcutManager.register('chat.search', { key: 'k', ctrl: true, scope: 'chat' }, vi.fn());

expect(shortcutManager.checkConflicts({ key: 'k', ctrl: true, scope: 'chat' }, 'chat.search'))
.toEqual([expect.objectContaining({ id: 'app.search' })]);
expect(shortcutManager.checkConflicts({ key: 'k', ctrl: true, scope: 'app' }, 'app.search'))
.toEqual([expect.objectContaining({ id: 'chat.search' })]);
});

it('detects Ctrl and Meta as the same primary modifier on macOS conflicts', () => {
setPlatform('MacIntel');
shortcutManager.register('app.find', { key: 'f', meta: true, scope: 'app' }, vi.fn());

expect(shortcutManager.checkConflicts({ key: 'f', ctrl: true, scope: 'editor' }))
.toEqual([expect.objectContaining({ id: 'app.find' })]);
});
});
58 changes: 45 additions & 13 deletions src/web-ui/src/infrastructure/services/ShortcutManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,17 +76,35 @@ function sanitizeOverrides(raw: Record<string, unknown>): KeybindingOverrides {

/**
* Compute the O(1) map key for a shortcut config.
* Format: "{scope}:{key_lower}:{ctrl}{shift}{alt}{meta}"
* Format: "{scope}:{key_lower}:{primary}{shift}{alt}{meta}"
* Example: "app:]:1000"
*/
function makeMapKey(scope: ShortcutScope, config: ShortcutConfig): string {
const c = config.ctrl ? '1' : '0';
const normalized = normalizeShortcutConfig(config);
const c = normalized.primary ? '1' : '0';
const s = config.shift ? '1' : '0';
const a = config.alt ? '1' : '0';
const m = config.meta ? '1' : '0';
const m = normalized.meta ? '1' : '0';
return `${scope}:${config.key.toLowerCase()}:${c}${s}${a}${m}`;
}

function isMacPlatform(): boolean {
return typeof navigator !== 'undefined' && navigator.platform.toUpperCase().includes('MAC');
}

function normalizeShortcutConfig(config: ShortcutConfig): { primary: boolean; meta: boolean } {
if (isMacPlatform()) {
return {
primary: Boolean(config.ctrl || config.meta),
meta: false,
};
}
return {
primary: Boolean(config.ctrl),
meta: Boolean(config.meta),
};
}

/**
* Normalize which logical key was pressed for lookup.
* Digit row: prefer `event.code` (Digit1–Digit9) so Ctrl+digit shortcuts work when `event.key`
Expand All @@ -106,13 +124,12 @@ function eventKeyForLookup(event: KeyboardEvent): string {
* Compute the map key directly from a KeyboardEvent for lookup.
*/
function makeEventKey(scope: ShortcutScope, event: KeyboardEvent): string {
const isMac = navigator.platform.toUpperCase().includes('MAC');
const ctrl = isMac ? event.metaKey : event.ctrlKey;
const ctrl = isMacPlatform() ? event.metaKey : event.ctrlKey;
const c = ctrl ? '1' : '0';
const s = event.shiftKey ? '1' : '0';
const a = event.altKey ? '1' : '0';
// meta is not used as standalone modifier in our system (folded into ctrl on Mac)
return `${scope}:${eventKeyForLookup(event)}:${c}${s}${a}0`;
const m = !isMacPlatform() && event.metaKey ? '1' : '0';
return `${scope}:${eventKeyForLookup(event)}:${c}${s}${a}${m}`;
}

export class ShortcutManager {
Expand Down Expand Up @@ -153,13 +170,14 @@ export class ShortcutManager {
}

private start(): void {
if (typeof window === 'undefined') return;
if (this.keyDownHandler) return;
this.keyDownHandler = this.handleKeyDown.bind(this);
window.addEventListener('keydown', this.keyDownHandler, true);
}

public stop(): void {
if (this.keyDownHandler) {
if (this.keyDownHandler && typeof window !== 'undefined') {
window.removeEventListener('keydown', this.keyDownHandler, true);
this.keyDownHandler = null;
}
Expand Down Expand Up @@ -439,13 +457,25 @@ export class ShortcutManager {
}

/**
* Check whether a given config conflicts with any registered shortcut in the same scope.
* Check whether a given config conflicts with registered shortcuts in the same scope
* or with app-scope shortcuts. App-scope shortcuts are active globally inside BitFun,
* so they can shadow or be shadowed by scoped shortcuts.
* Used by the settings UI for real-time conflict detection.
*/
public checkConflicts(config: ShortcutConfig, excludeId?: string, excludeIds?: string[]): ShortcutRegistration[] {
const scope = config.scope ?? 'app';
const key = makeMapKey(scope, config);
const list = this.lookupMap.get(key) ?? [];
const scopesToCheck: ShortcutScope[] = scope === 'app'
? ['app', 'chat', 'editor', 'canvas', 'filetree', 'git']
: [scope, 'app'];
const seen = new Set<string>();
const list: ShortcutRegistration[] = [];
for (const candidateScope of scopesToCheck) {
for (const reg of this.lookupMap.get(makeMapKey(candidateScope, config)) ?? []) {
if (seen.has(reg.id)) continue;
seen.add(reg.id);
list.push(reg);
}
}
const exclude = new Set<string>([...(excludeIds ?? [])]);
if (excludeId) exclude.add(excludeId);
return exclude.size ? list.filter((r) => !exclude.has(r.id)) : [...list];
Expand All @@ -454,11 +484,13 @@ export class ShortcutManager {
// ─── Utilities ─────────────────────────────────────────────────────────────

public formatShortcut(config: ShortcutConfig): string {
const isMac = navigator.platform.toUpperCase().includes('MAC');
const isMac = isMacPlatform();
const normalized = normalizeShortcutConfig(config);
const parts: string[] = [];
if (config.ctrl) parts.push(isMac ? '⌘' : 'Ctrl');
if (normalized.primary) parts.push(isMac ? '⌘' : 'Ctrl');
if (config.shift) parts.push(isMac ? '⇧' : 'Shift');
if (config.alt) parts.push(isMac ? '⌥' : 'Alt');
if (!isMac && normalized.meta) parts.push('Meta');
const key = config.key === ' ' ? 'Space' : config.key.length === 1 ? config.key.toUpperCase() : config.key;
parts.push(key);
return parts.join(isMac ? '' : '+');
Expand Down
10 changes: 5 additions & 5 deletions src/web-ui/src/shared/constants/shortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,14 @@ export const NON_USER_CUSTOMIZABLE_SHORTCUT_IDS = new Set<string>([

// ─── Helpers ───────────────────────────────────────────────────────────────

const isMac = typeof navigator !== 'undefined' && navigator.platform.toUpperCase().includes('MAC');

/** Build a ShortcutConfig using Ctrl (Win/Linux) or Meta (Mac) as the primary modifier. */
/** Build a ShortcutConfig using BitFun's logical primary modifier.
* ShortcutManager maps it to Ctrl on Windows/Linux and Command on macOS.
*/
function mod(
key: string,
extras: Omit<ShortcutConfig, 'key' | 'ctrl' | 'meta'> = {}
extras: Omit<ShortcutConfig, 'key' | 'ctrl'> = {}
): ShortcutConfig {
return isMac ? { key, meta: true, ...extras } : { key, ctrl: true, ...extras };
return { key, ctrl: true, ...extras };
}

// ─── Global shortcuts (scope: 'app') ──────────────────────────────────────
Expand Down
Loading