mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-20 00:17:44 +00:00
feat(tabs): add Ctrl+Tab MRU switcher
This commit is contained in:
@@ -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>
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "次のタブに切り替え",
|
||||
|
||||
@@ -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: "다음 탭으로 전환",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "切换到右侧标签页",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user