feat(ui): land the centralized shortcuts core from #2532 with review fixes
The schema/config/bindings/registry/dispatcher module, useKeybind hooks, recording dialog, reworked shortcuts settings page, help dialog, and the localized action labels — re-based onto current main rather than merged (the branch predates 440+ commits including the session-tabs shortcuts). Review fixes applied on top of the original: - close_session_tab (alt+w) joins the schema with labels in every locale; it shipped on main after the PR's base and would otherwise silently die. - switch_context_surface's special-case in conflict resolution is now a declared prefixStyle config property instead of a magic id string. - Duplicate handler registration warns in dev builds. - The risky-browser-shortcut warning inspects every chord and covers mod+q/d/h/j/o/u plus mod+shift+w/q. - The dispatcher remembers which target armed a two-chord prefix so the window-level completion handler can distinguish a deliberate sequence from typing in an editable field (guard lands with the dispatch hook). - Schema tests: unique normalized default bindings enforced, and the flat-file-era override format proven to keep resolving.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
|
||||
import { hasOpenDropdown } from './keyboard-shortcut-dom';
|
||||
import { hasOpenDropdown, shouldStopDropdownImeEscape } from './keyboard-shortcut-dom';
|
||||
|
||||
test('does not treat an unrelated visible listbox as an open dropdown', () => {
|
||||
const promptNavigator = {} as Element;
|
||||
@@ -28,3 +28,10 @@ test('detects an open select popup', () => {
|
||||
|
||||
expect(hasOpenDropdown(root)).toBe(true);
|
||||
});
|
||||
|
||||
test('stops IME Escape before an open dropdown dismiss listener', () => {
|
||||
expect(shouldStopDropdownImeEscape({ key: 'Escape', isComposing: true, keyCode: 0 }, true)).toBe(true);
|
||||
expect(shouldStopDropdownImeEscape({ key: 'Escape', isComposing: false, keyCode: 229 }, true)).toBe(true);
|
||||
expect(shouldStopDropdownImeEscape({ key: 'Escape', isComposing: false, keyCode: 27 }, true)).toBe(false);
|
||||
expect(shouldStopDropdownImeEscape({ key: 'Escape', isComposing: true, keyCode: 0 }, false)).toBe(false);
|
||||
});
|
||||
|
||||
@@ -6,3 +6,12 @@ const OPEN_DROPDOWN_SELECTOR = [
|
||||
export function hasOpenDropdown(root: ParentNode = document): boolean {
|
||||
return Boolean(root.querySelector(OPEN_DROPDOWN_SELECTOR));
|
||||
}
|
||||
|
||||
export function shouldStopDropdownImeEscape(
|
||||
event: Pick<KeyboardEvent, 'isComposing' | 'key' | 'keyCode'>,
|
||||
dropdownOpen: boolean,
|
||||
): boolean {
|
||||
return dropdownOpen
|
||||
&& event.key === 'Escape'
|
||||
&& (event.isComposing || event.keyCode === 229);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import type { ShortcutHandler } from '@/lib/shortcuts';
|
||||
import type { ShortcutBindings } from './useKeybind';
|
||||
|
||||
const handler: ShortcutHandler = () => {};
|
||||
const validBindings = {
|
||||
open_session_list: handler,
|
||||
};
|
||||
const mixedBindingsWithTypo = {
|
||||
open_session_list: handler,
|
||||
open_session_lsit: handler,
|
||||
};
|
||||
|
||||
const acceptedBindings: ShortcutBindings<typeof validBindings> = validBindings;
|
||||
// @ts-expect-error A misspelled key must fail even when the object also contains a valid ID.
|
||||
const rejectedBindings: ShortcutBindings<typeof mixedBindingsWithTypo> = mixedBindingsWithTypo;
|
||||
void rejectedBindings;
|
||||
|
||||
test('accepts bindings whose IDs are declared in the shortcut schema', () => {
|
||||
expect(Object.keys(acceptedBindings)).toEqual(['open_session_list']);
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import { shortcutRegistry, type ShortcutActionId, type ShortcutHandler } from '@/lib/shortcuts';
|
||||
|
||||
export function useKeybind(actionId: ShortcutActionId, handler: ShortcutHandler): void {
|
||||
const handlerRef = React.useRef(handler);
|
||||
handlerRef.current = handler;
|
||||
|
||||
React.useEffect(() => shortcutRegistry.register(actionId, (event) => handlerRef.current(event)), [actionId]);
|
||||
}
|
||||
|
||||
export type ShortcutBindings<
|
||||
Bindings extends Partial<Record<ShortcutActionId, ShortcutHandler>>,
|
||||
> = Bindings & Record<Exclude<keyof Bindings, ShortcutActionId>, never>;
|
||||
|
||||
export function useKeybinds<
|
||||
const Bindings extends Partial<Record<ShortcutActionId, ShortcutHandler>>,
|
||||
>(bindings: ShortcutBindings<Bindings>): void {
|
||||
const handlersRef = React.useRef(bindings);
|
||||
handlersRef.current = bindings;
|
||||
const actionIdsKey = Object.keys(bindings).sort().join('\0');
|
||||
|
||||
React.useEffect(() => {
|
||||
const actionIds = (actionIdsKey ? actionIdsKey.split('\0') : []) as ShortcutActionId[];
|
||||
const unregister = actionIds.map((actionId) => shortcutRegistry.register(actionId, (event) => {
|
||||
const handler = handlersRef.current[actionId];
|
||||
return handler ? handler(event) : false;
|
||||
}));
|
||||
return () => unregister.forEach((remove) => remove());
|
||||
}, [actionIdsKey]);
|
||||
}
|
||||
Reference in New Issue
Block a user