From 33b7f4c394876b2d6bde9a7b5500641ab4d9f37c Mon Sep 17 00:00:00 2001 From: Fred-Wu <4111978+Fred-Wu@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:44:53 +1000 Subject: [PATCH 01/10] Use active R session packages for language completion --- R/languageServer.R | 42 ++++++++++++++++++++++++++++++++++- src/extension.ts | 4 +++- src/languageService.ts | 50 ++++++++++++++++++++++++++++++++++++++++++ src/session.ts | 4 +++- 4 files changed, 97 insertions(+), 3 deletions(-) diff --git a/R/languageServer.R b/R/languageServer.R index b24c1eab..5499a9ab 100644 --- a/R/languageServer.R +++ b/R/languageServer.R @@ -42,4 +42,44 @@ if (identical(Sys.getenv("VSCR_LSP_SYNTHETIC_WORKSPACE"), "TRUE")) { options(languageserver.index_mode = "off") } -languageserver::run(port = port, debug = debug) +tools::Rd2txt_options(underline_titles = FALSE) +tools::Rd2txt_options(itemBullet = "* ") +languageserver:::lsp_settings$update_from_options() +if (isTRUE(debug)) { + languageserver:::lsp_settings$set("debug", TRUE) + languageserver:::lsp_settings$set("log_file", NULL) +} + +normalize_character <- function(value) { + if (is.list(value)) { + value <- unlist(value, use.names = FALSE) + } + if (!is.character(value)) { + return(character()) + } + unique(value[nzchar(value)]) +} + +server <- languageserver:::LanguageServer$new("localhost", port) +server$request_handlers[["r/syncSessionState"]] <- function(self, id, params) { + attached_packages <- normalize_character(params$attachedPackages) + loaded_namespaces <- normalize_character(params$loadedNamespaces) + + for (workspace in self$workspaces$values()) { + workspace$startup_packages <- if (length(attached_packages)) { + # languageserver resolves package conflicts from the end of this list. + rev(attached_packages) + } else { + languageserver:::workspace_startup_packages() + } + workspace$update_loaded_packages() + + for (pkg in unique(c(attached_packages, loaded_namespaces))) { + try(workspace$get_namespace(pkg), silent = TRUE) + } + } + + self$deliver(languageserver:::Response$new(id, result = TRUE)) +} + +server$run() diff --git a/src/extension.ts b/src/extension.ts index b511094a..e4ebc41e 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -40,6 +40,7 @@ export let globalPlotManager: PlotManager | undefined = undefined; export let rmdPreviewManager: rmarkdown.RMarkdownPreviewManager | undefined = undefined; export let rmdKnitManager: rmarkdown.RMarkdownKnitManager | undefined = undefined; export let sessionStatusBarItem: vscode.StatusBarItem | undefined = undefined; +export let rLanguageService: languageService.LanguageService | undefined = undefined; // Called (once) when the extension is activated export async function activate(context: vscode.ExtensionContext): Promise { @@ -187,7 +188,8 @@ export async function activate(context: vscode.ExtensionContext): Promise = new Map(); private readonly initSet: Set = new Set(); @@ -22,6 +32,8 @@ export class LanguageService implements Disposable { private readonly clientUpdates = new Map>(); private readonly listeners: Disposable[] = []; private disposed = false; + private sessionState: SessionState | undefined; + private sessionStateKey: string | undefined; constructor() { this.outputChannel = window.createOutputChannel('R Language Server'); @@ -35,6 +47,43 @@ export class LanguageService implements Disposable { return this.stopLanguageService(); } + syncSessionState(data?: SessionWorkspaceData): void { + const state: SessionState = { + attachedPackages: data?.search + .filter(value => value.startsWith('package:')) + .map(value => value.substring(8)) ?? [], + loadedNamespaces: data?.loaded_namespaces ?? [], + }; + const stateKey = this.getSessionStateKey(state); + if (this.sessionStateKey === stateKey) { + return; + } + + this.sessionState = state; + this.sessionStateKey = stateKey; + for (const client of this.clients.values()) { + void this.applySessionState(client); + } + } + + private async applySessionState(client: LanguageClient): Promise { + if (!this.sessionState) { + return; + } + try { + await client.sendRequest('r/syncSessionState', this.sessionState); + } catch { + // Keep language-service features available if session synchronization fails. + } + } + + private getSessionStateKey(state: SessionState): string { + return [ + state.attachedPackages.join('\u0000'), + state.loadedNamespaces.join('\u0000'), + ].join('\u0001'); + } + private spawnServer(client: LanguageClient, rPath: string, args: readonly string[], options: CommonOptions & { cwd: string }): DisposableProcess { const childProcess = spawn(rPath, args, options); const pid = childProcess.pid || -1; @@ -228,6 +277,7 @@ export class LanguageService implements Disposable { extensionContext.subscriptions.push(client); await client.start(); + await this.applySessionState(client); return client; } diff --git a/src/session.ts b/src/session.ts index 35403d32..8936451e 100644 --- a/src/session.ts +++ b/src/session.ts @@ -14,7 +14,7 @@ import * as rTerminal from './rTerminal'; import { getProcessAncestors } from './processTree'; import { purgeAddinPickerItems, RSEditOperation, RSRange } from './rstudioapi'; -import { extensionContext, rWorkspace, globalRHelp, globalPlotManager, sessionStatusBarItem, enableSessionWatcher } from './extension'; +import { extensionContext, rWorkspace, globalRHelp, globalPlotManager, sessionStatusBarItem, enableSessionWatcher, rLanguageService } from './extension'; import { resolveBackend, jgdEnabled, CommonPlotManager } from './plotViewer'; import type { RSessionConnectionInfo } from './api'; @@ -2063,6 +2063,7 @@ async function refreshActiveSession(session: Session): Promise { sessionDir = session.sessionDir; workingDir = session.workingDir; workspaceData = session.workspaceData; + rLanguageService?.syncSessionState(workspaceData); if (sessionStatusBarItem) { const version = rVer.replace(/^R (?:version )?/, '').replace(/\s+\(.*/, ''); @@ -2465,6 +2466,7 @@ export async function cleanupSession(sessionId: string, closingSocket?: IpcSocke session.socket.destroy(); } if (activeSession === session) { + rLanguageService?.syncSessionState(); await clearActiveSession(); } } From acb6b89d6d9e33f887ee4325a392c3aaabb1837e Mon Sep 17 00:00:00 2001 From: Fred-Wu <4111978+Fred-Wu@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:30:13 +1000 Subject: [PATCH 02/10] Route session packages to the active language server --- src/languageService.ts | 75 ++++++++++++++++++++++++++++-------------- src/rTerminal.ts | 2 +- src/session.ts | 19 +++++++---- 3 files changed, 64 insertions(+), 32 deletions(-) diff --git a/src/languageService.ts b/src/languageService.ts index 3e76df40..3727aae8 100644 --- a/src/languageService.ts +++ b/src/languageService.ts @@ -32,8 +32,7 @@ export class LanguageService implements Disposable { private readonly clientUpdates = new Map>(); private readonly listeners: Disposable[] = []; private disposed = false; - private sessionState: SessionState | undefined; - private sessionStateKey: string | undefined; + private readonly sessionStates: Map = new Map(); constructor() { this.outputChannel = window.createOutputChannel('R Language Server'); @@ -47,31 +46,51 @@ export class LanguageService implements Disposable { return this.stopLanguageService(); } - syncSessionState(data?: SessionWorkspaceData): void { + syncSessionState(data?: SessionWorkspaceData, resource?: Uri, sessionId = ''): void { + const clientKey = this.config.get('lsp.multiServer') === true + ? resource ? this.getClientKey(resource) : undefined + : 'global'; + if (!clientKey) { + return; + } + + const current = this.sessionStates.get(clientKey); + if (!data) { + if (!current || (sessionId && current.sessionId !== sessionId)) { + return; + } + this.sessionStates.delete(clientKey); + const client = this.clients.get(clientKey); + if (client) { + void this.applySessionState(client, { + attachedPackages: [], + loadedNamespaces: [], + }); + } + return; + } + const state: SessionState = { - attachedPackages: data?.search + attachedPackages: data.search .filter(value => value.startsWith('package:')) - .map(value => value.substring(8)) ?? [], - loadedNamespaces: data?.loaded_namespaces ?? [], + .map(value => value.substring(8)), + loadedNamespaces: data.loaded_namespaces, }; const stateKey = this.getSessionStateKey(state); - if (this.sessionStateKey === stateKey) { + if (current?.stateKey === stateKey && current.sessionId === sessionId) { return; } - this.sessionState = state; - this.sessionStateKey = stateKey; - for (const client of this.clients.values()) { - void this.applySessionState(client); + this.sessionStates.set(clientKey, { state, stateKey, sessionId }); + const client = this.clients.get(clientKey); + if (client) { + void this.applySessionState(client, state); } } - private async applySessionState(client: LanguageClient): Promise { - if (!this.sessionState) { - return; - } + private async applySessionState(client: LanguageClient, state: SessionState): Promise { try { - await client.sendRequest('r/syncSessionState', this.sessionState); + await client.sendRequest('r/syncSessionState', state); } catch { // Keep language-service features available if session synchronization fails. } @@ -115,7 +134,7 @@ export class LanguageService implements Disposable { private async createClient(selector: DocumentFilter[], cwd: string, workspaceFolder: WorkspaceFolder | undefined, outputChannel: OutputChannel, - resource?: Uri, target?: Session): Promise { + resource?: Uri, clientKey: string = 'global', target?: Session): Promise { let client: LanguageClient; const virtualOnly = selector.every(filter => 'scheme' in filter @@ -277,7 +296,10 @@ export class LanguageService implements Disposable { extensionContext.subscriptions.push(client); await client.start(); - await this.applySessionState(client); + const sessionState = this.sessionStates.get(clientKey)?.state; + if (sessionState) { + await this.applySessionState(client, sessionState); + } return client; } @@ -294,6 +316,11 @@ export class LanguageService implements Disposable { return false; } + private getClientKey(uri: Uri): string { + const folder = workspace.getWorkspaceFolder(uri); + return this.getKey(folder?.uri ?? uri); + } + private getKey(uri: Uri): string { switch (uri.scheme) { case 'untitled': @@ -324,7 +351,7 @@ export class LanguageService implements Disposable { const key = document.uri.toString(); if (!this.checkClient(key)) { const selector = [{ scheme: 'vscode-interactive-input', language: 'r', pattern: document.uri.fsPath }]; - const client = await this.createClient(selector, target?.workingDir ?? folder?.uri.fsPath ?? os.homedir(), folder, this.outputChannel, folder?.uri, target); + const client = await this.createClient(selector, target?.workingDir ?? folder?.uri.fsPath ?? os.homedir(), folder, this.outputChannel, folder?.uri, key, target); this.clients.set(key, client); this.initSet.delete(key); } return; @@ -339,7 +366,7 @@ export class LanguageService implements Disposable { { scheme: 'vscode-notebook-cell', language: 'r', pattern: `${document.uri.fsPath}` }, ]; const client = await this.createClient(documentSelector, - target?.workingDir ?? folder?.uri.fsPath ?? dirname(document.uri.fsPath), folder, this.outputChannel, folder?.uri ?? document.uri, target); + target?.workingDir ?? folder?.uri.fsPath ?? dirname(document.uri.fsPath), folder, this.outputChannel, folder?.uri ?? document.uri, key, target); this.clients.set(key, client); this.initSet.delete(key); } @@ -357,7 +384,7 @@ export class LanguageService implements Disposable { { scheme: 'file', language: 'r', pattern: pattern }, { scheme: 'file', language: 'rmd', pattern: pattern }, ]; - const client = await this.createClient(documentSelector, folder.uri.fsPath, folder, this.outputChannel, folder.uri); + const client = await this.createClient(documentSelector, folder.uri.fsPath, folder, this.outputChannel, folder.uri, key); this.clients.set(key, client); this.initSet.delete(key); } @@ -373,7 +400,7 @@ export class LanguageService implements Disposable { { scheme: 'untitled', language: 'r' }, { scheme: 'untitled', language: 'rmd' }, ]; - const client = await this.createClient(documentSelector, os.homedir(), undefined, this.outputChannel, document.uri); + const client = await this.createClient(documentSelector, os.homedir(), undefined, this.outputChannel, document.uri, key); this.clients.set(key, client); this.initSet.delete(key); } @@ -389,7 +416,7 @@ export class LanguageService implements Disposable { { scheme: 'file', pattern: document.uri.fsPath }, ]; const client = await this.createClient(documentSelector, - dirname(document.uri.fsPath), undefined, this.outputChannel, document.uri); + dirname(document.uri.fsPath), undefined, this.outputChannel, document.uri, key); this.clients.set(key, client); this.initSet.delete(key); } @@ -494,7 +521,7 @@ export class LanguageService implements Disposable { const workspaceFolder = workspace.workspaceFolders?.[0]; const cwd = workspaceFolder ? workspaceFolder.uri.fsPath : os.homedir(); - const client = await this.createClient(documentSelector, cwd, undefined, this.outputChannel, workspaceFolder?.uri); + const client = await this.createClient(documentSelector, cwd, undefined, this.outputChannel, workspaceFolder?.uri, 'global'); this.clients.set('global', client); this.startMultiLanguageService(true); } diff --git a/src/rTerminal.ts b/src/rTerminal.ts index d014c637..0afeb859 100644 --- a/src/rTerminal.ts +++ b/src/rTerminal.ts @@ -366,7 +366,7 @@ export function deleteTerminal(term: vscode.Terminal): void { } } -function getTerminalResource(term: vscode.Terminal): vscode.Uri | undefined { +export function getTerminalResource(term: vscode.Terminal): vscode.Uri | undefined { if (term === rTerm && rTermResource) { return rTermResource; } diff --git a/src/session.ts b/src/session.ts index 8936451e..496c1e2b 100644 --- a/src/session.ts +++ b/src/session.ts @@ -72,6 +72,7 @@ export class Session { public info: SessionInfo; public sessionDir: string; public workingDir: string; + public resource: Uri | undefined; public workspaceData: WorkspaceData; constructor(sessionId: string, host: string, sessVersion: string, pipePath: string, socket: IpcSocket) { @@ -85,6 +86,7 @@ export class Session { this.info = { version: '', command: '', start_time: '' }; this.sessionDir = ''; this.workingDir = ''; + this.resource = undefined; this.workspaceData = { search: [], loaded_namespaces: [], globalenv: {} }; } } @@ -2063,7 +2065,7 @@ async function refreshActiveSession(session: Session): Promise { sessionDir = session.sessionDir; workingDir = session.workingDir; workspaceData = session.workspaceData; - rLanguageService?.syncSessionState(workspaceData); + rLanguageService?.syncSessionState(workspaceData, session.resource, session.sessionId); if (sessionStatusBarItem) { const version = rVer.replace(/^R (?:version )?/, '').replace(/\s+\(.*/, ''); @@ -2100,13 +2102,13 @@ function isLocalHost(host: string): boolean { return host.length > 0 && host.toLocaleLowerCase() === os.hostname().toLocaleLowerCase(); } -async function findLocalTerminalPid(rPid: string): Promise { +async function findLocalTerminal(rPid: string): Promise { const candidates = new Map(); for (const terminal of window.terminals) { const terminalPid = await terminal.processId; if (terminalPid === undefined || isTerminalClosed(terminal) || terminal.exitStatus) { continue; } if (String(terminalPid) === rPid) { - return String(terminalPid); + return terminal; } candidates.set(terminalPid, terminal); } @@ -2122,7 +2124,7 @@ async function findLocalTerminalPid(rPid: string): Promise { if (attachedRPids.has(String(pid))) { return undefined; } const terminal = candidates.get(pid); if (terminal && !isTerminalClosed(terminal) && !terminal.exitStatus && window.terminals.includes(terminal)) { - return String(pid); + return terminal; } } return undefined; @@ -2185,9 +2187,11 @@ async function handleNotification(message: Record, socket: IpcS } const rPid = typeof params.pid === 'string' || typeof params.pid === 'number' ? String(params.pid) : ''; - const terminalPid = rPid && isLocalHost(host) - ? await findLocalTerminalPid(rPid) + const terminal = rPid && isLocalHost(host) + ? await findLocalTerminal(rPid) : undefined; + const matchedTerminalPid = await terminal?.processId; + const terminalPid = matchedTerminalPid === undefined ? undefined : String(matchedTerminalPid); const selectedTerminal = window.activeTerminal; const selectedTerminalPid = terminalPid ? await selectedTerminal?.processId : undefined; if (socket.destroyed) { @@ -2232,6 +2236,7 @@ async function handleNotification(message: Record, socket: IpcS session.info = (params.info as SessionInfo | undefined) ?? { version: session.rVer, command: '', start_time: '' }; session.sessionDir = params.tempdir; session.workingDir = params.wd; + session.resource = terminal ? rTerminal.getTerminalResource(terminal) : undefined; if (terminalPid) { terminalSessionAttached.fire(terminalPid); @@ -2465,8 +2470,8 @@ export async function cleanupSession(sessionId: string, closingSocket?: IpcSocke if (!session.socket.destroyed && session.socket !== closingSocket) { session.socket.destroy(); } + rLanguageService?.syncSessionState(undefined, session.resource, session.sessionId); if (activeSession === session) { - rLanguageService?.syncSessionState(); await clearActiveSession(); } } From b33b25575e84062fb5bea20c5698b221d6744f08 Mon Sep 17 00:00:00 2001 From: Fred-Wu <4111978+Fred-Wu@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:04:43 +1000 Subject: [PATCH 03/10] Handle unscoped session package completion --- src/languageService.ts | 71 ++++++++++++++++++++++++------------------ src/rTerminal.ts | 6 ++-- 2 files changed, 42 insertions(+), 35 deletions(-) diff --git a/src/languageService.ts b/src/languageService.ts index 3727aae8..cda54c75 100644 --- a/src/languageService.ts +++ b/src/languageService.ts @@ -32,6 +32,7 @@ export class LanguageService implements Disposable { private readonly clientUpdates = new Map>(); private readonly listeners: Disposable[] = []; private disposed = false; + private readonly clientScopes: Map = new Map(); private readonly sessionStates: Map = new Map(); constructor() { @@ -47,26 +48,17 @@ export class LanguageService implements Disposable { } syncSessionState(data?: SessionWorkspaceData, resource?: Uri, sessionId = ''): void { - const clientKey = this.config.get('lsp.multiServer') === true - ? resource ? this.getClientKey(resource) : undefined - : 'global'; - if (!clientKey) { - return; - } - - const current = this.sessionStates.get(clientKey); + const scope = this.getSessionScope(resource); + const current = this.sessionStates.get(scope); if (!data) { if (!current || (sessionId && current.sessionId !== sessionId)) { return; } - this.sessionStates.delete(clientKey); - const client = this.clients.get(clientKey); - if (client) { - void this.applySessionState(client, { - attachedPackages: [], - loadedNamespaces: [], - }); - } + this.sessionStates.delete(scope); + this.applySessionStateToScope(scope, { + attachedPackages: [], + loadedNamespaces: [], + }); return; } @@ -81,10 +73,15 @@ export class LanguageService implements Disposable { return; } - this.sessionStates.set(clientKey, { state, stateKey, sessionId }); - const client = this.clients.get(clientKey); - if (client) { - void this.applySessionState(client, state); + this.sessionStates.set(scope, { state, stateKey, sessionId }); + this.applySessionStateToScope(scope, state); + } + + private applySessionStateToScope(scope: string, state: SessionState): void { + for (const [key, client] of this.clients) { + if (this.clientScopes.get(key) === scope) { + void this.applySessionState(client, state); + } } } @@ -134,7 +131,7 @@ export class LanguageService implements Disposable { private async createClient(selector: DocumentFilter[], cwd: string, workspaceFolder: WorkspaceFolder | undefined, outputChannel: OutputChannel, - resource?: Uri, clientKey: string = 'global', target?: Session): Promise { + resource?: Uri, sessionScope: string = 'global', target?: Session): Promise { let client: LanguageClient; const virtualOnly = selector.every(filter => 'scheme' in filter @@ -296,7 +293,7 @@ export class LanguageService implements Disposable { extensionContext.subscriptions.push(client); await client.start(); - const sessionState = this.sessionStates.get(clientKey)?.state; + const sessionState = this.sessionStates.get(sessionScope)?.state; if (sessionState) { await this.applySessionState(client, sessionState); } @@ -316,9 +313,12 @@ export class LanguageService implements Disposable { return false; } - private getClientKey(uri: Uri): string { - const folder = workspace.getWorkspaceFolder(uri); - return this.getKey(folder?.uri ?? uri); + private getSessionScope(resource?: Uri): string { + if (this.config.get('lsp.multiServer') !== true) { + return 'global'; + } + const folder = resource ? workspace.getWorkspaceFolder(resource) : undefined; + return folder ? this.getKey(folder.uri) : 'unscoped'; } private getKey(uri: Uri): string { @@ -349,10 +349,11 @@ export class LanguageService implements Disposable { if (document.uri.scheme === 'vscode-interactive-input') { const key = document.uri.toString(); + const scope = this.getSessionScope(folder?.uri); if (!this.checkClient(key)) { const selector = [{ scheme: 'vscode-interactive-input', language: 'r', pattern: document.uri.fsPath }]; - const client = await this.createClient(selector, target?.workingDir ?? folder?.uri.fsPath ?? os.homedir(), folder, this.outputChannel, folder?.uri, key, target); - this.clients.set(key, client); this.initSet.delete(key); + const client = await this.createClient(selector, target?.workingDir ?? folder?.uri.fsPath ?? os.homedir(), folder, this.outputChannel, folder?.uri, scope, target); + this.clients.set(key, client); this.clientScopes.set(key, scope); this.initSet.delete(key); } return; } @@ -360,14 +361,16 @@ export class LanguageService implements Disposable { // Each notebook uses a server started from parent folder if (document.uri.scheme === 'vscode-notebook-cell') { const key = this.getKey(document.uri); + const scope = this.getSessionScope(folder?.uri); if (!this.checkClient(key)) { console.log(`Start language server for ${document.uri.toString(true)}`); const documentSelector: DocumentFilter[] = [ { scheme: 'vscode-notebook-cell', language: 'r', pattern: `${document.uri.fsPath}` }, ]; const client = await this.createClient(documentSelector, - target?.workingDir ?? folder?.uri.fsPath ?? dirname(document.uri.fsPath), folder, this.outputChannel, folder?.uri ?? document.uri, key, target); + target?.workingDir ?? folder?.uri.fsPath ?? dirname(document.uri.fsPath), folder, this.outputChannel, folder?.uri ?? document.uri, scope, target); this.clients.set(key, client); + this.clientScopes.set(key, scope); this.initSet.delete(key); } return; @@ -386,6 +389,7 @@ export class LanguageService implements Disposable { ]; const client = await this.createClient(documentSelector, folder.uri.fsPath, folder, this.outputChannel, folder.uri, key); this.clients.set(key, client); + this.clientScopes.set(key, key); this.initSet.delete(key); } @@ -400,8 +404,9 @@ export class LanguageService implements Disposable { { scheme: 'untitled', language: 'r' }, { scheme: 'untitled', language: 'rmd' }, ]; - const client = await this.createClient(documentSelector, os.homedir(), undefined, this.outputChannel, document.uri, key); + const client = await this.createClient(documentSelector, os.homedir(), undefined, this.outputChannel, document.uri, 'unscoped'); this.clients.set(key, client); + this.clientScopes.set(key, 'unscoped'); this.initSet.delete(key); } return; @@ -416,8 +421,9 @@ export class LanguageService implements Disposable { { scheme: 'file', pattern: document.uri.fsPath }, ]; const client = await this.createClient(documentSelector, - dirname(document.uri.fsPath), undefined, this.outputChannel, document.uri, key); + dirname(document.uri.fsPath), undefined, this.outputChannel, document.uri, 'unscoped'); this.clients.set(key, client); + this.clientScopes.set(key, 'unscoped'); this.initSet.delete(key); } return; @@ -449,6 +455,7 @@ export class LanguageService implements Disposable { const client = this.clients.get(key); if (client) { this.clients.delete(key); + this.clientScopes.delete(key); this.initSet.delete(key); void client.stop(); } @@ -466,7 +473,7 @@ export class LanguageService implements Disposable { // repeatedly. Restart its shared server only once per owner. if (restart && clientTargets.get(key) !== target) { const client = this.clients.get(key); - this.clients.delete(key); this.initSet.delete(key); + this.clients.delete(key); this.clientScopes.delete(key); this.initSet.delete(key); await client?.stop(); } await didOpenTextDocument(document); @@ -494,6 +501,7 @@ export class LanguageService implements Disposable { const client = this.clients.get(key); if (client) { this.clients.delete(key); + this.clientScopes.delete(key); this.initSet.delete(key); void client.stop(); } @@ -524,6 +532,7 @@ export class LanguageService implements Disposable { const client = await this.createClient(documentSelector, cwd, undefined, this.outputChannel, workspaceFolder?.uri, 'global'); this.clients.set('global', client); this.startMultiLanguageService(true); + this.clientScopes.set('global', 'global'); } } diff --git a/src/rTerminal.ts b/src/rTerminal.ts index 0afeb859..eb9cad06 100644 --- a/src/rTerminal.ts +++ b/src/rTerminal.ts @@ -368,7 +368,7 @@ export function deleteTerminal(term: vscode.Terminal): void { export function getTerminalResource(term: vscode.Terminal): vscode.Uri | undefined { if (term === rTerm && rTermResource) { - return rTermResource; + return vscode.workspace.getWorkspaceFolder(rTermResource)?.uri; } const creationOptions = term.creationOptions; @@ -377,9 +377,7 @@ export function getTerminalResource(term: vscode.Terminal): vscode.Uri | undefin const cwdResource = typeof cwd === 'string' ? vscode.workspace.workspaceFolders?.find(folder => folder.uri.fsPath === cwd)?.uri ?? vscode.Uri.file(cwd) : cwd; - return cwdResource - ? getCurrentWorkspaceFolder(cwdResource)?.uri ?? cwdResource - : getCurrentWorkspaceFolder()?.uri; + return cwdResource ? vscode.workspace.getWorkspaceFolder(cwdResource)?.uri : undefined; } export function findTerminal(): vscode.Terminal | undefined { From 0a5cf6f5e4dd937e4d3de9b7ab58845ffe7898d6 Mon Sep 17 00:00:00 2001 From: "4111978+Fred-Wu@users.noreply.github.com" <4111978+Fred-Wu@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:55:02 +1100 Subject: [PATCH 04/10] fix(lsp): preserve bound session packages after master rebase --- src/languageService.ts | 20 ++++-- src/session.ts | 4 ++ src/test/suite/languageService.test.ts | 96 ++++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 4 deletions(-) create mode 100644 src/test/suite/languageService.test.ts diff --git a/src/languageService.ts b/src/languageService.ts index cda54c75..53493ce0 100644 --- a/src/languageService.ts +++ b/src/languageService.ts @@ -47,8 +47,17 @@ export class LanguageService implements Disposable { return this.stopLanguageService(); } - syncSessionState(data?: SessionWorkspaceData, resource?: Uri, sessionId = ''): void { - const scope = this.getSessionScope(resource); + syncSessionState(data?: SessionWorkspaceData, resource?: Uri, sessionId = '', active = true): void { + // Bound virtual documents keep their owner's packages when focus moves. + if (sessionId) { + this.syncSessionScope(`session:${sessionId}`, data, sessionId); + } + if (active || !data) { + this.syncSessionScope(this.getSessionScope(resource), data, sessionId); + } + } + + private syncSessionScope(scope: string, data: SessionWorkspaceData | undefined, sessionId: string): void { const current = this.sessionStates.get(scope); if (!data) { if (!current || (sessionId && current.sessionId !== sessionId)) { @@ -293,6 +302,9 @@ export class LanguageService implements Disposable { extensionContext.subscriptions.push(client); await client.start(); + if (target) { + this.syncSessionScope(sessionScope, target.workspaceData, target.sessionId); + } const sessionState = this.sessionStates.get(sessionScope)?.state; if (sessionState) { await this.applySessionState(client, sessionState); @@ -349,7 +361,7 @@ export class LanguageService implements Disposable { if (document.uri.scheme === 'vscode-interactive-input') { const key = document.uri.toString(); - const scope = this.getSessionScope(folder?.uri); + const scope = target ? `session:${target.sessionId}` : this.getSessionScope(folder?.uri); if (!this.checkClient(key)) { const selector = [{ scheme: 'vscode-interactive-input', language: 'r', pattern: document.uri.fsPath }]; const client = await this.createClient(selector, target?.workingDir ?? folder?.uri.fsPath ?? os.homedir(), folder, this.outputChannel, folder?.uri, scope, target); @@ -361,7 +373,7 @@ export class LanguageService implements Disposable { // Each notebook uses a server started from parent folder if (document.uri.scheme === 'vscode-notebook-cell') { const key = this.getKey(document.uri); - const scope = this.getSessionScope(folder?.uri); + const scope = target ? `session:${target.sessionId}` : this.getSessionScope(folder?.uri); if (!this.checkClient(key)) { console.log(`Start language server for ${document.uri.toString(true)}`); const documentSelector: DocumentFilter[] = [ diff --git a/src/session.ts b/src/session.ts index 496c1e2b..4b850044 100644 --- a/src/session.ts +++ b/src/session.ts @@ -128,6 +128,7 @@ export function bindSessionDocument(uri: Uri, session: Session): void { export function unbindSessionDocument(uri: Uri): void { documentSessions.delete(uri.toString()); } export function unregisterSessionTransport(target: Session): void { + rLanguageService?.syncSessionState(undefined, target.resource, target.sessionId); for (const [uri, owner] of documentSessions) { if (owner === target) { documentSessions.delete(uri); } } sessions.delete(target.sessionId); if (activeSession === target) { void clearActiveSession(); } @@ -162,6 +163,7 @@ export async function executeSessionCode(target: Session, code: string): Promise export function updateSessionWorkspace(target: Session, data: WorkspaceData): void { target.workspaceData = data; + rLanguageService?.syncSessionState(data, target.resource, target.sessionId, false); if (activeSession === target) { void refreshActiveSession(target); } @@ -169,6 +171,7 @@ export function updateSessionWorkspace(target: Session, data: WorkspaceData): vo /** Move document routing to a new process; existing data viewers keep their old owner. */ export function replaceSessionTransport(previous: Session, next: Session): void { + rLanguageService?.syncSessionState(undefined, previous.resource, previous.sessionId); for (const [uri, owner] of documentSessions) { if (owner === previous) { documentSessions.set(uri, next); } } sessions.delete(previous.sessionId); if (activeSession === previous) { activeSession = next; } @@ -179,6 +182,7 @@ export function registerSessionTransport(id: string, host: string, directory: st const target = sessions.get(id) ?? new Session(id, host, '', '', new net.Socket()); target.requester = requester; target.workingDir = directory; + target.resource = vscode.workspace.getWorkspaceFolder(Uri.file(directory))?.uri; sessions.set(id, target); return target; } diff --git a/src/test/suite/languageService.test.ts b/src/test/suite/languageService.test.ts new file mode 100644 index 00000000..5e53e4a1 --- /dev/null +++ b/src/test/suite/languageService.test.ts @@ -0,0 +1,96 @@ +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import * as vscode from 'vscode'; +import { LanguageService } from '../../languageService'; +import * as session from '../../session'; +import * as extension from '../../extension'; + +suite('Session package completion', () => { + let sandbox: sinon.SinonSandbox; + let service: LanguageService; + let multiServer: boolean; + const folder = { uri: vscode.Uri.file('/project'), name: 'project', index: 0 }; + const packages = (name: string) => ({ search: ['.GlobalEnv', `package:${name}`, 'package:base'], loaded_namespaces: [name, 'base'], globalenv: {} }); + + // Keep the tests independent of installed R packages and server processes. + type ServiceInternals = { + startLanguageService(): Promise; + clients: Map; + clientScopes: Map; + }; + function client(key: string, scope: string): sinon.SinonStub { + const sendRequest = sandbox.stub().resolves(true); + const internals = service as unknown as ServiceInternals; + internals.clients.set(key, { sendRequest, stop: sandbox.stub().resolves() }); + internals.clientScopes.set(key, scope); + return sendRequest; + } + + setup(() => { + sandbox = sinon.createSandbox(); + multiServer = false; + sandbox.stub(LanguageService.prototype as unknown as ServiceInternals, 'startLanguageService').resolves(); + sandbox.stub(vscode.workspace, 'getConfiguration').returns({ + get: (key: string) => key === 'lsp.multiServer' ? multiServer : undefined + } as vscode.WorkspaceConfiguration); + sandbox.stub(vscode.workspace, 'getWorkspaceFolder').callsFake(uri => uri.fsPath.startsWith('/project') ? folder : undefined); + service = new LanguageService(); + sandbox.stub(extension, 'rLanguageService').value(service); + }); + + teardown(async () => { + await service.dispose(); + sandbox.restore(); + }); + + test('global server follows active packages and ignores duplicate updates', () => { + const request = client('global', 'global'); + service.syncSessionState(packages('stats'), folder.uri, 'first'); + service.syncSessionState(packages('stats'), folder.uri, 'first'); + sinon.assert.calledOnceWithExactly(request, 'r/syncSessionState', { + attachedPackages: ['stats', 'base'], loadedNamespaces: ['stats', 'base'] + }); + service.syncSessionState(packages('utils'), undefined, 'second'); + service.syncSessionState(undefined, folder.uri, 'first'); + assert.strictEqual(request.callCount, 2, 'closing an older session must not clear the active packages'); + service.syncSessionState(undefined, undefined, 'second'); + sinon.assert.calledWithExactly(request, 'r/syncSessionState', { attachedPackages: [], loadedNamespaces: [] }); + }); + + test('multi-server mode separates workspace and unscoped documents', () => { + multiServer = true; + const project = client('project', folder.uri.toString(true)); + const untitled = client('untitled', 'unscoped'); + const externalFile = client('external', 'unscoped'); + service.syncSessionState(packages('stats'), folder.uri, 'project-session'); + sinon.assert.calledOnce(project); + sinon.assert.notCalled(untitled); + service.syncSessionState(packages('utils'), undefined, 'external-session'); + sinon.assert.calledOnce(project); + sinon.assert.calledOnce(untitled); + sinon.assert.calledOnce(externalFile); + }); + + test('inactive Interactive workspace updates reach only its bound language servers', () => { + const global = client('global', 'global'); + const input = client('input', 'session:interactive-packages'); + const notebook = client('notebook', 'session:interactive-packages'); + const target = session.registerSessionTransport('interactive-packages', 'host', '/project', () => Promise.resolve(undefined)); + try { + assert.strictEqual(target.resource?.toString(), folder.uri.toString()); + session.updateSessionWorkspace(target, packages('stats')); + sinon.assert.notCalled(global); + sinon.assert.calledOnce(input); + sinon.assert.calledOnce(notebook); + service.syncSessionState(packages('utils'), undefined, 'terminal'); + sinon.assert.calledOnce(input); + sinon.assert.calledOnce(notebook); + session.unregisterSessionTransport(target); + sinon.assert.calledWithExactly(input, 'r/syncSessionState', { attachedPackages: [], loadedNamespaces: [] }); + sinon.assert.calledWithExactly(notebook, 'r/syncSessionState', { attachedPackages: [], loadedNamespaces: [] }); + sinon.assert.calledOnce(global); + } finally { + session.unregisterSessionTransport(target); + } + }); +}); From 6e361b6d2b904c503d35f3ca5cb8723a4f1fea85 Mon Sep 17 00:00:00 2001 From: Fred-Wu <4111978+Fred-Wu@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:08:48 +1100 Subject: [PATCH 05/10] fix(lsp): resolve session workspace from working directory --- src/session.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/session.ts b/src/session.ts index 4b850044..5a387920 100644 --- a/src/session.ts +++ b/src/session.ts @@ -2240,7 +2240,8 @@ async function handleNotification(message: Record, socket: IpcS session.info = (params.info as SessionInfo | undefined) ?? { version: session.rVer, command: '', start_time: '' }; session.sessionDir = params.tempdir; session.workingDir = params.wd; - session.resource = terminal ? rTerminal.getTerminalResource(terminal) : undefined; + session.resource = (terminal ? rTerminal.getTerminalResource(terminal) : undefined) + ?? (isLocalHost(host) ? vscode.workspace.getWorkspaceFolder(Uri.file(params.wd))?.uri : undefined); if (terminalPid) { terminalSessionAttached.fire(terminalPid); From b2fbee22cb3626d9260ef5b45f4b5baa0d9b7398 Mon Sep 17 00:00:00 2001 From: Fred-Wu <4111978+Fred-Wu@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:39:12 +1100 Subject: [PATCH 06/10] fix(lsp): register clients before session state sync --- src/languageService.ts | 38 +++++++++++++++++--------------------- 1 file changed, 17 insertions(+), 21 deletions(-) diff --git a/src/languageService.ts b/src/languageService.ts index 53493ce0..8cbc2488 100644 --- a/src/languageService.ts +++ b/src/languageService.ts @@ -138,7 +138,7 @@ export class LanguageService implements Disposable { return childProcess; } - private async createClient(selector: DocumentFilter[], + private async createClient(key: string, selector: DocumentFilter[], cwd: string, workspaceFolder: WorkspaceFolder | undefined, outputChannel: OutputChannel, resource?: Uri, sessionScope: string = 'global', target?: Session): Promise { @@ -301,18 +301,24 @@ export class LanguageService implements Disposable { } extensionContext.subscriptions.push(client); - await client.start(); if (target) { this.syncSessionScope(sessionScope, target.workspaceData, target.sessionId); } + await client.start(); + await this.registerClient(key, client, sessionScope); + return client; + } + + + private async registerClient(key: string, client: LanguageClient, sessionScope: string): Promise { + this.clients.set(key, client); + this.clientScopes.set(key, sessionScope); const sessionState = this.sessionStates.get(sessionScope)?.state; if (sessionState) { await this.applySessionState(client, sessionState); } - return client; } - private checkClient(name: string): boolean { if (this.initSet.has(name)) { return true; @@ -364,8 +370,8 @@ export class LanguageService implements Disposable { const scope = target ? `session:${target.sessionId}` : this.getSessionScope(folder?.uri); if (!this.checkClient(key)) { const selector = [{ scheme: 'vscode-interactive-input', language: 'r', pattern: document.uri.fsPath }]; - const client = await this.createClient(selector, target?.workingDir ?? folder?.uri.fsPath ?? os.homedir(), folder, this.outputChannel, folder?.uri, scope, target); - this.clients.set(key, client); this.clientScopes.set(key, scope); this.initSet.delete(key); + await this.createClient(key, selector, target?.workingDir ?? folder?.uri.fsPath ?? os.homedir(), folder, this.outputChannel, folder?.uri, scope, target); + this.initSet.delete(key); } return; } @@ -379,10 +385,8 @@ export class LanguageService implements Disposable { const documentSelector: DocumentFilter[] = [ { scheme: 'vscode-notebook-cell', language: 'r', pattern: `${document.uri.fsPath}` }, ]; - const client = await this.createClient(documentSelector, + await this.createClient(key, documentSelector, target?.workingDir ?? folder?.uri.fsPath ?? dirname(document.uri.fsPath), folder, this.outputChannel, folder?.uri ?? document.uri, scope, target); - this.clients.set(key, client); - this.clientScopes.set(key, scope); this.initSet.delete(key); } return; @@ -399,9 +403,7 @@ export class LanguageService implements Disposable { { scheme: 'file', language: 'r', pattern: pattern }, { scheme: 'file', language: 'rmd', pattern: pattern }, ]; - const client = await this.createClient(documentSelector, folder.uri.fsPath, folder, this.outputChannel, folder.uri, key); - this.clients.set(key, client); - this.clientScopes.set(key, key); + await this.createClient(key, documentSelector, folder.uri.fsPath, folder, this.outputChannel, folder.uri, key); this.initSet.delete(key); } @@ -416,9 +418,7 @@ export class LanguageService implements Disposable { { scheme: 'untitled', language: 'r' }, { scheme: 'untitled', language: 'rmd' }, ]; - const client = await this.createClient(documentSelector, os.homedir(), undefined, this.outputChannel, document.uri, 'unscoped'); - this.clients.set(key, client); - this.clientScopes.set(key, 'unscoped'); + await this.createClient(key, documentSelector, os.homedir(), undefined, this.outputChannel, document.uri, 'unscoped'); this.initSet.delete(key); } return; @@ -432,10 +432,8 @@ export class LanguageService implements Disposable { const documentSelector: DocumentFilter[] = [ { scheme: 'file', pattern: document.uri.fsPath }, ]; - const client = await this.createClient(documentSelector, + await this.createClient(key, documentSelector, dirname(document.uri.fsPath), undefined, this.outputChannel, document.uri, 'unscoped'); - this.clients.set(key, client); - this.clientScopes.set(key, 'unscoped'); this.initSet.delete(key); } return; @@ -541,10 +539,8 @@ export class LanguageService implements Disposable { const workspaceFolder = workspace.workspaceFolders?.[0]; const cwd = workspaceFolder ? workspaceFolder.uri.fsPath : os.homedir(); - const client = await this.createClient(documentSelector, cwd, undefined, this.outputChannel, workspaceFolder?.uri, 'global'); - this.clients.set('global', client); + await this.createClient('global', documentSelector, cwd, undefined, this.outputChannel, workspaceFolder?.uri, 'global'); this.startMultiLanguageService(true); - this.clientScopes.set('global', 'global'); } } From cc26c50294f4ad69d8f6825086235efbbdbe3b52 Mon Sep 17 00:00:00 2001 From: Fred-Wu <4111978+Fred-Wu@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:39:50 +1100 Subject: [PATCH 07/10] test(lsp): cover session synchronization races --- src/test/suite/languageService.test.ts | 36 ++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/test/suite/languageService.test.ts b/src/test/suite/languageService.test.ts index 5e53e4a1..6add6d92 100644 --- a/src/test/suite/languageService.test.ts +++ b/src/test/suite/languageService.test.ts @@ -15,6 +15,7 @@ suite('Session package completion', () => { // Keep the tests independent of installed R packages and server processes. type ServiceInternals = { startLanguageService(): Promise; + registerClient(key: string, client: { sendRequest: sinon.SinonStub; stop: sinon.SinonStub }, scope: string): Promise; clients: Map; clientScopes: Map; }; @@ -57,6 +58,41 @@ suite('Session package completion', () => { sinon.assert.calledWithExactly(request, 'r/syncSessionState', { attachedPackages: [], loadedNamespaces: [] }); }); + test('client receives session updates while initial synchronization is pending', async () => { + let resolveInitial!: () => void; + const initial = new Promise(resolve => { resolveInitial = resolve; }); + const request = sandbox.stub(); + request.onFirstCall().returns(initial); + request.resolves(true); + const fakeClient = { sendRequest: request, stop: sandbox.stub().resolves() }; + const internals = service as unknown as ServiceInternals; + + service.syncSessionState({ + search: ['.GlobalEnv', 'package:base'], + loaded_namespaces: ['base'], + globalenv: {}, + }, folder.uri, 'session'); + + const registering = internals.registerClient('global', fakeClient, 'global'); + await Promise.resolve(); + + service.syncSessionState(packages('dplyr'), folder.uri, 'session'); + service.syncSessionState(undefined, folder.uri, 'session'); + + sinon.assert.calledWithExactly(request, 'r/syncSessionState', { + attachedPackages: ['base'], loadedNamespaces: ['base'] + }); + sinon.assert.calledWithExactly(request, 'r/syncSessionState', { + attachedPackages: ['dplyr', 'base'], loadedNamespaces: ['dplyr', 'base'] + }); + sinon.assert.calledWithExactly(request, 'r/syncSessionState', { + attachedPackages: [], loadedNamespaces: [] + }); + + resolveInitial(); + await registering; + }); + test('multi-server mode separates workspace and unscoped documents', () => { multiServer = true; const project = client('project', folder.uri.toString(true)); From d53815dbdca529b4fba60a4cf39b69fb0bccf3d5 Mon Sep 17 00:00:00 2001 From: Fred-Wu <4111978+Fred-Wu@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:40:18 +1100 Subject: [PATCH 08/10] test(session): cover workspace fallback without terminal association --- src/test/suite/session.test.ts | 39 ++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/test/suite/session.test.ts b/src/test/suite/session.test.ts index 396f3b4f..d6afcfc6 100644 --- a/src/test/suite/session.test.ts +++ b/src/test/suite/session.test.ts @@ -745,6 +745,45 @@ suite('Session Communication', () => { }); } + test('local session working directory resolves workspace without terminal association', async () => { + const endpoint = await session.getGlobalPipePath(); + const workspaceUri = vscode.Uri.file(path.join(os.tmpdir(), 'vscode-r-session-workspace')); + const workspaceFolder = { uri: workspaceUri, name: 'session-workspace', index: 0 }; + sandbox.stub(vscode.workspace, 'getWorkspaceFolder').callsFake(uri => + uri.fsPath === workspaceUri.fsPath || uri.fsPath.startsWith(`${workspaceUri.fsPath}${path.sep}`) + ? workspaceFolder + : undefined); + const terminal = { processId: Promise.resolve(41000) } as unknown as vscode.Terminal; + sandbox.stub(vscode.window, 'terminals').value([terminal]); + sandbox.stub(vscode.window, 'activeTerminal').value(terminal); + ancestors.withArgs(41001).resolves([]); + + const existing = new Set(session.activeConnections); + const client = net.createConnection(endpoint); + try { + await new Promise((resolve, reject) => { + client.once('connect', resolve); + client.once('error', reject); + }); + const socket = await waitFor(() => [...session.activeConnections].find(candidate => !existing.has(candidate))); + assert.ok(socket); + client.write(`${JSON.stringify({ + jsonrpc: '2.0', method: 'attach', params: { + protocol_version: 2, session_id: 'workspace-fallback', host: os.hostname(), + pid: 41001, version: '4.4.0', tempdir: os.tmpdir(), + wd: path.join(workspaceUri.fsPath, 'project'), + }, + })}\n`); + const attached = await waitFor(() => session.activeSession?.sessionId === 'workspace-fallback' + ? session.activeSession : undefined); + assert.strictEqual(attached.resource?.toString(), workspaceUri.toString()); + assert.strictEqual(socket._terminalPid, undefined); + } finally { + client.destroy(); + await session.cleanupSession('workspace-fallback'); + } + }); + test('terminal readiness aborts on close even before processId resolves', async () => { const terminal = { processId: new Promise(() => undefined) } as unknown as vscode.Terminal; const close = new vscode.EventEmitter(); From 089f636ad6f57c347e409d6877e2f08c85e97b34 Mon Sep 17 00:00:00 2001 From: Fred-Wu <4111978+Fred-Wu@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:15:41 +1100 Subject: [PATCH 09/10] fix(lsp): replay session state for added workspaces --- src/languageService.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/languageService.ts b/src/languageService.ts index 8cbc2488..05328527 100644 --- a/src/languageService.ts +++ b/src/languageService.ts @@ -244,6 +244,17 @@ export class LanguageService implements Disposable { fileEvents: workspace.createFileSystemWatcher('**/*.{R,r}'), }, middleware: { + workspace: { + didChangeWorkspaceFolders: async (event, next) => { + await next(event); + if (sessionScope === 'global') { + const sessionState = this.sessionStates.get(sessionScope)?.state; + if (sessionState) { + await this.applySessionState(client, sessionState); + } + } + }, + }, provideSignatureHelp: async (document, position, context, token, next) => { const result = await next(document, position, context, token); // An empty LSP result still suppresses other VS Code providers. From bc96ca0c8ce03dd50a6f081ee87641e7c8db739e Mon Sep 17 00:00:00 2001 From: Fred-Wu <4111978+Fred-Wu@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:17:59 +1100 Subject: [PATCH 10/10] test(lsp): cover session workspace synchronization --- src/test/suite/languageService.test.ts | 33 +++++++++++++++++++++++++- src/test/suite/session.test.ts | 1 + 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/test/suite/languageService.test.ts b/src/test/suite/languageService.test.ts index 6add6d92..4141ff81 100644 --- a/src/test/suite/languageService.test.ts +++ b/src/test/suite/languageService.test.ts @@ -1,9 +1,11 @@ import * as assert from 'assert'; import * as sinon from 'sinon'; import * as vscode from 'vscode'; +import { LanguageClient } from 'vscode-languageclient/node'; import { LanguageService } from '../../languageService'; import * as session from '../../session'; import * as extension from '../../extension'; +import * as util from '../../util'; suite('Session package completion', () => { let sandbox: sinon.SinonSandbox; @@ -15,9 +17,13 @@ suite('Session package completion', () => { // Keep the tests independent of installed R packages and server processes. type ServiceInternals = { startLanguageService(): Promise; + createClient(key: string, selector: vscode.DocumentFilter[], cwd: string, + workspaceFolder: vscode.WorkspaceFolder | undefined, outputChannel: vscode.OutputChannel, + resource?: vscode.Uri, sessionScope?: string): Promise; registerClient(key: string, client: { sendRequest: sinon.SinonStub; stop: sinon.SinonStub }, scope: string): Promise; clients: Map; clientScopes: Map; + outputChannel: vscode.OutputChannel; }; function client(key: string, scope: string): sinon.SinonStub { const sendRequest = sandbox.stub().resolves(true); @@ -70,7 +76,6 @@ suite('Session package completion', () => { service.syncSessionState({ search: ['.GlobalEnv', 'package:base'], loaded_namespaces: ['base'], - globalenv: {}, }, folder.uri, 'session'); const registering = internals.registerClient('global', fakeClient, 'global'); @@ -93,6 +98,32 @@ suite('Session package completion', () => { await registering; }); + test('global server replays current packages after workspace folders change', async () => { + sandbox.stub(util, 'getRpath').resolves('/usr/bin/R'); + sandbox.stub(LanguageClient.prototype, 'start').resolves(); + const request = sandbox.stub(LanguageClient.prototype, 'sendRequest').resolves(true); + const internals = service as unknown as ServiceInternals; + const client = await internals.createClient( + 'global', [{ scheme: 'file', language: 'r' }], '/project', + undefined, internals.outputChannel, folder.uri, 'global' + ); + + service.syncSessionState(packages('dplyr'), folder.uri, 'session'); + request.resetHistory(); + + const middleware = client.middleware.workspace?.didChangeWorkspaceFolders; + assert.ok(middleware); + const event = { added: [folder], removed: [] }; + const next = sandbox.stub().resolves(); + await middleware(event, next); + + sinon.assert.calledOnceWithExactly(next, event); + sinon.assert.calledOnceWithExactly(request, 'r/syncSessionState', { + attachedPackages: ['dplyr', 'base'], loadedNamespaces: ['dplyr', 'base'] + }); + sinon.assert.callOrder(next, request); + }); + test('multi-server mode separates workspace and unscoped documents', () => { multiServer = true; const project = client('project', folder.uri.toString(true)); diff --git a/src/test/suite/session.test.ts b/src/test/suite/session.test.ts index d6afcfc6..f42e95c9 100644 --- a/src/test/suite/session.test.ts +++ b/src/test/suite/session.test.ts @@ -776,6 +776,7 @@ suite('Session Communication', () => { })}\n`); const attached = await waitFor(() => session.activeSession?.sessionId === 'workspace-fallback' ? session.activeSession : undefined); + assert.ok(attached); assert.strictEqual(attached.resource?.toString(), workspaceUri.toString()); assert.strictEqual(socket._terminalPid, undefined); } finally {