feat(shortcuts): add close other tabs shortcut
This commit is contained in:
parent
e143a76e1f
commit
d2975fc015
|
|
@ -53,6 +53,7 @@ import type { ConnectionConfig, ObjectSourceKind, QueryTab } from "@/types/datab
|
|||
import { parseConnectionDeepLink, type ConnectionDeepLinkDraft } from "@/lib/connection/connectionDeepLink";
|
||||
import {
|
||||
isBrowserReloadShortcut,
|
||||
isCloseOtherTabsShortcut,
|
||||
isCloseTabShortcut,
|
||||
isExecuteSqlShortcut,
|
||||
isFocusSearchShortcut,
|
||||
|
|
@ -183,6 +184,7 @@ const aiPanelReady = ref(false);
|
|||
const { sidebarWidth, aiPanelWidth, historyWidth, sqlLibraryWidth, sqlFilePanelWidth, startSidebarResize, startAiPanelResize, startHistoryResize, startSqlLibraryResize, startSqlFilePanelResize } = usePanelResize();
|
||||
const aiAssistantRef = ref<AiAssistantHandle | null>(null);
|
||||
const appSidebarRef = ref<InstanceType<typeof AppSidebar> | null>(null);
|
||||
const appTabBarRef = ref<InstanceType<typeof AppTabBar> | null>(null);
|
||||
const contentAreaRef = ref<InstanceType<typeof ContentArea> | null>(null);
|
||||
|
||||
const selectedSql = ref("");
|
||||
|
|
@ -1760,6 +1762,12 @@ function handleKeydown(e: KeyboardEvent) {
|
|||
}
|
||||
return;
|
||||
}
|
||||
if (isCloseOtherTabsShortcut(e, shortcuts)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
appTabBarRef.value?.closeOtherActiveTabs();
|
||||
return;
|
||||
}
|
||||
if (isCloseTabShortcut(e, shortcuts)) {
|
||||
e.preventDefault();
|
||||
if (showSettingsPage.value) {
|
||||
|
|
@ -2051,6 +2059,7 @@ onUnmounted(() => {
|
|||
<div :class="isClassicLayout ? 'flex-1 min-w-0 overflow-hidden' : 'flex-1 min-w-0 overflow-hidden rounded-md border border-border/80 bg-background'">
|
||||
<div class="h-full flex flex-col min-w-0">
|
||||
<AppTabBar
|
||||
ref="appTabBarRef"
|
||||
:driver-store-open="driverStoreTabOpen"
|
||||
:driver-store-active="driverStoreActive"
|
||||
:settings-page-open="settingsPageTabOpen"
|
||||
|
|
|
|||
|
|
@ -185,6 +185,26 @@ function closeAllRegularSurfaces() {
|
|||
closeSpecialRegularSurfaces();
|
||||
}
|
||||
|
||||
function closeOtherActiveTabs() {
|
||||
if (props.settingsPageActive) {
|
||||
queryStore.closeRegularTabs();
|
||||
closeSpecialRegularSurfaces("settings");
|
||||
return;
|
||||
}
|
||||
if (props.driverStoreActive) {
|
||||
queryStore.closeRegularTabs();
|
||||
closeSpecialRegularSurfaces("driverStore");
|
||||
return;
|
||||
}
|
||||
|
||||
const tab = queryStore.tabs.find((item) => item.id === queryStore.activeTabId);
|
||||
if (!tab) return;
|
||||
if (tab.pinned) queryStore.closeOtherFixedTabs(tab.id);
|
||||
else closeOtherRegularTabsFromTab(tab);
|
||||
}
|
||||
|
||||
defineExpose({ closeOtherActiveTabs });
|
||||
|
||||
function getSpecialRegularTabMenuItems(surface: SpecialRegularSurface): ContextMenuItem[] {
|
||||
const keep = surface;
|
||||
const closeCurrent = surface === "driverStore" ? () => emit("close-driver-store") : () => emit("close-settings-page");
|
||||
|
|
@ -208,6 +228,7 @@ function getSpecialRegularTabMenuItems(surface: SpecialRegularSurface): ContextM
|
|||
},
|
||||
disabled: closeOtherDisabled,
|
||||
icon: X,
|
||||
shortcut: settingsStore.editorSettings.shortcuts.closeOtherTabs,
|
||||
},
|
||||
{
|
||||
label: closeAllLabel,
|
||||
|
|
@ -270,6 +291,7 @@ function getTabMenuItems(tab: QueryTab): ContextMenuItem[] {
|
|||
action: closeOtherAction,
|
||||
disabled: closeOtherDisabled,
|
||||
icon: X,
|
||||
shortcut: settingsStore.editorSettings.shortcuts.closeOtherTabs,
|
||||
},
|
||||
{
|
||||
label: closeAllLabel,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,11 @@ describe("shortcut display", () => {
|
|||
expect(shortcutDisplayKeys("Mod+Alt+Enter", "MacIntel")).toEqual(["⌘", "⌥", "↵"]);
|
||||
});
|
||||
|
||||
it("formats the close other tabs shortcut by platform", () => {
|
||||
expect(formatShortcutDisplay("Alt+Mod+W", "MacIntel")).toBe("⌥ ⌘ W");
|
||||
expect(formatShortcutDisplay("Shift+Alt+W", "Win32")).toBe("Shift + Alt + W");
|
||||
});
|
||||
|
||||
it("formats shortcut pills with platform separators", () => {
|
||||
expect(formatShortcutDisplay("Shift+Alt+ArrowUp", "Win32")).toBe("Shift + Alt + ↑");
|
||||
expect(formatShortcutDisplay("Shift+Alt+ArrowUp", "MacIntel")).toBe("⇧ ⌥ ↑");
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_SHORTCUT_SETTINGS, SHORTCUT_DEFINITIONS, findShortcutConflict, formatShortcut, normalizeModifierOnlyShortcut, normalizeShortcutSettings, shortcutToCodeMirrorKey, type ShortcutActionId } from "@/lib/editor/shortcutRegistry";
|
||||
import { closeOtherTabsDefaultShortcut, DEFAULT_SHORTCUT_SETTINGS, SHORTCUT_DEFINITIONS, findShortcutConflict, formatShortcut, normalizeModifierOnlyShortcut, normalizeShortcutSettings, shortcutToCodeMirrorKey, type ShortcutActionId } from "@/lib/editor/shortcutRegistry";
|
||||
|
||||
describe("shortcutRegistry editor actions", () => {
|
||||
const formatterEditorActionIds: ShortcutActionId[] = [
|
||||
|
|
@ -30,6 +30,20 @@ describe("shortcutRegistry editor actions", () => {
|
|||
expect(formatShortcut(DEFAULT_SHORTCUT_SETTINGS.openDataInNewTab, "MacIntel")).toBe("Alt");
|
||||
});
|
||||
|
||||
it("resolves the close-other-tabs default per platform and heals cross-platform synced defaults", () => {
|
||||
// 本测试环境(darwin):默认应为 macOS 组合
|
||||
expect(DEFAULT_SHORTCUT_SETTINGS.closeOtherTabs).toBe(closeOtherTabsDefaultShortcut());
|
||||
expect(closeOtherTabsDefaultShortcut("MacIntel")).toBe("Alt+Mod+W");
|
||||
// Windows/Linux 不含 Ctrl+Alt(AltGr)也不含 Ctrl+Shift+W(浏览器关窗保留键)
|
||||
expect(closeOtherTabsDefaultShortcut("Win32")).toBe("Shift+Alt+W");
|
||||
expect(closeOtherTabsDefaultShortcut("Linux x86_64")).toBe("Shift+Alt+W");
|
||||
// 云同步把另一平台的默认值带过来:视为未自定义,按本机平台还原
|
||||
expect(normalizeShortcutSettings({ closeOtherTabs: "Alt+Mod+W" }).closeOtherTabs).toBe(closeOtherTabsDefaultShortcut());
|
||||
expect(normalizeShortcutSettings({ closeOtherTabs: "Shift+Alt+W" }).closeOtherTabs).toBe(closeOtherTabsDefaultShortcut());
|
||||
// 用户真正自定义的组合原样保留
|
||||
expect(normalizeShortcutSettings({ closeOtherTabs: "Shift+Mod+O" }).closeOtherTabs).toBe("Shift+Mod+O");
|
||||
});
|
||||
|
||||
it("normalizes custom, cleared, and invalid modifier-only shortcuts", () => {
|
||||
expect(normalizeShortcutSettings({ openDataInNewTab: "Shift" }).openDataInNewTab).toBe("Shift");
|
||||
expect(normalizeShortcutSettings({ openDataInNewTab: "" }).openDataInNewTab).toBe("");
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { normalizeShortcutSettings, type ShortcutActionId, type ShortcutSettings
|
|||
|
||||
export interface ShortcutLikeEvent {
|
||||
key: string;
|
||||
code?: string;
|
||||
metaKey?: boolean;
|
||||
ctrlKey?: boolean;
|
||||
altKey?: boolean;
|
||||
|
|
@ -16,6 +17,18 @@ function normalizeKey(key: string): string {
|
|||
return key.length === 1 ? key.toLowerCase() : key;
|
||||
}
|
||||
|
||||
function matchesShortcutKey(event: ShortcutLikeEvent, key: string, platform = globalThis.navigator?.platform || ""): boolean {
|
||||
if (normalizeKey(event.key) === normalizeKey(key)) return true;
|
||||
// KeyboardEvent.code 回退:event.key 随布局/修饰键变形时按物理键位匹配
|
||||
// (macOS Option+字母 → 变形字符如 ⌥W="∑";俄文等非拉丁布局 → "Ц")。
|
||||
// 仅限 Alt 组合键场景
|
||||
if (!event.altKey || !/^Key[A-Z]$/.test(event.code ?? "") || !/^[A-Z]$/i.test(key)) return false;
|
||||
// 非 macOS 的 Ctrl+Alt 是 AltGr 特征:用户可能在输入字符(如波兰语
|
||||
// AltGr+W → "ł"),按 code 强制匹配会让全局快捷键在打字时误触发
|
||||
if (!isMacShortcutPlatform(platform) && event.ctrlKey) return false;
|
||||
return event.code!.slice(3).toLowerCase() === key.toLowerCase();
|
||||
}
|
||||
|
||||
function shortcutKeyName(key: string): string | null {
|
||||
if (key === " ") return "Space";
|
||||
if (key === "+") return "Plus";
|
||||
|
|
@ -64,7 +77,7 @@ export function matchesModifierOnlyShortcut(event: Omit<ShortcutLikeEvent, "key"
|
|||
return false;
|
||||
}
|
||||
|
||||
export function matchesShortcut(event: ShortcutLikeEvent, shortcut: string): boolean {
|
||||
export function matchesShortcut(event: ShortcutLikeEvent, shortcut: string, platform = globalThis.navigator?.platform || ""): boolean {
|
||||
if (event.isComposing || !shortcut) return false;
|
||||
const parts = parseShortcutParts(shortcut);
|
||||
const key = parts[parts.length - 1] ?? "";
|
||||
|
|
@ -82,7 +95,7 @@ export function matchesShortcut(event: ShortcutLikeEvent, shortcut: string): boo
|
|||
|
||||
if (!!event.altKey !== modifiers.has("Alt")) return false;
|
||||
if (!!event.shiftKey !== modifiers.has("Shift")) return false;
|
||||
return normalizeKey(event.key) === normalizeKey(key);
|
||||
return matchesShortcutKey(event, key, platform);
|
||||
}
|
||||
|
||||
function actionShortcut(actionId: ShortcutActionId, shortcuts?: Partial<ShortcutSettings>): string {
|
||||
|
|
@ -99,6 +112,10 @@ export function isCloseTabShortcut(event: ShortcutLikeEvent, shortcuts?: Partial
|
|||
return matchesShortcut(event, actionShortcut("closeTab", shortcuts));
|
||||
}
|
||||
|
||||
export function isCloseOtherTabsShortcut(event: ShortcutLikeEvent, shortcuts?: Partial<ShortcutSettings>, platform = globalThis.navigator?.platform || ""): boolean {
|
||||
return matchesShortcut(event, actionShortcut("closeOtherTabs", shortcuts), platform);
|
||||
}
|
||||
|
||||
export function isSendSelectionToAiShortcut(event: ShortcutLikeEvent, shortcuts?: Partial<ShortcutSettings>): boolean {
|
||||
return matchesShortcut(event, actionShortcut("sendSelectionToAi", shortcuts));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { parseShortcutStrokes, shortcutDisplayParts } from "@/lib/editor/shortcutDisplay";
|
||||
import { isMacShortcutPlatform, parseShortcutStrokes, shortcutDisplayParts } from "@/lib/editor/shortcutDisplay";
|
||||
|
||||
export type ShortcutActionId =
|
||||
| "executeSql"
|
||||
|
|
@ -25,6 +25,7 @@ export type ShortcutActionId =
|
|||
| "newQuery"
|
||||
| "openSettings"
|
||||
| "closeTab"
|
||||
| "closeOtherTabs"
|
||||
| "focusSearch"
|
||||
| "quickOpen"
|
||||
| "switchToPreviousTab"
|
||||
|
|
@ -65,6 +66,19 @@ export interface ShortcutDefinition {
|
|||
|
||||
export type ShortcutSettings = Record<ShortcutActionId, string>;
|
||||
|
||||
// closeOtherTabs 的平台相关默认键。Windows/Linux 不用 Alt+Mod(= Ctrl+Alt,
|
||||
// 与国际键盘 AltGr 字符输入冲突),也不用 Ctrl+Shift+W(浏览器保留的关窗键,
|
||||
// Web 形态不可拦截,closeTab 默认 Meta+W 同理);Shift+Alt+W 无浏览器保留
|
||||
// 冲突(Firefox accesskey 同为 Alt+Shift+字母,属正常应用快捷键区)。
|
||||
// 已知取舍:Windows 的 Alt+Shift 布局切换只在单独按下并释放时触发,
|
||||
// Alt+Shift+字母会正常送达应用,多语言用户如遇干扰可自定义改键。
|
||||
// macOS 的 ⌥⌘W 无上述问题
|
||||
export function closeOtherTabsDefaultShortcut(platform = globalThis.navigator?.platform || ""): string {
|
||||
return isMacShortcutPlatform(platform) ? "Alt+Mod+W" : "Shift+Alt+W";
|
||||
}
|
||||
|
||||
const CLOSE_OTHER_TABS_PLATFORM_DEFAULTS = new Set(["Alt+Mod+W", "Shift+Alt+W"]);
|
||||
|
||||
export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [
|
||||
{
|
||||
id: "executeSql",
|
||||
|
|
@ -210,6 +224,12 @@ export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [
|
|||
scope: "global",
|
||||
defaultShortcut: "Meta+W",
|
||||
},
|
||||
{
|
||||
id: "closeOtherTabs",
|
||||
labelKey: "contextMenu.closeOtherTabs",
|
||||
scope: "global",
|
||||
defaultShortcut: closeOtherTabsDefaultShortcut(),
|
||||
},
|
||||
{
|
||||
id: "focusSearch",
|
||||
labelKey: "settings.shortcutFocusSearch",
|
||||
|
|
@ -389,7 +409,13 @@ export function normalizeShortcutSettings(settings?: Partial<ShortcutSettings>):
|
|||
return Object.fromEntries(
|
||||
SHORTCUT_DEFINITIONS.map((definition) => {
|
||||
const configuredValue = settings?.[definition.id];
|
||||
const configured = typeof configuredValue === "string" ? configuredValue : definition.defaultShortcut;
|
||||
let configured = typeof configuredValue === "string" ? configuredValue : definition.defaultShortcut;
|
||||
// 云同步会把另一平台的默认值当作显式配置带过来(macOS 的 Alt+Mod+W 到
|
||||
// Windows 上会还原成 Ctrl+Alt+W)。凡是平台默认集合内的值都视为"未
|
||||
// 自定义",按本机平台重新解析;用户真正自定义的其他组合原样保留
|
||||
if (definition.id === "closeOtherTabs" && CLOSE_OTHER_TABS_PLATFORM_DEFAULTS.has(configured)) {
|
||||
configured = definition.defaultShortcut;
|
||||
}
|
||||
const normalized = definition.inputKind === "modifier-only" ? normalizeModifierOnlyShortcut(configured, definition.defaultShortcut) : configured;
|
||||
return [definition.id, normalized];
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
eventToShortcut,
|
||||
isBrowserReloadShortcut,
|
||||
isCancelSearchShortcut,
|
||||
isCloseOtherTabsShortcut,
|
||||
isCloseTabShortcut,
|
||||
isCopySidebarSelectionShortcut,
|
||||
isExecuteSqlShortcut,
|
||||
|
|
@ -24,6 +25,7 @@ import {
|
|||
isToggleTransposeShortcut,
|
||||
isZoomInShortcut,
|
||||
isZoomOutShortcut,
|
||||
matchesShortcut,
|
||||
switchToTabIndexFromShortcut,
|
||||
} from "../../apps/desktop/src/lib/editor/keyboardShortcuts.ts";
|
||||
import { shortcutToCodeMirrorKey } from "../../apps/desktop/src/lib/editor/shortcutRegistry.ts";
|
||||
|
|
@ -135,6 +137,33 @@ test("ignores Ctrl+W for closing query tabs", () => {
|
|||
assert.equal(isCloseTabShortcut({ key: "w", ctrlKey: true }), false);
|
||||
});
|
||||
|
||||
test("matches platform shortcuts for closing other tabs", () => {
|
||||
// macOS 默认 ⌥⌘W:Option 会把 event.key 变形(⌥W → "∑"),按 code 回退匹配。
|
||||
// 平台默认集合内的值会被 normalize 按本机平台还原(云同步自愈),因此
|
||||
// 默认组合的匹配行为用 matchesShortcut 直接断言,自定义组合走完整入口
|
||||
assert.equal(matchesShortcut({ key: "∑", code: "KeyW", altKey: true, metaKey: true }, "Alt+Mod+W", "MacIntel"), true);
|
||||
assert.equal(matchesShortcut({ key: "w", metaKey: true }, "Alt+Mod+W", "MacIntel"), false);
|
||||
// Windows/Linux 默认 Shift+Alt+W(不含 Ctrl+Alt 避开 AltGr;不含 Ctrl+Shift+W 避开浏览器关窗保留键)
|
||||
assert.equal(matchesShortcut({ key: "W", altKey: true, shiftKey: true }, "Shift+Alt+W", "Win32"), true);
|
||||
assert.equal(matchesShortcut({ key: "w", altKey: true }, "Shift+Alt+W", "Win32"), false);
|
||||
// 非拉丁布局(俄文 Ц 在物理 KeyW 上):无 Ctrl 的 Alt 组合按 code 回退匹配,默认键不失效
|
||||
assert.equal(matchesShortcut({ key: "Ц", code: "KeyW", altKey: true, shiftKey: true }, "Shift+Alt+W", "Win32"), true);
|
||||
// 用户自定义组合(非平台默认集合)经完整入口匹配
|
||||
assert.equal(isCloseOtherTabsShortcut({ key: "o", ctrlKey: true, shiftKey: true }, { closeOtherTabs: "Shift+Mod+O" }, "Win32"), true);
|
||||
});
|
||||
|
||||
test("altgr character input never triggers close other tabs on windows layouts", () => {
|
||||
// 波兰语等布局:AltGr+W(= Ctrl+Alt+W)产生字符 "ł",event.key 不是 "w"。
|
||||
// 即使用户自定义了 Alt+Mod+W,code 回退在非 macOS 平台禁用,不得按物理
|
||||
// KeyW 强制匹配——否则用户输入文本会误触发关闭其他标签页
|
||||
assert.equal(matchesShortcut({ key: "ł", code: "KeyW", altKey: true, ctrlKey: true }, "Alt+Mod+W", "Win32"), false);
|
||||
assert.equal(matchesShortcut({ key: "ę", code: "KeyE", altKey: true, ctrlKey: true }, "Alt+Mod+E", "Linux x86_64"), false);
|
||||
// 显式按下字母本身(key 就是 "w")仍正常匹配自定义组合
|
||||
assert.equal(matchesShortcut({ key: "w", altKey: true, ctrlKey: true }, "Alt+Mod+W", "Win32"), true);
|
||||
// 经完整入口:非平台默认集合的自定义组合在 AltGr 布局下同样不误触发
|
||||
assert.equal(isCloseOtherTabsShortcut({ key: "ę", code: "KeyE", altKey: true, ctrlKey: true }, { closeOtherTabs: "Alt+Mod+E" }, "Win32"), false);
|
||||
});
|
||||
|
||||
test("matches Ctrl+F for focusing search", () => {
|
||||
assert.equal(isFocusSearchShortcut({ key: "f", ctrlKey: true }), true);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue