diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 388d7a52ae..12eb36c540 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -72,6 +72,7 @@ import { isExecuteSqlShortcut, isFocusSearchShortcut, isModRShortcut, + handleTabHistoryNavigationShortcut, isNewQueryShortcut, isObjectSourceSaveShortcutTarget, isOpenSettingsShortcut, @@ -88,6 +89,7 @@ import { switchToTabIndexFromShortcut, } from "@/lib/editor/keyboardShortcuts"; import { isPreviewTab } from "@/lib/tabs/tabPresentation"; +import { createTabNavigationHistory, moveInTabNavigationHistory, recordTabVisit } from "@/lib/tabs/tabNavigationHistory"; import { supportsSqlFileExecution } from "@/lib/database/databaseCapabilities"; import { classifyAiSqlExecution } from "@/lib/ai/aiSqlExecutionPolicy"; import { buildAppendedEditorSql } from "@/lib/ai/aiSqlAppend"; @@ -287,6 +289,8 @@ const pendingAppCloseAction = ref(null); const pendingCloseActionChoice = ref(false); const activeTab = computed(() => queryStore.tabs.find((t) => t.id === queryStore.activeTabId)); +const tabNavigationHistory = ref(createTabNavigationHistory()); +let pendingTabHistoryNavigationId: string | null = null; const externalSqlFileChanges = useExternalSqlFileChanges({ activeTab, @@ -725,6 +729,15 @@ watch( }), ); } + if (id) { + if (pendingTabHistoryNavigationId === id) pendingTabHistoryNavigationId = null; + else { + pendingTabHistoryNavigationId = null; + tabNavigationHistory.value = recordTabVisit(tabNavigationHistory.value, id); + } + } else { + pendingTabHistoryNavigationId = null; + } if (id) newQueryContextSource.value = "tab"; if (id && driverStoreActive.value) driverStoreActive.value = false; if (id && settingsStore.settingsPageActive) settingsStore.settingsPageActive = false; @@ -2236,6 +2249,20 @@ function activateAdjacentTab(direction: -1 | 1): boolean { return activateTabByIndex(nextIndex); } +function activateTabFromHistory(direction: -1 | 1): boolean { + const move = moveInTabNavigationHistory(tabNavigationHistory.value, direction, new Set(queryStore.tabs.map((tab) => tab.id)), queryStore.activeTabId); + if (!move) return false; + + const previousHistory = tabNavigationHistory.value; + tabNavigationHistory.value = move.history; + pendingTabHistoryNavigationId = move.tabId; + if (activateQueryTab(move.tabId)) return true; + + tabNavigationHistory.value = previousHistory; + pendingTabHistoryNavigationId = null; + return false; +} + function handleNativeSelectAll(e: KeyboardEvent) { if (shouldBlockAppNativeSelectAll(e)) e.preventDefault(); } @@ -2291,6 +2318,11 @@ async function handleKeydown(e: KeyboardEvent) { } return; } + if (handleTabHistoryNavigationShortcut(e, shortcuts, activateTabFromHistory)) { + e.preventDefault(); + e.stopPropagation(); + return; + } if (isSwitchToPreviousTabShortcut(e, shortcuts)) { if (activateAdjacentTab(-1)) { e.preventDefault(); diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 63a2ecdbe3..ccabcaf984 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -6298,6 +6298,8 @@ export default { shortcutCloseTab: "Close tab", shortcutFocusSearch: "Focus current view search", shortcutQuickOpen: "Quick open (search all database objects)", + shortcutNavigateTabHistoryBack: "Go back to the previously viewed tab", + shortcutNavigateTabHistoryForward: "Go forward to the next viewed tab", shortcutSwitchToPreviousTab: "Switch to previous tab", shortcutSwitchToNextTab: "Switch to next tab", shortcutSwitchToTab1: "Switch to tab 1", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 0cec156bf0..96744c9c77 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -5991,6 +5991,8 @@ export default withEnglishFallback({ shortcutToggleSidebar: "Alternar barra lateral", shortcutFocusSearch: "Enfocar búsqueda de la vista actual", shortcutQuickOpen: "Abrir rápido (buscar todos los objetos de base de datos)", + shortcutNavigateTabHistoryBack: "Volver a la pestaña vista anteriormente", + shortcutNavigateTabHistoryForward: "Avanzar a la siguiente pestaña vista", shortcutSwitchToPreviousTab: "Cambiar a la pestaña anterior", shortcutSwitchToNextTab: "Cambiar a la pestaña siguiente", shortcutSwitchToTab1: "Cambiar a la pestaña 1", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 165ab6a92e..9d70dc506c 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -5990,6 +5990,8 @@ export default withEnglishFallback({ shortcutCloseTab: "Chiudi scheda", shortcutFocusSearch: "Focalizza la ricerca nella vista corrente", shortcutQuickOpen: "Apertura rapida (ricerca tutti gli oggetti del database)", + shortcutNavigateTabHistoryBack: "Torna alla scheda visualizzata in precedenza", + shortcutNavigateTabHistoryForward: "Vai avanti alla scheda visualizzata successiva", shortcutSwitchToPreviousTab: "Passa alla scheda precedente", shortcutSwitchToNextTab: "Passa alla scheda successiva", shortcutSwitchToTab1: "Passa alla scheda 1", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 3f47ad1513..970732b899 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -6005,6 +6005,8 @@ export default withEnglishFallback({ shortcutCloseTab: "タブを閉じる", shortcutFocusSearch: "現在のビューの検索にフォーカス", shortcutQuickOpen: "クイックオープン (すべてのデータベースオブジェクトを検索)", + shortcutNavigateTabHistoryBack: "前に表示したタブに戻る", + shortcutNavigateTabHistoryForward: "次に表示したタブに進む", shortcutSwitchToPreviousTab: "前のタブに切り替え", shortcutSwitchToNextTab: "次のタブに切り替え", shortcutSwitchToTab1: "タブ 1 に切り替え", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 915b233b7d..9e6702140f 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -5728,6 +5728,8 @@ export default withEnglishFallback({ shortcutCloseTab: "탭 닫기", shortcutFocusSearch: "현재 화면 검색에 포커스", shortcutQuickOpen: "빠른 열기 (모든 데이터베이스 객체 검색)", + shortcutNavigateTabHistoryBack: "이전에 본 탭으로 돌아가기", + shortcutNavigateTabHistoryForward: "다음에 본 탭으로 이동", shortcutSwitchToPreviousTab: "이전 탭으로 전환", shortcutSwitchToNextTab: "다음 탭으로 전환", shortcutSwitchToTab1: "탭 1로 전환", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 9a7ab49559..61a3cbaf21 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -5993,6 +5993,8 @@ export default withEnglishFallback({ shortcutToggleSidebar: "Alternar barra lateral", shortcutFocusSearch: "Focar na pesquisa da tela atual", shortcutQuickOpen: "Abrir rapidamente (pesquisar todos os objetos do banco de dados)", + shortcutNavigateTabHistoryBack: "Voltar para a aba visualizada anteriormente", + shortcutNavigateTabHistoryForward: "Avançar para a próxima aba visualizada", shortcutSwitchToPreviousTab: "Alternar para a aba anterior", shortcutSwitchToNextTab: "Alternar para a próxima aba", shortcutSwitchToTab1: "Alternar para a aba 1", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index a86b52ed40..5db5c0572a 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -6287,6 +6287,8 @@ export default withEnglishFallback({ shortcutToggleSidebar: "切换侧边栏", shortcutFocusSearch: "聚焦当前页面搜索", shortcutQuickOpen: "快速打开 (搜索所有数据库对象)", + shortcutNavigateTabHistoryBack: "返回到上一个查看的标签页", + shortcutNavigateTabHistoryForward: "前进到下一个查看的标签页", shortcutSwitchToPreviousTab: "切换到左侧标签页", shortcutSwitchToNextTab: "切换到右侧标签页", shortcutSwitchToTab1: "切换到第 1 个标签页", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 1af981f0ee..01b8b35a4b 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -5301,6 +5301,8 @@ export default withEnglishFallback({ shortcutToggleSidebar: "切換側邊欄", shortcutFocusSearch: "聚焦目前頁面搜尋", shortcutQuickOpen: "快速開啟 (搜尋所有資料庫物件)", + shortcutNavigateTabHistoryBack: "返回到上一個檢視的分頁", + shortcutNavigateTabHistoryForward: "前進到下一個檢視的分頁", shortcutSwitchToPreviousTab: "切換到左側分頁", shortcutSwitchToNextTab: "切換到右側分頁", shortcutSwitchToTab1: "切換到第 1 個分頁", diff --git a/apps/desktop/src/lib/editor/keyboardShortcuts.ts b/apps/desktop/src/lib/editor/keyboardShortcuts.ts index 89f8311e74..8d790dc047 100644 --- a/apps/desktop/src/lib/editor/keyboardShortcuts.ts +++ b/apps/desktop/src/lib/editor/keyboardShortcuts.ts @@ -112,8 +112,8 @@ export function matchesShortcut(event: ShortcutLikeEvent, shortcut: string, plat return matchesShortcutKey(event, key, platform); } -function actionShortcut(actionId: ShortcutActionId, shortcuts?: Partial): string { - return normalizeShortcutSettings(shortcuts)[actionId]; +function actionShortcut(actionId: ShortcutActionId, shortcuts?: Partial, platform = globalThis.navigator?.platform || ""): string { + return normalizeShortcutSettings(shortcuts, platform)[actionId]; } const SWITCH_TO_TAB_ACTIONS: ShortcutActionId[] = ["switchToTab1", "switchToTab2", "switchToTab3", "switchToTab4", "switchToTab5", "switchToTab6", "switchToTab7", "switchToTab8", "switchToTab9"]; @@ -231,6 +231,20 @@ export function isQuickOpenShortcut(event: ShortcutLikeEvent, shortcuts?: Partia return matchesShortcut(event, actionShortcut("quickOpen", shortcuts)); } +export function isNavigateTabHistoryBackShortcut(event: ShortcutLikeEvent, shortcuts?: Partial, platform = globalThis.navigator?.platform || ""): boolean { + return matchesShortcut(event, actionShortcut("navigateTabHistoryBack", shortcuts, platform), platform); +} + +export function isNavigateTabHistoryForwardShortcut(event: ShortcutLikeEvent, shortcuts?: Partial, platform = globalThis.navigator?.platform || ""): boolean { + return matchesShortcut(event, actionShortcut("navigateTabHistoryForward", shortcuts, platform), platform); +} + +export function handleTabHistoryNavigationShortcut(event: ShortcutLikeEvent, shortcuts: Partial | undefined, navigate: (direction: -1 | 1) => boolean, platform = globalThis.navigator?.platform || ""): boolean { + if (isNavigateTabHistoryBackShortcut(event, shortcuts, platform)) return navigate(-1); + if (isNavigateTabHistoryForwardShortcut(event, shortcuts, platform)) return navigate(1); + return false; +} + export function isSwitchToPreviousTabShortcut(event: ShortcutLikeEvent, shortcuts?: Partial): boolean { return matchesShortcut(event, actionShortcut("switchToPreviousTab", shortcuts)); } diff --git a/apps/desktop/src/lib/editor/shortcutRegistry.ts b/apps/desktop/src/lib/editor/shortcutRegistry.ts index efabe606c1..924d00b25c 100644 --- a/apps/desktop/src/lib/editor/shortcutRegistry.ts +++ b/apps/desktop/src/lib/editor/shortcutRegistry.ts @@ -36,6 +36,8 @@ export type ShortcutActionId = | "closeOtherTabs" | "focusSearch" | "quickOpen" + | "navigateTabHistoryBack" + | "navigateTabHistoryForward" | "switchToPreviousTab" | "switchToNextTab" | "switchToTab1" @@ -87,8 +89,19 @@ export function closeOtherTabsDefaultShortcut(platform = globalThis.navigator?.p return isMacShortcutPlatform(platform) ? "Alt+Mod+W" : "Shift+Alt+W"; } -const CLOSE_OTHER_TABS_PLATFORM_DEFAULTS = new Set(["Alt+Mod+W", "Shift+Alt+W"]); +export function tabNavigationHistoryDefaultShortcut(direction: "back" | "forward", platform = globalThis.navigator?.platform || ""): string { + const modifier = isMacShortcutPlatform(platform) ? "Ctrl" : "Mod"; + const key = direction === "back" ? "ArrowLeft" : "ArrowRight"; + return `${modifier}+Alt+${key}`; +} + +const PLATFORM_DEFAULT_SHORTCUTS: Partial>> = { + closeOtherTabs: new Set(["Alt+Mod+W", "Shift+Alt+W"]), + navigateTabHistoryBack: new Set(["Ctrl+Alt+ArrowLeft", "Mod+Alt+ArrowLeft"]), + navigateTabHistoryForward: new Set(["Ctrl+Alt+ArrowRight", "Mod+Alt+ArrowRight"]), +}; const LEGACY_CLOSE_TAB_DEFAULT = "Meta+W"; +const TAB_NAVIGATION_HISTORY_ACTIONS: ShortcutActionId[] = ["navigateTabHistoryBack", "navigateTabHistoryForward"]; export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [ { @@ -301,6 +314,18 @@ export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [ scope: "global", defaultShortcut: "Mod+P", }, + { + id: "navigateTabHistoryBack", + labelKey: "settings.shortcutNavigateTabHistoryBack", + scope: "global", + defaultShortcut: tabNavigationHistoryDefaultShortcut("back"), + }, + { + id: "navigateTabHistoryForward", + labelKey: "settings.shortcutNavigateTabHistoryForward", + scope: "global", + defaultShortcut: tabNavigationHistoryDefaultShortcut("forward"), + }, { id: "switchToPreviousTab", labelKey: "settings.shortcutSwitchToPreviousTab", @@ -476,16 +501,36 @@ export function normalizeModifierOnlyShortcut(shortcut: string, fallback = ""): return modifierOnlyShortcuts.has(normalized) ? normalized : fallback; } -export function normalizeShortcutSettings(settings?: Partial): ShortcutSettings { - return Object.fromEntries( +function hasExplicitShortcut(settings: Partial | undefined, actionId: ShortcutActionId): boolean { + return !!settings && Object.prototype.hasOwnProperty.call(settings, actionId) && typeof settings[actionId] === "string"; +} + +function shortcutsUseSameKeys(first: string, second: string, platform = globalThis.navigator?.platform || ""): boolean { + return !!first && !!second && formatShortcut(first, platform).toLowerCase() === formatShortcut(second, platform).toLowerCase(); +} + +function shortcutDefaultForPlatform(definition: ShortcutDefinition, platform: string): string { + if (definition.id === "closeOtherTabs") return closeOtherTabsDefaultShortcut(platform); + if (definition.id === "navigateTabHistoryBack") return tabNavigationHistoryDefaultShortcut("back", platform); + if (definition.id === "navigateTabHistoryForward") return tabNavigationHistoryDefaultShortcut("forward", platform); + return definition.defaultShortcut; +} + +export function needsTabNavigationHistoryShortcutMigration(settings?: Partial): boolean { + return !!settings && TAB_NAVIGATION_HISTORY_ACTIONS.some((actionId) => !hasExplicitShortcut(settings, actionId)); +} + +export function normalizeShortcutSettings(settings?: Partial, platform = globalThis.navigator?.platform || ""): ShortcutSettings { + const normalized = Object.fromEntries( SHORTCUT_DEFINITIONS.map((definition) => { const configuredValue = settings?.[definition.id]; - 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 platformDefault = shortcutDefaultForPlatform(definition, platform); + let configured = typeof configuredValue === "string" ? configuredValue : platformDefault; + // 云同步会把另一平台的默认值当作显式配置带过来。平台默认集合内的值视为 + // 未自定义,按本机平台重新解析;用户真正自定义的其他组合原样保留 + const platformDefaults = PLATFORM_DEFAULT_SHORTCUTS[definition.id]; + if (platformDefaults?.has(configured)) { + configured = platformDefault; } // Meta+W was the old macOS-only default. Treat that exact value as a // legacy default so existing Windows/Linux settings adopt Ctrl+W. @@ -496,6 +541,17 @@ export function normalizeShortcutSettings(settings?: Partial): return [definition.id, normalized]; }), ) as ShortcutSettings; + + for (const actionId of TAB_NAVIGATION_HISTORY_ACTIONS) { + if (hasExplicitShortcut(settings, actionId)) continue; + const definition = SHORTCUT_DEFINITIONS.find((item) => item.id === actionId); + if (!definition) continue; + const defaultShortcut = normalized[actionId]; + const occupiedByExistingAction = SHORTCUT_DEFINITIONS.some((item) => item.id !== actionId && item.scope === definition.scope && hasExplicitShortcut(settings, item.id) && shortcutsUseSameKeys(normalized[item.id], defaultShortcut, platform)); + if (occupiedByExistingAction) normalized[actionId] = ""; + } + + return normalized; } export function shortcutToCodeMirrorKey(shortcut: string): string { @@ -521,11 +577,11 @@ export function formatShortcut(shortcut: string, platform = globalThis.navigator .join("+"); } -export function findShortcutConflict(actionId: ShortcutActionId, shortcut: string, shortcuts: ShortcutSettings): ShortcutActionId | null { +export function findShortcutConflict(actionId: ShortcutActionId, shortcut: string, shortcuts: ShortcutSettings, platform = globalThis.navigator?.platform || ""): ShortcutActionId | null { if (!shortcut) return null; const definition = SHORTCUT_DEFINITIONS.find((item) => item.id === actionId); if (!definition) return null; - const conflict = SHORTCUT_DEFINITIONS.find((item) => item.id !== actionId && item.scope === definition.scope && shortcuts[item.id] === shortcut); + const conflict = SHORTCUT_DEFINITIONS.find((item) => item.id !== actionId && item.scope === definition.scope && shortcutsUseSameKeys(shortcuts[item.id], shortcut, platform)); return conflict?.id ?? null; } diff --git a/apps/desktop/src/lib/tabs/tabNavigationHistory.ts b/apps/desktop/src/lib/tabs/tabNavigationHistory.ts new file mode 100644 index 0000000000..d8e159976c --- /dev/null +++ b/apps/desktop/src/lib/tabs/tabNavigationHistory.ts @@ -0,0 +1,30 @@ +export interface TabNavigationHistory { + entries: string[]; + index: number; +} + +export interface TabNavigationHistoryMove { + history: TabNavigationHistory; + tabId: string; +} + +const MAX_TAB_NAVIGATION_HISTORY = 100; + +export function createTabNavigationHistory(): TabNavigationHistory { + return { entries: [], index: -1 }; +} + +export function recordTabVisit(history: TabNavigationHistory, tabId: string): TabNavigationHistory { + if (!tabId || history.entries[history.index] === tabId) return history; + + const entries = [...history.entries.slice(0, history.index + 1), tabId].slice(-MAX_TAB_NAVIGATION_HISTORY); + return { entries, index: entries.length - 1 }; +} + +export function moveInTabNavigationHistory(history: TabNavigationHistory, direction: -1 | 1, openTabIds: ReadonlySet, currentTabId: string | null = null): TabNavigationHistoryMove | null { + for (let index = history.index + direction; index >= 0 && index < history.entries.length; index += direction) { + const tabId = history.entries[index]; + if (tabId !== currentTabId && openTabIds.has(tabId)) return { history: { entries: history.entries, index }, tabId }; + } + return null; +} diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts index 61bcc29a20..9a046ec8ce 100644 --- a/apps/desktop/src/stores/settingsStore.ts +++ b/apps/desktop/src/stores/settingsStore.ts @@ -12,7 +12,7 @@ import { DATA_GRID_TYPE_COLOR_SCHEME_AUTO_ID, type DataGridTypeColorScheme, norm import { normalizeResultPageSize } from "@/lib/dataGrid/paginationPageSize"; import { DEFAULT_QUERY_RESULT_MAX_ROWS, normalizeQueryResultMaxRows } from "@/lib/dataGrid/queryResultRowLimit"; import { normalizeConnectTimeoutSecs, normalizeQueryTimeoutSecs } from "@/lib/connection/timeoutLimits"; -import { normalizeShortcutSettings, type ShortcutSettings } from "@/lib/editor/shortcutRegistry"; +import { needsTabNavigationHistoryShortcutMigration, normalizeShortcutSettings, type ShortcutSettings } from "@/lib/editor/shortcutRegistry"; import type { SavedSqlOpenTargetMode } from "@/lib/savedSql/savedSqlExecutionTarget"; import type { ConnectionListSortMode } from "@/lib/sidebar/connectionListSort"; import { normalizeSidebarHiddenTablePrefixes } from "@/lib/sidebar/sidebarTableNameDisplay"; @@ -1360,8 +1360,9 @@ export const useSettingsStore = defineStore("settings", () => { const normalized = normalizeEditorSettings(savedSettings); editorSettings.value = normalized; const needsExecuteModeDefaultMigration = typeof savedSettings.executeModeDefaultVersion !== "number" || savedSettings.executeModeDefaultVersion < EXECUTE_MODE_CURRENT_DEFAULT_VERSION; + const needsTabNavigationShortcutMigration = needsTabNavigationHistoryShortcutMigration(savedSettings.shortcuts); const savedUpdateDownloadSource = (saved as { updateDownloadSource?: unknown }).updateDownloadSource; - if (savedUpdateDownloadSource === "atomgit" || needsExecuteModeDefaultMigration) { + if (savedUpdateDownloadSource === "atomgit" || needsExecuteModeDefaultMigration || needsTabNavigationShortcutMigration) { // Persist one-time migrations so removed or unsafe defaults cannot reappear. await enqueueEditorSettingsSave().catch(() => {}); } diff --git a/docs/content/docs/keyboard-shortcuts.cn.mdx b/docs/content/docs/keyboard-shortcuts.cn.mdx index 91d197f084..f657949a90 100644 --- a/docs/content/docs/keyboard-shortcuts.cn.mdx +++ b/docs/content/docs/keyboard-shortcuts.cn.mdx @@ -48,7 +48,8 @@ DBX 的快捷键来自同一份可配置 action registry。**设置 → 快捷 | `Mod+T` | 新建查询 | | `Mod+P` | 快速打开 | | `Mod+W` | 关闭当前标签页 | -| `Shift+Mod+[` / `Shift+Mod+]` | 上一个/下一个标签页 | +| `Ctrl+Alt+←` / `Ctrl+Alt+→` | 后退/前进标签页访问历史 | +| `Shift+Mod+[` / `Shift+Mod+]` | 左侧/右侧标签页 | | `Mod+1` … `Mod+9` | 切换到指定标签页 | | `Mod+B` | 显示或隐藏侧边栏 | | `Mod+=` / `Mod+-` / `Mod+0` | 放大、缩小、重置 UI 缩放 | diff --git a/docs/content/docs/keyboard-shortcuts.mdx b/docs/content/docs/keyboard-shortcuts.mdx index d78997b448..bb2b5a338b 100644 --- a/docs/content/docs/keyboard-shortcuts.mdx +++ b/docs/content/docs/keyboard-shortcuts.mdx @@ -48,6 +48,7 @@ Standard copy and paste still follow platform conventions. Copy Current Row, Del | `Mod+T` | New query | | `Mod+P` | Quick open | | `Mod+W` | Close current tab | +| `Ctrl+Alt+←` / `Ctrl+Alt+→` | Back/forward through tab visit history | | `Shift+Mod+[` / `Shift+Mod+]` | Previous/next tab | | `Mod+1` … `Mod+9` | Switch to a numbered tab | | `Mod+B` | Toggle the sidebar | diff --git a/packages/app-tests/keyboardShortcuts.test.ts b/packages/app-tests/keyboardShortcuts.test.ts index df9d7ed19f..affdc69559 100644 --- a/packages/app-tests/keyboardShortcuts.test.ts +++ b/packages/app-tests/keyboardShortcuts.test.ts @@ -2,6 +2,7 @@ import { strict as assert } from "node:assert"; import { afterAll, beforeAll, test, vi } from "vitest"; import { eventToShortcut, + handleTabHistoryNavigationShortcut, isBrowserReloadShortcut, isCancelSearchShortcut, isCloseOtherTabsShortcut, @@ -11,6 +12,8 @@ import { isEditSidebarConnectionShortcut, isFocusSearchShortcut, isModRShortcut, + isNavigateTabHistoryBackShortcut, + isNavigateTabHistoryForwardShortcut, isNewQueryShortcut, isObjectSourceSaveShortcutTarget, isOpenSettingsShortcut, @@ -28,7 +31,7 @@ import { matchesShortcut, switchToTabIndexFromShortcut, } from "../../apps/desktop/src/lib/editor/keyboardShortcuts.ts"; -import { shortcutToCodeMirrorKey } from "../../apps/desktop/src/lib/editor/shortcutRegistry.ts"; +import { DEFAULT_SHORTCUT_SETTINGS, findShortcutConflict, needsTabNavigationHistoryShortcutMigration, normalizeShortcutSettings, shortcutToCodeMirrorKey, tabNavigationHistoryDefaultShortcut } from "../../apps/desktop/src/lib/editor/shortcutRegistry.ts"; beforeAll(() => vi.stubGlobal("navigator", { platform: "Linux x86_64" })); afterAll(() => vi.unstubAllGlobals()); @@ -82,6 +85,137 @@ test("matches Shift+Mod+brackets for switching adjacent tabs", () => { assert.equal(isSwitchToNextTabShortcut({ key: "]", metaKey: true }), false); }); +test("navigates backward and forward through tab visit history with Ctrl+Alt+Arrow keys", () => { + assert.equal(isNavigateTabHistoryBackShortcut({ key: "ArrowLeft", ctrlKey: true, altKey: true }), true); + assert.equal(isNavigateTabHistoryForwardShortcut({ key: "ArrowRight", ctrlKey: true, altKey: true }), true); + assert.equal(isNavigateTabHistoryBackShortcut({ key: "ArrowLeft", ctrlKey: true, altKey: true }, undefined, "Win32"), true); + assert.equal(isNavigateTabHistoryBackShortcut({ key: "ArrowLeft", metaKey: true, altKey: true }, undefined, "Win32"), true); + assert.equal(isNavigateTabHistoryBackShortcut({ key: "ArrowLeft", ctrlKey: true }), false); + assert.equal(isNavigateTabHistoryForwardShortcut({ key: "ArrowRight", altKey: true }), false); +}); + +test("keeps Ctrl and Mod distinct for tab history shortcuts on macOS", () => { + const event = { key: "ArrowLeft", ctrlKey: true, altKey: true }; + + assert.equal(isNavigateTabHistoryBackShortcut(event, undefined, "MacIntel"), true); + assert.equal(isNavigateTabHistoryBackShortcut({ ...event, metaKey: true }, undefined, "MacIntel"), false); +}); + +test("matches tab history defaults to the recorded shortcut format on each platform", () => { + const backEvent = { key: "ArrowLeft", ctrlKey: true, altKey: true }; + const forwardEvent = { key: "ArrowRight", ctrlKey: true, altKey: true }; + + assert.equal(tabNavigationHistoryDefaultShortcut("back", "Win32"), "Mod+Alt+ArrowLeft"); + assert.equal(tabNavigationHistoryDefaultShortcut("forward", "Linux x86_64"), "Mod+Alt+ArrowRight"); + assert.equal(tabNavigationHistoryDefaultShortcut("back", "MacIntel"), "Ctrl+Alt+ArrowLeft"); + assert.equal(tabNavigationHistoryDefaultShortcut("forward", "MacIntel"), "Ctrl+Alt+ArrowRight"); + assert.equal(eventToShortcut(backEvent, "Win32"), tabNavigationHistoryDefaultShortcut("back", "Win32")); + assert.equal(eventToShortcut(forwardEvent, "Linux x86_64"), tabNavigationHistoryDefaultShortcut("forward", "Linux x86_64")); + assert.equal(eventToShortcut(backEvent, "MacIntel"), tabNavigationHistoryDefaultShortcut("back", "MacIntel")); + assert.equal(eventToShortcut(forwardEvent, "MacIntel"), tabNavigationHistoryDefaultShortcut("forward", "MacIntel")); +}); + +test("reports Ctrl and Mod as equivalent conflicts on Windows/Linux but not macOS", () => { + const backShortcut = eventToShortcut({ key: "ArrowLeft", ctrlKey: true, altKey: true }, "Win32")!; + const forwardShortcut = eventToShortcut({ key: "ArrowRight", ctrlKey: true, altKey: true }, "Win32")!; + const backSettings = { ...DEFAULT_SHORTCUT_SETTINGS, navigateTabHistoryBack: backShortcut, quickOpen: "Ctrl+Alt+ArrowLeft" }; + const forwardSettings = { ...DEFAULT_SHORTCUT_SETTINGS, navigateTabHistoryForward: forwardShortcut, quickOpen: "Ctrl+Alt+ArrowRight" }; + + assert.equal(findShortcutConflict("quickOpen", backShortcut, backSettings, "Win32"), "navigateTabHistoryBack"); + assert.equal(findShortcutConflict("quickOpen", forwardShortcut, forwardSettings, "Linux x86_64"), "navigateTabHistoryForward"); + assert.equal(findShortcutConflict("quickOpen", "Mod+Alt+ArrowLeft", { ...DEFAULT_SHORTCUT_SETTINGS, navigateTabHistoryBack: "Ctrl+Alt+ArrowLeft", quickOpen: "Mod+Alt+ArrowLeft" }, "MacIntel"), null); +}); + +test("restores the local tab history default format after cross-platform sync", () => { + const currentBackDefault = tabNavigationHistoryDefaultShortcut("back"); + const currentForwardDefault = tabNavigationHistoryDefaultShortcut("forward"); + const otherBackDefault = currentBackDefault.startsWith("Mod+") ? "Ctrl+Alt+ArrowLeft" : "Mod+Alt+ArrowLeft"; + const otherForwardDefault = currentForwardDefault.startsWith("Mod+") ? "Ctrl+Alt+ArrowRight" : "Mod+Alt+ArrowRight"; + const normalized = normalizeShortcutSettings({ + navigateTabHistoryBack: otherBackDefault, + navigateTabHistoryForward: otherForwardDefault, + }); + + assert.equal(normalized.navigateTabHistoryBack, currentBackDefault); + assert.equal(normalized.navigateTabHistoryForward, currentForwardDefault); +}); + +test("preserves existing actions and unbinds new history actions when defaults conflict", () => { + const normalized = normalizeShortcutSettings({ + switchToPreviousTab: "Ctrl+Alt+ArrowLeft", + switchToNextTab: "Mod+Alt+ArrowRight", + }); + + assert.equal(normalized.switchToPreviousTab, "Ctrl+Alt+ArrowLeft"); + assert.equal(normalized.switchToNextTab, "Mod+Alt+ArrowRight"); + assert.equal(normalized.navigateTabHistoryBack, ""); + assert.equal(normalized.navigateTabHistoryForward, ""); +}); + +test("uses platform-aware defaults when migrating legacy shortcut settings", () => { + const windows = normalizeShortcutSettings({ switchToPreviousTab: "Ctrl+Alt+ArrowLeft" }, "Win32"); + const mac = normalizeShortcutSettings({ switchToPreviousTab: "Mod+Alt+ArrowLeft" }, "MacIntel"); + + assert.equal(windows.navigateTabHistoryBack, ""); + assert.equal(mac.navigateTabHistoryBack, "Ctrl+Alt+ArrowLeft"); +}); + +test("only handles tab history shortcuts when navigation succeeds", () => { + const event = { key: "ArrowLeft", ctrlKey: true, altKey: true }; + const directions: number[] = []; + let prevented = false; + + prevented = handleTabHistoryNavigationShortcut( + event, + undefined, + (direction) => { + directions.push(direction); + return false; + }, + "Win32", + ); + assert.equal(prevented, false); + assert.deepEqual(directions, [-1]); + + prevented = handleTabHistoryNavigationShortcut( + event, + undefined, + (direction) => { + directions.push(direction); + return true; + }, + "Win32", + ); + assert.equal(prevented, true); + assert.deepEqual(directions, [-1, -1]); +}); + +test("requests tab history migration only when legacy shortcut settings exist", () => { + assert.equal(needsTabNavigationHistoryShortcutMigration(), false); + assert.equal(needsTabNavigationHistoryShortcutMigration({}), true); +}); + +test("adds default bindings for new history actions when legacy shortcuts do not conflict", () => { + const normalized = normalizeShortcutSettings({ + switchToPreviousTab: "Shift+Mod+[", + switchToNextTab: "Shift+Mod+]", + }); + + assert.equal(normalized.navigateTabHistoryBack, tabNavigationHistoryDefaultShortcut("back")); + assert.equal(normalized.navigateTabHistoryForward, tabNavigationHistoryDefaultShortcut("forward")); +}); + +test("preserves explicitly configured tab history shortcuts during migration", () => { + const backShortcut = tabNavigationHistoryDefaultShortcut("back"); + const normalized = normalizeShortcutSettings({ + navigateTabHistoryBack: backShortcut, + switchToPreviousTab: backShortcut, + }); + + assert.equal(normalized.navigateTabHistoryBack, backShortcut); + assert.equal(normalized.switchToPreviousTab, backShortcut); +}); + test("matches Mod+number for switching to numbered tabs", () => { assert.equal(switchToTabIndexFromShortcut({ key: "1", metaKey: true }), 0); assert.equal(switchToTabIndexFromShortcut({ key: "5", ctrlKey: true }), 4); diff --git a/packages/app-tests/settingsStore.test.ts b/packages/app-tests/settingsStore.test.ts index 867c2a7638..1692f62192 100644 --- a/packages/app-tests/settingsStore.test.ts +++ b/packages/app-tests/settingsStore.test.ts @@ -7,6 +7,7 @@ import { DEFAULT_TABLE_COLUMN_TEMPLATE_FIELDS } from "../../apps/desktop/src/lib import { DEFAULT_DATA_GRID_FONT_FAMILY, DEFAULT_UI_FONT_FAMILY, SYSTEM_UI_FONT_FAMILY } from "../../apps/desktop/src/lib/app/appFonts.ts"; import { tableOpenPageLimit } from "../../apps/desktop/src/lib/table/tableOpenPageLimit.ts"; import { AI_PROVIDER_PRESETS, DEFAULT_EDITOR_SETTINGS, EXECUTE_MODE_CURRENT_DEFAULT_VERSION, normalizeAiConfig, normalizeEditorSettings, useSettingsStore } from "../../apps/desktop/src/stores/settingsStore.ts"; +import { DEFAULT_SHORTCUT_SETTINGS, tabNavigationHistoryDefaultShortcut, type ShortcutSettings } from "../../apps/desktop/src/lib/editor/shortcutRegistry.ts"; const saveEditorSettingsMock = vi.hoisted(() => vi.fn()); vi.mock("../../apps/desktop/src/lib/backend/api", async (importOriginal) => { @@ -384,6 +385,8 @@ test("defaults shortcut settings", () => { assert.equal(settings.shortcuts.newQuery, "Mod+T"); assert.equal(settings.shortcuts.openSettings, "Mod+,"); assert.equal(settings.shortcuts.focusSearch, "Mod+F"); + assert.equal(settings.shortcuts.navigateTabHistoryBack, tabNavigationHistoryDefaultShortcut("back")); + assert.equal(settings.shortcuts.navigateTabHistoryForward, tabNavigationHistoryDefaultShortcut("forward")); assert.equal(settings.shortcuts.zoomInUi, "Mod+="); assert.equal(settings.shortcuts.zoomOutUi, "Mod+-"); assert.equal(settings.shortcuts.resetUiZoom, "Mod+0"); @@ -417,6 +420,47 @@ test("keeps saved shortcut overrides", () => { assert.equal(settings.shortcuts.saveSql, "Mod+S"); }); +test("preserves adjacent tab shortcuts and persists unbound history actions when upgrading legacy settings", async () => { + const legacyShortcuts: Partial = { ...DEFAULT_SHORTCUT_SETTINGS }; + delete legacyShortcuts.navigateTabHistoryBack; + delete legacyShortcuts.navigateTabHistoryForward; + legacyShortcuts.switchToPreviousTab = tabNavigationHistoryDefaultShortcut("back"); + legacyShortcuts.switchToNextTab = tabNavigationHistoryDefaultShortcut("forward"); + + await withMockLocalStorage( + { + "dbx-app-state:editor_settings": JSON.stringify({ + executeModeDefaultVersion: EXECUTE_MODE_CURRENT_DEFAULT_VERSION, + shortcuts: legacyShortcuts, + }), + }, + async () => { + setActivePinia(createPinia()); + const migratedStore = useSettingsStore(); + await migratedStore.initEditorSettings(); + + assert.equal(migratedStore.editorSettings.shortcuts.switchToPreviousTab, tabNavigationHistoryDefaultShortcut("back")); + assert.equal(migratedStore.editorSettings.shortcuts.switchToNextTab, tabNavigationHistoryDefaultShortcut("forward")); + assert.equal(migratedStore.editorSettings.shortcuts.navigateTabHistoryBack, ""); + assert.equal(migratedStore.editorSettings.shortcuts.navigateTabHistoryForward, ""); + + await vi.waitFor(() => { + const saved = saveEditorSettingsMock.mock.calls.at(-1)?.[0] as { shortcuts?: ShortcutSettings } | undefined; + assert.equal(saved?.shortcuts?.navigateTabHistoryBack, ""); + assert.equal(saved?.shortcuts?.navigateTabHistoryForward, ""); + }); + + setActivePinia(createPinia()); + const reloadedStore = useSettingsStore(); + await reloadedStore.initEditorSettings(); + assert.equal(reloadedStore.editorSettings.shortcuts.switchToPreviousTab, tabNavigationHistoryDefaultShortcut("back")); + assert.equal(reloadedStore.editorSettings.shortcuts.switchToNextTab, tabNavigationHistoryDefaultShortcut("forward")); + assert.equal(reloadedStore.editorSettings.shortcuts.navigateTabHistoryBack, ""); + assert.equal(reloadedStore.editorSettings.shortcuts.navigateTabHistoryForward, ""); + }, + ); +}); + test("defaults sidebar activation to single click", () => { assert.equal(DEFAULT_EDITOR_SETTINGS.sidebarActivation, "single"); assert.equal(normalizeEditorSettings({}).sidebarActivation, "single"); diff --git a/packages/app-tests/tabNavigationHistory.test.ts b/packages/app-tests/tabNavigationHistory.test.ts new file mode 100644 index 0000000000..6b21c87728 --- /dev/null +++ b/packages/app-tests/tabNavigationHistory.test.ts @@ -0,0 +1,60 @@ +import { strict as assert } from "node:assert"; +import { test } from "vitest"; +import { createTabNavigationHistory, moveInTabNavigationHistory, recordTabVisit } from "../../apps/desktop/src/lib/tabs/tabNavigationHistory.ts"; + +test("navigates backward and forward in tab visit order", () => { + let history = createTabNavigationHistory(); + history = recordTabVisit(history, "A"); + history = recordTabVisit(history, "D"); + history = recordTabVisit(history, "B"); + const openTabs = new Set(["A", "B", "C", "D"]); + + const backToD = moveInTabNavigationHistory(history, -1, openTabs); + assert.equal(backToD?.tabId, "D"); + history = backToD!.history; + + const backToA = moveInTabNavigationHistory(history, -1, openTabs); + assert.equal(backToA?.tabId, "A"); + history = backToA!.history; + + const forwardToD = moveInTabNavigationHistory(history, 1, openTabs); + assert.equal(forwardToD?.tabId, "D"); + history = forwardToD!.history; + + assert.equal(moveInTabNavigationHistory(history, 1, openTabs)?.tabId, "B"); +}); + +test("clears the forward branch after visiting a tab manually", () => { + let history = createTabNavigationHistory(); + history = recordTabVisit(history, "A"); + history = recordTabVisit(history, "D"); + history = recordTabVisit(history, "B"); + + history = moveInTabNavigationHistory(history, -1, new Set(["A", "B", "C", "D"]))!.history; + history = recordTabVisit(history, "C"); + + assert.deepEqual(history, { entries: ["A", "D", "C"], index: 2 }); + assert.equal(moveInTabNavigationHistory(history, 1, new Set(["A", "B", "C", "D"])), null); +}); + +test("skips closed tabs while navigating", () => { + let history = createTabNavigationHistory(); + history = recordTabVisit(history, "A"); + history = recordTabVisit(history, "B"); + history = recordTabVisit(history, "C"); + const openTabs = new Set(["A", "C"]); + + const backToA = moveInTabNavigationHistory(history, -1, openTabs); + assert.equal(backToA?.tabId, "A"); + assert.equal(moveInTabNavigationHistory(backToA!.history, 1, openTabs)?.tabId, "C"); +}); + +test("skips history entries matching the active tab", () => { + let history = createTabNavigationHistory(); + history = recordTabVisit(history, "A"); + history = recordTabVisit(history, "D"); + history = recordTabVisit(history, "B"); + history = recordTabVisit(history, "D"); + + assert.equal(moveInTabNavigationHistory(history, -1, new Set(["A", "D"]), "D")?.tabId, "A"); +});