feat(sidebar): add connection copy paste shortcuts

This commit is contained in:
二丫讲梵 2026-07-04 12:19:56 +08:00 committed by GitHub
parent d8047a77b4
commit 90d2ede740
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 509 additions and 20 deletions

View File

@ -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;
}

View File

@ -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;

View File

@ -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"),

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -2832,10 +2832,14 @@ export default withEnglishFallback({
shortcutToggleTranspose: "転置ビューを切替",
shortcutCancelSearch: "検索をキャンセル",
shortcutToggleSidebar: "サイドバーを切替",
shortcutCopySidebarSelection: "サイドバーの選択をコピー",
shortcutPasteSidebarSelection: "サイドバーに貼り付け",
shortcutEditSidebarConnection: "サイドバー接続を編集",
shortcutScopeGlobal: "グローバル",
shortcutScopeEditor: "SQLエディタ",
shortcutScopeGrid: "データグリッド",
shortcutScopeSearch: "フィールド検索",
shortcutScopeSidebar: "サイドバー",
shortcutPressShortcut: "ショートカットを押してください",
shortcutConflict: "このショートカットは同じスコープ内の別のアクションと競合しています。",
shortcutClear: "ショートカットをクリア",

View File

@ -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",

View File

@ -2894,10 +2894,14 @@ export default withEnglishFallback({
shortcutRefreshData: "刷新数据",
shortcutToggleTranspose: "切换转置视图",
shortcutCancelSearch: "取消搜索",
shortcutCopySidebarSelection: "复制侧边栏选中项",
shortcutPasteSidebarSelection: "粘贴到侧边栏",
shortcutEditSidebarConnection: "编辑侧边栏连接",
shortcutScopeGlobal: "全局",
shortcutScopeEditor: "SQL 编辑器",
shortcutScopeGrid: "数据表格",
shortcutScopeSearch: "搜索框",
shortcutScopeSidebar: "侧边栏",
shortcutPressShortcut: "按下快捷键",
shortcutConflict: "这个快捷键与同一作用域内的其他操作冲突。",
shortcutClear: "清除快捷键",

View File

@ -2736,10 +2736,14 @@ export default withEnglishFallback({
shortcutRefreshData: "重新整理資料",
shortcutToggleTranspose: "切換轉置檢視",
shortcutCancelSearch: "取消搜尋",
shortcutCopySidebarSelection: "複製側邊欄選取項",
shortcutPasteSidebarSelection: "貼到側邊欄",
shortcutEditSidebarConnection: "編輯側邊欄連線",
shortcutScopeGlobal: "全域",
shortcutScopeEditor: "SQL 編輯器",
shortcutScopeGrid: "資料表格",
shortcutScopeSearch: "搜尋框",
shortcutScopeSidebar: "側邊欄",
shortcutPressShortcut: "按下快速鍵",
shortcutConflict: "這個快速鍵與同一作用域內的其他操作衝突。",
shortcutClear: "清除快速鍵",

View File

@ -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);
});
});

View File

@ -146,6 +146,18 @@ export function isToggleSidebarShortcut(event: ShortcutLikeEvent, shortcuts?: Pa
return matchesShortcut(event, actionShortcut("toggleSidebar", shortcuts));
}
export function isCopySidebarSelectionShortcut(event: ShortcutLikeEvent, shortcuts?: Partial<ShortcutSettings>): boolean {
return matchesShortcut(event, actionShortcut("copySidebarSelection", shortcuts));
}
export function isPasteSidebarSelectionShortcut(event: ShortcutLikeEvent, shortcuts?: Partial<ShortcutSettings>): boolean {
return matchesShortcut(event, actionShortcut("pasteSidebarSelection", shortcuts));
}
export function isEditSidebarConnectionShortcut(event: ShortcutLikeEvent, shortcuts?: Partial<ShortcutSettings>): boolean {
return matchesShortcut(event, actionShortcut("editSidebarConnection", shortcuts));
}
export function isQuickOpenShortcut(event: ShortcutLikeEvent, shortcuts?: Partial<ShortcutSettings>): boolean {
return matchesShortcut(event, actionShortcut("quickOpen", shortcuts));
}

View File

@ -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;

View File

@ -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;
}

View File

@ -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<string>): number {
const seen = new Set<string>();
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<number> {
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,

View File

@ -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<string, string>();
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<typeof useConnectionStore>) => Promise<void>) {
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)"]);
});
});

View File

@ -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);
});

View File

@ -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");
});

View File

@ -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);
});