Skip to content

Commit fa2b901

Browse files
Copy Long Messages in SQL Notebooks (#22052)
* Add copy all support to sql notebooks * Generated loc changes * Update notebook copy output status message * Generated locs * Add unit tests * Copy query messages even when query result is present * Code review changes * Minor clean up * Code review changes
1 parent 218a589 commit fa2b901

9 files changed

Lines changed: 488 additions & 1 deletion

File tree

extensions/mssql/l10n/bundle.l10n.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2206,6 +2206,9 @@
22062206
"No active notebook.": "No active notebook.",
22072207
"No active connection.": "No active connection.",
22082208
"No connection selected.": "No connection selected.",
2209+
"Copy messages": "Copy messages",
2210+
"Copy all text output for this cell (messages, PRINT, errors)": "Copy all text output for this cell (messages, PRINT, errors)",
2211+
"$(check) Copied messages": "$(check) Copied messages",
22092212
"({0} row(s) affected)/{0} is the number of rows affected": {
22102213
"message": "({0} row(s) affected)",
22112214
"comment": ["{0} is the number of rows affected"]

extensions/mssql/package.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -911,6 +911,10 @@
911911
"command": "mssql.viewBackgroundTaskLogs",
912912
"when": "false"
913913
},
914+
{
915+
"command": "mssql.notebooks.copyCellMessages",
916+
"when": "false"
917+
},
914918
{
915919
"command": "mssql.runQueryWithUriOwnership",
916920
"when": "false"
@@ -1613,6 +1617,11 @@
16131617
"command": "mssql.notebooks.changeConnection",
16141618
"title": "%mssql.notebooks.changeConnection%",
16151619
"category": "MS SQL"
1620+
},
1621+
{
1622+
"command": "mssql.notebooks.copyCellMessages",
1623+
"title": "%mssql.notebooks.copyCellMessages%",
1624+
"category": "MS SQL"
16161625
}
16171626
],
16181627
"keybindings": [

extensions/mssql/package.nls.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -294,5 +294,6 @@
294294
"mssql.flatFileImport": "Import Data...",
295295
"mssql.notebooks.createNotebook": "New SQL Notebook",
296296
"mssql.notebooks.changeDatabase": "Change Notebook Database",
297-
"mssql.notebooks.changeConnection": "Change Notebook Connection"
297+
"mssql.notebooks.changeConnection": "Change Notebook Connection",
298+
"mssql.notebooks.copyCellMessages": "Copy Cell Messages"
298299
}

extensions/mssql/src/constants/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ export const cmdFlatFileImport = "mssql.flatFileImport";
158158
export const cmdNotebooksCreate = "mssql.notebooks.createNotebook";
159159
export const cmdNotebooksChangeDatabase = "mssql.notebooks.changeDatabase";
160160
export const cmdNotebooksChangeConnection = "mssql.notebooks.changeConnection";
161+
export const cmdNotebooksCopyCellMessages = "mssql.notebooks.copyCellMessages";
161162

162163
export const piiLogging = "piiLogging";
163164
export const mssqlPiiLogging = "mssql.piiLogging";

extensions/mssql/src/constants/locConstants.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -821,6 +821,13 @@ export class Notebooks {
821821
public static noActiveConnection = l10n.t("No active connection.");
822822
public static noConnectionSelected = l10n.t("No connection selected.");
823823

824+
// Copy cell output
825+
public static copyMessages = l10n.t("Copy messages");
826+
public static copyMessagesTooltip = l10n.t(
827+
"Copy all text output for this cell (messages, PRINT, errors)",
828+
);
829+
public static copiedMessages = l10n.t("$(check) Copied messages");
830+
824831
// Execution results
825832
public static rowsAffected(count: number) {
826833
return l10n.t({

extensions/mssql/src/controllers/mainController.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ import { CreateSessionResult } from "../objectExplorer/objectExplorerService";
8181
import { SqlCodeLensProvider } from "../queryResult/sqlCodeLensProvider";
8282
import { ConnectionSharingService } from "../connectionSharing/connectionSharingService";
8383
import { SqlNotebookController } from "../notebooks/sqlNotebookController";
84+
import { registerNotebookCopyOutput } from "../notebooks/notebookCopyOutputProvider";
8485
import { ConnectTool } from "../copilot/tools/connectTool";
8586
import { ListServersTool } from "../copilot/tools/listServersTool";
8687
import { DisconnectTool } from "../copilot/tools/disconnectTool";
@@ -716,6 +717,8 @@ export default class MainController implements vscode.Disposable {
716717
void this.sqlNotebookController.changeConnectionInteractive();
717718
});
718719

720+
registerNotebookCopyOutput(this._context);
721+
719722
const providerInstance = new this.ExecutionPlanCustomEditorProvider(
720723
this._context,
721724
this._vscodeWrapper,
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import * as os from "os";
7+
import * as vscode from "vscode";
8+
import * as Constants from "../constants/constants";
9+
import * as LocalizedConstants from "../constants/locConstants";
10+
import type {
11+
NotebookQueryResultBlock,
12+
NotebookQueryResultOutputData,
13+
} from "../sharedInterfaces/notebookQueryResult";
14+
15+
const MIME_TEXT_PLAIN = "text/plain";
16+
const MIME_STDERR = "application/vnd.code.notebook.stderr";
17+
const MIME_NOTEBOOK_QUERY_RESULT = "application/vnd.mssql.query-result";
18+
19+
/**
20+
* Registers a "Copy messages" status bar item for SQL notebook cells.
21+
* Reads raw output from the cell model to avoid virtualization issues (#21378).
22+
* For rich outputs (result grids), copies only text/error blocks.
23+
*/
24+
export function registerNotebookCopyOutput(context: vscode.ExtensionContext): void {
25+
context.subscriptions.push(
26+
vscode.commands.registerCommand(
27+
Constants.cmdNotebooksCopyCellMessages,
28+
async (cell: vscode.NotebookCell | undefined) => {
29+
if (!cell) {
30+
return;
31+
}
32+
const text = collectTextOutput(cell);
33+
if (!text) {
34+
return;
35+
}
36+
await vscode.env.clipboard.writeText(text);
37+
vscode.window.setStatusBarMessage(
38+
LocalizedConstants.Notebooks.copiedMessages,
39+
2000,
40+
);
41+
},
42+
),
43+
);
44+
45+
context.subscriptions.push(
46+
vscode.notebooks.registerNotebookCellStatusBarItemProvider("jupyter-notebook", {
47+
provideCellStatusBarItems(cell) {
48+
if (cell.document.languageId !== "sql") {
49+
return;
50+
}
51+
if (!cell.outputs.some(isCopyableTextOutput)) {
52+
return;
53+
}
54+
const item = new vscode.NotebookCellStatusBarItem(
55+
`$(copy) ${LocalizedConstants.Notebooks.copyMessages}`,
56+
vscode.NotebookCellStatusBarAlignment.Right,
57+
);
58+
item.command = {
59+
command: Constants.cmdNotebooksCopyCellMessages,
60+
title: LocalizedConstants.Notebooks.copyMessages,
61+
arguments: [cell],
62+
};
63+
item.tooltip = LocalizedConstants.Notebooks.copyMessagesTooltip;
64+
return item;
65+
},
66+
}),
67+
);
68+
}
69+
70+
function isCopyableTextOutput(output: vscode.NotebookCellOutput): boolean {
71+
if (output.items.length === 0) {
72+
return false;
73+
}
74+
const rich = findRichItem(output);
75+
if (rich) {
76+
return hasNonResultSetBlock(rich);
77+
}
78+
return output.items.some((item) => item.mime === MIME_TEXT_PLAIN || item.mime === MIME_STDERR);
79+
}
80+
81+
/**
82+
* Checks whether a rich output contains any non-resultSet blocks (text/error)
83+
* without deserializing the full JSON payload. This avoids the cost of
84+
* materializing large resultSet row arrays during frequent status-bar updates.
85+
*/
86+
function hasNonResultSetBlock(item: vscode.NotebookCellOutputItem): boolean {
87+
const raw = Buffer.from(item.data).toString("utf8");
88+
return /\"type\"\s*:\s*\"(?:text|error)\"/.test(raw);
89+
}
90+
91+
function collectTextOutput(cell: vscode.NotebookCell): string {
92+
const chunks: string[] = [];
93+
for (const output of cell.outputs) {
94+
const rich = findRichItem(output);
95+
if (rich) {
96+
chunks.push(...extractRichMessageText(rich));
97+
continue;
98+
}
99+
for (const item of output.items) {
100+
if (item.mime === MIME_TEXT_PLAIN || item.mime === MIME_STDERR) {
101+
chunks.push(Buffer.from(item.data).toString("utf8"));
102+
}
103+
}
104+
}
105+
return chunks.join(os.EOL);
106+
}
107+
108+
function findRichItem(
109+
output: vscode.NotebookCellOutput,
110+
): vscode.NotebookCellOutputItem | undefined {
111+
return output.items.find((item) => item.mime === MIME_NOTEBOOK_QUERY_RESULT);
112+
}
113+
114+
function extractRichMessageText(item: vscode.NotebookCellOutputItem): string[] {
115+
let data: NotebookQueryResultOutputData;
116+
try {
117+
data = JSON.parse(Buffer.from(item.data).toString("utf8"));
118+
} catch {
119+
return [];
120+
}
121+
if (!data || !Array.isArray(data.blocks)) {
122+
return [];
123+
}
124+
return data.blocks
125+
.filter(
126+
(block): block is Exclude<NotebookQueryResultBlock, { type: "resultSet" }> =>
127+
block.type !== "resultSet",
128+
)
129+
.map((block) => block.text);
130+
}

0 commit comments

Comments
 (0)