diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 3caae277e..e7ed58612 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -3,9 +3,11 @@ on:
push:
branches:
- main
+ - master
pull_request:
branches:
- main
+ - master
workflow_call:
inputs:
prerelease-date:
@@ -20,6 +22,8 @@ concurrency:
jobs:
test:
+ env:
+ VSCR_REQUIRE_PLOT_TESTS: '1'
strategy:
fail-fast: false
matrix:
@@ -45,7 +49,22 @@ jobs:
- name: Install R test dependencies
run: install.packages(c("remotes", "pkgbuild", "languageserver", "lintr", "styler", "renv"))
shell: Rscript {0}
+ - name: Install Linux plot system dependencies
+ if: runner.os == 'Linux'
+ # The unigd binary links libtiffxx.so.6, which the hosted image lacks.
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y libtiffxx6
- run: pnpm run build
+ - name: Verify live plot lifecycle dependencies
+ run: |
+ packages <- c("processx", "later", "jsonlite", "jgd", "httpgd", "svglite")
+ for (package in packages) {
+ message("Loading plot test dependency: ", package)
+ loadNamespace(package)
+ }
+ stopifnot(packageVersion("httpgd") >= "2.0.1")
+ shell: Rscript {0}
- name: Verify managed jgd preparation
run: pnpm run test:prepare-jgd
- name: Verify managed rstudioapi preparation
@@ -88,14 +107,20 @@ jobs:
sudo apt-get update
sudo apt-get install -y tmux
VSCR_TEST_TMUX=1 pnpm exec mocha out/test/node/interactiveRuntime.test.js --ui tdd --timeout 60000 --grep "^Interactive real R runtime standalone agent survives its launcher process exiting$"
- - name: Run Node tests
+ - name: Run Node tests including live plot lifecycle tests
run: pnpm run test:node
+ - name: Run plot lifecycle extension tests (Linux)
+ if: runner.os == 'Linux'
+ run: xvfb-run -a pnpm run test:extension --label plot-lifecycle
+ - name: Run plot lifecycle extension tests
+ if: runner.os != 'Linux'
+ run: pnpm run test:extension --label plot-lifecycle
- name: Run extension tests (Linux)
if: runner.os == 'Linux'
- run: xvfb-run -a pnpm run test:extension
+ run: xvfb-run -a pnpm run test:extension --label provider-tests --label editor-command-tests --label interactive-integration
- name: Run extension tests
if: runner.os != 'Linux'
- run: pnpm run test:extension
+ run: pnpm run test:extension --label provider-tests --label editor-command-tests --label interactive-integration
- name: Preserve extension test failure logs
if: failure()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
diff --git a/.vscode-test.mjs b/.vscode-test.mjs
index d138f5b57..aab9a9ce4 100644
--- a/.vscode-test.mjs
+++ b/.vscode-test.mjs
@@ -11,6 +11,11 @@ const common = {
}
};
+const plotSuites = [
+ 'jgdPlotHistory', 'jgdSocketServer', 'standardPlotViewer', 'plotCommands',
+ 'plotBackend', 'plotRestoration', 'httpgdViewer', 'viewerSession', 'sessionConnectionLifecycle'
+];
+
// Keep mock/provider tests independent of R language-server startup. Editor
// command tests also need a fresh window after fixtures create terminals and
// webviews. The real Interactive suite has its own window with the LSP enabled.
@@ -32,10 +37,17 @@ export default defineConfig([
{
...common,
label: 'provider-tests',
- files: 'out/test/suite/**/!(interactiveEditor|interactiveInputFeatures|completionMiddleware).test.js',
+ files: `out/test/suite/**/!(${['interactiveEditor', 'interactiveInputFeatures', 'completionMiddleware', ...plotSuites].join('|')}).test.js`,
mocha: { ...common.mocha, require: './out/test/common/extensionSetup.js' },
launchArgs: testProfile('provider-tests', false)
},
+ {
+ ...common,
+ label: 'plot-lifecycle',
+ files: plotSuites.map(name => `out/test/suite/${name}.test.js`),
+ mocha: { ...common.mocha, require: './out/test/common/extensionSetup.js' },
+ launchArgs: testProfile('plots', false)
+ },
{
...common,
label: 'editor-command-tests',
diff --git a/R/tests/attach_sess.R b/R/tests/attach_sess.R
index 61f33bd6b..af78b6e54 100644
--- a/R/tests/attach_sess.R
+++ b/R/tests/attach_sess.R
@@ -238,7 +238,7 @@ writeLines(c(
" sys.source(file.path(getwd(), 'sess', 'R', 'server.R'), scheduler)",
" scheduler$.read_discovery <- function(path) list(endpoint='endpoint-b')",
" scheduler$.configure_discovery_jgd <- function(discovery, enabled) NULL",
- " scheduler$runtime_stop <- function() invisible(NULL)",
+ " scheduler$runtime_stop <- function(preserve_plots=FALSE) invisible(NULL)",
" scheduler$connect <- function(endpoint, ...) {",
" connect_count <<- connect_count + 1L",
" observed[[connect_count]] <<- readLines(reconnect_file, warn=FALSE)",
@@ -410,14 +410,16 @@ writeLines(c(
" inherited <- vscode_r_startup_existing('endpoint')",
" if (!is.null(inherited)) stop('child reused the parent startup context')",
"}",
- "ids <- character(); identities <- character(); deadline <- Sys.time() + 30",
+ "ids <- character(); identities <- character(); deadline <- Sys.time() + 30; read_error <- NULL",
"while (length(ids) < count && Sys.time() < deadline) {",
" requests <- list.files(directory, pattern='\\\\.request$', full.names=TRUE)",
" for (request in requests) {",
" id <- sub('\\\\.request$', '', basename(request))",
" if (id %in% ids) next",
+ # Requests can disappear or be briefly locked during cancellation on Windows.
+ # Retry on the next scan; the existing deadline still bounds persistent errors.
" lines <- suppressWarnings(tryCatch(readLines(request, warn=FALSE),",
- " error=function(e) { if (!file.exists(request)) NULL else stop(e) }))",
+ " error=function(e) { read_error <<- conditionMessage(e); NULL }))",
" if (is.null(lines)) next",
" if (length(lines) != 5L || lines[[1L]] != 'vscode-r-sess-consent-v1' ||",
" lines[[2L]] != id) stop('bad request')",
@@ -431,7 +433,7 @@ writeLines(c(
" }",
" Sys.sleep(0.025)",
"}",
- "if (length(ids) != count) stop('timed out waiting for consent requests')",
+ "if (length(ids) != count) stop('timed out waiting for consent requests: ', read_error)",
"writeLines(ids, log)",
"writeLines(identities, identity_log)",
"writeLines('ok', status_log)",
diff --git a/R/tests/prepare-jgd.R b/R/tests/prepare-jgd.R
index 5381c366b..78bc055b7 100644
--- a/R/tests/prepare-jgd.R
+++ b/R/tests/prepare-jgd.R
@@ -46,10 +46,20 @@ local({
"jgd <- function() {",
" path <- Sys.getenv('JGD_TEST_DEVICE_LOG', '')",
" if (nzchar(path)) cat('jgd\\n', file=path, append=TRUE)",
+ # sess now tracks the opened device and attaches its session identity.
+ # A null PDF supplies a real device without requiring a JGD renderer.
+ " grDevices::pdf(NULL)",
+ " invisible(NULL)",
+ "}",
+ "jgd_server_info <- function() list(server_info=c(deviceId=as.character(grDevices::dev.cur())))",
+ "jgd_frame_ext <- function(value) {",
+ " path <- Sys.getenv('JGD_TEST_DEVICE_LOG', '')",
+ " if (nzchar(path)) cat(value, '\\n', file=paste0(path, '-frames'), append=TRUE)",
" invisible(NULL)",
"}"
), file.path(package, "R", "api.R"))
- writeLines(c("export(fixture_device)", "export(jgd)"), file.path(package, "NAMESPACE"))
+ writeLines(c("export(fixture_device)", "export(jgd)",
+ "export(jgd_server_info)", "export(jgd_frame_ext)"), file.path(package, "NAMESPACE"))
previous <- setwd(root)
on.exit(setwd(previous), add = TRUE)
output <- system2_with_child_environment(
@@ -336,6 +346,16 @@ local({
"fixture-jgd-socket-next", "TRUE")))
stopifnot(identical(readLines(file.path(root, "real-approved-real-device"), warn = FALSE),
c("jgd", "jgd")))
+ frames <- lapply(readLines(file.path(root, "real-approved-real-device-frames"), warn = FALSE),
+ jsonlite::fromJSON)
+ stopifnot(length(frames) == 2L)
+ stopifnot(all(vapply(frames, function(frame) {
+ identity <- frame$vscodeR
+ nzchar(identity$sessionId) && nzchar(identity$pid) &&
+ nzchar(identity$deviceId) && identical(identity$restoring, FALSE)
+ }, logical(1))))
+ stopifnot(identical(frames[[1L]]$vscodeR$sessionId, frames[[2L]]$vscodeR$sessionId))
+ stopifnot(!identical(frames[[1L]]$vscodeR$deviceId, frames[[2L]]$vscodeR$deviceId))
approved_root <- file.path(root, "approved")
dir.create(approved_root)
diff --git a/html/httpgd/index.ejs b/html/httpgd/index.ejs
deleted file mode 100644
index 99036c41c..000000000
--- a/html/httpgd/index.ejs
+++ /dev/null
@@ -1,25 +0,0 @@
-
-
-
-
-
- >
-
-
-
- >
- <%- largePlot?.data %>
-
-
-
- <% plots.forEach((plot)=> { %>
-
- <%- include(asLocalPath('./smallPlot.ejs'), {plot: plot}) %>
-
- <% }) %>
-
-
-
-
-
-
diff --git a/html/httpgd/index.ts b/html/httpgd/index.ts
deleted file mode 100644
index 71242128d..000000000
--- a/html/httpgd/index.ts
+++ /dev/null
@@ -1,238 +0,0 @@
-/* eslint-disable @typescript-eslint/no-non-null-assertion */
-/* eslint-disable @typescript-eslint/no-unsafe-assignment */
-/* eslint-disable @typescript-eslint/ban-ts-comment */
-/* eslint-disable @typescript-eslint/no-unsafe-call */
-/* eslint-disable @typescript-eslint/no-explicit-any */
-
-interface Plot {
- // unique ID for this plot (w.r.t. this connection/device)
- id: string;
-
- // svg of the plot
- svg: string;
-
- height?: number;
- width?: number;
-}
-
-// get vscode api
-declare function acquireVsCodeApi(): VsCode;
-const vscode = acquireVsCodeApi();
-
-// globals
-let oldHeight = -1;
-let oldWidth = -1;
-
-const handler = document.querySelector('#handler') as HTMLDivElement;
-const largePlotDiv = document.querySelector('#largePlot') as HTMLDivElement;
-const largeSvg = largePlotDiv.querySelector('svg') as SVGElement;
-const cssLink = document.querySelector('link.overwrites') as HTMLLinkElement;
-const smallPlotDiv = document.querySelector('#smallPlots') as HTMLDivElement;
-
-function getSmallPlots(): HTMLAnchorElement[] {
- const smallPlots: HTMLAnchorElement[] = [];
- document.querySelectorAll('a.focusPlot').forEach((elm) => {
- smallPlots.push(elm as HTMLAnchorElement);
- });
- return smallPlots;
-}
-
-let isHandlerDragging = false;
-
-let isFullWindow = false;
-
-function postResizeMessage(userTriggered: boolean = false) {
- let newHeight = largePlotDiv.clientHeight;
- let newWidth = largePlotDiv.clientWidth;
- if (isFullWindow) {
- newHeight = window.innerHeight;
- newWidth = window.innerWidth;
- }
- if (userTriggered || newHeight !== oldHeight || newWidth !== oldWidth) {
- const msg: ResizeMessage = {
- message: 'resize',
- height: newHeight,
- width: newWidth,
- userTriggered: userTriggered,
- };
- vscode.postMessage(msg);
- oldHeight = newHeight;
- oldWidth = newWidth;
- }
-}
-
-function postLogMessage(content: any) {
- console.log(content);
- vscode.postMessage({
- message: 'log',
- body: content,
- });
-}
-
-window.addEventListener('message', (ev: MessageEvent) => {
- const msg = ev.data;
- if (msg.message === 'updatePlot') {
- updatePlot({
- id: String(msg.plotId),
- svg: msg.svg,
- });
- } else if (msg.message === 'focusPlot') {
- focusPlot(String(msg.plotId));
- } else if (msg.message === 'toggleStyle') {
- toggleStyle(msg.useOverwrites);
- } else if (msg.message === 'hidePlot') {
- hidePlot(msg.plotId);
- } else if (msg.message === 'addPlot') {
- addPlot(msg.html);
- } else if (msg.message === 'togglePreviewPlotLayout') {
- togglePreviewPlotLayout(msg.style);
- } else if (msg.message === 'toggleFullWindow') {
- toggleFullWindowMode(msg.useFullWindow);
- }
-});
-
-function addPlot(html: string) {
- const wrapper = document.createElement('div');
- wrapper.classList.add('wrapper');
- wrapper.innerHTML = html;
- smallPlotDiv.appendChild(wrapper);
-}
-
-function focusPlot(plotId: string): void {
- const smallPlots = getSmallPlots();
-
- const ind = findIndex(plotId, smallPlots);
- if (ind < 0) {
- return;
- }
-
- for (const elm of smallPlots) {
- elm.classList.remove('active');
- }
-
- const smallPlot = smallPlots[ind];
-
- smallPlot.classList.add('active');
-
- largePlotDiv.innerHTML = smallPlot.innerHTML;
-}
-
-function updatePlot(plt: Plot): void {
- const smallPlots = getSmallPlots();
-
- const ind = findIndex(plt.id, smallPlots);
- if (ind < 0) {
- return;
- }
-
- smallPlots[ind].innerHTML = plt.svg;
-
- if (smallPlots[ind].classList.contains('active')) {
- largePlotDiv.innerHTML = plt.svg;
- }
-}
-
-function hidePlot(plotId: string): void {
- const smallPlots = getSmallPlots();
-
- const ind = findIndex(plotId, smallPlots);
- if (ind < 0) {
- return;
- }
-
- if (smallPlots[ind].classList.contains('active')) {
- largePlotDiv.innerHTML = '';
- }
-
- smallPlots[ind].parentElement?.remove();
-}
-
-function findIndex(plotId: string, smallPlots?: Element[]): number {
- smallPlots ||= getSmallPlots();
- const ind = smallPlots.findIndex((elm) => elm.getAttribute('plotId') === String(plotId));
- if (ind < 0) {
- console.warn(`plotId not found: ${plotId}`);
- }
- return ind;
-}
-
-function toggleStyle(useOverwrites: boolean): void {
- cssLink.disabled = !useOverwrites;
-}
-
-function togglePreviewPlotLayout(newStyle: PreviewPlotLayout): void {
- smallPlotDiv.classList.remove('multirow', 'scroll', 'hidden');
- smallPlotDiv.classList.add(newStyle);
-}
-
-function toggleFullWindowMode(useFullWindow: boolean): void {
- isFullWindow = useFullWindow;
- if (useFullWindow) {
- document.body.classList.add('fullWindow');
- window.scrollTo(0, 0);
- } else {
- document.body.classList.remove('fullWindow');
- }
- postResizeMessage(true);
-}
-
-////
-// On window load
-////
-
-window.onload = () => {
- largePlotDiv.style.height = `${largeSvg.clientHeight}px`;
- postResizeMessage(true);
-};
-
-////
-// Resize bar
-////
-
-document.addEventListener('mousedown', (e) => {
- // If mousedown event is fired from .handler, toggle flag to true
- if (!isFullWindow && e.target === handler) {
- isHandlerDragging = true;
- handler.classList.add('dragging');
- document.body.style.cursor = 'ns-resize';
- }
-});
-
-document.addEventListener('mousemove', (e) => {
- // Don't do anything if dragging flag is false
- if (isFullWindow || !isHandlerDragging) {
- return false;
- }
-
- // postLogMessage('mousemove');
-
- // Get offset
- const containerOffsetTop = document.body.offsetTop;
-
- // Get x-coordinate of pointer relative to container
- const pointerRelativeYpos = e.clientY - containerOffsetTop + window.scrollY;
-
- // Arbitrary minimum width set on box A, otherwise its inner content will collapse to width of 0
- const largePlotMinHeight = 60;
-
- // Resize large plot
- const newHeight = Math.max(largePlotMinHeight, pointerRelativeYpos - 5); // <- why 5?
- const newHeightString = `${newHeight}px`;
-
- if (largePlotDiv.style.height !== newHeightString) {
- largePlotDiv.style.height = newHeightString;
- postResizeMessage();
- }
-});
-
-window.onresize = () => postResizeMessage();
-
-document.addEventListener('mouseup', () => {
- // Turn off dragging flag when user mouse is up
- if (isHandlerDragging) {
- postResizeMessage(true);
- document.body.style.cursor = '';
- }
- handler.classList.remove('dragging');
- isHandlerDragging = false;
-});
diff --git a/html/httpgd/smallPlot.ejs b/html/httpgd/smallPlot.ejs
deleted file mode 100644
index 7d8337661..000000000
--- a/html/httpgd/smallPlot.ejs
+++ /dev/null
@@ -1,15 +0,0 @@
-
- <%- plot.data %>
-
-
- ✖
-
diff --git a/html/httpgd/style.css b/html/httpgd/style.css
deleted file mode 100644
index c7d30c9b4..000000000
--- a/html/httpgd/style.css
+++ /dev/null
@@ -1,136 +0,0 @@
-
-/* Use box-sizing: border-box everywhere: */
-html {
- box-sizing: border-box;
-}
-*, *::before, *::after {
- box-sizing: inherit;
-}
-
-/* General styling: */
-body {
- padding-left: 1px;
- padding-right: 1px;
-}
-body.fullWindow {
- overflow-x: hidden;
- overflow-y: hidden;
-}
-
-svg {
- user-select: none;
-}
-
-/* Stretch large plot during resizing, */
-/* Keep small plots the same size: */
-.httpgd {
- width: 100%;
- height: 100%;
-}
-
-/* Main plot area: */
-#largePlot {
- overflow-x: auto;
- overflow-y: hidden;
- padding: 10px;
- width: 100%;
- height: 100%;
-}
-body.fullWindow #largePlot {
- overflow-x: hidden;
- width: 100vw !important;
- height: 100vh !important;
-}
-
-/* Dragbar to resize main plot: */
-#handler {
- background-color: var(--vscode-textSeparator-foreground);
- height: 4px;
- cursor: ns-resize;
-}
-#handler:hover, #handler.dragging {
- background-color: var(--vscode-focusBorder);
- transition: background-color .1s ease-out;
- transition-delay: .2s;
-}
-body.fullWindow #handler {
- display: none;
-}
-
-#placeholder {
- height: 95vh;
-}
-body.fullWindow #placeHolder {
- display: none;
-}
-
-/* Plot history: */
-
-#smallPlots {
- display: flex;
- /* flex-direction: row; */
- position: relative;
- overflow-x: auto;
- flex-direction: row;
- overflow-y: hidden;
- padding: 10px;
-}
-body.fullWindow #smallPlots {
- display: none;
-}
-
-#smallPlots.multirow {
- overflow-x: hidden;
- flex-wrap: wrap;
-}
-
-#smallPlots.hidden {
- display: none;
-}
-
-/* Each small plot: */
-
-#smallPlots .wrapper {
- position: relative;
- height: 15vw;
- width: 19vw;
- flex: none;
- padding: 3px;
- padding-bottom: 12px;
-}
-
-a.focusPlot {
- height: 100%;
- width: 100%;
-}
-
-a.hidePlot {
- display: none;
- position: absolute;
- top: 0;
- right: 5px;
- text-decoration: none;
- font-size: 2em;
- user-select: none;
- color: var(--vscode-foreground);
-}
-
-.plotContent {
- height: 100%;
- width: 100%;
-}
-
-.smallPlot:not(.active):hover {
- background-color: var(--vscode-list-hoverBackground);
- background-clip: content-box;
-}
-
-/* Hide plot button: */
-#smallPlots .wrapper:hover a.hidePlot {
- display: block;
-}
-
-#smallPlots .wrapper a.hidePlot:hover {
- color: var(--vscode-errorForeground);
-}
-
diff --git a/html/httpgd/styleOverwrites.css b/html/httpgd/styleOverwrites.css
deleted file mode 100644
index 953ac5611..000000000
--- a/html/httpgd/styleOverwrites.css
+++ /dev/null
@@ -1,15 +0,0 @@
-
-
-.httpgd rect {
- stroke: none !important;
- fill: none !important;
-}
-
-svg text {
- font-family: var(--vscode-editor-font-family) !important;
- fill: var(--vscode-foreground) !important;
-}
-
-.httpgd line, .httpgd polyline, .httpgd polygon, .httpgd path, .httpgd circle, .httpgd rect:not(:first-of-type) {
- stroke: var(--vscode-foreground) !important;
-}
diff --git a/html/httpgd/vars.css b/html/httpgd/vars.css
deleted file mode 100644
index d20aad6b3..000000000
--- a/html/httpgd/vars.css
+++ /dev/null
@@ -1,470 +0,0 @@
-/* This file contains a list of variables available in a vscode webview and their values from the Dark+ theme */
-
-.fromvscode {
- --vscode-font-family: "Segoe WPC", "Segoe UI", sans-serif;
- --vscode-font-weight: normal;
- --vscode-font-size: 13px;
- --vscode-editor-font-family: Consolas, "Courier New", monospace;
- --vscode-editor-font-weight: normal;
- --vscode-editor-font-size: 14px;
- --vscode-foreground: #cccccc;
- --vscode-errorForeground: #f48771;
- --vscode-descriptionForeground: rgba(204, 204, 204, 0.7);
- --vscode-icon-foreground: #c5c5c5;
- --vscode-focusBorder: #007fd4;
- --vscode-textSeparator-foreground: rgba(255, 255, 255, 0.18);
- --vscode-textLink-foreground: #3794ff;
- --vscode-textLink-activeForeground: #3794ff;
- --vscode-textPreformat-foreground: #d7ba7d;
- --vscode-textBlockQuote-background: rgba(127, 127, 127, 0.1);
- --vscode-textBlockQuote-border: rgba(0, 122, 204, 0.5);
- --vscode-textCodeBlock-background: rgba(10, 10, 10, 0.4);
- --vscode-widget-shadow: rgba(0, 0, 0, 0.36);
- --vscode-input-background: #3c3c3c;
- --vscode-input-foreground: #cccccc;
- --vscode-inputOption-activeBorder: rgba(0, 122, 204, 0);
- --vscode-inputOption-activeBackground: rgba(0, 127, 212, 0.4);
- --vscode-inputOption-activeForeground: #ffffff;
- --vscode-input-placeholderForeground: #a6a6a6;
- --vscode-inputValidation-infoBackground: #063b49;
- --vscode-inputValidation-infoBorder: #007acc;
- --vscode-inputValidation-warningBackground: #352a05;
- --vscode-inputValidation-warningBorder: #b89500;
- --vscode-inputValidation-errorBackground: #5a1d1d;
- --vscode-inputValidation-errorBorder: #be1100;
- --vscode-dropdown-background: #3c3c3c;
- --vscode-dropdown-foreground: #f0f0f0;
- --vscode-dropdown-border: #3c3c3c;
- --vscode-checkbox-background: #3c3c3c;
- --vscode-checkbox-foreground: #f0f0f0;
- --vscode-checkbox-border: #3c3c3c;
- --vscode-button-foreground: #ffffff;
- --vscode-button-background: #0e639c;
- --vscode-button-hoverBackground: #1177bb;
- --vscode-button-secondaryForeground: #ffffff;
- --vscode-button-secondaryBackground: #3a3d41;
- --vscode-button-secondaryHoverBackground: #45494e;
- --vscode-badge-background: #4d4d4d;
- --vscode-badge-foreground: #ffffff;
- --vscode-scrollbar-shadow: #000000;
- --vscode-scrollbarSlider-background: rgba(121, 121, 121, 0.4);
- --vscode-scrollbarSlider-hoverBackground: rgba(100, 100, 100, 0.7);
- --vscode-scrollbarSlider-activeBackground: rgba(191, 191, 191, 0.4);
- --vscode-progressBar-background: #0e70c0;
- --vscode-editorError-foreground: #f48771;
- --vscode-editorWarning-foreground: #cca700;
- --vscode-editorInfo-foreground: #75beff;
- --vscode-editorHint-foreground: rgba(238, 238, 238, 0.7);
- --vscode-sash-hoverBorder: #007fd4;
- --vscode-editor-background: #1e1e1e;
- --vscode-editor-foreground: #d4d4d4;
- --vscode-editorWidget-background: #252526;
- --vscode-editorWidget-foreground: #cccccc;
- --vscode-editorWidget-border: #454545;
- --vscode-quickInput-background: #252526;
- --vscode-quickInput-foreground: #cccccc;
- --vscode-quickInputTitle-background: rgba(255, 255, 255, 0.1);
- --vscode-pickerGroup-foreground: #3794ff;
- --vscode-pickerGroup-border: #3f3f46;
- --vscode-editor-selectionBackground: #264f78;
- --vscode-editor-inactiveSelectionBackground: #3a3d41;
- --vscode-editor-selectionHighlightBackground: rgba(173, 214, 255, 0.15);
- --vscode-editor-findMatchBackground: #515c6a;
- --vscode-editor-findMatchHighlightBackground: rgba(234, 92, 0, 0.33);
- --vscode-editor-findRangeHighlightBackground: rgba(58, 61, 65, 0.4);
- --vscode-searchEditor-findMatchBackground: rgba(234, 92, 0, 0.22);
- --vscode-editor-hoverHighlightBackground: rgba(38, 79, 120, 0.25);
- --vscode-editorHoverWidget-background: #252526;
- --vscode-editorHoverWidget-foreground: #cccccc;
- --vscode-editorHoverWidget-border: #454545;
- --vscode-editorHoverWidget-statusBarBackground: #2c2c2d;
- --vscode-editorLink-activeForeground: #4e94ce;
- --vscode-editorInlineHint-foreground: #252526;
- --vscode-editorInlineHint-background: #cccccc;
- --vscode-editorLightBulb-foreground: #ffcc00;
- --vscode-editorLightBulbAutoFix-foreground: #75beff;
- --vscode-diffEditor-insertedTextBackground: rgba(155, 185, 85, 0.2);
- --vscode-diffEditor-removedTextBackground: rgba(255, 0, 0, 0.2);
- --vscode-diffEditor-diagonalFill: rgba(204, 204, 204, 0.2);
- --vscode-list-focusOutline: #007fd4;
- --vscode-list-activeSelectionBackground: #094771;
- --vscode-list-activeSelectionForeground: #ffffff;
- --vscode-list-inactiveSelectionBackground: #37373d;
- --vscode-list-hoverBackground: #2a2d2e;
- --vscode-list-dropBackground: #383b3d;
- --vscode-list-highlightForeground: #0097fb;
- --vscode-list-invalidItemForeground: #b89500;
- --vscode-list-errorForeground: #f88070;
- --vscode-list-warningForeground: #cca700;
- --vscode-listFilterWidget-background: #653723;
- --vscode-listFilterWidget-outline: rgba(0, 0, 0, 0);
- --vscode-listFilterWidget-noMatchesOutline: #be1100;
- --vscode-list-filterMatchBackground: rgba(234, 92, 0, 0.33);
- --vscode-tree-indentGuidesStroke: #585858;
- --vscode-tree-tableColumnsBorder: rgba(204, 204, 204, 0.13);
- --vscode-list-deemphasizedForeground: #8c8c8c;
- --vscode-quickInputList-focusBackground: #062f4a;
- --vscode-menu-foreground: #cccccc;
- --vscode-menu-background: #252526;
- --vscode-menu-selectionForeground: #ffffff;
- --vscode-menu-selectionBackground: #094771;
- --vscode-menu-separatorBackground: #bbbbbb;
- --vscode-toolbar-hoverBackground: rgba(90, 93, 94, 0.31);
- --vscode-toolbar-activeBackground: rgba(99, 102, 103, 0.31);
- --vscode-editor-snippetTabstopHighlightBackground: rgba(124, 124, 124, 0.3);
- --vscode-editor-snippetFinalTabstopHighlightBorder: #525252;
- --vscode-breadcrumb-foreground: rgba(204, 204, 204, 0.8);
- --vscode-breadcrumb-background: #1e1e1e;
- --vscode-breadcrumb-focusForeground: #e0e0e0;
- --vscode-breadcrumb-activeSelectionForeground: #e0e0e0;
- --vscode-breadcrumbPicker-background: #252526;
- --vscode-merge-currentHeaderBackground: rgba(64, 200, 174, 0.5);
- --vscode-merge-currentContentBackground: rgba(64, 200, 174, 0.2);
- --vscode-merge-incomingHeaderBackground: rgba(64, 166, 255, 0.5);
- --vscode-merge-incomingContentBackground: rgba(64, 166, 255, 0.2);
- --vscode-merge-commonHeaderBackground: rgba(96, 96, 96, 0.4);
- --vscode-merge-commonContentBackground: rgba(96, 96, 96, 0.16);
- --vscode-editorOverviewRuler-currentContentForeground: rgba(64, 200, 174, 0.5);
- --vscode-editorOverviewRuler-incomingContentForeground: rgba(64, 166, 255, 0.5);
- --vscode-editorOverviewRuler-commonContentForeground: rgba(96, 96, 96, 0.4);
- --vscode-editorOverviewRuler-findMatchForeground: rgba(209, 134, 22, 0.49);
- --vscode-editorOverviewRuler-selectionHighlightForeground: rgba(160, 160, 160, 0.8);
- --vscode-minimap-findMatchHighlight: #d18616;
- --vscode-minimap-selectionHighlight: #264f78;
- --vscode-minimap-errorHighlight: rgba(255, 18, 18, 0.7);
- --vscode-minimap-warningHighlight: #cca700;
- --vscode-minimapSlider-background: rgba(121, 121, 121, 0.2);
- --vscode-minimapSlider-hoverBackground: rgba(100, 100, 100, 0.35);
- --vscode-minimapSlider-activeBackground: rgba(191, 191, 191, 0.2);
- --vscode-problemsErrorIcon-foreground: #f48771;
- --vscode-problemsWarningIcon-foreground: #cca700;
- --vscode-problemsInfoIcon-foreground: #75beff;
- --vscode-charts-foreground: #cccccc;
- --vscode-charts-lines: rgba(204, 204, 204, 0.5);
- --vscode-charts-red: #f48771;
- --vscode-charts-blue: #75beff;
- --vscode-charts-yellow: #cca700;
- --vscode-charts-orange: #d18616;
- --vscode-charts-green: #89d185;
- --vscode-charts-purple: #b180d7;
- --vscode-editor-lineHighlightBorder: #282828;
- --vscode-editor-rangeHighlightBackground: rgba(255, 255, 255, 0.04);
- --vscode-editor-symbolHighlightBackground: rgba(234, 92, 0, 0.33);
- --vscode-editorCursor-foreground: #aeafad;
- --vscode-editorWhitespace-foreground: rgba(227, 228, 226, 0.16);
- --vscode-editorIndentGuide-background: #404040;
- --vscode-editorIndentGuide-activeBackground: #707070;
- --vscode-editorLineNumber-foreground: #858585;
- --vscode-editorActiveLineNumber-foreground: #c6c6c6;
- --vscode-editorLineNumber-activeForeground: #c6c6c6;
- --vscode-editorRuler-foreground: #5a5a5a;
- --vscode-editorCodeLens-foreground: #999999;
- --vscode-editorBracketMatch-background: rgba(0, 100, 0, 0.1);
- --vscode-editorBracketMatch-border: #888888;
- --vscode-editorOverviewRuler-border: rgba(127, 127, 127, 0.3);
- --vscode-editorGutter-background: #1e1e1e;
- --vscode-editorUnnecessaryCode-opacity: rgba(0, 0, 0, 0.67);
- --vscode-editorOverviewRuler-rangeHighlightForeground: rgba(0, 122, 204, 0.6);
- --vscode-editorOverviewRuler-errorForeground: rgba(255, 18, 18, 0.7);
- --vscode-editorOverviewRuler-warningForeground: #cca700;
- --vscode-editorOverviewRuler-infoForeground: #75beff;
- --vscode-symbolIcon-arrayForeground: #cccccc;
- --vscode-symbolIcon-booleanForeground: #cccccc;
- --vscode-symbolIcon-classForeground: #ee9d28;
- --vscode-symbolIcon-colorForeground: #cccccc;
- --vscode-symbolIcon-constantForeground: #cccccc;
- --vscode-symbolIcon-constructorForeground: #b180d7;
- --vscode-symbolIcon-enumeratorForeground: #ee9d28;
- --vscode-symbolIcon-enumeratorMemberForeground: #75beff;
- --vscode-symbolIcon-eventForeground: #ee9d28;
- --vscode-symbolIcon-fieldForeground: #75beff;
- --vscode-symbolIcon-fileForeground: #cccccc;
- --vscode-symbolIcon-folderForeground: #cccccc;
- --vscode-symbolIcon-functionForeground: #b180d7;
- --vscode-symbolIcon-interfaceForeground: #75beff;
- --vscode-symbolIcon-keyForeground: #cccccc;
- --vscode-symbolIcon-keywordForeground: #cccccc;
- --vscode-symbolIcon-methodForeground: #b180d7;
- --vscode-symbolIcon-moduleForeground: #cccccc;
- --vscode-symbolIcon-namespaceForeground: #cccccc;
- --vscode-symbolIcon-nullForeground: #cccccc;
- --vscode-symbolIcon-numberForeground: #cccccc;
- --vscode-symbolIcon-objectForeground: #cccccc;
- --vscode-symbolIcon-operatorForeground: #cccccc;
- --vscode-symbolIcon-packageForeground: #cccccc;
- --vscode-symbolIcon-propertyForeground: #cccccc;
- --vscode-symbolIcon-referenceForeground: #cccccc;
- --vscode-symbolIcon-snippetForeground: #cccccc;
- --vscode-symbolIcon-stringForeground: #cccccc;
- --vscode-symbolIcon-structForeground: #cccccc;
- --vscode-symbolIcon-textForeground: #cccccc;
- --vscode-symbolIcon-typeParameterForeground: #cccccc;
- --vscode-symbolIcon-unitForeground: #cccccc;
- --vscode-symbolIcon-variableForeground: #75beff;
- --vscode-editorOverviewRuler-bracketMatchForeground: #a0a0a0;
- --vscode-editor-linkedEditingBackground: rgba(255, 0, 0, 0.3);
- --vscode-editor-wordHighlightBackground: rgba(87, 87, 87, 0.72);
- --vscode-editor-wordHighlightStrongBackground: rgba(0, 73, 114, 0.72);
- --vscode-editorOverviewRuler-wordHighlightForeground: rgba(160, 160, 160, 0.8);
- --vscode-editorOverviewRuler-wordHighlightStrongForeground: rgba(192, 160, 192, 0.8);
- --vscode-editor-foldBackground: rgba(38, 79, 120, 0.3);
- --vscode-editorGutter-foldingControlForeground: #c5c5c5;
- --vscode-peekViewTitle-background: #1e1e1e;
- --vscode-peekViewTitleLabel-foreground: #ffffff;
- --vscode-peekViewTitleDescription-foreground: rgba(204, 204, 204, 0.7);
- --vscode-peekView-border: #007acc;
- --vscode-peekViewResult-background: #252526;
- --vscode-peekViewResult-lineForeground: #bbbbbb;
- --vscode-peekViewResult-fileForeground: #ffffff;
- --vscode-peekViewResult-selectionBackground: rgba(51, 153, 255, 0.2);
- --vscode-peekViewResult-selectionForeground: #ffffff;
- --vscode-peekViewEditor-background: #001f33;
- --vscode-peekViewEditorGutter-background: #001f33;
- --vscode-peekViewResult-matchHighlightBackground: rgba(234, 92, 0, 0.3);
- --vscode-peekViewEditor-matchHighlightBackground: rgba(255, 143, 0, 0.6);
- --vscode-editorMarkerNavigationError-background: #f48771;
- --vscode-editorMarkerNavigationWarning-background: #cca700;
- --vscode-editorMarkerNavigationInfo-background: #75beff;
- --vscode-editorMarkerNavigation-background: #2d2d30;
- --vscode-editorSuggestWidget-background: #252526;
- --vscode-editorSuggestWidget-border: #454545;
- --vscode-editorSuggestWidget-foreground: #d4d4d4;
- --vscode-editorSuggestWidget-selectedBackground: #062f4a;
- --vscode-editorSuggestWidget-highlightForeground: #0097fb;
- --vscode-tab-activeBackground: #1e1e1e;
- --vscode-tab-unfocusedActiveBackground: #1e1e1e;
- --vscode-tab-inactiveBackground: #2d2d2d;
- --vscode-tab-unfocusedInactiveBackground: #2d2d2d;
- --vscode-tab-activeForeground: #ffffff;
- --vscode-tab-inactiveForeground: rgba(255, 255, 255, 0.5);
- --vscode-tab-unfocusedActiveForeground: rgba(255, 255, 255, 0.5);
- --vscode-tab-unfocusedInactiveForeground: rgba(255, 255, 255, 0.25);
- --vscode-tab-border: #252526;
- --vscode-tab-lastPinnedBorder: rgba(204, 204, 204, 0.2);
- --vscode-tab-activeModifiedBorder: #3399cc;
- --vscode-tab-inactiveModifiedBorder: rgba(51, 153, 204, 0.5);
- --vscode-tab-unfocusedActiveModifiedBorder: rgba(51, 153, 204, 0.5);
- --vscode-tab-unfocusedInactiveModifiedBorder: rgba(51, 153, 204, 0.25);
- --vscode-editorPane-background: #1e1e1e;
- --vscode-editorGroupHeader-tabsBackground: #252526;
- --vscode-editorGroupHeader-noTabsBackground: #1e1e1e;
- --vscode-editorGroup-border: #444444;
- --vscode-editorGroup-dropBackground: rgba(83, 89, 93, 0.5);
- --vscode-imagePreview-border: rgba(128, 128, 128, 0.35);
- --vscode-panel-background: #1e1e1e;
- --vscode-panel-border: rgba(128, 128, 128, 0.35);
- --vscode-panelTitle-activeForeground: #e7e7e7;
- --vscode-panelTitle-inactiveForeground: rgba(231, 231, 231, 0.6);
- --vscode-panelTitle-activeBorder: #e7e7e7;
- --vscode-panel-dropBorder: #e7e7e7;
- --vscode-panelSection-dropBackground: rgba(83, 89, 93, 0.5);
- --vscode-panelSectionHeader-background: rgba(128, 128, 128, 0.2);
- --vscode-panelSection-border: rgba(128, 128, 128, 0.35);
- --vscode-statusBar-foreground: #ffffff;
- --vscode-statusBar-noFolderForeground: #ffffff;
- --vscode-statusBar-background: #007acc;
- --vscode-statusBar-noFolderBackground: #68217a;
- --vscode-statusBarItem-activeBackground: rgba(255, 255, 255, 0.18);
- --vscode-statusBarItem-hoverBackground: rgba(255, 255, 255, 0.12);
- --vscode-statusBarItem-prominentForeground: #ffffff;
- --vscode-statusBarItem-prominentBackground: rgba(0, 0, 0, 0.5);
- --vscode-statusBarItem-prominentHoverBackground: rgba(0, 0, 0, 0.3);
- --vscode-statusBarItem-errorBackground: #c72e0f;
- --vscode-statusBarItem-errorForeground: #ffffff;
- --vscode-activityBar-background: #333333;
- --vscode-activityBar-foreground: #ffffff;
- --vscode-activityBar-inactiveForeground: rgba(255, 255, 255, 0.4);
- --vscode-activityBar-activeBorder: #ffffff;
- --vscode-activityBar-dropBorder: #ffffff;
- --vscode-activityBarBadge-background: #007acc;
- --vscode-activityBarBadge-foreground: #ffffff;
- --vscode-statusBarItem-remoteBackground: #16825d;
- --vscode-statusBarItem-remoteForeground: #ffffff;
- --vscode-extensionBadge-remoteBackground: #007acc;
- --vscode-extensionBadge-remoteForeground: #ffffff;
- --vscode-sideBar-background: #252526;
- --vscode-sideBarTitle-foreground: #bbbbbb;
- --vscode-sideBar-dropBackground: rgba(83, 89, 93, 0.5);
- --vscode-sideBarSectionHeader-background: rgba(0, 0, 0, 0);
- --vscode-sideBarSectionHeader-border: rgba(204, 204, 204, 0.2);
- --vscode-titleBar-activeForeground: #cccccc;
- --vscode-titleBar-inactiveForeground: rgba(204, 204, 204, 0.6);
- --vscode-titleBar-activeBackground: #3c3c3c;
- --vscode-titleBar-inactiveBackground: rgba(60, 60, 60, 0.6);
- --vscode-menubar-selectionForeground: #cccccc;
- --vscode-menubar-selectionBackground: rgba(255, 255, 255, 0.1);
- --vscode-notifications-foreground: #cccccc;
- --vscode-notifications-background: #252526;
- --vscode-notificationLink-foreground: #3794ff;
- --vscode-notificationCenterHeader-background: #303031;
- --vscode-notifications-border: #303031;
- --vscode-notificationsErrorIcon-foreground: #f48771;
- --vscode-notificationsWarningIcon-foreground: #cca700;
- --vscode-notificationsInfoIcon-foreground: #75beff;
- --vscode-editorGutter-commentRangeForeground: #c5c5c5;
- --vscode-debugToolBar-background: #333333;
- --vscode-debugIcon-startForeground: #89d185;
- --vscode-settings-headerForeground: #e7e7e7;
- --vscode-settings-modifiedItemIndicator: #0c7d9d;
- --vscode-settings-dropdownBackground: #3c3c3c;
- --vscode-settings-dropdownForeground: #f0f0f0;
- --vscode-settings-dropdownBorder: #3c3c3c;
- --vscode-settings-dropdownListBorder: #454545;
- --vscode-settings-checkboxBackground: #3c3c3c;
- --vscode-settings-checkboxForeground: #f0f0f0;
- --vscode-settings-checkboxBorder: #3c3c3c;
- --vscode-settings-textInputBackground: #3c3c3c;
- --vscode-settings-textInputForeground: #cccccc;
- --vscode-settings-numberInputBackground: #3c3c3c;
- --vscode-settings-numberInputForeground: #cccccc;
- --vscode-settings-focusedRowBackground: rgba(128, 128, 128, 0.14);
- --vscode-notebook-rowHoverBackground: rgba(128, 128, 128, 0.07);
- --vscode-notebook-focusedRowBorder: rgba(255, 255, 255, 0.12);
- --vscode-terminal-foreground: #cccccc;
- --vscode-terminal-selectionBackground: rgba(255, 255, 255, 0.25);
- --vscode-terminal-border: rgba(128, 128, 128, 0.35);
- --vscode-testing-iconFailed: #f14c4c;
- --vscode-testing-iconErrored: #f14c4c;
- --vscode-testing-iconPassed: #73c991;
- --vscode-testing-runAction: #73c991;
- --vscode-testing-iconQueued: #cca700;
- --vscode-testing-iconUnset: #848484;
- --vscode-testing-iconSkipped: #848484;
- --vscode-testing-peekBorder: #f48771;
- --vscode-testing-message\.error\.decorationForeground: #f48771;
- --vscode-testing-message\.error\.lineBackground: rgba(255, 0, 0, 0.2);
- --vscode-testing-message\.warning\.decorationForeground: #cca700;
- --vscode-testing-message\.warning\.lineBackground: rgba(255, 208, 0, 0.2);
- --vscode-testing-message\.info\.decorationForeground: #75beff;
- --vscode-testing-message\.info\.lineBackground: rgba(0, 127, 255, 0.2);
- --vscode-testing-message\.hint\.decorationForeground: rgba(238, 238, 238, 0.7);
- --vscode-welcomePage-tileBackground: #252526;
- --vscode-welcomePage-tileHoverBackground: #2c2c2d;
- --vscode-welcomePage-tileShadow\.: rgba(0, 0, 0, 0.36);
- --vscode-welcomePage-progress\.background: #3c3c3c;
- --vscode-welcomePage-progress\.foreground: #3794ff;
- --vscode-workspaceTrust-trustedForegound: #89d185;
- --vscode-workspaceTrust-untrustedForeground: #f48771;
- --vscode-workspaceTrust-tileBackground: #252526;
- --vscode-statusBar-debuggingBackground: #cc6633;
- --vscode-statusBar-debuggingForeground: #ffffff;
- --vscode-debugExceptionWidget-border: #a31515;
- --vscode-debugExceptionWidget-background: #420b0d;
- --vscode-editorGutter-modifiedBackground: #0c7d9d;
- --vscode-editorGutter-addedBackground: #587c0c;
- --vscode-editorGutter-deletedBackground: #94151b;
- --vscode-minimapGutter-modifiedBackground: #0c7d9d;
- --vscode-minimapGutter-addedBackground: #587c0c;
- --vscode-minimapGutter-deletedBackground: #94151b;
- --vscode-editorOverviewRuler-modifiedForeground: rgba(12, 125, 157, 0.6);
- --vscode-editorOverviewRuler-addedForeground: rgba(88, 124, 12, 0.6);
- --vscode-editorOverviewRuler-deletedForeground: rgba(148, 21, 27, 0.6);
- --vscode-notebook-cellBorderColor: #37373d;
- --vscode-notebook-focusedEditorBorder: #007fd4;
- --vscode-notebookStatusSuccessIcon-foreground: #89d185;
- --vscode-notebookStatusErrorIcon-foreground: #f48771;
- --vscode-notebookStatusRunningIcon-foreground: #cccccc;
- --vscode-notebook-outputContainerBackgroundColor: #37373d;
- --vscode-notebook-cellToolbarSeparator: rgba(128, 128, 128, 0.35);
- --vscode-notebook-selectedCellBackground: #37373d;
- --vscode-notebook-selectedCellBorder: #37373d;
- --vscode-notebook-focusedCellBorder: #007fd4;
- --vscode-notebook-inactiveFocusedCellBorder: #37373d;
- --vscode-notebook-cellStatusBarItemHoverBackground: rgba(255, 255, 255, 0.15);
- --vscode-notebook-cellInsertionIndicator: #007fd4;
- --vscode-notebookScrollbarSlider-background: rgba(121, 121, 121, 0.4);
- --vscode-notebookScrollbarSlider-hoverBackground: rgba(100, 100, 100, 0.7);
- --vscode-notebookScrollbarSlider-activeBackground: rgba(191, 191, 191, 0.4);
- --vscode-notebook-symbolHighlightBackground: rgba(255, 255, 255, 0.04);
- --vscode-editor-stackFrameHighlightBackground: rgba(255, 255, 0, 0.2);
- --vscode-editor-focusedStackFrameHighlightBackground: rgba(122, 189, 122, 0.3);
- --vscode-debugIcon-breakpointForeground: #e51400;
- --vscode-debugIcon-breakpointDisabledForeground: #848484;
- --vscode-debugIcon-breakpointUnverifiedForeground: #848484;
- --vscode-debugIcon-breakpointCurrentStackframeForeground: #ffcc00;
- --vscode-debugIcon-breakpointStackframeForeground: #89d185;
- --vscode-scm-providerBorder: #454545;
- --vscode-extensionButton-prominentBackground: #0e639c;
- --vscode-extensionButton-prominentForeground: #ffffff;
- --vscode-extensionButton-prominentHoverBackground: #1177bb;
- --vscode-extensionIcon-starForeground: #ff8e00;
- --vscode-terminal-ansiBlack: #000000;
- --vscode-terminal-ansiRed: #cd3131;
- --vscode-terminal-ansiGreen: #0dbc79;
- --vscode-terminal-ansiYellow: #e5e510;
- --vscode-terminal-ansiBlue: #2472c8;
- --vscode-terminal-ansiMagenta: #bc3fbc;
- --vscode-terminal-ansiCyan: #11a8cd;
- --vscode-terminal-ansiWhite: #e5e5e5;
- --vscode-terminal-ansiBrightBlack: #666666;
- --vscode-terminal-ansiBrightRed: #f14c4c;
- --vscode-terminal-ansiBrightGreen: #23d18b;
- --vscode-terminal-ansiBrightYellow: #f5f543;
- --vscode-terminal-ansiBrightBlue: #3b8eea;
- --vscode-terminal-ansiBrightMagenta: #d670d6;
- --vscode-terminal-ansiBrightCyan: #29b8db;
- --vscode-terminal-ansiBrightWhite: #e5e5e5;
- --vscode-debugTokenExpression-name: #c586c0;
- --vscode-debugTokenExpression-value: rgba(204, 204, 204, 0.6);
- --vscode-debugTokenExpression-string: #ce9178;
- --vscode-debugTokenExpression-boolean: #4e94ce;
- --vscode-debugTokenExpression-number: #b5cea8;
- --vscode-debugTokenExpression-error: #f48771;
- --vscode-debugView-exceptionLabelForeground: #cccccc;
- --vscode-debugView-exceptionLabelBackground: #6c2022;
- --vscode-debugView-stateLabelForeground: #cccccc;
- --vscode-debugView-stateLabelBackground: rgba(136, 136, 136, 0.27);
- --vscode-debugView-valueChangedHighlight: #569cd6;
- --vscode-debugConsole-infoForeground: #75beff;
- --vscode-debugConsole-warningForeground: #cca700;
- --vscode-debugConsole-errorForeground: #f48771;
- --vscode-debugConsole-sourceForeground: #cccccc;
- --vscode-debugConsoleInputIcon-foreground: #cccccc;
- --vscode-debugIcon-pauseForeground: #75beff;
- --vscode-debugIcon-stopForeground: #f48771;
- --vscode-debugIcon-disconnectForeground: #f48771;
- --vscode-debugIcon-restartForeground: #89d185;
- --vscode-debugIcon-stepOverForeground: #75beff;
- --vscode-debugIcon-stepIntoForeground: #75beff;
- --vscode-debugIcon-stepOutForeground: #75beff;
- --vscode-debugIcon-continueForeground: #75beff;
- --vscode-debugIcon-stepBackForeground: #75beff;
- --vscode-gitDecoration-addedResourceForeground: #81b88b;
- --vscode-gitDecoration-modifiedResourceForeground: #e2c08d;
- --vscode-gitDecoration-deletedResourceForeground: #c74e39;
- --vscode-gitDecoration-renamedResourceForeground: #73c991;
- --vscode-gitDecoration-untrackedResourceForeground: #73c991;
- --vscode-gitDecoration-ignoredResourceForeground: #8c8c8c;
- --vscode-gitDecoration-stageModifiedResourceForeground: #e2c08d;
- --vscode-gitDecoration-stageDeletedResourceForeground: #c74e39;
- --vscode-gitDecoration-conflictingResourceForeground: #e4676b;
- --vscode-gitDecoration-submoduleResourceForeground: #8db9e2;
- --vscode-bookmarks-lineBackground: rgba(0, 0, 0, 0);
- --vscode-bookmarks-lineBorder: rgba(0, 0, 0, 0);
- --vscode-bookmarks-overviewRuler: rgba(21, 126, 251, 0.53);
- --vscode-gitlens-gutterBackgroundColor: rgba(255, 255, 255, 0.07);
- --vscode-gitlens-gutterForegroundColor: #bebebe;
- --vscode-gitlens-gutterUncommittedForegroundColor: rgba(0, 188, 242, 0.6);
- --vscode-gitlens-trailingLineBackgroundColor: rgba(0, 0, 0, 0);
- --vscode-gitlens-trailingLineForegroundColor: rgba(153, 153, 153, 0.35);
- --vscode-gitlens-lineHighlightBackgroundColor: rgba(0, 188, 242, 0.2);
- --vscode-gitlens-lineHighlightOverviewRulerColor: rgba(0, 188, 242, 0.6);
- --vscode-gitlens-closedPullRequestIconColor: #f85149;
- --vscode-gitlens-openPullRequestIconColor: #56d364;
- --vscode-gitlens-mergedPullRequestIconColor: #995dff;
- --vscode-gitlens-unpushlishedChangesIconColor: #35b15e;
- --vscode-gitlens-unpublishedCommitIconColor: #35b15e;
- --vscode-gitlens-unpulledChangesIconColor: #b15e35;
- --vscode-gitlens-decorations\.addedForegroundColor: #81b88b;
- --vscode-gitlens-decorations\.copiedForegroundColor: #73c991;
- --vscode-gitlens-decorations\.deletedForegroundColor: #c74e39;
- --vscode-gitlens-decorations\.ignoredForegroundColor: #8c8c8c;
- --vscode-gitlens-decorations\.modifiedForegroundColor: #e2c08d;
- --vscode-gitlens-decorations\.untrackedForegroundColor: #73c991;
- --vscode-gitlens-decorations\.renamedForegroundColor: #73c991;
- --vscode-gitlens-decorations\.branchAheadForegroundColor: #35b15e;
- --vscode-gitlens-decorations\.branchBehindForegroundColor: #b15e35;
- --vscode-gitlens-decorations\.branchDivergedForegroundColor: #d8af1b;
- --vscode-gitlens-decorations\.branchUnpublishedForegroundColor: #35b15e;
- --vscode-gitlens-decorations\.branchMissingUpstreamForegroundColor: #c74e39;
-}
diff --git a/html/httpgd/webviewMessages.d.ts b/html/httpgd/webviewMessages.d.ts
deleted file mode 100644
index 9f383df7a..000000000
--- a/html/httpgd/webviewMessages.d.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-/* eslint-disable @typescript-eslint/no-explicit-any */
-
-interface VsCode {
- postMessage: (msg: OutMessage) => void;
- setState: (state: string) => void;
-}
-
-interface IMessage {
- message: string;
-}
-interface ResizeMessage extends IMessage {
- message: 'resize';
- height: number;
- width: number;
- userTriggered: boolean;
-}
-interface LogMessage extends IMessage {
- message: 'log';
- body: any;
-}
-
-type OutMessage = ResizeMessage | LogMessage;
-
-interface UpdatePlotMessage extends IMessage {
- message: 'updatePlot';
- svg: string;
- plotId: string;
-}
-
-interface FocusPlotMessage extends IMessage {
- message: 'focusPlot';
- plotId: string;
-}
-
-interface ToggleStyleMessage extends IMessage {
- message: 'toggleStyle';
- useOverwrites: boolean;
-}
-
-interface ToggleFullWindowMessage extends IMessage {
- message: 'toggleFullWindow';
- useFullWindow: boolean;
-}
-
-type PreviewPlotLayout = 'multirow' | 'scroll' | 'hidden';
-interface PreviewPlotLayoutMessage extends IMessage {
- message: 'togglePreviewPlotLayout';
- style: PreviewPlotLayout;
-}
-
-interface HidePlotMessage extends IMessage {
- message: 'hidePlot';
- plotId: string;
-}
-
-interface AddPlotMessage extends IMessage {
- message: 'addPlot';
- html: string;
-}
-
-type InMessage =
- | UpdatePlotMessage
- | FocusPlotMessage
- | ToggleStyleMessage
- | HidePlotMessage
- | AddPlotMessage
- | PreviewPlotLayoutMessage
- | ToggleFullWindowMessage;
diff --git a/sess/DESCRIPTION b/sess/DESCRIPTION
index 20ab22572..b3a60c624 100644
--- a/sess/DESCRIPTION
+++ b/sess/DESCRIPTION
@@ -1,7 +1,7 @@
Package: sess
Type: Package
Title: High-Performance IPC Bridge for R Sessions
-Version: 3.4.0
+Version: 3.4.0.9005
Authors@R: c(
person(given = "Randy",
family = "Lai",
diff --git a/sess/R/hooks.R b/sess/R/hooks.R
index 7d3e65381..373fcc7f3 100644
--- a/sess/R/hooks.R
+++ b/sess/R/hooks.R
@@ -10,6 +10,27 @@
"standard"
}
+.install_httpgd_device_hook <- function() {
+ open_device <- httpgd::hgd
+ open <- function(owned, ...) {
+ result <- open_device(...)
+ if (owned) {
+ .runtime_track_device()
+ } else {
+ # Explicit devices belong to the caller. Retire any factory registration
+ # whose device number was reused, without taking ownership of the new one.
+ id <- unname(grDevices::dev.cur())
+ state <- .runtime_state()
+ state$devices <- Filter(function(entry) entry$id != id, state$devices)
+ }
+ # Register before notifying: a failed write may stop the runtime immediately.
+ notify_client("httpgd", list(url = httpgd::hgd_url()))
+ invisible(result)
+ }
+ .runtime_rebind("hgd", function(...) open(FALSE, ...), ns = "httpgd")
+ function(...) open(TRUE, silent = TRUE)
+}
+
# Send runtime notifications after task callbacks return, so transport failure
# cannot remove the currently executing task callback during runtime cleanup.
.defer_runtime_notification <- function(method, schedule = later::later) {
@@ -50,7 +71,11 @@ runtime_start <- function(use_rstudioapi = TRUE,
.sess_env$runtime_start_phase <- "previous-runtime-stop"
runtime_stop()
}
+ if (isTRUE(state$preserved_plots) && !identical(state$plot_backend, plot_backend)) {
+ runtime_stop()
+ }
state <- .runtime_state()
+ state$plot_backend <- plot_backend
state$active <- TRUE
completed <- FALSE
on.exit({
@@ -227,43 +252,101 @@ runtime_start <- function(use_rstudioapi = TRUE,
package_version(getNamespaceVersion("httpgd")) >= "2.0.1"
selected_backend <- .select_plot_backend(plot_backend, has_httpgd, has_jgd)
if (selected_backend == "jgd") {
- .runtime_set_option("device", function(...) {
- jgd::jgd()
+ open_device <- jgd::jgd
+ .runtime_rebind("jgd", function(...) {
+ result <- open_device(...)
+ # Windows defers the renderer welcome until server_info is requested.
+ # Cache it while connected so explicit devices can be identified on reload.
+ tryCatch(jgd::jgd_server_info(), error = function(e) NULL)
+ invisible(result)
+ }, "jgd")
+ open_jgd_device <- function(key = NULL, recorded = NULL, previous_device_id = NULL, ...) {
+ jgd::jgd(...)
.runtime_track_device()
- })
-
- # On reattach (e.g. after a VS Code window reload) the renderer starts a new
- # socket, but any jgd device opened before the reload is still bound to the
- # old, now-dead socket. jgd::jgd() only reads JGD_SOCKET at device-creation
- # time, so the stale device never reconnects and plots silently go nowhere.
- # Reopen it against the new socket, replaying the current plot if possible.
- reconnect_jgd_device <- function() {
- devs <- grDevices::dev.list()
- if (is.null(devs) || !"jgd" %in% names(devs)) {
- return(invisible(FALSE))
+ if (is.null(key)) {
+ .sess_env$plot_device_sequence <- (.sess_env$plot_device_sequence %||% 0L) + 1L
+ key <- as.character(.sess_env$plot_device_sequence)
}
- grDevices::dev.set(devs[names(devs) == "jgd"][[1]])
- recorded <- tryCatch(grDevices::recordPlot(), error = function(e) NULL)
- tryCatch(grDevices::dev.off(), error = function(e) NULL)
- before_reopen <- grDevices::dev.list()
- tryCatch(jgd::jgd(), error = function(e) NULL)
- after_reopen <- grDevices::dev.list()
- if (!is.null(after_reopen)) {
- opened <- if (is.null(before_reopen)) after_reopen else setdiff(after_reopen, before_reopen)
- if (length(opened)) .runtime_track_device(opened[[1L]])
+ devices <- .runtime_state()$devices
+ devices[[length(devices)]]$key <- key
+ devices[[length(devices)]]$renderer_device_id <- unname(
+ jgd::jgd_server_info()$server_info["deviceId"]
+ )
+ state$devices <- devices
+ # Carry the logical device identity across a new renderer socket. Device
+ # numbers and JGD's native counter can change when the device is reopened.
+ set_identity <- function(restoring = FALSE) {
+ jgd::jgd_frame_ext(jsonlite::toJSON(list(vscodeR = list(
+ sessionId = .session_id(), host = unname(Sys.info()[["nodename"]]),
+ pid = as.character(Sys.getpid()),
+ rVer = sprintf("%s.%s", R.version$major, R.version$minor),
+ processExited = FALSE, deviceId = key, restoring = restoring,
+ previousDeviceId = previous_device_id
+ )), auto_unbox = TRUE))
}
+ set_identity(!is.null(recorded))
if (!is.null(recorded)) {
tryCatch(grDevices::replayPlot(recorded), error = function(e) NULL)
+ set_identity()
}
- invisible(TRUE)
}
- reconnect_jgd_device()
+ .runtime_set_option("device", function(...) open_jgd_device())
+
+ # The R process survives reload, but JGD's renderer socket does not. Reopen
+ # each retained device and replay its current plot with the same logical ID.
+ previous <- grDevices::dev.cur()
+ retained <- .runtime_state()$devices
+ # Explicit jgd::jgd() calls bypass our device factory. Recover live devices
+ # belonging to VS Code too, leaving other renderers' devices untouched.
+ devs <- grDevices::dev.list()
+ for (id in devs[names(devs) == "jgd"]) {
+ grDevices::dev.set(id)
+ info <- tryCatch(jgd::jgd_server_info(), error = function(e) NULL)
+ previous_device_id <- unname(info$server_info["deviceId"])
+ if (length(previous_device_id) != 1L || is.na(previous_device_id) ||
+ !nzchar(previous_device_id)) {
+ previous_device_id <- NULL
+ }
+ # An explicit jgd() can reuse a managed device's number without calling
+ # our factory. Only the renderer's per-connection ID proves continuity.
+ same_device <- vapply(retained, function(entry) {
+ entry$id == id && !is.null(previous_device_id) &&
+ identical(entry$renderer_device_id, previous_device_id)
+ }, logical(1))
+ if (any(same_device)) next
+ retained <- Filter(function(entry) entry$id != id, retained)
+ if (identical(info$server_name, "jgd-vscode")) {
+ # JGD retains the renderer welcome even after its socket disconnects.
+ # Carry its original identity so reconnect preserves the saved history.
+ retained[[length(retained) + 1L]] <- list(
+ id = unname(id), name = "jgd", previous_device_id = previous_device_id
+ )
+ }
+ }
+ state$devices <- list()
+ for (entry in retained) {
+ devs <- grDevices::dev.list()
+ index <- match(entry$id, devs)
+ if (is.na(index) || !identical(names(devs)[[index]], entry$name)) next
+ if (!identical(entry$name, "jgd")) {
+ .runtime_track_device(entry$id)
+ next
+ }
+ grDevices::dev.set(entry$id)
+ # Capture the live size and resolution before closing this device. Explicit
+ # jgd() calls may use a different DPI, and the renderer may have resized it.
+ size <- grDevices::dev.size("in")
+ dpi <- grDevices::dev.size("px")[[1L]] / size[[1L]]
+ recorded <- tryCatch(grDevices::recordPlot(), error = function(e) NULL)
+ tryCatch(grDevices::dev.off(entry$id), error = function(e) NULL)
+ open_jgd_device(entry$key, recorded, entry$previous_device_id,
+ width = size[[1L]], height = size[[2L]], dpi = dpi)
+ if (identical(unname(previous), entry$id)) previous <- grDevices::dev.cur()
+ }
+ if (previous %in% grDevices::dev.list()) grDevices::dev.set(previous)
} else if (selected_backend == "httpgd") {
- .runtime_set_option("device", function(...) {
- httpgd::hgd(silent = TRUE)
- .runtime_track_device()
- notify_client("httpgd", list(url = httpgd::hgd_url()))
- })
+ # Notify for both explicit and default devices; own only factory devices.
+ .runtime_set_option("device", .install_httpgd_device_hook())
} else if (selected_backend == "standard") {
# If a specific interactive backend was explicitly requested but is
# unavailable, warn before silently degrading to the standard viewer.
@@ -315,7 +398,7 @@ runtime_start <- function(use_rstudioapi = TRUE,
grDevices::dev.control(displaylist = "enable")
})
- update_plot <- function(...) {
+ update_plot <- function(..., notify = TRUE) {
tryCatch(
{
if (check_null_dev()) {
@@ -328,7 +411,7 @@ runtime_start <- function(use_rstudioapi = TRUE,
plot_updated <<- FALSE
last_plot_record_length <<- curr_length
.runtime_set_field("latest_plot_record", record)
- .defer_runtime_notification("plot_updated")
+ if (notify) .defer_runtime_notification("plot_updated")
}
}
}
@@ -343,7 +426,8 @@ runtime_start <- function(use_rstudioapi = TRUE,
.runtime_set_hook("plot.new", new_plot, "replace")
.runtime_set_hook("grid.newpage", new_plot, "replace")
- update_plot()
+ # Reattach refreshes the record without reopening a viewer the user closed.
+ update_plot(notify = FALSE)
.runtime_add_task_callback(function(...) {
update_plot(...)
}, name = "sess.plot")
@@ -370,5 +454,19 @@ runtime_start <- function(use_rstudioapi = TRUE,
.runtime_add_task_callback(.workspace_update_task_callback, name = "sess.workspace")
completed <- TRUE
+ # Announce retained plots only after setup is complete: a failed write
+ # can stop the runtime, so no callbacks should be installed after this point.
+ if (selected_backend == "standard" && !is.null(.sess_env$latest_plot_record)) {
+ notify_client("plot_updated", list(restoring = TRUE))
+ } else if (selected_backend == "httpgd") {
+ # Discover live servers without adopting their devices. A plain unigd
+ # device has no HTTP server and must not prevent the others restoring.
+ devs <- grDevices::dev.list()
+ for (id in devs[names(devs) %in% c("httpgd", "unigd")]) {
+ url <- tryCatch(httpgd::hgd_url(which = id), error = function(e) NULL)
+ if (!is.null(url)) notify_client("httpgd", list(url = url, restoring = TRUE))
+ if (!isTRUE(state$active)) break
+ }
+ }
invisible(NULL)
}
diff --git a/sess/R/runtime.R b/sess/R/runtime.R
index c62eb22fd..9d739d8c1 100644
--- a/sess/R/runtime.R
+++ b/sess/R/runtime.R
@@ -179,6 +179,10 @@
if (is.null(devices)) return(invisible(NULL))
name <- names(devices)[match(id, devices)]
if (length(name) && !is.na(name)) {
+ # A newly opened device can reuse a number from dev.off(). Retire that
+ # registration so its logical plot identity cannot be replayed onto the
+ # replacement device during reconnect.
+ state$devices <- Filter(function(entry) entry$id != unname(id), state$devices)
state$devices[[length(state$devices) + 1L]] <- list(
id = unname(id),
name = name
@@ -187,7 +191,7 @@
invisible(id)
}
-.runtime_restore <- function() {
+.runtime_restore <- function(preserve_plots = FALSE) {
state <- .runtime_state()
for (name in rev(state$task_callbacks)) {
@@ -255,16 +259,17 @@
# Close devices opened through sess's device factory so plotting resumes on
# the device R selects after the runtime-owned device is removed.
- for (entry in rev(state$devices)) {
+ for (entry in if (preserve_plots) list() else rev(state$devices)) {
devices <- grDevices::dev.list()
index <- if (is.null(devices)) NA_integer_ else match(entry$id, devices)
if (!is.na(index) && identical(names(devices)[[index]], entry$name)) {
try(grDevices::dev.off(which = entry$id), silent = TRUE)
}
}
- state$devices <- list()
+ if (!preserve_plots) state$devices <- list()
for (entry in rev(state$fields)) {
+ if (preserve_plots && identical(entry$name, "latest_plot_record")) next
exists_current <- exists(entry$name, envir = .sess_env, inherits = FALSE)
current <- if (exists_current) get(entry$name, envir = .sess_env, inherits = FALSE) else NULL
if (identical(exists_current, entry$installed_exists) &&
@@ -276,15 +281,25 @@
}
}
}
- state$fields <- list()
+ state$fields <- if (preserve_plots) {
+ Filter(function(entry) identical(entry$name, "latest_plot_record"), state$fields)
+ } else {
+ list()
+ }
for (name in names(state$options)) {
+ if (preserve_plots && identical(name, "sess.null_dev")) next
entry <- state$options[[name]]
if (identical(getOption(name), entry$installed)) {
try(do.call(options, .runtime_named_value(name, entry$original)), silent = TRUE)
}
}
- state$options <- list()
+ state$options <- if (preserve_plots && "sess.null_dev" %in% names(state$options)) {
+ state$options["sess.null_dev"]
+ } else {
+ list()
+ }
+ state$preserved_plots <- preserve_plots
.runtime_clear_viewer_state()
state$active <- FALSE
invisible(NULL)
@@ -293,8 +308,10 @@
#' Stop the VS Code runtime integration (internal)
#'
#' Removes runtime callbacks and restores R state installed by runtime_start().
+#' @param preserve_plots Keep live graphics devices and the latest plot while
+#' reconnecting the same R process after an editor reload.
#' @keywords internal
-runtime_stop <- function() {
+runtime_stop <- function(preserve_plots = FALSE) {
state <- .runtime_state()
if (!isTRUE(state$active) && !length(state$options) &&
!length(state$bindings) && !length(state$hooks) &&
@@ -305,5 +322,5 @@ runtime_stop <- function() {
}
# Prevent callbacks from sending new notifications during cleanup.
state$active <- FALSE
- .runtime_restore()
+ .runtime_restore(preserve_plots)
}
diff --git a/sess/R/server.R b/sess/R/server.R
index 846bb4afc..4d2a5d3cc 100644
--- a/sess/R/server.R
+++ b/sess/R/server.R
@@ -44,7 +44,7 @@ connect <- function(endpoint = NULL, use_rstudioapi = TRUE,
plot_backend <- .resolve_plot_backend(plot_backend)
# Invalidate poll callbacks and restore a previous runtime before reconnecting.
- .transport_disconnect(silent = TRUE)
+ .transport_disconnect(silent = TRUE, preserve_plots = TRUE)
# Metadata belongs to this connect call, not to the R process. In particular,
# a later native attach must not inherit an external terminal's identity.
.sess_env$attachment_id <- attachment_id
@@ -339,7 +339,7 @@ connect <- function(endpoint = NULL, use_rstudioapi = TRUE,
}
# An attach write/poll failure may itself have queued a retry. Cancel it
# before scheduling our retry so there is only one pending chain.
- .transport_disconnect(silent = TRUE)
+ .transport_disconnect(silent = TRUE, preserve_plots = TRUE)
}
.schedule_reconnect(settings, .sess_env$transport_generation, schedule)
}, 1)
@@ -358,7 +358,7 @@ connect <- function(endpoint = NULL, use_rstudioapi = TRUE,
registration$run(endpoint, setup)
}
-.transport_disconnect <- function(silent = FALSE, reconnect = FALSE) {
+.transport_disconnect <- function(silent = FALSE, reconnect = FALSE, preserve_plots = FALSE) {
con <- .sess_env$con
settings <- .sess_env$reconnect
.sess_env$reconnect <- NULL
@@ -372,7 +372,7 @@ connect <- function(endpoint = NULL, use_rstudioapi = TRUE,
.sess_env$pending_responses <- list()
if (!is.null(con)) try(close(con), silent = TRUE)
- runtime_stop()
+ runtime_stop(preserve_plots = reconnect || preserve_plots)
if (reconnect && !is.null(con)) .schedule_reconnect(settings)
if (!silent && !is.null(con)) message("[sess] Disconnected from VS Code")
invisible(NULL)
diff --git a/sess/inst/tinytest/test-attachment-metadata.R b/sess/inst/tinytest/test-attachment-metadata.R
index ab353a3e8..f9924dab6 100644
--- a/sess/inst/tinytest/test-attachment-metadata.R
+++ b/sess/inst/tinytest/test-attachment-metadata.R
@@ -35,7 +35,7 @@ local({
env$connect <- get("connect", namespace)
environment(env$connect) <- env
disconnects <- 0L
- env$.transport_disconnect <- function(silent = FALSE) {
+ env$.transport_disconnect <- function(silent = FALSE, preserve_plots = FALSE) {
disconnects <<- disconnects + 1L
state$con <- NULL
state$reconnect <- NULL
diff --git a/sess/inst/tinytest/test-httpgd-devices.R b/sess/inst/tinytest/test-httpgd-devices.R
new file mode 100644
index 000000000..21cc1f4c2
--- /dev/null
+++ b/sess/inst/tinytest/test-httpgd-devices.R
@@ -0,0 +1,102 @@
+if (requireNamespace("httpgd", quietly = TRUE) &&
+ packageVersion("httpgd") >= "2.0.1") local({
+ original_device <- grDevices::dev.cur()
+ original_devices <- grDevices::dev.list()
+ original_hgd <- httpgd::hgd
+ sess_env <- sess:::.sess_env
+ cons <- processx::conn_create_pipepair()
+ previous_connection <- sess_env$con
+ sess_env$con <- cons[[2L]]
+ notifications <- function() {
+ lines <- strsplit(processx::conn_read_chars(cons[[1L]]), "\n", fixed = TRUE)[[1L]]
+ lapply(lines, jsonlite::fromJSON)
+ }
+ on.exit({
+ sess:::runtime_stop()
+ sess_env$con <- previous_connection
+ close(cons[[1L]])
+ close(cons[[2L]])
+ for (id in rev(setdiff(grDevices::dev.list(), original_devices))) {
+ try(grDevices::dev.off(id), silent = TRUE)
+ }
+ if (original_device %in% grDevices::dev.list()) grDevices::dev.set(original_device)
+ }, add = TRUE)
+ sess:::runtime_start(use_rstudioapi = FALSE, plot_backend = "httpgd")
+ # Direct calls and dev.new() both register through the same integration.
+ httpgd::hgd(silent = TRUE)
+ graphics::plot(1:10, main = "FIRST DEVICE")
+ first <- grDevices::dev.cur()
+ grDevices::dev.new()
+ graphics::plot(1:100, main = "SECOND DEVICE")
+ second <- grDevices::dev.cur()
+ registered <- notifications()
+ expect_equal(vapply(registered, `[[`, character(1), "method"), c("httpgd", "httpgd"))
+ expect_equal(vapply(registered, function(item) item$params$url, character(1)),
+ c(httpgd::hgd_url(which = first), httpgd::hgd_url(which = second)))
+ expect_length(sess:::.runtime_state()$devices, 1L)
+ expect_identical(sess:::.runtime_state()$devices[[1L]]$id, unname(second))
+ sess:::runtime_stop(preserve_plots = TRUE)
+ expect_identical(httpgd::hgd, original_hgd)
+ sess:::runtime_start(use_rstudioapi = FALSE, plot_backend = "httpgd")
+ restored <- notifications()
+ expect_length(restored, 2L)
+ expect_true(all(vapply(restored, function(item) isTRUE(item$params$restoring), logical(1))))
+ expect_length(sess:::.runtime_state()$devices, 1L)
+ sess:::runtime_stop()
+ expect_true(first %in% grDevices::dev.list())
+ expect_false(second %in% grDevices::dev.list())
+ expect_identical(httpgd::hgd, original_hgd)
+
+ # Replacing a closed factory device with an explicit hgd() must not inherit
+ # the old registration's cleanup ownership merely by reusing its number.
+ sess:::runtime_start(use_rstudioapi = FALSE, plot_backend = "httpgd")
+ grDevices::dev.new()
+ factory <- grDevices::dev.cur()
+ grDevices::dev.off(factory)
+ httpgd::hgd(silent = TRUE)
+ replacement <- grDevices::dev.cur()
+ expect_identical(replacement, factory)
+ expect_length(sess:::.runtime_state()$devices, 0L)
+ sess:::runtime_stop()
+ expect_true(replacement %in% grDevices::dev.list())
+ expect_true(first %in% grDevices::dev.list())
+})
+
+if (requireNamespace("httpgd", quietly = TRUE) &&
+ packageVersion("httpgd") >= "2.0.1") local({
+ # A restored-device notification can fail after attach succeeds. No runtime
+ # hooks or bindings may be installed after that failure cleans up the runtime.
+ has_rstudioapi <- requireNamespace("rstudioapi", quietly = TRUE)
+ original_available <- if (has_rstudioapi) rstudioapi::isAvailable else NULL
+ original_hook <- getHook(packageEvent("rstudioapi", "onLoad"))
+ original_callbacks <- getTaskCallbackNames()
+ original_device <- getOption("device")
+ original_hgd <- httpgd::hgd
+ sess_env <- sess:::.sess_env
+ previous_connection <- sess_env$con
+ httpgd::hgd(silent = TRUE)
+ device <- grDevices::dev.cur()
+ cons <- processx::conn_create_pipepair()
+ close(cons[[1L]])
+ sess_env$con <- cons[[2L]]
+ on.exit({
+ sess:::runtime_stop()
+ sess_env$con <- previous_connection
+ try(close(cons[[2L]]), silent = TRUE)
+ if (device %in% grDevices::dev.list()) grDevices::dev.off(device)
+ }, add = TRUE)
+
+ suppressWarnings(sess:::runtime_start(use_rstudioapi = TRUE, plot_backend = "httpgd"))
+ expect_null(sess_env$con)
+ state <- sess:::.runtime_state()
+ expect_false(isTRUE(state$active))
+ for (field in c("hooks", "bindings", "task_callbacks", "options", "s3_methods")) {
+ expect_length(state[[field]], 0L)
+ }
+ expect_identical(getTaskCallbackNames(), original_callbacks)
+ expect_identical(getHook(packageEvent("rstudioapi", "onLoad")), original_hook)
+ expect_identical(getOption("device"), original_device)
+ expect_identical(httpgd::hgd, original_hgd)
+ if (has_rstudioapi) expect_identical(rstudioapi::isAvailable, original_available)
+ expect_true(device %in% grDevices::dev.list())
+})
diff --git a/sess/inst/tinytest/test-httpgd-version.R b/sess/inst/tinytest/test-httpgd-version.R
index e551b2bba..e43e36c51 100644
--- a/sess/inst/tinytest/test-httpgd-version.R
+++ b/sess/inst/tinytest/test-httpgd-version.R
@@ -16,6 +16,7 @@ local({
identical(package, "httpgd") && !is.null(version)
}
env$getNamespaceVersion <- function(package) version
+ env$.install_httpgd_device_hook <- function() function(...) NULL
versions <- list(NULL, "1.2.0", "2.0.0", "2.0.1", "2.1.4", "10.0.0")
supported <- c(FALSE, FALSE, FALSE, TRUE, TRUE, TRUE)
for (i in seq_along(versions)) {
diff --git a/sess/inst/tinytest/test-plot-notification.R b/sess/inst/tinytest/test-plot-notification.R
index c2fb3cb0f..6a00705cc 100644
--- a/sess/inst/tinytest/test-plot-notification.R
+++ b/sess/inst/tinytest/test-plot-notification.R
@@ -69,5 +69,8 @@ local({
expect_warning(scheduled[[3L]](), "Failed to send IPC message")
expect_null(sess_env$con)
expect_false(isTRUE(sess:::.runtime_state()$active))
+ expect_equal(grDevices::dev.cur(), getOption("sess.null_dev"))
+ expect_inherits(sess_env$latest_plot_record, "recordedplot")
+ sess:::runtime_stop()
expect_equal(grDevices::dev.cur(), original_device)
})
diff --git a/sess/inst/tinytest/test-reconnect.R b/sess/inst/tinytest/test-reconnect.R
index e00f747fc..6e5d1fbc1 100644
--- a/sess/inst/tinytest/test-reconnect.R
+++ b/sess/inst/tinytest/test-reconnect.R
@@ -7,7 +7,7 @@ local({
environment(env$.schedule_reconnect) <- env
env$.reconnect_with_startup <- sess:::.reconnect_with_startup
environment(env$.reconnect_with_startup) <- env
- env$.transport_disconnect <- function(silent) {
+ env$.transport_disconnect <- function(silent, ...) {
env$.sess_env$con <- NULL
env$.sess_env$transport_generation <- env$.sess_env$transport_generation + 1L
}
diff --git a/sess/inst/tinytest/test-runtime-devices.R b/sess/inst/tinytest/test-runtime-devices.R
new file mode 100644
index 000000000..d8af7d129
--- /dev/null
+++ b/sess/inst/tinytest/test-runtime-devices.R
@@ -0,0 +1,36 @@
+# R reuses graphics device numbers after dev.off(). A new registration must
+# replace the previous lifetime without removing other live device identities.
+local({
+ original_devices <- grDevices::dev.list()
+ on.exit({
+ for (id in rev(setdiff(grDevices::dev.list(), original_devices))) {
+ try(grDevices::dev.off(id), silent = TRUE)
+ }
+ }, add = TRUE)
+
+ state <- new.env(parent = emptyenv())
+ state$devices <- list()
+ env <- new.env(parent = asNamespace("sess"))
+ env$.runtime_state <- function() state
+ track <- sess:::.runtime_track_device
+ environment(track) <- env
+
+ grDevices::pdf(NULL)
+ track()
+ state$devices[[1L]]$key <- "other-live-device"
+ other <- state$devices[[1L]]
+
+ grDevices::pdf(NULL)
+ id <- grDevices::dev.cur()
+ track()
+ state$devices[[2L]]$key <- "closed-device"
+ grDevices::dev.off(id)
+ grDevices::pdf(NULL)
+ expect_identical(grDevices::dev.cur(), id)
+ track()
+
+ expect_length(state$devices, 2L)
+ expect_identical(state$devices[[1L]], other)
+ expect_identical(state$devices[[2L]]$id, unname(id))
+ expect_null(state$devices[[2L]]$key)
+})
diff --git a/sess/man/runtime_stop.Rd b/sess/man/runtime_stop.Rd
index f16d8eb12..75df24155 100644
--- a/sess/man/runtime_stop.Rd
+++ b/sess/man/runtime_stop.Rd
@@ -4,7 +4,11 @@
\alias{runtime_stop}
\title{Stop the VS Code runtime integration (internal)}
\usage{
-runtime_stop()
+runtime_stop(preserve_plots = FALSE)
+}
+\arguments{
+\item{preserve_plots}{Keep live graphics devices and the latest plot while
+reconnecting the same R process after an editor reload.}
}
\description{
Removes runtime callbacks and restores R state installed by runtime_start().
diff --git a/src/extension.ts b/src/extension.ts
index d584bd5a2..b4a582adb 100644
--- a/src/extension.ts
+++ b/src/extension.ts
@@ -380,7 +380,9 @@ export function ensureWorkspaceViewer(): void {
}
export async function deactivate(): Promise {
- await shutdownHtmlWidgetViewers(true);
- (globalPlotManager as plotViewer.CommonPlotManager)?.dispose();
+ await Promise.all([
+ shutdownHtmlWidgetViewers(true),
+ (globalPlotManager as plotViewer.CommonPlotManager)?.dispose(),
+ ]);
await session.shutdownSessionWatcher();
}
diff --git a/src/plotViewer/httpgdClient.ts b/src/plotViewer/httpgdClient.ts
index 5fef5e2de..f1536ec0b 100644
--- a/src/plotViewer/httpgdClient.ts
+++ b/src/plotViewer/httpgdClient.ts
@@ -70,7 +70,9 @@ export class HttpgdClient {
private renderersLoaded = false;
private refreshTask?: Promise;
private refreshPending = false;
+ private unavailable = false;
private readonly listeners = new Set<(value: HttpgdPlotsResponse) => void>();
+ private readonly unavailableListeners = new Set<() => void>();
constructor(host: string, token?: string, options: ClientOptions = {}) {
this.base = new URL(host.includes('://') ? host : `http://${host}`);
@@ -107,6 +109,10 @@ export class HttpgdClient {
this.listeners.add(listener);
}
+ public onUnavailable(listener: () => void): void {
+ this.unavailableListeners.add(listener);
+ }
+
public getPlots(): HttpgdIdResponse[] {
return this.snapshot?.plots ?? [];
}
@@ -114,6 +120,10 @@ export class HttpgdClient {
return this.renderers;
}
+ public async refreshState(): Promise {
+ await this.updateState(await this.json(this.url('/state')));
+ }
+
public async getPlotText(request: HttpgdPlotRequest): Promise {
return (await this.getPlotBytes(request)).toString('utf8');
}
@@ -151,7 +161,25 @@ export class HttpgdClient {
}
// Rendering and exports may be slow; preserve httpgd-js's lack of a
// request timeout while still cancelling requests on disconnect.
- const response = await getHttpResponse(url, { headers: this.headers, signal });
+ let response;
+ try {
+ response = await getHttpResponse(url, { headers: this.headers, signal });
+ } catch (error) {
+ if (
+ !signal.aborted &&
+ ['ECONNREFUSED', 'ECONNRESET'].includes(
+ (error as NodeJS.ErrnoException)?.code ?? '',
+ )
+ ) {
+ this.unavailable = true;
+ this.closeWebSocket();
+ this.schedulePoll(this.retryInterval);
+ for (const listener of this.unavailableListeners) {
+ listener();
+ }
+ }
+ throw error;
+ }
if (response.status !== 200) {
throw new Error(`httpgd request failed (${response.status})`);
}
@@ -211,6 +239,7 @@ export class HttpgdClient {
return { id: plot.id };
});
this.snapshot = { state: state(value.state), plots };
+ this.unavailable = false;
// TODO: Revisit this notification boundary if httpgd and JGD viewer
// updates are unified. Their transports remain separate for now.
for (const listener of this.listeners) {
@@ -223,6 +252,7 @@ export class HttpgdClient {
const next = state(value);
const previous = this.snapshot?.state;
if (
+ this.unavailable ||
!previous ||
previous.upid !== next.upid ||
previous.hsize !== next.hsize ||
@@ -251,7 +281,7 @@ export class HttpgdClient {
if (!this.renderersLoaded) {
await this.loadRenderers();
}
- await this.updateState(await this.json(this.url('/state')));
+ await this.refreshState();
if (!this.socket && Date.now() >= this.retryAt) {
this.openWebSocket();
}
diff --git a/src/plotViewer/httpgdDevices.ts b/src/plotViewer/httpgdDevices.ts
new file mode 100644
index 000000000..6b278dac9
--- /dev/null
+++ b/src/plotViewer/httpgdDevices.ts
@@ -0,0 +1,350 @@
+import {
+ HttpgdClient,
+ HttpgdIdResponse,
+ HttpgdPlotRequest,
+ HttpgdPlotsResponse,
+ HttpgdRendererResponse,
+} from './httpgdClient';
+
+interface Device {
+ origin: string;
+ token?: string;
+ client: HttpgdClient;
+ plots: Map;
+ restoring: boolean;
+ active: boolean;
+ available: boolean;
+ upid?: number;
+}
+
+interface PlotTarget {
+ device: Device;
+ id: string;
+ position: number;
+}
+
+export interface HttpgdPlotReference {
+ origin: string;
+ id: string;
+}
+
+/** One session timeline; HTTP requests and credentials remain device-specific. */
+export class HttpgdDevices {
+ private devices = new Map();
+ // Map insertion order is plot arrival order. Only IDs are grouped, never SVGs.
+ private targets = new Map();
+ private nextId = 0;
+ private usedIds = new Set();
+ private order = new Map();
+ private nextPosition = 0;
+ private connected = false;
+ private listeners = new Set<
+ (
+ plots: HttpgdIdResponse[],
+ restoring: boolean,
+ changed: ReadonlySet,
+ orderChanged: boolean,
+ ) => void
+ >();
+
+ constructor(
+ origin: string,
+ token?: string,
+ restoring = false,
+ order: HttpgdPlotReference[] = [],
+ ) {
+ for (const plot of order) {
+ this.rememberPlot(plot);
+ }
+ void this.addDevice(origin, token, restoring);
+ }
+
+ get plotOrder(): HttpgdPlotReference[] {
+ return [...this.order.values()].map(({ plot }) => ({ ...plot }));
+ }
+
+ private rememberPlot(plot: HttpgdPlotReference): number {
+ const key = JSON.stringify([plot.origin, plot.id]);
+ let entry = this.order.get(key);
+ if (!entry) {
+ entry = { plot, position: this.nextPosition++ };
+ this.order.set(key, entry);
+ }
+ return entry.position;
+ }
+
+ get connections(): Array<{ origin: string; token?: string }> {
+ return [...this.devices.values()].map(({ origin, token }) => ({ origin, token }));
+ }
+
+ connection(origin: string): { origin: string; token?: string } | undefined {
+ return this.devices.get(origin);
+ }
+
+ async addDevice(origin: string, token?: string, restoring = false): Promise {
+ const previous = this.devices.get(origin);
+ if (previous && previous.token === token) {
+ return;
+ }
+ if (previous) {
+ this.removeDevice(origin);
+ }
+ const device: Device = {
+ origin,
+ token,
+ client: new HttpgdClient(origin, token),
+ plots: new Map(),
+ restoring,
+ active: false,
+ available: true,
+ };
+ this.devices.set(origin, device);
+ device.client.onPlotsChanged((snapshot) => this.update(device, snapshot));
+ device.client.onUnavailable(() => {
+ if (!this.connected || this.devices.get(origin) !== device || !device.available) {
+ return;
+ }
+ device.available = false;
+ device.active = false;
+ });
+ if (this.connected) {
+ await device.client.connect();
+ }
+ }
+
+ removeDevice(origin: string): void {
+ const device = this.devices.get(origin);
+ if (!device) {
+ return;
+ }
+ this.devices.delete(origin);
+ device.client.disconnect();
+ for (const id of device.plots.values()) {
+ this.targets.delete(id);
+ }
+ for (const [key, { plot }] of this.order) {
+ if (plot.origin === origin) {
+ this.order.delete(key);
+ }
+ }
+ this.notify(true, new Set(), true);
+ }
+
+ private update(device: Device, snapshot: HttpgdPlotsResponse): void {
+ if (!this.connected || this.devices.get(device.origin) !== device) {
+ return;
+ }
+ device.active = snapshot.state.active;
+ const contentChanged = !device.available || device.upid !== snapshot.state.upid;
+ device.available = true;
+ device.upid = snapshot.state.upid;
+ if (device.active) {
+ for (const other of this.devices.values()) {
+ if (other !== device && other.active) {
+ other.active = false;
+ // dev.off() can leave httpgd's WebSocket open. Check the
+ // previous device when R activates its successor.
+ void other.client.refreshState().catch(() => undefined);
+ }
+ }
+ }
+ const previousLatest = [...device.plots.keys()].at(-1);
+ const incoming = new Set(snapshot.plots.map((plot) => plot.id));
+ let added = false;
+ let orderChanged = false;
+ // Reconcile only this device: other restored devices may not have
+ // delivered their first snapshot yet.
+ for (const [key, { plot }] of this.order) {
+ if (plot.origin === device.origin && !incoming.has(plot.id)) {
+ this.order.delete(key);
+ orderChanged = true;
+ }
+ }
+ for (const [rawId, id] of device.plots) {
+ if (!incoming.has(rawId)) {
+ device.plots.delete(rawId);
+ this.targets.delete(id);
+ }
+ }
+ let restoreOrder = false;
+ for (const rawId of incoming) {
+ if (device.plots.has(rawId)) {
+ continue;
+ }
+ // Preserve ordinary single-device IDs, allocating an alias only for
+ // collisions. Routing never relies on parsing a server-supplied ID.
+ let id = rawId;
+ while (this.usedIds.has(id)) {
+ id = `httpgd-${++this.nextId}`;
+ }
+ this.usedIds.add(id);
+ added = true;
+ device.plots.set(rawId, id);
+ const saved = this.order.has(JSON.stringify([device.origin, rawId]));
+ restoreOrder ||= saved;
+ orderChanged ||= !saved;
+ this.targets.set(id, {
+ device,
+ id: rawId,
+ position: this.rememberPlot({ origin: device.origin, id: rawId }),
+ });
+ }
+ if (restoreOrder) {
+ this.targets = new Map([...this.targets].sort((a, b) => a[1].position - b[1].position));
+ }
+ // The WebSocket repeats the initial HTTP snapshot. An unchanged snapshot
+ // or an annotation must not turn a restoration into a new-plot reveal.
+ const restoring = device.restoring || !added;
+ device.restoring = false;
+ // The previous latest plot can receive its final annotations before a
+ // new page appears in the same snapshot. Invalidate both boundaries.
+ const latest = snapshot.plots.at(-1)?.id;
+ this.notify(
+ restoring,
+ new Set(
+ contentChanged
+ ? [previousLatest, latest]
+ .flatMap((id) => (id === undefined ? [] : [device.plots.get(id)]))
+ .filter((id): id is string => id !== undefined)
+ : [],
+ ),
+ orderChanged,
+ );
+ }
+
+ /** Restore routing before connecting; retained images remain usable when a
+ * device no longer has a server. Aliases stay stable across reloads. */
+ restorePlots(plots: Array<{ id: string; reference: HttpgdPlotReference }>): void {
+ for (const { id, reference } of plots) {
+ const device = this.devices.get(reference.origin);
+ if (!device || device.plots.has(reference.id) || this.usedIds.has(id)) {
+ continue;
+ }
+ this.usedIds.add(id);
+ device.plots.set(reference.id, id);
+ this.targets.set(id, {
+ device,
+ id: reference.id,
+ position: this.rememberPlot(reference),
+ });
+ }
+ this.targets = new Map([...this.targets].sort((a, b) => a[1].position - b[1].position));
+ }
+
+ plotReference(id: string): HttpgdPlotReference | undefined {
+ const target = this.targets.get(id);
+ return target && { origin: target.device.origin, id: target.id };
+ }
+
+ private notify(
+ restoring: boolean,
+ changed: ReadonlySet = new Set(),
+ orderChanged = false,
+ ): void {
+ const plots = this.getPlots();
+ for (const listener of this.listeners) {
+ listener(plots, restoring, changed, orderChanged);
+ }
+ }
+
+ onPlotsChanged(
+ listener: (
+ plots: HttpgdIdResponse[],
+ restoring: boolean,
+ changed: ReadonlySet,
+ orderChanged: boolean,
+ ) => void,
+ ): void {
+ this.listeners.add(listener);
+ }
+
+ async connect(): Promise {
+ this.connected = true;
+ // Start every connection even if one retained device has been closed in R.
+ await Promise.all([...this.devices.values()].map((device) => device.client.connect()));
+ }
+
+ disconnect(): void {
+ this.connected = false;
+ for (const device of this.devices.values()) {
+ device.restoring = true;
+ device.client.disconnect();
+ }
+ }
+
+ getPlots(): HttpgdIdResponse[] {
+ return [...this.targets.keys()].map((id) => ({ id }));
+ }
+
+ releasePlots(): void {
+ this.targets.clear();
+ this.usedIds.clear();
+ this.order.clear();
+ for (const device of this.devices.values()) {
+ device.plots.clear();
+ }
+ }
+
+ private target(id?: string): PlotTarget {
+ const target = id === undefined ? undefined : this.targets.get(id);
+ if (target) {
+ return target;
+ }
+ if (id !== undefined) {
+ throw new Error('Plot is no longer available');
+ }
+ const device = this.devices.values().next().value;
+ if (!device) {
+ throw new Error('No httpgd device is available');
+ }
+ return { device, id: '', position: -1 };
+ }
+
+ plotConnection(id?: string): { origin: string; token?: string } {
+ return this.target(id).device;
+ }
+
+ isActive(id: string): boolean {
+ return this.targets.get(id)?.device.active !== false;
+ }
+
+ isAvailable(id: string): boolean {
+ return this.targets.get(id)?.device.available !== false;
+ }
+
+ getRenderers(id?: string): HttpgdRendererResponse[] {
+ return this.target(id).device.client.getRenderers();
+ }
+
+ async getPlotText(request: HttpgdPlotRequest): Promise {
+ return (await this.getPlotBytes(request)).toString('utf8');
+ }
+
+ async getPlotBytes(request: HttpgdPlotRequest): Promise {
+ const target = this.target(request.id);
+ const routed = { ...request, id: request.id === undefined ? undefined : target.id };
+ // unigd replays resize requests on R's current graphics device. For an
+ // inactive device, request its existing geometry and let SVG fit the
+ // panel; replaying here can blank this plot and draw into another device.
+ const dimensions = target.device.active
+ ? {}
+ : { width: undefined, height: undefined, zoom: undefined };
+ return target.device.client.getPlotBytes({ ...routed, ...dimensions });
+ }
+
+ async removePlot(request: { id: string }): Promise {
+ const target = this.target(request.id);
+ await target.device.client.removePlot({ id: target.id });
+ }
+
+ forgetPlot(id: string): void {
+ const target = this.targets.get(id);
+ if (!target) {
+ return;
+ }
+ this.targets.delete(id);
+ target.device.plots.delete(target.id);
+ this.order.delete(JSON.stringify([target.device.origin, target.id]));
+ this.notify(true, new Set(), true);
+ }
+}
diff --git a/src/plotViewer/httpgdTypes.d.ts b/src/plotViewer/httpgdTypes.d.ts
index db601ed0e..427fabb72 100644
--- a/src/plotViewer/httpgdTypes.d.ts
+++ b/src/plotViewer/httpgdTypes.d.ts
@@ -1,10 +1,10 @@
-import { HttpgdClient, HttpgdPlotId } from './httpgdClient';
+import { HttpgdPlotId } from './httpgdClient';
+import { HttpgdDevices, HttpgdPlotReference } from './httpgdDevices';
import * as vscode from 'vscode';
-import { HttpgdManager } from '.';
+import { HttpgdManager } from './httpgdViewer';
+import type { ViewerSessionSource } from '../viewerSession';
import { PreviewPlotLayout } from './webviewMessages';
-export type MaybePromise = T | Promise;
-
export interface HttpgdPlot {
// unique ID for this plot (w.r.t. this connection/device)
id: HttpgdPlotId;
@@ -16,10 +16,30 @@ export interface HttpgdPlot {
height: number;
width: number;
zoom: number;
+
+ // The viewport size above was applied by scaling; replay it when the device is active.
+ resizeDeferred?: boolean;
+}
+
+/** Received images persist independently of device servers; credentials remain in SecretStorage. */
+export interface HttpgdHistoryState {
+ source?: ViewerSessionSource;
+ plots: Array & { reference: HttpgdPlotReference }>;
+}
+
+export interface HttpgdSelectionState {
+ activePlot?: string;
+ hiddenPlots: string[];
}
// Example for possible viewer creation options:
export interface HttpgdViewerOptions {
+ historyId?: string;
+ history?: HttpgdHistoryState;
+ selection?: HttpgdSelectionState;
+ connections?: Array<{ origin: string; token?: string }>;
+ plotOrder?: HttpgdPlotReference[];
+ restoring?: boolean;
parent: HttpgdManager;
token?: string;
preserveFocus?: boolean;
@@ -30,6 +50,7 @@ export interface HttpgdViewerOptions {
previewPlotLayout?: PreviewPlotLayout;
resizeTimeoutLength?: number;
refreshTimeoutLength?: number;
+ source?: ViewerSessionSource;
}
// Roughly combines the functionality of HttpgdNavigator and HttpgdViewer
@@ -39,10 +60,10 @@ export class IHttpgdViewer {
webviewPanel?: vscode.WebviewPanel;
// Api that provides plot contents etc.
- api: HttpgdClient;
+ api: HttpgdDevices;
// active plots
- plots: HttpgdPlot[];
+ readonly plots: readonly HttpgdPlot[];
// Id of the currently viewed plot
activePlot?: HttpgdPlotId;
diff --git a/src/plotViewer/httpgdViewer.ts b/src/plotViewer/httpgdViewer.ts
index 332696246..e9b315303 100644
--- a/src/plotViewer/httpgdViewer.ts
+++ b/src/plotViewer/httpgdViewer.ts
@@ -1,9 +1,31 @@
import * as vscode from 'vscode';
-import { HttpgdClient, HttpgdIdResponse, HttpgdPlotId, HttpgdRendererId } from './httpgdClient';
-import { HttpgdPlot, IHttpgdViewer, HttpgdViewerOptions } from './httpgdTypes';
+import { HttpgdIdResponse, HttpgdPlotId, HttpgdRendererId } from './httpgdClient';
+import { HttpgdDevices, HttpgdPlotReference } from './httpgdDevices';
+import { PlotLifecycle } from './plotLifecycle';
+import { PlotViewerRegistry } from './plotViewerRegistry';
+import {
+ HttpgdPlot,
+ IHttpgdViewer,
+ HttpgdViewerOptions,
+ HttpgdHistoryState,
+ HttpgdSelectionState,
+} from './httpgdTypes';
import * as path from 'path';
import * as fs from 'fs';
import ejs from 'ejs';
+import { randomUUID } from 'crypto';
+import {
+ isViewerSessionSource,
+ viewerSessionStyle,
+ ViewerSessionContext,
+ ViewerSessionSource,
+} from '../viewerSession';
+import {
+ getViewerSessionContext,
+ onDidChangeSessionConnection,
+ resolvePlotSession,
+} from '../session';
+import { sessionProcessIdentity } from '../sessionIdentity';
import { asViewColumn, config, setContext, UriIcon, makeWebviewCommandUriString } from '../util';
import { extensionContext } from '../extension';
@@ -21,12 +43,277 @@ import {
} from './webviewMessages';
import { PlotViewer } from './types';
+interface HttpgdConnection {
+ url: string;
+ source?: ViewerSessionSource;
+ plotOrder?: HttpgdPlotReference[];
+ historyId?: string;
+}
+
+function isPlotReference(value: unknown): value is HttpgdPlotReference {
+ return (
+ !!value &&
+ typeof value === 'object' &&
+ typeof (value as HttpgdPlotReference).origin === 'string' &&
+ typeof (value as HttpgdPlotReference).id === 'string'
+ );
+}
+
+function isHttpgdHistoryState(value: unknown): value is HttpgdHistoryState {
+ if (!value || typeof value !== 'object') {
+ return false;
+ }
+ const state = value as Partial;
+ return (
+ (state.source === undefined || isViewerSessionSource(state.source)) &&
+ Array.isArray(state.plots) &&
+ state.plots.every(
+ (plot) =>
+ plot &&
+ typeof plot.id === 'string' &&
+ typeof plot.data === 'string' &&
+ Number.isFinite(plot.width) &&
+ plot.width > 0 &&
+ Number.isFinite(plot.height) &&
+ plot.height > 0 &&
+ Number.isFinite(plot.zoom) &&
+ plot.zoom > 0 &&
+ isPlotReference(plot.reference),
+ )
+ );
+}
+
+function isHttpgdSelectionState(value: unknown): value is HttpgdSelectionState {
+ if (!value || typeof value !== 'object') {
+ return false;
+ }
+ const state = value as Partial;
+ return (
+ (state.activePlot === undefined || typeof state.activePlot === 'string') &&
+ Array.isArray(state.hiddenPlots) &&
+ state.hiddenPlots.every((id) => typeof id === 'string')
+ );
+}
+
+function isHttpgdConnection(value: unknown): value is HttpgdConnection {
+ if (!value || typeof value !== 'object') {
+ return false;
+ }
+ const connection = value as Partial;
+ try {
+ return (
+ typeof connection.url === 'string' &&
+ ['http:', 'https:'].includes(new URL(connection.url).protocol) &&
+ (connection.source === undefined || isViewerSessionSource(connection.source))
+ );
+ } catch {
+ return false;
+ }
+}
+
export class HttpgdManager {
- viewers: HttpgdViewer[] = [];
+ private viewerStore: PlotViewerRegistry;
viewerOptions: HttpgdViewerOptions;
- recentlyActiveViewers: HttpgdViewer[] = [];
+ get viewers(): HttpgdViewer[] {
+ return this.viewerStore.values().reverse();
+ }
+
+ get recentlyActiveViewers(): readonly HttpgdViewer[] {
+ return this.viewerStore.recentlyActive;
+ }
+ private savedConnections: Promise = Promise.resolve([]);
+ private secretKey: string | undefined;
+ private disposed = false;
+ private connectionWrites: Promise = Promise.resolve();
+ private saveTimer?: NodeJS.Timeout;
+ private histories = new Map();
+ private selections = new Map();
+ private historyDirty = true;
+ private selectionDirty = true;
+
+ getViewers(): HttpgdViewer[] {
+ return this.viewers;
+ }
+
+ dispose(): void {
+ this.disposed = true;
+ clearTimeout(this.saveTimer);
+ this.viewerStore.dispose();
+ }
+
+ initialize(): void {
+ const histories = extensionContext.workspaceState.get('r.plot.httpgdHistory');
+ const selections = extensionContext.workspaceState.get('r.plot.httpgdSelection');
+ if (histories && typeof histories === 'object') {
+ for (const [id, value] of Object.entries(histories)) {
+ if (isHttpgdHistoryState(value)) {
+ this.histories.set(id, value);
+ }
+ }
+ }
+ if (selections && typeof selections === 'object') {
+ for (const [id, value] of Object.entries(selections)) {
+ if (isHttpgdSelectionState(value)) {
+ this.selections.set(id, value);
+ }
+ }
+ }
+ this.secretKey = extensionContext.workspaceState.get('r.plot.httpgdConnectionsKey');
+ if (this.secretKey) {
+ // URLs contain device tokens, so keep them in SecretStorage.
+ this.savedConnections = Promise.resolve(extensionContext.secrets.get(this.secretKey))
+ .then((value) => {
+ const connections: unknown = value === undefined ? [] : JSON.parse(value);
+ if (!Array.isArray(connections)) {
+ throw new Error('Invalid saved httpgd connections');
+ }
+ const saved = connections.filter(isHttpgdConnection).map((connection) => ({
+ ...connection,
+ plotOrder: Array.isArray(connection.plotOrder)
+ ? connection.plotOrder.filter(isPlotReference)
+ : undefined,
+ }));
+ if (!this.disposed) {
+ for (const connection of saved) {
+ this.viewerStore.lifecycle.observe(connection.source);
+ }
+ }
+ return saved;
+ })
+ .catch(() => {
+ // Parse errors can contain token-bearing JSON. Expose only
+ // a fixed message and keep the load rejected so a later save
+ // cannot overwrite unread credentials with an empty list.
+ throw new Error('Could not load saved httpgd plot connections.');
+ });
+ void this.savedConnections.catch((error: Error) => {
+ void vscode.window.showErrorMessage(error.message);
+ });
+ }
+ }
+
+ private hasExited(source?: ViewerSessionSource): boolean {
+ return this.viewerStore.lifecycle.hasExited(source);
+ }
+
+ public registerViewer(viewer: HttpgdViewer): void {
+ this.viewerStore.register(viewer, viewer.connection.source);
+ }
+
+ async restoreConnection(source: ViewerSessionSource): Promise {
+ if (this.disposed || this.hasExited(source)) {
+ return undefined;
+ }
+ const key = sessionProcessIdentity(source);
+ const matches = (owner?: ViewerSessionSource) =>
+ owner && sessionProcessIdentity(owner) === key;
+ const existing = () =>
+ [...this.recentlyActiveViewers, ...this.viewers].find(
+ (viewer) => !viewer.isDisposed && matches(viewer.connection.source),
+ );
+ const connections = await this.savedConnections.catch((error: unknown) => {
+ if (existing()) {
+ return [];
+ }
+ throw error;
+ });
+ for (const connection of connections.filter((saved) => matches(saved.source))) {
+ await this.showViewer(connection.url, source, true, true);
+ }
+ return existing();
+ }
+
+ saveConnections(): Promise {
+ clearTimeout(this.saveTimer);
+ this.saveTimer = undefined;
+ // Capture before shutdown releases the SVGs. Queued writes still filter
+ // confirmed process exits, including exits during a SecretStorage write.
+ for (const viewer of this.viewers) {
+ if (!viewer.isDisposed) {
+ this.histories.set(viewer.historyId, viewer.snapshotHistory());
+ this.selections.set(viewer.historyId, viewer.snapshotSelection());
+ }
+ }
+ this.connectionWrites = this.connectionWrites
+ .then(() => this.writeConnections())
+ .catch((error) => {
+ this.historyDirty = this.selectionDirty = true;
+ console.warn('[Plot Viewer] Could not save httpgd connections', error);
+ });
+ return this.connectionWrites;
+ }
+
+ scheduleSaveConnections(contentChanged = true): void {
+ this.historyDirty ||= contentChanged;
+ this.selectionDirty = true;
+ if (!this.disposed && !this.saveTimer) {
+ this.saveTimer = setTimeout(() => void this.saveConnections(), 250);
+ }
+ }
+
+ private async writeConnections(): Promise {
+ const connections = new Map(
+ (await this.savedConnections)
+ .filter((connection) => !this.hasExited(connection.source))
+ .map((connection) => [new URL(connection.url).origin, connection]),
+ );
+ for (const viewer of this.viewers) {
+ // Extension shutdown disposes clients before its queued save runs.
+ // Only confirmed process exits invalidate restoration credentials.
+ for (const connection of viewer.connections) {
+ if (!this.hasExited(connection.source)) {
+ connections.set(new URL(connection.url).origin, connection);
+ }
+ }
+ }
+ this.savedConnections = Promise.resolve([...connections.values()]);
+ const retained = new Set(
+ [...connections.values()].map((connection) => connection.historyId),
+ );
+ for (const id of this.histories.keys()) {
+ if (!retained.has(id)) {
+ this.histories.delete(id);
+ this.selections.delete(id);
+ this.historyDirty = this.selectionDirty = true;
+ }
+ }
+ if (this.historyDirty) {
+ this.historyDirty = false;
+ await extensionContext.workspaceState.update(
+ 'r.plot.httpgdHistory',
+ Object.fromEntries(this.histories),
+ );
+ }
+ if (this.selectionDirty) {
+ this.selectionDirty = false;
+ await extensionContext.workspaceState.update(
+ 'r.plot.httpgdSelection',
+ Object.fromEntries(this.selections),
+ );
+ }
+ if (!connections.size) {
+ if (this.secretKey) {
+ await extensionContext.secrets.delete(this.secretKey);
+ await extensionContext.workspaceState.update(
+ 'r.plot.httpgdConnectionsKey',
+ undefined,
+ );
+ this.secretKey = undefined;
+ }
+ return;
+ }
+ this.secretKey ??= `r.plot.httpgd.${randomUUID()}`;
+ await extensionContext.secrets.store(
+ this.secretKey,
+ JSON.stringify([...connections.values()]),
+ );
+ await extensionContext.workspaceState.update('r.plot.httpgdConnectionsKey', this.secretKey);
+ }
- constructor() {
+ constructor(lifecycle?: PlotLifecycle) {
+ this.viewerStore = new PlotViewerRegistry(() => {
+ void this.saveConnections();
+ }, lifecycle);
const htmlRoot = extensionContext.asAbsolutePath('dist/webviews/httpgd');
this.viewerOptions = {
parent: this,
@@ -35,25 +322,70 @@ export class HttpgdManager {
};
}
- public async showViewer(urlString: string): Promise {
- await Promise.resolve();
+ public async showViewer(
+ urlString: string,
+ source?: ViewerSessionSource,
+ onlyIfMissing = false,
+ restoring = false,
+ ): Promise {
+ const saved = await this.savedConnections.catch(() => []);
+ if (this.disposed || this.hasExited(source)) {
+ return;
+ }
const url = new URL(urlString);
const token = url.searchParams.get('token') || undefined;
- const ind = this.viewers.findIndex((viewer) => viewer.origin === url.origin);
- if (ind >= 0) {
- const viewer = this.viewers.splice(ind, 1)[0];
- if (viewer.token === token) {
- this.viewers.unshift(viewer);
- viewer.show();
+ const shared = this.viewerStore.forSession(source);
+ const existing = this.viewers.find((viewer) => viewer.api.connection(url.origin));
+ // Check after the async boundary: a new device may have attached while
+ // restoration was pending. Never replace its credentials with saved ones.
+ if (onlyIfMissing && existing) {
+ return;
+ }
+ if (existing) {
+ const viewer = existing;
+ const owner = viewer.connection.source;
+ const sameSession =
+ !source ||
+ !owner ||
+ sessionProcessIdentity(source) === sessionProcessIdentity(owner);
+ if (
+ !viewer.isDisposed &&
+ sameSession &&
+ (!shared || shared === viewer) &&
+ (viewer.api.connection(url.origin)?.token === token ||
+ viewer.connections.length > 1)
+ ) {
+ viewer.bindSession(source);
+ this.registerViewer(viewer);
+ if (viewer.isDisposed) {
+ return;
+ }
+ if (viewer.api.connection(url.origin)?.token !== token) {
+ viewer.addDevice(url.origin, token, restoring);
+ }
+ if (!restoring) {
+ viewer.show();
+ }
+ if (!onlyIfMissing) {
+ await this.saveConnections();
+ }
return;
}
- // A restarted device can reuse its port with a new authentication
- // token. Retire the connection rather than reusing stale credentials.
- viewer.dispose();
- viewer.webviewPanel?.dispose();
- this.recentlyActiveViewers = this.recentlyActiveViewers.filter(
- (active) => active !== viewer,
- );
+ // A restarted process can reuse both its port and token. Retire
+ // disposed or differently owned connections as well as stale credentials.
+ if (!viewer.isDisposed && viewer.connections.length > 1) {
+ viewer.api.removeDevice(url.origin);
+ } else {
+ this.viewerStore.remove(viewer);
+ viewer.dispose();
+ }
+ }
+ if (shared && !shared.isDisposed) {
+ shared.addDevice(url.origin, token, restoring);
+ if (!onlyIfMissing) {
+ await this.saveConnections();
+ }
+ return;
}
const conf = config();
const colorTheme = conf.get('plot.defaults.colorTheme', 'vscode');
@@ -66,26 +398,68 @@ export class HttpgdManager {
this.viewerOptions.resizeTimeoutLength = conf.get('plot.timing.resizeInterval', 100);
this.viewerOptions.fullWindow = conf.get('plot.defaults.fullWindowMode', false);
this.viewerOptions.token = token;
+ this.viewerOptions.source = source;
+ this.viewerOptions.restoring = restoring;
+ const matching = saved.filter((connection) =>
+ source
+ ? connection.source &&
+ sessionProcessIdentity(connection.source) === sessionProcessIdentity(source)
+ : !connection.source && new URL(connection.url).origin === url.origin,
+ );
+ const sameDevice = matching.some((connection) => {
+ const previous = new URL(connection.url);
+ return (
+ previous.origin === url.origin &&
+ (previous.searchParams.get('token') || undefined) === token
+ );
+ });
+ this.viewerOptions.plotOrder = matching
+ .flatMap((connection) => connection.plotOrder ?? [])
+ .filter((plot) => plot.origin !== url.origin || sameDevice);
+ const historyId = matching.find(
+ (connection) => typeof connection.historyId === 'string',
+ )?.historyId;
+ this.viewerOptions.historyId = historyId;
+ const history = historyId ? this.histories.get(historyId) : undefined;
+ this.viewerOptions.history = history && {
+ ...history,
+ plots: history.plots.filter(
+ (plot) => plot.reference.origin !== url.origin || sameDevice,
+ ),
+ };
+ this.viewerOptions.selection = historyId ? this.selections.get(historyId) : undefined;
+ this.viewerOptions.connections = matching
+ .map((connection) => new URL(connection.url))
+ .filter((connection) => connection.origin !== url.origin)
+ .filter(
+ (connection) =>
+ !this.viewers.some((viewer) => viewer.api.connection(connection.origin)),
+ )
+ .map((connection) => ({
+ origin: connection.origin,
+ token: connection.searchParams.get('token') || undefined,
+ }));
const viewer = new HttpgdViewer(url.origin, this.viewerOptions);
- this.viewers.unshift(viewer);
+ this.registerViewer(viewer);
+ if (viewer.isDisposed) {
+ return;
+ }
+ if (!onlyIfMissing) {
+ await this.saveConnections();
+ }
}
public registerActiveViewer(viewer: HttpgdViewer): void {
- const ind = this.recentlyActiveViewers.indexOf(viewer);
- if (ind >= 0) {
- this.recentlyActiveViewers.splice(ind, 1);
+ if (this.disposed || viewer.isDisposed) {
+ return;
}
- this.recentlyActiveViewers.unshift(viewer);
+ this.viewerStore.markActive(viewer);
}
public getRecentViewer(): HttpgdViewer | undefined {
return this.recentlyActiveViewers.find((viewer) => !!viewer.webviewPanel);
}
- public getNewestViewer(): HttpgdViewer | undefined {
- return this.viewers[0];
- }
-
public async openUrl(): Promise {
const clipText = await vscode.env.clipboard.readText();
const val0 = clipText.trim().split(/[\n ]/)[0];
@@ -101,10 +475,14 @@ export class HttpgdManager {
}
interface EjsData {
+ plotHeight: number;
+ sessionHtml: string;
+ sessionStyle: string;
+ codicons: string;
overwriteStyles: boolean;
previewPlotLayout: PreviewPlotLayout;
activePlot?: HttpgdPlotId;
- plots: HttpgdPlot[];
+ plots: readonly HttpgdPlot[];
largePlot: HttpgdPlot;
host: string;
asLocalPath: (relPath: string) => string;
@@ -119,16 +497,91 @@ interface ShowOptions {
preserveFocus?: boolean;
}
+interface PlotRefresh {
+ plots: HttpgdIdResponse[];
+ redraw: boolean;
+ force: boolean;
+ restoring: boolean;
+ changed?: ReadonlySet;
+}
+
export class HttpgdViewer implements IHttpgdViewer, PlotViewer {
readonly id: string;
+ readonly historyId: string;
readonly parent: HttpgdManager;
readonly host: string;
readonly origin: string;
readonly token?: string;
webviewPanel?: vscode.WebviewPanel;
- readonly api: HttpgdClient;
+ readonly api: HttpgdDevices;
private disposed = false;
- plots: HttpgdPlot[] = [];
+ private source?: ViewerSessionSource;
+ private sessionContext?: ViewerSessionContext;
+ private sessionConnectionSubscription?: vscode.Disposable;
+ private sessionDisconnected = false;
+ private restoring: boolean;
+ private pendingRefresh?: PlotRefresh;
+ private refreshTask?: Promise;
+ private requestGeneration = 0;
+ private changedPlots = new Set();
+ private stalePlots = new Set();
+ private failedPlots = new Set();
+ private pendingReveal = false;
+ private retainedPlotOrder?: HttpgdPlotReference[];
+ private retainedPlots: HttpgdPlot[] = [];
+ private visiblePlots: HttpgdPlot[] = [];
+
+ get isDisposed(): boolean {
+ return this.disposed;
+ }
+
+ get connection(): HttpgdConnection {
+ return this.connections[0];
+ }
+
+ get connections(): HttpgdConnection[] {
+ return this.api.connections.map(({ origin, token }, index) => {
+ const url = new URL(origin);
+ if (token) {
+ url.searchParams.set('token', token);
+ }
+ return {
+ url: url.toString(),
+ source: this.source,
+ historyId: this.historyId,
+ ...(index === 0 ? { plotOrder: this.retainedPlotOrder ?? this.api.plotOrder } : {}),
+ };
+ });
+ }
+
+ public addDevice(origin: string, token?: string, restoring = false): void {
+ void this.api
+ .addDevice(origin, token, restoring)
+ .catch((err) => this.reportRefreshError(err));
+ }
+
+ snapshotHistory(): HttpgdHistoryState {
+ return {
+ source: this.source && { ...this.source },
+ plots: this.retainedPlots.flatMap((plot) => {
+ const reference = this.api.plotReference(plot.id);
+ return reference ? [{ ...plot, reference }] : [];
+ }),
+ };
+ }
+
+ snapshotSelection(): HttpgdSelectionState {
+ return { activePlot: this.activePlot, hiddenPlots: [...this.hiddenPlots] };
+ }
+ get plots(): readonly HttpgdPlot[] {
+ return this.visiblePlots;
+ }
+
+ protected setPlots(plots: HttpgdPlot[]): void {
+ this.retainedPlots = plots;
+ const hidden = new Set(this.hiddenPlots);
+ this.visiblePlots = plots.filter((plot) => !hidden.has(plot.id));
+ }
activePlot?: HttpgdPlotId;
hiddenPlots: HttpgdPlotId[] = [];
readonly defaultStripStyles: boolean = true;
@@ -140,8 +593,6 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer {
customOverwriteCssPath?: string;
viewHeight: number = 600;
viewWidth: number = 800;
- plotHeight: number = 600;
- plotWidth: number = 800;
readonly zoom0: number = 1;
zoom: number = this.zoom0;
protected resizeTimeout?: NodeJS.Timeout;
@@ -175,13 +626,40 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer {
const url = new URL(host.includes('://') ? host : `http://${host}`);
this.host = url.host;
this.origin = url.origin;
- this.id = this.host;
+ // A device's port can be reused while this session still owns other
+ // devices. Panel command identity must outlive any individual origin.
+ this.id = `httpgd:${randomUUID()}`;
+ this.historyId = options.historyId ?? randomUUID();
this.token = options.token;
+ this.restoring = options.restoring ?? false;
this.parent = options.parent;
- this.api = new HttpgdClient(this.origin, this.token);
- this.api.onPlotsChanged((newState) => {
- void this.refreshPlotsDelayed(newState.plots).catch((err) => {
+ this.api = new HttpgdDevices(this.origin, this.token, this.restoring, options.plotOrder);
+ for (const connection of options.connections ?? []) {
+ void this.api.addDevice(connection.origin, connection.token, true);
+ }
+ if (options.history) {
+ this.api.restorePlots(options.history.plots);
+ const restored = new Set(this.api.getPlots().map(({ id }) => id));
+ this.hiddenPlots = (options.selection?.hiddenPlots ?? []).filter((id) =>
+ restored.has(id),
+ );
+ const saved = new Map(options.history.plots.map((plot) => [plot.id, plot]));
+ this.setPlots(
+ this.api.getPlots().flatMap(({ id }) => {
+ const plot = saved.get(id);
+ return plot ? [{ ...plot }] : [];
+ }),
+ );
+ this.activePlot =
+ this.plots.find((plot) => plot.id === options.selection?.activePlot)?.id ??
+ this.plots.at(-1)?.id;
+ }
+ this.api.onPlotsChanged((plots, restoring, changed, orderChanged) => {
+ if (orderChanged) {
+ this.parent.scheduleSaveConnections();
+ }
+ void this.refreshPlotsDelayed(plots, false, false, restoring, changed).catch((err) => {
this.reportRefreshError(err);
});
});
@@ -219,8 +697,27 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer {
this.fullWindow = options.fullWindow ?? this.defaultFullWindow;
this.resizeTimeoutLength = options.resizeTimeoutLength ?? this.resizeTimeoutLength;
this.refreshTimeoutLength = options.refreshTimeoutLength ?? this.refreshTimeoutLength;
+ this.bindSession(options.source);
+ this.connect();
+ }
+
+ private get canRefresh(): boolean {
+ return !this.disposed && !this.sessionDisconnected && !this.sessionContext?.hasExited;
+ }
+
+ private connect(): void {
+ if (!this.canRefresh) {
+ return;
+ }
void this.api.connect().catch((err) => {
- if (!this.disposed) {
+ const retainedUnavailable =
+ this.retainedPlots.length > 0 &&
+ ['ECONNREFUSED', 'ECONNRESET'].includes((err as NodeJS.ErrnoException)?.code ?? '');
+ if (
+ this.canRefresh &&
+ !retainedUnavailable &&
+ !(err instanceof Error && err.name === 'AbortError')
+ ) {
void vscode.window.showErrorMessage(`httpgd connection failed: ${String(err)}`);
}
});
@@ -264,7 +761,45 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer {
}
}
+ public bindSession(source?: ViewerSessionSource): void {
+ if (!source || this.source) {
+ return;
+ }
+ this.source = {
+ sessionId: source.sessionId,
+ host: source.host,
+ pid: source.pid,
+ rVer: source.rVer,
+ processExited: source.processExited,
+ };
+ source = this.source;
+ this.sessionContext = getViewerSessionContext(source.sessionId, source);
+ this.sessionConnectionSubscription = onDidChangeSessionConnection((event) => {
+ if (event.session.sessionId !== source.sessionId || this.disposed) {
+ return;
+ }
+ const connected = Boolean(resolvePlotSession(source));
+ if (!connected && !this.sessionDisconnected) {
+ this.sessionDisconnected = true;
+ this.stopRequests();
+ } else if (connected && this.sessionDisconnected) {
+ this.sessionDisconnected = false;
+ // The first snapshot after reconnect is not a new plot.
+ this.restoring = true;
+ this.connect();
+ }
+ });
+ if (this.webviewPanel) {
+ this.sessionContext?.attach(this.webviewPanel);
+ this.refreshHtml();
+ }
+ }
+
public show(preserveFocus?: boolean): void {
+ if (this.disposed) {
+ return;
+ }
+ this.restoring = false;
preserveFocus ??= this.showOptions.preserveFocus;
if (!this.webviewPanel) {
const showOptions = {
@@ -280,9 +815,10 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer {
}
public openExternal(): void {
- const url = new URL('/live', this.origin);
- if (this.token) {
- url.searchParams.set('token', this.token);
+ const connection = this.api.plotConnection(this.activePlot);
+ const url = new URL('/live', connection.origin);
+ if (connection.token) {
+ url.searchParams.set('token', connection.token);
}
// Uri.parse decodes query escapes, including delimiters within a token.
// Preserve URLSearchParams' encoding when handing the URL to VS Code.
@@ -300,14 +836,17 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer {
const plt = this.plots[this.activeIndex];
if (
plt &&
+ this.canRefresh &&
(plt.height !== this.viewHeight ||
plt.width !== this.viewWidth ||
- plt.zoom !== this.zoom)
+ plt.zoom !== this.zoom ||
+ (plt.resizeDeferred && this.api.isActive(plt.id) && this.api.isAvailable(plt.id)))
) {
await this.refreshPlots(this.api.getPlots());
} else {
this._focusPlot();
}
+ this.parent.scheduleSaveConnections(false);
}
protected _focusPlot(plotId?: HttpgdPlotId): void {
plotId ??= this.activePlot;
@@ -346,12 +885,13 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer {
}
const tmpIndex = this.activeIndex;
this.hiddenPlots.push(id);
- this.plots = this.plots.filter((plt) => !this.hiddenPlots.includes(plt.id));
+ this.setPlots(this.retainedPlots);
if (id === this.activePlot) {
this.activeIndex = tmpIndex;
this._focusPlot();
}
this._hidePlot(id);
+ this.parent.scheduleSaveConnections(false);
}
protected _hidePlot(id: HttpgdPlotId): void {
const msg: HidePlotMessage = {
@@ -365,7 +905,20 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer {
id ??= this.activePlot;
if (id) {
this.hidePlot(id);
- await this.api.removePlot({ id: id });
+ if (this.canRefresh) {
+ if (!this.api.isAvailable(id)) {
+ this.api.forgetPlot(id);
+ } else {
+ try {
+ await this.api.removePlot({ id });
+ } catch (error) {
+ if (this.api.isAvailable(id)) {
+ throw error;
+ }
+ this.api.forgetPlot(id);
+ }
+ }
+ }
}
}
@@ -427,14 +980,8 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer {
}
}
- public getPanelPath(): string | undefined {
- if (!this.webviewPanel) {
- return undefined;
- }
- const dummyUri = this.webviewPanel.webview.asWebviewUri(vscode.Uri.file(''));
- const m = /^[^.]*/.exec(dummyUri.authority);
- const webviewId = m?.[0] || '';
- return `webview-panel/webview-${webviewId}`;
+ public get isActive(): boolean {
+ return this.webviewPanel?.active ?? false;
}
protected getIndex(id: HttpgdPlotId): number {
@@ -442,8 +989,12 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer {
}
protected handleResize(height: number, width: number, userTriggered: boolean = false): void {
+ // Keep viewport changes while disconnected for the reconnect snapshot.
this.viewHeight = height;
this.viewWidth = width;
+ if (!this.canRefresh) {
+ return;
+ }
if (userTriggered || this.resizeTimeoutLength === 0) {
if (this.resizeTimeout) {
clearTimeout(this.resizeTimeout);
@@ -460,22 +1011,26 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer {
}
private reportRefreshError(err: unknown): void {
- if (!this.disposed) {
+ if (this.canRefresh && !(err instanceof Error && err.name === 'AbortError')) {
void vscode.window.showErrorMessage(`Plot refresh failed: ${String(err)}`);
}
}
protected async resizePlot(id?: HttpgdPlotId): Promise {
+ if (!this.canRefresh) {
+ return;
+ }
id ??= this.activePlot;
if (!id) {
return;
}
+ const generation = this.requestGeneration;
const plt = await this.getPlotContent(id, this.viewWidth, this.viewHeight, this.zoom);
- if (this.disposed) {
+ if (!this.canRefresh || generation !== this.requestGeneration) {
return;
}
- this.plotWidth = plt.width;
- this.plotHeight = plt.height;
+ this.setPlots(this.retainedPlots.map((plot) => (plot.id === id ? plt : plot)));
+ this.parent.scheduleSaveConnections();
this.updatePlot(plt);
}
@@ -483,56 +1038,208 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer {
plotsIdResponse: HttpgdIdResponse[],
redraw: boolean = false,
force: boolean = false,
+ restoring = this.restoring,
+ changed?: ReadonlySet,
): Promise {
+ if (!this.canRefresh) {
+ return;
+ }
if (this.refreshTimeoutLength === 0) {
- await this.refreshPlots(plotsIdResponse, redraw, force);
+ await this.refreshPlots(plotsIdResponse, redraw, force, restoring, changed);
} else {
+ for (const id of changed ?? []) {
+ this.changedPlots.add(id);
+ }
+ this.pendingReveal ||= !restoring;
clearTimeout(this.refreshTimeout);
this.refreshTimeout = setTimeout(() => {
- void this.refreshPlots(plotsIdResponse, redraw, force)
- .catch((err) => {
- this.reportRefreshError(err);
- })
- .then(() => (this.refreshTimeout = undefined));
+ const changedPlots = changed ? this.changedPlots : undefined;
+ const restoring = !this.pendingReveal;
+ this.changedPlots = new Set();
+ this.pendingReveal = false;
+ this.refreshTimeout = undefined;
+ void this.refreshPlots(
+ plotsIdResponse,
+ redraw,
+ force,
+ restoring,
+ changedPlots,
+ ).catch((err) => {
+ this.reportRefreshError(err);
+ });
}, this.refreshTimeoutLength);
}
}
- protected async refreshPlots(
+ protected refreshPlots(
plotsIdResponse: HttpgdIdResponse[],
redraw: boolean = false,
force: boolean = false,
+ restoring = this.restoring,
+ changed?: ReadonlySet,
): Promise {
- if (this.disposed) {
+ const pending = this.pendingRefresh;
+ this.pendingRefresh = {
+ plots: plotsIdResponse,
+ redraw: redraw || !!pending?.redraw,
+ force: force || !!pending?.force,
+ restoring: restoring && (pending?.restoring ?? true),
+ changed: pending
+ ? changed && pending.changed
+ ? new Set([...pending.changed, ...changed])
+ : undefined
+ : changed,
+ };
+ this.refreshTask ??= Promise.resolve()
+ .then(async () => {
+ while (this.pendingRefresh) {
+ const request = this.pendingRefresh;
+ this.pendingRefresh = undefined;
+ try {
+ await this.applyPlotRefresh(request);
+ } catch (error) {
+ // Keep newer device notifications even when an older render fails.
+ this.reportRefreshError(error);
+ }
+ }
+ })
+ .then(() => {
+ this.refreshTask = undefined;
+ const pending = this.pendingRefresh;
+ if (pending) {
+ return this.refreshPlots(
+ pending.plots,
+ pending.redraw,
+ pending.force,
+ pending.restoring,
+ pending.changed,
+ );
+ }
+ });
+ return this.refreshTask;
+ }
+
+ private async applyPlotRefresh({
+ plots: plotsIdResponse,
+ redraw,
+ force,
+ restoring,
+ changed,
+ }: PlotRefresh): Promise {
+ if (!this.canRefresh) {
return;
}
- const nPlots = this.plots.length;
+ const generation = this.requestGeneration;
const hidden = new Set(this.hiddenPlots);
- const existing = new Map(this.plots.map((plot) => [plot.id, plot]));
- const plotIds = plotsIdResponse.map((x) => x.id).filter((id) => !hidden.has(id));
- const newPlotPromises = plotIds.map(async (id) => {
- const plot = existing.get(id);
- if (force || !plot || id === this.activePlot) {
- return await this.getPlotContent(id, this.viewWidth, this.viewHeight, this.zoom);
- } else {
- return plot;
+ const existing = new Map(this.retainedPlots.map((plot) => [plot.id, plot]));
+ const incoming = new Set(plotsIdResponse.map(({ id }) => id));
+ this.hiddenPlots = this.hiddenPlots.filter((id) => incoming.has(id));
+ for (const tracked of [this.stalePlots, this.failedPlots]) {
+ for (const id of tracked) {
+ if (!incoming.has(id)) {
+ tracked.delete(id);
+ }
+ }
+ }
+ for (const id of changed ?? []) {
+ if (incoming.has(id)) {
+ this.stalePlots.add(id);
+ this.failedPlots.delete(id);
+ }
+ }
+ const refreshIds = [...incoming].filter((id) => {
+ // Hiding a received plot only changes visibility. Keep its drawing
+ // current while R can still render it, even if the device later closes.
+ if (hidden.has(id) && !existing.has(id)) {
+ return false;
+ }
+ // A closed device must not be retried on every other device's update.
+ // Retry on content changes, reactivation, reconnect or explicit refresh.
+ if (this.failedPlots.has(id) && !this.api.isActive(id) && !force) {
+ return false;
}
+ const plot = existing.get(id);
+ return (
+ force ||
+ !plot ||
+ this.stalePlots.has(id) ||
+ (id === this.activePlot &&
+ (!changed ||
+ (plot.resizeDeferred && this.api.isActive(id) && this.api.isAvailable(id))))
+ );
});
- const newPlots = await Promise.all(newPlotPromises);
- if (this.disposed) {
+ const results = await Promise.allSettled(
+ refreshIds.map((id) =>
+ this.getPlotContent(
+ id,
+ this.viewWidth,
+ this.viewHeight,
+ this.zoom,
+ existing.get(id) ?? null,
+ ),
+ ),
+ );
+ if (!this.canRefresh || generation !== this.requestGeneration) {
return;
}
+ const refreshed = new Map>();
+ results.forEach((result, index) => {
+ const id = refreshIds[index];
+ if (result.status === 'fulfilled') {
+ refreshed.set(id, result.value);
+ this.stalePlots.delete(id);
+ this.failedPlots.delete(id);
+ } else {
+ if (!this.failedPlots.has(id)) {
+ this.reportRefreshError(result.reason);
+ }
+ this.failedPlots.add(id);
+ }
+ });
+ // Preserve cached failures, omit unavailable uncached plots, and commit
+ // successful renders from other devices without losing their updates.
const oldPlotIds = new Set(this.plots.map((plt) => plt.id));
- this.plots = newPlots;
- if (this.plots.length !== nPlots) {
+ this.setPlots(
+ [...incoming]
+ .map((id) => refreshed.get(id) ?? existing.get(id))
+ .filter((plot): plot is HttpgdPlot => plot !== undefined),
+ );
+ const newPlotIds = new Set(this.plots.map((plot) => plot.id));
+ this.restoring = false;
+ const added = this.plots.filter((plot) => !oldPlotIds.has(plot.id));
+ this.parent.scheduleSaveConnections();
+ if (
+ (!restoring && added.length) ||
+ !this.plots.some((plot) => plot.id === this.activePlot)
+ ) {
this.activePlot = this.plots[this.plots.length - 1]?.id;
}
- if (redraw || !this.webviewPanel) {
+ // Initial connection replays existing plots. Only a new plot or an
+ // explicit Show Viewer should reopen a closed panel.
+ if (
+ !this.webviewPanel &&
+ !redraw &&
+ (restoring || !this.plots.some((plot) => !oldPlotIds.has(plot.id)))
+ ) {
+ return;
+ }
+ const appendedOrder = [...oldPlotIds]
+ .filter((id) => newPlotIds.has(id))
+ .concat(added.map((plot) => plot.id));
+ const reordered = appendedOrder.some((id, index) => this.plots[index]?.id !== id);
+ if (redraw || !this.webviewPanel || reordered) {
this.refreshHtml();
} else {
+ for (const id of oldPlotIds) {
+ if (!newPlotIds.has(id)) {
+ this._hidePlot(id);
+ }
+ }
for (const plt of this.plots) {
if (oldPlotIds.has(plt.id)) {
- this.updatePlot(plt);
+ if (existing.get(plt.id) !== plt) {
+ this.updatePlot(plt);
+ }
} else {
this.addPlot(plt);
}
@@ -559,8 +1266,6 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer {
html: html,
};
this.postWebviewMessage(msg);
- void this.focusPlot(plt.id);
- void this.setContextValues();
}
protected async getPlotContent(
@@ -568,7 +1273,25 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer {
width: number,
height: number,
zoom: number,
+ cached: HttpgdPlot | null = this.plots.find((plot) => plot.id === id) ?? null,
): Promise> {
+ const active = this.api.isActive(id);
+ const scaled = cached && {
+ ...cached,
+ width,
+ height,
+ zoom,
+ resizeDeferred:
+ cached.resizeDeferred ||
+ cached.width !== width ||
+ cached.height !== height ||
+ cached.zoom !== zoom,
+ };
+ if (scaled && (!this.api.isAvailable(id) || (!active && !this.stalePlots.has(id)))) {
+ // Unchanged inactive plots only need scaling. Changed content must
+ // still be fetched; HttpgdDevices omits resize arguments for it.
+ return scaled;
+ }
const args = {
id: id,
height: height,
@@ -576,16 +1299,29 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer {
zoom: zoom,
renderer: 'svgp',
};
- const svg = await this.api.getPlotText(args);
+ let svg: string;
+ try {
+ svg = await this.api.getPlotText(args);
+ } catch (error) {
+ if (
+ scaled &&
+ ['ECONNREFUSED', 'ECONNRESET'].includes(
+ (error as NodeJS.ErrnoException)?.code ?? '',
+ )
+ ) {
+ return scaled;
+ }
+ throw error;
+ }
const plt: HttpgdPlot = {
id: id,
data: svg,
height: height,
width: width,
zoom: zoom,
+ // Inactive-device requests omit geometry even when fetching changed content.
+ resizeDeferred: !active,
};
- this.viewHeight = plt.height;
- this.viewWidth = plt.width;
return plt;
}
@@ -626,12 +1362,26 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer {
overwriteCssPath = asWebViewPath('styleOverwrites.css');
}
return {
+ plotHeight: this.viewHeight,
+ sessionHtml: this.sessionContext?.getHtml() ?? '',
+ sessionStyle: viewerSessionStyle,
+ codicons:
+ this.webviewPanel?.webview
+ .asWebviewUri(
+ vscode.Uri.joinPath(
+ extensionContext.extensionUri,
+ 'dist',
+ 'resources',
+ 'codicon.css',
+ ),
+ )
+ .toString() ?? '',
overwriteStyles: this.stripStyles,
previewPlotLayout: this.previewPlotLayout,
plots: this.plots,
largePlot: this.plots[this.activeIndex],
activePlot: this.activePlot,
- host: this.host,
+ host: this.id,
asLocalPath: asLocalPath,
asWebViewPath: asWebViewPath,
makeCommandUri: makeWebviewCommandUriString,
@@ -640,6 +1390,7 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer {
}
protected makeNewWebview(showOptions?: ShowOptions): vscode.WebviewPanel {
+ this.parent.registerActiveViewer(this);
const webviewPanel = vscode.window.createWebviewPanel(
'RPlot',
'R Plot',
@@ -647,13 +1398,16 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer {
this.webviewOptions,
);
webviewPanel.iconPath = new UriIcon('graph');
- webviewPanel.onDidDispose(() => (this.webviewPanel = undefined));
+ webviewPanel.onDidDispose(() => {
+ this.webviewPanel = undefined;
+ });
webviewPanel.onDidChangeViewState(() => {
void this.setContextValues();
});
webviewPanel.webview.onDidReceiveMessage((e: OutMessage) => {
this.handleWebviewMessage(e);
});
+ this.sessionContext?.attach(webviewPanel);
return webviewPanel;
}
@@ -680,8 +1434,11 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer {
void vscode.window.showWarningMessage('No plot available for export.');
return;
}
+ const renderers = this.api.isAvailable(plot.id) ? [...this.api.getRenderers(id)] : [];
+ if (!renderers.some((renderer) => renderer.id === 'svgp')) {
+ renderers.push({ id: 'svgp', name: 'SVG', ext: 'svg', descr: 'Retained plot' });
+ }
if (!rendererId) {
- const renderers = this.api.getRenderers();
const qpItems = renderers.map((renderer) => ({
label: renderer.name,
detail: renderer.descr,
@@ -697,7 +1454,7 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer {
}
if (!outFile) {
const options: vscode.SaveDialogOptions = {};
- const renderer = this.api.getRenderers().find((r) => r.id === rendererId);
+ const renderer = renderers.find((r) => r.id === rendererId);
const ext = renderer?.ext.replace(/^\./, '');
if (this.lastExportUri) {
const noExtPath = this.lastExportUri.fsPath.replace(/\.[^.]*$/, '');
@@ -722,7 +1479,25 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer {
}
}
try {
- const bytes = await this.api.getPlotBytes({ id, renderer: rendererId });
+ const svg = rendererId === 'svgp' || rendererId === 'svg';
+ let bytes: Buffer;
+ if (svg && !this.api.isAvailable(plot.id)) {
+ bytes = Buffer.from(plot.data, 'utf8');
+ } else {
+ try {
+ bytes = await this.api.getPlotBytes({ id, renderer: rendererId });
+ } catch (error) {
+ if (
+ !svg ||
+ !['ECONNREFUSED', 'ECONNRESET'].includes(
+ (error as NodeJS.ErrnoException)?.code ?? '',
+ )
+ ) {
+ throw error;
+ }
+ bytes = Buffer.from(plot.data, 'utf8');
+ }
+ }
await fs.promises.writeFile(outFile, bytes);
void vscode.window.showInformationMessage(`Export done: ${outFile}`);
} catch (err) {
@@ -732,10 +1507,44 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer {
}
}
- public dispose(): void {
+ public dispose(keepPanel = false): void {
+ if (this.disposed) {
+ return;
+ }
this.disposed = true;
+ if (!keepPanel) {
+ // The shutdown checkpoint may run after clients and SVG caches are
+ // disposed. Retain only the small ordering metadata for that write.
+ this.retainedPlotOrder = this.api.plotOrder;
+ }
+ this.sessionConnectionSubscription?.dispose();
+ this.stopRequests();
+ if (!keepPanel) {
+ this.webviewPanel?.dispose();
+ }
+ this.webviewPanel = undefined;
+ this.releasePlots();
+ }
+
+ private releasePlots(): void {
+ this.setPlots([]);
+ this.hiddenPlots = [];
+ this.activePlot = undefined;
+ this.stalePlots.clear();
+ this.failedPlots.clear();
+ this.api.releasePlots();
+ }
+
+ private stopRequests(): void {
+ this.requestGeneration++;
+ this.pendingRefresh = undefined;
+ this.changedPlots.clear();
+ this.failedPlots.clear();
+ this.pendingReveal = false;
clearTimeout(this.resizeTimeout);
clearTimeout(this.refreshTimeout);
+ this.resizeTimeout = undefined;
+ this.refreshTimeout = undefined;
this.api.disconnect();
}
}
diff --git a/src/plotViewer/index.ts b/src/plotViewer/index.ts
index 89ccc4f87..42302055c 100644
--- a/src/plotViewer/index.ts
+++ b/src/plotViewer/index.ts
@@ -7,6 +7,13 @@ import { JgdManager } from './jgdViewer';
import { extensionContext } from '../extension';
import { config } from '../util';
import { getMigratedSetting } from '../configuration';
+import { activeSession, resolvePlotSession, Session } from '../session';
+import { sessionProcessIdentity } from '../sessionIdentity';
+import { isViewerSessionSource, ViewerSessionSource } from '../viewerSession';
+import { randomUUID } from 'crypto';
+import type { PlotHistoryState } from './jgdPlotHistory';
+import { PlotLifecycle } from './plotLifecycle';
+import { PlotViewerRegistry } from './plotViewerRegistry';
export type PlotBackend = 'auto' | 'standard' | 'httpgd' | 'jgd' | 'native';
@@ -49,38 +56,87 @@ const commands = [
export class CommonPlotManager implements PlotManager {
public httpgdManager: HttpgdManager;
- public standardPlotViewer: StandardPlotViewer;
public jgdManager: JgdManager;
+ private lifecycle = new PlotLifecycle();
+ private standardViewers = new PlotViewerRegistry((source) => {
+ const key = sessionProcessIdentity(source);
+ this.restoredStandardSessions = this.restoredStandardSessions.filter(
+ (saved) => sessionProcessIdentity(saved) !== key,
+ );
+ void this.saveStandardSources();
+ }, this.lifecycle);
+ private restoredStandardSessions: ViewerSessionSource[] = [];
+ private disposed = false;
+ private historySaveTimer: NodeJS.Timeout | undefined;
+ private historySubscription: { dispose(): void } | undefined;
+ private historyWrites: Promise | undefined;
+ private historyContentDirty = false;
+ private historySelectionDirty = false;
+ private historyCheckpointId: string | undefined;
constructor() {
- this.httpgdManager = new HttpgdManager();
- this.standardPlotViewer = new StandardPlotViewer();
- this.jgdManager = new JgdManager();
+ this.httpgdManager = new HttpgdManager(this.lifecycle);
+ this.jgdManager = new JgdManager(this.lifecycle);
}
get viewers(): PlotViewer[] {
- const viewers: PlotViewer[] = [...this.httpgdManager.viewers];
- const jgdViewer = this.jgdManager.getViewer();
- if (jgdViewer) {
- viewers.push(jgdViewer);
- }
- viewers.push(this.standardPlotViewer);
+ const viewers: PlotViewer[] = this.httpgdManager.getViewers();
+ viewers.push(...this.jgdManager.getViewers());
+ viewers.push(...this.standardViewers.values());
return viewers;
}
get activeViewer(): PlotViewer | undefined {
+ const focused = this.viewers.find((viewer) => viewer.isActive);
+ if (focused) {
+ return focused;
+ }
if (jgdEnabled()) {
- return (
- this.jgdManager.getViewer() ||
- this.httpgdManager.getRecentViewer() ||
- this.standardPlotViewer
- );
+ return this.jgdManager.getViewer() || this.httpgdManager.getRecentViewer();
}
- return this.httpgdManager.getRecentViewer() || this.standardPlotViewer;
+ return this.httpgdManager.getRecentViewer();
}
public initialize(): void {
this.jgdManager.initialize(extensionContext.extensionUri);
+ const saved = extensionContext.workspaceState.get<
+ PlotHistoryState & { checkpointId?: string }
+ >('r.plot.jgdState');
+ const selection = extensionContext.workspaceState.get<{
+ checkpointId?: string;
+ positions?: unknown;
+ }>('r.plot.jgdSelection');
+ this.historyCheckpointId = saved?.checkpointId;
+ const trimmed = this.jgdManager.history.restore(
+ saved,
+ this.historyCheckpointId && selection?.checkpointId === this.historyCheckpointId
+ ? selection.positions
+ : undefined,
+ );
+ this.historyContentDirty =
+ !this.historyCheckpointId ||
+ trimmed ||
+ saved?.sessions.length !== this.jgdManager.history.sessionIds().length;
+ const sources = extensionContext.workspaceState.get('r.plot.standardSessions');
+ this.restoredStandardSessions = Array.isArray(sources)
+ ? sources.filter(isViewerSessionSource)
+ : [];
+ for (const source of this.restoredStandardSessions) {
+ this.lifecycle.observe(source);
+ }
+ this.httpgdManager.initialize();
+ this.historySubscription = this.jgdManager.history.onDidChange((_id, selectionOnly) => {
+ this.historyContentDirty ||= !selectionOnly;
+ this.historySelectionDirty = true;
+ // Checkpoint during normal operation: shutdown-only writes can be
+ // lost when VS Code tears down the extension host's storage bridge.
+ if (!this.historySaveTimer && !this.disposed) {
+ this.historySaveTimer = setTimeout(() => {
+ this.historySaveTimer = undefined;
+ void this.saveJgdHistory();
+ }, 250);
+ }
+ });
for (const cmd of commands) {
const fullCommand = `r.plot.${cmd}`;
@@ -88,7 +144,7 @@ export class CommonPlotManager implements PlotManager {
vscode.commands.registerCommand(
fullCommand,
(hostOrWebviewUri?: string | vscode.Uri, ...args: unknown[]) => {
- void this.handleCommand(cmd, hostOrWebviewUri, ...args);
+ return this.handleCommand(cmd, hostOrWebviewUri, ...args);
},
),
);
@@ -110,6 +166,9 @@ export class CommonPlotManager implements PlotManager {
// Start the JGD server when the backend allows it, so switching to jgd
// takes effect on the next R (re)start without reloading VS Code.
private applyBackend(): void {
+ if (this.disposed) {
+ return;
+ }
const backend = resolveBackend();
void vscode.commands.executeCommand('setContext', 'r.plot.backend', backend);
const envCollection = extensionContext.environmentVariableCollection;
@@ -125,20 +184,124 @@ export class CommonPlotManager implements PlotManager {
}
}
- public async showStandardPlot(): Promise {
- await this.standardPlotViewer.update();
+ public async showStandardPlot(target: Session, restoring = false): Promise {
+ if (this.disposed) {
+ return;
+ }
+ this.lifecycle.observe(target);
+ if (this.lifecycle.hasExited(target)) {
+ return;
+ }
+ const viewer = restoring
+ ? this.standardViewers.get(sessionProcessIdentity(target))
+ : this.getStandardViewer(target);
+ await viewer?.update(restoring);
}
- public async showHttpgdPlot(url: string): Promise {
- await this.httpgdManager.showViewer(url);
+ private getStandardViewer(target: Session): StandardPlotViewer {
+ const existing = this.standardViewers.forSession(target);
+ const viewer = this.standardViewers.getOrCreate(
+ target,
+ target.sessionId,
+ () => new StandardPlotViewer(target),
+ );
+ viewer.bindSession(target);
+ if (!existing) {
+ void this.saveStandardSources();
+ }
+ return viewer;
+ }
+
+ public async showHttpgdPlot(url: string, target?: Session, restoring = false): Promise {
+ const source = target
+ ? {
+ sessionId: target.sessionId,
+ host: target.host,
+ pid: target.pid,
+ rVer: target.rVer,
+ processExited: target.processExited,
+ }
+ : undefined;
+ await this.httpgdManager.showViewer(url, source, false, restoring);
}
public getJgdEnvVars(): Record {
return this.jgdManager.getEnvVars();
}
- public dispose(): void {
+ public async dispose(): Promise {
+ if (this.disposed) {
+ return;
+ }
+ this.disposed = true;
+ clearTimeout(this.historySaveTimer);
+ this.historySubscription?.dispose();
+ this.lifecycle.dispose();
+ const saved = Promise.all([
+ this.saveJgdHistory(),
+ this.saveStandardSources(),
+ this.httpgdManager.saveConnections(),
+ ]);
this.jgdManager.stop();
+ this.standardViewers.dispose();
+ this.httpgdManager.dispose();
+ await saved;
+ }
+
+ private saveJgdHistory(): Promise {
+ // Keep one writer and coalesce changes arriving during storage I/O.
+ // Navigation writes only small cursor metadata, never plot geometry.
+ this.historyWrites ??= Promise.resolve()
+ .then(async () => {
+ while (this.historyContentDirty || this.historySelectionDirty) {
+ const contentDirty = this.historyContentDirty;
+ this.historyContentDirty = false;
+ this.historySelectionDirty = false;
+ if (contentDirty) {
+ const checkpointId = randomUUID();
+ await extensionContext.workspaceState.update('r.plot.jgdState', {
+ ...this.jgdManager.history.snapshot(),
+ checkpointId,
+ });
+ this.historyCheckpointId = checkpointId;
+ }
+ // Tie cursors to their frame checkpoint so an interrupted write
+ // cannot apply old indices to a different set of retained plots.
+ if (!this.historyContentDirty) {
+ await extensionContext.workspaceState.update('r.plot.jgdSelection', {
+ checkpointId: this.historyCheckpointId,
+ positions: this.jgdManager.history.snapshotSelection(),
+ });
+ }
+ }
+ })
+ .catch((error) => {
+ this.historyContentDirty = true;
+ console.warn('[Plot Viewer] Could not save JGD state', error);
+ })
+ .finally(() => {
+ this.historyWrites = undefined;
+ });
+ return this.historyWrites;
+ }
+
+ private async saveStandardSources(): Promise {
+ const standardSources = new Map(
+ this.restoredStandardSessions.map((source) => [sessionProcessIdentity(source), source]),
+ );
+ for (const viewer of this.standardViewers.values()) {
+ if (!this.lifecycle.hasExited(viewer.sessionSource)) {
+ standardSources.set(
+ sessionProcessIdentity(viewer.sessionSource),
+ viewer.sessionSource,
+ );
+ }
+ }
+ await extensionContext.workspaceState
+ .update('r.plot.standardSessions', [...standardSources.values()])
+ .then(undefined, (error) =>
+ console.warn('[Plot Viewer] Could not save standard plot sessions', error),
+ );
}
private async handleCommand(
@@ -147,8 +310,38 @@ export class CommonPlotManager implements PlotManager {
...args: unknown[]
): Promise {
if (command === 'showViewers') {
- for (const viewer of this.viewers) {
- viewer.show(true);
+ const source = activeSession;
+ const target = source && resolvePlotSession(source);
+ if (this.disposed) {
+ return;
+ }
+ if (!target) {
+ void vscode.window.showInformationMessage(
+ 'Focus an attached R session to show its plot viewer.',
+ );
+ return;
+ }
+ if (this.jgdManager.showForSession(target)) {
+ return;
+ }
+ const httpgd = await this.httpgdManager.restoreConnection(target);
+ if (this.disposed) {
+ return;
+ }
+ if (httpgd) {
+ httpgd.show(true);
+ return;
+ }
+ const key = sessionProcessIdentity(target);
+ if (
+ this.standardViewers.get(key) ||
+ this.restoredStandardSessions.some((saved) => sessionProcessIdentity(saved) === key)
+ ) {
+ this.getStandardViewer(target).show(true);
+ } else {
+ void vscode.window.showInformationMessage(
+ 'No plot is available for the active R session.',
+ );
}
return;
}
@@ -161,17 +354,30 @@ export class CommonPlotManager implements PlotManager {
// Identify the correct viewer
let viewer: PlotViewer | undefined;
if (typeof hostOrWebviewUri === 'string') {
- viewer = this.httpgdManager.viewers.find(
- (v: HttpgdViewer) => v.host === hostOrWebviewUri,
- );
- } else if (hostOrWebviewUri instanceof vscode.Uri) {
- viewer = this.httpgdManager.viewers.find(
- (v: HttpgdViewer) => v.getPanelPath() === hostOrWebviewUri.path,
- );
+ const viewers = this.httpgdManager.getViewers();
+ viewer =
+ viewers.find((v: HttpgdViewer) => v.id === hostOrWebviewUri) ??
+ viewers.find((v) =>
+ v.connections.some((connection) => {
+ const url = new URL(connection.url);
+ return url.host === hostOrWebviewUri || url.origin === hostOrWebviewUri;
+ }),
+ );
+ } else if (
+ hostOrWebviewUri instanceof vscode.Uri &&
+ hostOrWebviewUri.scheme === 'webview-panel'
+ ) {
+ // Editor toolbar actions belong to the focused panel. Resource URLs
+ // returned by asWebviewUri are shared across panels, not editor IDs.
+ // Use only a currently focused live viewer, never the recent fallback:
+ // an ended session can still have its rendered tab open.
+ viewer = this.viewers.find((candidate) => candidate.isActive);
}
- // Fallback to active viewer
- viewer ||= this.activeViewer;
+ // A command from an ended session's retained tab must not target another session.
+ if (hostOrWebviewUri === undefined) {
+ viewer = this.activeViewer;
+ }
if (viewer) {
await viewer.handleCommand(command, ...args);
diff --git a/src/plotViewer/jgdPlotHistory.ts b/src/plotViewer/jgdPlotHistory.ts
index a7679d470..394cedfdd 100644
--- a/src/plotViewer/jgdPlotHistory.ts
+++ b/src/plotViewer/jgdPlotHistory.ts
@@ -1,4 +1,6 @@
import { EventEmitter } from 'events';
+import { sessionProcessIdentity } from '../sessionIdentity';
+import { isViewerSessionSource, ViewerSessionSource } from '../viewerSession';
export interface PlotFrame {
version: number;
@@ -14,23 +16,332 @@ export interface PlotFrame {
rIndex?: number;
}
-interface SessionHistory {
+interface DeviceHistory {
plots: PlotFrame[];
- currentIndex: number;
latestDeleted: boolean;
+ label?: string;
+ source?: ViewerSessionSource;
+}
+
+interface Timeline {
+ plots: PlotFrame[];
+ currentIndex: number;
+ devices: Set;
+}
+
+export interface PlotHistoryState {
+ activeSessionId: string;
+ sessions: Array;
+ timelines?: Array<{
+ deviceId: string;
+ plots: Array<{ deviceId: string; index: number }>;
+ currentIndex: number;
+ }>;
}
export class PlotHistory {
- private sessions: Map = new Map();
+ private sessions: Map = new Map();
+ // Device histories index replay targets; timelines own session navigation.
+ // Both reference the same frames, so operations are never copied for grouping.
+ private timelines = new Map();
+ private owners = new Map();
+ private order = new WeakMap();
+ private sequence = 0;
+ // Checkpoints own immutable copies. Reuse unchanged frames across saves,
+ // while the live history remains mutable for incremental drawing.
+ private snapshots = new WeakMap();
private activeSessionId: string = '';
private maxPlots: number;
private emitter = new EventEmitter();
constructor(maxPlots: number = 50) {
- this.maxPlots = maxPlots;
+ this.maxPlots = Math.max(1, Math.floor(maxPlots) || 50);
+ }
+
+ sessionIds(): string[] {
+ return [...this.sessions.keys()];
+ }
+
+ sessionLabel(sessionId: string): string | undefined {
+ return this.sessions.get(sessionId)?.label;
+ }
+
+ sessionSource(sessionId: string): ViewerSessionSource | undefined {
+ return this.sessions.get(sessionId)?.source;
+ }
+
+ removeSession(source: ViewerSessionSource): void {
+ const key = sessionProcessIdentity(source);
+ const timeline = this.owners.get(key);
+ if (!timeline) {
+ return;
+ }
+ for (const id of timeline.devices) {
+ this.sessions.delete(id);
+ this.timelines.delete(id);
+ }
+ this.owners.delete(key);
+ if (timeline.devices.has(this.activeSessionId)) {
+ this.activeSessionId = '';
+ }
+ this.emitter.emit('change');
+ }
+
+ private retainedTimelines(): Timeline[] {
+ return [...new Set(this.timelines.values())].filter(
+ (timeline) =>
+ !this.sessionSource(timeline.devices.values().next().value!)?.processExited,
+ );
+ }
+
+ restoreDeviceIdentity(previousId: string, id: string, source: ViewerSessionSource): void {
+ const device = this.sessions.get(previousId);
+ if (
+ !device?.source ||
+ this.sessions.has(id) ||
+ sessionProcessIdentity(device.source) !== sessionProcessIdentity(source)
+ ) {
+ return;
+ }
+ // Explicit jgd() devices first acquire a sess logical ID on reconnect.
+ // Transfer their existing frame index and timeline membership together.
+ const timeline = this.timelines.get(previousId)!;
+ this.sessions.delete(previousId);
+ this.sessions.set(id, device);
+ this.timelines.delete(previousId);
+ this.timelines.set(id, timeline);
+ timeline.devices.delete(previousId);
+ timeline.devices.add(id);
+ for (const plot of device.plots) {
+ plot.sessionId = id;
+ this.snapshots.delete(plot);
+ }
+ if (this.activeSessionId === previousId) {
+ this.activeSessionId = id;
+ }
+ this.emitter.emit('change', id);
+ }
+
+ setSessionSource(sessionId: string, source: ViewerSessionSource): void {
+ const history = this.sessions.get(sessionId);
+ if (history && !history.source) {
+ history.source = { ...source };
+ this.bindOwner(sessionId, source);
+ this.emitter.emit('change', sessionId);
+ }
+ }
+
+ setSessionLabel(sessionId: string, label: string): void {
+ label = label.slice(0, 256);
+ const history = this.sessions.get(sessionId);
+ if (history && history.label !== label) {
+ history.label = label;
+ this.emitter.emit('change', sessionId);
+ }
+ }
+
+ snapshot(): PlotHistoryState {
+ return {
+ activeSessionId: this.sessionSource(this.activeSessionId)?.processExited
+ ? ''
+ : this.activeSessionId,
+ sessions: [...this.sessions]
+ .filter(([, history]) => !history.source?.processExited)
+ .map(([id, history]) => ({
+ id,
+ ...history,
+ source: history.source ? { ...history.source } : undefined,
+ plots: history.plots.map((plot) => {
+ let snapshot = this.snapshots.get(plot);
+ if (!snapshot) {
+ snapshot = structuredClone(plot);
+ this.snapshots.set(plot, snapshot);
+ }
+ return snapshot;
+ }),
+ currentIndex: Math.max(
+ history.plots.length ? 0 : -1,
+ history.plots.indexOf(this.currentPlot(id)!),
+ ),
+ })),
+ timelines: this.retainedTimelines().map((timeline) => ({
+ deviceId: timeline.devices.values().next().value!,
+ plots: timeline.plots.map((plot) => ({
+ deviceId: plot.sessionId,
+ index: this.sessions.get(plot.sessionId)!.plots.indexOf(plot),
+ })),
+ currentIndex: timeline.currentIndex,
+ })),
+ };
+ }
+
+ snapshotSelection(): Array<{ deviceId: string; currentIndex: number }> {
+ return this.retainedTimelines().map((timeline) => ({
+ deviceId: timeline.devices.values().next().value!,
+ currentIndex: timeline.currentIndex,
+ }));
+ }
+
+ /** Returns whether enforcing the history limit changed the saved content. */
+ restore(value: unknown, selection?: unknown): boolean {
+ if (!value || typeof value !== 'object') {
+ return false;
+ }
+ const state = value as Partial;
+ if (!Array.isArray(state.sessions)) {
+ return false;
+ }
+ let trimmed = false;
+ for (const entry of state.sessions) {
+ if (isViewerSessionSource(entry?.source) && entry.source.processExited) {
+ trimmed = true;
+ continue;
+ }
+ if (
+ !entry ||
+ typeof entry.id !== 'string' ||
+ !entry.id ||
+ !Array.isArray(entry.plots) ||
+ !entry.plots.every(isPlotFrame) ||
+ !Number.isInteger(entry.currentIndex) ||
+ entry.currentIndex < -1 ||
+ entry.currentIndex >= entry.plots.length ||
+ (entry.plots.length > 0 && entry.currentIndex < 0) ||
+ typeof entry.latestDeleted !== 'boolean'
+ ) {
+ continue;
+ }
+ const plots = entry.plots.map((plot) => ({
+ ...structuredClone(plot),
+ sessionId: entry.id,
+ }));
+ for (const plot of plots) {
+ this.order.set(plot, this.sequence++);
+ }
+ this.timelines.set(entry.id, {
+ plots: [...plots],
+ currentIndex: entry.currentIndex,
+ devices: new Set([entry.id]),
+ });
+ this.sessions.set(entry.id, {
+ plots,
+ latestDeleted: entry.latestDeleted,
+ label: typeof entry.label === 'string' ? entry.label.slice(0, 256) : undefined,
+ source: isViewerSessionSource(entry.source) ? { ...entry.source } : undefined,
+ });
+ }
+ this.activeSessionId =
+ state.activeSessionId && this.sessions.has(state.activeSessionId)
+ ? state.activeSessionId
+ : '';
+ // Older snapshots only stored per-device histories. Group those by owner;
+ // newer snapshots also retain the exact interleaving and session cursor.
+ for (const [id, device] of this.sessions) {
+ if (device.source) {
+ this.bindOwner(id, device.source, false);
+ }
+ }
+ if (Array.isArray(state.timelines)) {
+ for (const saved of state.timelines) {
+ const timeline = this.timelines.get(saved?.deviceId);
+ if (!timeline || !Array.isArray(saved.plots)) {
+ continue;
+ }
+ const plots = saved.plots.map((ref) =>
+ ref && this.timelines.get(ref.deviceId) === timeline
+ ? this.sessions.get(ref.deviceId)?.plots[ref.index]
+ : undefined,
+ );
+ if (
+ plots.length !== timeline.plots.length ||
+ plots.some((plot) => !plot) ||
+ new Set(plots).size !== plots.length ||
+ !Number.isInteger(saved.currentIndex) ||
+ saved.currentIndex < (plots.length ? 0 : -1) ||
+ saved.currentIndex >= plots.length
+ ) {
+ continue;
+ }
+ timeline.plots = plots as PlotFrame[];
+ timeline.currentIndex = saved.currentIndex;
+ }
+ }
+ if (Array.isArray(selection)) {
+ for (const entry of selection as unknown[]) {
+ if (!entry || typeof entry !== 'object') {
+ continue;
+ }
+ const saved = entry as Partial<{ deviceId: string; currentIndex: number }>;
+ const timeline =
+ typeof saved.deviceId === 'string'
+ ? this.timelines.get(saved.deviceId)
+ : undefined;
+ if (
+ timeline &&
+ typeof saved.currentIndex === 'number' &&
+ Number.isInteger(saved.currentIndex) &&
+ saved.currentIndex >= (timeline.plots.length ? 0 : -1) &&
+ saved.currentIndex < timeline.plots.length
+ ) {
+ timeline.currentIndex = saved.currentIndex;
+ }
+ }
+ }
+ for (const timeline of new Set(this.timelines.values())) {
+ for (const plot of timeline.plots) {
+ this.order.set(plot, this.sequence++);
+ }
+ trimmed ||= timeline.plots.length > this.maxPlots;
+ this.trim(timeline);
+ }
+ this.emitter.emit('change');
+ return trimmed;
+ }
+
+ private bindOwner(id: string, source: ViewerSessionSource, trim = true): void {
+ const incoming = this.timelines.get(id)!;
+ const key = sessionProcessIdentity(source);
+ const existing = this.owners.get(key);
+ if (!existing) {
+ this.owners.set(key, incoming);
+ } else if (existing !== incoming) {
+ const selected = incoming.devices.has(this.activeSessionId)
+ ? incoming.plots[incoming.currentIndex]
+ : existing.plots[existing.currentIndex];
+ existing.plots.push(...incoming.plots);
+ existing.plots.sort((a, b) => this.order.get(a)! - this.order.get(b)!);
+ existing.currentIndex = selected
+ ? existing.plots.indexOf(selected)
+ : existing.plots.length - 1;
+ for (const device of incoming.devices) {
+ existing.devices.add(device);
+ this.timelines.set(device, existing);
+ }
+ if (trim) {
+ this.trim(existing);
+ }
+ }
+ }
+
+ private trim(timeline: Timeline): void {
+ while (timeline.plots.length > this.maxPlots) {
+ this.removeFrame(timeline.plots.shift()!);
+ timeline.currentIndex = Math.max(0, timeline.currentIndex - 1);
+ }
}
- onDidChange(listener: () => void): { dispose(): void } {
+ private removeFrame(plot: PlotFrame): void {
+ const device = this.sessions.get(plot.sessionId)!;
+ const index = device.plots.indexOf(plot);
+ if (index === device.plots.length - 1) {
+ device.latestDeleted = true;
+ }
+ device.plots.splice(index, 1);
+ }
+
+ onDidChange(listener: (sessionId?: string, selectionOnly?: boolean) => void): {
+ dispose(): void;
+ } {
this.emitter.on('change', listener);
return {
dispose: () => {
@@ -42,32 +353,25 @@ export class PlotHistory {
addPlot(sessionId: string, plot: PlotFrame) {
let session = this.sessions.get(sessionId);
if (!session) {
- session = { plots: [], currentIndex: -1, latestDeleted: false };
+ session = { plots: [], latestDeleted: false };
this.sessions.set(sessionId, session);
+ this.timelines.set(sessionId, {
+ plots: [],
+ currentIndex: -1,
+ devices: new Set([sessionId]),
+ });
}
+ plot.sessionId = sessionId;
+ this.order.set(plot, this.sequence++);
session.latestDeleted = false;
session.plots.push(plot);
- while (session.plots.length > this.maxPlots) {
- session.plots.shift();
- }
- session.currentIndex = session.plots.length - 1;
- this.activeSessionId = sessionId;
- this.emitter.emit('change');
- }
-
- replaceCurrent(sessionId: string, plot: PlotFrame) {
- const session = this.sessions.get(sessionId);
- if (!session || session.plots.length === 0) {
- return this.addPlot(sessionId, plot);
- }
- const old = session.plots[session.currentIndex];
- if (old?.rIndex !== undefined) {
- plot.rIndex = old.rIndex;
- }
- session.plots[session.currentIndex] = plot;
+ const timeline = this.timelines.get(sessionId)!;
+ timeline.plots.push(plot);
+ timeline.currentIndex = timeline.plots.length - 1;
+ this.trim(timeline);
this.activeSessionId = sessionId;
- this.emitter.emit('change');
+ this.emitter.emit('change', sessionId);
}
appendOps(sessionId: string, plot: PlotFrame): boolean {
@@ -80,6 +384,7 @@ export class PlotHistory {
return true;
}
const latest = session.plots[session.plots.length - 1];
+ this.snapshots.delete(latest);
const newOps = plot.ops || [];
for (const op of newOps) {
latest.ops.push(op);
@@ -89,7 +394,7 @@ export class PlotHistory {
latest.frameExt = plot.frameExt;
}
this.activeSessionId = sessionId;
- this.emitter.emit('change');
+ this.emitter.emit('change', sessionId);
return true;
}
@@ -103,9 +408,8 @@ export class PlotHistory {
return false;
}
plot.rIndex = rIndex;
- session.plots[idx] = plot;
- this.activeSessionId = sessionId;
- this.emitter.emit('change');
+ this.replaceFrame(session.plots[idx], plot);
+ this.emitter.emit('change', sessionId);
return true;
}
@@ -131,14 +435,22 @@ export class PlotHistory {
} else if (expectedRIndex !== undefined) {
plot.rIndex = expectedRIndex;
}
- session.plots[session.plots.length - 1] = plot;
- this.activeSessionId = sessionId;
- this.emitter.emit('change');
+ this.replaceFrame(old, plot);
+ this.emitter.emit('change', sessionId);
return true;
}
- currentPlot(): PlotFrame | null {
- const session = this.sessions.get(this.activeSessionId);
+ private replaceFrame(old: PlotFrame, plot: PlotFrame): void {
+ // Preserve reference identity in both indexes and the original arrival order.
+ const rIndex = old.rIndex ?? plot.rIndex;
+ this.snapshots.delete(old);
+ Object.assign(old, plot, { sessionId: old.sessionId, rIndex });
+ }
+
+ // Navigation accepts any device ID in the session; replay updates above
+ // remain device-specific because R plot indices are only unique per device.
+ currentPlot(sessionId = this.activeSessionId): PlotFrame | null {
+ const session = this.timelines.get(sessionId);
if (!session || session.currentIndex < 0) {
return null;
}
@@ -149,23 +461,42 @@ export class PlotHistory {
return this.sessions.get(sessionId)?.plots.at(-1);
}
- navigatePrevious(): PlotFrame | null {
- const session = this.sessions.get(this.activeSessionId);
- if (!session || session.currentIndex <= 0) {
- return null;
+ invalidateReplayIndices(sessionId: string): void {
+ for (const plot of this.sessions.get(sessionId)?.plots ?? []) {
+ delete plot.rIndex;
+ this.snapshots.delete(plot);
}
- session.currentIndex--;
- this.emitter.emit('change');
- return session.plots[session.currentIndex];
+ this.emitter.emit('change', sessionId);
}
- navigateNext(): PlotFrame | null {
- const session = this.sessions.get(this.activeSessionId);
- if (!session || session.currentIndex >= session.plots.length - 1) {
+ navigatePrevious(sessionId = this.activeSessionId): PlotFrame | null {
+ return this.navigateTo(this.currentIndex(sessionId) - 2, sessionId);
+ }
+
+ navigateNext(sessionId = this.activeSessionId): PlotFrame | null {
+ return this.navigateTo(this.currentIndex(sessionId), sessionId);
+ }
+
+ navigateFirst(sessionId = this.activeSessionId): PlotFrame | null {
+ return this.navigateTo(0, sessionId);
+ }
+
+ navigateLast(sessionId = this.activeSessionId): PlotFrame | null {
+ return this.navigateTo(this.count(sessionId) - 1, sessionId);
+ }
+
+ private navigateTo(index: number, sessionId: string): PlotFrame | null {
+ const session = this.timelines.get(sessionId);
+ if (
+ !session ||
+ index < 0 ||
+ index >= session.plots.length ||
+ index === session.currentIndex
+ ) {
return null;
}
- session.currentIndex++;
- this.emitter.emit('change');
+ session.currentIndex = index;
+ this.emitter.emit('change', sessionId, true);
return session.plots[session.currentIndex];
}
@@ -173,55 +504,79 @@ export class PlotHistory {
return this.activeSessionId;
}
- currentIndex(): number {
- const session = this.sessions.get(this.activeSessionId);
+ currentIndex(sessionId = this.activeSessionId): number {
+ const session = this.timelines.get(sessionId);
return session ? session.currentIndex + 1 : 0;
}
- currentRIndex(): number | undefined {
- const plot = this.currentPlot();
+ currentRIndex(sessionId = this.activeSessionId): number | undefined {
+ const plot = this.currentPlot(sessionId);
return plot?.rIndex;
}
- count(): number {
- const session = this.sessions.get(this.activeSessionId);
+ count(sessionId = this.activeSessionId): number {
+ const session = this.timelines.get(sessionId);
return session ? session.plots.length : 0;
}
- clear() {
- const session = this.sessions.get(this.activeSessionId);
- if (session) {
- session.plots = [];
- session.currentIndex = -1;
- session.latestDeleted = false;
+ clear(sessionId = this.activeSessionId) {
+ const timeline = this.timelines.get(sessionId);
+ if (timeline) {
+ for (const id of timeline.devices) {
+ const device = this.sessions.get(id)!;
+ device.latestDeleted = device.latestDeleted || device.plots.length > 0;
+ device.plots = [];
+ }
+ timeline.plots = [];
+ timeline.currentIndex = -1;
}
- this.emitter.emit('change');
+ this.emitter.emit('change', sessionId);
}
- isLatestDeleted(): boolean {
- const session = this.sessions.get(this.activeSessionId);
+ isLatestDeleted(sessionId = this.activeSessionId): boolean {
+ const session = this.sessions.get(sessionId);
return session ? session.latestDeleted : false;
}
- removeCurrent(): PlotFrame | null {
- const session = this.sessions.get(this.activeSessionId);
+ removeCurrent(sessionId = this.activeSessionId): PlotFrame | null {
+ const session = this.timelines.get(sessionId);
if (!session || session.plots.length === 0) {
return null;
}
- const wasLatest = session.currentIndex === session.plots.length - 1;
- session.plots.splice(session.currentIndex, 1);
- if (wasLatest) {
- session.latestDeleted = true;
- }
+ this.removeFrame(session.plots.splice(session.currentIndex, 1)[0]);
if (session.plots.length === 0) {
session.currentIndex = -1;
- this.emitter.emit('change');
+ this.emitter.emit('change', sessionId);
return null;
}
if (session.currentIndex >= session.plots.length) {
session.currentIndex = session.plots.length - 1;
}
- this.emitter.emit('change');
+ this.emitter.emit('change', sessionId);
return session.plots[session.currentIndex];
}
}
+
+function isPlotFrame(value: unknown): value is PlotFrame {
+ if (!value || typeof value !== 'object') {
+ return false;
+ }
+ const frame = value as Partial;
+ const device = frame.device;
+ return (
+ typeof frame.version === 'number' &&
+ typeof frame.sessionId === 'string' &&
+ Array.isArray(frame.ops) &&
+ Boolean(
+ device &&
+ Number.isFinite(device.width) &&
+ device.width > 0 &&
+ Number.isFinite(device.height) &&
+ device.height > 0 &&
+ Number.isFinite(device.dpi) &&
+ device.dpi > 0 &&
+ (device.bg === null || typeof device.bg === 'string'),
+ ) &&
+ (frame.rIndex === undefined || (Number.isInteger(frame.rIndex) && frame.rIndex >= 0))
+ );
+}
diff --git a/src/plotViewer/jgdSocketServer.ts b/src/plotViewer/jgdSocketServer.ts
index 29e7f100b..9edfefd75 100644
--- a/src/plotViewer/jgdSocketServer.ts
+++ b/src/plotViewer/jgdSocketServer.ts
@@ -4,10 +4,12 @@ import * as path from 'path';
import * as fs from 'fs';
import * as crypto from 'crypto';
import { PlotHistory, PlotFrame } from './jgdPlotHistory';
+import { isViewerSessionSource, ViewerSessionSource } from '../viewerSession';
+import { sessionProcessIdentity } from '../sessionIdentity';
const SERVER_NAME = 'jgd-vscode';
-export type ConnectionChangeListener = (count: number) => void;
+export type ConnectionChangeListener = (count: number, sessionId: string) => void;
export interface JgdMessage {
type: string;
@@ -25,25 +27,33 @@ export interface JgdMessage {
gc?: Record;
}
+interface ResizeRequest {
+ width: number;
+ height: number;
+ plotIndex?: number;
+}
+
interface RSession {
id: string;
socket: net.Socket;
buffer: string;
welcomeSent: boolean;
- lastResizeW: number;
- lastResizeH: number;
- lastResizeHadPlotIndex: boolean;
+ lastResize?: ResizeRequest;
+ pendingResize?: ResizeRequest;
+ queuedResize?: ResizeRequest;
dpi: number;
+ deviceClosed?: boolean;
+ source?: ViewerSessionSource;
}
const isWindows = process.platform === 'win32';
export interface JgdMeasureText {
- (request: JgdMessage, dpi: number): Promise;
+ (request: JgdMessage, dpi: number, sessionId: string): Promise;
}
export interface JgdGetDimensions {
- (): { width: number; height: number } | null;
+ (sessionId: string): { width: number; height: number } | null;
}
export class JgdSocketServer {
@@ -52,10 +62,8 @@ export class JgdSocketServer {
private socketDir: string = '';
private sessions: Map = new Map();
private connectionListeners: ConnectionChangeListener[] = [];
- private sessionCounter = 0;
private readyListeners: (() => void)[] = [];
- private resizeListener: ((w: number, h: number) => void) | null = null;
private measureTextFn: JgdMeasureText | null = null;
private getDimensionsFn: JgdGetDimensions | null = null;
private onFrameFn: ((sessionId: string, msg: JgdMessage) => void) | null = null;
@@ -79,10 +87,6 @@ export class JgdSocketServer {
this.connectionListeners.push(listener);
}
- setResizeListener(listener: (w: number, h: number) => void) {
- this.resizeListener = listener;
- }
-
setMeasureText(fn: JgdMeasureText) {
this.measureTextFn = fn;
}
@@ -99,26 +103,39 @@ export class JgdSocketServer {
this.onDeviceClosedFn = fn;
}
- handleResize(w: number, h: number) {
- const idx = this.history.currentIndex();
- const total = this.history.count();
- if ((total > 0 && idx < total) || (total > 0 && this.history.isLatestDeleted())) {
- const rIndex = this.history.currentRIndex();
- if (rIndex !== undefined) {
- const sessionId = this.history.getActiveSessionId();
- this.broadcastResize(w, h, rIndex, sessionId);
- } else {
- this.broadcastResize(w, h);
+ handleResize(w: number, h: number, sessionId: string) {
+ const plot = this.history.currentPlot(sessionId);
+ const deviceId = plot?.sessionId ?? sessionId;
+ if (
+ plot &&
+ (plot !== this.history.latestPlot(deviceId) || this.history.isLatestDeleted(deviceId))
+ ) {
+ if (plot.rIndex !== undefined) {
+ this.broadcastResize(w, h, plot.rIndex, deviceId);
}
} else {
- this.broadcastResize(w, h);
+ this.broadcastResize(w, h, undefined, deviceId);
+ }
+ }
+
+ isConnected(sessionId: string): boolean {
+ const session = this.sessions.get(sessionId);
+ return !!session && !session.deviceClosed;
+ }
+
+ disconnect(sessionId: string): void {
+ const session = this.sessions.get(sessionId);
+ if (session) {
+ this.sessions.delete(sessionId);
+ session.socket.destroy();
+ this.notifyConnectionChange(sessionId);
}
}
- private notifyConnectionChange() {
+ private notifyConnectionChange(sessionId: string) {
const count = this.sessions.size;
for (const l of this.connectionListeners) {
- l(count);
+ l(count, sessionId);
}
}
@@ -191,7 +208,8 @@ export class JgdSocketServer {
}
private handleConnection(socket: net.Socket) {
- const sessionId = `session-${++this.sessionCounter}`;
+ // A new device must never acquire a restored device's replay indices.
+ const sessionId = `session-${crypto.randomUUID()}`;
// Metrics requests omit DPI. Use JGD's default until a frame announces
// the device's resolution, then keep it specific to this connection.
const session: RSession = {
@@ -199,13 +217,10 @@ export class JgdSocketServer {
socket,
buffer: '',
welcomeSent: false,
- lastResizeW: 0,
- lastResizeH: 0,
- lastResizeHadPlotIndex: false,
dpi: 96,
};
this.sessions.set(sessionId, session);
- this.notifyConnectionChange();
+ this.notifyConnectionChange(sessionId);
socket.on('data', (data) => {
session.buffer += data.toString();
@@ -224,13 +239,13 @@ export class JgdSocketServer {
serverName: SERVER_NAME,
protocolVersion: 1,
transport: isWindows ? 'npipe' : 'unix',
+ serverInfo: { deviceId: sessionId },
};
socket.write(JSON.stringify(welcome) + '\n');
- const dims = this.getDimensionsFn?.();
+ const dims = this.getDimensionsFn?.(sessionId);
if (dims) {
- session.lastResizeW = dims.width;
- session.lastResizeH = dims.height;
+ session.lastResize = dims;
socket.write(
JSON.stringify({
type: 'resize',
@@ -246,27 +261,79 @@ export class JgdSocketServer {
});
socket.on('close', () => {
- this.sessions.delete(sessionId);
- this.notifyConnectionChange();
+ if (this.sessions.get(session.id) === session) {
+ this.sessions.delete(session.id);
+ this.notifyConnectionChange(session.id);
+ }
});
socket.on('error', (err) => {
console.error(`jgd session ${sessionId} error:`, err.message);
- this.sessions.delete(sessionId);
- this.notifyConnectionChange();
+ if (this.sessions.get(session.id) === session) {
+ this.sessions.delete(session.id);
+ this.notifyConnectionChange(session.id);
+ }
});
}
private handleMessage(session: RSession, line: string) {
+ if (this.sessions.get(session.id) !== session) {
+ return;
+ }
try {
const msg = JSON.parse(line) as JgdMessage;
switch (msg.type) {
case 'frame': {
const plot = msg.plot;
if (plot) {
+ const identity = msg.ext?.vscodeR;
+ let restoring = false;
+ if (
+ isViewerSessionSource(identity) &&
+ 'deviceId' in identity &&
+ typeof identity.deviceId === 'string' &&
+ identity.deviceId.length > 0 &&
+ identity.deviceId.length <= 256
+ ) {
+ const id = `session-${sessionProcessIdentity(identity)}:${identity.deviceId}`;
+ if (!session.source) {
+ this.sessions.delete(session.id);
+ this.sessions.get(id)?.socket.destroy();
+ session.id = id;
+ session.source = {
+ sessionId: identity.sessionId,
+ host: identity.host,
+ pid: identity.pid,
+ rVer: identity.rVer,
+ processExited: false,
+ };
+ if (
+ 'restoring' in identity &&
+ identity.restoring === true &&
+ 'previousDeviceId' in identity &&
+ typeof identity.previousDeviceId === 'string' &&
+ !this.isConnected(identity.previousDeviceId)
+ ) {
+ this.history.restoreDeviceIdentity(
+ identity.previousDeviceId,
+ id,
+ session.source,
+ );
+ }
+ this.sessions.set(id, session);
+ // Historical R replay indices belong to the old
+ // physical device, not this replacement socket.
+ this.history.invalidateReplayIndices(id);
+ }
+ restoring =
+ session.id === id &&
+ 'restoring' in identity &&
+ identity.restoring === true;
+ }
if (Number.isFinite(plot.device?.dpi) && plot.device.dpi > 0) {
session.dpi = plot.device.dpi;
}
+ const deviceLabel = plot.sessionId;
plot.sessionId = session.id;
plot.frameExt = msg.ext ?? null;
@@ -276,7 +343,23 @@ export class JgdSocketServer {
? msg.plotIndex
: undefined;
+ // A frame reports what R actually drew. A resize read by
+ // JGD during drawing can be buffered without waking its
+ // idle handler; it must not permanently suppress a new
+ // request after the frame arrives.
+ const queuedResize = session.queuedResize;
+ session.pendingResize = undefined;
+ session.queuedResize = undefined;
+ session.lastResize = {
+ width: plot.device.width,
+ height: plot.device.height,
+ plotIndex: isResizeReplay ? plotIndex : undefined,
+ };
+
let accepted = true;
+ // JGD snapshots retain frame extensions, including
+ // restoring=true. Resize routing must take precedence
+ // so historical replays cannot replace the latest plot.
if (isResizeReplay && plotIndex !== undefined) {
accepted = this.history.replaceAtIndex(
session.id,
@@ -294,6 +377,11 @@ export class JgdSocketServer {
plot as PlotFrame,
plotNumber,
);
+ } else if (restoring) {
+ plot.rIndex = msg.plotNumber;
+ accepted = msg.incremental
+ ? this.history.appendOps(session.id, plot as PlotFrame)
+ : this.history.replaceLatest(session.id, plot as PlotFrame);
} else if (msg.incremental) {
accepted = this.history.appendOps(session.id, plot as PlotFrame);
} else if (msg.newPage) {
@@ -305,18 +393,34 @@ export class JgdSocketServer {
}
this.history.addPlot(session.id, plot as PlotFrame);
} else {
- this.history.replaceLatest(session.id, plot as PlotFrame);
+ accepted = this.history.replaceLatest(session.id, plot as PlotFrame);
}
if (accepted) {
+ if (session.source) {
+ this.history.setSessionSource(session.id, session.source);
+ }
+ if (typeof deviceLabel === 'string' && deviceLabel) {
+ this.history.setSessionLabel(session.id, deviceLabel);
+ }
this.onFrameFn?.(session.id, msg);
}
+ // The viewer callback may have requested a newer size.
+ // Otherwise send only the last size queued during replay.
+ if (queuedResize && !session.pendingResize) {
+ this.broadcastResize(
+ queuedResize.width,
+ queuedResize.height,
+ queuedResize.plotIndex,
+ session.id,
+ );
+ }
}
break;
}
case 'metrics_request':
if (this.measureTextFn) {
- void this.measureTextFn(msg, session.dpi)
+ void this.measureTextFn(msg, session.dpi, session.id)
.then((response: unknown) => {
const resp = JSON.stringify(response) + '\n';
session.socket.write(resp);
@@ -329,6 +433,7 @@ export class JgdSocketServer {
case 'close':
console.log(`jgd: session ${session.id} device closed`);
+ session.deviceClosed = true;
this.onDeviceClosedFn?.(session.id);
break;
@@ -348,33 +453,23 @@ export class JgdSocketServer {
}
private broadcastResize(w: number, h: number, plotIndex?: number, sessionId?: string) {
- if (plotIndex !== undefined) {
- if (!sessionId) {
- return;
- }
- const session = this.sessions.get(sessionId);
- if (!session) {
- return;
- }
- session.lastResizeW = w;
- session.lastResizeH = h;
- session.lastResizeHadPlotIndex = true;
- const data = JSON.stringify({ type: 'resize', width: w, height: h, plotIndex }) + '\n';
- session.socket.write(data);
+ const session = sessionId ? this.sessions.get(sessionId) : undefined;
+ if (!session || session.deviceClosed) {
return;
}
-
- const data = JSON.stringify({ type: 'resize', width: w, height: h }) + '\n';
- for (const session of this.sessions.values()) {
- if (session.lastResizeW === w && session.lastResizeH === h) {
- if (!session.lastResizeHadPlotIndex) {
- continue;
- }
- }
- session.lastResizeHadPlotIndex = false;
- session.lastResizeW = w;
- session.lastResizeH = h;
- session.socket.write(data);
+ const request = { width: w, height: h, plotIndex };
+ if (session.pendingResize) {
+ session.queuedResize = request;
+ return;
+ }
+ if (
+ session.lastResize?.width === w &&
+ session.lastResize.height === h &&
+ session.lastResize.plotIndex === plotIndex
+ ) {
+ return;
}
+ session.pendingResize = request;
+ session.socket.write(JSON.stringify({ type: 'resize', ...request }) + '\n');
}
}
diff --git a/src/plotViewer/jgdViewer.ts b/src/plotViewer/jgdViewer.ts
index 5714c835c..8fa8c93e1 100644
--- a/src/plotViewer/jgdViewer.ts
+++ b/src/plotViewer/jgdViewer.ts
@@ -3,6 +3,20 @@ import { PlotViewer } from './types';
import { PlotHistory, PlotFrame } from './jgdPlotHistory';
import { JgdSocketServer, JgdMessage } from './jgdSocketServer';
import { config, UriIcon } from '../util';
+import {
+ getViewerSessionContext,
+ onDidChangeSessionConnection,
+ resolveJgdSessionSource,
+} from '../session';
+import {
+ getViewerSessionScript,
+ viewerSessionStyle,
+ ViewerSessionContext,
+ ViewerSessionSource,
+} from '../viewerSession';
+import { sessionProcessIdentity } from '../sessionIdentity';
+import { PlotLifecycle } from './plotLifecycle';
+import { PlotViewerRegistry } from './plotViewerRegistry';
interface MetricsRequest {
id: number;
@@ -80,6 +94,7 @@ interface WebviewDeleteCurrent {
}
type WebviewMessage =
+ | { type: 'ready' }
| WebviewMetricsResponse
| WebviewMetricsWarmup
| WebviewExportData
@@ -91,19 +106,63 @@ type WebviewMessage =
export class JgdManager {
public server: JgdSocketServer;
public history: PlotHistory;
- private viewer: JgdViewer | null = null;
+ private viewers: PlotViewerRegistry;
private extensionUri: vscode.Uri | null = null;
private historyChangeDisposable: { dispose(): void } | null = null;
+ private sessionConnectionDisposable: vscode.Disposable | undefined;
private started = false;
+ private receivedPlots = new WeakSet();
- constructor() {
+ constructor(lifecycle?: PlotLifecycle) {
const maxPlots = config().get('plot.jgd.historyLimit', 50);
this.history = new PlotHistory(maxPlots);
this.server = new JgdSocketServer(this.history);
+ this.viewers = new PlotViewerRegistry((source) => {
+ const key = sessionProcessIdentity(source);
+ for (const id of this.history.sessionIds()) {
+ const owner = this.history.sessionSource(id);
+ if (owner && sessionProcessIdentity(owner) === key) {
+ this.server.disconnect(id);
+ }
+ }
+ this.history.removeSession(source);
+ }, lifecycle);
}
initialize(extensionUri: vscode.Uri) {
this.extensionUri = extensionUri;
+ // Observe history owners even when their viewer is closed or only restored
+ // from storage. A transport disconnect alone never proves process exit.
+ this.historyChangeDisposable = this.history.onDidChange((id, selectionOnly) => {
+ if (!selectionOnly) {
+ for (const device of id ? [id] : this.history.sessionIds()) {
+ const source = this.history.sessionSource(device);
+ if (source) {
+ this.viewers.lifecycle.observe(source);
+ }
+ }
+ }
+ if (id) {
+ this.findViewer(id)?.updateToolbar();
+ } else {
+ for (const viewer of this.viewers.values()) {
+ viewer.updateToolbar();
+ }
+ }
+ });
+ this.sessionConnectionDisposable = onDidChangeSessionConnection((event) => {
+ if (event.kind === 'connected') {
+ // Binding an owner can rekey or merge viewers during this pass.
+ const viewers = Array.from(this.viewers.values());
+ for (const viewer of viewers) {
+ const id = viewer.sessionId;
+ if (this.server.isConnected(id) && !this.history.sessionSource(id)) {
+ viewer.updateToolbar();
+ this.getOrCreateViewer(id);
+ }
+ }
+ }
+ });
}
start() {
@@ -111,80 +170,151 @@ export class JgdManager {
return;
}
this.started = true;
- this.server.setOnFrame((_sessionId, msg: JgdMessage) => {
- const current = this.history.currentPlot();
+ this.server.setOnFrame((sessionId, message) => {
+ // Resolve the owner before reading the session cursor: the first frame
+ // from an explicit jgd() device may join an existing timeline here.
+ this.sourceForDevice(sessionId);
+ const current = this.history.currentPlot(sessionId);
if (current) {
- this.getOrCreateViewer().showPlot(current);
- } else if (msg.plot) {
- this.getOrCreateViewer().showPlot(msg.plot as PlotFrame);
+ const metadata = message.ext?.vscodeR;
+ const restoring = Boolean(
+ metadata &&
+ typeof metadata === 'object' &&
+ 'restoring' in metadata &&
+ metadata.restoring === true,
+ );
+ const latest = this.history.latestPlot(sessionId)!;
+ const newPlot = !this.receivedPlots.has(latest);
+ this.receivedPlots.add(latest);
+ // Receiving more drawing for an existing plot updates retained
+ // content without reopening a panel the user closed.
+ this.getOrCreateViewer(sessionId).showPlot(
+ current,
+ newPlot && !restoring && !message.resizeReplay,
+ );
}
});
- this.server.setOnDeviceClosed((_sessionId) => {
- this.viewer?.updateToolbar();
+ this.server.setOnDeviceClosed((sessionId) => {
+ this.findViewer(sessionId)?.updateToolbar();
});
+ this.server.onConnectionChange((_count, sessionId) =>
+ this.findViewer(sessionId)?.updateToolbar(),
+ );
- this.server.setMeasureText((request: JgdMessage) => {
- return this.getOrCreateViewer().measureText(request as unknown as MetricsRequest);
+ this.server.setMeasureText((request: JgdMessage, _dpi, sessionId) => {
+ // Metrics can arrive before the first frame identifies the device.
+ // They must not create an ownerless viewer for the temporary socket ID.
+ // Known devices share their owner's renderer even when that viewer
+ // is displaying another device. Measuring must not switch the plot.
+ const viewer =
+ this.findViewer(sessionId) ?? this.viewers.get(this.viewerKey(sessionId));
+ return (
+ viewer?.measureText(request as unknown as MetricsRequest) ??
+ Promise.resolve({
+ type: 'metrics_response',
+ id: request.id,
+ width: 0,
+ ascent: 0,
+ descent: 0,
+ })
+ );
});
- this.server.setGetDimensions(() => {
- return this.viewer?.getPanelDimensions() ?? null;
+ this.server.setGetDimensions((sessionId) => {
+ return this.findViewer(sessionId)?.getPanelDimensions() ?? null;
});
this.server.start();
-
- this.historyChangeDisposable = this.history.onDidChange(() => {
- void vscode.commands.executeCommand(
- 'setContext',
- 'r.plot.canGoBack',
- this.history.currentIndex() > 1,
- );
- void vscode.commands.executeCommand(
- 'setContext',
- 'r.plot.canGoForward',
- this.history.currentIndex() < this.history.count(),
- );
- });
}
stop() {
this.started = false;
this.server.stop();
this.historyChangeDisposable?.dispose();
- this.viewer?.dispose();
- this.viewer = null;
+ this.sessionConnectionDisposable?.dispose();
+ this.viewers.dispose();
}
- getViewer(): JgdViewer | null {
- return this.viewer;
+ getViewers(): JgdViewer[] {
+ return [...this.viewers.values()];
+ }
+
+ showForSession(source: ViewerSessionSource): boolean {
+ const key = sessionProcessIdentity(source);
+ const ids = this.history.sessionIds().filter((id) => {
+ const owner = this.sourceForDevice(id);
+ return owner && sessionProcessIdentity(owner) === key && this.history.count(id) > 0;
+ });
+ const current = this.viewers.get(key)?.sessionId ?? this.history.getActiveSessionId();
+ const id = ids.includes(current) ? current : ids.at(-1);
+ if (!id) {
+ return false;
+ }
+ this.getOrCreateViewer(id).show(true);
+ return true;
+ }
+
+ private sourceForDevice(id: string): ViewerSessionSource | undefined {
+ const source =
+ this.history.sessionSource(id) ??
+ (this.server.isConnected(id)
+ ? resolveJgdSessionSource(this.history.sessionLabel(id) ?? '')
+ : undefined);
+ if (source) {
+ this.history.setSessionSource(id, source);
+ }
+ return source;
+ }
+
+ private viewerKey(id: string): string {
+ const source = this.sourceForDevice(id);
+ return source ? sessionProcessIdentity(source) : id;
+ }
+
+ private findViewer(id: string): JgdViewer | undefined {
+ const source = this.history.sessionSource(id);
+ return this.viewers.get(id) ?? this.viewers.forSession(source);
+ }
+
+ getViewer(sessionId = this.history.getActiveSessionId()): JgdViewer | null {
+ return sessionId && this.extensionUri ? this.getOrCreateViewer(sessionId) : null;
}
getEnvVars(): Record {
return this.server.getEnvVars();
}
- private getOrCreateViewer(): JgdViewer {
- if (!this.viewer) {
- this.viewer = new JgdViewer(this.extensionUri!, this.history, this.server);
- }
- return this.viewer;
+ private getOrCreateViewer(sessionId: string): JgdViewer {
+ const viewer = this.viewers.getOrCreate(
+ this.sourceForDevice(sessionId),
+ sessionId,
+ () => new JgdViewer(this.extensionUri!, this.history, this.server, sessionId),
+ );
+ viewer.sessionId = sessionId;
+ return viewer;
}
}
export class JgdViewer implements PlotViewer {
- readonly id = 'jgd';
+ get id(): string {
+ return `jgd:${this.sessionId}`;
+ }
private panel: vscode.WebviewPanel | null = null;
private pendingMetrics: Map void> = new Map();
private metricsIdCounter = 0;
private metricsCache: Map = new Map();
private panelWidth = 800;
private panelHeight = 600;
+ private lastToolbarState: string | undefined;
+ private lastPanelVisibility: boolean | undefined;
+ private sessionContext: ViewerSessionContext | undefined;
constructor(
private extensionUri: vscode.Uri,
private history: PlotHistory,
private server: JgdSocketServer,
+ public sessionId: string,
) {}
show(preserveFocus?: boolean): void {
@@ -193,21 +323,29 @@ export class JgdViewer implements PlotViewer {
} else {
this.createPanel(preserveFocus);
}
- const plot = this.history.currentPlot();
+ const plot = this.history.currentPlot(this.sessionId);
if (plot) {
this.sendPlotToWebview(plot);
}
+ this.updateToolbar();
+ }
+
+ get isActive(): boolean {
+ return this.panel?.active ?? false;
}
- dispose(): void {
- this.panel?.dispose();
+ dispose(keepPanel = false): void {
+ if (!keepPanel) {
+ this.panel?.dispose();
+ }
this.panel = null;
+ this.metricsCache.clear();
}
async handleCommand(command: string, ...args: unknown[]): Promise {
switch (command) {
case 'nextPlot': {
- const plot = this.history.navigateNext();
+ const plot = this.history.navigateNext(this.sessionId);
if (plot) {
this.sendPlotToWebview(plot);
this.updateToolbar();
@@ -215,13 +353,13 @@ export class JgdViewer implements PlotViewer {
plot.device.width !== this.panelWidth ||
plot.device.height !== this.panelHeight
) {
- this.server.handleResize(this.panelWidth, this.panelHeight);
+ this.server.handleResize(this.panelWidth, this.panelHeight, this.sessionId);
}
}
break;
}
case 'prevPlot': {
- const plot = this.history.navigatePrevious();
+ const plot = this.history.navigatePrevious(this.sessionId);
if (plot) {
this.sendPlotToWebview(plot);
this.updateToolbar();
@@ -229,40 +367,26 @@ export class JgdViewer implements PlotViewer {
plot.device.width !== this.panelWidth ||
plot.device.height !== this.panelHeight
) {
- this.server.handleResize(this.panelWidth, this.panelHeight);
+ this.server.handleResize(this.panelWidth, this.panelHeight, this.sessionId);
}
}
break;
}
case 'firstPlot': {
- let plot = this.history.navigatePrevious();
- while (plot) {
- const prev = this.history.navigatePrevious();
- if (!prev) {
- break;
- }
- plot = prev;
- }
+ const plot = this.history.navigateFirst(this.sessionId);
if (plot) {
this.sendPlotToWebview(plot);
this.updateToolbar();
- this.server.handleResize(this.panelWidth, this.panelHeight);
+ this.server.handleResize(this.panelWidth, this.panelHeight, this.sessionId);
}
break;
}
case 'lastPlot': {
- let plot = this.history.navigateNext();
- while (plot) {
- const next = this.history.navigateNext();
- if (!next) {
- break;
- }
- plot = next;
- }
+ const plot = this.history.navigateLast(this.sessionId);
if (plot) {
this.sendPlotToWebview(plot);
this.updateToolbar();
- this.server.handleResize(this.panelWidth, this.panelHeight);
+ this.server.handleResize(this.panelWidth, this.panelHeight, this.sessionId);
}
break;
}
@@ -276,7 +400,7 @@ export class JgdViewer implements PlotViewer {
}
case 'closePlot':
case 'hidePlot': {
- const plot = this.history.removeCurrent();
+ const plot = this.history.removeCurrent(this.sessionId);
if (plot) {
this.sendPlotToWebview(plot);
} else {
@@ -286,7 +410,7 @@ export class JgdViewer implements PlotViewer {
break;
}
case 'resetPlots':
- this.history.clear();
+ this.history.clear(this.sessionId);
void this.panel?.webview.postMessage({ type: 'clear' });
this.updateToolbar();
break;
@@ -303,26 +427,79 @@ export class JgdViewer implements PlotViewer {
}
}
- showPlot(plot: PlotFrame) {
+ showPlot(plot: PlotFrame, reveal = true) {
if (!this.panel) {
+ if (!reveal) {
+ return;
+ }
this.createPanel(true);
}
this.sendPlotToWebview(plot);
this.updateToolbar();
+ // A device can join an already-sized panel without a ResizeObserver event.
+ // The server deduplicates requests per device while R is still drawing.
+ if (plot.device.width !== this.panelWidth || plot.device.height !== this.panelHeight) {
+ this.server.handleResize(this.panelWidth, this.panelHeight, this.sessionId);
+ }
}
- updateToolbar() {
- void this.panel?.webview.postMessage({
+ updateToolbar(force = false) {
+ if (this.resolveSessionContext() && this.panel) {
+ this.sessionContext?.attach(this.panel);
+ this.panel.webview.html = this.getWebviewHtml();
+ this.lastToolbarState = undefined;
+ }
+ if (!this.panel) {
+ return;
+ }
+ const message = {
type: 'toolbar',
- current: this.history.currentIndex(),
- total: this.history.count(),
- });
+ current: this.history.currentIndex(this.sessionId),
+ total: this.history.count(this.sessionId),
+ };
+ const state = JSON.stringify(message);
+ if (!force && state === this.lastToolbarState) {
+ return;
+ }
+ this.lastToolbarState = state;
+ void this.panel.webview.postMessage(message);
+ if (this.panel.active) {
+ void vscode.commands.executeCommand(
+ 'setContext',
+ 'r.plot.canGoBack',
+ message.current > 1,
+ );
+ void vscode.commands.executeCommand(
+ 'setContext',
+ 'r.plot.canGoForward',
+ message.current < message.total,
+ );
+ }
}
getPanelDimensions(): { width: number; height: number } {
return { width: this.panelWidth, height: this.panelHeight };
}
+ private resolveSessionContext(): boolean {
+ if (this.sessionContext) {
+ return false;
+ }
+ const saved = this.history.sessionSource(this.sessionId);
+ // Restored, disconnected devices must never bind to a reused PID.
+ const source =
+ saved ??
+ (this.server.isConnected(this.sessionId)
+ ? resolveJgdSessionSource(this.history.sessionLabel(this.sessionId) ?? '')
+ : undefined);
+ if (!source) {
+ return false;
+ }
+ this.sessionContext = getViewerSessionContext(source.sessionId, source);
+ this.history.setSessionSource(this.sessionId, source);
+ return Boolean(this.sessionContext);
+ }
+
private canonicalizeFamily(family: string | undefined): string {
if (!family || family === '' || family === 'sans') {
return 'sans-serif';
@@ -438,6 +615,7 @@ export class JgdViewer implements PlotViewer {
}
private createPanel(preserveFocus = false) {
+ this.resolveSessionContext();
const viewColumnConfig =
config().get>('session.viewers.viewColumn') ?? {};
const plotColumn = viewColumnConfig['plot'] ?? 'Two';
@@ -459,10 +637,20 @@ export class JgdViewer implements PlotViewer {
);
this.panel.iconPath = new UriIcon('graph');
- this.panel.webview.html = this.getWebviewHtml();
-
this.panel.webview.onDidReceiveMessage((raw: WebviewMessage) => {
+ if (!this.panel) {
+ return;
+ }
switch (raw.type) {
+ case 'ready': {
+ this.updateVisibility(true);
+ const plot = this.history.currentPlot(this.sessionId);
+ if (plot) {
+ this.sendPlotToWebview(plot);
+ }
+ this.updateToolbar(true);
+ break;
+ }
case 'metrics_response': {
const resolver = this.pendingMetrics.get(raw.id);
if (resolver) {
@@ -496,7 +684,7 @@ export class JgdViewer implements PlotViewer {
case 'resize': {
this.panelWidth = raw.width;
this.panelHeight = raw.height;
- this.server.handleResize(raw.width, raw.height);
+ this.server.handleResize(raw.width, raw.height, this.sessionId);
break;
}
case 'navigate': {
@@ -521,7 +709,15 @@ export class JgdViewer implements PlotViewer {
this.panel.onDidDispose(() => {
this.panel = null;
this.metricsCache.clear();
+ this.lastToolbarState = undefined;
+ this.lastPanelVisibility = undefined;
});
+ this.panel.onDidChangeViewState(() => {
+ this.updateVisibility();
+ this.updateToolbar(true);
+ });
+ this.sessionContext?.attach(this.panel);
+ this.panel.webview.html = this.getWebviewHtml();
}
private async handleExportRequest(format: 'png' | 'svg') {
@@ -560,7 +756,15 @@ export class JgdViewer implements PlotViewer {
}
private sendPlotToWebview(plot: PlotFrame) {
- void this.panel?.webview.postMessage({ type: 'render', plot });
+ void this.panel?.webview.postMessage({ type: 'render', plot, visible: this.panel.visible });
+ }
+
+ private updateVisibility(force = false) {
+ if (!this.panel || (!force && this.panel.visible === this.lastPanelVisibility)) {
+ return;
+ }
+ this.lastPanelVisibility = this.panel.visible;
+ void this.panel.webview.postMessage({ type: 'visibility', visible: this.panel.visible });
}
private async handleExportData(msg: WebviewExportData) {
@@ -585,12 +789,17 @@ export class JgdViewer implements PlotViewer {
}
private getWebviewHtml(): string {
+ const webview = this.panel!.webview;
+ const codicons = webview.asWebviewUri(
+ vscode.Uri.joinPath(this.extensionUri, 'dist', 'resources', 'codicon.css'),
+ );
return `
-
+
+
@@ -623,6 +837,7 @@ canvas { display: block; }
+ ${this.sessionContext?.getHtml() ?? ''}
@@ -639,13 +854,16 @@ ${getRendererScript()}
function getRendererScript(): string {
return `
const vscode = acquireVsCodeApi();
+${getViewerSessionScript()}
const canvas = document.getElementById('plot-canvas');
const ctx = canvas.getContext('2d');
const metricsCanvas = document.getElementById('metrics-canvas');
const metricsCtx = metricsCanvas.getContext('2d');
let currentPlot = null;
-
+// A retained tab can be hidden without changing document.hidden.
+// VS Code's panel state is authoritative for whether this layout is usable.
+let panelVisible = false;
document.getElementById('btn-prev').addEventListener('click', () => {
vscode.postMessage({ type: 'navigate', direction: 'previous' });
});
@@ -667,21 +885,41 @@ const container = document.getElementById('canvas-container');
let resizeTimer = null;
let lastSentW = 0;
let lastSentH = 0;
+function reportViewportSize() {
+ if (!panelVisible || document.hidden) return;
+ const w = container.clientWidth;
+ const h = container.clientHeight;
+ if (w <= 0 || h <= 0) return;
+ if (w !== lastSentW || h !== lastSentH) {
+ lastSentW = w;
+ lastSentH = h;
+ vscode.postMessage({ type: 'resize', width: w, height: h });
+ }
+}
const resizeObserver = new ResizeObserver(() => {
+ if (!panelVisible || document.hidden) return;
if (currentPlot) replay(currentPlot);
clearTimeout(resizeTimer);
- resizeTimer = setTimeout(() => {
- const w = container.clientWidth;
- const h = container.clientHeight;
- if (w !== lastSentW || h !== lastSentH) {
- lastSentW = w;
- lastSentH = h;
- vscode.postMessage({ type: 'resize', width: w, height: h });
- }
- }, 300);
+ resizeTimer = setTimeout(reportViewportSize, 300);
});
resizeObserver.observe(container);
+function refreshVisibility() {
+ if (!panelVisible || document.hidden) {
+ clearTimeout(resizeTimer);
+ ++replayGeneration;
+ return;
+ }
+ // A retained, hidden webview has a temporary layout. Its visible size
+ // may be unchanged, so ResizeObserver alone cannot restore the canvas.
+ requestAnimationFrame(() => {
+ if (!panelVisible || document.hidden) return;
+ if (currentPlot) replay(currentPlot);
+ reportViewportSize();
+ });
+}
+document.addEventListener('visibilitychange', refreshVisibility);
+
(function warmupMetrics() {
const fonts = [
{ size: 12, family: 'sans-serif', face: 1 },
@@ -717,8 +955,13 @@ window.addEventListener('message', (event) => {
switch (msg.type) {
case 'render':
currentPlot = msg.plot;
+ panelVisible = msg.visible;
replay(msg.plot);
break;
+ case 'visibility':
+ panelVisible = msg.visible;
+ refreshVisibility();
+ break;
case 'clear':
currentPlot = null;
ctx.clearRect(0, 0, canvas.width, canvas.height);
@@ -739,6 +982,8 @@ window.addEventListener('message', (event) => {
}
});
+vscode.postMessage({ type: 'ready' });
+
function applyGc(ctx, gc) {
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 1;
@@ -862,7 +1107,7 @@ async function replay(plot) {
}
async function doReplay(plot, gen) {
- if (replayGeneration !== gen) return;
+ if (replayGeneration !== gen || !panelVisible || document.hidden) return;
const dpr = window.devicePixelRatio || 1;
const containerW = container.clientWidth;
diff --git a/src/plotViewer/plotLifecycle.ts b/src/plotViewer/plotLifecycle.ts
new file mode 100644
index 000000000..ffc20215a
--- /dev/null
+++ b/src/plotViewer/plotLifecycle.ts
@@ -0,0 +1,73 @@
+import { getViewerSessionContext } from '../session';
+import { sessionProcessIdentity } from '../sessionIdentity';
+import type { ViewerSessionSource } from '../viewerSession';
+
+/** Confirmed R exit triggers full cleanup, regardless of panel visibility.
+ * Disposing the extension or losing its transport does not end an R session.
+ */
+export class PlotLifecycle {
+ private subscriptions = new Map
();
+ private exited = new Set();
+ private listeners = new Set<(source: ViewerSessionSource) => void>();
+ private disposed = false;
+
+ onExit(listener: (source: ViewerSessionSource) => void): { dispose(): void } {
+ this.listeners.add(listener);
+ return { dispose: () => this.listeners.delete(listener) };
+ }
+
+ hasExited(source?: ViewerSessionSource): boolean {
+ return (
+ !!source && (source.processExited || this.exited.has(sessionProcessIdentity(source)))
+ );
+ }
+
+ observe(source?: ViewerSessionSource): void {
+ if (!source || this.disposed) {
+ return;
+ }
+ const key = sessionProcessIdentity(source);
+ if (this.exited.has(key) || this.subscriptions.has(key)) {
+ return;
+ }
+ // Session adapters can clear PID metadata during detach. Keep the
+ // original process identity for the eventual exit callback.
+ const captured: ViewerSessionSource = {
+ sessionId: source.sessionId,
+ host: source.host,
+ pid: source.pid,
+ rVer: source.rVer,
+ processExited: source.processExited,
+ };
+ const context = getViewerSessionContext(captured.sessionId, captured);
+ const subscription = context?.observeExit(() => this.markExited(captured));
+ if (subscription) {
+ this.subscriptions.set(key, subscription);
+ }
+ if (source.processExited || context?.hasExited) {
+ this.markExited(captured);
+ }
+ }
+
+ markExited(source: ViewerSessionSource): void {
+ const key = sessionProcessIdentity(source);
+ if (this.disposed || this.exited.has(key)) {
+ return;
+ }
+ this.exited.add(key);
+ this.subscriptions.get(key)?.dispose();
+ this.subscriptions.delete(key);
+ for (const listener of this.listeners) {
+ listener(source);
+ }
+ }
+
+ dispose(): void {
+ this.disposed = true;
+ for (const subscription of this.subscriptions.values()) {
+ subscription.dispose();
+ }
+ this.subscriptions.clear();
+ this.listeners.clear();
+ }
+}
diff --git a/src/plotViewer/plotViewerRegistry.ts b/src/plotViewer/plotViewerRegistry.ts
new file mode 100644
index 000000000..8ecdef2e4
--- /dev/null
+++ b/src/plotViewer/plotViewerRegistry.ts
@@ -0,0 +1,100 @@
+import { sessionProcessIdentity } from '../sessionIdentity';
+import type { ViewerSessionSource } from '../viewerSession';
+import type { PlotViewer } from './types';
+import { PlotLifecycle } from './plotLifecycle';
+
+/** One viewer per R process, including devices whose owner is identified later. */
+export class PlotViewerRegistry {
+ private viewers = new Map();
+ private recent: T[] = [];
+ readonly lifecycle: PlotLifecycle;
+ private readonly ownsLifecycle: boolean;
+ private readonly exitSubscription: { dispose(): void };
+
+ constructor(onExit: (source: ViewerSessionSource) => void, lifecycle?: PlotLifecycle) {
+ this.ownsLifecycle = !lifecycle;
+ this.lifecycle = lifecycle ?? new PlotLifecycle();
+ this.exitSubscription = this.lifecycle.onExit((source) => {
+ const viewer = this.forSession(source);
+ if (viewer) {
+ this.remove(viewer);
+ viewer.dispose(true);
+ }
+ // Backing data may exist even when no viewer was opened.
+ onExit(source);
+ });
+ }
+
+ get(key: string): T | undefined {
+ return this.viewers.get(key);
+ }
+
+ forSession(source?: ViewerSessionSource): T | undefined {
+ return source ? this.get(sessionProcessIdentity(source)) : undefined;
+ }
+
+ values(): T[] {
+ return [...this.viewers.values()];
+ }
+
+ get recentlyActive(): readonly T[] {
+ return this.recent;
+ }
+
+ markActive(viewer: T): void {
+ this.recent = this.recent.filter((active) => active !== viewer);
+ this.recent.unshift(viewer);
+ }
+
+ remove(viewer: T): void {
+ for (const [key, value] of this.viewers) {
+ if (value === viewer) {
+ this.viewers.delete(key);
+ }
+ }
+ this.recent = this.recent.filter((active) => active !== viewer);
+ }
+
+ register(viewer: T, source?: ViewerSessionSource, deviceId = viewer.id): void {
+ const key = source ? sessionProcessIdentity(source) : deviceId;
+ // Preserve recent focus when moving an ownerless viewer to its owner.
+ if (this.viewers.get(key) !== viewer) {
+ for (const [oldKey, value] of this.viewers) {
+ if (value === viewer) {
+ this.viewers.delete(oldKey);
+ }
+ }
+ }
+ this.viewers.delete(key);
+ this.viewers.set(key, viewer);
+ this.lifecycle.observe(source);
+ if (this.lifecycle.hasExited(source) && this.viewers.get(key) === viewer) {
+ this.remove(viewer);
+ viewer.dispose(true);
+ }
+ }
+
+ getOrCreate(source: ViewerSessionSource | undefined, deviceId: string, create: () => T): T {
+ let viewer = this.forSession(source) ?? this.get(deviceId);
+ const previous = this.get(deviceId);
+ if (previous && previous !== viewer) {
+ this.remove(previous);
+ previous.dispose();
+ }
+ viewer ??= create();
+ this.register(viewer, source, deviceId);
+ return viewer;
+ }
+
+ dispose(): void {
+ this.exitSubscription.dispose();
+ if (this.ownsLifecycle) {
+ this.lifecycle.dispose();
+ }
+ for (const viewer of this.viewers.values()) {
+ viewer.dispose();
+ }
+ // Shutdown writers still read the owners/endpoints for live R sessions.
+ // Permanent exit instead removes entries immediately in the handler above.
+ }
+}
diff --git a/src/plotViewer/standardViewer.ts b/src/plotViewer/standardViewer.ts
index 7b4efa239..76cd175b8 100644
--- a/src/plotViewer/standardViewer.ts
+++ b/src/plotViewer/standardViewer.ts
@@ -2,6 +2,13 @@ import * as vscode from 'vscode';
import { asViewColumn, config, UriIcon } from '../util';
import * as session from '../session';
import { PlotViewer } from './types';
+import {
+ getViewerSessionScript,
+ viewerSessionStyle,
+ ViewerSessionContext,
+ ViewerSessionSource,
+} from '../viewerSession';
+import { extensionContext } from '../extension';
interface PlotResponse {
data: string;
@@ -11,7 +18,7 @@ interface PlotResponse {
interface PendingPlotRequest {
panel: vscode.WebviewPanel;
panelGeneration: number;
- targetSession: session.Session | undefined;
+ targetSession: session.Session;
width: number;
height: number;
format: string;
@@ -23,13 +30,41 @@ export class StandardPlotViewer implements PlotViewer {
private panel: vscode.WebviewPanel | undefined;
private viewWidth: number = 800;
private viewHeight: number = 600;
- private plotData: string | undefined;
- private plotFormat: string | undefined;
private panelGeneration = 0;
private pendingPlotRequest: PendingPlotRequest | undefined;
private plotRequest: Promise | undefined;
- public async update(): Promise {
+ private source!: ViewerSessionSource;
+ private sessionContext: ViewerSessionContext | undefined;
+
+ get sessionSource(): ViewerSessionSource {
+ return this.source;
+ }
+
+ constructor(owner: session.Session) {
+ this.bindSession(owner);
+ }
+
+ bindSession(owner: session.Session): void {
+ this.source = {
+ sessionId: owner.sessionId,
+ host: owner.host,
+ pid: owner.pid,
+ rVer: owner.rVer,
+ processExited: owner.processExited,
+ };
+ }
+
+ get isActive(): boolean {
+ return this.panel?.active ?? false;
+ }
+
+ public async update(restoring = false): Promise {
+ if (restoring) {
+ // Refresh retained panels without creating or revealing a viewer.
+ await this.requestPlot();
+ return;
+ }
const viewColumn = asViewColumn(
config().get('session.viewers.viewColumn.plot'),
vscode.ViewColumn.Two,
@@ -43,6 +78,16 @@ export class StandardPlotViewer implements PlotViewer {
}
public show(preserveFocus?: boolean): void {
+ if (!this.panel && !this.source.processExited && !this.sessionContext?.hasExited) {
+ this.createPanel(
+ asViewColumn(
+ config().get('session.viewers.viewColumn.plot'),
+ vscode.ViewColumn.Two,
+ ),
+ );
+ } else if (this.panel) {
+ void this.requestPlot();
+ }
if (this.panel) {
this.panel.reveal(undefined, preserveFocus);
}
@@ -52,12 +97,15 @@ export class StandardPlotViewer implements PlotViewer {
// Contextual plot commands are not supported by the standard viewer.
}
- public dispose(): void {
+ public dispose(keepPanel = false): void {
const panel = this.panel;
if (panel) {
this.panel = undefined;
this.panelGeneration++;
- panel.dispose();
+ this.pendingPlotRequest = undefined;
+ if (!keepPanel) {
+ panel.dispose();
+ }
}
}
@@ -105,23 +153,27 @@ export class StandardPlotViewer implements PlotViewer {
});
// Install the receiver before starting the document so its first ready
// message cannot be missed, even without a visible layout/resize event.
+ this.sessionContext = session.getViewerSessionContext(this.source.sessionId, this.source);
+ this.sessionContext?.attach(panel);
panel.webview.html = this.getHtml();
}
private requestPlot(): Promise {
const panel = this.panel;
- if (!session.globalPipePath || !panel) {
+ const targetSession = session.resolvePlotSession(this.source);
+ if (!panel || !targetSession) {
return Promise.resolve();
}
+ const conf = config();
this.pendingPlotRequest = {
panel,
panelGeneration: this.panelGeneration,
- targetSession: session.activeSession,
+ targetSession,
width: this.viewWidth,
height: this.viewHeight,
- format: config().get('plot.format', 'svglite'),
- devArgs: config().get>('plot.devArgs'),
+ format: conf.get('plot.format', 'svglite'),
+ devArgs: conf.get>('plot.devArgs'),
};
return this.startPlotDrain();
}
@@ -204,12 +256,10 @@ export class StandardPlotViewer implements PlotViewer {
}
if (this.isCurrentRequest(request)) {
- this.plotData = response.data;
- this.plotFormat = response.format || request.format;
void request.panel.webview.postMessage({
type: 'update',
- data: this.plotData,
- format: this.plotFormat,
+ data: response.data,
+ format: response.format || request.format,
});
}
}
@@ -217,26 +267,29 @@ export class StandardPlotViewer implements PlotViewer {
private isCurrentRequest(request: PendingPlotRequest): boolean {
return (
- Boolean(session.globalPipePath) &&
this.panel === request.panel &&
this.panelGeneration === request.panelGeneration &&
- session.activeSession === request.targetSession
+ session.resolvePlotSession(this.source) === request.targetSession
);
}
- private discardPendingForSession(targetSession: session.Session | undefined): void {
+ private discardPendingForSession(targetSession: session.Session): void {
if (this.pendingPlotRequest?.targetSession === targetSession) {
this.pendingPlotRequest = undefined;
}
}
private getHtml() {
+ const codicons = this.panel!.webview.asWebviewUri(
+ vscode.Uri.joinPath(extensionContext.extensionUri, 'dist', 'resources', 'codicon.css'),
+ );
return `
+
+ ${this.sessionContext?.getHtml() ?? ''}
`,
+ );
+ }
+}
+
+suite('Standalone plot restoration', () => {
+ let sandbox: sinon.SinonSandbox;
+ let state: Map;
+ let secrets: Map;
+ let managers: CommonPlotManager[];
+ let connect: sinon.SinonStub;
+ let callbacks: Map Promise>;
+ let panels: Array<{
+ panel: vscode.WebviewPanel;
+ receive: (message: unknown) => Promise;
+ post: sinon.SinonStub;
+ }>;
+
+ setup(() => {
+ sandbox = sinon.createSandbox();
+ state = new Map();
+ secrets = new Map();
+ managers = [];
+ callbacks = new Map();
+ panels = [];
+ mockExtensionContext(path.resolve(__dirname, '../../..'), sandbox);
+ (extensionContext.workspaceState.get as sinon.SinonStub).callsFake(
+ (key: string, fallback?: unknown) => state.get(key) ?? fallback,
+ );
+ (extensionContext.workspaceState.update as sinon.SinonStub).callsFake(
+ (key: string, value: unknown) => {
+ state.set(key, structuredClone(value));
+ return Promise.resolve();
+ },
+ );
+ (extensionContext.secrets.get as sinon.SinonStub).callsFake((key: string) =>
+ Promise.resolve(secrets.get(key)),
+ );
+ (extensionContext.secrets.store as sinon.SinonStub).callsFake(
+ (key: string, value: string) => {
+ secrets.set(key, value);
+ return Promise.resolve();
+ },
+ );
+ (extensionContext.secrets.delete as sinon.SinonStub).callsFake((key: string) => {
+ secrets.delete(key);
+ return Promise.resolve();
+ });
+ sandbox.stub(vscode.commands, 'registerCommand').callsFake((command, callback) => {
+ callbacks.set(command, callback as (...args: unknown[]) => Promise);
+ return new vscode.Disposable(() => {});
+ });
+ sandbox.stub(vscode.commands, 'executeCommand').resolves();
+ sandbox.stub(JgdManager.prototype, 'start');
+ connect = sandbox.stub(HttpgdClient.prototype, 'connect').resolves();
+ sandbox.stub(util, 'config').returns({
+ get: (_key: string, fallback: unknown) => fallback,
+ } as vscode.WorkspaceConfiguration);
+ sandbox.stub(vscode.window, 'createWebviewPanel').callsFake((viewType, title) => {
+ const panelId = panels.length;
+ const disposed = new vscode.EventEmitter();
+ const listeners = new Set<(message: unknown) => unknown>();
+ const post = sandbox.stub().resolves(true);
+ const panel = {
+ title,
+ viewType,
+ active: true,
+ reveal: sandbox.stub(),
+ webview: {
+ html: '',
+ postMessage: post,
+ asWebviewUri: (uri: vscode.Uri) =>
+ uri.with({
+ scheme: 'https',
+ authority: `panel${panelId}.vscode-resource.vscode-cdn.net`,
+ }),
+ onDidReceiveMessage: (callback: (message: unknown) => unknown) => {
+ listeners.add(callback);
+ return new vscode.Disposable(() => {
+ listeners.delete(callback);
+ });
+ },
+ },
+ onDidDispose: disposed.event,
+ onDidChangeViewState: () => new vscode.Disposable(() => {}),
+ dispose: () => disposed.fire(),
+ } as unknown as vscode.WebviewPanel;
+ panels.push({
+ panel,
+ post,
+ receive: async (message) => {
+ await Promise.all([...listeners].map((listener) => listener(message)));
+ },
+ });
+ return panel;
+ });
+ });
+
+ teardown(async () => {
+ for (const manager of managers) {
+ await manager.dispose();
+ }
+ for (const { panel } of panels) {
+ panel.dispose();
+ }
+ sandbox.restore();
+ });
+
+ function manager(): CommonPlotManager {
+ const value = new CommonPlotManager();
+ value.initialize();
+ managers.push(value);
+ return value;
+ }
+
+ function owner(id: string, pid: string): session.Session {
+ return {
+ sessionId: id,
+ host: 'external-host',
+ pid,
+ rVer: 'R version 4.6.0 (2026-04-24)',
+ processExited: false,
+ } as session.Session;
+ }
+
+ function focus(source: session.Session): void {
+ sandbox.stub(session, 'activeSession').value(source);
+ }
+
+ function processExits() {
+ const exited = new Set();
+ const listeners = new Map void>>();
+ sandbox.stub(session, 'getViewerSessionContext').callsFake((_id, source) => {
+ if (!source) {
+ return undefined;
+ }
+ const key = sessionProcessIdentity(source);
+ let callbacks = listeners.get(key);
+ if (!callbacks) {
+ callbacks = new Set();
+ listeners.set(key, callbacks);
+ }
+ const observers = callbacks;
+ return {
+ source,
+ sessionId: source.sessionId,
+ get hasExited() {
+ return exited.has(key);
+ },
+ observeExit: (callback) => {
+ observers.add(callback);
+ return { dispose: () => observers.delete(callback) };
+ },
+ attach: () => {},
+ getHtml: () => '',
+ };
+ });
+ return {
+ exit(source: ViewerSessionSource) {
+ const key = sessionProcessIdentity(source);
+ exited.add(key);
+ for (const callback of listeners.get(key) ?? []) {
+ callback();
+ }
+ },
+ listeners,
+ };
+ }
+
+ for (const backend of ['jgd', 'httpgd', 'standard'] as const) {
+ for (const closeFirst of [false, true]) {
+ test(`${backend} deletes history and saved state without altering the rendered document on R exit with closeFirst=${closeFirst}`, async () => {
+ const exits = processExits();
+ const clock = sandbox.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
+ const value = manager();
+ const source = owner('lifecycle', '123');
+ if (backend === 'jgd') {
+ value.jgdManager.history.addPlot('device', frame('device'));
+ value.jgdManager.history.setSessionSource('device', source);
+ value.jgdManager.showForSession(source);
+ } else if (backend === 'httpgd') {
+ await value.showHttpgdPlot('http://localhost:1234?token=secret', source);
+ value.httpgdManager.viewers[0].show();
+ } else {
+ await value.showStandardPlot(source);
+ }
+ const panel = panels[0].panel;
+ assert.ok(panel.webview.html.length > 0);
+ const rendered = panel.webview.html;
+ const close = sandbox.spy(panel, 'dispose');
+ if (closeFirst) {
+ panel.dispose();
+ }
+ await clock.tickAsync(250);
+ const captured: ViewerSessionSource = {
+ sessionId: source.sessionId,
+ host: source.host,
+ pid: source.pid,
+ rVer: source.rVer,
+ processExited: source.processExited,
+ };
+ // Detaching a Session clears transport metadata before its
+ // original process can exit. Cleanup must retain that identity.
+ source.pid = '';
+ source.rVer = '';
+ exits.exit(captured);
+ assert.strictEqual(value.viewers.length, 0, 'exit releases viewers immediately');
+ await clock.tickAsync(250);
+ assert.strictEqual(
+ close.callCount,
+ closeFirst ? 1 : 0,
+ "R exit must not close the user's viewer tab",
+ );
+ if (!closeFirst) {
+ assert.strictEqual(
+ panel.webview.html,
+ rendered,
+ 'the rendered document is unchanged',
+ );
+ }
+ if (backend === 'jgd') {
+ assert.deepStrictEqual(
+ (state.get('r.plot.jgdState') as PlotHistoryState).sessions,
+ [],
+ );
+ } else if (backend === 'httpgd') {
+ await value.httpgdManager.saveConnections();
+ assert.strictEqual(secrets.size, 0);
+ } else {
+ assert.deepStrictEqual(state.get('r.plot.standardSessions'), []);
+ }
+ assert.strictEqual(value.viewers.length, 0);
+ assert.strictEqual(exits.listeners.get(sessionProcessIdentity(captured))!.size, 0);
+ if (!closeFirst) {
+ panel.dispose();
+ }
+ await value.dispose();
+ const restored = manager();
+ assert.strictEqual(restored.viewers.length, 0);
+ assert.strictEqual(restored.jgdManager.history.sessionIds().length, 0);
+ });
+ }
+ }
+
+ test('all engines share one process-exit observer and release their backing data together', async () => {
+ const exits = processExits();
+ const value = manager();
+ const source = owner('shared-lifetime', '123');
+ await value.showStandardPlot(source);
+ await value.showHttpgdPlot('http://localhost:1234', source);
+ const httpgd = value.httpgdManager.viewers[0];
+ RefreshViewer.prototype.seedPlots.call(httpgd, [
+ { id: '1', data: '', width: 800, height: 600, zoom: 1 },
+ ]);
+ httpgd.activePlot = '1';
+ value.jgdManager.history.addPlot('device', frame('device'));
+ value.jgdManager.history.setSessionSource('device', source);
+ const jgd = value.jgdManager.getViewer('device')!;
+ const releaseHttpgd = sandbox.spy(httpgd, 'dispose');
+ const releaseJgd = sandbox.spy(jgd, 'dispose');
+ assert.strictEqual(exits.listeners.get(sessionProcessIdentity(source))!.size, 1);
+ exits.exit(source);
+ sinon.assert.calledOnce(releaseHttpgd);
+ sinon.assert.calledOnce(releaseJgd);
+ assert.deepStrictEqual(httpgd.plots, []);
+ assert.strictEqual(httpgd.activePlot, undefined);
+ assert.deepStrictEqual(value.jgdManager.history.sessionIds(), []);
+ assert.deepStrictEqual(value.viewers, []);
+ assert.strictEqual(exits.listeners.get(sessionProcessIdentity(source))!.size, 0);
+ });
+
+ for (const closeFirst of [false, true]) {
+ test(`JGD clears exited session history with viewer ${closeFirst ? 'closed' : 'open'}`, async () => {
+ const exits = processExits();
+ const clock = sandbox.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
+ const value = manager();
+ const disconnect = sandbox.spy(value.jgdManager.server, 'disconnect');
+ const source = owner('dead', '123');
+ const live = owner('live', '456');
+ for (const [id, owner] of [
+ ['a', source],
+ ['b', source],
+ ['c', live],
+ ] as const) {
+ value.jgdManager.history.addPlot(id, frame(id));
+ value.jgdManager.history.setSessionSource(id, owner);
+ }
+ value.jgdManager.showForSession(source);
+ const panel = panels[0].panel;
+ if (closeFirst) {
+ panel.dispose();
+ }
+ await clock.tickAsync(250);
+ exits.exit(source);
+ assert.deepStrictEqual(
+ disconnect.getCalls().map((call) => call.args[0]),
+ ['a', 'b'],
+ );
+ assert.strictEqual(value.jgdManager.history.count('a'), 0);
+ assert.strictEqual(value.jgdManager.history.count('b'), 0);
+ assert.strictEqual(value.jgdManager.showForSession(source), false);
+ await clock.tickAsync(250);
+ const saved = state.get('r.plot.jgdState') as PlotHistoryState;
+ assert.deepStrictEqual(
+ saved.sessions.map((entry) => entry.id),
+ ['c'],
+ );
+ assert.deepStrictEqual(value.jgdManager.history.sessionIds(), ['c']);
+ assert.strictEqual(exits.listeners.get(sessionProcessIdentity(source))!.size, 0);
+ await value.dispose();
+ assert.deepStrictEqual(manager().jgdManager.history.sessionIds(), ['c']);
+ });
+ }
+
+ test('JGD watches restored closed viewers and preserves temporary disconnections', async () => {
+ const exits = processExits();
+ const source = owner('saved', '123');
+ const first = manager();
+ first.jgdManager.history.addPlot('device', frame('device'));
+ first.jgdManager.history.setSessionSource('device', source);
+ await first.dispose();
+ const restored = manager();
+ assert.strictEqual(restored.jgdManager.history.count('device'), 1);
+ assert.strictEqual(restored.jgdManager.getViewers().length, 0);
+ assert.strictEqual(exits.listeners.get(sessionProcessIdentity(source))!.size, 1);
+ // Disconnection is not an exit; only the authoritative exit observer clears history.
+ assert.strictEqual(restored.jgdManager.server.isConnected('device'), false);
+ exits.exit(source);
+ assert.deepStrictEqual(restored.jgdManager.history.sessionIds(), []);
+ await restored.dispose();
+ assert.deepStrictEqual((state.get('r.plot.jgdState') as PlotHistoryState).sessions, []);
+ });
+
+ test('JGD corrects a saved checkpoint when exit was already confirmed before restoration', async () => {
+ const exits = processExits();
+ const source = owner('saved', '123');
+ const first = manager();
+ first.jgdManager.history.addPlot('device', frame('device'));
+ first.jgdManager.history.setSessionSource('device', source);
+ await first.dispose();
+ exits.exit(source);
+ const restored = manager();
+ assert.deepStrictEqual(restored.jgdManager.history.sessionIds(), []);
+ await restored.dispose();
+ assert.deepStrictEqual((state.get('r.plot.jgdState') as PlotHistoryState).sessions, []);
+ });
+
+ test('JGD exit during a checkpoint removes stale saved frames after the write completes', async () => {
+ const exits = processExits();
+ const clock = sandbox.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
+ const gate = deferred();
+ let writes = 0;
+ (extensionContext.workspaceState.update as sinon.SinonStub).callsFake(
+ async (key: string, data: unknown) => {
+ if (key === 'r.plot.jgdState' && ++writes === 1) {
+ await gate.promise;
+ }
+ state.set(key, structuredClone(data));
+ },
+ );
+ const value = manager();
+ const source = owner('pending-exit', '123');
+ value.jgdManager.history.addPlot('device', frame('device'));
+ value.jgdManager.history.setSessionSource('device', source);
+ await clock.tickAsync(250);
+ assert.strictEqual(writes, 1);
+ exits.exit(source);
+ gate.resolve();
+ await value.dispose();
+ assert.deepStrictEqual((state.get('r.plot.jgdState') as PlotHistoryState).sessions, []);
+ });
+
+ test('httpgd process exit clears history, connections and saved credentials without affecting other owners', async () => {
+ const exits = processExits();
+ const source = owner('httpgd-owner', '14001');
+ const replacement = owner(source.sessionId, '14002');
+ const value = manager();
+ await value.showHttpgdPlot('http://localhost:1234?token=old-1', source);
+ await value.showHttpgdPlot('http://localhost:1235?token=old-2', source);
+ const old = [...value.httpgdManager.viewers];
+ old[0].show();
+ const panel = old[0].webviewPanel;
+ const rendered = panel!.webview.html;
+ const close = sandbox.spy(panel!, 'dispose');
+ const disconnects = old.map((viewer) => sandbox.spy(viewer.api, 'disconnect'));
+ await value.showHttpgdPlot('http://localhost:1236?token=replacement', replacement);
+ await value.httpgdManager.showViewer('http://localhost:1237?token=unowned');
+ exits.exit(source);
+ await value.httpgdManager.saveConnections();
+ for (const disconnect of disconnects) {
+ sinon.assert.calledOnce(disconnect);
+ }
+ sinon.assert.notCalled(close);
+ assert.strictEqual(panel!.webview.html, rendered);
+ assert.strictEqual(old[0].webviewPanel, undefined);
+ assert.deepStrictEqual(old[0].plots, []);
+ assert.deepStrictEqual(old[0].api.getPlots(), []);
+ assert.strictEqual(value.httpgdManager.viewers.length, 2);
+ assert.ok(
+ old.every((viewer) => !value.httpgdManager.recentlyActiveViewers.includes(viewer)),
+ );
+ const saved = JSON.parse([...secrets.values()][0]) as Array<{
+ source?: ViewerSessionSource;
+ }>;
+ assert.strictEqual(saved.length, 2);
+ assert.ok(saved.some((connection) => connection.source?.pid === replacement.pid));
+ assert.ok(saved.some((connection) => !connection.source));
+ await value.dispose();
+ const restored = manager();
+ assert.strictEqual(await restored.httpgdManager.restoreConnection(source), undefined);
+ assert.ok(await restored.httpgdManager.restoreConnection(replacement));
+ });
+
+ test('httpgd saved connections are cleaned on exit even without reopening a viewer', async () => {
+ const exits = processExits();
+ const source = owner('httpgd-owner', '14001');
+ const first = manager();
+ await first.showHttpgdPlot('http://localhost:1234?token=secret', source);
+ await first.dispose();
+ const restored = manager();
+ await restored.httpgdManager.saveConnections();
+ assert.strictEqual(restored.httpgdManager.viewers.length, 0);
+ assert.strictEqual(secrets.size, 1, 'reload preserves the live session');
+ connect.resetHistory();
+ exits.exit(source);
+ await waitForValue(() =>
+ secrets.size === 0 && !state.get('r.plot.httpgdConnectionsKey') ? true : undefined,
+ );
+ await restored.httpgdManager.saveConnections();
+ assert.strictEqual(secrets.size, 0);
+ assert.strictEqual(state.get('r.plot.httpgdConnectionsKey'), undefined);
+ assert.strictEqual(exits.listeners.get(sessionProcessIdentity(source))?.size, 0);
+ assert.strictEqual(await restored.httpgdManager.restoreConnection(source), undefined);
+ sinon.assert.notCalled(connect);
+ });
+
+ test('httpgd exit during credential loading prevents a pending restore from reconnecting', async () => {
+ const exits = processExits();
+ const source = owner('httpgd-owner', '14001');
+ const first = manager();
+ await first.showHttpgdPlot('http://localhost:1234?token=secret', source);
+ await first.dispose();
+ const saved = [...secrets.values()][0];
+ let load!: (value: string) => void;
+ (extensionContext.secrets.get as sinon.SinonStub).returns(
+ new Promise((resolve) => {
+ load = resolve;
+ }),
+ );
+ const restored = manager();
+ const pending = restored.httpgdManager.restoreConnection(source);
+ exits.exit(source);
+ connect.resetHistory();
+ load(saved);
+ assert.strictEqual(await pending, undefined);
+ await restored.httpgdManager.saveConnections();
+ assert.strictEqual(secrets.size, 0);
+ assert.strictEqual(state.get('r.plot.httpgdConnectionsKey'), undefined);
+ sinon.assert.notCalled(connect);
+ });
+
+ test('httpgd exit during a credential write removes that write after it completes', async () => {
+ const exits = processExits();
+ const source = owner('httpgd-owner', '14001');
+ const value = manager();
+ let stored!: () => void;
+ let started!: () => void;
+ const writing = new Promise((resolve) => {
+ started = resolve;
+ });
+ (extensionContext.secrets.store as sinon.SinonStub).onFirstCall().callsFake(
+ (key: string, data: string) =>
+ new Promise((resolve) => {
+ stored = () => {
+ secrets.set(key, data);
+ resolve();
+ };
+ started();
+ }),
+ );
+ const opening = value.showHttpgdPlot('http://localhost:1234?token=secret', source);
+ await writing;
+ exits.exit(source);
+ stored();
+ await opening;
+ await value.httpgdManager.saveConnections();
+ assert.strictEqual(secrets.size, 0);
+ assert.strictEqual(state.get('r.plot.httpgdConnectionsKey'), undefined);
+ assert.deepStrictEqual(value.httpgdManager.viewers, []);
+ });
+
+ for (const failure of ['read', 'json', 'shape'] as const) {
+ test(`httpgd ${failure} failure is reported and cannot erase saved credentials`, async () => {
+ const source = owner('httpgd-owner', '14001');
+ const key = 'saved-connections';
+ const saved = failure === 'json' ? '{"token":"must-not-be-reported"' : '{}';
+ state.set('r.plot.httpgdConnectionsKey', key);
+ secrets.set(key, saved);
+ if (failure === 'read') {
+ (extensionContext.secrets.get as sinon.SinonStub).rejects(new Error('read failed'));
+ }
+ const report = sandbox.stub(vscode.window, 'showErrorMessage').resolves(undefined);
+ sandbox.stub(console, 'warn');
+ const value = manager();
+ await assert.rejects(value.httpgdManager.restoreConnection(source), {
+ message: 'Could not load saved httpgd plot connections.',
+ });
+ sinon.assert.calledOnce(report);
+ assert.deepStrictEqual(report.firstCall.args, [
+ 'Could not load saved httpgd plot connections.',
+ ]);
+ await value.httpgdManager.saveConnections();
+ // A newly announced device still works, but must not overwrite
+ // credentials for other sessions that could not be read.
+ await value.showHttpgdPlot('http://localhost:1234?token=new-device', source);
+ assert.ok(await value.httpgdManager.restoreConnection(source));
+ await value.dispose();
+ assert.strictEqual(state.get('r.plot.httpgdConnectionsKey'), key);
+ assert.strictEqual(secrets.get(key), saved);
+ assert.strictEqual((extensionContext.secrets.store as sinon.SinonStub).callCount, 0);
+ assert.strictEqual((extensionContext.secrets.delete as sinon.SinonStub).callCount, 0);
+ });
+ }
+
+ test('a missing httpgd secret is empty storage and accepts a new connection', async () => {
+ const source = owner('httpgd-owner', '14001');
+ state.set('r.plot.httpgdConnectionsKey', 'missing-secret');
+ const report = sandbox.stub(vscode.window, 'showErrorMessage').resolves(undefined);
+ const value = manager();
+ assert.strictEqual(await value.httpgdManager.restoreConnection(source), undefined);
+ await value.showHttpgdPlot('http://localhost:1234?token=new-device', source);
+ assert.ok(secrets.get('missing-secret')?.includes('new-device'));
+ sinon.assert.notCalled(report);
+ });
+
+ test('httpgd already exited owners cannot register a new connection', async () => {
+ const exits = processExits();
+ const source = owner('httpgd-owner', '14001');
+ exits.exit(source);
+ const value = manager();
+ await value.showHttpgdPlot('http://localhost:1234?token=secret', source);
+ await value.httpgdManager.saveConnections();
+ assert.deepStrictEqual(value.httpgdManager.viewers, []);
+ assert.strictEqual(secrets.size, 0);
+ sinon.assert.notCalled(connect);
+ });
+
+ test('all plot backends show the exact shared session indicator and retain it after reload', async () => {
+ const a = owner('indicator-a', '12101');
+ const b = owner('indicator-b', '12102');
+
+ sandbox.stub(session, 'sessionRequest').resolves({ data: 'plot', format: 'svg' });
+ sandbox
+ .stub(session, 'resolvePlotSession')
+ .callsFake((source) => (source.sessionId === a.sessionId ? a : b));
+ const first = manager();
+ for (const [index, source] of [a, b].entries()) {
+ await first.showStandardPlot(source);
+ await first.showHttpgdPlot(
+ `http://localhost:${1234 + index}/live?token=secret`,
+ source,
+ );
+ first.jgdManager.history.addPlot(source.sessionId, frame(source.sessionId));
+ first.jgdManager.history.setSessionSource(source.sessionId, source);
+ }
+ async function checkIndicators(value: CommonPlotManager) {
+ for (const source of [a, b]) {
+ await value.showStandardPlot(source);
+ (await value.httpgdManager.restoreConnection(source))!.show();
+ value.jgdManager.showForSession(source);
+ }
+ assert.strictEqual(panels.length, 6);
+ for (const source of [a, b]) {
+ const label = `R 4.6.0: ${source.pid}`;
+ const owned = panels.filter(({ panel }) =>
+ panel.webview.html.includes(`aria-label="${label}"`),
+ );
+ assert.strictEqual(owned.length, 3, 'each backend has its own session indicator');
+ for (const item of owned) {
+ assert.strictEqual(
+ item.panel.title,
+ item.panel.viewType === 'jgd.plotPane' ? 'R Plot (JGD)' : 'R Plot',
+ );
+ assert.ok(item.panel.webview.html.includes('class="viewer-session-tooltip"'));
+ assert.ok(item.panel.webview.html.includes('codicon.css'));
+ item.post.resetHistory();
+ await item.receive({ message: 'viewer-session/ready' });
+ sinon.assert.calledWithExactly(item.post, {
+ message: 'viewer-session/update',
+ text: label,
+ });
+ }
+ }
+ }
+ await checkIndicators(first);
+ for (const { panel } of panels) {
+ panel.dispose();
+ }
+ await first.dispose();
+ panels = [];
+ await checkIndicators(manager());
+ });
+
+ test('JGD binds live devices including late R attachments, but never guesses a restored owner', async () => {
+ const events = new vscode.EventEmitter();
+ sandbox.stub(session, 'onDidChangeSessionConnection').callsFake(events.event);
+ const a = owner('jgd-owner-a', '12111');
+ const b = owner('jgd-owner-b', '12112');
+ const resolve = sandbox.stub(session, 'resolveJgdSessionSource');
+ resolve.withArgs('r-12111-1').returns(a);
+ const value = manager();
+ const live = sandbox
+ .stub(value.jgdManager.server, 'isConnected')
+ .callsFake((id) => id !== 'restored');
+ const deliver = startJgd(value);
+ for (const [id, label] of [
+ ['a', 'r-12111-1'],
+ ['b', 'r-12112-1'],
+ ['restored', 'r-12113-1'],
+ ]) {
+ value.jgdManager.history.addPlot(id, frame(id));
+ value.jgdManager.history.setSessionLabel(id, label);
+ deliver(id, { type: 'frame' });
+ }
+ assert.ok(panels[0].panel.webview.html.includes('R 4.6.0: 12111'));
+ assert.ok(!panels[1].panel.webview.html.includes('class="viewer-session"'));
+ resolve.withArgs('r-12112-1').returns(b);
+ events.fire({
+ kind: 'connected',
+ session: { sessionId: b.sessionId, connectionId: 'late' },
+ });
+ assert.ok(panels[1].panel.webview.html.includes('R 4.6.0: 12112'));
+ sinon.assert.neverCalledWith(resolve, 'r-12113-1');
+ assert.strictEqual(value.jgdManager.history.sessionSource('b')?.sessionId, b.sessionId);
+ live.returns(false);
+ value.jgdManager.getViewer('a')!.updateToolbar();
+ await panels[0].receive({ message: 'viewer-session/ready' });
+ sinon.assert.calledWith(panels[0].post, {
+ message: 'viewer-session/update',
+ text: 'R 4.6.0: 12111',
+ });
+ events.dispose();
+ });
+
+ test('owning process exit releases all engines without changing rendered documents or continuing process polling', async () => {
+ const clock = sandbox.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] });
+ const kill = sandbox.stub(process, 'kill').returns(true);
+ const source = owner('plot-exit', '12120');
+ source.host = os.hostname();
+ const value = manager();
+ await value.showStandardPlot(source);
+ await value.showHttpgdPlot('http://localhost:1234', source);
+ value.jgdManager.history.addPlot('device', frame('device'));
+ value.jgdManager.history.setSessionSource('device', source);
+ value.httpgdManager.viewers[0].show();
+ value.jgdManager.showForSession(source);
+ assert.strictEqual(panels.length, 3);
+ const rendered = panels.map(({ panel }) => panel.webview.html);
+ const closes = panels.map(({ panel }) => sandbox.spy(panel, 'dispose'));
+ kill.callsFake(() => {
+ throw Object.assign(new Error('gone'), { code: 'ESRCH' });
+ });
+ await clock.tickAsync(1000);
+ closes.forEach((close) => sinon.assert.notCalled(close));
+ for (const [index, { panel }] of panels.entries()) {
+ assert.strictEqual(panel.webview.html, rendered[index]);
+ panel.dispose();
+ }
+ assert.deepStrictEqual(value.viewers, []);
+ kill.resetHistory();
+ await clock.tickAsync(2000);
+ sinon.assert.notCalled(kill);
+ });
+
+ test('httpgd gains the session indicator when an existing URL is attached to R', async () => {
+ const value = manager();
+ await value.showHttpgdPlot('http://localhost:1234');
+ value.httpgdManager.viewers[0].show();
+ assert.ok(!panels[0].panel.webview.html.includes('class="viewer-session"'));
+ await value.showHttpgdPlot('http://localhost:1234', owner('late-httpgd', '12130'));
+ assert.strictEqual(panels.length, 1);
+ assert.ok(panels[0].panel.webview.html.includes('R 4.6.0: 12130'));
+ await panels[0].receive({ message: 'viewer-session/ready' });
+ sinon.assert.calledWith(panels[0].post, {
+ message: 'viewer-session/update',
+ text: 'R 4.6.0: 12130',
+ });
+ });
+
+ test('JGD navigation checkpoints only cursors and restores them without rewriting frames', async () => {
+ const clock = sandbox.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
+ const first = manager();
+ const history = first.jgdManager.history;
+ history.addPlot('device', frame('device', 'first'));
+ history.addPlot('device', frame('device', 'second'));
+ await clock.tickAsync(250);
+ const snapshot = sandbox.spy(history, 'snapshot');
+ const update = (extensionContext.workspaceState as unknown as { update: sinon.SinonStub })
+ .update;
+ update.resetHistory();
+ history.navigateFirst('device');
+ await clock.tickAsync(250);
+ sinon.assert.notCalled(snapshot);
+ sinon.assert.neverCalledWith(update, 'r.plot.jgdState');
+ sinon.assert.calledWith(update, 'r.plot.jgdSelection');
+ assert.strictEqual(manager().jgdManager.history.currentPlot('device')?.device.bg, 'first');
+ await first.dispose();
+ sinon.assert.notCalled(snapshot);
+ sinon.assert.neverCalledWith(update, 'r.plot.jgdState');
+
+ // A crash between the content and cursor writes leaves mismatched IDs.
+ state.set('r.plot.jgdSelection', {
+ checkpointId: 'obsolete',
+ positions: [{ deviceId: 'device', currentIndex: 0 }],
+ });
+ assert.strictEqual(manager().jgdManager.history.currentPlot('device')?.device.bg, 'second');
+ });
+
+ test('JGD coalesces pending checkpoints without mutating an in-flight snapshot', async () => {
+ const clock = sandbox.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
+ const gate = deferred();
+ const writes: PlotHistoryState[] = [];
+ (extensionContext.workspaceState.update as sinon.SinonStub).callsFake(
+ async (key: string, value: unknown) => {
+ if (key === 'r.plot.jgdState') {
+ writes.push(value as PlotHistoryState);
+ if (writes.length === 1) {
+ await gate.promise;
+ }
+ }
+ state.set(key, structuredClone(value));
+ },
+ );
+ const first = manager();
+ const history = first.jgdManager.history;
+ history.addPlot('device', frame('device', 'first'));
+ await clock.tickAsync(250);
+ for (let i = 0; i < 3; i++) {
+ history.appendOps('device', {
+ ...frame('device'),
+ ops: [{ op: 'text', str: String(i) }],
+ });
+ await clock.tickAsync(250);
+ }
+ assert.strictEqual(writes.length, 1, 'do not queue full snapshots behind a slow write');
+ assert.deepStrictEqual(
+ writes[0].sessions[0].plots[0].ops,
+ [],
+ 'live drawing must not change the pending snapshot',
+ );
+ gate.resolve();
+ await first.dispose();
+ assert.strictEqual(writes.length, 2);
+ assert.strictEqual(manager().jgdManager.history.currentPlot('device')?.ops.length, 3);
+ });
+
+ test('JGD checkpoints a reduced history limit before saving new cursor positions', async () => {
+ const clock = sandbox.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
+ const first = manager();
+ for (const bg of ['first', 'second', 'third']) {
+ first.jgdManager.history.addPlot('device', frame('device', bg));
+ }
+ await first.dispose();
+ (util.config as sinon.SinonStub).returns({
+ get: (key: string, fallback: unknown) =>
+ key === 'plot.jgd.historyLimit' ? 2 : fallback,
+ });
+ const restored = manager();
+ assert.strictEqual(restored.jgdManager.history.count('device'), 2);
+ restored.jgdManager.history.navigateFirst('device');
+ await clock.tickAsync(250);
+ assert.strictEqual(manager().jgdManager.history.currentPlot('device')?.device.bg, 'second');
+ });
+
+ test('all backends checkpoint restoration state before shutdown', async () => {
+ const source = owner('checkpoint', '12150');
+ sandbox.stub(session, 'sessionRequest').resolves({ data: 'live', format: 'svg' });
+ sandbox.stub(session, 'resolvePlotSession').returns(source);
+ const first = manager();
+ await first.showStandardPlot(source);
+ await first.showHttpgdPlot('http://localhost:1234?token=checkpoint', source);
+ first.jgdManager.history.addPlot('device', frame('device'));
+ first.jgdManager.history.setSessionSource('device', source);
+ await waitForValue(() => (state.has('r.plot.jgdState') ? true : undefined));
+ assert.ok(state.has('r.plot.standardSessions'));
+ assert.ok(state.has('r.plot.httpgdConnectionsKey'));
+ for (const { panel } of panels) {
+ panel.dispose();
+ }
+ panels = [];
+ const restored = manager();
+ // Deliberately do not call first.dispose(): the old extension host may
+ // stop before its shutdown hook can write through VS Code's storage bridge.
+ restored.jgdManager.showForSession(source);
+ (await restored.httpgdManager.restoreConnection(source))!.show();
+ await restored.showStandardPlot(source);
+ assert.strictEqual(panels.length, 3);
+ assert.strictEqual(restored.jgdManager.history.count('device'), 1);
+ assert.strictEqual(restored.httpgdManager.viewers[0].token, 'checkpoint');
+ });
+
+ test('Show Viewers requests the standard plot from its original attached session after reload', async () => {
+ const first = manager();
+ const owner = {
+ sessionId: 'a',
+ host: 'host',
+ pid: '123',
+ rVer: '4.6.0',
+ processExited: false,
+ } as session.Session;
+ const request = sandbox
+ .stub(session, 'sessionRequest')
+ .resolves({ data: 'original', format: 'svg' });
+ focus(owner);
+ const resolve = sandbox.stub(session, 'resolvePlotSession').returns(owner);
+ await first.showStandardPlot(owner);
+ await panels[0].receive({ type: 'ready' });
+ await first.dispose();
+ assert.ok(
+ !JSON.stringify([...state.values()]).includes('original'),
+ 'standard images are not persisted',
+ );
+ panels = [];
+ request.resetHistory();
+ request.resolves({ data: 'fresh-from-R', format: 'svg' });
+ resolve.resetHistory();
+ manager();
+ assert.strictEqual(panels.length, 0);
+ await callbacks.get('r.plot.showViewers')!();
+ sinon.assert.calledOnce(resolve);
+ await panels[0].receive({ type: 'ready' });
+ assert.strictEqual(request.lastCall.args[1], owner);
+ sinon.assert.calledWith(panels[0].post, {
+ type: 'update',
+ data: 'fresh-from-R',
+ format: 'svg',
+ });
+ request.resetHistory();
+ resolve.resetHistory();
+ await callbacks.get('r.plot.showViewers')!();
+ sinon.assert.calledOnce(request);
+ assert.strictEqual(request.firstCall.args[1], owner);
+ assert.strictEqual(panels.length, 1);
+ request.resetHistory();
+ const reattached = {
+ sessionId: owner.sessionId,
+ host: owner.host,
+ pid: owner.pid,
+ rVer: owner.rVer,
+ processExited: false,
+ } as session.Session;
+ resolve.returns(reattached);
+ panels[0].panel.dispose();
+ await callbacks.get('r.plot.showViewers')!();
+ await panels[1].receive({ type: 'ready' });
+ sinon.assert.calledOnce(request);
+ assert.strictEqual(request.lastCall.args[1], reattached);
+ });
+
+ function startJgd(value: CommonPlotManager) {
+ (JgdManager.prototype.start as sinon.SinonStub).callThrough();
+ sandbox.stub(value.jgdManager.server, 'start');
+ const onFrame = sandbox.stub(value.jgdManager.server, 'setOnFrame');
+ value.jgdManager.start();
+ return onFrame.firstCall.args[0] as (id: string, message: JgdMessage) => void;
+ }
+
+ function frame(sessionId: string, bg = 'white'): PlotFrame {
+ return {
+ version: 1,
+ sessionId,
+ device: { width: 400, height: 300, dpi: 96, bg },
+ ops: [],
+ };
+ }
+
+ test('Show Viewer opens only the focused session and never ownerless or empty JGD viewers', async () => {
+ const a = owner('a', '123');
+ const b = owner('b', '456');
+ const active = sandbox.stub(session, 'activeSession').value(a);
+ const resolve = sandbox
+ .stub(session, 'resolvePlotSession')
+ .callsFake((source) => source as session.Session);
+ const value = manager();
+ const metrics = sandbox.spy(value.jgdManager.server, 'setMeasureText');
+ const deliver = startJgd(value);
+ await metrics.firstCall.args[0]({ type: 'metrics_request', id: 1 }, 96, 'temporary-socket');
+ assert.strictEqual(value.jgdManager.getViewers().length, 0);
+ for (const source of [a, b]) {
+ value.jgdManager.history.addPlot(source.sessionId, frame(source.sessionId));
+ value.jgdManager.history.setSessionSource(source.sessionId, source);
+ deliver(source.sessionId, { type: 'frame' });
+ }
+ value.jgdManager.history.addPlot('unknown', frame('unknown'));
+ value.jgdManager.history.addPlot('empty', frame('empty'));
+ value.jgdManager.history.clear('empty');
+ value.jgdManager.history.setSessionSource('empty', a);
+ for (const { panel } of panels) {
+ panel.dispose();
+ }
+ panels = [];
+ await callbacks.get('r.plot.showViewers')!();
+ assert.strictEqual(panels.length, 1);
+ assert.ok(panels[0].panel.webview.html.includes('R 4.6.0: 123'));
+ sinon.assert.calledOnceWithExactly(resolve, a);
+ await callbacks.get('r.plot.showViewers')!();
+ assert.strictEqual(panels.length, 1);
+ active.value(b);
+ await callbacks.get('r.plot.showViewers')!();
+ assert.strictEqual(panels.length, 2);
+ assert.ok(panels[1].panel.webview.html.includes('R 4.6.0: 456'));
+ active.value(undefined);
+ await callbacks.get('r.plot.showViewers')!();
+ assert.strictEqual(panels.length, 2);
+ });
+
+ test('a missing saved JGD device does not select another session during restore', async () => {
+ const a = owner('a', '123');
+ const b = owner('b', '456');
+ focus(a);
+ sandbox.stub(session, 'resolvePlotSession').returns(a);
+ const first = manager();
+ for (const source of [a, b]) {
+ first.jgdManager.history.addPlot(source.sessionId, frame(source.sessionId));
+ first.jgdManager.history.setSessionSource(source.sessionId, source);
+ }
+ await first.dispose();
+ const saved = state.get('r.plot.jgdState') as PlotHistoryState;
+ saved.activeSessionId = 'missing-device';
+ const restored = manager();
+ assert.strictEqual(restored.jgdManager.history.getActiveSessionId(), '');
+ assert.strictEqual(restored.jgdManager.getViewer(), null);
+ assert.strictEqual(panels.length, 0);
+ await callbacks.get('r.plot.showViewers')!();
+ assert.strictEqual(panels.length, 1);
+ assert.ok(panels[0].panel.webview.html.includes('R 4.6.0: 123'));
+ assert.strictEqual(restored.jgdManager.history.count('b'), 1);
+ });
+
+ for (const backend of ['httpgd', 'jgd', 'standard'] as const) {
+ test(`${backend} recovers the active session across reloads without focusing its terminal`, async () => {
+ const source = owner('attached', '123');
+ focus(source);
+ sandbox.stub(session, 'resolvePlotSession').returns(source);
+ sandbox.stub(session, 'sessionRequest').resolves({ data: 'plot', format: 'svg' });
+ const showTerminal = sandbox.stub();
+ const unrelatedTerminal = { show: showTerminal } as unknown as vscode.Terminal;
+ sandbox.stub(vscode.window, 'activeTerminal').value(unrelatedTerminal);
+ let value = manager();
+ if (backend === 'httpgd') {
+ await value.showHttpgdPlot('http://localhost:1234/live?token=secret', source);
+ } else if (backend === 'jgd') {
+ value.jgdManager.history.addPlot('device', frame('device'));
+ value.jgdManager.history.setSessionSource('device', source);
+ } else {
+ await value.showStandardPlot(source);
+ }
+ for (let reload = 0; reload < 4; reload++) {
+ await value.dispose();
+ panels = [];
+ value = manager();
+ await callbacks.get('r.plot.showViewers')!();
+ assert.strictEqual(panels.length, 1, `viewer recovered after reload ${reload}`);
+ assert.ok(panels[0].panel.webview.html.includes('R 4.6.0: 123'));
+ assert.strictEqual(session.activeSession, source);
+ sinon.assert.notCalled(showTerminal);
+ }
+ });
+ }
+
+ test('multiple JGD devices in one R session reuse one viewer', async () => {
+ const source = owner('a', '123');
+ focus(source);
+ sandbox.stub(session, 'resolvePlotSession').returns(source);
+ const value = manager();
+ const deliver = startJgd(value);
+ for (const id of ['device-1', 'device-2', 'device-1']) {
+ value.jgdManager.history.addPlot(id, frame(id));
+ value.jgdManager.history.setSessionSource(id, source);
+ deliver(id, { type: 'frame' });
+ }
+ assert.strictEqual(panels.length, 1);
+ assert.strictEqual(value.jgdManager.getViewers().length, 1);
+ assert.strictEqual(value.jgdManager.getViewers()[0].sessionId, 'device-1');
+ panels[0].panel.dispose();
+ await callbacks.get('r.plot.showViewers')!();
+ assert.strictEqual(panels.length, 2, 'one replacement tab after closing');
+ });
+
+ test('an explicit device joins the session history and gets the existing panel dimensions', async () => {
+ const value = manager();
+ const deliver = startJgd(value);
+ const history = value.jgdManager.history;
+ const source = owner('a', '123');
+ const resize = sandbox.stub(value.jgdManager.server, 'handleResize');
+ sandbox.stub(value.jgdManager.server, 'isConnected').returns(true);
+ sandbox
+ .stub(session, 'resolveJgdSessionSource')
+ .callsFake((label) => (label === 'r-123-2' ? source : undefined));
+ history.addPlot('managed', frame('managed', 'first'));
+ history.setSessionSource('managed', source);
+ deliver('managed', { type: 'frame' });
+ await panels[0].receive({ type: 'resize', width: 900, height: 700 });
+ resize.resetHistory();
+ history.addPlot('explicit', frame('explicit', 'second'));
+ history.setSessionLabel('explicit', 'r-123-2');
+ deliver('explicit', { type: 'frame', newPage: true });
+ assert.strictEqual(panels.length, 1);
+ assert.strictEqual(history.count('managed'), 2);
+ sinon.assert.calledOnceWithExactly(resize, 900, 700, 'explicit');
+ sinon.assert.calledWith(panels[0].post, { type: 'toolbar', current: 2, total: 2 });
+ const viewer = value.jgdManager.getViewers()[0];
+ await viewer.handleCommand('prevPlot');
+ assert.strictEqual(history.currentPlot('explicit')?.device.bg, 'first');
+ history.replaceLatest('explicit', frame('explicit', 'second resized'));
+ deliver('explicit', { type: 'frame', resizeReplay: true });
+ assert.strictEqual(history.currentPlot('explicit')?.device.bg, 'first');
+ await viewer.handleCommand('nextPlot');
+ assert.strictEqual(history.currentPlot('managed')?.device.bg, 'second resized');
+ });
+
+ test('JGD measures an earlier device through its session viewer without switching plots', async () => {
+ const value = manager();
+ const metrics = sandbox.spy(value.jgdManager.server, 'setMeasureText');
+ const deliver = startJgd(value);
+ const source = owner('a', '123');
+ for (const id of ['device-1', 'device-2', 'other-session']) {
+ value.jgdManager.history.addPlot(id, frame(id));
+ value.jgdManager.history.setSessionSource(
+ id,
+ id === 'other-session' ? owner('b', '456') : source,
+ );
+ deliver(id, { type: 'frame' });
+ }
+ const viewer = value.jgdManager.getViewers()[0];
+ const selectedPlot = value.jgdManager.history.currentPlot(viewer.sessionId);
+ for (const item of panels) {
+ item.post.resetHistory();
+ }
+ const measure = metrics.firstCall.args[0];
+ const measured = measure(
+ { type: 'metrics_request', id: 7, kind: 'strWidth', str: 'W' },
+ 96,
+ 'device-1',
+ );
+ sinon.assert.calledOnce(panels[0].post);
+ sinon.assert.notCalled(panels[1].post);
+ const request = panels[0].post.lastCall.args[0] as { type: string; id: number };
+ assert.strictEqual(request.type, 'metrics_request');
+ await panels[0].receive({
+ type: 'metrics_response',
+ id: request.id,
+ originalId: 7,
+ width: 42,
+ ascent: 10,
+ descent: 3,
+ });
+ assert.deepStrictEqual(await measured, {
+ type: 'metrics_response',
+ id: 7,
+ width: 42,
+ ascent: 10,
+ descent: 3,
+ });
+ assert.strictEqual(viewer.sessionId, 'device-2');
+ assert.strictEqual(value.jgdManager.history.currentPlot(viewer.sessionId), selectedPlot);
+ assert.strictEqual(panels.length, 2);
+
+ panels[0].panel.dispose();
+ assert.deepStrictEqual(await measure({ type: 'metrics_request', id: 8 }, 96, 'device-1'), {
+ type: 'metrics_response',
+ id: 8,
+ width: 0,
+ ascent: 0,
+ descent: 0,
+ });
+ assert.strictEqual(panels.length, 2, 'metrics must not reopen a closed viewer');
+ });
+
+ test('JGD reattachment after reload keeps closed viewers closed until Show Viewer or a new plot', async () => {
+ const source = owner('a', '123');
+ focus(source);
+ sandbox.stub(session, 'resolvePlotSession').returns(source);
+ const first = manager();
+ const deliver = startJgd(first);
+ first.jgdManager.history.addPlot('device', frame('device'));
+ first.jgdManager.history.setSessionSource('device', source);
+ deliver('device', { type: 'frame' });
+ panels[0].panel.dispose();
+ await first.dispose();
+ (JgdManager.prototype.start as sinon.SinonStub).resetBehavior();
+ panels = [];
+ const restored = manager();
+ const replay = startJgd(restored);
+ replay('device', { type: 'frame', ext: { vscodeR: { restoring: true } } });
+ replay('device', { type: 'frame', resizeReplay: true });
+ assert.strictEqual(panels.length, 0);
+ assert.strictEqual(restored.jgdManager.history.count('device'), 1);
+ await callbacks.get('r.plot.showViewers')!();
+ assert.strictEqual(panels.length, 1);
+ panels[0].panel.dispose();
+ replay('device', { type: 'frame', ext: { vscodeR: { restoring: true } } });
+ assert.strictEqual(panels.length, 1);
+ restored.jgdManager.history.addPlot('device', frame('device', 'new'));
+ replay('device', { type: 'frame' });
+ assert.strictEqual(panels.length, 2);
+ });
+
+ test('httpgd restoration connects only the focused session', async () => {
+ const a = owner('a', '123');
+ const b = owner('b', '456');
+ focus(a);
+ sandbox.stub(session, 'resolvePlotSession').returns(a);
+ const first = manager();
+ await first.showHttpgdPlot('http://localhost:1234?token=a', a);
+ await first.showHttpgdPlot('http://localhost:1235?token=b', b);
+ await first.dispose();
+ connect.resetHistory();
+ const restored = manager();
+ await callbacks.get('r.plot.showViewers')!();
+ sinon.assert.calledOnce(connect);
+ assert.strictEqual(restored.httpgdManager.viewers.length, 1);
+ assert.strictEqual(restored.httpgdManager.viewers[0].connection.source?.sessionId, 'a');
+ assert.strictEqual(panels.length, 1);
+ });
+
+ test('httpgd reattachment and existing plot updates keep closed panels closed', async () => {
+ const source = owner('a', '123');
+ const value = manager();
+ const viewer = new RefreshViewer('http://localhost:1234', {
+ ...value.httpgdManager.viewerOptions,
+ source,
+ restoring: true,
+ });
+ value.httpgdManager.registerViewer(viewer);
+ sandbox.stub(viewer.api, 'getPlotText').resolves('');
+ await viewer.receivePlots(['1']);
+ assert.strictEqual(panels.length, 0);
+ await viewer.receivePlots(['1']);
+ assert.strictEqual(panels.length, 0);
+ viewer.show();
+ assert.strictEqual(panels.length, 1);
+ panels[0].panel.dispose();
+ await value.showHttpgdPlot('http://localhost:1234', source, true);
+ await viewer.receivePlots(['1']);
+ assert.strictEqual(panels.length, 1);
+ await viewer.receivePlots(['1', '2']);
+ assert.strictEqual(panels.length, 2, 'a new plot opens one replacement panel');
+ });
+
+ test('httpgd devices reuse one session viewer and preserve its panel and settings', async () => {
+ const a = owner('a', '123');
+ const b = owner('b', '456');
+ focus(a);
+ sandbox.stub(session, 'resolvePlotSession').returns(a);
+ const value = manager();
+ await value.showHttpgdPlot('http://localhost:1234', a);
+ const first = value.httpgdManager.viewers[0];
+ first.show();
+ const panel = first.webviewPanel;
+ first.zoom = 2;
+ first.togglePreviewPlots('hidden');
+ await value.showHttpgdPlot('http://localhost:1235', b);
+ const other = value.httpgdManager.viewers[0];
+ other.show();
+ await value.showHttpgdPlot('http://localhost:1236', a);
+ assert.strictEqual(value.httpgdManager.viewers.length, 2);
+ assert.strictEqual(first.connections.length, 2);
+ assert.strictEqual(first.webviewPanel, panel);
+ assert.strictEqual(first.zoom, 2);
+ assert.strictEqual(first.previewPlotLayout, 'hidden');
+ assert.ok(other.webviewPanel);
+ panel!.dispose();
+ const count = panels.length;
+ await callbacks.get('r.plot.showViewers')!();
+ assert.strictEqual(panels.length, count + 1);
+ assert.ok(first.webviewPanel);
+ assert.strictEqual(first.connections.length, 2);
+ });
+
+ test('httpgd late ownership joins an existing session viewer without replacing its panel', async () => {
+ const source = owner('late-owner', '123');
+ const value = manager();
+ await value.showHttpgdPlot('http://localhost:1234', source);
+ const shared = value.httpgdManager.viewers[0];
+ shared.show();
+ const panel = shared.webviewPanel;
+ await value.showHttpgdPlot('http://localhost:1235');
+ const unowned = value.httpgdManager.viewers[0];
+ unowned.show();
+ await value.showHttpgdPlot('http://localhost:1235', source);
+ assert.deepStrictEqual(value.httpgdManager.viewers, [shared]);
+ assert.strictEqual(shared.webviewPanel, panel);
+ assert.strictEqual(shared.connections.length, 2);
+ assert.strictEqual(unowned.isDisposed, true);
+ assert.strictEqual(unowned.webviewPanel, undefined);
+ });
+
+ test('httpgd command routing survives port reuse by a different session', async () => {
+ const value = manager();
+ const source = owner('original', '123');
+ await value.showHttpgdPlot('http://localhost:1234', source);
+ const first = value.httpgdManager.viewers[0];
+ first.show();
+ await value.showHttpgdPlot('http://localhost:1235', source);
+ const panel = first.webviewPanel;
+ await value.showHttpgdPlot('http://localhost:1234', owner('replacement', '456'));
+ const replacement = value.httpgdManager.viewers[0];
+ assert.notStrictEqual(replacement, first);
+ assert.strictEqual(first.webviewPanel, panel);
+ assert.strictEqual(first.connections.length, 1);
+ const originalCommand = sandbox.stub(first, 'handleCommand');
+ const replacementCommand = sandbox.stub(replacement, 'handleCommand');
+ await callbacks.get('r.plot.hidePlot')!(first.id, 'plot');
+ sinon.assert.calledOnceWithExactly(originalCommand, 'hidePlot', 'plot');
+ sinon.assert.notCalled(replacementCommand);
+ await callbacks.get('r.plot.hidePlot')!('localhost:1234', 'replacement-plot');
+ sinon.assert.calledOnceWithExactly(replacementCommand, 'hidePlot', 'replacement-plot');
+ });
+
+ test('httpgd reuses inactive geometry and applies its latest size once reactivated', async () => {
+ const value = manager();
+ const viewer = new RefreshViewer(
+ 'http://localhost:1234',
+ value.httpgdManager.viewerOptions,
+ );
+ value.httpgdManager.registerViewer(viewer);
+ const active = sandbox.stub(viewer.api, 'isActive').returns(false);
+ const render = sandbox.stub(viewer.api, 'getPlotText').resolves('');
+ await viewer.receivePlots(['a'], new Set(['a']));
+ render.resolves('');
+ await viewer.receivePlots(['a'], new Set(['a']));
+ assert.strictEqual(viewer.plots[0].data, '');
+ sinon.assert.calledTwice(render);
+
+ viewer.viewWidth = 900;
+ await viewer.receivePlots(['a']);
+ assert.strictEqual(viewer.plots[0].width, 900);
+ sinon.assert.calledTwice(render);
+
+ viewer.viewWidth = 1000;
+ viewer.viewHeight = 700;
+ viewer.zoom = 1.2;
+ await viewer.receivePlots(['a']);
+ sinon.assert.calledTwice(render);
+
+ // dev.set() changes activity without changing the drawing's update ID.
+ active.returns(true);
+ await viewer.receivePlots(['a'], new Set());
+ sinon.assert.calledThrice(render);
+ assert.deepStrictEqual(render.lastCall.args, [
+ { id: 'a', width: 1000, height: 700, zoom: 1.2, renderer: 'svgp' },
+ ]);
+ await viewer.receivePlots(['a'], new Set());
+ sinon.assert.calledThrice(render);
+ active.returns(false);
+ await viewer.receivePlots(['a']);
+ active.returns(true);
+ await viewer.receivePlots(['a'], new Set());
+ sinon.assert.calledThrice(render);
+ });
+
+ test('httpgd defers background plot resizing until selected on its active device', async () => {
+ const value = manager();
+ const viewer = new RefreshViewer(
+ 'http://localhost:1234',
+ value.httpgdManager.viewerOptions,
+ );
+ value.httpgdManager.registerViewer(viewer);
+ const active = sandbox.stub(viewer.api, 'isActive').returns(false);
+ sandbox.stub(viewer.api, 'getPlots').returns([{ id: 'a' }, { id: 'b' }]);
+ const render = sandbox.stub(viewer.api, 'getPlotText').resolves('');
+ await viewer.receivePlots(['a', 'b'], new Set(['a', 'b']));
+ viewer.viewWidth = 1000;
+ await viewer.focusPlot('a');
+ await viewer.focusPlot('b');
+ render.resetHistory();
+
+ active.returns(true);
+ await viewer.receivePlots(['a', 'b'], new Set());
+ sinon.assert.calledOnce(render);
+ assert.strictEqual(render.firstCall.args[0].id, 'b');
+ // Both cache entries already have the current viewport dimensions.
+ // Focusing the other plot must still replay its deferred geometry.
+ await viewer.focusPlot('a');
+ sinon.assert.calledTwice(render);
+ assert.strictEqual(render.lastCall.args[0].id, 'a');
+ await viewer.focusPlot('a');
+ await viewer.receivePlots(['a', 'b'], new Set());
+ sinon.assert.calledTwice(render);
+ });
+
+ test('httpgd commits healthy devices when a closed device has an uncached plot', async () => {
+ const value = manager();
+ const viewer = new RefreshViewer(
+ 'http://localhost:1234',
+ value.httpgdManager.viewerOptions,
+ );
+ value.httpgdManager.registerViewer(viewer);
+ sandbox.stub(viewer.api, 'isActive').callsFake((id) => id.startsWith('b'));
+ const render = sandbox.stub(viewer.api, 'getPlotText').resolves('');
+ const error = sandbox.stub(vscode.window, 'showErrorMessage');
+ await viewer.receivePlots(['a1', 'b1'], new Set(['a1', 'b1']));
+ const retained = viewer.plots[0];
+ render.withArgs(sinon.match({ id: 'a2' })).rejects(new Error('device closed'));
+ await viewer.receivePlots(['a1', 'b1', 'a2', 'b2'], new Set(['a2', 'b2']));
+ assert.deepStrictEqual(
+ viewer.plots.map((plot) => plot.id),
+ ['a1', 'b1', 'b2'],
+ );
+ assert.strictEqual(viewer.plots[0], retained);
+ assert.strictEqual(viewer.activePlot, 'b2');
+ sinon.assert.calledOnce(error);
+
+ // Another device's notifications must not keep retrying the closed one.
+ await viewer.receivePlots(['a1', 'b1', 'a2', 'b2', 'b3'], new Set(['b3']));
+ assert.strictEqual(viewer.activePlot, 'b3');
+ sinon.assert.calledOnce(render.withArgs(sinon.match({ id: 'a2' })));
+ sinon.assert.calledOnce(error);
+
+ // An explicit refresh can recover a temporarily unavailable device.
+ render.withArgs(sinon.match({ id: 'a2' })).resolves('');
+ await viewer.receivePlots(['a1', 'b1', 'a2', 'b2', 'b3'], new Set(), true);
+ assert.ok(viewer.plots.some((plot) => plot.id === 'a2'));
+ });
+
+ test('httpgd keeps failed content invalidated until its device becomes active again', async () => {
+ const value = manager();
+ const viewer = new RefreshViewer(
+ 'http://localhost:1234',
+ value.httpgdManager.viewerOptions,
+ );
+ value.httpgdManager.registerViewer(viewer);
+ const active = sandbox.stub(viewer.api, 'isActive').returns(false);
+ const render = sandbox.stub(viewer.api, 'getPlotText').resolves('');
+ sandbox.stub(vscode.window, 'showErrorMessage');
+ await viewer.receivePlots(['a'], new Set(['a']));
+ render.rejects(new Error('temporarily unavailable'));
+ await viewer.receivePlots(['a'], new Set(['a']));
+ assert.strictEqual(viewer.plots[0].data, '');
+ await viewer.receivePlots(['a'], new Set());
+ sinon.assert.calledTwice(render);
+
+ active.returns(true);
+ render.resolves('');
+ await viewer.receivePlots(['a'], new Set());
+ assert.strictEqual(viewer.plots[0].data, '');
+ sinon.assert.calledThrice(render);
+ await viewer.receivePlots(['a'], new Set());
+ sinon.assert.calledThrice(render);
+ });
+
+ test('httpgd coalesces notifications arriving during a slow render without losing plots', async () => {
+ const value = manager();
+ const viewer = new RefreshViewer(
+ 'http://localhost:1234',
+ value.httpgdManager.viewerOptions,
+ );
+ value.httpgdManager.registerViewer(viewer);
+ const gate = deferred();
+ const render = sandbox.stub(viewer.api, 'getPlotText').resolves('');
+ render.onFirstCall().returns(gate.promise);
+ const first = viewer.receivePlots(['1']);
+ await Promise.resolve();
+ const second = viewer.receivePlots(['1', '2']);
+ const third = viewer.receivePlots(['1', '2', '3']);
+ sinon.assert.calledOnce(render);
+ gate.resolve('');
+ await Promise.all([first, second, third]);
+ assert.deepStrictEqual(
+ viewer.plots.map((plot) => plot.id),
+ ['1', '2', '3'],
+ );
+ assert.strictEqual(viewer.activePlot, '3');
+ assert.strictEqual(render.getCalls().filter((call) => call.args[0].id === '2').length, 1);
+ assert.strictEqual(render.getCalls().filter((call) => call.args[0].id === '3').length, 1);
+ });
+
+ async function startHttpgdR(context: Mocha.Context) {
+ try {
+ const probe = await promisify(execFile)('Rscript', [
+ '--vanilla',
+ '-e',
+ 'cat(requireNamespace("httpgd", quietly=TRUE) && requireNamespace("jsonlite", quietly=TRUE))',
+ ]);
+ if (probe.stdout.trim() !== 'TRUE') {
+ throw new Error('Live httpgd tests require httpgd and jsonlite');
+ }
+ } catch (error) {
+ if (process.env.VSCR_REQUIRE_PLOT_TESTS === '1') {
+ throw error;
+ }
+ context.skip();
+ }
+ const r = spawn('R', [
+ '--vanilla',
+ '--slave',
+ process.platform === 'win32' ? '--ess' : '--interactive',
+ ]);
+ const exited = new Promise((resolve) => r.once('exit', () => resolve()));
+ let output = '';
+ let errors = '';
+ r.stdout.on('data', (data: Buffer) => {
+ output += data.toString();
+ });
+ r.stderr.on('data', (data: Buffer) => {
+ errors += data.toString();
+ });
+ let sequence = 0;
+ const evaluate = async (code: string): Promise => {
+ const marker = `HTTPGD_RESULT_${++sequence}:`;
+ r.stdin.write(
+ `tryCatch({value <- {${code}}; cat(${JSON.stringify(marker)}, jsonlite::toJSON(value, auto_unbox=TRUE), "\\n", sep="")}, error=function(e) cat(${JSON.stringify(marker)}, jsonlite::toJSON(list(error=conditionMessage(e)), auto_unbox=TRUE), "\\n", sep=""))\n`,
+ );
+ const line = await waitForValue(() => {
+ if (r.exitCode !== null) {
+ throw new Error(`R exited: ${errors}`);
+ }
+ const start = output.indexOf(marker);
+ const end = output.indexOf('\n', start);
+ return start >= 0 && end >= 0
+ ? output.slice(start + marker.length, end)
+ : undefined;
+ });
+ const result: unknown = JSON.parse(line);
+ assert.strictEqual(typeof result, 'string', line);
+ return result as string;
+ };
+ return {
+ r,
+ evaluate,
+ exited,
+ async dispose() {
+ if (r.exitCode === null && r.signalCode === null) {
+ r.kill();
+ }
+ await exited;
+ },
+ };
+ }
+
+ test('live httpgd dev.new shares navigation, resize, export, reload and exit cleanup', async function () {
+ this.timeout(30000);
+ connect.callThrough();
+ const process = await startHttpgdR(this);
+ const { r, evaluate, exited } = process;
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'httpgd-session-'));
+ try {
+ const source = owner('live-httpgd', String(r.pid));
+ source.host = os.hostname();
+ focus(source);
+ sandbox.stub(session, 'resolvePlotSession').returns(source);
+ const first = manager();
+ const firstUrl = await evaluate(
+ 'options(device=function(...) httpgd::hgd(silent=TRUE)); plot(1:10, main="FIRST DEVICE"); first_device <- dev.cur(); httpgd::hgd_url()',
+ );
+ await first.showHttpgdPlot(firstUrl, source);
+ const viewer = first.httpgdManager.viewers[0];
+ await waitForValue(() => (viewer.plots.length === 1 ? true : undefined));
+ const panel = viewer.webviewPanel;
+ await evaluate('plot(1:100, main="SECOND SAME DEVICE"); "OK"');
+ await waitForValue(() => (viewer.plots.length === 2 ? true : undefined));
+ const toolbarUri = vscode.Uri.parse(
+ 'webview-panel:webview-panel/webview-RPlot-live-httpgd',
+ );
+ await callbacks.get('r.plot.prevPlot')!(toolbarUri, { groupId: 0 });
+ assert.strictEqual(viewer.activePlot, viewer.plots[0].id);
+ assert.ok(viewer.plots[0].data.includes('FIRST DEVICE'));
+ await callbacks.get('r.plot.nextPlot')!(toolbarUri, { groupId: 0 });
+ assert.strictEqual(viewer.activePlot, viewer.plots[1].id);
+ assert.ok(viewer.plots[1].data.includes('SECOND SAME DEVICE'));
+ await viewer.closePlot(viewer.plots[1].id);
+ await waitForValue(() => (viewer.plots.length === 1 ? true : undefined));
+ const secondUrl = await evaluate(
+ 'dev.new(); plot(1:100, main="SECOND DEVICE"); httpgd::hgd_url()',
+ );
+ await first.showHttpgdPlot(secondUrl, source);
+ await waitForValue(() => (viewer.plots.length === 2 ? true : undefined));
+ assert.strictEqual(first.httpgdManager.viewers.length, 1);
+ assert.strictEqual(viewer.webviewPanel, panel);
+ assert.ok(viewer.plots[0].data.includes('FIRST DEVICE'));
+ assert.ok(viewer.plots[1].data.includes('SECOND DEVICE'));
+ await viewer.prevPlot();
+ assert.strictEqual(viewer.activePlot, viewer.plots[0].id);
+ viewer.viewWidth = 920;
+ viewer.viewHeight = 680;
+ await viewer.focusPlot();
+ assert.strictEqual(viewer.plots[0].width, 920);
+ assert.ok(viewer.plots[0].data.includes('FIRST DEVICE'));
+ assert.ok(!viewer.plots[0].data.includes('width="920.00"'));
+ // Reactivation alone must apply the deferred size, without another
+ // resize event, new plot, or annotation to invalidate the drawing.
+ await evaluate('dev.set(first_device); "OK"');
+ await waitForValue(() =>
+ viewer.plots[0].data.includes('width="920.00"') ? true : undefined,
+ );
+ assert.ok(viewer.plots[0].data.includes('height="680.00"'));
+ assert.ok(viewer.plots[0].data.includes('FIRST DEVICE'));
+ const file = path.join(directory, 'first.svg');
+ await viewer.exportPlot(viewer.activePlot, 'svgp', file);
+ assert.ok((await fs.readFile(file, 'utf8')).includes('FIRST DEVICE'));
+ await evaluate('dev.set(first_device); plot(11:20, main="THIRD PLOT"); "OK"');
+ await waitForValue(() => (viewer.plots.length === 3 ? true : undefined));
+ assert.ok(viewer.plots[2].data.includes('THIRD PLOT'));
+ viewer.viewWidth = 960;
+ await viewer.focusPlot(viewer.plots[0].id);
+ assert.ok(viewer.plots[0].data.includes('width="960.00"'));
+ assert.ok(viewer.plots[0].data.includes('FIRST DEVICE'));
+ panel!.dispose();
+ const titles = (plots: HttpgdViewer['plots']) =>
+ plots.map((plot) => plot.data.match(/FIRST DEVICE|SECOND DEVICE|THIRD PLOT/)?.[0]);
+ const originalOrder = titles(viewer.plots);
+ await waitForValue(() => {
+ const key = state.get('r.plot.httpgdConnectionsKey') as string;
+ const saved = JSON.parse(secrets.get(key) ?? '[]') as typeof viewer.connections;
+ const order = saved.flatMap((connection) => connection.plotOrder ?? []);
+ return JSON.stringify(order) === JSON.stringify(viewer.api.plotOrder)
+ ? true
+ : undefined;
+ });
+ await first.dispose();
+ const restored = manager();
+ await callbacks.get('r.plot.showViewers')!();
+ const live = restored.httpgdManager.viewers[0];
+ await waitForValue(() => (live.plots.length === 3 ? true : undefined));
+ assert.deepStrictEqual(titles(live.plots), originalOrder);
+ assert.strictEqual(restored.httpgdManager.viewers.length, 1);
+ assert.strictEqual(live.connections.length, 2);
+ const second = live.plots.find((plot) => plot.data.includes('SECOND DEVICE'))!;
+ await live.closePlot(second.id);
+ await waitForValue(() => (live.api.getPlots().length === 2 ? true : undefined));
+ assert.strictEqual(live.plots.length, 2);
+ assert.ok(live.plots[0].data.includes('FIRST DEVICE'));
+ await evaluate('dev.off(first_device); "OK"');
+ live.viewWidth = 1010;
+ await live.focusPlot(live.plots[0].id);
+ assert.strictEqual(live.plots[0].width, 1010);
+ assert.ok(live.plots[0].data.includes('FIRST DEVICE'));
+ const retainedPanel = live.webviewPanel!;
+ const rendered = retainedPanel.webview.html;
+ const closed = sandbox.spy(retainedPanel, 'dispose');
+ await live.nextPlot(true);
+ r.kill();
+ await exited;
+ await waitForValue(() => (live.isDisposed ? true : undefined));
+ await restored.httpgdManager.saveConnections();
+ assert.strictEqual(secrets.size, 0);
+ sinon.assert.notCalled(closed);
+ assert.strictEqual(retainedPanel.webview.html, rendered);
+ assert.strictEqual(live.webviewPanel, undefined);
+ assert.strictEqual(live.plots.length, 0);
+ await callbacks.get('r.plot.firstPlot')!(live.id);
+ assert.strictEqual(live.activePlot, undefined, 'ended sessions have no navigation');
+ assert.strictEqual(live.plots.length, 0);
+ assert.strictEqual(live.api.getPlots().length, 0);
+ retainedPanel.dispose();
+ } finally {
+ await process.dispose();
+ await fs.rm(directory, { recursive: true, force: true });
+ }
+ });
+
+ test('live httpgd retains selection and navigation after every device closes', async function () {
+ this.timeout(30000);
+ connect.callThrough();
+ (vscode.window.createWebviewPanel as sinon.SinonStub).callsFake(
+ (...args: Parameters) => createRealWebviewPanel(...args),
+ );
+ const errors = sandbox.stub(vscode.window, 'showErrorMessage').resolves();
+ const process = await startHttpgdR(this);
+ await prepareEditorLayout();
+ const originalLayout = await executeRealCommand('vscode.getEditorLayout');
+ let viewer: HttpgdViewer | undefined;
+ const document = await vscode.workspace.openTextDocument({ language: 'plaintext' });
+ try {
+ await vscode.window.showTextDocument(document, { viewColumn: vscode.ViewColumn.One });
+ const layout = (width: number) =>
+ executeRealCommand('vscode.setEditorLayout', {
+ orientation: 0,
+ groups: [{ size: 1 - width }, { size: width }],
+ });
+ await layout(0.6);
+ const source = owner('live-httpgd-close', String(process.r.pid));
+ source.host = os.hostname();
+ const value = manager();
+ const first = await process.evaluate(
+ 'options(device=function(...) httpgd::hgd(silent=TRUE)); plot(1:5, main="FIRST DEVICE"); first_device <- dev.cur(); httpgd::hgd_url()',
+ );
+ await value.showHttpgdPlot(first, source);
+ viewer = value.httpgdManager.viewers[0];
+ await waitForValue(() => (viewer!.plots.length === 1 ? true : undefined));
+ viewer.show(false);
+ await waitForValue(() => (viewer!.plots[0].width !== 800 ? true : undefined));
+ const firstId = viewer.plots[0].id;
+ const second = await process.evaluate(
+ 'dev.new(); plot(5:1, main="SECOND DEVICE"); httpgd::hgd_url()',
+ );
+ await value.showHttpgdPlot(second, source);
+ await waitForValue(() => (viewer!.plots.length === 2 ? true : undefined));
+ assert.notStrictEqual(viewer.activePlot, firstId);
+ const secondId = viewer.activePlot;
+ const third = await process.evaluate(
+ 'dev.new(); plot(1:10, main="THIRD DEVICE"); httpgd::hgd_url()',
+ );
+ await value.showHttpgdPlot(third, source);
+ await waitForValue(() => (viewer!.plots.length === 3 ? true : undefined));
+ const thirdId = viewer.activePlot;
+ await process.evaluate('dev.off(first_device); "OK"');
+ assert.strictEqual(viewer.activePlot, thirdId);
+ await process.evaluate('dev.off(); "OK"');
+ assert.strictEqual(
+ viewer.activePlot,
+ thirdId,
+ 'closing a device preserves its rendered plot',
+ );
+ await process.evaluate('dev.off(); "OK"');
+ const render = sandbox.spy(viewer.api, 'getPlotText');
+ for (const width of [0.3, 0.5]) {
+ const before = viewer.viewWidth;
+ await layout(width);
+ await waitForValue(() => (viewer!.viewWidth !== before ? true : undefined));
+ await new Promise((resolve) => setTimeout(resolve, 200));
+ }
+ assert.ok(
+ render.callCount <= 1,
+ 'Only the first resize may discover the closed server',
+ );
+ sinon.assert.notCalled(errors);
+ assert.strictEqual(viewer.activePlot, thirdId);
+ await viewer.focusPlot(firstId);
+ assert.ok(
+ viewer.plots.find((plot) => plot.id === firstId)!.data.includes('FIRST DEVICE'),
+ );
+ await viewer.focusPlot(secondId);
+ assert.ok(
+ viewer.plots.find((plot) => plot.id === secondId)!.data.includes('SECOND DEVICE'),
+ );
+ assert.strictEqual(viewer.plots.length, 3);
+ } finally {
+ viewer?.webviewPanel?.dispose();
+ await process.dispose();
+ for (const group of vscode.window.tabGroups.all) {
+ for (const tab of group.tabs) {
+ if (
+ tab.input instanceof vscode.TabInputText &&
+ tab.input.uri.toString() === document.uri.toString()
+ ) {
+ await vscode.window.tabGroups.close(tab);
+ }
+ }
+ }
+ await executeRealCommand('vscode.setEditorLayout', originalLayout);
+ }
+ });
+
+ test('standard reconnect refreshes only its existing panel without revealing or reopening it', async () => {
+ const a = owner('a', '123');
+ const b = owner('b', '456');
+ const reattached = owner('a', '123');
+ focus(b);
+ sandbox
+ .stub(session, 'resolvePlotSession')
+ .callsFake((source) => (source.sessionId === a.sessionId ? reattached : b));
+ const request = sandbox
+ .stub(session, 'sessionRequest')
+ .resolves({ data: 'old', format: 'svg' });
+ const value = manager();
+ await value.showStandardPlot(a);
+ await value.showStandardPlot(b);
+ await panels[0].receive({ type: 'ready' });
+ request.resetHistory();
+ panels[0].post.resetHistory();
+ request.resolves({ data: 'drawn-while-disconnected', format: 'svg' });
+
+ await value.showStandardPlot(reattached, true);
+ sinon.assert.calledOnce(request);
+ assert.strictEqual(request.firstCall.args[1], reattached);
+ sinon.assert.calledWith(panels[0].post, {
+ type: 'update',
+ data: 'drawn-while-disconnected',
+ format: 'svg',
+ });
+ sinon.assert.notCalled((panels[0].panel as unknown as { reveal: sinon.SinonStub }).reveal);
+ sinon.assert.notCalled(panels[1].post);
+
+ panels[0].panel.dispose();
+ request.resetHistory();
+ await value.showStandardPlot(reattached, true);
+ await value.showStandardPlot(owner('never-opened', '789'), true);
+ sinon.assert.notCalled(request);
+ assert.strictEqual(panels.length, 2);
+ assert.strictEqual(value.viewers.length, 2);
+ });
+
+ test('standard restoration requests only the focused session', async () => {
+ const a = owner('a', '123');
+ const b = owner('b', '456');
+ focus(a);
+ sandbox.stub(session, 'resolvePlotSession').returns(a);
+ const request = sandbox
+ .stub(session, 'sessionRequest')
+ .resolves({ data: 'svg', format: 'svg' });
+ const first = manager();
+ await first.showStandardPlot(a);
+ await first.showStandardPlot(b);
+ for (const { panel } of panels) {
+ panel.dispose();
+ }
+ await first.dispose();
+ panels = [];
+ manager();
+ await callbacks.get('r.plot.showViewers')!();
+ assert.strictEqual(panels.length, 1);
+ await panels[0].receive({ type: 'ready' });
+ sinon.assert.calledOnce(request);
+ assert.strictEqual(request.firstCall.args[1], a);
+ });
+
+ test('JGD frames update only their own viewer without redundant toolbar messages', async () => {
+ const value = manager();
+ const deliver = startJgd(value);
+ const history = value.jgdManager.history;
+ history.addPlot('a', frame('a'));
+ history.setSessionLabel('a', 'r-123-1');
+ deliver('a', { type: 'frame' });
+ await panels[0].receive({ type: 'ready' });
+ const post = panels[0].post;
+ post.resetHistory();
+ for (let index = 0; index < 100; index++) {
+ history.addPlot('b', frame('b'));
+ deliver('b', { type: 'frame' });
+ }
+ sinon.assert.notCalled(post);
+ assert.strictEqual(panels.length, 2);
+ history.setSessionLabel('b', 'r-456-1');
+ await panels[1].receive({ type: 'ready' });
+ assert.strictEqual(panels[0].panel.title, 'R Plot (JGD)');
+ assert.strictEqual(panels[1].panel.title, 'R Plot (JGD)');
+ assert.ok(!panels[0].panel.webview.html.includes('session-select'));
+ panels[1].post.resetHistory();
+
+ for (let index = 0; index < 100; index++) {
+ history.appendOps('a', { ...frame('a'), ops: [{ op: 'line', index }] });
+ deliver('a', { type: 'frame' });
+ }
+ assert.strictEqual(post.withArgs(sinon.match({ type: 'render' })).callCount, 100);
+ sinon.assert.notCalled(post.withArgs(sinon.match({ type: 'toolbar' })));
+ sinon.assert.notCalled(panels[1].post);
+ assert.strictEqual(history.currentPlot('a')?.ops.length, 100);
+ await panels[0].receive({ type: 'ready' });
+ sinon.assert.calledOnce(post.withArgs(sinon.match({ type: 'toolbar' })));
+ });
+
+ test('JGD tabs independently navigate, resize, measure text, remove plots and reopen', async () => {
+ const value = manager();
+ const metrics = sandbox.spy(value.jgdManager.server, 'setMeasureText');
+ const dimensions = sandbox.spy(value.jgdManager.server, 'setGetDimensions');
+ const deliver = startJgd(value);
+ const resize = sandbox.stub(value.jgdManager.server, 'handleResize');
+ const history = value.jgdManager.history;
+ const aOwner = owner('a', '123');
+ focus(aOwner);
+ sandbox.stub(session, 'resolvePlotSession').returns(aOwner);
+ for (const id of ['a', 'b']) {
+ history.addPlot(id, frame(id, `${id}1`));
+ history.addPlot(id, frame(id, `${id}2`));
+ history.setSessionSource(id, id === 'a' ? aOwner : owner('b', '456'));
+ deliver(id, { type: 'frame' });
+ await panels.at(-1)!.receive({ type: 'ready' });
+ }
+ const a = value.jgdManager.getViewer('a')!;
+ const b = value.jgdManager.getViewer('b')!;
+ assert.notStrictEqual(a.id, b.id);
+ Object.assign(panels[0].panel, { active: false });
+ assert.strictEqual(value.activeViewer, b);
+ Object.assign(panels[0].panel, { active: true });
+ Object.assign(panels[1].panel, { active: false });
+ await callbacks.get('r.plot.prevPlot')!(
+ vscode.Uri.parse('webview-panel:webview-panel/webview-jgd.plotPane-a'),
+ );
+ assert.strictEqual(history.currentIndex('a'), 1);
+ assert.strictEqual(history.currentIndex('b'), 2);
+ sinon.assert.calledWithExactly(resize, 800, 600, 'a');
+ await panels[0].receive({ type: 'resize', width: 700, height: 500 });
+ await panels[1].receive({ type: 'resize', width: 900, height: 650 });
+ sinon.assert.calledWithExactly(resize, 700, 500, 'a');
+ sinon.assert.calledWithExactly(resize, 900, 650, 'b');
+ const getDimensions = dimensions.firstCall.args[0];
+ assert.deepStrictEqual(getDimensions('a'), { width: 700, height: 500 });
+ assert.deepStrictEqual(getDimensions('b'), { width: 900, height: 650 });
+ const measure = metrics.firstCall.args[0];
+ panels[0].post.resetHistory();
+ panels[1].post.resetHistory();
+ const measured = measure(
+ { type: 'metrics_request', id: 7, kind: 'strWidth', str: 'x' },
+ 96,
+ 'a',
+ );
+ const request = panels[0].post.lastCall.args[0] as { id: number };
+ sinon.assert.notCalled(panels[1].post);
+ await panels[0].receive({
+ type: 'metrics_response',
+ id: request.id,
+ originalId: 7,
+ width: 42,
+ ascent: 10,
+ descent: 3,
+ });
+ assert.deepStrictEqual(await measured, {
+ type: 'metrics_response',
+ id: 7,
+ width: 42,
+ ascent: 10,
+ descent: 3,
+ });
+ await panels[0].receive({ type: 'deleteCurrent' });
+ assert.strictEqual(history.count('a'), 1);
+ assert.strictEqual(history.count('b'), 2);
+ await b.handleCommand('resetPlots');
+ assert.strictEqual(history.count('a'), 1);
+ assert.strictEqual(history.count('b'), 0);
+ panels[0].panel.dispose();
+ await callbacks.get('r.plot.showViewers')!();
+ assert.strictEqual(panels.length, 3, 'only the closed tab is recreated');
+ await panels[2].receive({ type: 'ready' });
+ sinon.assert.calledWith(
+ panels[2].post,
+ sinon.match({ type: 'render', plot: sinon.match({ sessionId: 'a' }) }),
+ );
+ });
+
+ test('JGD restores separate tabs with each session history position after reload', async () => {
+ const first = manager();
+ const history = first.jgdManager.history;
+ history.addPlot('a', frame('a', 'a1'));
+ history.addPlot('a', frame('a', 'a2'));
+ history.addPlot('b', frame('b', 'b1'));
+ history.addPlot('b', frame('b', 'b2'));
+ history.navigatePrevious('a');
+ const a = owner('a', '123');
+ const b = owner('b', '456');
+ history.setSessionSource('a', a);
+ history.setSessionSource('b', b);
+ const active = sandbox.stub(session, 'activeSession').value(a);
+ sandbox
+ .stub(session, 'resolvePlotSession')
+ .callsFake((source) => source as session.Session);
+ await first.dispose();
+ const restored = manager();
+ assert.strictEqual(panels.length, 0);
+ await callbacks.get('r.plot.showViewers')!();
+ assert.strictEqual(panels.length, 1);
+ active.value(b);
+ await callbacks.get('r.plot.showViewers')!();
+ assert.strictEqual(panels.length, 2);
+ for (const [index, id, bg] of [
+ [0, 'a', 'a1'],
+ [1, 'b', 'b2'],
+ ] as const) {
+ await panels[index].receive({ type: 'ready' });
+ sinon.assert.calledWith(
+ panels[index].post,
+ sinon.match({
+ type: 'render',
+ plot: sinon.match({ sessionId: id, device: sinon.match({ bg }) }),
+ }),
+ );
+ assert.strictEqual(panels[index].panel.title, 'R Plot (JGD)');
+ }
+ await restored.jgdManager.getViewer('b')!.handleCommand('firstPlot');
+ assert.strictEqual(restored.jgdManager.history.currentIndex('a'), 1);
+ assert.strictEqual(restored.jgdManager.history.currentIndex('b'), 1);
+ });
+
+ test('standard restoration waits for the owning session instead of showing an archived image', async () => {
+ const first = manager();
+ const owner = {
+ sessionId: 'a',
+ host: 'host',
+ pid: '123',
+ rVer: '4.6.0',
+ processExited: false,
+ } as session.Session;
+ sandbox.stub(session, 'sessionRequest').resolves({ data: 'plot', format: 'svg' });
+ focus(owner);
+ const resolve = sandbox.stub(session, 'resolvePlotSession').returns(undefined);
+ const information = sandbox.stub(vscode.window, 'showInformationMessage').resolves();
+ await first.showStandardPlot(owner);
+ await panels[0].receive({ type: 'ready' });
+ await first.dispose();
+ panels = [];
+ manager();
+ await callbacks.get('r.plot.showViewers')!();
+ assert.strictEqual(panels.length, 0);
+ sinon.assert.calledOnce(information);
+ resolve.returns(owner);
+ await callbacks.get('r.plot.showViewers')!();
+ assert.strictEqual(panels.length, 1);
+ });
+
+ test('httpgd reconnects with its token and retains live rendering and removal after reload', async () => {
+ const source = owner('httpgd-owner', '14001');
+ focus(source);
+ sandbox.stub(session, 'resolvePlotSession').returns(source);
+ const first = manager();
+ await first.showHttpgdPlot('http://localhost:1234/live?token=secret', source);
+ RefreshViewer.prototype.seedPlots.call(first.httpgdManager.viewers[0], [
+ { id: '1', data: '', width: 400, height: 300, zoom: 1 },
+ ]);
+ await first.dispose();
+ assert.ok(!JSON.stringify([...state.values()]).includes('secret'));
+ assert.ok(!JSON.stringify([...secrets.values()]).includes('