diff --git a/apps/vscode/CHANGELOG.md b/apps/vscode/CHANGELOG.md index 0caafddc..5856c04b 100644 --- a/apps/vscode/CHANGELOG.md +++ b/apps/vscode/CHANGELOG.md @@ -4,9 +4,10 @@ - By default, headers from markdown files _in R projects_ (projects with a `DESCRIPTION` file such as R package) are no longer exported as workspace symbols. They remain exported as usual in other projects. This behaviour can be controlled manually with the new `quarto.symbols.exportToWorkspace` setting. - Added a new setting `quarto.useBundledQuartoInPositron` to prefer the Quarto CLI bundled with Positron when available. This setting has precedence _between_ `quarto.path` and `quarto.usePipQuarto`, and has no effect outside of Positron (). -- Visual Editor: uses a text box for alternative text and captions in callouts, images, and tables interface. () +- Visual Editor: uses a text box for alternative text and captions in callouts, images, and tables interface. (). - Fixed a bug where previewing showed "Not Found" on Quarto files with spaces in the name in subfolders of projects (). - Added support for semantic highlighting in Quarto documents, when using an LSP that supports it (for example, Pylance) (). +- Visual Editor: in Positron, add support for statement execution (). ## 1.126.0 (Release on 2025-10-08) diff --git a/apps/vscode/package.json b/apps/vscode/package.json index 45d70986..c8b4789b 100644 --- a/apps/vscode/package.json +++ b/apps/vscode/package.json @@ -29,7 +29,7 @@ "private": true, "engines": { "vscode": "^1.75.0", - "positron": "^2025.6.0" + "positron": "^2025.12.0" }, "main": "./out/main.js", "browser": "./browser.js", diff --git a/apps/vscode/src/host/executors.ts b/apps/vscode/src/host/executors.ts index fddfe2b5..af23290e 100644 --- a/apps/vscode/src/host/executors.ts +++ b/apps/vscode/src/host/executors.ts @@ -22,16 +22,21 @@ import { documentFrontMatter } from "../markdown/document"; import { isExecutableLanguageBlockOf } from "quarto-core"; import { workspace } from "vscode"; import { JupyterKernelspec } from "core"; +import { Position } from "vscode"; export interface CellExecutor { execute: (blocks: string[], editorUri?: Uri) => Promise; executeSelection?: () => Promise; + executeAtPosition?: (uri: Uri, pos: Position) => Promise; } export function executableLanguages() { return kCellExecutors.map((executor) => executor.language); } +// This function is always used by the `defaultExtensionHost`, and is used +// by the `hooksExtensionHost` as a backup. Please see `hooksExtensionHost` +// how executors are retrieved in Positron. export async function cellExecutorForLanguage( language: string, document: TextDocument, diff --git a/apps/vscode/src/host/hooks.ts b/apps/vscode/src/host/hooks.ts index d7bbf093..fc04f17d 100644 --- a/apps/vscode/src/host/hooks.ts +++ b/apps/vscode/src/host/hooks.ts @@ -23,6 +23,8 @@ import { CellExecutor, cellExecutorForLanguage, executableLanguages, isKnitrDocu import { ExecuteQueue } from './execute-queue'; import { MarkdownEngine } from '../markdown/engine'; import { virtualDoc, adjustedPosition, unadjustedRange, withVirtualDocUri } from "../vdoc/vdoc"; +import { Position } from 'vscode'; +import { Uri } from 'vscode'; declare global { function acquirePositronApi(): hooks.PositronApi; @@ -83,6 +85,20 @@ export function hooksExtensionHost(): ExtensionHost { }, executeSelection: async (): Promise => { await vscode.commands.executeCommand('workbench.action.positronConsole.executeCode', { languageId: language }); + }, + executeAtPosition: async (uri: Uri, position: Position): Promise => { + try { + return await vscode.commands.executeCommand( + 'positron.executeCodeFromPosition', + language, + uri, + position + ) as Position; + } catch (e) { + // an error can happen, we think, if the statementRangeProvider errors + console.error('error when using `positron.executeCodeFromPosition`'); + } + return position; } }; diff --git a/apps/vscode/src/providers/cell/commands.ts b/apps/vscode/src/providers/cell/commands.ts index 11a07a39..0a042d68 100644 --- a/apps/vscode/src/providers/cell/commands.ts +++ b/apps/vscode/src/providers/cell/commands.ts @@ -46,11 +46,18 @@ import { codeWithoutOptionsFromBlock, executeInteractive, executeSelectionInteractive, + executeAtPositionInteractive, } from "./executors"; import { ExtensionHost } from "../../host"; -import { hasHooks } from "../../host/hooks"; +import { tryAcquirePositronApi } from "@posit-dev/positron"; import { isKnitrDocument } from "../../host/executors"; import { commands } from "vscode"; +import { virtualDocForCode, withVirtualDocUri } from "../../vdoc/vdoc"; +import { embeddedLanguage } from "../../vdoc/languages"; +import { Uri } from "vscode"; +import { StatementRange } from "positron"; + +const isPositron = tryAcquirePositronApi(); export function cellCommands(host: ExtensionHost, engine: MarkdownEngine): Command[] { return [ @@ -146,9 +153,7 @@ abstract class RunCommand { } private async hasExecutorForLanguage(language: string, document: TextDocument, engine: MarkdownEngine) { - // TODO: this is incorrect right? `cellExecutorForLanguage` returns a promise, and a promise will always be truthy? - // We should have to await it before doing `!!` - return !!this.cellExecutorForLanguage(language, document, engine); + return undefined !== (await this.cellExecutorForLanguage(language, document, engine)); } } @@ -259,7 +264,11 @@ class RunPreviousCellCommand extends RunCommand implements Command { } } +// More permissive type than `Position` so its easier to construct via a literal +type LineAndCharPos = { line: number, character: number; }; + +// Run the code at the cursor class RunCurrentCommand extends RunCommand implements Command { constructor( host: ExtensionHost, @@ -292,7 +301,7 @@ class RunCurrentCommand extends RunCommand implements Command { const resolveToRunCell = editor.selection.isEmpty && !this.runSelection_ && !isKnitrDocument(editor.document, this.engine_) && - (!hasHooks() && (language === "python" || language === "r")); + (!isPositron && (language === "python" || language === "r")); if (resolveToRunCell) { const code = codeWithoutOptionsFromBlock(block); @@ -336,38 +345,66 @@ class RunCurrentCommand extends RunCommand implements Command { context: CodeViewActiveBlockContext ): Promise { // get selection and active block - let selection = context.selectedText; + const selection = context.selectedText; const activeBlock = context.blocks.find(block => block.active); - // if the selection is empty and this isn't a knitr document then it resolves to run cell - if (selection.length <= 0 && !isKnitrDocument(editor.document, this.engine_)) { - if (activeBlock) { - const executor = await this.cellExecutorForLanguage(activeBlock.language, editor.document, this.engine_); - if (executor) { - await executeInteractive(executor, [activeBlock.code], editor.document); - await activateIfRequired(editor); + // if in Positron + if (isPositron) { + if (activeBlock && selection.length <= 0) { + const codeLines = lines(activeBlock.code); + const vdoc = virtualDocForCode(codeLines, embeddedLanguage(activeBlock.language)!); + if (vdoc) { + const parentUri = Uri.file(editor.document.fileName); + const injectedLines = (vdoc.language?.inject?.length ?? 0); + + const positionIntoVdoc = (p: LineAndCharPos) => + new Position(p.line + injectedLines, p.character); + const positionOutOfVdoc = (p: LineAndCharPos) => + new Position(p.line - injectedLines, p.character); + + const executor = await this.cellExecutorForLanguage(context.activeLanguage, editor.document, this.engine_); + if (executor) { + const nextStatementPos = await withVirtualDocUri( + vdoc, + parentUri, + "executeSelectionAtPositionInteractive", + (uri) => executeAtPositionInteractive( + executor, + uri, + positionIntoVdoc(context.selection.start) + ) + ); + + if (nextStatementPos !== undefined) { + await editor.setBlockSelection(context, positionOutOfVdoc(nextStatementPos)); + } + } } } - + // if not in Positron } else { - // if the selection is empty take the whole line, otherwise take the selected text exactly - let action: CodeViewSelectionAction | undefined; - if (selection.length <= 0) { + // if the selection is empty and this isn't a knitr document then it resolves to run cell + if (selection.length <= 0 && !isKnitrDocument(editor.document, this.engine_)) { if (activeBlock) { - selection = lines(activeBlock.code)[context.selection.start.line]; - action = "nextline"; + const executor = await this.cellExecutorForLanguage(activeBlock.language, editor.document, this.engine_); + if (executor) { + await executeInteractive(executor, [activeBlock.code], editor.document); + await activateIfRequired(editor); + } } - } - - // run code - const executor = await this.cellExecutorForLanguage(context.activeLanguage, editor.document, this.engine_); - if (executor) { - await executeInteractive(executor, [selection], editor.document); + } else { + const executor = await this.cellExecutorForLanguage(context.activeLanguage, editor.document, this.engine_); + if (executor) { + if (selection.length > 0) { + await executeInteractive(executor, [selection], editor.document); + await editor.setBlockSelection(context, "nextline"); + } else if (activeBlock) { // if the selection is empty take the whole line as the selection + await executeInteractive(executor, [lines(activeBlock.code)[context.selection.start.line]], editor.document); + await editor.setBlockSelection(context, "nextline"); + } - // advance cursor if necessary - if (action) { - editor.setBlockSelection(context, "nextline"); } + } } } @@ -382,7 +419,7 @@ class RunSelectionCommand extends RunCurrentCommand implements Command { } - +// Run Cell and Advance class RunCurrentAdvanceCommand extends RunCommand implements Command { constructor(host: ExtensionHost, engine: MarkdownEngine) { super(host, engine); diff --git a/apps/vscode/src/providers/cell/executors.ts b/apps/vscode/src/providers/cell/executors.ts index e6092386..9f28fab9 100644 --- a/apps/vscode/src/providers/cell/executors.ts +++ b/apps/vscode/src/providers/cell/executors.ts @@ -34,6 +34,8 @@ import { cellOptionsForToken, kExecuteEval } from "./options"; import { CellExecutor, ExtensionHost } from "../../host"; import { executableLanguages } from "../../host/executors"; +import { Position } from "vscode"; +import { Uri } from "vscode"; export function hasExecutor(_host: ExtensionHost, language: string) { @@ -90,6 +92,12 @@ export async function executeInteractive( return await executor.execute(blocks, !document.isUntitled ? document.uri : undefined); } + +export async function executeAtPositionInteractive(executor: CellExecutor, uri: Uri, position: Position) { + if (executor?.executeAtPosition) { + return await executor.executeAtPosition(uri, position); + } +} // attempt language aware execution of current selection (returns false // if the executor doesn't support this, in which case generic // executeInteractive will be called) diff --git a/apps/vscode/src/providers/editor/codeview.ts b/apps/vscode/src/providers/editor/codeview.ts index 13a990ce..d2500a6e 100644 --- a/apps/vscode/src/providers/editor/codeview.ts +++ b/apps/vscode/src/providers/editor/codeview.ts @@ -60,6 +60,11 @@ export function vscodeCodeViewServer(_engine: MarkdownEngine, document: TextDocu async codeViewAssist(context: CodeViewCellContext) { await commands.executeCommand("quarto.codeViewAssist", context, lspRequest); }, + // This execute command is used when the user clicks an execute button on a cell in the visual editor. + // + // Note: this is NOT used when the user uses a keyboard command to execute a cell, + // that goes through VSCode commands (commands are registered in package.json), + // the keyboard command code is in apps/vscode/src/providers/cell/commands.ts. async codeViewExecute(execute: CodeViewExecute) { switch (execute) { case "cell": diff --git a/apps/vscode/src/vdoc/vdoc.ts b/apps/vscode/src/vdoc/vdoc.ts index 03a69786..b1830bc1 100644 --- a/apps/vscode/src/vdoc/vdoc.ts +++ b/apps/vscode/src/vdoc/vdoc.ts @@ -120,6 +120,7 @@ export type VirtualDocAction = "format" | "statementRange" | "helpTopic" | + "executeSelectionAtPositionInteractive" | "semanticTokens"; export type VirtualDocUri = { uri: Uri, cleanup?: () => Promise; }; diff --git a/packages/editor-codemirror/src/behaviors/trackselection.ts b/packages/editor-codemirror/src/behaviors/trackselection.ts index bfcd073f..79a4b84f 100644 --- a/packages/editor-codemirror/src/behaviors/trackselection.ts +++ b/packages/editor-codemirror/src/behaviors/trackselection.ts @@ -27,7 +27,7 @@ import { DispatchEvent, codeViewCellContext, kCodeViewNextLineTransaction } from import { Behavior, BehaviorContext, State } from "."; // track the selection in prosemirror -export function trackSelectionBehavior(context: BehaviorContext) : Behavior { +export function trackSelectionBehavior(context: BehaviorContext): Behavior { let unsubscribe: VoidFunction; @@ -50,32 +50,32 @@ export function trackSelectionBehavior(context: BehaviorContext) : Behavior { unsubscribe = context.pmContext.events.subscribe(DispatchEvent, (tr: Transaction | undefined) => { if (tr) { // track selection changes that occur when we don't have focus - if (!cmView.hasFocus && tr.selectionSet && !tr.docChanged && (tr.selection instanceof TextSelection)) { + if (tr.selectionSet && !tr.docChanged && (tr.selection instanceof TextSelection)) { const cmSelection = asCodeMirrorSelection(context.view, cmView, context.getPos); context.withState(State.Updating, () => { if (cmSelection) { cmView.dispatch({ selection: cmSelection }); } else { - cmView.dispatch({ selection: EditorSelection.single(0)}) - } + cmView.dispatch({ selection: EditorSelection.single(0) }) + } }) } else if (tr.getMeta(kCodeViewNextLineTransaction) === true) { // NOTE: this is a special directive to advance to the next line. as distinct // from the block above it is not a reporting of a change in the PM selection - // but rather an instruction to move the CM selection to the next line. as + // but rather an instruction to move the CM selection to the next line. as // such we do not encose the code in State.Updating, because we want an update // to the PM selection to occur const cmSelection = asCodeMirrorSelection(context.view, cmView, context.getPos); if (cmSelection) { if (cursorLineDown(cmView)) { cursorLineStart(cmView); - } + } } - // for other selection changes + // for other selection changes } else if (cmView.hasFocus && tr.selectionSet && (tr.selection instanceof TextSelection)) { codeViewAssist(); } - } + } }); }, @@ -91,7 +91,7 @@ export const asCodeMirrorSelection = ( cmView: EditorView, getPos: (() => number) | boolean ) => { - if (typeof(getPos) === "function") { + if (typeof (getPos) === "function") { const offset = getPos() + 1; const node = pmView.state.doc.nodeAt(getPos()); if (node) { @@ -104,8 +104,8 @@ export const asCodeMirrorSelection = ( } else if (selection.from <= cmRange.from && selection.to >= cmRange.to) { return EditorSelection.single(0, cmView.state.doc.length); } - + } } return undefined; -} \ No newline at end of file +} diff --git a/packages/editor-types/src/codeview.ts b/packages/editor-types/src/codeview.ts index 2977f404..580d0a93 100644 --- a/packages/editor-types/src/codeview.ts +++ b/packages/editor-types/src/codeview.ts @@ -27,14 +27,15 @@ export const kCodeViewGetDiagnostics = 'code_view_get_diagnostics'; export type CodeViewExecute = "selection" | "cell" | "cell+advance" | "above" | "below"; +export type CodeViewBlock = { pos: number, language: string, code: string; active: boolean; }; export interface CodeViewActiveBlockContext { activeLanguage: string; - blocks: Array<{ pos: number, language: string, code: string; active: boolean; }>; + blocks: Array; selection: Range; selectedText: string; } -export type CodeViewSelectionAction = "nextline" | "nextblock" | "prevblock"; +export type CodeViewSelectionAction = "nextline" | "nextblock" | "prevblock" | { line: number, character: number; }; export interface CodeViewCellContext { filepath: string; diff --git a/packages/editor/src/api/codeview.ts b/packages/editor/src/api/codeview.ts index 25e4df42..4b3f88e3 100644 --- a/packages/editor/src/api/codeview.ts +++ b/packages/editor/src/api/codeview.ts @@ -31,8 +31,8 @@ import { editingRootNode } from './node'; import { editorScrollContainer } from './scroll'; import { rmdChunk } from './rmd'; -import { CodeViewActiveBlockContext, CodeViewCellContext, CodeViewCompletionContext, CodeViewSelectionAction } from 'editor-types'; -import { navigateToPos } from './navigation'; +import { CodeViewActiveBlockContext, CodeViewBlock, CodeViewCellContext, CodeViewCompletionContext, CodeViewSelectionAction } from 'editor-types'; +import { navigateToPos, setSelection } from './navigation'; export const kCodeViewNextLineTransaction = "codeViewNextLine"; @@ -200,17 +200,42 @@ export function scrollCodeViewElementIntoView(ele: HTMLElement, codeViewDom: HTM } } +// convert action line and character in code block space to pos in prosemirror space +function codeBlockPositionToViewPos(block: CodeViewBlock, action: { line: number, character: number; }) { + // asummes the meta line looks like this: + const metaLine = '{' + block.language + '}\n'; + // block.code always has a trailing newline, resulting in an empty line here that is not in the actual editor + // so we slice it off + const code = lines(block.code).slice(0, -1); + + if (action.line >= code.length) { + const endOfBlockPos = block.pos + metaLine.length + block.code.length - 1; + return endOfBlockPos; + } + + let pos = block.pos + metaLine.length; + for (let i = 0; i < action.line; i++) { + pos += code[i].length + 1; + } + pos += action.character; + + return pos; +} + export function codeViewSetBlockSelection( view: EditorView, context: CodeViewActiveBlockContext, action: CodeViewSelectionAction ) { - - const activeIndex = context.blocks.findIndex(block => block.active); if (activeIndex !== -1) { - if (action === "nextline") { + if (typeof action === 'object') { + // action is of type `{ line: number, character: number }` + view.focus(); + setSelection(view, codeBlockPositionToViewPos(context.blocks[activeIndex], action)); + } + else if (action === "nextline") { const tr = view.state.tr; tr.setMeta(kCodeViewNextLineTransaction, true); view.dispatch(tr); @@ -226,9 +251,6 @@ export function codeViewSetBlockSelection( } } } - - - } diff --git a/packages/editor/src/api/navigation.ts b/packages/editor/src/api/navigation.ts index b7eddcb7..9825c4ff 100644 --- a/packages/editor/src/api/navigation.ts +++ b/packages/editor/src/api/navigation.ts @@ -81,14 +81,7 @@ export function navigateToXRef(view: EditorView, editorFormat: EditorFormat, xre } } -export function navigateToPos(view: EditorView, pos: number, animate = true): Navigation | null { - // get previous position - const prevPos = view.state.selection.from; - - // need to target at least the body - pos = Math.max(pos, 2); - - // set selection (detect node selection) +export function setSelection(view: EditorView, pos: number) { const tr = view.state.tr; const pmNode = view.state.doc.nodeAt(pos); if (pmNode?.type.spec.selectable) { @@ -98,6 +91,12 @@ export function navigateToPos(view: EditorView, pos: number, animate = true): Na } tr.setMeta(kNavigationTransaction, true); view.dispatch(tr); +} +export function navigateToPos(view: EditorView, pos: number, animate = true): Navigation | null { + // need to target at least the body + pos = Math.max(pos, 2); + + setSelection(view, pos); // find a targetable dom node at the position const node = findDomRefAtPos(pos, view.domAtPos.bind(view)); @@ -132,7 +131,7 @@ export function navigateToPos(view: EditorView, pos: number, animate = true): Na } }, 200); - return { pos, prevPos }; + return { pos, prevPos: view.state.selection.from }; } else { return null; }