mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-22 00:42:47 +08:00
✨ feat(shortcuts): 支持快捷键关闭当前标签页
- 新增可自定义的关闭当前标签页动作,macOS 默认 ⌘W,Windows/Linux 默认 Ctrl+W - 根据最近交互区域路由工作区或结果标签关闭,并隔离分离窗口 - 关闭日志标签时隐藏整个结果区,避免连续按键误关工作区 - 沿用未保存 SQL 与导入任务等工作区关闭保护 - 隔离 IME、快捷键录制、弹窗、抽屉与上下文菜单 - 补充多语言文案及快捷键路由回归测试
This commit is contained in:
207
frontend/src/utils/closeTabShortcut.test.ts
Normal file
207
frontend/src/utils/closeTabShortcut.test.ts
Normal 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' }),
|
||||
}));
|
||||
});
|
||||
});
|
||||
285
frontend/src/utils/closeTabShortcut.ts
Normal file
285
frontend/src/utils/closeTabShortcut.ts
Normal 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;
|
||||
};
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user