diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index cdced5ed0a..3614b6d83a 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -260,6 +260,20 @@ const emit = defineEmits<{ }>(); const store = useConnectionStore(); +const UNGROUPED_CONNECTION_GROUP = "__dbx_ungrouped_connection_group__"; +const selectedConnectionGroupId = ref(null); +const connectionGroupSelectValue = computed({ + get: () => selectedConnectionGroupId.value ?? UNGROUPED_CONNECTION_GROUP, + set: (value: string) => { + selectedConnectionGroupId.value = value === UNGROUPED_CONNECTION_GROUP ? null : value; + }, +}); +const connectionGroupOptions = computed(() => store.connectionGroupOptions.map((group) => ({ id: group.id, label: group.path.join(" / ") }))); + +function initialConnectionGroupId(): string | null { + const preferred = store.newConnectionGroupId ?? store.selectedConnectionGroupId; + return preferred && connectionGroupOptions.value.some((group) => group.id === preferred) ? preferred : null; +} const tunnelProfileStore = useTunnelProfileStore(); const isTesting = ref(false); const isSaving = ref(false); @@ -2582,6 +2596,7 @@ watch( } else { clearSavedDatabaseInfo(); editingId.value = null; + selectedConnectionGroupId.value = initialConnectionGroupId(); form.value = defaultForm(); redisKeyTemplatesText.value = ""; productionProtectionEnabled.value = false; @@ -4818,6 +4833,7 @@ function openJdbcDriverManagerFromError() { function resetForm(options: { preservePickerState?: boolean } = {}) { editingId.value = null; + selectedConnectionGroupId.value = initialConnectionGroupId(); form.value = defaultForm(); redisKeyTemplatesText.value = ""; resetConnectionNoteVisibilityDraft(connectionNoteVisibilityDraft, settingsStore.editorSettings.sidebarShowConnectionNotes); @@ -4976,6 +4992,10 @@ watch( { immediate: true }, ); +watch(connectionGroupOptions, (groups) => { + if (selectedConnectionGroupId.value && !groups.some((group) => group.id === selectedConnectionGroupId.value)) selectedConnectionGroupId.value = null; +}); + watch( () => props.prefillConfig, (draft) => { @@ -5231,7 +5251,7 @@ async function save() { await ensureRequiredAgentDriverInstalled(config); await ensureRequiredGaussdbMJdbcRuntime(config); await persistGlobalTimeoutDrafts(); - await store.addConnection(config); + await store.addConnection(config, selectedConnectionGroupId.value); connectionSaved = true; await persistConnectionNoteVisibilityDraft(); draftTestConnectionId.value = uuid(); @@ -5846,35 +5866,49 @@ function openExternalUrl(url: string) {
-
- - - -
- - -
-
- +
+
+ + + +
+ + +
+
+ +
+
+ + +
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index d66826961f..0f593d57e7 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -2348,6 +2348,7 @@ export default { }, connectionGroup: { createGroup: "New Group", + group: "Group", groupNamePlaceholder: "Group name", selectConnection: "Select connection", deselectConnection: "Deselect connection", @@ -2362,7 +2363,7 @@ export default { moveToGroup: "Move to Group", moveToNewGroup: "Move to New Group", ungrouped: "Ungrouped", - ungroupedLabel: "Ungrouped", + ungroupedLabel: "Default", newGroup: "New Group...", newGroupDefault: "New Group", deleteGroupConfirmTitle: "Delete Group", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index b25759adf5..a454c85225 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -2283,6 +2283,7 @@ export default withEnglishFallback({ }, connectionGroup: { createGroup: "Nuevo grupo", + group: "Grupo", groupNamePlaceholder: "Nombre del grupo", renameGroup: "Renombrar grupo", closeConnections: "Cerrar conexiones del grupo ({count})", @@ -2291,7 +2292,7 @@ export default withEnglishFallback({ moveToGroup: "Mover al grupo", moveToNewGroup: "Mover a nuevo grupo", ungrouped: "Sin grupo", - ungroupedLabel: "Sin grupo", + ungroupedLabel: "Predeterminado", newGroup: "Nuevo grupo...", newGroupDefault: "Nuevo grupo", deleteGroupConfirmTitle: "Eliminar grupo", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index c250009257..3e8db1de4f 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -2281,6 +2281,7 @@ export default withEnglishFallback({ }, connectionGroup: { createGroup: "Nuovo Gruppo", + group: "Gruppo", groupNamePlaceholder: "Nome gruppo", renameGroup: "Rinomina Gruppo", closeConnections: "Chiudi le connessioni del gruppo ({count})", @@ -2289,7 +2290,7 @@ export default withEnglishFallback({ moveToGroup: "Sposta nel Gruppo", moveToNewGroup: "Sposta in un Nuovo Gruppo", ungrouped: "Non raggruppati", - ungroupedLabel: "Non raggruppati", + ungroupedLabel: "Predefinito", newGroup: "Nuovo Gruppo...", newGroupDefault: "Nuovo Gruppo", deleteGroupConfirmTitle: "Elimina Gruppo", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 04550a4121..dc274808b7 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -2308,6 +2308,7 @@ export default withEnglishFallback({ }, connectionGroup: { createGroup: "新しいグループ", + group: "グループ", groupNamePlaceholder: "グループ名", renameGroup: "グループ名を変更", closeConnections: "グループ内の接続を閉じる({count})", @@ -2316,7 +2317,7 @@ export default withEnglishFallback({ moveToGroup: "グループに移動", moveToNewGroup: "新しいグループに移動", ungrouped: "グループなし", - ungroupedLabel: "グループなし", + ungroupedLabel: "デフォルト", newGroup: "新しいグループ...", newGroupDefault: "新しいグループ", deleteGroupConfirmTitle: "グループを削除", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 81c642c061..2100ca811e 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -2182,6 +2182,7 @@ export default withEnglishFallback({ }, connectionGroup: { createGroup: "새 그룹", + group: "그룹", groupNamePlaceholder: "그룹 이름", selectConnection: "연결 선택", deselectConnection: "연결 선택 해제", @@ -2196,7 +2197,7 @@ export default withEnglishFallback({ moveToGroup: "그룹으로 이동", moveToNewGroup: "새 그룹으로 이동", ungrouped: "그룹 없음", - ungroupedLabel: "그룹 없음", + ungroupedLabel: "기본", newGroup: "새 그룹...", newGroupDefault: "새 그룹", deleteGroupConfirmTitle: "그룹 삭제", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index d478636a20..3ac09794ed 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -2283,6 +2283,7 @@ export default withEnglishFallback({ }, connectionGroup: { createGroup: "Novo Grupo", + group: "Grupo", groupNamePlaceholder: "Nome do grupo", renameGroup: "Renomear Grupo", closeConnections: "Fechar conexões do grupo ({count})", @@ -2291,7 +2292,7 @@ export default withEnglishFallback({ moveToGroup: "Mover para Grupo", moveToNewGroup: "Mover para Novo Grupo", ungrouped: "Sem grupo", - ungroupedLabel: "Sem grupo", + ungroupedLabel: "Padrão", newGroup: "Novo Grupo...", newGroupDefault: "Novo Grupo", deleteGroupConfirmTitle: "Excluir Grupo", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 9da9619608..d6c4f1da2a 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -2272,6 +2272,7 @@ export default withEnglishFallback({ }, connectionGroup: { createGroup: "新建分组", + group: "分组", groupNamePlaceholder: "分组名称", selectConnection: "选择连接", deselectConnection: "取消选择连接", @@ -2286,7 +2287,7 @@ export default withEnglishFallback({ moveToGroup: "移至分组", moveToNewGroup: "移至新分组", ungrouped: "取消分组", - ungroupedLabel: "未分组", + ungroupedLabel: "默认", newGroup: "新建分组...", newGroupDefault: "新分组", deleteGroupConfirmTitle: "删除分组", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 1d27815187..15df2a6fae 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -2282,6 +2282,7 @@ export default withEnglishFallback({ }, connectionGroup: { createGroup: "建立群組", + group: "群組", groupNamePlaceholder: "群組名稱", renameGroup: "重新命名群組", closeConnections: "關閉群組內連線({count})", @@ -2290,7 +2291,7 @@ export default withEnglishFallback({ moveToGroup: "移至群組", moveToNewGroup: "移至新群組", ungrouped: "取消群組", - ungroupedLabel: "未分組", + ungroupedLabel: "預設", newGroup: "新增群組……", newGroupDefault: "新群組", deleteGroupConfirmTitle: "刪除群組", diff --git a/apps/desktop/src/lib/__tests__/sidebar/sidebarLayout.spec.ts b/apps/desktop/src/lib/__tests__/sidebar/sidebarLayout.spec.ts index be0c6f9abf..b39b1634c7 100644 --- a/apps/desktop/src/lib/__tests__/sidebar/sidebarLayout.spec.ts +++ b/apps/desktop/src/lib/__tests__/sidebar/sidebarLayout.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { connectionIdsInGroups, deleteGroups, findConnectionGroupPath, reorderEntries } from "@/lib/sidebar/sidebarLayout"; +import { connectionGroupDestinationRows, connectionGroupIdForSelection, connectionIdsInGroups, deleteGroups, findConnectionGroupPath, reorderEntries } from "@/lib/sidebar/sidebarLayout"; import type { SidebarLayout } from "@/types/database"; const layout: SidebarLayout = { @@ -203,3 +203,24 @@ describe("sorted sidebar move-only drops", () => { }); }); }); + +describe("connection group selection", () => { + it("lists nested groups in sidebar order with complete paths", () => { + expect(connectionGroupDestinationRows(layout)).toEqual([ + { id: "project", name: "Project", depth: 0, path: ["Project"] }, + { id: "staging", name: "Staging", depth: 1, path: ["Project", "Staging"] }, + ]); + }); + + it("uses the focused group or the group containing a focused connection", () => { + expect(connectionGroupIdForSelection(layout, "staging")).toBe("staging"); + expect(connectionGroupIdForSelection(layout, "nested", "nested")).toBe("staging"); + expect(connectionGroupIdForSelection(layout, "grouped")).toBe("project"); + }); + + it("falls back to ungrouped for root or unknown selections", () => { + expect(connectionGroupIdForSelection(layout, "root", "root")).toBeNull(); + expect(connectionGroupIdForSelection(layout, "missing")).toBeNull(); + expect(connectionGroupIdForSelection(layout)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/lib/sidebar/sidebarLayout.ts b/apps/desktop/src/lib/sidebar/sidebarLayout.ts index 22407b9a5f..aad1571f3a 100644 --- a/apps/desktop/src/lib/sidebar/sidebarLayout.ts +++ b/apps/desktop/src/lib/sidebar/sidebarLayout.ts @@ -367,6 +367,14 @@ export function connectionGroupDestinationRows(layout: SidebarLayout): Connectio return rows; } +/** Resolve the group implied by a selected group, connection, or descendant node. */ +export function connectionGroupIdForSelection(layout: SidebarLayout, selectedNodeId?: string | null, selectedConnectionId?: string | null): string | null { + if (selectedNodeId && connectionGroupDestinationRows(layout).some((group) => group.id === selectedNodeId)) return selectedNodeId; + const connectionId = selectedConnectionId || selectedNodeId; + if (!connectionId) return null; + return findConnectionLocation(layout, connectionId)?.groupId ?? null; +} + function findGroupEntry(entries: SidebarOrderEntry[], groupId: string): Extract | null { for (const entry of entries) { if (entry.type !== "group") continue; diff --git a/apps/desktop/src/stores/__tests__/connectionStore.createInGroup.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.createInGroup.spec.ts new file mode 100644 index 0000000000..1d4ea34a62 --- /dev/null +++ b/apps/desktop/src/stores/__tests__/connectionStore.createInGroup.spec.ts @@ -0,0 +1,110 @@ +import { createPinia, setActivePinia } from "pinia"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ConnectionConfig, SidebarLayout } from "@/types/database"; + +function connection(id: string): ConnectionConfig { + return { + id, + name: id, + db_type: "mysql", + host: "127.0.0.1", + port: 3306, + username: "root", + password: "", + connect_timeout_secs: 10, + query_timeout_secs: 30, + connect_timeout_inherit: false, + query_timeout_inherit: false, + }; +} + +const groupedLayout: SidebarLayout = { + groups: [ + { id: "parent", name: "Parent", collapsed: false }, + { id: "child", name: "Child", collapsed: false }, + ], + order: [ + { + type: "group", + id: "parent", + children: [{ type: "group", id: "child", children: [{ type: "connection", id: "existing" }] }], + }, + ], +}; + +describe("connectionStore creating connections in groups", () => { + beforeEach(() => { + vi.resetModules(); + vi.stubGlobal("localStorage", { + getItem: vi.fn(() => null), + setItem: vi.fn(), + removeItem: vi.fn(), + }); + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + loadEditorSettings: vi.fn().mockResolvedValue(null), + saveConnections: vi.fn().mockResolvedValue(undefined), + saveEditorSettings: vi.fn().mockResolvedValue(undefined), + saveSidebarLayout: vi.fn().mockResolvedValue(undefined), + })); + setActivePinia(createPinia()); + }); + + it("places a new connection in the explicitly selected nested group", async () => { + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + store.sidebarLayout = structuredClone(groupedLayout); + + await store.addConnection(connection("created"), "child"); + + expect(store.groupIdForConnection("created")).toBe("child"); + expect(store.connectionGroupPaths.get("created")).toEqual(["Parent", "Child"]); + }); + + it("places a new connection at the top level when ungrouped is selected", async () => { + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + store.sidebarLayout = structuredClone(groupedLayout); + store.startCreatingConnectionInGroup("child"); + + await store.addConnection(connection("created"), null); + + expect(store.groupIdForConnection("created")).toBeNull(); + expect(store.sidebarLayout.order.at(-1)).toEqual({ type: "connection", id: "created" }); + expect(store.newConnectionGroupId).toBeNull(); + }); + + it("derives the default group from a selected descendant node", async () => { + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + store.sidebarLayout = structuredClone(groupedLayout); + store.addEphemeralConnection(connection("existing")); + store.treeNodes = [ + { + id: "parent", + label: "Parent", + type: "connection-group", + children: [ + { + id: "child", + label: "Child", + type: "connection-group", + children: [ + { + id: "existing", + label: "existing", + type: "connection", + connectionId: "existing", + children: [{ id: "existing:demo", label: "demo", type: "database", connectionId: "existing" }], + }, + ], + }, + ], + }, + ]; + + store.selectedTreeNodeId = "existing:demo"; + + expect(store.selectedConnectionGroupId).toBe("child"); + }); +}); diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index df56653bbc..f3c32e9e5f 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -58,6 +58,8 @@ import { reorderEntry as reorderEntryOp, reorderEntries as reorderEntriesOp, buildConnectionGroupPathMap, + connectionGroupDestinationRows, + connectionGroupIdForSelection, connectionSidebarSearchAliases, type DropPosition, type ReorderEntriesOptions, @@ -557,6 +559,12 @@ export const useConnectionStore = defineStore("connection", () => { } | null>(null); const sidebarLayout = ref(emptyLayout()); const connectionGroupPaths = computed(() => buildConnectionGroupPathMap(sidebarLayout.value)); + const connectionGroupOptions = computed(() => connectionGroupDestinationRows(sidebarLayout.value)); + const selectedConnectionGroupId = computed(() => { + const selectedNodeId = selectedTreeNodeId.value; + const selectedNode = selectedNodeId ? findNode(treeNodes.value, selectedNodeId) : null; + return connectionGroupIdForSelection(sidebarLayout.value, selectedNodeId, selectedNode?.connectionId); + }); let layoutPersistTimer: ReturnType | null = null; const staleTreeRefreshIds = new Set(); const activeTreeRefreshGenerations = new Map(); @@ -8749,6 +8757,8 @@ export const useConnectionStore = defineStore("connection", () => { recordConnectionLostError, sidebarLayout, connectionGroupPaths, + connectionGroupOptions, + selectedConnectionGroupId, getConfig, connectionIdentifierQuote, isTreeNodePinned,