From e08d138a16de3eeeb74176f2d02e07de011e2209 Mon Sep 17 00:00:00 2001 From: Aasim Khan Date: Wed, 25 Feb 2026 16:53:55 -0800 Subject: [PATCH 01/23] Add cancel connection feature with UI support and command registration --- extensions/mssql/media/cancelConnect_dark.svg | 23 +++++++++++++++++++ .../mssql/media/cancelConnect_light.svg | 23 +++++++++++++++++++ extensions/mssql/package.json | 18 +++++++++++++-- extensions/mssql/package.nls.json | 1 + extensions/mssql/src/constants/constants.ts | 1 + .../src/controllers/connectionManager.ts | 19 +++++++++++++++ .../mssql/src/controllers/mainController.ts | 15 ++++++++++++ 7 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 extensions/mssql/media/cancelConnect_dark.svg create mode 100644 extensions/mssql/media/cancelConnect_light.svg diff --git a/extensions/mssql/media/cancelConnect_dark.svg b/extensions/mssql/media/cancelConnect_dark.svg new file mode 100644 index 0000000000..659dcc0699 --- /dev/null +++ b/extensions/mssql/media/cancelConnect_dark.svg @@ -0,0 +1,23 @@ + + + + + cancelConnect_dark + + + + diff --git a/extensions/mssql/media/cancelConnect_light.svg b/extensions/mssql/media/cancelConnect_light.svg new file mode 100644 index 0000000000..d41e066a45 --- /dev/null +++ b/extensions/mssql/media/cancelConnect_light.svg @@ -0,0 +1,23 @@ + + + + + cancelConnect_light + + + + diff --git a/extensions/mssql/package.json b/extensions/mssql/package.json index 66defdebfb..4920a6fc1b 100644 --- a/extensions/mssql/package.json +++ b/extensions/mssql/package.json @@ -367,7 +367,7 @@ "editor/title": [ { "command": "mssql.runQuery", - "when": "editorLangId == sql && !isInDiffEditor && resourcePath not in mssql.runningQueries", + "when": "editorLangId == sql && !isInDiffEditor && resourcePath not in mssql.runningQueries && resource not in mssql.connecting", "group": "navigation@1" }, { @@ -382,7 +382,12 @@ }, { "command": "mssql.connect", - "when": "editorLangId == sql && !isInDiffEditor && resource not in mssql.connections", + "when": "editorLangId == sql && !isInDiffEditor && resource not in mssql.connections && resource not in mssql.connecting", + "group": "navigation@3" + }, + { + "command": "mssql.cancelConnect", + "when": "editorLangId == sql && !isInDiffEditor && resource in mssql.connecting && resourcePath not in mssql.runningQueries", "group": "navigation@3" }, { @@ -959,6 +964,15 @@ "category": "MS SQL", "icon": "$(debug-disconnect)" }, + { + "command": "mssql.cancelConnect", + "title": "%mssql.cancelConnect%", + "category": "MS SQL", + "icon": { + "dark": "media/cancelConnect_dark.svg", + "light": "media/cancelConnect_light.svg" + } + }, { "command": "mssql.filterNode", "title": "%mssql.filterNode%", diff --git a/extensions/mssql/package.nls.json b/extensions/mssql/package.nls.json index 7a2aec1986..6e914c250e 100644 --- a/extensions/mssql/package.nls.json +++ b/extensions/mssql/package.nls.json @@ -35,6 +35,7 @@ "extension.queryResult": "Query Results", "mssql.connect": "Connect", "mssql.disconnect": "Disconnect", + "mssql.cancelConnect": "Cancel Connection", "mssql.manageProfiles": "Manage Connection Profiles", "mssql.clearPooledConnections": "Clear Pooled Connections", "mssql.chooseDatabase": "Use Database", diff --git a/extensions/mssql/src/constants/constants.ts b/extensions/mssql/src/constants/constants.ts index 17135501ee..78b143e217 100644 --- a/extensions/mssql/src/constants/constants.ts +++ b/extensions/mssql/src/constants/constants.ts @@ -37,6 +37,7 @@ export const cmdrevealQueryResult = "mssql.revealQueryResult"; export const cmdCopyAll = "mssql.copyAll"; export const cmdConnect = "mssql.connect"; export const cmdDisconnect = "mssql.disconnect"; +export const cmdCancelConnect = "mssql.cancelConnect"; export const cmdChangeConnection = "mssql.changeConnection"; export const cmdChangeDatabase = "mssql.changeDatabase"; export const cmdChooseDatabase = "mssql.chooseDatabase"; diff --git a/extensions/mssql/src/controllers/connectionManager.ts b/extensions/mssql/src/controllers/connectionManager.ts index cd05a6f7a0..6557b8cb14 100644 --- a/extensions/mssql/src/controllers/connectionManager.ts +++ b/extensions/mssql/src/controllers/connectionManager.ts @@ -938,6 +938,23 @@ export default class ConnectionManager { }) .filter((key): key is string => !!key), ); + + vscode.commands.executeCommand( + "setContext", + "mssql.connecting", + Object.keys(this._connections) + .filter((key) => this.isConnecting(key)) + .map((key) => { + try { + key = vscode.Uri.parse(key).toString(); + } catch { + // ignore invalid URIs (for example OE-only keys) in context resource list + return undefined; + } + return key; + }) + .filter((key): key is string => !!key), + ); } /** @@ -1226,6 +1243,7 @@ export default class ConnectionManager { this._connections[fileUri] = connectionInfo; this._onConnectionsChangedEmitter.fire(); + this.updateConnectionsContext(); // Note: must call flavor changed before connecting, or the timer showing an animation doesn't occur if (this.statusView) { @@ -1361,6 +1379,7 @@ export default class ConnectionManager { ), ); this._onConnectionsChangedEmitter.fire(); + this.updateConnectionsContext(); connectionActivity.endFailed( new Error(result.errorMessage), false, // Do not include error message diff --git a/extensions/mssql/src/controllers/mainController.ts b/extensions/mssql/src/controllers/mainController.ts index 15d339e62b..d32f8a626d 100644 --- a/extensions/mssql/src/controllers/mainController.ts +++ b/extensions/mssql/src/controllers/mainController.ts @@ -252,6 +252,10 @@ export default class MainController implements vscode.Disposable { this._event.on(Constants.cmdDisconnect, () => { void this.runAndLogErrors(this.onDisconnect()); }); + this.registerCommand(Constants.cmdCancelConnect); + this._event.on(Constants.cmdCancelConnect, () => { + void this.runAndLogErrors(this.onCancelConnect()); + }); this.registerCommand(Constants.cmdRunQuery); this._event.on(Constants.cmdRunQuery, () => { void UserSurvey.getInstance().promptUserForNPSFeedback("runQuery"); @@ -2345,6 +2349,17 @@ export default class MainController implements vscode.Disposable { return false; } + /** + * Cancel an in-progress connection, if any + */ + private async onCancelConnect(): Promise { + if (this.canRunCommand() && this.validateTextDocumentHasFocus()) { + await this._connectionMgr.onCancelConnect(); + return true; + } + return false; + } + /** * Manage connection profiles (create, edit, remove). * Public for testing purposes From 45a42318c284fce14f1605c982572efaddc468e1 Mon Sep 17 00:00:00 2001 From: Aasim Khan Date: Wed, 25 Feb 2026 16:54:10 -0800 Subject: [PATCH 02/23] Add translation for "Cancel Connection" in localization file --- localization/xliff/vscode-mssql.xlf | 3 +++ 1 file changed, 3 insertions(+) diff --git a/localization/xliff/vscode-mssql.xlf b/localization/xliff/vscode-mssql.xlf index bdab78411d..038460db94 100644 --- a/localization/xliff/vscode-mssql.xlf +++ b/localization/xliff/vscode-mssql.xlf @@ -6656,6 +6656,9 @@ Backup Database (Preview) + + Cancel Connection + Cancel Query From 87305caa6169b4d7c5e8b559b64816c896d18900 Mon Sep 17 00:00:00 2001 From: Aasim Khan Date: Wed, 25 Feb 2026 21:42:57 -0800 Subject: [PATCH 03/23] Refactor query execution handling and update UI components for improved performance and clarity --- .../mssql/src/controllers/queryRunner.ts | 19 +- .../src/models/sqlOutputContentProvider.ts | 10 +- .../queryResultWebViewController.ts | 33 +- .../queryResultWebviewPanelController.ts | 1 + .../src/reactviews/common/locConstants.ts | 10 + .../pages/QueryResult/queryResultPane.tsx | 7 +- .../QueryResult/queryResultSummaryFooter.tsx | 516 ++++++++++++++++++ .../mssql/src/sharedInterfaces/queryResult.ts | 4 + extensions/mssql/src/views/statusView.ts | 99 +--- .../mssql/test/unit/queryRunner.test.ts | 3 +- 10 files changed, 570 insertions(+), 132 deletions(-) create mode 100644 extensions/mssql/src/reactviews/pages/QueryResult/queryResultSummaryFooter.tsx diff --git a/extensions/mssql/src/controllers/queryRunner.ts b/extensions/mssql/src/controllers/queryRunner.ts index b90952c145..c3d9e4bc7e 100644 --- a/extensions/mssql/src/controllers/queryRunner.ts +++ b/extensions/mssql/src/controllers/queryRunner.ts @@ -458,10 +458,6 @@ export default class QueryRunner { this._uriToQueryPromiseMap.delete(result.ownerUri); } this._statusView.executedQuery(result.ownerUri); - this._statusView.setExecutionTime( - result.ownerUri, - Utils.parseNumAsTimeString(this._totalElapsedMilliseconds), - ); let hasError = this._batchSets.some((batch) => batch.hasError === true); this.removeRunningQuery(); this._completeEmitter.fire({ @@ -566,13 +562,6 @@ export default class QueryRunner { // Send the message to the results pane this._messageEmitter.fire(message); - - // Set row count on status bar if there are no errors - if (!obj.message.isError) { - this._statusView.showRowCount(obj.ownerUri, obj.message.message); - } else { - this._statusView.hideRowCount(obj.ownerUri, true); - } } /** @@ -993,6 +982,8 @@ export default class QueryRunner { text: `$(play-circle) ${LocalizedConstants.QueryResult.summaryFetchConfirmation(totalRows)}`, tooltip: LocalizedConstants.QueryResult.clickToFetchSummary, uri: this.uri, + batchId, + resultId, }); await proceed.promise; }; @@ -1008,6 +999,8 @@ export default class QueryRunner { text: `$(loading~spin) ${LocalizedConstants.QueryResult.summaryLoadingProgress(totalRows)}`, tooltip: LocalizedConstants.QueryResult.clickToCancelLoadingSummary, uri: this.uri, + batchId, + resultId, }); }; @@ -1119,6 +1112,8 @@ export default class QueryRunner { uri: this.uri, command: undefined, continue: undefined, + batchId, + resultId, }); } catch (error) { // Clean up on error @@ -1134,6 +1129,8 @@ export default class QueryRunner { uri: this.uri, command: undefined, continue: undefined, + batchId, + resultId, }); throw error; } diff --git a/extensions/mssql/src/models/sqlOutputContentProvider.ts b/extensions/mssql/src/models/sqlOutputContentProvider.ts index 2c4360ff6e..9cea8a3c38 100644 --- a/extensions/mssql/src/models/sqlOutputContentProvider.ts +++ b/extensions/mssql/src/models/sqlOutputContentProvider.ts @@ -432,6 +432,8 @@ export class SqlOutputContentProvider { executionPlanState: {}, messages: [], fontSettings: { fontSize: 0, fontFamily: "" }, + isExecuting: false, + executionStartTime: undefined, }); }); @@ -442,6 +444,8 @@ export class SqlOutputContentProvider { resultWebviewState.tabStates.resultPaneTab = QueryResultPaneTabs.Messages; resultWebviewState.isExecutionPlan = false; resultWebviewState.initializationError = undefined; + resultWebviewState.isExecuting = true; + resultWebviewState.executionStartTime = Date.now(); this.updateWebviewState(queryRunner.uri, resultWebviewState); this.revealQueryResult(queryRunner.uri); sendActionEvent(TelemetryViews.QueryResult, TelemetryActions.OpenQueryResult, { @@ -548,6 +552,8 @@ export class SqlOutputContentProvider { const resultWebviewState = this._queryResultWebviewController.getQueryResultState( queryRunner.uri, ); + resultWebviewState.isExecuting = false; + resultWebviewState.executionStartTime = undefined; resultWebviewState.messages.push({ message: LocalizedConstants.elapsedTimeLabel(totalMilliseconds), isError: false, // Elapsed time messages are never displayed as errors @@ -612,8 +618,10 @@ export class SqlOutputContentProvider { command: e.command, tooltip: e.tooltip, continue: e.continue, + batchId: e.batchId, + resultId: e.resultId, }; - this._queryResultWebviewController.updateSelectionSummary(); + this.updateWebviewState(e.uri, state); }); const queryRunnerState = new QueryRunnerState(queryRunner); diff --git a/extensions/mssql/src/queryResult/queryResultWebViewController.ts b/extensions/mssql/src/queryResult/queryResultWebViewController.ts index 79dac0521e..3f9b7cc0af 100644 --- a/extensions/mssql/src/queryResult/queryResultWebViewController.ts +++ b/extensions/mssql/src/queryResult/queryResultWebViewController.ts @@ -38,8 +38,6 @@ export class QueryResultWebviewController extends ReactWebviewViewController< private _queryResultWebviewPanelControllerMap: Map = new Map(); private _correlationId: string = randomUUID(); - private _selectionSummaryStatusBarItem: vscode.StatusBarItem = - vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 2); public actualPlanStatuses: string[] = []; private _sqlDocumentService: SqlDocumentService; @@ -58,14 +56,13 @@ export class QueryResultWebviewController extends ReactWebviewViewController< executionPlanState: {}, fontSettings: {}, autoSizeColumnsMode: qr.ResultsGridAutoSizeStyle.HeadersAndData, + isExecuting: false, }); void this.initialize(); context.subscriptions.push( vscode.window.onDidChangeActiveTextEditor((editor) => { - this.updateSelectionSummary(); - const uri = getUriKey(editor?.document?.uri); const hasPanel = uri && this.hasPanel(uri); const hasWebviewViewState = uri && this._queryResultStateMap.has(uri); @@ -239,6 +236,7 @@ export class QueryResultWebviewController extends ReactWebviewViewController< tabStates: undefined, isExecutionPlan: false, executionPlanState: {}, + isExecuting: false, fontSettings: { fontSize: this.getFontSizeConfig(), @@ -297,6 +295,7 @@ export class QueryResultWebviewController extends ReactWebviewViewController< }, autoSizeColumnsMode: this.getAutoSizeColumnsConfig(), inMemoryDataProcessingThreshold: getInMemoryGridDataProcessingThreshold(), + isExecuting: false, } as qr.QueryResultWebviewState; this._queryResultStateMap.set(uri, currentState); } @@ -475,30 +474,4 @@ export class QueryResultWebviewController extends ReactWebviewViewController< }); return total; } - - public updateSelectionSummary() { - let activeUri = Array.from(this._queryResultWebviewPanelControllerMap.keys()).find( - (uri) => this._queryResultWebviewPanelControllerMap.get(uri).panel.active, - ); - - if (!activeUri) { - activeUri = getUriKey(vscode.window.activeTextEditor?.document.uri); - } - - if (!this._queryResultStateMap.has(activeUri)) { - this._selectionSummaryStatusBarItem.hide(); - return; - } - - const state = this._queryResultStateMap.get(activeUri); - - if (state?.selectionSummary) { - this._selectionSummaryStatusBarItem.text = state.selectionSummary.text; - this._selectionSummaryStatusBarItem.tooltip = state.selectionSummary.tooltip; - this._selectionSummaryStatusBarItem.command = state.selectionSummary.command; - this._selectionSummaryStatusBarItem.show(); - } else { - this._selectionSummaryStatusBarItem.hide(); - } - } } diff --git a/extensions/mssql/src/queryResult/queryResultWebviewPanelController.ts b/extensions/mssql/src/queryResult/queryResultWebviewPanelController.ts index e647e778dc..a7392fab4b 100644 --- a/extensions/mssql/src/queryResult/queryResultWebviewPanelController.ts +++ b/extensions/mssql/src/queryResult/queryResultWebviewPanelController.ts @@ -38,6 +38,7 @@ export class QueryResultWebviewPanelController extends ReactWebviewPanelControll }, executionPlanState: {}, fontSettings: {}, + isExecuting: false, }, { title: title, diff --git a/extensions/mssql/src/reactviews/common/locConstants.ts b/extensions/mssql/src/reactviews/common/locConstants.ts index 679240cf21..dccee0eeff 100644 --- a/extensions/mssql/src/reactviews/common/locConstants.ts +++ b/extensions/mssql/src/reactviews/common/locConstants.ts @@ -686,6 +686,16 @@ export class LocConstants { }); } }, + noRowsAffected: l10n.t("No rows affected"), + selectedItemLabel: l10n.t("Selected"), + rowsAffectedLabel: l10n.t("Rows"), + timeLabel: l10n.t("Time"), + runningLabel: l10n.t("Running"), + executionLabel: l10n.t("Execution"), + noSelectionSummary: l10n.t("No selection"), + executionCancelled: l10n.t("Execution cancelled"), + executionTimeUnavailable: l10n.t("Execution time unavailable"), + totalExecutionTimePrefix: l10n.t("Total execution time:"), resultSet: (batchNumber: number, queryNumber: number) => l10n.t({ message: "Result Set Batch {0} - Query {1}", diff --git a/extensions/mssql/src/reactviews/pages/QueryResult/queryResultPane.tsx b/extensions/mssql/src/reactviews/pages/QueryResult/queryResultPane.tsx index de3bae8373..97348e139e 100644 --- a/extensions/mssql/src/reactviews/pages/QueryResult/queryResultPane.tsx +++ b/extensions/mssql/src/reactviews/pages/QueryResult/queryResultPane.tsx @@ -28,6 +28,7 @@ import { QueryExecutionPlanTab } from "./queryExecutionPlanTab"; import { QueryResultsTab } from "./queryResultsTab"; import { useVscodeWebview } from "../../common/vscodeWebviewProvider"; import { eventMatchesShortcut } from "../../common/keyboardUtils"; +import { QueryResultSummaryFooter } from "./queryResultSummaryFooter"; const useStyles = makeStyles({ root: { @@ -69,7 +70,8 @@ const useStyles = makeStyles({ }, noResultsContainer: { width: "100%", - height: "100%", + flex: 1, + minHeight: 0, display: "flex", alignItems: "center", justifyContent: "center", @@ -207,6 +209,7 @@ export const QueryResultPane = () => { {initilizationError} + ); } @@ -245,6 +248,7 @@ export const QueryResultPane = () => { )} + ); } @@ -342,6 +346,7 @@ export const QueryResultPane = () => { + ); }; diff --git a/extensions/mssql/src/reactviews/pages/QueryResult/queryResultSummaryFooter.tsx b/extensions/mssql/src/reactviews/pages/QueryResult/queryResultSummaryFooter.tsx new file mode 100644 index 0000000000..b52141e0f5 --- /dev/null +++ b/extensions/mssql/src/reactviews/pages/QueryResult/queryResultSummaryFooter.tsx @@ -0,0 +1,516 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Tooltip, makeStyles } from "@fluentui/react-components"; +import { Fragment, useContext, useEffect, useMemo, useState } from "react"; +import { ExecuteCommandRequest } from "../../../sharedInterfaces/webview"; +import * as qr from "../../../sharedInterfaces/queryResult"; +import { locConstants } from "../../common/locConstants"; +import { useQueryResultSelector } from "./queryResultSelector"; +import { QueryResultCommandsContext } from "./queryResultStateProvider"; + +const useStyles = makeStyles({ + footer: { + position: "sticky", + bottom: 0, + zIndex: 3, + width: "100%", + minHeight: "26px", + boxSizing: "border-box", + display: "flex", + alignItems: "center", + gap: "6px", + padding: "3px 10px", + borderTop: "1px solid var(--vscode-editorWidget-border)", + backgroundColor: "var(--vscode-sideBar-background)", + }, + metricsGroup: { + minWidth: 0, + display: "flex", + alignItems: "center", + flexShrink: 0, + gap: "8px", + }, + metric: { + minWidth: 0, + display: "flex", + alignItems: "baseline", + gap: "6px", + }, + spacer: { + minWidth: 0, + flex: "1 1 auto", + }, + label: { + flexShrink: 0, + fontSize: "10px", + color: "var(--vscode-descriptionForeground)", + textTransform: "uppercase", + letterSpacing: "0.08em", + }, + value: { + minWidth: 0, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + fontSize: "11px", + color: "var(--vscode-foreground)", + fontWeight: 600, + }, + divider: { + color: "var(--vscode-editorWidget-border)", + flexShrink: 0, + }, + rowsAccent: { + color: "var(--vscode-textLink-foreground)", + }, + timeAccent: { + color: "var(--vscode-terminal-ansiYellow)", + }, + selectionSegment: { + minWidth: 0, + display: "flex", + alignItems: "center", + gap: "8px", + color: "var(--vscode-descriptionForeground)", + }, + selectionValue: { + minWidth: 0, + maxWidth: "42vw", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + fontSize: "11px", + color: "var(--vscode-foreground)", + fontWeight: 600, + }, + selectionValueButton: { + minWidth: 0, + maxWidth: "42vw", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + textAlign: "left", + border: "none", + background: "none", + padding: 0, + margin: 0, + color: "var(--vscode-textLink-foreground)", + cursor: "pointer", + fontSize: "11px", + fontWeight: 600, + }, + selectionTooltipText: { + whiteSpace: "pre-line", + }, + selectionMetricLabel: { + color: "var(--vscode-descriptionForeground)", + }, + selectionMetricValue: { + color: "var(--vscode-textLink-foreground)", + }, + cancelled: { + color: "var(--vscode-errorForeground)", + }, +}); + +function getFirstResultSetRowCount( + summaries: Record>, +): number | undefined { + for (const batch of Object.values(summaries ?? {})) { + for (const result of Object.values(batch ?? {})) { + if (typeof result?.rowCount === "number") { + return result.rowCount; + } + } + } + return undefined; +} + +function getActiveResultSetRowCount( + summaries: Record>, + selectionSummary?: qr.SelectionSummary, +): number | undefined { + if ( + selectionSummary?.batchId !== undefined && + selectionSummary?.resultId !== undefined && + typeof summaries?.[selectionSummary.batchId]?.[selectionSummary.resultId]?.rowCount === + "number" + ) { + return summaries[selectionSummary.batchId][selectionSummary.resultId].rowCount; + } + + return getFirstResultSetRowCount(summaries); +} + +function getRowsAffectedFromMessages(messages: qr.IMessage[]): number | undefined { + const rowsAffectedRegex = /\(?\s*(\d+)\s+rows?\s+affected\s*\)?/i; + for (let i = messages.length - 1; i >= 0; i--) { + const text = messages[i]?.message; + if (!text) { + continue; + } + const match = text.match(rowsAffectedRegex); + if (match && match[1] !== undefined) { + const parsed = Number(match[1]); + if (!Number.isNaN(parsed)) { + return parsed; + } + } + } + return undefined; +} + +function getLatestExecutionTimeMessage(messages: qr.IMessage[]): string | undefined { + const prefix = locConstants.queryResult.totalExecutionTimePrefix; + for (let i = messages.length - 1; i >= 0; i--) { + const text = messages[i]?.message; + if (!text) { + continue; + } + if (text.startsWith(prefix) || /execution\s+time/i.test(text)) { + return text; + } + } + return undefined; +} + +function hasCancellationMessage(messages: qr.IMessage[]): boolean { + return messages.some((message) => /cancel(?:ed|led|ing)?/i.test(message?.message ?? "")); +} + +function normalizeStatusText(text?: string): string { + if (!text) { + return ""; + } + return text.replace(/\$\([^)]+\)\s*/g, "").trim(); +} + +function normalizeExecutionText(text: string): string { + return text.replace(locConstants.queryResult.totalExecutionTimePrefix, "").trim(); +} + +function parseTimeStringToMilliseconds(value: string): number | undefined { + const match = value.match(/(\d+):(\d{2}):(\d{2})(?:\.(\d{1,3}))?/); + if (!match) { + return undefined; + } + + const hours = Number(match[1]); + const minutes = Number(match[2]); + const seconds = Number(match[3]); + const milliseconds = Number((match[4] ?? "0").padEnd(3, "0").slice(0, 3)); + + if ([hours, minutes, seconds, milliseconds].some((num) => Number.isNaN(num))) { + return undefined; + } + + return hours * 3600000 + minutes * 60000 + seconds * 1000 + milliseconds; +} + +function formatMillisecondsCompact(milliseconds: number): string { + if (milliseconds < 1000) { + return `${milliseconds}ms`; + } + + if (milliseconds < 60000) { + const seconds = milliseconds / 1000; + return seconds < 10 ? `${seconds.toFixed(1)}s` : `${Math.round(seconds)}s`; + } + + if (milliseconds < 3600000) { + const minutes = Math.floor(milliseconds / 60000); + const seconds = Math.round((milliseconds % 60000) / 1000); + if (seconds === 0) { + return `${minutes}m`; + } + if (seconds === 60) { + return `${minutes + 1}m`; + } + return `${minutes}m ${seconds}s`; + } + + const hours = Math.floor(milliseconds / 3600000); + const minutes = Math.round((milliseconds % 3600000) / 60000); + if (minutes === 0) { + return `${hours}h`; + } + if (minutes === 60) { + return `${hours + 1}h`; + } + return `${hours}h ${minutes}m`; +} + +function formatExecutionTextCompact(text: string): string { + const normalized = normalizeExecutionText(text); + const timeMatch = normalized.match(/\d+:\d{2}:\d{2}(?:\.\d{1,3})?/); + if (!timeMatch) { + return normalized; + } + + const totalMilliseconds = parseTimeStringToMilliseconds(timeMatch[0]); + if (totalMilliseconds === undefined) { + return normalized; + } + + return normalized.replace(timeMatch[0], formatMillisecondsCompact(totalMilliseconds)); +} + +function formatRunningTimeCompact(milliseconds: number): string { + if (milliseconds < 1000) { + return locConstants.queryResult.runningLabel; + } + + if (milliseconds < 60000) { + return `${Math.floor(milliseconds / 1000)}s`; + } + + if (milliseconds < 3600000) { + const minutes = Math.floor(milliseconds / 60000); + const seconds = Math.floor((milliseconds % 60000) / 1000); + return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; + } + + const hours = Math.floor(milliseconds / 3600000); + const minutes = Math.floor((milliseconds % 3600000) / 60000); + return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`; +} + +function abbreviateSummaryText(text: string): string { + if (!text) { + return ""; + } + + return text + .replace(/\bDistinct Count\b/gi, "DISTINCT") + .replace(/\bNull Count\b/gi, "NULL") + .replace(/\bAverage\b/gi, "AVG") + .replace(/\bCount\b/gi, "COUNT") + .replace(/\bSum\b/gi, "SUM") + .replace(/\bMin\b/gi, "MIN") + .replace(/\bMax\b/gi, "MAX"); +} + +const METRIC_ORDER = ["COUNT", "AVG", "SUM", "MIN", "MAX", "DISTINCT", "NULL"] as const; + +function isZeroMetricValue(value: string): boolean { + const normalized = value.replace(/,/g, "").trim(); + const parsed = Number(normalized); + return Number.isFinite(parsed) && parsed === 0; +} + +function parseSelectionMetrics(text: string): Array<{ label: string; value: string }> { + const metricMap = new Map(); + const discoveredOrder: string[] = []; + const metricRegex = /([A-Z]+):\s*([^:\n]+?)(?=(?:\s{2,}[A-Z]+:)|$|\n)/g; + let match: RegExpExecArray | null; + + while ((match = metricRegex.exec(text)) !== null) { + const label = match[1]; + const value = match[2].trim(); + if (!metricMap.has(label)) { + discoveredOrder.push(label); + } + metricMap.set(label, value); + } + + const orderedKnownMetrics: Array<{ label: string; value: string }> = []; + for (const label of METRIC_ORDER) { + const value = metricMap.get(label); + if (value === undefined) { + continue; + } + if (label === "NULL" && isZeroMetricValue(value)) { + continue; + } + orderedKnownMetrics.push({ label, value }); + } + + const orderedUnknownMetrics = discoveredOrder + .filter((label) => !METRIC_ORDER.includes(label as (typeof METRIC_ORDER)[number])) + .map((label) => ({ label, value: metricMap.get(label)! })); + + return [...orderedKnownMetrics, ...orderedUnknownMetrics]; +} + +function renderSelectionMetricsInline(text: string, classes: Record): JSX.Element { + const metrics = parseSelectionMetrics(text); + if (metrics.length === 0) { + return {text}; + } + + return ( + + {metrics.map((metric, index) => ( + + {index > 0 ? " \u00b7 " : ""} + {metric.label}:{" "} + {metric.value} + + ))} + + ); +} + +function renderSelectionMetricsTooltip(text: string, classes: Record): JSX.Element { + const metrics = parseSelectionMetrics(text); + if (metrics.length === 0) { + return {text}; + } + + return ( + + {metrics.map((metric, index) => ( + + {index > 0 ? " \u00b7 " : ""} + {metric.label}:{" "} + {metric.value} + + ))} + + ); +} + +export interface QueryResultSummaryFooterProps { + hideMetrics?: boolean; +} + +export const QueryResultSummaryFooter = ({ + hideMetrics = false, +}: QueryResultSummaryFooterProps) => { + const classes = useStyles(); + const context = useContext(QueryResultCommandsContext); + const resultSetSummaries = useQueryResultSelector((state) => state.resultSetSummaries); + const messages = useQueryResultSelector((state) => state.messages); + const selectionSummary = useQueryResultSelector((state) => state.selectionSummary); + const isExecuting = useQueryResultSelector((state) => state.isExecuting ?? false); + const executionStartTime = useQueryResultSelector((state) => state.executionStartTime); + const [tickTimestamp, setTickTimestamp] = useState(Date.now()); + + useEffect(() => { + if (!isExecuting || !executionStartTime) { + return; + } + + setTickTimestamp(Date.now()); + const timer = window.setInterval(() => { + setTickTimestamp(Date.now()); + }, 1000); + + return () => { + window.clearInterval(timer); + }; + }, [isExecuting, executionStartTime]); + + const rowsAffectedCount = useMemo(() => { + const activeResultRowCount = getActiveResultSetRowCount( + resultSetSummaries, + selectionSummary, + ); + if (typeof activeResultRowCount === "number") { + return activeResultRowCount; + } + return getRowsAffectedFromMessages(messages); + }, [messages, resultSetSummaries, selectionSummary]); + + const rowsText = + typeof rowsAffectedCount === "number" + ? rowsAffectedCount > 0 + ? locConstants.queryResult.rowsAffected(rowsAffectedCount) + : locConstants.queryResult.noRowsAffected + : locConstants.queryResult.noRowsAffected; + + const executionTimeText = getLatestExecutionTimeMessage(messages); + const cancelled = hasCancellationMessage(messages); + + const executionText = cancelled + ? executionTimeText + ? `${locConstants.queryResult.executionCancelled} - ${executionTimeText}` + : locConstants.queryResult.executionCancelled + : (executionTimeText ?? locConstants.queryResult.executionTimeUnavailable); + const liveExecutionMilliseconds = + isExecuting && executionStartTime + ? Math.max(0, tickTimestamp - executionStartTime) + : undefined; + const compactExecutionText = + liveExecutionMilliseconds !== undefined + ? formatRunningTimeCompact(liveExecutionMilliseconds) + : formatExecutionTextCompact(executionText); + const executionTooltipText = + liveExecutionMilliseconds !== undefined + ? compactExecutionText === locConstants.queryResult.runningLabel + ? locConstants.queryResult.runningLabel + : `${locConstants.queryResult.runningLabel}: ${compactExecutionText}` + : executionText; + + const selectionText = abbreviateSummaryText(normalizeStatusText(selectionSummary?.text)); + const selectionDisplayText = selectionText || locConstants.queryResult.noSelectionSummary; + const selectionTooltip = abbreviateSummaryText( + selectionSummary?.tooltip || selectionDisplayText, + ); + const compactRowsText = + typeof rowsAffectedCount === "number" ? rowsAffectedCount.toLocaleString() : "0"; + + return ( +
+ {!hideMetrics && ( +
+
+ + {locConstants.queryResult.rowsAffectedLabel} + + + + {compactRowsText} + + +
+ | +
+ {locConstants.queryResult.timeLabel} + + + {compactExecutionText} + + +
+
+ )} +
+
+ | + + {selectionSummary?.command?.command ? ( + + ) : ( + + {renderSelectionMetricsInline(selectionDisplayText, classes)} + + )} + +
+
+ ); +}; diff --git a/extensions/mssql/src/sharedInterfaces/queryResult.ts b/extensions/mssql/src/sharedInterfaces/queryResult.ts index 8bd75991a9..710e861e42 100644 --- a/extensions/mssql/src/sharedInterfaces/queryResult.ts +++ b/extensions/mssql/src/sharedInterfaces/queryResult.ts @@ -75,6 +75,8 @@ export interface QueryResultWebviewState extends ExecutionPlanWebviewState { inMemoryDataProcessingThreshold?: number; initializationError?: string; selectionSummary?: SelectionSummary; + isExecuting?: boolean; + executionStartTime?: number; } export interface SelectionSummary { @@ -86,6 +88,8 @@ export interface SelectionSummary { }; tooltip: string; continue?: any; + batchId?: number; + resultId?: number; } export interface QueryResultReducers extends Omit { diff --git a/extensions/mssql/src/views/statusView.ts b/extensions/mssql/src/views/statusView.ts index 8e4313f1af..c2e4716413 100644 --- a/extensions/mssql/src/views/statusView.ts +++ b/extensions/mssql/src/views/statusView.ts @@ -29,10 +29,6 @@ class FileStatusBar { public statusLanguageService: vscode.StatusBarItem; // Item for SQLCMD Mode public sqlCmdMode: vscode.StatusBarItem; - // Item for Row Count - public rowCount: vscode.StatusBarItem; - // Item for execution time - public executionTime: vscode.StatusBarItem; // Timer used for displaying a progress indicator on queries public progressTimerId: NodeJS.Timeout; @@ -66,8 +62,6 @@ export default class StatusView implements vscode.Disposable { this._statusBars[bar].statusQuery.dispose(); this._statusBars[bar].statusLanguageService.dispose(); this._statusBars[bar].sqlCmdMode.dispose(); - this._statusBars[bar].rowCount.dispose(); - this._statusBars[bar].executionTime.dispose(); clearInterval(this._statusBars[bar].progressTimerId); clearInterval(this._statusBars[bar].queryTimer); delete this._statusBars[bar]; @@ -98,8 +92,6 @@ export default class StatusView implements vscode.Disposable { vscode.StatusBarAlignment.Right, ); bar.sqlCmdMode = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 90); - bar.rowCount = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 80); - bar.executionTime = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 70); this._statusBars[fileUri] = bar; } @@ -130,12 +122,6 @@ export default class StatusView implements vscode.Disposable { if (bar.sqlCmdMode) { bar.sqlCmdMode.dispose(); } - if (bar.rowCount) { - bar.rowCount.dispose(); - } - if (bar.executionTime) { - bar.executionTime.dispose(); - } delete this._statusBars[fileUri]; } @@ -162,8 +148,6 @@ export default class StatusView implements vscode.Disposable { this.showStatusBarItem(fileUri, bar.statusQuery); this.showStatusBarItem(fileUri, bar.statusLanguageService); this.showStatusBarItem(fileUri, bar.sqlCmdMode); - this.showStatusBarItem(fileUri, bar.rowCount); - this.showStatusBarItem(fileUri, bar.executionTime); } public setNotConnected(fileUri: string): void { @@ -183,8 +167,6 @@ export default class StatusView implements vscode.Disposable { this.hideStatusBarItem(fileUri, bar.statusChangeDatabase); this.hideStatusBarItem(fileUri, bar.statusQuery); - this.hideStatusBarItem(fileUri, bar.rowCount); - this.hideStatusBarItem(fileUri, bar.executionTime); clearInterval(bar.queryTimer); } @@ -310,35 +292,30 @@ export default class StatusView implements vscode.Disposable { public executingQuery(fileUri: string): void { let bar = this.getStatusBar(fileUri); bar.statusQuery.command = undefined; - bar.statusQuery.text = LocalizedConstants.executeQueryLabel; - this.showStatusBarItem(fileUri, bar.statusQuery); - this.showProgress(fileUri, LocalizedConstants.executeQueryLabel, bar.statusQuery); + bar.statusQuery.text = ""; + bar.statusQuery.hide(); + clearInterval(bar.queryTimer); } public executedQuery(fileUri: string): void { let bar = this.getStatusBar(fileUri); - bar.statusQuery.text = LocalizedConstants.QueryExecutedLabel; - // hide the status bar item with a delay so that the change can be announced by screen reader. - setTimeout(() => { - bar.statusQuery.hide(); - }, 200); - } - - public setExecutionTime(fileUri: string, time: string): void { - let bar = this.getStatusBar(fileUri); - bar.executionTime.text = time; - this.showStatusBarItem(fileUri, bar.executionTime); + bar.statusQuery.text = ""; + bar.statusQuery.hide(); clearInterval(bar.queryTimer); } + /** + * Intentionally a no-op. Query execution time is now shown in the query results webview footer. + */ + public setExecutionTime(_fileUri: string, _time: string): void {} + public cancelingQuery(fileUri: string): void { let bar = this.getStatusBar(fileUri); bar.statusQuery.hide(); bar.statusQuery.command = undefined; - bar.statusQuery.text = LocalizedConstants.cancelingQueryLabel; - this.showStatusBarItem(fileUri, bar.statusQuery); - this.showProgress(fileUri, LocalizedConstants.cancelingQueryLabel, bar.statusQuery); + bar.statusQuery.text = ""; + bar.statusQuery.hide(); clearInterval(bar.queryTimer); } @@ -371,23 +348,6 @@ export default class StatusView implements vscode.Disposable { this.showStatusBarItem(fileUri, bar.sqlCmdMode); } - public showRowCount(fileUri: string, message?: string): void { - let bar = this.getStatusBar(fileUri); - if (message && message.includes("row")) { - // Remove parentheses from start and end - bar.rowCount.text = message.replace("(", "").replace(")", ""); - } - this.showStatusBarItem(fileUri, bar.rowCount); - } - - public hideRowCount(fileUri: string, clear: boolean = false): void { - let bar = this.getStatusBar(fileUri); - if (clear) { - bar.rowCount.text = ""; - } - bar.rowCount.hide(); - } - public updateStatusMessage( newStatus: string, getCurrentStatus: () => string, @@ -445,8 +405,6 @@ export default class StatusView implements vscode.Disposable { this._lastShownStatusBar.statusQuery.hide(); this._lastShownStatusBar.statusLanguageService.hide(); this._lastShownStatusBar.sqlCmdMode.hide(); - this._lastShownStatusBar.rowCount.hide(); - this._lastShownStatusBar.executionTime.hide(); } } @@ -521,37 +479,4 @@ export default class StatusView implements vscode.Disposable { } } } - - private showProgress( - fileUri: string, - statusText: string, - statusBarItem: vscode.StatusBarItem, - ): void { - // Do not use the text based in progress indicator when screen reader is on, it is not user friendly to announce the changes every 200 ms. - const screenReaderOptimized = vscode.workspace - .getConfiguration("editor") - .get("accessibilitySupport"); - if (screenReaderOptimized === "on") { - return; - } - const self = this; - let bar = this.getStatusBar(fileUri); - - // Clear any existing timer first - clearInterval(bar.queryTimer); - - let milliseconds = 0; - bar.queryTimer = setInterval(() => { - milliseconds += 1000; - const timeString = self.formatMillisecondsToTimeString(milliseconds); - statusBarItem.text = statusText + " " + timeString; - self.showStatusBarItem(fileUri, statusBarItem); - }, 1000); - } - - private formatMillisecondsToTimeString(milliseconds: number): string { - const minutes = Math.floor(milliseconds / 60000); - const seconds = ((milliseconds % 60000) / 1000).toFixed(0); - return minutes + ":" + (parseInt(seconds) < 10 ? "0" : "") + seconds; - } } diff --git a/extensions/mssql/test/unit/queryRunner.test.ts b/extensions/mssql/test/unit/queryRunner.test.ts index 58078421ae..2fbcf5887b 100644 --- a/extensions/mssql/test/unit/queryRunner.test.ts +++ b/extensions/mssql/test/unit/queryRunner.test.ts @@ -423,8 +423,7 @@ suite("Query Runner tests", () => { // Then: // ... The VS Code view should have stopped executing expect(testStatusView.executedQuery).to.have.been.calledOnceWithExactly(standardUri); - expect(testStatusView.setExecutionTime).to.have.been.calledOnce; - expect(testStatusView.setExecutionTime.firstCall.args[0]).to.equal(standardUri); + expect(testStatusView.setExecutionTime).to.not.have.been.called; // ... The state of the query runner has been updated expect(queryRunner.batchSets.length).to.equal(1); From 8d3ae9cb54e566ce0044b530703e71293361798b Mon Sep 17 00:00:00 2001 From: Aasim Khan Date: Wed, 25 Feb 2026 21:44:54 -0800 Subject: [PATCH 04/23] Add localization entries for execution status and results in l10n files --- extensions/mssql/l10n/bundle.l10n.json | 11 ++++++++++- localization/xliff/vscode-mssql.xlf | 27 ++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/extensions/mssql/l10n/bundle.l10n.json b/extensions/mssql/l10n/bundle.l10n.json index e6d9897a94..161f92fb9b 100644 --- a/extensions/mssql/l10n/bundle.l10n.json +++ b/extensions/mssql/l10n/bundle.l10n.json @@ -494,6 +494,16 @@ "message": "({0} rows affected)", "comment": ["{0} is the number of rows affected"] }, + "No rows affected": "No rows affected", + "Selected": "Selected", + "Rows": "Rows", + "Time": "Time", + "Running": "Running", + "Execution": "Execution", + "No selection": "No selection", + "Execution cancelled": "Execution cancelled", + "Execution time unavailable": "Execution time unavailable", + "Total execution time:": "Total execution time:", "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"] @@ -1471,7 +1481,6 @@ "message": "Read-only disconnected mode for '{0}'. Cannot create or start live sessions without a database connection.", "comment": ["{0} is the XEL file name"] }, - "Running": "Running", "Paused": "Paused", "Stopped": "Stopped", "Not Started": "Not Started", diff --git a/localization/xliff/vscode-mssql.xlf b/localization/xliff/vscode-mssql.xlf index 038460db94..96aa744128 100644 --- a/localization/xliff/vscode-mssql.xlf +++ b/localization/xliff/vscode-mssql.xlf @@ -2225,9 +2225,18 @@ Executing query... + + Execution + Execution Plan + + Execution cancelled + + + Execution time unavailable + Existing Database @@ -3975,6 +3984,9 @@ No results to display + + No rows affected + No saved connection profiles found. @@ -3993,6 +4005,9 @@ No script generated. + + No selection + No storage accounts found @@ -4827,6 +4842,9 @@ Row marked for removal. + + Rows + Rows per page @@ -5273,6 +5291,9 @@ Select the SQL Server Container Image + + Selected + Selected Microsoft Entra account removed successfully. @@ -5930,6 +5951,9 @@ This will deploy a Data API Builder container locally using Docker. The container will expose REST and GraphQL APIs based on your configuration. + + Time + Timestamp @@ -5974,6 +5998,9 @@ {0} is the part name {1} is the part input + + Total execution time: + Total execution time: {0} {0} is the elapsed time From 1531eab3854d1ca00aac80e55d20e8ba16de2da9 Mon Sep 17 00:00:00 2001 From: Aasim Khan Date: Thu, 5 Mar 2026 01:02:50 -0800 Subject: [PATCH 05/23] Redesign selection metrics tooltip for improved layout and styling --- .../QueryResult/queryResultSummaryFooter.tsx | 54 ++++++++++++++----- 1 file changed, 41 insertions(+), 13 deletions(-) diff --git a/extensions/mssql/src/reactviews/pages/QueryResult/queryResultSummaryFooter.tsx b/extensions/mssql/src/reactviews/pages/QueryResult/queryResultSummaryFooter.tsx index b52141e0f5..4931303268 100644 --- a/extensions/mssql/src/reactviews/pages/QueryResult/queryResultSummaryFooter.tsx +++ b/extensions/mssql/src/reactviews/pages/QueryResult/queryResultSummaryFooter.tsx @@ -64,7 +64,7 @@ const useStyles = makeStyles({ flexShrink: 0, }, rowsAccent: { - color: "var(--vscode-textLink-foreground)", + color: "var(--vscode-terminal-ansiBlue)", }, timeAccent: { color: "var(--vscode-terminal-ansiYellow)", @@ -105,11 +105,28 @@ const useStyles = makeStyles({ selectionTooltipText: { whiteSpace: "pre-line", }, + selectionTooltipMetrics: { + minWidth: "180px", + display: "grid", + rowGap: "6px", + }, + selectionTooltipMetricRow: { + display: "grid", + gridTemplateColumns: "max-content minmax(0, 1fr)", + columnGap: "12px", + alignItems: "baseline", + }, selectionMetricLabel: { color: "var(--vscode-descriptionForeground)", }, selectionMetricValue: { - color: "var(--vscode-textLink-foreground)", + color: "var(--vscode-terminal-ansiBlue)", + }, + selectionTooltipMetricValue: { + justifySelf: "end", + textAlign: "right", + fontVariantNumeric: "tabular-nums", + fontFeatureSettings: '"tnum"', }, cancelled: { color: "var(--vscode-errorForeground)", @@ -307,7 +324,7 @@ function parseSelectionMetrics(text: string): Array<{ label: string; value: stri const metricRegex = /([A-Z]+):\s*([^:\n]+?)(?=(?:\s{2,}[A-Z]+:)|$|\n)/g; let match: RegExpExecArray | null; - while ((match = metricRegex.exec(text)) !== null) { + while ((match = metricRegex.exec(text))) { const label = match[1]; const value = match[2].trim(); if (!metricMap.has(label)) { @@ -335,7 +352,7 @@ function parseSelectionMetrics(text: string): Array<{ label: string; value: stri return [...orderedKnownMetrics, ...orderedUnknownMetrics]; } -function renderSelectionMetricsInline(text: string, classes: Record): JSX.Element { +function renderSelectionMetricsInline(text: string, classes: Record) { const metrics = parseSelectionMetrics(text); if (metrics.length === 0) { return {text}; @@ -354,22 +371,24 @@ function renderSelectionMetricsInline(text: string, classes: Record): JSX.Element { +function renderSelectionMetricsTooltip(text: string, classes: Record) { const metrics = parseSelectionMetrics(text); if (metrics.length === 0) { return {text}; } return ( - - {metrics.map((metric, index) => ( - - {index > 0 ? " \u00b7 " : ""} - {metric.label}:{" "} - {metric.value} - +
+ {metrics.map((metric) => ( +
+ {metric.label}: + + {metric.value} + +
))} - +
); } @@ -385,6 +404,7 @@ export const QueryResultSummaryFooter = ({ const resultSetSummaries = useQueryResultSelector((state) => state.resultSetSummaries); const messages = useQueryResultSelector((state) => state.messages); const selectionSummary = useQueryResultSelector((state) => state.selectionSummary); + const tabStates = useQueryResultSelector((state) => state.tabStates); const isExecuting = useQueryResultSelector((state) => state.isExecuting ?? false); const executionStartTime = useQueryResultSelector((state) => state.executionStartTime); const [tickTimestamp, setTickTimestamp] = useState(Date.now()); @@ -452,6 +472,14 @@ export const QueryResultSummaryFooter = ({ ); const compactRowsText = typeof rowsAffectedCount === "number" ? rowsAffectedCount.toLocaleString() : "0"; + const isTextResultsView = + tabStates?.resultPaneTab === qr.QueryResultPaneTabs.Results && + tabStates?.resultViewMode === qr.QueryResultViewMode.Text; + const isMessagesPane = tabStates?.resultPaneTab === qr.QueryResultPaneTabs.Messages; + + if (isTextResultsView || isMessagesPane) { + return ; + } return (
From b65c8165da715d7b7f7376b05c89ac59601f1b7a Mon Sep 17 00:00:00 2001 From: Aasim Khan Date: Thu, 5 Mar 2026 02:03:26 -0800 Subject: [PATCH 06/23] Adding settings and number column styling --- .../mssql/src/controllers/mainController.ts | 5 + .../queryResultWebViewController.ts | 36 ++++ extensions/mssql/src/queryResult/utils.ts | 13 ++ .../src/reactviews/common/locConstants.ts | 6 + .../mssql/src/reactviews/media/slickgrid.css | 6 +- .../pages/QueryResult/queryResultPane.tsx | 60 +++--- .../queryResultSettingsControl.tsx | 194 ++++++++++++++++++ .../table/plugins/rowNumberColumn.plugin.ts | 2 +- .../mssql/src/sharedInterfaces/queryResult.ts | 18 +- .../mssql/src/sharedInterfaces/telemetry.ts | 1 + 10 files changed, 311 insertions(+), 30 deletions(-) create mode 100644 extensions/mssql/src/reactviews/pages/QueryResult/queryResultSettingsControl.tsx diff --git a/extensions/mssql/src/controllers/mainController.ts b/extensions/mssql/src/controllers/mainController.ts index d32f8a626d..b106a61703 100644 --- a/extensions/mssql/src/controllers/mainController.ts +++ b/extensions/mssql/src/controllers/mainController.ts @@ -232,6 +232,10 @@ export default class MainController implements vscode.Disposable { return this.configuration.get(Constants.configEnableRichExperiences); } + public get isOpenQueryResultsInTabByDefaultEnabled(): boolean { + return this.configuration.get(Constants.configOpenQueryResultsInTabByDefault, false); + } + /** * Initializes the extension */ @@ -1005,6 +1009,7 @@ export default class MainController implements vscode.Disposable { sendActionEvent(TelemetryViews.General, TelemetryActions.Activated, { experimentalFeaturesEnabled: this.isExperimentalEnabled.toString(), modernFeaturesEnabled: this.isRichExperiencesEnabled.toString(), + openQueryResultsInTabByDefault: this.isOpenQueryResultsInTabByDefaultEnabled.toString(), cloudType: getCloudId(), }); diff --git a/extensions/mssql/src/queryResult/queryResultWebViewController.ts b/extensions/mssql/src/queryResult/queryResultWebViewController.ts index 3f9b7cc0af..fbf87715a6 100644 --- a/extensions/mssql/src/queryResult/queryResultWebViewController.ts +++ b/extensions/mssql/src/queryResult/queryResultWebViewController.ts @@ -474,4 +474,40 @@ export class QueryResultWebviewController extends ReactWebviewViewController< }); return total; } + + public getOpenQueryResultsInTabByDefaultRequestHandler(): boolean { + return this.vscodeWrapper + .getConfiguration() + .get(Constants.configOpenQueryResultsInTabByDefault, false); + } + + public async setOpenQueryResultsInTabByDefaultRequestHandler(enabled: boolean): Promise { + const configuration = this.vscodeWrapper.getConfiguration(); + const previousValue = configuration.get( + Constants.configOpenQueryResultsInTabByDefault, + false, + ); + + await configuration.update( + Constants.configOpenQueryResultsInTabByDefault, + enabled, + vscode.ConfigurationTarget.Global, + ); + + // Skip the one-time prompt after users explicitly choose their preferred result location. + await configuration.update( + Constants.configOpenQueryResultsInTabByDefaultDoNotShowPrompt, + true, + vscode.ConfigurationTarget.Global, + ); + + sendActionEvent( + TelemetryViews.QueryResult, + TelemetryActions.QueryResultsTabDefaultSettingToggled, + { + enabled: enabled.toString(), + previousValue: previousValue.toString(), + }, + ); + } } diff --git a/extensions/mssql/src/queryResult/utils.ts b/extensions/mssql/src/queryResult/utils.ts index 8a0da27c5f..6b64428337 100644 --- a/extensions/mssql/src/queryResult/utils.ts +++ b/extensions/mssql/src/queryResult/utils.ts @@ -83,6 +83,19 @@ export function registerCommonRequestHandlers( return result; }); + webviewController.onRequest(qr.GetOpenQueryResultsInTabByDefaultRequest.type, async () => { + return webviewViewController.getOpenQueryResultsInTabByDefaultRequestHandler(); + }); + + webviewController.onRequest( + qr.SetOpenQueryResultsInTabByDefaultRequest.type, + async (message) => { + await webviewViewController.setOpenQueryResultsInTabByDefaultRequestHandler( + message.enabled, + ); + }, + ); + webviewController.onRequest(qr.SetEditorSelectionRequest.type, async (message) => { if (!message.uri || !message.selectionData) { console.warn( diff --git a/extensions/mssql/src/reactviews/common/locConstants.ts b/extensions/mssql/src/reactviews/common/locConstants.ts index dccee0eeff..7aad3f872a 100644 --- a/extensions/mssql/src/reactviews/common/locConstants.ts +++ b/extensions/mssql/src/reactviews/common/locConstants.ts @@ -537,6 +537,12 @@ export class LocConstants { timestamp: l10n.t("Timestamp"), message: l10n.t("Message"), openResultInNewTab: l10n.t("Open in New Tab"), + resultsSettings: l10n.t("Results Settings"), + showResultsInEditorTab: l10n.t("Open results in new tab"), + showResultsInEditorTabDescription: l10n.t( + "Show query results in a new editor tab instead of the query pane.", + ), + closeResultsSettings: l10n.t("Close results settings"), showplanXML: l10n.t("Showplan XML"), showMenu: (shortcut: string) => { if (shortcut) { diff --git a/extensions/mssql/src/reactviews/media/slickgrid.css b/extensions/mssql/src/reactviews/media/slickgrid.css index e3f79e84e2..92ea01b52f 100644 --- a/extensions/mssql/src/reactviews/media/slickgrid.css +++ b/extensions/mssql/src/reactviews/media/slickgrid.css @@ -151,11 +151,11 @@ } .slick-cell > .row-number { - color: var(--color-content); - font-style: italic; + color: var(--vscode-editor-foreground); + background-color: var(--vscode-keybindingTable-headerBackground); font-weight: lighter; display: flex; - justify-content: flex-end; + text-align: left; } .slick-reorder-proxy { diff --git a/extensions/mssql/src/reactviews/pages/QueryResult/queryResultPane.tsx b/extensions/mssql/src/reactviews/pages/QueryResult/queryResultPane.tsx index 97348e139e..3647acccb4 100644 --- a/extensions/mssql/src/reactviews/pages/QueryResult/queryResultPane.tsx +++ b/extensions/mssql/src/reactviews/pages/QueryResult/queryResultPane.tsx @@ -13,7 +13,7 @@ import { Text, Spinner, } from "@fluentui/react-components"; -import { useContext, useEffect, useRef, useState } from "react"; +import { useContext, useEffect, useState } from "react"; import { DatabaseSearch24Regular, ErrorCircle24Regular, OpenRegular } from "@fluentui/react-icons"; import * as qr from "../../../sharedInterfaces/queryResult"; import { locConstants } from "../../common/locConstants"; @@ -29,6 +29,7 @@ import { QueryResultsTab } from "./queryResultsTab"; import { useVscodeWebview } from "../../common/vscodeWebviewProvider"; import { eventMatchesShortcut } from "../../common/keyboardUtils"; import { QueryResultSummaryFooter } from "./queryResultSummaryFooter"; +import { QueryResultSettingsControl } from "./queryResultSettingsControl"; const useStyles = makeStyles({ root: { @@ -45,6 +46,11 @@ const useStyles = makeStyles({ marginRight: "10px", }, }, + ribbonActions: { + display: "flex", + alignItems: "center", + gap: "4px", + }, queryResultPaneTabs: { flex: 1, }, @@ -134,9 +140,6 @@ export const QueryResultPane = () => { (s) => s.executionPlanState?.executionPlanGraphs, ); - const resultPaneParentRef = useRef(null); - const ribbonRef = useRef(null); - const { keyBindings } = useVscodeWebview(); useEffect(() => { @@ -189,11 +192,14 @@ export const QueryResultPane = () => { }); setWebviewLocation(res); }; - const [webviewLocation, setWebviewLocation] = useState(""); + const [webviewLocation, setWebviewLocation] = useState( + qr.QueryResultWebviewLocation.Panel, + ); + useEffect(() => { getWebviewLocation().catch((e) => { console.error(e); - setWebviewLocation("panel"); + setWebviewLocation(qr.QueryResultWebviewLocation.Panel); }); }, []); @@ -219,7 +225,7 @@ export const QueryResultPane = () => {
- {webviewLocation === "document" ? ( + {webviewLocation === qr.QueryResultWebviewLocation.Document ? ( { } return ( -
-
+
+
{ )} - {webviewLocation === "panel" && ( - - )} +
+ + {webviewLocation === qr.QueryResultWebviewLocation.Panel && ( + + )} +
diff --git a/extensions/mssql/src/reactviews/pages/QueryResult/queryResultSettingsControl.tsx b/extensions/mssql/src/reactviews/pages/QueryResult/queryResultSettingsControl.tsx new file mode 100644 index 0000000000..7dc9e78993 --- /dev/null +++ b/extensions/mssql/src/reactviews/pages/QueryResult/queryResultSettingsControl.tsx @@ -0,0 +1,194 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { + Button, + Popover, + PopoverSurface, + PopoverTrigger, + Switch, + makeStyles, +} from "@fluentui/react-components"; +import { Dismiss20Regular, Settings20Regular } from "@fluentui/react-icons"; +import { useContext, useEffect, useState } from "react"; +import * as qr from "../../../sharedInterfaces/queryResult"; +import { ExecuteCommandRequest } from "../../../sharedInterfaces/webview"; +import { locConstants } from "../../common/locConstants"; +import { QueryResultCommandsContext } from "./queryResultStateProvider"; + +const useStyles = makeStyles({ + ribbonIconButton: { + width: "28px", + height: "28px", + minWidth: "28px", + padding: 0, + }, + settingsPopoverSurface: { + padding: 0, + minWidth: "300px", + maxWidth: "400px", + borderRadius: "8px", + border: "1px solid var(--vscode-widget-border)", + backgroundColor: "var(--vscode-editorWidget-background)", + color: "var(--vscode-foreground)", + boxShadow: "0 10px 28px rgba(0, 0, 0, 0.35)", + }, + settingsPopoverHeader: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + padding: "8px 10px", + borderBottom: "1px solid var(--vscode-widget-border)", + }, + settingsPopoverTitleGroup: { + display: "flex", + alignItems: "center", + gap: "8px", + fontSize: "14px", + fontWeight: 600, + }, + settingsPopoverOption: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: "10px", + padding: "10px", + }, + settingsPopoverOptionText: { + minWidth: 0, + display: "flex", + flexDirection: "column", + gap: "4px", + }, + settingsPopoverOptionTitle: { + fontSize: "13px", + lineHeight: "18px", + color: "var(--vscode-foreground)", + }, + settingsPopoverOptionDescription: { + fontSize: "12px", + lineHeight: "16px", + color: "var(--vscode-descriptionForeground)", + }, +}); + +export interface QueryResultSettingsControlProps { + uri?: string; + webviewLocation: qr.QueryResultWebviewLocation; +} + +export const QueryResultSettingsControl = ({ + uri, + webviewLocation, +}: QueryResultSettingsControlProps) => { + const classes = useStyles(); + const context = useContext(QueryResultCommandsContext); + const [isSettingsOpen, setIsSettingsOpen] = useState(false); + const [openResultsInEditorTabByDefault, setOpenResultsInEditorTabByDefault] = + useState(false); + + useEffect(() => { + if (!context) { + return; + } + + context.extensionRpc + .sendRequest(qr.GetOpenQueryResultsInTabByDefaultRequest.type) + .then((isEnabled) => { + setOpenResultsInEditorTabByDefault(isEnabled); + }) + .catch((e) => { + console.error(e); + }); + }, [context]); + + if (!context) { + return <>; + } + + const setDefaultResultLocation = async (enabled: boolean): Promise => { + const previousValue = openResultsInEditorTabByDefault; + setOpenResultsInEditorTabByDefault(enabled); + + try { + await context.extensionRpc.sendRequest( + qr.SetOpenQueryResultsInTabByDefaultRequest.type, + { + enabled, + }, + ); + + if ( + enabled && + webviewLocation === qr.QueryResultWebviewLocation.Panel && + Boolean(uri) + ) { + await context.extensionRpc.sendRequest(qr.OpenInNewTabRequest.type, { + uri: uri!, + }); + await context.extensionRpc.sendRequest(ExecuteCommandRequest.type, { + command: "workbench.action.closePanel", + }); + } + } catch (e) { + console.error(e); + setOpenResultsInEditorTabByDefault(previousValue); + } + }; + + return ( + { + setIsSettingsOpen(data.open); + }}> + +
+
+
+ + {locConstants.queryResult.showResultsInEditorTab} + + + {locConstants.queryResult.showResultsInEditorTabDescription} + +
+ { + void setDefaultResultLocation(data.checked); + }} + /> +
+ + + ); +}; diff --git a/extensions/mssql/src/reactviews/pages/QueryResult/table/plugins/rowNumberColumn.plugin.ts b/extensions/mssql/src/reactviews/pages/QueryResult/table/plugins/rowNumberColumn.plugin.ts index fa3c794839..23baeb9fb9 100644 --- a/extensions/mssql/src/reactviews/pages/QueryResult/table/plugins/rowNumberColumn.plugin.ts +++ b/extensions/mssql/src/reactviews/pages/QueryResult/table/plugins/rowNumberColumn.plugin.ts @@ -91,6 +91,6 @@ export class RowNumberColumn implements Slick.Plugin< private formatter(row: number): string { // row is zero-based, we need make it 1 based for display in the result grid - return `${row + 1}`; + return `${row + 1}`; } } diff --git a/extensions/mssql/src/sharedInterfaces/queryResult.ts b/extensions/mssql/src/sharedInterfaces/queryResult.ts index 710e861e42..9be8b81221 100644 --- a/extensions/mssql/src/sharedInterfaces/queryResult.ts +++ b/extensions/mssql/src/sharedInterfaces/queryResult.ts @@ -84,10 +84,10 @@ export interface SelectionSummary { command: { title: string; command: string; - arguments: any[]; + arguments: unknown[]; }; tooltip: string; - continue?: any; + continue?: unknown; batchId?: number; resultId?: number; } @@ -351,6 +351,20 @@ export namespace GetWebviewLocationRequest { ); } +export namespace GetOpenQueryResultsInTabByDefaultRequest { + export const type = new RequestType("getOpenQueryResultsInTabByDefault"); +} + +export interface SetOpenQueryResultsInTabByDefaultParams { + enabled: boolean; +} + +export namespace SetOpenQueryResultsInTabByDefaultRequest { + export const type = new RequestType( + "setOpenQueryResultsInTabByDefault", + ); +} + export interface SetEditorSelectionParams { uri: string; selectionData: ISelectionData; diff --git a/extensions/mssql/src/sharedInterfaces/telemetry.ts b/extensions/mssql/src/sharedInterfaces/telemetry.ts index fd05523e09..6ac8fb8a8a 100644 --- a/extensions/mssql/src/sharedInterfaces/telemetry.ts +++ b/extensions/mssql/src/sharedInterfaces/telemetry.ts @@ -111,6 +111,7 @@ export enum TelemetryActions { CopyHeaders = "CopyHeaders", EnableRichExperiencesPrompt = "EnableRichExperiencesPrompt", OpenQueryResultsInTabByDefaultPrompt = "OpenQueryResultsInTabByDefaultPrompt", + QueryResultsTabDefaultSettingToggled = "QueryResultsTabDefaultSettingToggled", OpenQueryResult = "OpenQueryResult", Restore = "Restore", LoadConnection = "LoadConnection", From e2d9b431c81161d4e46703e65c1105785b5b4657 Mon Sep 17 00:00:00 2001 From: Aasim Khan Date: Thu, 5 Mar 2026 02:03:52 -0800 Subject: [PATCH 07/23] Add localization entries for results settings and query results display --- extensions/mssql/l10n/bundle.l10n.json | 4 ++++ localization/xliff/vscode-mssql.xlf | 12 ++++++++++++ 2 files changed, 16 insertions(+) diff --git a/extensions/mssql/l10n/bundle.l10n.json b/extensions/mssql/l10n/bundle.l10n.json index 161f92fb9b..667553db15 100644 --- a/extensions/mssql/l10n/bundle.l10n.json +++ b/extensions/mssql/l10n/bundle.l10n.json @@ -412,6 +412,10 @@ "Timestamp": "Timestamp", "Message": "Message", "Open in New Tab": "Open in New Tab", + "Results Settings": "Results Settings", + "Open results in new tab": "Open results in new tab", + "Show query results in a new editor tab instead of the query pane.": "Show query results in a new editor tab instead of the query pane.", + "Close results settings": "Close results settings", "Showplan XML": "Showplan XML", "Show Menu ({0})/{0} is the keyboard shortcut for showing the menu": { "message": "Show Menu ({0})", diff --git a/localization/xliff/vscode-mssql.xlf b/localization/xliff/vscode-mssql.xlf index 96aa744128..eb60c2de1b 100644 --- a/localization/xliff/vscode-mssql.xlf +++ b/localization/xliff/vscode-mssql.xlf @@ -963,6 +963,9 @@ Close properties pane + + Close results settings + Close the current connection @@ -4179,6 +4182,9 @@ Open in editor + + Open results in new tab + Open text data in a new editor @@ -4784,6 +4790,9 @@ Results ({0}) {0} is the keyboard shortcut for the results tab + + Results Settings + Results copied to clipboard @@ -5438,6 +5447,9 @@ Show password + + Show query results in a new editor tab instead of the query pane. + Show schema for connection '{0}' (ID: {1})? {0} is the connection display name From ad86433858e25fa30c35b8b614a956cf6156b952 Mon Sep 17 00:00:00 2001 From: Aasim Khan Date: Thu, 5 Mar 2026 02:18:30 -0800 Subject: [PATCH 08/23] Redesign slickgrid row borders and improve alternating row colors for better visibility --- extensions/mssql/src/reactviews/media/slickgrid.css | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/extensions/mssql/src/reactviews/media/slickgrid.css b/extensions/mssql/src/reactviews/media/slickgrid.css index 92ea01b52f..7b11a2bb56 100644 --- a/extensions/mssql/src/reactviews/media/slickgrid.css +++ b/extensions/mssql/src/reactviews/media/slickgrid.css @@ -32,8 +32,8 @@ padding: 4px; border-right: 1px solid silver; border-left: 0px !important; - border-top: 0px !important; - border-bottom: 2px solid #bbb; + border-top: 1px solid silver !important; + border-bottom: 1px solid silver !important; float: left; background-color: #eee; box-sizing: border-box; @@ -91,6 +91,14 @@ width: 100%; } +.slick-row.even { + background-color: var(--vscode-editor-background); +} + +.slick-row.odd { + background-color: var(--vscode-editorWidget-background); +} + .slick-cell, .slick-headerrow-column, .slick-footerrow-column { From 0ca080e4fc3c99d957d449a705ab63aadfe003cc Mon Sep 17 00:00:00 2001 From: Aasim Khan Date: Thu, 5 Mar 2026 10:56:45 -0800 Subject: [PATCH 09/23] Fix localization entry for "Time" in vscode-mssql.xlf --- localization/xliff/vscode-mssql.xlf | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/localization/xliff/vscode-mssql.xlf b/localization/xliff/vscode-mssql.xlf index 88ee557700..fda7a0323a 100644 --- a/localization/xliff/vscode-mssql.xlf +++ b/localization/xliff/vscode-mssql.xlf @@ -6131,12 +6131,12 @@ This will deploy a Data API Builder container locally using Docker. The container will expose REST and GraphQL APIs based on your configuration. - - Time - This will reset all rules to their default severity and disable 'Enable Code Analysis on Build'. This cannot be undone. Would you like to continue? + + Time + Timestamp @@ -7702,4 +7702,4 @@ user - + \ No newline at end of file From 2d3e3d4b72fd1391afc443d169445c348a120e3a Mon Sep 17 00:00:00 2001 From: Aasim Khan Date: Thu, 5 Mar 2026 14:41:20 -0800 Subject: [PATCH 10/23] Implement functionality to move query results to document tab based on configuration changes and add unit tests for the new behavior --- .../queryResultWebViewController.ts | 37 +++++ .../unit/queryResultWebViewController.test.ts | 146 ++++++++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 extensions/mssql/test/unit/queryResultWebViewController.test.ts diff --git a/extensions/mssql/src/queryResult/queryResultWebViewController.ts b/extensions/mssql/src/queryResult/queryResultWebViewController.ts index 3f6f74419e..8dd8b47f04 100644 --- a/extensions/mssql/src/queryResult/queryResultWebViewController.ts +++ b/extensions/mssql/src/queryResult/queryResultWebViewController.ts @@ -112,6 +112,12 @@ export class QueryResultWebviewController extends ReactWebviewViewController< this._queryResultStateMap.set(uri, state); } } + if ( + e.affectsConfiguration(Constants.configOpenQueryResultsInTabByDefault) && + this.isOpenQueryResultsInTabByDefaultEnabled + ) { + void this.moveCurrentPanelResultToDocumentTab(); + } }), ); @@ -261,6 +267,33 @@ export class QueryResultWebviewController extends ReactWebviewViewController< }; } + private getCurrentPanelResultUri(): string | undefined { + const stateUri = this.state?.uri; + if (stateUri && this._queryResultStateMap.has(stateUri) && !this.hasPanel(stateUri)) { + return stateUri; + } + + const activeEditorUri = getUriKey(this.vscodeWrapper.activeTextEditor?.document?.uri); + if ( + activeEditorUri && + this._queryResultStateMap.has(activeEditorUri) && + !this.hasPanel(activeEditorUri) + ) { + return activeEditorUri; + } + + return undefined; + } + + private async moveCurrentPanelResultToDocumentTab(): Promise { + const uriToMove = this.getCurrentPanelResultUri(); + if (!uriToMove) { + return; + } + + await this.createPanelController(uriToMove); + } + public async createPanelController(uri: string) { const viewColumn = getNewResultPaneViewColumn(uri, this.vscodeWrapper); if (this._queryResultWebviewPanelControllerMap.has(uri)) { @@ -598,6 +631,10 @@ export class QueryResultWebviewController extends ReactWebviewViewController< vscode.ConfigurationTarget.Global, ); + if (enabled) { + await this.moveCurrentPanelResultToDocumentTab(); + } + sendActionEvent( TelemetryViews.QueryResult, TelemetryActions.QueryResultsTabDefaultSettingToggled, diff --git a/extensions/mssql/test/unit/queryResultWebViewController.test.ts b/extensions/mssql/test/unit/queryResultWebViewController.test.ts new file mode 100644 index 0000000000..08f2f02af4 --- /dev/null +++ b/extensions/mssql/test/unit/queryResultWebViewController.test.ts @@ -0,0 +1,146 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as chai from "chai"; +import { expect } from "chai"; +import * as sinon from "sinon"; +import sinonChai from "sinon-chai"; +import * as vscode from "vscode"; +import * as Constants from "../../src/constants/constants"; +import VscodeWrapper from "../../src/controllers/vscodeWrapper"; +import { SqlOutputContentProvider } from "../../src/models/sqlOutputContentProvider"; +import { QueryResultWebviewController } from "../../src/queryResult/queryResultWebViewController"; +import { ExecutionPlanService } from "../../src/services/executionPlanService"; +import { stubExtensionContext, stubVscodeWrapper } from "./utils"; + +chai.use(sinonChai); + +suite("QueryResultWebviewController", () => { + let sandbox: sinon.SinonSandbox; + let vscodeWrapper: sinon.SinonStubbedInstance; + let executionPlanService: sinon.SinonStubbedInstance; + let sqlOutputContentProvider: sinon.SinonStubbedInstance; + let controller: QueryResultWebviewController; + let configuration: { + get: sinon.SinonStub; + update: sinon.SinonStub; + }; + let onDidChangeConfigurationHandler: ((e: vscode.ConfigurationChangeEvent) => void) | undefined; + let openResultsInTabByDefault = false; + + const testUri = "file:///test.sql"; + + setup(() => { + sandbox = sinon.createSandbox(); + + vscodeWrapper = stubVscodeWrapper(sandbox); + executionPlanService = sandbox.createStubInstance(ExecutionPlanService); + sqlOutputContentProvider = sandbox.createStubInstance(SqlOutputContentProvider); + + const context = stubExtensionContext(sandbox); + const disposable = new vscode.Disposable(() => undefined); + + sandbox.stub(vscode.commands, "registerCommand").returns(disposable); + sandbox.stub(vscode.window, "createStatusBarItem").returns({ + text: "", + tooltip: "", + command: undefined, + show: sandbox.stub(), + hide: sandbox.stub(), + dispose: sandbox.stub(), + } as unknown as vscode.StatusBarItem); + + sandbox.stub(vscodeWrapper, "onDidCloseTextDocument").get(() => { + return () => disposable; + }); + + sandbox.stub(vscodeWrapper, "onDidChangeConfiguration").get(() => { + return (handler: (e: vscode.ConfigurationChangeEvent) => void) => { + onDidChangeConfigurationHandler = handler; + return disposable; + }; + }); + + const activeEditor = { + document: { + uri: vscode.Uri.parse(testUri), + }, + viewColumn: vscode.ViewColumn.One, + } as unknown as vscode.TextEditor; + sandbox.stub(vscodeWrapper, "activeTextEditor").get(() => activeEditor); + + configuration = { + get: sandbox.stub().callsFake((key: string, defaultValue?: unknown) => { + if (key === Constants.configOpenQueryResultsInTabByDefault) { + return openResultsInTabByDefault; + } + return defaultValue; + }), + update: sandbox.stub().resolves(), + }; + + vscodeWrapper.getConfiguration.callsFake(() => { + return configuration as unknown as vscode.WorkspaceConfiguration; + }); + + controller = new QueryResultWebviewController( + context, + vscodeWrapper, + executionPlanService as unknown as ExecutionPlanService, + sqlOutputContentProvider as unknown as SqlOutputContentProvider, + ); + + controller.addQueryResultState(testUri, "test-query"); + controller.state = controller.getQueryResultState(testUri); + }); + + teardown(() => { + sandbox.restore(); + }); + + test("moves current result to a tab when open-by-default is enabled via request handler", async () => { + openResultsInTabByDefault = false; + const createPanelControllerStub = sandbox + .stub(controller, "createPanelController") + .resolves(); + + await controller.setOpenQueryResultsInTabByDefaultRequestHandler(true); + + expect(createPanelControllerStub).to.have.been.calledOnceWithExactly(testUri); + expect(configuration.update).to.have.been.calledWith( + Constants.configOpenQueryResultsInTabByDefault, + true, + vscode.ConfigurationTarget.Global, + ); + }); + + test("does not move current result when open-by-default is disabled via request handler", async () => { + openResultsInTabByDefault = true; + const createPanelControllerStub = sandbox + .stub(controller, "createPanelController") + .resolves(); + + await controller.setOpenQueryResultsInTabByDefaultRequestHandler(false); + + expect(createPanelControllerStub).to.not.have.been.called; + }); + + test("moves current result to a tab when the setting is enabled through configuration change", async () => { + openResultsInTabByDefault = true; + const createPanelControllerStub = sandbox + .stub(controller, "createPanelController") + .resolves(); + + onDidChangeConfigurationHandler?.({ + affectsConfiguration: (section: string) => { + return section === Constants.configOpenQueryResultsInTabByDefault; + }, + } as vscode.ConfigurationChangeEvent); + + await Promise.resolve(); + + expect(createPanelControllerStub).to.have.been.calledOnceWithExactly(testUri); + }); +}); From 4ae2fda2cb791ef89bf0d24669d034a86d5acd84 Mon Sep 17 00:00:00 2001 From: Aasim Khan Date: Tue, 10 Mar 2026 15:29:35 -0700 Subject: [PATCH 11/23] Refactor selection summary handling and improve localization for metrics --- .../mssql/src/constants/locConstants.ts | 87 -------- .../mssql/src/controllers/queryRunner.ts | 42 +--- .../src/models/sqlOutputContentProvider.ts | 1 + .../queryResultWebViewController.ts | 34 +--- .../queryResultWebviewPanelController.ts | 2 - .../src/reactviews/common/locConstants.ts | 7 + .../QueryResult/queryResultSummaryFooter.tsx | 192 +++++++++++------- .../mssql/src/sharedInterfaces/queryResult.ts | 17 +- 8 files changed, 156 insertions(+), 226 deletions(-) diff --git a/extensions/mssql/src/constants/locConstants.ts b/extensions/mssql/src/constants/locConstants.ts index c7bb36f33d..bfc018afef 100644 --- a/extensions/mssql/src/constants/locConstants.ts +++ b/extensions/mssql/src/constants/locConstants.ts @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import { l10n } from "vscode"; -import * as os from "os"; // Warning: Only update these strings if you are sure you want to affect _all_ locations they're shared between. export class Common { @@ -1120,92 +1119,6 @@ export class FabricProvisioning { } export class QueryResult { - public static nonNumericSelectionSummary = ( - count: number, - distinctCount: number, - nullCount: number, - ) => - l10n.t({ - message: "Count: {0} Distinct Count: {1} Null Count: {2}", - args: [count, distinctCount, nullCount], - comment: ["{0} is the count, {1} is the distinct count, and {2} is the null count"], - }); - public static numericSelectionSummary = (average: string, count: number, sum: number) => - l10n.t({ - message: "Average: {0} Count: {1} Sum: {2}", - args: [average, count, sum], - comment: ["{0} is the average, {1} is the count, {2} is the sum"], - }); - public static numericSelectionSummaryTooltip = ( - average: string, - count: number, - distinctCount: number, - max: number, - min: number, - nullCount: number, - sum: number, - ) => { - return [ - l10n.t({ - message: "Average: {0}", - args: [average], - comment: ["{0} is the average"], - }), - l10n.t({ - message: "Count: {0}", - args: [count], - comment: ["{0} is the count"], - }), - l10n.t({ - message: "Distinct Count: {0}", - args: [distinctCount], - comment: ["{0} is the distinct count"], - }), - l10n.t({ - message: "Max: {0}", - args: [max], - comment: ["{0} is the max"], - }), - l10n.t({ - message: "Min: {0}", - args: [min], - comment: ["{0} is the min"], - }), - l10n.t({ - message: "Null Count: {0}", - args: [nullCount], - comment: ["{0} is the null count"], - }), - l10n.t({ - message: "Sum: {0}", - args: [sum], - comment: ["{0} is the sum"], - }), - ].join(os.EOL); - }; - public static nonNumericSelectionSummaryTooltip = ( - count: number, - distinctCount: number, - nullCount: number, - ) => { - return [ - l10n.t({ - message: "Count: {0}", - args: [count], - comment: ["{0} is the count"], - }), - l10n.t({ - message: "Distinct Count: {0}", - args: [distinctCount], - comment: ["{0} is the distinct count"], - }), - l10n.t({ - message: "Null Count: {0}", - args: [nullCount], - comment: ["{0} is the null count"], - }), - ].join(os.EOL); - }; public static copyError = (error: string) => l10n.t({ message: "An error occurred while copying results: {0}", diff --git a/extensions/mssql/src/controllers/queryRunner.ts b/extensions/mssql/src/controllers/queryRunner.ts index 7c13dfd27e..1106b31fb9 100644 --- a/extensions/mssql/src/controllers/queryRunner.ts +++ b/extensions/mssql/src/controllers/queryRunner.ts @@ -1096,38 +1096,17 @@ export default class QueryRunner { return; } - let text = ""; - let tooltip = ""; + const stats: NonNullable = { + count: result.count, + distinctCount: result.distinctCount, + nullCount: result.nullCount, + }; - // the selection is numeric if (result.average !== undefined && result.average !== null) { - const average = result.average.toFixed(2); - text = LocalizedConstants.QueryResult.numericSelectionSummary( - average, - result.count, - result.sum, - ); - tooltip = LocalizedConstants.QueryResult.numericSelectionSummaryTooltip( - average, - result.count, - result.distinctCount, - result.max ?? 0, - result.min ?? 0, - result.nullCount, - result.sum, - ); - } else { - text = LocalizedConstants.QueryResult.nonNumericSelectionSummary( - result.count, - result.distinctCount, - result.nullCount, - ); - tooltip = LocalizedConstants.QueryResult.nonNumericSelectionSummaryTooltip( - result.count, - result.distinctCount, - result.nullCount, - ); - tooltip = text; + stats.average = result.average; + stats.sum = result.sum; + stats.max = result.max; + stats.min = result.min; } // Resolve the cancel confirmation to clean up @@ -1136,8 +1115,7 @@ export default class QueryRunner { } this.fireSummaryChangedEvent(requestId, { - text, - tooltip, + stats, uri: this.uri, command: undefined, continue: undefined, diff --git a/extensions/mssql/src/models/sqlOutputContentProvider.ts b/extensions/mssql/src/models/sqlOutputContentProvider.ts index c7341576bb..28f860a4e3 100644 --- a/extensions/mssql/src/models/sqlOutputContentProvider.ts +++ b/extensions/mssql/src/models/sqlOutputContentProvider.ts @@ -672,6 +672,7 @@ export class SqlOutputContentProvider { return; } state.selectionSummary = { + stats: e.stats, text: e.text, command: e.command, tooltip: e.tooltip, diff --git a/extensions/mssql/src/queryResult/queryResultWebViewController.ts b/extensions/mssql/src/queryResult/queryResultWebViewController.ts index 8dd8b47f04..e42a0d67ee 100644 --- a/extensions/mssql/src/queryResult/queryResultWebViewController.ts +++ b/extensions/mssql/src/queryResult/queryResultWebViewController.ts @@ -38,8 +38,6 @@ export class QueryResultWebviewController extends ReactWebviewViewController< private _queryResultWebviewPanelControllerMap: Map = new Map(); private _correlationId: string = randomUUID(); - private _selectionSummaryStatusBarItem: vscode.StatusBarItem = - vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 2); public actualPlanStatuses: string[] = []; private _sqlDocumentService: SqlDocumentService; @@ -127,7 +125,7 @@ export class QueryResultWebviewController extends ReactWebviewViewController< if (!state) { return; } - (state.selectionSummary.continue as Deferred).resolve(); + (state.selectionSummary?.continue as Deferred | undefined)?.resolve(); }), ); } @@ -137,8 +135,6 @@ export class QueryResultWebviewController extends ReactWebviewViewController< } public updateResultsOnActiveEditorChange(editor: vscode.TextEditor | undefined): void { - this.updateSelectionSummary(); - const uri = getUriKey(editor?.document?.uri); const hasPanel = uri && this.hasPanel(uri); const hasWebviewViewState = uri && this._queryResultStateMap.has(uri); @@ -488,8 +484,6 @@ export class QueryResultWebviewController extends ReactWebviewViewController< this._queryResultStateMap.delete(uri); await this._sqlOutputContentProvider.cleanupRunner(uri); } - - this.updateSelectionSummary(); } } @@ -579,32 +573,6 @@ export class QueryResultWebviewController extends ReactWebviewViewController< return total; } - public updateSelectionSummary() { - let activeUri = Array.from(this._queryResultWebviewPanelControllerMap.keys()).find( - (uri) => this._queryResultWebviewPanelControllerMap.get(uri).panel.active, - ); - - if (!activeUri) { - activeUri = getUriKey(vscode.window.activeTextEditor?.document.uri); - } - - if (!this._queryResultStateMap.has(activeUri)) { - this._selectionSummaryStatusBarItem.hide(); - return; - } - - const state = this._queryResultStateMap.get(activeUri); - - if (state?.selectionSummary) { - this._selectionSummaryStatusBarItem.text = state.selectionSummary.text; - this._selectionSummaryStatusBarItem.tooltip = state.selectionSummary.tooltip; - this._selectionSummaryStatusBarItem.command = state.selectionSummary.command; - this._selectionSummaryStatusBarItem.show(); - } else { - this._selectionSummaryStatusBarItem.hide(); - } - } - public getOpenQueryResultsInTabByDefaultRequestHandler(): boolean { return this.vscodeWrapper .getConfiguration() diff --git a/extensions/mssql/src/queryResult/queryResultWebviewPanelController.ts b/extensions/mssql/src/queryResult/queryResultWebviewPanelController.ts index 6a21ff0afc..3b8d1d065d 100644 --- a/extensions/mssql/src/queryResult/queryResultWebviewPanelController.ts +++ b/extensions/mssql/src/queryResult/queryResultWebviewPanelController.ts @@ -72,8 +72,6 @@ export class QueryResultWebviewPanelController extends ReactWebviewPanelControll if (params.webviewPanel.viewColumn) { this._viewColumn = params.webviewPanel.viewColumn; } - - this._queryResultWebviewViewController.updateSelectionSummary(); }); } diff --git a/extensions/mssql/src/reactviews/common/locConstants.ts b/extensions/mssql/src/reactviews/common/locConstants.ts index 2eb73a7d7d..ca6f136d19 100644 --- a/extensions/mssql/src/reactviews/common/locConstants.ts +++ b/extensions/mssql/src/reactviews/common/locConstants.ts @@ -722,6 +722,13 @@ export class LocConstants { runningLabel: l10n.t("Running"), executionLabel: l10n.t("Execution"), noSelectionSummary: l10n.t("No selection"), + selectionSummaryCountLabel: l10n.t("COUNT"), + selectionSummaryAverageLabel: l10n.t("AVG"), + selectionSummarySumLabel: l10n.t("SUM"), + selectionSummaryMinLabel: l10n.t("MIN"), + selectionSummaryMaxLabel: l10n.t("MAX"), + selectionSummaryDistinctLabel: l10n.t("DISTINCT"), + selectionSummaryNullLabel: l10n.t("NULL"), executionCancelled: l10n.t("Execution cancelled"), executionTimeUnavailable: l10n.t("Execution time unavailable"), totalExecutionTimePrefix: l10n.t("Total execution time:"), diff --git a/extensions/mssql/src/reactviews/pages/QueryResult/queryResultSummaryFooter.tsx b/extensions/mssql/src/reactviews/pages/QueryResult/queryResultSummaryFooter.tsx index 4931303268..0fa891a36e 100644 --- a/extensions/mssql/src/reactviews/pages/QueryResult/queryResultSummaryFooter.tsx +++ b/extensions/mssql/src/reactviews/pages/QueryResult/queryResultSummaryFooter.tsx @@ -295,68 +295,109 @@ function formatRunningTimeCompact(milliseconds: number): string { return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`; } -function abbreviateSummaryText(text: string): string { - if (!text) { - return ""; - } - - return text - .replace(/\bDistinct Count\b/gi, "DISTINCT") - .replace(/\bNull Count\b/gi, "NULL") - .replace(/\bAverage\b/gi, "AVG") - .replace(/\bCount\b/gi, "COUNT") - .replace(/\bSum\b/gi, "SUM") - .replace(/\bMin\b/gi, "MIN") - .replace(/\bMax\b/gi, "MAX"); +type SelectionMetricKey = keyof qr.SelectionSummaryMetrics; + +const INLINE_NUMERIC_METRIC_ORDER: readonly SelectionMetricKey[] = ["count", "average", "sum"]; +const INLINE_NON_NUMERIC_METRIC_ORDER: readonly SelectionMetricKey[] = [ + "count", + "distinctCount", + "nullCount", +]; +const TOOLTIP_NUMERIC_METRIC_ORDER: readonly SelectionMetricKey[] = [ + "count", + "average", + "sum", + "min", + "max", + "distinctCount", + "nullCount", +]; +const TOOLTIP_NON_NUMERIC_METRIC_ORDER: readonly SelectionMetricKey[] = [ + "count", + "distinctCount", + "nullCount", +]; + +const SELECTION_TOOLTIP_POSITIONING = { + position: "above", + align: "end", + strategy: "fixed", + overflowBoundary: "window", + flipBoundary: "window", +} as const; + +function isNumericSelectionSummary(stats: qr.SelectionSummaryMetrics): boolean { + return typeof stats.average === "number"; } -const METRIC_ORDER = ["COUNT", "AVG", "SUM", "MIN", "MAX", "DISTINCT", "NULL"] as const; - -function isZeroMetricValue(value: string): boolean { - const normalized = value.replace(/,/g, "").trim(); - const parsed = Number(normalized); - return Number.isFinite(parsed) && parsed === 0; +function getSelectionMetricLabel(metric: SelectionMetricKey): string { + switch (metric) { + case "count": + return locConstants.queryResult.selectionSummaryCountLabel; + case "average": + return locConstants.queryResult.selectionSummaryAverageLabel; + case "sum": + return locConstants.queryResult.selectionSummarySumLabel; + case "min": + return locConstants.queryResult.selectionSummaryMinLabel; + case "max": + return locConstants.queryResult.selectionSummaryMaxLabel; + case "distinctCount": + return locConstants.queryResult.selectionSummaryDistinctLabel; + case "nullCount": + return locConstants.queryResult.selectionSummaryNullLabel; + } } -function parseSelectionMetrics(text: string): Array<{ label: string; value: string }> { - const metricMap = new Map(); - const discoveredOrder: string[] = []; - const metricRegex = /([A-Z]+):\s*([^:\n]+?)(?=(?:\s{2,}[A-Z]+:)|$|\n)/g; - let match: RegExpExecArray | null; - - while ((match = metricRegex.exec(text))) { - const label = match[1]; - const value = match[2].trim(); - if (!metricMap.has(label)) { - discoveredOrder.push(label); - } - metricMap.set(label, value); +function formatSelectionMetricValue(metric: SelectionMetricKey, value: number): string { + if (metric === "average") { + return value.toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); } - const orderedKnownMetrics: Array<{ label: string; value: string }> = []; - for (const label of METRIC_ORDER) { - const value = metricMap.get(label); - if (value === undefined) { - continue; - } - if (label === "NULL" && isZeroMetricValue(value)) { - continue; - } - orderedKnownMetrics.push({ label, value }); + if (Number.isInteger(value)) { + return value.toLocaleString(); } - const orderedUnknownMetrics = discoveredOrder - .filter((label) => !METRIC_ORDER.includes(label as (typeof METRIC_ORDER)[number])) - .map((label) => ({ label, value: metricMap.get(label)! })); + return value.toLocaleString(undefined, { + maximumFractionDigits: 20, + }); +} + +function getSelectionMetrics( + stats: qr.SelectionSummaryMetrics, + order: readonly SelectionMetricKey[], +): Array<{ label: string; value: string }> { + return order.flatMap((metric) => { + const value = stats[metric]; + if (typeof value !== "number") { + return []; + } + if (metric === "nullCount" && value === 0) { + return []; + } - return [...orderedKnownMetrics, ...orderedUnknownMetrics]; + return [ + { + label: getSelectionMetricLabel(metric), + value: formatSelectionMetricValue(metric, value), + }, + ]; + }); } -function renderSelectionMetricsInline(text: string, classes: Record) { - const metrics = parseSelectionMetrics(text); - if (metrics.length === 0) { - return {text}; - } +function renderSelectionMetricsInline( + stats: qr.SelectionSummaryMetrics, + classes: Record, +) { + const metrics = getSelectionMetrics( + stats, + isNumericSelectionSummary(stats) + ? INLINE_NUMERIC_METRIC_ORDER + : INLINE_NON_NUMERIC_METRIC_ORDER, + ); return ( @@ -371,11 +412,16 @@ function renderSelectionMetricsInline(text: string, classes: Record) { - const metrics = parseSelectionMetrics(text); - if (metrics.length === 0) { - return {text}; - } +function renderSelectionMetricsTooltip( + stats: qr.SelectionSummaryMetrics, + classes: Record, +) { + const metrics = getSelectionMetrics( + stats, + isNumericSelectionSummary(stats) + ? TOOLTIP_NUMERIC_METRIC_ORDER + : TOOLTIP_NON_NUMERIC_METRIC_ORDER, + ); return (
@@ -464,11 +510,20 @@ export const QueryResultSummaryFooter = ({ ? locConstants.queryResult.runningLabel : `${locConstants.queryResult.runningLabel}: ${compactExecutionText}` : executionText; - - const selectionText = abbreviateSummaryText(normalizeStatusText(selectionSummary?.text)); - const selectionDisplayText = selectionText || locConstants.queryResult.noSelectionSummary; - const selectionTooltip = abbreviateSummaryText( - selectionSummary?.tooltip || selectionDisplayText, + const selectionStats = selectionSummary?.stats; + const selectionCommand = selectionSummary?.command; + const selectionStatusText = normalizeStatusText(selectionSummary?.text); + const selectionDisplayContent = selectionStats + ? renderSelectionMetricsInline(selectionStats, classes) + : (selectionStatusText ?? "") || locConstants.queryResult.noSelectionSummary; + const selectionTooltipContent = selectionStats ? ( + renderSelectionMetricsTooltip(selectionStats, classes) + ) : ( + + {selectionSummary?.tooltip || + selectionStatusText || + locConstants.queryResult.noSelectionSummary} + ); const compactRowsText = typeof rowsAffectedCount === "number" ? rowsAffectedCount.toLocaleString() : "0"; @@ -516,8 +571,9 @@ export const QueryResultSummaryFooter = ({ - {selectionSummary?.command?.command ? ( + positioning={SELECTION_TOOLTIP_POSITIONING} + content={selectionTooltipContent}> + {selectionCommand?.command ? ( ) : ( - - {renderSelectionMetricsInline(selectionDisplayText, classes)} - + {selectionDisplayContent} )}
diff --git a/extensions/mssql/src/sharedInterfaces/queryResult.ts b/extensions/mssql/src/sharedInterfaces/queryResult.ts index 9be8b81221..e9cc8dc5ac 100644 --- a/extensions/mssql/src/sharedInterfaces/queryResult.ts +++ b/extensions/mssql/src/sharedInterfaces/queryResult.ts @@ -79,14 +79,25 @@ export interface QueryResultWebviewState extends ExecutionPlanWebviewState { executionStartTime?: number; } +export interface SelectionSummaryMetrics { + average?: number; + count: number; + distinctCount: number; + max?: number; + min?: number; + nullCount: number; + sum?: number; +} + export interface SelectionSummary { - text: string; - command: { + stats?: SelectionSummaryMetrics; + text?: string; + command?: { title: string; command: string; arguments: unknown[]; }; - tooltip: string; + tooltip?: string; continue?: unknown; batchId?: number; resultId?: number; From 652582ee5976db7dea04433700e7263dfcae7cfe Mon Sep 17 00:00:00 2001 From: Aasim Khan Date: Tue, 10 Mar 2026 15:30:11 -0700 Subject: [PATCH 12/23] Update localization for aggregate functions and adjust SQL Tools Service path --- extensions/mssql/l10n/bundle.l10n.json | 42 +++----------------- localization/xliff/vscode-mssql.xlf | 54 +++++++++----------------- 2 files changed, 24 insertions(+), 72 deletions(-) diff --git a/extensions/mssql/l10n/bundle.l10n.json b/extensions/mssql/l10n/bundle.l10n.json index d330c6af7c..c5ac1a2c9a 100644 --- a/extensions/mssql/l10n/bundle.l10n.json +++ b/extensions/mssql/l10n/bundle.l10n.json @@ -523,6 +523,12 @@ "Running": "Running", "Execution": "Execution", "No selection": "No selection", + "COUNT": "COUNT", + "AVG": "AVG", + "SUM": "SUM", + "MIN": "MIN", + "MAX": "MAX", + "DISTINCT": "DISTINCT", "Execution cancelled": "Execution cancelled", "Execution time unavailable": "Execution time unavailable", "Total execution time:": "Total execution time:", @@ -2241,42 +2247,6 @@ "Enter Database Description": "Enter Database Description", "Please select a workspace where you have sufficient permissions (Contributor or higher)": "Please select a workspace where you have sufficient permissions (Contributor or higher)", "This database name is already in use. Please choose a different name.": "This database name is already in use. Please choose a different name.", - "Count: {0} Distinct Count: {1} Null Count: {2}/{0} is the count, {1} is the distinct count, and {2} is the null count": { - "message": "Count: {0} Distinct Count: {1} Null Count: {2}", - "comment": ["{0} is the count, {1} is the distinct count, and {2} is the null count"] - }, - "Average: {0} Count: {1} Sum: {2}/{0} is the average, {1} is the count, {2} is the sum": { - "message": "Average: {0} Count: {1} Sum: {2}", - "comment": ["{0} is the average, {1} is the count, {2} is the sum"] - }, - "Average: {0}/{0} is the average": { - "message": "Average: {0}", - "comment": ["{0} is the average"] - }, - "Count: {0}/{0} is the count": { - "message": "Count: {0}", - "comment": ["{0} is the count"] - }, - "Distinct Count: {0}/{0} is the distinct count": { - "message": "Distinct Count: {0}", - "comment": ["{0} is the distinct count"] - }, - "Max: {0}/{0} is the max": { - "message": "Max: {0}", - "comment": ["{0} is the max"] - }, - "Min: {0}/{0} is the min": { - "message": "Min: {0}", - "comment": ["{0} is the min"] - }, - "Null Count: {0}/{0} is the null count": { - "message": "Null Count: {0}", - "comment": ["{0} is the null count"] - }, - "Sum: {0}/{0} is the sum": { - "message": "Sum: {0}", - "comment": ["{0} is the sum"] - }, "An error occurred while copying results: {0}/{0} is the error message": { "message": "An error occurred while copying results: {0}", "comment": ["{0} is the error message"] diff --git a/localization/xliff/vscode-mssql.xlf b/localization/xliff/vscode-mssql.xlf index 8eaf34bde8..f36f792295 100644 --- a/localization/xliff/vscode-mssql.xlf +++ b/localization/xliff/vscode-mssql.xlf @@ -105,6 +105,9 @@ API Type + + AVG + Accelerate schema evolution by autogenerating ORM migrations or T-SQL change scripts @@ -468,14 +471,6 @@ Available Servers - - Average: {0} - {0} is the average - - - Average: {0} Count: {1} Sum: {2} - {0} is the average, {1} is the count, {2} is the sum - Azure (China) @@ -669,6 +664,9 @@ Bulk-logged + + COUNT + CSV @@ -1492,14 +1490,6 @@ Could not load restore plan - - Count: {0} - {0} is the count - - - Count: {0} Distinct Count: {1} Null Count: {2} - {0} is the count, {1} is the distinct count, and {2} is the null count - Create @@ -1639,6 +1629,9 @@ DACPAC path not found. Please build the project first. + + DISTINCT + DacFx service is not available. Profile loaded without deployment options. Publish and generate script operations cannot be performed. @@ -1916,10 +1909,6 @@ Dissatisfied - - Distinct Count: {0} - {0} is the distinct count - Do not compress backup @@ -3641,6 +3630,9 @@ Looking for Azure Data Studio key bindings, like F5 to execute queries? + + MAX + MCP @@ -3652,6 +3644,9 @@ MCP server is already configured in {0} {0} is the file path where the MCP server configuration exists + + MIN + MSSQL @@ -3695,10 +3690,6 @@ Max row count for filtering/sorting has been exceeded. To update it, navigate to User Settings and change the setting: mssql.resultsGrid.inMemoryDataProcessingThreshold - - Max: {0} - {0} is the max - Maximize @@ -3777,10 +3768,6 @@ Migrate saved connections, connection groups, and connection settings from Azure Data Studio into the MSSQL extension. Additionally, the MSSQL Data Management Keymap can be installed to add familiar shortcuts from Azure Data Studio. - - Min: {0} - {0} is the min - Missing connectionId. Please provide a connectionId to open Data API Builder. @@ -4248,10 +4235,6 @@ Note: A self-signed certificate offers only limited protection and is not a recommended practice for production environments. Do you want to enable 'Trust server certificate' on this connection and retry? - - Null Count: {0} - {0} is the null count - Number of Rows Read @@ -5120,6 +5103,9 @@ SQLCMD Variables + + SUM + SVG @@ -5904,10 +5890,6 @@ Successfully saved results to - - Sum: {0} - {0} is the sum - Summary loading canceled From 322d45c5273753d05fad919e9684150c1c892a40 Mon Sep 17 00:00:00 2001 From: Aasim Khan Date: Tue, 10 Mar 2026 15:44:36 -0700 Subject: [PATCH 13/23] Remove unused SVG files and update color styles in query result summary footer --- extensions/mssql/media/cancelConnect_dark.svg | 23 ------------------- .../mssql/media/cancelConnect_light.svg | 23 ------------------- .../QueryResult/queryResultSummaryFooter.tsx | 10 ++++---- 3 files changed, 5 insertions(+), 51 deletions(-) delete mode 100644 extensions/mssql/media/cancelConnect_dark.svg delete mode 100644 extensions/mssql/media/cancelConnect_light.svg diff --git a/extensions/mssql/media/cancelConnect_dark.svg b/extensions/mssql/media/cancelConnect_dark.svg deleted file mode 100644 index 659dcc0699..0000000000 --- a/extensions/mssql/media/cancelConnect_dark.svg +++ /dev/null @@ -1,23 +0,0 @@ - - - - - cancelConnect_dark - - - - diff --git a/extensions/mssql/media/cancelConnect_light.svg b/extensions/mssql/media/cancelConnect_light.svg deleted file mode 100644 index d41e066a45..0000000000 --- a/extensions/mssql/media/cancelConnect_light.svg +++ /dev/null @@ -1,23 +0,0 @@ - - - - - cancelConnect_light - - - - diff --git a/extensions/mssql/src/reactviews/pages/QueryResult/queryResultSummaryFooter.tsx b/extensions/mssql/src/reactviews/pages/QueryResult/queryResultSummaryFooter.tsx index 0fa891a36e..fd3159693d 100644 --- a/extensions/mssql/src/reactviews/pages/QueryResult/queryResultSummaryFooter.tsx +++ b/extensions/mssql/src/reactviews/pages/QueryResult/queryResultSummaryFooter.tsx @@ -64,10 +64,10 @@ const useStyles = makeStyles({ flexShrink: 0, }, rowsAccent: { - color: "var(--vscode-terminal-ansiBlue)", + color: "var(--vscode-foreground)", }, timeAccent: { - color: "var(--vscode-terminal-ansiYellow)", + color: "var(--vscode-foreground)", }, selectionSegment: { minWidth: 0, @@ -97,7 +97,7 @@ const useStyles = makeStyles({ background: "none", padding: 0, margin: 0, - color: "var(--vscode-textLink-foreground)", + color: "var(--vscode-foreground)", cursor: "pointer", fontSize: "11px", fontWeight: 600, @@ -120,7 +120,7 @@ const useStyles = makeStyles({ color: "var(--vscode-descriptionForeground)", }, selectionMetricValue: { - color: "var(--vscode-terminal-ansiBlue)", + color: "var(--vscode-foreground)", }, selectionTooltipMetricValue: { justifySelf: "end", @@ -129,7 +129,7 @@ const useStyles = makeStyles({ fontFeatureSettings: '"tnum"', }, cancelled: { - color: "var(--vscode-errorForeground)", + color: "var(--vscode-foreground)", }, }); From 29b672acae7377d919a7b4f8178e2b4f3800b4fb Mon Sep 17 00:00:00 2001 From: Aasim Khan Date: Sat, 11 Apr 2026 11:19:59 -0700 Subject: [PATCH 14/23] Remove unused status bar items from StatusView --- .../mssql/src/controllers/mainController.ts | 204 ++---------------- extensions/mssql/src/views/statusView.ts | 2 - 2 files changed, 17 insertions(+), 189 deletions(-) diff --git a/extensions/mssql/src/controllers/mainController.ts b/extensions/mssql/src/controllers/mainController.ts index 3a2635e1e3..a3f177f7bf 100644 --- a/extensions/mssql/src/controllers/mainController.ts +++ b/extensions/mssql/src/controllers/mainController.ts @@ -47,9 +47,7 @@ import VscodeWrapper from "./vscodeWrapper"; import { sendActionEvent } from "../telemetry/telemetry"; import { TelemetryActions, TelemetryViews } from "../sharedInterfaces/telemetry"; import { TableDesignerService } from "../services/tableDesignerService"; -import { getPreviewConfigKey, PreviewFeature, previewService } from "../previews/previewService"; import { TableDesignerWebviewController } from "../tableDesigner/tableDesignerWebviewController"; -import { uriOwnershipCoordinator } from "../extension"; import { ConnectionDialogWebviewController } from "../connectionconfig/connectionDialogWebviewController"; import { DacpacDialogWebviewController } from "./dacpacDialogWebviewController"; import { CreateDatabaseWebviewController } from "./createDatabaseWebviewController"; @@ -121,10 +119,6 @@ import { AzureBlobService } from "../services/azureBlobService"; import { FlatFileImportWebviewController } from "./flatFileImportWebviewController"; import { RestoreDatabaseWebviewController } from "./restoreDatabaseWebviewController"; import { CopilotChat } from "../sharedInterfaces/copilotChat"; -import { BackgroundTasksProvider } from "../backgroundTasks/backgroundTasksProvider"; -import { BackgroundTaskNode } from "../backgroundTasks/backgroundTaskNode"; -import { BackgroundTaskLogContentProvider } from "../backgroundTasks/backgroundTaskLogContentProvider"; -import { BackgroundTasksService } from "../backgroundTasks/backgroundTasksService"; /** * The main controller class that initializes the extension @@ -141,17 +135,12 @@ export default class MainController implements vscode.Disposable { private _sqlDocumentService: SqlDocumentService; private _objectExplorerProvider: ObjectExplorerProvider; private _queryHistoryProvider: QueryHistoryProvider; - private _backgroundTaskLogContentProvider: BackgroundTaskLogContentProvider; - private _backgroundTasksProvider: BackgroundTasksProvider; private _scriptingService: ScriptingService; private _queryHistoryRegistered: boolean = false; private _availableCommands: string[] | undefined; private _logger: Logger; - private _lastBackgroundTaskClickTime = 0; - private _lastBackgroundTaskId: string | undefined; public sqlTasksService: SqlTasksService; - public backgroundTasksService: BackgroundTasksService; public dacFxService: DacFxService; public objectManagementService: ObjectManagementService; public schemaCompareService: SchemaCompareService; @@ -216,21 +205,6 @@ export default class MainController implements vscode.Disposable { ); } - private onConnectCommand(): void { - if (uriOwnershipCoordinator?.isActiveEditorOwnedByOtherExtensionWithWarning()) { - return; - } - void this.runAndLogErrors(this.promptToConnect()); - } - - private onRunQueryCommand(): void { - if (uriOwnershipCoordinator?.isActiveEditorOwnedByOtherExtensionWithWarning()) { - return; - } - void UserSurvey.getInstance().promptUserForNPSFeedback("runQuery"); - void this.onRunQuery(); - } - /** * Disposes the controller */ @@ -248,7 +222,7 @@ export default class MainController implements vscode.Disposable { } public get isExperimentalEnabled(): boolean { - return previewService.experimentalFeaturesEnabled; + return this.configuration.get(Constants.configEnableExperimentalFeatures); } public get isOpenQueryResultsInTabByDefaultEnabled(): boolean { @@ -263,9 +237,9 @@ export default class MainController implements vscode.Disposable { if (didInitialize) { // register VS Code commands this.registerCommand(Constants.cmdConnect); - this._event.on(Constants.cmdConnect, () => this.onConnectCommand()); - this.registerCommand(Constants.cmdConnectWithUriOwnership); - this._event.on(Constants.cmdConnectWithUriOwnership, () => this.onConnectCommand()); + this._event.on(Constants.cmdConnect, () => { + void this.runAndLogErrors(this.promptToConnect()); + }); this.registerCommand(Constants.cmdChangeConnection); this._event.on(Constants.cmdChangeConnection, () => { void this.runAndLogErrors(this.promptToConnect()); @@ -279,9 +253,10 @@ export default class MainController implements vscode.Disposable { void this.runAndLogErrors(this.onCancelConnect()); }); this.registerCommand(Constants.cmdRunQuery); - this._event.on(Constants.cmdRunQuery, () => this.onRunQueryCommand()); - this.registerCommand(Constants.cmdRunQueryWithUriOwnership); - this._event.on(Constants.cmdRunQueryWithUriOwnership, () => this.onRunQueryCommand()); + this._event.on(Constants.cmdRunQuery, () => { + void UserSurvey.getInstance().promptUserForNPSFeedback("runQuery"); + void this.onRunQuery(); + }); this.registerCommand(Constants.cmdManageConnectionProfiles); this._event.on(Constants.cmdManageConnectionProfiles, async () => { await this.onManageProfiles(); @@ -292,9 +267,13 @@ export default class MainController implements vscode.Disposable { }); this.registerCommandWithArgs(Constants.cmdDeployNewDatabase); this._event.on(Constants.cmdDeployNewDatabase, (args?: any) => { - let initialConnectionGroup: string | undefined; - if (args && args instanceof ConnectionGroupNode) { - initialConnectionGroup = args.connectionGroup?.id; + let initialConnectionGroup: string; + if (args) { + if (args instanceof ConnectionGroupNode) { + initialConnectionGroup = args.connectionGroup?.id; + } else if (typeof args === "object" && args.id) { + initialConnectionGroup = args.id; + } } this.onDeployNewDatabase(initialConnectionGroup); }); @@ -632,13 +611,11 @@ export default class MainController implements vscode.Disposable { ); this.initializeQueryHistory(); - this.initializeBackgroundTasks(); this.sqlTasksService = new SqlTasksService( SqlToolsServerClient.instance, this._sqlDocumentService, this._vscodeWrapper, - this.backgroundTasksService, ); this.dacFxService = new DacFxService( SqlToolsServerClient.instance, @@ -1073,17 +1050,16 @@ export default class MainController implements vscode.Disposable { // capture basic metadata sendActionEvent(TelemetryViews.General, TelemetryActions.Activated, { - experimentalFeaturesEnabled: previewService.experimentalFeaturesEnabled.toString(), + experimentalFeaturesEnabled: this.isExperimentalEnabled.toString(), openQueryResultsInTabByDefault: this.isOpenQueryResultsInTabByDefaultEnabled.toString(), cloudType: getCloudId(), - previewFeatureOverrides: JSON.stringify(previewService.getNonDefaultOverrides()), }); // Set context for experimental features (used for conditional menu visibility) await vscode.commands.executeCommand( "setContext", "mssql.experimentalFeaturesEnabled", - previewService.experimentalFeaturesEnabled, + this.isExperimentalEnabled, ); await this._connectionMgr.initialized; @@ -1854,27 +1830,6 @@ export default class MainController implements vscode.Disposable { ), ); - this._context.subscriptions.push( - vscode.commands.registerCommand( - Constants.cmdDesignSchemaForTable, - async (node: TreeNodeInfo, databaseName: string, filterTable: string) => { - const schemaDesigner = - await SchemaDesignerWebviewManager.getInstance().getSchemaDesigner( - this._context, - this._vscodeWrapper, - this, - this.schemaDesignerService, - databaseName, - node, - ); - - schemaDesigner.setInitialFilterTables([filterTable]); - schemaDesigner.showView(SchemaDesigner.SchemaDesignerActiveView.SchemaDesigner); - schemaDesigner.revealToForeground(); - }, - ), - ); - this._context.subscriptions.push( vscode.commands.registerCommand( Constants.cmdBuildDataApi, @@ -2270,130 +2225,6 @@ export default class MainController implements vscode.Disposable { ); } - /** - * Initializes the Background Tasks commands - */ - private initializeBackgroundTasks(): void { - this._backgroundTasksProvider = new BackgroundTasksProvider(); - this.backgroundTasksService = this._backgroundTasksProvider.backgroundTasksService; - this._backgroundTaskLogContentProvider = new BackgroundTaskLogContentProvider( - this.backgroundTasksService, - ); - - const treeView = vscode.window.createTreeView(Constants.backgroundTasks, { - treeDataProvider: this._backgroundTasksProvider, - }); - - this._backgroundTasksProvider.treeView = treeView; - - this._context.subscriptions.push( - vscode.workspace.registerTextDocumentContentProvider( - Constants.backgroundTaskLogUriScheme, - this._backgroundTaskLogContentProvider, - ), - ); - this._context.subscriptions.push(this._backgroundTaskLogContentProvider); - this._context.subscriptions.push(this._backgroundTasksProvider); - this._context.subscriptions.push(treeView); - - this._context.subscriptions.push( - vscode.commands.registerCommand(Constants.cmdClearFinishedBackgroundTasks, () => { - this._backgroundTasksProvider.clearFinished(); - }), - ); - - this._context.subscriptions.push( - vscode.commands.registerCommand( - Constants.cmdOpenBackgroundTask, - async (node: BackgroundTaskNode) => { - await this._backgroundTasksProvider.openTask(node.taskId); - }, - ), - ); - - this._context.subscriptions.push( - vscode.commands.registerCommand( - Constants.cmdViewBackgroundTaskLogs, - async (node: BackgroundTaskNode) => { - if (!node) { - return; - } - await this._backgroundTaskLogContentProvider.showTaskLog(node.taskId); - }, - ), - ); - - this._context.subscriptions.push( - vscode.commands.registerCommand( - Constants.cmdCancelBackgroundTask, - async (node: BackgroundTaskNode) => { - await this.confirmAndCancelBackgroundTask(node); - }, - ), - ); - - this._context.subscriptions.push( - vscode.commands.registerCommand( - Constants.cmdBackgroundTaskAction, - async (node: BackgroundTaskNode) => { - await this.handleBackgroundTaskNodeAction(node); - }, - ), - ); - } - - private async handleBackgroundTaskNodeAction(node: BackgroundTaskNode): Promise { - const currentTime = Date.now(); - const doubleClickThreshold = 500; - - if ( - this._lastBackgroundTaskId === node.taskId && - currentTime - this._lastBackgroundTaskClickTime < doubleClickThreshold - ) { - await this._backgroundTasksProvider.openTask(node.taskId); - this._lastBackgroundTaskId = undefined; - this._lastBackgroundTaskClickTime = 0; - } else { - this._lastBackgroundTaskId = node.taskId; - this._lastBackgroundTaskClickTime = currentTime; - } - } - - private async confirmAndCancelBackgroundTask( - node: BackgroundTaskNode | undefined, - ): Promise { - if (!node) { - return; - } - - const detail = this.getBackgroundTaskCancelConfirmationDetail(node); - - const confirmation = await vscode.window.showWarningMessage( - LocalizedConstants.backgroundTaskCancelConfirmation, - { - modal: true, - detail, - }, - LocalizedConstants.backgroundTaskCancelConfirm, - ); - - if (confirmation !== LocalizedConstants.backgroundTaskCancelConfirm) { - return; - } - - await this._backgroundTasksProvider.cancelTask(node.taskId); - } - - private getBackgroundTaskCancelConfirmationDetail( - node: BackgroundTaskNode, - ): string | undefined { - const label = typeof node.label === "string" ? node.label : node.label?.label; - const description = typeof node.description === "string" ? node.description : undefined; - const sections = [label, description].filter((value): value is string => Boolean(value)); - - return sections.length > 0 ? sections.join("\n") : undefined; - } - /** * Initializes the Query History commands */ @@ -3193,7 +3024,6 @@ export default class MainController implements vscode.Disposable { Constants.configSovereignCloudEnvironment, Constants.configSovereignCloudCustomEnvironment, Constants.configCustomEnvironment, - getPreviewConfigKey(PreviewFeature.UseVscodeAccountsForEntraMFA), ]; if (configSettingsRequiringReload.some((setting) => e.affectsConfiguration(setting))) { diff --git a/extensions/mssql/src/views/statusView.ts b/extensions/mssql/src/views/statusView.ts index bfce71516b..969c0fbc14 100644 --- a/extensions/mssql/src/views/statusView.ts +++ b/extensions/mssql/src/views/statusView.ts @@ -75,8 +75,6 @@ export default class StatusView implements vscode.Disposable { this.showStatusBarItem(fileUri, bar.statusChangeDatabase); this.showStatusBarItem(fileUri, bar.statusLanguageService); this.showStatusBarItem(fileUri, bar.sqlCmdMode); - this.showStatusBarItem(fileUri, bar.rowCount); - this.showStatusBarItem(fileUri, bar.executionTime); } } }); From 56e4f8d101cd63103ed1ade8becb598da845f172 Mon Sep 17 00:00:00 2001 From: Aasim Khan Date: Sun, 12 Apr 2026 21:17:59 -0700 Subject: [PATCH 15/23] Refactor query execution status handling and improve background tasks integration --- .../mssql/src/controllers/mainController.ts | 210 ++++++++++++++++-- .../mssql/src/controllers/queryRunner.ts | 5 - .../src/models/sqlOutputContentProvider.ts | 3 - extensions/mssql/src/views/statusView.ts | 75 ------- .../mssql/src/webviews/common/locConstants.ts | 14 +- .../mssql/src/webviews/media/slickgrid.css | 18 +- .../QueryResult/queryResultSummaryFooter.tsx | 59 +---- .../pages/QueryResult/queryResultUtils.ts | 70 ++++++ .../unit/queryResultSummaryFooter.test.ts | 70 ++++++ .../mssql/test/unit/queryRunner.test.ts | 16 +- .../unit/sqlOutputContentProvider.test.ts | 7 +- 11 files changed, 344 insertions(+), 203 deletions(-) create mode 100644 extensions/mssql/test/unit/queryResultSummaryFooter.test.ts diff --git a/extensions/mssql/src/controllers/mainController.ts b/extensions/mssql/src/controllers/mainController.ts index a3f177f7bf..270fde1692 100644 --- a/extensions/mssql/src/controllers/mainController.ts +++ b/extensions/mssql/src/controllers/mainController.ts @@ -47,7 +47,9 @@ import VscodeWrapper from "./vscodeWrapper"; import { sendActionEvent } from "../telemetry/telemetry"; import { TelemetryActions, TelemetryViews } from "../sharedInterfaces/telemetry"; import { TableDesignerService } from "../services/tableDesignerService"; +import { getPreviewConfigKey, PreviewFeature, previewService } from "../previews/previewService"; import { TableDesignerWebviewController } from "../tableDesigner/tableDesignerWebviewController"; +import { uriOwnershipCoordinator } from "../extension"; import { ConnectionDialogWebviewController } from "../connectionconfig/connectionDialogWebviewController"; import { DacpacDialogWebviewController } from "./dacpacDialogWebviewController"; import { CreateDatabaseWebviewController } from "./createDatabaseWebviewController"; @@ -119,6 +121,10 @@ import { AzureBlobService } from "../services/azureBlobService"; import { FlatFileImportWebviewController } from "./flatFileImportWebviewController"; import { RestoreDatabaseWebviewController } from "./restoreDatabaseWebviewController"; import { CopilotChat } from "../sharedInterfaces/copilotChat"; +import { BackgroundTasksProvider } from "../backgroundTasks/backgroundTasksProvider"; +import { BackgroundTaskNode } from "../backgroundTasks/backgroundTaskNode"; +import { BackgroundTaskLogContentProvider } from "../backgroundTasks/backgroundTaskLogContentProvider"; +import { BackgroundTasksService } from "../backgroundTasks/backgroundTasksService"; /** * The main controller class that initializes the extension @@ -135,12 +141,17 @@ export default class MainController implements vscode.Disposable { private _sqlDocumentService: SqlDocumentService; private _objectExplorerProvider: ObjectExplorerProvider; private _queryHistoryProvider: QueryHistoryProvider; + private _backgroundTaskLogContentProvider: BackgroundTaskLogContentProvider; + private _backgroundTasksProvider: BackgroundTasksProvider; private _scriptingService: ScriptingService; private _queryHistoryRegistered: boolean = false; private _availableCommands: string[] | undefined; private _logger: Logger; + private _lastBackgroundTaskClickTime = 0; + private _lastBackgroundTaskId: string | undefined; public sqlTasksService: SqlTasksService; + public backgroundTasksService: BackgroundTasksService; public dacFxService: DacFxService; public objectManagementService: ObjectManagementService; public schemaCompareService: SchemaCompareService; @@ -205,6 +216,21 @@ export default class MainController implements vscode.Disposable { ); } + private onConnectCommand(): void { + if (uriOwnershipCoordinator?.isActiveEditorOwnedByOtherExtensionWithWarning()) { + return; + } + void this.runAndLogErrors(this.promptToConnect()); + } + + private onRunQueryCommand(): void { + if (uriOwnershipCoordinator?.isActiveEditorOwnedByOtherExtensionWithWarning()) { + return; + } + void UserSurvey.getInstance().promptUserForNPSFeedback("runQuery"); + void this.onRunQuery(); + } + /** * Disposes the controller */ @@ -221,13 +247,6 @@ export default class MainController implements vscode.Disposable { this._statusview.dispose(); } - public get isExperimentalEnabled(): boolean { - return this.configuration.get(Constants.configEnableExperimentalFeatures); - } - - public get isOpenQueryResultsInTabByDefaultEnabled(): boolean { - return this.configuration.get(Constants.configOpenQueryResultsInTabByDefault, false); - } /** * Initializes the extension */ @@ -237,9 +256,9 @@ export default class MainController implements vscode.Disposable { if (didInitialize) { // register VS Code commands this.registerCommand(Constants.cmdConnect); - this._event.on(Constants.cmdConnect, () => { - void this.runAndLogErrors(this.promptToConnect()); - }); + this._event.on(Constants.cmdConnect, () => this.onConnectCommand()); + this.registerCommand(Constants.cmdConnectWithUriOwnership); + this._event.on(Constants.cmdConnectWithUriOwnership, () => this.onConnectCommand()); this.registerCommand(Constants.cmdChangeConnection); this._event.on(Constants.cmdChangeConnection, () => { void this.runAndLogErrors(this.promptToConnect()); @@ -253,10 +272,9 @@ export default class MainController implements vscode.Disposable { void this.runAndLogErrors(this.onCancelConnect()); }); this.registerCommand(Constants.cmdRunQuery); - this._event.on(Constants.cmdRunQuery, () => { - void UserSurvey.getInstance().promptUserForNPSFeedback("runQuery"); - void this.onRunQuery(); - }); + this._event.on(Constants.cmdRunQuery, () => this.onRunQueryCommand()); + this.registerCommand(Constants.cmdRunQueryWithUriOwnership); + this._event.on(Constants.cmdRunQueryWithUriOwnership, () => this.onRunQueryCommand()); this.registerCommand(Constants.cmdManageConnectionProfiles); this._event.on(Constants.cmdManageConnectionProfiles, async () => { await this.onManageProfiles(); @@ -267,13 +285,9 @@ export default class MainController implements vscode.Disposable { }); this.registerCommandWithArgs(Constants.cmdDeployNewDatabase); this._event.on(Constants.cmdDeployNewDatabase, (args?: any) => { - let initialConnectionGroup: string; - if (args) { - if (args instanceof ConnectionGroupNode) { - initialConnectionGroup = args.connectionGroup?.id; - } else if (typeof args === "object" && args.id) { - initialConnectionGroup = args.id; - } + let initialConnectionGroup: string | undefined; + if (args && args instanceof ConnectionGroupNode) { + initialConnectionGroup = args.connectionGroup?.id; } this.onDeployNewDatabase(initialConnectionGroup); }); @@ -611,11 +625,13 @@ export default class MainController implements vscode.Disposable { ); this.initializeQueryHistory(); + this.initializeBackgroundTasks(); this.sqlTasksService = new SqlTasksService( SqlToolsServerClient.instance, this._sqlDocumentService, this._vscodeWrapper, + this.backgroundTasksService, ); this.dacFxService = new DacFxService( SqlToolsServerClient.instance, @@ -1050,16 +1066,16 @@ export default class MainController implements vscode.Disposable { // capture basic metadata sendActionEvent(TelemetryViews.General, TelemetryActions.Activated, { - experimentalFeaturesEnabled: this.isExperimentalEnabled.toString(), - openQueryResultsInTabByDefault: this.isOpenQueryResultsInTabByDefaultEnabled.toString(), + experimentalFeaturesEnabled: previewService.experimentalFeaturesEnabled.toString(), cloudType: getCloudId(), + previewFeatureOverrides: JSON.stringify(previewService.getNonDefaultOverrides()), }); // Set context for experimental features (used for conditional menu visibility) await vscode.commands.executeCommand( "setContext", "mssql.experimentalFeaturesEnabled", - this.isExperimentalEnabled, + previewService.experimentalFeaturesEnabled, ); await this._connectionMgr.initialized; @@ -1830,6 +1846,27 @@ export default class MainController implements vscode.Disposable { ), ); + this._context.subscriptions.push( + vscode.commands.registerCommand( + Constants.cmdDesignSchemaForTable, + async (node: TreeNodeInfo, databaseName: string, filterTable: string) => { + const schemaDesigner = + await SchemaDesignerWebviewManager.getInstance().getSchemaDesigner( + this._context, + this._vscodeWrapper, + this, + this.schemaDesignerService, + databaseName, + node, + ); + + schemaDesigner.setInitialFilterTables([filterTable]); + schemaDesigner.showView(SchemaDesigner.SchemaDesignerActiveView.SchemaDesigner); + schemaDesigner.revealToForeground(); + }, + ), + ); + this._context.subscriptions.push( vscode.commands.registerCommand( Constants.cmdBuildDataApi, @@ -2225,6 +2262,130 @@ export default class MainController implements vscode.Disposable { ); } + /** + * Initializes the Background Tasks commands + */ + private initializeBackgroundTasks(): void { + this._backgroundTasksProvider = new BackgroundTasksProvider(); + this.backgroundTasksService = this._backgroundTasksProvider.backgroundTasksService; + this._backgroundTaskLogContentProvider = new BackgroundTaskLogContentProvider( + this.backgroundTasksService, + ); + + const treeView = vscode.window.createTreeView(Constants.backgroundTasks, { + treeDataProvider: this._backgroundTasksProvider, + }); + + this._backgroundTasksProvider.treeView = treeView; + + this._context.subscriptions.push( + vscode.workspace.registerTextDocumentContentProvider( + Constants.backgroundTaskLogUriScheme, + this._backgroundTaskLogContentProvider, + ), + ); + this._context.subscriptions.push(this._backgroundTaskLogContentProvider); + this._context.subscriptions.push(this._backgroundTasksProvider); + this._context.subscriptions.push(treeView); + + this._context.subscriptions.push( + vscode.commands.registerCommand(Constants.cmdClearFinishedBackgroundTasks, () => { + this._backgroundTasksProvider.clearFinished(); + }), + ); + + this._context.subscriptions.push( + vscode.commands.registerCommand( + Constants.cmdOpenBackgroundTask, + async (node: BackgroundTaskNode) => { + await this._backgroundTasksProvider.openTask(node.taskId); + }, + ), + ); + + this._context.subscriptions.push( + vscode.commands.registerCommand( + Constants.cmdViewBackgroundTaskLogs, + async (node: BackgroundTaskNode) => { + if (!node) { + return; + } + await this._backgroundTaskLogContentProvider.showTaskLog(node.taskId); + }, + ), + ); + + this._context.subscriptions.push( + vscode.commands.registerCommand( + Constants.cmdCancelBackgroundTask, + async (node: BackgroundTaskNode) => { + await this.confirmAndCancelBackgroundTask(node); + }, + ), + ); + + this._context.subscriptions.push( + vscode.commands.registerCommand( + Constants.cmdBackgroundTaskAction, + async (node: BackgroundTaskNode) => { + await this.handleBackgroundTaskNodeAction(node); + }, + ), + ); + } + + private async handleBackgroundTaskNodeAction(node: BackgroundTaskNode): Promise { + const currentTime = Date.now(); + const doubleClickThreshold = 500; + + if ( + this._lastBackgroundTaskId === node.taskId && + currentTime - this._lastBackgroundTaskClickTime < doubleClickThreshold + ) { + await this._backgroundTasksProvider.openTask(node.taskId); + this._lastBackgroundTaskId = undefined; + this._lastBackgroundTaskClickTime = 0; + } else { + this._lastBackgroundTaskId = node.taskId; + this._lastBackgroundTaskClickTime = currentTime; + } + } + + private async confirmAndCancelBackgroundTask( + node: BackgroundTaskNode | undefined, + ): Promise { + if (!node) { + return; + } + + const detail = this.getBackgroundTaskCancelConfirmationDetail(node); + + const confirmation = await vscode.window.showWarningMessage( + LocalizedConstants.backgroundTaskCancelConfirmation, + { + modal: true, + detail, + }, + LocalizedConstants.backgroundTaskCancelConfirm, + ); + + if (confirmation !== LocalizedConstants.backgroundTaskCancelConfirm) { + return; + } + + await this._backgroundTasksProvider.cancelTask(node.taskId); + } + + private getBackgroundTaskCancelConfirmationDetail( + node: BackgroundTaskNode, + ): string | undefined { + const label = typeof node.label === "string" ? node.label : node.label?.label; + const description = typeof node.description === "string" ? node.description : undefined; + const sections = [label, description].filter((value): value is string => Boolean(value)); + + return sections.length > 0 ? sections.join("\n") : undefined; + } + /** * Initializes the Query History commands */ @@ -3024,6 +3185,7 @@ export default class MainController implements vscode.Disposable { Constants.configSovereignCloudEnvironment, Constants.configSovereignCloudCustomEnvironment, Constants.configCustomEnvironment, + getPreviewConfigKey(PreviewFeature.UseVscodeAccountsForEntraMFA), ]; if (configSettingsRequiringReload.some((setting) => e.affectsConfiguration(setting))) { diff --git a/extensions/mssql/src/controllers/queryRunner.ts b/extensions/mssql/src/controllers/queryRunner.ts index db55d61d5d..b8ba11e4eb 100644 --- a/extensions/mssql/src/controllers/queryRunner.ts +++ b/extensions/mssql/src/controllers/queryRunner.ts @@ -433,8 +433,6 @@ export default class QueryRunner { this._resultLineOffset = selection ? selection.startLine : 0; this._isExecuting = true; this._totalElapsedMilliseconds = 0; - // Update the status view to show that we're executing - this._statusView.executingQuery(this.uri); QueryRunner.addRunningQuery(this._ownerUri); @@ -465,7 +463,6 @@ export default class QueryRunner { promise.resolve(); this._uriToQueryPromiseMap.delete(result.ownerUri); } - this._statusView.executedQuery(result.ownerUri); let hasError = this._batchSets.some((batch) => batch.hasError === true); this.removeRunningQuery(); this.unregisterAllNotificationUris(); @@ -614,8 +611,6 @@ export default class QueryRunner { totalMilliseconds: Utils.parseNumAsTimeString(this._totalElapsedMilliseconds), hasError: !!error, }); - this._statusView.executedQuery(this._ownerUri); - this.unregisterAllNotificationUris(); if (errorMsg) { diff --git a/extensions/mssql/src/models/sqlOutputContentProvider.ts b/extensions/mssql/src/models/sqlOutputContentProvider.ts index 87b5d7369e..cf0b01f19d 100644 --- a/extensions/mssql/src/models/sqlOutputContentProvider.ts +++ b/extensions/mssql/src/models/sqlOutputContentProvider.ts @@ -747,9 +747,6 @@ export class SqlOutputContentProvider { return; } - // Switch the spinner to canceling, which will be reset when the query execute sends back its completed event - this._statusView.cancelingQuery(queryRunner.uri); - // Cancel the query try { await queryRunner.cancel(); diff --git a/extensions/mssql/src/views/statusView.ts b/extensions/mssql/src/views/statusView.ts index 969c0fbc14..31d276830f 100644 --- a/extensions/mssql/src/views/statusView.ts +++ b/extensions/mssql/src/views/statusView.ts @@ -24,17 +24,12 @@ class FileStatusBar { public statusConnection: vscode.StatusBarItem; // Item for the change database public statusChangeDatabase: vscode.StatusBarItem; - // Item for the query status - public statusQuery: vscode.StatusBarItem; // Item for language service status public statusLanguageService: vscode.StatusBarItem; // Item for SQLCMD Mode public sqlCmdMode: vscode.StatusBarItem; - // Timer used for displaying a progress indicator on queries - public progressTimerId: NodeJS.Timeout; public currentLanguageServiceStatus: string; - public queryTimer: NodeJS.Timeout; public connectionId: string; } @@ -87,11 +82,8 @@ export default class StatusView implements vscode.Disposable { this._statusBars[bar].statusLanguageFlavor.dispose(); this._statusBars[bar].statusConnection.dispose(); this._statusBars[bar].statusChangeDatabase.dispose(); - this._statusBars[bar].statusQuery.dispose(); this._statusBars[bar].statusLanguageService.dispose(); this._statusBars[bar].sqlCmdMode.dispose(); - clearInterval(this._statusBars[bar].progressTimerId); - clearInterval(this._statusBars[bar].queryTimer); delete this._statusBars[bar]; } } @@ -114,8 +106,6 @@ export default class StatusView implements vscode.Disposable { bar.statusChangeDatabase = vscode.window.createStatusBarItem( vscode.StatusBarAlignment.Right, ); - bar.statusQuery = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right); - bar.statusQuery.accessibilityInformation = { role: "alert", label: "" }; bar.statusLanguageService = vscode.window.createStatusBarItem( vscode.StatusBarAlignment.Right, ); @@ -135,18 +125,9 @@ export default class StatusView implements vscode.Disposable { if (bar.statusChangeDatabase) { bar.statusChangeDatabase.dispose(); } - if (bar.statusQuery) { - bar.statusQuery.dispose(); - } if (bar.statusLanguageService) { bar.statusLanguageService.dispose(); } - if (bar.progressTimerId) { - clearInterval(bar.progressTimerId); - } - if (bar.queryTimer) { - clearInterval(bar.queryTimer); - } if (bar.sqlCmdMode) { bar.sqlCmdMode.dispose(); } @@ -162,9 +143,6 @@ export default class StatusView implements vscode.Disposable { } let bar = this._statusBars[fileUri]; - if (bar.progressTimerId) { - clearInterval(bar.progressTimerId); - } return bar; } @@ -173,7 +151,6 @@ export default class StatusView implements vscode.Disposable { this.showStatusBarItem(fileUri, bar.statusLanguageFlavor); this.showStatusBarItem(fileUri, bar.statusConnection); this.showStatusBarItem(fileUri, bar.statusChangeDatabase); - this.showStatusBarItem(fileUri, bar.statusQuery); this.showStatusBarItem(fileUri, bar.statusLanguageService); this.showStatusBarItem(fileUri, bar.sqlCmdMode); } @@ -194,8 +171,6 @@ export default class StatusView implements vscode.Disposable { this.showStatusBarItem(fileUri, bar.statusLanguageFlavor); this.hideStatusBarItem(fileUri, bar.statusChangeDatabase); - this.hideStatusBarItem(fileUri, bar.statusQuery); - clearInterval(bar.queryTimer); } public setConnecting(fileUri: string, connCreds: IConnectionInfo): void { @@ -317,36 +292,6 @@ export default class StatusView implements vscode.Disposable { this.showStatusBarItem(fileUri, bar.statusConnection); } - public executingQuery(fileUri: string): void { - let bar = this.getStatusBar(fileUri); - bar.statusQuery.command = undefined; - bar.statusQuery.text = ""; - bar.statusQuery.hide(); - clearInterval(bar.queryTimer); - } - - public executedQuery(fileUri: string): void { - let bar = this.getStatusBar(fileUri); - bar.statusQuery.text = ""; - bar.statusQuery.hide(); - clearInterval(bar.queryTimer); - } - - /** - * Intentionally a no-op. Query execution time is now shown in the query results webview footer. - */ - public setExecutionTime(_fileUri: string, _time: string): void {} - - public cancelingQuery(fileUri: string): void { - let bar = this.getStatusBar(fileUri); - bar.statusQuery.hide(); - - bar.statusQuery.command = undefined; - bar.statusQuery.text = ""; - bar.statusQuery.hide(); - clearInterval(bar.queryTimer); - } - public languageServiceStatusChanged(fileUri: string, status: string): void { let bar = this.getStatusBar(fileUri); bar.currentLanguageServiceStatus = status; @@ -406,31 +351,11 @@ export default class StatusView implements vscode.Disposable { } } - /** - * Associate a new uri with an existing Uri's status bar - * - * @param existingUri The already existing URI's status bar you want to associated - * @param newUri The new URI you want to associate with the existing status bar - * @return True or False whether the association was able to be made. False indicated the exitingUri specified - * did not exist - */ - - public associateWithExisting(existingUri: string, newUri: string): boolean { - let bar = this.getStatusBar(existingUri); - if (bar) { - this._statusBars[newUri] = bar; - return true; - } else { - return false; - } - } - public hideLastShownStatusBar(): void { if (typeof this._lastShownStatusBar !== "undefined") { this._lastShownStatusBar.statusLanguageFlavor.hide(); this._lastShownStatusBar.statusConnection.hide(); this._lastShownStatusBar.statusChangeDatabase.hide(); - this._lastShownStatusBar.statusQuery.hide(); this._lastShownStatusBar.statusLanguageService.hide(); this._lastShownStatusBar.sqlCmdMode.hide(); } diff --git a/extensions/mssql/src/webviews/common/locConstants.ts b/extensions/mssql/src/webviews/common/locConstants.ts index 5cedacbf98..2882c46a31 100644 --- a/extensions/mssql/src/webviews/common/locConstants.ts +++ b/extensions/mssql/src/webviews/common/locConstants.ts @@ -761,13 +761,13 @@ export class LocConstants { runningLabel: l10n.t("Running"), executionLabel: l10n.t("Execution"), noSelectionSummary: l10n.t("No selection"), - selectionSummaryCountLabel: l10n.t("COUNT"), - selectionSummaryAverageLabel: l10n.t("AVG"), - selectionSummarySumLabel: l10n.t("SUM"), - selectionSummaryMinLabel: l10n.t("MIN"), - selectionSummaryMaxLabel: l10n.t("MAX"), - selectionSummaryDistinctLabel: l10n.t("DISTINCT"), - selectionSummaryNullLabel: l10n.t("NULL"), + selectionSummaryCountLabel: l10n.t("Count"), + selectionSummaryAverageLabel: l10n.t("Avg"), + selectionSummarySumLabel: l10n.t("Sum"), + selectionSummaryMinLabel: l10n.t("Min"), + selectionSummaryMaxLabel: l10n.t("Max"), + selectionSummaryDistinctLabel: l10n.t("Distinct"), + selectionSummaryNullLabel: l10n.t("Null"), executionCancelled: l10n.t("Execution cancelled"), executionTimeUnavailable: l10n.t("Execution time unavailable"), totalExecutionTimePrefix: l10n.t("Total execution time:"), diff --git a/extensions/mssql/src/webviews/media/slickgrid.css b/extensions/mssql/src/webviews/media/slickgrid.css index a11d83bd2c..b21adbc467 100644 --- a/extensions/mssql/src/webviews/media/slickgrid.css +++ b/extensions/mssql/src/webviews/media/slickgrid.css @@ -32,8 +32,8 @@ padding: 4px; border-right: 1px solid silver; border-left: 0px !important; - border-top: 1px solid silver !important; - border-bottom: 1px solid silver !important; + border-top: 0px !important; + border-bottom: 2px solid #bbb; float: left; background-color: #eee; box-sizing: border-box; @@ -91,14 +91,6 @@ width: 100%; } -.slick-row.even { - background-color: var(--vscode-editor-background); -} - -.slick-row.odd { - background-color: var(--vscode-editorWidget-background); -} - .slick-cell, .slick-headerrow-column, .slick-footerrow-column { @@ -159,11 +151,11 @@ } .slick-cell > .row-number { - color: var(--vscode-editor-foreground); - background-color: var(--vscode-keybindingTable-headerBackground); + color: var(--color-content); + font-style: italic; font-weight: lighter; display: flex; - text-align: left; + justify-content: flex-end; } .slick-reorder-proxy { diff --git a/extensions/mssql/src/webviews/pages/QueryResult/queryResultSummaryFooter.tsx b/extensions/mssql/src/webviews/pages/QueryResult/queryResultSummaryFooter.tsx index fd3159693d..2a55a92934 100644 --- a/extensions/mssql/src/webviews/pages/QueryResult/queryResultSummaryFooter.tsx +++ b/extensions/mssql/src/webviews/pages/QueryResult/queryResultSummaryFooter.tsx @@ -8,6 +8,7 @@ import { Fragment, useContext, useEffect, useMemo, useState } from "react"; import { ExecuteCommandRequest } from "../../../sharedInterfaces/webview"; import * as qr from "../../../sharedInterfaces/queryResult"; import { locConstants } from "../../common/locConstants"; +import { getDisplayedRowsCount } from "./queryResultUtils"; import { useQueryResultSelector } from "./queryResultSelector"; import { QueryResultCommandsContext } from "./queryResultStateProvider"; @@ -47,8 +48,6 @@ const useStyles = makeStyles({ flexShrink: 0, fontSize: "10px", color: "var(--vscode-descriptionForeground)", - textTransform: "uppercase", - letterSpacing: "0.08em", }, value: { minWidth: 0, @@ -133,53 +132,6 @@ const useStyles = makeStyles({ }, }); -function getFirstResultSetRowCount( - summaries: Record>, -): number | undefined { - for (const batch of Object.values(summaries ?? {})) { - for (const result of Object.values(batch ?? {})) { - if (typeof result?.rowCount === "number") { - return result.rowCount; - } - } - } - return undefined; -} - -function getActiveResultSetRowCount( - summaries: Record>, - selectionSummary?: qr.SelectionSummary, -): number | undefined { - if ( - selectionSummary?.batchId !== undefined && - selectionSummary?.resultId !== undefined && - typeof summaries?.[selectionSummary.batchId]?.[selectionSummary.resultId]?.rowCount === - "number" - ) { - return summaries[selectionSummary.batchId][selectionSummary.resultId].rowCount; - } - - return getFirstResultSetRowCount(summaries); -} - -function getRowsAffectedFromMessages(messages: qr.IMessage[]): number | undefined { - const rowsAffectedRegex = /\(?\s*(\d+)\s+rows?\s+affected\s*\)?/i; - for (let i = messages.length - 1; i >= 0; i--) { - const text = messages[i]?.message; - if (!text) { - continue; - } - const match = text.match(rowsAffectedRegex); - if (match && match[1] !== undefined) { - const parsed = Number(match[1]); - if (!Number.isNaN(parsed)) { - return parsed; - } - } - } - return undefined; -} - function getLatestExecutionTimeMessage(messages: qr.IMessage[]): string | undefined { const prefix = locConstants.queryResult.totalExecutionTimePrefix; for (let i = messages.length - 1; i >= 0; i--) { @@ -471,14 +423,7 @@ export const QueryResultSummaryFooter = ({ }, [isExecuting, executionStartTime]); const rowsAffectedCount = useMemo(() => { - const activeResultRowCount = getActiveResultSetRowCount( - resultSetSummaries, - selectionSummary, - ); - if (typeof activeResultRowCount === "number") { - return activeResultRowCount; - } - return getRowsAffectedFromMessages(messages); + return getDisplayedRowsCount(resultSetSummaries, selectionSummary, messages); }, [messages, resultSetSummaries, selectionSummary]); const rowsText = diff --git a/extensions/mssql/src/webviews/pages/QueryResult/queryResultUtils.ts b/extensions/mssql/src/webviews/pages/QueryResult/queryResultUtils.ts index 3322cab29d..d05bf85a1c 100644 --- a/extensions/mssql/src/webviews/pages/QueryResult/queryResultUtils.ts +++ b/extensions/mssql/src/webviews/pages/QueryResult/queryResultUtils.ts @@ -55,3 +55,73 @@ export const splitMessages = (messages: qr.IMessage[] | undefined | null): qr.IM }); }); }; + +export function getTotalResultSetRowCount( + summaries: Record>, +): number | undefined { + let total = 0; + let hasRowCount = false; + + for (const batch of Object.values(summaries ?? {})) { + for (const result of Object.values(batch ?? {})) { + if (typeof result?.rowCount === "number") { + total += result.rowCount; + hasRowCount = true; + } + } + } + + return hasRowCount ? total : undefined; +} + +function getActiveResultSetRowCount( + summaries: Record>, + selectionSummary?: qr.SelectionSummary, +): number | undefined { + if ( + selectionSummary?.batchId !== undefined && + selectionSummary?.resultId !== undefined && + typeof summaries?.[selectionSummary.batchId]?.[selectionSummary.resultId]?.rowCount === + "number" + ) { + return summaries[selectionSummary.batchId][selectionSummary.resultId].rowCount; + } + + return undefined; +} + +function getRowsAffectedFromMessages(messages: qr.IMessage[]): number | undefined { + const rowsAffectedRegex = /\(?\s*(\d+)\s+rows?\s+affected\s*\)?/i; + for (let i = messages.length - 1; i >= 0; i--) { + const text = messages[i]?.message; + if (!text) { + continue; + } + const match = text.match(rowsAffectedRegex); + if (match && match[1] !== undefined) { + const parsed = Number(match[1]); + if (!Number.isNaN(parsed)) { + return parsed; + } + } + } + return undefined; +} + +export function getDisplayedRowsCount( + summaries: Record>, + selectionSummary: qr.SelectionSummary | undefined, + messages: qr.IMessage[], +): number | undefined { + const activeResultRowCount = getActiveResultSetRowCount(summaries, selectionSummary); + if (typeof activeResultRowCount === "number") { + return activeResultRowCount; + } + + const totalResultRowCount = getTotalResultSetRowCount(summaries); + if (typeof totalResultRowCount === "number") { + return totalResultRowCount; + } + + return getRowsAffectedFromMessages(messages); +} diff --git a/extensions/mssql/test/unit/queryResultSummaryFooter.test.ts b/extensions/mssql/test/unit/queryResultSummaryFooter.test.ts new file mode 100644 index 0000000000..45f2098759 --- /dev/null +++ b/extensions/mssql/test/unit/queryResultSummaryFooter.test.ts @@ -0,0 +1,70 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { expect } from "chai"; +import * as qr from "../../src/sharedInterfaces/queryResult"; +import { + getDisplayedRowsCount, + getTotalResultSetRowCount, +} from "../../src/webviews/pages/QueryResult/queryResultUtils"; + +suite("QueryResultSummaryFooter row count", () => { + const summaries: Record> = { + 0: { + 0: { + id: 0, + batchId: 0, + rowCount: 10, + columnInfo: [], + }, + 1: { + id: 1, + batchId: 0, + rowCount: 25, + columnInfo: [], + }, + }, + 1: { + 0: { + id: 0, + batchId: 1, + rowCount: 7, + columnInfo: [], + }, + }, + }; + + test("returns total rows across all grids when no grid is selected", () => { + const result = getDisplayedRowsCount(summaries, undefined, []); + + expect(result).to.equal(42); + }); + + test("returns the selected grid row count when a grid selection is active", () => { + const selectionSummary: qr.SelectionSummary = { + batchId: 0, + resultId: 1, + }; + + const result = getDisplayedRowsCount(summaries, selectionSummary, []); + + expect(result).to.equal(25); + }); + + test("falls back to the latest rows affected message when no grid summaries exist", () => { + const messages: qr.IMessage[] = [ + { message: "(3 rows affected)", isError: false }, + { message: "(5 rows affected)", isError: false }, + ]; + + const result = getDisplayedRowsCount({}, undefined, messages); + + expect(result).to.equal(5); + }); + + test("sums row counts only when result sets have row counts", () => { + expect(getTotalResultSetRowCount({})).to.equal(undefined); + }); +}); diff --git a/extensions/mssql/test/unit/queryRunner.test.ts b/extensions/mssql/test/unit/queryRunner.test.ts index 4a602aaa5f..db35423be4 100644 --- a/extensions/mssql/test/unit/queryRunner.test.ts +++ b/extensions/mssql/test/unit/queryRunner.test.ts @@ -121,8 +121,7 @@ suite("Query Runner tests", () => { expect(testQueryNotificationHandler.registerRunner).to.have.been.calledOnce; expect(testQueryNotificationHandler.registerRunner.firstCall.args[1]).to.equal(standardUri); - // ... The VS Code status should be updated - expect(testStatusView.executingQuery).to.have.been.calledOnceWithExactly(standardUri); + // ... The VS Code output should be updated expect(testVscodeWrapper.logToOutputChannel as sinon.SinonStub).to.have.been.calledOnce; // ... The query runner should indicate that it is running a query and elapsed time should be set to 0 @@ -139,10 +138,6 @@ suite("Query Runner tests", () => { }); setupStandardQueryNotificationHandlerMock(testQueryNotificationHandler); - // ... Setup the status view to handle start and stop updates - testStatusView.executedQuery.resetHistory(); - testStatusView.executingQuery.resetHistory(); - let testDoc: vscode.TextDocument = { getText: () => { return undefined; @@ -162,10 +157,8 @@ suite("Query Runner tests", () => { expect.fail("Expected runQuery to throw an error"); } catch (error) { // Then: - // ... The view status should have started and stopped + // ... The output channel should still log the failed start expect(testVscodeWrapper.logToOutputChannel as sinon.SinonStub).to.have.been.calledOnce; - expect(testStatusView.executingQuery).to.have.been.calledOnceWithExactly(standardUri); - expect(testStatusView.executedQuery).to.have.been.called; // ... The query runner should not be running a query expect(queryRunner.isExecutingQuery).to.equal(false); } @@ -421,11 +414,6 @@ suite("Query Runner tests", () => { // ... And I handle a query completion event queryRunner.handleQueryComplete(result); - // Then: - // ... The VS Code view should have stopped executing - expect(testStatusView.executedQuery).to.have.been.calledOnceWithExactly(standardUri); - expect(testStatusView.setExecutionTime).to.not.have.been.called; - // ... The state of the query runner has been updated expect(queryRunner.batchSets.length).to.equal(1); expect(queryRunner.isExecutingQuery).to.equal(false); diff --git a/extensions/mssql/test/unit/sqlOutputContentProvider.test.ts b/extensions/mssql/test/unit/sqlOutputContentProvider.test.ts index a305684ab0..971be5f6d6 100644 --- a/extensions/mssql/test/unit/sqlOutputContentProvider.test.ts +++ b/extensions/mssql/test/unit/sqlOutputContentProvider.test.ts @@ -115,7 +115,6 @@ suite("SqlOutputProvider Tests using mocks", () => { }); mockContentProvider.cancelQuery.callsFake(async (uri: string) => { - statusView.cancelingQuery(uri); const entry = ensureRunnerState(uri); entry.queryRunner.isExecutingQuery = false; }); @@ -363,9 +362,8 @@ suite("SqlOutputProvider Tests using mocks", () => { await mockContentProvider.runQuery(statusViewInstance, uri, querySelection, title); - // Check that the first one was ran and that a canceling dialogue was opened + // Check that the first one was ran expect(mockContentProvider.isRunningQuery(resultUri)).to.be.true; - expect(statusView.cancelingQuery).to.have.been.calledOnceWithExactly(resultUri); expect(mockMap.size).to.equal(1); }); @@ -388,9 +386,8 @@ suite("SqlOutputProvider Tests using mocks", () => { await mockContentProvider.runQuery(statusViewInstance, uri, querySelection, title); - // Check that the first one was ran and that a canceling dialogue was opened + // Check that the first one was ran expect(mockContentProvider.isRunningQuery(uri)).to.be.true; - expect(statusView.cancelingQuery).to.have.been.calledOnceWithExactly(uri); expect(mockMap.size).to.equal(1); }); From 169d0af33c291182d4ecef2da8b6d3bb5c5c067c Mon Sep 17 00:00:00 2001 From: Aasim Khan Date: Sun, 12 Apr 2026 21:19:31 -0700 Subject: [PATCH 16/23] Update localization strings for aggregate functions to use proper casing --- extensions/mssql/l10n/bundle.l10n.json | 13 +++++---- localization/xliff/vscode-mssql.xlf | 39 ++++++++++++++------------ 2 files changed, 28 insertions(+), 24 deletions(-) diff --git a/extensions/mssql/l10n/bundle.l10n.json b/extensions/mssql/l10n/bundle.l10n.json index eea2cc03dc..b4f55b5470 100644 --- a/extensions/mssql/l10n/bundle.l10n.json +++ b/extensions/mssql/l10n/bundle.l10n.json @@ -503,12 +503,13 @@ "Running": "Running", "Execution": "Execution", "No selection": "No selection", - "COUNT": "COUNT", - "AVG": "AVG", - "SUM": "SUM", - "MIN": "MIN", - "MAX": "MAX", - "DISTINCT": "DISTINCT", + "Count": "Count", + "Avg": "Avg", + "Sum": "Sum", + "Min": "Min", + "Max": "Max", + "Distinct": "Distinct", + "Null": "Null", "Execution cancelled": "Execution cancelled", "Execution time unavailable": "Execution time unavailable", "Total execution time:": "Total execution time:", diff --git a/localization/xliff/vscode-mssql.xlf b/localization/xliff/vscode-mssql.xlf index 93579b0328..8f58b29289 100644 --- a/localization/xliff/vscode-mssql.xlf +++ b/localization/xliff/vscode-mssql.xlf @@ -108,9 +108,6 @@ API Type - - AVG - Accelerate schema evolution by autogenerating ORM migrations or T-SQL change scripts @@ -486,6 +483,9 @@ Available Servers + + Avg + Azure (China) @@ -676,9 +676,6 @@ Bulk-logged - - COUNT - CSV @@ -1518,6 +1515,9 @@ Could not load restore plan + + Count + Create @@ -1665,9 +1665,6 @@ DACPAC path not found. Please build the project first. - - DISTINCT - DacFx service is not available. Profile loaded without deployment options. Publish and generate script operations cannot be performed. @@ -1943,6 +1940,9 @@ Dissatisfied + + Distinct + Do not compress backup @@ -3718,9 +3718,6 @@ Looking for Azure Data Studio key bindings, like F5 to execute queries? - - MAX - MCP @@ -3732,9 +3729,6 @@ MCP server is already configured in {0} {0} is the file path where the MCP server configuration exists - - MIN - MSSQL @@ -3778,6 +3772,9 @@ Mandatory (True) + + Max + Max Length @@ -3865,6 +3862,9 @@ Migrate saved connections, connection groups, and connection settings from Azure Data Studio into the MSSQL extension. Additionally, the MSSQL Data Management Keymap can be installed to add familiar shortcuts from Azure Data Studio. + + Min + Missing connectionId. Please provide a connectionId to open Data API builder. @@ -4345,6 +4345,9 @@ Note: A self-signed certificate offers only limited protection and is not a recommended practice for production environments. Do you want to enable 'Trust server certificate' on this connection and retry? + + Null + Number of Rows Read @@ -5222,9 +5225,6 @@ SQLCMD Variables - - SUM - SVG @@ -6023,6 +6023,9 @@ Successfully saved results to + + Sum + Summary From 7a13fc0ced3765fe0abbb286466b24086a9f5e04 Mon Sep 17 00:00:00 2001 From: Aasim Khan Date: Sun, 12 Apr 2026 21:52:50 -0700 Subject: [PATCH 17/23] Implement copy success notifications and update copy methods to return success status --- .../mssql/src/controllers/queryRunner.ts | 47 ++++---- .../src/models/sqlOutputContentProvider.ts | 111 +++++++++++++----- .../queryResultWebViewController.ts | 22 ++++ .../mssql/src/sharedInterfaces/queryResult.ts | 4 + .../mssql/src/webviews/common/locConstants.ts | 2 + .../pages/QueryResult/queryResultPane.tsx | 68 ++++++++++- .../unit/queryResultWebViewController.test.ts | 23 ++++ 7 files changed, 222 insertions(+), 55 deletions(-) diff --git a/extensions/mssql/src/controllers/queryRunner.ts b/extensions/mssql/src/controllers/queryRunner.ts index b8ba11e4eb..a43ca7876a 100644 --- a/extensions/mssql/src/controllers/queryRunner.ts +++ b/extensions/mssql/src/controllers/queryRunner.ts @@ -708,7 +708,7 @@ export default class QueryRunner { batchId: number, resultId: number, selection: ISlickRange[], - ): Promise { + ): Promise { let copyString = ""; let firstCol: number; let lastCol: number; @@ -738,6 +738,8 @@ export default class QueryRunner { if (process.platform === "darwin") { process.env["LANG"] = oldLang; } + + return true; } /** @@ -752,8 +754,8 @@ export default class QueryRunner { batchId: number, resultId: number, includeHeaders?: boolean, - ): Promise { - await this.copyResults2(selection, batchId, resultId, CopyType.Text, { + ): Promise { + return await this.copyResults2(selection, batchId, resultId, CopyType.Text, { includeHeaders: includeHeaders ?? false, }); } @@ -774,7 +776,7 @@ export default class QueryRunner { textIdentifier?: string; encoding?: string; }, - ): Promise { + ): Promise { // Cancel any in-progress copy operation if (this._copyOperationCancellation) { this._copyOperationCancellation.cancel(); @@ -792,23 +794,23 @@ export default class QueryRunner { _progress?: vscode.Progress, token?: vscode.CancellationToken, ) => { - return new Promise(async (resolve, reject) => { + return new Promise(async (resolve, reject) => { try { // Handle cancellation from the progress dialog (user clicked cancel) token?.onCancellationRequested(async () => { await this._client.sendNotification(CancelCopy2Notification.type); vscode.window.showInformationMessage("Copying results cancelled"); - resolve(); + resolve(false); }); // Handle internal cancellation (new copy operation started) - no notification copyToken.onCancellationRequested(async () => { - resolve(); + resolve(false); }); // Check if already cancelled before starting if (copyToken.isCancellationRequested) { - resolve(); + resolve(false); return; } @@ -836,7 +838,7 @@ export default class QueryRunner { // Check if cancelled while waiting for the request if (copyToken.isCancellationRequested) { - resolve(); + resolve(false); return; } @@ -844,14 +846,11 @@ export default class QueryRunner { await this.writeStringToClipboard(result.content); } - vscode.window.showInformationMessage( - LocalizedConstants.resultsCopiedToClipboard, - ); - resolve(); + resolve(true); } catch (error) { // Don't show error if cancelled if (copyToken.isCancellationRequested) { - resolve(); + resolve(false); return; } vscode.window.showErrorMessage( @@ -863,7 +862,7 @@ export default class QueryRunner { }; if (showProgress) { - await vscode.window.withProgress( + return await vscode.window.withProgress( { location: vscode.ProgressLocation.Notification, title: LocalizedConstants.copyingResults, @@ -872,7 +871,7 @@ export default class QueryRunner { executeCopy, ); } else { - await executeCopy(); + return await executeCopy(); } } @@ -936,7 +935,7 @@ export default class QueryRunner { selection: ISlickRange[], batchId: number, resultId: number, - ): Promise { + ): Promise { const config = this._vscodeWrapper.getConfiguration(Constants.extensionConfigSectionName); const csvConfig = config[Constants.configSaveAsCsv] || {}; @@ -946,7 +945,7 @@ export default class QueryRunner { const encoding = csvConfig.encoding; const includeHeaders = csvConfig.includeHeaders; - await this.copyResults2(selection, batchId, resultId, CopyType.CSV, { + return await this.copyResults2(selection, batchId, resultId, CopyType.CSV, { includeHeaders: includeHeaders, delimiter, textIdentifier, @@ -966,8 +965,8 @@ export default class QueryRunner { selection: ISlickRange[], batchId: number, resultId: number, - ): Promise { - await this.copyResults2(selection, batchId, resultId, CopyType.JSON, { + ): Promise { + return await this.copyResults2(selection, batchId, resultId, CopyType.JSON, { includeHeaders: true, }); } @@ -976,16 +975,16 @@ export default class QueryRunner { selection: ISlickRange[], batchId: number, resultId: number, - ): Promise { - await this.copyResults2(selection, batchId, resultId, CopyType.IN); + ): Promise { + return await this.copyResults2(selection, batchId, resultId, CopyType.IN); } public async copyResultsAsInsertInto( selection: ISlickRange[], batchId: number, resultId: number, - ): Promise { - await this.copyResults2(selection, batchId, resultId, CopyType.INSERT, { + ): Promise { + return await this.copyResults2(selection, batchId, resultId, CopyType.INSERT, { includeHeaders: true, }); } diff --git a/extensions/mssql/src/models/sqlOutputContentProvider.ts b/extensions/mssql/src/models/sqlOutputContentProvider.ts index cf0b01f19d..242fe0b323 100644 --- a/extensions/mssql/src/models/sqlOutputContentProvider.ts +++ b/extensions/mssql/src/models/sqlOutputContentProvider.ts @@ -209,69 +209,122 @@ export class SqlOutputContentProvider { this.openLink(content, columnName, linkType); } - public copyHeadersRequestHandler( + public async copyHeadersRequestHandler( uri: string, batchId: number, resultId: number, - selection, - ): void { - void this._queryResultsMap.get(uri).queryRunner.copyHeaders(batchId, resultId, selection); + selection: Interfaces.ISlickRange[], + ): Promise { + const result = this._queryResultsMap.get(uri); + if (!result) { + return; + } + + const copied = await result.queryRunner.copyHeaders(batchId, resultId, selection); + + if (copied) { + await this._queryResultWebviewController.notifyCopySuccess(uri); + } } - public copyRequestHandler( + public async copyRequestHandler( uri: string, batchId: number, resultId: number, selection: Interfaces.ISlickRange[], includeHeaders?: boolean, - ): void { - void this._queryResultsMap - .get(uri) - .queryRunner.copyResults(selection, batchId, resultId, includeHeaders); + ): Promise { + const result = this._queryResultsMap.get(uri); + if (!result) { + return; + } + + const copied = await result.queryRunner.copyResults( + selection, + batchId, + resultId, + includeHeaders, + ); + + if (copied) { + await this._queryResultWebviewController.notifyCopySuccess(uri); + } } - public copyAsCsvRequestHandler( + public async copyAsCsvRequestHandler( uri: string, batchId: number, resultId: number, selection: Interfaces.ISlickRange[], - ): void { - void this._queryResultsMap - .get(uri) - .queryRunner.copyResultsAsCsv(selection, batchId, resultId); + ): Promise { + const result = this._queryResultsMap.get(uri); + if (!result) { + return; + } + + const copied = await result.queryRunner.copyResultsAsCsv(selection, batchId, resultId); + + if (copied) { + await this._queryResultWebviewController.notifyCopySuccess(uri); + } } - public copyAsJsonRequestHandler( + public async copyAsJsonRequestHandler( uri: string, batchId: number, resultId: number, selection: Interfaces.ISlickRange[], - ): void { - void this._queryResultsMap - .get(uri) - .queryRunner.copyResultsAsJson(selection, batchId, resultId); + ): Promise { + const result = this._queryResultsMap.get(uri); + if (!result) { + return; + } + + const copied = await result.queryRunner.copyResultsAsJson(selection, batchId, resultId); + + if (copied) { + await this._queryResultWebviewController.notifyCopySuccess(uri); + } } - public copyAsInClauseRequestHandler( + public async copyAsInClauseRequestHandler( uri: string, batchId: number, resultId: number, selection: Interfaces.ISlickRange[], - ): void { - void this._queryResultsMap - .get(uri) - .queryRunner.copyResultsAsInClause(selection, batchId, resultId); + ): Promise { + const result = this._queryResultsMap.get(uri); + if (!result) { + return; + } + + const copied = await result.queryRunner.copyResultsAsInClause(selection, batchId, resultId); + + if (copied) { + await this._queryResultWebviewController.notifyCopySuccess(uri); + } } - public copyAsInsertIntoRequestHandler( + public async copyAsInsertIntoRequestHandler( uri: string, batchId: number, resultId: number, selection: Interfaces.ISlickRange[], - ): void { - void this._queryResultsMap - .get(uri) - .queryRunner.copyResultsAsInsertInto(selection, batchId, resultId); + ): Promise { + const result = this._queryResultsMap.get(uri); + if (!result) { + return; + } + + const copied = await result.queryRunner.copyResultsAsInsertInto( + selection, + batchId, + resultId, + ); + + if (copied) { + await this._queryResultWebviewController.notifyCopySuccess(uri); + } } public generateSelectionSummaryData( diff --git a/extensions/mssql/src/queryResult/queryResultWebViewController.ts b/extensions/mssql/src/queryResult/queryResultWebViewController.ts index d1532e9e44..ab904fa86e 100644 --- a/extensions/mssql/src/queryResult/queryResultWebViewController.ts +++ b/extensions/mssql/src/queryResult/queryResultWebViewController.ts @@ -593,6 +593,28 @@ export class QueryResultWebviewController extends WebviewViewController< const messageText = messages.join("\n"); await this.vscodeWrapper.clipboardWriteText(messageText); + void this.notifyCopySuccess(uri); + } + + public async notifyCopySuccess(uri: string): Promise { + if (!uri || !this._queryResultStateMap.has(uri)) { + return; + } + + if (this.hasPanel(uri)) { + const panelController = this._queryResultWebviewPanelControllerMap.get(uri); + if (panelController) { + await panelController.sendNotification( + qr.ShowCopySuccessNotification.type, + undefined, + ); + } + return; + } + + if (this.state?.uri === uri) { + await this.sendNotification(qr.ShowCopySuccessNotification.type, undefined); + } } public getNumExecutionPlanResultSets( diff --git a/extensions/mssql/src/sharedInterfaces/queryResult.ts b/extensions/mssql/src/sharedInterfaces/queryResult.ts index b0657559f3..6c3b10ef81 100644 --- a/extensions/mssql/src/sharedInterfaces/queryResult.ts +++ b/extensions/mssql/src/sharedInterfaces/queryResult.ts @@ -364,6 +364,10 @@ export namespace SetSelectionSummaryRequest { export const type = new NotificationType("setSelectionSummary"); } +export namespace ShowCopySuccessNotification { + export const type = new NotificationType("showCopySuccess"); +} + export interface OpenInNewTabParams { uri: string; } diff --git a/extensions/mssql/src/webviews/common/locConstants.ts b/extensions/mssql/src/webviews/common/locConstants.ts index 2882c46a31..9856fb5343 100644 --- a/extensions/mssql/src/webviews/common/locConstants.ts +++ b/extensions/mssql/src/webviews/common/locConstants.ts @@ -598,6 +598,8 @@ export class LocConstants { messages: l10n.t("Messages"), timestamp: l10n.t("Timestamp"), message: l10n.t("Message"), + copied: l10n.t("Copied"), + copiedToClipboard: l10n.t("Copied to clipboard"), openResultInNewTab: l10n.t("Open in New Tab"), resultsSettings: l10n.t("Results Settings"), showResultsInEditorTab: l10n.t("Open results in new tab"), diff --git a/extensions/mssql/src/webviews/pages/QueryResult/queryResultPane.tsx b/extensions/mssql/src/webviews/pages/QueryResult/queryResultPane.tsx index 3647acccb4..786412b0de 100644 --- a/extensions/mssql/src/webviews/pages/QueryResult/queryResultPane.tsx +++ b/extensions/mssql/src/webviews/pages/QueryResult/queryResultPane.tsx @@ -13,8 +13,13 @@ import { Text, Spinner, } from "@fluentui/react-components"; -import { useContext, useEffect, useState } from "react"; -import { DatabaseSearch24Regular, ErrorCircle24Regular, OpenRegular } from "@fluentui/react-icons"; +import { useContext, useEffect, useRef, useState } from "react"; +import { + CheckmarkCircle20Regular, + DatabaseSearch24Regular, + ErrorCircle24Regular, + OpenRegular, +} from "@fluentui/react-icons"; import * as qr from "../../../sharedInterfaces/queryResult"; import { locConstants } from "../../common/locConstants"; import { hasResultsOrMessages } from "./queryResultUtils"; @@ -32,6 +37,17 @@ import { QueryResultSummaryFooter } from "./queryResultSummaryFooter"; import { QueryResultSettingsControl } from "./queryResultSettingsControl"; const useStyles = makeStyles({ + copiedIndicator: { + display: "flex", + alignItems: "center", + gap: "6px", + padding: "0 6px", + whiteSpace: "nowrap", + }, + copiedIndicatorText: { + fontSize: "12px", + fontWeight: 600, + }, root: { width: "100%", height: "100%", @@ -115,6 +131,10 @@ const useStyles = makeStyles({ }, }); +const COPY_INDICATOR_DURATION_MS = 3000; +let copySuccessNotificationHandlerRegistered = false; +let showCopySuccessIndicator: (() => void) | undefined; + export const QueryResultPane = () => { const classes = useStyles(); const context = useContext(QueryResultCommandsContext); @@ -195,6 +215,38 @@ export const QueryResultPane = () => { const [webviewLocation, setWebviewLocation] = useState( qr.QueryResultWebviewLocation.Panel, ); + const [showCopiedIndicator, setShowCopiedIndicator] = useState(false); + const copyIndicatorTimeoutRef = useRef(undefined); + + useEffect(() => { + showCopySuccessIndicator = () => { + if (copyIndicatorTimeoutRef.current !== undefined) { + window.clearTimeout(copyIndicatorTimeoutRef.current); + } + + setShowCopiedIndicator(true); + copyIndicatorTimeoutRef.current = window.setTimeout(() => { + setShowCopiedIndicator(false); + copyIndicatorTimeoutRef.current = undefined; + }, COPY_INDICATOR_DURATION_MS); + }; + + if (!copySuccessNotificationHandlerRegistered) { + context.extensionRpc.onNotification(qr.ShowCopySuccessNotification.type, () => { + showCopySuccessIndicator?.(); + }); + copySuccessNotificationHandlerRegistered = true; + } + + return () => { + if (showCopySuccessIndicator) { + showCopySuccessIndicator = undefined; + } + if (copyIndicatorTimeoutRef.current !== undefined) { + window.clearTimeout(copyIndicatorTimeoutRef.current); + } + }; + }, [context.extensionRpc]); useEffect(() => { getWebviewLocation().catch((e) => { @@ -299,6 +351,18 @@ export const QueryResultPane = () => { )}
+ {showCopiedIndicator && ( +
+ + + {locConstants.queryResult.copied} + +
+ )} {webviewLocation === qr.QueryResultWebviewLocation.Panel && (
{ void setDefaultResultLocation(data.checked); diff --git a/extensions/mssql/src/webviews/pages/QueryResult/queryResultSummaryFooter.tsx b/extensions/mssql/src/webviews/pages/QueryResult/queryResultSummaryFooter.tsx index 2a55a92934..1d0b906c89 100644 --- a/extensions/mssql/src/webviews/pages/QueryResult/queryResultSummaryFooter.tsx +++ b/extensions/mssql/src/webviews/pages/QueryResult/queryResultSummaryFooter.tsx @@ -132,24 +132,6 @@ const useStyles = makeStyles({ }, }); -function getLatestExecutionTimeMessage(messages: qr.IMessage[]): string | undefined { - const prefix = locConstants.queryResult.totalExecutionTimePrefix; - for (let i = messages.length - 1; i >= 0; i--) { - const text = messages[i]?.message; - if (!text) { - continue; - } - if (text.startsWith(prefix) || /execution\s+time/i.test(text)) { - return text; - } - } - return undefined; -} - -function hasCancellationMessage(messages: qr.IMessage[]): boolean { - return messages.some((message) => /cancel(?:ed|led|ing)?/i.test(message?.message ?? "")); -} - function normalizeStatusText(text?: string): string { if (!text) { return ""; @@ -157,74 +139,39 @@ function normalizeStatusText(text?: string): string { return text.replace(/\$\([^)]+\)\s*/g, "").trim(); } -function normalizeExecutionText(text: string): string { - return text.replace(locConstants.queryResult.totalExecutionTimePrefix, "").trim(); -} - -function parseTimeStringToMilliseconds(value: string): number | undefined { - const match = value.match(/(\d+):(\d{2}):(\d{2})(?:\.(\d{1,3}))?/); - if (!match) { - return undefined; - } - - const hours = Number(match[1]); - const minutes = Number(match[2]); - const seconds = Number(match[3]); - const milliseconds = Number((match[4] ?? "0").padEnd(3, "0").slice(0, 3)); - - if ([hours, minutes, seconds, milliseconds].some((num) => Number.isNaN(num))) { - return undefined; - } - - return hours * 3600000 + minutes * 60000 + seconds * 1000 + milliseconds; -} - function formatMillisecondsCompact(milliseconds: number): string { if (milliseconds < 1000) { - return `${milliseconds}ms`; + return locConstants.queryResult.compactMilliseconds(milliseconds); } if (milliseconds < 60000) { const seconds = milliseconds / 1000; - return seconds < 10 ? `${seconds.toFixed(1)}s` : `${Math.round(seconds)}s`; + return locConstants.queryResult.compactSeconds( + seconds < 10 ? seconds.toFixed(1) : Math.round(seconds), + ); } if (milliseconds < 3600000) { const minutes = Math.floor(milliseconds / 60000); const seconds = Math.round((milliseconds % 60000) / 1000); if (seconds === 0) { - return `${minutes}m`; + return locConstants.queryResult.compactMinutes(minutes); } if (seconds === 60) { - return `${minutes + 1}m`; + return locConstants.queryResult.compactMinutes(minutes + 1); } - return `${minutes}m ${seconds}s`; + return locConstants.queryResult.compactMinutesSeconds(minutes, seconds); } const hours = Math.floor(milliseconds / 3600000); const minutes = Math.round((milliseconds % 3600000) / 60000); if (minutes === 0) { - return `${hours}h`; + return locConstants.queryResult.compactHours(hours); } if (minutes === 60) { - return `${hours + 1}h`; + return locConstants.queryResult.compactHours(hours + 1); } - return `${hours}h ${minutes}m`; -} - -function formatExecutionTextCompact(text: string): string { - const normalized = normalizeExecutionText(text); - const timeMatch = normalized.match(/\d+:\d{2}:\d{2}(?:\.\d{1,3})?/); - if (!timeMatch) { - return normalized; - } - - const totalMilliseconds = parseTimeStringToMilliseconds(timeMatch[0]); - if (totalMilliseconds === undefined) { - return normalized; - } - - return normalized.replace(timeMatch[0], formatMillisecondsCompact(totalMilliseconds)); + return locConstants.queryResult.compactHoursMinutes(hours, minutes); } function formatRunningTimeCompact(milliseconds: number): string { @@ -233,18 +180,22 @@ function formatRunningTimeCompact(milliseconds: number): string { } if (milliseconds < 60000) { - return `${Math.floor(milliseconds / 1000)}s`; + return locConstants.queryResult.compactSeconds(Math.floor(milliseconds / 1000)); } if (milliseconds < 3600000) { const minutes = Math.floor(milliseconds / 60000); const seconds = Math.floor((milliseconds % 60000) / 1000); - return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; + return seconds > 0 + ? locConstants.queryResult.compactMinutesSeconds(minutes, seconds) + : locConstants.queryResult.compactMinutes(minutes); } const hours = Math.floor(milliseconds / 3600000); const minutes = Math.floor((milliseconds % 3600000) / 60000); - return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`; + return minutes > 0 + ? locConstants.queryResult.compactHoursMinutes(hours, minutes) + : locConstants.queryResult.compactHours(hours); } type SelectionMetricKey = keyof qr.SelectionSummaryMetrics; @@ -400,11 +351,14 @@ export const QueryResultSummaryFooter = ({ const classes = useStyles(); const context = useContext(QueryResultCommandsContext); const resultSetSummaries = useQueryResultSelector((state) => state.resultSetSummaries); - const messages = useQueryResultSelector((state) => state.messages); + const rowsAffected = useQueryResultSelector((state) => state.rowsAffected); const selectionSummary = useQueryResultSelector((state) => state.selectionSummary); const tabStates = useQueryResultSelector((state) => state.tabStates); const isExecuting = useQueryResultSelector((state) => state.isExecuting ?? false); const executionStartTime = useQueryResultSelector((state) => state.executionStartTime); + const executionElapsedMilliseconds = useQueryResultSelector( + (state) => state.executionElapsedMilliseconds, + ); const [tickTimestamp, setTickTimestamp] = useState(Date.now()); useEffect(() => { @@ -422,25 +376,18 @@ export const QueryResultSummaryFooter = ({ }; }, [isExecuting, executionStartTime]); - const rowsAffectedCount = useMemo(() => { - return getDisplayedRowsCount(resultSetSummaries, selectionSummary, messages); - }, [messages, resultSetSummaries, selectionSummary]); + const displayedResultRowsCount = useMemo(() => { + return getDisplayedRowsCount(resultSetSummaries, selectionSummary, undefined); + }, [resultSetSummaries, selectionSummary]); + const rowsCount = + typeof displayedResultRowsCount === "number" ? displayedResultRowsCount : rowsAffected; const rowsText = - typeof rowsAffectedCount === "number" - ? rowsAffectedCount > 0 - ? locConstants.queryResult.rowsAffected(rowsAffectedCount) - : locConstants.queryResult.noRowsAffected - : locConstants.queryResult.noRowsAffected; - - const executionTimeText = getLatestExecutionTimeMessage(messages); - const cancelled = hasCancellationMessage(messages); - - const executionText = cancelled - ? executionTimeText - ? `${locConstants.queryResult.executionCancelled} - ${executionTimeText}` - : locConstants.queryResult.executionCancelled - : (executionTimeText ?? locConstants.queryResult.executionTimeUnavailable); + typeof rowsCount === "number" + ? typeof displayedResultRowsCount === "number" + ? locConstants.queryResult.rowsReturned(rowsCount) + : locConstants.queryResult.rowsAffected(rowsCount) + : locConstants.queryResult.rowsCount(0); const liveExecutionMilliseconds = isExecuting && executionStartTime ? Math.max(0, tickTimestamp - executionStartTime) @@ -448,13 +395,15 @@ export const QueryResultSummaryFooter = ({ const compactExecutionText = liveExecutionMilliseconds !== undefined ? formatRunningTimeCompact(liveExecutionMilliseconds) - : formatExecutionTextCompact(executionText); + : executionElapsedMilliseconds !== undefined + ? formatMillisecondsCompact(executionElapsedMilliseconds) + : locConstants.queryResult.executionTimeUnavailable; const executionTooltipText = liveExecutionMilliseconds !== undefined ? compactExecutionText === locConstants.queryResult.runningLabel ? locConstants.queryResult.runningLabel - : `${locConstants.queryResult.runningLabel}: ${compactExecutionText}` - : executionText; + : locConstants.queryResult.runningWithDuration(compactExecutionText) + : compactExecutionText; const selectionStats = selectionSummary?.stats; const selectionCommand = selectionSummary?.command; const selectionStatusText = normalizeStatusText(selectionSummary?.text); @@ -470,19 +419,17 @@ export const QueryResultSummaryFooter = ({ locConstants.queryResult.noSelectionSummary}
); - const compactRowsText = - typeof rowsAffectedCount === "number" ? rowsAffectedCount.toLocaleString() : "0"; + const compactRowsText = typeof rowsCount === "number" ? rowsCount.toLocaleString() : "0"; const isTextResultsView = tabStates?.resultPaneTab === qr.QueryResultPaneTabs.Results && tabStates?.resultViewMode === qr.QueryResultViewMode.Text; - const isMessagesPane = tabStates?.resultPaneTab === qr.QueryResultPaneTabs.Messages; - if (isTextResultsView || isMessagesPane) { + if (isTextResultsView) { return ; } return ( -
+
{!hideMetrics && (
@@ -502,8 +449,7 @@ export const QueryResultSummaryFooter = ({ withArrow relationship="description" content={executionTooltipText}> - + {compactExecutionText} diff --git a/extensions/mssql/src/webviews/pages/QueryResult/queryResultUtils.ts b/extensions/mssql/src/webviews/pages/QueryResult/queryResultUtils.ts index d05bf85a1c..69d2b56b8e 100644 --- a/extensions/mssql/src/webviews/pages/QueryResult/queryResultUtils.ts +++ b/extensions/mssql/src/webviews/pages/QueryResult/queryResultUtils.ts @@ -90,28 +90,10 @@ function getActiveResultSetRowCount( return undefined; } -function getRowsAffectedFromMessages(messages: qr.IMessage[]): number | undefined { - const rowsAffectedRegex = /\(?\s*(\d+)\s+rows?\s+affected\s*\)?/i; - for (let i = messages.length - 1; i >= 0; i--) { - const text = messages[i]?.message; - if (!text) { - continue; - } - const match = text.match(rowsAffectedRegex); - if (match && match[1] !== undefined) { - const parsed = Number(match[1]); - if (!Number.isNaN(parsed)) { - return parsed; - } - } - } - return undefined; -} - export function getDisplayedRowsCount( summaries: Record>, selectionSummary: qr.SelectionSummary | undefined, - messages: qr.IMessage[], + rowsAffected: number | undefined, ): number | undefined { const activeResultRowCount = getActiveResultSetRowCount(summaries, selectionSummary); if (typeof activeResultRowCount === "number") { @@ -123,5 +105,5 @@ export function getDisplayedRowsCount( return totalResultRowCount; } - return getRowsAffectedFromMessages(messages); + return rowsAffected; } diff --git a/extensions/mssql/test/unit/queryResultSummaryFooter.test.ts b/extensions/mssql/test/unit/queryResultSummaryFooter.test.ts index 45f2098759..f0615b75ba 100644 --- a/extensions/mssql/test/unit/queryResultSummaryFooter.test.ts +++ b/extensions/mssql/test/unit/queryResultSummaryFooter.test.ts @@ -37,7 +37,7 @@ suite("QueryResultSummaryFooter row count", () => { }; test("returns total rows across all grids when no grid is selected", () => { - const result = getDisplayedRowsCount(summaries, undefined, []); + const result = getDisplayedRowsCount(summaries, undefined, undefined); expect(result).to.equal(42); }); @@ -48,18 +48,13 @@ suite("QueryResultSummaryFooter row count", () => { resultId: 1, }; - const result = getDisplayedRowsCount(summaries, selectionSummary, []); + const result = getDisplayedRowsCount(summaries, selectionSummary, undefined); expect(result).to.equal(25); }); - test("falls back to the latest rows affected message when no grid summaries exist", () => { - const messages: qr.IMessage[] = [ - { message: "(3 rows affected)", isError: false }, - { message: "(5 rows affected)", isError: false }, - ]; - - const result = getDisplayedRowsCount({}, undefined, messages); + test("falls back to structured rows affected when no grid summaries exist", () => { + const result = getDisplayedRowsCount({}, undefined, 5); expect(result).to.equal(5); }); diff --git a/extensions/mssql/test/unit/queryResultWebViewController.test.ts b/extensions/mssql/test/unit/queryResultWebViewController.test.ts index 63c8fc28a4..1d9856e0e5 100644 --- a/extensions/mssql/test/unit/queryResultWebViewController.test.ts +++ b/extensions/mssql/test/unit/queryResultWebViewController.test.ts @@ -107,9 +107,13 @@ suite("QueryResultWebviewController", () => { .stub(controller, "createPanelController") .resolves(); - await controller.setOpenQueryResultsInTabByDefaultRequestHandler(true); + await controller.setOpenQueryResultsInTabByDefaultRequestHandler({ + enabled: true, + uri: testUri, + webviewLocation: qr.QueryResultWebviewLocation.Panel, + }); - expect(createPanelControllerStub).to.have.been.calledOnceWithExactly(testUri); + expect(createPanelControllerStub).to.have.been.calledWithExactly(testUri); expect(configuration.update).to.have.been.calledWith( Constants.configOpenQueryResultsInTabByDefault, true, @@ -123,7 +127,11 @@ suite("QueryResultWebviewController", () => { .stub(controller, "createPanelController") .resolves(); - await controller.setOpenQueryResultsInTabByDefaultRequestHandler(false); + await controller.setOpenQueryResultsInTabByDefaultRequestHandler({ + enabled: false, + uri: testUri, + webviewLocation: qr.QueryResultWebviewLocation.Panel, + }); expect(createPanelControllerStub).to.not.have.been.called; }); diff --git a/localization/xliff/vscode-mssql.xlf b/localization/xliff/vscode-mssql.xlf index b65d92e929..55973954ef 100644 --- a/localization/xliff/vscode-mssql.xlf +++ b/localization/xliff/vscode-mssql.xlf @@ -81,6 +81,18 @@ - + + 0 rows + + + 0 rows returned + + + 1 row + + + 1 row returned + = @@ -1525,6 +1537,9 @@ Copy-only Backup + + Copying results cancelled + Copying results... @@ -2416,9 +2431,6 @@ Executing query... - - Execution - Execution Plan @@ -5285,6 +5297,10 @@ {0} is the connection display name {1} is the connection ID + + Running: {0} + {0} is how long the query has been running + SQL Analytics Endpoint @@ -6536,9 +6552,6 @@ {0} is the part name {1} is the part input - - Total execution time: - Total execution time: {0} {0} is the elapsed time @@ -7183,6 +7196,14 @@ {0} properties {0} is the object type + + {0} rows + {0} is the number of rows + + + {0} rows returned + {0} is the number of rows returned + {0} rows selected, click to load summary {0} is the number of rows to fetch summary statistics for @@ -7279,24 +7300,50 @@ {0} is the elapsed time in days {1} is the remaining elapsed time in hours + + {0}h + {0} is the number of hours + {0}h {1}m {0} is the elapsed time in hours {1} is the remaining elapsed time in minutes + + {0}h {1}m + {0} is the number of hours +{1} is the number of minutes + + + {0}m + {0} is the number of minutes + {0}m {1}s {0} is the elapsed time in minutes {1} is the remaining elapsed time in seconds + + + {0}m {1}s + {0} is the number of minutes +{1} is the number of seconds {0}ms {0} is the elapsed time in milliseconds + + {0}ms + {0} is the number of milliseconds + {0}s {0} is the elapsed time in seconds + + {0}s + {0} is the number of seconds + {{put-server-name-here}} From 95bef52bebf7c6021ebe0c4d2305a9aaf743f50c Mon Sep 17 00:00:00 2001 From: Aasim Khan Date: Wed, 13 May 2026 14:59:08 -0700 Subject: [PATCH 23/23] Implement selection state updates and enhance grid selection handling --- .../queryResultWebViewController.ts | 25 +++++++++++++++ extensions/mssql/src/queryResult/utils.ts | 6 ++++ .../mssql/src/sharedInterfaces/queryResult.ts | 3 ++ .../webviews/pages/QueryResult/resultGrid.tsx | 31 ++++++++++++++++++- .../plugins/cellSelectionModel.plugin.ts | 11 ++++++- .../webviews/pages/QueryResult/table/table.ts | 4 +++ 6 files changed, 78 insertions(+), 2 deletions(-) diff --git a/extensions/mssql/src/queryResult/queryResultWebViewController.ts b/extensions/mssql/src/queryResult/queryResultWebViewController.ts index 402329bfcb..4d81be1de0 100644 --- a/extensions/mssql/src/queryResult/queryResultWebViewController.ts +++ b/extensions/mssql/src/queryResult/queryResultWebViewController.ts @@ -457,6 +457,31 @@ export class QueryResultWebviewController extends WebviewViewController< this._queryResultStateMap.set(uri, state); } + public updateSelectionState( + uri: string, + gridId: string, + selection: qr.ISlickRange[], + displaySelection: qr.ISlickRange[], + ): void { + const state = this._queryResultStateMap.get(uri); + if (!state) { + return; + } + + state.selection = selection; + state.gridSelections = { + ...(state.gridSelections ?? {}), + [gridId]: displaySelection, + }; + this._queryResultStateMap.set(uri, state); + + if (this._queryResultWebviewPanelControllerMap.has(uri)) { + this.updatePanelState(uri); + } else if (this.state?.uri === uri) { + this.state = state; + } + } + public hasQueryResultState(uri: string): boolean { return this._queryResultStateMap.has(uri); } diff --git a/extensions/mssql/src/queryResult/utils.ts b/extensions/mssql/src/queryResult/utils.ts index 715b851929..a3662a6c0c 100644 --- a/extensions/mssql/src/queryResult/utils.ts +++ b/extensions/mssql/src/queryResult/utils.ts @@ -302,6 +302,12 @@ export function registerCommonRequestHandlers( }); webviewController.onNotification(qr.SetSelectionSummaryRequest.type, async (message) => { + webviewViewController.updateSelectionState( + message.uri, + message.gridId, + message.selection, + message.displaySelection, + ); // Fetch all the data needed for the summary await webviewViewController .getSqlOutputContentProvider() diff --git a/extensions/mssql/src/sharedInterfaces/queryResult.ts b/extensions/mssql/src/sharedInterfaces/queryResult.ts index 5ba4d3146a..3599393d58 100644 --- a/extensions/mssql/src/sharedInterfaces/queryResult.ts +++ b/extensions/mssql/src/sharedInterfaces/queryResult.ts @@ -77,6 +77,7 @@ export interface QueryResultWebviewState extends ExecutionPlanWebviewState { tabStates?: QueryResultTabStates; isExecutionPlan?: boolean; selection?: ISlickRange[]; + gridSelections?: Record; executionPlanState: ExecutionPlanState; fontSettings: FontSettings; gridSettings?: GridSettings; @@ -359,9 +360,11 @@ export namespace CopyColumnNameRequest { export interface SetSelectionSummary { uri: string; + gridId: string; batchId: number; resultId: number; selection: ISlickRange[]; + displaySelection: ISlickRange[]; } export namespace SetSelectionSummaryRequest { export const type = new NotificationType("setSelectionSummary"); diff --git a/extensions/mssql/src/webviews/pages/QueryResult/resultGrid.tsx b/extensions/mssql/src/webviews/pages/QueryResult/resultGrid.tsx index 7ba01d8774..0995482a4d 100644 --- a/extensions/mssql/src/webviews/pages/QueryResult/resultGrid.tsx +++ b/extensions/mssql/src/webviews/pages/QueryResult/resultGrid.tsx @@ -79,6 +79,7 @@ const ResultGrid = forwardRef((props: ResultG return a?.rowCount === b?.rowCount; }, ); + const savedSelection = useQueryResultSelector((state) => state.gridSelections?.[props.gridId]); const gridContainerRef = useRef(null); const isTableCreated = useRef(false); @@ -294,6 +295,20 @@ const ResultGrid = forwardRef((props: ResultG await tableRef.current.restoreColumnWidths(); // Restore scroll position await tableRef.current.setupScrollPosition(); + if (savedSelection?.length) { + tableRef.current.setSelectedRanges( + savedSelection.map( + (range) => + new Slick.Range( + range.fromRow, + range.fromCell, + range.toRow, + range.toCell, + ), + ), + false, + ); + } } void restoreGridState(); }; @@ -320,7 +335,21 @@ const ResultGrid = forwardRef((props: ResultG } else { void createTable(); } - }, [resultSetSummary, gridSettings?.rowPadding, fontSettings?.fontSize]); + }, [uri, resultSetSummary, gridSettings?.rowPadding, fontSettings?.fontSize]); + + useEffect(() => { + if (!tableRef.current) { + return; + } + + tableRef.current.setSelectedRanges( + (savedSelection ?? []).map( + (range) => + new Slick.Range(range.fromRow, range.fromCell, range.toRow, range.toCell), + ), + false, + ); + }, [savedSelection]); // Update key bindings on slickgrid when key bindings change useEffect(() => { diff --git a/extensions/mssql/src/webviews/pages/QueryResult/table/plugins/cellSelectionModel.plugin.ts b/extensions/mssql/src/webviews/pages/QueryResult/table/plugins/cellSelectionModel.plugin.ts index 94d6a3924b..6b326100fd 100644 --- a/extensions/mssql/src/webviews/pages/QueryResult/table/plugins/cellSelectionModel.plugin.ts +++ b/extensions/mssql/src/webviews/pages/QueryResult/table/plugins/cellSelectionModel.plugin.ts @@ -8,6 +8,7 @@ import { CellRangeSelector, ICellRangeSelector } from "./cellRangeSelector"; import { + ISlickRange, ResultSetSummary, SetSelectionSummaryRequest, } from "../../../../../sharedInterfaces/queryResult"; @@ -926,12 +927,18 @@ export class CellSelectionModel } public async updateSummaryText(ranges?: Slick.Range[]): Promise { - if (!this.context || !this.uri || !this.resultSetSummary) { + if (!this.context || !this.uri || !this.resultSetSummary || !this.gridId) { return; } if (!ranges) { ranges = this.getSelectedRanges(); } + const displaySelection: ISlickRange[] = ranges.map((range) => ({ + fromRow: range.fromRow, + fromCell: range.fromCell, + toRow: range.toRow, + toCell: range.toCell, + })); const simplifiedRanges = ranges.map((range) => ({ fromRow: range.fromRow, fromCell: range.fromCell - 1, // adjust for number column @@ -941,7 +948,9 @@ export class CellSelectionModel const actualRanges = convertDisplayedSelectionToActual(this.grid, simplifiedRanges); await this.context.extensionRpc.sendNotification(SetSelectionSummaryRequest.type, { selection: actualRanges, + displaySelection, uri: this.uri, + gridId: this.gridId, batchId: this.resultSetSummary.batchId, resultId: this.resultSetSummary.id, }); diff --git a/extensions/mssql/src/webviews/pages/QueryResult/table/table.ts b/extensions/mssql/src/webviews/pages/QueryResult/table/table.ts index 803992fc36..a15396fb38 100644 --- a/extensions/mssql/src/webviews/pages/QueryResult/table/table.ts +++ b/extensions/mssql/src/webviews/pages/QueryResult/table/table.ts @@ -549,6 +549,10 @@ export class Table implements IThemable { return (undefined); } + setSelectedRanges(ranges: Slick.Range[], updateSummary: boolean = true): void { + this.selectionModel.setSelectedRanges(ranges, updateSummary); + } + focus(): void { this._grid.focus(); }