From 90d2ede740351005e99d767fc6074fb3658cc687 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E4=B8=AB=E8=AE=B2=E6=A2=B5?= Date: Sat, 4 Jul 2026 12:19:56 +0800 Subject: [PATCH] feat(sidebar): add connection copy paste shortcuts --- .../src/components/objects/ObjectBrowser.vue | 2 +- .../src/components/sidebar/ConnectionTree.vue | 56 ++++++- .../src/components/sidebar/TreeItem.vue | 57 ++++++- apps/desktop/src/i18n/locales/en.ts | 4 + apps/desktop/src/i18n/locales/es.ts | 4 + apps/desktop/src/i18n/locales/it.ts | 4 + apps/desktop/src/i18n/locales/ja.ts | 4 + apps/desktop/src/i18n/locales/pt-BR.ts | 4 + apps/desktop/src/i18n/locales/zh-CN.ts | 4 + apps/desktop/src/i18n/locales/zh-TW.ts | 4 + .../lib/__tests__/shortcutRegistry.spec.ts | 17 ++ apps/desktop/src/lib/keyboardShortcuts.ts | 12 ++ apps/desktop/src/lib/shortcutRegistry.ts | 25 ++- .../src/lib/sidebarConnectionSelection.ts | 25 ++- apps/desktop/src/stores/connectionStore.ts | 55 +++++- .../app-tests/connectionClipboard.test.ts | 157 ++++++++++++++++++ packages/app-tests/keyboardShortcuts.test.ts | 21 +++ packages/app-tests/settingsStore.test.ts | 5 + .../sidebarConnectionSelection.test.ts | 69 ++++++++ 19 files changed, 509 insertions(+), 20 deletions(-) create mode 100644 packages/app-tests/connectionClipboard.test.ts create mode 100644 packages/app-tests/sidebarConnectionSelection.test.ts diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index da2d1bf7ae..dfe5a9cefe 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -1019,7 +1019,7 @@ function copySingleTableToClipboard(row: ObjectBrowserRow) { function openPasteTableDialog() { const clipboard = connectionStore.treeClipboard; - if (!canPasteTableClipboard() || !clipboard) { + if (!canPasteTableClipboard() || clipboard?.kind !== "table-copy") { toast(t("contextMenu.noTableToPaste"), 2000); return; } diff --git a/apps/desktop/src/components/sidebar/ConnectionTree.vue b/apps/desktop/src/components/sidebar/ConnectionTree.vue index 59c7f7ac6d..2dada96b5a 100644 --- a/apps/desktop/src/components/sidebar/ConnectionTree.vue +++ b/apps/desktop/src/components/sidebar/ConnectionTree.vue @@ -8,9 +8,10 @@ import { useSettingsStore } from "@/stores/settingsStore"; import { useToast } from "@/composables/useToast"; import type { TreeNode, TreeNodeType } from "@/types/database"; import { filterSidebarSearchRootsByConnectionState, filterSidebarTree } from "@/lib/sidebarSearchTree"; -import { isCancelSearchShortcut } from "@/lib/keyboardShortcuts"; +import { isCancelSearchShortcut, isCopySidebarSelectionShortcut, isEditSidebarConnectionShortcut, isPasteSidebarSelectionShortcut } from "@/lib/keyboardShortcuts"; import { copyNameForTreeNode } from "@/lib/treeNodeClick"; -import { copyToClipboard, eventTargetAllowsAppClipboardShortcut } from "@/lib/clipboard"; +import { copyToClipboard } from "@/lib/clipboard"; +import { connectionPasteTargetGroupId, selectedConnectionClipboardNodes, selectedConnectionEditTarget } from "@/lib/sidebarConnectionSelection"; import { isEditableSidebarTypeSearchTarget, sidebarTypeSearchNextQuery } from "@/lib/sidebarTypeSearch"; import { usesTreeSchemaMode } from "@/lib/databaseFeatureSupport"; import { connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection } from "@/lib/jdbcDialect"; @@ -781,15 +782,22 @@ function focusSearchAtEnd() { function onWindowKeydown(event: KeyboardEvent) { if (event.defaultPrevented) return; if (sidebarShortcutTargetIsActive(event.target)) { - if (eventTargetAllowsAppClipboardShortcut(event, "c")) { + if (sidebarShortcutTargetAllowsAppShortcut(event.target) && isEditConnectionShortcut(event)) { + if (requestSelectedConnectionEdit()) { + event.preventDefault(); + event.stopPropagation(); + } + return; + } + if (sidebarShortcutTargetAllowsAppShortcut(event.target) && isCopySidebarSelectionShortcut(event, settingsStore.editorSettings.shortcuts)) { if (copySelectedSidebarNames()) { event.preventDefault(); event.stopPropagation(); } return; } - if (eventTargetAllowsAppClipboardShortcut(event, "v")) { - if (requestSelectedSidebarPasteTable()) { + if (sidebarShortcutTargetAllowsAppShortcut(event.target) && isPasteSidebarSelectionShortcut(event, settingsStore.editorSettings.shortcuts)) { + if (requestSelectedSidebarPaste()) { event.preventDefault(); event.stopPropagation(); } @@ -820,14 +828,39 @@ function sidebarShortcutTargetIsActive(target: EventTarget | null): boolean { return pointerInsideTree.value && (!active || active === document.body || root.contains(active)); } +function sidebarShortcutTargetAllowsAppShortcut(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return true; + return !(target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target.isContentEditable || !!target.closest("[contenteditable='true'], [role='textbox']")); +} + function selectedSidebarNodesInVisibleOrder(): TreeNode[] { const selectedIds = new Set(store.selectedTreeNodeIds); return visibleNodes.value.filter((node) => selectedIds.has(node.id)); } +function isEditConnectionShortcut(event: KeyboardEvent): boolean { + return isEditSidebarConnectionShortcut(event, settingsStore.editorSettings.shortcuts); +} + +function requestSelectedConnectionEdit(): boolean { + const selectedNodeId = store.selectedTreeNodeId; + const currentNode = selectedNodeId ? visibleNodes.value.find((node) => node.id === selectedNodeId) : null; + if (!currentNode) return false; + const editTarget = selectedConnectionEditTarget(currentNode, selectedSidebarNodesInVisibleOrder()); + if (!editTarget) return false; + store.startEditing(editTarget.connectionId); + return true; +} + function copySelectedSidebarNames(): boolean { const nodes = selectedSidebarNodesInVisibleOrder(); if (nodes.length === 0) return false; + const connectionNodes = selectedConnectionClipboardNodes(nodes); + if (connectionNodes.length > 0) { + const copiedCount = store.copyConnectionsToTreeClipboard(connectionNodes.map((node) => node.connectionId)); + if (copiedCount > 0) toast(t("connection.copied"), 2000); + return copiedCount > 0; + } const tableNodes = nodes.filter((node) => node.type === "table" && !!node.connectionId && !!node.database); store.treeClipboard = tableNodes.length > 0 @@ -847,9 +880,20 @@ function copySelectedSidebarNames(): boolean { return true; } -function requestSelectedSidebarPasteTable(): boolean { +function requestSelectedSidebarPaste(): boolean { const clipboard = store.treeClipboard; const selectedNodeId = store.selectedTreeNodeId; + if (clipboard?.kind === "connection-copy") { + const selectedNode = selectedNodeId ? visibleNodes.value.find((node) => node.id === selectedNodeId) : null; + const targetGroupId = connectionPasteTargetGroupId(selectedNode, (connectionId) => store.groupIdForConnection(connectionId)); + void store + .pasteConnectionClipboard(targetGroupId) + .then((count) => { + if (count > 0) toast(count > 1 ? t("connection.duplicatedSelected", { count }) : t("connection.duplicated"), 2000); + }) + .catch((e: any) => toast(t("connection.saveFailed", { message: e?.message || String(e) }), 5000)); + return true; + } if (clipboard?.kind !== "table-copy" || clipboard.tables.length === 0 || !selectedNodeId) return false; window.dispatchEvent(new CustomEvent("dbx:sidebar-request-paste-table", { detail: { nodeId: selectedNodeId } })); return true; diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index b87885f404..856ecf696d 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -79,7 +79,8 @@ import { clearActiveTableReferencePayload, createTableReferencePayload, createTa import { editableRowIdentifierColumns, usesSyntheticRowIdKey } from "@/lib/tableEditing"; import { tableOpenPageLimit } from "@/lib/tableOpenPageLimit"; import { supportsDatabaseCreation, supportsDatabaseSearch, supportsFieldLineage, supportsObjectBrowserTreeNode, supportsSchemaDiagram, supportsSqlFileExecution, supportsTableImport, supportsTableTruncate, supportsTableStructureEditing, usesTreeSchemaMode } from "@/lib/databaseCapabilities"; -import { copyNameForTreeNode, isDocumentBrowserTreeNode, objectSourceKindForTreeNode, shouldRunTreeNodeRowAction, sidebarSelectionCopyAction, treeNodeRowAction, treeNodeRowDoubleClickAction } from "@/lib/treeNodeClick"; +import { copyNameForTreeNode, isDocumentBrowserTreeNode, objectSourceKindForTreeNode, shouldRunTreeNodeRowAction, treeNodeRowAction, treeNodeRowDoubleClickAction } from "@/lib/treeNodeClick"; +import { isCopySidebarSelectionShortcut, isEditSidebarConnectionShortcut, isPasteSidebarSelectionShortcut } from "@/lib/keyboardShortcuts"; import { formatSqlInsert } from "@/lib/exportFormats"; import { joinExportedDdls } from "@/lib/ddlExport"; import { fetchTableDataForExport } from "@/lib/tableDataExport"; @@ -118,7 +119,7 @@ import { defaultPasteTableMode, pasteTableModeCopiesData, supportsWholeRowTableD import { sidebarDisplayTableName } from "@/lib/sidebarTableNameDisplay"; import { shouldMeasureSidebarLabelOverflow } from "@/lib/sidebarLabelTooltip"; import { selectedTreeNodesInVisibleOrder as orderSelectedTreeNodes, treeSelectionRangeIdsByIndex, treeSelectionRangeIds } from "@/lib/sidebarTreeSelection"; -import { selectedConnectionDeleteTargets, selectedConnectionDuplicateTargets } from "@/lib/sidebarConnectionSelection"; +import { connectionPasteTargetGroupId, selectedConnectionClipboardTargets, selectedConnectionDeleteTargets, selectedConnectionDuplicateTargets, selectedConnectionEditTarget } from "@/lib/sidebarConnectionSelection"; import { supportsDatabaseUserAdmin } from "@/lib/databaseUserAdmin"; import { canCloseSidebarDatabaseConnection, isSidebarDatabaseOpened } from "@/lib/sidebarDatabaseOpenState"; import { sidebarTreeContextKey } from "@/lib/sidebarTreeContext"; @@ -842,6 +843,12 @@ function onKeydown(event: KeyboardEvent) { event.stopPropagation(); return; } + if (isEditConnectionShortcut(event)) { + if (!requestEditSelectedConnection()) return; + event.preventDefault(); + event.stopPropagation(); + return; + } if (!event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey && event.key === "F2") { if (!requestRenameSelectedNode()) return; event.preventDefault(); @@ -860,8 +867,7 @@ function onKeydown(event: KeyboardEvent) { event.stopPropagation(); return; } - const action = sidebarSelectionCopyAction(event); - if (action !== "copy-name") return; + if (!isCopyTreeSelectionShortcut(event)) return; event.preventDefault(); event.stopPropagation(); copySelectedNames(); @@ -872,7 +878,15 @@ function isDeleteTreeNodeShortcut(event: KeyboardEvent): boolean { } function isPasteTreeClipboardShortcut(event: KeyboardEvent): boolean { - return (event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "v"; + return isPasteSidebarSelectionShortcut(event, settingsStore.editorSettings.shortcuts); +} + +function isEditConnectionShortcut(event: KeyboardEvent): boolean { + return isEditSidebarConnectionShortcut(event, settingsStore.editorSettings.shortcuts); +} + +function isCopyTreeSelectionShortcut(event: KeyboardEvent): boolean { + return isCopySidebarSelectionShortcut(event, settingsStore.editorSettings.shortcuts); } function pasteTableTargetContext(): TableClipboardContext | null { @@ -891,6 +905,16 @@ function canPasteTreeClipboardToCurrentNode(): boolean { function requestPasteTreeClipboard(): boolean { const clipboard = connectionStore.treeClipboard; + if (clipboard?.kind === "connection-copy") { + const targetGroupId = connectionPasteTargetGroupId(props.node, (connectionId) => connectionStore.groupIdForConnection(connectionId)); + void connectionStore + .pasteConnectionClipboard(targetGroupId) + .then((count) => { + if (count > 0) toast(count > 1 ? t("connection.duplicatedSelected", { count }) : t("connection.duplicated"), 2000); + }) + .catch((e: any) => toast(t("connection.saveFailed", { message: e?.message || String(e) }), 5000)); + return true; + } if (clipboard?.kind !== "table-copy" || !canPasteTreeClipboardToCurrentNode()) return false; pasteTableMode.value = defaultPasteTableMode(currentDatabaseType()); pasteTableEntries.value = clipboard.tables.map((entry) => ({ @@ -927,6 +951,11 @@ function canRefreshTreeNodeShortcut(): boolean { function requestRenameSelectedNode(): boolean { const selected = selectedTreeNodesInVisibleOrder(); if (selected.length > 1 && selected.some((node) => node.id === props.node.id)) return false; + const editTarget = selectedConnectionEditTarget(props.node, selected); + if (editTarget) { + connectionStore.startEditing(editTarget.connectionId); + return true; + } if (canRenameObject.value) { openRenameObjectDialog(); return true; @@ -938,6 +967,13 @@ function requestRenameSelectedNode(): boolean { return false; } +function requestEditSelectedConnection(): boolean { + const editTarget = selectedConnectionEditTarget(props.node, selectedTreeNodesInVisibleOrder()); + if (!editTarget) return false; + connectionStore.startEditing(editTarget.connectionId); + return true; +} + function requestDeleteSelectedNode(): boolean { if (requestDropSelectedNodes()) return true; if (props.node.type === "connection") { @@ -1568,6 +1604,12 @@ async function copyFinalProxyPort() { async function copySelectedNames() { const selectedNodes = selectedTreeNodesInVisibleOrder(); const nodes = selectedNodes.length > 1 && selectedNodes.some((node) => node.id === props.node.id) ? selectedNodes : [props.node]; + const connectionTargets = selectedConnectionClipboardTargets(props.node, nodes); + if (connectionTargets.length > 0) { + const copiedCount = connectionStore.copyConnectionsToTreeClipboard(connectionTargets.map((node) => node.connectionId)); + if (copiedCount > 0) toast(t("connection.copied"), 2000); + return; + } updateTreeClipboardForNodes(nodes); try { await copyToClipboard(nodes.map(copyNameForTreeNode).join("\n")); @@ -3917,7 +3959,8 @@ onBeforeUnmount(() => { // ---- CustomContextMenu ---- -const shortcutCopyName = computed(() => formatShortcut("Mod+C")); +const shortcutCopyName = computed(() => formatShortcut(settingsStore.editorSettings.shortcuts.copySidebarSelection)); +const shortcutEditConnection = computed(() => formatShortcut(settingsStore.editorSettings.shortcuts.editSidebarConnection)); const shortcutRename = "F2"; const shortcutRefresh = "F5"; const shortcutDelete = "Delete"; @@ -4108,7 +4151,7 @@ function treeItemMenuItems(): ContextMenuItem[] { icon: ListFilter, }); } - items.push({ label: t("contextMenu.editConnection"), action: editConnection, icon: Pencil }); + items.push({ label: t("contextMenu.editConnection"), action: editConnection, icon: Pencil, shortcut: shortcutEditConnection.value }); if (revealConnectionFilePath.value) { items.push({ label: t("contextMenu.revealDatabaseFile"), diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 8691efd5a6..b16673ddf2 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -2894,10 +2894,14 @@ export default { shortcutToggleTranspose: "Toggle transpose view", shortcutCancelSearch: "Cancel search", shortcutToggleSidebar: "Toggle sidebar", + shortcutCopySidebarSelection: "Copy sidebar selection", + shortcutPasteSidebarSelection: "Paste into sidebar", + shortcutEditSidebarConnection: "Edit sidebar connection", shortcutScopeGlobal: "Global", shortcutScopeEditor: "SQL editor", shortcutScopeGrid: "Data grid", shortcutScopeSearch: "Search fields", + shortcutScopeSidebar: "Sidebar", shortcutPressShortcut: "Press shortcut", shortcutConflict: "This shortcut conflicts with another action in the same scope.", shortcutClear: "Clear shortcut", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 1fd4649249..a1df642ff7 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -2850,10 +2850,14 @@ export default withEnglishFallback({ shortcutRefreshData: "Actualizar datos", shortcutToggleTranspose: "Alternar vista transpuesta", shortcutCancelSearch: "Cancelar búsqueda", + shortcutCopySidebarSelection: "Copiar selección de la barra lateral", + shortcutPasteSidebarSelection: "Pegar en la barra lateral", + shortcutEditSidebarConnection: "Editar conexión de la barra lateral", shortcutScopeGlobal: "Global", shortcutScopeEditor: "Editor SQL", shortcutScopeGrid: "Cuadrícula de datos", shortcutScopeSearch: "Campos de búsqueda", + shortcutScopeSidebar: "Barra lateral", shortcutPressShortcut: "Presiona un atajo", shortcutConflict: "Este atajo entra en conflicto con otra acción del mismo ámbito.", shortcutClear: "Borrar atajo", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 98ccf470b9..f3960d9d3f 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -2848,10 +2848,14 @@ export default withEnglishFallback({ shortcutToggleTranspose: "Attiva/disattiva vista trasposta", shortcutCancelSearch: "Annulla ricerca", shortcutToggleSidebar: "Attiva/disattiva barra laterale", + shortcutCopySidebarSelection: "Copia selezione barra laterale", + shortcutPasteSidebarSelection: "Incolla nella barra laterale", + shortcutEditSidebarConnection: "Modifica connessione barra laterale", shortcutScopeGlobal: "Globale", shortcutScopeEditor: "Editor SQL", shortcutScopeGrid: "Griglia dati", shortcutScopeSearch: "Cerca campi", + shortcutScopeSidebar: "Barra laterale", shortcutPressShortcut: "Premi scorciatoia", shortcutConflict: "Questa scorciatoia è in conflitto con un'altra azione nello stesso ambito.", shortcutClear: "Cancella scorciatoia", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index e79f906186..c6e2cc0a29 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -2832,10 +2832,14 @@ export default withEnglishFallback({ shortcutToggleTranspose: "転置ビューを切替", shortcutCancelSearch: "検索をキャンセル", shortcutToggleSidebar: "サイドバーを切替", + shortcutCopySidebarSelection: "サイドバーの選択をコピー", + shortcutPasteSidebarSelection: "サイドバーに貼り付け", + shortcutEditSidebarConnection: "サイドバー接続を編集", shortcutScopeGlobal: "グローバル", shortcutScopeEditor: "SQLエディタ", shortcutScopeGrid: "データグリッド", shortcutScopeSearch: "フィールド検索", + shortcutScopeSidebar: "サイドバー", shortcutPressShortcut: "ショートカットを押してください", shortcutConflict: "このショートカットは同じスコープ内の別のアクションと競合しています。", shortcutClear: "ショートカットをクリア", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index cc4f7d41a4..a550206d59 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -2849,10 +2849,14 @@ export default withEnglishFallback({ shortcutRefreshData: "Atualizar dados", shortcutToggleTranspose: "Alternar visualização transposta", shortcutCancelSearch: "Cancelar pesquisa", + shortcutCopySidebarSelection: "Copiar seleção da barra lateral", + shortcutPasteSidebarSelection: "Colar na barra lateral", + shortcutEditSidebarConnection: "Editar conexão da barra lateral", shortcutScopeGlobal: "Global", shortcutScopeEditor: "Editor SQL", shortcutScopeGrid: "Grade de dados", shortcutScopeSearch: "Campos de pesquisa", + shortcutScopeSidebar: "Barra lateral", shortcutPressShortcut: "Pressione o atalho", shortcutConflict: "Este atalho conflita com outra ação no mesmo escopo.", shortcutClear: "Limpar atalho", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 1363345178..4502157374 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -2894,10 +2894,14 @@ export default withEnglishFallback({ shortcutRefreshData: "刷新数据", shortcutToggleTranspose: "切换转置视图", shortcutCancelSearch: "取消搜索", + shortcutCopySidebarSelection: "复制侧边栏选中项", + shortcutPasteSidebarSelection: "粘贴到侧边栏", + shortcutEditSidebarConnection: "编辑侧边栏连接", shortcutScopeGlobal: "全局", shortcutScopeEditor: "SQL 编辑器", shortcutScopeGrid: "数据表格", shortcutScopeSearch: "搜索框", + shortcutScopeSidebar: "侧边栏", shortcutPressShortcut: "按下快捷键", shortcutConflict: "这个快捷键与同一作用域内的其他操作冲突。", shortcutClear: "清除快捷键", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 00fb82f096..0a327aa8c4 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -2736,10 +2736,14 @@ export default withEnglishFallback({ shortcutRefreshData: "重新整理資料", shortcutToggleTranspose: "切換轉置檢視", shortcutCancelSearch: "取消搜尋", + shortcutCopySidebarSelection: "複製側邊欄選取項", + shortcutPasteSidebarSelection: "貼到側邊欄", + shortcutEditSidebarConnection: "編輯側邊欄連線", shortcutScopeGlobal: "全域", shortcutScopeEditor: "SQL 編輯器", shortcutScopeGrid: "資料表格", shortcutScopeSearch: "搜尋框", + shortcutScopeSidebar: "側邊欄", shortcutPressShortcut: "按下快速鍵", shortcutConflict: "這個快速鍵與同一作用域內的其他操作衝突。", shortcutClear: "清除快速鍵", diff --git a/apps/desktop/src/lib/__tests__/shortcutRegistry.spec.ts b/apps/desktop/src/lib/__tests__/shortcutRegistry.spec.ts index 12cf92f586..efb84cdded 100644 --- a/apps/desktop/src/lib/__tests__/shortcutRegistry.spec.ts +++ b/apps/desktop/src/lib/__tests__/shortcutRegistry.spec.ts @@ -3,6 +3,7 @@ import { DEFAULT_SHORTCUT_SETTINGS, SHORTCUT_DEFINITIONS, findShortcutConflict, describe("shortcutRegistry editor actions", () => { const formatterEditorActionIds: ShortcutActionId[] = ["formatSql", "indentMore", "indentLess", "duplicateLine", "deleteLine", "moveLineUp", "moveLineDown", "copyLineUp", "copyLineDown", "undo", "redo", "selectAll"]; + const sidebarShortcutActionIds: ShortcutActionId[] = ["copySidebarSelection", "pasteSidebarSelection", "editSidebarConnection"]; it("registers formatter editor shortcuts in the generic editor scope", () => { for (const actionId of formatterEditorActionIds) { @@ -36,4 +37,20 @@ describe("shortcutRegistry editor actions", () => { expect(findShortcutConflict("duplicateLine", shortcuts.duplicateLine, shortcuts)).toBe("find"); }); + + it("registers sidebar shortcuts in the sidebar scope", () => { + for (const actionId of sidebarShortcutActionIds) { + const definition = SHORTCUT_DEFINITIONS.find((item) => item.id === actionId); + + expect(definition?.scope).toBe("sidebar"); + expect(DEFAULT_SHORTCUT_SETTINGS[actionId]).toBe(definition?.defaultShortcut); + } + }); + + it("detects conflicts only within sidebar shortcuts", () => { + const shortcuts = normalizeShortcutSettings({ copySidebarSelection: "Mod+E" }); + + expect(findShortcutConflict("copySidebarSelection", shortcuts.copySidebarSelection, shortcuts)).toBe("editSidebarConnection"); + expect(findShortcutConflict("copyCurrentRow", shortcuts.copyCurrentRow, shortcuts)).toBe(null); + }); }); diff --git a/apps/desktop/src/lib/keyboardShortcuts.ts b/apps/desktop/src/lib/keyboardShortcuts.ts index ed16a36a37..2970f4b35a 100644 --- a/apps/desktop/src/lib/keyboardShortcuts.ts +++ b/apps/desktop/src/lib/keyboardShortcuts.ts @@ -146,6 +146,18 @@ export function isToggleSidebarShortcut(event: ShortcutLikeEvent, shortcuts?: Pa return matchesShortcut(event, actionShortcut("toggleSidebar", shortcuts)); } +export function isCopySidebarSelectionShortcut(event: ShortcutLikeEvent, shortcuts?: Partial): boolean { + return matchesShortcut(event, actionShortcut("copySidebarSelection", shortcuts)); +} + +export function isPasteSidebarSelectionShortcut(event: ShortcutLikeEvent, shortcuts?: Partial): boolean { + return matchesShortcut(event, actionShortcut("pasteSidebarSelection", shortcuts)); +} + +export function isEditSidebarConnectionShortcut(event: ShortcutLikeEvent, shortcuts?: Partial): boolean { + return matchesShortcut(event, actionShortcut("editSidebarConnection", shortcuts)); +} + export function isQuickOpenShortcut(event: ShortcutLikeEvent, shortcuts?: Partial): boolean { return matchesShortcut(event, actionShortcut("quickOpen", shortcuts)); } diff --git a/apps/desktop/src/lib/shortcutRegistry.ts b/apps/desktop/src/lib/shortcutRegistry.ts index 46a929b75c..edf125ac12 100644 --- a/apps/desktop/src/lib/shortcutRegistry.ts +++ b/apps/desktop/src/lib/shortcutRegistry.ts @@ -40,9 +40,12 @@ export type ShortcutActionId = | "refreshData" | "toggleTranspose" | "cancelSearch" - | "toggleSidebar"; + | "toggleSidebar" + | "copySidebarSelection" + | "pasteSidebarSelection" + | "editSidebarConnection"; -export type ShortcutScope = "global" | "editor" | "grid" | "search"; +export type ShortcutScope = "global" | "editor" | "grid" | "search" | "sidebar"; export interface ShortcutDefinition { id: ShortcutActionId; @@ -306,6 +309,24 @@ export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [ scope: "global", defaultShortcut: "Mod+B", }, + { + id: "copySidebarSelection", + labelKey: "settings.shortcutCopySidebarSelection", + scope: "sidebar", + defaultShortcut: "Mod+C", + }, + { + id: "pasteSidebarSelection", + labelKey: "settings.shortcutPasteSidebarSelection", + scope: "sidebar", + defaultShortcut: "Mod+V", + }, + { + id: "editSidebarConnection", + labelKey: "settings.shortcutEditSidebarConnection", + scope: "sidebar", + defaultShortcut: "Mod+E", + }, ]; export const DEFAULT_SHORTCUT_SETTINGS: ShortcutSettings = Object.fromEntries(SHORTCUT_DEFINITIONS.map((definition) => [definition.id, definition.defaultShortcut])) as ShortcutSettings; diff --git a/apps/desktop/src/lib/sidebarConnectionSelection.ts b/apps/desktop/src/lib/sidebarConnectionSelection.ts index a5b3e2c4d0..871476cd37 100644 --- a/apps/desktop/src/lib/sidebarConnectionSelection.ts +++ b/apps/desktop/src/lib/sidebarConnectionSelection.ts @@ -2,7 +2,7 @@ import type { TreeNode } from "@/types/database"; type ConnectionTreeNode = TreeNode & { connectionId: string }; -function isConnectionNode(node: TreeNode): node is ConnectionTreeNode { +export function isConnectionNode(node: TreeNode): node is ConnectionTreeNode { return node.type === "connection" && !!node.connectionId; } @@ -22,3 +22,26 @@ export function selectedConnectionDeleteTargets(currentNode: TreeNode, selectedN export function selectedConnectionDuplicateTargets(currentNode: TreeNode, selectedNodes: TreeNode[]): ConnectionTreeNode[] { return selectedConnectionActionTargets(currentNode, selectedNodes); } + +export function selectedConnectionClipboardTargets(currentNode: TreeNode, selectedNodes: TreeNode[]): ConnectionTreeNode[] { + return selectedConnectionActionTargets(currentNode, selectedNodes); +} + +export function selectedConnectionEditTarget(currentNode: TreeNode, selectedNodes: TreeNode[]): ConnectionTreeNode | null { + if (!isConnectionNode(currentNode)) return null; + const selectedContainsCurrent = selectedNodes.some((node) => node.id === currentNode.id); + if (selectedNodes.length > 1 && selectedContainsCurrent) return null; + return currentNode; +} + +export function selectedConnectionClipboardNodes(selectedNodes: TreeNode[]): ConnectionTreeNode[] { + if (selectedNodes.length === 0 || !selectedNodes.every(isConnectionNode)) return []; + return selectedNodes; +} + +export function connectionPasteTargetGroupId(node: TreeNode | null | undefined, groupIdForConnection: (connectionId: string) => string | null): string | null { + if (!node) return null; + if (node.type === "connection-group") return node.id; + if (isConnectionNode(node)) return groupIdForConnection(node.connectionId); + return null; +} diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 15536b55f0..50738eacc3 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -147,11 +147,21 @@ interface TreeClipboardTableEntry { tableName: string; } -export interface TreeClipboard { - kind: "table-copy"; - tables: TreeClipboardTableEntry[]; +interface TreeClipboardConnectionEntry { + config: ConnectionConfig; + sourceGroupId: string | null; } +export type TreeClipboard = + | { + kind: "table-copy"; + tables: TreeClipboardTableEntry[]; + } + | { + kind: "connection-copy"; + connections: TreeClipboardConnectionEntry[]; + }; + interface LoadTreeOptions { force?: boolean; expectedSidebarSearchQuery?: string; @@ -1017,6 +1027,43 @@ export const useConnectionStore = defineStore("connection", () => { stopCreatingConnectionInGroup(); } + function copyConnectionsToTreeClipboard(connectionIds: Iterable): number { + const seen = new Set(); + const entries: TreeClipboardConnectionEntry[] = []; + for (const connectionId of connectionIds) { + if (seen.has(connectionId)) continue; + seen.add(connectionId); + const config = getConfig(connectionId); + if (!config) continue; + entries.push({ + config: { ...config }, + sourceGroupId: findConnectionLocation(sidebarLayout.value, connectionId)?.groupId ?? null, + }); + } + if (!entries.length) return 0; + treeClipboard.value = { kind: "connection-copy", connections: entries }; + return entries.length; + } + + async function pasteConnectionClipboard(targetGroupId?: string | null): Promise { + const clipboard = treeClipboard.value; + if (clipboard?.kind !== "connection-copy" || clipboard.connections.length === 0) return 0; + + let pastedCount = 0; + for (const entry of clipboard.connections) { + await addConnection( + { + ...entry.config, + id: uuid(), + name: `${entry.config.name} (Copy)`, + }, + targetGroupId === undefined ? entry.sourceGroupId : targetGroupId, + ); + pastedCount += 1; + } + return pastedCount; + } + function invalidateCompletionCache(connectionId: string, database?: string) { const cachePrefix = database == null ? `${connectionId}:` : `${connectionId}:${database}:`; const exactCacheKey = database == null ? null : `${connectionId}:${database}`; @@ -3979,6 +4026,8 @@ export const useConnectionStore = defineStore("connection", () => { isTreeNodePinned, toggleTreeNodePin, addConnection, + copyConnectionsToTreeClipboard, + pasteConnectionClipboard, addEphemeralConnection, updateConnection, setDefaultDatabase, diff --git a/packages/app-tests/connectionClipboard.test.ts b/packages/app-tests/connectionClipboard.test.ts new file mode 100644 index 0000000000..de6ef316d5 --- /dev/null +++ b/packages/app-tests/connectionClipboard.test.ts @@ -0,0 +1,157 @@ +import { test } from "vitest"; +import assert from "node:assert/strict"; +import { createPinia, setActivePinia } from "pinia"; +import { useConnectionStore } from "../../apps/desktop/src/stores/connectionStore.ts"; +import type { ConnectionConfig, SidebarLayout, TreeNode } from "../../apps/desktop/src/types/database.ts"; + +function installMemoryStorage() { + const values = new Map(); + const original = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + removeItem: (key: string) => values.delete(key), + clear: () => values.clear(), + }, + }); + return { + restore() { + if (original) Object.defineProperty(globalThis, "localStorage", original); + else Reflect.deleteProperty(globalThis, "localStorage"); + }, + }; +} + +function conn(id: string, name: string): ConnectionConfig { + return { + id, + name, + db_type: "mysql", + host: "127.0.0.1", + port: 3306, + username: "root", + password: "secret", + }; +} + +async function withConnectionStore(initialConnections: ConnectionConfig[], initialLayout: SidebarLayout | null, run: (store: ReturnType) => Promise) { + const originalFetch = globalThis.fetch; + const storage = installMemoryStorage(); + let savedConnections = initialConnections; + let savedLayout = initialLayout; + + globalThis.fetch = (async (input, init) => { + const url = String(input); + if (url === "/api/connection/list") { + return new Response(JSON.stringify(savedConnections), { status: 200, headers: { "Content-Type": "application/json" } }); + } + if (url === "/api/layout/sidebar") { + if (init?.method === "POST") { + savedLayout = JSON.parse(String(init.body ?? "null")); + return new Response("null", { status: 200, headers: { "Content-Type": "application/json" } }); + } + return new Response(JSON.stringify(savedLayout), { status: 200, headers: { "Content-Type": "application/json" } }); + } + if (url === "/api/connection/save") { + savedConnections = JSON.parse(String(init?.body ?? "[]")); + return new Response("null", { status: 200, headers: { "Content-Type": "application/json" } }); + } + return new Response("null", { status: 200, headers: { "Content-Type": "application/json" } }); + }) as typeof fetch; + + try { + setActivePinia(createPinia()); + const store = useConnectionStore(); + await store.initFromDisk(); + await run(store); + } finally { + globalThis.fetch = originalFetch; + storage.restore(); + } +} + +function connectionLabels(nodes: TreeNode[]): string[] { + const labels: string[] = []; + for (const node of nodes) { + if (node.type === "connection") labels.push(node.label); + if (node.children) labels.push(...connectionLabels(node.children)); + } + return labels; +} + +test("copies and pastes a root connection without writing to the OS clipboard", async () => { + await withConnectionStore([conn("conn-1", "Main MySQL")], { groups: [], order: [{ type: "connection", id: "conn-1" }] }, async (store) => { + assert.equal(store.copyConnectionsToTreeClipboard(["conn-1"]), 1); + assert.equal(store.treeClipboard?.kind, "connection-copy"); + + const pasted = await store.pasteConnectionClipboard(null); + + assert.equal(pasted, 1); + assert.equal(store.connections.length, 2); + assert.deepEqual(connectionLabels(store.treeNodes), ["Main MySQL", "Main MySQL (Copy)"]); + assert.notEqual(store.connections[0].id, store.connections[1].id); + }); +}); + +test("pastes a copied grouped connection into the same group when that group is targeted", async () => { + const originalConnection = conn("conn-1", "Grouped MySQL"); + const layout: SidebarLayout = { + groups: [{ id: "group-1", name: "Group", collapsed: false }], + order: [{ type: "group", id: "group-1", children: [{ type: "connection", id: "conn-1" }] }], + }; + + await withConnectionStore([originalConnection], layout, async (store) => { + assert.equal(store.copyConnectionsToTreeClipboard(["conn-1"]), 1); + const pasted = await store.pasteConnectionClipboard(); + + assert.equal(pasted, 1); + assert.equal(store.treeNodes[0].type, "connection-group"); + assert.deepEqual( + store.treeNodes[0].children?.map((node) => node.label), + ["Grouped MySQL", "Grouped MySQL (Copy)"], + ); + }); +}); + +test("pastes copied connections into an explicitly selected target group", async () => { + const layout: SidebarLayout = { + groups: [ + { id: "group-1", name: "Source", collapsed: false }, + { id: "group-2", name: "Target", collapsed: false }, + ], + order: [ + { type: "group", id: "group-1", children: [{ type: "connection", id: "conn-1" }] }, + { type: "group", id: "group-2", children: [] }, + ], + }; + + await withConnectionStore([conn("conn-1", "Source MySQL")], layout, async (store) => { + store.copyConnectionsToTreeClipboard(["conn-1"]); + await store.pasteConnectionClipboard("group-2"); + + assert.equal(store.treeNodes[1].type, "connection-group"); + assert.deepEqual( + store.treeNodes[1].children?.map((node) => node.label), + ["Source MySQL (Copy)"], + ); + }); +}); + +test("pastes multiple copied connections in the copied order", async () => { + const layout: SidebarLayout = { + groups: [], + order: [ + { type: "connection", id: "conn-1" }, + { type: "connection", id: "conn-2" }, + ], + }; + + await withConnectionStore([conn("conn-1", "First"), conn("conn-2", "Second")], layout, async (store) => { + store.copyConnectionsToTreeClipboard(["conn-2", "conn-1"]); + await store.pasteConnectionClipboard(null); + + assert.deepEqual(connectionLabels(store.treeNodes), ["First", "Second", "Second (Copy)", "First (Copy)"]); + }); +}); diff --git a/packages/app-tests/keyboardShortcuts.test.ts b/packages/app-tests/keyboardShortcuts.test.ts index 9e6103a9bd..57e69e0596 100644 --- a/packages/app-tests/keyboardShortcuts.test.ts +++ b/packages/app-tests/keyboardShortcuts.test.ts @@ -5,12 +5,15 @@ import { isBrowserReloadShortcut, isCancelSearchShortcut, isCloseTabShortcut, + isCopySidebarSelectionShortcut, isExecuteSqlShortcut, + isEditSidebarConnectionShortcut, isFocusSearchShortcut, isModRShortcut, isNewQueryShortcut, isObjectSourceSaveShortcutTarget, isOpenSettingsShortcut, + isPasteSidebarSelectionShortcut, isResetZoomShortcut, isRefreshDataShortcut, isSaveShortcut, @@ -246,3 +249,21 @@ test("matches Escape for cancelling search", () => { test("ignores cancelling search while composing", () => { assert.equal(isCancelSearchShortcut({ key: "Escape", isComposing: true }), false); }); + +test("matches configurable sidebar shortcuts", () => { + assert.equal(isCopySidebarSelectionShortcut({ key: "c", metaKey: true }), true); + assert.equal(isPasteSidebarSelectionShortcut({ key: "v", ctrlKey: true }), true); + assert.equal(isEditSidebarConnectionShortcut({ key: "e", metaKey: true }), true); + + const shortcuts = { + copySidebarSelection: "Alt+C", + pasteSidebarSelection: "Alt+V", + editSidebarConnection: "Shift+Mod+E", + } as any; + + assert.equal(isCopySidebarSelectionShortcut({ key: "c", metaKey: true }, shortcuts), false); + assert.equal(isCopySidebarSelectionShortcut({ key: "c", altKey: true }, shortcuts), true); + assert.equal(isPasteSidebarSelectionShortcut({ key: "v", altKey: true }, shortcuts), true); + assert.equal(isEditSidebarConnectionShortcut({ key: "e", metaKey: true }, shortcuts), false); + assert.equal(isEditSidebarConnectionShortcut({ key: "e", ctrlKey: true, shiftKey: true }, shortcuts), true); +}); diff --git a/packages/app-tests/settingsStore.test.ts b/packages/app-tests/settingsStore.test.ts index a5fb0924c6..80e62b43b3 100644 --- a/packages/app-tests/settingsStore.test.ts +++ b/packages/app-tests/settingsStore.test.ts @@ -161,6 +161,9 @@ test("defaults shortcut settings", () => { assert.equal(settings.shortcuts.resetUiZoom, "Mod+0"); assert.equal(settings.shortcuts.refreshData, "F5"); assert.equal(settings.shortcuts.toggleTranspose, "Tab"); + assert.equal(settings.shortcuts.copySidebarSelection, "Mod+C"); + assert.equal(settings.shortcuts.pasteSidebarSelection, "Mod+V"); + assert.equal(settings.shortcuts.editSidebarConnection, "Mod+E"); }); test("keeps saved shortcut overrides", () => { @@ -172,6 +175,7 @@ test("keeps saved shortcut overrides", () => { newQuery: "Shift+Mod+N", openSettings: "Shift+Mod+P", zoomInUi: "Alt+Mod+=", + editSidebarConnection: "Alt+E", } as any, }); @@ -181,6 +185,7 @@ test("keeps saved shortcut overrides", () => { assert.equal(settings.shortcuts.newQuery, "Shift+Mod+N"); assert.equal(settings.shortcuts.openSettings, "Shift+Mod+P"); assert.equal(settings.shortcuts.zoomInUi, "Alt+Mod+="); + assert.equal(settings.shortcuts.editSidebarConnection, "Alt+E"); assert.equal(settings.shortcuts.saveSql, "Mod+S"); }); diff --git a/packages/app-tests/sidebarConnectionSelection.test.ts b/packages/app-tests/sidebarConnectionSelection.test.ts new file mode 100644 index 0000000000..200ce22767 --- /dev/null +++ b/packages/app-tests/sidebarConnectionSelection.test.ts @@ -0,0 +1,69 @@ +import { test } from "vitest"; +import assert from "node:assert/strict"; +import { connectionPasteTargetGroupId, selectedConnectionClipboardNodes, selectedConnectionClipboardTargets, selectedConnectionEditTarget } from "../../apps/desktop/src/lib/sidebarConnectionSelection.ts"; +import type { TreeNode } from "../../apps/desktop/src/types/database.ts"; + +function connectionNode(id: string): TreeNode { + return { + id, + label: id, + type: "connection", + connectionId: id, + }; +} + +test("uses selected connections as clipboard targets only when the selection is connection-only", () => { + const first = connectionNode("conn-1"); + const second = connectionNode("conn-2"); + const table: TreeNode = { + id: "table-1", + label: "users", + type: "table", + connectionId: "conn-1", + database: "main", + }; + + assert.deepEqual(selectedConnectionClipboardNodes([first, second]).map((node) => node.connectionId), ["conn-1", "conn-2"]); + assert.deepEqual(selectedConnectionClipboardNodes([first, table]), []); + assert.deepEqual(selectedConnectionClipboardTargets(first, [first, second]).map((node) => node.connectionId), ["conn-1", "conn-2"]); + assert.deepEqual(selectedConnectionClipboardTargets(first, [first, table]).map((node) => node.connectionId), ["conn-1"]); +}); + +test("resolves connection paste target groups from the selected sidebar node", () => { + const group: TreeNode = { + id: "group-1", + label: "Group", + type: "connection-group", + }; + const connection = connectionNode("conn-1"); + const table: TreeNode = { + id: "table-1", + label: "users", + type: "table", + connectionId: "conn-1", + database: "main", + }; + + const groupIdForConnection = (connectionId: string) => (connectionId === "conn-1" ? "group-1" : null); + + assert.equal(connectionPasteTargetGroupId(group, groupIdForConnection), "group-1"); + assert.equal(connectionPasteTargetGroupId(connection, groupIdForConnection), "group-1"); + assert.equal(connectionPasteTargetGroupId(table, groupIdForConnection), null); + assert.equal(connectionPasteTargetGroupId(null, groupIdForConnection), null); +}); + +test("allows editing only a single selected connection", () => { + const first = connectionNode("conn-1"); + const second = connectionNode("conn-2"); + const table: TreeNode = { + id: "table-1", + label: "users", + type: "table", + connectionId: "conn-1", + database: "main", + }; + + assert.equal(selectedConnectionEditTarget(first, [first])?.connectionId, "conn-1"); + assert.equal(selectedConnectionEditTarget(first, [first, second]), null); + assert.equal(selectedConnectionEditTarget(table, [table]), null); +});