diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index c42910b85d..3a53e558dd 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -87,9 +87,13 @@ import { isZoomInShortcut, isZoomOutShortcut, switchToTabIndexFromShortcut, + tabSwitcherDirectionFromShortcut, } from "@/lib/editor/keyboardShortcuts"; import { isPreviewTab } from "@/lib/tabs/tabPresentation"; import { createTabNavigationHistory, moveInTabNavigationHistory, recordTabVisit } from "@/lib/tabs/tabNavigationHistory"; +import { initialTabSwitcherSelection, moveTabSwitcherSelection, tabSwitcherOrder } from "@/lib/tabs/tabSwitcher"; +import { createTabSwitcherKeyboardController } from "@/lib/tabs/tabSwitcherKeyboard"; +import { formatShortcutDisplay } from "@/lib/editor/shortcutDisplay"; import { supportsSqlFileExecution } from "@/lib/database/databaseCapabilities"; import { classifyAiSqlExecution } from "@/lib/ai/aiSqlExecutionPolicy"; import { buildAppendedEditorSql } from "@/lib/ai/aiSqlAppend"; @@ -134,6 +138,7 @@ const UpdateDialog = defineAsyncComponent(() => import("@/components/layout/Upda const CloseActionPromptDialog = defineAsyncComponent(() => import("@/components/layout/CloseActionPromptDialog.vue")); const LoginPage = defineAsyncComponent(() => import("@/components/auth/LoginPage.vue")); const QuickOpenDialog = defineAsyncComponent(() => import("@/components/quick-open/QuickOpenDialog.vue")); +const TabSwitcherDialog = defineAsyncComponent(() => import("@/components/tabs/TabSwitcherDialog.vue")); const QueryEditorDdlViewDialog = defineAsyncComponent(() => import("@/components/objects/DdlViewDialog.vue")); const QueryEditorObjectSourceDialog = defineAsyncComponent(() => import("@/components/objects/ObjectSourceDialog.vue")); @@ -226,6 +231,8 @@ const settingsReturnSurface = ref<"query" | "driverStore" | "welcome">("welcome" const showDriverStore = computed(() => driverStoreTabOpen.value && driverStoreActive.value); const showSettingsPage = computed(() => settingsPageTabOpen.value && settingsStore.settingsPageActive); const showQuickOpen = ref(false); +const showTabSwitcher = ref(false); +const tabSwitcherIndex = ref(0); const agentDriverUpdateCount = ref(0); const showHistory = ref(false); const showAiPanel = ref(safeLocalStorageGet("dbx-ai-panel-open") === "true"); @@ -2318,6 +2325,66 @@ function activateTabFromHistory(direction: -1 | 1): boolean { return false; } +const tabSwitcherTabs = computed(() => tabSwitcherOrder(queryStore.tabs, queryStore.recentTabIds)); +const tabSwitcherShortcutHint = computed(() => formatShortcutDisplay(settingsStore.editorSettings.shortcuts.tabSwitcher)); + +function handleTabSwitcherShortcut(direction: -1 | 1, e: KeyboardEvent): boolean { + if (!queryStore.tabs.length) return false; + if (!showTabSwitcher.value) { + tabSwitcherKeyboard.rememberOpeningEvent(e); + tabSwitcherIndex.value = initialTabSwitcherSelection(tabSwitcherTabs.value.length); + showTabSwitcher.value = true; + } else { + tabSwitcherIndex.value = moveTabSwitcherSelection(tabSwitcherIndex.value, direction, tabSwitcherTabs.value.length); + } + return true; +} + +function closeTabSwitcher(commit: boolean) { + tabSwitcherKeyboard.reset(); + if (!showTabSwitcher.value) return; + showTabSwitcher.value = false; + if (!commit) return; + const tab = tabSwitcherTabs.value[tabSwitcherIndex.value]; + if (tab) activateQueryTab(tab.id); +} + +function handleKeyup(e: KeyboardEvent) { + tabSwitcherKeyboard.handleKeyup(e); +} + +function handleTabSwitcherKeydownCapture(e: KeyboardEvent) { + tabSwitcherKeyboard.handleKeydownCapture(e); +} + +function handleTabSwitcherWindowBlur() { + tabSwitcherKeyboard.handleWindowBlur(); +} + +function handleTabSwitcherVisibilityChange() { + tabSwitcherKeyboard.handleVisibilityChange(document.visibilityState); +} + +function handleTabSwitcherSelect(tabId: string) { + tabSwitcherKeyboard.reset(); + showTabSwitcher.value = false; + activateQueryTab(tabId); +} + +function handleTabSwitcherOpenChange(open: boolean) { + if (!open) closeTabSwitcher(false); +} + +const tabSwitcherKeyboard = createTabSwitcherKeyboardController({ + isOpen: () => showTabSwitcher.value, + shortcut: () => settingsStore.editorSettings.shortcuts.tabSwitcher, + move: (direction) => { + tabSwitcherIndex.value = moveTabSwitcherSelection(tabSwitcherIndex.value, direction, tabSwitcherTabs.value.length); + }, + commit: () => closeTabSwitcher(true), + cancel: () => closeTabSwitcher(false), +}); + function handleNativeSelectAll(e: KeyboardEvent) { if (shouldBlockAppNativeSelectAll(e)) e.preventDefault(); } @@ -2326,6 +2393,15 @@ async function handleKeydown(e: KeyboardEvent) { if (e.defaultPrevented) return; const shortcuts = settingsStore.editorSettings.shortcuts; + const tabSwitcherDirection = tabSwitcherDirectionFromShortcut(e, shortcuts); + if (showTabSwitcher.value) return; + if (tabSwitcherDirection) { + e.preventDefault(); + e.stopPropagation(); + handleTabSwitcherShortcut(tabSwitcherDirection, e); + return; + } + const switchTabIndex = switchToTabIndexFromShortcut(e, shortcuts); if (isOpenSettingsShortcut(e, shortcuts)) { @@ -2599,7 +2675,11 @@ onMounted(async () => { applyTheme(); void applyUiScale(settingsStore.editorSettings.uiScale); window.addEventListener("keydown", handleNativeSelectAll, true); + window.addEventListener("keydown", handleTabSwitcherKeydownCapture, true); window.addEventListener("keydown", handleKeydown); + window.addEventListener("keyup", handleKeyup, true); + window.addEventListener("blur", handleTabSwitcherWindowBlur); + document.addEventListener("visibilitychange", handleTabSwitcherVisibilityChange); window.addEventListener("dbx-open-driver-store", openDriverStoreFromEvent); window.addEventListener("dbx-mcp-status-changed", handleMcpStatusChanged); if (isDesktop) { @@ -2667,7 +2747,12 @@ onUnmounted(() => { clearInterval(updateCheckTimer); } window.removeEventListener("keydown", handleNativeSelectAll, true); + window.removeEventListener("keydown", handleTabSwitcherKeydownCapture, true); window.removeEventListener("keydown", handleKeydown); + window.removeEventListener("keyup", handleKeyup, true); + window.removeEventListener("blur", handleTabSwitcherWindowBlur); + document.removeEventListener("visibilitychange", handleTabSwitcherVisibilityChange); + tabSwitcherKeyboard.reset(); window.removeEventListener("dbx-open-driver-store", openDriverStoreFromEvent); window.removeEventListener("dbx-mcp-status-changed", handleMcpStatusChanged); document.removeEventListener("contextmenu", handleContextMenu); @@ -3020,6 +3105,7 @@ onUnmounted(() => { + +import { nextTick, ref, watch } from "vue"; +import { useI18n } from "vue-i18n"; +import DatabaseIcon from "@/components/icons/DatabaseIcon.vue"; +import { useConnectionStore } from "@/stores/connectionStore"; +import { connectionIconType } from "@/lib/connection/connectionPresentation"; +import { hexToRgba } from "@/lib/common/color"; +import { connectionColor, connectionDisplayName, tabDisplayTitle, tabModeLabel } from "@/lib/tabs/tabPresentation"; +import type { QueryTab } from "@/types/database"; + +const props = defineProps<{ + open: boolean; + tabs: QueryTab[]; + selectedIndex: number; + shortcutHint: string; +}>(); + +const emit = defineEmits<{ + "update:open": [value: boolean]; + "update:selectedIndex": [index: number]; + select: [tabId: string]; +}>(); + +const { t } = useI18n(); +const connectionStore = useConnectionStore(); +const listRef = ref(); + +function tabColor(tab: QueryTab): string { + return connectionColor(tab.connectionId); +} + +function tabRowStyle(tab: QueryTab, index: number) { + const color = tabColor(tab); + if (!color) return undefined; + return { + backgroundColor: hexToRgba(color, index === props.selectedIndex ? 0.28 : 0.07), + boxShadow: `inset 3px 0 0 ${color}`, + }; +} + +watch( + () => [props.open, props.selectedIndex], + async ([open]) => { + if (!open) return; + await nextTick(); + listRef.value?.querySelectorAll("[data-tab-switcher-row]")[props.selectedIndex]?.scrollIntoView({ block: "nearest" }); + }, +); + + + diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 0e38b9aab5..943aac79a3 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1415,6 +1415,8 @@ export default { scrollRight: "Scroll tabs right", openTabs: "Open tabs", searchOpenTabs: "Search open tabs...", + switcherTitle: "Switch Tab", + switcherHint: "keep pressing to move, release to switch", noMatchingTabs: "No matching tabs", fixedTabs: "Fixed tabs", openDataTabs: "Open tables", @@ -6338,6 +6340,7 @@ export default { shortcutFocusSearch: "Focus current view search", shortcutQuickOpen: "Quick open (search all database objects)", shortcutNavigateTabHistoryBack: "Go back to the previously viewed tab", + shortcutTabSwitcher: "Tab switcher (hold to show, release to switch)", shortcutNavigateTabHistoryForward: "Go forward to the next viewed tab", shortcutSwitchToPreviousTab: "Switch to previous tab", shortcutSwitchToNextTab: "Switch to next tab", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 4fdc97a691..568d05476f 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -1392,6 +1392,8 @@ export default withEnglishFallback({ scrollRight: "Desplazar pestañas a la derecha", openTabs: "Pestañas abiertas", searchOpenTabs: "Buscar pestañas abiertas...", + switcherTitle: "Cambiar de pestaña", + switcherHint: "sigue pulsando para moverte, suelta para cambiar", noMatchingTabs: "No se encontraron pestañas", fixedTabs: "Pestañas fijadas", openDataTabs: "Tablas abiertas", @@ -6017,6 +6019,7 @@ export default withEnglishFallback({ 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", + shortcutTabSwitcher: "Selector de pestañas (mantén pulsado para mostrar, suelta para cambiar)", shortcutNavigateTabHistoryForward: "Avanzar a la siguiente pestaña vista", shortcutSwitchToPreviousTab: "Cambiar a la pestaña anterior", shortcutSwitchToNextTab: "Cambiar a la pestaña siguiente", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index edcc29ff89..9dddc9009a 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -1390,6 +1390,8 @@ export default withEnglishFallback({ scrollRight: "Scorri schede a destra", openTabs: "Schede aperte", searchOpenTabs: "Cerca schede aperte...", + switcherTitle: "Cambia scheda", + switcherHint: "continua a premere per spostarti, rilascia per cambiare", noMatchingTabs: "Nessuna scheda corrispondente", fixedTabs: "Schede bloccate", openDataTabs: "Tabelle aperte", @@ -6016,6 +6018,7 @@ export default withEnglishFallback({ shortcutFocusSearch: "Focalizza la ricerca nella vista corrente", shortcutQuickOpen: "Apertura rapida (ricerca tutti gli oggetti del database)", shortcutNavigateTabHistoryBack: "Torna alla scheda visualizzata in precedenza", + shortcutTabSwitcher: "Selettore schede (tieni premuto per mostrare, rilascia per cambiare)", shortcutNavigateTabHistoryForward: "Vai avanti alla scheda visualizzata successiva", shortcutSwitchToPreviousTab: "Passa alla scheda precedente", shortcutSwitchToNextTab: "Passa alla scheda successiva", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 879eb4811b..218090159c 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1409,6 +1409,8 @@ export default withEnglishFallback({ scrollRight: "タブを右にスクロール", openTabs: "開いているタブ", searchOpenTabs: "開いているタブを検索...", + switcherTitle: "タブを切り替え", + switcherHint: "押し続けて移動、離して切り替え", noMatchingTabs: "一致するタブがありません", fixedTabs: "固定タブ", openDataTabs: "開いているテーブル", @@ -6031,6 +6033,7 @@ export default withEnglishFallback({ shortcutFocusSearch: "現在のビューの検索にフォーカス", shortcutQuickOpen: "クイックオープン (すべてのデータベースオブジェクトを検索)", shortcutNavigateTabHistoryBack: "前に表示したタブに戻る", + shortcutTabSwitcher: "タブスイッチャー(押し続けて表示、離して切り替え)", shortcutNavigateTabHistoryForward: "次に表示したタブに進む", shortcutSwitchToPreviousTab: "前のタブに切り替え", shortcutSwitchToNextTab: "次のタブに切り替え", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 9b33013f2f..4d4ed39634 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -1296,6 +1296,8 @@ export default withEnglishFallback({ scrollRight: "탭을 오른쪽으로 스크롤", openTabs: "열린 탭", searchOpenTabs: "열린 탭 검색...", + switcherTitle: "탭 전환", + switcherHint: "계속 눌러 이동하고 놓으면 전환", noMatchingTabs: "일치하는 탭이 없습니다", fixedTabs: "고정 탭", openDataTabs: "열린 테이블", @@ -5754,6 +5756,7 @@ export default withEnglishFallback({ shortcutFocusSearch: "현재 화면 검색에 포커스", shortcutQuickOpen: "빠른 열기 (모든 데이터베이스 객체 검색)", shortcutNavigateTabHistoryBack: "이전에 본 탭으로 돌아가기", + shortcutTabSwitcher: "탭 전환기 (길게 눌러 표시, 놓으면 전환)", shortcutNavigateTabHistoryForward: "다음에 본 탭으로 이동", shortcutSwitchToPreviousTab: "이전 탭으로 전환", shortcutSwitchToNextTab: "다음 탭으로 전환", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 1bff78c2bd..5b19eba75b 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -1392,6 +1392,8 @@ export default withEnglishFallback({ scrollRight: "Rolar abas para a direita", openTabs: "Abas abertas", searchOpenTabs: "Pesquisar abas abertas...", + switcherTitle: "Alternar aba", + switcherHint: "continue pressionando para mover, solte para alternar", noMatchingTabs: "Nenhuma aba correspondente", fixedTabs: "Abas fixadas", openDataTabs: "Tabelas abertas", @@ -6019,6 +6021,7 @@ export default withEnglishFallback({ 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", + shortcutTabSwitcher: "Alternador de abas (segure para mostrar, solte para alternar)", shortcutNavigateTabHistoryForward: "Avançar para a próxima aba visualizada", shortcutSwitchToPreviousTab: "Alternar para a aba anterior", shortcutSwitchToNextTab: "Alternar para a próxima aba", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index d80fb88d4b..8a526674fa 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1339,6 +1339,8 @@ export default withEnglishFallback({ scrollRight: "向右滚动标签页", openTabs: "已打开的标签页", searchOpenTabs: "搜索已打开的标签页...", + switcherTitle: "切换标签页", + switcherHint: "继续按选择,松开修饰键切换", noMatchingTabs: "未找到匹配的标签页", fixedTabs: "固定标签页", openDataTabs: "已打开的数据表", @@ -6327,6 +6329,7 @@ export default withEnglishFallback({ shortcutFocusSearch: "聚焦当前页面搜索", shortcutQuickOpen: "快速打开 (搜索所有数据库对象)", shortcutNavigateTabHistoryBack: "返回到上一个查看的标签页", + shortcutTabSwitcher: "标签页切换器(按住显示,松开切换)", shortcutNavigateTabHistoryForward: "前进到下一个查看的标签页", shortcutSwitchToPreviousTab: "切换到左侧标签页", shortcutSwitchToNextTab: "切换到右侧标签页", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 724c2b57f5..cddb84d24b 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1391,6 +1391,8 @@ export default withEnglishFallback({ scrollRight: "向右捲動分頁", openTabs: "已開啟的分頁", searchOpenTabs: "搜尋已開啟的分頁...", + switcherTitle: "切換分頁", + switcherHint: "繼續按選擇,放開修飾鍵切換", noMatchingTabs: "找不到符合的分頁", fixedTabs: "固定分頁", openDataTabs: "已開啟的資料表", @@ -5327,6 +5329,7 @@ export default withEnglishFallback({ shortcutFocusSearch: "聚焦目前頁面搜尋", shortcutQuickOpen: "快速開啟 (搜尋所有資料庫物件)", shortcutNavigateTabHistoryBack: "返回到上一個檢視的分頁", + shortcutTabSwitcher: "分頁切換器(按住顯示,放開切換)", shortcutNavigateTabHistoryForward: "前進到下一個檢視的分頁", shortcutSwitchToPreviousTab: "切換到左側分頁", shortcutSwitchToNextTab: "切換到右側分頁", diff --git a/apps/desktop/src/lib/__tests__/editor/keyboardShortcuts.spec.ts b/apps/desktop/src/lib/__tests__/editor/keyboardShortcuts.spec.ts index 93e2c23c92..c4f7febba3 100644 --- a/apps/desktop/src/lib/__tests__/editor/keyboardShortcuts.spec.ts +++ b/apps/desktop/src/lib/__tests__/editor/keyboardShortcuts.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { eventToModifierOnlyShortcut, eventToShortcut, isExecuteSqlInNewResultTabShortcut, matchesModifierOnlyShortcut, matchesShortcut } from "@/lib/editor/keyboardShortcuts"; +import { eventToModifierOnlyShortcut, eventToShortcut, isExecuteSqlInNewResultTabShortcut, matchesModifierOnlyShortcut, matchesShortcut, tabSwitcherDirectionFromShortcut } from "@/lib/editor/keyboardShortcuts"; import { formatShortcutDisplay, isMacShortcutPlatform } from "@/lib/editor/shortcutDisplay"; describe("keyboard shortcut matching", () => { @@ -83,3 +83,24 @@ describe("keyboard shortcut matching", () => { expect(matchesShortcut({ key: "+", ctrlKey: true, shiftKey: true }, "Shift+Mod++", "Win32")).toBe(true); }); }); + +describe("tabSwitcherDirectionFromShortcut", () => { + it("advances forward on the default Ctrl+Tab", () => { + expect(tabSwitcherDirectionFromShortcut({ key: "Tab", ctrlKey: true }, { tabSwitcher: "Ctrl+Tab" })).toBe(1); + }); + + it("moves backward when Shift is added to the configured shortcut", () => { + expect(tabSwitcherDirectionFromShortcut({ key: "Tab", ctrlKey: true, shiftKey: true }, { tabSwitcher: "Ctrl+Tab" })).toBe(-1); + }); + + it("ignores unrelated keys and modifiers", () => { + expect(tabSwitcherDirectionFromShortcut({ key: "Tab" }, { tabSwitcher: "Ctrl+Tab" })).toBeNull(); + expect(tabSwitcherDirectionFromShortcut({ key: "Tab", ctrlKey: true, altKey: true }, { tabSwitcher: "Ctrl+Tab" })).toBeNull(); + expect(tabSwitcherDirectionFromShortcut({ key: "w", ctrlKey: true }, { tabSwitcher: "Ctrl+Tab" })).toBeNull(); + }); + + it("honors a remapped shortcut and does not reverse when it already uses Shift", () => { + expect(tabSwitcherDirectionFromShortcut({ key: "Tab", ctrlKey: true, shiftKey: true }, { tabSwitcher: "Shift+Ctrl+Tab" })).toBe(1); + expect(tabSwitcherDirectionFromShortcut({ key: "Tab", ctrlKey: true }, { tabSwitcher: "Shift+Ctrl+Tab" })).toBeNull(); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/tabs/tabSwitcher.spec.ts b/apps/desktop/src/lib/__tests__/tabs/tabSwitcher.spec.ts new file mode 100644 index 0000000000..e01741a41f --- /dev/null +++ b/apps/desktop/src/lib/__tests__/tabs/tabSwitcher.spec.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { initialTabSwitcherSelection, moveTabSwitcherSelection, tabSwitcherOrder } from "@/lib/tabs/tabSwitcher"; + +const tabs = [{ id: "a" }, { id: "b" }, { id: "c" }, { id: "d" }]; + +describe("tabSwitcherOrder", () => { + it("orders tabs by most recent use first", () => { + expect(tabSwitcherOrder(tabs, ["a", "c"]).map((tab) => tab.id)).toEqual(["c", "a", "b", "d"]); + }); + + it("returns tab-bar order when there is no history", () => { + expect(tabSwitcherOrder(tabs, []).map((tab) => tab.id)).toEqual(["a", "b", "c", "d"]); + }); + + it("drops history ids whose tab no longer exists", () => { + expect(tabSwitcherOrder(tabs, ["gone", "b"]).map((tab) => tab.id)).toEqual(["b", "a", "c", "d"]); + }); + + it("keeps duplicated history entries only once, at their latest position", () => { + expect(tabSwitcherOrder(tabs, ["a", "b", "a"]).map((tab) => tab.id)).toEqual(["a", "b", "c", "d"]); + }); +}); + +describe("initialTabSwitcherSelection", () => { + it("highlights the previous tab so a quick tap toggles between the two most recent", () => { + expect(initialTabSwitcherSelection(3)).toBe(1); + }); + + it("highlights the only tab when just one is open", () => { + expect(initialTabSwitcherSelection(1)).toBe(0); + }); +}); + +describe("moveTabSwitcherSelection", () => { + it("moves forward and wraps past the end", () => { + expect(moveTabSwitcherSelection(1, 1, 3)).toBe(2); + expect(moveTabSwitcherSelection(2, 1, 3)).toBe(0); + }); + + it("moves backward and wraps before the start", () => { + expect(moveTabSwitcherSelection(0, -1, 3)).toBe(2); + expect(moveTabSwitcherSelection(2, -1, 3)).toBe(1); + }); + + it("returns -1 when there is nothing to select", () => { + expect(moveTabSwitcherSelection(0, 1, 0)).toBe(-1); + }); + + it("enters the list from an unset highlight", () => { + expect(moveTabSwitcherSelection(-1, 1, 3)).toBe(0); + expect(moveTabSwitcherSelection(-1, -1, 3)).toBe(2); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/tabs/tabSwitcherKeyboard.spec.ts b/apps/desktop/src/lib/__tests__/tabs/tabSwitcherKeyboard.spec.ts new file mode 100644 index 0000000000..612f7e5796 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/tabs/tabSwitcherKeyboard.spec.ts @@ -0,0 +1,155 @@ +// @vitest-environment happy-dom + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createTabSwitcherKeyboardController } from "@/lib/tabs/tabSwitcherKeyboard"; + +function keyboardEvent(type: "keydown" | "keyup", key: string, init: KeyboardEventInit = {}): KeyboardEvent { + return new KeyboardEvent(type, { key, bubbles: true, cancelable: true, ...init }); +} + +function createHarness(initialShortcut = "Ctrl+Tab") { + let open = true; + let shortcut = initialShortcut; + const moves: Array<-1 | 1> = []; + let commits = 0; + let cancellations = 0; + const controller = createTabSwitcherKeyboardController( + { + isOpen: () => open, + shortcut: () => shortcut, + move: (direction) => moves.push(direction), + commit: () => { + commits += 1; + open = false; + }, + cancel: () => { + cancellations += 1; + open = false; + }, + }, + "Win32", + ); + + return { + controller, + moves, + setOpen: (value: boolean) => { + open = value; + }, + setShortcut: (value: string) => { + shortcut = value; + }, + commits: () => commits, + cancellations: () => cancellations, + }; +} + +afterEach(() => { + document.body.innerHTML = ""; +}); + +describe("tab switcher capture handling", () => { + it.each([ + ["ArrowDown", "move", 1], + ["ArrowUp", "move", -1], + ["Enter", "commit", 0], + ["Escape", "cancel", 0], + ] as const)("handles %s even when the event was already prevented", (key, action, direction) => { + const harness = createHarness(); + const event = keyboardEvent("keydown", key); + event.preventDefault(); + + expect(harness.controller.handleKeydownCapture(event)).toBe(true); + expect(event.defaultPrevented).toBe(true); + if (action === "move") expect(harness.moves).toEqual([direction]); + if (action === "commit") expect(harness.commits()).toBe(1); + if (action === "cancel") expect(harness.cancellations()).toBe(1); + }); + + it("captures navigation before a focused editor can prevent the event", () => { + const harness = createHarness(); + const editor = document.createElement("div"); + const editorKeydown = vi.fn((event: KeyboardEvent) => event.preventDefault()); + editor.addEventListener("keydown", editorKeydown); + document.body.append(editor); + const capture = (event: KeyboardEvent) => harness.controller.handleKeydownCapture(event); + window.addEventListener("keydown", capture, true); + + try { + const event = keyboardEvent("keydown", "ArrowDown"); + editor.dispatchEvent(event); + + expect(harness.moves).toEqual([1]); + expect(event.defaultPrevented).toBe(true); + expect(editorKeydown).not.toHaveBeenCalled(); + } finally { + window.removeEventListener("keydown", capture, true); + } + }); + + it("leaves unrelated keys available while the switcher is open", () => { + const harness = createHarness(); + const event = keyboardEvent("keydown", "x"); + + expect(harness.controller.handleKeydownCapture(event)).toBe(false); + expect(event.defaultPrevented).toBe(false); + expect(harness.moves).toEqual([]); + }); +}); + +describe("tab switcher release conditions", () => { + it.each([ + { + shortcut: "Ctrl+Tab", + opening: keyboardEvent("keydown", "Tab", { ctrlKey: true }), + nonRelease: keyboardEvent("keyup", "Tab", { ctrlKey: true }), + release: keyboardEvent("keyup", "Control"), + }, + { + shortcut: "Shift+Tab", + opening: keyboardEvent("keydown", "Tab", { shiftKey: true }), + nonRelease: keyboardEvent("keyup", "Tab", { shiftKey: true }), + release: keyboardEvent("keyup", "Shift"), + }, + { + shortcut: "F6", + opening: keyboardEvent("keydown", "F6"), + nonRelease: keyboardEvent("keyup", "F5"), + release: keyboardEvent("keyup", "F6"), + }, + ])("commits $shortcut when its configured hold key is released", ({ shortcut, opening, nonRelease, release }) => { + const harness = createHarness(shortcut); + harness.controller.rememberOpeningEvent(opening); + + expect(harness.controller.handleKeyup(nonRelease)).toBe(false); + expect(harness.commits()).toBe(0); + expect(harness.controller.handleKeyup(release)).toBe(true); + expect(harness.commits()).toBe(1); + }); +}); + +describe("tab switcher deactivation cleanup", () => { + it("cancels on window blur and clears the previous release condition", () => { + const harness = createHarness(); + harness.controller.rememberOpeningEvent(keyboardEvent("keydown", "Tab", { ctrlKey: true })); + + expect(harness.controller.handleWindowBlur()).toBe(true); + expect(harness.cancellations()).toBe(1); + harness.setOpen(true); + expect(harness.controller.handleKeyup(keyboardEvent("keyup", "Control"))).toBe(false); + expect(harness.commits()).toBe(0); + }); + + it("cancels when hidden but not when visibility remains visible", () => { + const harness = createHarness("F6"); + harness.controller.rememberOpeningEvent(keyboardEvent("keydown", "F6")); + + expect(harness.controller.handleVisibilityChange("visible")).toBe(false); + expect(harness.cancellations()).toBe(0); + expect(harness.controller.handleVisibilityChange("hidden")).toBe(true); + expect(harness.cancellations()).toBe(1); + harness.setOpen(true); + expect(harness.controller.handleKeyup(keyboardEvent("keyup", "F6"))).toBe(false); + expect(harness.commits()).toBe(0); + }); +}); diff --git a/apps/desktop/src/lib/editor/keyboardShortcuts.ts b/apps/desktop/src/lib/editor/keyboardShortcuts.ts index 8d790dc047..3a8bf7447e 100644 --- a/apps/desktop/src/lib/editor/keyboardShortcuts.ts +++ b/apps/desktop/src/lib/editor/keyboardShortcuts.ts @@ -253,6 +253,18 @@ export function isSwitchToNextTabShortcut(event: ShortcutLikeEvent, shortcuts?: return matchesShortcut(event, actionShortcut("switchToNextTab", shortcuts)); } +/** + * JetBrains-style tab switcher: exact match advances forward; adding Shift to + * the configured combo (Ctrl+Shift+Tab by default) moves backward. Returns + * null when the event is not the switcher shortcut. + */ +export function tabSwitcherDirectionFromShortcut(event: ShortcutLikeEvent, shortcuts?: Partial, platform = globalThis.navigator?.platform || ""): -1 | 1 | null { + const shortcut = actionShortcut("tabSwitcher", shortcuts, platform); + if (matchesShortcut(event, shortcut, platform)) return 1; + if (event.shiftKey && matchesShortcut({ ...event, shiftKey: false }, shortcut, platform)) return -1; + return null; +} + export function switchToTabIndexFromShortcut(event: ShortcutLikeEvent, shortcuts?: Partial): number | null { const normalized = normalizeShortcutSettings(shortcuts); const index = SWITCH_TO_TAB_ACTIONS.findIndex((actionId) => matchesShortcut(event, normalized[actionId])); diff --git a/apps/desktop/src/lib/editor/shortcutRegistry.ts b/apps/desktop/src/lib/editor/shortcutRegistry.ts index 924d00b25c..475a4bb16b 100644 --- a/apps/desktop/src/lib/editor/shortcutRegistry.ts +++ b/apps/desktop/src/lib/editor/shortcutRegistry.ts @@ -38,6 +38,7 @@ export type ShortcutActionId = | "quickOpen" | "navigateTabHistoryBack" | "navigateTabHistoryForward" + | "tabSwitcher" | "switchToPreviousTab" | "switchToNextTab" | "switchToTab1" @@ -326,6 +327,12 @@ export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [ scope: "global", defaultShortcut: tabNavigationHistoryDefaultShortcut("forward"), }, + { + id: "tabSwitcher", + labelKey: "settings.shortcutTabSwitcher", + scope: "global", + defaultShortcut: "Ctrl+Tab", + }, { id: "switchToPreviousTab", labelKey: "settings.shortcutSwitchToPreviousTab", diff --git a/apps/desktop/src/lib/tabs/tabSwitcher.ts b/apps/desktop/src/lib/tabs/tabSwitcher.ts new file mode 100644 index 0000000000..49cfbfaa49 --- /dev/null +++ b/apps/desktop/src/lib/tabs/tabSwitcher.ts @@ -0,0 +1,40 @@ +/** + * Ordering and selection helpers for the JetBrains-style Ctrl+Tab switcher. + * Pure functions so the behavior is unit-testable without mounting the UI. + */ + +/** + * Orders tabs by most-recent use: tabs that appear in `mruIds` (oldest first, + * as recorded on activation) come first in reverse-chronological order; tabs + * never visited (e.g. restored from a previous session) follow in tab-bar + * order. Ids that no longer have a tab are dropped. + */ +export function tabSwitcherOrder(tabs: readonly T[], mruIds: readonly string[]): T[] { + const tabById = new Map(tabs.map((tab) => [tab.id, tab])); + const ordered: T[] = []; + const seen = new Set(); + + for (let i = mruIds.length - 1; i >= 0; i--) { + const id = mruIds[i]!; + if (seen.has(id)) continue; + seen.add(id); + const tab = tabById.get(id); + if (tab) ordered.push(tab); + } + for (const tab of tabs) { + if (!seen.has(tab.id)) ordered.push(tab); + } + return ordered; +} + +/** Initial highlight when the switcher opens: the previous tab, so a quick tap toggles between the two most recent tabs. */ +export function initialTabSwitcherSelection(count: number): number { + return count > 1 ? 1 : 0; +} + +/** Moves the highlight with wrap-around; returns -1 when there is nothing to select. */ +export function moveTabSwitcherSelection(current: number, delta: -1 | 1, count: number): number { + if (count <= 0) return -1; + if (current < 0) return delta > 0 ? 0 : count - 1; + return (current + delta + count) % count; +} diff --git a/apps/desktop/src/lib/tabs/tabSwitcherKeyboard.ts b/apps/desktop/src/lib/tabs/tabSwitcherKeyboard.ts new file mode 100644 index 0000000000..1c4448d2f8 --- /dev/null +++ b/apps/desktop/src/lib/tabs/tabSwitcherKeyboard.ts @@ -0,0 +1,124 @@ +import { tabSwitcherDirectionFromShortcut, type ShortcutLikeEvent } from "@/lib/editor/keyboardShortcuts"; +import { isMacShortcutPlatform, parseShortcutParts } from "@/lib/editor/shortcutDisplay"; + +export type TabSwitcherKeyboardAction = { type: "move"; direction: -1 | 1 } | { type: "commit" } | { type: "cancel" }; + +export interface TabSwitcherKeyboardControllerOptions { + isOpen: () => boolean; + shortcut: () => string; + move: (direction: -1 | 1) => void; + commit: () => void; + cancel: () => void; +} + +function normalizeReleaseKey(key: string): string { + if (key === "Ctrl" || key === "Control") return "Control"; + if (key === "Cmd" || key === "Meta") return "Meta"; + if (key === "Space") return " "; + if (key === "Plus") return "+"; + return key.length === 1 ? key.toLowerCase() : key; +} + +export function tabSwitcherReleaseKeys(shortcut: string, event: ShortcutLikeEvent, platform = globalThis.navigator?.platform || ""): ReadonlySet { + const parts = parseShortcutParts(shortcut); + const key = parts[parts.length - 1]; + const modifiers = new Set(parts.slice(0, -1)); + const releaseKeys = new Set(); + + if (modifiers.has("Mod")) { + if (isMacShortcutPlatform(platform)) { + releaseKeys.add("Meta"); + } else { + if (event.ctrlKey) releaseKeys.add("Control"); + if (event.metaKey) releaseKeys.add("Meta"); + } + } + if (modifiers.has("Ctrl") || modifiers.has("Control")) releaseKeys.add("Control"); + if (modifiers.has("Meta") || modifiers.has("Cmd")) releaseKeys.add("Meta"); + if (modifiers.has("Alt")) releaseKeys.add("Alt"); + if (modifiers.has("Shift")) releaseKeys.add("Shift"); + + if (!releaseKeys.size && key) releaseKeys.add(normalizeReleaseKey(key)); + return releaseKeys; +} + +export function tabSwitcherKeydownAction(event: ShortcutLikeEvent, shortcut: string, platform = globalThis.navigator?.platform || ""): TabSwitcherKeyboardAction | null { + const direction = tabSwitcherDirectionFromShortcut(event, { tabSwitcher: shortcut }, platform); + if (direction) return { type: "move", direction }; + if (event.key === "ArrowDown") return { type: "move", direction: 1 }; + if (event.key === "ArrowUp") return { type: "move", direction: -1 }; + if (event.key === "Enter") return { type: "commit" }; + if (event.key === "Escape") return { type: "cancel" }; + return null; +} + +export function captureTabSwitcherKeydown(event: KeyboardEvent, shortcut: string, onAction: (action: TabSwitcherKeyboardAction) => void, platform = globalThis.navigator?.platform || ""): boolean { + const action = tabSwitcherKeydownAction(event, shortcut, platform); + if (!action) return false; + event.preventDefault(); + event.stopPropagation(); + onAction(action); + return true; +} + +export function createTabSwitcherKeyboardController(options: TabSwitcherKeyboardControllerOptions, platform = globalThis.navigator?.platform || "") { + let releaseKeys: ReadonlySet = new Set(); + + function reset() { + releaseKeys = new Set(); + } + + function rememberOpeningEvent(event: ShortcutLikeEvent) { + releaseKeys = tabSwitcherReleaseKeys(options.shortcut(), event, platform); + } + + function handleKeydownCapture(event: KeyboardEvent): boolean { + if (!options.isOpen()) return false; + return captureTabSwitcherKeydown( + event, + options.shortcut(), + (action) => { + if (action.type === "move") { + options.move(action.direction); + } else if (action.type === "commit") { + reset(); + options.commit(); + } else { + reset(); + options.cancel(); + } + }, + platform, + ); + } + + function handleKeyup(event: KeyboardEvent): boolean { + if (!options.isOpen() || !releaseKeys.has(normalizeReleaseKey(event.key))) return false; + reset(); + options.commit(); + return true; + } + + function cancelForDeactivation(): boolean { + if (!options.isOpen()) { + reset(); + return false; + } + reset(); + options.cancel(); + return true; + } + + function handleVisibilityChange(visibilityState: DocumentVisibilityState): boolean { + return visibilityState === "visible" ? false : cancelForDeactivation(); + } + + return { + reset, + rememberOpeningEvent, + handleKeydownCapture, + handleKeyup, + handleWindowBlur: cancelForDeactivation, + handleVisibilityChange, + }; +} diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 71e111ba5d..813aeb9d96 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -705,6 +705,9 @@ export const useQueryStore = defineStore("query", () => { const activeTabId = ref(null); const isOpenTabsLoaded = ref(false); const activeTabHistory = ref([]); + // Most-recently-activated tab ids, oldest first. Read-only view for the + // Ctrl+Tab switcher, which renders them in reverse. + const recentTabIds = computed(() => activeTabHistory.value); const showCloseConfirm = ref(false); const pendingCloseTabId = ref(null); const pendingBatchCloseTabIds = ref(null); @@ -6263,6 +6266,7 @@ export const useQueryStore = defineStore("query", () => { tabs, activeTabId, isOpenTabsLoaded, + recentTabIds, initOpenTabs, showCloseConfirm, pendingCloseTabId,