diff --git a/.vscode/launch.json b/.vscode/launch.json index b5403eb8d8c..8d772880c8f 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -185,6 +185,7 @@ "order": 4 } }, + // --- Start Positron { // Note this uses the version of Code/Positron you launch it from. // i.e., launch from dev Positron if your tests need dev Positron. @@ -205,6 +206,7 @@ "order": 5 } }, + // --- End Positron { "type": "extensionHost", "request": "launch", diff --git a/extensions/positron-r/.zed/settings.json b/extensions/positron-r/.zed/settings.json new file mode 100644 index 00000000000..56826c418ba --- /dev/null +++ b/extensions/positron-r/.zed/settings.json @@ -0,0 +1,19 @@ +// Folder-specific settings +// +// For a full list of overridable settings, and general information on folder-specific settings, +// see the documentation: https://zed.dev/docs/configuring-zed#settings-files +{ + "languages": { + "TypeScript": { + "tab_size": 2, + "hard_tabs": true, + "ensure_final_newline_on_save": true, + "remove_trailing_whitespace_on_save": true, + "format_on_save": "on", + "formatter": "language_server", + "code_actions_on_format": { + "source.fixAll.eslint": false + } + } + } +} diff --git a/extensions/positron-r/src/commands.ts b/extensions/positron-r/src/commands.ts index b7fc208b1ae..5641feda527 100644 --- a/extensions/positron-r/src/commands.ts +++ b/extensions/positron-r/src/commands.ts @@ -76,7 +76,7 @@ export async function registerCommands(context: vscode.ExtensionContext, runtime if (!isInstalled) { return; } - const session = RSessionManager.instance.getConsoleSession(); + const session = await RSessionManager.instance.getConsoleSession(); if (!session) { return; } @@ -169,7 +169,7 @@ export async function registerCommands(context: vscode.ExtensionContext, runtime }), vscode.commands.registerCommand('r.scriptPath', async () => { - const session = RSessionManager.instance.getConsoleSession(); + const session = await RSessionManager.instance.getConsoleSession(); if (!session) { throw new Error(`Cannot get Rscript path; no R session available`); } diff --git a/extensions/positron-r/src/llm-tools.ts b/extensions/positron-r/src/llm-tools.ts index a18627a3e26..4e61178b855 100644 --- a/extensions/positron-r/src/llm-tools.ts +++ b/extensions/positron-r/src/llm-tools.ts @@ -15,7 +15,7 @@ export function registerRLanguageModelTools(context: vscode.ExtensionContext): v const rListPackageHelpTopicsTool = vscode.lm.registerTool<{ sessionIdentifier: string; packageName: string }>('listPackageHelpTopics', { invoke: async (options, token) => { const manager = RSessionManager.instance; - const session = manager.getSessionById(options.input.sessionIdentifier); + const session = await manager.getSessionById(options.input.sessionIdentifier); if (!session) { return new vscode.LanguageModelToolResult([ new vscode.LanguageModelTextPart(`No active R session with identifier ${options.input.sessionIdentifier}`), @@ -44,7 +44,7 @@ export function registerRLanguageModelTools(context: vscode.ExtensionContext): v const rListAvailableVignettesTool = vscode.lm.registerTool<{ sessionIdentifier: string; packageName: string }>('listAvailableVignettes', { invoke: async (options, token) => { const manager = RSessionManager.instance; - const session = manager.getSessionById(options.input.sessionIdentifier); + const session = await manager.getSessionById(options.input.sessionIdentifier); if (!session) { return new vscode.LanguageModelToolResult([ new vscode.LanguageModelTextPart(`No active R session with identifier ${options.input.sessionIdentifier}`), @@ -73,7 +73,7 @@ export function registerRLanguageModelTools(context: vscode.ExtensionContext): v const rGetPackageVignetteTool = vscode.lm.registerTool<{ sessionIdentifier: string; packageName: string; vignetteName: string }>('getPackageVignette', { invoke: async (options, token) => { const manager = RSessionManager.instance; - const session = manager.getSessionById(options.input.sessionIdentifier); + const session = await manager.getSessionById(options.input.sessionIdentifier); if (!session) { return new vscode.LanguageModelToolResult([ new vscode.LanguageModelTextPart(`No active R session with identifier ${options.input.sessionIdentifier}`), @@ -103,7 +103,7 @@ export function registerRLanguageModelTools(context: vscode.ExtensionContext): v const rGetHelpPageTool = vscode.lm.registerTool<{ sessionIdentifier: string; packageName?: string; helpTopic: string }>('getHelpPage', { invoke: async (options, token) => { const manager = RSessionManager.instance; - const session = manager.getSessionById(options.input.sessionIdentifier); + const session = await manager.getSessionById(options.input.sessionIdentifier); if (!session) { return new vscode.LanguageModelToolResult([ new vscode.LanguageModelTextPart(`No active R session with identifier ${options.input.sessionIdentifier}`), diff --git a/extensions/positron-r/src/lsp-output-channel-manager.ts b/extensions/positron-r/src/lsp-output-channel-manager.ts deleted file mode 100644 index 0809580ded1..00000000000 --- a/extensions/positron-r/src/lsp-output-channel-manager.ts +++ /dev/null @@ -1,58 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (C) 2024 Posit Software, PBC. All rights reserved. - * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as vscode from 'vscode'; -import * as positron from 'positron'; - -const LSP_OUTPUT_CHANNEL_DESCRIPTOR = 'Language Server'; - -/** - * Manages all the R LSP output channels - * - * Only cleaned up when Positron is closed. Output channels are persistant so they can be reused - * between sessions of the same key (session name + session mode), and so you can use them for - * debugging after a kernel crashes. - */ -export class RLspOutputChannelManager { - /// Singleton instance - private static _instance: RLspOutputChannelManager; - - /// Map of keys to OutputChannel instances - private _channels: Map = new Map(); - - /// Constructor; private since we only want one of these - // eslint-disable-next-line @typescript-eslint/no-empty-function - private constructor() { } - - /** - * Accessor for the singleton instance; creates it if it doesn't exist. - */ - static get instance(): RLspOutputChannelManager { - if (!RLspOutputChannelManager._instance) { - RLspOutputChannelManager._instance = new RLspOutputChannelManager(); - } - return RLspOutputChannelManager._instance; - } - - /** - * Gets the output channel for the given key. Creates one if the key hasn't been provided yet. - * - * @param sessionName The session name of the session to get the output channel for. - * @param sessionMode The session mode of the session to get the output channel for. - * @returns An output channel. - */ - getOutputChannel(sessionName: string, sessionMode: string): vscode.OutputChannel { - const key = `${sessionName}-${sessionMode}`; - let out = this._channels.get(key); - - if (!out) { - const name = `${sessionName}: ${LSP_OUTPUT_CHANNEL_DESCRIPTOR} (${sessionMode.charAt(0).toUpperCase() + sessionMode.slice(1)})`; - out = positron.window.createRawLogOutputChannel(name); - this._channels.set(key, out); - } - - return out; - } -} diff --git a/extensions/positron-r/src/lsp.ts b/extensions/positron-r/src/lsp.ts index 28850d2f9dd..7c26729b176 100644 --- a/extensions/positron-r/src/lsp.ts +++ b/extensions/positron-r/src/lsp.ts @@ -20,10 +20,25 @@ import { import { Socket } from 'net'; import { RHelpTopicProvider } from './help'; -import { RLspOutputChannelManager } from './lsp-output-channel-manager'; import { R_DOCUMENT_SELECTORS } from './provider'; import { VirtualDocumentProvider } from './virtual-documents'; +/** + * Global output channel for R LSP sessions + * + * Since we only have one LSP session active at any time, and since the start of + * a new session is logged with a session ID, we use a single output channel for + * all LSP sessions. Watch out for session start log messages to find the + * relevant section of the log. + */ +let _lspOutputChannel: vscode.OutputChannel | undefined; +function getLspOutputChannel(): vscode.OutputChannel { + if (!_lspOutputChannel) { + _lspOutputChannel = positron.window.createRawLogOutputChannel('R Language Server'); + } + return _lspOutputChannel; +} + /** * The state of the language server. */ @@ -107,12 +122,6 @@ export class ArkLsp implements vscode.Disposable { const { notebookUri } = this._metadata; - // Persistant output channel, used across multiple sessions of the same name + mode combination - const outputChannel = RLspOutputChannelManager.instance.getOutputChannel( - this._dynState.sessionName, - this._metadata.sessionMode - ); - const clientOptions: LanguageClientOptions = { // If this client belongs to a notebook, set the document selector to only include that notebook. // Otherwise, this is the main client for this language, so set the document selector to include @@ -126,7 +135,7 @@ export class ArkLsp implements vscode.Disposable { fileEvents: vscode.workspace.createFileSystemWatcher('**/*.R') }, errorHandler: new RErrorHandler(this._version, port), - outputChannel: outputChannel, + outputChannel: getLspOutputChannel(), revealOutputChannelOn: RevealOutputChannelOn.Never, middleware: { handleDiagnostics(uri, diagnostics, next) { @@ -146,7 +155,7 @@ export class ArkLsp implements vscode.Disposable { const message = `Creating language client ${this._dynState.sessionName} for session ${this._metadata.sessionId} on port ${port}`; LOGGER.info(message); - outputChannel.appendLine(message); + getLspOutputChannel().appendLine(message); this.client = new LanguageClient(id, this.languageClientName, serverOptions, clientOptions); @@ -319,10 +328,6 @@ export class ArkLsp implements vscode.Disposable { } public showOutput() { - const outputChannel = RLspOutputChannelManager.instance.getOutputChannel( - this._dynState.sessionName, - this._metadata.sessionMode - ); - outputChannel.show(); + getLspOutputChannel().show(); } } diff --git a/extensions/positron-r/src/session-manager.ts b/extensions/positron-r/src/session-manager.ts index bc5b9331a4d..1db0119e67b 100644 --- a/extensions/positron-r/src/session-manager.ts +++ b/extensions/positron-r/src/session-manager.ts @@ -5,12 +5,10 @@ import * as positron from 'positron'; import * as vscode from 'vscode'; -import { RSession } from './session'; +import { RSession, getActiveRSessions } from './session'; /** - * Manages all the R sessions. We keep our own references to each session in a - * singleton instance of this class so that we can invoke methods/check status - * directly, without going through Positron's API. + * Manages all the R sessions. */ export class RSessionManager implements vscode.Disposable { /// Singleton instance @@ -21,9 +19,6 @@ export class RSessionManager implements vscode.Disposable { /// but we may improve on this in the future so it is good practice to track them. private readonly _disposables: vscode.Disposable[] = []; - /// Map of session IDs to RSession instances - private _sessions: Map = new Map(); - /// The most recent foreground R session (foreground implies it is a console session) private _lastForegroundSessionId: string | null = null; @@ -50,18 +45,12 @@ export class RSessionManager implements vscode.Disposable { } /** - * Registers a runtime with the manager. Throws an error if a runtime with - * the same ID is already registered. + * Registers a runtime with the manager. * - * @param id The runtime's ID - * @param runtime The runtime. + * @param session The session. */ - setSession(sessionId: string, session: RSession): void { - if (this._sessions.has(sessionId)) { - throw new Error(`Session ${sessionId} already registered.`); - } - this._sessions.set(sessionId, session); - this._disposables.push( + setSession(session: RSession): void { + session.register( session.onDidChangeRuntimeState(async (state) => { await this.didChangeSessionRuntimeState(session, state); }) @@ -99,11 +88,10 @@ export class RSessionManager implements vscode.Disposable { return; } - // TODO: Switch to `getActiveRSessions()` built on `positron.runtime.getActiveSessions()` - // and remove `this._sessions` entirely. - const session = this._sessions.get(sessionId); + const sessions = await getActiveRSessions(); + const session = sessions.find(s => s.metadata.sessionId === sessionId); if (!session) { - // The foreground session is for another language. + // The foreground session is for another language or was deactivated in the meantime return; } @@ -111,6 +99,9 @@ export class RSessionManager implements vscode.Disposable { throw Error(`Foreground session with ID ${sessionId} must not be a background session.`); } + // Multiple `activateConsoleSession()` might run concurrently if the + // `didChangeForegroundSession` event fires rapidly. We might want to queue + // the handling. this._lastForegroundSessionId = session.metadata.sessionId; await this.activateConsoleSession(session, 'foreground session changed'); } @@ -120,7 +111,8 @@ export class RSessionManager implements vscode.Disposable { */ private async activateConsoleSession(session: RSession, reason: string): Promise { // Deactivate other console session servers first - await Promise.all(Array.from(this._sessions.values()) + const sessions = await getActiveRSessions(); + await Promise.all(sessions .filter(s => { return s.metadata.sessionId !== session.metadata.sessionId && s.metadata.sessionMode === positron.LanguageRuntimeSessionMode.Console; @@ -152,9 +144,10 @@ export class RSessionManager implements vscode.Disposable { * * @returns The R console session, or undefined if there isn't one. */ - getConsoleSession(): RSession | undefined { + async getConsoleSession(): Promise { + const sessions = await getActiveRSessions(); + // Sort the sessions by creation time (descending) - const sessions = Array.from(this._sessions.values()); sessions.sort((a, b) => b.created - a.created); // Remove any sessions that aren't console sessions and have either @@ -186,8 +179,9 @@ export class RSessionManager implements vscode.Disposable { * @param sessionId The session identifier * @returns The R session, or undefined if not found */ - getSessionById(sessionId: string): RSession | undefined { - return this._sessions.get(sessionId); + async getSessionById(sessionId: string): Promise { + const sessions = await getActiveRSessions(); + return sessions.find(s => s.metadata.sessionId === sessionId); } /** diff --git a/extensions/positron-r/src/session.ts b/extensions/positron-r/src/session.ts index 4852c5e86b8..5d09fba0059 100644 --- a/extensions/positron-r/src/session.ts +++ b/extensions/positron-r/src/session.ts @@ -99,6 +99,9 @@ export class RSession implements positron.LanguageRuntimeSession, vscode.Disposa /** Cache of installed packages and associated version info */ private _packageCache: Map = new Map(); + /** Disposables. Disposed of after main resources (LSP, kernel, etc) */ + private _disposables: vscode.Disposable[] = []; + /** The current dynamic runtime state */ public dynState: positron.LanguageRuntimeDynState; @@ -126,7 +129,7 @@ export class RSession implements positron.LanguageRuntimeSession, vscode.Disposa this._created = Date.now(); // Register this session with the session manager - RSessionManager.instance.setSession(metadata.sessionId, this); + RSessionManager.instance.setSession(this); this.onDidChangeRuntimeState(async (state) => { await this.onStateChange(state); @@ -408,6 +411,15 @@ export class RSession implements positron.LanguageRuntimeSession, vscode.Disposa if (this._kernel) { await this._kernel.dispose(); } + + // LIFO clean up of external resources + while (this._disposables.length > 0) { + this._disposables.pop()?.dispose(); + } + } + + async register(disposable: vscode.Disposable) { + this._disposables.push(disposable); } /** @@ -882,7 +894,7 @@ export class RSession implements positron.LanguageRuntimeSession, vscode.Disposa LOGGER.info(`Unknown DAP message: ${message.method}`); if (message.kind === 'request') { - message.handle(() => { throw new Error(`Unknown request '${message.method}' for DAP comm`) }); + message.handle(() => { throw new Error(`Unknown request '${message.method}' for DAP comm`); }); } } } @@ -976,7 +988,7 @@ export function createJupyterKernelExtra(): JupyterKernelExtra { export async function checkInstalled(pkgName: string, pkgVersion?: string, session?: RSession): Promise { - session = session || RSessionManager.instance.getConsoleSession(); + session = session || await RSessionManager.instance.getConsoleSession(); if (session) { return session.checkInstalled(pkgName, pkgVersion); } @@ -984,7 +996,7 @@ export async function checkInstalled(pkgName: string, } export async function getLocale(session?: RSession): Promise { - session = session || RSessionManager.instance.getConsoleSession(); + session = session || await RSessionManager.instance.getConsoleSession(); if (session) { return session.getLocale(); } @@ -992,9 +1004,15 @@ export async function getLocale(session?: RSession): Promise { } export async function getEnvVars(envVars: string[], session?: RSession): Promise { - session = session || RSessionManager.instance.getConsoleSession(); + session = session || await RSessionManager.instance.getConsoleSession(); if (session) { return session.getEnvVars(envVars); } throw new Error(`Cannot get env var information; no R session available`); } + +/** Get the active R language runtime sessions. */ +export async function getActiveRSessions(): Promise { + const sessions = await positron.runtime.getActiveSessions(); + return sessions.filter((session) => session instanceof RSession) as RSession[]; +} diff --git a/extensions/positron-r/src/test/ark-comm.test.ts b/extensions/positron-r/src/test/ark-comm.test.ts index 076e38d875b..82f1841cc87 100644 --- a/extensions/positron-r/src/test/ark-comm.test.ts +++ b/extensions/positron-r/src/test/ark-comm.test.ts @@ -3,7 +3,7 @@ * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ -import './mocha-setup' +import './mocha-setup'; import * as assert from 'assert'; import * as vscode from 'vscode'; @@ -49,12 +49,12 @@ suite('ArkComm', () => { i: -10 } } - ) + ); }); test('Can send request', async () => { const requestReply = await assertRequest(comm, 'test_request', { i: 11 }); - assert.deepStrictEqual(requestReply, { i: -11 }) + assert.deepStrictEqual(requestReply, { i: -11 }); }); test('Invalid method sends error', async () => { @@ -86,7 +86,7 @@ async function assertNextMessage(comm: Comm): Promise { whenTimeout(5000, () => assert.fail(`Timeout while expecting comm message on ${comm.id}`)), ]) as any; - assert.strictEqual(result.done, false) + assert.strictEqual(result.done, false); return result.value; } diff --git a/extensions/positron-r/src/test/debugger.test.ts b/extensions/positron-r/src/test/debugger.test.ts index 5674c1ec7b6..8d7ade97cb3 100644 --- a/extensions/positron-r/src/test/debugger.test.ts +++ b/extensions/positron-r/src/test/debugger.test.ts @@ -3,7 +3,7 @@ * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ -import './mocha-setup' +import './mocha-setup'; import * as vscode from 'vscode'; import * as assert from 'assert'; @@ -55,7 +55,7 @@ suite('Debugger', () => { new RegExp('Virtual namespace of package graphics'), `Unexpected editor contents for ${ed.document.uri.fsPath}: Expected graphics namespace` ); - }) + }); }); }); @@ -86,7 +86,7 @@ suite('Debugger', () => { new RegExp('f <- function'), `Unexpected editor contents for ${ed.document.uri.fsPath}` ); - }) + }); }); }); }); diff --git a/extensions/positron-r/src/test/discovery.test.ts b/extensions/positron-r/src/test/discovery.test.ts index 44f12a86067..d2c0f396ae0 100644 --- a/extensions/positron-r/src/test/discovery.test.ts +++ b/extensions/positron-r/src/test/discovery.test.ts @@ -3,7 +3,7 @@ * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ -import './mocha-setup' +import './mocha-setup'; import * as assert from 'assert'; import * as Fs from "fs"; diff --git a/extensions/positron-r/src/test/hyperlink.test.ts b/extensions/positron-r/src/test/hyperlink.test.ts index de3768c69a6..fb5467a82f8 100644 --- a/extensions/positron-r/src/test/hyperlink.test.ts +++ b/extensions/positron-r/src/test/hyperlink.test.ts @@ -3,7 +3,7 @@ * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ -import './mocha-setup' +import './mocha-setup'; import * as assert from 'assert'; import { matchRunnable } from '../hyperlink'; diff --git a/extensions/positron-r/src/test/indentation.test.ts b/extensions/positron-r/src/test/indentation.test.ts index 361711f0e72..522da942502 100644 --- a/extensions/positron-r/src/test/indentation.test.ts +++ b/extensions/positron-r/src/test/indentation.test.ts @@ -3,7 +3,7 @@ * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ -import './mocha-setup' +import './mocha-setup'; import * as vscode from 'vscode'; import * as positron from 'positron'; diff --git a/extensions/positron-r/src/test/lsp.unit.test.ts b/extensions/positron-r/src/test/lsp.unit.test.ts index 89af1eda42b..f7a7cb95863 100644 --- a/extensions/positron-r/src/test/lsp.unit.test.ts +++ b/extensions/positron-r/src/test/lsp.unit.test.ts @@ -3,7 +3,7 @@ * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ -import './mocha-setup' +import './mocha-setup'; import * as assert from 'assert'; import * as testKit from './kit'; @@ -45,7 +45,7 @@ suite('Session manager', () => { // The LSP of the first session eventually goes back online testKit.pollForSuccess(() => { assert.strictEqual(ses1Lsp.state, ArkLspState.Running); - }) + }); // We would expect the following but currently we start the LSP client // anew on each activation, so the event handler is no longer active. diff --git a/extensions/positron-r/src/test/mocha-setup.ts b/extensions/positron-r/src/test/mocha-setup.ts index c2bf00f8102..003842bbe3c 100644 --- a/extensions/positron-r/src/test/mocha-setup.ts +++ b/extensions/positron-r/src/test/mocha-setup.ts @@ -9,8 +9,10 @@ import * as testKit from './kit'; export let currentTestName: string | undefined; suiteSetup(async () => { - // Set global Positron log level to trace for easier debugging - await vscode.commands.executeCommand('_extensionTests.setLogLevel', 'trace'); + // Set global Positron log level to trace on CI for easier debugging + if (process.env.CI) { + await vscode.commands.executeCommand('_extensionTests.setLogLevel', 'trace'); + } // Set Ark kernel process log level to trace await vscode.workspace.getConfiguration().update('positron.r.kernel.logLevel', 'trace', vscode.ConfigurationTarget.Global); diff --git a/extensions/positron-r/src/test/rstudioapi.test.ts b/extensions/positron-r/src/test/rstudioapi.test.ts index fdc09f4c62c..adb7de06d13 100644 --- a/extensions/positron-r/src/test/rstudioapi.test.ts +++ b/extensions/positron-r/src/test/rstudioapi.test.ts @@ -3,7 +3,7 @@ * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ -import './mocha-setup' +import './mocha-setup'; import * as vscode from 'vscode'; import * as path from 'path'; diff --git a/extensions/positron-r/src/test/view.test.ts b/extensions/positron-r/src/test/view.test.ts index b8528694480..15d2ef9e969 100644 --- a/extensions/positron-r/src/test/view.test.ts +++ b/extensions/positron-r/src/test/view.test.ts @@ -3,7 +3,7 @@ * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ -import './mocha-setup' +import './mocha-setup'; import * as vscode from 'vscode'; import * as path from 'path'; diff --git a/extensions/positron-r/src/uri-handler.ts b/extensions/positron-r/src/uri-handler.ts index 89d194223d0..40406424877 100644 --- a/extensions/positron-r/src/uri-handler.ts +++ b/extensions/positron-r/src/uri-handler.ts @@ -24,7 +24,7 @@ export async function registerUriHandler() { // "fragment": "", // "fsPath": "/cli" // } -function handleUri(uri: vscode.Uri): void { +async function handleUri(uri: vscode.Uri): Promise { if (uri.path !== '/cli') { return; } @@ -44,7 +44,7 @@ function handleUri(uri: vscode.Uri): void { return; } - const session = RSessionManager.instance.getConsoleSession(); + const session = await RSessionManager.instance.getConsoleSession(); if (!session) { return; } @@ -54,7 +54,7 @@ function handleUri(uri: vscode.Uri): void { } export async function prepCliEnvVars(session?: RSession): Promise { - session = session || RSessionManager.instance.getConsoleSession(); + session = session || await RSessionManager.instance.getConsoleSession(); if (!session) { return {}; } diff --git a/extensions/positron-supervisor/src/KallichoreSession.ts b/extensions/positron-supervisor/src/KallichoreSession.ts index 0160c9eeb04..a0c4e218a73 100644 --- a/extensions/positron-supervisor/src/KallichoreSession.ts +++ b/extensions/positron-supervisor/src/KallichoreSession.ts @@ -47,6 +47,7 @@ import { CommBackendRequest, CommRpcMessage, CommImpl } from './Comm'; import { channel, Sender } from './Channel'; import { DapComm } from './DapComm'; import { JupyterKernelStatus } from './jupyter/JupyterKernelStatus.js'; +import { OutputChannelFormatted, LogOutputChannelFormatted } from './OutputChannelFormatted'; /** * The reason for a disconnection event. @@ -135,12 +136,12 @@ export class KallichoreSession implements JupyterLanguageRuntimeSession { /** * The channel to which output for this specific kernel is logged, if any */ - private readonly _kernelChannel: vscode.OutputChannel; + private readonly _kernelChannel: OutputChannelFormatted; /** * The channel to which output for this specific console is logged */ - private readonly _consoleChannel: vscode.LogOutputChannel; + private readonly _consoleChannel: LogOutputChannelFormatted; /** * The channel to which profile output for this specific kernel is logged, if any @@ -213,14 +214,18 @@ export class KallichoreSession implements JupyterLanguageRuntimeSession { this.onDidEndSession = this._exit.event; // Establish log channels for the console and kernel we're connecting to - this._consoleChannel = vscode.window.createOutputChannel( - metadata.notebookUri ? - `${runtimeMetadata.runtimeName}: Notebook: (${path.basename(metadata.notebookUri.path)})` : - `${runtimeMetadata.runtimeName}: Console`, - { log: true }); - - this._kernelChannel = positron.window.createRawLogOutputChannel( - `${runtimeMetadata.runtimeName}: Kernel`); + this._consoleChannel = new LogOutputChannelFormatted( + vscode.window.createOutputChannel( + `${runtimeMetadata.languageName} Supervisor`, + { log: true } + ), + (msg) => `${metadata.sessionId} ${msg}` + ); + + this._kernelChannel = new OutputChannelFormatted( + positron.window.createRawLogOutputChannel(`${runtimeMetadata.languageName} Kernel`), + (msg) => `${metadata.sessionId} ${msg}` + ); this._kernelChannel.appendLine(`** Begin kernel log for session ${dynState.sessionName} (${metadata.sessionId}) at ${new Date().toLocaleString()} **`); // Open the established barrier immediately if we're restoring an @@ -447,7 +452,7 @@ export class KallichoreSession implements JupyterLanguageRuntimeSession { }; await this._api.newSession(session); - this.log(`${kernelSpec.display_name} session '${this.metadata.sessionId}' created in ${workingDir} with command:`, vscode.LogLevel.Info); + this.log(`Session ${session.display_name} (${this.metadata.sessionId}) created in ${workingDir} with command:`, vscode.LogLevel.Info); this.log(args.join(' '), vscode.LogLevel.Info); this._established.open(); diff --git a/extensions/positron-supervisor/src/OutputChannelFormatted.ts b/extensions/positron-supervisor/src/OutputChannelFormatted.ts new file mode 100644 index 00000000000..bec3d902b29 --- /dev/null +++ b/extensions/positron-supervisor/src/OutputChannelFormatted.ts @@ -0,0 +1,144 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2025 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; + +/** + * Function type for formatting messages before they are appended to the channel. + */ +export type MessageFormatter = (message: string) => string; + +/** + * OutputChannel with formatting applied to `appendLine()`. + */ +export class OutputChannelFormatted implements vscode.OutputChannel { + constructor( + private readonly channel: vscode.OutputChannel, + private readonly formatter: MessageFormatter + ) { } + + get name(): string { + return this.channel.name; + } + + append(value: string): void { + this.channel.append(value); + } + + appendLine(value: string): void { + this.channel.appendLine(this.formatter(value)); + } + + replace(value: string): void { + this.channel.replace(value); + } + + clear(): void { + this.channel.clear(); + } + + show(preserveFocus?: boolean): void; + show(column?: vscode.ViewColumn, preserveFocus?: boolean): void; + show(columnOrPreserveFocus?: vscode.ViewColumn | boolean, preserveFocus?: boolean): void { + if (typeof columnOrPreserveFocus === 'boolean' || columnOrPreserveFocus === undefined) { + this.channel.show(columnOrPreserveFocus); + } else { + this.channel.show(preserveFocus); + } + } + + hide(): void { + this.channel.hide(); + } + + dispose(): void { + this.channel.dispose(); + } +} + +/** + * A wrapper around LogOutputChannel that allows custom message formatting for all log methods. + */ +export class LogOutputChannelFormatted implements vscode.LogOutputChannel { + constructor( + private readonly channel: vscode.LogOutputChannel, + private readonly formatter: MessageFormatter + ) { } + + get logLevel(): vscode.LogLevel { + return this.channel.logLevel; + } + + get onDidChangeLogLevel(): vscode.Event { + return this.channel.onDidChangeLogLevel; + } + + get name(): string { + return this.channel.name; + } + + append(value: string): void { + this.channel.append(value); + } + + appendLine(value: string): void { + this.channel.appendLine(this.formatter(value)); + } + + replace(value: string): void { + this.channel.replace(value); + } + + clear(): void { + this.channel.clear(); + } + + show(preserveFocus?: boolean): void; + show(column?: vscode.ViewColumn, preserveFocus?: boolean): void; + show(columnOrPreserveFocus?: vscode.ViewColumn | boolean, preserveFocus?: boolean): void { + if (typeof columnOrPreserveFocus === 'boolean' || columnOrPreserveFocus === undefined) { + this.channel.show(columnOrPreserveFocus); + } else { + this.channel.show(preserveFocus); + } + } + + hide(): void { + this.channel.hide(); + } + + dispose(): void { + this.channel.dispose(); + } + + trace(message: string, ...args: any[]): void { + this.channel.trace(this.formatter(message), ...args); + } + + debug(message: string, ...args: any[]): void { + this.channel.debug(this.formatter(message), ...args); + } + + info(message: string, ...args: any[]): void { + this.channel.info(this.formatter(message), ...args); + } + + warn(message: string, ...args: any[]): void { + this.channel.warn(this.formatter(message), ...args); + } + + error(message: string | Error, ...args: any[]): void { + if (typeof message === 'string') { + this.channel.error(this.formatter(message), ...args); + } else { + // Format the error message and include stack trace if available + const formatted = this.formatter(message.message); + const fullMessage = message.stack + ? `${formatted}\n${message.stack}` + : formatted; + this.channel.error(fullMessage, ...args); + } + } +}