feat(shortcuts): open data in a new tab

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-07-15 21:43:03 +08:00
1 parent 75652b10fe
commit 11536da7e5
16 files changed
+265 -23

No files matched your search

@@ -1,7 +1,27 @@
import { describe, expect, it } from "vitest";
import { eventToShortcut, matchesShortcut } from "@/lib/editor/keyboardShortcuts";
import { eventToModifierOnlyShortcut, eventToShortcut, matchesModifierOnlyShortcut, matchesShortcut } from "@/lib/editor/keyboardShortcuts";
describe("keyboard shortcut matching", () => {
it("records modifier-only mouse shortcut settings", () => {
expect(eventToModifierOnlyShortcut({ key: "Alt", altKey: true })).toBe("Alt");
expect(eventToModifierOnlyShortcut({ key: "Shift", shiftKey: true })).toBe("Shift");
expect(eventToModifierOnlyShortcut({ key: "Control", ctrlKey: true }, "Win32")).toBe("Mod");
expect(eventToModifierOnlyShortcut({ key: "Meta", metaKey: true }, "Win32")).toBe("Meta");
expect(eventToModifierOnlyShortcut({ key: "Meta", metaKey: true }, "MacIntel")).toBe("Mod");
expect(eventToModifierOnlyShortcut({ key: "Control", ctrlKey: true }, "MacIntel")).toBe("Ctrl");
expect(eventToModifierOnlyShortcut({ key: "A", altKey: true })).toBeNull();
});
it("matches a configured mouse modifier exactly", () => {
expect(matchesModifierOnlyShortcut({ altKey: true }, "Alt")).toBe(true);
expect(matchesModifierOnlyShortcut({ ctrlKey: true }, "Mod")).toBe(true);
expect(matchesModifierOnlyShortcut({ metaKey: true }, "Mod")).toBe(true);
expect(matchesModifierOnlyShortcut({ ctrlKey: true }, "Ctrl")).toBe(true);
expect(matchesModifierOnlyShortcut({ metaKey: true }, "Meta")).toBe(true);
expect(matchesModifierOnlyShortcut({ altKey: true, shiftKey: true }, "Alt")).toBe(false);
expect(matchesModifierOnlyShortcut({ shiftKey: true }, "")).toBe(false);
});
it("records the plus key without losing it to the separator", () => {
expect(eventToShortcut({ key: "+", ctrlKey: true })).toBe("Mod+Plus");
expect(eventToShortcut({ key: "+", ctrlKey: true, shiftKey: true })).toBe("Shift+Mod+Plus");
@@ -2,6 +2,11 @@ import { describe, expect, it } from "vitest";
import { formatShortcutDisplay, shortcutDisplayKeys } from "@/lib/editor/shortcutDisplay";
describe("shortcut display", () => {
it("shows the default mouse modifier as Option on macOS and Alt elsewhere", () => {
expect(formatShortcutDisplay("Alt", "MacIntel")).toBe("⌥");
expect(formatShortcutDisplay("Alt", "Win32")).toBe("Alt");
});
it("uses readable modifier labels on Windows", () => {
expect(shortcutDisplayKeys("Shift+Alt+U", "Win32")).toEqual(["Shift", "Alt", "U"]);
});
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_SHORTCUT_SETTINGS, SHORTCUT_DEFINITIONS, findShortcutConflict, formatShortcut, normalizeShortcutSettings, shortcutToCodeMirrorKey, type ShortcutActionId } from "@/lib/editor/shortcutRegistry";
import { DEFAULT_SHORTCUT_SETTINGS, SHORTCUT_DEFINITIONS, findShortcutConflict, formatShortcut, normalizeModifierOnlyShortcut, normalizeShortcutSettings, shortcutToCodeMirrorKey, type ShortcutActionId } from "@/lib/editor/shortcutRegistry";
describe("shortcutRegistry editor actions", () => {
const formatterEditorActionIds: ShortcutActionId[] = [
@@ -22,6 +22,21 @@ describe("shortcutRegistry editor actions", () => {
];
const sidebarShortcutActionIds: ShortcutActionId[] = ["copySidebarSelection", "pasteSidebarSelection", "editSidebarConnection"];
it("registers the new-data-tab mouse modifier as a configurable sidebar shortcut", () => {
const definition = SHORTCUT_DEFINITIONS.find((item) => item.id === "openDataInNewTab");
expect(definition).toMatchObject({ scope: "sidebar", defaultShortcut: "Alt", inputKind: "modifier-only" });
expect(DEFAULT_SHORTCUT_SETTINGS.openDataInNewTab).toBe("Alt");
expect(formatShortcut(DEFAULT_SHORTCUT_SETTINGS.openDataInNewTab, "MacIntel")).toBe("Alt");
});
it("normalizes custom, cleared, and invalid modifier-only shortcuts", () => {
expect(normalizeShortcutSettings({ openDataInNewTab: "Shift" }).openDataInNewTab).toBe("Shift");
expect(normalizeShortcutSettings({ openDataInNewTab: "" }).openDataInNewTab).toBe("");
expect(normalizeShortcutSettings({ openDataInNewTab: "Mod+Enter" }).openDataInNewTab).toBe("Alt");
expect(normalizeModifierOnlyShortcut("Control")).toBe("Ctrl");
});
it("registers formatter editor shortcuts in the generic editor scope", () => {
for (const actionId of formatterEditorActionIds) {
const definition = SHORTCUT_DEFINITIONS.find((item) => item.id === actionId);
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import { dataTabOpenModeFromTreeClick, findExistingDataTabCandidate } from "@/lib/sidebar/dataTabOpenPolicy";
import type { QueryTab } from "@/types/database";
function click(modifiers: Partial<Pick<MouseEvent, "metaKey" | "ctrlKey" | "altKey" | "shiftKey">> = {}) {
return {
metaKey: modifiers.metaKey ?? false,
ctrlKey: modifiers.ctrlKey ?? false,
altKey: modifiers.altKey ?? false,
shiftKey: modifiers.shiftKey ?? false,
};
}
function dataTab(id: string, title: string, schema = "public"): QueryTab {
return {
id,
title,
connectionId: "conn",
database: "app",
schema,
sql: "",
isExecuting: false,
mode: "data",
};
}
const usersTarget = { connectionId: "conn", database: "app", schema: "public", tableName: "users" };
describe("dataTabOpenPolicy", () => {
it("maps the default Alt/Option modifier to explicit new-tab mode for data nodes only", () => {
expect(dataTabOpenModeFromTreeClick("table", click({ altKey: true }), "Alt")).toBe("new-tab");
expect(dataTabOpenModeFromTreeClick("view", click({ altKey: true }), "Alt")).toBe("new-tab");
expect(dataTabOpenModeFromTreeClick("materialized_view", click({ altKey: true }), "Alt")).toBe("new-tab");
expect(dataTabOpenModeFromTreeClick("database", click({ altKey: true }), "Alt")).toBe("default");
});
it("honors customized modifier-only shortcuts and a cleared shortcut", () => {
expect(dataTabOpenModeFromTreeClick("table", click({ ctrlKey: true }), "Mod")).toBe("new-tab");
expect(dataTabOpenModeFromTreeClick("table", click({ metaKey: true }), "Mod")).toBe("new-tab");
expect(dataTabOpenModeFromTreeClick("table", click({ shiftKey: true }), "Shift")).toBe("new-tab");
expect(dataTabOpenModeFromTreeClick("table", click({ altKey: true }), "")).toBe("default");
});
it("does not consume tree selection modifier combinations under the default Alt shortcut", () => {
expect(dataTabOpenModeFromTreeClick("table", click({ metaKey: true }), "Alt")).toBe("default");
expect(dataTabOpenModeFromTreeClick("table", click({ ctrlKey: true }), "Alt")).toBe("default");
expect(dataTabOpenModeFromTreeClick("table", click({ shiftKey: true }), "Alt")).toBe("default");
expect(dataTabOpenModeFromTreeClick("table", click({ altKey: true, shiftKey: true }), "Alt")).toBe("default");
});
it("never returns an existing tab in explicit new-tab mode", () => {
const existing = dataTab("users", "users");
existing.tableMeta = { schema: "public", tableName: "users", columns: [], primaryKeys: [] };
expect(findExistingDataTabCandidate([existing], usersTarget, { openMode: "new-tab", reuseDataTab: true })).toBeUndefined();
expect(findExistingDataTabCandidate([existing], usersTarget, { openMode: "new-tab", reuseDataTab: false })).toBeUndefined();
});
it("preserves same-table activation and configured database-tab reuse for ordinary opens", () => {
const sameTable = dataTab("users", "users");
sameTable.tableMeta = { schema: "public", tableName: "users", columns: [], primaryKeys: [] };
const otherTable = dataTab("orders", "orders");
expect(findExistingDataTabCandidate([sameTable], usersTarget, { openMode: "default", reuseDataTab: false })).toEqual({ tab: sameTable, match: "same-table" });
expect(findExistingDataTabCandidate([otherTable], usersTarget, { openMode: "default", reuseDataTab: true })).toEqual({ tab: otherTable, match: "database" });
expect(findExistingDataTabCandidate([otherTable], usersTarget, { openMode: "default", reuseDataTab: false })).toBeUndefined();
});
});
@@ -1,4 +1,4 @@
import { parseShortcutParts } from "@/lib/editor/shortcutDisplay";
import { isMacShortcutPlatform, parseShortcutParts } from "@/lib/editor/shortcutDisplay";
import { normalizeShortcutSettings, type ShortcutActionId, type ShortcutSettings } from "@/lib/editor/shortcutRegistry";
export interface ShortcutLikeEvent {
@@ -41,6 +41,29 @@ export function eventToShortcut(event: ShortcutLikeEvent): string | null {
return parts.join("+");
}
export function eventToModifierOnlyShortcut(event: ShortcutLikeEvent, platform = globalThis.navigator?.platform || ""): string | null {
if (event.isComposing) return null;
if (event.key === "Alt") return "Alt";
if (event.key === "Shift") return "Shift";
if (event.key === "Meta") return isMacShortcutPlatform(platform) ? "Mod" : "Meta";
if (event.key === "Control") return isMacShortcutPlatform(platform) ? "Ctrl" : "Mod";
return null;
}
export function matchesModifierOnlyShortcut(event: Omit<ShortcutLikeEvent, "key">, shortcut: string): boolean {
if (event.isComposing || !shortcut) return false;
const meta = !!event.metaKey;
const ctrl = !!event.ctrlKey;
const alt = !!event.altKey;
const shift = !!event.shiftKey;
if (shortcut === "Mod") return meta !== ctrl && !alt && !shift;
if (shortcut === "Meta") return meta && !ctrl && !alt && !shift;
if (shortcut === "Ctrl") return ctrl && !meta && !alt && !shift;
if (shortcut === "Alt") return alt && !meta && !ctrl && !shift;
if (shortcut === "Shift") return shift && !meta && !ctrl && !alt;
return false;
}
export function matchesShortcut(event: ShortcutLikeEvent, shortcut: string): boolean {
if (event.isComposing || !shortcut) return false;
const parts = parseShortcutParts(shortcut);
@@ -50,6 +50,7 @@ export type ShortcutActionId =
| "copySidebarSelection"
| "pasteSidebarSelection"
| "editSidebarConnection"
| "openDataInNewTab"
| "sendSelectionToAi";
export type ShortcutScope = "global" | "editor" | "grid" | "search" | "sidebar";
@@ -59,6 +60,7 @@ export interface ShortcutDefinition {
labelKey: string;
scope: ShortcutScope;
defaultShortcut: string;
inputKind?: "keyboard" | "modifier-only";
}
export type ShortcutSettings = Record<ShortcutActionId, string>;
@@ -358,6 +360,13 @@ export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [
scope: "sidebar",
defaultShortcut: "Mod+E",
},
{
id: "openDataInNewTab",
labelKey: "settings.shortcutOpenDataInNewTab",
scope: "sidebar",
defaultShortcut: "Alt",
inputKind: "modifier-only",
},
{
id: "sendSelectionToAi",
labelKey: "settings.shortcutSendSelectionToAi",
@@ -368,8 +377,23 @@ export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [
export const DEFAULT_SHORTCUT_SETTINGS: ShortcutSettings = Object.fromEntries(SHORTCUT_DEFINITIONS.map((definition) => [definition.id, definition.defaultShortcut])) as ShortcutSettings;
const modifierOnlyShortcuts = new Set(["Alt", "Shift", "Mod", "Ctrl", "Meta"]);
export function normalizeModifierOnlyShortcut(shortcut: string, fallback = ""): string {
const normalized = shortcut.trim() === "Control" ? "Ctrl" : shortcut.trim();
if (normalized === "") return "";
return modifierOnlyShortcuts.has(normalized) ? normalized : fallback;
}
export function normalizeShortcutSettings(settings?: Partial<ShortcutSettings>): ShortcutSettings {
return Object.fromEntries(SHORTCUT_DEFINITIONS.map((definition) => [definition.id, typeof settings?.[definition.id] === "string" ? settings[definition.id] : definition.defaultShortcut])) as ShortcutSettings;
return Object.fromEntries(
SHORTCUT_DEFINITIONS.map((definition) => {
const configuredValue = settings?.[definition.id];
const configured = typeof configuredValue === "string" ? configuredValue : definition.defaultShortcut;
const normalized = definition.inputKind === "modifier-only" ? normalizeModifierOnlyShortcut(configured, definition.defaultShortcut) : configured;
return [definition.id, normalized];
}),
) as ShortcutSettings;
}
export function shortcutToCodeMirrorKey(shortcut: string): string {
@@ -0,0 +1,49 @@
import { matchesModifierOnlyShortcut, type ShortcutLikeEvent } from "@/lib/editor/keyboardShortcuts";
import type { QueryTab, TreeNodeType } from "@/types/database";
export type DataTabOpenMode = "default" | "new-tab";
type DataTabLike = Pick<QueryTab, "id" | "mode" | "connectionId" | "database" | "schema" | "title" | "tableMeta">;
export interface DataTabTarget {
connectionId: string;
database: string;
schema?: string;
catalog?: string;
tableName: string;
}
export type ExistingDataTabCandidate<T extends DataTabLike> = {
tab: T;
match: "same-table" | "database";
};
const dataNodeTypes = new Set<TreeNodeType>(["table", "view", "materialized_view"]);
export function isDataTreeNodeType(type: TreeNodeType): boolean {
return dataNodeTypes.has(type);
}
export function dataTabOpenModeFromTreeClick(type: TreeNodeType, event: Omit<ShortcutLikeEvent, "key">, shortcut: string): DataTabOpenMode {
if (!isDataTreeNodeType(type)) return "default";
return matchesModifierOnlyShortcut(event, shortcut) ? "new-tab" : "default";
}
function isSameDatabase(tab: DataTabLike, target: Pick<DataTabTarget, "connectionId" | "database">): boolean {
return tab.mode === "data" && tab.connectionId === target.connectionId && tab.database === target.database;
}
function isSameTable(tab: DataTabLike, target: DataTabTarget): boolean {
return isSameDatabase(tab, target) && (tab.tableMeta?.catalog || "") === (target.catalog || "") && (tab.schema || "") === (target.schema || "") && (tab.tableMeta?.tableName || tab.title) === target.tableName;
}
export function findExistingDataTabCandidate<T extends DataTabLike>(tabs: T[], target: DataTabTarget, options: { openMode: DataTabOpenMode; reuseDataTab: boolean }): ExistingDataTabCandidate<T> | undefined {
if (options.openMode === "new-tab") return undefined;
const sameTable = tabs.find((tab) => isSameTable(tab, target));
if (sameTable) return { tab: sameTable, match: "same-table" };
if (!options.reuseDataTab) return undefined;
const sameDatabase = tabs.find((tab) => isSameDatabase(tab, target));
return sameDatabase ? { tab: sameDatabase, match: "database" } : undefined;
}