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('original'); + await viewer.receivePlots(['a'], new Set(['a'])); + render.resolves('annotation'); + await viewer.receivePlots(['a'], new Set(['a'])); + assert.strictEqual(viewer.plots[0].data, 'annotation'); + 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('retained'); + 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('recovered'); + 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('original'); + 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, 'original'); + await viewer.receivePlots(['a'], new Set()); + sinon.assert.calledTwice(render); + + active.returns(true); + render.resolves('annotation'); + await viewer.receivePlots(['a'], new Set()); + assert.strictEqual(viewer.plots[0].data, 'annotation'); + 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: 'not persisted', width: 400, height: 300, zoom: 1 }, + ]); + await first.dispose(); + assert.ok(!JSON.stringify([...state.values()]).includes('secret')); + assert.ok(!JSON.stringify([...secrets.values()]).includes('')); + panels = []; + connect.resetHistory(); + const restored = manager(); + sinon.assert.notCalled(connect); + await callbacks.get('r.plot.showViewers')!(); + sinon.assert.calledOnce(connect); + const viewer = restored.httpgdManager.viewers[0]; + assert.strictEqual(viewer.token, 'secret'); + assert.deepStrictEqual(viewer.plots, []); + RefreshViewer.prototype.seedPlots.call(viewer, [ + { id: 'live', data: '', width: 1, height: 1, zoom: 1 }, + ]); + const render = sandbox.stub(viewer.api, 'getPlotText').resolves('fresh'); + sandbox.stub(viewer.api, 'getPlots').returns([{ id: 'live' }]); + await viewer.focusPlot('live'); + sinon.assert.called(render); + const remove = sandbox.stub(viewer.api, 'removePlot').resolves(); + await viewer.closePlot('live'); + sinon.assert.calledWithExactly(remove, { id: 'live' }); + await callbacks.get('r.plot.showViewers')!(); + sinon.assert.calledOnce(connect); + }); + + for (const fullWindow of [false, true]) { + test(`httpgd ${fullWindow ? 'full-window' : 'divider'} layout follows a resized group after closing and reopening`, async () => { + interface Layout { + width: number; + height: number; + windowWidth: number; + windowHeight: number; + fullWindow: boolean; + } + let inspection: Layout | undefined; + (vscode.window.createWebviewPanel as sinon.SinonStub).callsFake( + (...args: Parameters) => { + const panel = createRealWebviewPanel(...args); + panel.webview.onDidReceiveMessage((message: Layout & { message: string }) => { + if (message.message === 'layout-inspection') { + inspection = message; + } + }); + return panel; + }, + ); + const layout = (width: number, height: number) => + executeRealCommand('vscode.setEditorLayout', { + orientation: 0, + groups: [ + { size: 1 - width }, + { size: width, groups: [{ size: height }, { size: 1 - height }] }, + ], + }); + const value = manager(); + const viewer = new LayoutViewer('http://localhost:1234', { + ...value.httpgdManager.viewerOptions, + fullWindow, + resizeTimeoutLength: 0, + source: owner('layout', '14002'), + }); + value.httpgdManager.registerViewer(viewer); + const svg = (width: number, height: number) => + ``; + RefreshViewer.prototype.seedPlots.call(viewer, [ + { id: '1', data: svg(800, 400), width: 800, height: 400, zoom: 1 }, + ]); + viewer.activePlot = '1'; + const render = sandbox + .stub(viewer.api, 'getPlotText') + .callsFake((request) => Promise.resolve(svg(request.width!, request.height!))); + const inspect = async (dragHeight?: number): Promise => { + inspection = undefined; + await viewer.webviewPanel!.webview.postMessage({ + message: 'inspect-layout', + dragHeight, + }); + return waitForValue(() => inspection); + }; + await prepareEditorLayout(); + const originalLayout = await executeRealCommand('vscode.getEditorLayout'); + const documents: vscode.TextDocument[] = []; + try { + for (const viewColumn of [ + vscode.ViewColumn.One, + vscode.ViewColumn.Two, + vscode.ViewColumn.Three, + ]) { + const document = await vscode.workspace.openTextDocument({ + language: 'plaintext', + }); + documents.push(document); + await vscode.window.showTextDocument(document, { viewColumn, preview: false }); + } + await layout(0.7, 0.75); + viewer.show(); + await waitForValue(() => (render.called ? true : undefined)); + const enlarged = await inspect(fullWindow ? undefined : 240); + assert.strictEqual(enlarged.fullWindow, fullWindow); + if (!fullWindow) { + assert.strictEqual(enlarged.height, 240); + } + viewer.webviewPanel!.dispose(); + assert.strictEqual(viewer.webviewPanel, undefined); + await layout(0.4, 0.5); + render.resetHistory(); + viewer.show(); + await waitForValue(() => (render.called ? true : undefined)); + const reopened = await inspect(); + assert.ok( + reopened.windowWidth < enlarged.windowWidth, + `width should shrink: ${enlarged.windowWidth} -> ${reopened.windowWidth}`, + ); + assert.ok( + reopened.windowHeight < enlarged.windowHeight, + `height should shrink: ${enlarged.windowHeight} -> ${reopened.windowHeight}`, + ); + assert.strictEqual(reopened.fullWindow, fullWindow); + assert.strictEqual( + viewer.viewWidth, + fullWindow ? reopened.windowWidth : reopened.width, + ); + assert.strictEqual(viewer.viewHeight, reopened.height); + if (fullWindow) { + assert.strictEqual(reopened.height, reopened.windowHeight - 28); + } else { + assert.strictEqual( + reopened.height, + enlarged.height, + 'preserve the divider height when reopening', + ); + } + } finally { + viewer.webviewPanel?.dispose(); + for (const group of vscode.window.tabGroups.all) { + for (const tab of group.tabs) { + if ( + tab.input instanceof vscode.TabInputText && + documents.some( + (document) => + document.uri.toString() === + (tab.input as vscode.TabInputText).uri.toString(), + ) + ) { + await vscode.window.tabGroups.close(tab); + } + } + } + await executeRealCommand('vscode.setEditorLayout', originalLayout); + } + }); + } + + test('httpgd reload fetches fresh plots from an authenticated server', async () => { + const source = owner('httpgd-owner', '14001'); + focus(source); + sandbox.stub(session, 'resolvePlotSession').returns(source); + connect.callThrough(); + let revision = 1; + let removed = false; + const requests: string[] = []; + const server = http.createServer((request, response) => { + if (request.headers['x-httpgd-token'] !== 'device-token') { + response.writeHead(401).end(); + return; + } + const route = new URL(request.url!, 'http://localhost').pathname; + requests.push(route); + const state = { upid: revision, hsize: removed ? 0 : 1, active: true }; + switch (route) { + case '/renderers': + response.end( + JSON.stringify({ + renderers: [{ id: 'svgp', name: 'SVG', ext: 'svg', descr: 'SVG' }], + }), + ); + break; + case '/plots': + response.end(JSON.stringify({ state, plots: removed ? [] : [{ id: '1' }] })); + break; + case '/plot': + response.end(`revision ${revision}`); + break; + case '/remove': + removed = true; + response.end(JSON.stringify({ ...state, hsize: 0 })); + break; + default: + response.end(JSON.stringify(state)); + } + }); + server.on('upgrade', (_request, socket) => socket.destroy()); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + let restored: CommonPlotManager | undefined; + try { + const first = manager(); + const address = server.address() as AddressInfo; + await first.showHttpgdPlot( + `http://127.0.0.1:${address.port}/live?token=device-token`, + source, + ); + await waitForValue(() => + first.httpgdManager.viewers[0].plots.length ? true : undefined, + ); + await first.dispose(); + revision = 2; + requests.length = 0; + restored = manager(); + await callbacks.get('r.plot.showViewers')!(); + const viewer = restored.httpgdManager.viewers[0]; + await waitForValue(() => + viewer.plots[0]?.data.includes('revision 2') ? true : undefined, + ); + assert.ok(requests.includes('/renderers')); + assert.ok(requests.includes('/plots')); + assert.ok(requests.includes('/plot')); + assert.ok( + (await viewer.api.getPlotBytes({ id: '1', renderer: 'svgp' })) + .toString() + .includes('revision 2'), + ); + await viewer.closePlot('1'); + assert.strictEqual(removed, true); + } finally { + await restored?.dispose(); + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + test('httpgd restoration does not replace a device that already attached with a new token', 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=old', source); + await first.dispose(); + const restored = manager(); + await restored.showHttpgdPlot('http://localhost:1234/live?token=new', source); + const live = restored.httpgdManager.viewers[0]; + await callbacks.get('r.plot.showViewers')!(); + assert.deepStrictEqual(restored.httpgdManager.viewers, [live]); + assert.strictEqual(live.token, 'new'); + }); + + test('httpgd restoration keeps credentials from an attachment arriving at its async boundary', 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=old', source); + await first.dispose(); + const restored = manager(); + const show = restored.httpgdManager.showViewer.bind(restored.httpgdManager); + sandbox + .stub(restored.httpgdManager, 'showViewer') + .callsFake((url, source, onlyIfMissing) => { + if (new URL(url).searchParams.get('token') === 'old') { + void show('http://localhost:1234/live?token=new', source); + } + return show(url, source, onlyIfMissing); + }); + await callbacks.get('r.plot.showViewers')!(); + assert.strictEqual(restored.httpgdManager.viewers.length, 1); + assert.strictEqual(restored.httpgdManager.viewers[0].token, 'new'); + }); + + test('a real JGD webview receives and exports restored frames after its ready handshake', async () => { + const first = manager(); + first.jgdManager.history.addPlot('old-device', { + version: 1, + sessionId: 'old-device', + device: { width: 400, height: 300, dpi: 96, bg: 'magenta' }, + ops: [], + }); + const source = owner('jgd-real-indicator', '12140'); + focus(source); + sandbox.stub(session, 'resolvePlotSession').returns(source); + first.jgdManager.history.setSessionSource('old-device', source); + await first.dispose(); + let panel: vscode.WebviewPanel | undefined; + let resolveIndicator!: (message: { label: string; fontLoaded: boolean }) => void; + const indicator = new Promise<{ label: string; fontLoaded: boolean }>((resolve) => { + resolveIndicator = resolve; + }); + let resolveReady!: () => void; + let resolveExport!: (data: string) => void; + const ready = new Promise((resolve) => { + resolveReady = resolve; + }); + const exported = new Promise((resolve) => { + resolveExport = resolve; + }); + (vscode.window.createWebviewPanel as sinon.SinonStub).callsFake( + (...args: Parameters) => { + panel = createRealWebviewPanel(...args); + panel.webview.onDidReceiveMessage( + (message: { + type: string; + data?: string; + label: string; + fontLoaded: boolean; + }) => { + if (message.type === 'indicator-check') { + resolveIndicator(message); + } + if (message.type === 'ready') { + resolveReady(); + } + if (message.type === 'export_data' && message.data) { + resolveExport(message.data); + } + }, + ); + return panel; + }, + ); + sandbox.stub(vscode.window, 'showSaveDialog').resolves(undefined); + manager(); + let timer: NodeJS.Timeout | undefined; + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout( + () => reject(new Error('Restored webview did not render/export')), + 15000, + ); + }); + try { + await callbacks.get('r.plot.showViewers')!(); + panel!.webview.html = panel!.webview.html.replace( + '', + ``, + ); + await Promise.race([ready, timeout]); + const details = await Promise.race([indicator, timeout]); + assert.strictEqual(details.label, 'R 4.6.0: 12140'); + assert.strictEqual( + details.fontLoaded, + true, + 'the shared information icon font loads under the JGD CSP', + ); + await panel!.webview.postMessage({ + type: 'export', + format: 'svg', + width: 400, + height: 300, + }); + const svg = Buffer.from(await Promise.race([exported, timeout]), 'base64').toString( + 'utf8', + ); + assert.ok(svg.includes('