diff --git a/extensions/mssql/l10n/bundle.l10n.json b/extensions/mssql/l10n/bundle.l10n.json index d5ca527f2c..62b0449637 100644 --- a/extensions/mssql/l10n/bundle.l10n.json +++ b/extensions/mssql/l10n/bundle.l10n.json @@ -893,42 +893,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"] @@ -955,6 +919,7 @@ "comment": ["{0} is the error message"] }, "The query results panel failed to load. Please try running the query again.": "The query results panel failed to load. Please try running the query again.", + "Copying results cancelled": "Copying results cancelled", "{0} stopped successfully./{0} stopped successfully.": { "message": "{0} stopped successfully.", "comment": ["{0} stopped successfully."] @@ -2604,6 +2569,10 @@ "comment": ["{0} is the keyboard shortcut for the messages tab"] }, "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})", @@ -2685,6 +2654,60 @@ "message": "({0} rows affected)", "comment": ["{0} is the number of rows affected"] }, + "0 rows returned": "0 rows returned", + "1 row returned": "1 row returned", + "{0} rows returned/{0} is the number of rows returned": { + "message": "{0} rows returned", + "comment": ["{0} is the number of rows returned"] + }, + "0 rows": "0 rows", + "1 row": "1 row", + "{0} rows/{0} is the number of rows": { + "message": "{0} rows", + "comment": ["{0} is the number of rows"] + }, + "No rows affected": "No rows affected", + "Selected": "Selected", + "Rows": "Rows", + "Time": "Time", + "No selection": "No selection", + "Count": "Count", + "Avg": "Avg", + "Sum": "Sum", + "Min": "Min", + "Max": "Max", + "Distinct": "Distinct", + "Null": "Null", + "Execution cancelled": "Execution cancelled", + "Execution time unavailable": "Execution time unavailable", + "Running: {0}/{0} is how long the query has been running": { + "message": "Running: {0}", + "comment": ["{0} is how long the query has been running"] + }, + "{0}ms/{0} is the number of milliseconds": { + "message": "{0}ms", + "comment": ["{0} is the number of milliseconds"] + }, + "{0}s/{0} is the number of seconds": { + "message": "{0}s", + "comment": ["{0} is the number of seconds"] + }, + "{0}m/{0} is the number of minutes": { + "message": "{0}m", + "comment": ["{0} is the number of minutes"] + }, + "{0}m {1}s/{0} is the number of minutes{1} is the number of seconds": { + "message": "{0}m {1}s", + "comment": ["{0} is the number of minutes", "{1} is the number of seconds"] + }, + "{0}h/{0} is the number of hours": { + "message": "{0}h", + "comment": ["{0} is the number of hours"] + }, + "{0}h {1}m/{0} is the number of hours{1} is the number of minutes": { + "message": "{0}h {1}m", + "comment": ["{0} is the number of hours", "{1} is the number of minutes"] + }, "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"] diff --git a/extensions/mssql/src/constants/locConstants.ts b/extensions/mssql/src/constants/locConstants.ts index f55914546a..cecd7c2342 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 { @@ -1556,92 +1555,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}", @@ -1681,6 +1594,7 @@ export class QueryResult { public static queryResultPanelFailedToLoad = l10n.t( "The query results panel failed to load. Please try running the query again.", ); + public static copyingResultsCancelled = l10n.t("Copying results cancelled"); } export class LocalContainers { diff --git a/extensions/mssql/src/controllers/queryRunner.ts b/extensions/mssql/src/controllers/queryRunner.ts index be29716867..6dbdeb0259 100644 --- a/extensions/mssql/src/controllers/queryRunner.ts +++ b/extensions/mssql/src/controllers/queryRunner.ts @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from "vscode"; -import StatusView from "../views/statusView"; import SqlToolsServerClient from "../languageservice/serviceclient"; import { QueryNotificationHandler } from "./queryNotificationHandler"; import VscodeWrapper from "./vscodeWrapper"; @@ -67,6 +66,7 @@ export interface IResultSet { export interface QueryExecutionCompleteEvent { totalMilliseconds: string; + totalElapsedMilliseconds: number; hasError: boolean; isRefresh?: boolean; } @@ -82,6 +82,16 @@ export interface SummaryChanged extends SelectionSummary { uri: string; } +function getRowsAffectedFromMessage(message: string): number | undefined { + const rowsAffectedMatch = message.match(/\(?\s*(\d+)\s+rows?\s+affected\s*\)?/i); + if (!rowsAffectedMatch?.[1]) { + return undefined; + } + + const rowsAffected = Number(rowsAffectedMatch[1]); + return Number.isNaN(rowsAffected) ? undefined : rowsAffected; +} + export const editorEol = vscode.workspace.getConfiguration("files").get("eol") === "auto" ? os.EOL @@ -153,7 +163,6 @@ export default class QueryRunner { constructor( private _ownerUri: string, private _editorTitle: string, - private _statusView: StatusView, private _client?: SqlToolsServerClient, private _notificationHandler?: QueryNotificationHandler, private _vscodeWrapper?: VscodeWrapper, @@ -430,8 +439,6 @@ export default class QueryRunner { ); 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); @@ -455,16 +462,12 @@ export default class QueryRunner { promise.resolve(); 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.unregisterAllNotificationUris(); this._completeEmitter.fire({ totalMilliseconds: Utils.parseNumAsTimeString(this._totalElapsedMilliseconds), + totalElapsedMilliseconds: this._totalElapsedMilliseconds, hasError, }); sendActionEvent( @@ -551,6 +554,7 @@ export default class QueryRunner { public handleMessage(obj: QueryExecuteMessageParams): void { let message = obj.message; message.time = new Date(message.time).toLocaleTimeString(); + message.rowsAffected = getRowsAffectedFromMessage(message.message); // save the message into the batch summary so it can be restored on view refresh if (message.batchId >= 0 && this._batchSetMessages[message.batchId] !== undefined) { @@ -559,13 +563,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); - } } /** @@ -607,10 +604,9 @@ export default class QueryRunner { this._completeEmitter.fire({ totalMilliseconds: Utils.parseNumAsTimeString(this._totalElapsedMilliseconds), + totalElapsedMilliseconds: this._totalElapsedMilliseconds, hasError: !!error, }); - this._statusView.executedQuery(this._ownerUri); - this.unregisterAllNotificationUris(); if (errorMsg) { @@ -708,7 +704,7 @@ export default class QueryRunner { batchId: number, resultId: number, selection: ISlickRange[], - ): Promise { + ): Promise { let copyString = ""; let firstCol: number; let lastCol: number; @@ -738,6 +734,8 @@ export default class QueryRunner { if (process.platform === "darwin") { process.env["LANG"] = oldLang; } + + return true; } /** @@ -752,8 +750,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 +772,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 +790,25 @@ 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(); + vscode.window.showInformationMessage( + LocalizedConstants.QueryResult.copyingResultsCancelled, + ); + 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 +836,7 @@ export default class QueryRunner { // Check if cancelled while waiting for the request if (copyToken.isCancellationRequested) { - resolve(); + resolve(false); return; } @@ -844,14 +844,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 +860,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 +869,7 @@ export default class QueryRunner { executeCopy, ); } else { - await executeCopy(); + return await executeCopy(); } } @@ -936,7 +933,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 +943,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 +963,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 +973,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, }); } @@ -1014,6 +1011,8 @@ export default class QueryRunner { text: `$(play-circle) ${LocalizedConstants.QueryResult.summaryFetchConfirmation(totalRows)}`, tooltip: LocalizedConstants.QueryResult.clickToFetchSummary, uri: this.uri, + batchId, + resultId, }); await proceed.promise; }; @@ -1029,6 +1028,8 @@ export default class QueryRunner { text: `$(loading~spin) ${LocalizedConstants.QueryResult.summaryLoadingProgress(totalRows)}`, tooltip: LocalizedConstants.QueryResult.clickToCancelLoadingSummary, uri: this.uri, + batchId, + resultId, }); }; @@ -1095,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 @@ -1135,11 +1115,12 @@ export default class QueryRunner { } this.fireSummaryChangedEvent(requestId, { - text, - tooltip, + stats, uri: this.uri, command: undefined, continue: undefined, + batchId, + resultId, }); } catch (error) { // Clean up on error @@ -1155,6 +1136,8 @@ export default class QueryRunner { uri: this.uri, command: undefined, continue: undefined, + batchId, + resultId, }); throw error; } diff --git a/extensions/mssql/src/models/interfaces.ts b/extensions/mssql/src/models/interfaces.ts index 2d6a42e236..c7a0c2a456 100644 --- a/extensions/mssql/src/models/interfaces.ts +++ b/extensions/mssql/src/models/interfaces.ts @@ -156,6 +156,7 @@ export interface IResultMessage { isError: boolean; time: string; message: string; + rowsAffected?: number; } export interface IGridBatchMetaData { diff --git a/extensions/mssql/src/models/sqlOutputContentProvider.ts b/extensions/mssql/src/models/sqlOutputContentProvider.ts index f8669f7876..93697ec5be 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( @@ -507,7 +560,7 @@ export class SqlOutputContentProvider { } else { // We do not have a query runner for this editor, so create a new one // and map it to the results uri - queryRunner = new QueryRunner(uri, title, statusView ? statusView : this._statusView); + queryRunner = new QueryRunner(uri, title); const startFailedListener = queryRunner.onStartFailed(async (error) => { this.updateWebviewState(queryRunner.uri, { @@ -516,6 +569,10 @@ export class SqlOutputContentProvider { executionPlanState: {}, messages: [], fontSettings: { fontSize: 0, fontFamily: "" }, + isExecuting: false, + executionStartTime: undefined, + executionElapsedMilliseconds: undefined, + rowsAffected: undefined, }); }); @@ -526,6 +583,10 @@ export class SqlOutputContentProvider { resultWebviewState.tabStates.resultPaneTab = QueryResultPaneTabs.Messages; resultWebviewState.isExecutionPlan = false; resultWebviewState.initializationError = undefined; + resultWebviewState.isExecuting = true; + resultWebviewState.executionStartTime = Date.now(); + resultWebviewState.executionElapsedMilliseconds = undefined; + resultWebviewState.rowsAffected = undefined; this.updateWebviewState(queryRunner.uri, resultWebviewState); this.revealQueryResult(queryRunner.uri, "throw"); sendActionEvent(TelemetryViews.QueryResult, TelemetryActions.OpenQueryResult, { @@ -614,12 +675,15 @@ export class SqlOutputContentProvider { ); resultWebviewState.messages.push(message); + if (typeof message.rowsAffected === "number") { + resultWebviewState.rowsAffected = message.rowsAffected; + } this.scheduleThrottledUpdate(queryRunner.uri); }); const onCompleteListener = queryRunner.onComplete(async (e) => { - const { totalMilliseconds, hasError, isRefresh } = e; + const { totalMilliseconds, totalElapsedMilliseconds, hasError, isRefresh } = e; if (!isRefresh) { // only update query history with new queries this._vscodeWrapper.executeCommand( @@ -632,6 +696,9 @@ export class SqlOutputContentProvider { const resultWebviewState = this._queryResultWebviewController.getQueryResultState( queryRunner.uri, ); + resultWebviewState.isExecuting = false; + resultWebviewState.executionStartTime = undefined; + resultWebviewState.executionElapsedMilliseconds = totalElapsedMilliseconds; resultWebviewState.messages.push({ message: LocalizedConstants.elapsedTimeLabel(totalMilliseconds), isError: false, // Elapsed time messages are never displayed as errors @@ -692,12 +759,15 @@ export class SqlOutputContentProvider { return; } state.selectionSummary = { + stats: e.stats, text: e.text, 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); @@ -738,9 +808,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/queryResult/queryResultWebViewController.ts b/extensions/mssql/src/queryResult/queryResultWebViewController.ts index a8d115f198..4d81be1de0 100644 --- a/extensions/mssql/src/queryResult/queryResultWebViewController.ts +++ b/extensions/mssql/src/queryResult/queryResultWebViewController.ts @@ -38,8 +38,6 @@ export class QueryResultWebviewController extends WebviewViewController< 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; @@ -59,6 +57,9 @@ export class QueryResultWebviewController extends WebviewViewController< fontSettings: {}, gridSettings: {}, autoSizeColumnsMode: qr.ResultsGridAutoSizeStyle.HeadersAndData, + isExecuting: false, + executionElapsedMilliseconds: undefined, + rowsAffected: undefined, }); void this.initialize(); @@ -136,6 +137,12 @@ export class QueryResultWebviewController extends WebviewViewController< } } } + if ( + e.affectsConfiguration(Constants.configOpenQueryResultsInTabByDefault) && + this.isOpenQueryResultsInTabByDefaultEnabled + ) { + void this.moveCurrentPanelResultToDocumentTab(); + } }), ); @@ -145,7 +152,7 @@ export class QueryResultWebviewController extends WebviewViewController< if (!state) { return; } - (state.selectionSummary.continue as Deferred).resolve(); + (state.selectionSummary?.continue as Deferred | undefined)?.resolve(); }), ); } @@ -155,8 +162,6 @@ export class QueryResultWebviewController extends WebviewViewController< } 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); @@ -195,13 +200,13 @@ export class QueryResultWebviewController extends WebviewViewController< private get isOpenQueryResultsInTabByDefaultEnabled(): boolean { return this.vscodeWrapper .getConfiguration() - .get(Constants.configOpenQueryResultsInTabByDefault); + .get(Constants.configOpenQueryResultsInTabByDefault, false); } private get isDefaultQueryResultToDocumentDoNotShowPromptEnabled(): boolean { return this.vscodeWrapper .getConfiguration() - .get(Constants.configOpenQueryResultsInTabByDefaultDoNotShowPrompt); + .get(Constants.configOpenQueryResultsInTabByDefaultDoNotShowPrompt, false); } private get shouldShowDefaultQueryResultToDocumentPrompt(): boolean { @@ -273,6 +278,9 @@ export class QueryResultWebviewController extends WebviewViewController< tabStates: undefined, isExecutionPlan: false, executionPlanState: {}, + isExecuting: false, + executionElapsedMilliseconds: undefined, + rowsAffected: undefined, fontSettings: { fontSize: this.getFontSizeConfig(), fontFamily: this.getFontFamilyConfig(), @@ -284,6 +292,33 @@ export class QueryResultWebviewController extends WebviewViewController< }; } + 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)) { @@ -349,6 +384,9 @@ export class QueryResultWebviewController extends WebviewViewController< gridSettings: this.getGridSettingsConfig(), autoSizeColumnsMode: this.getAutoSizeColumnsConfig(), inMemoryDataProcessingThreshold: getInMemoryGridDataProcessingThreshold(), + isExecuting: false, + executionElapsedMilliseconds: undefined, + rowsAffected: undefined, } as qr.QueryResultWebviewState; this._queryResultStateMap.set(uri, currentState); } @@ -419,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); } @@ -495,8 +558,6 @@ export class QueryResultWebviewController extends WebviewViewController< this._queryResultStateMap.delete(uri); await this._sqlOutputContentProvider.cleanupRunner(uri); } - - this.updateSelectionSummary(); } } @@ -563,6 +624,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( @@ -586,29 +669,46 @@ export class QueryResultWebviewController extends WebviewViewController< return total; } - public updateSelectionSummary() { - let activeUri = Array.from(this._queryResultWebviewPanelControllerMap.keys()).find( - (uri) => this._queryResultWebviewPanelControllerMap.get(uri).panel.active, + public getOpenQueryResultsInTabByDefaultRequestHandler(): boolean { + return this.vscodeWrapper + .getConfiguration() + .get(Constants.configOpenQueryResultsInTabByDefault, false); + } + + public async setOpenQueryResultsInTabByDefaultRequestHandler( + params: qr.SetOpenQueryResultsInTabByDefaultParams, + ): Promise { + const { enabled, uri, webviewLocation } = params; + const configuration = this.vscodeWrapper.getConfiguration(); + const previousValue = configuration.get( + Constants.configOpenQueryResultsInTabByDefault, + false, ); - if (!activeUri) { - activeUri = getUriKey(vscode.window.activeTextEditor?.document.uri); + if (enabled && webviewLocation === qr.QueryResultWebviewLocation.Panel && uri) { + await this.createPanelController(uri); } - if (!this._queryResultStateMap.has(activeUri)) { - this._selectionSummaryStatusBarItem.hide(); - return; - } + await configuration.update( + Constants.configOpenQueryResultsInTabByDefault, + enabled, + vscode.ConfigurationTarget.Global, + ); - const state = this._queryResultStateMap.get(activeUri); + // Skip the one-time prompt after users explicitly choose their preferred result location. + await configuration.update( + Constants.configOpenQueryResultsInTabByDefaultDoNotShowPrompt, + true, + vscode.ConfigurationTarget.Global, + ); - 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(); - } + sendActionEvent( + TelemetryViews.QueryResult, + TelemetryActions.QueryResultsTabDefaultSettingToggled, + { + enabled: enabled.toString(), + previousValue: previousValue.toString(), + }, + ); } } diff --git a/extensions/mssql/src/queryResult/queryResultWebviewPanelController.ts b/extensions/mssql/src/queryResult/queryResultWebviewPanelController.ts index 56b86bfcc8..049bea27e8 100644 --- a/extensions/mssql/src/queryResult/queryResultWebviewPanelController.ts +++ b/extensions/mssql/src/queryResult/queryResultWebviewPanelController.ts @@ -38,6 +38,7 @@ export class QueryResultWebviewPanelController extends WebviewPanelController< }, executionPlanState: {}, fontSettings: {}, + isExecuting: false, }, { title: title, @@ -71,8 +72,6 @@ export class QueryResultWebviewPanelController extends WebviewPanelController< if (params.webviewPanel.viewColumn) { this._viewColumn = params.webviewPanel.viewColumn; } - - this._queryResultWebviewViewController.updateSelectionSummary(); }); } diff --git a/extensions/mssql/src/queryResult/utils.ts b/extensions/mssql/src/queryResult/utils.ts index 0203fe7129..a3662a6c0c 100644 --- a/extensions/mssql/src/queryResult/utils.ts +++ b/extensions/mssql/src/queryResult/utils.ts @@ -83,6 +83,17 @@ 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); + }, + ); + webviewController.onRequest(qr.SetEditorSelectionRequest.type, async (message) => { if (!message.uri || !message.selectionData) { console.warn( @@ -291,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 5bf92cbd36..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; @@ -84,17 +85,34 @@ export interface QueryResultWebviewState extends ExecutionPlanWebviewState { inMemoryDataProcessingThreshold?: number; initializationError?: string; selectionSummary?: SelectionSummary; + isExecuting?: boolean; + executionStartTime?: number; + executionElapsedMilliseconds?: number; + rowsAffected?: 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: any[]; + arguments: unknown[]; }; - tooltip: string; - continue?: any; + tooltip?: string; + continue?: unknown; + batchId?: number; + resultId?: number; } export interface QueryResultReducers extends Omit { @@ -141,6 +159,7 @@ export interface IMessage { isError: boolean; link?: IMessageLink; selection?: ISelectionData; + rowsAffected?: number; } export interface ResultSetSummary { @@ -341,14 +360,20 @@ 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"); } +export namespace ShowCopySuccessNotification { + export const type = new NotificationType("showCopySuccess"); +} + export interface OpenInNewTabParams { uri: string; } @@ -365,6 +390,22 @@ export namespace GetWebviewLocationRequest { ); } +export namespace GetOpenQueryResultsInTabByDefaultRequest { + export const type = new RequestType("getOpenQueryResultsInTabByDefault"); +} + +export interface SetOpenQueryResultsInTabByDefaultParams { + enabled: boolean; + uri?: string; + webviewLocation?: QueryResultWebviewLocation; +} + +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 31fdb629c0..e0c70d72d2 100644 --- a/extensions/mssql/src/sharedInterfaces/telemetry.ts +++ b/extensions/mssql/src/sharedInterfaces/telemetry.ts @@ -113,6 +113,7 @@ export enum TelemetryActions { CopyResultsHeaders = "CopyResultsHeaders", CopyHeaders = "CopyHeaders", OpenQueryResultsInTabByDefaultPrompt = "OpenQueryResultsInTabByDefaultPrompt", + QueryResultsTabDefaultSettingToggled = "QueryResultsTabDefaultSettingToggled", OpenQueryResult = "OpenQueryResult", Restore = "Restore", LoadConnection = "LoadConnection", diff --git a/extensions/mssql/src/views/statusView.ts b/extensions/mssql/src/views/statusView.ts index 6397404e84..31d276830f 100644 --- a/extensions/mssql/src/views/statusView.ts +++ b/extensions/mssql/src/views/statusView.ts @@ -24,21 +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; - // 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; public currentLanguageServiceStatus: string; - public queryTimer: NodeJS.Timeout; public connectionId: string; } @@ -79,8 +70,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); } } }); @@ -93,13 +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(); - this._statusBars[bar].rowCount.dispose(); - this._statusBars[bar].executionTime.dispose(); - clearInterval(this._statusBars[bar].progressTimerId); - clearInterval(this._statusBars[bar].queryTimer); delete this._statusBars[bar]; } } @@ -122,14 +106,10 @@ 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, ); 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; } @@ -145,27 +125,12 @@ 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(); } - if (bar.rowCount) { - bar.rowCount.dispose(); - } - if (bar.executionTime) { - bar.executionTime.dispose(); - } delete this._statusBars[fileUri]; } @@ -178,9 +143,6 @@ export default class StatusView implements vscode.Disposable { } let bar = this._statusBars[fileUri]; - if (bar.progressTimerId) { - clearInterval(bar.progressTimerId); - } return bar; } @@ -189,11 +151,8 @@ 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); - this.showStatusBarItem(fileUri, bar.rowCount); - this.showStatusBarItem(fileUri, bar.executionTime); } public setNotConnected(fileUri: string): void { @@ -212,10 +171,6 @@ export default class StatusView implements vscode.Disposable { this.showStatusBarItem(fileUri, bar.statusLanguageFlavor); this.hideStatusBarItem(fileUri, bar.statusChangeDatabase); - this.hideStatusBarItem(fileUri, bar.statusQuery); - this.hideStatusBarItem(fileUri, bar.rowCount); - this.hideStatusBarItem(fileUri, bar.executionTime); - clearInterval(bar.queryTimer); } public setConnecting(fileUri: string, connCreds: IConnectionInfo): void { @@ -337,41 +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 = LocalizedConstants.executeQueryLabel; - this.showStatusBarItem(fileUri, bar.statusQuery); - this.showProgress(fileUri, LocalizedConstants.executeQueryLabel, bar.statusQuery); - } - - 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); - clearInterval(bar.queryTimer); - } - - 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); - clearInterval(bar.queryTimer); - } - public languageServiceStatusChanged(fileUri: string, status: string): void { let bar = this.getStatusBar(fileUri); bar.currentLanguageServiceStatus = status; @@ -401,23 +321,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, @@ -448,35 +351,13 @@ 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(); - this._lastShownStatusBar.rowCount.hide(); - this._lastShownStatusBar.executionTime.hide(); } } @@ -558,37 +439,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/src/webviews/common/locConstants.ts b/extensions/mssql/src/webviews/common/locConstants.ts index 73344b1f92..0e4c6b2b69 100644 --- a/extensions/mssql/src/webviews/common/locConstants.ts +++ b/extensions/mssql/src/webviews/common/locConstants.ts @@ -683,7 +683,15 @@ 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"), + 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) { @@ -844,6 +852,91 @@ export class LocConstants { }); } }, + rowsReturned: (rowCount: number) => { + switch (rowCount) { + case 0: + return l10n.t("0 rows returned"); + case 1: + return l10n.t("1 row returned"); + default: + return l10n.t({ + message: "{0} rows returned", + args: [rowCount], + comment: ["{0} is the number of rows returned"], + }); + } + }, + rowsCount: (rowCount: number) => { + switch (rowCount) { + case 0: + return l10n.t("0 rows"); + case 1: + return l10n.t("1 row"); + default: + return l10n.t({ + message: "{0} rows", + args: [rowCount], + comment: ["{0} is the number of rows"], + }); + } + }, + noRowsAffected: l10n.t("No rows affected"), + selectedItemLabel: l10n.t("Selected"), + rowsAffectedLabel: l10n.t("Rows"), + timeLabel: l10n.t("Time"), + runningLabel: l10n.t("Running"), + 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"), + runningWithDuration: (duration: string) => + l10n.t({ + message: "Running: {0}", + args: [duration], + comment: ["{0} is how long the query has been running"], + }), + compactMilliseconds: (milliseconds: number) => + l10n.t({ + message: "{0}ms", + args: [milliseconds], + comment: ["{0} is the number of milliseconds"], + }), + compactSeconds: (seconds: number | string) => + l10n.t({ + message: "{0}s", + args: [seconds], + comment: ["{0} is the number of seconds"], + }), + compactMinutes: (minutes: number) => + l10n.t({ + message: "{0}m", + args: [minutes], + comment: ["{0} is the number of minutes"], + }), + compactMinutesSeconds: (minutes: number, seconds: number) => + l10n.t({ + message: "{0}m {1}s", + args: [minutes, seconds], + comment: ["{0} is the number of minutes", "{1} is the number of seconds"], + }), + compactHours: (hours: number) => + l10n.t({ + message: "{0}h", + args: [hours], + comment: ["{0} is the number of hours"], + }), + compactHoursMinutes: (hours: number, minutes: number) => + l10n.t({ + message: "{0}h {1}m", + args: [hours, minutes], + comment: ["{0} is the number of hours", "{1} is the number of minutes"], + }), resultSet: (batchNumber: number, queryNumber: number) => l10n.t({ message: "Result Set Batch {0} - Query {1}", diff --git a/extensions/mssql/src/webviews/pages/QueryResult/queryResultPane.tsx b/extensions/mssql/src/webviews/pages/QueryResult/queryResultPane.tsx index de3bae8373..786412b0de 100644 --- a/extensions/mssql/src/webviews/pages/QueryResult/queryResultPane.tsx +++ b/extensions/mssql/src/webviews/pages/QueryResult/queryResultPane.tsx @@ -14,7 +14,12 @@ import { Spinner, } from "@fluentui/react-components"; import { useContext, useEffect, useRef, useState } from "react"; -import { DatabaseSearch24Regular, ErrorCircle24Regular, OpenRegular } from "@fluentui/react-icons"; +import { + CheckmarkCircle20Regular, + DatabaseSearch24Regular, + ErrorCircle24Regular, + OpenRegular, +} from "@fluentui/react-icons"; import * as qr from "../../../sharedInterfaces/queryResult"; import { locConstants } from "../../common/locConstants"; import { hasResultsOrMessages } from "./queryResultUtils"; @@ -28,8 +33,21 @@ import { QueryExecutionPlanTab } from "./queryExecutionPlanTab"; 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({ + copiedIndicator: { + display: "flex", + alignItems: "center", + gap: "6px", + padding: "0 6px", + whiteSpace: "nowrap", + }, + copiedIndicatorText: { + fontSize: "12px", + fontWeight: 600, + }, root: { width: "100%", height: "100%", @@ -44,6 +62,11 @@ const useStyles = makeStyles({ marginRight: "10px", }, }, + ribbonActions: { + display: "flex", + alignItems: "center", + gap: "4px", + }, queryResultPaneTabs: { flex: 1, }, @@ -69,7 +92,8 @@ const useStyles = makeStyles({ }, noResultsContainer: { width: "100%", - height: "100%", + flex: 1, + minHeight: 0, display: "flex", alignItems: "center", justifyContent: "center", @@ -107,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); @@ -132,9 +160,6 @@ export const QueryResultPane = () => { (s) => s.executionPlanState?.executionPlanGraphs, ); - const resultPaneParentRef = useRef(null); - const ribbonRef = useRef(null); - const { keyBindings } = useVscodeWebview(); useEffect(() => { @@ -187,11 +212,46 @@ export const QueryResultPane = () => { }); setWebviewLocation(res); }; - const [webviewLocation, setWebviewLocation] = useState(""); + 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) => { console.error(e); - setWebviewLocation("panel"); + setWebviewLocation(qr.QueryResultWebviewLocation.Panel); }); }, []); @@ -207,6 +267,7 @@ export const QueryResultPane = () => { {initilizationError} + ); } @@ -216,7 +277,7 @@ export const QueryResultPane = () => {
- {webviewLocation === "document" ? ( + {webviewLocation === qr.QueryResultWebviewLocation.Document ? ( { )}
+
); } return ( -
-
+
+
{ )} - {webviewLocation === "panel" && ( - - )} +
+ {showCopiedIndicator && ( +
+ + + {locConstants.queryResult.copied} + +
+ )} + + {webviewLocation === qr.QueryResultWebviewLocation.Panel && ( + + )} +
@@ -342,6 +422,7 @@ export const QueryResultPane = () => {
+
); }; diff --git a/extensions/mssql/src/webviews/pages/QueryResult/queryResultSettingsControl.tsx b/extensions/mssql/src/webviews/pages/QueryResult/queryResultSettingsControl.tsx new file mode 100644 index 0000000000..b01913c3ae --- /dev/null +++ b/extensions/mssql/src/webviews/pages/QueryResult/queryResultSettingsControl.tsx @@ -0,0 +1,189 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Dismiss16Regular, Settings16Regular, Settings20Regular } from "@fluentui/react-icons"; +import { useContext, useEffect, useState } from "react"; +import * as qr from "../../../sharedInterfaces/queryResult"; +import { locConstants } from "../../common/locConstants"; +import { QueryResultCommandsContext } from "./queryResultStateProvider"; + +const useStyles = makeStyles({ + ribbonIconButton: { + width: "28px", + height: "28px", + minWidth: "28px", + padding: 0, + }, + settingsPopoverSurface: { + padding: 0, + width: "280px", + maxWidth: "calc(100vw - 16px)", + borderRadius: "6px", + border: "1px solid var(--vscode-widget-border)", + backgroundColor: "var(--vscode-editorWidget-background)", + color: "var(--vscode-foreground)", + boxShadow: "0 8px 24px var(--vscode-widget-shadow)", + }, + settingsPopoverHeader: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + padding: "6px 8px", + borderBottom: "1px solid var(--vscode-widget-border)", + }, + settingsPopoverTitleGroup: { + display: "flex", + alignItems: "center", + gap: "6px", + fontSize: "13px", + fontWeight: 600, + }, + settingsPopoverCloseButton: { + width: "24px", + height: "24px", + minWidth: "24px", + padding: 0, + }, + settingsPopoverOption: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: "8px", + padding: "8px", + }, + settingsPopoverOptionText: { + minWidth: 0, + display: "flex", + flexDirection: "column", + gap: "4px", + }, + settingsPopoverOptionTitle: { + fontSize: "12px", + lineHeight: "16px", + color: "var(--vscode-foreground)", + }, + settingsPopoverOptionDescription: { + fontSize: "12px", + lineHeight: "15px", + 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, + uri, + webviewLocation, + }, + ); + } 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/webviews/pages/QueryResult/queryResultSummaryFooter.tsx b/extensions/mssql/src/webviews/pages/QueryResult/queryResultSummaryFooter.tsx new file mode 100644 index 0000000000..1d0b906c89 --- /dev/null +++ b/extensions/mssql/src/webviews/pages/QueryResult/queryResultSummaryFooter.tsx @@ -0,0 +1,489 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { getDisplayedRowsCount } from "./queryResultUtils"; +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)", + }, + 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-foreground)", + }, + timeAccent: { + color: "var(--vscode-foreground)", + }, + 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-foreground)", + cursor: "pointer", + fontSize: "11px", + fontWeight: 600, + }, + 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-foreground)", + }, + selectionTooltipMetricValue: { + justifySelf: "end", + textAlign: "right", + fontVariantNumeric: "tabular-nums", + fontFeatureSettings: '"tnum"', + }, + cancelled: { + color: "var(--vscode-foreground)", + }, +}); + +function normalizeStatusText(text?: string): string { + if (!text) { + return ""; + } + return text.replace(/\$\([^)]+\)\s*/g, "").trim(); +} + +function formatMillisecondsCompact(milliseconds: number): string { + if (milliseconds < 1000) { + return locConstants.queryResult.compactMilliseconds(milliseconds); + } + + if (milliseconds < 60000) { + const seconds = milliseconds / 1000; + 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 locConstants.queryResult.compactMinutes(minutes); + } + if (seconds === 60) { + return locConstants.queryResult.compactMinutes(minutes + 1); + } + return locConstants.queryResult.compactMinutesSeconds(minutes, seconds); + } + + const hours = Math.floor(milliseconds / 3600000); + const minutes = Math.round((milliseconds % 3600000) / 60000); + if (minutes === 0) { + return locConstants.queryResult.compactHours(hours); + } + if (minutes === 60) { + return locConstants.queryResult.compactHours(hours + 1); + } + return locConstants.queryResult.compactHoursMinutes(hours, minutes); +} + +function formatRunningTimeCompact(milliseconds: number): string { + if (milliseconds < 1000) { + return locConstants.queryResult.runningLabel; + } + + if (milliseconds < 60000) { + 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 + ? 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 + ? locConstants.queryResult.compactHoursMinutes(hours, minutes) + : locConstants.queryResult.compactHours(hours); +} + +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"; +} + +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 formatSelectionMetricValue(metric: SelectionMetricKey, value: number): string { + if (metric === "average") { + return value.toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); + } + + if (Number.isInteger(value)) { + return value.toLocaleString(); + } + + 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 [ + { + label: getSelectionMetricLabel(metric), + value: formatSelectionMetricValue(metric, value), + }, + ]; + }); +} + +function renderSelectionMetricsInline( + stats: qr.SelectionSummaryMetrics, + classes: Record, +) { + const metrics = getSelectionMetrics( + stats, + isNumericSelectionSummary(stats) + ? INLINE_NUMERIC_METRIC_ORDER + : INLINE_NON_NUMERIC_METRIC_ORDER, + ); + + return ( + + {metrics.map((metric, index) => ( + + {index > 0 ? " \u00b7 " : ""} + {metric.label}:{" "} + {metric.value} + + ))} + + ); +} + +function renderSelectionMetricsTooltip( + stats: qr.SelectionSummaryMetrics, + classes: Record, +) { + const metrics = getSelectionMetrics( + stats, + isNumericSelectionSummary(stats) + ? TOOLTIP_NUMERIC_METRIC_ORDER + : TOOLTIP_NON_NUMERIC_METRIC_ORDER, + ); + + return ( +
+ {metrics.map((metric) => ( +
+ {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 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(() => { + if (!isExecuting || !executionStartTime) { + return; + } + + setTickTimestamp(Date.now()); + const timer = window.setInterval(() => { + setTickTimestamp(Date.now()); + }, 1000); + + return () => { + window.clearInterval(timer); + }; + }, [isExecuting, executionStartTime]); + + const displayedResultRowsCount = useMemo(() => { + return getDisplayedRowsCount(resultSetSummaries, selectionSummary, undefined); + }, [resultSetSummaries, selectionSummary]); + const rowsCount = + typeof displayedResultRowsCount === "number" ? displayedResultRowsCount : rowsAffected; + + const rowsText = + 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) + : undefined; + const compactExecutionText = + liveExecutionMilliseconds !== undefined + ? formatRunningTimeCompact(liveExecutionMilliseconds) + : executionElapsedMilliseconds !== undefined + ? formatMillisecondsCompact(executionElapsedMilliseconds) + : locConstants.queryResult.executionTimeUnavailable; + const executionTooltipText = + liveExecutionMilliseconds !== undefined + ? compactExecutionText === locConstants.queryResult.runningLabel + ? locConstants.queryResult.runningLabel + : locConstants.queryResult.runningWithDuration(compactExecutionText) + : compactExecutionText; + 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 rowsCount === "number" ? rowsCount.toLocaleString() : "0"; + const isTextResultsView = + tabStates?.resultPaneTab === qr.QueryResultPaneTabs.Results && + tabStates?.resultViewMode === qr.QueryResultViewMode.Text; + + if (isTextResultsView) { + return ; + } + + return ( +
+ {!hideMetrics && ( +
+
+ + {locConstants.queryResult.rowsAffectedLabel} + + + + {compactRowsText} + + +
+ | +
+ {locConstants.queryResult.timeLabel} + + + {compactExecutionText} + + +
+
+ )} +
+
+ | + + {selectionCommand?.command ? ( + + ) : ( + {selectionDisplayContent} + )} + +
+
+ ); +}; diff --git a/extensions/mssql/src/webviews/pages/QueryResult/queryResultUtils.ts b/extensions/mssql/src/webviews/pages/QueryResult/queryResultUtils.ts index 3322cab29d..69d2b56b8e 100644 --- a/extensions/mssql/src/webviews/pages/QueryResult/queryResultUtils.ts +++ b/extensions/mssql/src/webviews/pages/QueryResult/queryResultUtils.ts @@ -55,3 +55,55 @@ 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; +} + +export function getDisplayedRowsCount( + summaries: Record>, + selectionSummary: qr.SelectionSummary | undefined, + rowsAffected: number | undefined, +): number | undefined { + const activeResultRowCount = getActiveResultSetRowCount(summaries, selectionSummary); + if (typeof activeResultRowCount === "number") { + return activeResultRowCount; + } + + const totalResultRowCount = getTotalResultSetRowCount(summaries); + if (typeof totalResultRowCount === "number") { + return totalResultRowCount; + } + + return rowsAffected; +} 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 ff358dcea9..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"; @@ -149,10 +150,10 @@ export class CellSelectionModel return result; } - public setSelectedRanges(ranges: Array): void { + public setSelectedRanges(ranges: Array, updateSummary: boolean = true): void { this.ranges = this.removeInvalidRanges(ranges); this.onSelectedRangesChanged.notify(this.ranges); - if (this.context) { + if (updateSummary && this.context) { void this.updateSummaryText(this.ranges); } } @@ -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/plugins/rowNumberColumn.plugin.ts b/extensions/mssql/src/webviews/pages/QueryResult/table/plugins/rowNumberColumn.plugin.ts index fa3c794839..23baeb9fb9 100644 --- a/extensions/mssql/src/webviews/pages/QueryResult/table/plugins/rowNumberColumn.plugin.ts +++ b/extensions/mssql/src/webviews/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/webviews/pages/QueryResult/table/table.ts b/extensions/mssql/src/webviews/pages/QueryResult/table/table.ts index 151d4dd2e1..a15396fb38 100644 --- a/extensions/mssql/src/webviews/pages/QueryResult/table/table.ts +++ b/extensions/mssql/src/webviews/pages/QueryResult/table/table.ts @@ -399,9 +399,9 @@ export class Table implements IThemable { requestAnimationFrame(() => { this.focus(); const recentlyScrolled = Date.now() - this._lastScrollAt < 250; - // Restore selection always – this does not force scroll + // Restore selection visuals without recomputing footer summary metrics. if (selectedRanges?.length) { - this.selectionModel.setSelectedRanges(selectedRanges); + this.selectionModel.setSelectedRanges(selectedRanges, false); } // Only restore active cell if it would not force-scroll the viewport if (activeCell && !recentlyScrolled) { @@ -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(); } diff --git a/extensions/mssql/test/unit/queryResultSummaryFooter.test.ts b/extensions/mssql/test/unit/queryResultSummaryFooter.test.ts new file mode 100644 index 0000000000..f0615b75ba --- /dev/null +++ b/extensions/mssql/test/unit/queryResultSummaryFooter.test.ts @@ -0,0 +1,65 @@ +/*--------------------------------------------------------------------------------------------- + * 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, 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, undefined); + + expect(result).to.equal(25); + }); + + test("falls back to structured rows affected when no grid summaries exist", () => { + const result = getDisplayedRowsCount({}, undefined, 5); + + 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/queryResultWebViewController.test.ts b/extensions/mssql/test/unit/queryResultWebViewController.test.ts new file mode 100644 index 0000000000..1d9856e0e5 --- /dev/null +++ b/extensions/mssql/test/unit/queryResultWebViewController.test.ts @@ -0,0 +1,177 @@ +/*--------------------------------------------------------------------------------------------- + * 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 * as qr from "../../src/sharedInterfaces/queryResult"; +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({ + enabled: true, + uri: testUri, + webviewLocation: qr.QueryResultWebviewLocation.Panel, + }); + + expect(createPanelControllerStub).to.have.been.calledWithExactly(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({ + enabled: false, + uri: testUri, + webviewLocation: qr.QueryResultWebviewLocation.Panel, + }); + + 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); + }); + + test("notifies the active webview when messages are copied to the clipboard", async () => { + controller.setQueryResultState(testUri, { + ...controller.getQueryResultState(testUri), + messages: [ + { message: "first message", isError: false }, + { message: "second message", isError: false }, + ], + }); + controller.state = controller.getQueryResultState(testUri); + const sendNotificationStub = sandbox.stub(controller, "sendNotification").resolves(); + + await controller.copyAllMessagesToClipboard(testUri); + + expect(vscodeWrapper.clipboardWriteText).to.have.been.calledOnceWithExactly( + "first message\nsecond message", + ); + expect(sendNotificationStub).to.have.been.calledOnceWithExactly( + qr.ShowCopySuccessNotification.type, + undefined, + ); + }); +}); diff --git a/extensions/mssql/test/unit/queryRunner.test.ts b/extensions/mssql/test/unit/queryRunner.test.ts index 22ed02accc..9563fa1d43 100644 --- a/extensions/mssql/test/unit/queryRunner.test.ts +++ b/extensions/mssql/test/unit/queryRunner.test.ts @@ -21,7 +21,6 @@ import { CopyType, } from "../../src/models/contracts/queryExecute"; import VscodeWrapper from "../../src/controllers/vscodeWrapper"; -import StatusView from "../../src/views/statusView"; import * as Constants from "../../src/constants/constants"; import * as QueryExecuteContracts from "../../src/models/contracts/queryExecute"; import * as QueryDisposeContracts from "../../src/models/contracts/queryDispose"; @@ -49,7 +48,6 @@ suite("Query Runner tests", () => { let testSqlToolsServerClient: sinon.SinonStubbedInstance; let testQueryNotificationHandler: sinon.SinonStubbedInstance; let testVscodeWrapper: sinon.SinonStubbedInstance; - let testStatusView: sinon.SinonStubbedInstance; function createQueryRunner( uri: string = standardUri, @@ -58,7 +56,6 @@ suite("Query Runner tests", () => { return new QueryRunner( uri, title, - testStatusView, testSqlToolsServerClient, testQueryNotificationHandler, testVscodeWrapper, @@ -70,7 +67,6 @@ suite("Query Runner tests", () => { testSqlToolsServerClient = sandbox.createStubInstance(SqlToolsServerClient); testQueryNotificationHandler = sandbox.createStubInstance(QueryNotificationHandler); testVscodeWrapper = stubVscodeWrapper(sandbox); - testStatusView = sandbox.createStubInstance(StatusView); QueryRunner["_runningQueries"] = []; (testVscodeWrapper.parseUri as sinon.SinonStub).callsFake((value: string) => @@ -121,8 +117,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 +134,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 +153,8 @@ suite("Query Runner tests", () => { expect.fail("Expected runQuery to throw an error"); } catch { // 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); } @@ -454,12 +443,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.have.been.calledOnce; - expect(testStatusView.setExecutionTime.firstCall.args[0]).to.equal(standardUri); - // ... 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 8bb942019b..480c91e6c1 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); }); diff --git a/localization/xliff/vscode-mssql.xlf b/localization/xliff/vscode-mssql.xlf index a3e8922173..e64b147f39 100644 --- a/localization/xliff/vscode-mssql.xlf +++ b/localization/xliff/vscode-mssql.xlf @@ -84,9 +84,21 @@ - + + 0 rows + + + 0 rows returned + 1 + + 1 row + + + 1 row returned + 100K vCore seconds @@ -549,13 +561,8 @@ 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 + + Avg Azure (China) @@ -1156,6 +1163,9 @@ Close properties pane + + Close results settings + Close the current connection @@ -1685,6 +1695,9 @@ Copy-only Backup + + Copying results cancelled + Copying results... @@ -1706,13 +1719,8 @@ 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 + + Count Create @@ -2199,9 +2207,8 @@ Dissatisfied - - Distinct Count: {0} - {0} is the distinct count + + Distinct Do not compress backup @@ -2714,6 +2721,12 @@ Execution Plan + + Execution cancelled + + + Execution time unavailable + Existing Database @@ -4277,6 +4290,9 @@ Mandatory (True) + + Max + Max 10 databases / subscription @@ -4289,10 +4305,6 @@ Max vCores - - Max: {0} - {0} is the max - Maximize @@ -4380,9 +4392,8 @@ 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 + + Min Missing connectionId. Please provide a connectionId to open Data API builder. @@ -4769,6 +4780,9 @@ No results to display + + No rows affected + No rules match the current filter. @@ -4790,6 +4804,9 @@ No script generated. + + No selection + No servers found @@ -4900,9 +4917,8 @@ 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 + + Null Number of Rows Read @@ -5002,6 +5018,9 @@ Open in editor + + Open results in new tab + Open text data in a new editor @@ -5678,6 +5697,9 @@ Results ({0}) {0} is the keyboard shortcut for the results tab + + Results Settings + Results copied to clipboard @@ -5739,6 +5761,9 @@ Row marked for removal. + + Rows + Rows per page @@ -5783,6 +5808,10 @@ {0} is the connection display name {1} is the connection ID + + Running: {0} + {0} is how long the query has been running + SQL @@ -6288,6 +6317,9 @@ Select the SQL Server Container Image + + Selected + Selected Microsoft Entra account removed successfully. @@ -6462,6 +6494,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 @@ -6736,9 +6771,8 @@ Successfully saved results to {0} {0} is the file path - - Sum: {0} - {0} is the sum + + Sum Summary @@ -7089,6 +7123,9 @@ 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 @@ -7898,6 +7935,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 @@ -7993,21 +8038,47 @@ {0}d {1}h {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 number of hours +{1} is the number of minutes {0}h {1}m {0} is the elapsed time in hours {1} is the remaining elapsed time in minutes + + + {0}m + {0} is the number of minutes + + + {0}m {1}s + {0} is the number of minutes +{1} is the number of seconds {0}m {1}s {0} is the elapsed time in minutes {1} is the remaining elapsed time in seconds + + {0}ms + {0} is the number of milliseconds + {0}ms {0} is the elapsed time in milliseconds + + {0}s + {0} is the number of seconds + {0}s {0} is the elapsed time in seconds