feat(sidebar): support quick connection rename

Closes #6792
This commit is contained in:
t8y2 committed 2026-08-22 10:06:21 +00:00
1 parent 3f1544d45e
commit de99931c04
7 files changed
+376 -4

No files matched your search

@@ -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"
@@ -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) {
@@ -935,6 +935,8 @@ const isRenamingGroup = ref(false);
const isRenamingSavedSql = ref(false);
const isRenamingConnection = ref(false);
const renameInput = ref("");
const renameInputRef = ref<HTMLInputElement>();
@@ -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) {
<div ref="trailingCommentLayoutRef" :class="hasTrailingMetadata() ? 'flex flex-1 min-w-0 items-center' : 'contents'">
<div ref="trailingCommentLeadingRef" :class="trailingComment ? 'flex max-w-full min-w-0 shrink-0 items-center gap-2' : formattedObjectStorage() ? 'flex min-w-0 flex-1 items-center gap-2' : 'contents'" :style="alignedCommentLeadingStyle()">
<input
v-if="isRenamingGroup || isRenamingSavedSql"
v-if="isRenamingGroup || isRenamingSavedSql || isRenamingConnection"
ref="renameInputRef"
v-model="renameInput"
class="min-w-0 flex-1 truncate bg-transparent border border-primary/50 rounded px-1 outline-none"
@@ -0,0 +1,192 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, type App } from "vue";
import { createI18n } from "vue-i18n";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import TreeItem from "@/components/sidebar/TreeItem.vue";
import { createSidebarTreeRuntime, sidebarTreeRuntimeKey, type SidebarTreeRuntimeHost } from "@/lib/sidebar/sidebarTreeRuntime";
import type { ConnectionConfig, SidebarLayout, TreeNode } from "@/types/database";
const renameConnection = vi.fn();
const toast = vi.fn();
const connection: ConnectionConfig = {
id: "connection-1",
name: "Reporting",
db_type: "postgres",
host: "127.0.0.1",
port: 5432,
username: "postgres",
password: "",
};
const connectionStore = {
activeConnectionId: connection.id,
connectedIds: new Set([connection.id]),
connectingIds: new Set<string>(),
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<string>() }),
}));
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: "<span />" } }));
vi.mock("@/components/connection/ConnectionErrorIndicator.vue", () => ({ default: { template: "<span />" } }));
vi.mock("@/components/common/ProductionContextBadge.vue", () => ({ default: { template: "<span />" } }));
vi.mock("@/components/ui/badge", () => ({ Badge: { template: "<span><slot /></span>" } }));
vi.mock("@/components/ui/input", () => ({ Input: { template: "<input />" } }));
vi.mock("@/components/ui/switch", () => ({ Switch: { template: "<input />" } }));
vi.mock("@/components/ui/LightTooltip.vue", () => ({ default: { template: "<span><slot /></span>" } }));
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<HTMLInputElement>("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));
});
});
@@ -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> = {}): 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<string, string>();
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<typeof vi.fn>) {
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);
});
});
@@ -2996,6 +2996,20 @@ export const useConnectionStore = defineStore("connection", () => {
}
}
async function renameConnection(connectionId: string, name: string): Promise<boolean> {
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<void> {
const normalized = normalizeDatabaseConnectionInfo(databaseInfo);
if (!normalized) return;
@@ -8400,6 +8414,7 @@ export const useConnectionStore = defineStore("connection", () => {
pasteConnectionClipboard,
addEphemeralConnection,
updateConnection,
renameConnection,
applyGlobalTimeouts,
updateConnectionDatabaseInfo,
setDefaultDatabase,