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
1 change: 1 addition & 0 deletions apps/vscode/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (<https://github.com/quarto-dev/quarto/pull/1063>).
- Make "insert cell" split the cell when cursor is inside, or insert a cell above when the cursor is at the top (<https://github.com/quarto-dev/quarto/pull/1086>).

## 1.135.0 (Release on 2026-07-08)

Expand Down
2 changes: 1 addition & 1 deletion apps/vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -1064,7 +1064,7 @@
"order": 22,
"scope": "window",
"type": "integer",
"default": 250,
"default": 50,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The delay default drops from 250 ms to 50 ms, so the two throttles it feeds can fire five times as often. Most of that extra work looks avoidable, so what do you think about caching this? I'm not necessarily making an argument for keeping 250 ms.

engine.parse is already cheap on repeat calls. markdownitParser wraps cachingParser (packages/quarto-core/src/markdown/parser.ts:23), which memoizes by uri and version. A tick with no edit does not reparse.

The part that has no cache right now is the inline code scan in background.ts:166-176. It calls lineAt and matchAll for every line of the document, on every tick. The document highlight provider registered at background.ts:95 fires as the cursor moves, so this whole-document scan can now run 20 times per second while the user only moves the cursor around.

If blockRanges and inlineRanges were cached by document version, the way div-brackets.ts:56 already caches its tokens, those ticks would cost almost nothing. Then 50 ms is a fine default.

"markdownDescription": "Millisecond delay between background color updates."
},
"quarto.cells.useReticulate": {
Expand Down
4 changes: 2 additions & 2 deletions apps/vscode/src/providers/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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_) {
Expand Down Expand Up @@ -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();
2 changes: 1 addition & 1 deletion apps/vscode/src/providers/div-brackets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, {
Expand Down
117 changes: 113 additions & 4 deletions apps/vscode/src/providers/insert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";


Expand All @@ -37,19 +39,26 @@ class InsertCodeCellCommand implements Command {

async execute(): Promise<void> {
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;
Expand Down Expand Up @@ -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<boolean> {
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);
}
Comment on lines +186 to +189

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

splitStart and splitEnd come from selection.start and selection.end at exact character offsets. Nothing snaps them to line boundaries, so a partial-line selection splits the code in the middle of a line.

The empty-selection branch just above already normalizes to column 0. Can the selection branch can do the same, taking selection.start.line for the start, and the line after selection.end.line for the end Ignore an end position at column 0, because that position belongs to the previous line.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

You're suggesting making it so it does not split in the middle of a line right?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Yes, for this case. In other branches, we already handle not splitting in the middle of the line.


// cell bodies, with surrounding blank lines removed (but indentation kept)
const cellBody = (range: Range) => {
const text = doc
.getText(range)
.replace(/^([ \t]*\n)+/, "")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

We do sometimes get reports from Windows users dealing with line ending issues. Both trim patterns match LF only, and the joins below hard-code "\n". A CRLF document therefore keeps a blank line it should lose, gains a stray carriage return, and ends up with mixed line endings.

doc.eol gives the line ending of the document. Could we use it for both joins, and make the two trim patterns \r?\n aware? EndOfLine is not in the import list at the top of the file yet.

.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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

header (line 152) is the whole fence line, and cellText reuses it for every entry in bodies. Chunk labels and cell options are therefore copied onto each new cell.

To reproduce: split a cell whose header is ```{r setup, include=FALSE}. The result is two chunks that both carry the label setup. knitr then stops the render with Duplicate chunk label 'setup'. The include=FALSE option also lands on a cell that the user did not intend it for.

languageNameFromBlock is already imported in this file, so a new cell can get a bare header like this: fence + "{" + languageNameFromBlock(block) + "}".

One cell still needs to keep the original header, so we do have to decide that and I think the first cell is mostly. One rule that I think will work is for the first cell that has a body keeps the original header, and every other cell gets the bare header.

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;
}
Comment on lines +236 to +243

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This returns true even when editor.edit rejects the edit, and line 57 returns as soon as it sees true. A rejected edit therefore looks the same as a successful split, with no text changes, no fallback, and no message to the user.

This isn't a huge deal as the window is small and the result is a no-op rather than damaged text. Do you think it's worth a line because the no-op is silent?

155 changes: 155 additions & 0 deletions apps/vscode/src/test/insert.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 };
}
Loading