feat(tabs): add Ctrl+Tab MRU switcher

This commit is contained in:
Allen authored and GitHub committed 2026-08-20 22:12:52 +08:00
1 parent 81872165da
commit 7ce992b8f8
18 files changed
+615 -1

No files matched your search

@@ -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,
};
}