diff --git a/package.json b/package.json index c6b3e067..f530ffd6 100644 --- a/package.json +++ b/package.json @@ -1608,31 +1608,6 @@ "default": true, "markdownDescription": "Watch the global environment to provide hover, autocompletions, and workspace viewer information. Requires `#r.sessionWatcher#` to be set to `true`." }, - "r.session.objectLengthLimit": { - "type": "integer", - "default": 2000, - "markdownDescription": "The upper limit of object length to show object details in workspace viewer and provide session symbol completion. Decrease this value if you experience significant delay after executing R commands caused by large global objects with many elements. Requires `#r.sessionWatcher#` to be set to `true`." - }, - "r.session.objectTimeout": { - "type": "integer", - "default": 50, - "markdownDescription": "The maximum number of milliseconds to get information of a single object in the global environment. Decrease this value if you experience significant delay after executing R commands caused by large global objects with many elements. Requires `#r.sessionWatcher#` to be set to `true`." - }, - "r.session.levelOfObjectDetail": { - "type": "string", - "markdownDescription": "How much of the object to show on hover, autocompletion, and in the workspace viewer? Requires `#r.sessionWatcher#` to be set to `true`.", - "default": "Minimal", - "enum": [ - "Minimal", - "Normal", - "Detailed" - ], - "enumDescriptions": [ - "Display literal values and object types only.", - "Display the top level of list content, data frame column values, and example values.", - "Display the top two levels of list content, data frame column values, and example values. This option may cause notable delay after each user input in the terminal." - ] - }, "r.session.emulateRStudioAPI": { "type": "boolean", "default": true, diff --git a/sess/R/handlers.R b/sess/R/handlers.R index 6de0e8b3..a9d0d52b 100644 --- a/sess/R/handlers.R +++ b/sess/R/handlers.R @@ -1,21 +1,88 @@ # Handlers for the client Pull Requests (HTTP GET/POST) +capture_str <- function(object, max_level = 0L) { + paste0(utils::capture.output( + utils::str(object, + max.level = max_level, + give.attr = FALSE, + vec.len = 1L + ) + ), collapse = "\n") +} + +try_capture_str <- function(object, max_level = 0L) { + tryCatch( + capture_str(object, max_level), + error = function(e) paste0(class(object), collapse = ", ") + ) +} + +workspace_child_count <- function(object) { + if (is.environment(object)) { + length(object) + } else if (isS4(object)) { + length(methods::slotNames(object)) + } else if (typeof(object) %in% c("list", "pairlist")) { + length(object) + } else { + 0L + } +} + get_workspace_data <- function() { env <- .GlobalEnv - all_names <- ls(env) + all_names <- ls(env, sorted = FALSE) objs <- lapply(all_names, function(name) { + if (bindingIsActive(name, env)) { + return(list( + class = "active_binding", + type = "active_binding", + length = 0L, + str = "(active-binding)", + has_children = FALSE + )) + } + obj <- env[[name]] - list( + obj_class <- class(obj) + obj_type <- typeof(obj) + obj_length <- length(obj) + obj_dim <- dim(obj) + first_class <- if (length(obj_class)) obj_class[[1]] else obj_type + info <- list( class = class(obj), - type = typeof(obj), - length = length(obj), - # Create a concise string representation - str = paste0( - utils::capture.output(utils::str(obj, max.level = 0, give.attr = FALSE)), - collapse = "\n" - ) + type = obj_type, + length = obj_length, + str = if (!is.null(obj_dim)) { + paste0(first_class, ": ", paste(obj_dim, collapse = " x ")) + } else if (obj_type == "environment") { + "" + } else if (obj_type %in% c("closure", "builtin")) { + trimws(try_capture_str(obj)) + } else { + paste0(first_class, ", length ", obj_length) + }, + has_children = workspace_child_count(obj) > 0L ) + + obj_names <- if (is.object(obj)) { + utils::.DollarNames(obj, pattern = "") + } else if (is.recursive(obj)) { + names(obj) + } else { + NULL + } + if (length(obj_names)) { + info$names <- obj_names + } + if (isS4(obj)) { + info$slots <- methods::slotNames(obj) + } + if (!is.null(obj_dim)) { + info$dim <- obj_dim + } + info }) names(objs) <- all_names @@ -26,16 +93,113 @@ get_workspace_data <- function() { ) } +workspace_object <- function(name, path = list()) { + object <- get(name, envir = .GlobalEnv, inherits = FALSE) + for (selector in path) { + object <- switch(selector$kind, + index = object[[as.integer(selector$value)]], + name = get(selector$value, envir = object, inherits = FALSE), + slot = methods::slot(object, selector$value), + stop("Unknown workspace selector") + ) + } + object +} + +workspace_child_page_size <- 500L + +workspace_child_item <- function(object, str, selector) { + list( + str = str, + class = paste(class(object), collapse = ", "), + type = typeof(object), + has_children = workspace_child_count(object) > 0L, + selector = selector + ) +} + +workspace_child_label <- function(name, index) { + if (!is.null(name) && !is.na(name) && nzchar(name)) { + paste0("$ ", name) + } else { + paste0("[[", index, "]]") + } +} + +get_workspace_children <- function(name, path = list(), start = 1L) { + tryCatch({ + object <- workspace_object(name, path) + child_count <- workspace_child_count(object) + if (child_count == 0L) { + return(list(children = I(list()), next_start = NULL)) + } + + 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 <- ls(object, sorted = FALSE)[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 + ) + } 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) + ) + } + }) + } 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) + ) + }) + } + + list( + children = I(children), + next_start = if (end < child_count) end + 1L else NULL + ) + }, error = function(e) list(children = I(list()), next_start = NULL)) +} + handle_hover <- function(expr_str) { tryCatch( { expr <- parse(text = expr_str, keep.source = FALSE)[[1]] obj <- eval(expr, .GlobalEnv) - str_preview <- paste0( - utils::capture.output(utils::str(obj, max.level = 0, give.attr = FALSE)), - collapse = "\n" - ) - list(str = str_preview) + list(str = capture_str(obj)) }, error = function(e) NULL ) @@ -77,7 +241,7 @@ handle_complete <- function(expr_str, trigger = NULL) { })) } - if (trigger == "@" && methods::isS4(obj)) { + if (trigger == "@" && isS4(obj)) { nms <- methods::slotNames(obj) return(lapply(nms, function(n) { item <- methods::slot(obj, n) diff --git a/sess/R/server.R b/sess/R/server.R index 26fc67e7..310639b6 100644 --- a/sess/R/server.R +++ b/sess/R/server.R @@ -172,6 +172,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), "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/src/rTerminal.ts b/src/rTerminal.ts index a9f75913..677ce20a 100644 --- a/src/rTerminal.ts +++ b/src/rTerminal.ts @@ -9,7 +9,7 @@ import { extensionContext } from './extension'; import * as util from './util'; import * as selection from './selection'; import { getSelection } from './selection'; -import { cleanupSession } from './session'; +import { cleanupSession, deferWorkspaceRefresh } from './session'; import { config, delay, getRterm, getCurrentWorkspaceFolder } from './util'; import * as fs from 'fs'; import * as yaml from 'js-yaml'; @@ -342,6 +342,7 @@ export async function runChunksInTerm(chunks: vscode.Range[]): Promise { } export async function runTextInTerm(text: string, execute: boolean = true): Promise { + deferWorkspaceRefresh(); const term = await chooseTerminal(); if (term === undefined) { return; diff --git a/src/session.ts b/src/session.ts index 5ec62cab..018bf7c6 100644 --- a/src/session.ts +++ b/src/session.ts @@ -25,14 +25,15 @@ export interface SessionInfo { export interface GlobalEnv { [key: string]: { - class: string[]; + class: string[] | string; type: string; length: number; str: string; size?: number; dim?: number[], names?: string[], - slots?: string[] + slots?: string[], + has_children?: boolean } } @@ -85,6 +86,9 @@ export let workspaceFile: string; const sessions = new Map(); export let activeSession: Session | undefined; let activeBrowserUri: Uri | undefined; +let workspaceRefreshTimer: NodeJS.Timeout | undefined; +let workspaceRefreshInProgress = false; +let workspaceRefreshPending = false; interface DataViewColumnDef { headerName: string; @@ -580,15 +584,48 @@ async function updatePlot() { await globalPlotManager?.showStandardPlot(); } +export function deferWorkspaceRefresh(): void { + if (workspaceRefreshTimer) { + clearTimeout(workspaceRefreshTimer); + workspaceRefreshTimer = undefined; + } +} + +function scheduleWorkspaceRefresh(delayMs: number = 500): void { + workspaceRefreshPending = true; + if (workspaceRefreshTimer) { + clearTimeout(workspaceRefreshTimer); + } + workspaceRefreshTimer = setTimeout(() => { + workspaceRefreshTimer = undefined; + void runWorkspaceRefresh(); + }, delayMs); +} + +async function runWorkspaceRefresh(): Promise { + if (workspaceRefreshInProgress || !workspaceRefreshPending) { + return; + } + workspaceRefreshPending = false; + workspaceRefreshInProgress = true; + try { + await updateWorkspace(); + } finally { + workspaceRefreshInProgress = false; + if (workspaceRefreshPending) { + scheduleWorkspaceRefresh(); + } + } +} + export async function updateWorkspace() { - if (!globalPipePath) {return;} + const requestedSession = activeSession; + if (!globalPipePath || !requestedSession) {return;} try { const response = await sessionRequest({ method: 'workspace' }); - if (response) { + if (response && activeSession === requestedSession) { workspaceData = response as WorkspaceData; - if (activeSession) { - activeSession.workspaceData = workspaceData; - } + requestedSession.workspaceData = workspaceData; void rWorkspace?.refresh(); console.info('[updateWorkspace] Done'); } @@ -1500,13 +1537,15 @@ async function handleNotification(message: Record, socket: IpcS if (params.plot_url) { await globalPlotManager?.showHttpgdPlot(String(params.plot_url)); } - void updateWorkspace(); + scheduleWorkspaceRefresh(0); void watchProcess(rPid).then((v: string) => { void cleanupSession(v); }); break; } case 'workspace_updated': { - void updateWorkspace(); + if (socket === activeSession?.socket) { + scheduleWorkspaceRefresh(); + } break; } case 'help': { @@ -1674,6 +1713,8 @@ export async function cleanupSession(pidArg: string): Promise { session.socket.destroy(); } if (activeSession === session || pid === pidArg) { + deferWorkspaceRefresh(); + workspaceRefreshPending = false; resetStatusBar(); globalPipePath = undefined; activeSession = undefined; diff --git a/src/test/suite/session.test.ts b/src/test/suite/session.test.ts index 61971588..975ec988 100644 --- a/src/test/suite/session.test.ts +++ b/src/test/suite/session.test.ts @@ -97,6 +97,16 @@ suite('Session Communication', () => { assert.ok(listData, 'my_list should be in workspaceData.globalenv'); const className = Array.isArray(listData.class) ? listData.class[0] : listData.class; assert.strictEqual(className, 'list', 'my_list should be a list'); + assert.strictEqual(listData.has_children, true, 'my_list should be expandable'); + + const childrenResult = await session.sessionRequest({ + method: 'workspace_children', + params: { name: 'my_list', path: [], start: 1 } + }) as { children: Record[], next_start?: number }; + + assert.ok(Array.isArray(childrenResult.children), 'workspace children should be an array'); + assert.strictEqual(childrenResult.children.length, 1, 'my_list should have one workspace child'); + assert.match(String(childrenResult.children[0].str), /hello_vscode/); const completionRequestParams = { expr: 'my_list', diff --git a/src/workspaceViewer.ts b/src/workspaceViewer.ts index d6ad006a..12a9f628 100644 --- a/src/workspaceViewer.ts +++ b/src/workspaceViewer.ts @@ -4,16 +4,40 @@ import * as vscode from 'vscode'; import * as path from 'path'; import { TreeDataProvider, EventEmitter, TreeItemCollapsibleState, TreeItem, Event, Uri, window, ThemeIcon } from 'vscode'; import { runTextInTerm } from './rTerminal'; -import { workspaceData, workingDir, WorkspaceData, GlobalEnv } from './session'; +import { workspaceData, workingDir, WorkspaceData, GlobalEnv, globalPipePath, sessionRequest } from './session'; import { config } from './util'; import { extensionContext, globalRHelp } from './extension'; import { PackageNode } from './helpViewer/treeView'; const collapsibleTypes: string[] = [ 'list', - 'environment' + 'environment', + 'pairlist', + 'S4' ]; +interface WorkspaceChild { + str: string; + class: string; + type: string; + has_children: boolean; + selector?: WorkspaceSelector; +} + +interface WorkspaceSelector { + kind: 'index' | 'name' | 'slot'; + value: number | string; +} + +interface WorkspaceChildPage { + children: WorkspaceChild[]; + nextStart?: number; +} + +function getFirstClass(rClass: string[] | string | undefined): string { + return Array.isArray(rClass) ? rClass[0] : rClass ?? ''; +} + async function populatePackageNodes(): Promise { const rootNode = globalRHelp?.treeViewWrapper.helpViewProvider.rootItem; if (rootNode) { @@ -34,14 +58,20 @@ export class WorkspaceDataProvider implements TreeDataProvider { private readonly attachedNamespacesRootItem: TreeItem; private readonly loadedNamespacesRootItem: TreeItem; private readonly globalEnvRootItem: TreeItem; - private _onDidChangeTreeData: EventEmitter = new EventEmitter(); + private readonly childPages = new Map(); + private readonly childPageLoads = new Set(); + private childPageGeneration = 0; + private _onDidChangeTreeData: EventEmitter = new EventEmitter(); - public readonly onDidChangeTreeData: Event = this._onDidChangeTreeData.event; + public readonly onDidChangeTreeData: Event = this._onDidChangeTreeData.event; public data: WorkspaceData | undefined; public refresh(): void { this.data = workspaceData; - this._onDidChangeTreeData.fire(); + this.childPageGeneration++; + this.childPages.clear(); + this.childPageLoads.clear(); + this._onDidChangeTreeData.fire(undefined); } public constructor() { @@ -60,6 +90,9 @@ export class WorkspaceDataProvider implements TreeDataProvider { extensionContext.subscriptions.push( vscode.commands.registerCommand(PackageItem.command, async (node: PackageNode) => { await node.showQuickPick(); + }), + vscode.commands.registerCommand(LoadMoreItem.command, async (node: LoadMoreItem) => { + await this.loadMore(node.parent, node.start); }) ); @@ -105,19 +138,25 @@ export class WorkspaceDataProvider implements TreeDataProvider { } else if (element.id === 'globalenv') { return this.getGlobalEnvItems(this.data.globalenv); } else if (element instanceof GlobalEnvItem) { - return element.str - .split('\n') - .filter((elem, index) => { return index > 0; }) - .map(strItem => - new GlobalEnvItem( - '', - '', - strItem.replace(/\s+/g, ' ').trim(), - '', - 0, - element.treeLevel + 1 - ) - ); + const page = await this.getGlobalEnvChildren(element); + const items: TreeItem[] = page.children.map(child => + new GlobalEnvItem( + '', + child.class, + child.str.replace(/\s+/g, ' ').trim(), + child.type, + 0, + element.treeLevel + 1, + undefined, + child.has_children, + element.rootName, + child.selector ? [...element.objectPath, child.selector] : element.objectPath + ) + ); + if (page.nextStart !== undefined) { + items.push(new LoadMoreItem(element, page.nextStart)); + } + return items; } else { return []; } @@ -137,7 +176,8 @@ export class WorkspaceDataProvider implements TreeDataProvider { str: string, type: string, size?: number, - dim?: number[] + dim?: number[], + hasChildren?: boolean ): GlobalEnvItem => { return new GlobalEnvItem( key, @@ -147,17 +187,19 @@ export class WorkspaceDataProvider implements TreeDataProvider { size, TreeLevel.Parent, dim, + hasChildren, ); }; const items = globalenv ? Object.keys(globalenv).map((key) => toItem( key, - globalenv[key].class[0], + getFirstClass(globalenv[key].class), globalenv[key].str, globalenv[key].type, globalenv[key].size, globalenv[key].dim, + globalenv[key].has_children, )) : []; function sortItems(a: GlobalEnvItem, b: GlobalEnvItem) { @@ -172,6 +214,83 @@ export class WorkspaceDataProvider implements TreeDataProvider { return items.sort((a, b) => sortItems(a, b)); } + + private getChildPageKey(element: GlobalEnvItem): string { + return JSON.stringify([element.rootName, element.objectPath]); + } + + private async getGlobalEnvChildren(element: GlobalEnvItem): Promise { + const key = this.getChildPageKey(element); + const cached = this.childPages.get(key); + if (cached) { + return cached; + } + + const generation = this.childPageGeneration; + const page = await this.requestGlobalEnvChildren(element, 1); + if (generation === this.childPageGeneration) { + this.childPages.set(key, page); + return page; + } + return { children: [] }; + } + + private async requestGlobalEnvChildren(element: GlobalEnvItem, start: number): Promise { + if (globalPipePath && element.rootName) { + try { + const response = await sessionRequest({ + method: 'workspace_children', + params: { + name: element.rootName, + path: element.objectPath, + start, + }, + }) as { children?: unknown, next_start?: unknown } | undefined; + if (response && Array.isArray(response.children)) { + const children = response.children.filter((child): child is WorkspaceChild => + typeof child === 'object' && + child !== null && + 'str' in child && + 'type' in child && + 'has_children' in child + ); + const nextStart = typeof response.next_start === 'number' ? + response.next_start : + undefined; + return { children, nextStart }; + } + } catch { + return { children: [] }; + } + } + + return { children: [] }; + } + + private async loadMore(parent: GlobalEnvItem, start: number): Promise { + const key = this.getChildPageKey(parent); + const loadKey = `${key}:${start}`; + if (this.childPageLoads.has(loadKey)) { + return; + } + + this.childPageLoads.add(loadKey); + const generation = this.childPageGeneration; + try { + const current = this.childPages.get(key) ?? { children: [] }; + const next = await this.requestGlobalEnvChildren(parent, start); + if (generation !== this.childPageGeneration) { + return; + } + this.childPages.set(key, { + children: [...current.children, ...next.children], + nextStart: next.nextStart + }); + this._onDidChangeTreeData.fire(parent); + } finally { + this.childPageLoads.delete(loadKey); + } + } } class PackageItem extends TreeItem { @@ -195,20 +314,36 @@ class PackageItem extends TreeItem { } } +class LoadMoreItem extends TreeItem { + public static command = 'r.workspaceViewer.loadMore'; + + public constructor( + public readonly parent: GlobalEnvItem, + public readonly start: number + ) { + super('...', TreeItemCollapsibleState.None); + this.tooltip = 'Load next 500 items'; + this.iconPath = new ThemeIcon('ellipsis'); + this.command = { + command: LoadMoreItem.command, + title: 'Load next 500 items', + arguments: [this] + }; + } +} + enum TreeLevel { Parent = 0, - Scalar = 1, - Child = 2 + Scalar = 1 } export class GlobalEnvItem extends TreeItem { declare public label?: string; - public desc?: string; - public str: string; - public type: string; public treeLevel: number; public contextValue: string; public priority: number; + public rootName: string; + public objectPath: WorkspaceSelector[]; constructor( label: string, @@ -218,15 +353,18 @@ export class GlobalEnvItem extends TreeItem { size?: number, treeLevel?: number, dim?: number[], + hasChildren?: boolean, + rootName?: string, + objectPath?: WorkspaceSelector[], ) { super( label, - GlobalEnvItem.setCollapsibleState(treeLevel ?? TreeLevel.Scalar, type, str) + GlobalEnvItem.setCollapsibleState(type, hasChildren) ); - this.type = type; - this.str = str; this.treeLevel = treeLevel ?? TreeLevel.Scalar; this.priority = dim ? 1 : 0; + this.rootName = rootName ?? label; + this.objectPath = objectPath ?? []; this.description = this.getDescription( dim, @@ -293,8 +431,11 @@ export class GlobalEnvItem extends TreeItem { during the super constructor above. I created it to give full control of what elements can have have 'child' nodes os not. It can be expanded in the futere for more tree levels.*/ - private static setCollapsibleState(treeLevel: number, type: string, str: string): vscode.TreeItemCollapsibleState { - if (treeLevel === TreeLevel.Parent && collapsibleTypes.includes(type) && str.includes('\n')) { + private static setCollapsibleState( + type: string, + hasChildren?: boolean + ): vscode.TreeItemCollapsibleState { + if (collapsibleTypes.includes(type) && hasChildren) { return TreeItemCollapsibleState.Collapsed; } else { return TreeItemCollapsibleState.None;