diff --git a/client/src/components/notebook/Controller.ts b/client/src/components/notebook/Controller.ts index c23bab5b7..72ad68d04 100644 --- a/client/src/components/notebook/Controller.ts +++ b/client/src/components/notebook/Controller.ts @@ -6,14 +6,16 @@ import { getSession } from "../../connection"; import { SASCodeDocument } from "../utils/SASCodeDocument"; import { getCodeDocumentConstructionParameters } from "../utils/SASCodeDocumentHelper"; import { Deferred, deferred } from "../utils/deferred"; +import { NotebookMagicLanguageSwitcher } from "./NotebookMagicLanguageSwitcher"; export class NotebookController { readonly controllerId = "sas-notebook-controller-id"; readonly notebookType = "sas-notebook"; readonly label = "SAS Notebook"; - readonly supportedLanguages = ["sas", "sql", "python"]; + readonly supportedLanguages = ["sas", "sql", "python", "markdown"]; private readonly _controller: vscode.NotebookController; + private readonly _magicLanguageSwitcher: NotebookMagicLanguageSwitcher; private _executionOrder = 0; private _interrupted: Deferred | undefined; @@ -28,10 +30,12 @@ export class NotebookController { this._controller.supportsExecutionOrder = true; this._controller.executeHandler = this._execute.bind(this); this._controller.interruptHandler = this._interrupt.bind(this); + this._magicLanguageSwitcher = new NotebookMagicLanguageSwitcher(); } dispose(): void { this._controller.dispose(); + this._magicLanguageSwitcher.dispose(); } private async _execute(cells: vscode.NotebookCell[]): Promise { diff --git a/client/src/components/notebook/MagicCommandProcessor.ts b/client/src/components/notebook/MagicCommandProcessor.ts new file mode 100644 index 000000000..5de0091cd --- /dev/null +++ b/client/src/components/notebook/MagicCommandProcessor.ts @@ -0,0 +1,58 @@ +export interface MagicCommand { + command: string; + language: string; + aliases?: string[]; +} + +export const MAGIC_COMMANDS: MagicCommand[] = [ + { command: "%sas", language: "sas", aliases: ["%s"] }, + { command: "%sql", language: "sql", aliases: ["%q"] }, + { command: "%python", language: "python", aliases: ["%py", "%p"] }, + { command: "%markdown", language: "markdown", aliases: ["%md", "%m"] }, +]; + +export interface MagicProcessResult { + language: string; + code: string; + hasMagic: boolean; +} + +export class MagicCommandProcessor { + public static process( + content: string, + defaultLanguage: string, + ): MagicProcessResult { + const lines = content.split("\n"); + + const firstLine = lines[0]?.trim(); + if (!firstLine || !firstLine.startsWith("%")) { + return { + language: defaultLanguage, + code: content, + hasMagic: false, + }; + } + const magicCommand = firstLine.split(/\s+/)[0].toLowerCase(); + + const matchedCommand = MAGIC_COMMANDS.find( + (cmd) => + cmd.command === magicCommand || cmd.aliases?.includes(magicCommand), + ); + + if (!matchedCommand) { + return { + language: defaultLanguage, + code: content, + hasMagic: false, + }; + } + + const remainingContent = lines.slice(1).join("\n"); + + return { + language: matchedCommand.language, + code: remainingContent, + hasMagic: true, + }; + } +} diff --git a/client/src/components/notebook/NotebookMagicLanguageSwitcher.ts b/client/src/components/notebook/NotebookMagicLanguageSwitcher.ts new file mode 100644 index 000000000..36bdfd008 --- /dev/null +++ b/client/src/components/notebook/NotebookMagicLanguageSwitcher.ts @@ -0,0 +1,117 @@ +import * as vscode from "vscode"; + +import { MagicCommandProcessor } from "./MagicCommandProcessor"; + +export class NotebookMagicLanguageSwitcher { + private _disposables: vscode.Disposable[] = []; + private _cellChangeMap = new Map(); + + constructor() { + this._disposables.push( + vscode.workspace.onDidChangeTextDocument( + this._onDidChangeTextDocument, + this, + ), + ); + } + + dispose(): void { + this._disposables.forEach((d) => d.dispose()); + this._cellChangeMap.forEach((timeout) => clearTimeout(timeout)); + this._cellChangeMap.clear(); + } + + private _onDidChangeTextDocument( + event: vscode.TextDocumentChangeEvent, + ): void { + if (event.document.uri.scheme !== "vscode-notebook-cell") { + return; + } + + const notebook = vscode.workspace.notebookDocuments.find((nb) => + nb.getCells().some((cell) => cell.document === event.document), + ); + + if (!notebook) { + return; + } + + const cell = notebook.getCells().find((c) => c.document === event.document); + if (!cell) { + return; + } + + // Debouncing so changes don't rapidly happen. + const cellId = cell.document.uri.toString(); + const existingTimeout = this._cellChangeMap.get(cellId); + if (existingTimeout) { + clearTimeout(existingTimeout); + } + + const timeout = setTimeout(() => { + this._checkAndSwitchLanguage(cell); + this._cellChangeMap.delete(cellId); + }, 500); + + this._cellChangeMap.set(cellId, timeout); + } + + private async _checkAndSwitchLanguage( + cell: vscode.NotebookCell, + ): Promise { + const content = cell.document.getText(); + const currentLanguage = cell.document.languageId; + + const magicResult = MagicCommandProcessor.process(content, currentLanguage); + + if (!magicResult.hasMagic || magicResult.language === currentLanguage) { + return; + } + + const cellKind = + magicResult.language === "markdown" + ? vscode.NotebookCellKind.Markup + : vscode.NotebookCellKind.Code; + + await this._replaceCell( + cell, + magicResult.language, + magicResult.code, + cellKind, + ); + } + + private async _replaceCell( + cell: vscode.NotebookCell, + newLanguage: string, + newCode: string, + cellKind: vscode.NotebookCellKind, + ): Promise { + try { + const notebook = cell.notebook; + const cellIndex = notebook.getCells().indexOf(cell); + + if (cellIndex === -1) { + return; + } + + const newCellData = new vscode.NotebookCellData( + cellKind, + newCode, + newLanguage, + ); + + const edit = new vscode.WorkspaceEdit(); + edit.set(notebook.uri, [ + vscode.NotebookEdit.replaceCells( + new vscode.NotebookRange(cellIndex, cellIndex + 1), + [newCellData], + ), + ]); + + await vscode.workspace.applyEdit(edit); + } catch (error) { + console.error("Cell replacement failed:", error); + } + } +} diff --git a/client/src/components/notebook/exporters/index.ts b/client/src/components/notebook/exporters/index.ts index 536cb3259..d343c99ef 100644 --- a/client/src/components/notebook/exporters/index.ts +++ b/client/src/components/notebook/exporters/index.ts @@ -30,3 +30,34 @@ export const exportNotebook = async (client: LanguageClient) => { workspace.fs.writeFile(uri, Buffer.from(content)); }; + +export const exportNotebookCell = async (client: LanguageClient) => { + const notebook = window.activeNotebookEditor?.notebook; + const activeCell = window.activeNotebookEditor?.selection?.start; + + if (!notebook || activeCell === undefined) { + return; + } + + const cell = notebook.cellAt(activeCell); + if (!cell) { + return; + } + + const uri = await window.showSaveDialog({ + filters: { SAS: ["sas"], HTML: ["html"] }, + defaultUri: Uri.parse( + `${path.basename(notebook.uri.path, ".sasnb")}_cell_${activeCell + 1}`, + ), + }); + + if (!uri) { + return; + } + + const content = uri.path.endsWith(".html") + ? await exportToHTML(notebook, client, activeCell) + : exportToSAS(notebook, activeCell); + + workspace.fs.writeFile(uri, Buffer.from(content)); +}; diff --git a/client/src/components/notebook/exporters/toHTML.ts b/client/src/components/notebook/exporters/toHTML.ts index c8c910eae..0ac6f4673 100644 --- a/client/src/components/notebook/exporters/toHTML.ts +++ b/client/src/components/notebook/exporters/toHTML.ts @@ -32,8 +32,12 @@ hljs.registerLanguage("sql", sql); export const exportToHTML = async ( notebook: NotebookDocument, client: LanguageClient, + cellIndex?: number, ) => { - const cells = notebook.getCells(); + const cells = + cellIndex !== undefined + ? [notebook.cellAt(cellIndex)] + : notebook.getCells(); let template = readFileSync(`${templatesDir}/default.html`).toString(); diff --git a/client/src/components/notebook/exporters/toSAS.ts b/client/src/components/notebook/exporters/toSAS.ts index 9bd136054..e96fecfbe 100644 --- a/client/src/components/notebook/exporters/toSAS.ts +++ b/client/src/components/notebook/exporters/toSAS.ts @@ -2,11 +2,19 @@ // SPDX-License-Identifier: Apache-2.0 import { NotebookCell, NotebookDocument } from "vscode"; -export const exportToSAS = (notebook: NotebookDocument) => - notebook +export const exportToSAS = (notebook: NotebookDocument, cellIndex?: number) => { + if (cellIndex !== undefined) { + // Export single cell + const cell = notebook.cellAt(cellIndex); + return exportCell(cell) + "\n"; + } + + // Export all cells + return notebook .getCells() .map((cell) => exportCell(cell) + "\n") .join("\n"); +}; const exportCell = (cell: NotebookCell) => { const text = cell.document.getText(); diff --git a/client/src/node/extension.ts b/client/src/node/extension.ts index 563aa2f12..9d010c437 100644 --- a/client/src/node/extension.ts +++ b/client/src/node/extension.ts @@ -54,7 +54,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 } from "../components/notebook/exporters"; +import { + exportNotebook, + exportNotebookCell, +} from "../components/notebook/exporters"; import { ConnectionType } from "../components/profile"; import { SasTaskProvider } from "../components/tasks/SasTaskProvider"; import { SAS_TASK_TYPE } from "../components/tasks/SasTasks"; @@ -202,6 +205,9 @@ export function activate(context: ExtensionContext) { commands.registerCommand("SAS.notebook.export", () => exportNotebook(client), ), + commands.registerCommand("SAS.notebook.exportCell", () => + exportNotebookCell(client), + ), tasks.registerTaskProvider(SAS_TASK_TYPE, new SasTaskProvider()), ...sasDiagnostic.getSubscriptions(), commands.registerTextEditorCommand("SAS.toggleLineComment", (editor) => { diff --git a/package.json b/package.json index ce4396757..0fc0cdc17 100644 --- a/package.json +++ b/package.json @@ -825,6 +825,12 @@ "title": "%commands.SAS.notebook.export%", "category": "SAS Notebook" }, + { + "command": "SAS.notebook.exportCell", + "title": "%commands.SAS.notebook.exportCell%", + "category": "SAS Notebook", + "icon": "$(export)" + }, { "command": "SAS.file.new", "shortTitle": "%commands.SAS.file.new.short%", @@ -1097,6 +1103,27 @@ "command": "SAS.notebook.export" } ], + "notebook/cell/execute": [ + { + "command": "SAS.notebook.exportCell", + "when": "notebookType == 'sas-notebook'", + "group": "inline" + } + ], + "notebook/cell/title": [ + { + "command": "SAS.notebook.exportCell", + "when": "notebookType == 'sas-notebook'", + "group": "inline" + } + ], + "notebook/cell/output": [ + { + "command": "SAS.notebook.exportCell", + "when": "notebookType == 'sas-notebook'", + "group": "inline" + } + ], "commandPalette": [ { "when": "editorLangId == sas && !SAS.hideRunMenuItem", diff --git a/package.nls.json b/package.nls.json index e7a288ac9..ccab6ec57 100644 --- a/package.nls.json +++ b/package.nls.json @@ -16,6 +16,7 @@ "commands.SAS.file.new": "New SAS File", "commands.SAS.file.new.short": "SAS File", "commands.SAS.notebook.export": "Export", + "commands.SAS.notebook.exportCell": "Export Cell", "commands.SAS.notebook.new": "New SAS Notebook", "commands.SAS.notebook.new.short": "SAS Notebook", "commands.SAS.refresh": "Refresh",