diff --git a/R/session/init.R b/R/session/init.R index 410263c2e..32c663c2e 100644 --- a/R/session/init.R +++ b/R/session/init.R @@ -9,18 +9,19 @@ dir_init <- getwd() init_first <- function() { # return early if not a vscode term session if ( - !interactive() - || Sys.getenv("RSTUDIO") != "" - || Sys.getenv("TERM_PROGRAM") != "vscode" + !interactive() || + Sys.getenv("RSTUDIO") != "" || + Sys.getenv("TERM_PROGRAM") != "vscode" ) { return() } # check required packages - required_packages <- c("jsonlite", "rlang") + required_packages <- c("jsonlite", "rlang", "readr") missing_packages <- required_packages[ !vapply(required_packages, requireNamespace, - logical(1L), quietly = TRUE + logical(1L), + quietly = TRUE ) ] @@ -45,59 +46,13 @@ old.First.sys <- .First.sys init_last <- function() { old.First.sys() - # cleanup previous version - removeTaskCallback("vscode-R") - options(vscodeR = NULL) - .vsc.name <- "tools:vscode" - if (.vsc.name %in% search()) { - detach(.vsc.name, character.only = TRUE) - } - - # Source vsc utils in new environmeent - .vsc <- new.env() - source(file.path(dir_init, "vsc.R"), local = .vsc) - - # attach functions that are meant to be called by the user/vscode - exports <- local({ - .vsc <- .vsc - .vsc.attach <- .vsc$attach - .vsc.view <- .vsc$show_dataview - .vsc.browser <- .vsc$show_browser - .vsc.viewer <- .vsc$show_viewer - .vsc.page_viewer <- .vsc$show_page_viewer - View <- .vsc.view - environment() - }) - attach(exports, name = .vsc.name, warn.conflicts = FALSE) - - # overwrite S3 bindings from other packages - suppressWarnings({ - if (!identical(getOption("vsc.helpPanel", "Two"), FALSE)) { - # Overwrite print function for results of `?` - .vsc$.S3method( - "print", - "help_files_with_topic", - .vsc$print.help_files_with_topic - ) - # Overwrite print function for results of `??` - .vsc$.S3method( - "print", - "hsearch", - .vsc$print.hsearch - ) - } - # Further S3 overwrites can go here - # ... - }) + source(file.path(dir_init, "init_late.R"), chdir = TRUE) # remove this function from globalenv() suppressWarnings( rm(".First.sys", envir = globalenv()) ) - # Attach to vscode - exports$.vsc.attach() - invisible() } diff --git a/R/session/init_late.R b/R/session/init_late.R new file mode 100644 index 000000000..5bac25b0e --- /dev/null +++ b/R/session/init_late.R @@ -0,0 +1,47 @@ +dir_init <- getwd() + + +# cleanup previous version +removeTaskCallback("vscode-R") +options(vscodeR = NULL) +.vsc.name <- "tools:vscode" +if (.vsc.name %in% search()) { + detach(.vsc.name, character.only = TRUE) +} + +# Source vsc utils in new environmeent +.vsc <- new.env() +source(file.path(dir_init, "vsc.R"), local = .vsc) + +# attach functions that are meant to be called by the user/vscode +exports <- local({ + .vsc <- .vsc + .vsc.attach <- .vsc$attach + .vsc.detach <- .vsc$detach + .vsc.view <- .vsc$show_dataview + .vsc.browser <- .vsc$show_browser + .vsc.viewer <- .vsc$show_viewer + .vsc.page_viewer <- .vsc$show_page_viewer + environment() +}) +attach(exports, name = .vsc.name, warn.conflicts = FALSE) + +# overwrite S3 bindings from other packages +suppressWarnings({ + if (!identical(getOption("vsc.helpPanel", "Two"), FALSE)) { + # Overwrite print function for results of `?` + .vsc$.S3method( + "print", + "help_files_with_topic", + .vsc$print.help_files_with_topic + ) + # Overwrite print function for results of `??` + .vsc$.S3method( + "print", + "hsearch", + .vsc$print.hsearch + ) + } + # Further S3 overwrites can go here + # ... +}) diff --git a/R/session/vsc.R b/R/session/vsc.R index 5ab461394..fde6cece0 100644 --- a/R/session/vsc.R +++ b/R/session/vsc.R @@ -8,7 +8,25 @@ dir_watcher <- Sys.getenv("VSCODE_WATCHER_DIR", file.path(homedir, ".vscode-R")) request_file <- file.path(dir_watcher, "request.log") request_lock_file <- file.path(dir_watcher, "request.lock") settings_file <- file.path(dir_watcher, "settings.json") +request_tcp_connection <- NA +request_is_attached <- FALSE +before_attach_options <- list() +options_when_connected_list <- list() +options_when_connected <- function(...) { + l <- list(...) + mapply(function(option, value) { + options_when_connected_list[[option]] <<- value + }, names(l), l) +} +before_attach_hooks <- list() +hooks_when_connected_list <- list() +hook_when_connected <- function(hook, cb) { + hooks_when_connected_list[[hook]] <<- cb +} user_options <- names(options()) +created_devices <- c() +View_impl <- NULL +old_view_impl <- View logger <- if (getOption("vsc.debug", FALSE)) { function(...) cat(..., "\n", sep = "") @@ -22,7 +40,10 @@ load_settings <- function() { } setting <- function(x, ...) { - switch(EXPR = x, ..., x) + switch(EXPR = x, + ..., + x + ) } mapping <- quote(list( @@ -63,7 +84,7 @@ load_settings <- function() { load_settings() if (is.null(getOption("help_type"))) { - options(help_type = "html") + options_when_connected(help_type = "html") } use_webserver <- isTRUE(getOption("vsc.use_webserver", FALSE)) @@ -71,18 +92,23 @@ if (use_webserver) { if (requireNamespace("httpuv", quietly = TRUE)) { request_handlers <- list( hover = function(expr, ...) { - tryCatch({ - expr <- parse(text = expr, keep.source = FALSE)[[1]] - obj <- eval(expr, .GlobalEnv) - list(str = capture_str(obj)) - }, error = function(e) NULL) + tryCatch( + { + expr <- parse(text = expr, keep.source = FALSE)[[1]] + obj <- eval(expr, .GlobalEnv) + list(str = capture_str(obj)) + }, + error = function(e) NULL + ) }, - complete = function(expr, trigger, ...) { - obj <- tryCatch({ - expr <- parse(text = expr, keep.source = FALSE)[[1]] - eval(expr, .GlobalEnv) - }, error = function(e) NULL) + obj <- tryCatch( + { + expr <- parse(text = expr, keep.source = FALSE)[[1]] + eval(expr, .GlobalEnv) + }, + error = function(e) NULL + ) if (is.null(obj)) { return(NULL) @@ -132,10 +158,12 @@ if (use_webserver) { host <- "127.0.0.1" port <- httpuv::randomPort() token <- sprintf("%d:%d:%.6f", pid, port, Sys.time()) - server <- httpuv::startServer(host, port, + server <- httpuv::startServer( + host, port, list( onHeaders = function(req) { - logger("http request ", + logger( + "http request ", req[["REMOTE_ADDR"]], ":", req[["REMOTE_PORT"]], " ", req[["REQUEST_METHOD"]], " ", @@ -208,10 +236,23 @@ request <- function(command, ...) { command = command, ... ) - jsonlite::write_json(obj, request_file, - auto_unbox = TRUE, null = "null", force = TRUE + request_target <- if (is.na(request_tcp_connection)) request_file else request_tcp_connection + jsonlite::write_json(obj, request_target, + auto_unbox = TRUE, na = "string", null = "null", force = TRUE ) - cat(get_timestamp(), file = request_lock_file) + if (is.na(request_tcp_connection)) { + cat(get_timestamp(), file = request_lock_file) + } else { + response <- readLines(request_tcp_connection, n = 1) + if (length(response) == 0) { + # If the server ends up the connection + detach(including_request_detach = FALSE) + return(TRUE) + } else if (length(response) != 1 && response != "req_finished") { + stop(paste("Error in connection: Malform response: \n", paste(response, collapse = "\n"), "\n")) + } + } + FALSE } try_catch_timeout <- function(expr, timeout = Inf, ...) { @@ -364,15 +405,22 @@ workspace_lock_file <- file.path(dir_session, "workspace.lock") file.create(workspace_lock_file, showWarnings = FALSE) update_workspace <- function(...) { - tryCatch({ - data <- list( - search = search()[-1], - loaded_namespaces = loadedNamespaces(), - globalenv = if (show_globalenv) inspect_env(.GlobalEnv, globalenv_cache) else NULL - ) - jsonlite::write_json(data, workspace_file, force = TRUE, pretty = FALSE) - cat(get_timestamp(), file = workspace_lock_file) - }, error = message) + tryCatch( + { + data <- list( + search = search()[-1], + loaded_namespaces = loadedNamespaces(), + globalenv = if (show_globalenv) inspect_env(.GlobalEnv, globalenv_cache) else NULL + ) + if (is.na(request_tcp_connection)) { + jsonlite::write_json(data, workspace_file, force = TRUE, pretty = FALSE) + cat(get_timestamp(), file = workspace_lock_file) + } else { + request("updateWorkspace", workspaceData = data) + } + }, + error = message + ) TRUE } update_workspace() @@ -382,10 +430,11 @@ removeTaskCallback("vsc.plot") use_httpgd <- identical(getOption("vsc.use_httpgd", FALSE), TRUE) show_plot <- !identical(getOption("vsc.plot", "Two"), FALSE) if (use_httpgd && "httpgd" %in% .packages(all.available = TRUE)) { - options(device = function(...) { + options_when_connected(device = function(...) { httpgd::hgd( silent = TRUE ) + created_devices <<- append(created_devices, dev.cur()) .vsc$request("httpgd", url = httpgd::hgd_url()) }) } else if (use_httpgd) { @@ -410,41 +459,70 @@ if (use_httpgd && "httpgd" %in% .packages(all.available = TRUE)) { } } - options( + options_when_connected( device = function(...) { pdf(NULL, width = null_dev_size[[1L]], height = null_dev_size[[2L]], - bg = "white") + bg = "white" + ) + created_devices <<- append(created_devices, dev.cur()) dev.control(displaylist = "enable") } ) update_plot <- function(...) { - tryCatch({ - if (plot_updated && check_null_dev()) { - plot_updated <<- FALSE - record <- recordPlot() - if (length(record[[1L]])) { - dev_args <- getOption("vsc.dev.args") - do.call(png, c(list(filename = plot_file), dev_args)) - on.exit({ - dev.off() - cat(get_timestamp(), file = plot_lock_file) - }) - replayPlot(record) + tryCatch( + { + if (plot_updated && check_null_dev()) { + plot_updated <<- FALSE + record <- recordPlot() + if (length(record[[1L]])) { + dev_args <- getOption("vsc.dev.args") + do.call(png, c(list(filename = plot_file), dev_args)) + on.exit({ + cur_dev <- dev.cur() + dev.off() + created_devices <<- created_devices[created_devices != cur_dev] + cat(get_timestamp(), file = plot_lock_file) + if (!is.na(request_tcp_connection)) { + tryCatch( + { + plot_file_content <- readr::read_file_raw(plot_file) + format <- "image/png" # right now only this format is supported + if (request("plot", + format = format, + plot_base64 = jsonlite::base64_enc(plot_file_content) + )) { + cat( + stderr(), + paste( + "The connection to VSCode was disconnected,", + "and it was detected only now after plotting.", + "Please run the plot task again." + ) + ) + } + }, + error = message + ) + } + }) + replayPlot(record) + } } - } - }, error = message) + }, + error = message + ) TRUE } - setHook("plot.new", new_plot, "replace") - setHook("grid.newpage", new_plot, "replace") + hook_when_connected("plot.new", new_plot) + hook_when_connected("grid.newpage", new_plot) rebind(".External.graphics", function(...) { out <- .Primitive(".External.graphics")(...) - if (check_null_dev()) { + if (request_is_attached && check_null_dev()) { plot_updated <<- TRUE } out @@ -510,7 +588,8 @@ if (show_view) { names(data) <- fields class(data) <- "data.frame" attr(data, "row.names") <- .set_row_names(.nrow) - columns <- .mapply(get_column_def, + columns <- .mapply( + get_column_def, list(.colnames, fields, data), NULL ) @@ -596,51 +675,76 @@ if (show_view) { ) } } - if (is.data.frame(x) || is.matrix(x)) { - x <- as_truncated_data(x) - data <- dataview_table(x) - file <- tempfile(tmpdir = tempdir, fileext = ".json") - jsonlite::write_json(data, file, na = "string", null = "null", auto_unbox = TRUE, force = TRUE) - request("dataview", source = "table", type = "json", - title = title, file = file, viewer = viewer, uuid = uuid - ) - } else if (is.list(x)) { - tryCatch({ - file <- tempfile(tmpdir = tempdir, fileext = ".json") - jsonlite::write_json(x, file, na = "string", null = "null", auto_unbox = TRUE, force = TRUE) - request("dataview", source = "list", type = "json", + send_data_request <- function(data, source, type, ...) { + if (is.na(request_tcp_connection)) { + file <- tempfile(tmpdir = tempdir, ...) + if (type == "json") { + jsonlite::write_json(data, file, na = "string", null = "null", auto_unbox = TRUE, force = TRUE) + } else { + writeLines(code, file) + } + request("dataview", + source = source, type = type, title = title, file = file, viewer = viewer, uuid = uuid ) - }, error = function(e) { - file <- file.path(tempdir, paste0(make.names(title), ".txt")) - text <- utils::capture.output(print(x)) - writeLines(text, file) - request("dataview", source = "object", type = "txt", - title = title, file = file, viewer = viewer, uuid = uuid + } else { + request("dataview", + source = source, type = type, + title = title, data = data, viewer = viewer, uuid = uuid ) - }) + } + } + should_default_view <- if (is.data.frame(x) || is.matrix(x)) { + x <- as_truncated_data(x) + data <- dataview_table(x) + send_data_request(data, source = "table", type = "json", fileext = ".json") + } else if (is.list(x)) { + tryCatch( + { + send_data_request(x, source = "list", type = "json", fileext = ".json") + }, + error = function(e) { + text <- utils::capture.output(print(x)) + send_data_request( + text, source = "object", + type = "txt", paste0(make.names(title)), fileext = ".txt" + ) + } + ) } else { - file <- file.path(tempdir, paste0(make.names(title), ".R")) if (is.primitive(x)) { code <- utils::capture.output(print(x)) } else { code <- deparse(x) } - writeLines(code, file) - request("dataview", source = "object", type = "R", - title = title, file = file, viewer = viewer, uuid = uuid - ) + send_data_request(code, source = "object", type = "R", paste0(make.names(title)), fileext = ".R") + } + if (should_default_view) { + old_view_impl(x, title) } } - rebind("View", show_dataview, "utils") + View_impl <- show_dataview } -attach <- function() { +attach <- function(host = "127.0.0.1", port = NA) { + if (request_is_attached) { + detach() + } load_settings() if (rstudioapi_enabled()) { rstudioapi_util_env$update_addin_registry(addin_registry) } + if (!is.na(port)) { + request_tcp_connection <<- socketConnection( + host = host, + port = port, + blocking = TRUE, + server = FALSE, + open = "a+" + ) + } + parent <- parent.env(environment()) request("attach", version = sprintf("%s.%s", R.version$major, R.version$minor), tempdir = tempdir, @@ -650,12 +754,62 @@ attach <- function() { start_time = format(file.info(tempdir)$ctime) ), plot_url = if (identical(names(dev.cur()), "httpgd")) httpgd::hgd_url(), - server = if (use_webserver) list( - host = host, - port = port, - token = token - ) else NULL + server = if (use_webserver) { + list( + host = parent$host, + port = parent$port, + token = parent$token + ) + } else { + NULL + } ) + if (!request_is_attached) { + options_name <- names(options_when_connected_list) + before_attach_options <<- setNames(lapply(options_name, function(option) getOption(option)), options_name) + + hooks_name <- names(hooks_when_connected_list) + before_attach_hooks <<- setNames(lapply(hooks_name, function(hook_name) getHook(hook_name)), hooks_name) + + old_view_impl <<- View + if (!is.null(View_impl)) { + rebind("View", View_impl, "utils") + } + } + do.call(options, options_when_connected_list) + mapply(function(hook_name, cb) { + setHook(hook_name, cb, "replace") + }, hooks_name, hooks_when_connected_list) + request_is_attached <<- TRUE +} + +detach <- function(including_request_detach = TRUE) { + if (including_request_detach) { + request("detach") + } + if (!is.na(request_tcp_connection)) { + close(request_tcp_connection) + request_tcp_connection <<- NA + } + if (request_is_attached) { + # restore previous options + options_name <- names(options_when_connected_list) + do.call(options, setNames(before_attach_options[options_name], options_name)) + + # restore previous hooks + hooks_name <- names(hooks_when_connected_list) + mapply(function(hook_name, cbs) { + setHook(hook_name, cbs, "replace") + }, hooks_name, before_attach_hooks[hooks_name]) + + if (!is.null(View_impl)) { + rebind("View", old_view_impl, "utils") + } + + lapply(created_devices, function(dev) dev.off(dev)) + created_devices <<- c() + } + request_is_attached <<- FALSE } path_to_uri <- function(path) { @@ -684,7 +838,8 @@ show_browser <- function(url, title = url, ..., proxy_uri <- Sys.getenv("VSCODE_PROXY_URI") if (nzchar(proxy_uri)) { is_base_path <- grepl("\\:\\d+$", url) - url <- sub("^https?\\://(127\\.0\\.0\\.1|localhost)(\\:)?", + url <- sub( + "^https?\\://(127\\.0\\.0\\.1|localhost)(\\:)?", sub("\\{\\{?port\\}\\}?/?", "", proxy_uri), url ) if (is_base_path) { @@ -705,24 +860,30 @@ show_browser <- function(url, title = url, ..., request_browser(url = url, title = title, ..., viewer = FALSE) } else { path <- sub("^file\\://", "", url) - if (file.exists(path)) { + should_default_browser <- if (file.exists(path)) { path <- normalizePath(path, "/", mustWork = TRUE) if (grepl("\\.html?$", path, ignore.case = TRUE)) { message( "VSCode WebView has restricted access to local file.\n", "Opening in external browser..." ) - request_browser(url = path_to_uri(path), + request_browser( + url = path_to_uri(path), title = title, ..., viewer = FALSE ) } else { - request("dataview", source = "object", type = "txt", + request("dataview", + source = "object", type = "txt", title = title, file = path, viewer = viewer ) } } else { stop("File not exists") } + if (should_default_browser) { + browser <- before_attach_options[["browser"]] + utils::browseURL(url, browser = browser) + } } } @@ -744,7 +905,8 @@ show_webview <- function(url, title, ..., viewer) { proxy_uri <- Sys.getenv("VSCODE_PROXY_URI") if (nzchar(proxy_uri)) { is_base_path <- grepl("\\:\\d+$", url) - url <- sub("^https?\\://(127\\.0\\.0\\.1|localhost)(\\:)?", + url <- sub( + "^https?\\://(127\\.0\\.0\\.1|localhost)(\\:)?", sub("\\{\\{?port\\}\\}?/?", "", proxy_uri), url ) if (is_base_path) { @@ -765,7 +927,32 @@ show_webview <- function(url, title, ..., viewer) { request_browser(url = url, title = title, ..., viewer = FALSE) } else if (file.exists(url)) { file <- normalizePath(url, "/", mustWork = TRUE) - request("webview", file = file, title = title, viewer = viewer, ...) + file_basename <- basename(file) + dir <- dirname(file) + lib_dir <- file.path(dir, "lib") + if (!is.na(request_tcp_connection) && file_basename == "index.html" && file.exists(lib_dir)) { + # pass detailed content via TCP, instead of just give a path to HTML + lib_dir_absolute <- normalizePath(path.expand(lib_dir), "/", mustWork = TRUE) + lib_files_path_relative <- file.path( + "lib", + list.files(lib_dir_absolute, all.files = TRUE, recursive = TRUE) + ) + files_path_relative <- c(lib_files_path_relative, file_basename) + + files_content_base64 <- setNames( + lapply(files_path_relative, function(file_path) { + raw_content <- readr::read_file_raw(file.path(dir, file_path)) + jsonlite::base64_enc(raw_content) + }), + files_path_relative + ) + request("webview", + file = file_basename, files_content_base64 = files_content_base64, + title = title, viewer = viewer, ... + ) + } else { + request("webview", file = file, title = title, viewer = viewer, ...) + } } else { stop("File not exists") } @@ -781,7 +968,16 @@ show_viewer <- function(url, title = NULL, ..., title <- deparse(expr, nlines = 1) } } - show_webview(url = url, title = title, ..., viewer = viewer) + should_default_viewer <- show_webview(url = url, title = title, ..., viewer = viewer) + if (should_default_viewer) { + # Reference: https://rstudio.github.io/rstudio-extensions/rstudio_viewer.html + viewer <- before_attach_options[["viewer"]] + if (!is.null(viewer)) { + viewer(url) + } else { + utils::browseURL(url) + } + } } show_page_viewer <- function(url, title = NULL, ..., @@ -794,10 +990,19 @@ show_page_viewer <- function(url, title = NULL, ..., title <- deparse(expr, nlines = 1) } } - show_webview(url = url, title = title, ..., viewer = viewer) + should_default_page_viewer <- show_webview(url = url, title = title, ..., viewer = viewer) + if (should_default_page_viewer) { + # Reference: https://rstudio.github.io/rstudio-extensions/rstudio_viewer.html + page_viewer <- before_attach_options[["page_viewer"]] + if (!is.null(page_viewer)) { + page_viewer(url) + } else { + utils::browseURL(url) + } + } } -options( +options_when_connected( browser = show_browser, viewer = show_viewer, page_viewer = show_page_viewer @@ -852,7 +1057,7 @@ if (rstudioapi_enabled()) { rstudioapi_env <- new.env(parent = rstudioapi_util_env) source(file.path(dir_init, "rstudioapi_util.R"), local = rstudioapi_util_env) source(file.path(dir_init, "rstudioapi.R"), local = rstudioapi_env) - setHook( + hook_when_connected( packageEvent("rstudioapi", "onLoad"), function(...) { rstudioapi_util_env$rstudioapi_patch_hook(rstudioapi_env) @@ -865,12 +1070,11 @@ if (rstudioapi_enabled()) { # work in the event that the namespace is unloaded and reloaded. rstudioapi_util_env$rstudioapi_patch_hook(rstudioapi_env) } - } print.help_files_with_topic <- function(h, ...) { viewer <- getOption("vsc.helpPanel", "Two") - if (!identical(FALSE, viewer) && length(h) >= 1 && is.character(h)) { + if (request_is_attached && !identical(FALSE, viewer) && length(h) >= 1 && is.character(h)) { file <- h[1] path <- dirname(file) dirpath <- dirname(path) @@ -882,7 +1086,9 @@ print.help_files_with_topic <- function(h, ...) { basename(file), ".html" ) - request(command = "help", requestPath = requestPath, viewer = viewer) + if (request(command = "help", requestPath = requestPath, viewer = viewer)) { + utils:::print.help_files_with_topic(h, ...) + } } else { utils:::print.help_files_with_topic(h, ...) } @@ -891,7 +1097,7 @@ print.help_files_with_topic <- function(h, ...) { print.hsearch <- function(x, ...) { viewer <- getOption("vsc.helpPanel", "Two") - if (!identical(FALSE, viewer) && length(x) >= 1) { + if (request_is_attached && !identical(FALSE, viewer) && length(x) >= 1) { requestPath <- paste0( "/doc/html/Search?pattern=", tools:::escapeAmpersand(x$pattern), @@ -921,7 +1127,9 @@ print.hsearch <- function(x, ...) { ) } ) - request(command = "help", requestPath = requestPath, viewer = viewer) + if (request(command = "help", requestPath = requestPath, viewer = viewer)) { + utils:::print.hsearch(x, ...) + } } else { utils:::print.hsearch(x, ...) } @@ -938,4 +1146,11 @@ print.hsearch <- function(x, ...) { invisible(NULL) } -reg.finalizer(.GlobalEnv, function(e) .vsc$request("detach"), onexit = TRUE) +reg.finalizer(.GlobalEnv, function(e) { + tryCatch( + { + detach() + }, + error = function(e) NULL + ) +}, onexit = TRUE) diff --git a/package.json b/package.json index fb6465b2b..a6c9ca6e3 100644 --- a/package.json +++ b/package.json @@ -286,6 +286,13 @@ } ], "commands": [ + { + "command": "r.workspaceViewer.detachSession", + "title": "Detach Session", + "icon": "$(debug-disconnect)", + "category": "R Workspace Viewer", + "enablement": "rSessionActive" + }, { "command": "r.workspaceViewer.refreshEntry", "title": "Manual Refresh", @@ -1334,6 +1341,11 @@ "group": "navigation@3", "when": "r.WorkspaceViewer:show && view == workspaceViewer" }, + { + "command": "r.workspaceViewer.detachSession", + "group": "navigation@4", + "when": "r.WorkspaceViewer:show && view == workspaceViewer && !r.liveShare:isGuest" + }, { "command": "r.helpPanel.showQuickPick", "group": "navigation", @@ -1705,6 +1717,21 @@ "default": true, "description": "Enable R session watcher. Required for workspace viewer and most features to work with an R session. Restart required to take effect." }, + "r.sessionWatcherTcpServer": { + "type": "boolean", + "default": true, + "description": "Enable TCP server connection for watching over R sessions. Requires `#r.sessionWatcher#` to be set to `true`. Requires reloading for changes to take effect." + }, + "r.sessionWatcherTcpServerHostName": { + "type": "string", + "default": "127.0.0.1", + "description": "The host name to listen for watching over R sessions. E.g. 127.0.0.1 for local connection only, and 0.0.0.0 for global exposure (security note: use the latter only in a secured close local network!). Requires `#r.sessionWatcher#` and `r.sessionWatcher.TCPServer` to be set to `true`. Requires reloading for changes to take effect." + }, + "r.sessionWatcherTcpServerPort": { + "type": "integer", + "default": 0, + "description": "The port to listen to, or 0 for automatic port selection (recommended). Requires `#r.sessionWatcher#` and `r.sessionWatcher.TCPServer` to be set to `true`. Requires reloading for changes to take effect." + }, "r.session.useWebServer": { "type": "boolean", "default": false, @@ -2096,6 +2123,7 @@ "@types/node": "^18.17.1", "@types/node-fetch": "^2.5.10", "@types/sinon": "^10.0.13", + "@types/tmp": "^0.2.3", "@types/vscode": "^1.75.0", "@types/winreg": "^1.2.31", "@typescript-eslint/eslint-plugin": "^5.30.0", @@ -2125,6 +2153,8 @@ "jquery.json-viewer": "^1.5.0", "js-yaml": "^4.1.0", "node-fetch": "^2.6.7", + "promise-socket": "^7.0.0", + "tmp": "^0.2.1", "vscode-languageclient": "^9.0.1", "vsls": "^1.0.4753", "winreg": "^1.2.4" diff --git a/src/extension.ts b/src/extension.ts index 67a07ea07..fcea79bf7 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -24,6 +24,7 @@ import * as rShare from './liveShare'; import * as httpgdViewer from './plotViewer'; import * as languageService from './languageService'; import { RTaskProvider } from './tasks'; +import { docProvider, docScheme } from './virtualDocs'; // global objects used in other files @@ -45,6 +46,10 @@ export async function activate(context: vscode.ExtensionContext): Promise rWorkspace?.refresh(), 'r.workspaceViewer.view': (node: workspaceViewer.GlobalEnvItem) => node?.label && workspaceViewer.viewItem(node.label), 'r.workspaceViewer.remove': (node: workspaceViewer.GlobalEnvItem) => node?.label && workspaceViewer.removeItem(node.label), diff --git a/src/liveShare/index.ts b/src/liveShare/index.ts index e18305693..423905d5a 100644 --- a/src/liveShare/index.ts +++ b/src/liveShare/index.ts @@ -2,7 +2,6 @@ export * from './shareCommands'; export * from './shareSession'; export * from './shareTree'; -export * from './virtualDocs'; import * as vscode from 'vscode'; import * as vsls from 'vsls'; diff --git a/src/liveShare/shareSession.ts b/src/liveShare/shareSession.ts index d11b930e0..522968847 100644 --- a/src/liveShare/shareSession.ts +++ b/src/liveShare/shareSession.ts @@ -6,7 +6,6 @@ import { asViewColumn, config, readContent } from '../util'; import { showBrowser, showDataView, showWebView, WorkspaceData } from '../session'; import { liveSession, UUID, rGuestService, _sessionStatusBarItem as sessionStatusBarItem } from '.'; import { autoShareBrowser } from './shareTree'; -import { docProvider, docScheme } from './virtualDocs'; // Workspace Vars let guestPid: string; @@ -27,9 +26,14 @@ export interface IRequest { source?: string; type?: string; title?: string; + data?: string; file?: string; + files_content_base64?: Record; viewer?: string; plot?: string; + format?: string; + plot_base64?: string; + workspaceData?: WorkspaceData; action?: string; args?: unknown; sd?: string; @@ -54,8 +58,7 @@ export function initGuest(context: vscode.ExtensionContext): void { sessionStatusBarItem.tooltip = 'Click to attach to host terminal'; sessionStatusBarItem.show(); context.subscriptions.push( - sessionStatusBarItem, - vscode.workspace.registerTextDocumentContentProvider(docScheme, docProvider) + sessionStatusBarItem ); rGuestService?.setStatusBarItem(sessionStatusBarItem); guestResDir = path.join(context.extensionPath, 'dist', 'resources'); @@ -141,7 +144,7 @@ export async function updateGuestRequest(file: string, force: boolean = false): } case 'webview': { if (request.file && request.title && request.viewer !== undefined) { - await showWebView(request.file, request.title, request.viewer); + await showWebView(request.file, request.files_content_base64, request.title, request.viewer); } break; } @@ -149,7 +152,7 @@ export async function updateGuestRequest(file: string, force: boolean = false): if (request.source && request.type && request.title && request.file && request.viewer !== undefined) { await showDataView(request.source, - request.type, request.title, request.file, request.viewer); + request.type, request.title, request.file, request.data, request.viewer); } break; } diff --git a/src/rTerminal.ts b/src/rTerminal.ts index e527066a7..5c71e1f3c 100644 --- a/src/rTerminal.ts +++ b/src/rTerminal.ts @@ -9,10 +9,11 @@ import { extensionContext, homeExtDir } from './extension'; import * as util from './util'; import * as selection from './selection'; import { getSelection } from './selection'; -import { cleanupSession } from './session'; -import { config, delay, getRterm, getCurrentWorkspaceFolder } from './util'; +import { cleanupSession, incomingRequestServerAddressInfo } from './session'; +import { config, delay, getRterm, getCurrentWorkspaceFolder, hostnameOfListeningAddress } from './util'; import { rGuestService, isGuestSession } from './liveShare'; import * as fs from 'fs'; +import { isAbsolute } from 'path'; export let rTerm: vscode.Terminal | undefined = undefined; export async function runSource(echo: boolean): Promise { @@ -131,7 +132,10 @@ export async function makeTerminalOptions(): Promise { R_PROFILE_USER_OLD: process.env.R_PROFILE_USER, R_PROFILE_USER: newRprofile, VSCODE_INIT_R: initR, - VSCODE_WATCHER_DIR: homeExtDir() + VSCODE_WATCHER_DIR: homeExtDir(), + VSCODE_ATTACH_HOST: incomingRequestServerAddressInfo === undefined ? undefined : + hostnameOfListeningAddress(incomingRequestServerAddressInfo), + VSCODE_ATTACH_PORT: incomingRequestServerAddressInfo?.port?.toString(), }; } return termOptions; @@ -143,7 +147,7 @@ export async function createRTerm(preserveshow?: boolean): Promise { if(!termPath){ void vscode.window.showErrorMessage('Could not find R path. Please check r.term and r.path setting.'); return false; - } else if(!fs.existsSync(termPath)){ + } else if(isAbsolute(termPath) && !fs.existsSync(termPath)){ void vscode.window.showErrorMessage(`Cannot find R client at ${termPath}. Please check r.rterm setting.`); return false; } diff --git a/src/session.ts b/src/session.ts index b5959019a..fff8c5867 100644 --- a/src/session.ts +++ b/src/session.ts @@ -4,17 +4,20 @@ import * as fs from 'fs-extra'; import * as os from 'os'; import * as path from 'path'; import { Agent } from 'http'; +import { AddressInfo, Server, Socket } from 'node:net'; +import { PromiseSocket } from 'promise-socket'; import fetch from 'node-fetch'; import { commands, StatusBarItem, Uri, ViewColumn, Webview, window, workspace, env, WebviewPanelOnDidChangeViewStateEvent, WebviewPanel } from 'vscode'; import { runTextInTerm } from './rTerminal'; import { FSWatcher } from 'fs-extra'; -import { config, readContent, setContext, UriIcon } from './util'; +import { config, createTempDir2, createTempFile, createWaiterForInvoker, hostnameOfListeningAddress, readContent, setContext, UriIcon } from './util'; import { purgeAddinPickerItems, dispatchRStudioAPICall } from './rstudioapi'; import { IRequest } from './liveShare/shareSession'; import { homeExtDir, rWorkspace, globalRHelp, globalHttpgdManager, extensionContext, sessionStatusBarItem } from './extension'; -import { UUID, rHostService, rGuestService, isLiveShare, isHost, isGuestSession, closeBrowser, guestResDir, shareBrowser, openVirtualDoc, shareWorkspace } from './liveShare'; +import { UUID, rHostService, rGuestService, isLiveShare, isHost, isGuestSession, closeBrowser, guestResDir, shareBrowser, shareWorkspace } from './liveShare'; +import { openVirtualDoc } from './virtualDocs'; export interface GlobalEnv { [key: string]: { @@ -49,6 +52,7 @@ let requestTimeStamp: number; let responseTimeStamp: number; export let sessionDir: string; export let workingDir: string; +let incomingRequestServerCurrentSocket: Socket | null = null; let rVer: string; let pid: string; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -66,14 +70,58 @@ let plotWatcher: FSWatcher; let activeBrowserPanel: WebviewPanel | undefined; let activeBrowserUri: Uri | undefined; let activeBrowserExternalUri: Uri | undefined; +export let incomingRequestServerAddressInfo: AddressInfo | undefined = undefined; +export let attached = false; + +enum InterruptReason { + ANOTHER_CONNECTION, + USER_REQUEST +} + +class InterruptSocketConnectionError extends Error { + reason: InterruptReason; + finishWaiter: Promise; + private finishInvoker: () => void; + + constructor (reason: InterruptReason) { + super(); + + this.reason = reason; + + const pair = createWaiterForInvoker(); + this.finishWaiter = pair.waiter; + this.finishInvoker = pair.invoker; + } + + reportFinishHandling() { + this.finishInvoker(); + } +} + +const addressToStr = (addressInfo: AddressInfo) => `${addressInfo.address}:${addressInfo.port}`; + +function updateSessionStatusBarItem(sessionStatusBarItem: StatusBarItem) { + const addressInfoStr = incomingRequestServerAddressInfo && addressToStr(incomingRequestServerAddressInfo); + if (attached) { + sessionStatusBarItem.text = `R ${rVer}: ${pid}` + (addressInfoStr ? ` (Connected via ${addressInfoStr})` : ''); + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions, @typescript-eslint/no-unsafe-member-access + sessionStatusBarItem.tooltip = `${info?.version}\nProcess ID: ${pid}\n${addressInfoStr ? (`Connected via TCP address: ${addressInfoStr}\n`) : ''}Command: ${info?.command}\nStart time: ${info?.start_time}\nClick to attach to active terminal.`; + } else { + sessionStatusBarItem.text = `R: (not attached${addressInfoStr ? `, listening on ${addressInfoStr}` : ''})`; + sessionStatusBarItem.tooltip = 'Click to attach active terminal.'; + } + sessionStatusBarItem.show(); +} export function deploySessionWatcher(extensionPath: string): void { console.info(`[deploySessionWatcher] extensionPath: ${extensionPath}`); resDir = path.join(extensionPath, 'dist', 'resources'); - const initPath = path.join(extensionPath, 'R', 'session', 'init.R'); - const linkPath = path.join(homeExtDir(), 'init.R'); - fs.writeFileSync(linkPath, `local(source("${initPath.replace(/\\/g, '\\\\')}", chdir = TRUE, local = TRUE))\n`); + for (const initFileName of ['init.R', 'init_late.R']) { + const initPath = path.join(extensionPath, 'R', 'session', initFileName); + const linkPath = path.join(homeExtDir(), initFileName); + fs.writeFileSync(linkPath, `local(source("${initPath.replace(/\\/g, '\\\\')}", chdir = TRUE, local = TRUE))\n`); + } writeSettings(); workspace.onDidChangeConfiguration(event => { @@ -85,23 +133,47 @@ export function deploySessionWatcher(extensionPath: string): void { export function startRequestWatcher(sessionStatusBarItem: StatusBarItem): void { console.info('[startRequestWatcher] Starting'); - requestFile = path.join(homeExtDir(), 'request.log'); - requestLockFile = path.join(homeExtDir(), 'request.lock'); - requestTimeStamp = 0; - responseTimeStamp = 0; - if (!fs.existsSync(requestLockFile)) { - fs.createFileSync(requestLockFile); - } - fs.watch(requestLockFile, {}, () => { - void updateRequest(sessionStatusBarItem); - }); + + try { + requestFile = path.join(homeExtDir(), 'request.log'); + requestLockFile = path.join(homeExtDir(), 'request.lock'); + requestTimeStamp = 0; + responseTimeStamp = 0; + if (!fs.existsSync(requestLockFile)) { + fs.createFileSync(requestLockFile); + } + fs.watch(requestLockFile, {}, () => { + void updateRequest(sessionStatusBarItem); + }); + } catch (e) { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + console.error(`Error in request file creating and watching: ${e}`); + // TODO: Handle better + } + + try { + if (config().get('sessionWatcherTcpServer')) { + void startIncomingRequestServer(sessionStatusBarItem); + } + } catch (e) { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + console.error(`Error in incoming request server setup: ${e}`); + // TODO: Handle better + } + console.info('[startRequestWatcher] Done'); } export function attachActive(): void { if (config().get('sessionWatcher')) { console.info('[attachActive]'); - void runTextInTerm('.vsc.attach()'); + if (incomingRequestServerAddressInfo) { + void runTextInTerm(`.vsc.attach(host=${ + JSON.stringify(hostnameOfListeningAddress(incomingRequestServerAddressInfo)) + }, port=${incomingRequestServerAddressInfo.port}L)`); + } else { + void runTextInTerm('.vsc.attach()'); + } if (isLiveShare() && shareWorkspace) { rHostService?.notifyRequest(requestFile, true); } @@ -186,11 +258,7 @@ async function updatePlot() { if (newTimeStamp !== plotTimeStamp) { plotTimeStamp = newTimeStamp; if (fs.existsSync(plotFile) && fs.statSync(plotFile).size > 0) { - void commands.executeCommand('vscode.open', Uri.file(plotFile), { - preserveFocus: true, - preview: true, - viewColumn: ViewColumn[(config().get('session.viewers.viewColumn.plot') || 'Two') as keyof typeof ViewColumn], - }); + showPlot(plotFile); console.info('[updatePlot] Done'); if (isLiveShare()) { void rHostService?.notifyPlot(plotFile); @@ -201,6 +269,14 @@ async function updatePlot() { } } +function showPlot(plotFile: string) { + void commands.executeCommand('vscode.open', Uri.file(plotFile), { + preserveFocus: true, + preview: true, + viewColumn: ViewColumn[(config().get('session.viewers.viewColumn.plot') || 'Two') as keyof typeof ViewColumn], + }); +} + async function updateWorkspace() { console.info(`[updateWorkspace] ${workspaceFile}`); @@ -293,6 +369,17 @@ function getBrowserHtml(uri: Uri): string { `; } +export async function detach() { + if (incomingRequestServerCurrentSocket === null) { + return; + } + // otherwise + + const interrupt = new InterruptSocketConnectionError(InterruptReason.USER_REQUEST); + incomingRequestServerCurrentSocket.destroy(interrupt); + await interrupt.finishWaiter; +} + export function refreshBrowser(): void { console.log('[refreshBrowser]'); if (activeBrowserPanel) { @@ -310,12 +397,29 @@ export function openExternalBrowser(): void { } } -export async function showWebView(file: string, title: string, viewer: string | boolean): Promise { +export async function showWebView(file: string, files_content_base64: Record | undefined, + title: string, viewer: string | boolean): Promise { console.info(`[showWebView] file: ${file}, viewer: ${viewer.toString()}`); if (viewer === false) { void env.openExternal(Uri.file(file)); } else { - const dir = path.dirname(file); + let dir: string; + if (files_content_base64 !== undefined) { + dir = (await createTempDir2()).path; + const subdirs = new Set(Object.keys(files_content_base64).map((relativePath) => path.dirname(relativePath))); + subdirs.delete(''); + subdirs.delete('.'); + await Promise.all( + Array.from(subdirs).map((subdir) => fs.mkdir(path.join(dir, subdir), { recursive: true })) + ); + await Promise.all(Object.entries(files_content_base64).map(async ([realtivePath, contentBase64]) => { + const arrayData = Buffer.from(contentBase64, 'base64'); + return fs.writeFile(path.join(dir, realtivePath), arrayData); + })); + file = path.join(dir, file); + } else { + dir = path.dirname(file); + } const webviewDir = extensionContext.asAbsolutePath('html/session/webview/'); const panel = window.createWebviewPanel('webview', title, { @@ -334,8 +438,21 @@ export async function showWebView(file: string, title: string, viewer: string | console.info('[showWebView] Done'); } -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 | undefined, data: string | object | undefined, viewer: string): Promise { + console.info(`[showDataView] source: ${source}, type: ${type}, title: ${title}, file: ${file ?? 'none'}, viewer: ${viewer}`); + console.debug(`data: ${JSON.stringify(data)}`); + + const getDataContent = async () : Promise => { + if (file === undefined) { + return typeof data === 'string' ? data : JSON.stringify(data); + } else { + const fileContent = await readContent(file, 'utf8'); + if (fileContent === undefined) { + console.error('Error: File wasn\'t found!'); + return undefined; + } + } + }; if (isGuestSession) { resDir = guestResDir; @@ -353,9 +470,12 @@ export async function showDataView(source: string, type: string, title: string, retainContextWhenHidden: true, localResourceRoots: [Uri.file(resDir)], }); - const content = await getTableHtml(panel.webview, file); - panel.iconPath = new UriIcon('open-preview'); - panel.webview.html = content; + const fileContent = await getDataContent(); + if (fileContent !== undefined) { + const content = getTableHtml(panel.webview, fileContent); + panel.iconPath = new UriIcon('open-preview'); + panel.webview.html = content; + } } else if (source === 'list') { const panel = window.createWebviewPanel('dataview', title, { @@ -368,14 +488,17 @@ export async function showDataView(source: string, type: string, title: string, retainContextWhenHidden: true, localResourceRoots: [Uri.file(resDir)], }); - const content = await getListHtml(panel.webview, file); - panel.iconPath = new UriIcon('open-preview'); - panel.webview.html = content; + const fileContent = await getDataContent(); + if (fileContent !== undefined) { + const content = getListHtml(panel.webview, fileContent); + panel.iconPath = new UriIcon('open-preview'); + panel.webview.html = content; + } } else { - if (isGuestSession) { - const fileContent = await rGuestService?.requestFileContent(file, 'utf8'); + if (isGuestSession || file === undefined) { + const fileContent = file === undefined ? data as string : await rGuestService?.requestFileContent(file, 'utf8'); if (fileContent) { - await openVirtualDoc(file, fileContent, true, true, ViewColumn[viewer as keyof typeof ViewColumn]); + await openVirtualDoc(file ?? 'R View', fileContent, true, true, ViewColumn[viewer as keyof typeof ViewColumn]); } } else { await commands.executeCommand('vscode.open', Uri.file(file), { @@ -388,10 +511,9 @@ export async function showDataView(source: string, type: string, title: string, console.info('[showDataView] Done'); } -export async function getTableHtml(webview: Webview, file: string): Promise { +export function getTableHtml(webview: Webview, content: string): string { resDir = isGuestSession ? guestResDir : resDir; const pageSize = config().get('session.data.pageSize', 500); - const content = await readContent(file, 'utf8'); return ` @@ -512,7 +634,7 @@ export async function getTableHtml(webview: Webview, file: string): Promise { +export function getListHtml(webview: Webview, content: string): string { resDir = isGuestSession ? guestResDir : resDir; - const content = await readContent(file, 'utf8'); return ` @@ -629,7 +750,7 @@ export async function getListHtml(webview: Webview, file: string): Promise