diff --git a/CHANGELOG.md b/CHANGELOG.md index ca1ecfb8..f5bef815 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## 3.2.0 - 2026-07-06 + +### Bug Fixes + +* Improved the R language server lifecycle for Quarto R chunks with delayed shutdown, cancellation of pending shutdowns, serialised restart handling, correct virtual-document routing and selectors, and cleanup of stopped clients and listeners. + +### Minor + +* Added formatted current-row and total-row feedback while dragging the data viewer's vertical scrollbar. + +### Maintenance + +* Upgraded AG Grid Community to 35.2.1 and refactored the existing data viewer to use the AG Grid 35 grid and theme APIs. +* Removed `data.table` as a dependency. + ## 3.1.0 - 2026-06-16 ### Bug Fixes diff --git a/R/session/init.R b/R/session/init.R index e81df40c..410263c2 100644 --- a/R/session/init.R +++ b/R/session/init.R @@ -17,7 +17,7 @@ init_first <- function() { } # check required packages - required_packages <- c("jsonlite", "rlang", "data.table") + required_packages <- c("jsonlite", "rlang") missing_packages <- required_packages[ !vapply(required_packages, requireNamespace, logical(1L), quietly = TRUE @@ -48,7 +48,6 @@ init_last <- function() { # cleanup previous version removeTaskCallback("vscode-R") options(vscodeR = NULL) - options(datatable.quiet = TRUE) .vsc.name <- "tools:vscode" if (.vsc.name %in% search()) { detach(.vsc.name, character.only = TRUE) diff --git a/R/session/vsc.R b/R/session/vsc.R index 18c84967..445b4dfd 100644 --- a/R/session/vsc.R +++ b/R/session/vsc.R @@ -72,28 +72,58 @@ get_column_def <- function(name, field, value) { toString(class(value)), typeof(value) ) - if (is.numeric(value)) { + units <- attr(value, "units", exact = TRUE) + if (!is.null(units)) { + tooltip <- sprintf("%s, units: %s", tooltip, toString(units)) + } + if (inherits(value, "integer64")) { + if (!requireNamespace("bit64", quietly = TRUE)) { + stop("Viewing integer64 columns requires the optional 'bit64' package") + } + type <- "bigintColumn" + filter <- "agBigIntColumnFilter" + } else if (is.numeric(value)) { type <- "numericColumn" filter <- "agNumberColumnFilter" - } else if (inherits(value, "Date") - || inherits(value, "POSIXct") - || inherits(value, "POSIXlt")) { + } else if (inherits(value, "Date")) { type <- "dateColumn" filter <- "agDateColumnFilter" + } else if (inherits(value, "POSIXct") || + inherits(value, "POSIXlt")) { + type <- "datetimeColumn" + filter <- "agDateColumnFilter" } else if (is.logical(value)) { type <- "booleanColumn" - filter <- "agNumberColumnFilter" + filter <- TRUE } else { type <- "textColumn" filter <- "agTextColumnFilter" } - list( - headerName = name, - headerTooltip = tooltip, - field = field, - type = type, - filter = filter + sortable <- !is.complex(value) && + !(is.list(value) && !inherits(value, "POSIXlt")) && + !is.raw(value) + if (field %in% c("x1", "x2")) { + sortable <- FALSE + filter <- FALSE + } + if (!sortable) { + filter <- FALSE + } + col_def <- list( + headerName = jsonlite::unbox(name), + headerTooltip = jsonlite::unbox(tooltip), + field = jsonlite::unbox(field), + type = jsonlite::unbox(type), + filter = jsonlite::unbox(filter), + sortable = jsonlite::unbox(sortable) ) + if (is.logical(value)) { + col_def$cellDataType <- jsonlite::unbox("boolean") + } + if (identical(field, "x1")) { + col_def$suppressHeaderMenuButton <- jsonlite::unbox(TRUE) + } + col_def } dataview_is_table <- function(data) { @@ -104,25 +134,38 @@ dataview_is_table <- function(data) { dataview_schema <- function(data) { if (inherits(data, "ArrowTabular")) { - return(data[0, ]$to_data_frame()) + return(data$Slice(0L, 0L)$to_data_frame()) } if (inherits(data, "polars_data_frame")) { - return(as.data.frame(data[0, ])) + return(as.data.frame(data$slice(0L, 0L))) } data[0, , drop = FALSE] } -dataview_slice <- function(data, rows) { +dataview_slice <- function(data, row_idx) { if (inherits(data, "ArrowTabular")) { - if (!length(rows)) { - return(data[0, ]$to_data_frame()) + if (!length(row_idx)) { + return(data$Slice(0L, 0L)$to_data_frame()) } - return(data[rows, ]$to_data_frame()) + return(data[row_idx, ]$to_data_frame()) } if (inherits(data, "polars_data_frame")) { - return(as.data.frame(data[rows, ])) + if (!length(row_idx)) { + return(as.data.frame(data$slice(0L, 0L))) + } + if (length(row_idx) == 1L || all(diff(row_idx) == 1L)) { + return(as.data.frame(data$slice(row_idx[[1L]] - 1L, length(row_idx)))) + } + return(as.data.frame(data[row_idx, ])) + } + if (is.matrix(data) && is.object(data)) { + page <- lapply(seq_len(ncol(data)), function(position) { + dataview_column(data, position)[row_idx] + }) + names(page) <- colnames(data) + return(as.data.frame(page, optional = TRUE)) } - data[rows, , drop = FALSE] + data[row_idx, , drop = FALSE] } dataview_column <- function(data, position) { @@ -130,7 +173,7 @@ dataview_column <- function(data, position) { return(as.vector(data[[position]])) } if (inherits(data, "polars_data_frame")) { - return(as.data.frame(data[, position])[[1]]) + return(as.data.frame(data[, position])[[1L]]) } if (is.matrix(data)) { return(data[, position]) @@ -138,110 +181,85 @@ dataview_column <- function(data, position) { data[[position]] } -dataview_text_values <- function(values) { - if (is.character(values) || is.factor(values)) { - return(as.character(values)) - } - if (is.list(values)) { - return(vapply(values, function(value) { - tryCatch( - paste(format(value), collapse = " "), - error = function(e) paste0("<", paste(class(value), collapse = ", "), ">") - ) - }, character(1))) - } - tryCatch( - as.character(values), - error = function(e) { - vapply(seq_along(values), function(index) { - paste(format(values[index]), collapse = " ") - }, character(1)) - } - ) -} - -dataview_sort_values <- function(values) { - if (is.list(values) && !inherits(values, "POSIXlt")) { - return(dataview_text_values(values)) - } - tryCatch({ - xtfrm(values) - values - }, error = function(e) dataview_text_values(values)) -} - -dataview_format_page <- function(page) { - if (!is.data.frame(page)) { - return(page) - } - for (position in seq_len(ncol(page))) { - column <- page[[position]] - if (is.list(column) && - !inherits(column, "POSIXlt")) { - page[[position]] <- dataview_text_values(column) - } - } - page -} - dataview_filter_condition <- function(values, condition) { op <- condition$type if (is.null(op)) { return(rep(TRUE, length(values))) } - text_values <- NULL - blank <- function() { - if (is.character(values) || is.factor(values) || is.list(values)) { - text_values <<- dataview_text_values(values) - is.na(values) | text_values == "" - } else { - is.na(values) - } - } - if (op == "blank") { - return(blank()) + return(is.na(values) | trimws(as.character(values)) == "") } if (op == "notBlank") { - return(!blank()) - } - - if (inherits(values, "Date") || - inherits(values, "POSIXct") || - inherits(values, "POSIXlt")) { - values <- as.Date(values) - low <- as.Date(if (is.null(condition$dateFrom)) condition$filter else condition$dateFrom) - high <- as.Date(if (is.null(condition$dateTo)) condition$filterTo else condition$dateTo) - } else if (inherits(values, "integer64") && - requireNamespace("bit64", quietly = TRUE)) { - low <- bit64::as.integer64(as.character(condition$filter)) - high <- bit64::as.integer64(as.character(condition$filterTo)) + return(!(is.na(values) | trimws(as.character(values)) == "")) + } + + if (is.logical(values) && op == "true") { + result <- !is.na(values) & values + } else if (is.logical(values) && op == "false") { + result <- !is.na(values) & !values + } else if (inherits(values, "Date") || + inherits(values, "POSIXct") || + inherits(values, "POSIXlt")) { + if (inherits(values, "Date")) { + comparable <- as.Date(values) + low <- as.Date(if (is.null(condition$dateFrom)) condition$filter else condition$dateFrom) + high <- as.Date(if (is.null(condition$dateTo)) condition$filterTo else condition$dateTo) + } else { + comparable <- as.POSIXct(values) + timezone <- attr(comparable, "tzone", exact = TRUE) %||% "" + low <- as.POSIXct( + if (is.null(condition$dateFrom)) condition$filter else condition$dateFrom, + tz = timezone + ) + high <- as.POSIXct( + if (is.null(condition$dateTo)) condition$filterTo else condition$dateTo, + tz = timezone + ) + } + result <- switch(op, + equals = comparable == low, + notEqual = comparable != low, + greaterThan = comparable > low, + greaterThanOrEqual = comparable >= low, + lessThan = comparable < low, + lessThanOrEqual = comparable <= low, + inRange = comparable >= low & comparable <= high, + rep(TRUE, length(values)) + ) } else if (is.numeric(values) || is.logical(values)) { - values <- as.numeric(values) - low <- suppressWarnings(as.numeric(condition$filter)) - high <- suppressWarnings(as.numeric(condition$filterTo)) + if (inherits(values, "integer64")) { + comparable <- values + low <- bit64::as.integer64(condition$filter) + high <- bit64::as.integer64(condition$filterTo) + } else { + comparable <- as.numeric(values) + low <- suppressWarnings(as.numeric(condition$filter)) + high <- suppressWarnings(as.numeric(condition$filterTo)) + } + result <- switch(op, + equals = comparable == low, + notEqual = comparable != low, + greaterThan = comparable > low, + greaterThanOrEqual = comparable >= low, + lessThan = comparable < low, + lessThanOrEqual = comparable <= low, + inRange = comparable >= low & comparable <= high, + rep(TRUE, length(values)) + ) } else { - values <- tolower(dataview_text_values(values)) - low <- tolower(as.character(condition$filter)) - high <- NULL - } - - result <- switch(op, - equals = values == low, - notEqual = values != low, - greaterThan = values > low, - greaterThanOrEqual = values >= low, - lessThan = values < low, - lessThanOrEqual = values <= low, - contains = grepl(low, values, fixed = TRUE), - notContains = !grepl(low, values, fixed = TRUE), - startsWith = startsWith(values, low), - endsWith = endsWith(values, low), - regexp = grepl(low, values), - inRange = values >= low & values <= high, - rep(TRUE, length(values)) - ) + text <- tolower(as.character(values)) + filter_value <- tolower(as.character(condition$filter %||% "")) + result <- switch(op, + equals = text == filter_value, + notEqual = text != filter_value, + contains = grepl(filter_value, text, fixed = TRUE), + notContains = !grepl(filter_value, text, fixed = TRUE), + startsWith = startsWith(text, filter_value), + endsWith = endsWith(text, filter_value), + rep(TRUE, length(values)) + ) + } result[is.na(result)] <- FALSE result } @@ -265,6 +283,10 @@ dataview_filter_values <- function(values, model) { } } +`%||%` <- function(x, y) { + if (is.null(x)) y else x +} + dataview_field_position <- function(field, column_count) { position <- suppressWarnings(as.integer(sub("^x", "", field))) - 2L if (is.na(position) || position < 1L || position > column_count) { @@ -297,6 +319,9 @@ dataview_query_indices <- function(state, sortModel, filterModel) { if (is.na(position)) { next } + if (isFALSE(state$columns[[position + 2L]]$filter)) { + next + } values <- dataview_column(state$data, position) matches <- matches & dataview_filter_values(values, filterModel[[field]]) } @@ -308,34 +333,82 @@ dataview_query_indices <- function(state, sortModel, filterModel) { row_indices <- seq_len(state$total_unfiltered) } - sort_values <- list() - decreasing <- logical() + sort_specs <- list() for (sort_item in sortModel) { position <- dataview_field_position(sort_item$colId, state$column_count) if (is.na(position)) { next } - values <- dataview_column(state$data, position)[row_indices] - sort_values[[length(sort_values) + 1L]] <- dataview_sort_values(values) - decreasing <- c(decreasing, identical(sort_item$sort, "desc")) + if (isFALSE(state$columns[[position + 2L]]$sortable)) { + next + } + raw_values <- dataview_column(state$data, position)[row_indices] + values <- if (is.factor(raw_values) && !is.ordered(raw_values)) { + as.character(raw_values) + } else { + raw_values + } + sort_specs[[length(sort_specs) + 1L]] <- list( + values = values, + decreasing = identical(sort_item$sort, "desc") + ) } - if (length(sort_values)) { - # The source row index is the deterministic tie-breaker. - sort_values[[length(sort_values) + 1L]] <- row_indices - decreasing <- c(decreasing, FALSE) - args <- c(sort_values, list( - na.last = TRUE, - decreasing = decreasing, - method = "radix" - )) - row_indices <- row_indices[do.call(order, args)] + row_order <- seq_along(row_indices) + for (index in rev(seq_along(sort_specs))) { + spec <- sort_specs[[index]] + values <- spec$values[row_order] + order_index <- if (inherits(values, "integer64")) { + bit64::order( + values, + na.last = TRUE, + decreasing = spec$decreasing + ) + } else { + order( + values, + na.last = TRUE, + decreasing = spec$decreasing, + method = "radix" + ) + } + row_order <- row_order[order_index] } + row_indices <- row_indices[row_order] } row_indices } +dataview_rows <- function(state, row_indices) { + page <- dataview_slice(state$data, row_indices) + if (is.data.frame(page)) { + for (position in seq_len(ncol(page))) { + if (inherits(page[[position]], "POSIXct") || + inherits(page[[position]], "POSIXlt")) { + page[[position]] <- format( + page[[position]], + "%Y-%m-%dT%H:%M:%S" + ) + } else if (inherits(page[[position]], "integer64")) { + page[[position]] <- as.character(page[[position]]) + } + } + } + row_labels <- if (is.null(state$row_index)) { + row_indices + } else { + state$row_index[row_indices] + } + rows <- cbind( + data.frame(row_labels, row_indices, check.names = FALSE), + page + ) + names(rows) <- state$fields + rownames(rows) <- NULL + rows +} + dataview_table <- local({ cache <- new.env(parent = emptyenv()) @@ -344,17 +417,33 @@ dataview_table <- local({ stop("data must be a data frame, a matrix, an arrow table or a polars data frame.") } - column_names <- colnames(data) + column_names <- if (inherits(data, "ArrowTabular") || + inherits(data, "polars_data_frame")) { + names(data) + } else { + colnames(data) + } if (is.null(column_names)) { - column_names <- sprintf("V%d", seq_len(ncol(data))) + column_names <- sprintf("(X%d)", seq_len(ncol(data))) } else { column_names <- trimws(column_names) } + row_index <- if (is.data.frame(data) && .row_names_info(data) > 0L) { + rownames(data) + } else if (is.matrix(data)) { + matrix_row_names <- rownames(data) + if (is.null(matrix_row_names)) NULL else trimws(matrix_row_names) + } else { + NULL + } fields <- sprintf("x%d", seq_len(length(column_names) + 2L)) - full_names <- c("(row)", "rowId", column_names) + full_names <- c(" ", "rowId", column_names) schema <- dataview_schema(data) schema_columns <- c( - list(integer(), integer()), + list( + if (is.null(row_index)) integer() else character(), + integer() + ), lapply(seq_len(ncol(schema)), function(position) { dataview_column(schema, position) }) @@ -364,6 +453,7 @@ dataview_table <- local({ state <- list( data = data, + row_index = row_index, column_count = length(column_names), columns = .mapply( get_column_def, @@ -431,7 +521,6 @@ dataview_table <- local({ if (first > total_rows || last < 1L || first > last) { source_rows <- integer() - display_rows <- integer() } else { display_rows <- seq.int(first, last) source_rows <- if (is.null(state$query_indices)) { @@ -441,12 +530,7 @@ dataview_table <- local({ } } - page <- dataview_format_page(dataview_slice(state$data, source_rows)) - rows <- cbind( - data.frame(display_rows, source_rows, check.names = FALSE), - page - ) - names(rows) <- state$fields + rows <- dataview_rows(state, source_rows) list( rows = rows, @@ -556,7 +640,7 @@ if (use_webserver) { } obj <- eval(source$expression, envir = source$environment) - if (is.environment(obj)) { + if (is.environment(obj) && !dataview_is_table(obj)) { all_names <- ls(obj) is_active <- vapply(all_names, bindingIsActive, logical(1), USE.NAMES = TRUE, obj) is_promise <- rlang::env_binding_are_lazy(obj, all_names[!is_active]) @@ -699,12 +783,7 @@ if (use_webserver) { body = jsonlite::toJSON( response, auto_unbox = TRUE, - force = TRUE, - na = if (identical(request$type, "dataview_fetch_rows")) { - "string" - } else { - "null" - } + force = TRUE ) ) } @@ -1160,7 +1239,7 @@ if (show_view) { logger("Created new dataview UUID for title:", title, "UUID:", dataview_uuid) } - if (is.environment(x)) { + if (is.environment(x) && !dataview_is_table(x)) { all_names <- ls(x) is_active <- vapply(all_names, bindingIsActive, logical(1), USE.NAMES = TRUE, x) is_promise <- rlang::env_binding_are_lazy(x, all_names[!is_active]) diff --git a/package-lock.json b/package-lock.json index 5aae0c2e..2153b962 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "r", - "version": "3.1.0", + "version": "3.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "r", - "version": "3.1.0", + "version": "3.2.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { - "ag-grid-community": "^31.3.2", + "ag-grid-community": "^35.2.1", "cheerio": "1.0.0-rc.12", "ejs": "^3.1.10", "fs-extra": "^10.0.0", @@ -1279,12 +1279,21 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/ag-grid-community": { - "version": "31.3.4", - "resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-31.3.4.tgz", - "integrity": "sha512-jOxQO86C6eLnk1GdP24HB6aqaouFzMWizgfUwNY5MnetiWzz9ZaAmOGSnW/XBvdjXvC5Fpk3gSbvVKKQ7h9kBw==", + "node_modules/ag-charts-types": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/ag-charts-types/-/ag-charts-types-13.2.1.tgz", + "integrity": "sha512-r7veb3QqJtIKlXmeUsLR4/oDPwmHxFI2tmbZra/203mdaz3uwQUrrgYNg628nrK+7L2YxXnwGc6L05tWjLLjNQ==", "license": "MIT" }, + "node_modules/ag-grid-community": { + "version": "35.2.1", + "resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-35.2.1.tgz", + "integrity": "sha512-ycmGI+1EbUT7i3eg/Kgi1owwnkdHXRufo10Xm6cfSsVPM3TMpvlbLgi28KIPt9DGHZWHq9fOBn7nxMNdv1Yaow==", + "license": "MIT", + "dependencies": { + "ag-charts-types": "13.2.1" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", diff --git a/package.json b/package.json index 19b84e70..c75f7ed3 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "r", "displayName": "R", "description": "R Extension for Visual Studio Code", - "version": "3.1.0", + "version": "3.2.0", "author": "REditorSupport", "license": "SEE LICENSE IN LICENSE", "publisher": "REditorSupport", @@ -2011,7 +2011,7 @@ "webpack-cli": "^4.10.0" }, "dependencies": { - "ag-grid-community": "^31.3.2", + "ag-grid-community": "^35.2.1", "cheerio": "1.0.0-rc.12", "ejs": "^3.1.10", "fs-extra": "^10.0.0", diff --git a/src/languageService.ts b/src/languageService.ts index be598193..314ea5b3 100644 --- a/src/languageService.ts +++ b/src/languageService.ts @@ -2,20 +2,28 @@ import * as os from 'os'; import { dirname } from 'path'; import * as net from 'net'; import { URL } from 'url'; -import { LanguageClient, LanguageClientOptions, StreamInfo, DocumentFilter, ErrorAction, CloseAction, RevealOutputChannelOn } from 'vscode-languageclient/node'; +import { LanguageClient, LanguageClientOptions, StreamInfo, DocumentFilter, ErrorAction, CloseAction, RevealOutputChannelOn, Middleware } from 'vscode-languageclient/node'; import { Disposable, workspace, Uri, TextDocument, WorkspaceConfiguration, OutputChannel, window, WorkspaceFolder } from 'vscode'; import { DisposableProcess, getRLibPaths, getRpath, promptToInstallRPackage, spawn, substituteVariables } from './util'; import { extensionContext } from './extension'; import { CommonOptions } from 'child_process'; export class LanguageService implements Disposable { + private static readonly singleClientKey = 'global'; + private static readonly idleStopDelayMs = 30_000; private client: LanguageClient | undefined; private readonly clients: Map = new Map(); private readonly initSet: Set = new Set(); // Track open documents per server key for proper cleanup private readonly openDocuments: Map> = new Map(); + private readonly stoppingClients: Map> = new Map(); + private readonly restartAfterStop: Map void> = new Map(); + private readonly idleStopTimers: Map> = new Map(); + private readonly quartoVirtualDocumentServerKeys: Map = new Map(); + private readonly disposables: Disposable[] = []; private readonly config: WorkspaceConfiguration; private readonly outputChannel: OutputChannel; + private disposed = false; constructor() { this.outputChannel = window.createOutputChannel('R Language Server'); @@ -25,10 +33,12 @@ export class LanguageService implements Disposable { } dispose(): Thenable { + this.disposed = true; return this.stopLanguageService(); } - private spawnServer(client: LanguageClient, rPath: string, args: readonly string[], options: CommonOptions & { cwd: string }): DisposableProcess { + private spawnServer(client: LanguageClient, rPath: string, args: readonly string[], options: CommonOptions & { cwd: string }, + onExit?: (client: LanguageClient) => void): DisposableProcess { const childProcess = spawn(rPath, args, options); const pid = childProcess.pid || -1; client.outputChannel.appendLine(`R Language Server (${pid}) started`); @@ -50,13 +60,14 @@ export class LanguageService implements Disposable { client.outputChannel.show(); } } - void client.stop(); + onExit?.(client); }); return childProcess; } private async createClient(config: WorkspaceConfiguration, selector: DocumentFilter[], - cwd: string, workspaceFolder: WorkspaceFolder | undefined, outputChannel: OutputChannel): Promise { + cwd: string, workspaceFolder: WorkspaceFolder | undefined, outputChannel: OutputChannel, + serverKey: string, onExit?: (client: LanguageClient) => void): Promise { let client: LanguageClient; @@ -116,11 +127,26 @@ export class LanguageService implements Disposable { server.listen(0, '127.0.0.1', () => { const port = (server.address() as net.AddressInfo).port; env.VSCR_LSP_PORT = String(port); - return this.spawnServer(client, rPath, args, options); + return this.spawnServer(client, rPath, args, options, onExit); }); }); // Options to control the language client + const middleware: Middleware = { + sendRequest: async (type, param, token, next) => { + if (!this.shouldRouteToClient(serverKey, param)) { + return undefined as never; + } + return next(type, param, token); + }, + sendNotification: async (type, next, params) => { + if (!this.shouldRouteToClient(serverKey, params)) { + return; + } + return next(type, params); + } + }; + const clientOptions: LanguageClientOptions = { // Register the server for selected R documents documentSelector: selector, @@ -137,6 +163,7 @@ export class LanguageService implements Disposable { configurationSection: 'r.lsp', fileEvents: workspace.createFileSystemWatcher('**/*.{R,r}'), }, + middleware, revealOutputChannelOn: RevealOutputChannelOn.Never, errorHandler: { error: () => { @@ -145,6 +172,7 @@ export class LanguageService implements Disposable { }; }, closed: () => { + onExit?.(client); return { action: CloseAction.DoNotRestart, handled: true @@ -160,34 +188,98 @@ export class LanguageService implements Disposable { client = new LanguageClient('r', 'R Language Server', tcpServerOptions, clientOptions); } - extensionContext.subscriptions.push(client); - await client.start(); - return client; + try { + await client.start(); + return client; + } catch (error) { + try { + await client.dispose(); + } catch { + // A failed start may leave no active connection to dispose. + } + throw error; + } } private isClientInitializing(name: string): boolean { return this.initSet.has(name); } - - private isQuartoChunkTempUri(uriString: string): boolean { - try { - const uri = Uri.parse(uriString); - if (uri.scheme !== 'file') { - return false; - } - const fsPath = uri.fsPath; - // Quarto temp docs look like: /var/.../tmp-.../.vdoc..r - return fsPath.includes('.vdoc.') && fsPath.toLowerCase().endsWith('.r'); - } catch { + + private isQuartoDocument(document: TextDocument): boolean { + return document.languageId === 'quarto' || + document.uri.fsPath.toLowerCase().endsWith('.qmd'); + } + + private isQuartoVirtualDocument(document: TextDocument): boolean { + const fsPath = document.uri.fsPath.toLowerCase(); + return document.uri.scheme === 'file' && + document.languageId === 'r' && + fsPath.includes('.vdoc.') && + fsPath.endsWith('.r'); + } + + private isTemporaryRSource(document: TextDocument): boolean { + if (document.uri.scheme !== 'file') { return false; } + const fsPath = document.uri.fsPath.toLowerCase(); + return fsPath.includes('rtmp') && + fsPath.endsWith('.r') && + !fsPath.includes('.vdoc.'); } - private isUntitledQuartoDoc(document: TextDocument): boolean { - return document.uri.scheme === 'untitled' && - (document.languageId === 'quarto' || - document.languageId === 'r' || - document.languageId === 'rmd'); + private shouldRouteToClient(serverKey: string, params: unknown): boolean { + if (!params || typeof params !== 'object') { + return true; + } + const textDocument = (params as { textDocument?: { uri?: unknown } }).textDocument; + if (typeof textDocument?.uri !== 'string') { + return true; + } + const documentKey = Uri.parse(textDocument.uri).toString(); + const mappedServerKey = this.quartoVirtualDocumentServerKeys.get(documentKey); + return !mappedServerKey || mappedServerKey === serverKey; + } + + private getParentQuartoDocument(document: TextDocument): TextDocument | undefined { + const sourceFolder = workspace.getWorkspaceFolder(document.uri); + const matchesWorkspace = (candidate: TextDocument): boolean => + this.isQuartoDocument(candidate) && + (!sourceFolder || + workspace.getWorkspaceFolder(candidate.uri)?.uri.toString(true) === sourceFolder.uri.toString(true)); + + const activeDocument = window.activeTextEditor?.document; + if (activeDocument && matchesWorkspace(activeDocument)) { + return activeDocument; + } + + const visibleDocuments = window.visibleTextEditors + .map(editor => editor.document) + .filter(matchesWorkspace); + if (visibleDocuments.length === 1) { + return visibleDocuments[0]; + } + const visibleServerKeys = new Set( + visibleDocuments + .map(candidate => this.getServerKey(candidate)) + .filter((key): key is string => key !== null) + ); + if (visibleDocuments.length > 1 && visibleServerKeys.size === 1) { + return visibleDocuments[visibleDocuments.length - 1]; + } + + const openDocuments = workspace.textDocuments.filter(matchesWorkspace); + if (openDocuments.length === 1) { + return openDocuments[0]; + } + const openServerKeys = new Set( + openDocuments + .map(candidate => this.getServerKey(candidate)) + .filter((key): key is string => key !== null) + ); + return openDocuments.length > 1 && openServerKeys.size === 1 + ? openDocuments[openDocuments.length - 1] + : undefined; } private getServerKey(document: TextDocument): string | null { @@ -233,12 +325,167 @@ export class LanguageService implements Disposable { } return false; // Still has open documents } - + + private hasOpenTrackedDocuments(serverKey: string): boolean { + const documents = this.openDocuments.get(serverKey); + if (!documents) { + return false; + } + + for (const uri of Array.from(documents)) { + const isOpen = workspace.textDocuments.some(document => + document.uri.toString(true) === uri + ); + if (isOpen) { + return true; + } + documents.delete(uri); + } + + this.openDocuments.delete(serverKey); + return false; + } + + private hasOpenSingleServerDocuments(): boolean { + const hasOpenRDocument = workspace.textDocuments.some(document => + (document.uri.scheme === 'file' || + document.uri.scheme === 'untitled' || + document.uri.scheme === 'vscode-notebook-cell') && + (document.languageId === 'r' || document.languageId === 'rmd') && + !this.isTemporaryRSource(document) && + !this.isQuartoVirtualDocument(document) + ); + return hasOpenRDocument || + this.hasOpenTrackedDocuments(LanguageService.singleClientKey); + } + + private getClient(serverKey: string): LanguageClient | undefined { + return serverKey === LanguageService.singleClientKey + ? this.client + : this.clients.get(serverKey); + } + + private deleteClient(serverKey: string): void { + if (serverKey === LanguageService.singleClientKey) { + this.client = undefined; + } else { + this.clients.delete(serverKey); + } + } + + private cancelIdleStop(serverKey: string): void { + const timer = this.idleStopTimers.get(serverKey); + if (timer) { + clearTimeout(timer); + this.idleStopTimers.delete(serverKey); + } + } + + private scheduleIdleStop(serverKey: string, shouldStop: () => boolean): void { + if (this.disposed || this.idleStopTimers.has(serverKey)) { + return; + } + const timer = setTimeout(() => { + this.idleStopTimers.delete(serverKey); + if (shouldStop()) { + void this.stopClient(serverKey); + } + }, LanguageService.idleStopDelayMs); + this.idleStopTimers.set(serverKey, timer); + } + + private clearIdleStops(): void { + for (const timer of this.idleStopTimers.values()) { + clearTimeout(timer); + } + this.idleStopTimers.clear(); + } + private stopAndDisposeClient(client: LanguageClient): Thenable { client.clientOptions.errorHandler = undefined; + if (!client.needsStop()) { + return client.dispose(); + } return client.stop().then(() => client.dispose()); } + private queueRestartAfterStop(serverKey: string, restart: () => void): boolean { + if (!this.stoppingClients.has(serverKey)) { + return false; + } + this.restartAfterStop.set(serverKey, restart); + return true; + } + + private stopClient(serverKey: string): Promise | undefined { + this.cancelIdleStop(serverKey); + const existingStop = this.stoppingClients.get(serverKey); + if (existingStop) { + return existingStop; + } + + const client = this.getClient(serverKey); + this.openDocuments.delete(serverKey); + if (!client) { + return undefined; + } + + this.deleteClient(serverKey); + this.initSet.delete(serverKey); + const stopPromise = Promise.resolve(this.stopAndDisposeClient(client)) + .catch(error => { + this.outputChannel.appendLine(`Failed to stop R language server: ${String(error)}`); + }) + .finally(() => { + this.stoppingClients.delete(serverKey); + const restart = this.restartAfterStop.get(serverKey); + this.restartAfterStop.delete(serverKey); + if (!this.disposed) { + restart?.(); + } + }); + this.stoppingClients.set(serverKey, stopPromise); + return stopPromise; + } + + private handleClientExit(serverKey: string, client: LanguageClient): void { + if (this.getClient(serverKey) !== client) { + return; + } + this.deleteClient(serverKey); + this.initSet.delete(serverKey); + this.cancelIdleStop(serverKey); + void client.dispose(); + } + + private forgetStoppedClient(serverKey: string): void { + const client = this.getClient(serverKey); + if (client && !client.needsStop()) { + this.deleteClient(serverKey); + this.initSet.delete(serverKey); + void client.dispose(); + } + } + + private withQuartoVirtualSelector(selector: DocumentFilter[]): DocumentFilter[] { + return selector.concat({ + scheme: 'file', + language: 'r', + pattern: '**/.vdoc.*.r' + }); + } + + private async registerMultiClient(serverKey: string, client: LanguageClient): Promise { + if (this.disposed) { + await this.stopAndDisposeClient(client); + return; + } + this.clients.set(serverKey, client); + if (!this.hasOpenTrackedDocuments(serverKey)) { + this.scheduleIdleStop(serverKey, () => !this.hasOpenTrackedDocuments(serverKey)); + } + } + private startMultiLanguageService(self: LanguageService): void { async function didOpenTextDocument(document: TextDocument) { if (document.uri.scheme !== 'file' && document.uri.scheme !== 'untitled' && document.uri.scheme !== 'vscode-notebook-cell') { @@ -249,27 +496,35 @@ export class LanguageService implements Disposable { return; } - if (document.uri.scheme === 'file') { - const path = document.uri.fsPath.toLowerCase(); - // Detect R's temporary source files in Rtmp* folders - const isRTempFile = path.includes('rtmp') && - (path.endsWith('.r') || path.endsWith('.R')) && - !path.includes('.vdoc.'); - - if (isRTempFile) { - return; - } + if (self.isTemporaryRSource(document)) { + return; } - const serverKey = self.getServerKey(document); + const quartoParent = self.isQuartoVirtualDocument(document) + ? self.getParentQuartoDocument(document) + : undefined; + const serverDocument = quartoParent ?? document; + const serverKey = self.getServerKey(serverDocument); if (!serverKey) { return; } - // Track this document - self.trackDocument(serverKey, document.uri.toString(true)); + if (self.isQuartoVirtualDocument(document)) { + self.quartoVirtualDocumentServerKeys.set(document.uri.toString(), serverKey); + } + self.trackDocument(serverKey, serverDocument.uri.toString(true)); + self.cancelIdleStop(serverKey); + + if (self.queueRestartAfterStop(serverKey, () => { + if (self.hasOpenTrackedDocuments(serverKey)) { + void didOpenTextDocument(document); + } + })) { + return; + } // Check if server already exists or is being initialized + self.forgetStoppedClient(serverKey); if (self.clients.has(serverKey) || self.isClientInitializing(serverKey)) { return; } @@ -278,17 +533,20 @@ export class LanguageService implements Disposable { self.initSet.add(serverKey); try { - const folder = workspace.getWorkspaceFolder(document.uri); + const folder = workspace.getWorkspaceFolder(serverDocument.uri); // Each notebook uses a server started from parent folder - if (document.uri.scheme === 'vscode-notebook-cell') { + if (serverDocument.uri.scheme === 'vscode-notebook-cell') { console.log(`Starting language server for notebook: ${document.uri.toString(true)}`); - const documentSelector: DocumentFilter[] = [ - { scheme: 'vscode-notebook-cell', language: 'r', pattern: `${document.uri.fsPath}` }, - ]; - const client = await self.createClient(self.config, documentSelector, - dirname(document.uri.fsPath), folder, self.outputChannel); - self.clients.set(serverKey, client); + const documentSelector = self.withQuartoVirtualSelector([ + { scheme: 'vscode-notebook-cell', language: 'r', pattern: `${serverDocument.uri.fsPath}` }, + ]); + const client = await self.createClient( + self.config, documentSelector, dirname(serverDocument.uri.fsPath), + folder, self.outputChannel, serverKey, + exitedClient => self.handleClientExit(serverKey, exitedClient) + ); + await self.registerMultiClient(serverKey, client); return; } @@ -296,38 +554,47 @@ export class LanguageService implements Disposable { // Each workspace uses a server started from the workspace folder console.log(`Starting language server for workspace: ${folder.name} (${folder.uri.toString(true)})`); const pattern = `${folder.uri.fsPath}/**/*`; - const documentSelector: DocumentFilter[] = [ + const documentSelector = self.withQuartoVirtualSelector([ { scheme: 'file', language: 'r', pattern: pattern }, { scheme: 'file', language: 'rmd', pattern: pattern }, - ]; - const client = await self.createClient(self.config, documentSelector, - folder.uri.fsPath, folder, self.outputChannel); - self.clients.set(serverKey, client); + ]); + const client = await self.createClient( + self.config, documentSelector, folder.uri.fsPath, + folder, self.outputChannel, serverKey, + exitedClient => self.handleClientExit(serverKey, exitedClient) + ); + await self.registerMultiClient(serverKey, client); } else { // All untitled documents share a server started from home folder - if (document.uri.scheme === 'untitled') { + if (serverDocument.uri.scheme === 'untitled') { console.log(`Starting language server for untitled documents`); - const documentSelector: DocumentFilter[] = [ + const documentSelector = self.withQuartoVirtualSelector([ { scheme: 'untitled', language: 'r' }, { scheme: 'untitled', language: 'rmd' }, - ]; - const client = await self.createClient(self.config, documentSelector, - os.homedir(), undefined, self.outputChannel); - self.clients.set(serverKey, client); + ]); + const client = await self.createClient( + self.config, documentSelector, os.homedir(), + undefined, self.outputChannel, serverKey, + exitedClient => self.handleClientExit(serverKey, exitedClient) + ); + await self.registerMultiClient(serverKey, client); return; } // Each file outside workspace uses a server started from parent folder - if (document.uri.scheme === 'file') { + if (serverDocument.uri.scheme === 'file') { console.log(`Starting language server for standalone file: ${document.uri.toString(true)}`); - const dir = dirname(document.uri.fsPath); - const documentSelector: DocumentFilter[] = [ + const dir = dirname(serverDocument.uri.fsPath); + const documentSelector = self.withQuartoVirtualSelector([ { scheme: 'file', pattern: `${dir}/**/*.{R,r,Rmd,rmd}` }, - ]; - const client = await self.createClient(self.config, documentSelector, - dir, undefined, self.outputChannel); - self.clients.set(serverKey, client); + ]); + const client = await self.createClient( + self.config, documentSelector, dir, + undefined, self.outputChannel, serverKey, + exitedClient => self.handleClientExit(serverKey, exitedClient) + ); + await self.registerMultiClient(serverKey, client); return; } } @@ -339,70 +606,56 @@ export class LanguageService implements Disposable { function didCloseTextDocument(document: TextDocument): void { const isRDoc = document.languageId === 'r' || document.languageId === 'rmd'; - const isQuartoDoc = document.uri.fsPath.toLowerCase().endsWith('.qmd'); - // Normal R / Rmd behaviour (unchanged) if (isRDoc) { const serverKey = self.getServerKey(document); if (!serverKey) { return; } - const shouldStop = self.untrackDocument(serverKey, document.uri.toString(true)); - if (shouldStop) { - const client = self.clients.get(serverKey); - if (client) { - console.log(`Stopping language server for: ${serverKey}`); - self.clients.delete(serverKey); - self.initSet.delete(serverKey); - void self.stopAndDisposeClient(client); - } + self.untrackDocument(serverKey, document.uri.toString(true)); + if (!self.hasOpenTrackedDocuments(serverKey)) { + self.scheduleIdleStop( + serverKey, + () => !self.hasOpenTrackedDocuments(serverKey) + ); } return; } - // Extra: when a Quarto document (.qmd) closes, immediately - // stop any clients that only serve Quarto temp chunk docs - if (isQuartoDoc || self.isUntitledQuartoDoc(document)) { - for (const [serverKey, client] of self.clients.entries()) { - const docs = self.openDocuments.get(serverKey); - if (!docs || docs.size === 0) { - continue; - } - - const allQuartoTemp = Array.from(docs).every(uriStr => - self.isQuartoChunkTempUri(uriStr) - ); - - if (allQuartoTemp) { - console.log(`Stopping language server for Quarto chunks: ${serverKey} (closed ${document.uri.toString(true)})`); - self.openDocuments.delete(serverKey); - self.clients.delete(serverKey); - self.initSet.delete(serverKey); - void self.stopAndDisposeClient(client); + if (self.isQuartoDocument(document)) { + const serverKey = self.getServerKey(document); + if (serverKey) { + self.untrackDocument(serverKey, document.uri.toString(true)); + if (!self.hasOpenTrackedDocuments(serverKey)) { + self.scheduleIdleStop( + serverKey, + () => !self.hasOpenTrackedDocuments(serverKey) + ); } } } } - - workspace.onDidOpenTextDocument(didOpenTextDocument); - workspace.onDidCloseTextDocument(didCloseTextDocument); + const openDisposable = workspace.onDidOpenTextDocument(didOpenTextDocument); + const closeDisposable = workspace.onDidCloseTextDocument(document => { + didCloseTextDocument(document); + if (self.isQuartoVirtualDocument(document)) { + setTimeout(() => { + self.quartoVirtualDocumentServerKeys.delete(document.uri.toString()); + }, 0); + } + }); workspace.textDocuments.forEach((doc) => void didOpenTextDocument(doc)); - workspace.onDidChangeWorkspaceFolders((event) => { + const workspaceDisposable = workspace.onDidChangeWorkspaceFolders((event) => { for (const folder of event.removed) { const serverKey = folder.uri.toString(true); - const client = self.clients.get(serverKey); - if (client) { - console.log(`Stopping language server for removed workspace: ${folder.name}`); - self.clients.delete(serverKey); - self.initSet.delete(serverKey); - self.openDocuments.delete(serverKey); - void self.stopAndDisposeClient(client); - } + console.log(`Stopping language server for removed workspace: ${folder.name}`); + void self.stopClient(serverKey); } }); + self.disposables.push(openDisposable, closeDisposable, workspaceDisposable); } private async startLanguageService(self: LanguageService): Promise { @@ -410,68 +663,147 @@ export class LanguageService implements Disposable { return this.startMultiLanguageService(self); } else { // Single server mode - only start when R files are opened - const startSingleServer = async () => { - if (self.client) { - return; // Already started + const startSingleServer = async (document?: TextDocument) => { + const serverKey = LanguageService.singleClientKey; + const isQuartoVirtualDocument = document && + self.isQuartoVirtualDocument(document); + if (isQuartoVirtualDocument) { + const parent = self.getParentQuartoDocument(document); + if (parent) { + self.trackDocument(serverKey, parent.uri.toString(true)); + } + } + + if (self.disposed || + (!isQuartoVirtualDocument && !self.hasOpenSingleServerDocuments())) { + return; } + self.cancelIdleStop(serverKey); + if (self.queueRestartAfterStop(serverKey, () => { + if (self.hasOpenSingleServerDocuments()) { + void startSingleServer(); + } + })) { + return; + } + + self.forgetStoppedClient(serverKey); + if (self.client || self.isClientInitializing(serverKey)) { + return; + } + + self.initSet.add(serverKey); const documentSelector: DocumentFilter[] = [ - { language: 'r' }, - { language: 'rmd' }, + { scheme: 'file', language: 'r' }, + { scheme: 'file', language: 'rmd' }, + { scheme: 'untitled', language: 'r' }, + { scheme: 'untitled', language: 'rmd' }, + { scheme: 'vscode-notebook-cell', language: 'r' }, ]; const workspaceFolder = workspace.workspaceFolders?.[0]; const cwd = workspaceFolder ? workspaceFolder.uri.fsPath : os.homedir(); console.log(`Starting single language server in: ${cwd}`); - self.client = await self.createClient(self.config, documentSelector, cwd, workspaceFolder, self.outputChannel); + try { + const client = await self.createClient( + self.config, documentSelector, cwd, + workspaceFolder, self.outputChannel, serverKey, + exitedClient => self.handleClientExit(serverKey, exitedClient) + ); + if (self.disposed) { + await self.stopAndDisposeClient(client); + return; + } + self.client = client; + if (!self.hasOpenSingleServerDocuments()) { + self.scheduleIdleStop( + serverKey, + () => !self.hasOpenSingleServerDocuments() + ); + } + } finally { + self.initSet.delete(serverKey); + } }; const stopSingleServer = () => { - // Check if any R files are still open - const hasRFiles = workspace.textDocuments.some(doc => - (doc.languageId === 'r' || doc.languageId === 'rmd') - ); - - if (!hasRFiles && self.client) { - console.log('Stopping single language server - no R files open'); - const client = self.client; - self.client = undefined; - void self.stopAndDisposeClient(client); + if (!self.hasOpenSingleServerDocuments()) { + self.scheduleIdleStop( + LanguageService.singleClientKey, + () => !self.hasOpenSingleServerDocuments() + ); } }; // Set up listeners for single server mode - workspace.onDidOpenTextDocument(async (document) => { + const openDisposable = workspace.onDidOpenTextDocument(async (document) => { if (document.languageId === 'r' || document.languageId === 'rmd') { - await startSingleServer(); + await startSingleServer(document); } }); - workspace.onDidCloseTextDocument(() => { + const closeDisposable = workspace.onDidCloseTextDocument(document => { + if (self.isQuartoDocument(document)) { + self.untrackDocument( + LanguageService.singleClientKey, + document.uri.toString(true) + ); + } stopSingleServer(); }); + self.disposables.push(openDisposable, closeDisposable); + + for (const document of workspace.textDocuments) { + if (self.isQuartoVirtualDocument(document)) { + const parent = self.getParentQuartoDocument(document); + if (parent) { + self.trackDocument( + LanguageService.singleClientKey, + parent.uri.toString(true) + ); + } + } + } - // Start server if R files are already open - const hasRFiles = workspace.textDocuments.some(doc => - (doc.languageId === 'r' || doc.languageId === 'rmd') + const openRDocument = workspace.textDocuments.find(document => + (document.languageId === 'r' || document.languageId === 'rmd') && + !self.isTemporaryRSource(document) ); - if (hasRFiles) { - await startSingleServer(); + if (openRDocument) { + await startSingleServer(openRDocument); } } } private stopLanguageService(): Thenable { - const promises: Thenable[] = []; + this.clearIdleStops(); + this.restartAfterStop.clear(); + for (const disposable of this.disposables.splice(0)) { + disposable.dispose(); + } + + const promises: Promise[] = []; if (this.client) { - promises.push(this.stopAndDisposeClient(this.client)); + const stopping = this.stopClient(LanguageService.singleClientKey); + if (stopping) { + promises.push(stopping); + } + } + for (const serverKey of Array.from(this.clients.keys())) { + const stopping = this.stopClient(serverKey); + if (stopping) { + promises.push(stopping); + } } - for (const client of this.clients.values()) { - promises.push(this.stopAndDisposeClient(client)); + for (const stopping of this.stoppingClients.values()) { + if (!promises.includes(stopping)) { + promises.push(stopping); + } } - this.clients.clear(); this.initSet.clear(); this.openDocuments.clear(); + this.quartoVirtualDocumentServerKeys.clear(); return Promise.all(promises).then(() => undefined); } -} \ No newline at end of file +} diff --git a/src/session.ts b/src/session.ts index e428a42a..201f7013 100644 --- a/src/session.ts +++ b/src/session.ts @@ -730,35 +730,6 @@ export async function getTableHtml(webview: Webview, file: string): Promise - - - -
-
-
Loading...
+
+
+ + +
+
+
-
`; diff --git a/webpack.config.js b/webpack.config.js index a302db17..b927996f 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -46,8 +46,6 @@ module.exports = { { from: './node_modules/jquery/dist/jquery.min.js', to: 'resources' }, { from: './node_modules/jquery.json-viewer/json-viewer', to: 'resources' }, { from: './node_modules/ag-grid-community/dist/ag-grid-community.min.noStyle.js', to: 'resources' }, - { from: './node_modules/ag-grid-community/styles/ag-grid.min.css', to: 'resources' }, - { from: './node_modules/ag-grid-community/styles/ag-theme-balham.min.css', to: 'resources' }, ] }), ],