diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index e3ceec281..2d54582d4 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -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(null); const appSidebarRef = ref | null>(null); +const appTabBarRef = ref | null>(null); const contentAreaRef = ref | 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(() => {
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, diff --git a/apps/desktop/src/lib/__tests__/editor/shortcutDisplay.spec.ts b/apps/desktop/src/lib/__tests__/editor/shortcutDisplay.spec.ts index 05986339b..66dcd0b37 100644 --- a/apps/desktop/src/lib/__tests__/editor/shortcutDisplay.spec.ts +++ b/apps/desktop/src/lib/__tests__/editor/shortcutDisplay.spec.ts @@ -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("⇧ ⌥ ↑"); diff --git a/apps/desktop/src/lib/__tests__/editor/shortcutRegistry.spec.ts b/apps/desktop/src/lib/__tests__/editor/shortcutRegistry.spec.ts index 76a26a41d..5ed294260 100644 --- a/apps/desktop/src/lib/__tests__/editor/shortcutRegistry.spec.ts +++ b/apps/desktop/src/lib/__tests__/editor/shortcutRegistry.spec.ts @@ -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(""); diff --git a/apps/desktop/src/lib/editor/keyboardShortcuts.ts b/apps/desktop/src/lib/editor/keyboardShortcuts.ts index 0ae8b1833..41fad69d2 100644 --- a/apps/desktop/src/lib/editor/keyboardShortcuts.ts +++ b/apps/desktop/src/lib/editor/keyboardShortcuts.ts @@ -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): 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, platform = globalThis.navigator?.platform || ""): boolean { + return matchesShortcut(event, actionShortcut("closeOtherTabs", shortcuts), platform); +} + export function isSendSelectionToAiShortcut(event: ShortcutLikeEvent, shortcuts?: Partial): boolean { return matchesShortcut(event, actionShortcut("sendSelectionToAi", shortcuts)); } diff --git a/apps/desktop/src/lib/editor/shortcutRegistry.ts b/apps/desktop/src/lib/editor/shortcutRegistry.ts index de78c2b66..fbf8787fe 100644 --- a/apps/desktop/src/lib/editor/shortcutRegistry.ts +++ b/apps/desktop/src/lib/editor/shortcutRegistry.ts @@ -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; +// 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): 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]; }), diff --git a/packages/app-tests/keyboardShortcuts.test.ts b/packages/app-tests/keyboardShortcuts.test.ts index 322e4de40..b08a486e9 100644 --- a/packages/app-tests/keyboardShortcuts.test.ts +++ b/packages/app-tests/keyboardShortcuts.test.ts @@ -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); });