From c16dcefe716fb0c76ca4d7bd72aa5ed4243ad7d4 Mon Sep 17 00:00:00 2001 From: "4111978+Fred-Wu@users.noreply.github.com" <4111978+Fred-Wu@users.noreply.github.com> Date: Sun, 20 Sep 2026 16:38:54 +1000 Subject: [PATCH 1/6] Enable viewing nested workspace lists and tables --- package.json | 2 +- sess/R/handlers.R | 13 ++++++++++++- sess/R/server.R | 1 + sess/inst/tinytest/test-dataview.R | 9 +++++++++ src/extension.ts | 2 +- src/workspaceViewer.ts | 19 +++++++++++++++---- 6 files changed, 39 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index f9b1a747..fb774494 100644 --- a/package.json +++ b/package.json @@ -1119,7 +1119,7 @@ { "command": "r.workspaceViewer.view", "group": "inline", - "when": "view == workspaceViewer && viewItem == rootNode" + "when": "view == workspaceViewer && (viewItem == rootNode || viewItem == viewableNode)" }, { "command": "r.workspaceViewer.remove", diff --git a/sess/R/handlers.R b/sess/R/handlers.R index 6dec46e2..2cd74ea9 100644 --- a/sess/R/handlers.R +++ b/sess/R/handlers.R @@ -118,6 +118,7 @@ workspace_child_item <- function(object, str, selector) { class = paste(class(object), collapse = ", "), type = typeof(object), has_children = workspace_child_count(object) > 0L, + viewable = is.list(object) || dataview_is_table(object), selector = selector ) } @@ -130,6 +131,16 @@ workspace_child_label <- function(name, index) { } } +handle_workspace_view <- function(name, path = list()) { + title <- paste0(c(name, vapply(path, function(selector) switch(selector$kind, + index = if (!is.null(selector$name) && !is.na(selector$name) && nzchar(selector$name)) paste0("$", selector$name) else paste0("[[", selector$value, "]]"), + name = paste0("$", selector$value), + slot = paste0("@", selector$value) + ), "")), collapse = "") + utils::View(workspace_object(name, path), title = title) + TRUE +} + get_workspace_children <- function(name, path = list(), start = 1L) { tryCatch({ object <- workspace_object(name, path) @@ -186,7 +197,7 @@ get_workspace_children <- function(name, path = list(), start = 1L) { ": ", trimws(try_capture_str(child)) ), - list(kind = "index", value = index) + list(kind = "index", value = index, name = child_name) ) }) } diff --git a/sess/R/server.R b/sess/R/server.R index fe5c8eba..adc90132 100644 --- a/sess/R/server.R +++ b/sess/R/server.R @@ -175,6 +175,7 @@ dispatch_message <- function(line) { handlers <- list( "workspace" = function(p) get_workspace_data(), "workspace_children" = function(p) get_workspace_children(p$name, p$path, p$start), + "workspace_view" = function(p) handle_workspace_view(p$name, p$path), "hover" = function(p) handle_hover(p$expr), "completion" = function(p) handle_complete(p$expr, p$trigger), "plot_latest" = function(p) handle_plot_latest(p), diff --git a/sess/inst/tinytest/test-dataview.R b/sess/inst/tinytest/test-dataview.R index 82e7fe2d..bda38e66 100644 --- a/sess/inst/tinytest/test-dataview.R +++ b/sess/inst/tinytest/test-dataview.R @@ -79,3 +79,12 @@ local({ ), 2L, info = case_name) } }) + + +# Workspace children expose the View action only for supported table objects. +local({ + selector <- list(kind = "index", value = 1L) + expect_true(sess:::workspace_child_item(data.frame(x = 1), "df", selector)$viewable) + expect_true(sess:::workspace_child_item(matrix(1:4, 2), "matrix", selector)$viewable) + expect_true(sess:::workspace_child_item(list(x = 1), "list", selector)$viewable) +}) diff --git a/src/extension.ts b/src/extension.ts index 68cc595d..9fc9cfc9 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -138,7 +138,7 @@ export async function activate(context: vscode.ExtensionContext): Promise rWorkspace?.refresh(), - 'r.workspaceViewer.view': (node: workspaceViewer.GlobalEnvItem) => node?.label && workspaceViewer.viewItem(node.label), + 'r.workspaceViewer.view': (node: workspaceViewer.GlobalEnvItem) => node && workspaceViewer.viewItem(node), 'r.workspaceViewer.remove': (node: workspaceViewer.GlobalEnvItem) => node?.label && workspaceViewer.removeItem(node.label), 'r.workspaceViewer.clear': workspaceViewer.clearWorkspace, 'r.workspaceViewer.load': workspaceViewer.loadWorkspace, diff --git a/src/workspaceViewer.ts b/src/workspaceViewer.ts index 14162fb4..ad831a2f 100644 --- a/src/workspaceViewer.ts +++ b/src/workspaceViewer.ts @@ -21,6 +21,7 @@ interface WorkspaceChild { class: string; type: string; has_children: boolean; + viewable?: boolean; selector?: WorkspaceSelector; } @@ -149,7 +150,8 @@ export class WorkspaceDataProvider implements TreeDataProvider { undefined, child.has_children, element.rootName, - child.selector ? [...element.objectPath, child.selector] : element.objectPath + child.selector ? [...element.objectPath, child.selector] : element.objectPath, + child.viewable ) ); if (page.nextStart !== undefined) { @@ -351,6 +353,7 @@ export class GlobalEnvItem extends TreeItem { hasChildren?: boolean, rootName?: string, objectPath?: WorkspaceSelector[], + viewable?: boolean, ) { super( label, @@ -369,7 +372,7 @@ export class GlobalEnvItem extends TreeItem { ); this.tooltip = this.getTooltip(label, rClass, treeLevel); this.iconPath = this.getIcon(type, dim); - this.contextValue = treeLevel === 0 ? 'rootNode' : `childNode${this.treeLevel}`; + this.contextValue = treeLevel === 0 ? 'rootNode' : viewable ? 'viewableNode' : `childNode${this.treeLevel}`; } private getDescription(dim: number[] | undefined, str: string, rClass: string, type: string): string { @@ -494,8 +497,16 @@ export function loadWorkspace(): void { }); } -export function viewItem(node: string): void { - void runTextInTerm(`View(${node})`); +export function viewItem(node: GlobalEnvItem): void { + if (globalPipePath && node.rootName) { + void sessionRequest({ + method: 'workspace_view', + params: { + name: node.rootName, + path: node.objectPath, + }, + }); + } } export function removeItem(node: string): void { From bcf1db8a4af35fa99f7706eb72cd1f99109ad56e Mon Sep 17 00:00:00 2001 From: "4111978+Fred-Wu@users.noreply.github.com" <4111978+Fred-Wu@users.noreply.github.com> Date: Sun, 20 Sep 2026 16:39:00 +1000 Subject: [PATCH 2/6] Show shallow list summaries with child navigation --- sess/R/handlers.R | 18 +++++++ sess/R/hooks.R | 21 +++++++- sess/R/server.R | 1 + src/session.ts | 124 +++++++++++++++++++++++++++++----------------- 4 files changed, 116 insertions(+), 48 deletions(-) diff --git a/sess/R/handlers.R b/sess/R/handlers.R index 2cd74ea9..c54c19d2 100644 --- a/sess/R/handlers.R +++ b/sess/R/handlers.R @@ -209,6 +209,24 @@ get_workspace_children <- function(name, path = list(), start = 1L) { }, error = function(e) list(children = I(list()), next_start = NULL)) } +handle_listview_view <- function(view_id, index) { + state <- .sess_env$dataviews[[as.character(view_id)]] + index <- as.integer(index) + if (is.null(state) || !identical(state$type, "list") || + is.na(index) || index < 1L || index > length(state$data)) { + return(FALSE) + } + child_names <- names(state$data) + child_name <- if (is.null(child_names)) NULL else child_names[[index]] + title <- if (!is.null(child_name) && !is.na(child_name) && nzchar(child_name)) { + paste0(state$title, "$", child_name) + } else { + paste0(state$title, "[[", index, "]]") + } + utils::View(state$data[[index]], title = title) + TRUE +} + handle_hover <- function(expr_str) { tryCatch( { diff --git a/sess/R/hooks.R b/sess/R/hooks.R index 0cb2dd90..c029fb77 100644 --- a/sess/R/hooks.R +++ b/sess/R/hooks.R @@ -38,13 +38,30 @@ register_hooks <- function(use_rstudioapi = TRUE, use_httpgd = TRUE, use_jgd = F view_id = registration$view_id )) } else if (is.list(x)) { + view_id <- dataview_new_id() + .sess_env$dataviews[[view_id]] <- list( + type = "list", + data = x, + title = paste(as.character(title), collapse = "\n") + ) + x_names <- names(x) + children <- lapply(seq_along(x), function(index) { + child <- x[[index]] + list( + label = workspace_child_label(if (is.null(x_names)) NULL else x_names[[index]], index), + str = trimws(try_capture_str(child)), + viewable = is.list(child) || dataview_is_table(child), + index = index + ) + }) file_path <- tempfile(tmpdir = .sess_env$tempdir, fileext = ".json") - jsonlite::write_json(x, file_path, auto_unbox = TRUE, null = "null", na = "string") + jsonlite::write_json(list(children = I(children)), file_path, auto_unbox = TRUE) notify_client("dataview", list( title = title, file = file_path, source = "list", - type = "json" + type = "json", + view_id = view_id )) } else { code <- if (is.primitive(x)) utils::capture.output(print(x)) else deparse(x) diff --git a/sess/R/server.R b/sess/R/server.R index adc90132..475109bf 100644 --- a/sess/R/server.R +++ b/sess/R/server.R @@ -179,6 +179,7 @@ dispatch_message <- function(line) { "hover" = function(p) handle_hover(p$expr), "completion" = function(p) handle_complete(p$expr, p$trigger), "plot_latest" = function(p) handle_plot_latest(p), + "listview_view" = function(p) handle_listview_view(p$view_id, p$index), "dataview_init" = function(p) handle_dataview_init(p), "dataview_page" = function(p) handle_dataview_page(p), "dataview_dispose" = function(p) handle_dataview_dispose(p) diff --git a/src/session.ts b/src/session.ts index 9fd9125f..0e3bfa1a 100644 --- a/src/session.ts +++ b/src/session.ts @@ -745,6 +745,22 @@ export async function showDataView(source: string, type: string, title: string, }); const content = await getListHtml(panel.webview, file, title); panel.iconPath = new UriIcon('open-preview'); + if (viewId) { + panel.webview.onDidReceiveMessage((message: { message?: string; index?: number }) => { + if (message.message === 'listview/view' && typeof message.index === 'number' && Number.isInteger(message.index)) { + void sessionRequest({ + method: 'listview_view', + params: { view_id: viewId, index: message.index }, + }); + } + }); + panel.onDidDispose(() => { + void sessionRequest({ + method: 'dataview_dispose', + params: { view_id: viewId }, + }); + }); + } panel.webview.html = content; } else { await commands.executeCommand('vscode.open', Uri.file(file), { @@ -1552,7 +1568,7 @@ export async function getTableHtml(webview: Webview, file: string | undefined, t } export async function getListHtml(webview: Webview, file: string, title: string): Promise { - const content = await readContent(file, 'utf8'); + const content = (await readContent(file, 'utf8')).replace(/ @@ -1561,64 +1577,80 @@ export async function getListHtml(webview: Webview, file: string, title: string) ${escapeHtml(title)} - - - - - -

+    
+ `; From 051ace3d48436609d3f656642805da91528447d6 Mon Sep 17 00:00:00 2001 From: "4111978+Fred-Wu@users.noreply.github.com" <4111978+Fred-Wu@users.noreply.github.com> Date: Sun, 20 Sep 2026 16:39:07 +1000 Subject: [PATCH 3/6] Match list viewer actions to workspace View icons --- images/icons/dark/open-preview-codicon.svg | 1 + images/icons/light/open-preview-codicon.svg | 1 + src/session.ts | 32 +++++++++++++++++---- 3 files changed, 28 insertions(+), 6 deletions(-) create mode 100644 images/icons/dark/open-preview-codicon.svg create mode 100644 images/icons/light/open-preview-codicon.svg diff --git a/images/icons/dark/open-preview-codicon.svg b/images/icons/dark/open-preview-codicon.svg new file mode 100644 index 00000000..5dc0bba6 --- /dev/null +++ b/images/icons/dark/open-preview-codicon.svg @@ -0,0 +1 @@ + diff --git a/images/icons/light/open-preview-codicon.svg b/images/icons/light/open-preview-codicon.svg new file mode 100644 index 00000000..9e363217 --- /dev/null +++ b/images/icons/light/open-preview-codicon.svg @@ -0,0 +1 @@ + diff --git a/src/session.ts b/src/session.ts index 0e3bfa1a..057711e8 100644 --- a/src/session.ts +++ b/src/session.ts @@ -741,7 +741,7 @@ export async function showDataView(source: string, type: string, title: string, enableScripts: true, enableFindWidget: true, retainContextWhenHidden: true, - localResourceRoots: [Uri.file(resDir)], + localResourceRoots: [Uri.file(resDir), Uri.file(extensionContext.asAbsolutePath('images/icons'))], }); const content = await getListHtml(panel.webview, file, title); panel.iconPath = new UriIcon('open-preview'); @@ -1569,6 +1569,9 @@ export async function getTableHtml(webview: Webview, file: string | undefined, t export async function getListHtml(webview: Webview, file: string, title: string): Promise { const content = (await readContent(file, 'utf8')).replace(/ @@ -1607,14 +1610,29 @@ export async function getListHtml(webview: Webview, file: string, title: string) white-space: pre-wrap; } button { + display: flex; + align-items: center; border: 0; - padding: 2px 8px; - color: var(--vscode-button-foreground); - background-color: var(--vscode-button-background); + padding: 2px; + color: var(--vscode-foreground); + background: transparent; cursor: pointer; } button:hover { - background-color: var(--vscode-button-hoverBackground); + background-color: var(--vscode-toolbar-hoverBackground); + } + button img { + width: 16px; + height: 16px; + } + .light-icon { + display: none; + } + body.vscode-light .dark-icon { + display: none; + } + body.vscode-light .light-icon { + display: block; } @@ -1641,7 +1659,9 @@ export async function getListHtml(webview: Webview, file: string, title: string) if (item.viewable) { const button = document.createElement('button'); - button.textContent = 'View'; + button.title = 'View'; + button.setAttribute('aria-label', 'View'); + button.innerHTML = ''; button.addEventListener('click', () => { vscode.postMessage({ message: 'listview/view', index: item.index }); }); From 34ee660af987351e4958c3093790f483b21574d2 Mon Sep 17 00:00:00 2001 From: "4111978+Fred-Wu@users.noreply.github.com" <4111978+Fred-Wu@users.noreply.github.com> Date: Sun, 20 Sep 2026 16:39:13 +1000 Subject: [PATCH 4/6] Reuse list viewer panels by title --- sess/R/hooks.R | 20 +++++++++++--------- src/session.ts | 25 +++++++++++++++++++------ 2 files changed, 30 insertions(+), 15 deletions(-) diff --git a/sess/R/hooks.R b/sess/R/hooks.R index c029fb77..747e33f0 100644 --- a/sess/R/hooks.R +++ b/sess/R/hooks.R @@ -14,21 +14,24 @@ register_hooks <- function(use_rstudioapi = TRUE, use_httpgd = TRUE, use_jgd = F # make sure title is computed. force(title) - if (dataview_is_table(x)) { + view_type <- if (dataview_is_table(x)) "table" else if (is.list(x)) "list" else "object" + if (view_type != "object") { title_key <- paste(as.character(title), collapse = "\n") + registry_key <- paste0(view_type, ":", title_key) dataview_registry <- .sess_env$dataview_registry - has_view_id <- nzchar(title_key) && - exists(title_key, envir = dataview_registry, inherits = FALSE) - view_id <- if (has_view_id) { - get(title_key, envir = dataview_registry, inherits = FALSE) + view_id <- if (nzchar(title_key) && + exists(registry_key, envir = dataview_registry, inherits = FALSE)) { + get(registry_key, envir = dataview_registry, inherits = FALSE) } else { id <- dataview_new_id() if (nzchar(title_key)) { - assign(title_key, id, envir = dataview_registry) + assign(registry_key, id, envir = dataview_registry) } id } + } + if (view_type == "table") { registration <- dataview_register(x, view_id = view_id) notify_client("dataview", list( @@ -37,12 +40,11 @@ register_hooks <- function(use_rstudioapi = TRUE, use_httpgd = TRUE, use_jgd = F type = "json", view_id = registration$view_id )) - } else if (is.list(x)) { - view_id <- dataview_new_id() + } else if (view_type == "list") { .sess_env$dataviews[[view_id]] <- list( type = "list", data = x, - title = paste(as.character(title), collapse = "\n") + title = title_key ) x_names <- names(x) children <- lapply(seq_along(x), function(index) { diff --git a/src/session.ts b/src/session.ts index 057711e8..a32f3312 100644 --- a/src/session.ts +++ b/src/session.ts @@ -732,6 +732,16 @@ export async function showDataView(source: string, type: string, title: string, const content = await getTableHtml(panel.webview, file || undefined, title); panel.webview.html = content; } else if (source === 'list') { + if (viewId) { + const existing = dynamicDataViewPanels.get(viewId); + if (existing) { + existing.title = title; + existing.reveal(ViewColumn[viewer as keyof typeof ViewColumn], true); + existing.webview.html = await getListHtml(existing.webview, file, title); + return; + } + } + const panel = window.createWebviewPanel('dataview', title, { preserveFocus: true, @@ -741,11 +751,11 @@ export async function showDataView(source: string, type: string, title: string, enableScripts: true, enableFindWidget: true, retainContextWhenHidden: true, - localResourceRoots: [Uri.file(resDir), Uri.file(extensionContext.asAbsolutePath('images/icons'))], + localResourceRoots: [Uri.file(extensionContext.asAbsolutePath('images/icons'))], }); - const content = await getListHtml(panel.webview, file, title); panel.iconPath = new UriIcon('open-preview'); if (viewId) { + dynamicDataViewPanels.set(viewId, panel); panel.webview.onDidReceiveMessage((message: { message?: string; index?: number }) => { if (message.message === 'listview/view' && typeof message.index === 'number' && Number.isInteger(message.index)) { void sessionRequest({ @@ -755,13 +765,16 @@ export async function showDataView(source: string, type: string, title: string, } }); panel.onDidDispose(() => { + if (dynamicDataViewPanels.get(viewId) === panel) { + dynamicDataViewPanels.delete(viewId); + } void sessionRequest({ method: 'dataview_dispose', params: { view_id: viewId }, }); }); } - panel.webview.html = content; + panel.webview.html = await getListHtml(panel.webview, file, title); } else { await commands.executeCommand('vscode.open', Uri.file(file), { preserveFocus: true, @@ -1568,10 +1581,10 @@ export async function getTableHtml(webview: Webview, file: string | undefined, t } export async function getListHtml(webview: Webview, file: string, title: string): Promise { - const content = (await readContent(file, 'utf8')).replace(/ From b2a509b7cd0ddc6b8b7276f003f0cc22d38d8c57 Mon Sep 17 00:00:00 2001 From: "4111978+Fred-Wu@users.noreply.github.com" <4111978+Fred-Wu@users.noreply.github.com> Date: Sun, 20 Sep 2026 20:16:49 +1000 Subject: [PATCH 5/6] Nested View icons now appear for all supported values --- sess/R/handlers.R | 28 +++++++++++++++----- sess/R/hooks.R | 41 +++++++++++++++++++++++++----- sess/inst/tinytest/test-dataview.R | 9 ++++++- 3 files changed, 63 insertions(+), 15 deletions(-) diff --git a/sess/R/handlers.R b/sess/R/handlers.R index c54c19d2..581bdfec 100644 --- a/sess/R/handlers.R +++ b/sess/R/handlers.R @@ -118,7 +118,7 @@ workspace_child_item <- function(object, str, selector) { class = paste(class(object), collapse = ", "), type = typeof(object), has_children = workspace_child_count(object) > 0L, - viewable = is.list(object) || dataview_is_table(object), + viewable = TRUE, selector = selector ) } @@ -211,19 +211,33 @@ get_workspace_children <- function(name, path = list(), start = 1L) { handle_listview_view <- function(view_id, index) { state <- .sess_env$dataviews[[as.character(view_id)]] + if (is.null(state) || !identical(state$type, "list")) { + return(FALSE) + } index <- as.integer(index) - if (is.null(state) || !identical(state$type, "list") || - is.na(index) || index < 1L || index > length(state$data)) { + child_count <- if (state$kind == "index") length(state$data) else length(state$names) + if (length(index) != 1L || is.na(index) || index < 1L || index > child_count) { return(FALSE) } - child_names <- names(state$data) - child_name <- if (is.null(child_names)) NULL else child_names[[index]] - title <- if (!is.null(child_name) && !is.na(child_name) && nzchar(child_name)) { + child_name <- if (is.null(state$names)) NULL else state$names[[index]] + if (state$kind == "name" && + (!exists(child_name, envir = state$data, inherits = FALSE) || + bindingIsActive(child_name, state$data))) { + return(FALSE) + } + child <- switch(state$kind, + name = get(child_name, envir = state$data, inherits = FALSE), + slot = methods::slot(state$data, child_name), + index = state$data[[index]] + ) + title <- if (state$kind == "slot") { + paste0(state$title, "@", child_name) + } else if (!is.null(child_name) && !is.na(child_name) && nzchar(child_name)) { paste0(state$title, "$", child_name) } else { paste0(state$title, "[[", index, "]]") } - utils::View(state$data[[index]], title = title) + utils::View(child, title = title) TRUE } diff --git a/sess/R/hooks.R b/sess/R/hooks.R index 747e33f0..077d7074 100644 --- a/sess/R/hooks.R +++ b/sess/R/hooks.R @@ -14,7 +14,13 @@ register_hooks <- function(use_rstudioapi = TRUE, use_httpgd = TRUE, use_jgd = F # make sure title is computed. force(title) - view_type <- if (dataview_is_table(x)) "table" else if (is.list(x)) "list" else "object" + view_type <- if (dataview_is_table(x)) { + "table" + } else if (is.list(x) || is.environment(x) || isS4(x)) { + "list" + } else { + "object" + } if (view_type != "object") { title_key <- paste(as.character(title), collapse = "\n") registry_key <- paste0(view_type, ":", title_key) @@ -41,18 +47,39 @@ register_hooks <- function(use_rstudioapi = TRUE, use_httpgd = TRUE, use_jgd = F view_id = registration$view_id )) } else if (view_type == "list") { + child_kind <- if (is.environment(x)) "name" else if (isS4(x)) "slot" else "index" + x_names <- switch(child_kind, + name = workspace_env_names(x), + slot = methods::slotNames(x), + index = names(x) + ) .sess_env$dataviews[[view_id]] <- list( type = "list", data = x, - title = title_key + title = title_key, + kind = child_kind, + names = x_names ) - x_names <- names(x) - children <- lapply(seq_along(x), function(index) { - child <- x[[index]] + indices <- if (child_kind == "index") seq_along(x) else seq_along(x_names) + children <- lapply(indices, function(index) { + child_name <- if (is.null(x_names)) NULL else x_names[[index]] + label <- if (child_kind == "slot") { + paste0("@ ", child_name) + } else { + workspace_child_label(child_name, index) + } + if (child_kind == "name" && bindingIsActive(child_name, x)) { + return(list(label = label, str = "(active-binding)", viewable = FALSE, index = index)) + } + child <- switch(child_kind, + name = get(child_name, envir = x, inherits = FALSE), + slot = methods::slot(x, child_name), + index = x[[index]] + ) list( - label = workspace_child_label(if (is.null(x_names)) NULL else x_names[[index]], index), + label = label, str = trimws(try_capture_str(child)), - viewable = is.list(child) || dataview_is_table(child), + viewable = TRUE, index = index ) }) diff --git a/sess/inst/tinytest/test-dataview.R b/sess/inst/tinytest/test-dataview.R index bda38e66..b981a695 100644 --- a/sess/inst/tinytest/test-dataview.R +++ b/sess/inst/tinytest/test-dataview.R @@ -81,10 +81,17 @@ local({ }) -# Workspace children expose the View action only for supported table objects. +# Workspace children expose View for both structured and text-viewable objects. local({ selector <- list(kind = "index", value = 1L) expect_true(sess:::workspace_child_item(data.frame(x = 1), "df", selector)$viewable) expect_true(sess:::workspace_child_item(matrix(1:4, 2), "matrix", selector)$viewable) expect_true(sess:::workspace_child_item(list(x = 1), "list", selector)$viewable) + expect_true(sess:::workspace_child_item(new.env(), "environment", selector)$viewable) + expect_true(sess:::workspace_child_item(pairlist(x = 1), "pairlist", selector)$viewable) + methods::setClass("list_viewer_test_slots", slots = c(child = "list")) + on.exit(methods::removeClass("list_viewer_test_slots"), add = TRUE) + object <- methods::new("list_viewer_test_slots", child = list(x = 1)) + expect_true(sess:::workspace_child_item(object, "S4", selector)$viewable) + expect_true(sess:::workspace_child_item(1:3, "vector", selector)$viewable) }) From 856dbea7073844baecfc3096761dec2780d5e179 Mon Sep 17 00:00:00 2001 From: "4111978+Fred-Wu@users.noreply.github.com" <4111978+Fred-Wu@users.noreply.github.com> Date: Sun, 20 Sep 2026 21:49:25 +1000 Subject: [PATCH 6/6] Load list viewer items on demand using workspace pagination --- sess/R/handlers.R | 116 +++++++++++++++-------------- sess/R/hooks.R | 26 ------- sess/R/server.R | 2 +- sess/inst/tinytest/test-dataview.R | 31 ++++++++ src/session.ts | 113 ++++++++++++++++++++-------- 5 files changed, 174 insertions(+), 114 deletions(-) diff --git a/sess/R/handlers.R b/sess/R/handlers.R index 581bdfec..b5538bb9 100644 --- a/sess/R/handlers.R +++ b/sess/R/handlers.R @@ -141,72 +141,76 @@ handle_workspace_view <- function(name, path = list()) { TRUE } -get_workspace_children <- function(name, path = list(), start = 1L) { +get_workspace_children <- function(name = NULL, path = list(), start = 1L, view_id = NULL) { tryCatch({ - object <- workspace_object(name, path) - child_count <- workspace_child_count(object) - if (child_count == 0L) { - return(list(children = I(list()), next_start = NULL)) + if (is.null(view_id)) { + object <- workspace_object(name, path) + kind <- if (is.environment(object)) "name" else if (isS4(object)) "slot" else "index" + child_names <- switch(kind, + name = workspace_env_names(object), + slot = methods::slotNames(object), + index = names(object) + ) + } else { + state <- dataview_get_state(view_id) + if (!identical(state$type, "list")) stop("Not a list view") + object <- state$data + kind <- state$kind + child_names <- state$names } - + child_count <- if (kind == "index") workspace_child_count(object) else length(child_names) start <- max(1L, as.integer(start)) end <- min(child_count, start + workspace_child_page_size - 1L) if (start > end) { return(list(children = I(list()), next_start = NULL)) } - children <- if (is.environment(object)) { - child_names <- workspace_env_names(object)[seq.int(start, end)] - lapply(child_names, function(child_name) { - if (bindingIsActive(child_name, object)) { - list( - str = paste0("$ ", child_name, ": (active-binding)"), - class = "active_binding", - type = "active_binding", - has_children = FALSE - ) + children <- lapply(seq.int(start, end), function(index) { + child_name <- if (is.null(child_names)) NULL else child_names[[index]] + label <- if (kind == "slot") { + paste0("@ ", child_name) + } else { + workspace_child_label(child_name, index) + } + unavailable <- if (kind == "name" && !exists(child_name, envir = object, inherits = FALSE)) { + "removed" + } else if (kind == "name" && bindingIsActive(child_name, object)) { + "active_binding" + } else { + NULL + } + if (!is.null(unavailable)) { + summary <- if (unavailable == "removed") "(removed)" else "(active-binding)" + if (!is.null(view_id)) { + return(list(label = label, str = summary, viewable = FALSE, index = index)) + } + return(list( + str = paste0(label, ": ", summary), class = unavailable, + type = unavailable, has_children = FALSE + )) + } + child <- switch(kind, + name = get(child_name, envir = object, inherits = FALSE), + slot = methods::slot(object, child_name), + index = object[[index]] + ) + summary <- trimws(try_capture_str(child)) + if (!is.null(view_id)) { + list(label = label, str = summary, viewable = TRUE, index = index) + } else { + selector <- if (kind == "index") { + list(kind = kind, value = index, name = child_name) } else { - child <- get(child_name, envir = object, inherits = FALSE) - workspace_child_item( - child, - paste0("$ ", child_name, ": ", trimws(try_capture_str(child))), - list(kind = "name", value = child_name) - ) + list(kind = kind, value = child_name) } - }) - } else if (isS4(object)) { - child_names <- methods::slotNames(object)[seq.int(start, end)] - lapply(child_names, function(child_name) { - child <- methods::slot(object, child_name) - workspace_child_item( - child, - paste0("@ ", child_name, ": ", trimws(try_capture_str(child))), - list(kind = "slot", value = child_name) - ) - }) - } else { - indices <- seq.int(start, end) - child_names <- names(object) - lapply(indices, function(index) { - child <- object[[index]] - child_name <- if (is.null(child_names)) NULL else child_names[[index]] - workspace_child_item( - child, - paste0( - workspace_child_label(child_name, index), - ": ", - trimws(try_capture_str(child)) - ), - list(kind = "index", value = index, name = child_name) - ) - }) - } - - list( - children = I(children), - next_start = if (end < child_count) end + 1L else NULL - ) - }, error = function(e) list(children = I(list()), next_start = NULL)) + workspace_child_item(child, paste0(label, ": ", summary), selector) + } + }) + list(children = I(children), next_start = if (end < child_count) end + 1L else NULL) + }, error = function(e) { + if (!is.null(view_id)) stop(e) + list(children = I(list()), next_start = NULL) + }) } handle_listview_view <- function(view_id, index) { diff --git a/sess/R/hooks.R b/sess/R/hooks.R index 077d7074..be00ef02 100644 --- a/sess/R/hooks.R +++ b/sess/R/hooks.R @@ -60,34 +60,8 @@ register_hooks <- function(use_rstudioapi = TRUE, use_httpgd = TRUE, use_jgd = F kind = child_kind, names = x_names ) - indices <- if (child_kind == "index") seq_along(x) else seq_along(x_names) - children <- lapply(indices, function(index) { - child_name <- if (is.null(x_names)) NULL else x_names[[index]] - label <- if (child_kind == "slot") { - paste0("@ ", child_name) - } else { - workspace_child_label(child_name, index) - } - if (child_kind == "name" && bindingIsActive(child_name, x)) { - return(list(label = label, str = "(active-binding)", viewable = FALSE, index = index)) - } - child <- switch(child_kind, - name = get(child_name, envir = x, inherits = FALSE), - slot = methods::slot(x, child_name), - index = x[[index]] - ) - list( - label = label, - str = trimws(try_capture_str(child)), - viewable = TRUE, - index = index - ) - }) - file_path <- tempfile(tmpdir = .sess_env$tempdir, fileext = ".json") - jsonlite::write_json(list(children = I(children)), file_path, auto_unbox = TRUE) notify_client("dataview", list( title = title, - file = file_path, source = "list", type = "json", view_id = view_id diff --git a/sess/R/server.R b/sess/R/server.R index 475109bf..8b0d1023 100644 --- a/sess/R/server.R +++ b/sess/R/server.R @@ -174,7 +174,7 @@ dispatch_message <- function(line) { # Request from vscode → R must reply handlers <- list( "workspace" = function(p) get_workspace_data(), - "workspace_children" = function(p) get_workspace_children(p$name, p$path, p$start), + "workspace_children" = function(p) get_workspace_children(p$name, p$path, p$start, p$view_id), "workspace_view" = function(p) handle_workspace_view(p$name, p$path), "hover" = function(p) handle_hover(p$expr), "completion" = function(p) handle_complete(p$expr, p$trigger), diff --git a/sess/inst/tinytest/test-dataview.R b/sess/inst/tinytest/test-dataview.R index b981a695..ec5dc142 100644 --- a/sess/inst/tinytest/test-dataview.R +++ b/sess/inst/tinytest/test-dataview.R @@ -95,3 +95,34 @@ local({ expect_true(sess:::workspace_child_item(object, "S4", selector)$viewable) expect_true(sess:::workspace_child_item(1:3, "vector", selector)$viewable) }) + +# List pages inspect only the requested children, with stable indices across pages. +local({ + runtime <- sess:::.sess_env + previous <- runtime$dataviews + on.exit(runtime$dataviews <- previous, add = TRUE) + object <- new.env(parent = emptyenv()) + child_names <- paste0("item", seq_len(501L)) + for (name in child_names[1:500]) assign(name, 1L, envir = object) + delayedAssign("item501", stop("unrequested binding was evaluated"), assign.env = object) + runtime$dataviews$paging_test <- list( + type = "list", data = object, kind = "name", names = child_names, title = "object" + ) + + first <- sess:::get_workspace_children(view_id = "paging_test", start = 1L) + expect_length(first$children, 500L) + expect_equal(first$next_start, 501L) + expect_equal(vapply(first$children, `[[`, 1L, "index"), 1:500) + + # Removing a binding must not shift later indices or fail the next page. + rm("item501", envir = object) + last <- sess:::get_workspace_children(view_id = "paging_test", start = 501L) + expect_equal(last$children[[1L]]$label, "$ item501") + expect_false(last$children[[1L]]$viewable) + expect_null(last$next_start) + makeActiveBinding("item501", function() stop("active binding was evaluated"), object) + last <- sess:::get_workspace_children(view_id = "paging_test", start = 501L) + expect_equal(last$children[[1L]]$str, "(active-binding)") + expect_false(last$children[[1L]]$viewable) + expect_length(sess:::get_workspace_children(view_id = "paging_test", start = 502L)$children, 0L) +}) diff --git a/src/session.ts b/src/session.ts index a32f3312..53aeaca3 100644 --- a/src/session.ts +++ b/src/session.ts @@ -737,7 +737,7 @@ export async function showDataView(source: string, type: string, title: string, if (existing) { existing.title = title; existing.reveal(ViewColumn[viewer as keyof typeof ViewColumn], true); - existing.webview.html = await getListHtml(existing.webview, file, title); + existing.webview.html = getListHtml(existing.webview, title); return; } } @@ -756,12 +756,24 @@ export async function showDataView(source: string, type: string, title: string, panel.iconPath = new UriIcon('open-preview'); if (viewId) { dynamicDataViewPanels.set(viewId, panel); - panel.webview.onDidReceiveMessage((message: { message?: string; index?: number }) => { + panel.webview.onDidReceiveMessage(async (message: { message?: string; index?: number; start?: number; requestId?: number }) => { if (message.message === 'listview/view' && typeof message.index === 'number' && Number.isInteger(message.index)) { void sessionRequest({ method: 'listview_view', params: { view_id: viewId, index: message.index }, }); + } else if (message.message === 'listview/page' && typeof message.start === 'number' && + Number.isInteger(message.start) && typeof message.requestId === 'number') { + const page = await sessionRequest({ + method: 'workspace_children', + params: { view_id: viewId, start: message.start }, + }) as { children?: unknown; next_start?: number | null } | undefined; + void panel.webview.postMessage({ + message: 'listview/page', + requestId: message.requestId, + ...page, + error: Array.isArray(page?.children) ? undefined : 'Unable to load items. Check the R session and try again.', + }); } }); panel.onDidDispose(() => { @@ -774,7 +786,7 @@ export async function showDataView(source: string, type: string, title: string, }); }); } - panel.webview.html = await getListHtml(panel.webview, file, title); + panel.webview.html = getListHtml(panel.webview, title); } else { await commands.executeCommand('vscode.open', Uri.file(file), { preserveFocus: true, @@ -1580,8 +1592,7 @@ export async function getTableHtml(webview: Webview, file: string | undefined, t `; } -export async function getListHtml(webview: Webview, file: string, title: string): Promise { - const content = (await fs.readFile(file, 'utf8')).replace(/
+ +