feat(tabs): add Ctrl+Tab MRU switcher
This commit is contained in:
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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user