From 4692945db2d223496a4e827d777fa8fbf86d234d Mon Sep 17 00:00:00 2001 From: Doug Torrance Date: Fri, 29 May 2026 18:10:48 -0400 Subject: [PATCH] Add Emacs-style send-to-REPL commands with keybindings Adds six commands mirroring the Macaulay2 Emacs major mode, each sending a different slice of the editor to the M2 REPL: C-c C-j sendLineToREPL current line (advances cursor) C-c C-r sendRegionToREPL active selection C-c C-b sendBufferToREPL entire document C-c C-up sendAboveToREPL everything from start to cursor C-c C-down sendBelowToREPL everything from cursor to end C-c C-p sendParagraphToREPL current paragraph (advances past it) Paragraph detection follows Emacs forward/backward-paragraph semantics: blank lines are delimiters; cursor on a blank line targets the next paragraph below, falling back to the previous one if only whitespace remains below. The paragraph boundary logic is extracted into getParagraphLineRange, a pure exported function covered by 11 unit tests. All send commands (including the existing sendToWebview/sendToTerminal) briefly highlight the exact range being sent for 300 ms using editor.selectionHighlightBackground. The flash is suppressed when editor.renderLineHighlight is "none" or "gutter". Co-Authored-By: Claude Sonnet 4.6 --- package.json | 24 +++ src/backend/repl.ts | 243 ++++++++++++++++++++++++++--- src/backend/test/extension.test.ts | 96 ++++++++++++ 3 files changed, 342 insertions(+), 21 deletions(-) diff --git a/package.json b/package.json index b618adb..a80c945 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,30 @@ "command": "macaulay2.interruptREPL", "title": "Interrupt M2 Computation" }, + { + "command": "macaulay2.sendLineToREPL", + "title": "Send Line to M2 REPL" + }, + { + "command": "macaulay2.sendRegionToREPL", + "title": "Send Region to M2 REPL" + }, + { + "command": "macaulay2.sendBufferToREPL", + "title": "Send Buffer to M2 REPL" + }, + { + "command": "macaulay2.sendAboveToREPL", + "title": "Send Everything Above Point to M2 REPL" + }, + { + "command": "macaulay2.sendBelowToREPL", + "title": "Send Everything Below Point to M2 REPL" + }, + { + "command": "macaulay2.sendParagraphToREPL", + "title": "Send Paragraph to M2 REPL" + }, { "command": "macaulay2.selectExecutablePath", "title": "Select M2 Executable" diff --git a/src/backend/repl.ts b/src/backend/repl.ts index b19710c..c3ebf6f 100644 --- a/src/backend/repl.ts +++ b/src/backend/repl.ts @@ -32,6 +32,7 @@ const keepWebviewOpenOnProcessClose = new WeakSet(); const closeWebviewOnProcessClose = new WeakSet(); let shouldRestoreEditorFocusAfterWebviewOutput = false; let editorToRestoreAfterWebviewOutput: vscode.TextEditor | undefined; +let g_flashDecoration: vscode.TextEditorDecorationType | undefined; type WebviewCompletionItem = { label: string; @@ -783,25 +784,116 @@ async function executeCodeInTerminal(text: string) { terminal.sendText(text, false); } -function getSelectedM2Code(): string | undefined { - var editor = vscode.window.activeTextEditor; - if (!editor) { - return undefined; +function flashRange(editor: vscode.TextEditor, range: vscode.Range): void { + const lineHighlight = vscode.workspace + .getConfiguration("editor") + .get("renderLineHighlight", "line"); + if (lineHighlight === "none" || lineHighlight === "gutter") { + return; + } + if (!g_flashDecoration) { + g_flashDecoration = vscode.window.createTextEditorDecorationType({ + backgroundColor: new vscode.ThemeColor( + "editor.selectionHighlightBackground", + ), + }); + } + editor.setDecorations(g_flashDecoration, [{ range }]); + setTimeout(() => { + if (g_flashDecoration) editor.setDecorations(g_flashDecoration, []); + }, 300); +} + +export function getParagraphLineRange( + currentLine: number, + lineCount: number, + getLineText: (line: number) => string, +): { startLine: number; endLine: number } | undefined { + const lastLine = lineCount - 1; + + if (getLineText(currentLine).trim() !== "") { + let startLine = currentLine; + while (startLine > 0 && getLineText(startLine - 1).trim() !== "") { + startLine--; + } + let endLine = currentLine; + while (endLine < lastLine && getLineText(endLine + 1).trim() !== "") { + endLine++; + } + return { startLine, endLine }; + } + + // On a blank line: find the next paragraph below. + let below = currentLine + 1; + while (below <= lastLine && getLineText(below).trim() === "") { + below++; + } + if (below <= lastLine) { + let endLine = below; + while (endLine < lastLine && getLineText(endLine + 1).trim() !== "") { + endLine++; + } + return { startLine: below, endLine }; + } + + // All whitespace below: fall back to the previous paragraph above. + let above = currentLine - 1; + while (above >= 0 && getLineText(above).trim() === "") { + above--; + } + if (above >= 0) { + let startLine = above; + while (startLine > 0 && getLineText(startLine - 1).trim() !== "") { + startLine--; + } + return { startLine, endLine: above }; } - var selection = editor.selection; - return selection.isEmpty - ? editor.document.lineAt(selection.start.line).text - : editor.document.getText(selection); + return undefined; } function executeSelectionInTerminal() { - const text = getSelectedM2Code(); - if (text === undefined) { - return; + const editor = vscode.window.activeTextEditor; + if (!editor) return; + const selection = editor.selection; + const range = selection.isEmpty + ? editor.document.lineAt(selection.start.line).range + : new vscode.Range(selection.start, selection.end); + flashRange(editor, range); + executeCodeInTerminal(editor.document.getText(range)); + if (selection.isEmpty) { + vscode.commands.executeCommand("cursorMove", { + to: "down", + by: "line", + value: 1, + }); } +} - executeCodeInTerminal(text); +async function executeSelectionInWebview() { + const editor = vscode.window.activeTextEditor; + if (!editor) return; + const selection = editor.selection; + const range = selection.isEmpty + ? editor.document.lineAt(selection.start.line).range + : new vscode.Range(selection.start, selection.end); + flashRange(editor, range); + await executeCode(editor.document.getText(range), true, true); + if (selection.isEmpty) { + vscode.commands.executeCommand("cursorMove", { + to: "down", + by: "line", + value: 1, + }); + } +} + +async function executeLineInWebview() { + const editor = vscode.window.activeTextEditor; + if (!editor) return; + const line = editor.document.lineAt(editor.selection.active.line); + flashRange(editor, line.range); + await executeCode(line.text, true, true); vscode.commands.executeCommand("cursorMove", { to: "down", by: "line", @@ -809,18 +901,87 @@ function executeSelectionInTerminal() { }); } -async function executeSelectionInWebview() { - const text = getSelectedM2Code(); - if (text === undefined) { +async function executeRegionInWebview() { + const editor = vscode.window.activeTextEditor; + if (!editor) return; + const selection = editor.selection; + if (selection.isEmpty) { + vscode.window.showInformationMessage("No region selected."); return; } + flashRange(editor, selection); + await executeCode(editor.document.getText(selection), true, true); +} - await executeCode(text, true, true); - vscode.commands.executeCommand("cursorMove", { - to: "down", - by: "line", - value: 1, - }); +async function executeBufferInWebview() { + const editor = vscode.window.activeTextEditor; + if (!editor) return; + const document = editor.document; + const lastLine = document.lineCount - 1; + const range = new vscode.Range( + new vscode.Position(0, 0), + new vscode.Position(lastLine, document.lineAt(lastLine).text.length), + ); + flashRange(editor, range); + await executeCode(document.getText(), true, true); +} + +async function executeAboveInWebview() { + const editor = vscode.window.activeTextEditor; + if (!editor) return; + const position = editor.selection.active; + if (position.isEqual(new vscode.Position(0, 0))) return; + const range = new vscode.Range(new vscode.Position(0, 0), position); + flashRange(editor, range); + await executeCode(editor.document.getText(range), true, true); +} + +async function executeBelowInWebview() { + const editor = vscode.window.activeTextEditor; + if (!editor) return; + const position = editor.selection.active; + const lastLine = editor.document.lineCount - 1; + const lastChar = editor.document.lineAt(lastLine).text.length; + const end = new vscode.Position(lastLine, lastChar); + if (position.isEqual(end)) return; + const range = new vscode.Range(position, end); + flashRange(editor, range); + await executeCode(editor.document.getText(range), true, true); +} + +async function executeParagraphInWebview() { + const editor = vscode.window.activeTextEditor; + if (!editor) return; + const document = editor.document; + const currentLine = editor.selection.active.line; + + const lineRange = getParagraphLineRange( + currentLine, + document.lineCount, + (line) => document.lineAt(line).text, + ); + if (!lineRange) return; + + const { startLine, endLine } = lineRange; + const range = new vscode.Range( + new vscode.Position(startLine, 0), + new vscode.Position(endLine, document.lineAt(endLine).text.length), + ); + flashRange(editor, range); + await executeCode(document.getText(range), true, true); + + if (endLine + 1 < document.lineCount) { + editor.selection = new vscode.Selection( + new vscode.Position(endLine + 1, 0), + new vscode.Position(endLine + 1, 0), + ); + } else { + const endChar = document.lineAt(endLine).text.length; + editor.selection = new vscode.Selection( + new vscode.Position(endLine, endChar), + new vscode.Position(endLine, endChar), + ); + } } async function executeFileInWebview(resource?: vscode.Uri) { @@ -2302,6 +2463,42 @@ export function activate( context.subscriptions.push( vscode.commands.registerCommand("macaulay2.interruptREPL", interruptM2), ); + context.subscriptions.push( + vscode.commands.registerCommand( + "macaulay2.sendLineToREPL", + executeLineInWebview, + ), + ); + context.subscriptions.push( + vscode.commands.registerCommand( + "macaulay2.sendRegionToREPL", + executeRegionInWebview, + ), + ); + context.subscriptions.push( + vscode.commands.registerCommand( + "macaulay2.sendBufferToREPL", + executeBufferInWebview, + ), + ); + context.subscriptions.push( + vscode.commands.registerCommand( + "macaulay2.sendAboveToREPL", + executeAboveInWebview, + ), + ); + context.subscriptions.push( + vscode.commands.registerCommand( + "macaulay2.sendBelowToREPL", + executeBelowInWebview, + ), + ); + context.subscriptions.push( + vscode.commands.registerCommand( + "macaulay2.sendParagraphToREPL", + executeParagraphInWebview, + ), + ); registerM2ExecutableSwitcher(context, handleM2ExecutableChanged); context.subscriptions.push( vscode.window.registerTerminalLinkProvider( @@ -2340,4 +2537,8 @@ export function deactivate() { terminalWorkingDir = undefined; terminalSourceSearchRoots = []; } + if (g_flashDecoration) { + g_flashDecoration.dispose(); + g_flashDecoration = undefined; + } } diff --git a/src/backend/test/extension.test.ts b/src/backend/test/extension.test.ts index 75d8507..6e316c7 100644 --- a/src/backend/test/extension.test.ts +++ b/src/backend/test/extension.test.ts @@ -31,6 +31,7 @@ import { getM2StartupPatch, getM2TerminalProcessArgs, getM2WebviewProcessArgs, + getParagraphLineRange, shouldCloseWebviewOnM2Input, } from "../repl"; import { formatMacaulay2Text } from "../formatter"; @@ -647,3 +648,98 @@ suite("Executable Launch", function () { ); }); }); + +suite("Send Paragraph to REPL", function () { + function lines(...ls: string[]) { + return (n: number) => ls[n]; + } + + test("expands from a non-blank line to full paragraph boundaries", function () { + // cursor on line 1 (middle of paragraph) + assert.deepEqual( + getParagraphLineRange(1, 4, lines("x = 1", "y = 2", "", "z = 3")), + { startLine: 0, endLine: 1 }, + ); + }); + + test("expands to the entire paragraph when there are no surrounding blank lines", function () { + assert.deepEqual( + getParagraphLineRange(1, 3, lines("x = 1", "y = 2", "z = 3")), + { startLine: 0, endLine: 2 }, + ); + }); + + test("handles a single non-blank line", function () { + assert.deepEqual( + getParagraphLineRange(0, 1, lines("x = 1")), + { startLine: 0, endLine: 0 }, + ); + }); + + test("on a blank line with a paragraph below, sends that next paragraph", function () { + // cursor on line 2 (blank), paragraph is lines 3-4 + assert.deepEqual( + getParagraphLineRange( + 2, + 5, + lines("x = 1", "y = 2", "", "z = 3", "w = 4"), + ), + { startLine: 3, endLine: 4 }, + ); + }); + + test("skips multiple blank lines to reach the next paragraph", function () { + // cursor on line 1, three blank lines before paragraph at 4-5 + assert.deepEqual( + getParagraphLineRange( + 1, + 6, + lines("x = 1", "", "", "", "y = 2", "z = 3"), + ), + { startLine: 4, endLine: 5 }, + ); + }); + + test("on a blank line at the top of the file, sends the paragraph below", function () { + assert.deepEqual( + getParagraphLineRange(0, 3, lines("", "x = 1", "y = 2")), + { startLine: 1, endLine: 2 }, + ); + }); + + test("on a blank line with only whitespace below, falls back to the previous paragraph", function () { + // cursor on line 2 (blank), line 3 also blank — previous paragraph is 0-1 + assert.deepEqual( + getParagraphLineRange(2, 4, lines("x = 1", "y = 2", "", "")), + { startLine: 0, endLine: 1 }, + ); + }); + + test("on the last line when blank, falls back to the previous paragraph", function () { + assert.deepEqual( + getParagraphLineRange(2, 3, lines("x = 1", "y = 2", "")), + { startLine: 0, endLine: 1 }, + ); + }); + + test("returns undefined for an entirely blank buffer", function () { + assert.equal( + getParagraphLineRange(0, 3, lines("", "", "")), + undefined, + ); + }); + + test("cursor at start of file on non-blank line", function () { + assert.deepEqual( + getParagraphLineRange(0, 4, lines("x = 1", "y = 2", "", "z = 3")), + { startLine: 0, endLine: 1 }, + ); + }); + + test("cursor at last non-blank line", function () { + assert.deepEqual( + getParagraphLineRange(2, 3, lines("x = 1", "", "z = 3")), + { startLine: 2, endLine: 2 }, + ); + }); +});