feat(tabs): add Ctrl+Tab MRU switcher

This commit is contained in:
Allen
2026-08-20 22:12:52 +08:00
committed by GitHub
parent 81872165da
commit 7ce992b8f8
18 changed files with 615 additions and 1 deletions
+86
View File
@@ -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(() => {
<ExternalSqlFileChangeDialog :prompt="externalSqlFilePrompt" @decide="externalSqlFileChanges.resolvePrompt" />
<CloseActionPromptDialog v-if="isDesktop && showCloseActionPrompt" :open="showCloseActionPrompt" @update:open="handleCloseActionPromptOpenChange" @quit="chooseQuit" @minimize="chooseMinimize" />
<QuickOpenDialog :open="showQuickOpen" @update:open="showQuickOpen = $event" @select="handleQuickOpenSelect" />
<TabSwitcherDialog :open="showTabSwitcher" :tabs="tabSwitcherTabs" :selected-index="tabSwitcherIndex" :shortcut-hint="tabSwitcherShortcutHint" @update:open="handleTabSwitcherOpenChange" @update:selected-index="tabSwitcherIndex = $event" @select="handleTabSwitcherSelect" />
</div>
<Teleport to="body">
<FileText
@@ -0,0 +1,88 @@
<script setup lang="ts">
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<HTMLElement>();
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" });
},
);
</script>
<template>
<Teleport to="body">
<div v-if="open" class="fixed inset-0 z-[100] flex items-start justify-center pt-[18vh]" @mousedown.self="emit('update:open', false)">
<div class="w-[420px] max-w-[calc(100vw-2rem)] overflow-hidden rounded-lg border bg-popover shadow-2xl" role="listbox" :aria-label="t('tabs.switcherTitle')">
<div class="border-b px-3 py-2 text-xs font-medium text-muted-foreground">{{ t("tabs.switcherTitle") }}</div>
<div ref="listRef" class="max-h-[60vh] overflow-y-auto p-1">
<div
v-for="(tab, index) in tabs"
:key="tab.id"
:data-tab-switcher-row="tab.id"
role="option"
:aria-selected="index === selectedIndex"
:style="tabRowStyle(tab, index)"
:class="['flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5', index === selectedIndex ? 'bg-accent text-accent-foreground' : 'hover:bg-muted/60']"
@mouseenter="emit('update:selectedIndex', index)"
@click="emit('select', tab.id)"
>
<DatabaseIcon :db-type="connectionIconType(connectionStore.getConfig(tab.connectionId))" class="h-4 w-4 shrink-0" />
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium">{{ tabDisplayTitle(tab, t) }}</div>
<div class="truncate text-xs text-muted-foreground">
{{ connectionDisplayName(tab.connectionId) }}<template v-if="tab.database"> · {{ tab.database }}</template>
</div>
</div>
<span class="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">{{ tabModeLabel(tab, t) }}</span>
</div>
</div>
<div class="flex items-center justify-between border-t px-3 py-1.5 text-xs text-muted-foreground">
<span>
<kbd class="rounded bg-muted px-1.5 py-0.5">{{ shortcutHint }}</kbd>
{{ t("tabs.switcherHint") }}
</span>
<span><kbd class="rounded bg-muted px-1.5 py-0.5">Esc</kbd> {{ t("quickOpen.close") }}</span>
</div>
</div>
</div>
</Teleport>
</template>
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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: "次のタブに切り替え",
+3
View File
@@ -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: "다음 탭으로 전환",
+3
View File
@@ -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",
+3
View File
@@ -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: "切换到右侧标签页",
+3
View File
@@ -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: "切換到右側分頁",
@@ -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();
});
});
+53
View File
@@ -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);
});
});
@@ -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);
});
});
@@ -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<ShortcutSettings>, 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<ShortcutSettings>): number | null {
const normalized = normalizeShortcutSettings(shortcuts);
const index = SWITCH_TO_TAB_ACTIONS.findIndex((actionId) => matchesShortcut(event, normalized[actionId]));
@@ -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",
+40
View File
@@ -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<T extends { id: string }>(tabs: readonly T[], mruIds: readonly string[]): T[] {
const tabById = new Map(tabs.map((tab) => [tab.id, tab]));
const ordered: T[] = [];
const seen = new Set<string>();
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;
}
@@ -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<string> {
const parts = parseShortcutParts(shortcut);
const key = parts[parts.length - 1];
const modifiers = new Set(parts.slice(0, -1));
const releaseKeys = new Set<string>();
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<string> = 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,
};
}
+4
View File
@@ -705,6 +705,9 @@ export const useQueryStore = defineStore("query", () => {
const activeTabId = ref<string | null>(null);
const isOpenTabsLoaded = ref(false);
const activeTabHistory = ref<string[]>([]);
// 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<string | null>(null);
const pendingBatchCloseTabIds = ref<string[] | null>(null);
@@ -6263,6 +6266,7 @@ export const useQueryStore = defineStore("query", () => {
tabs,
activeTabId,
isOpenTabsLoaded,
recentTabIds,
initOpenTabs,
showCloseConfirm,
pendingCloseTabId,