feat(shortcuts): open data in a new tab
This commit is contained in:
16 files changed
+265
-23
No files matched your search
@@ -80,7 +80,7 @@ import {
|
|||||||
type SnippetSyncConfig,
|
type SnippetSyncConfig,
|
||||||
type WebDavConfig,
|
type WebDavConfig,
|
||||||
} from "@/lib/backend/api";
|
} 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 { SHORTCUT_DEFINITIONS, findShortcutConflict, normalizeShortcutSettings, type ShortcutActionId } from "@/lib/editor/shortcutRegistry";
|
||||||
import { formatShortcutDisplay } from "@/lib/editor/shortcutDisplay";
|
import { formatShortcutDisplay } from "@/lib/editor/shortcutDisplay";
|
||||||
import { normalizeSidebarHiddenTablePrefixes } from "@/lib/sidebar/sidebarTableNameDisplay";
|
import { normalizeSidebarHiddenTablePrefixes } from "@/lib/sidebar/sidebarTableNameDisplay";
|
||||||
@@ -1202,7 +1202,8 @@ function onShortcutKeydown(actionId: ShortcutActionId, event: KeyboardEvent) {
|
|||||||
editingShortcutId.value = null;
|
editingShortcutId.value = null;
|
||||||
return;
|
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;
|
if (!shortcut) return;
|
||||||
onShortcutChange(actionId, shortcut);
|
onShortcutChange(actionId, shortcut);
|
||||||
editingShortcutId.value = null;
|
editingShortcutId.value = null;
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ import {
|
|||||||
usesTreeSchemaMode,
|
usesTreeSchemaMode,
|
||||||
} from "@/lib/database/databaseCapabilities";
|
} from "@/lib/database/databaseCapabilities";
|
||||||
import { copyNameForTreeNode, isDocumentBrowserTreeNode, objectSourceKindForTreeNode, shouldRunTreeNodeRowAction, treeNodeRowAction, treeNodeRowDoubleClickAction } from "@/lib/sidebar/treeNodeClick";
|
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 { isCopySidebarSelectionShortcut, isEditSidebarConnectionShortcut, isPasteSidebarSelectionShortcut } from "@/lib/editor/keyboardShortcuts";
|
||||||
import { formatSqlInsert } from "@/lib/export/exportFormats";
|
import { formatSqlInsert } from "@/lib/export/exportFormats";
|
||||||
import { joinExportedDdls } from "@/lib/export/ddlExport";
|
import { joinExportedDdls } from "@/lib/export/ddlExport";
|
||||||
@@ -987,6 +988,15 @@ function onClick(event: MouseEvent) {
|
|||||||
// Row clicks must not bubble to the tree container, whose click handler
|
// Row clicks must not bubble to the tree container, whose click handler
|
||||||
// clears the selection when the blank area is clicked (issue #681).
|
// clears the selection when the blank area is clicked (issue #681).
|
||||||
event.stopPropagation();
|
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) {
|
if (event.shiftKey) {
|
||||||
selectTreeNodeRange(props.node);
|
selectTreeNodeRange(props.node);
|
||||||
rowRef.value?.focus({ preventScroll: true });
|
rowRef.value?.focus({ preventScroll: true });
|
||||||
@@ -1190,7 +1200,8 @@ function requestDeleteSelectedNode(): boolean {
|
|||||||
return false;
|
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);
|
const action = treeNodeRowDoubleClickAction(props.node.type, canOpenObjectBrowser.value, settingsStore.editorSettings.sidebarActivation, canExpand.value);
|
||||||
if (action === "open-object-browser") {
|
if (action === "open-object-browser") {
|
||||||
void openObjectBrowser();
|
void openObjectBrowser();
|
||||||
@@ -1349,7 +1360,11 @@ function openDataImmediately(node: TreeNode = props.node) {
|
|||||||
emit("open-data", node, false, openData);
|
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;
|
if (!(node.type === "table" || node.type === "view" || node.type === "materialized_view") || !hasNodeDatabaseContext(node)) return;
|
||||||
const config = connectionStore.getConfig(node.connectionId);
|
const config = connectionStore.getConfig(node.connectionId);
|
||||||
const traceId = uuid().slice(0, 8);
|
const traceId = uuid().slice(0, 8);
|
||||||
@@ -1381,9 +1396,18 @@ async function openData(node: TreeNode, request?: SidebarDataOpenRequest) {
|
|||||||
const querySchema = config ? connectionObjectTreeQuerySchema(config, node.database, tableSchema) : (tableSchema ?? "");
|
const querySchema = config ? connectionObjectTreeQuerySchema(config, node.database, tableSchema) : (tableSchema ?? "");
|
||||||
const effectiveDbType = effectiveDatabaseTypeForConnection(config);
|
const effectiveDbType = effectiveDatabaseTypeForConnection(config);
|
||||||
const metadataDatabaseType = effectiveDbType || config?.db_type || "";
|
const metadataDatabaseType = effectiveDbType || config?.db_type || "";
|
||||||
const isSameDataTableTab = (tab: (typeof queryStore.tabs)[number]) =>
|
const existingDataTabCandidate = findExistingDataTabCandidate(
|
||||||
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;
|
queryStore.tabs,
|
||||||
const existingSameTableTab = queryStore.tabs.find(isSameDataTableTab);
|
{
|
||||||
|
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]) => {
|
const resetReusedDataTabState = (tab: (typeof queryStore.tabs)[number]) => {
|
||||||
tab.title = node.label;
|
tab.title = node.label;
|
||||||
tab.schema = tableSchema;
|
tab.schema = tableSchema;
|
||||||
@@ -1414,18 +1438,10 @@ async function openData(node: TreeNode, request?: SidebarDataOpenRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tabId = (() => {
|
const tabId = (() => {
|
||||||
if (existingSameTableTab) {
|
if (existingDataTabCandidate) {
|
||||||
queryStore.switchTab(existingSameTableTab.id);
|
queryStore.switchTab(existingDataTabCandidate.tab.id);
|
||||||
resetReusedDataTabState(existingSameTableTab);
|
resetReusedDataTabState(existingDataTabCandidate.tab);
|
||||||
return existingSameTableTab.id;
|
return existingDataTabCandidate.tab.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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return queryStore.createTab(node.connectionId, node.database, node.label, "data", tableSchema);
|
return queryStore.createTab(node.connectionId, node.database, node.label, "data", tableSchema);
|
||||||
})();
|
})();
|
||||||
@@ -4977,6 +4993,7 @@ onBeforeUnmount(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const shortcutCopyName = computed(() => settingsStore.editorSettings.shortcuts.copySidebarSelection);
|
const shortcutCopyName = computed(() => settingsStore.editorSettings.shortcuts.copySidebarSelection);
|
||||||
|
const shortcutOpenDataInNewTab = computed(() => settingsStore.editorSettings.shortcuts.openDataInNewTab);
|
||||||
const shortcutEditConnection = computed(() => settingsStore.editorSettings.shortcuts.editSidebarConnection);
|
const shortcutEditConnection = computed(() => settingsStore.editorSettings.shortcuts.editSidebarConnection);
|
||||||
const shortcutRename = "F2";
|
const shortcutRename = "F2";
|
||||||
const shortcutRefresh = "F5";
|
const shortcutRefresh = "F5";
|
||||||
@@ -5423,6 +5440,12 @@ function treeItemMenuItems(): ContextMenuItem[] {
|
|||||||
items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value });
|
items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value });
|
||||||
items.push({ label: "", separator: true });
|
items.push({ label: "", separator: true });
|
||||||
items.push({ label: t("contextMenu.viewData"), action: openDataImmediately, icon: TableProperties });
|
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") {
|
if (node.type === "table") {
|
||||||
items.push({
|
items.push({
|
||||||
label: t("contextMenu.viewDdl"),
|
label: t("contextMenu.viewDdl"),
|
||||||
|
|||||||
@@ -1453,6 +1453,7 @@ export default {
|
|||||||
noSqlHistory: "No related SQL",
|
noSqlHistory: "No related SQL",
|
||||||
openObjectBrowser: "Browse Objects",
|
openObjectBrowser: "Browse Objects",
|
||||||
viewData: "View Data",
|
viewData: "View Data",
|
||||||
|
openInNewDataTab: "View in New Tab",
|
||||||
editStructure: "Edit Structure",
|
editStructure: "Edit Structure",
|
||||||
editColumn: "Edit Column",
|
editColumn: "Edit Column",
|
||||||
editIndex: "Edit Index",
|
editIndex: "Edit Index",
|
||||||
@@ -3550,6 +3551,7 @@ export default {
|
|||||||
shortcutCopySidebarSelection: "Copy sidebar selection",
|
shortcutCopySidebarSelection: "Copy sidebar selection",
|
||||||
shortcutPasteSidebarSelection: "Paste into sidebar",
|
shortcutPasteSidebarSelection: "Paste into sidebar",
|
||||||
shortcutEditSidebarConnection: "Edit sidebar connection",
|
shortcutEditSidebarConnection: "Edit sidebar connection",
|
||||||
|
shortcutOpenDataInNewTab: "Open data in new tab (mouse click)",
|
||||||
shortcutSendSelectionToAi: "Send selection to AI",
|
shortcutSendSelectionToAi: "Send selection to AI",
|
||||||
shortcutScopeGlobal: "Global",
|
shortcutScopeGlobal: "Global",
|
||||||
shortcutScopeEditor: "SQL editor",
|
shortcutScopeEditor: "SQL editor",
|
||||||
|
|||||||
@@ -1395,6 +1395,7 @@ export default withEnglishFallback({
|
|||||||
noSqlHistory: "Sin SQL relacionado",
|
noSqlHistory: "Sin SQL relacionado",
|
||||||
openObjectBrowser: "Explorar objetos",
|
openObjectBrowser: "Explorar objetos",
|
||||||
viewData: "Ver datos",
|
viewData: "Ver datos",
|
||||||
|
openInNewDataTab: "Ver en nueva pestaña",
|
||||||
editStructure: "Editar estructura",
|
editStructure: "Editar estructura",
|
||||||
editColumn: "Editar columna",
|
editColumn: "Editar columna",
|
||||||
editIndex: "Editar índice",
|
editIndex: "Editar índice",
|
||||||
@@ -3332,6 +3333,7 @@ export default withEnglishFallback({
|
|||||||
shortcutCopySidebarSelection: "Copiar selección de la barra lateral",
|
shortcutCopySidebarSelection: "Copiar selección de la barra lateral",
|
||||||
shortcutPasteSidebarSelection: "Pegar en la barra lateral",
|
shortcutPasteSidebarSelection: "Pegar en la barra lateral",
|
||||||
shortcutEditSidebarConnection: "Editar conexión de 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",
|
shortcutSendSelectionToAi: "Enviar selección a IA",
|
||||||
shortcutScopeGlobal: "Global",
|
shortcutScopeGlobal: "Global",
|
||||||
shortcutScopeEditor: "Editor SQL",
|
shortcutScopeEditor: "Editor SQL",
|
||||||
|
|||||||
@@ -1393,6 +1393,7 @@ export default withEnglishFallback({
|
|||||||
noSqlHistory: "Nessun SQL correlato",
|
noSqlHistory: "Nessun SQL correlato",
|
||||||
openObjectBrowser: "Esplora Oggetti",
|
openObjectBrowser: "Esplora Oggetti",
|
||||||
viewData: "Visualizza Dati",
|
viewData: "Visualizza Dati",
|
||||||
|
openInNewDataTab: "Visualizza in nuova scheda",
|
||||||
editStructure: "Modifica Struttura",
|
editStructure: "Modifica Struttura",
|
||||||
editColumn: "Modifica Colonna",
|
editColumn: "Modifica Colonna",
|
||||||
editIndex: "Modifica Indice",
|
editIndex: "Modifica Indice",
|
||||||
@@ -3330,6 +3331,7 @@ export default withEnglishFallback({
|
|||||||
shortcutCopySidebarSelection: "Copia selezione barra laterale",
|
shortcutCopySidebarSelection: "Copia selezione barra laterale",
|
||||||
shortcutPasteSidebarSelection: "Incolla nella barra laterale",
|
shortcutPasteSidebarSelection: "Incolla nella barra laterale",
|
||||||
shortcutEditSidebarConnection: "Modifica connessione barra laterale",
|
shortcutEditSidebarConnection: "Modifica connessione barra laterale",
|
||||||
|
shortcutOpenDataInNewTab: "Apri dati in una nuova scheda (clic del mouse)",
|
||||||
shortcutSendSelectionToAi: "Invia selezione ad AI",
|
shortcutSendSelectionToAi: "Invia selezione ad AI",
|
||||||
shortcutScopeGlobal: "Globale",
|
shortcutScopeGlobal: "Globale",
|
||||||
shortcutScopeEditor: "Editor SQL",
|
shortcutScopeEditor: "Editor SQL",
|
||||||
|
|||||||
@@ -1392,6 +1392,7 @@ export default withEnglishFallback({
|
|||||||
noSqlHistory: "関連するSQLはありません",
|
noSqlHistory: "関連するSQLはありません",
|
||||||
openObjectBrowser: "オブジェクトを参照",
|
openObjectBrowser: "オブジェクトを参照",
|
||||||
viewData: "データを表示",
|
viewData: "データを表示",
|
||||||
|
openInNewDataTab: "新しいタブで表示",
|
||||||
editStructure: "構造を編集",
|
editStructure: "構造を編集",
|
||||||
editColumn: "列を編集",
|
editColumn: "列を編集",
|
||||||
editIndex: "インデックスを編集",
|
editIndex: "インデックスを編集",
|
||||||
@@ -3313,6 +3314,7 @@ export default withEnglishFallback({
|
|||||||
shortcutCopySidebarSelection: "サイドバーの選択をコピー",
|
shortcutCopySidebarSelection: "サイドバーの選択をコピー",
|
||||||
shortcutPasteSidebarSelection: "サイドバーに貼り付け",
|
shortcutPasteSidebarSelection: "サイドバーに貼り付け",
|
||||||
shortcutEditSidebarConnection: "サイドバー接続を編集",
|
shortcutEditSidebarConnection: "サイドバー接続を編集",
|
||||||
|
shortcutOpenDataInNewTab: "新しいデータタブで開く(マウスクリック)",
|
||||||
shortcutSendSelectionToAi: "選択範囲をAIに送信",
|
shortcutSendSelectionToAi: "選択範囲をAIに送信",
|
||||||
shortcutScopeGlobal: "グローバル",
|
shortcutScopeGlobal: "グローバル",
|
||||||
shortcutScopeEditor: "SQLエディタ",
|
shortcutScopeEditor: "SQLエディタ",
|
||||||
|
|||||||
@@ -1395,6 +1395,7 @@ export default withEnglishFallback({
|
|||||||
noSqlHistory: "Nenhum SQL relacionado",
|
noSqlHistory: "Nenhum SQL relacionado",
|
||||||
openObjectBrowser: "Explorar Objetos",
|
openObjectBrowser: "Explorar Objetos",
|
||||||
viewData: "Ver Dados",
|
viewData: "Ver Dados",
|
||||||
|
openInNewDataTab: "Ver em nova aba",
|
||||||
editStructure: "Editar Estrutura",
|
editStructure: "Editar Estrutura",
|
||||||
editColumn: "Editar Coluna",
|
editColumn: "Editar Coluna",
|
||||||
editIndex: "Editar Índice",
|
editIndex: "Editar Índice",
|
||||||
@@ -3332,6 +3333,7 @@ export default withEnglishFallback({
|
|||||||
shortcutCopySidebarSelection: "Copiar seleção da barra lateral",
|
shortcutCopySidebarSelection: "Copiar seleção da barra lateral",
|
||||||
shortcutPasteSidebarSelection: "Colar na barra lateral",
|
shortcutPasteSidebarSelection: "Colar na barra lateral",
|
||||||
shortcutEditSidebarConnection: "Editar conexão da barra lateral",
|
shortcutEditSidebarConnection: "Editar conexão da barra lateral",
|
||||||
|
shortcutOpenDataInNewTab: "Abrir dados em nova aba (clique do mouse)",
|
||||||
shortcutSendSelectionToAi: "Enviar seleção para IA",
|
shortcutSendSelectionToAi: "Enviar seleção para IA",
|
||||||
shortcutScopeGlobal: "Global",
|
shortcutScopeGlobal: "Global",
|
||||||
shortcutScopeEditor: "Editor SQL",
|
shortcutScopeEditor: "Editor SQL",
|
||||||
|
|||||||
@@ -1455,6 +1455,7 @@ export default withEnglishFallback({
|
|||||||
noSqlHistory: "暂无关联 SQL",
|
noSqlHistory: "暂无关联 SQL",
|
||||||
openObjectBrowser: "浏览对象",
|
openObjectBrowser: "浏览对象",
|
||||||
viewData: "查看数据",
|
viewData: "查看数据",
|
||||||
|
openInNewDataTab: "新标签页查看",
|
||||||
editStructure: "编辑表结构",
|
editStructure: "编辑表结构",
|
||||||
editColumn: "编辑字段",
|
editColumn: "编辑字段",
|
||||||
editIndex: "编辑索引",
|
editIndex: "编辑索引",
|
||||||
@@ -3545,6 +3546,7 @@ export default withEnglishFallback({
|
|||||||
shortcutCopySidebarSelection: "复制侧边栏选中项",
|
shortcutCopySidebarSelection: "复制侧边栏选中项",
|
||||||
shortcutPasteSidebarSelection: "粘贴到侧边栏",
|
shortcutPasteSidebarSelection: "粘贴到侧边栏",
|
||||||
shortcutEditSidebarConnection: "编辑侧边栏连接",
|
shortcutEditSidebarConnection: "编辑侧边栏连接",
|
||||||
|
shortcutOpenDataInNewTab: "在新数据标签页中打开(鼠标点击)",
|
||||||
shortcutSendSelectionToAi: "发送选中代码到 AI",
|
shortcutSendSelectionToAi: "发送选中代码到 AI",
|
||||||
shortcutScopeGlobal: "全局",
|
shortcutScopeGlobal: "全局",
|
||||||
shortcutScopeEditor: "SQL 编辑器",
|
shortcutScopeEditor: "SQL 编辑器",
|
||||||
|
|||||||
@@ -1395,6 +1395,7 @@ export default withEnglishFallback({
|
|||||||
noSqlHistory: "暫無關聯 SQL",
|
noSqlHistory: "暫無關聯 SQL",
|
||||||
openObjectBrowser: "瀏覽物件",
|
openObjectBrowser: "瀏覽物件",
|
||||||
viewData: "檢視資料",
|
viewData: "檢視資料",
|
||||||
|
openInNewDataTab: "新分頁檢視",
|
||||||
editStructure: "編輯資料表結構",
|
editStructure: "編輯資料表結構",
|
||||||
editColumn: "編輯欄位",
|
editColumn: "編輯欄位",
|
||||||
editIndex: "編輯索引",
|
editIndex: "編輯索引",
|
||||||
@@ -3172,6 +3173,7 @@ export default withEnglishFallback({
|
|||||||
shortcutCopySidebarSelection: "複製側邊欄選取項",
|
shortcutCopySidebarSelection: "複製側邊欄選取項",
|
||||||
shortcutPasteSidebarSelection: "貼到側邊欄",
|
shortcutPasteSidebarSelection: "貼到側邊欄",
|
||||||
shortcutEditSidebarConnection: "編輯側邊欄連線",
|
shortcutEditSidebarConnection: "編輯側邊欄連線",
|
||||||
|
shortcutOpenDataInNewTab: "在新資料分頁中開啟(滑鼠點擊)",
|
||||||
shortcutSendSelectionToAi: "傳送選取程式碼至 AI",
|
shortcutSendSelectionToAi: "傳送選取程式碼至 AI",
|
||||||
shortcutScopeGlobal: "全域",
|
shortcutScopeGlobal: "全域",
|
||||||
shortcutScopeEditor: "SQL 編輯器",
|
shortcutScopeEditor: "SQL 編輯器",
|
||||||
|
|||||||
@@ -1,7 +1,27 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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", () => {
|
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", () => {
|
it("records the plus key without losing it to the separator", () => {
|
||||||
expect(eventToShortcut({ key: "+", ctrlKey: true })).toBe("Mod+Plus");
|
expect(eventToShortcut({ key: "+", ctrlKey: true })).toBe("Mod+Plus");
|
||||||
expect(eventToShortcut({ key: "+", ctrlKey: true, shiftKey: true })).toBe("Shift+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";
|
import { formatShortcutDisplay, shortcutDisplayKeys } from "@/lib/editor/shortcutDisplay";
|
||||||
|
|
||||||
describe("shortcut display", () => {
|
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", () => {
|
it("uses readable modifier labels on Windows", () => {
|
||||||
expect(shortcutDisplayKeys("Shift+Alt+U", "Win32")).toEqual(["Shift", "Alt", "U"]);
|
expect(shortcutDisplayKeys("Shift+Alt+U", "Win32")).toEqual(["Shift", "Alt", "U"]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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", () => {
|
describe("shortcutRegistry editor actions", () => {
|
||||||
const formatterEditorActionIds: ShortcutActionId[] = [
|
const formatterEditorActionIds: ShortcutActionId[] = [
|
||||||
@@ -22,6 +22,21 @@ describe("shortcutRegistry editor actions", () => {
|
|||||||
];
|
];
|
||||||
const sidebarShortcutActionIds: ShortcutActionId[] = ["copySidebarSelection", "pasteSidebarSelection", "editSidebarConnection"];
|
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", () => {
|
it("registers formatter editor shortcuts in the generic editor scope", () => {
|
||||||
for (const actionId of formatterEditorActionIds) {
|
for (const actionId of formatterEditorActionIds) {
|
||||||
const definition = SHORTCUT_DEFINITIONS.find((item) => item.id === actionId);
|
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";
|
import { normalizeShortcutSettings, type ShortcutActionId, type ShortcutSettings } from "@/lib/editor/shortcutRegistry";
|
||||||
|
|
||||||
export interface ShortcutLikeEvent {
|
export interface ShortcutLikeEvent {
|
||||||
@@ -41,6 +41,29 @@ export function eventToShortcut(event: ShortcutLikeEvent): string | null {
|
|||||||
return parts.join("+");
|
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 {
|
export function matchesShortcut(event: ShortcutLikeEvent, shortcut: string): boolean {
|
||||||
if (event.isComposing || !shortcut) return false;
|
if (event.isComposing || !shortcut) return false;
|
||||||
const parts = parseShortcutParts(shortcut);
|
const parts = parseShortcutParts(shortcut);
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export type ShortcutActionId =
|
|||||||
| "copySidebarSelection"
|
| "copySidebarSelection"
|
||||||
| "pasteSidebarSelection"
|
| "pasteSidebarSelection"
|
||||||
| "editSidebarConnection"
|
| "editSidebarConnection"
|
||||||
|
| "openDataInNewTab"
|
||||||
| "sendSelectionToAi";
|
| "sendSelectionToAi";
|
||||||
|
|
||||||
export type ShortcutScope = "global" | "editor" | "grid" | "search" | "sidebar";
|
export type ShortcutScope = "global" | "editor" | "grid" | "search" | "sidebar";
|
||||||
@@ -59,6 +60,7 @@ export interface ShortcutDefinition {
|
|||||||
labelKey: string;
|
labelKey: string;
|
||||||
scope: ShortcutScope;
|
scope: ShortcutScope;
|
||||||
defaultShortcut: string;
|
defaultShortcut: string;
|
||||||
|
inputKind?: "keyboard" | "modifier-only";
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ShortcutSettings = Record<ShortcutActionId, string>;
|
export type ShortcutSettings = Record<ShortcutActionId, string>;
|
||||||
@@ -358,6 +360,13 @@ export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [
|
|||||||
scope: "sidebar",
|
scope: "sidebar",
|
||||||
defaultShortcut: "Mod+E",
|
defaultShortcut: "Mod+E",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "openDataInNewTab",
|
||||||
|
labelKey: "settings.shortcutOpenDataInNewTab",
|
||||||
|
scope: "sidebar",
|
||||||
|
defaultShortcut: "Alt",
|
||||||
|
inputKind: "modifier-only",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "sendSelectionToAi",
|
id: "sendSelectionToAi",
|
||||||
labelKey: "settings.shortcutSendSelectionToAi",
|
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;
|
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 {
|
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 {
|
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;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user