Skip to content

"insert code cell" splits cell if cursor inside - #1086

Merged
vezwork merged 11 commits into
mainfrom
fix/insert-code-cell-split
Aug 19, 2026
Merged

"insert code cell" splits cell if cursor inside#1086
vezwork merged 11 commits into
mainfrom
fix/insert-code-cell-split

Conversation

@vezwork

@vezwork vezwork commented Aug 12, 2026

Copy link
Copy Markdown
Member

Fixes #382

Kapture.2026-08-12.at.14.52.30.mp4

Improves the "insert code cell" functionality to split cell when cursor is inside, or insert cell above when cursor is at top. Also splits three ways when there is a selection (not shown in video).

Tested in both VSCode and Positron.

Design considerations

  • An LLM looked up the RStudio source code for "insert code cell" as the reference implementation for this feature. The functionality should match RStudio.

  • I lowered the background highlighting delay ms in this PR from 250ms to 50ms because 250s looked disorientingly jarring when splitting code cells. I don't know how reasonable this is to do, but it seems much nicer in general if people's computers can handle it. RStudio has very fast background highlighting so its nice if the experience in VSCode and Positron can try to match that.

@posit-snyk-bot

posit-snyk-bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues
Licenses 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

@vezwork
vezwork requested a review from juliasilge August 12, 2026 19:08
@vezwork vezwork changed the title make it so "insert code cell" splits code cell with cursor inside "insert code cell" splits code cell if cursor inside Aug 12, 2026
@vezwork vezwork changed the title "insert code cell" splits code cell if cursor inside "insert code cell" splits cell if cursor inside Aug 12, 2026

@juliasilge juliasilge left a comment

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.

Thank you so much for working on this! I left five inline comments on some suggested improvements, and two of them ask for behavior that may not match RStudio exactly FWIW. Take a look and see if you agree!

Comment on lines +186 to +189
} else {
splitStart = clamp(selection.start);
splitEnd = clamp(selection.end);
}

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.

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.

Done

Comment thread apps/vscode/src/providers/insert.ts Outdated
}

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

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.

Done

Comment thread apps/vscode/src/providers/insert.ts Outdated
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.

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.

Done

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

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?

@vezwork vezwork Aug 18, 2026

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.

Hmmm. It seems more correct to return false if we fail to do the edit that splits the cell... but when we return false from splitCodeCell, the calling code inserts a code cell instead of trying to split.

It's hard for me to say here. I have a hard time imagining what would cause such an edit to fail and what I would want to happen if it does. I guess a failing edit represents a bug in the code since I don't expect it to happen. Maybe we should show a message to the user so the bug could be noticed and fixed? idk

For now, I've made it return false if the edit isn't applied.

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.

I think false on a rejected edit is better than the silent true, although it can mean multiple things now. 👍

Comment thread apps/vscode/package.json
"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.

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.

I've added a cache to background.ts. I like having a 50 ms default because it will feel instant to people as they type and insert cells etc.

For posterity: a backup approach would be to make it so the insert code cell command forces background.ts to re-apply, ignoring throttling.

@vezwork

vezwork commented Aug 18, 2026

Copy link
Copy Markdown
Member Author

@juliasilge I've addressed your comments. I tested in VSCode and Positron with various code cells. I wasn't totally sure what to do about your comment about returning true in splitCodeCell, I did something but let me know if you have further thoughts there.

Also, I wonder if we should add commands like "Quarto: insert code cell below" and "Quarto: insert code cell above" for people who want to insert blank code cells while their cursor remains in an existing code cell? I think I recall wanting to do such things when I used Notebooks in the past, but its been quite a while. What do you think?

@vezwork
vezwork requested a review from juliasilge August 18, 2026 19:14

@juliasilge juliasilge left a comment

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.

I think this is looking great! I am clicking approve ✅ as I think this could go in now and be a big improvement for folks, but I do have two further thoughts that you could address if you've got the bandwidth.

On the idea of commands to add cells above/below, we do offer that in Positron when inline output is enabled, via the same method as notebooks in Positron:

Image

I think we could wait for that as a specific user request over here in the Quarto extension.

Comment thread apps/vscode/src/providers/insert.ts Outdated
footerLine--;
}
const closed = footerLine > headerLine;
const blockEndLine = closed ? footerLine : Math.min(block.range.end.line, doc.lineCount - 1);

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.

When the fence is unclosed, the markdown-it token map runs to the end of the document, so bodyEnd covers every line below the cursor. A split then pulls any prose that follows into the code cell and adds a closing fence at the end of the file.

I think the simplest fix is to bail out and let the existing insert path handle it:

if (!closed) {
  // an unclosed fence parses to the end of the document
  return false;
}

closed is then always true, so what's below can collapse.

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

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.

I think false on a rejected edit is better than the silent true, although it can mean multiple things now. 👍

});
});

suite("Splitting the cell at the cursor", function () {

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.

So great to have these tests! 🎉

I notice that the two subtlest fixes from this round of review are not yet covered, so a refactor could bring either back quietly. Could we add a case for each?

  • A header with a label, like {r setup, include=FALSE}, asserting the second cell gets a bare ```{r}. That locks in the Duplicate chunk label fix.
  • A CRLF copy of PYTHON_CELL_DOC joined with \r\n, asserting the output has no mixed line endings.

@vezwork
vezwork merged commit 27213f0 into main Aug 19, 2026
10 of 13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

VS Code extension: "insert code cell" behavior from inside a code cell

3 participants