feat(shortcuts): 支持快捷键关闭当前标签页

- 新增可自定义的关闭当前标签页动作,macOS 默认 ⌘W,Windows/Linux 默认 Ctrl+W
- 根据最近交互区域路由工作区或结果标签关闭,并隔离分离窗口
- 关闭日志标签时隐藏整个结果区,避免连续按键误关工作区
- 沿用未保存 SQL 与导入任务等工作区关闭保护
- 隔离 IME、快捷键录制、弹窗、抽屉与上下文菜单
- 补充多语言文案及快捷键路由回归测试
This commit is contained in:
Syngnat
2026-07-20 20:22:35 +08:00
parent db74e01447
commit b2819db495
29 changed files with 1204 additions and 47 deletions

View File

@@ -0,0 +1,207 @@
import { describe, expect, it, vi } from 'vitest';
import { DEFAULT_SHORTCUT_OPTIONS, cloneShortcutOptions } from './shortcuts';
import {
CLOSE_SHORTCUT_BACKGROUND_BLOCKER_SELECTOR,
dispatchCloseActiveResultTab,
getPlatformNativeCloseCombo,
hasVisibleCloseShortcutBackgroundBlocker,
resolveCloseShortcutKeydownDecision,
resolveCloseShortcutScopeFromTarget,
resolveDockedActiveTabId,
} from './closeTabShortcut';
const keyEvent = (overrides: Partial<{
key: string;
code: string;
ctrlKey: boolean;
metaKey: boolean;
altKey: boolean;
shiftKey: boolean;
isComposing: boolean;
keyCode: number;
}> = {}) => ({
key: 'w',
code: 'KeyW',
ctrlKey: false,
metaKey: false,
altKey: false,
shiftKey: false,
isComposing: false,
keyCode: 87,
...overrides,
});
describe('close tab shortcut routing decision', () => {
it('routes the enabled platform default to closeActiveTab', () => {
expect(resolveCloseShortcutKeydownDecision({
event: keyEvent({ metaKey: true }),
shortcutOptions: DEFAULT_SHORTCUT_OPTIONS,
platform: 'mac',
capturingShortcut: false,
imeComposing: false,
interactionBlocked: false,
})).toEqual({
kind: 'close',
preventDefault: true,
stopImmediatePropagation: true,
ownerAction: 'closeActiveTab',
});
});
it('consumes the native close combo when the action is disabled', () => {
const options = cloneShortcutOptions(DEFAULT_SHORTCUT_OPTIONS);
options.closeActiveTab.windows.enabled = false;
expect(resolveCloseShortcutKeydownDecision({
event: keyEvent({ ctrlKey: true }),
shortcutOptions: options,
platform: 'windows',
capturingShortcut: false,
imeComposing: false,
interactionBlocked: false,
})).toMatchObject({ kind: 'consume', ownerAction: null, preventDefault: true });
});
it('delegates a migrated native combo to its existing action owner', () => {
const options = cloneShortcutOptions(DEFAULT_SHORTCUT_OPTIONS);
options.closeActiveTab.windows.enabled = false;
options.newQueryTab.windows.combo = 'Ctrl+W';
expect(resolveCloseShortcutKeydownDecision({
event: keyEvent({ ctrlKey: true }),
shortcutOptions: options,
platform: 'windows',
capturingShortcut: false,
imeComposing: false,
interactionBlocked: false,
})).toEqual({
kind: 'delegate',
preventDefault: true,
stopImmediatePropagation: false,
ownerAction: 'newQueryTab',
});
});
it('consumes without dispatch during IME or guarded interactions', () => {
for (const flags of [
{ imeComposing: true, interactionBlocked: false },
{ imeComposing: false, interactionBlocked: true },
]) {
expect(resolveCloseShortcutKeydownDecision({
event: keyEvent({ metaKey: true, isComposing: flags.imeComposing }),
shortcutOptions: DEFAULT_SHORTCUT_OPTIONS,
platform: 'mac',
capturingShortcut: false,
...flags,
})).toMatchObject({ kind: 'consume', preventDefault: true, stopImmediatePropagation: true });
}
});
it('lets the recorder own the event before close routing', () => {
expect(resolveCloseShortcutKeydownDecision({
event: keyEvent({ metaKey: true }),
shortcutOptions: DEFAULT_SHORTCUT_OPTIONS,
platform: 'mac',
capturingShortcut: true,
imeComposing: false,
interactionBlocked: false,
})).toEqual({ kind: 'recording', preventDefault: false, stopImmediatePropagation: false });
});
it('ignores unrelated combinations', () => {
expect(resolveCloseShortcutKeydownDecision({
event: keyEvent({ key: 'q', code: 'KeyQ', metaKey: true }),
shortcutOptions: DEFAULT_SHORTCUT_OPTIONS,
platform: 'mac',
capturingShortcut: false,
imeComposing: false,
interactionBlocked: false,
})).toEqual({ kind: 'ignore', preventDefault: false, stopImmediatePropagation: false });
});
it('maps native close keys per platform', () => {
expect(getPlatformNativeCloseCombo('mac')).toBe('Meta+W');
expect(getPlatformNativeCloseCombo('windows')).toBe('Ctrl+W');
});
});
describe('close shortcut interaction scope', () => {
const target = (matches: Record<string, { scope?: string } | null>) => ({
closest: vi.fn((selector: string) => {
const match = matches[selector];
if (!match) return null;
return {
getAttribute: (name: string) => name === 'data-gonavi-close-shortcut-scope'
? match.scope ?? null
: null,
};
}),
});
it('prefers detached blocked ownership over a background workspace', () => {
const node = target({
'[data-gonavi-close-shortcut-scope="blocked"], .gn-detached-result-window, .gn-detached-window, .gn-detached-ai-chat-window, .gn-result-diff-floating-window': {},
});
expect(resolveCloseShortcutScopeFromTarget(node)).toBe('blocked');
});
it('returns the explicit result or workspace scope', () => {
const result = target({
'[data-gonavi-close-shortcut-scope]': { scope: 'result' },
});
const workspace = target({
'[data-gonavi-close-shortcut-scope]': { scope: 'workspace' },
});
expect(resolveCloseShortcutScopeFromTarget(result)).toBe('result');
expect(resolveCloseShortcutScopeFromTarget(workspace)).toBe('workspace');
});
it('does not let an ordinary guard change the remembered scope', () => {
const guarded = {
closest: vi.fn((selector: string) => selector.includes('data-gonavi-close-shortcut-guard') ? {} : null),
};
expect(resolveCloseShortcutScopeFromTarget(guarded)).toBeNull();
});
it('detects only visible background blockers', () => {
const visible = {
hidden: false,
style: {},
getAttribute: () => null,
classList: { contains: () => false },
ownerDocument: { defaultView: { getComputedStyle: () => ({ display: 'block', visibility: 'visible' }) } },
};
const hidden = {
...visible,
style: { display: 'none' },
};
const documentTarget = {
querySelectorAll: vi.fn(() => [hidden, visible]),
};
expect(hasVisibleCloseShortcutBackgroundBlocker(documentTarget)).toBe(true);
expect(documentTarget.querySelectorAll).toHaveBeenCalledWith(CLOSE_SHORTCUT_BACKGROUND_BLOCKER_SELECTOR);
});
});
describe('result close command', () => {
it('resolves the visible docked tab independently from detached activity', () => {
const tabs = [{ id: 'docked-1' }, { id: 'detached-1' }, { id: 'docked-2' }];
const detached = [{ tabId: 'detached-1' }];
expect(resolveDockedActiveTabId(tabs, 'docked-2', detached)).toBe('docked-2');
expect(resolveDockedActiveTabId(tabs, 'detached-1', detached)).toBe('docked-1');
expect(resolveDockedActiveTabId([{ id: 'detached-1' }], 'detached-1', detached)).toBeNull();
});
it('returns the synchronously mutated request outcome', () => {
const eventTarget = {
dispatchEvent: vi.fn((event: CustomEvent) => {
event.detail.handled = true;
event.detail.outcome = 'hidden';
return true;
}),
};
expect(dispatchCloseActiveResultTab('tab-1', eventTarget as unknown as Window)).toBe('hidden');
expect(eventTarget.dispatchEvent).toHaveBeenCalledWith(expect.objectContaining({
detail: expect.objectContaining({ targetTabId: 'tab-1' }),
}));
});
});

View File

@@ -0,0 +1,285 @@
import {
SHORTCUT_ACTION_ORDER,
isShortcutPhysicalMatch,
resolveShortcutBinding,
type ShortcutAction,
type ShortcutOptions,
type ShortcutPlatform,
} from './shortcuts';
export const CLOSE_ACTIVE_WORKSPACE_TAB_EVENT = 'gonavi:close-active-workspace-tab';
export const CLOSE_ACTIVE_RESULT_TAB_EVENT = 'gonavi:close-active-result-tab';
export type CloseShortcutScope = 'workspace' | 'result' | 'blocked';
export type CloseActiveResultShortcutOutcome = 'closed' | 'hidden' | 'ignored';
export interface CloseActiveResultShortcutRequest {
targetTabId: string | null;
handled: boolean;
outcome: CloseActiveResultShortcutOutcome;
}
export const resolveDockedActiveTabId = (
tabs: Array<{ id: string }>,
activeTabId: string | null | undefined,
detachedWindows: Array<{ tabId: string }>,
): string | null => {
const detachedTabIds = new Set(detachedWindows.map((windowState) => windowState.tabId));
const dockedTabs = tabs.filter((tab) => !detachedTabIds.has(tab.id));
if (activeTabId && dockedTabs.some((tab) => tab.id === activeTabId)) {
return activeTabId;
}
return dockedTabs[0]?.id ?? null;
};
export interface CloseShortcutKeyEvent {
key: string;
code?: string;
ctrlKey: boolean;
metaKey: boolean;
altKey: boolean;
shiftKey: boolean;
isComposing?: boolean;
keyCode?: number;
which?: number;
}
export type CloseShortcutKeydownDecision =
| { kind: 'ignore' | 'recording'; preventDefault: false; stopImmediatePropagation: false }
| { kind: 'consume'; preventDefault: true; stopImmediatePropagation: true; ownerAction: ShortcutAction | null }
| { kind: 'close'; preventDefault: true; stopImmediatePropagation: true; ownerAction: 'closeActiveTab' }
| { kind: 'delegate'; preventDefault: true; stopImmediatePropagation: false; ownerAction: ShortcutAction };
export const getPlatformNativeCloseCombo = (platform: ShortcutPlatform): string => (
platform === 'mac' ? 'Meta+W' : 'Ctrl+W'
);
const resolvePhysicalShortcutOwner = (
event: CloseShortcutKeyEvent,
shortcutOptions: Partial<ShortcutOptions> | null | undefined,
platform: ShortcutPlatform,
): ShortcutAction | null => (
SHORTCUT_ACTION_ORDER.find((action) => {
const binding = resolveShortcutBinding(shortcutOptions, action, platform);
return binding.enabled && isShortcutPhysicalMatch(event as KeyboardEvent, binding.combo);
}) ?? null
);
export const resolveCloseShortcutKeydownDecision = ({
event,
shortcutOptions,
platform,
capturingShortcut,
imeComposing,
interactionBlocked,
}: {
event: CloseShortcutKeyEvent;
shortcutOptions: Partial<ShortcutOptions> | null | undefined;
platform: ShortcutPlatform;
capturingShortcut: boolean;
imeComposing: boolean;
interactionBlocked: boolean;
}): CloseShortcutKeydownDecision => {
const nativeCloseMatched = isShortcutPhysicalMatch(
event as KeyboardEvent,
getPlatformNativeCloseCombo(platform),
);
const closeBinding = resolveShortcutBinding(shortcutOptions, 'closeActiveTab', platform);
const configuredCloseMatched = closeBinding.enabled
&& isShortcutPhysicalMatch(event as KeyboardEvent, closeBinding.combo);
if (!nativeCloseMatched && !configuredCloseMatched) {
return { kind: 'ignore', preventDefault: false, stopImmediatePropagation: false };
}
if (capturingShortcut) {
return { kind: 'recording', preventDefault: false, stopImmediatePropagation: false };
}
const ownerAction = resolvePhysicalShortcutOwner(event, shortcutOptions, platform);
if (imeComposing || interactionBlocked) {
return {
kind: 'consume',
preventDefault: true,
stopImmediatePropagation: true,
ownerAction,
};
}
if (ownerAction === 'closeActiveTab') {
return {
kind: 'close',
preventDefault: true,
stopImmediatePropagation: true,
ownerAction,
};
}
if (ownerAction) {
return {
kind: 'delegate',
preventDefault: true,
stopImmediatePropagation: false,
ownerAction,
};
}
return {
kind: 'consume',
preventDefault: true,
stopImmediatePropagation: true,
ownerAction: null,
};
};
type ClosestTarget = {
closest?: (selector: string) => ClosestTarget | null;
parentElement?: ClosestTarget | null;
getAttribute?: (name: string) => string | null;
hidden?: boolean;
style?: {
display?: string;
visibility?: string;
};
classList?: {
contains?: (name: string) => boolean;
};
ownerDocument?: {
defaultView?: {
getComputedStyle?: (element: unknown) => {
display?: string;
visibility?: string;
};
} | null;
} | null;
};
type QueryDocument = {
querySelectorAll?: (selector: string) => ArrayLike<ClosestTarget>;
};
const CLOSE_SHORTCUT_SCOPE_SELECTOR = '[data-gonavi-close-shortcut-scope]';
const DETACHED_CLOSE_SHORTCUT_SCOPE_SELECTOR = [
'[data-gonavi-close-shortcut-scope="blocked"]',
'.gn-detached-result-window',
'.gn-detached-window',
'.gn-detached-ai-chat-window',
'.gn-result-diff-floating-window',
].join(', ');
export const CLOSE_SHORTCUT_GUARD_SELECTOR = [
'[data-gonavi-close-shortcut-guard="true"]',
'.ant-modal-wrap',
'.ant-drawer',
'.ant-dropdown',
'.ant-select-dropdown',
'.ant-picker-dropdown',
'.ant-popover',
'.gn-v2-table-context-menu-portal',
'.gn-v2-sidebar-context-menu-portal',
'.gn-v2-table-overview-context-menu-portal',
'.gn-v2-redis-context-menu',
'.gn-v2-context-menu',
].join(', ');
export const CLOSE_SHORTCUT_BACKGROUND_BLOCKER_SELECTOR = [
'[data-gonavi-close-shortcut-blocks-background="true"]',
'.ant-modal-wrap',
'.ant-drawer.ant-drawer-open',
'.ant-dropdown:not(.ant-dropdown-hidden)',
'.ant-select-dropdown:not(.ant-select-dropdown-hidden)',
'.ant-picker-dropdown:not(.ant-picker-dropdown-hidden)',
'.ant-popover:not(.ant-popover-hidden)',
'.gn-v2-table-context-menu-portal',
'.gn-v2-sidebar-context-menu-portal',
'.gn-v2-table-overview-context-menu-portal',
'.gn-v2-redis-context-menu',
'.gn-v2-context-menu',
].join(', ');
const asClosestTarget = (target: EventTarget | ClosestTarget | null | undefined): ClosestTarget | null => {
if (!target || typeof target !== 'object') return null;
const candidate = target as ClosestTarget;
if (typeof candidate.closest === 'function') return candidate;
return candidate.parentElement && typeof candidate.parentElement.closest === 'function'
? candidate.parentElement
: null;
};
const closest = (target: ClosestTarget | null, selector: string): ClosestTarget | null => {
if (!target || typeof target.closest !== 'function') return null;
return target.closest(selector);
};
export const resolveCloseShortcutScopeFromTarget = (
target: EventTarget | ClosestTarget | null | undefined,
): CloseShortcutScope | null => {
const element = asClosestTarget(target);
if (!element) return null;
if (closest(element, DETACHED_CLOSE_SHORTCUT_SCOPE_SELECTOR)) {
return 'blocked';
}
if (closest(element, CLOSE_SHORTCUT_GUARD_SELECTOR)) {
return null;
}
const scopeElement = closest(element, CLOSE_SHORTCUT_SCOPE_SELECTOR);
const scope = scopeElement?.getAttribute?.('data-gonavi-close-shortcut-scope');
return scope === 'workspace' || scope === 'result' || scope === 'blocked'
? scope
: null;
};
export const isCloseShortcutGuardTarget = (
target: EventTarget | ClosestTarget | null | undefined,
): boolean => Boolean(closest(asClosestTarget(target), CLOSE_SHORTCUT_GUARD_SELECTOR));
const isVisibleBlocker = (element: ClosestTarget): boolean => {
if (element.hidden || element.getAttribute?.('aria-hidden') === 'true') return false;
if (
element.classList?.contains?.('ant-dropdown-hidden')
|| element.classList?.contains?.('ant-select-dropdown-hidden')
|| element.classList?.contains?.('ant-picker-dropdown-hidden')
|| element.classList?.contains?.('ant-popover-hidden')
) {
return false;
}
if (element.style?.display === 'none' || element.style?.visibility === 'hidden') return false;
const computedStyle = element.ownerDocument?.defaultView?.getComputedStyle?.(element);
return computedStyle?.display !== 'none' && computedStyle?.visibility !== 'hidden';
};
export const hasVisibleCloseShortcutBackgroundBlocker = (
documentTarget: QueryDocument | null | undefined,
): boolean => {
const elements = documentTarget?.querySelectorAll?.(CLOSE_SHORTCUT_BACKGROUND_BLOCKER_SELECTOR);
if (!elements) return false;
return Array.from(elements).some(isVisibleBlocker);
};
export const isCloseShortcutInteractionBlocked = (
target: EventTarget | ClosestTarget | null | undefined,
documentTarget: QueryDocument | null | undefined,
): boolean => (
isCloseShortcutGuardTarget(target)
|| hasVisibleCloseShortcutBackgroundBlocker(documentTarget)
);
export const dispatchCloseActiveWorkspaceTab = (eventTarget: Window = window): void => {
eventTarget.dispatchEvent(new CustomEvent(CLOSE_ACTIVE_WORKSPACE_TAB_EVENT));
};
export const dispatchCloseActiveResultTab = (
targetTabId: string | null,
eventTarget: Window = window,
): CloseActiveResultShortcutOutcome => {
const request: CloseActiveResultShortcutRequest = {
targetTabId,
handled: false,
outcome: 'ignored',
};
eventTarget.dispatchEvent(new CustomEvent<CloseActiveResultShortcutRequest>(
CLOSE_ACTIVE_RESULT_TAB_EVENT,
{ detail: request },
));
return request.outcome;
};

View File

@@ -18,10 +18,13 @@ import {
getShortcutPrimaryModifierDisplayLabel,
installGlobalImeCompositionTracking,
isGlobalImeCompositionActive,
isGlobalShortcutCaptureActive,
isImeComposingKeyEvent,
isShortcutMatch,
isShortcutPhysicalMatch,
resolveShortcutBinding,
resolveShortcutDisplay,
setGlobalShortcutCaptureActive,
setGlobalImeCompositionActive,
sanitizeShortcutOptions,
SHORTCUT_ACTION_META,
@@ -31,6 +34,7 @@ import type { ConflictInfo } from './shortcuts';
beforeEach(() => {
setCurrentLanguage('zh-CN');
setGlobalImeCompositionActive(false);
setGlobalShortcutCaptureActive(false);
});
// ─── findReservedConflict ────────────────────────────────────────────
@@ -93,6 +97,10 @@ describe('findReservedConflicts', () => {
expect(findReservedConflicts('Ctrl+Shift+Q')).toEqual([]);
});
it('does not reserve Ctrl+W after the app takes ownership of close-tab', () => {
expect(findReservedConflicts('Ctrl+W')).toEqual([]);
});
it('preserves monacoCommandId in results', () => {
const results = findReservedConflicts('Ctrl+F');
expect(results[0].monacoCommandId).toBe('actions.find');
@@ -199,6 +207,49 @@ describe('RESERVED_SHORTCUTS', () => {
});
describe('IME shortcut guards', () => {
it('suppresses normal shortcut owners while the recorder is active', () => {
const event = {
key: 'w',
code: 'KeyW',
ctrlKey: true,
metaKey: false,
altKey: false,
shiftKey: false,
} as KeyboardEvent;
setGlobalShortcutCaptureActive(true);
expect(isGlobalShortcutCaptureActive()).toBe(true);
expect(isShortcutMatch(event, 'Ctrl+W')).toBe(false);
expect(isShortcutPhysicalMatch(event, 'Ctrl+W')).toBe(true);
});
it('keeps a recorder registered after an existing owner safe from listener order', () => {
const target = new EventTarget();
const owner = vi.fn();
const recorder = vi.fn();
target.addEventListener('keydown', (rawEvent) => {
if (isShortcutMatch(rawEvent as KeyboardEvent, 'Ctrl+W')) owner();
});
setGlobalShortcutCaptureActive(true);
target.addEventListener('keydown', (rawEvent) => {
recorder(eventToShortcut(rawEvent as KeyboardEvent));
});
const event = new Event('keydown', { cancelable: true });
Object.defineProperties(event, {
key: { value: 'w' },
code: { value: 'KeyW' },
ctrlKey: { value: true },
metaKey: { value: false },
altKey: { value: false },
shiftKey: { value: false },
});
target.dispatchEvent(event);
expect(owner).not.toHaveBeenCalled();
expect(recorder).toHaveBeenCalledWith('Ctrl+W');
});
it('tracks composition state through global listeners', () => {
const windowListeners = new Map<string, EventListener[]>();
const documentListeners = new Map<string, EventListener[]>();
@@ -267,6 +318,28 @@ describe('IME shortcut guards', () => {
expect(isShortcutMatch(event, 'Ctrl+Enter')).toBe(false);
});
it('matches a physical shortcut during IME composition without changing the guarded matcher', () => {
const event = {
key: 'w',
code: 'KeyW',
keyCode: 229,
which: 229,
isComposing: true,
ctrlKey: true,
metaKey: false,
altKey: false,
shiftKey: false,
nativeEvent: {
isComposing: true,
keyCode: 229,
which: 229,
},
} as unknown as KeyboardEvent;
expect(isShortcutPhysicalMatch(event, 'Ctrl+W')).toBe(true);
expect(isShortcutMatch(event, 'Ctrl+W')).toBe(false);
});
it('matches modifier shortcuts from KeyboardEvent.code when WebView reports Process', () => {
const event = {
key: 'Process',
@@ -368,6 +441,18 @@ describe('IME shortcut guards', () => {
// ─── shortcut defaults ───────────────────────────────────────────────
describe('shortcut defaults', () => {
it('registers close active tab as an editable global shortcut', () => {
expect(DEFAULT_SHORTCUT_OPTIONS.closeActiveTab).toEqual({
mac: { combo: 'Meta+W', enabled: true },
windows: { combo: 'Ctrl+W', enabled: true },
});
expect(SHORTCUT_ACTION_META.closeActiveTab).toMatchObject({
label: '关闭当前标签页',
scope: 'global',
allowInEditable: true,
});
});
it('registers select current statement as a query editor shortcut', () => {
expect(DEFAULT_SHORTCUT_OPTIONS.selectCurrentStatement).toEqual({
mac: { combo: 'Meta+E', enabled: true },
@@ -518,6 +603,68 @@ describe('shortcut defaults', () => {
windows: { combo: 'Ctrl+Shift+R', enabled: false },
});
expect(options.newQueryTab.windows.combo).toBe('Ctrl+N');
expect(options.closeActiveTab).toEqual({
mac: { combo: 'Meta+W', enabled: true },
windows: { combo: 'Ctrl+W', enabled: true },
});
});
it('keeps close active tab enabled for new and empty shortcut settings', () => {
expect(sanitizeShortcutOptions(undefined).closeActiveTab).toEqual(DEFAULT_SHORTCUT_OPTIONS.closeActiveTab);
expect(sanitizeShortcutOptions({}).closeActiveTab).toEqual(DEFAULT_SHORTCUT_OPTIONS.closeActiveTab);
});
it('disables only the conflicting close-tab platform while preserving current platform bindings', () => {
const options = sanitizeShortcutOptions({
saveQuery: {
mac: { combo: 'Meta+W', enabled: true },
windows: { combo: 'Ctrl+S', enabled: true },
},
toggleTheme: {
mac: { combo: 'Meta+Shift+D', enabled: true },
windows: { combo: 'Ctrl+W', enabled: true },
},
});
expect(options.saveQuery.mac).toEqual({ combo: 'Meta+W', enabled: true });
expect(options.toggleTheme.windows).toEqual({ combo: 'Ctrl+W', enabled: true });
expect(options.closeActiveTab).toEqual({
mac: { combo: 'Meta+W', enabled: false },
windows: { combo: 'Ctrl+W', enabled: false },
});
});
it('migrates legacy single-platform close-tab conflicts independently per platform', () => {
const options = sanitizeShortcutOptions({
saveQuery: { combo: 'Meta+W', enabled: true },
});
expect(options.saveQuery).toEqual({
mac: { combo: 'Meta+W', enabled: true },
windows: { combo: 'Meta+W', enabled: true },
});
expect(options.closeActiveTab).toEqual({
mac: { combo: 'Meta+W', enabled: false },
windows: { combo: 'Ctrl+W', enabled: true },
});
});
it('respects an existing close active tab binding during sanitization', () => {
const options = sanitizeShortcutOptions({
closeActiveTab: {
mac: { combo: 'Meta+Shift+W', enabled: false },
windows: { combo: 'Ctrl+Shift+W', enabled: true },
},
saveQuery: {
mac: { combo: 'Meta+W', enabled: true },
windows: { combo: 'Ctrl+W', enabled: true },
},
});
expect(options.closeActiveTab).toEqual({
mac: { combo: 'Meta+Shift+W', enabled: false },
windows: { combo: 'Ctrl+Shift+W', enabled: true },
});
});
it('sanitizes partial platform shortcut bindings without losing defaults', () => {

View File

@@ -13,6 +13,7 @@ export type ShortcutAction =
| 'sendAIChatMessage'
| 'focusSidebarSearch'
| 'newQueryTab'
| 'closeActiveTab'
| 'switchToNextTab'
| 'switchToPreviousTab'
| 'newConnection'
@@ -114,6 +115,7 @@ export const SHORTCUT_ACTION_ORDER: ShortcutAction[] = [
'sendAIChatMessage',
'focusSidebarSearch',
'newQueryTab',
'closeActiveTab',
'switchToNextTab',
'switchToPreviousTab',
'newConnection',
@@ -206,6 +208,12 @@ const SHORTCUT_ACTION_META_DEFINITIONS: Record<ShortcutAction, ShortcutActionMet
labelKey: 'app.shortcuts.action.newQueryTab.label',
descriptionKey: 'app.shortcuts.action.newQueryTab.description',
},
closeActiveTab: {
labelKey: 'app.shortcuts.action.closeActiveTab.label',
descriptionKey: 'app.shortcuts.action.closeActiveTab.description',
scope: 'global',
allowInEditable: true,
},
switchToNextTab: {
labelKey: 'app.shortcuts.action.switchToNextTab.label',
descriptionKey: 'app.shortcuts.action.switchToNextTab.description',
@@ -310,6 +318,10 @@ export const DEFAULT_SHORTCUT_OPTIONS: ShortcutOptions = {
mac: { combo: 'Meta+N', enabled: true },
windows: { combo: 'Ctrl+N', enabled: true },
},
closeActiveTab: {
mac: { combo: 'Meta+W', enabled: true },
windows: { combo: 'Ctrl+W', enabled: true },
},
switchToNextTab: {
mac: { combo: 'Ctrl+Tab', enabled: true },
windows: { combo: 'Ctrl+Tab', enabled: true },
@@ -467,6 +479,7 @@ const normalizeKeyboardEventCode = (
};
let globalImeCompositionActive = false;
let globalShortcutCaptureActive = false;
export const setGlobalImeCompositionActive = (active: boolean): void => {
globalImeCompositionActive = active === true;
@@ -474,6 +487,12 @@ export const setGlobalImeCompositionActive = (active: boolean): void => {
export const isGlobalImeCompositionActive = (): boolean => globalImeCompositionActive;
export const setGlobalShortcutCaptureActive = (active: boolean): void => {
globalShortcutCaptureActive = active === true;
};
export const isGlobalShortcutCaptureActive = (): boolean => globalShortcutCaptureActive;
type ImeCompositionEventTarget = Pick<Window, 'addEventListener' | 'removeEventListener'>;
type ImeCompositionDocumentTarget = Pick<Document, 'addEventListener' | 'removeEventListener'> & {
visibilityState?: DocumentVisibilityState;
@@ -604,10 +623,7 @@ const isUsableShortcutKey = (key: string): boolean => (
&& key !== 'Dead'
);
const eventToShortcutCandidates = (event: KeyboardEvent | ReactKeyboardEvent): string[] => {
if (isImeComposingKeyEvent(event)) {
return [];
}
const eventToPhysicalShortcutCandidates = (event: KeyboardEvent | ReactKeyboardEvent): string[] => {
const modifiers = resolveShortcutModifiersFromEvent(event);
const candidates: string[] = [];
const pushCandidate = (key: string) => {
@@ -636,16 +652,30 @@ const eventToShortcutCandidates = (event: KeyboardEvent | ReactKeyboardEvent): s
return candidates;
};
const eventToShortcutCandidates = (event: KeyboardEvent | ReactKeyboardEvent): string[] => {
if (isImeComposingKeyEvent(event)) {
return [];
}
return eventToPhysicalShortcutCandidates(event);
};
export const eventToShortcut = (event: KeyboardEvent | ReactKeyboardEvent): string => {
return eventToShortcutCandidates(event)[0] || '';
};
export const isShortcutMatch = (event: KeyboardEvent | ReactKeyboardEvent, combo: string): boolean => {
if (globalShortcutCaptureActive) return false;
const expected = normalizeShortcutCombo(combo);
if (!expected) return false;
return eventToShortcutCandidates(event).includes(expected);
};
export const isShortcutPhysicalMatch = (event: KeyboardEvent | ReactKeyboardEvent, combo: string): boolean => {
const expected = normalizeShortcutCombo(combo);
if (!expected) return false;
return eventToPhysicalShortcutCandidates(event).includes(expected);
};
export const getShortcutPlatform = (isMacRuntime?: boolean): ShortcutPlatform => (
isMacRuntime ? 'mac' : 'windows'
);
@@ -735,6 +765,10 @@ const sanitizeShortcutPlatformBinding = (
export const sanitizeShortcutOptions = (value: unknown): ShortcutOptions => {
const raw = (value && typeof value === 'object') ? value as Record<string, unknown> : {};
const defaults = cloneShortcutOptions(DEFAULT_SHORTCUT_OPTIONS);
const hasPersistedCloseActiveTab = Object.prototype.hasOwnProperty.call(raw, 'closeActiveTab');
const hasPersistedShortcutAction = SHORTCUT_ACTION_ORDER.some((action) => (
action !== 'closeActiveTab' && Object.prototype.hasOwnProperty.call(raw, action)
));
SHORTCUT_ACTION_ORDER.forEach((action) => {
const actionRaw = raw[action];
@@ -755,6 +789,21 @@ export const sanitizeShortcutOptions = (value: unknown): ShortcutOptions => {
};
});
if (!hasPersistedCloseActiveTab && hasPersistedShortcutAction) {
(['mac', 'windows'] as const).forEach((platform) => {
const closeBinding = defaults.closeActiveTab[platform];
const closeCombo = normalizeShortcutCombo(closeBinding.combo);
const occupied = SHORTCUT_ACTION_ORDER.some((action) => {
if (action === 'closeActiveTab') return false;
const binding = defaults[action][platform];
return binding.enabled && normalizeShortcutCombo(binding.combo) === closeCombo;
});
if (occupied) {
defaults.closeActiveTab[platform] = { ...closeBinding, enabled: false };
}
});
}
return defaults;
};
@@ -861,7 +910,6 @@ const RESERVED_SHORTCUT_DEFINITIONS: ReservedShortcutDefinition[] = [
// Browser / WebView built-in shortcuts
{ combo: 'Ctrl+S', labelKey: 'app.shortcuts.reserved.browser_save', context: 'global' },
{ combo: 'Ctrl+P', labelKey: 'app.shortcuts.reserved.browser_print', context: 'global' },
{ combo: 'Ctrl+W', labelKey: 'app.shortcuts.reserved.browser_close_tab', context: 'global' },
{ combo: 'Ctrl+T', labelKey: 'app.shortcuts.reserved.browser_new_tab', context: 'global' },
{ combo: 'Ctrl+N', labelKey: 'app.shortcuts.reserved.browser_new_window', context: 'global' },
{ combo: 'Ctrl+Shift+N', labelKey: 'app.shortcuts.reserved.browser_new_incognito_window', context: 'global' },