diff --git a/extensions/positron-zed/src/positronZedLanguageRuntime.ts b/extensions/positron-zed/src/positronZedLanguageRuntime.ts index 2125cf99bc13..30c089056090 100644 --- a/extensions/positron-zed/src/positronZedLanguageRuntime.ts +++ b/extensions/positron-zed/src/positronZedLanguageRuntime.ts @@ -54,6 +54,13 @@ const HelpLines = [ 'busy X Y - Simulates an interuptible busy state for X seconds that takes Y seconds to interrupt (default X = 5, Y = 1)', 'cd X - Changes the current working directory to X, or to a random directory if X is not specified', 'clock - Show a plot containing a clock, using the notebook renderer API', + 'console active - Show the current active console editor URI, language, and text (tests positron.window.activeConsoleEditor)', + 'console watch - Subscribe to onDidChangeActiveConsoleEditor and print each change', + 'console watch stop - Stop watching for active console changes', + 'console edit X - Replace the console input with X via TextEditor.edit() (tests the Editor interface)', + 'console append X - Insert X at the end of the console input via TextEditor.edit()', + 'console insert X - Insert X at the cursor via TextEditor.insertSnippet()', + 'console select - Select the entire console input and report TextEditor.selection', 'connection X - Create a database connection, optionally named X', 'connection close - Close a random database connection', 'code X Y - Simulates a successful X line input with Y lines of output (where X >= 1 and Y >= 0)', @@ -191,6 +198,11 @@ export class PositronZedRuntimeSession implements positron.LanguageRuntimeSessio */ private _busyInterruptSeconds: number; + /** + * Disposable for the active `console watch` subscription; undefined when not watching. + */ + private _consoleWatchDisposable: vscode.Disposable | undefined; + /** * The number of seconds by which a shutdown should be delayed. This is used * to help simulate a state in which a runtime locks up during shutdown. @@ -470,6 +482,13 @@ export class PositronZedRuntimeSession implements positron.LanguageRuntimeSessio this.simulateConnection(id, code, title); } return; + } else if (match = code.match(/^console (edit|insert|append|select)(?: ([\s\S]*))?$/)) { + // Exercise the editing side of positron.window.activeConsoleEditor's + // vscode.TextEditor interface (edit(), insertSnippet(), selection). + const subcommand = match[1]; + const arg = (match.length > 2 && match[2] !== undefined) ? match[2] : ''; + this.simulateConsoleEditorCommand(id, code, subcommand, arg); + return; } // Process the "code". @@ -938,6 +957,43 @@ export class PositronZedRuntimeSession implements positron.LanguageRuntimeSessio break; } + case 'console active': { + const editor = positron.window.activeConsoleEditor; + const msg = editor + ? `Active console editor: ${editor.document.uri.toString()}\nLanguage: ${editor.document.languageId}\nText: ${JSON.stringify(editor.document.getText())}` + : `Active console editor: none (no console is currently active)`; + this.simulateSuccessfulCodeExecution(id, code, msg); + break; + } + + case 'console watch': { + if (this._consoleWatchDisposable) { + this.simulateSuccessfulCodeExecution(id, code, `Already watching. Use 'console watch stop' to stop.`); + break; + } + const watchId = id; + this._consoleWatchDisposable = positron.window.onDidChangeActiveConsoleEditor((editor) => { + const msg = editor + ? `[console watch] Active console changed: ${editor.document.uri.toString()} (${editor.document.languageId})` + : `[console watch] Active console changed: none`; + this.simulateOutputMessage(watchId, msg + '\n'); + }); + this.context.subscriptions.push(this._consoleWatchDisposable); + this.simulateSuccessfulCodeExecution(id, code, `Watching for active console changes. Switch consoles to see events. Use 'console watch stop' to stop.`); + break; + } + + case 'console watch stop': { + if (this._consoleWatchDisposable) { + this._consoleWatchDisposable.dispose(); + this._consoleWatchDisposable = undefined; + this.simulateSuccessfulCodeExecution(id, code, `Stopped watching for active console changes.`); + } else { + this.simulateSuccessfulCodeExecution(id, code, `Not currently watching. Use 'console watch' to start.`); + } + break; + } + default: { this.simulateUnsuccessfulCodeExecution(id, code, 'Unknown Command', `Error. '${code}' not recognized.\n`, []); break; @@ -1985,6 +2041,67 @@ export class PositronZedRuntimeSession implements positron.LanguageRuntimeSessio this.simulateIdleState(parentId); } + /** + * Exercises the mutating side of the `vscode.TextEditor` interface exposed by + * `positron.window.activeConsoleEditor`. This complements the read-only `console active` + * and `console watch` commands by verifying that `edit()`, `insertSnippet()`, and + * `selection` operate on the live console input. + * @param parentId The parent identifier. + * @param code The originating command text. + * @param subcommand The editor operation to perform: `edit`, `append`, `insert`, or `select`. + * @param arg The text argument for `edit`, `append`, and `insert` (ignored for `select`). + */ + private simulateConsoleEditorCommand(parentId: string, code: string, subcommand: string, arg: string) { + const editor = positron.window.activeConsoleEditor; + if (!editor) { + this.simulateUnsuccessfulCodeExecution(parentId, code, 'No Active Console', + `No console editor is currently active.\n`, []); + return; + } + + // The full range of the current console input, used for whole-document operations. + const fullRange = () => new vscode.Range( + editor.document.positionAt(0), + editor.document.positionAt(editor.document.getText().length)); + + switch (subcommand) { + case 'edit': { + // Replace the entire input with `arg`. + editor.edit(editBuilder => editBuilder.replace(fullRange(), arg)).then((applied) => { + this.simulateSuccessfulCodeExecution(parentId, code, + `edit() replace applied: ${applied}\nText: ${JSON.stringify(editor.document.getText())}`); + }); + break; + } + case 'append': { + // Insert `arg` at the end of the input. + editor.edit(editBuilder => editBuilder.insert(fullRange().end, arg)).then((applied) => { + this.simulateSuccessfulCodeExecution(parentId, code, + `edit() insert applied: ${applied}\nText: ${JSON.stringify(editor.document.getText())}`); + }); + break; + } + case 'insert': { + // Insert `arg` as a snippet at the current cursor position. + editor.insertSnippet(new vscode.SnippetString(arg)).then((applied) => { + this.simulateSuccessfulCodeExecution(parentId, code, + `insertSnippet() applied: ${applied}\nText: ${JSON.stringify(editor.document.getText())}`); + }); + break; + } + case 'select': { + // Set the selection to cover the whole input, then read it back. + editor.selection = new vscode.Selection(fullRange().start, fullRange().end); + const selection = editor.selection; + this.simulateSuccessfulCodeExecution(parentId, code, + `Selection: [${selection.start.line}:${selection.start.character} - ` + + `${selection.end.line}:${selection.end.character}]\n` + + `Selected text: ${JSON.stringify(editor.document.getText(selection))}`); + break; + } + } + } + /** * Simulates unsuccessful code execution. * @param parentId The parent ID. diff --git a/src/positron-dts/positron.d.ts b/src/positron-dts/positron.d.ts index 5a2c16223634..c28003c8453f 100644 --- a/src/positron-dts/positron.d.ts +++ b/src/positron-dts/positron.d.ts @@ -2584,6 +2584,20 @@ declare module 'positron' { */ export function getConsoleForLanguage(languageId: string): Thenable; + /** + * The currently active console editor, or `undefined` if no console is active. + * Provides the full `vscode.TextEditor` API for the console input, including + * `document`, `selection`, `edit()`, and `insertSnippet()`. + * + * Note: this editor is intentionally NOT `vscode.window.activeTextEditor`. + */ + export const activeConsoleEditor: vscode.TextEditor | undefined; + + /** + * An event that fires when the active console editor changes. + */ + export const onDidChangeActiveConsoleEditor: vscode.Event; + /** * Fires when the width of the console input changes. The new width is passed as * a number, which represents the number of characters that can fit in the diff --git a/src/vs/workbench/api/browser/mainThreadDocumentsAndEditors.ts b/src/vs/workbench/api/browser/mainThreadDocumentsAndEditors.ts index de8a7fa95e9f..bf60d3f3bd43 100644 --- a/src/vs/workbench/api/browser/mainThreadDocumentsAndEditors.ts +++ b/src/vs/workbench/api/browser/mainThreadDocumentsAndEditors.ts @@ -4,7 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import { Event } from '../../../base/common/event.js'; -import { combinedDisposable, DisposableStore, DisposableMap } from '../../../base/common/lifecycle.js'; +// --- Start Positron --- +// Added `IDisposable` and `toDisposable` for `registerConsoleEditor` below. Extended in place +// rather than imported separately to avoid a duplicate import of `lifecycle.js`. +// import { combinedDisposable, DisposableStore, DisposableMap } from '../../../base/common/lifecycle.js'; +import { combinedDisposable, DisposableStore, DisposableMap, IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; +// --- End Positron --- import { ICodeEditor, isCodeEditor, isDiffEditor, IActiveCodeEditor } from '../../../editor/browser/editorBrowser.js'; import { ICodeEditorService } from '../../../editor/browser/services/codeEditorService.js'; import { IEditor } from '../../../editor/common/editorCommon.js'; @@ -33,6 +38,11 @@ import { IPaneCompositePartService } from '../../services/panecomposite/browser/ import { ViewContainerLocation } from '../../common/views.js'; import { IConfigurationService } from '../../../platform/configuration/common/configuration.js'; import { IQuickDiffModelService } from '../../contrib/scm/browser/quickDiffModel.js'; +// --- Start Positron --- +// Used by `registerConsoleEditor`, which exposes the console input editor to the extension host +// as a `vscode.TextEditor`. +import { IMainThreadConsoleEditorManager, MainPositronContext } from '../common/positron/extHost.positron.protocol.js'; +// --- End Positron --- class TextEditorSnapshot { @@ -274,7 +284,12 @@ class MainThreadDocumentAndEditorStateComputer { } @extHostCustomer -export class MainThreadDocumentsAndEditors implements IMainThreadEditorLocator { +// --- Start Positron --- +// Additionally implement IMainThreadConsoleEditorManager so MainThreadConsoleService can register +// console input editors with the extension host (see `registerConsoleEditor` below). +// export class MainThreadDocumentsAndEditors implements IMainThreadEditorLocator { +export class MainThreadDocumentsAndEditors implements IMainThreadEditorLocator, IMainThreadConsoleEditorManager { + // --- End Positron --- private readonly _toDispose = new DisposableStore(); private readonly _proxy: ExtHostDocumentsAndEditorsShape; @@ -310,6 +325,11 @@ export class MainThreadDocumentsAndEditors implements IMainThreadEditorLocator { // It is expected that the ctor of the state computer calls our `_onDelta`. this._toDispose.add(new MainThreadDocumentAndEditorStateComputer(delta => this._onDelta(delta), _modelService, codeEditorService, this._editorService, paneCompositeService)); + + // --- Start Positron --- + // Register this instance so MainThreadConsoleService can reach it via getRaw. + extHostContext.set(MainPositronContext.MainThreadConsoleEditorManager, this); + // --- End Positron --- } dispose(): void { @@ -433,4 +453,76 @@ export class MainThreadDocumentsAndEditors implements IMainThreadEditorLocator { getEditor(id: string): MainThreadTextEditor | undefined { return this._textEditors.get(id); } + + // --- Start Positron --- + /** + * Registers a console input editor so it is accessible as a `vscode.TextEditor` via + * `positron.window.activeConsoleEditor`. The editor is NOT set as `vscode.window.activeTextEditor`. + * + * @param id A stable id for this editor (e.g. `console-`) + * @param codeEditor The Monaco editor backing the console input + * @param onRegistered Invoked once the editor has been sent to the ext host. Registration is + * deferred until the code editor has a text model, so this may run after this method returns. + * @returns A disposable that removes the editor from the ext host when disposed + */ + registerConsoleEditor(id: string, codeEditor: ICodeEditor, onRegistered?: () => void): IDisposable { + const store = new DisposableStore(); + + const doRegister = (model: ITextModel) => { + const editor = new MainThreadTextEditor( + id, + model, + codeEditor, + { onGainedFocus() { }, onLostFocus() { } }, + this._mainThreadDocuments, + this._modelService, + this._clipboardService, + ); + + this._textEditors.set(id, editor); + + // Order matters, and mirrors `_onDelta`: tell the ext host about the editor first, then + // wire up the dependent editor state. `handleTextEditorAdded` starts an autorun that + // immediately sends `$acceptEditorDiffInformation` for this id, and the ext host throws + // `unknown text editor` for any id it hasn't received an `addedEditors` delta for. + this._proxy.$acceptDocumentsAndEditorsDelta({ + addedEditors: [this._toTextEditorAddData(editor)], + }); + this._mainThreadEditors.handleTextEditorAdded(editor); + + store.add(toDisposable(() => { + // Mirror image of registration: drop the listeners before the ext host forgets the + // editor, so nothing can send it state for an id it no longer knows. + this._textEditors.delete(id); + editor.dispose(); + this._mainThreadEditors.handleTextEditorRemoved(id); + this._proxy.$acceptDocumentsAndEditorsDelta({ removedEditors: [id] }); + })); + + // Notify the caller last, so that anything it sends to the ext host (e.g. the active + // console editor id) arrives after the delta that makes the editor resolvable there. + onRegistered?.(); + }; + + const model = codeEditor.getModel(); + if (model) { + doRegister(model); + } else { + // The console input assigns its code editor before attaching the text model, so the + // model may not be present yet. Wait for it rather than silently skipping registration, + // otherwise `positron.window.activeConsoleEditor` would never resolve this editor. + const sub = store.add(codeEditor.onDidChangeModel(e => { + if (e.newModelUrl) { + const newModel = codeEditor.getModel(); + if (newModel) { + sub.dispose(); + doRegister(newModel); + } + } + })); + } + + return store; + } + // --- End Positron --- } diff --git a/src/vs/workbench/api/browser/positron/mainThreadConsoleService.ts b/src/vs/workbench/api/browser/positron/mainThreadConsoleService.ts index 2f474c59ae9a..d26575a9afd9 100644 --- a/src/vs/workbench/api/browser/positron/mainThreadConsoleService.ts +++ b/src/vs/workbench/api/browser/positron/mainThreadConsoleService.ts @@ -3,8 +3,8 @@ * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ -import { DisposableStore } from '../../../../base/common/lifecycle.js'; -import { ExtHostConsoleServiceShape, ExtHostPositronContext, MainPositronContext, MainThreadConsoleServiceShape } from '../../common/positron/extHost.positron.protocol.js'; +import { DisposableStore, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { ExtHostConsoleServiceShape, ExtHostPositronContext, IMainThreadConsoleEditorManager, MainPositronContext, MainThreadConsoleServiceShape } from '../../common/positron/extHost.positron.protocol.js'; import { extHostNamedCustomer, IExtHostContext } from '../../../services/extensions/common/extHostCustomers.js'; import { IPositronConsoleInstance, IPositronConsoleService } from '../../../services/positronConsole/browser/interfaces/positronConsoleService.js'; import { MainThreadConsole } from './mainThreadConsole.js'; @@ -25,6 +25,15 @@ export class MainThreadConsoleService implements MainThreadConsoleServiceShape { */ private readonly _mainThreadConsolesBySessionId = new Map(); + /** Disposables for registered console text editors, keyed by session id. */ + private readonly _consoleEditorDisposables = new Map>(); + + /** Session ids whose console text editor is known to the extension host. */ + private readonly _registeredConsoleEditors = new Set(); + + /** The last editor id sent to the extension host, used to suppress duplicate notifications. */ + private _notifiedConsoleEditorId: string | null = null; + private readonly _proxy: ExtHostConsoleServiceShape; constructor( @@ -53,6 +62,21 @@ export class MainThreadConsoleService implements MainThreadConsoleServiceShape { // Then update main thread this.addConsole(sessionId, console); + + // Register the console's Monaco editor with the text editor tracking so + // extensions can access it via positron.window.activeConsoleEditor. + const manager = extHostContext.getRaw( + MainPositronContext.MainThreadConsoleEditorManager + ); + this._registerConsoleEditor(console, manager); + }) + ); + + // Forward active console changes to the extension host + this._disposables.add( + this._positronConsoleService.onDidChangeActivePositronConsoleInstance((instance) => { + this._proxy.$onDidChangeActiveConsole(instance?.sessionId); + this._notifyActiveConsoleEditor(instance); }) ); @@ -78,6 +102,9 @@ export class MainThreadConsoleService implements MainThreadConsoleServiceShape { dispose(): void { this._disposables.dispose(); + for (const d of this._consoleEditorDisposables.values()) { + d.dispose(); + } } private addConsole(sessionId: string, console: IPositronConsoleInstance) { @@ -93,6 +120,79 @@ export class MainThreadConsoleService implements MainThreadConsoleServiceShape { // this._mainThreadConsolesByLanguageId.delete(id); // } + /** + * Registers the Monaco editor for `instance` with the text editor tracking pipeline so + * that extensions can use `positron.window.activeConsoleEditor` to access a full + * `vscode.TextEditor` for the console input. + * + * The editor is intentionally NOT set as `vscode.window.activeTextEditor`. + */ + private _registerConsoleEditor(instance: IPositronConsoleInstance, manager: IMainThreadConsoleEditorManager): void { + const sessionId = instance.sessionMetadata.sessionId; + const editorId = `console-${sessionId}`; + + const doRegister = () => { + const mutable = new MutableDisposable(); + this._consoleEditorDisposables.set(sessionId, mutable); + this._disposables.add(mutable); + // Registration is deferred until the code editor has a text model, so the editor is + // only resolvable in the ext host once `onRegistered` runs. Notifying any earlier + // would fire `onDidChangeActiveConsoleEditor` with an unresolvable editor. + mutable.value = manager.registerConsoleEditor(editorId, instance.codeEditor!, () => { + this._registeredConsoleEditors.add(sessionId); + + // If this instance is the active console, notify now. + if (this._positronConsoleService.activePositronConsoleInstance === instance) { + this._setActiveConsoleEditor(editorId); + } + }); + }; + + if (instance.codeEditor) { + // Editor already mounted — register immediately. + doRegister(); + } else { + // Editor not yet mounted (React component hasn't mounted yet). + // Wait for the first assignment. + const sub = instance.onDidSetCodeEditor(() => { + sub.dispose(); + doRegister(); + }); + this._disposables.add(sub); + } + } + + /** + * Notifies the extension host of the active console editor id so + * `positron.window.activeConsoleEditor` can be updated. + */ + private _notifyActiveConsoleEditor(instance: IPositronConsoleInstance | undefined): void { + if (!instance) { + this._setActiveConsoleEditor(null); + return; + } + const sessionId = instance.sessionMetadata.sessionId; + // Only notify with an editor id once the ext host knows about the editor; until then the + // active console has no resolvable editor. `_registerConsoleEditor` notifies once it does. + if (this._registeredConsoleEditors.has(sessionId)) { + this._setActiveConsoleEditor(`console-${sessionId}`); + } else { + this._setActiveConsoleEditor(null); + } + } + + /** + * Sends the active console editor id to the extension host, skipping notifications that + * wouldn't change the value the extension host already has. + */ + private _setActiveConsoleEditor(editorId: string | null): void { + if (this._notifiedConsoleEditorId === editorId) { + return; + } + this._notifiedConsoleEditorId = editorId; + this._proxy.$setActiveConsoleEditor(editorId); + } + // --- from extension host process $getConsoleWidth(): Promise { @@ -124,6 +224,10 @@ export class MainThreadConsoleService implements MainThreadConsoleServiceShape { return Promise.resolve(undefined); } + $getActiveConsoleSessionId(): Promise { + return Promise.resolve(this._positronConsoleService.activePositronConsoleInstance?.sessionId); + } + $tryPasteText(sessionId: string, text: string): void { const mainThreadConsole = this._mainThreadConsolesBySessionId.get(sessionId); diff --git a/src/vs/workbench/api/common/positron/extHost.positron.api.impl.ts b/src/vs/workbench/api/common/positron/extHost.positron.api.impl.ts index 3b095ad60bff..66129e516371 100644 --- a/src/vs/workbench/api/common/positron/extHost.positron.api.impl.ts +++ b/src/vs/workbench/api/common/positron/extHost.positron.api.impl.ts @@ -29,6 +29,7 @@ import { ExtHostLanguageFeatures } from '../extHostLanguageFeatures.js'; import { createExtHostQuickOpen } from '../extHostQuickOpen.js'; import { ExtHostOutputService } from '../extHostOutput.js'; import { ExtHostConsoleService } from './extHostConsoleService.js'; +import { ExtHostDocumentsAndEditors } from '../extHostDocumentsAndEditors.js'; import { ExtHostMethods } from './extHostMethods.js'; import { ExtHostEditors } from '../extHostTextEditors.js'; import { UiFrontendRequest } from '../../../services/languageRuntime/common/positronUiComm.js'; @@ -80,13 +81,14 @@ export function createPositronApiFactoryAndRegisterActors(accessor: ServicesAcce rpcProtocol.getRaw(ExtHostContext.ExtHostLanguageFeatures); const extHostEditors: ExtHostEditors = rpcProtocol.getRaw(ExtHostContext.ExtHostEditors); const extHostDocuments: ExtHostDocuments = rpcProtocol.getRaw(ExtHostContext.ExtHostDocuments); + const extHostDocumentsAndEditors: ExtHostDocumentsAndEditors = rpcProtocol.getRaw(ExtHostContext.ExtHostDocumentsAndEditors); const extHostQuickOpen = rpcProtocol.set(ExtHostPositronContext.ExtHostQuickOpen, createExtHostQuickOpen(rpcProtocol, extHostWorkspace, extHostCommands)); const extHostLanguageRuntime = rpcProtocol.set(ExtHostPositronContext.ExtHostLanguageRuntime, new ExtHostLanguageRuntime(rpcProtocol, extHostLogService)); const extHostAiFeatures = rpcProtocol.set(ExtHostPositronContext.ExtHostAiFeatures, new ExtHostAiFeatures(rpcProtocol, extHostCommands)); const extHostPreviewPanels = rpcProtocol.set(ExtHostPositronContext.ExtHostPreviewPanel, new ExtHostPreviewPanels(rpcProtocol, extHostWebviews, extHostWorkspace)); const extHostModalDialogs = rpcProtocol.set(ExtHostPositronContext.ExtHostModalDialogs, new ExtHostModalDialogs(rpcProtocol)); const extHostContextKeyService = rpcProtocol.set(ExtHostPositronContext.ExtHostContextKeyService, new ExtHostContextKeyService(rpcProtocol)); - const extHostConsoleService = rpcProtocol.set(ExtHostPositronContext.ExtHostConsoleService, new ExtHostConsoleService(rpcProtocol, extHostLogService)); + const extHostConsoleService = rpcProtocol.set(ExtHostPositronContext.ExtHostConsoleService, new ExtHostConsoleService(rpcProtocol, extHostLogService, extHostDocumentsAndEditors)); const extHostPlotsService = rpcProtocol.set(ExtHostPositronContext.ExtHostPlotsService, new ExtHostPlotsService(rpcProtocol)); const extHostMethods = rpcProtocol.set(ExtHostPositronContext.ExtHostMethods, new ExtHostMethods(rpcProtocol, extHostEditors, extHostDocuments, extHostModalDialogs, @@ -235,6 +237,12 @@ export function createPositronApiFactoryAndRegisterActors(accessor: ServicesAcce getConsoleForLanguage(languageId: string) { return extHostConsoleService.getConsoleForLanguage(languageId); }, + get activeConsoleEditor() { + return extHostConsoleService.activeConsoleEditor; + }, + get onDidChangeActiveConsoleEditor() { + return extHostConsoleService.onDidChangeActiveConsoleEditor; + }, get onDidChangeConsoleWidth() { return extHostConsoleService.onDidChangeConsoleWidth; }, diff --git a/src/vs/workbench/api/common/positron/extHost.positron.protocol.ts b/src/vs/workbench/api/common/positron/extHost.positron.protocol.ts index f6cc1b8e6c5b..e36606e27b65 100644 --- a/src/vs/workbench/api/common/positron/extHost.positron.protocol.ts +++ b/src/vs/workbench/api/common/positron/extHost.positron.protocol.ts @@ -26,6 +26,7 @@ import { ILanguageRuntimeCodeExecutedEvent } from '../../../services/positronCon import { IPositronChatProvider } from '../../../contrib/chat/common/languageModels.js'; import { ICodeLocation } from '../../../services/positronConsole/common/codeLocation.js'; import { EvalResult } from '../../../services/languageRuntime/common/positronUiComm.js'; +import { ICodeEditor } from '../../../../editor/browser/editorBrowser.js'; // NOTE: This check is really to ensure that extHost.protocol is included by the TypeScript compiler // as a dependency of this module, and therefore that it's initialized first. This is to avoid a @@ -163,12 +164,33 @@ export interface MainThreadConsoleServiceShape { $getConsoleWidth(): Promise; $getSessionIdForLanguage(languageId: string): Promise; $tryPasteText(sessionId: string, text: string): void; + $getActiveConsoleSessionId(): Promise; } export interface ExtHostConsoleServiceShape { $onDidChangeConsoleWidth(newWidth: number): void; $addConsole(sessionId: string): void; $removeConsole(sessionId: string): void; + $onDidChangeActiveConsole(sessionId: string | undefined): void; + $setActiveConsoleEditor(editorId: string | null): void; +} + +/** + * Implemented by MainThreadDocumentsAndEditors to allow Positron-specific + * console editor registration without exposing the full upstream internals. + */ +export interface IMainThreadConsoleEditorManager { + /** + * Registers a console input editor with the extension host. + * + * @param id A stable id for this editor (e.g. `console-`) + * @param codeEditor The Monaco editor backing the console input + * @param onRegistered Invoked once the editor is actually known to the extension host. + * Registration is deferred until the code editor has a text model, so this may be called + * after `registerConsoleEditor` returns (or never, if a model is never attached). + * @returns A disposable that removes the editor from the ext host when disposed + */ + registerConsoleEditor(id: string, codeEditor: ICodeEditor, onRegistered?: () => void): IDisposable; } export interface MainThreadMethodsShape { } @@ -496,6 +518,7 @@ export interface MainThreadPositronEphemeralStorageShape extends IDisposable { } export const MainPositronContext = { + MainThreadConsoleEditorManager: createProxyIdentifier('MainThreadConsoleEditorManager'), MainThreadLanguageRuntime: createProxyIdentifier('MainThreadLanguageRuntime'), MainThreadPreviewPanel: createProxyIdentifier('MainThreadPreviewPanel'), MainThreadModalDialogs: createProxyIdentifier('MainThreadModalDialogs'), diff --git a/src/vs/workbench/api/common/positron/extHostConsoleService.ts b/src/vs/workbench/api/common/positron/extHostConsoleService.ts index 830beb60db76..bcbd34e48cc9 100644 --- a/src/vs/workbench/api/common/positron/extHostConsoleService.ts +++ b/src/vs/workbench/api/common/positron/extHostConsoleService.ts @@ -4,11 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import * as positron from 'positron'; +import * as vscode from 'vscode'; import { Emitter } from '../../../../base/common/event.js'; import * as extHostProtocol from './extHost.positron.protocol.js'; import { ExtHostConsole } from './extHostConsole.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { dispose } from '../../../../base/common/lifecycle.js'; +import { ExtHostDocumentsAndEditors } from '../extHostDocumentsAndEditors.js'; export class ExtHostConsoleService implements extHostProtocol.ExtHostConsoleServiceShape { @@ -25,17 +27,61 @@ export class ExtHostConsoleService implements extHostProtocol.ExtHostConsoleServ private readonly _onDidChangeConsoleWidth = new Emitter(); + private readonly _onDidChangeActiveConsole = new Emitter(); + + private readonly _onDidChangeActiveConsoleEditor = new Emitter(); + + private _activeConsoleSessionId: string | undefined; + + // Guards the startup seed: once a live $onDidChangeActiveConsole arrives we + // must not let the async startup promise overwrite it. + private _receivedLiveActiveConsoleEvent = false; + private readonly _proxy: extHostProtocol.MainThreadConsoleServiceShape; constructor( mainContext: extHostProtocol.IMainPositronContext, private readonly _logService: ILogService, + private readonly _extHostDocumentsAndEditors: ExtHostDocumentsAndEditors, ) { this._proxy = mainContext.getProxy(extHostProtocol.MainPositronContext.MainThreadConsoleService); + + // Fetch the current active console session on startup so we don't miss + // consoles that were already active before this ext host started. + this._proxy.$getActiveConsoleSessionId().then((sessionId) => { + // A live $onDidChangeActiveConsole event already arrived; skip so we + // don't overwrite it with a potentially stale startup value. + if (this._receivedLiveActiveConsoleEvent) { + return; + } + this._activeConsoleSessionId = sessionId; + // If $addConsole already registered this session before the promise + // resolved, the re-fire guard in $addConsole was skipped. Fire now. + if (sessionId !== undefined && this._extHostConsolesBySessionId.has(sessionId)) { + this._onDidChangeActiveConsole.fire(this.activeConsole); + } + }); } onDidChangeConsoleWidth = this._onDidChangeConsoleWidth.event; + onDidChangeActiveConsole = this._onDidChangeActiveConsole.event; + + onDidChangeActiveConsoleEditor = this._onDidChangeActiveConsoleEditor.event; + + get activeConsole(): positron.Console | undefined { + if (this._activeConsoleSessionId === undefined) { + return undefined; + } + return this._extHostConsolesBySessionId.get(this._activeConsoleSessionId)?.getConsole(); + } + + get activeConsoleEditor(): vscode.TextEditor | undefined { + return this._activeConsoleSessionId + ? this._extHostDocumentsAndEditors.getEditor(`console-${this._activeConsoleSessionId}`)?.value + : undefined; + } + /** * Queries the main thread for the current width of the console input. * @@ -86,6 +132,11 @@ export class ExtHostConsoleService implements extHostProtocol.ExtHostConsoleServ $addConsole(sessionId: string): void { const extHostConsole = new ExtHostConsole(sessionId, this._proxy, this._logService); this._extHostConsolesBySessionId.set(sessionId, extHostConsole); + // If the active session ID arrived before this console was registered, re-fire now that + // the map is populated so listeners receive the resolved console instead of undefined. + if (sessionId === this._activeConsoleSessionId) { + this._onDidChangeActiveConsole.fire(this.activeConsole); + } } // Called when a console instance is removed @@ -95,5 +146,18 @@ export class ExtHostConsoleService implements extHostProtocol.ExtHostConsoleServ // "Dispose" of an `ExtHostConsole`, ensuring that future API calls warn / error dispose(extHostConsole); } -} + // Called when the active console changes + $onDidChangeActiveConsole(sessionId: string | undefined): void { + this._receivedLiveActiveConsoleEvent = true; + this._activeConsoleSessionId = sessionId; + this._onDidChangeActiveConsole.fire(this.activeConsole); + } + + // Called when the active console editor changes (separate from vscode.window.activeTextEditor). + // The editorId is derived from the active session, so we fire using the getter which derives + // the TextEditor from _activeConsoleSessionId (already updated by $onDidChangeActiveConsole). + $setActiveConsoleEditor(_editorId: string | null): void { + this._onDidChangeActiveConsoleEditor.fire(this.activeConsoleEditor); + } +} diff --git a/src/vs/workbench/api/test/browser/positron/consoleEditorTestServices.ts b/src/vs/workbench/api/test/browser/positron/consoleEditorTestServices.ts new file mode 100644 index 000000000000..cf6cecd752db --- /dev/null +++ b/src/vs/workbench/api/test/browser/positron/consoleEditorTestServices.ts @@ -0,0 +1,217 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2026 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../../../base/common/event.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { ICodeEditorService } from '../../../../../editor/browser/services/codeEditorService.js'; +import { ILanguageConfigurationService } from '../../../../../editor/common/languages/languageConfigurationRegistry.js'; +import { ILanguageService } from '../../../../../editor/common/languages/language.js'; +import { ITextModel } from '../../../../../editor/common/model.js'; +import { LanguageService } from '../../../../../editor/common/services/languageService.js'; +import { ModelService } from '../../../../../editor/common/services/modelService.js'; +import { ITreeSitterLibraryService } from '../../../../../editor/common/services/treeSitter/treeSitterLibraryService.js'; +import { TestCodeEditorService } from '../../../../../editor/test/browser/editorTestServices.js'; +import { createTestCodeEditor, ITestCodeEditor } from '../../../../../editor/test/browser/testCodeEditor.js'; +import { TestLanguageConfigurationService } from '../../../../../editor/test/common/modes/testLanguageConfigurationService.js'; +import { TestTreeSitterLibraryService } from '../../../../../editor/test/common/services/testTreeSitterLibraryService.js'; +import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { TestDialogService } from '../../../../../platform/dialogs/test/common/testDialogService.js'; +import { ITextEditorDiffInformation } from '../../../../../platform/editor/common/editor.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; +import { TestNotificationService } from '../../../../../platform/notification/test/common/testNotificationService.js'; +import { TestThemeService } from '../../../../../platform/theme/test/common/testThemeService.js'; +import { IUndoRedoService } from '../../../../../platform/undoRedo/common/undoRedo.js'; +import { UndoRedoService } from '../../../../../platform/undoRedo/common/undoRedoService.js'; +import { UriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentityService.js'; +import { IQuickDiffModelService } from '../../../../contrib/scm/browser/quickDiffModel.js'; +import { IPaneCompositePartService } from '../../../../services/panecomposite/browser/panecomposite.js'; +import { ITextFileEditorModelManager, ITextFileService } from '../../../../services/textfile/common/textfiles.js'; +import { IUntitledTextEditorModelManager } from '../../../../services/untitled/common/untitledTextEditorService.js'; +import { TestEditorGroupsService, TestEditorService, TestEnvironmentService, TestPathService } from '../../../../test/browser/workbenchTestServices.js'; +import { TestTextResourcePropertiesService, TestWorkingCopyFileService } from '../../../../test/common/workbenchTestServices.js'; +import { stubInterface } from '../../../../../test/vitest/stubInterface.js'; +import { MainThreadDocumentsAndEditors } from '../../../browser/mainThreadDocumentsAndEditors.js'; +import { IDocumentsAndEditorsDelta } from '../../../common/extHost.protocol.js'; +import { SingleProxyRPCProtocol } from '../../common/testRPCProtocol.js'; + +/** + * A real `MainThreadDocumentsAndEditors` plus the services it needs, wired for tests that + * exercise the Positron-only console editor registration path. + * + * The wiring mirrors the upstream Mocha suite in `../mainThreadDocumentsAndEditors.test.ts` + * (real `ModelService` + real test code editors) so that `ICodeEditor.onDidChangeModel` fires + * genuinely when a model is attached -- the exact timing console editor registration depends on. + * + * Create one per test and `dispose()` it in `afterEach`. + */ +export class ConsoleEditorTestServices { + + /** Deltas the main thread sent to the (fake) extension host. */ + readonly deltas: IDocumentsAndEditorsDelta[] = []; + + /** + * Editor ids the main thread sent state for before the ext host knew about them. The real + * `ExtHostEditors` throws `unknown text editor` in this case, so anything recorded here is a + * bug in the ordering of the calls the main thread makes. + */ + readonly unknownEditorCalls: string[] = []; + + /** Editor ids the (fake) ext host currently knows about, per the deltas it received. */ + private readonly _knownEditorIds = new Set(); + + readonly modelService: ModelService; + readonly documentsAndEditors: MainThreadDocumentsAndEditors; + + private readonly _codeEditorService: TestCodeEditorService; + + /** Long-lived services; disposed last, after everything that reads them. */ + private readonly _services = new DisposableStore(); + + /** + * Editors, models and console registrations created by a test. Disposed while the main-thread + * instance is still alive so their `MainThreadTextEditor`s drain through the live state + * computer. + */ + private readonly _perTest = new DisposableStore(); + + constructor() { + const configService = new TestConfigurationService(); + configService.setUserConfiguration('editor', { 'detectIndentation': false }); + const dialogService = new TestDialogService(); + const notificationService = new TestNotificationService(); + const undoRedoService = new UndoRedoService(dialogService, notificationService); + const themeService = new TestThemeService(); + // TestInstantiationService here only bootstraps the ModelService helper (per vitest-tests.md + // exception), it is not used as the primary DI container for the class under test. + const instantiationService = new TestInstantiationService(); + instantiationService.set(ILanguageService, this._services.add(new LanguageService())); + instantiationService.set(ILanguageConfigurationService, this._services.add(new TestLanguageConfigurationService())); + instantiationService.set(ITreeSitterLibraryService, new TestTreeSitterLibraryService()); + instantiationService.set(IUndoRedoService, undoRedoService); + this.modelService = this._services.add(new ModelService( + configService, + new TestTextResourcePropertiesService(configService), + undoRedoService, + instantiationService + )); + this._codeEditorService = this._services.add(new TestCodeEditorService(themeService)); + const textFileService = new class extends mock() { + override isDirty() { return false; } + // Only the events subscribed by MainThreadDocuments's constructor are read here. + override files = stubInterface({ + onDidSave: Event.None, + onDidChangeDirty: Event.None, + onDidChangeEncoding: Event.None + }); + override untitled = stubInterface({ + onDidChangeEncoding: Event.None + }); + override getEncoding() { return 'utf8'; } + }; + const workbenchEditorService = this._services.add(new TestEditorService()); + const editorGroupService = new TestEditorGroupsService(); + + const fileService = new class extends mock() { + override onDidRunOperation = Event.None; + override onDidChangeFileSystemProviderCapabilities = Event.None; + override onDidChangeFileSystemProviderRegistrations = Event.None; + }; + + this.documentsAndEditors = new MainThreadDocumentsAndEditors( + SingleProxyRPCProtocol({ + $acceptDocumentsAndEditorsDelta: (delta: IDocumentsAndEditorsDelta) => { + this.deltas.push(delta); + delta.addedEditors?.forEach(e => this._knownEditorIds.add(e.id)); + delta.removedEditors?.forEach(id => this._knownEditorIds.delete(id)); + }, + $acceptEditorDiffInformation: (id: string, _diffInformation: ITextEditorDiffInformation | undefined) => { + this._recordEditorIdLookup(id); + }, + $acceptEditorPropertiesChanged: (id: string) => { + this._recordEditorIdLookup(id); + } + }), + this.modelService, + textFileService, + workbenchEditorService, + this._codeEditorService, + fileService, + null!, + editorGroupService, + new class extends mock() implements IPaneCompositePartService { + override onDidPaneCompositeOpen = Event.None; + override onDidPaneCompositeClose = Event.None; + override getActivePaneComposite() { + return undefined; + } + }, + TestEnvironmentService, + new TestWorkingCopyFileService(), + this._services.add(new UriIdentityService(fileService)), + new class extends mock() { + override readText() { + return Promise.resolve('clipboard_contents'); + } + }, + new TestPathService(), + new TestConfigurationService(), + new class extends mock() { + override createQuickDiffModelReference() { + return undefined; + } + } + ); + } + + /** Creates a test code editor, optionally with a model already attached. */ + createCodeEditor(model: ITextModel | undefined): ITestCodeEditor { + return this._perTest.add(createTestCodeEditor(model, { + hasTextFocus: false, + serviceCollection: new ServiceCollection( + [ICodeEditorService, this._codeEditorService] + ) + })); + } + + createModel(value: string): ITextModel { + return this._perTest.add(this.modelService.createModel(value, null)); + } + + /** Registers a disposable owned by the current test (e.g. a console editor registration). */ + add(disposable: T): T { + return this._perTest.add(disposable); + } + + /** + * Deltas emitted by `registerConsoleEditor` contain a single `addedEditors` entry whose id is + * the console id we passed; deltas from the ambient state computer use composite + * `${editorId},${modelId}` ids, so filtering by our exact id isolates the registration under + * test. + */ + consoleAdds(id: string): IDocumentsAndEditorsDelta[] { + return this.deltas.filter(d => d.addedEditors?.some(e => e.id === id)); + } + + consoleRemoves(id: string): IDocumentsAndEditorsDelta[] { + return this.deltas.filter(d => d.removedEditors?.includes(id)); + } + + /** Mimics the ext host resolving an editor id, recording the ones it can't resolve. */ + private _recordEditorIdLookup(id: string): void { + if (!this._knownEditorIds.has(id)) { + this.unknownEditorCalls.push(id); + } + } + + dispose(): void { + this._perTest.dispose(); + this.documentsAndEditors.dispose(); + this._services.dispose(); + } +} diff --git a/src/vs/workbench/api/test/browser/positron/mainThreadConsoleService.vitest.ts b/src/vs/workbench/api/test/browser/positron/mainThreadConsoleService.vitest.ts new file mode 100644 index 000000000000..4b4a02ecb6d7 --- /dev/null +++ b/src/vs/workbench/api/test/browser/positron/mainThreadConsoleService.vitest.ts @@ -0,0 +1,156 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2026 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +/// + +import { ILanguageRuntimeMetadata, LanguageRuntimeSessionMode } from '../../../../services/languageRuntime/common/languageRuntimeService.js'; +import { IRuntimeSessionMetadata } from '../../../../services/runtimeSession/common/runtimeSessionService.js'; +import { TestPositronConsoleInstance, TestPositronConsoleService } from '../../../../services/positronConsole/test/browser/testPositronConsoleService.js'; +import { IExtHostContext } from '../../../../services/extensions/common/extHostCustomers.js'; +import { stubInterface } from '../../../../../test/vitest/stubInterface.js'; +import { ensureNoLeakedDisposables } from '../../../../../test/vitest/vitestUtils.js'; +import { MainThreadConsoleService } from '../../../browser/positron/mainThreadConsoleService.js'; +import { ExtHostConsoleServiceShape } from '../../../common/positron/extHost.positron.protocol.js'; +import { SingleProxyRPCProtocol } from '../../common/testRPCProtocol.js'; +import { ConsoleEditorTestServices } from './consoleEditorTestServices.js'; + +// A record of every `$setActiveConsoleEditor` call the main thread made, paired with whether the +// extension host could have resolved that editor at the time of the call. `resolvable: false` +// means `positron.window.activeConsoleEditor` would have been `undefined` when the +// `onDidChangeActiveConsoleEditor` event fired -- the bug this suite guards against. +interface IActiveEditorNotification { + editorId: string | null; + resolvable: boolean; +} + +// Tests for the notification side of `positron.window.activeConsoleEditor`: the main thread must +// only tell the extension host about a console editor once that editor has actually been +// registered, which is deferred until the console input attaches its text model. +describe('MainThreadConsoleService (active console editor)', () => { + + ensureNoLeakedDisposables(); + + let services: ConsoleEditorTestServices; + let consoleService: TestPositronConsoleService; + let mainThreadConsoleService: MainThreadConsoleService; + let notifications: IActiveEditorNotification[]; + + beforeEach(() => { + services = new ConsoleEditorTestServices(); + notifications = []; + + const proxy = stubInterface({ + $addConsole: vi.fn(), + $onDidChangeActiveConsole: vi.fn(), + $setActiveConsoleEditor: (editorId: string | null) => { + notifications.push({ + editorId, + resolvable: editorId !== null && services.documentsAndEditors.getEditor(editorId) !== undefined + }); + } + }); + const extHostContext: IExtHostContext = { + ...SingleProxyRPCProtocol(proxy), + // `getRaw` is generic over the proxy identifier, so the cast is what tells the + // compiler which actor this test hands back -- the console editor manager. + getRaw: (): R => services.documentsAndEditors as unknown as R, + }; + + consoleService = new TestPositronConsoleService(); + mainThreadConsoleService = new MainThreadConsoleService(extHostContext, consoleService); + }); + + afterEach(() => { + // Dispose the console registrations while the main-thread editor tracking is still alive. + mainThreadConsoleService.dispose(); + services.dispose(); + }); + + function createInstance(sessionId: string): TestPositronConsoleInstance { + const sessionMetadata: IRuntimeSessionMetadata = { + sessionId, + sessionMode: LanguageRuntimeSessionMode.Console, + notebookUri: undefined, + createdTimestamp: 0, + startReason: 'test', + }; + const runtimeMetadata = stubInterface({ languageId: 'python' }); + return new TestPositronConsoleInstance(sessionId, 'Python', sessionMetadata, runtimeMetadata); + } + + // Drives a console through the real mount sequence: the instance starts without a code editor, + // the React input assigns one, and only then is a text model attached. + function addMountedConsole(sessionId: string): TestPositronConsoleInstance { + const instance = createInstance(sessionId); + consoleService.addTestConsoleInstance(instance); + const editor = services.createCodeEditor(undefined); + instance.setCodeEditor(editor); + editor.setModel(services.createModel('> ')); + return instance; + } + + it('waits for the editor to exist before announcing it to the ext host', () => { + const instance = createInstance('session-1'); + consoleService.addTestConsoleInstance(instance); + consoleService.setActivePositronConsoleSession('session-1'); + + // The console is active but has no input editor yet, so there is nothing to announce. + expect(notifications).toEqual([]); + + // The console input assigns its code editor before attaching a text model. Registration + // with the ext host is deferred until the model arrives, so announcing the editor here + // would fire `onDidChangeActiveConsoleEditor` with an unresolvable editor -- and, since + // there is no second notification, extensions would never see the usable one. + const editor = services.createCodeEditor(undefined); + instance.setCodeEditor(editor); + expect(notifications).toEqual([]); + expect(services.consoleAdds('console-session-1')).toHaveLength(0); + + // Attaching the model completes registration; only now is the editor announced. + editor.setModel(services.createModel('> ')); + expect(notifications).toEqual([{ editorId: 'console-session-1', resolvable: true }]); + }); + + it('announces the editor of each console as it becomes active', () => { + // Each console announces itself once mounted, since adding it also makes it active. + addMountedConsole('session-a'); + addMountedConsole('session-b'); + + consoleService.setActivePositronConsoleSession('session-a'); + consoleService.setActivePositronConsoleSession('session-b'); + // Re-activating the console that is already announced is not a change. + consoleService.setActivePositronConsoleSession('session-b'); + + expect(notifications).toEqual([ + { editorId: 'console-session-a', resolvable: true }, + { editorId: 'console-session-b', resolvable: true }, + { editorId: 'console-session-a', resolvable: true }, + { editorId: 'console-session-b', resolvable: true }, + ]); + }); + + it('clears the announced editor when the newly active console has none yet', () => { + addMountedConsole('session-a'); + + // A console whose input has not mounted becomes active: the ext host must stop reporting + // the previous console's editor rather than hold on to a stale one. + const pending = createInstance('session-pending'); + consoleService.addTestConsoleInstance(pending); + consoleService.setActivePositronConsoleSession('session-pending'); + + expect(notifications).toEqual([ + { editorId: 'console-session-a', resolvable: true }, + { editorId: null, resolvable: false }, + ]); + + // Once its editor mounts, the pending console announces it. + const editor = services.createCodeEditor(undefined); + pending.setCodeEditor(editor); + editor.setModel(services.createModel('> ')); + + expect(notifications).toHaveLength(3); + expect(notifications[2]).toEqual({ editorId: 'console-session-pending', resolvable: true }); + }); +}); diff --git a/src/vs/workbench/api/test/browser/positron/mainThreadDocumentsAndEditors.vitest.ts b/src/vs/workbench/api/test/browser/positron/mainThreadDocumentsAndEditors.vitest.ts new file mode 100644 index 000000000000..fb147b0a57a5 --- /dev/null +++ b/src/vs/workbench/api/test/browser/positron/mainThreadDocumentsAndEditors.vitest.ts @@ -0,0 +1,90 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2026 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +/// + +import { ensureNoLeakedDisposables } from '../../../../../test/vitest/vitestUtils.js'; +import { ConsoleEditorTestServices } from './consoleEditorTestServices.js'; + +// Tests for the Positron-only `MainThreadDocumentsAndEditors.registerConsoleEditor` method, which +// backs `positron.window.activeConsoleEditor`. See `ConsoleEditorTestServices` for the wiring. +describe('MainThreadDocumentsAndEditors (Positron console editor)', () => { + + ensureNoLeakedDisposables(); + + let services: ConsoleEditorTestServices; + + beforeEach(() => { + services = new ConsoleEditorTestServices(); + }); + + // Registered after `ensureNoLeakedDisposables`, so it runs before the leak check (Vitest runs + // afterEach hooks in reverse registration order). + afterEach(() => { + services.dispose(); + }); + + it('registers immediately when the code editor already has a model', () => { + const model = services.createModel('> '); + const editor = services.createCodeEditor(model); + + const store = services.documentsAndEditors.registerConsoleEditor('console-1', editor); + + expect(services.consoleAdds('console-1')).toHaveLength(1); + + // Disposing the registration removes the editor from the ext host. + store.dispose(); + expect(services.consoleRemoves('console-1')).toHaveLength(1); + }); + + it('defers registration until a model is attached (the fix)', () => { + // Console input assigns its code editor before the text model attaches. + const editor = services.createCodeEditor(undefined); + + services.add(services.documentsAndEditors.registerConsoleEditor('console-2', editor)); + + // Nothing registered yet -- a regression that bailed on the missing model would leave + // `activeConsoleEditor` permanently unresolved here. + expect(services.consoleAdds('console-2')).toHaveLength(0); + + // Attaching the model fires `onDidChangeModel` with a new url, which triggers registration. + editor.setModel(services.createModel('> ')); + expect(services.consoleAdds('console-2')).toHaveLength(1); + + // A later model swap must not register the console editor a second time. + editor.setModel(services.createModel('>> ')); + expect(services.consoleAdds('console-2')).toHaveLength(1); + }); + + it('sends the added-editor delta before any state for that editor', () => { + const editor = services.createCodeEditor(services.createModel('> ')); + + const store = services.add(services.documentsAndEditors.registerConsoleEditor('console-4', editor)); + + // `handleTextEditorAdded` immediately pushes diff information for the new id. Sending that + // before the `addedEditors` delta made the ext host throw `unknown text editor` on every + // console session startup. + expect(services.unknownEditorCalls).toEqual([]); + + store.dispose(); + expect(services.unknownEditorCalls).toEqual([]); + }); + + it('notifies the caller only once the editor is known to the ext host', () => { + const editor = services.createCodeEditor(undefined); + const onRegistered = vi.fn(() => services.consoleAdds('console-3').length); + + services.add(services.documentsAndEditors.registerConsoleEditor('console-3', editor, onRegistered)); + + expect(onRegistered).not.toHaveBeenCalled(); + + editor.setModel(services.createModel('> ')); + + // Called exactly once, and only after the `addedEditors` delta went out -- callers rely on + // that ordering to avoid announcing an editor the ext host can't resolve yet. + expect(onRegistered).toHaveBeenCalledTimes(1); + expect(onRegistered).toHaveReturnedWith(1); + }); +}); diff --git a/src/vs/workbench/api/test/common/positron/extHostConsoleService.vitest.ts b/src/vs/workbench/api/test/common/positron/extHostConsoleService.vitest.ts new file mode 100644 index 000000000000..cfdd46fb0d10 --- /dev/null +++ b/src/vs/workbench/api/test/common/positron/extHostConsoleService.vitest.ts @@ -0,0 +1,283 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2026 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +/// + +import { mock } from '../../../../../base/test/common/mock.js'; +import { NullLogService } from '../../../../../platform/log/common/log.js'; +import { ensureNoLeakedDisposables } from '../../../../../test/vitest/vitestUtils.js'; +import { MainThreadConsoleServiceShape } from '../../../common/positron/extHost.positron.protocol.js'; +import { ExtHostConsoleService } from '../../../common/positron/extHostConsoleService.js'; +import { SingleProxyRPCProtocol } from '../testRPCProtocol.js'; +import { ExtHostDocumentsAndEditors } from '../../../common/extHostDocumentsAndEditors.js'; +import { ExtHostTextEditor } from '../../../common/extHostTextEditor.js'; + +function createMockShape(activeSessionId: string | undefined = undefined) { + return new class extends mock() { + private _activeSessionId = activeSessionId; + override $getActiveConsoleSessionId(): Promise { + return Promise.resolve(this._activeSessionId); + } + override $getConsoleWidth(): Promise { + return Promise.resolve(80); + } + override $getSessionIdForLanguage(_languageId: string): Promise { + return Promise.resolve(undefined); + } + override $tryPasteText(_sessionId: string, _text: string): void { + // no-op + } + }; +} + +// Returns a shape whose $getActiveConsoleSessionId promise is manually resolved via the +// returned callback — lets tests control when the startup seed arrives. +function createControllableMockShape() { + let resolve: (sessionId: string | undefined) => void; + const shape = new class extends mock() { + override $getActiveConsoleSessionId(): Promise { + return new Promise((r) => { resolve = r; }); + } + override $getConsoleWidth(): Promise { + return Promise.resolve(80); + } + override $getSessionIdForLanguage(_languageId: string): Promise { + return Promise.resolve(undefined); + } + override $tryPasteText(_sessionId: string, _text: string): void { + // no-op + } + }; + return { shape, resolveActiveSessionId: (id: string | undefined) => resolve(id) }; +} + +/** Minimal DocsAndEditors stub; `idToEditor` maps editor id → the ExtHostTextEditor stub. */ +function createDocsAndEditors(idToEditor: Record = {}) { + return new class extends mock() { + override getEditor(id: string): ExtHostTextEditor | undefined { + return idToEditor[id]; + } + }; +} + +const nullDocsAndEditors = createDocsAndEditors(); + +describe('ExtHostConsoleService', function () { + + const disposables = ensureNoLeakedDisposables(); + + it('normal order: $addConsole then $onDidChangeActiveConsole resolves activeConsole', function () { + const shape = createMockShape(); + const svc = new ExtHostConsoleService(SingleProxyRPCProtocol(shape), new NullLogService(), nullDocsAndEditors); + + const fired: (import('positron').Console | undefined)[] = []; + disposables.add(svc.onDidChangeActiveConsole((c) => fired.push(c))); + + svc.$addConsole('session-1'); + svc.$onDidChangeActiveConsole('session-1'); + + expect(svc.activeConsole).toBeDefined(); + expect(fired.length).toBe(1); + expect(fired[0]).toBe(svc.activeConsole); + }); + + it('race condition: $onDidChangeActiveConsole before $addConsole fires again on $addConsole', function () { + const shape = createMockShape(); + const svc = new ExtHostConsoleService(SingleProxyRPCProtocol(shape), new NullLogService(), nullDocsAndEditors); + + const fired: (import('positron').Console | undefined)[] = []; + disposables.add(svc.onDidChangeActiveConsole((c) => fired.push(c))); + + // Active session arrives before the console is registered + svc.$onDidChangeActiveConsole('session-1'); + expect(svc.activeConsole).toBeUndefined(); + expect(fired).toEqual([undefined]); + + // Console is registered later — should re-fire with the resolved console + svc.$addConsole('session-1'); + expect(svc.activeConsole).toBeDefined(); + expect(fired.length).toBe(2); + expect(fired[1]).toBe(svc.activeConsole); + }); + + it('$onDidChangeActiveConsole(undefined) clears activeConsole and fires with undefined', function () { + const shape = createMockShape(); + const svc = new ExtHostConsoleService(SingleProxyRPCProtocol(shape), new NullLogService(), nullDocsAndEditors); + + svc.$addConsole('session-1'); + svc.$onDidChangeActiveConsole('session-1'); + expect(svc.activeConsole).toBeDefined(); + + const fired: (import('positron').Console | undefined)[] = []; + disposables.add(svc.onDidChangeActiveConsole((c) => fired.push(c))); + + svc.$onDidChangeActiveConsole(undefined); + expect(svc.activeConsole).toBeUndefined(); + expect(fired).toEqual([undefined]); + }); + + it('unknown sessionId: activeConsole is undefined when sessionId is not registered', function () { + const shape = createMockShape(); + const svc = new ExtHostConsoleService(SingleProxyRPCProtocol(shape), new NullLogService(), nullDocsAndEditors); + + const fired: (import('positron').Console | undefined)[] = []; + disposables.add(svc.onDidChangeActiveConsole((c) => fired.push(c))); + + svc.$onDidChangeActiveConsole('session-unknown'); + expect(svc.activeConsole).toBeUndefined(); + expect(fired).toEqual([undefined]); + }); + + it('startup race: $addConsole before $getActiveConsoleSessionId resolves still fires event', async function () { + const { shape, resolveActiveSessionId } = createControllableMockShape(); + const svc = new ExtHostConsoleService(SingleProxyRPCProtocol(shape), new NullLogService(), nullDocsAndEditors); + + const fired: (import('positron').Console | undefined)[] = []; + disposables.add(svc.onDidChangeActiveConsole((c) => fired.push(c))); + + // Console registers BEFORE the startup promise resolves + svc.$addConsole('session-preexisting'); + expect(svc.activeConsole).toBeUndefined(); + expect(fired).toHaveLength(0); + + // Startup promise resolves — should set active and fire the event + resolveActiveSessionId('session-preexisting'); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(svc.activeConsole).toBeDefined(); + expect(fired).toHaveLength(1); + expect(fired[0]).toBe(svc.activeConsole); + }); + + it('startup race: live $onDidChangeActiveConsole before $getActiveConsoleSessionId resolves wins', async function () { + const { shape, resolveActiveSessionId } = createControllableMockShape(); + const svc = new ExtHostConsoleService(SingleProxyRPCProtocol(shape), new NullLogService(), nullDocsAndEditors); + + svc.$addConsole('session-1'); + svc.$addConsole('session-stale'); + + const fired: (import('positron').Console | undefined)[] = []; + disposables.add(svc.onDidChangeActiveConsole((c) => fired.push(c))); + + // Live event fires: session-1 is the active console + svc.$onDidChangeActiveConsole('session-1'); + const activeFromLiveEvent = svc.activeConsole; + expect(activeFromLiveEvent).toBeDefined(); + expect(fired).toHaveLength(1); + + // Startup promise resolves with a stale session ID — must not overwrite + resolveActiveSessionId('session-stale'); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(svc.activeConsole).toBe(activeFromLiveEvent); + expect(fired).toHaveLength(1); // no spurious second event + }); + + it('constructor seeds _activeConsoleSessionId from $getActiveConsoleSessionId', async function () { + const shape = createMockShape('session-preexisting'); + const svc = new ExtHostConsoleService(SingleProxyRPCProtocol(shape), new NullLogService(), nullDocsAndEditors); + + // Wait for the async init promise to resolve + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Adding a console with the pre-existing session ID should re-fire the event + const fired: (import('positron').Console | undefined)[] = []; + disposables.add(svc.onDidChangeActiveConsole((c) => fired.push(c))); + + svc.$addConsole('session-preexisting'); + expect(svc.activeConsole).toBeDefined(); + expect(fired.length).toBe(1); + expect(fired[0]).toBe(svc.activeConsole); + }); + + describe('$setActiveConsoleEditor / activeConsoleEditor', function () { + + it('returns undefined initially', function () { + const shape = createMockShape(); + const svc = new ExtHostConsoleService(SingleProxyRPCProtocol(shape), new NullLogService(), nullDocsAndEditors); + expect(svc.activeConsoleEditor).toBeUndefined(); + }); + + it('sets activeConsoleEditor from registered editor (derived via _activeConsoleSessionId)', function () { + const fakeValue = Object.freeze({}) as import('vscode').TextEditor; + const fakeExtEditor = { value: fakeValue } as unknown as ExtHostTextEditor; + // getEditor key matches `console-${sessionId}` format used by the getter + const docsAndEditors = createDocsAndEditors({ 'console-session-1': fakeExtEditor }); + + const shape = createMockShape(); + const svc = new ExtHostConsoleService(SingleProxyRPCProtocol(shape), new NullLogService(), docsAndEditors); + + const fired: (import('vscode').TextEditor | undefined)[] = []; + disposables.add(svc.onDidChangeActiveConsoleEditor((e) => fired.push(e))); + + // $onDidChangeActiveConsole sets _activeConsoleSessionId; $setActiveConsoleEditor fires the event + svc.$onDidChangeActiveConsole('session-1'); + svc.$setActiveConsoleEditor('console-session-1'); + + expect(svc.activeConsoleEditor).toBe(fakeValue); + expect(fired).toEqual([fakeValue]); + }); + + it('clears activeConsoleEditor when session becomes null', function () { + const fakeValue = Object.freeze({}) as import('vscode').TextEditor; + const fakeExtEditor = { value: fakeValue } as unknown as ExtHostTextEditor; + const docsAndEditors = createDocsAndEditors({ 'console-session-1': fakeExtEditor }); + + const shape = createMockShape(); + const svc = new ExtHostConsoleService(SingleProxyRPCProtocol(shape), new NullLogService(), docsAndEditors); + + svc.$onDidChangeActiveConsole('session-1'); + svc.$setActiveConsoleEditor('console-session-1'); + expect(svc.activeConsoleEditor).toBe(fakeValue); + + const fired: (import('vscode').TextEditor | undefined)[] = []; + disposables.add(svc.onDidChangeActiveConsoleEditor((e) => fired.push(e))); + + // Active console clears; $onDidChangeActiveConsole(undefined) precedes $setActiveConsoleEditor(null) + svc.$onDidChangeActiveConsole(undefined); + svc.$setActiveConsoleEditor(null); + expect(svc.activeConsoleEditor).toBeUndefined(); + expect(fired).toEqual([undefined]); + }); + + it('yields undefined when no active session even with an editorId signal', function () { + const shape = createMockShape(); + const svc = new ExtHostConsoleService(SingleProxyRPCProtocol(shape), new NullLogService(), nullDocsAndEditors); + + const fired: (import('vscode').TextEditor | undefined)[] = []; + disposables.add(svc.onDidChangeActiveConsoleEditor((e) => fired.push(e))); + + // Without $onDidChangeActiveConsole, _activeConsoleSessionId is undefined + svc.$setActiveConsoleEditor('console-session-1'); + expect(svc.activeConsoleEditor).toBeUndefined(); + expect(fired).toEqual([undefined]); + }); + + it('fires onDidChangeActiveConsoleEditor on each session change', function () { + const fakeA = Object.freeze({}) as import('vscode').TextEditor; + const fakeB = Object.freeze({}) as import('vscode').TextEditor; + const docsAndEditors = createDocsAndEditors({ + 'console-session-a': { value: fakeA } as unknown as ExtHostTextEditor, + 'console-session-b': { value: fakeB } as unknown as ExtHostTextEditor, + }); + + const shape = createMockShape(); + const svc = new ExtHostConsoleService(SingleProxyRPCProtocol(shape), new NullLogService(), docsAndEditors); + + const fired: (import('vscode').TextEditor | undefined)[] = []; + disposables.add(svc.onDidChangeActiveConsoleEditor((e) => fired.push(e))); + + svc.$onDidChangeActiveConsole('session-a'); + svc.$setActiveConsoleEditor('console-session-a'); + svc.$onDidChangeActiveConsole('session-b'); + svc.$setActiveConsoleEditor('console-session-b'); + svc.$onDidChangeActiveConsole(undefined); + svc.$setActiveConsoleEditor(null); + + expect(fired).toEqual([fakeA, fakeB, undefined]); + expect(svc.activeConsoleEditor).toBeUndefined(); + }); + }); +}); diff --git a/src/vs/workbench/services/positronConsole/browser/interfaces/positronConsoleService.ts b/src/vs/workbench/services/positronConsole/browser/interfaces/positronConsoleService.ts index 53af87cad109..3ec8752572ac 100644 --- a/src/vs/workbench/services/positronConsole/browser/interfaces/positronConsoleService.ts +++ b/src/vs/workbench/services/positronConsole/browser/interfaces/positronConsoleService.ts @@ -487,6 +487,12 @@ export interface IPositronConsoleInstance { */ codeEditor: ICodeEditor | undefined; + /** + * An event that fires when the code editor is assigned for the first time + * (i.e. when the ConsoleInput React component mounts and sets the editor). + */ + readonly onDidSetCodeEditor: Event; + /** * Toggles trace. */ diff --git a/src/vs/workbench/services/positronConsole/browser/positronConsoleService.ts b/src/vs/workbench/services/positronConsole/browser/positronConsoleService.ts index 515c1017e756..2d01cd188ee6 100644 --- a/src/vs/workbench/services/positronConsole/browser/positronConsoleService.ts +++ b/src/vs/workbench/services/positronConsole/browser/positronConsoleService.ts @@ -1357,6 +1357,11 @@ class PositronConsoleInstance extends Disposable implements IPositronConsoleInst */ private readonly _onDidRequestRevealExecutionEmitter = this._register(new Emitter); + /** + * Fires once when the ConsoleInput React component assigns the code editor. + */ + private readonly _onDidSetCodeEditorEmitter = this._register(new Emitter()); + /** * Provides access to the code editor, if it's available. Note that we generally prefer to * interact with this editor indirectly, since its state is managed by React. @@ -1465,6 +1470,9 @@ class PositronConsoleInstance extends Disposable implements IPositronConsoleInst */ set codeEditor(value: ICodeEditor | undefined) { this._codeEditor = value; + if (value) { + this._onDidSetCodeEditorEmitter.fire(value); + } } get sessionMetadata(): IRuntimeSessionMetadata { @@ -1654,6 +1662,11 @@ class PositronConsoleInstance extends Disposable implements IPositronConsoleInst */ readonly onDidPasteText = this._onDidPasteTextEmitter.event; + /** + * onDidSetCodeEditor event. + */ + readonly onDidSetCodeEditor = this._onDidSetCodeEditorEmitter.event; + /** * onDidSelectAll event. */ diff --git a/src/vs/workbench/services/positronConsole/test/browser/testPositronConsoleService.ts b/src/vs/workbench/services/positronConsole/test/browser/testPositronConsoleService.ts index fa8eb70e4a85..ea8dc53744b9 100644 --- a/src/vs/workbench/services/positronConsole/test/browser/testPositronConsoleService.ts +++ b/src/vs/workbench/services/positronConsole/test/browser/testPositronConsoleService.ts @@ -313,6 +313,7 @@ export class TestPositronConsoleInstance implements IPositronConsoleInstance { private readonly _onDidAttachSessionEmitter = new Emitter(); private readonly _onDidChangeWidthInCharsEmitter = new Emitter(); private readonly _onDidRequestRevealExecutionEmitter = new Emitter(); + private readonly _onDidSetCodeEditorEmitter = new Emitter(); private _findWidget: IConsoleFindWidget | undefined; @@ -331,9 +332,19 @@ export class TestPositronConsoleInstance implements IPositronConsoleInstance { public readonly sessionMetadata: IRuntimeSessionMetadata, public readonly runtimeMetadata: ILanguageRuntimeMetadata, public readonly runtimeItems: RuntimeItem[] = [], - public readonly codeEditor: ICodeEditor | undefined = undefined + public codeEditor: ICodeEditor | undefined = undefined ) { } + /** + * Attaches a code editor and fires the onDidSetCodeEditor event, mirroring the console input + * component assigning its Monaco editor once it mounts. + * @param codeEditor The code editor to attach. + */ + setCodeEditor(codeEditor: ICodeEditor): void { + this.codeEditor = codeEditor; + this._onDidSetCodeEditorEmitter.fire(codeEditor); + } + get onFocusInput(): Event { return this._onFocusInputEmitter.event; } @@ -406,6 +417,10 @@ export class TestPositronConsoleInstance implements IPositronConsoleInstance { return this._onDidRequestRevealExecutionEmitter.event; } + get onDidSetCodeEditor(): Event { + return this._onDidSetCodeEditorEmitter.event; + } + get onDidChangeWidthInChars(): Event { return this._onDidChangeWidthInCharsEmitter.event; }