Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions apps/desktop/src/components/editor/EditorSettingsDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ import {
type SnippetSyncConfig,
type WebDavConfig,
} from "@/lib/backend/api";
import { eventToShortcut } from "@/lib/editor/keyboardShortcuts";
import { eventToModifierOnlyShortcut, eventToShortcut } from "@/lib/editor/keyboardShortcuts";
import { SHORTCUT_DEFINITIONS, findShortcutConflict, normalizeShortcutSettings, type ShortcutActionId } from "@/lib/editor/shortcutRegistry";
import { formatShortcutDisplay } from "@/lib/editor/shortcutDisplay";
import { normalizeSidebarHiddenTablePrefixes } from "@/lib/sidebar/sidebarTableNameDisplay";
Expand Down Expand Up @@ -1202,7 +1202,8 @@ function onShortcutKeydown(actionId: ShortcutActionId, event: KeyboardEvent) {
editingShortcutId.value = null;
return;
}
const shortcut = eventToShortcut(event);
const definition = SHORTCUT_DEFINITIONS.find((item) => item.id === actionId);
const shortcut = definition?.inputKind === "modifier-only" ? eventToModifierOnlyShortcut(event) : eventToShortcut(event);
if (!shortcut) return;
onShortcutChange(actionId, shortcut);
editingShortcutId.value = null;
Expand Down
57 changes: 40 additions & 17 deletions apps/desktop/src/components/sidebar/TreeItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ import {
usesTreeSchemaMode,
} from "@/lib/database/databaseCapabilities";
import { copyNameForTreeNode, isDocumentBrowserTreeNode, objectSourceKindForTreeNode, shouldRunTreeNodeRowAction, treeNodeRowAction, treeNodeRowDoubleClickAction } from "@/lib/sidebar/treeNodeClick";
import { dataTabOpenModeFromTreeClick, findExistingDataTabCandidate, type DataTabOpenMode } from "@/lib/sidebar/dataTabOpenPolicy";
import { isCopySidebarSelectionShortcut, isEditSidebarConnectionShortcut, isPasteSidebarSelectionShortcut } from "@/lib/editor/keyboardShortcuts";
import { formatSqlInsert } from "@/lib/export/exportFormats";
import { joinExportedDdls } from "@/lib/export/ddlExport";
Expand Down Expand Up @@ -975,6 +976,15 @@ function onClick(event: MouseEvent) {
// Row clicks must not bubble to the tree container, whose click handler
// clears the selection when the blank area is clicked (issue #681).
event.stopPropagation();
const dataTabOpenMode = dataTabOpenModeFromTreeClick(props.node.type, event, settingsStore.editorSettings.shortcuts.openDataInNewTab);
if (dataTabOpenMode === "new-tab") {
event.preventDefault();
if (event.detail > 1) return;
selectSingleTreeNode(props.node);
rowRef.value?.focus({ preventScroll: true });
openDataInNewTabImmediately(props.node);
return;
}
if (event.shiftKey) {
selectTreeNodeRange(props.node);
rowRef.value?.focus({ preventScroll: true });
Expand Down Expand Up @@ -1178,7 +1188,8 @@ function requestDeleteSelectedNode(): boolean {
return false;
}

function onDoubleClick() {
function onDoubleClick(event: MouseEvent) {
if (dataTabOpenModeFromTreeClick(props.node.type, event, settingsStore.editorSettings.shortcuts.openDataInNewTab) === "new-tab") return;
const action = treeNodeRowDoubleClickAction(props.node.type, canOpenObjectBrowser.value, settingsStore.editorSettings.sidebarActivation, canExpand.value);
if (action === "open-object-browser") {
void openObjectBrowser();
Expand Down Expand Up @@ -1309,7 +1320,11 @@ function openDataImmediately(node: TreeNode = props.node) {
emit("open-data", node, false, openData);
}

async function openData(node: TreeNode, request?: SidebarDataOpenRequest) {
function openDataInNewTabImmediately(node: TreeNode = props.node) {
emit("open-data", node, false, (target, request) => openData(target, request, "new-tab"));
}

async function openData(node: TreeNode, request?: SidebarDataOpenRequest, openMode: DataTabOpenMode = "default") {
if (!(node.type === "table" || node.type === "view" || node.type === "materialized_view") || !hasNodeDatabaseContext(node)) return;
const config = connectionStore.getConfig(node.connectionId);
const traceId = uuid().slice(0, 8);
Expand Down Expand Up @@ -1341,9 +1356,18 @@ async function openData(node: TreeNode, request?: SidebarDataOpenRequest) {
const querySchema = config ? connectionObjectTreeQuerySchema(config, node.database, tableSchema) : (tableSchema ?? "");
const effectiveDbType = effectiveDatabaseTypeForConnection(config);
const metadataDatabaseType = effectiveDbType || config?.db_type || "";
const isSameDataTableTab = (tab: (typeof queryStore.tabs)[number]) =>
tab.mode === "data" && tab.connectionId === node.connectionId && tab.database === node.database && (tab.tableMeta?.catalog || "") === (node.catalog || "") && (tab.schema || "") === (tableSchema || "") && (tab.tableMeta?.tableName || tab.title) === node.label;
const existingSameTableTab = queryStore.tabs.find(isSameDataTableTab);
const existingDataTabCandidate = findExistingDataTabCandidate(
queryStore.tabs,
{
connectionId: node.connectionId,
database: node.database,
schema: tableSchema,
catalog: node.catalog,
tableName: node.label,
},
{ openMode, reuseDataTab: settingsStore.editorSettings.reuseDataTab },
);
const existingSameTableTab = existingDataTabCandidate?.match === "same-table" ? existingDataTabCandidate.tab : undefined;
const resetReusedDataTabState = (tab: (typeof queryStore.tabs)[number]) => {
tab.title = node.label;
tab.schema = tableSchema;
Expand Down Expand Up @@ -1374,18 +1398,10 @@ async function openData(node: TreeNode, request?: SidebarDataOpenRequest) {
}

const tabId = (() => {
if (existingSameTableTab) {
queryStore.switchTab(existingSameTableTab.id);
resetReusedDataTabState(existingSameTableTab);
return existingSameTableTab.id;
}
if (settingsStore.editorSettings.reuseDataTab) {
const existing = queryStore.tabs.find((tab) => tab.mode === "data" && tab.connectionId === node.connectionId && tab.database === node.database);
if (existing) {
queryStore.switchTab(existing.id);
resetReusedDataTabState(existing);
return existing.id;
}
if (existingDataTabCandidate) {
queryStore.switchTab(existingDataTabCandidate.tab.id);
resetReusedDataTabState(existingDataTabCandidate.tab);
return existingDataTabCandidate.tab.id;
}
return queryStore.createTab(node.connectionId, node.database, node.label, "data", tableSchema);
})();
Expand Down Expand Up @@ -4947,6 +4963,7 @@ onBeforeUnmount(() => {
});

const shortcutCopyName = computed(() => settingsStore.editorSettings.shortcuts.copySidebarSelection);
const shortcutOpenDataInNewTab = computed(() => settingsStore.editorSettings.shortcuts.openDataInNewTab);
const shortcutEditConnection = computed(() => settingsStore.editorSettings.shortcuts.editSidebarConnection);
const shortcutRename = "F2";
const shortcutRefresh = "F5";
Expand Down Expand Up @@ -5393,6 +5410,12 @@ function treeItemMenuItems(): ContextMenuItem[] {
items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value });
items.push({ label: "", separator: true });
items.push({ label: t("contextMenu.viewData"), action: openDataImmediately, icon: TableProperties });
items.push({
label: t("contextMenu.openInNewDataTab"),
action: openDataInNewTabImmediately,
icon: CopyPlus,
shortcut: shortcutOpenDataInNewTab.value,
});
if (node.type === "table") {
items.push({
label: t("contextMenu.viewDdl"),
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1423,6 +1423,7 @@ export default {
noSqlHistory: "No related SQL",
openObjectBrowser: "Browse Objects",
viewData: "View Data",
openInNewDataTab: "View in New Tab",
editStructure: "Edit Structure",
editColumn: "Edit Column",
editIndex: "Edit Index",
Expand Down Expand Up @@ -3520,6 +3521,7 @@ export default {
shortcutCopySidebarSelection: "Copy sidebar selection",
shortcutPasteSidebarSelection: "Paste into sidebar",
shortcutEditSidebarConnection: "Edit sidebar connection",
shortcutOpenDataInNewTab: "Open data in new tab (mouse click)",
shortcutSendSelectionToAi: "Send selection to AI",
shortcutScopeGlobal: "Global",
shortcutScopeEditor: "SQL editor",
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/i18n/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1365,6 +1365,7 @@ export default withEnglishFallback({
noSqlHistory: "Sin SQL relacionado",
openObjectBrowser: "Explorar objetos",
viewData: "Ver datos",
openInNewDataTab: "Ver en nueva pestaña",
editStructure: "Editar estructura",
editColumn: "Editar columna",
editIndex: "Editar índice",
Expand Down Expand Up @@ -3302,6 +3303,7 @@ export default withEnglishFallback({
shortcutCopySidebarSelection: "Copiar selección de la barra lateral",
shortcutPasteSidebarSelection: "Pegar en la barra lateral",
shortcutEditSidebarConnection: "Editar conexión de la barra lateral",
shortcutOpenDataInNewTab: "Abrir datos en una pestaña nueva (clic del ratón)",
shortcutSendSelectionToAi: "Enviar selección a IA",
shortcutScopeGlobal: "Global",
shortcutScopeEditor: "Editor SQL",
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/i18n/locales/it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1363,6 +1363,7 @@ export default withEnglishFallback({
noSqlHistory: "Nessun SQL correlato",
openObjectBrowser: "Esplora Oggetti",
viewData: "Visualizza Dati",
openInNewDataTab: "Visualizza in nuova scheda",
editStructure: "Modifica Struttura",
editColumn: "Modifica Colonna",
editIndex: "Modifica Indice",
Expand Down Expand Up @@ -3300,6 +3301,7 @@ export default withEnglishFallback({
shortcutCopySidebarSelection: "Copia selezione barra laterale",
shortcutPasteSidebarSelection: "Incolla nella barra laterale",
shortcutEditSidebarConnection: "Modifica connessione barra laterale",
shortcutOpenDataInNewTab: "Apri dati in una nuova scheda (clic del mouse)",
shortcutSendSelectionToAi: "Invia selezione ad AI",
shortcutScopeGlobal: "Globale",
shortcutScopeEditor: "Editor SQL",
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/i18n/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1362,6 +1362,7 @@ export default withEnglishFallback({
noSqlHistory: "関連するSQLはありません",
openObjectBrowser: "オブジェクトを参照",
viewData: "データを表示",
openInNewDataTab: "新しいタブで表示",
editStructure: "構造を編集",
editColumn: "列を編集",
editIndex: "インデックスを編集",
Expand Down Expand Up @@ -3283,6 +3284,7 @@ export default withEnglishFallback({
shortcutCopySidebarSelection: "サイドバーの選択をコピー",
shortcutPasteSidebarSelection: "サイドバーに貼り付け",
shortcutEditSidebarConnection: "サイドバー接続を編集",
shortcutOpenDataInNewTab: "新しいデータタブで開く(マウスクリック)",
shortcutSendSelectionToAi: "選択範囲をAIに送信",
shortcutScopeGlobal: "グローバル",
shortcutScopeEditor: "SQLエディタ",
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/i18n/locales/pt-BR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1365,6 +1365,7 @@ export default withEnglishFallback({
noSqlHistory: "Nenhum SQL relacionado",
openObjectBrowser: "Explorar Objetos",
viewData: "Ver Dados",
openInNewDataTab: "Ver em nova aba",
editStructure: "Editar Estrutura",
editColumn: "Editar Coluna",
editIndex: "Editar Índice",
Expand Down Expand Up @@ -3302,6 +3303,7 @@ export default withEnglishFallback({
shortcutCopySidebarSelection: "Copiar seleção da barra lateral",
shortcutPasteSidebarSelection: "Colar na barra lateral",
shortcutEditSidebarConnection: "Editar conexão da barra lateral",
shortcutOpenDataInNewTab: "Abrir dados em nova aba (clique do mouse)",
shortcutSendSelectionToAi: "Enviar seleção para IA",
shortcutScopeGlobal: "Global",
shortcutScopeEditor: "Editor SQL",
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/i18n/locales/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1425,6 +1425,7 @@ export default withEnglishFallback({
noSqlHistory: "暂无关联 SQL",
openObjectBrowser: "浏览对象",
viewData: "查看数据",
openInNewDataTab: "新标签页查看",
editStructure: "编辑表结构",
editColumn: "编辑字段",
editIndex: "编辑索引",
Expand Down Expand Up @@ -3515,6 +3516,7 @@ export default withEnglishFallback({
shortcutCopySidebarSelection: "复制侧边栏选中项",
shortcutPasteSidebarSelection: "粘贴到侧边栏",
shortcutEditSidebarConnection: "编辑侧边栏连接",
shortcutOpenDataInNewTab: "在新数据标签页中打开(鼠标点击)",
shortcutSendSelectionToAi: "发送选中代码到 AI",
shortcutScopeGlobal: "全局",
shortcutScopeEditor: "SQL 编辑器",
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/i18n/locales/zh-TW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1365,6 +1365,7 @@ export default withEnglishFallback({
noSqlHistory: "暫無關聯 SQL",
openObjectBrowser: "瀏覽物件",
viewData: "檢視資料",
openInNewDataTab: "新分頁檢視",
editStructure: "編輯資料表結構",
editColumn: "編輯欄位",
editIndex: "編輯索引",
Expand Down Expand Up @@ -3142,6 +3143,7 @@ export default withEnglishFallback({
shortcutCopySidebarSelection: "複製側邊欄選取項",
shortcutPasteSidebarSelection: "貼到側邊欄",
shortcutEditSidebarConnection: "編輯側邊欄連線",
shortcutOpenDataInNewTab: "在新資料分頁中開啟(滑鼠點擊)",
shortcutSendSelectionToAi: "傳送選取程式碼至 AI",
shortcutScopeGlobal: "全域",
shortcutScopeEditor: "SQL 編輯器",
Expand Down
22 changes: 21 additions & 1 deletion apps/desktop/src/lib/__tests__/editor/keyboardShortcuts.spec.ts
Original file line number Diff line number Diff line change
@@ -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");
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/lib/__tests__/editor/shortcutDisplay.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
});
Expand Down
17 changes: 16 additions & 1 deletion apps/desktop/src/lib/__tests__/editor/shortcutRegistry.spec.ts
Original file line number Diff line number Diff line change
@@ -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[] = [
Expand All @@ -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);
Expand Down
Loading
Loading