diff --git a/client/src/components/notebook/exporters/index.ts b/client/src/components/notebook/exporters/index.ts index e09cf463a..1153e7c8f 100644 --- a/client/src/components/notebook/exporters/index.ts +++ b/client/src/components/notebook/exporters/index.ts @@ -1,6 +1,6 @@ // Copyright © 2025, SAS Institute Inc., Cary, NC, USA. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { Uri, l10n, window, workspace } from "vscode"; +import { NotebookDocument, Uri, l10n, window, workspace } from "vscode"; import type { LanguageClient } from "vscode-languageclient/node"; import path from "path"; @@ -31,86 +31,32 @@ export const exportNotebook = async (client: LanguageClient) => { workspace.fs.writeFile(uri, Buffer.from(content)); }; -export const saveOutput = async () => { - const notebook = window.activeNotebookEditor?.notebook; - const activeCell = window.activeNotebookEditor?.selection?.start; - - if (!notebook || activeCell === undefined) { - return; - } - - const cell = notebook.cellAt(activeCell); - if (!cell) { - return; - } +let timesOutputSaved = 0; - let odsItem = null; - let logItem = null; - - for (const output of cell.outputs) { - if (!odsItem) { - odsItem = output.items.find( - (item) => item.mime === "application/vnd.sas.ods.html5", - ); - } - if (!logItem) { - logItem = output.items.find( - (item) => item.mime === "application/vnd.sas.compute.log.lines", - ); - } - - if (odsItem && logItem) { - break; - } - } - - const choices: Array<{ - label: string; +export const saveOutputFromRenderer = async ( + message: { outputType: "html" | "log"; - }> = []; - - if (odsItem) { - choices.push({ - label: l10n.t("Save ODS HTML"), - outputType: "html", - }); - } - - if (logItem) { - choices.push({ - label: l10n.t("Save Log"), - outputType: "log", - }); - } - - const exportChoice = await window.showQuickPick(choices, { - placeHolder: l10n.t("Choose output type to save"), - ignoreFocusOut: true, - }); - - if (!exportChoice) { - return; - } - - let content = ""; + content: unknown; + mime: string; + cellIndex?: number; + }, + notebook: NotebookDocument, +) => { + const { outputType, content } = message; + + let fileContent = ""; let fileExtension = ""; let fileName = ""; + try { - if (exportChoice.outputType === "html" && odsItem) { - content = odsItem.data.toString(); + if (outputType === "html" && typeof content === "string") { + fileContent = content; fileExtension = "html"; - fileName = `${path.basename(notebook.uri.path, ".sasnb")}_${l10n.t("output")}_${ - activeCell + 1 - }.html`; - } else if (exportChoice.outputType === "log" && logItem) { - const logs: Array<{ line: string; type: string }> = JSON.parse( - logItem.data.toString(), - ); - content = logs.map((log) => log.line).join("\n"); + fileName = `${path.basename(notebook.uri.path, ".sasnb")}_${l10n.t("output")}_${timesOutputSaved + 1}.html`; + } else if (outputType === "log" && Array.isArray(content)) { + fileContent = content.map((log: { line: string }) => log.line).join("\n"); fileExtension = "log"; - fileName = `${path.basename(notebook.uri.path, ".sasnb")}_${l10n.t("output")}_${ - activeCell + 1 - }.log`; + fileName = `${path.basename(notebook.uri.path, ".sasnb")}_${l10n.t("output")}_${timesOutputSaved + 1}.log`; } } catch (error) { window.showErrorMessage( @@ -131,7 +77,8 @@ export const saveOutput = async () => { return; } - await workspace.fs.writeFile(uri, Buffer.from(content)); + await workspace.fs.writeFile(uri, Buffer.from(fileContent)); + timesOutputSaved++; window.showInformationMessage(l10n.t("Saved to {0}", uri.fsPath)); }; diff --git a/client/src/components/notebook/renderers/HTMLRenderer.ts b/client/src/components/notebook/renderers/HTMLRenderer.ts index 3fde97365..44d9f7c93 100644 --- a/client/src/components/notebook/renderers/HTMLRenderer.ts +++ b/client/src/components/notebook/renderers/HTMLRenderer.ts @@ -2,6 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import type { ActivationFunction } from "vscode-notebook-renderer"; +let outputIndex = 0; + /** * Replace the last occurrence of a substring */ @@ -19,18 +21,94 @@ function replaceLast( ); } -export const activate: ActivationFunction = () => ({ +export const activate: ActivationFunction = (context) => ({ renderOutputItem(data, element) { const html = data.text(); + const currentIndex = outputIndex++; + let shadow = element.shadowRoot; if (!shadow) { shadow = element.attachShadow({ mode: "open" }); } - shadow.innerHTML = replaceLast( + + const container = document.createElement("div"); + container.style.position = "relative"; + + if (context.postMessage) { + const toolbar = document.createElement("div"); + toolbar.style.cssText = ` + position: absolute; + top: -22px; + right: 8px; + display: flex; + gap: 4px; + opacity: 0; + transition: opacity 0.1s ease; + background: var(--vscode-editor-background); + border: 1px solid var(--vscode-widget-border); + border-radius: 4px; + padding: 2px; + z-index: 1000; + `; + + const saveButton = document.createElement("button"); + saveButton.title = "Save Output"; + saveButton.setAttribute("aria-label", "Save Output"); + saveButton.innerHTML = ` + + + + + `; + saveButton.style.cssText = ` + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + background: transparent; + border: none; + color: var(--vscode-icon-foreground); + cursor: pointer; + border-radius: 3px; + `; + + saveButton.onmouseover = () => { + saveButton.style.background = "var(--vscode-toolbar-hoverBackground)"; + }; + saveButton.onmouseout = () => { + saveButton.style.background = "transparent"; + }; + + saveButton.onclick = () => { + context.postMessage({ + command: "saveOutput", + outputType: "html", + content: html, + mime: data.mime, + cellIndex: currentIndex, + }); + }; + + toolbar.appendChild(saveButton); + container.onmouseenter = () => { + toolbar.style.opacity = "1"; + }; + container.onmouseleave = () => { + toolbar.style.opacity = "0"; + }; + + container.appendChild(toolbar); + } // Add the HTML content + const contentDiv = document.createElement("div"); + contentDiv.innerHTML = replaceLast( // it's not a whole webview, body not allowed html.replace("", "", ); + container.appendChild(contentDiv); + shadow.replaceChildren(container); }, }); diff --git a/client/src/components/notebook/renderers/LogRenderer.ts b/client/src/components/notebook/renderers/LogRenderer.ts index 0e9b3445f..355728904 100644 --- a/client/src/components/notebook/renderers/LogRenderer.ts +++ b/client/src/components/notebook/renderers/LogRenderer.ts @@ -4,19 +4,93 @@ import type { ActivationFunction } from "vscode-notebook-renderer"; import type { LogLine } from "../../../connection"; +let outputIndex = 0; + const colorMap = { error: "var(--vscode-editorError-foreground)", warning: "var(--vscode-editorWarning-foreground)", note: "var(--vscode-editorInfo-foreground)", }; -export const activate: ActivationFunction = () => ({ +export const activate: ActivationFunction = (context) => ({ renderOutputItem(data, element) { + const logs: LogLine[] = data.json(); + const currentIndex = outputIndex++; + + const container = document.createElement("div"); + container.style.position = "relative"; + + if (context.postMessage) { + const toolbar = document.createElement("div"); + toolbar.style.cssText = ` + position: absolute; + top: -10px; + right: 8px; + display: flex; + gap: 4px; + opacity: 0; + transition: opacity 0.1s ease; + background: var(--vscode-editor-background); + border: 1px solid var(--vscode-widget-border); + border-radius: 4px; + padding: 2px; + z-index: 1000; + `; + + const saveButton = document.createElement("button"); + saveButton.title = "Save Output"; + saveButton.setAttribute("aria-label", "Save Output"); + saveButton.innerHTML = ` + + + + + `; + saveButton.style.cssText = ` + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + background: transparent; + border: none; + color: var(--vscode-icon-foreground); + cursor: pointer; + border-radius: 3px; + `; + + saveButton.onmouseover = () => { + saveButton.style.background = "var(--vscode-toolbar-hoverBackground)"; + }; + saveButton.onmouseout = () => { + saveButton.style.background = "transparent"; + }; + + saveButton.onclick = () => { + context.postMessage({ + command: "saveOutput", + outputType: "log", + content: logs, + mime: data.mime, + cellIndex: currentIndex, + }); + }; + + toolbar.appendChild(saveButton); + container.onmouseenter = () => { + toolbar.style.opacity = "1"; + }; + container.onmouseleave = () => { + toolbar.style.opacity = "0"; + }; + + container.appendChild(toolbar); + } const root = document.createElement("div"); root.style.whiteSpace = "pre"; root.style.fontFamily = "var(--vscode-editor-font-family)"; - const logs: LogLine[] = data.json(); for (const line of logs) { const color = colorMap[line.type]; const div = document.createElement("div"); @@ -26,6 +100,7 @@ export const activate: ActivationFunction = () => ({ } root.append(div); } - element.replaceChildren(root); + container.appendChild(root); + element.replaceChildren(container); }, }); diff --git a/client/src/node/extension.ts b/client/src/node/extension.ts index c50becba0..76cb82476 100644 --- a/client/src/node/extension.ts +++ b/client/src/node/extension.ts @@ -8,6 +8,7 @@ import { commands, l10n, languages, + notebooks, tasks, window, workspace, @@ -54,7 +55,10 @@ import { LogTokensProvider, legend } from "../components/logViewer"; import { sasDiagnostic } from "../components/logViewer/sasDiagnostics"; import { NotebookController } from "../components/notebook/Controller"; import { NotebookSerializer } from "../components/notebook/Serializer"; -import { exportNotebook, saveOutput } from "../components/notebook/exporters"; +import { + exportNotebook, + saveOutputFromRenderer, +} from "../components/notebook/exporters"; import { ConnectionType } from "../components/profile"; import { SasTaskProvider } from "../components/tasks/SasTaskProvider"; import { SAS_TASK_TYPE } from "../components/tasks/SasTasks"; @@ -202,7 +206,6 @@ export function activate(context: ExtensionContext) { commands.registerCommand("SAS.notebook.export", () => exportNotebook(client), ), - commands.registerCommand("SAS.notebook.saveOutput", saveOutput), tasks.registerTaskProvider(SAS_TASK_TYPE, new SasTaskProvider()), ...sasDiagnostic.getSubscriptions(), commands.registerTextEditorCommand("SAS.toggleLineComment", (editor) => { @@ -210,6 +213,25 @@ export function activate(context: ExtensionContext) { }), ); + // Set up message handlers for notebook renderers + const htmlRendererMessaging = + notebooks.createRendererMessaging("sas-html-renderer"); + const logRendererMessaging = + notebooks.createRendererMessaging("sas-log-renderer"); + + context.subscriptions.push( + htmlRendererMessaging.onDidReceiveMessage((e) => { + if (e.message.command === "saveOutput") { + saveOutputFromRenderer(e.message, e.editor.notebook); + } + }), + logRendererMessaging.onDidReceiveMessage((e) => { + if (e.message.command === "saveOutput") { + saveOutputFromRenderer(e.message, e.editor.notebook); + } + }), + ); + // Reset first to set "No Active Profiles" resetStatusBarItem(); // Update status bar if profile is found diff --git a/package.json b/package.json index 75651447b..78c47b34e 100644 --- a/package.json +++ b/package.json @@ -848,11 +848,6 @@ "title": "%commands.SAS.notebook.export%", "category": "SAS Notebook" }, - { - "command": "SAS.notebook.saveOutput", - "title": "%commands.SAS.notebook.saveOutput%", - "category": "SAS Notebook" - }, { "command": "SAS.file.new", "shortTitle": "%commands.SAS.file.new.short%", @@ -1125,12 +1120,6 @@ "command": "SAS.notebook.export" } ], - "notebook/cell/title": [ - { - "command": "SAS.notebook.saveOutput", - "when": "notebookType == 'sas-notebook' && notebookCellHasOutputs" - } - ], "commandPalette": [ { "when": "editorLangId == sas && !SAS.hideRunMenuItem", @@ -1376,7 +1365,8 @@ "entrypoint": "./client/dist/notebook/LogRenderer.js", "mimeTypes": [ "application/vnd.sas.compute.log.lines" - ] + ], + "requiresMessaging": "optional" }, { "id": "sas-html-renderer", @@ -1384,7 +1374,8 @@ "entrypoint": "./client/dist/notebook/HTMLRenderer.js", "mimeTypes": [ "application/vnd.sas.ods.html5" - ] + ], + "requiresMessaging": "optional" } ] }, diff --git a/package.nls.json b/package.nls.json index 78ebdaf8c..996da0f2f 100644 --- a/package.nls.json +++ b/package.nls.json @@ -18,7 +18,6 @@ "commands.SAS.notebook.export": "Export", "commands.SAS.notebook.new": "New SAS Notebook", "commands.SAS.notebook.new.short": "SAS Notebook", - "commands.SAS.notebook.saveOutput": "Save Output", "commands.SAS.refresh": "Refresh", "commands.SAS.removeFromFavorites": "Remove from My Favorites", "commands.SAS.renameResource": "Rename...",