From de99931c044d6b3b8af0c6c78ca0053f287b8ce8 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Sat, 22 Aug 2026 10:06:21 +0000 Subject: [PATCH] feat(sidebar): support quick connection rename Closes #6792 --- .../src/components/sidebar/ConnectionTree.vue | 9 + .../sidebar/SidebarTreeRuntimeHost.vue | 3 +- .../src/components/sidebar/TreeItem.vue | 33 ++- .../TreeItem.connectionRename.spec.ts | 192 ++++++++++++++++++ .../__tests__/connectionStore.rename.spec.ts | 112 ++++++++++ apps/desktop/src/stores/connectionStore.ts | 15 ++ .../sidebarConnectionQuickRename.test.ts | 16 ++ 7 files changed, 376 insertions(+), 4 deletions(-) create mode 100644 apps/desktop/src/components/sidebar/__tests__/TreeItem.connectionRename.spec.ts create mode 100644 apps/desktop/src/stores/__tests__/connectionStore.rename.spec.ts create mode 100644 packages/app-tests/sidebarConnectionQuickRename.test.ts diff --git a/apps/desktop/src/components/sidebar/ConnectionTree.vue b/apps/desktop/src/components/sidebar/ConnectionTree.vue index 7ee82b69d3..c8dda5c8f9 100644 --- a/apps/desktop/src/components/sidebar/ConnectionTree.vue +++ b/apps/desktop/src/components/sidebar/ConnectionTree.vue @@ -1295,6 +1295,14 @@ async function startRenamingSavedSqlNode(nodeId: string) { store.selectedTreeNodeId = nodeId; } +async function startRenamingConnectionNode(connectionId: string) { + pendingRenameNodeId.value = connectionId; + store.selectedTreeNodeId = connectionId; + store.selectedTreeNodeIds = [connectionId]; + await scrollToSidebarNode(connectionId); + store.selectedTreeNodeId = connectionId; +} + async function locateActiveTabInSidebar() { const tab = activeTab.value; if (!tab) return; @@ -2176,6 +2184,7 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes }); @open-visible-nacos-namespaces="openSidebarVisibleNacosNamespaces" @open-table-name-filters="openSidebarTableNameFilters" @add-to-ai="(node) => emit('add-to-ai', node)" + @request-connection-rename="startRenamingConnectionNode" @request-group-rename="startRenamingCreatedGroup" @request-saved-sql-rename="startRenamingSavedSqlNode" @open-danger-dialog="openSidebarDangerDialog" diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index 66791c53b6..f83910e60e 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -369,6 +369,7 @@ const { openAllDatabasesExport, openDataCompare, openDatabaseExport, openDatabas const emit = defineEmits<{ "rename-started": []; + "request-connection-rename": [connectionId: string]; "request-group-rename": [groupId: string]; "request-saved-sql-rename": [nodeId: string]; "node-toggled": [node: TreeNode, expanded: boolean]; @@ -1209,7 +1210,7 @@ function requestRenameSelectedNode(): boolean { if (selected.length > 1 && selected.some((node) => node.id === activeNode.value.id)) return false; const editTarget = selectedConnectionEditTarget(activeNode.value, selected); if (editTarget) { - connectionStore.startEditing(editTarget.connectionId); + emit("request-connection-rename", editTarget.connectionId); return true; } if (canRenameMongoCollection.value) { diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index ca48aa454e..e74c532f62 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -935,6 +935,8 @@ const isRenamingGroup = ref(false); const isRenamingSavedSql = ref(false); +const isRenamingConnection = ref(false); + const renameInput = ref(""); const renameInputRef = ref(); @@ -954,12 +956,21 @@ function startRenameSavedSql() { focusSidebarRenameInput(() => (isRenamingSavedSql.value ? renameInputRef.value : undefined)); } +function startRenameConnection() { + if (activeNode.value.type !== "connection" || !activeNode.value.connectionId) return; + renameInput.value = activeNode.value.label; + isRenamingConnection.value = true; + emit("rename-started"); + focusSidebarRenameInput(() => (isRenamingConnection.value ? renameInputRef.value : undefined)); +} + watch( () => props.pendingRename, (pending) => { if (!pending) return; if (activeNode.value.type === "connection-group") startRenameGroup(); else if (activeNode.value.type === "saved-sql-file") startRenameSavedSql(); + else if (activeNode.value.type === "connection") startRenameConnection(); }, { immediate: true }, ); @@ -967,7 +978,7 @@ watch( function shouldMeasureLabelOverflow(): boolean { return shouldMeasureSidebarLabelOverflow({ hasDetailTooltip: !!detailTooltip.value?.rows.length, - isRenaming: isRenamingGroup.value || isRenamingSavedSql.value, + isRenaming: isRenamingGroup.value || isRenamingSavedSql.value || isRenamingConnection.value, usesFullWidthLabel: usesFullWidthLabel.value, }); } @@ -1001,14 +1012,29 @@ async function finishRenameSavedSql() { } } +async function finishRenameConnection() { + if (!isRenamingConnection.value) return; + isRenamingConnection.value = false; + const connectionId = activeNode.value.connectionId; + const trimmed = renameInput.value.trim(); + if (!connectionId || !trimmed || trimmed === activeNode.value.label) return; + try { + await connectionStore.renameConnection(connectionId, trimmed); + } catch (e: any) { + toast(t("connection.saveFailed", { message: e?.message || String(e) }), 5000); + } +} + function finishRename() { - if (isRenamingSavedSql.value) void finishRenameSavedSql(); + if (isRenamingConnection.value) void finishRenameConnection(); + else if (isRenamingSavedSql.value) void finishRenameSavedSql(); else finishRenameGroup(); } function cancelRename() { isRenamingGroup.value = false; isRenamingSavedSql.value = false; + isRenamingConnection.value = false; } const PINNED_TREE_NODE_DRAG_TYPE = "__pinned-tree-node__"; @@ -1232,6 +1258,7 @@ watch( // from the previously rendered node into the new row. isRenamingGroup.value = false; isRenamingSavedSql.value = false; + isRenamingConnection.value = false; renameInput.value = ""; labelOverflowing.value = false; suppressNextTableReferenceClick = false; @@ -1399,7 +1426,7 @@ function onKeydown(event: KeyboardEvent) {
(), + connectionErrors: {}, + connectionMultiSelectActive: false, + connections: [connection], + getConfig: () => connection, + getSidebarVisibleFilterSummary: () => null, + isDefaultDatabase: () => false, + isDefaultSchema: () => false, + isPinnedTreeNodeReorderTarget: () => false, + isTreeNodeChildrenLoaded: () => false, + isTreeNodePinned: () => false, + renameConnection, + selectedTreeNodeId: connection.id as string | null, + selectedTreeNodeIds: [connection.id], + selectedTreeNodeIdsSet: new Set([connection.id]), + sidebarLayout: { groups: [], order: [] } as SidebarLayout, + sidebarTableSearchQueries: {}, + tableNameFilterForScope: () => undefined, + treeNodes: [] as TreeNode[], + treeSelectionAnchorId: connection.id as string | null, +}; + +vi.mock("@/stores/connectionStore", () => ({ + useConnectionStore: () => connectionStore, +})); + +vi.mock("@/stores/queryStore", () => ({ + useQueryStore: () => ({ openDatabaseKeys: new Set() }), +})); + +vi.mock("@/stores/settingsStore", () => ({ + useSettingsStore: () => ({ + editorSettings: { + shortcuts: { openDataInNewTab: "" }, + sidebarActivation: "double", + sidebarAllowHorizontalScroll: false, + sidebarHiddenTablePrefixes: [], + sidebarObjectInfoMode: "none", + }, + }), +})); + +vi.mock("@/composables/useToast", () => ({ + useToast: () => ({ toast }), +})); + +vi.mock("@/components/icons/DatabaseIcon.vue", () => ({ default: { template: "" } })); +vi.mock("@/components/connection/ConnectionErrorIndicator.vue", () => ({ default: { template: "" } })); +vi.mock("@/components/common/ProductionContextBadge.vue", () => ({ default: { template: "" } })); +vi.mock("@/components/ui/badge", () => ({ Badge: { template: "" } })); +vi.mock("@/components/ui/input", () => ({ Input: { template: "" } })); +vi.mock("@/components/ui/switch", () => ({ Switch: { template: "" } })); +vi.mock("@/components/ui/LightTooltip.vue", () => ({ default: { template: "" } })); + +const mountedApps: App[] = []; +const i18n = createI18n({ + legacy: false, + locale: "en", + messages: { + en: { + connection: { + saveFailed: "Failed to save connection: {message}", + }, + }, + }, +}); + +function runtimeHost(): SidebarTreeRuntimeHost { + return { + buildContextMenu: vi.fn(() => []), + handleRowClick: vi.fn(), + handleRowDoubleClick: vi.fn(), + handleRowKeydown: vi.fn(), + openPrimaryVisibleFilter: vi.fn(), + openDataInNewTab: vi.fn(), + requestPaste: vi.fn(() => false), + toggleNode: vi.fn(), + }; +} + +async function mountConnectionRename() { + const container = document.createElement("div"); + document.body.append(container); + const node: TreeNode = { + id: connection.id, + label: connection.name, + type: "connection", + connectionId: connection.id, + children: [], + }; + const runtime = createSidebarTreeRuntime(); + runtime.bindHost(runtimeHost()); + const app = createApp( + defineComponent({ + setup: () => () => h(TreeItem, { node, depth: 0, pendingRename: true }), + }), + ); + mountedApps.push(app); + app.use(i18n); + app.provide(sidebarTreeRuntimeKey, runtime); + app.mount(container); + await nextTick(); + const input = container.querySelector("input"); + if (!input) throw new Error(`Connection rename input was not rendered: ${container.innerHTML}`); + return { container, input }; +} + +function typeName(input: HTMLInputElement, value: string) { + input.value = value; + input.dispatchEvent(new Event("input", { bubbles: true })); +} + +function press(input: HTMLInputElement, key: string) { + input.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true })); +} + +beforeEach(() => { + renameConnection.mockReset().mockResolvedValue(true); + toast.mockReset(); +}); + +afterEach(() => { + for (const app of mountedApps.splice(0)) app.unmount(); + document.body.replaceChildren(); +}); + +describe("TreeItem connection quick rename", () => { + it("trims and persists the edited name on Enter", async () => { + const { input } = await mountConnectionRename(); + typeName(input, " Reporting EU "); + + press(input, "Enter"); + + await vi.waitFor(() => expect(renameConnection).toHaveBeenCalledWith(connection.id, "Reporting EU")); + expect(renameConnection).toHaveBeenCalledTimes(1); + }); + + it.each(["", " ", connection.name])("cancels blank or unchanged input %j", async (name) => { + const { input } = await mountConnectionRename(); + typeName(input, name); + + press(input, "Enter"); + await nextTick(); + + expect(renameConnection).not.toHaveBeenCalled(); + }); + + it("cancels on Escape without persisting", async () => { + const { container, input } = await mountConnectionRename(); + typeName(input, "Reporting EU"); + + press(input, "Escape"); + await nextTick(); + + expect(renameConnection).not.toHaveBeenCalled(); + expect(container.querySelector("input")).toBeNull(); + expect(container.textContent).toContain(connection.name); + }); + + it("surfaces persistence failures", async () => { + renameConnection.mockRejectedValueOnce(new Error("disk full")); + const { input } = await mountConnectionRename(); + typeName(input, "Reporting EU"); + + press(input, "Enter"); + + await vi.waitFor(() => expect(toast).toHaveBeenCalledWith(expect.stringContaining("disk full"), 5000)); + }); +}); diff --git a/apps/desktop/src/stores/__tests__/connectionStore.rename.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.rename.spec.ts new file mode 100644 index 0000000000..d07e1958cc --- /dev/null +++ b/apps/desktop/src/stores/__tests__/connectionStore.rename.spec.ts @@ -0,0 +1,112 @@ +import { createPinia, setActivePinia } from "pinia"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ConnectionConfig, SidebarLayout, TreeNode } from "@/types/database"; + +function postgresConnection(overrides: Partial = {}): ConnectionConfig { + return { + id: "pg-1", + name: "Reporting", + db_type: "postgres", + host: "127.0.0.1", + port: 5432, + username: "postgres", + password: "secret", + database: "analytics", + read_only: false, + note: "Production reporting", + ...overrides, + } as ConnectionConfig; +} + +function installLocalStorage() { + const data = new Map(); + vi.stubGlobal("localStorage", { + getItem: vi.fn((key: string) => data.get(key) ?? null), + setItem: vi.fn((key: string, value: string) => data.set(key, value)), + removeItem: vi.fn((key: string) => data.delete(key)), + }); +} + +async function createStore(saveConnections: ReturnType) { + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined), + saveConnections, + saveSidebarLayout: vi.fn().mockResolvedValue(undefined), + })); + const { useConnectionStore } = await import("@/stores/connectionStore"); + return useConnectionStore(); +} + +describe("connectionStore quick rename", () => { + beforeEach(() => { + vi.resetModules(); + vi.unstubAllGlobals(); + installLocalStorage(); + setActivePinia(createPinia()); + }); + + it("persists only the trimmed name while preserving runtime and tree identity", async () => { + const saveConnections = vi.fn().mockResolvedValue(undefined); + const store = await createStore(saveConnections); + const connection = postgresConnection(); + const layout: SidebarLayout = { + groups: [{ id: "group-1", name: "Production", collapsed: false }], + order: [{ type: "group", id: "group-1", children: [{ type: "connection", id: connection.id }] }], + }; + const existingNode: TreeNode = { + id: connection.id, + label: connection.name, + type: "connection", + connectionId: connection.id, + isExpanded: true, + children: [], + }; + store.connections = [connection]; + store.sidebarLayout = layout; + store.treeNodes = [existingNode]; + store.connectedIds.add(connection.id); + store.activeConnectionId = connection.id; + store.selectedTreeNodeId = connection.id; + store.selectedTreeNodeIds = [connection.id]; + + await expect(store.renameConnection(connection.id, " Reporting EU ")).resolves.toBe(true); + + expect(saveConnections).toHaveBeenCalledWith([{ ...connection, name: "Reporting EU" }]); + expect(store.getConfig(connection.id)).toEqual({ ...connection, name: "Reporting EU" }); + expect(store.connectedIds.has(connection.id)).toBe(true); + expect(store.activeConnectionId).toBe(connection.id); + expect(store.selectedTreeNodeId).toBe(connection.id); + expect(store.selectedTreeNodeIds).toEqual([connection.id]); + expect(store.sidebarLayout).toStrictEqual(layout); + const renamedNode = store.treeNodes[0]?.children?.find((node) => node.id === connection.id); + expect(renamedNode).toEqual(expect.objectContaining({ id: connection.id, label: "Reporting EU", isExpanded: true })); + }); + + it("does not mutate the connection when persistence fails", async () => { + const saveConnections = vi.fn().mockRejectedValue(new Error("disk full")); + const store = await createStore(saveConnections); + const connection = postgresConnection(); + store.connections = [connection]; + store.connectedIds.add(connection.id); + + await expect(store.renameConnection(connection.id, "Renamed")).rejects.toThrow("disk full"); + + expect(store.getConfig(connection.id)).toEqual(connection); + expect(store.connectedIds.has(connection.id)).toBe(true); + }); + + it("ignores blank, unchanged, and missing connection names", async () => { + const saveConnections = vi.fn().mockResolvedValue(undefined); + const store = await createStore(saveConnections); + const connection = postgresConnection(); + store.connections = [connection]; + + await expect(store.renameConnection(connection.id, " ")).resolves.toBe(false); + await expect(store.renameConnection(connection.id, ` ${connection.name} `)).resolves.toBe(false); + await expect(store.renameConnection("missing", "Renamed")).resolves.toBe(false); + + expect(saveConnections).not.toHaveBeenCalled(); + expect(store.getConfig(connection.id)).toEqual(connection); + }); +}); diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 2c141bd398..62b77a5c45 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -2996,6 +2996,20 @@ export const useConnectionStore = defineStore("connection", () => { } } + async function renameConnection(connectionId: string, name: string): Promise { + const trimmed = name.trim(); + if (!trimmed) return false; + const index = connections.value.findIndex((connection) => connection.id === connectionId); + if (index < 0 || connections.value[index].name === trimmed) return false; + + const nextConnections = [...connections.value]; + nextConnections[index] = { ...nextConnections[index], name: trimmed }; + await persistConnections(nextConnections); + connections.value = nextConnections; + rebuildTreeNodes(); + return true; + } + async function updateConnectionDatabaseInfo(connectionId: string, databaseInfo: DatabaseConnectionInfo, expectedConfigFingerprint?: string): Promise { const normalized = normalizeDatabaseConnectionInfo(databaseInfo); if (!normalized) return; @@ -8400,6 +8414,7 @@ export const useConnectionStore = defineStore("connection", () => { pasteConnectionClipboard, addEphemeralConnection, updateConnection, + renameConnection, applyGlobalTimeouts, updateConnectionDatabaseInfo, setDefaultDatabase, diff --git a/packages/app-tests/sidebarConnectionQuickRename.test.ts b/packages/app-tests/sidebarConnectionQuickRename.test.ts new file mode 100644 index 0000000000..fff202824d --- /dev/null +++ b/packages/app-tests/sidebarConnectionQuickRename.test.ts @@ -0,0 +1,16 @@ +import { strict as assert } from "node:assert"; +import { readFileSync } from "node:fs"; +import { test } from "vitest"; + +const repositoryRoot = new URL("../../", import.meta.url); +const runtimeHost = readFileSync(new URL("apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue", repositoryRoot), "utf8"); +const connectionTree = readFileSync(new URL("apps/desktop/src/components/sidebar/ConnectionTree.vue", repositoryRoot), "utf8"); +const treeItem = readFileSync(new URL("apps/desktop/src/components/sidebar/TreeItem.vue", repositoryRoot), "utf8"); + +test("connection F2 rename uses the rendered row while explicit edit stays unchanged", () => { + assert.match(runtimeHost, /const editTarget = selectedConnectionEditTarget[\s\S]*?emit\("request-connection-rename", editTarget\.connectionId\)/); + assert.match(runtimeHost, /function requestEditSelectedConnection\(\): boolean \{[\s\S]*?connectionStore\.startEditing\(editTarget\.connectionId\)/); + assert.match(connectionTree, /@request-connection-rename="startRenamingConnectionNode"/); + assert.match(treeItem, /activeNode\.value\.type === "connection"\) startRenameConnection\(\)/); + assert.match(treeItem, /async function finishRenameConnection\(\)[\s\S]*?connectionStore\.renameConnection\(connectionId, trimmed\)/); +});