From 9013ee1944a826739ddc73a8a84519ee4b719e24 Mon Sep 17 00:00:00 2001 From: elliot Date: Wed, 12 Aug 2026 14:49:31 -0400 Subject: [PATCH 1/3] make it so "insert code cell" splits code cell with cursor inside --- apps/vscode/src/providers/insert.ts | 117 ++++++++++++++++++++- apps/vscode/src/test/insert.test.ts | 155 ++++++++++++++++++++++++++++ 2 files changed, 268 insertions(+), 4 deletions(-) diff --git a/apps/vscode/src/providers/insert.ts b/apps/vscode/src/providers/insert.ts index 6266ff89..f19cc6fc 100644 --- a/apps/vscode/src/providers/insert.ts +++ b/apps/vscode/src/providers/insert.ts @@ -18,11 +18,13 @@ import { window, Range, Position, + Selection, + TextEditor, } from "vscode"; import { Command } from "../core/command"; import { isQuartoDoc } from "../core/doc"; import { MarkdownEngine } from "../markdown/engine"; -import { isExecutableLanguageBlock, languageBlockAtPosition, languageNameFromBlock } from "quarto-core"; +import { Token, isExecutableLanguageBlock, languageBlockAtPosition, languageNameFromBlock } from "quarto-core"; import { tryAcquirePositronApi } from "@posit-dev/positron"; @@ -37,19 +39,26 @@ class InsertCodeCellCommand implements Command { async execute(): Promise { if (window.activeTextEditor) { - const doc = window.activeTextEditor?.document; + const editor = window.activeTextEditor; + const doc = editor.document; if (doc && isQuartoDoc(doc)) { // determine most recently used language engien above the cursor const tokens = this.engine_.parse(doc); - const cursorLine = window.activeTextEditor?.selection.active.line; + const cursorLine = editor.selection.active.line; let language = ""; let insertTopPaddingLine = false; const pos = new Position(cursorLine, 0); const block = languageBlockAtPosition(tokens, pos, true); if (block) { - // cursor is in an executable block + // cursor is in an executable block: split it into two cells at the + // cursor (or three around the selection), like RStudio does + if (await splitCodeCell(editor, block)) { + return; + } + // block isn't a backtick code fence (e.g. display math), so + // insert a new cell below it language = languageNameFromBlock(block); insertTopPaddingLine = true; const moveDown = block.range.end.line - cursorLine + 1; @@ -132,3 +141,103 @@ class InsertCodeCellCommand implements Command { } } } + +// split the code cell containing the cursor into two cells at the cursor line +// (with a selection, into three cells: before / selection / after), mirroring +// RStudio's insert chunk behavior. returns false if the block isn't a backtick +// code fence (e.g. display math) and so can't be split +async function splitCodeCell(editor: TextEditor, block: Token): Promise { + const doc = editor.document; + const headerLine = block.range.start.line; + const header = doc.lineAt(headerLine).text; + const fenceMatch = header.match(/^(`{3,})\s*\{/); + if (!fenceMatch) { + return false; + } + const fence = fenceMatch[1]; + + // locate the closing fence: the parsed range can end one line past it (when + // the next line has content) or the block may be unclosed at end of document + const closingFenceRegex = new RegExp("^ {0,3}`{" + fence.length + ",}\\s*$"); + let footerLine = Math.min(block.range.end.line, doc.lineCount - 1); + while (footerLine > headerLine && !closingFenceRegex.test(doc.lineAt(footerLine).text)) { + footerLine--; + } + const closed = footerLine > headerLine; + const blockEndLine = closed ? footerLine : Math.min(block.range.end.line, doc.lineCount - 1); + if (blockEndLine <= headerLine) { + // header-only block with no body to split + return false; + } + const bodyStart = new Position(headerLine + 1, 0); + const bodyEnd = closed + ? new Position(footerLine, 0) + : new Position(blockEndLine, doc.lineAt(blockEndLine).text.length); + + // determine the split point(s): the cursor line with no selection, otherwise + // the selection boundaries (clamped to the cell body) + const clamp = (p: Position) => + p.isBefore(bodyStart) ? bodyStart : p.isAfter(bodyEnd) ? bodyEnd : p; + const selection = editor.selection; + let splitStart: Position; + let splitEnd: Position; + if (selection.isEmpty) { + splitStart = splitEnd = clamp(new Position(selection.active.line, 0)); + } else { + splitStart = clamp(selection.start); + splitEnd = clamp(selection.end); + } + + // cell bodies, with surrounding blank lines removed (but indentation kept) + const cellBody = (range: Range) => { + const text = doc + .getText(range) + .replace(/^([ \t]*\n)+/, "") + .replace(/(\n[ \t]*)+$/, ""); + return text.trim() ? text : ""; + }; + const before = cellBody(new Range(bodyStart, splitStart)); + const middle = cellBody(new Range(splitStart, splitEnd)); + const after = cellBody(new Range(splitEnd, bodyEnd)); + + // assemble the new cells and pick the one that should receive the cursor + const bodies: string[] = []; + let cursorCell: number; + if (splitStart.isEqual(splitEnd)) { + bodies.push(before, after); + // when everything ends up in the second cell the first (empty) cell is + // the new one, so put the cursor there + cursorCell = !before && after ? 0 : 1; + } else { + if (before) { + bodies.push(before); + } + cursorCell = bodies.length; + bodies.push(middle); + if (after) { + bodies.push(after); + } + } + + // render the cells (empty cells get a blank line for the cursor to land on) + const cellText = (body: string) => header + "\n" + body + "\n" + fence; + const newText = bodies.map(cellText).join("\n\n"); + + // cursor goes to the first body line of the target cell + let cursorLine = headerLine + 1; + for (let i = 0; i < cursorCell; i++) { + cursorLine += (bodies[i] ? bodies[i].split("\n").length : 1) + 3; + } + + const replaceRange = new Range( + new Position(headerLine, 0), + closed ? new Position(footerLine, doc.lineAt(footerLine).text.length) : bodyEnd + ); + const applied = await editor.edit((edit) => edit.replace(replaceRange, newText)); + if (applied) { + const cursor = new Position(cursorLine, 0); + editor.selection = new Selection(cursor, cursor); + editor.revealRange(new Range(cursor, cursor)); + } + return true; +} diff --git a/apps/vscode/src/test/insert.test.ts b/apps/vscode/src/test/insert.test.ts index c5fdf47b..c16258f0 100644 --- a/apps/vscode/src/test/insert.test.ts +++ b/apps/vscode/src/test/insert.test.ts @@ -75,6 +75,153 @@ suite("Insert Code Cell", function () { }); }); + suite("Splitting the cell at the cursor", function () { + const PYTHON_CELL_DOC = [ + "---", + "title: Test", + "---", + "", + "```{python}", // line 4 + "1 + 1", // line 5 + "", // line 6 + "2 + 2", // line 7 + "```", // line 8 + "", + ].join("\n"); + + test("Splits the cell in two when cursor is between statements", async function () { + const { doc, editor } = await openTestDocument("insert-test-split.qmd", PYTHON_CELL_DOC); + + editor.selection = new vscode.Selection(6, 0, 6, 0); + await vscode.commands.executeCommand("quarto.insertCodeCell"); + + assert.strictEqual( + doc.getText(), + [ + "---", + "title: Test", + "---", + "", + "```{python}", + "1 + 1", + "```", + "", + "```{python}", + "2 + 2", + "```", + "", + ].join("\n") + ); + // cursor lands at the start of the second cell's body + assert.deepStrictEqual( + [editor.selection.active.line, editor.selection.active.character], + [9, 0] + ); + }); + + test("Inserts an empty cell above when cursor is on the first line of the cell body", async function () { + const { doc, editor } = await openTestDocument("insert-test-split-above.qmd", PYTHON_CELL_DOC); + + editor.selection = new vscode.Selection(5, 0, 5, 0); + await vscode.commands.executeCommand("quarto.insertCodeCell"); + + assert.strictEqual( + doc.getText(), + [ + "---", + "title: Test", + "---", + "", + "```{python}", + "", + "```", + "", + "```{python}", + "1 + 1", + "", + "2 + 2", + "```", + "", + ].join("\n") + ); + // cursor lands in the new empty cell + assert.deepStrictEqual( + [editor.selection.active.line, editor.selection.active.character], + [5, 0] + ); + }); + + test("Inserts an empty cell below when cursor is on the closing fence", async function () { + const { doc, editor } = await openTestDocument("insert-test-split-below.qmd", PYTHON_CELL_DOC); + + editor.selection = new vscode.Selection(8, 0, 8, 0); + await vscode.commands.executeCommand("quarto.insertCodeCell"); + + assert.strictEqual( + doc.getText(), + [ + "---", + "title: Test", + "---", + "", + "```{python}", + "1 + 1", + "", + "2 + 2", + "```", + "", + "```{python}", + "", + "```", + "", + ].join("\n") + ); + // cursor lands in the new empty cell + assert.deepStrictEqual( + [editor.selection.active.line, editor.selection.active.character], + [11, 0] + ); + }); + + test("Splits the cell in three around a selection", async function () { + const content = [ + "```{python}", // line 0 + "a = 1", // line 1 + "b = 2", // line 2 + "c = 3", // line 3 + "```", // line 4 + "", + ].join("\n"); + const { doc, editor } = await openTestDocument("insert-test-split-selection.qmd", content); + + editor.selection = new vscode.Selection(2, 0, 2, 5); + await vscode.commands.executeCommand("quarto.insertCodeCell"); + + assert.strictEqual( + doc.getText(), + [ + "```{python}", + "a = 1", + "```", + "", + "```{python}", + "b = 2", + "```", + "", + "```{python}", + "c = 3", + "```", + "", + ].join("\n") + ); + // cursor lands in the cell holding the selected code + assert.deepStrictEqual( + [editor.selection.active.line, editor.selection.active.character], + [5, 0] + ); + }); + }); + suite("Language picker fallback", function () { test("Inserts a code fence when document has no executable code cells", async function () { const content = "---\ntitle: Test\n---\n\nJust some markdown.\n"; @@ -94,3 +241,11 @@ suite("Insert Code Cell", function () { function countOccurrences(text: string, substring: string): number { return text.split(substring).length - 1; } + +async function openTestDocument(fileName: string, content: string) { + const uri = examplesOutUri(fileName); + await vscode.workspace.fs.writeFile(uri, Buffer.from(content, "utf8") as Uint8Array); + const doc = await vscode.workspace.openTextDocument(uri); + const editor = await vscode.window.showTextDocument(doc); + return { doc, editor }; +} From 992f241b445e63406dceaa5fbb1781b097ec4fa5 Mon Sep 17 00:00:00 2001 From: elliot Date: Wed, 12 Aug 2026 15:04:11 -0400 Subject: [PATCH 2/3] Lower cell background throttle ms so it feels snappier --- apps/vscode/package.json | 2 +- apps/vscode/src/providers/background.ts | 4 ++-- apps/vscode/src/providers/div-brackets.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/vscode/package.json b/apps/vscode/package.json index 71f587ac..19430e7f 100644 --- a/apps/vscode/package.json +++ b/apps/vscode/package.json @@ -1064,7 +1064,7 @@ "order": 22, "scope": "window", "type": "integer", - "default": 250, + "default": 50, "markdownDescription": "Millisecond delay between background color updates." }, "quarto.cells.useReticulate": { diff --git a/apps/vscode/src/providers/background.ts b/apps/vscode/src/providers/background.ts index 7b25a45b..8503afe1 100644 --- a/apps/vscode/src/providers/background.ts +++ b/apps/vscode/src/providers/background.ts @@ -231,7 +231,7 @@ class HiglightingConfig { } this.enabled_ = backgroundOption !== CellBackgroundColor.off; - this.delayMs_ = config.get("cells.background.delay", 250); + this.delayMs_ = config.get("cells.background.delay", 50); if (this.backgroundDecoration_) { @@ -264,7 +264,7 @@ class HiglightingConfig { private enabled_ = true; private backgroundDecoration_: vscode.TextEditorDecorationType | undefined; private inlineBackgroundDecoration_: vscode.TextEditorDecorationType | undefined; - private delayMs_ = 250; + private delayMs_ = 50; } const highlightingConfig = new HiglightingConfig(); diff --git a/apps/vscode/src/providers/div-brackets.ts b/apps/vscode/src/providers/div-brackets.ts index 6dc1f39e..3e9dc573 100644 --- a/apps/vscode/src/providers/div-brackets.ts +++ b/apps/vscode/src/providers/div-brackets.ts @@ -50,7 +50,7 @@ export function activateDivBracketDecorations(context: vscode.ExtensionContext) // Read debounce delay from config const getDelayMs = () => - vscode.workspace.getConfiguration('quarto').get('cells.background.delay', 250); + vscode.workspace.getConfiguration('quarto').get('cells.background.delay', 50); // Cache for parsed tokens const parseCache = new Map Date: Wed, 12 Aug 2026 15:06:42 -0400 Subject: [PATCH 3/3] Add CHANGELOG --- apps/vscode/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/vscode/CHANGELOG.md b/apps/vscode/CHANGELOG.md index 444cf4e7..9d3eb3cc 100644 --- a/apps/vscode/CHANGELOG.md +++ b/apps/vscode/CHANGELOG.md @@ -4,6 +4,7 @@ - Reduce memory usage by only starting the language server (LSP) in projects containing Quarto documents (https://github.com/quarto-dev/quarto/pull/1059). - Fixed a bug where single-line display math with a cross-reference label (e.g. `$$1+1$$ {#eq-spec0}`), or an unclosed `$$`, stopped the rest of the document from being parsed, so headings went missing from the outline, LaTeX preview was unavailable, and code cells below could not be run (). +- Make "insert cell" split the cell when cursor is inside, or insert a cell above when the cursor is at the top (). ## 1.135.0 (Release on 2026-07-08)