Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 22 additions & 75 deletions client/src/components/notebook/exporters/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it looks like this one is global. So if I just save another notebook, the notebook name is different but the count simply added which may look weird.
In my opinion it's not necessary to manage a count. If a name duplicated, system will prompt the user to change a name, right?


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(
Expand All @@ -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));
};
82 changes: 80 additions & 2 deletions client/src/components/notebook/renderers/HTMLRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
// SPDX-License-Identifier: Apache-2.0
import type { ActivationFunction } from "vscode-notebook-renderer";

let outputIndex = 0;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks like this one is global. So if I switch between multiple notebooks the output index will be wrong?


/**
* Replace the last occurrence of a substring
*/
Expand All @@ -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";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i18n?

saveButton.setAttribute("aria-label", "Save Output");
saveButton.innerHTML = `
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.012 2H2.5l-.5.5v11l.5.5h11l.5-.5V5l-4-3h-.488zM3 13V3h6v2.5l.5.5h3v7H3zm7-9v2h2l-2-2z"/>
<path d="M5 7h6v1H5V7zm0 2h6v1H5V9z"/>
</svg>
`;
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("<body ", "<div "),
"</body>",
"</div>",
);
container.appendChild(contentDiv);
shadow.replaceChildren(container);
},
});
81 changes: 78 additions & 3 deletions client/src/components/notebook/renderers/LogRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are you able to re-use some code with HTMLRenderer?

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 = `
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.012 2H2.5l-.5.5v11l.5.5h11l.5-.5V5l-4-3h-.488zM3 13V3h6v2.5l.5.5h3v7H3zm7-9v2h2l-2-2z"/>
<path d="M5 7h6v1H5V7zm0 2h6v1H5V9z"/>
</svg>
`;
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");
Expand All @@ -26,6 +100,7 @@ export const activate: ActivationFunction = () => ({
}
root.append(div);
}
element.replaceChildren(root);
container.appendChild(root);
element.replaceChildren(container);
},
});
26 changes: 24 additions & 2 deletions client/src/node/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
commands,
l10n,
languages,
notebooks,
tasks,
window,
workspace,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -202,14 +206,32 @@ 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) => {
toggleLineComment(editor, client);
}),
);

// 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
Expand Down
Loading
Loading