-
Notifications
You must be signed in to change notification settings - Fork 61
"insert code cell" splits cell if cursor inside #1086
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<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; | ||
|
|
@@ -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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The empty-selection branch just above already normalizes to column 0. Can the selection branch can do the same, taking
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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?
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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)+/, "") | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
|
||
| .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; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
To reproduce: split a cell whose header is
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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This returns 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? |
||
There was a problem hiding this comment.
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.parseis already cheap on repeat calls.markdownitParserwrapscachingParser(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 callslineAtandmatchAllfor every line of the document, on every tick. The document highlight provider registered atbackground.ts:95fires 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
blockRangesandinlineRangeswere cached by document version, the waydiv-brackets.ts:56already caches its tokens, those ticks would cost almost nothing. Then 50 ms is a fine default.