From d1963bb818edc3341e51f351acb947c4283fbbad Mon Sep 17 00:00:00 2001 From: Kun Ren Date: Sun, 10 May 2026 08:27:23 +0800 Subject: [PATCH 1/6] Implement dynamic paging --- sess/R/handlers.R | 371 +++++++++++++++++++++++++++++++ sess/R/hooks.R | 108 +-------- sess/R/server.R | 6 +- sess/tests/testthat/test-ipc.R | 79 +++++++ src/session.ts | 388 ++++++++++++++++++++++++++++++++- 5 files changed, 843 insertions(+), 109 deletions(-) diff --git a/sess/R/handlers.R b/sess/R/handlers.R index 05e25771..7ede3f20 100644 --- a/sess/R/handlers.R +++ b/sess/R/handlers.R @@ -153,3 +153,374 @@ handle_plot_latest <- function(params) { list(data = NULL) } } + +dataview_data_type <- function(x) { + if (is.logical(x)) { + "logical" + } else if (is.factor(x)) { + "factor" + } else if (inherits(x, "POSIXct") || inherits(x, "POSIXlt")) { + "datetime" + } else if (inherits(x, "Date")) { + "date" + } else if (is.numeric(x)) { + if (is.null(attr(x, "class"))) { + "num" + } else { + "num-fmt" + } + } else { + "string" + } +} + +dataview_to_state <- function(data) { + if (is.data.frame(data)) { + n <- nrow(data) + colnames <- colnames(data) + if (is.null(colnames)) { + colnames <- sprintf("(X%d)", seq_len(ncol(data))) + } else { + colnames <- trimws(colnames) + } + if (.row_names_info(data) > 0L) { + row_index <- rownames(data) + rownames(data) <- NULL + } else { + row_index <- seq_len(n) + } + cols <- c(list(row_index), .subset(data)) + headers <- c(" ", colnames) + types <- vapply(cols, dataview_data_type, character(1L), USE.NAMES = FALSE) + } else if (is.matrix(data)) { + if (is.factor(data)) { + data <- format(data) + } + n <- nrow(data) + colnames <- colnames(data) + colnames(data) <- NULL + if (is.null(colnames)) { + colnames <- sprintf("(X%d)", seq_len(ncol(data))) + } else { + colnames <- trimws(colnames) + } + row_index <- rownames(data) + rownames(data) <- NULL + cols <- c(list(if (is.null(row_index)) seq_len(n) else trimws(row_index)), lapply(seq_len(ncol(data)), function(i) data[, i])) + headers <- c(" ", colnames) + matrix_type <- dataview_data_type(data) + types <- c(if (is.null(row_index)) "num" else "string", rep(matrix_type, ncol(data))) + } else { + stop("data must be data.frame or matrix") + } + + list( + columns = cols, + headers = headers, + types = types, + total_rows = length(cols[[1L]]) + ) +} + +dataview_columns <- function(state) { + .mapply(function(title, type, index, col_data) { + # Determine cell alignment: numeric/date types right-align, everything else left-align + class <- if (type %in% c("num", "num-fmt", "date", "datetime")) "text-right" else "text-left" + # Map R data types to ag-grid column types for proper filtering + ag_type <- if (type == "date") { + "dateColumn" + } else if (type == "datetime") { + "datetimeColumn" + } else if (type %in% c("num", "num-fmt")) { + "numberColumn" + } else if (type %in% c("logical", "factor")) { + "setColumn" + } else { + "" + } + + col_def <- list( + headerName = jsonlite::unbox(title), + field = jsonlite::unbox(as.character(index - 1L)), + cellClass = jsonlite::unbox(class), + type = jsonlite::unbox(ag_type) + ) + + # For set filters, include unique values so ag-grid can show all options + if (type %in% c("logical", "factor")) { + unique_vals <- sort(unique(as.character(col_data))) + unique_vals <- unique_vals[!is.na(unique_vals)] + # Keep as vector, not list, so JSON serialization is [val1, val2, ...] + col_def$filterParams <- list(values = I(unique_vals)) + } + + col_def + }, list(state$headers, state$types, seq_along(state$headers), state$columns), NULL) +} + +dataview_register <- function(data) { + if (is.null(.sess_env$dataviews)) { + .sess_env$dataviews <- list() + } + ts <- gsub("[^0-9]", "", format(Sys.time(), "%Y%m%d%H%M%OS6"), perl = TRUE) + view_id <- sprintf("dv_%s_%06d", ts, sample.int(999999L, 1L)) + state <- dataview_to_state(data) + .sess_env$dataviews[[view_id]] <- state + list( + view_id = view_id, + total_rows = state$total_rows, + columns = dataview_columns(state) + ) +} + +dataview_get_state <- function(view_id) { + if (is.null(.sess_env$dataviews) || is.null(.sess_env$dataviews[[view_id]])) { + stop(sprintf("Unknown dataview id: %s", view_id)) + } + .sess_env$dataviews[[view_id]] +} + +dataview_match_condition <- function(values, cond, type_hint) { + if (is.null(cond$type)) { + return(rep(TRUE, length(values))) + } + + cond_type <- as.character(cond$type) + if (cond_type == "blank") { + return(is.na(values) | trimws(as.character(values)) == "") + } + if (cond_type == "notBlank") { + return(!(is.na(values) | trimws(as.character(values)) == "")) + } + + # Determine filter type from cond or use hint + filter_type <- as.character(cond$filterType %||% + if (type_hint == "date") "date" + else if (type_hint == "datetime") "datetime" + else if (type_hint %in% c("num", "num-fmt")) "number" + else if (type_hint %in% c("logical", "factor")) "set" + else "text" + ) + + if (filter_type == "number") { + nums <- suppressWarnings(as.numeric(values)) + f1 <- suppressWarnings(as.numeric(cond$filter)) + f2 <- suppressWarnings(as.numeric(cond$filterTo)) + switch(cond_type, + equals = nums == f1, + notEqual = nums != f1, + greaterThan = nums > f1, + greaterThanOrEqual = nums >= f1, + lessThan = nums < f1, + lessThanOrEqual = nums <= f1, + inRange = nums >= f1 & nums <= f2, + rep(TRUE, length(values)) + ) + } else if (filter_type == "date") { + as_dates <- function(x) { + if (inherits(x, "Date")) { + x + } else { + suppressWarnings(as.Date(as.character(x))) + } + } + ds <- as_dates(values) + d1 <- suppressWarnings(as.Date(cond$dateFrom %||% cond$filter)) + d2 <- suppressWarnings(as.Date(cond$dateTo %||% cond$filterTo)) + switch(cond_type, + equals = ds == d1, + notEqual = ds != d1, + greaterThan = ds > d1, + greaterThanOrEqual = ds >= d1, + lessThan = ds < d1, + lessThanOrEqual = ds <= d1, + inRange = ds >= d1 & ds <= d2, + rep(TRUE, length(values)) + ) + } else if (filter_type == "datetime") { + as_datetimes <- function(x) { + if (inherits(x, "POSIXct") || inherits(x, "POSIXlt")) { + as.POSIXct(x) + } else { + suppressWarnings(as.POSIXct(as.character(x))) + } + } + dts <- as_datetimes(values) + dt1 <- suppressWarnings(as.POSIXct(cond$dateFrom %||% cond$filter)) + dt2 <- suppressWarnings(as.POSIXct(cond$dateTo %||% cond$filterTo)) + switch(cond_type, + equals = dts == dt1, + notEqual = dts != dt1, + greaterThan = dts > dt1, + greaterThanOrEqual = dts >= dt1, + lessThan = dts < dt1, + lessThanOrEqual = dts <= dt1, + inRange = dts >= dt1 & dts <= dt2, + rep(TRUE, length(values)) + ) + } else if (filter_type == "set") { + # For set filters (logical, factor), ag-grid sends selected values + if (!is.null(cond$values) && length(cond$values) > 0L) { + # Convert to character for comparison + text_values <- as.character(values) + selected <- unlist(cond$values) + match(text_values, selected, nomatch = 0L) > 0L + } else { + rep(TRUE, length(values)) + } + } else { + text <- tolower(as.character(values)) + filter_value <- tolower(as.character(cond$filter %||% "")) + switch(cond_type, + 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)) + ) + } +} + +`%||%` <- function(x, y) { + if (is.null(x)) y else x +} + +dataview_apply_filter_model <- function(state, filter_model, row_idx) { + if (is.null(filter_model) || !length(filter_model)) { + return(row_idx) + } + + matched <- rep(TRUE, length(row_idx)) + + for (col_id in names(filter_model)) { + col_model <- filter_model[[col_id]] + col_pos <- suppressWarnings(as.integer(col_id)) + 1L + if (is.na(col_pos) || col_pos < 1L || col_pos > length(state$columns)) { + next + } + + values <- state$columns[[col_pos]][row_idx] + type_hint <- state$types[[col_pos]] + column_match <- rep(TRUE, length(values)) + + if (!is.null(col_model$operator) && !is.null(col_model$condition1) && !is.null(col_model$condition2)) { + left <- dataview_match_condition(values, col_model$condition1, type_hint) + right <- dataview_match_condition(values, col_model$condition2, type_hint) + op <- toupper(as.character(col_model$operator)) + column_match <- if (op == "OR") left | right else left & right + } else { + column_match <- dataview_match_condition(values, col_model, type_hint) + } + + column_match[is.na(column_match)] <- FALSE + matched <- matched & column_match + } + + row_idx[matched] +} + +dataview_apply_sort_model <- function(state, sort_model, row_idx) { + if (is.null(sort_model) || !length(sort_model)) { + return(row_idx) + } + + sort_vectors <- list() + decreasing <- logical(0) + + for (sort_item in sort_model) { + col_id <- as.character(sort_item$colId %||% "") + col_pos <- suppressWarnings(as.integer(col_id)) + 1L + if (is.na(col_pos) || col_pos < 1L || col_pos > length(state$columns)) { + next + } + raw_values <- state$columns[[col_pos]][row_idx] + type_hint <- state$types[[col_pos]] + if (type_hint %in% c("num", "num-fmt")) { + sort_vectors[[length(sort_vectors) + 1L]] <- suppressWarnings(as.numeric(raw_values)) + } else if (type_hint == "date") { + sort_vectors[[length(sort_vectors) + 1L]] <- suppressWarnings(as.Date(as.character(raw_values))) + } else { + sort_vectors[[length(sort_vectors) + 1L]] <- tolower(as.character(raw_values)) + } + decreasing <- c(decreasing, identical(as.character(sort_item$sort), "desc")) + } + + if (!length(sort_vectors)) { + return(row_idx) + } + + ord <- do.call(order, c(sort_vectors, list(na.last = TRUE, decreasing = decreasing))) + row_idx[ord] +} + +dataview_rows <- function(state, row_idx) { + if (!length(row_idx)) { + return(list()) + } + + formatted_cols <- Map(function(col, type_hint) { + if (type_hint == "date") { + trimws(format(col[row_idx], "%Y-%m-%d")) + } else { + trimws(format(col[row_idx])) + } + }, state$columns, state$types) + + keys <- as.character(seq_along(formatted_cols) - 1L) + lapply(seq_along(row_idx), function(i) { + values <- lapply(formatted_cols, function(col) col[[i]]) + names(values) <- keys + values + }) +} + +handle_dataview_init <- function(params) { + view_id <- as.character(params$view_id %||% "") + state <- dataview_get_state(view_id) + list( + columns = dataview_columns(state), + totalRows = state$total_rows + ) +} + +handle_dataview_page <- function(params) { + view_id <- as.character(params$view_id %||% "") + state <- dataview_get_state(view_id) + + start_row <- suppressWarnings(as.integer(params$startRow %||% 0L)) + end_row <- suppressWarnings(as.integer(params$endRow %||% min(state$total_rows, 500L))) + if (is.na(start_row) || start_row < 0L) { + start_row <- 0L + } + if (is.na(end_row) || end_row < start_row) { + end_row <- start_row + } + + row_idx <- seq_len(state$total_rows) + row_idx <- dataview_apply_filter_model(state, params$filterModel, row_idx) + row_idx <- dataview_apply_sort_model(state, params$sortModel, row_idx) + + total <- length(row_idx) + if (start_row >= total) { + page_idx <- integer(0) + } else { + end_inclusive <- min(total, end_row) + page_idx <- row_idx[(start_row + 1L):end_inclusive] + } + + list( + rows = dataview_rows(state, page_idx), + totalRows = total, + lastRow = total + ) +} + +handle_dataview_dispose <- function(params) { + view_id <- as.character(params$view_id %||% "") + if (!is.null(.sess_env$dataviews) && !is.null(.sess_env$dataviews[[view_id]])) { + .sess_env$dataviews[[view_id]] <- NULL + } + TRUE +} diff --git a/sess/R/hooks.R b/sess/R/hooks.R index 5f7dcda4..320d2210 100644 --- a/sess/R/hooks.R +++ b/sess/R/hooks.R @@ -1,126 +1,30 @@ -dataview_data_type <- function(x) { - if (is.numeric(x)) { - if (is.null(attr(x, "class"))) { - "num" - } else { - "num-fmt" - } - } else if (inherits(x, "Date")) { - "date" - } else { - "string" - } -} - -dataview_table <- function(data) { - if (is.data.frame(data)) { - nrow <- nrow(data) - colnames <- colnames(data) - if (is.null(colnames)) { - colnames <- sprintf("(X%d)", seq_len(ncol(data))) - } else { - colnames <- trimws(colnames) - } - if (.row_names_info(data) > 0L) { - rownames <- rownames(data) - rownames(data) <- NULL - } else { - rownames <- seq_len(nrow) - } - data <- c(list(" " = rownames), .subset(data)) - colnames <- c(" ", colnames) - types <- vapply(data, dataview_data_type, - character(1L), - USE.NAMES = FALSE - ) - data <- vapply(data, function(x) { - trimws(format(x)) - }, character(nrow), USE.NAMES = FALSE) - dim(data) <- c(length(rownames), length(colnames)) - } else if (is.matrix(data)) { - if (is.factor(data)) { - data <- format(data) - } - types <- rep(dataview_data_type(data), ncol(data)) - colnames <- colnames(data) - colnames(data) <- NULL - if (is.null(colnames)) { - colnames <- sprintf("(X%d)", seq_len(ncol(data))) - } else { - colnames <- trimws(colnames) - } - rownames <- rownames(data) - rownames(data) <- NULL - data <- trimws(format(data)) - if (is.null(rownames)) { - types <- c("num", types) - rownames <- seq_len(nrow(data)) - } else { - types <- c("string", types) - rownames <- trimws(rownames) - } - dim(data) <- c(length(rownames), length(colnames)) - colnames <- c(" ", colnames) - data <- cbind(rownames, data) - } else { - stop("data must be data.frame or matrix") - } - columns <- .mapply(function(title, type, index) { - class <- if (type == "string") "text-left" else "text-right" - list( - headerName = jsonlite::unbox(title), - field = jsonlite::unbox(as.character(index - 1L)), - cellClass = jsonlite::unbox(class), - type = jsonlite::unbox(if (type == "date") "dateColumn" else type) - ) - }, list(colnames, types, seq_along(colnames)), NULL) - list(columns = columns, data = data) -} - #' Register hooks for the client IPC #' #' @param use_rstudioapi Logical. Enable rstudioapi emulation. #' @param use_httpgd Logical. Enable httpgd plot device if available. #' @export register_hooks <- function(use_rstudioapi = TRUE, use_httpgd = TRUE) { - # 1. Override View() to push data directly via WebSocket + # 1. Override View() to serve table data via paged RPC. show_dataview <- function(x, title = deparse(substitute(x))) { # make sure title is computed. force(title) - # Dump to a temporary file locally so the payload size over WS isn't massive - file_path <- tempfile(tmpdir = .sess_env$tempdir, fileext = ".json") - - row_limit <- abs(getOption("sess.row_limit", 100)) - - as_truncated_data <- function(.data) { - .nrow <- nrow(.data) - if (row_limit != 0 && row_limit < .nrow) { - title <<- sprintf("%s (limited to %d/%d)", title, row_limit, .nrow) - .data <- utils::head(.data, n = row_limit) - } - .data - } if (inherits(x, "ArrowTabular")) { - x <- as_truncated_data(x) x <- as.data.frame(x) } if (is.data.frame(x) || is.matrix(x)) { - x <- as_truncated_data(x) - data <- dataview_table(x) - jsonlite::write_json( - data, file_path, - matrix = "rowmajor", auto_unbox = TRUE, null = "null", na = "string" - ) + registration <- dataview_register(x) notify_client("dataview", list( title = title, - file = file_path, source = "table", - type = "json" + type = "json", + view_id = registration$view_id, + total_rows = registration$total_rows )) } else if (is.list(x)) { + file_path <- tempfile(tmpdir = .sess_env$tempdir, fileext = ".json") jsonlite::write_json(x, file_path, auto_unbox = TRUE, null = "null", na = "string") notify_client("dataview", list( title = title, diff --git a/sess/R/server.R b/sess/R/server.R index 06918b7f..5ecf0ec0 100644 --- a/sess/R/server.R +++ b/sess/R/server.R @@ -10,6 +10,7 @@ connect <- function(pipe_path = NULL, use_rstudioapi = TRUE, use_httpgd = TRUE) .sess_env$con <- NULL .sess_env$pending_responses <- list() .sess_env$read_buffer <- "" + .sess_env$dataviews <- list() .sess_env$tempdir <- file.path(tempdir(), "sess") dir.create(.sess_env$tempdir, showWarnings = FALSE, recursive = TRUE) @@ -170,7 +171,10 @@ dispatch_message <- function(line) { "workspace" = function(p) get_workspace_data(), "hover" = function(p) handle_hover(p$expr), "completion" = function(p) handle_complete(p$expr, p$trigger), - "plot_latest" = function(p) handle_plot_latest(p) + "plot_latest" = function(p) handle_plot_latest(p), + "dataview_init" = function(p) handle_dataview_init(p), + "dataview_page" = function(p) handle_dataview_page(p), + "dataview_dispose" = function(p) handle_dataview_dispose(p) ) if (payload$method %in% names(handlers)) { diff --git a/sess/tests/testthat/test-ipc.R b/sess/tests/testthat/test-ipc.R index cccde80c..713123b3 100644 --- a/sess/tests/testthat/test-ipc.R +++ b/sess/tests/testthat/test-ipc.R @@ -82,3 +82,82 @@ test_that("ipc_write returns FALSE when no connection is open", { result <- sess:::ipc_write(list(jsonrpc = "2.0", method = "test")) expect_false(isTRUE(result)) }) + +test_that("dataview init/page/dispose lifecycle works", { + .sess_env <- sess:::.sess_env + orig_dataviews <- .sess_env$dataviews + on.exit(.sess_env$dataviews <- orig_dataviews, add = TRUE) + + .sess_env$dataviews <- list() + + df <- data.frame(a = c(3, 1, 2), b = c("x", "y", "z"), stringsAsFactors = FALSE) + registration <- sess:::dataview_register(df) + + expect_true(is.character(registration$view_id)) + expect_equal(registration$total_rows, 3) + expect_length(registration$columns, 3) + + init_res <- sess:::handle_dataview_init(list(view_id = registration$view_id)) + expect_equal(init_res$totalRows, 3) + expect_length(init_res$columns, 3) + + page_res <- sess:::handle_dataview_page(list( + view_id = registration$view_id, + startRow = 0L, + endRow = 2L, + sortModel = list(), + filterModel = list() + )) + + expect_equal(length(page_res$rows), 2) + expect_equal(page_res$rows[[1]][["1"]], "3") + expect_equal(page_res$rows[[2]][["1"]], "1") + + disposed <- sess:::handle_dataview_dispose(list(view_id = registration$view_id)) + expect_true(isTRUE(disposed)) + expect_error( + sess:::handle_dataview_init(list(view_id = registration$view_id)), + "Unknown dataview id" + ) +}) + +test_that("dataview paging applies global filter and sort", { + .sess_env <- sess:::.sess_env + orig_dataviews <- .sess_env$dataviews + on.exit(.sess_env$dataviews <- orig_dataviews, add = TRUE) + + .sess_env$dataviews <- list() + + df <- data.frame(a = c(10, 30, 20), b = c("apple", "banana", "berry"), stringsAsFactors = FALSE) + registration <- sess:::dataview_register(df) + + filtered <- sess:::handle_dataview_page(list( + view_id = registration$view_id, + startRow = 0L, + endRow = 10L, + sortModel = list(), + filterModel = list( + "2" = list(filterType = "text", type = "contains", filter = "b") + ) + )) + + expect_equal(filtered$totalRows, 2) + expect_equal(length(filtered$rows), 2) + expect_equal(filtered$rows[[1]][["2"]], "banana") + expect_equal(filtered$rows[[2]][["2"]], "berry") + + sorted <- sess:::handle_dataview_page(list( + view_id = registration$view_id, + startRow = 0L, + endRow = 10L, + sortModel = list( + list(colId = "1", sort = "desc") + ), + filterModel = list() + )) + + expect_equal(sorted$totalRows, 3) + expect_equal(sorted$rows[[1]][["1"]], "30") + expect_equal(sorted$rows[[2]][["1"]], "20") + expect_equal(sorted$rows[[3]][["1"]], "10") +}) diff --git a/src/session.ts b/src/session.ts index 7e02f9bb..59c2cddb 100644 --- a/src/session.ts +++ b/src/session.ts @@ -86,6 +86,117 @@ const sessions = new Map(); export let activeSession: Session | undefined; let activeBrowserUri: Uri | undefined; +interface DataViewColumnDef { + headerName: string; + field: string; + cellClass: string; + type: string; +} + +interface DataViewInitResult { + columns: DataViewColumnDef[]; + totalRows: number; +} + +interface DataViewPageResult { + rows: Record[]; + totalRows: number; + lastRow: number; +} + +interface DataViewRequestMessage { + message: 'dataview/request'; + action: 'init' | 'page' | 'dispose'; + requestId: number; + startRow?: number; + endRow?: number; + sortModel?: unknown[]; + filterModel?: Record; +} + +const dynamicDataViewPanels = new WeakSet(); + +function formatDataViewPanelTitle(baseTitle: string, totalRows: number): string { + return `${baseTitle} (rows: ${totalRows.toLocaleString()})`; +} + +function attachDynamicDataViewBridge(panel: vscode.WebviewPanel, viewId: string, baseTitle: string): void { + dynamicDataViewPanels.add(panel); + + const postResponse = (requestId: number, ok: boolean, result?: unknown, error?: string) => { + void panel.webview.postMessage({ + message: 'dataview/response', + requestId, + ok, + result, + error, + }); + }; + + panel.webview.onDidReceiveMessage(async (raw: unknown) => { + const msg = raw as Partial; + if (msg.message !== 'dataview/request' || typeof msg.requestId !== 'number') { + return; + } + + try { + if (msg.action === 'init') { + const result = await sessionRequest({ + method: 'dataview_init', + params: { view_id: viewId }, + }) as DataViewInitResult; + if (Number.isFinite(result.totalRows)) { + panel.title = formatDataViewPanelTitle(baseTitle, result.totalRows); + } + postResponse(msg.requestId, true, result); + return; + } + + if (msg.action === 'page') { + const result = await sessionRequest({ + method: 'dataview_page', + params: { + view_id: viewId, + startRow: Number(msg.startRow ?? 0), + endRow: Number(msg.endRow ?? 0), + sortModel: Array.isArray(msg.sortModel) ? msg.sortModel : [], + filterModel: msg.filterModel ?? {}, + }, + }) as DataViewPageResult; + if (Number.isFinite(result.totalRows)) { + panel.title = formatDataViewPanelTitle(baseTitle, result.totalRows); + } + postResponse(msg.requestId, true, result); + return; + } + + if (msg.action === 'dispose') { + await sessionRequest({ + method: 'dataview_dispose', + params: { view_id: viewId }, + }); + postResponse(msg.requestId, true, true); + return; + } + + postResponse(msg.requestId, false, undefined, `Unsupported dataview action: ${String(msg.action)}`); + } catch (e) { + postResponse(msg.requestId, false, undefined, e instanceof Error ? e.message : String(e)); + } + }); + + panel.onDidDispose(() => { + if (!dynamicDataViewPanels.has(panel)) { + return; + } + dynamicDataViewPanels.delete(panel); + void sessionRequest({ + method: 'dataview_dispose', + params: { view_id: viewId }, + }); + }); +} + export function deploySessionWatcher(extensionPath: string): void { console.info(`[deploySessionWatcher] extensionPath: ${extensionPath}`); resDir = path.join(extensionPath, 'dist', 'resources'); @@ -495,8 +606,8 @@ export function openExternalBrowser(): void { } } -export async function showDataView(source: string, type: string, title: string, file: string, viewer: string): Promise { - console.info(`[showDataView] source: ${source}, type: ${type}, title: ${title}, file: ${file}, viewer: ${viewer}`); +export async function showDataView(source: string, type: string, title: string, file: string, viewer: string, viewId?: string): Promise { + console.info(`[showDataView] source: ${source}, type: ${type}, title: ${title}, file: ${file}, viewer: ${viewer}, viewId: ${String(viewId ?? '')}`); if (source === 'table') { const panel = window.createWebviewPanel('dataview', title, @@ -510,9 +621,12 @@ export async function showDataView(source: string, type: string, title: string, retainContextWhenHidden: true, localResourceRoots: [Uri.file(resDir)], }); - const content = await getTableHtml(panel.webview, file, title); + const content = await getTableHtml(panel.webview, file || undefined, title); panel.iconPath = new UriIcon('open-preview'); panel.webview.html = content; + if (viewId) { + attachDynamicDataViewBridge(panel, viewId, title); + } } else if (source === 'list') { const panel = window.createWebviewPanel('dataview', title, { @@ -538,8 +652,263 @@ export async function showDataView(source: string, type: string, title: string, console.info('[showDataView] Done'); } -export async function getTableHtml(webview: Webview, file: string, title: string): Promise { +export async function getTableHtml(webview: Webview, file: string | undefined, title: string): Promise { const pageSize = config().get('session.data.pageSize', 500); + if (!file) { + return ` + + + + + + ${title} + + + + + + + +
+ + +`; + } + const content = await readContent(file, 'utf8'); return ` @@ -933,11 +1302,18 @@ async function handleNotification(message: Record, socket: IpcS break; } case 'dataview': { - if (params.source && params.type && params.file && params.title) { + if (params.source && params.type && params.title) { const viewColumnConfig = config().get>('session.viewers.viewColumn') ?? {}; const viewer = viewColumnConfig['view'] ?? 'Two'; if (viewer !== 'Disable') { - await showDataView(String(params.source), String(params.type), String(params.title), String(params.file), viewer); + await showDataView( + String(params.source), + String(params.type), + String(params.title), + String(params.file ?? ''), + viewer, + params.view_id ? String(params.view_id) : undefined, + ); } } break; From 5e627c43e056966f7551551e8cade73e4a674eed Mon Sep 17 00:00:00 2001 From: Kun Ren Date: Sun, 10 May 2026 08:55:18 +0800 Subject: [PATCH 2/6] Upgrid ag-grid --- package-lock.json | 19 +++++++--- package.json | 2 +- src/session.ts | 92 +++++++++++++++++++++++++++++++---------------- 3 files changed, 77 insertions(+), 36 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7f586b28..6f5df139 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "3.0.0-rc.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { - "ag-grid-community": "^31.3.4", + "ag-grid-community": "^35.2.1", "cheerio": "1.0.0-rc.12", "crypto": "^1.0.1", "ejs": "^3.1.10", @@ -1500,12 +1500,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 3ac798ae..c6b3e067 100644 --- a/package.json +++ b/package.json @@ -2013,7 +2013,7 @@ "typescript": "^4.9.5" }, "dependencies": { - "ag-grid-community": "^31.3.4", + "ag-grid-community": "^35.2.1", "cheerio": "1.0.0-rc.12", "crypto": "^1.0.1", "ejs": "^3.1.10", diff --git a/src/session.ts b/src/session.ts index 59c2cddb..429404bb 100644 --- a/src/session.ts +++ b/src/session.ts @@ -768,12 +768,19 @@ export async function getTableHtml(webview: Webview, file: string | undefined, t const requestId = requestIdSeq++; return new Promise((resolve, reject) => { pending.set(requestId, { resolve, reject }); - vscode.postMessage({ - message: 'dataview/request', - action, - requestId, - ...payload, - }); + try { + console.log('[dataview] Sending request:', action, 'with payload:', payload); + vscode.postMessage({ + message: 'dataview/request', + action, + requestId, + ...payload, + }); + } catch (e) { + console.error('[dataview] Failed to send request:', e); + pending.delete(requestId); + reject(e); + } }); } @@ -808,11 +815,15 @@ export async function getTableHtml(webview: Webview, file: string | undefined, t } async function initialize() { + console.log('[dataview] agGrid object:', window.agGrid); + console.log('[dataview] agGrid.Grid:', typeof window.agGrid.Grid); + const init = await request('init', {}); const columns = Array.isArray(init.columns) ? init.columns : []; columns.forEach((column) => { if (column.type === 'dateColumn') { + column.filter = 'agDateColumnFilter'; column.filterParams = dateFilterParams; } else if (column.type === 'datetimeColumn') { column.filter = 'agDateColumnFilter'; @@ -827,17 +838,42 @@ export async function getTableHtml(webview: Webview, file: string | undefined, t buttons: ['reset', 'apply'] }; } else if (column.type === 'setColumn') { - // With infinite row model, use text filter for factors/logicals - // The server-side filtering still works correctly - delete column.type; + // agSetColumnFilter requires ag-grid-enterprise in v35. + // Keep community build compatible by using text filter and + // relying on server-side filtering in sess. + column.filter = 'agTextColumnFilter'; column.filterParams = { + ...column.filterParams, buttons: ['reset', 'apply'] }; } + // Remove column type field - v35 doesn't use it + delete column.type; }); const blockSize = ${pageSize > 0 ? pageSize : 500}; + let gridApi; + + const datasource = { + getRows: async function(params) { + try { + const result = await request('page', { + startRow: params.startRow, + endRow: params.endRow, + sortModel: params.sortModel, + filterModel: params.filterModel, + }); + const resolvedLastRow = Number.isFinite(result.totalRows) ? result.totalRows : result.lastRow; + params.successCallback(result.rows || [], resolvedLastRow); + } catch (e) { + console.error('[dataview] Failed to load page', e); + params.failCallback(); + } + } + }; + const gridOptions = { + theme: 'legacy', defaultColDef: { sortable: true, resizable: true, @@ -850,38 +886,31 @@ export async function getTableHtml(webview: Webview, file: string | undefined, t }, columnDefs: columns, rowModelType: 'infinite', + datasource: datasource, cacheBlockSize: blockSize, pagination: ${pageSize > 0 ? 'true' : 'false'}, paginationPageSize: blockSize, + paginationPageSizeSelector: [20, 50, 100, blockSize], enableCellTextSelection: true, ensureDomOrder: true, tooltipShowDelay: 100, onFirstDataRendered: function() { - gridOptions.columnApi.autoSizeAllColumns(false); - } - }; - - const datasource = { - getRows: async function(params) { - try { - const result = await request('page', { - startRow: params.startRow, - endRow: params.endRow, - sortModel: params.sortModel, - filterModel: params.filterModel, - }); - const resolvedLastRow = Number.isFinite(result.totalRows) ? result.totalRows : result.lastRow; - params.successCallback(result.rows || [], resolvedLastRow); - } catch (e) { - console.error('[dataview] Failed to load page', e); - params.failCallback(); + if (gridApi) { + gridApi.columnApi?.autoSizeAllColumns(false); } } }; const gridDiv = document.querySelector('#myGrid'); - new agGrid.Grid(gridDiv, gridOptions); - gridOptions.api.setDatasource(datasource); + try { + console.log('[dataview] Creating grid with options:', gridOptions); + gridApi = window.agGrid.createGrid(gridDiv, gridOptions); + console.log('[dataview] Grid created successfully'); + } catch (e) { + console.error('[dataview] Grid creation failed:', e); + console.error('[dataview] Error stack:', e instanceof Error ? e.stack : 'N/A'); + gridDiv.innerHTML = '
Error: ' + (e instanceof Error ? e.message : String(e)) + '
'; + } } document.addEventListener('DOMContentLoaded', () => { @@ -1056,6 +1085,7 @@ export async function getTableHtml(webview: Webview, file: string | undefined, t rowSelection: 'multiple', pagination: ${pageSize > 0 ? 'true' : 'false'}, paginationPageSize: ${pageSize}, + paginationPageSizeSelector: [20, 50, 100, ${pageSize}], enableCellTextSelection: true, ensureDomOrder: true, tooltipShowDelay: 100, @@ -1075,11 +1105,13 @@ export async function getTableHtml(webview: Webview, file: string | undefined, t document.addEventListener('DOMContentLoaded', () => { gridOptions.columnDefs.forEach(function(column) { if (column.type === 'dateColumn') { + column.filter = 'agDateColumnFilter'; column.filterParams = dateFilterParams; } + delete column.type; }); const gridDiv = document.querySelector('#myGrid'); - new agGrid.Grid(gridDiv, gridOptions); + gridApi = window.agGrid.createGrid(gridDiv, gridOptions); }); function onload() { updateTheme(); From 3e7509373d54e80686f10d6fa14dd0a2214b8cac Mon Sep 17 00:00:00 2001 From: Kun Ren Date: Sun, 10 May 2026 08:58:46 +0800 Subject: [PATCH 3/6] Update ag-grid theming --- src/session.ts | 38 ++++++++++++++++++++++---------------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/src/session.ts b/src/session.ts index 429404bb..108e03dd 100644 --- a/src/session.ts +++ b/src/session.ts @@ -757,12 +757,11 @@ export async function getTableHtml(webview: Webview, file: string | undefined, t } - - - - -
+
+
+
+ + +
+
`; From 4f2abff4086ae2ae06f70d70f45713c79624a6a8 Mon Sep 17 00:00:00 2001 From: Kun Ren Date: Sun, 10 May 2026 17:28:03 +0800 Subject: [PATCH 5/6] Fix linting issues --- sess/R/handlers.R | 38 +++++++++++++++++++++++++------------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/sess/R/handlers.R b/sess/R/handlers.R index 7ede3f20..b5a1824f 100644 --- a/sess/R/handlers.R +++ b/sess/R/handlers.R @@ -206,7 +206,10 @@ dataview_to_state <- function(data) { } row_index <- rownames(data) rownames(data) <- NULL - cols <- c(list(if (is.null(row_index)) seq_len(n) else trimws(row_index)), lapply(seq_len(ncol(data)), function(i) data[, i])) + cols <- c( + list(if (is.null(row_index)) seq_len(n) else trimws(row_index)), + lapply(seq_len(ncol(data)), function(i) data[, i]) + ) headers <- c(" ", colnames) matrix_type <- dataview_data_type(data) types <- c(if (is.null(row_index)) "num" else "string", rep(matrix_type, ncol(data))) @@ -238,14 +241,14 @@ dataview_columns <- function(state) { } else { "" } - + col_def <- list( headerName = jsonlite::unbox(title), field = jsonlite::unbox(as.character(index - 1L)), cellClass = jsonlite::unbox(class), type = jsonlite::unbox(ag_type) ) - + # For set filters, include unique values so ag-grid can show all options if (type %in% c("logical", "factor")) { unique_vals <- sort(unique(as.character(col_data))) @@ -253,7 +256,7 @@ dataview_columns <- function(state) { # Keep as vector, not list, so JSON serialization is [val1, val2, ...] col_def$filterParams <- list(values = I(unique_vals)) } - + col_def }, list(state$headers, state$types, seq_along(state$headers), state$columns), NULL) } @@ -294,13 +297,19 @@ dataview_match_condition <- function(values, cond, type_hint) { } # Determine filter type from cond or use hint - filter_type <- as.character(cond$filterType %||% - if (type_hint == "date") "date" - else if (type_hint == "datetime") "datetime" - else if (type_hint %in% c("num", "num-fmt")) "number" - else if (type_hint %in% c("logical", "factor")) "set" - else "text" - ) + filter_type <- if (!is.null(cond$filterType)) { + as.character(cond$filterType) + } else if (type_hint == "date") { + "date" + } else if (type_hint == "datetime") { + "datetime" + } else if (type_hint %in% c("num", "num-fmt")) { + "number" + } else if (type_hint %in% c("logical", "factor")) { + "set" + } else { + "text" + } if (filter_type == "number") { nums <- suppressWarnings(as.numeric(values)) @@ -405,7 +414,9 @@ dataview_apply_filter_model <- function(state, filter_model, row_idx) { type_hint <- state$types[[col_pos]] column_match <- rep(TRUE, length(values)) - if (!is.null(col_model$operator) && !is.null(col_model$condition1) && !is.null(col_model$condition2)) { + if (!is.null(col_model$operator) && + !is.null(col_model$condition1) && + !is.null(col_model$condition2)) { left <- dataview_match_condition(values, col_model$condition1, type_hint) right <- dataview_match_condition(values, col_model$condition2, type_hint) op <- toupper(as.character(col_model$operator)) @@ -440,7 +451,8 @@ dataview_apply_sort_model <- function(state, sort_model, row_idx) { if (type_hint %in% c("num", "num-fmt")) { sort_vectors[[length(sort_vectors) + 1L]] <- suppressWarnings(as.numeric(raw_values)) } else if (type_hint == "date") { - sort_vectors[[length(sort_vectors) + 1L]] <- suppressWarnings(as.Date(as.character(raw_values))) + sort_vectors[[length(sort_vectors) + 1L]] <- + suppressWarnings(as.Date(as.character(raw_values))) } else { sort_vectors[[length(sort_vectors) + 1L]] <- tolower(as.character(raw_values)) } From b79afee929616c07f0b7b9c4997a0a69995c39bd Mon Sep 17 00:00:00 2001 From: Kun Ren Date: Sun, 10 May 2026 18:06:01 +0800 Subject: [PATCH 6/6] Fix data viewer code --- sess/R/handlers.R | 41 ++++++++++++++++++++++++++++++++--------- src/session.ts | 35 +++++++++++++++++++++++++++-------- 2 files changed, 59 insertions(+), 17 deletions(-) diff --git a/sess/R/handlers.R b/sess/R/handlers.R index b5a1824f..f08b2ff9 100644 --- a/sess/R/handlers.R +++ b/sess/R/handlers.R @@ -437,8 +437,7 @@ dataview_apply_sort_model <- function(state, sort_model, row_idx) { return(row_idx) } - sort_vectors <- list() - decreasing <- logical(0) + sort_specs <- list() for (sort_item in sort_model) { col_id <- as.character(sort_item$colId %||% "") @@ -448,22 +447,46 @@ dataview_apply_sort_model <- function(state, sort_model, row_idx) { } raw_values <- state$columns[[col_pos]][row_idx] type_hint <- state$types[[col_pos]] + is_desc <- identical(as.character(sort_item$sort), "desc") + if (type_hint %in% c("num", "num-fmt")) { - sort_vectors[[length(sort_vectors) + 1L]] <- suppressWarnings(as.numeric(raw_values)) + sort_vec <- suppressWarnings(as.numeric(raw_values)) } else if (type_hint == "date") { - sort_vectors[[length(sort_vectors) + 1L]] <- - suppressWarnings(as.Date(as.character(raw_values))) + sort_vec <- suppressWarnings(as.Date(as.character(raw_values))) } else { - sort_vectors[[length(sort_vectors) + 1L]] <- tolower(as.character(raw_values)) + sort_vec <- tolower(as.character(raw_values)) } - decreasing <- c(decreasing, identical(as.character(sort_item$sort), "desc")) + + sort_specs[[length(sort_specs) + 1L]] <- list( + values = sort_vec, + decreasing = is_desc + ) } - if (!length(sort_vectors)) { + if (!length(sort_specs)) { return(row_idx) } - ord <- do.call(order, c(sort_vectors, list(na.last = TRUE, decreasing = decreasing))) + # Build arguments for order() with per-column decreasing handling. + # R's order() doesn't support per-column decreasing, so transform values: + # - For numeric: negate for descending + # - For other types: negate rank for descending + order_args <- list() + for (i in seq_along(sort_specs)) { + spec <- sort_specs[[i]] + if (spec$decreasing) { + if (is.numeric(spec$values)) { + order_args[[i]] <- -spec$values + } else { + order_args[[i]] <- -rank(spec$values, na.last = "keep") + } + } else { + order_args[[i]] <- spec$values + } + } + order_args$na.last <- TRUE + + ord <- do.call(order, order_args) row_idx[ord] } diff --git a/src/session.ts b/src/session.ts index ba054deb..a796609d 100644 --- a/src/session.ts +++ b/src/session.ts @@ -116,6 +116,17 @@ interface DataViewRequestMessage { const dynamicDataViewPanels = new WeakSet(); +function escapeHtml(text: string): string { + const map: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + '\'': ''', + }; + return text.replace(/[&<>"']/g, c => map[c]); +} + function formatDataViewPanelTitle(baseTitle: string, totalRows: number): string { return `${baseTitle} (rows: ${totalRows.toLocaleString()})`; } @@ -144,7 +155,10 @@ function attachDynamicDataViewBridge(panel: vscode.WebviewPanel, viewId: string, const result = await sessionRequest({ method: 'dataview_init', params: { view_id: viewId }, - }) as DataViewInitResult; + }) as DataViewInitResult | undefined; + if (!result || typeof result.totalRows !== 'number') { + throw new Error('Invalid dataview_init response: missing or invalid totalRows'); + } if (Number.isFinite(result.totalRows)) { panel.title = formatDataViewPanelTitle(baseTitle, result.totalRows); } @@ -162,7 +176,10 @@ function attachDynamicDataViewBridge(panel: vscode.WebviewPanel, viewId: string, sortModel: Array.isArray(msg.sortModel) ? msg.sortModel : [], filterModel: msg.filterModel ?? {}, }, - }) as DataViewPageResult; + }) as DataViewPageResult | undefined; + if (!result || typeof result.totalRows !== 'number') { + throw new Error('Invalid dataview_page response: missing or invalid totalRows'); + } if (Number.isFinite(result.totalRows)) { panel.title = formatDataViewPanelTitle(baseTitle, result.totalRows); } @@ -175,6 +192,8 @@ function attachDynamicDataViewBridge(panel: vscode.WebviewPanel, viewId: string, method: 'dataview_dispose', params: { view_id: viewId }, }); + // Remove from panels set to prevent duplicate disposal on panel close + dynamicDataViewPanels.delete(panel); postResponse(msg.requestId, true, true); return; } @@ -661,7 +680,7 @@ export async function getTableHtml(webview: Webview, file: string | undefined, t - ${title} + ${escapeHtml(title)}