diff --git a/mssql/localization/l10n/bundle.l10n.json b/mssql/localization/l10n/bundle.l10n.json index db73ac5bac..d7d5ff19cc 100644 --- a/mssql/localization/l10n/bundle.l10n.json +++ b/mssql/localization/l10n/bundle.l10n.json @@ -449,9 +449,9 @@ "message": "({0} rows affected)", "comment": ["{0} is the number of rows affected"] }, - "Result Set {0}/{0} is the index of the result set": { - "message": "Result Set {0}", - "comment": ["{0} is the index of the result set"] + "Result Set Batch {0} - Query {1}/{0} is the batch number{1} is the query number": { + "message": "Result Set Batch {0} - Query {1}", + "comment": ["{0} is the batch number", "{1} is the query number"] }, "Loading text view...": "Loading text view...", "Loading results...": "Loading results...", @@ -1329,6 +1329,10 @@ "Clear cache and refresh token": "Clear cache and refresh token", "Clear token cache": "Clear token cache", "No workspaces found. Please change Fabric account or tenant to view available workspaces.": "No workspaces found. Please change Fabric account or tenant to view available workspaces.", + "Unable to enforce default User Connections group: {0}/{0} is the error message": { + "message": "Unable to enforce default User Connections group: {0}", + "comment": ["{0} is the error message"] + }, "Add Firewall Rule to {0}/{0} is the server name": { "message": "Add Firewall Rule to {0}", "comment": ["{0} is the server name"] diff --git a/mssql/localization/xliff/vscode-mssql.xlf b/mssql/localization/xliff/vscode-mssql.xlf index 3f09fb6f42..449e7c589e 100644 --- a/mssql/localization/xliff/vscode-mssql.xlf +++ b/mssql/localization/xliff/vscode-mssql.xlf @@ -3061,9 +3061,10 @@ Restore properties pane - - Result Set {0} - {0} is the index of the result set + + Result Set Batch {0} - Query {1} + {0} is the batch number +{1} is the query number Results @@ -3892,6 +3893,10 @@ Type + + Unable to enforce default User Connections group: {0} + {0} is the error message + Unable to execute the command while the extension is initializing. Please try again later. diff --git a/mssql/src/connectionconfig/connectionDialogWebviewController.ts b/mssql/src/connectionconfig/connectionDialogWebviewController.ts index d63d89e676..50eb4b593e 100644 --- a/mssql/src/connectionconfig/connectionDialogWebviewController.ts +++ b/mssql/src/connectionconfig/connectionDialogWebviewController.ts @@ -252,6 +252,23 @@ export class ConnectionDialogWebviewController extends FormWebviewController< this.state.connectionProfile.groupId = initialConnectionGroup.id; } + // Enforce default group selection: if groupId is missing or points to ROOT, set to User Connections + try { + const rootGroupId = this._mainController.connectionManager.connectionStore.rootGroupId; + if ( + !this.state.connectionProfile.groupId || + this.state.connectionProfile.groupId === rootGroupId + ) { + const userGroupId = + this._mainController.connectionManager.connectionStore.connectionConfig.getUserConnectionsGroupId(); + if (userGroupId) { + this.state.connectionProfile.groupId = userGroupId; + } + } + } catch (err) { + this.logger.error(Loc.unableToEnforceDefaultUserConnectionsGroup(getErrorMessage(err))); + } + await this.updateItemVisibility(); } @@ -1080,6 +1097,10 @@ export class ConnectionDialogWebviewController extends FormWebviewController< // eslint-disable-next-line @typescript-eslint/no-explicit-any cleanedConnection as any, ); + // Refresh the Object Explorer tree to include new connections/groups + if (self._objectExplorerProvider?.objectExplorerService?.refreshTree) { + await self._objectExplorerProvider.objectExplorerService.refreshTree(); + } const node = await self._mainController.createObjectExplorerSession(cleanedConnection); await self.updateLoadedConnections(state); diff --git a/mssql/src/connectionconfig/connectionconfig.ts b/mssql/src/connectionconfig/connectionconfig.ts index 096ca457c2..04c54cfd5a 100644 --- a/mssql/src/connectionconfig/connectionconfig.ts +++ b/mssql/src/connectionconfig/connectionconfig.ts @@ -9,6 +9,8 @@ import * as Utils from "../models/utils"; import { IConnectionGroup, IConnectionProfile } from "../models/interfaces"; import { IConnectionConfig } from "./iconnectionconfig"; import VscodeWrapper, { ConfigurationTarget } from "../controllers/vscodeWrapper"; + +export { ConfigurationTarget }; import { ConnectionProfile } from "../models/connectionProfile"; import { getConnectionDisplayName } from "../models/connectionInfo"; import { Deferred } from "../protocol"; @@ -20,6 +22,14 @@ export type ConfigTarget = ConfigurationTarget.Global | ConfigurationTarget.Work * Implements connection profile file storage. */ export class ConnectionConfig implements IConnectionConfig { + /** + * Get all connection groups from both user and workspace settings. + */ + public getAllConnectionGroups(): IConnectionGroup[] { + const userGroups = this.getGroupsFromSettings(ConfigurationTarget.Global); + const workspaceGroups = this.getGroupsFromSettings(ConfigurationTarget.Workspace); + return [...userGroups, ...workspaceGroups]; + } protected _logger: Logger; public initialized: Deferred = new Deferred(); @@ -39,13 +49,68 @@ export class ConnectionConfig implements IConnectionConfig { void this.initialize(); } + public getUserConnectionsGroup(): IConnectionGroup | undefined { + const rootGroup = this.getRootGroup(); + if (!rootGroup) return undefined; + const groups = this.getGroupsFromSettings(); + return groups.find((g) => g.name === "User Connections" && g.parentId === rootGroup.id); + } + + public getWorkspaceConnectionsGroup(): IConnectionGroup | undefined { + const rootGroup = this.getRootGroup(); + if (!rootGroup) return undefined; + const groups = this.getAllConnectionGroups(); + return groups.find( + (g) => g.name === "Workspace Connections" && g.parentId === rootGroup.id, + ); + } + + public getUserConnectionsGroupId(): string | undefined { + const group = this.getUserConnectionsGroup(); + return group?.id; + } + + public getWorkspaceConnectionsGroupId(): string | undefined { + const group = this.getWorkspaceConnectionsGroup(); + return group?.id; + } + private async initialize(): Promise { + // Ensure workspace arrays exist + await this.ensureWorkspaceArraysInitialized(); await this.assignConnectionGroupMissingIds(); await this.assignConnectionMissingIds(); this.initialized.resolve(); } + private async ensureWorkspaceArraysInitialized(): Promise { + const workspaceGroups = this.getGroupsFromSettings(ConfigurationTarget.Workspace); + const workspaceConnections = this.getConnectionsFromSettings(ConfigurationTarget.Workspace); + let changed = false; + if (!workspaceGroups || workspaceGroups.length === 0) { + await this._vscodeWrapper.setConfiguration( + Constants.extensionName, + Constants.connectionGroupsArrayName, + [], + ConfigurationTarget.Workspace, + ); + changed = true; + } + if (!workspaceConnections || workspaceConnections.length === 0) { + await this._vscodeWrapper.setConfiguration( + Constants.extensionName, + Constants.connectionsArrayName, + [], + ConfigurationTarget.Workspace, + ); + changed = true; + } + if (changed) { + this._logger.logDebug("Initialized workspace arrays for connections and groups."); + } + } + //#region Connection Profiles /** @@ -117,7 +182,11 @@ export class ConnectionConfig implements IConnectionConfig { } // filter out any connection with a group that isn't defined - const groupIds = new Set((await this.getGroups()).map((g) => g.id)); + // Merge user and workspace groups for group existence check + const userGroups = this.getGroupsFromSettings(ConfigurationTarget.Global); + const workspaceGroups = this.getGroupsFromSettings(ConfigurationTarget.Workspace); + const allGroups = [...userGroups, ...workspaceGroups]; + const groupIds = new Set(allGroups.map((g) => g.id)); profiles = profiles.filter((p) => { if (!groupIds.has(p.groupId)) { this._logger.warn( @@ -139,40 +208,65 @@ export class ConnectionConfig implements IConnectionConfig { return profiles.find((profile) => profile.id === id); } - public async addConnection(profile: IConnectionProfile): Promise { + public async addConnection( + profile: IConnectionProfile, + target: ConfigTarget = ConfigurationTarget.Global, + ): Promise { this.populateMissingConnectionIds(profile); - let profiles = await this.getConnections(false /* getWorkspaceConnections */); + // If the group is Workspace Connections, always use workspace settings + const workspaceGroupId = this.getWorkspaceConnectionsGroupId(); + if (profile.groupId === workspaceGroupId) { + target = ConfigurationTarget.Workspace; + } + + let profiles = this.getConnectionsFromSettings(target); // Remove the profile if already set profiles = profiles.filter((value) => !Utils.isSameProfile(value, profile)); profiles.push(profile); - return await this.writeConnectionsToSettings(profiles); + return await this.writeConnectionsToSettings(profiles, target); } - /** * Remove an existing connection from the connection config if it exists. * @returns true if the connection was removed, false if the connection wasn't found. */ public async removeConnection(profile: IConnectionProfile): Promise { - let profiles = await this.getConnections(false /* getWorkspaceConnections */); + // Determine if this is a workspace connection + let target = ConfigurationTarget.Global; + if (profile.scope === "workspace") { + target = ConfigurationTarget.Workspace; + } + let profiles = this.getConnectionsFromSettings(target); const found = this.removeConnectionHelper(profile, profiles); if (found) { - await this.writeConnectionsToSettings(profiles); + await this.writeConnectionsToSettings(profiles, target); } return found; } public async updateConnection(updatedProfile: IConnectionProfile): Promise { - const profiles = await this.getConnections(false /* getWorkspaceConnections */); + return this.updateConnectionWithTarget(updatedProfile, ConfigurationTarget.Global); + } + + public async updateConnectionWithTarget( + updatedProfile: IConnectionProfile, + target: ConfigTarget, + ): Promise { + // If the group is Workspace Connections, always use workspace settings + const workspaceGroupId = this.getWorkspaceConnectionsGroupId(); + if (updatedProfile.groupId === workspaceGroupId) { + target = ConfigurationTarget.Workspace; + } + const profiles = this.getConnectionsFromSettings(target); const index = profiles.findIndex((p) => p.id === updatedProfile.id); if (index === -1) { throw new Error(`Connection with ID ${updatedProfile.id} not found`); } profiles[index] = updatedProfile; - await this.writeConnectionsToSettings(profiles); + await this.writeConnectionsToSettings(profiles, target); } //#endregion @@ -210,8 +304,11 @@ export class ConnectionConfig implements IConnectionConfig { * @returns The connection group with the specified ID, or `undefined` if not found. */ public getGroupById(id: string): IConnectionGroup | undefined { - const connGroups = this.getGroupsFromSettings(ConfigurationTarget.Global); - return connGroups.find((g) => g.id === id); + // Search both user and workspace groups for the given ID + const userGroups = this.getGroupsFromSettings(ConfigurationTarget.Global); + const workspaceGroups = this.getGroupsFromSettings(ConfigurationTarget.Workspace); + const allGroups = [...userGroups, ...workspaceGroups]; + return allGroups.find((g) => g.id === id); } public addGroup(group: IConnectionGroup): Promise { @@ -219,13 +316,25 @@ export class ConnectionConfig implements IConnectionConfig { group.id = Utils.generateGuid(); } + // If this is Workspace Connections or a child, use workspace settings + const workspaceGroupId = this.getWorkspaceConnectionsGroupId(); + let target: ConfigTarget = ConfigurationTarget.Global; + if (group.parentId === workspaceGroupId || group.id === workspaceGroupId) { + target = ConfigurationTarget.Workspace; + } + if (!group.parentId) { - group.parentId = this.getRootGroup().id; + // If target is workspace, parent should be Workspace Connections group + if (target === ConfigurationTarget.Workspace) { + group.parentId = this.getWorkspaceConnectionsGroupId(); + } else { + group.parentId = this.getUserConnectionsGroupId(); + } } - const groups = this.getGroupsFromSettings(); + const groups = this.getGroupsFromSettings(target); groups.push(group); - return this.writeConnectionGroupsToSettings(groups); + return this.writeConnectionGroupsToSettingsWithTarget(groups, target); } /** @@ -239,8 +348,10 @@ export class ConnectionConfig implements IConnectionConfig { id: string, contentAction: "delete" | "move" = "delete", ): Promise { - const connections = this.getConnectionsFromSettings(); - const groups = this.getGroupsFromSettings(); + // Get all connections and groups from both user and workspace + const userConnections = this.getConnectionsFromSettings(ConfigurationTarget.Global); + const workspaceConnections = this.getConnectionsFromSettings(ConfigurationTarget.Workspace); + const groups = this.getAllConnectionGroups(); const rootGroup = this.getRootGroup(); if (!rootGroup) { @@ -261,18 +372,34 @@ export class ConnectionConfig implements IConnectionConfig { }; let connectionModified = false; - let remainingConnections: IConnectionProfile[]; - let remainingGroups: IConnectionGroup[]; + let remainingUserConnections: IConnectionProfile[] = userConnections.slice(); + let remainingWorkspaceConnections: IConnectionProfile[] = workspaceConnections.slice(); + let remainingUserGroups: IConnectionGroup[] = this.getGroupsFromSettings( + ConfigurationTarget.Global, + ).slice(); + let remainingWorkspaceGroups: IConnectionGroup[] = this.getGroupsFromSettings( + ConfigurationTarget.Workspace, + ).slice(); if (contentAction === "delete") { // Get all nested subgroups to remove const groupsToRemove = getAllSubgroupIds(id); // Remove all connections in the groups being removed - remainingConnections = connections.filter((conn) => { + remainingUserConnections = remainingUserConnections.filter((conn) => { if (groupsToRemove.has(conn.groupId)) { this._logger.verbose( - `Removing connection '${conn.id}' because its group '${conn.groupId}' was removed`, + `Removing user connection '${conn.id}' because its group '${conn.groupId}' was removed`, + ); + connectionModified = true; + return false; + } + return true; + }); + remainingWorkspaceConnections = remainingWorkspaceConnections.filter((conn) => { + if (groupsToRemove.has(conn.groupId)) { + this._logger.verbose( + `Removing workspace connection '${conn.id}' because its group '${conn.groupId}' was removed`, ); connectionModified = true; return false; @@ -281,50 +408,104 @@ export class ConnectionConfig implements IConnectionConfig { }); // Remove all groups that were marked for removal - remainingGroups = groups.filter((g) => !groupsToRemove.has(g.id)); + remainingUserGroups = remainingUserGroups.filter((g) => !groupsToRemove.has(g.id)); + remainingWorkspaceGroups = remainingWorkspaceGroups.filter( + (g) => !groupsToRemove.has(g.id), + ); } else { - // Move immediate child connections to root - remainingConnections = connections.map((conn) => { + // Move immediate child connections and groups to User Connections group + const userGroupId = this.getUserConnectionsGroupId(); + remainingUserConnections = remainingUserConnections.map((conn) => { if (conn.groupId === id) { this._logger.verbose( - `Moving connection '${conn.id}' to root group because its immediate parent group '${id}' was removed`, + `Moving user connection '${conn.id}' to User Connections group because its immediate parent group '${id}' was removed`, ); connectionModified = true; - return { ...conn, groupId: rootGroup.id }; + return { ...conn, groupId: userGroupId }; + } + return conn; + }); + remainingWorkspaceConnections = remainingWorkspaceConnections.map((conn) => { + if (conn.groupId === id) { + this._logger.verbose( + `Moving workspace connection '${conn.id}' to User Connections group because its immediate parent group '${id}' was removed`, + ); + connectionModified = true; + return { ...conn, groupId: userGroupId }; } return conn; }); // First remove the target group - remainingGroups = groups.filter((g) => g.id !== id); + remainingUserGroups = remainingUserGroups.filter((g) => g.id !== id); + remainingWorkspaceGroups = remainingWorkspaceGroups.filter((g) => g.id !== id); - // Then reparent immediate children to root - remainingGroups = remainingGroups.map((g) => { + // Then reparent immediate children to User Connections group + remainingUserGroups = remainingUserGroups.map((g) => { + if (g.parentId === id) { + this._logger.verbose( + `Moving user group '${g.id}' to User Connections group because its immediate parent group '${id}' was removed`, + ); + return { ...g, parentId: userGroupId }; + } + return g; + }); + remainingWorkspaceGroups = remainingWorkspaceGroups.map((g) => { if (g.parentId === id) { this._logger.verbose( - `Moving group '${g.id}' to root group because its immediate parent group '${id}' was removed`, + `Moving workspace group '${g.id}' to User Connections group because its immediate parent group '${id}' was removed`, ); - return { ...g, parentId: rootGroup.id }; + return { ...g, parentId: userGroupId }; } return g; }); } - if (remainingGroups.length === groups.length) { + // If no group was removed, return false + const originalUserGroups = this.getGroupsFromSettings(ConfigurationTarget.Global); + const originalWorkspaceGroups = this.getGroupsFromSettings(ConfigurationTarget.Workspace); + if ( + remainingUserGroups.length === originalUserGroups.length && + remainingWorkspaceGroups.length === originalWorkspaceGroups.length + ) { this._logger.error(`Connection group with ID '${id}' not found when removing.`); return false; } + // Write updated connections and groups to correct settings if (connectionModified) { - await this.writeConnectionsToSettings(remainingConnections); + await this.writeConnectionsToSettings( + remainingUserConnections, + ConfigurationTarget.Global, + ); + await this.writeConnectionsToSettings( + remainingWorkspaceConnections, + ConfigurationTarget.Workspace, + ); } - await this.writeConnectionGroupsToSettings(remainingGroups); + await this.writeConnectionGroupsToSettings(remainingUserGroups); + await this.writeConnectionGroupsToSettingsWithTarget( + remainingWorkspaceGroups, + ConfigurationTarget.Workspace, + ); return true; } public async updateGroup(updatedGroup: IConnectionGroup): Promise { - const groups = this.getGroupsFromSettings(); + return this.updateGroupWithTarget(updatedGroup, ConfigurationTarget.Global); + } + + public async updateGroupWithTarget( + updatedGroup: IConnectionGroup, + target: ConfigTarget, + ): Promise { + // If this is Workspace Connections or a child, use workspace settings + const workspaceGroupId = this.getWorkspaceConnectionsGroupId(); + if (updatedGroup.parentId === workspaceGroupId || updatedGroup.id === workspaceGroupId) { + target = ConfigurationTarget.Workspace; + } + const groups = this.getGroupsFromSettings(target); const index = groups.findIndex((g) => g.id === updatedGroup.id); if (index === -1) { throw Error(`Connection group with ID ${updatedGroup.id} not found when updating`); @@ -332,7 +513,7 @@ export class ConnectionConfig implements IConnectionConfig { groups[index] = updatedGroup; } - return await this.writeConnectionGroupsToSettings(groups); + return await this.writeConnectionGroupsToSettingsWithTarget(groups, target); } //#endregion @@ -378,9 +559,9 @@ export class ConnectionConfig implements IConnectionConfig { // ensure each profile is in a group if (profile.groupId === undefined) { - const rootGroup = this.getRootGroup(); - if (rootGroup) { - profile.groupId = rootGroup.id; + const userGroupId = this.getUserConnectionsGroupId(); + if (userGroupId) { + profile.groupId = userGroupId; modified = true; } } @@ -400,50 +581,112 @@ export class ConnectionConfig implements IConnectionConfig { private async assignConnectionGroupMissingIds(): Promise { let madeChanges = false; - const groups: IConnectionGroup[] = this.getGroupsFromSettings(); - - // ensure ROOT group exists - let rootGroup = await this.getRootGroup(); + // User groups and connections + const userGroups: IConnectionGroup[] = this.getGroupsFromSettings( + ConfigurationTarget.Global, + ); + let userConnections: IConnectionProfile[] = this.getConnectionsFromSettings( + ConfigurationTarget.Global, + ); + // Workspace groups and connections + const workspaceGroups: IConnectionGroup[] = this.getGroupsFromSettings( + ConfigurationTarget.Workspace, + ); + let workspaceConnections: IConnectionProfile[] = this.getConnectionsFromSettings( + ConfigurationTarget.Workspace, + ); + // ensure ROOT group exists in user settings + let rootGroup = userGroups.find((g) => g.name === ConnectionConfig.RootGroupName); if (!rootGroup) { rootGroup = { name: ConnectionConfig.RootGroupName, id: Utils.generateGuid(), }; + userGroups.push(rootGroup); + madeChanges = true; + this._logger.logDebug(`Adding missing ROOT group to user connection groups`); + } + + // Ensure User Connections group exists in user settings + let userConnectionsGroup = userGroups.find( + (g) => g.name === "User Connections" && g.parentId === rootGroup.id, + ); + if (!userConnectionsGroup) { + userConnectionsGroup = { + name: "User Connections", + id: Utils.generateGuid(), + parentId: rootGroup.id, + }; + userGroups.push(userConnectionsGroup); + madeChanges = true; + this._logger.logDebug(`Created 'User Connections' group under ROOT`); + } - this._logger.logDebug(`Adding missing ROOT group to connection groups`); + // Ensure Workspace Connections group exists in workspace settings, parented to ROOT (user) + let workspaceConnectionsGroup = workspaceGroups.find( + (g) => g.name === "Workspace Connections" && g.parentId === rootGroup.id, + ); + if (!workspaceConnectionsGroup) { + workspaceConnectionsGroup = { + name: "Workspace Connections", + id: Utils.generateGuid(), + parentId: rootGroup.id, + }; + workspaceGroups.push(workspaceConnectionsGroup); madeChanges = true; - groups.push(rootGroup); + this._logger.logDebug(`Created 'Workspace Connections' group under ROOT (user)`); } - // Clean up connection groups - for (const group of groups) { - if (group.id === rootGroup.id) { - continue; + // Reparent all workspace groups directly under ROOT to Workspace Connections group + for (const group of workspaceGroups) { + if (group.parentId === rootGroup.id && group.id !== workspaceConnectionsGroup.id) { + group.parentId = workspaceConnectionsGroup.id; + madeChanges = true; + this._logger.logDebug( + `Reparented workspace group '${group.name}' to 'Workspace Connections'`, + ); } + } - // ensure each group has an ID - if (!group.id) { - group.id = Utils.generateGuid(); + // Reparent any existing USER connections that are still directly under ROOT (legacy ) to User Connections group + for (const conn of userConnections) { + if (!conn.groupId || conn.groupId === rootGroup.id) { + conn.groupId = userConnectionsGroup.id; madeChanges = true; - this._logger.logDebug(`Adding missing ID to connection group '${group.name}'`); + this._logger.logDebug( + `Reparented legacy user connection '${getConnectionDisplayName(conn)}' from ROOT to 'User Connections'`, + ); } + } - // ensure each group is in a group - if (!group.parentId) { - group.parentId = rootGroup.id; + // Reparent all workspace connections directly under ROOT to Workspace Connections group + for (const conn of workspaceConnections) { + if (!conn.groupId || conn.groupId === rootGroup.id) { + conn.groupId = workspaceConnectionsGroup.id; madeChanges = true; - this._logger.logDebug(`Adding missing parentId to connection '${group.name}'`); + this._logger.logDebug( + `Reparented workspace connection '${getConnectionDisplayName(conn)}' to 'Workspace Connections'`, + ); } } - // Save the changes to settings + // Save changes to settings if (madeChanges) { + this._logger.logDebug(`Writing updated user groups and connections to user settings.`); + await this.writeConnectionGroupsToSettings(userGroups); + await this.writeConnectionsToSettings(userConnections); this._logger.logDebug( - `Updates made to connection groups. Writing all ${groups.length} group(s) to settings.`, + `Writing updated workspace groups and connections to workspace settings.`, + ); + await this.writeConnectionGroupsToSettingsWithTarget( + workspaceGroups, + ConfigurationTarget.Workspace, + ); + await this.writeConnectionsToSettings( + workspaceConnections, + ConfigurationTarget.Workspace, ); - - await this.writeConnectionGroupsToSettings(groups); } } @@ -494,30 +737,76 @@ export class ConnectionConfig implements IConnectionConfig { public getGroupsFromSettings( configLocation: ConfigTarget = ConfigurationTarget.Global, ): IConnectionGroup[] { - return this.getArrayFromSettings( + const groups = this.getArrayFromSettings( Constants.connectionGroupsArrayName, configLocation, ); + // Ensure scope is set for legacy groups + const expectedScope = + configLocation === ConfigurationTarget.Workspace ? "workspace" : "user"; + let changed = false; + for (const group of groups) { + if (!group.scope) { + group.scope = expectedScope; + changed = true; + } + } + // If any legacy group was updated, write back + if (changed) { + if (configLocation === ConfigurationTarget.Workspace) { + void this.writeConnectionGroupsToSettingsWithTarget( + groups, + ConfigurationTarget.Workspace, + ); + } else { + void this.writeConnectionGroupsToSettings(groups); + } + } + return groups; } /** * Replace existing profiles in the user settings with a new set of profiles. * @param profiles the set of profiles to insert into the settings file. */ - private async writeConnectionsToSettings(profiles: IConnectionProfile[]): Promise { - // Save the file + private async writeConnectionsToSettings( + profiles: IConnectionProfile[], + target: ConfigTarget = ConfigurationTarget.Global, + ): Promise { + // Ensure scope is set before writing + const expectedScope = target === ConfigurationTarget.Workspace ? "workspace" : "user"; + for (const conn of profiles) { + conn.scope = conn.scope || expectedScope; + } await this._vscodeWrapper.setConfiguration( Constants.extensionName, Constants.connectionsArrayName, profiles, + target, ); } private async writeConnectionGroupsToSettings(connGroups: IConnectionGroup[]): Promise { + return this.writeConnectionGroupsToSettingsWithTarget( + connGroups, + ConfigurationTarget.Global, + ); + } + + private async writeConnectionGroupsToSettingsWithTarget( + connGroups: IConnectionGroup[], + target: ConfigTarget, + ): Promise { + // Ensure scope is set before writing + const expectedScope = target === ConfigurationTarget.Workspace ? "workspace" : "user"; + for (const group of connGroups) { + group.scope = group.scope || expectedScope; + } await this._vscodeWrapper.setConfiguration( Constants.extensionName, Constants.connectionGroupsArrayName, connGroups, + target, ); } diff --git a/mssql/src/constants/locConstants.ts b/mssql/src/constants/locConstants.ts index c1965d5705..88b65ed9ab 100644 --- a/mssql/src/constants/locConstants.ts +++ b/mssql/src/constants/locConstants.ts @@ -746,6 +746,14 @@ export class ConnectionDialog { public static noWorkspacesFound = l10n.t( "No workspaces found. Please change Fabric account or tenant to view available workspaces.", ); + + public static unableToEnforceDefaultUserConnectionsGroup(error: string) { + return l10n.t({ + message: "Unable to enforce default User Connections group: {0}", + args: [error], + comment: ["{0} is the error message"], + }); + } } export class FirewallRule { diff --git a/mssql/src/controllers/connectionGroupWebviewController.ts b/mssql/src/controllers/connectionGroupWebviewController.ts index 3d21a07369..e297ce9b73 100644 --- a/mssql/src/controllers/connectionGroupWebviewController.ts +++ b/mssql/src/controllers/connectionGroupWebviewController.ts @@ -77,44 +77,58 @@ export class ConnectionGroupWebviewController extends ReactWebviewPanelControlle return state; }); - this.registerReducer("saveConnectionGroup", async (state, payload) => { - try { - if (this.connectionGroupToEdit) { - this.logger.verbose("Updating existing connection group", payload); - await this.connectionConfig.updateGroup({ - ...this.connectionGroupToEdit, - name: payload.name, - description: payload.description, - color: payload.color, - }); - } else { - this.logger.verbose("Creating new connection group", payload); - await this.connectionConfig.addGroup(createConnectionGroupFromSpec(payload)); - } + this.registerReducer( + "saveConnectionGroup", + async (state, payload: ConnectionGroupSpec & { scope: "user" | "workspace" }) => { + try { + if (this.connectionGroupToEdit) { + this.logger.verbose("Updating existing connection group", payload); + // Only update name, description, color; parentId and scope are not editable for existing groups + await this.connectionConfig.updateGroup({ + ...this.connectionGroupToEdit, + name: payload.name, + description: payload.description, + color: payload.color, + }); + } else { + this.logger.verbose("Creating new connection group", payload); + // Set parentId based on scope + let parentId: string | undefined; + if (payload.scope === "workspace") { + parentId = this.connectionConfig.getWorkspaceConnectionsGroupId(); + } else { + parentId = this.connectionConfig.getUserConnectionsGroupId(); + } + const groupSpec = { ...payload, parentId }; + await this.connectionConfig.addGroup( + createConnectionGroupFromSpec(groupSpec), + ); + } - sendActionEvent( - TelemetryViews.ConnectionGroup, - TelemetryActions.SaveConnectionGroup, - { newOrEdit: this.connectionGroupToEdit ? "edit" : "new" }, - ); + sendActionEvent( + TelemetryViews.ConnectionGroup, + TelemetryActions.SaveConnectionGroup, + { newOrEdit: this.connectionGroupToEdit ? "edit" : "new" }, + ); - this.dialogResult.resolve(true); - await this.panel.dispose(); - } catch (err) { - state.message = getErrorMessage(err); - sendErrorEvent( - TelemetryViews.ConnectionGroup, - TelemetryActions.SaveConnectionGroup, - err, - true, // includeErrorMessage - undefined, // errorCode - undefined, // errorType - { newOrEdit: this.connectionGroupToEdit ? "edit" : "new" }, - ); - } + this.dialogResult.resolve(true); + await this.panel.dispose(); + } catch (err) { + state.message = getErrorMessage(err); + sendErrorEvent( + TelemetryViews.ConnectionGroup, + TelemetryActions.SaveConnectionGroup, + err, + true, // includeErrorMessage + undefined, // errorCode + undefined, // errorType + { newOrEdit: this.connectionGroupToEdit ? "edit" : "new" }, + ); + } - return state; - }); + return state; + }, + ); } } @@ -124,6 +138,8 @@ export function createConnectionGroupFromSpec(spec: ConnectionGroupState): IConn description: spec.description, color: spec.color, id: Utils.generateGuid(), + parentId: + "parentId" in spec && spec.parentId !== undefined ? String(spec.parentId) : undefined, }; } diff --git a/mssql/src/models/connectionStore.ts b/mssql/src/models/connectionStore.ts index 76b7d50cd4..5fa71ce6c9 100644 --- a/mssql/src/models/connectionStore.ts +++ b/mssql/src/models/connectionStore.ts @@ -20,7 +20,7 @@ import { IConnectionGroup, } from "./interfaces"; import { ICredentialStore } from "../credentialstore/icredentialstore"; -import { ConnectionConfig } from "../connectionconfig/connectionconfig"; +import { ConnectionConfig, ConfigurationTarget } from "../connectionconfig/connectionconfig"; import VscodeWrapper from "../controllers/vscodeWrapper"; import { IConnectionInfo } from "vscode-mssql"; import { Logger } from "./logger"; @@ -369,6 +369,17 @@ export class ConnectionStore { ): Promise { await this._connectionConfig.populateMissingConnectionIds(profile); + // Determine the correct target for saving based on groupId + let target = ConfigurationTarget.Global; + // Get all workspace group IDs + const workspaceGroups = this._connectionConfig.getGroupsFromSettings( + ConfigurationTarget.Workspace, + ); + const workspaceGroupIds = new Set(workspaceGroups.map((g) => g.id)); + if (workspaceGroupIds.has(profile.groupId)) { + target = ConfigurationTarget.Workspace; + } + // Add the profile to the saved list, taking care to clear out the password field if necessary let savedProfile: IConnectionProfile; if (profile.authenticationType === Utils.authTypeToString(AuthenticationTypes.AzureMFA)) { @@ -383,7 +394,7 @@ export class ConnectionStore { } } - await this._connectionConfig.addConnection(savedProfile); + await this._connectionConfig.addConnection(savedProfile, target); if (await this.saveProfilePasswordIfNeeded(profile)) { ConnInfo.fixupConnectionCredentials(profile); diff --git a/mssql/src/models/interfaces.ts b/mssql/src/models/interfaces.ts index 4010f0ff71..c9d657cead 100644 --- a/mssql/src/models/interfaces.ts +++ b/mssql/src/models/interfaces.ts @@ -68,6 +68,7 @@ export interface IConnectionProfile extends vscodeMssql.IConnectionInfo { accountStore: AccountStore; isValidProfile(): boolean; isAzureActiveDirectory(): boolean; + scope?: "user" | "workspace"; } export interface IConnectionGroup { @@ -76,6 +77,7 @@ export interface IConnectionGroup { parentId?: string; color?: string; description?: string; + scope?: "user" | "workspace"; } export enum CredentialsQuickPickItemType { diff --git a/mssql/src/objectExplorer/objectExplorerDragAndDropController.ts b/mssql/src/objectExplorer/objectExplorerDragAndDropController.ts index ea3fb3593c..6e33c8e7ec 100644 --- a/mssql/src/objectExplorer/objectExplorerDragAndDropController.ts +++ b/mssql/src/objectExplorer/objectExplorerDragAndDropController.ts @@ -48,6 +48,15 @@ export class ObjectExplorerDragAndDropController ): void { const item = source[0]; // Handle only the first item for simplicity + // Prevent dragging User Connections and Workspace Connections groups + if ( + item instanceof ConnectionGroupNode && + (item.label === "User Connections" || item.label === "Workspace Connections") + ) { + // Do not set drag data, effectively disabling drag + return; + } + if (item instanceof ConnectionNode || item instanceof ConnectionGroupNode) { const dragData: ObjectExplorerDragMetadata = { name: item.label.toString(), @@ -83,23 +92,32 @@ export class ObjectExplorerDragAndDropController return; } + // Prevent dropping User Connections or Workspace Connections groups anywhere + const userGroupId = this.connectionStore.connectionConfig.getUserConnectionsGroupId(); + const workspaceGroupId = + this.connectionStore.connectionConfig.getWorkspaceConnectionsGroupId(); + if ( + dragData.type === "connectionGroup" && + (dragData.id === userGroupId || dragData.id === workspaceGroupId) + ) { + // Do nothing, prevent drop + return; + } + + // Allow dropping child items into User Connections or Workspace Connections groups + + // Prevent drag-and-drop if target is root + if (target === undefined) { + return; + } + try { if (dragData.isConnectionOrGroup && dragData.type && dragData.id) { - if (target instanceof ConnectionGroupNode || target === undefined) { - let targetInfo: { label: string; id: string }; - - // If the target is undefined, we're dropping onto the root of the Object Explorer - if (target === undefined) { - targetInfo = { - label: "ROOT", - id: this.connectionStore.rootGroupId, - }; - } else { - targetInfo = { - label: target.label.toString(), - id: target.id, - }; - } + if (target instanceof ConnectionGroupNode) { + let targetInfo: { label: string; id: string } = { + label: target.label.toString(), + id: target.id, + }; this._logger.verbose( `Dragged ${dragData.type} '${dragData.name}' (ID: ${dragData.id}) onto group '${targetInfo.label}' (ID: ${targetInfo.id})`, @@ -110,24 +128,47 @@ export class ObjectExplorerDragAndDropController dragData.id, ); conn.groupId = targetInfo.id; - await this.connectionStore.connectionConfig.updateConnection(conn); + // Set scope based on target group + const targetGroup = this.connectionStore.connectionConfig.getGroupById( + targetInfo.id, + ); + conn.scope = targetGroup?.scope || "user"; + const configTarget = + conn.scope === "workspace" + ? vscode.ConfigurationTarget.Workspace + : vscode.ConfigurationTarget.Global; + await this.connectionStore.connectionConfig.updateConnectionWithTarget( + conn, + configTarget, + ); } else { const group = this.connectionStore.connectionConfig.getGroupById( dragData.id, ); - if (group.id === targetInfo.id) { this._logger.verbose("Cannot move group into itself; skipping."); return; } group.parentId = targetInfo.id; - await this.connectionStore.connectionConfig.updateGroup(group); + // Set scope based on target group + const targetGroup = this.connectionStore.connectionConfig.getGroupById( + targetInfo.id, + ); + group.scope = targetGroup?.scope || "user"; + const configTarget = + group.scope === "workspace" + ? vscode.ConfigurationTarget.Workspace + : vscode.ConfigurationTarget.Global; + await this.connectionStore.connectionConfig.updateGroupWithTarget( + group, + configTarget, + ); } sendActionEvent(TelemetryViews.ObjectExplorer, TelemetryActions.DragAndDrop, { dragType: dragData.type, - dropTarget: target ? "connectionGroup" : "ROOT", + dropTarget: "connectionGroup", }); } } diff --git a/mssql/src/objectExplorer/objectExplorerService.ts b/mssql/src/objectExplorer/objectExplorerService.ts index 9a7a15f984..a032629396 100644 --- a/mssql/src/objectExplorer/objectExplorerService.ts +++ b/mssql/src/objectExplorer/objectExplorerService.ts @@ -31,7 +31,7 @@ import { AddConnectionTreeNode } from "./nodes/addConnectionTreeNode"; import { AccountSignInTreeNode } from "./nodes/accountSignInTreeNode"; import { ConnectTreeNode, TreeNodeType } from "./nodes/connectTreeNode"; import { Deferred } from "../protocol"; -import * as Constants from "../constants/constants"; +// import * as Constants from "../constants/constants"; import { ObjectExplorerUtils } from "./objectExplorerUtils"; import * as Utils from "../models/utils"; import { ConnectionCredentials } from "../models/connectionCredentials"; @@ -82,7 +82,6 @@ export class ObjectExplorerService { const result = []; const rootId = this._connectionManager.connectionStore.rootGroupId; - if (!this._connectionGroupNodes.has(rootId)) { this._logger.verbose( "Root server group is not defined. Cannot get root nodes for Object Explorer.", @@ -90,13 +89,30 @@ export class ObjectExplorerService { return []; } - for (const child of this._connectionGroupNodes.get(rootId)?.children || []) { - result.push(child); - } + // Always show both User Connections and Workspace Connections as children of ROOT + const rootChildren = this._connectionGroupNodes.get(rootId)?.children || []; + let userGroup = rootChildren.find((child) => child.label === "User Connections"); + let workspaceGroup = rootChildren.find((child) => child.label === "Workspace Connections"); + if (userGroup) result.push(userGroup); + if (workspaceGroup) result.push(workspaceGroup); return result; } + /** + * Public method to refresh the Object Explorer tree and internal maps, merging user and workspace connections/groups. + * Call this after adding/removing connections/groups to ensure the tree is up to date. + */ + public async refreshTree(): Promise { + await this.getRootNodes(); + // Optionally, trigger a UI refresh if needed + if (this._refreshCallback && this._rootTreeNodeArray.length > 0) { + for (const node of this._rootTreeNodeArray) { + this._refreshCallback(node); + } + } + } + /** * Map of pending session creations */ @@ -368,16 +384,22 @@ export class ObjectExplorerService { ); const rootId = this._connectionManager.connectionStore.rootGroupId; - const serverGroups = - await this._connectionManager.connectionStore.readAllConnectionGroups(); + // Read user and workspace groups separately + const userGroups = + this._connectionManager.connectionStore.connectionConfig.getGroupsFromSettings(); + // Import ConfigurationTarget from the correct module + // Use VscodeWrapper.ConfigurationTarget.Workspace + const { ConfigurationTarget } = require("../controllers/vscodeWrapper"); + const workspaceGroups = + this._connectionManager.connectionStore.connectionConfig.getGroupsFromSettings( + ConfigurationTarget.Workspace, + ); + // Merge user and workspace groups before building hierarchy + const allGroups = [...userGroups, ...workspaceGroups]; let savedConnections = await this._connectionManager.connectionStore.readAllConnections(); // if there are no saved connections, show the add connection node - if ( - savedConnections.length === 0 && - serverGroups.length === 1 && - serverGroups[0].id === rootId - ) { + if (savedConnections.length === 0 && allGroups.length === 1 && allGroups[0].id === rootId) { this._logger.verbose( "No saved connections or groups found. Showing add connection node.", ); @@ -387,69 +409,37 @@ export class ObjectExplorerService { return this.getAddConnectionNodes(); } + // Build group nodes from merged settings const newConnectionGroupNodes = new Map(); - const newConnectionNodes = new Map(); - - // Add all group nodes from settings first - // Read the user setting for collapsed/expanded state - const config = vscode.workspace.getConfiguration(Constants.extensionName); - const collapseGroups = config.get( - Constants.cmdObjectExplorerCollapseOrExpandByDefault, - false, - ); - - for (const group of serverGroups) { - // Pass the desired collapsible state to the ConnectionGroupNode constructor - const initialState = collapseGroups - ? vscode.TreeItemCollapsibleState.Collapsed - : vscode.TreeItemCollapsibleState.Expanded; - const groupNode = new ConnectionGroupNode(group, initialState); - + for (const group of allGroups) { + const groupNode = new ConnectionGroupNode(group); if (this._connectionGroupNodes.has(group.id)) { groupNode.id = this._connectionGroupNodes.get(group.id).id; } - newConnectionGroupNodes.set(group.id, groupNode); } - // Populate group hierarchy - add each group as a child to its parent - for (const group of serverGroups) { - // Skip the root group as it has no parent - if (group.id === rootId) { - continue; - } - + // Build hierarchy: add each group as a child to its parent + for (const group of allGroups) { + if (group.id === rootId) continue; if (group.parentId && newConnectionGroupNodes.has(group.parentId)) { const parentNode = newConnectionGroupNodes.get(group.parentId); const childNode = newConnectionGroupNodes.get(group.id); - if (parentNode && childNode) { parentNode.addChild(childNode); - if (parentNode.id !== rootId) { - // set the parent node for the child group unless the parent is the root group - // parent property is used to childNode.parentNode = parentNode; } - } else { - this._logger.error( - `Child group '${group.name}' with ID '${group.id}' does not have a valid parent group (${group.parentId}).`, - ); } - } else { - this._logger.error( - `Group '${group.name}' with ID '${group.id}' does not have a valid parent group ID. This should have been corrected when reading server groups from settings.`, - ); } } // Add connections as children of their respective groups + const newConnectionNodes = new Map(); for (const connection of savedConnections) { if (connection.groupId && newConnectionGroupNodes.has(connection.groupId)) { const groupNode = newConnectionGroupNodes.get(connection.groupId); - let connectionNode: ConnectionNode; - if (this._connectionNodes.has(connection.id)) { connectionNode = this._connectionNodes.get(connection.id); connectionNode.updateConnectionProfile(connection); @@ -460,27 +450,24 @@ export class ObjectExplorerService { groupNode.id === rootId ? undefined : groupNode, ); } - connectionNode.parentNode = groupNode.id === rootId ? undefined : groupNode; - newConnectionNodes.set(connection.id, connectionNode); groupNode.addChild(connectionNode); - } else { - this._logger.error( - `Connection '${getConnectionDisplayName(connection)}' with ID '${connection.id}' does not have a valid group ID. This should have been corrected when reading connections from settings.`, - ); } } + // Set the new maps before refreshing UI this._connectionGroupNodes = newConnectionGroupNodes; this._connectionNodes = newConnectionNodes; - const result = [...this._rootTreeNodeArray]; - + // For the ROOT node, include as children any group whose parentId matches rootId + const rootChildren = Array.from(newConnectionGroupNodes.values()).filter( + (groupNode) => groupNode.connectionGroup.parentId === rootId, + ); getConnectionActivity.end(ActivityStatus.Succeeded, undefined, { - nodeCount: result.length, + nodeCount: rootChildren.length, }); - return result; + return rootChildren; } /** diff --git a/mssql/src/reactviews/common/searchableDropdown.component.tsx b/mssql/src/reactviews/common/searchableDropdown.component.tsx index 8dc95e5873..82b6231b6c 100644 --- a/mssql/src/reactviews/common/searchableDropdown.component.tsx +++ b/mssql/src/reactviews/common/searchableDropdown.component.tsx @@ -138,9 +138,7 @@ const searchOptions = (text: string, items: SearchableDropdownOptions[]) => { export const SearchableDropdown = (props: SearchableDropdownProps) => { const [searchText, setSearchText] = useState(""); const [selectedOption, setSelectedOption] = useState( - props.selectedOption ?? { - value: "", - }, + props.selectedOption ?? props.options[0] ?? { value: "", text: props.placeholder ?? "" }, ); const id = props.id ?? useId(); @@ -296,11 +294,14 @@ export const SearchableDropdown = (props: SearchableDropdownProps) => { }, [buttonRef.current]); useEffect(() => { - setSelectedOption(props.selectedOption ?? props.options[0]); + setSelectedOption( + props.selectedOption ?? + props.options[0] ?? { value: "", text: props.placeholder ?? "" }, + ); setSelectedOptionIndex( props.options.findIndex((opt) => opt.value === props.selectedOption?.value), ); - }, [props.selectedOption]); + }, [props.selectedOption, props.options, props.placeholder]); return ( { setSelectedDatabase(db); setConnectionProperty("database", db ?? ""); }, - placeholder: `<${Loc.connectionDialog.default}>`, + // Formerly showed "" (root) placeholder; now omit since root selection is disallowed + placeholder: "", invalidOptionErrorMessage: Loc.connectionDialog.invalidAzureBrowse( Loc.connectionDialog.database, diff --git a/mssql/src/reactviews/pages/ConnectionDialog/connectionFormPage.tsx b/mssql/src/reactviews/pages/ConnectionDialog/connectionFormPage.tsx index 36f78c0be4..48f1709c75 100644 --- a/mssql/src/reactviews/pages/ConnectionDialog/connectionFormPage.tsx +++ b/mssql/src/reactviews/pages/ConnectionDialog/connectionFormPage.tsx @@ -13,6 +13,8 @@ import { ConnectionDialogWebviewState, IConnectionDialogProfile, } from "../../../sharedInterfaces/connectionDialog"; +// import { SearchableDropdownOptions } from "../../common/searchableDropdown.component"; +// import { IConnectionGroup } from "../../../sharedInterfaces/connectionGroup"; import { ConnectButton } from "./components/connectButton.component"; import { locConstants } from "../../common/locConstants"; import { AdvancedOptionsDrawer } from "./components/advancedOptionsDrawer.component"; @@ -26,15 +28,58 @@ export const ConnectionFormPage = () => { return undefined; } + // Helper to flatten group hierarchy for dropdown, excluding ROOT group + // function getGroupOptions(): SearchableDropdownOptions[] { + // if (!context?.state?.connectionGroups) return []; + // // Find the root group id (assuming name is "ROOT") + // const rootGroup = context.state.connectionGroups.find((g) => g.name === "ROOT"); + // const rootGroupId = rootGroup?.id; + // // Recursively build hierarchical options, skipping ROOT + // function buildOptions( + // groups: IConnectionGroup[], + // parentId?: string, + // prefix: string = "", + // ): SearchableDropdownOptions[] { + // return groups + // .filter((g) => g.parentId === parentId && g.id !== rootGroupId && g.name !== "ROOT") + // .flatMap((g) => { + // const label = prefix ? `${prefix} / ${g.name}` : g.name; + // const children = buildOptions(groups, g.id, label); + // return [{ key: g.id, text: label, value: g.id }, ...children]; + // }); + // } + + // // Start from rootGroupId if available, otherwise undefined + // return buildOptions(context.state.connectionGroups, rootGroupId ?? undefined); + // } + + // Selected group state + // const [selectedGroup, setSelectedGroup] = useState(getGroupOptions()[0]?.value ?? ""); + return (
+ {/* Connection Group Dropdown */} + {/*
+ + o.value === selectedGroup)} + onSelect={(option: SearchableDropdownOptions) => setSelectedGroup(option.value)} + placeholder="Select a group" + /> +
*/} + {/* Existing connection form fields */} {context.state.connectionComponents.mainOptions.map((inputName, idx) => { const component = context.state.formComponents[inputName as keyof IConnectionDialogProfile]; if (component?.hidden !== false) { return undefined; } - return ( { + // Explicitly remove undefined from the possible type so parameters are contextually typed + const handleChange: NonNullable = (_event, data) => { setColor({ ...data.color, a: 1 }); }; @@ -117,6 +125,7 @@ export const ConnectionGroupDialog = ({ color: new TinyColor(color).toHexString(false /* allow3Char */).toUpperCase() || undefined, + scope, }); } } @@ -146,25 +155,42 @@ export const ConnectionGroupDialog = ({
)}{" "} + + setScope(data.optionValue as "user" | "workspace")}> + + + + { + onChange={( + _e: React.ChangeEvent, + data: InputOnChangeData, + ) => { setGroupName(data.value); }} required placeholder={Loc.connectionGroups.enterConnectionGroupName} /> - {" "} +