diff --git a/.vscode-test.mjs b/.vscode-test.mjs
index 2b4bbe963..037c02ca6 100644
--- a/.vscode-test.mjs
+++ b/.vscode-test.mjs
@@ -19,9 +19,6 @@ const common = {
ui: 'tdd',
color: true,
timeout: 20000
- },
- desktop: {
- installExtensions: ['REditorSupport.r-syntax']
}
};
diff --git a/esbuild.js b/esbuild.js
index cfa0d554b..5bdef1db0 100644
--- a/esbuild.js
+++ b/esbuild.js
@@ -55,6 +55,10 @@ function copyWebviewAssets() {
}
async function main() {
+ // Remove the former standalone iframe bridge left by incremental builds.
+ for (const file of ['widget.js', 'widget.js.map']) {
+ fs.rmSync(path.join(__dirname, 'dist', 'webviews', 'webview', file), { force: true });
+ }
require('./scripts/prepare-sess').prepareBundledSess();
copyResources();
copyWebviewAssets();
diff --git a/package.json b/package.json
index e2c3c2dde..471eff3d6 100644
--- a/package.json
+++ b/package.json
@@ -32,6 +32,7 @@
"activationEvents": [
"workspaceContains:**/*.{rproj,Rproj,r,R,rd,Rd,rmd,Rmd}",
"onWebviewPanel:rhelp",
+ "onWebviewPanel:r.htmlViewer",
"onLanguage:r",
"onLanguage:rmd",
"onNotebook:r-interactive",
@@ -254,6 +255,39 @@
"icon": "$(search)",
"category": "R"
},
+ {
+ "command": "r.htmlViewer.restore",
+ "title": "Restore HTML Viewer",
+ "category": "R"
+ },
+ {
+ "command": "r.htmlViewer.back",
+ "title": "Previous HTML Output",
+ "category": "R HTML Viewer",
+ "icon": "$(arrow-circle-left)",
+ "enablement": "activeWebviewPanelId == 'r.htmlViewer' && r.htmlViewer.canGoBack"
+ },
+ {
+ "command": "r.htmlViewer.forward",
+ "title": "Next HTML Output",
+ "category": "R HTML Viewer",
+ "icon": "$(arrow-circle-right)",
+ "enablement": "activeWebviewPanelId == 'r.htmlViewer' && r.htmlViewer.canGoForward"
+ },
+ {
+ "command": "r.htmlViewer.remove",
+ "title": "Remove Current HTML Output",
+ "category": "R HTML Viewer",
+ "icon": "$(trash)",
+ "enablement": "activeWebviewPanelId == 'r.htmlViewer' && r.htmlViewer.canRemove"
+ },
+ {
+ "command": "r.htmlViewer.info",
+ "title": "Toggle Session Information in Tab Title",
+ "category": "R HTML Viewer",
+ "icon": "$(info)",
+ "enablement": "activeWebviewPanelId == 'r.htmlViewer' && r.htmlViewer.canShowInfo"
+ },
{
"title": "Create R terminal",
"category": "R",
@@ -1020,6 +1054,22 @@
],
"menus": {
"commandPalette": [
+ {
+ "command": "r.htmlViewer.back",
+ "when": "activeWebviewPanelId == 'r.htmlViewer'"
+ },
+ {
+ "command": "r.htmlViewer.forward",
+ "when": "activeWebviewPanelId == 'r.htmlViewer'"
+ },
+ {
+ "command": "r.htmlViewer.remove",
+ "when": "activeWebviewPanelId == 'r.htmlViewer'"
+ },
+ {
+ "command": "r.htmlViewer.info",
+ "when": "activeWebviewPanelId == 'r.htmlViewer'"
+ },
{
"command": "r.plot.toggleStyle",
"when": "false"
@@ -1193,6 +1243,26 @@
}
],
"editor/title": [
+ {
+ "command": "r.htmlViewer.back",
+ "when": "activeWebviewPanelId == 'r.htmlViewer'",
+ "group": "navigation@1"
+ },
+ {
+ "command": "r.htmlViewer.forward",
+ "when": "activeWebviewPanelId == 'r.htmlViewer'",
+ "group": "navigation@2"
+ },
+ {
+ "command": "r.htmlViewer.remove",
+ "when": "activeWebviewPanelId == 'r.htmlViewer'",
+ "group": "htmlViewer@1"
+ },
+ {
+ "command": "r.htmlViewer.info",
+ "when": "activeWebviewPanelId == 'r.htmlViewer'",
+ "group": "htmlViewer@2"
+ },
{
"command": "r.rmarkdown.preview.openExternal",
"when": "resourceScheme =~ /webview/ && r.rmarkdown.preview.active",
diff --git a/src/extension.ts b/src/extension.ts
index b511094a1..f9b4fbabf 100644
--- a/src/extension.ts
+++ b/src/extension.ts
@@ -26,6 +26,7 @@ import * as languageService from './languageService';
import { RTaskProvider } from './tasks';
import { showRDebuggerCompatibilityWarningOnce } from './rDebuggerCompatibility';
import { InteractiveManager } from './interactive/manager';
+import { initializeHtmlWidgetViewers, restoreHtmlViewer, runHtmlViewerCommand, shutdownHtmlWidgetViewers } from './webViewer';
// global objects used in other files
@@ -66,6 +67,10 @@ export async function activate(context: vscode.ExtensionContext): Promise session.getViewerSessionContext(source.sessionId, source)!,
+ getActiveSessionId: () => session.activeSession?.sessionId,
+ });
// assign session watcher setting to global variable
enableSessionWatcher = util.config().get('sessionWatcher') ?? false;
@@ -163,6 +168,11 @@ export async function activate(context: vscode.ExtensionContext): Promise vscode.commands.executeCommand('editor.action.webvieweditor.showFind'),
+ 'r.htmlViewer.restore': restoreHtmlViewer,
+ 'r.htmlViewer.back': () => runHtmlViewerCommand('back'),
+ 'r.htmlViewer.forward': () => runHtmlViewerCommand('forward'),
+ 'r.htmlViewer.remove': () => runHtmlViewerCommand('remove'),
+ 'r.htmlViewer.info': () => runHtmlViewerCommand('info'),
// (help related commands are registered in rHelp.initializeHelp)
};
@@ -282,6 +292,7 @@ export function ensureWorkspaceViewer(): void {
}
export async function deactivate(): Promise {
+ await shutdownHtmlWidgetViewers(true);
(globalPlotManager as plotViewer.CommonPlotManager)?.dispose();
await session.shutdownSessionWatcher();
}
diff --git a/src/session.ts b/src/session.ts
index c290f1e2f..1e649e2ee 100644
--- a/src/session.ts
+++ b/src/session.ts
@@ -16,6 +16,7 @@ import * as rTerminal from './rTerminal';
import { getProcessAncestors } from './processTree';
import { TerminalSessionRegistry } from './terminalSessionRegistry';
import { SessionProcessMonitor } from './sessionProcessMonitor';
+import { sessionProcessIdentity } from './sessionIdentity';
import { purgeAddinPickerItems, RSEditOperation, RSRange } from './rstudioapi';
import { extensionContext, rWorkspace, globalRHelp, globalPlotManager, sessionStatusBarItem, enableSessionWatcher } from './extension';
@@ -26,7 +27,7 @@ import { showWebView } from './webViewer';
import { getListViewerScript, listViewerStyle, ListViewNavigation } from './listViewer';
import { getDataViewerScript, getDataViewerStyle, getDataViewerToolbarHtml } from './dataViewer';
import { getDataViewerColumnPanelHtml, getDataViewerColumnPanelScript, getDataViewerColumnPanelStyle } from './dataViewerColumnPanel';
-import { formatSessionLabel, getViewerSessionScript } from './viewerSession';
+import { createViewerSessionContext, formatSessionLabel, getViewerSessionScript, ViewerSessionContext, ViewerSessionSource } from './viewerSession';
export interface SessionInfo {
version: string;
@@ -115,7 +116,7 @@ export let workspaceFile: string;
const SESS_PROTOCOL_VERSION = 2;
const sessions = new Map();
-const sessionProcessMonitor = new SessionProcessMonitor(isLocalHost);
+const sessionProcessMonitor = new SessionProcessMonitor(isLocalHost);
// Only the newest handshake for a stable ID may commit after terminal discovery.
const pendingSessionAttachments = new Map();
const terminalRegistry = new TerminalSessionRegistry(
@@ -1240,33 +1241,19 @@ export async function showDataView(
}
function getViewerSessionHtml(sessionId: string | null): string {
- const owner = sessions.get(sessionId ?? '');
- if (!owner) { return ''; }
- const exited = sessionProcessMonitor.hasExited(owner);
- if (!exited && (!owner.pid || !owner.rVer)) { return ''; }
- const info = escapeHtml(exited ? 'R: (not attached)' : formatSessionLabel(owner.rVer, owner.pid));
- return `${info}`;
+ return getViewerSessionContext(sessionId)?.getHtml() ?? '';
}
function attachViewerSessionBridge(panel: vscode.WebviewPanel, sessionId: string | null): void {
- const owner = sessions.get(sessionId ?? '');
- if (!owner || (!sessionProcessMonitor.hasExited(owner) && (!owner.pid || !owner.rVer))) { return; }
- // Keep the original process identity after detaching, reconnecting or restarting.
- const sourcePid = owner.pid;
- const attachedLabel = formatSessionLabel(owner.rVer, sourcePid);
- const refresh = (force = false) => {
- const text = source.exited ? 'R: (not attached)' : attachedLabel;
- if (force || text !== lastLabel) {
- lastLabel = text;
- void panel.webview.postMessage({ message: 'viewer-session/update', text });
- }
- };
- const source = sessionProcessMonitor.observe(owner, refresh);
- let lastLabel = source.exited ? 'R: (not attached)' : attachedLabel;
- const received = panel.webview.onDidReceiveMessage((message: { message?: string }) => {
- if (message?.message === 'viewer-session/ready') { refresh(true); }
- });
- panel.onDidDispose(() => { source.dispose(); received?.dispose(); });
+ getViewerSessionContext(sessionId)?.attach(panel);
+}
+
+export function getViewerSessionContext(sessionId: string | null, saved?: ViewerSessionSource): ViewerSessionContext | undefined {
+ const attached = sessions.get(sessionId ?? '');
+ // A saved Viewer belongs to its original process, never a replacement.
+ const owner = saved && (saved.processExited || !attached || sessionProcessIdentity(attached) !== sessionProcessIdentity(saved))
+ ? saved : attached;
+ return owner ? createViewerSessionContext(owner, sessionProcessMonitor) : undefined;
}
export async function getTableHtml(
@@ -2287,7 +2274,10 @@ async function handleNotification(message: Record, socket: IpcS
}
} else {
if (url.toLowerCase().endsWith('.html') || url.toLowerCase().endsWith('.htm')) {
- await showWebView(url, title, viewColumn);
+ // Standalone HTML can arrive through page_viewer (e.g. profvis)
+ // or browseURL as well as viewer; all belong to the source session.
+ await showWebView(url, title, viewColumn,
+ getViewerSessionContext(socket._sessionId ?? null));
} else {
await showDataView('object', 'txt', title, url, String(viewColumn));
}
diff --git a/src/sessionIdentity.ts b/src/sessionIdentity.ts
new file mode 100644
index 000000000..e7b84375e
--- /dev/null
+++ b/src/sessionIdentity.ts
@@ -0,0 +1,4 @@
+/** Session IDs identify a process lifetime; host and PID guard against ID reuse. */
+export function sessionProcessIdentity(source: { readonly sessionId: string; readonly host: string; readonly pid: string }): string {
+ return JSON.stringify([source.sessionId, source.host.toLowerCase(), source.pid]);
+}
diff --git a/src/sessionProcessMonitor.ts b/src/sessionProcessMonitor.ts
index 2d897701a..66abf21a8 100644
--- a/src/sessionProcessMonitor.ts
+++ b/src/sessionProcessMonitor.ts
@@ -1,3 +1,5 @@
+import { sessionProcessIdentity } from './sessionIdentity';
+
interface ProcessSession {
readonly sessionId: string;
readonly pid: string;
@@ -7,6 +9,8 @@ interface ProcessSession {
interface WatchedProcess {
readonly key: string;
+ readonly sessionId: string;
+ readonly host: string;
readonly pid: number;
exited: boolean;
readonly listeners: Set<() => void>;
@@ -22,20 +26,21 @@ export class SessionProcessMonitor {
constructor(private readonly isLocalHost: (host: string) => boolean) {}
hasExited(owner: Session): boolean {
- return owner.processExited || Boolean((this.sources.get(owner) ?? this.identities.get(this.key(owner)))?.exited);
+ return owner.processExited || Boolean(this.find(owner)?.exited);
}
observe(owner: Session, listener: () => void): { readonly exited: boolean; dispose(): void } {
- let source = this.sources.get(owner);
+ let source = this.find(owner);
if (!source) {
// Capture the original PID; a restart must not redirect old viewers.
- const key = this.key(owner);
+ const key = sessionProcessIdentity(owner);
// Native reconnects replace the transport object but keep their session ID.
- source = this.identities.get(key) ?? {
- key, pid: Number(owner.pid), exited: owner.processExited, listeners: new Set(),
+ source = {
+ key, sessionId: owner.sessionId, host: owner.host.toLowerCase(),
+ pid: Number(owner.pid), exited: owner.processExited, listeners: new Set(),
};
- this.sources.set(owner, source);
}
+ this.sources.set(owner, source);
const watched = source;
if (owner.processExited) { this.finish(watched); }
this.identities.set(watched.key, watched);
@@ -63,13 +68,19 @@ export class SessionProcessMonitor {
/** A confirmed exit is authoritative, including for remote sessions. */
markExited(owner: Session): void {
- const source = this.sources.get(owner) ?? this.identities.get(this.key(owner));
+ const source = this.find(owner);
if (source) { this.finish(source); }
}
- private key(owner: Session): string {
- // Session IDs identify a process lifetime even after exit clears its PID.
- return JSON.stringify([owner.host.toLowerCase(), owner.sessionId]);
+ private find(owner: Session): WatchedProcess | undefined {
+ const captured = this.sources.get(owner);
+ const known = this.identities.get(captured?.key ?? sessionProcessIdentity(owner)) ?? captured;
+ if (known || owner.pid) { return known; }
+ // An exit/reconnect may arrive after PID metadata was cleared. Resolve
+ // only an unambiguous original process; never select a replacement.
+ const matches = [...this.identities.values()].filter(source =>
+ source.sessionId === owner.sessionId && source.host === owner.host.toLowerCase());
+ return matches.length === 1 ? matches[0] : undefined;
}
private poll(): void {
diff --git a/src/test/node/htmlWidgetHistory.test.ts b/src/test/node/htmlWidgetHistory.test.ts
new file mode 100644
index 000000000..667c1de1a
--- /dev/null
+++ b/src/test/node/htmlWidgetHistory.test.ts
@@ -0,0 +1,155 @@
+import * as assert from 'assert';
+import * as sinon from 'sinon';
+import type { Memento } from 'vscode';
+import { SessionProcessMonitor } from '../../sessionProcessMonitor';
+import { createViewerSessionContext, ViewerSessionSource } from '../../viewerSession';
+import { WidgetHistory, WidgetHistoryStore, widgetHistoryKey } from '../../webViewer/history';
+
+suite('Retained HTML widget history', () => {
+ let sandbox: sinon.SinonSandbox;
+ let clock: sinon.SinonFakeTimers;
+ let kill: sinon.SinonStub;
+ let state: Memento;
+ let persisted: unknown;
+ const stores: WidgetHistoryStore[] = [];
+ const source = (sessionId = 'history-source', host = 'local'): ViewerSessionSource => ({
+ sessionId, host, pid: '12345', rVer: '4.6.1', processExited: false,
+ });
+ const record = (owner = source()): WidgetHistory => ({
+ source: owner, history: [{ file: '/tmp/a.html', title: 'A' }, { file: '/tmp/b.html', title: 'B' }],
+ index: 0, viewColumn: 2,
+ });
+ const create = () => {
+ const monitor = new SessionProcessMonitor(host => host === 'local');
+ const store = new WidgetHistoryStore(state, owner => createViewerSessionContext(owner, monitor));
+ stores.push(store);
+ return { store, monitor };
+ };
+
+ setup(() => {
+ sandbox = sinon.createSandbox();
+ clock = sandbox.useFakeTimers();
+ kill = sandbox.stub(process, 'kill').returns(true);
+ persisted = undefined;
+ state = {
+ keys: () => persisted ? [widgetHistoryKey] : [],
+ get: (_key: string, fallback?: T) => persisted === undefined ? fallback : JSON.parse(JSON.stringify(persisted)) as T,
+ update: (key, value) => {
+ assert.strictEqual(key, widgetHistoryKey);
+ persisted = JSON.parse(JSON.stringify(value));
+ return Promise.resolve();
+ },
+ } as Memento;
+ });
+
+ teardown(async () => {
+ await Promise.all(stores.map(store => store.flush()));
+ stores.splice(0).forEach(store => store.dispose());
+ sandbox.restore();
+ });
+
+ test('fresh storage restores paths, selection, process ownership, and editor group', async () => {
+ persisted = [record()];
+ const first = create();
+ await first.store.flush();
+ first.store.dispose();
+ assert.strictEqual(clock.countTimers(), 0);
+ const second = create();
+ assert.deepStrictEqual(second.store.entries.get('history-source'), record());
+ assert.strictEqual(clock.countTimers(), 1);
+ await second.store.flush();
+ assert.deepStrictEqual(persisted, [record()]);
+ });
+
+ test('the same session and process retains history regardless of host casing', async () => {
+ const { store, monitor } = create();
+ const original = source('same-process', 'foreign');
+ const history = store.remember(createViewerSessionContext(original, monitor));
+ history.history.push({ file: '/tmp/a.html', title: 'A' }); history.index = 0;
+ const reconnected = { ...original, host: 'FOREIGN' };
+ assert.strictEqual(store.remember(createViewerSessionContext(reconnected, monitor)), history);
+ await store.save(history);
+ assert.deepStrictEqual(persisted, [history]);
+ });
+
+ for (const change of ['host', 'pid'] as const) {
+ test(`a reused session ID with a different ${change} replaces retained history`, async () => {
+ const { store, monitor } = create();
+ const original = source('reused-id', 'foreign');
+ const history = store.remember(createViewerSessionContext(original, monitor));
+ history.history.push({ file: '/tmp/a.html', title: 'A' }); history.index = 0;
+ await store.save(history);
+ const replacement = { ...original, [change]: change === 'host' ? 'another-host' : '54321' };
+ const fresh = store.remember(createViewerSessionContext(replacement, monitor));
+ assert.notStrictEqual(fresh, history);
+ assert.strictEqual(fresh.history.length, 0);
+ assert.strictEqual(fresh.index, -1);
+ fresh.history.push({ file: '/tmp/b.html', title: 'B' }); fresh.index = 0;
+ await store.save(fresh);
+ await store.save(history);
+ assert.deepStrictEqual(persisted, [fresh]);
+ });
+ }
+
+ test('a process exit clears persisted history even without an open Viewer or attached transport', async () => {
+ persisted = [record()];
+ const { store } = create();
+ kill.throws(Object.assign(new Error('exited'), { code: 'ESRCH' }));
+ clock.tick(1000);
+ await store.flush();
+ assert.strictEqual(store.entries.size, 0);
+ assert.deepStrictEqual(persisted, []);
+ assert.strictEqual(clock.countTimers(), 0);
+ });
+
+ test('remote detachment and permission errors preserve history until a confirmed exit', async () => {
+ const remote = source('remote', 'foreign');
+ persisted = [record(), record(remote)];
+ const { store, monitor } = create();
+ kill.throws(Object.assign(new Error('permission denied'), { code: 'EPERM' }));
+ clock.tick(2000);
+ assert.strictEqual(store.entries.size, 2);
+ monitor.markExited({ ...remote, processExited: true });
+ await store.flush();
+ assert.deepStrictEqual(persisted, [record()]);
+ });
+
+ test('rejects invalid persisted state, trims excess history, and adjusts its selection', async () => {
+ const long = record();
+ long.history = Array.from({ length: 52 }, (_, index) => ({ file: `/tmp/${index}.html`, title: String(index) }));
+ long.index = 51;
+ persisted = [null, { source: {} }, { ...record(source('bad-index')), index: -1 },
+ record({ ...source('already-exited'), processExited: true }), long];
+ const { store } = create();
+ await store.flush();
+ assert.strictEqual(store.entries.size, 1);
+ const retained = store.entries.get('history-source')!;
+ assert.strictEqual(retained.history.length, 50);
+ assert.strictEqual(retained.history[0].title, '2');
+ assert.strictEqual(retained.index, 49);
+ assert.deepStrictEqual(persisted, [retained]);
+ });
+
+ test('queued writes capture their revision and an exit wins over pending output saves', async () => {
+ const writes: unknown[] = [];
+ let release!: () => void;
+ const blocked = new Promise(resolve => { release = resolve; });
+ sandbox.stub(state, 'update').callsFake(async (_key, value) => {
+ writes.push(JSON.parse(JSON.stringify(value)));
+ if (writes.length === 1) { await blocked; }
+ persisted = value;
+ });
+ const { store, monitor } = create();
+ const owner = source();
+ const history = store.remember(createViewerSessionContext(owner, monitor));
+ history.history.push({ file: '/tmp/a.html', title: 'A' }); history.index = 0;
+ void store.save(history);
+ history.history.push({ file: '/tmp/b.html', title: 'B' }); history.index = 1;
+ void store.save(history);
+ monitor.markExited({ ...owner, processExited: true });
+ release();
+ await store.flush();
+ assert.deepStrictEqual(writes.map(value => (value as WidgetHistory[])[0]?.history.length ?? 0), [1, 2, 0]);
+ assert.deepStrictEqual(persisted, []);
+ });
+});
diff --git a/src/test/node/sessionProcessMonitor.test.ts b/src/test/node/sessionProcessMonitor.test.ts
index 7db630346..92f2b2a67 100644
--- a/src/test/node/sessionProcessMonitor.test.ts
+++ b/src/test/node/sessionProcessMonitor.test.ts
@@ -137,4 +137,49 @@ suite('Session process monitor', () => {
viewer.dispose();
assert.strictEqual(clock.countTimers(), 0);
});
+
+ test('reusing a session ID with a different PID keeps process watches and exits separate', () => {
+ const original = owner('reused', '12345');
+ const replacement = owner('reused', '23456');
+ const firstCallback = sandbox.stub();
+ const secondCallback = sandbox.stub();
+ const first = monitor.observe(original, firstCallback);
+ const second = monitor.observe(replacement, secondCallback);
+ clock.tick(1000);
+ sinon.assert.calledWithExactly(kill, 12345, 0);
+ sinon.assert.calledWithExactly(kill, 23456, 0);
+ original.pid = '';
+ original.processExited = true;
+ monitor.markExited(original);
+ sinon.assert.calledOnce(firstCallback);
+ sinon.assert.notCalled(secondCallback);
+ assert.strictEqual(first.exited, true);
+ assert.strictEqual(second.exited, false);
+ assert.strictEqual(monitor.hasExited(replacement), false);
+ first.dispose(); second.dispose();
+ });
+
+ test('an exit without PID metadata cannot select between reused session IDs', () => {
+ const firstCallback = sandbox.stub();
+ const secondCallback = sandbox.stub();
+ const first = monitor.observe(owner('ambiguous', '12345'), firstCallback);
+ const second = monitor.observe(owner('ambiguous', '23456'), secondCallback);
+ monitor.markExited({ ...owner('ambiguous', ''), processExited: true });
+ sinon.assert.notCalled(firstCallback);
+ sinon.assert.notCalled(secondCallback);
+ first.dispose(); second.dispose();
+ });
+
+ test('a captured source can confirm exit after its viewers are restored through a new owner object', () => {
+ const original = owner('restored');
+ monitor.observe(original, sandbox.stub()).dispose();
+ const callback = sandbox.stub();
+ const restored = monitor.observe({ ...original }, callback);
+ original.pid = '';
+ original.processExited = true;
+ monitor.markExited(original);
+ sinon.assert.calledOnce(callback);
+ assert.strictEqual(restored.exited, true);
+ restored.dispose();
+ });
});
diff --git a/src/test/suite/dataViewerSessions.test.ts b/src/test/suite/dataViewerSessions.test.ts
index b6c0d2cfc..7a6582a9c 100644
--- a/src/test/suite/dataViewerSessions.test.ts
+++ b/src/test/suite/dataViewerSessions.test.ts
@@ -1,4 +1,5 @@
import * as assert from 'assert';
+import fs from 'fs/promises';
import * as net from 'net';
import * as os from 'os';
import * as path from 'path';
@@ -7,6 +8,7 @@ import * as vscode from 'vscode';
import * as extension from '../../extension';
import * as session from '../../session';
import * as util from '../../util';
+import { initializeHtmlWidgetViewers, restoreHtmlViewer, runHtmlViewerCommand, shutdownHtmlWidgetViewers } from '../../webViewer';
import { mockExtensionContext } from '../common/mockvscode';
interface Request {
@@ -39,22 +41,31 @@ async function waitFor(condition: () => boolean): Promise {
suite('Viewer session ownership', () => {
let sandbox: sinon.SinonSandbox;
+ let htmlViewers: ReturnType;
+ let information: sinon.SinonStub;
const clients: Client[] = [];
const panels: Panel[] = [];
setup(() => {
sandbox = sinon.createSandbox();
+ information = sandbox.stub(vscode.window, 'showInformationMessage').resolves();
+ sandbox.stub(vscode.window, 'registerWebviewPanelSerializer').returns({ dispose: sandbox.stub() });
const root = path.join(__dirname, '..', '..', '..');
mockExtensionContext(root, sandbox);
+ htmlViewers = initializeHtmlWidgetViewers(extension.extensionContext, {
+ resolveSession: source => session.getViewerSessionContext(source.sessionId, source)!,
+ getActiveSessionId: () => session.activeSession?.sessionId,
+ });
sandbox.stub(extension, 'enableSessionWatcher').value(true);
sandbox.stub(util, 'config').returns({
get: (_key: string, defaultValue: unknown) => defaultValue,
} as vscode.WorkspaceConfiguration);
session.deploySessionWatcher(root);
- sandbox.stub(vscode.window, 'createWebviewPanel').callsFake((_type, title) => {
+ sandbox.stub(vscode.window, 'createWebviewPanel').callsFake((_type, title, _column, options) => {
const disposed = new vscode.EventEmitter();
const listeners: Array<(message: unknown) => unknown> = [];
let closed = false;
+ let html = '';
const item: Panel = {
panel: undefined as unknown as vscode.WebviewPanel,
receive: async message => { await Promise.all(listeners.map(listener => listener(message))); }, replies: [],
@@ -62,7 +73,13 @@ suite('Viewer session ownership', () => {
item.panel = {
title, viewColumn: vscode.ViewColumn.Two, reveal: sandbox.stub(),
webview: {
- html: '', asWebviewUri: (uri: vscode.Uri) => uri,
+ get html() { return html; },
+ set html(value: string) {
+ html = value;
+ const generation = Number(/data-generation="(\d+)"/.exec(value)?.[1]);
+ if (generation) { queueMicrotask(() => { void item.receive({ message: 'widget/loaded', generation }); }); }
+ },
+ options: options ?? {}, asWebviewUri: (uri: vscode.Uri) => uri,
onDidReceiveMessage: (listener: Panel['receive']) => {
listeners.push(listener);
return { dispose: () => { listeners.splice(listeners.indexOf(listener), 1); } };
@@ -73,6 +90,7 @@ suite('Viewer session ownership', () => {
},
},
onDidDispose: disposed.event,
+ onDidChangeViewState: sandbox.stub(),
dispose: () => {
if (!closed) {
closed = true;
@@ -88,9 +106,19 @@ suite('Viewer session ownership', () => {
teardown(async () => {
panels.splice(0).forEach(item => { item.panel.dispose(); });
+ await shutdownHtmlWidgetViewers();
+ session.deferWorkspaceRefresh();
for (const client of clients.splice(0)) {
+ // Finish queued replies before closing the server side; destroying
+ // it first can leave the mock client writing to a broken Unix pipe.
+ if (!client.socket.destroyed) {
+ await new Promise((resolve, reject) => {
+ client.socket.once('close', () => resolve());
+ client.socket.once('error', reject);
+ client.socket.end();
+ });
+ }
await session.cleanupSession(client.id);
- client.socket.destroy();
}
await session.shutdownSessionWatcher();
sandbox.restore();
@@ -124,7 +152,9 @@ suite('Viewer session ownership', () => {
result = { title: 'x$child', path: [1], breadcrumbs: [{ label: 'x', path: [] }] };
break;
}
- socket.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, result }) + '\n');
+ if (socket.writable) {
+ socket.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, result }) + '\n');
+ }
}
});
await new Promise((resolve, reject) => {
@@ -142,6 +172,143 @@ suite('Viewer session ownership', () => {
return client;
}
+ async function htmlSessionInfo(panel: vscode.WebviewPanel): Promise {
+ for (const viewer of panels) {
+ Object.defineProperty(viewer.panel, 'active', { value: viewer.panel === panel, configurable: true });
+ }
+ await runHtmlViewerCommand('info');
+ const label = panel.title.slice('HTML Viewer · '.length);
+ await runHtmlViewerCommand('info');
+ sinon.assert.notCalled(information);
+ return label;
+ }
+
+ function htmlPosition(panel: vscode.WebviewPanel): string {
+ const entry = [...htmlViewers.viewers.values()].find(entry => entry.panel === panel)!;
+ return `${entry.state.index + 1} / ${entry.state.history.length}`;
+ }
+
+ test('HTML widget notifications use their source session while another session is active', async () => {
+ const read: sinon.SinonStub = sandbox.stub(fs, 'readFile');
+ read.callsFake(file => Promise.resolve(`${String(file)}
`));
+ const first = await attach('html-source', '12101', '4.6.1');
+ const second = await attach('html-active', '12102', '4.6.2');
+ assert.strictEqual(session.activeSession?.sessionId, second.id);
+
+ notify(first, 'webview', { url: '/tmp/first-widget.html' });
+ await waitFor(() => panels.length === 1 && panels[0].panel.webview.html.includes('first-widget.html'));
+ const original = panels[0].panel;
+ assert.ok((await htmlSessionInfo(original)).includes('R 4.6.1: 12101'));
+ notify(first, 'webview', { url: '/tmp/updated-widget.html' });
+ await waitFor(() => original.webview.html.includes('updated-widget.html'));
+ assert.strictEqual(panels.length, 1);
+
+ notify(second, 'webview', { url: '/tmp/second-widget.html' });
+ await waitFor(() => panels.length === 2 && panels[1].panel.webview.html.includes('second-widget.html'));
+ assert.ok((await htmlSessionInfo(panels[1].panel)).includes('R 4.6.2: 12102'));
+ assert.ok(original.webview.html.includes('updated-widget.html'));
+ assert.strictEqual(session.activeSession?.sessionId, second.id);
+ });
+
+ function outputTitle(panel: vscode.WebviewPanel): string | undefined {
+ assert.strictEqual(panel.title, 'HTML Viewer');
+ const viewer = [...htmlViewers.viewers.values()].find(viewer => viewer.panel === panel)!;
+ const item = viewer.state.history[viewer.state.index];
+ assert.ok(panel.webview.html.includes(`${item.file}
`));
+ return item.title;
+ }
+
+ test('page_viewer and browser HTML share widget history, source ownership, and restoration', async () => {
+ const read: sinon.SinonStub = sandbox.stub(fs, 'readFile');
+ read.callsFake(file => Promise.resolve(`${String(file)}
`));
+ const source = await attach('html-mixed-source', '12101', '4.6.1');
+ const other = await attach('html-mixed-active', '12102', '4.6.2');
+ notify(source, 'webview', { url: '/tmp/widget.html', title: 'Widget' });
+ await waitFor(() => panels.length === 1 && panels[0].panel.webview.html.includes('widget.html'));
+ const viewer = panels[0];
+ // htmlwidgets::print.suppress_viewer sends profvis through page_viewer.
+ notify(source, 'page_viewer', { url: '/tmp/profvis/index.html' });
+ await waitFor(() => viewer.panel.webview.html.includes('/tmp/profvis'));
+ assert.strictEqual(panels.length, 1);
+ assert.strictEqual(outputTitle(viewer.panel), 'Page Viewer');
+ assert.strictEqual(htmlPosition(viewer.panel), '2 / 2');
+ assert.ok((await htmlSessionInfo(viewer.panel)).includes('R 4.6.1: 12101'));
+
+ notify(source, 'browser', { url: '/tmp/report.HTM', title: 'Report' });
+ await waitFor(() => viewer.panel.webview.html.includes('/tmp/report.HTM
'));
+ assert.strictEqual(panels.length, 1);
+ assert.strictEqual(htmlPosition(viewer.panel), '3 / 3');
+ assert.strictEqual(outputTitle(viewer.panel), 'Report');
+ assert.strictEqual(session.activeSession?.sessionId, other.id);
+ const navigate = (direction: 'back' | 'forward') => viewer.receive({
+ message: 'widget/navigate', direction,
+ generation: Number(/data-generation="(\d+)"/.exec(viewer.panel.webview.html)?.[1]),
+ });
+ await navigate('back');
+ assert.strictEqual(outputTitle(viewer.panel), 'Page Viewer');
+ assert.ok(viewer.panel.webview.html.includes('/tmp/profvis'));
+ await navigate('back');
+ assert.strictEqual(outputTitle(viewer.panel), 'Widget');
+ await navigate('forward');
+ viewer.panel.dispose();
+ await restoreHtmlViewer(source.id);
+ assert.strictEqual(panels.length, 2);
+ assert.strictEqual(outputTitle(panels[1].panel), 'Page Viewer');
+ assert.strictEqual(htmlPosition(panels[1].panel), '2 / 3');
+ assert.ok((await htmlSessionInfo(panels[1].panel)).includes('R 4.6.1: 12101'));
+
+ notify(other, 'page_viewer', { url: '/tmp/other-profvis.html', title: 'Other profile' });
+ await waitFor(() => panels.length === 3 && panels[2].panel.webview.html.includes('/tmp/other-profvis.html
'));
+ assert.strictEqual(htmlPosition(panels[2].panel), '1 / 1');
+ assert.strictEqual(outputTitle(panels[2].panel), 'Other profile');
+ assert.ok((await htmlSessionInfo(panels[2].panel)).includes('R 4.6.2: 12102'));
+ });
+
+ test('server URLs from every HTML route use Simple Browser and stay outside widget history', async () => {
+ const read: sinon.SinonStub = sandbox.stub(fs, 'readFile');
+ read.resolves('Widget
');
+ const source = await attach('html-server-source', '12101', '4.6.1');
+ notify(source, 'webview', { url: '/tmp/widget.html' });
+ await waitFor(() => panels.length === 1 && panels[0].panel.webview.html.includes('data-generation='));
+ const html = panels[0].panel.webview.html;
+ const commands = sandbox.stub(vscode.commands, 'executeCommand').resolves();
+ const externalUri = sandbox.stub(vscode.env, 'asExternalUri').resolves(vscode.Uri.parse('https://forwarded.invalid/shiny'));
+ const routes = ['webview', 'page_viewer', 'browser'];
+ for (const [index, method] of routes.entries()) {
+ notify(source, method, { url: 'http://localhost:4321' });
+ await waitFor(() => commands.withArgs('simpleBrowser.show').callCount === index + 1);
+ }
+ sinon.assert.calledThrice(externalUri);
+ sinon.assert.calledWith(commands, 'simpleBrowser.show', 'https://forwarded.invalid/shiny');
+ notify(source, 'page_viewer', { url: 'https://example.com/report' });
+ await waitFor(() => commands.withArgs('simpleBrowser.show').callCount === 4);
+ sinon.assert.calledWith(commands, 'simpleBrowser.show', 'https://example.com/report');
+ assert.strictEqual(panels.length, 1);
+ assert.strictEqual(panels[0].panel.webview.html, html);
+ });
+
+ test('disabled Page Viewer and Browser settings open local HTML externally without adding history', async () => {
+ (util.config as sinon.SinonStub).returns({
+ get: (key: string, fallback: unknown) => key === 'session.viewers.viewColumn'
+ ? { viewer: 'Two', pageViewer: 'Disable', browser: 'Disable' } : fallback,
+ });
+ const read: sinon.SinonStub = sandbox.stub(fs, 'readFile');
+ read.resolves('Widget
');
+ const external = sandbox.stub(vscode.env, 'openExternal').resolves(true);
+ const source = await attach('html-disabled-routes', '12101', '4.6.1');
+ notify(source, 'webview', { url: '/tmp/widget.html' });
+ await waitFor(() => panels.length === 1 && panels[0].panel.webview.html.includes('data-generation='));
+ const html = panels[0].panel.webview.html;
+ notify(source, 'page_viewer', { url: '/tmp/profvis.html' });
+ await waitFor(() => external.callCount === 1);
+ assert.strictEqual((external.lastCall.args[0] as vscode.Uri).fsPath, vscode.Uri.file('/tmp/profvis.html').fsPath);
+ notify(source, 'browser', { url: '/tmp/report.htm' });
+ await waitFor(() => external.callCount === 2);
+ assert.strictEqual((external.lastCall.args[0] as vscode.Uri).fsPath, vscode.Uri.file('/tmp/report.htm').fsPath);
+ assert.strictEqual(panels.length, 1);
+ assert.strictEqual(panels[0].panel.webview.html, html);
+ });
+
async function open(
client: Client, source: 'list' | 'table', existing?: Panel, stateGeneration = 1,
): Promise {
@@ -308,6 +475,18 @@ suite('Viewer session ownership', () => {
} finally { session.unregisterSessionTransport(owner); }
});
+ test('a saved confirmed exit remains authoritative when a matching transport still has stale metadata', () => {
+ const owner = session.registerSessionTransport('saved-exited-viewer', 'foreign-host', '/tmp', sandbox.stub().resolves({}));
+ owner.pid = '12103'; owner.rVer = '4.6.2';
+ try {
+ const context = session.getViewerSessionContext(owner.sessionId, {
+ sessionId: owner.sessionId, host: owner.host, pid: owner.pid, rVer: owner.rVer, processExited: true,
+ })!;
+ assert.strictEqual(context.hasExited, true);
+ assert.ok(context.getHtml().includes('R: (not attached)'));
+ } finally { session.unregisterSessionTransport(owner); }
+ });
+
test('identical viewer ids stay separate and background views keep their originating session', async () => {
const a = await attach('viewer-session-a');
const b = await attach('viewer-session-b');
diff --git a/src/test/suite/htmlWidgetBrowser.test.ts b/src/test/suite/htmlWidgetBrowser.test.ts
new file mode 100644
index 000000000..a5b479532
--- /dev/null
+++ b/src/test/suite/htmlWidgetBrowser.test.ts
@@ -0,0 +1,770 @@
+import * as assert from 'assert';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as net from 'net';
+import type { RExtension } from '../../api';
+import * as path from 'path';
+import * as sinon from 'sinon';
+import * as vscode from 'vscode';
+import * as session from '../../session';
+import { initializeHtmlWidgetViewers, restoreHtmlViewer, runHtmlViewerCommand, showWebView, shutdownHtmlWidgetViewers } from '../../webViewer';
+import { extensionContext } from '../../extension';
+import { mockExtensionContext } from '../common/mockvscode';
+import { waitForValue } from '../common/sessionConnections';
+
+async function focusHtmlViewer(panel: vscode.WebviewPanel): Promise {
+ panel.reveal(panel.viewColumn, false);
+ // reveal() queues a workbench request. Await editor focus before running
+ // commands, rather than relying on a possibly stale panel.active value.
+ await vscode.commands.executeCommand('workbench.action.focusActiveEditorGroup');
+ await waitForValue(() => panel.active &&
+ vscode.window.tabGroups.activeTabGroup.activeTab?.label === panel.title ? true : undefined);
+}
+
+suite('HTML widget browser rendering', () => {
+ test('CSP-blocked acknowledgements unlock navigation and stop the spinner after ten seconds in a real webview', async function () {
+ this.timeout(30000);
+ const sandbox = sinon.createSandbox();
+ const statusItems = sandbox.spy(vscode.window, 'createStatusBarItem');
+ sandbox.stub(vscode.window, 'registerWebviewPanelSerializer').returns({ dispose: sandbox.stub() });
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'vscode-r-widget-csp-'));
+ const panels: vscode.WebviewPanel[] = [];
+ const acknowledgements: number[] = [];
+ const source = session.registerSessionTransport('html-browser-csp', 'widget-test-host', directory, () => Promise.resolve({}));
+ source.pid = '12105'; source.rVer = '4.6.1';
+ try {
+ mockExtensionContext(path.resolve(__dirname, '../../..'), sandbox);
+ const manager = initializeHtmlWidgetViewers(extensionContext, {
+ resolveSession: source => session.getViewerSessionContext(source.sessionId, source)!,
+ getActiveSessionId: () => session.activeSession?.sessionId,
+ });
+ const loadingStatus = statusItems.lastCall.returnValue as vscode.StatusBarItem;
+ const createPanel = vscode.window.createWebviewPanel.bind(vscode.window);
+ sandbox.stub(vscode.window, 'createWebviewPanel').callsFake((...args) => {
+ const panel = createPanel(...args);
+ panels.push(panel);
+ panel.webview.onDidReceiveMessage((message: { message?: string; generation?: number }) => {
+ if (message.message === 'widget/loaded' && message.generation !== undefined) {
+ acknowledgements.push(message.generation);
+ }
+ });
+ return panel;
+ });
+ const first = path.join(directory, 'first.html');
+ const blocked = path.join(directory, 'blocked.html');
+ fs.writeFileSync(first, 'First output');
+ fs.writeFileSync(blocked, 'Output with scripts disabled');
+ const context = session.getViewerSessionContext(source.sessionId);
+ await showWebView(first, 'First', 'Two', context);
+ const entry = [...manager.viewers.values()][0];
+ await waitForValue(() => acknowledgements.includes(entry.revision) ? true : undefined);
+ await showWebView(blocked, 'Blocked', 'Two', context);
+ const blockedGeneration = entry.revision;
+ const html = entry.panel.webview.html;
+ assert.strictEqual(entry.loading, true);
+ await waitForValue(() => !entry.loading ? true : undefined);
+ await waitForValue(() => entry.loadStatus === 'unconfirmed' ? true : undefined);
+ assert.strictEqual(loadingStatus.text, 'HTML Viewer: Load not confirmed. Please wait or retry.');
+ assert.strictEqual(loadingStatus.command, undefined);
+ assert.ok(!acknowledgements.includes(blockedGeneration), 'The authored CSP must block the injected load script');
+ assert.strictEqual(entry.panel.webview.html, html, 'The timeout must preserve the output');
+ await focusHtmlViewer(entry.panel);
+ await runHtmlViewerCommand('back');
+ assert.strictEqual(entry.state.index, 0);
+ await waitForValue(() => acknowledgements.includes(entry.revision) ? true : undefined);
+ assert.ok(entry.panel.webview.html.includes('First output'));
+ await focusHtmlViewer(entry.panel);
+ await runHtmlViewerCommand('forward');
+ assert.strictEqual(entry.state.index, 1);
+ await waitForValue(() => !entry.loading ? true : undefined);
+ await focusHtmlViewer(entry.panel);
+ await runHtmlViewerCommand('remove');
+ assert.strictEqual(entry.state.history.length, 1);
+ await waitForValue(() => acknowledgements.includes(entry.revision) ? true : undefined);
+ } finally {
+ panels.forEach(panel => { panel.dispose(); });
+ await shutdownHtmlWidgetViewers();
+ session.unregisterSessionTransport(source);
+ sandbox.restore();
+ fs.rmSync(directory, { recursive: true, force: true });
+ }
+ });
+
+ test('document edits preserve authored titles, SVG accessibility, and tag-shaped script text', async () => {
+ const sandbox = sinon.createSandbox();
+ sandbox.stub(vscode.window, 'registerWebviewPanelSerializer').returns({ dispose: sandbox.stub() });
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'vscode-r-widget-document-'));
+ const panels: vscode.WebviewPanel[] = [];
+ let result: vscode.Uri | undefined;
+ try {
+ mockExtensionContext(path.resolve(__dirname, '../../..'), sandbox);
+ initializeHtmlWidgetViewers(extensionContext, {
+ resolveSession: source => session.getViewerSessionContext(source.sessionId, source)!,
+ getActiveSessionId: () => session.activeSession?.sessionId,
+ });
+ const createPanel = vscode.window.createWebviewPanel.bind(vscode.window);
+ sandbox.stub(vscode.window, 'createWebviewPanel').callsFake((...args) => {
+ const panel = createPanel(...args); panels.push(panel); return panel;
+ });
+ sandbox.stub(vscode.env, 'openExternal').callsFake(uri => { result = uri; return Promise.resolve(true); });
+ for (const structure of ['explicit', 'implicit-head', 'fragment']) {
+ result = undefined;
+ const title = 'Authored R output';
+ const content = `
+
+ `;
+ const html = structure === 'explicit'
+ ? `${title}${content}`
+ : structure === 'implicit-head'
+ ? `${title}${content}`
+ : title + content;
+ const file = path.join(directory, `${structure}.html`);
+ fs.writeFileSync(file, html);
+ await showWebView(file, 'History entry title', 'Two');
+ const response = await waitForValue(() => result);
+ assert.strictEqual(response.path, '/document');
+ const observations = new URLSearchParams(response.query);
+ assert.strictEqual(observations.get('title'), 'Authored R output', structure);
+ assert.strictEqual(observations.get('svgTitle'), 'Accessible chart', structure);
+ assert.strictEqual(observations.get('example'), '
Widget
', structure);
+ assert.strictEqual(observations.get('textarea'), '
', structure);
+ assert.strictEqual(panels[panels.length - 1].title, 'HTML Viewer');
+ panels[panels.length - 1].dispose();
+ }
+ } finally {
+ panels.forEach(panel => { panel.dispose(); });
+ await shutdownHtmlWidgetViewers();
+ sandbox.restore();
+ fs.rmSync(directory, { recursive: true, force: true });
+ }
+ });
+
+ test('authored local bases load relative resources from their own directories', async () => {
+ const sandbox = sinon.createSandbox();
+ sandbox.stub(vscode.window, 'registerWebviewPanelSerializer').returns({ dispose: sandbox.stub() });
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'vscode-r-widget-base-'));
+ sandbox.stub(vscode.workspace, 'isTrusted').get(() => true);
+ sandbox.stub(vscode.workspace, 'workspaceFolders').get(() => [{
+ uri: vscode.Uri.file(directory), name: 'Trusted HTML output workspace', index: 0,
+ }]);
+ const panels: vscode.WebviewPanel[] = [];
+ let result: vscode.Uri | undefined;
+ try {
+ mockExtensionContext(path.resolve(__dirname, '../../..'), sandbox);
+ initializeHtmlWidgetViewers(extensionContext, {
+ resolveSession: source => session.getViewerSessionContext(source.sessionId, source)!,
+ getActiveSessionId: () => session.activeSession?.sessionId,
+ });
+ const createPanel = vscode.window.createWebviewPanel.bind(vscode.window);
+ sandbox.stub(vscode.window, 'createWebviewPanel').callsFake((...args) => {
+ const panel = createPanel(...args); panels.push(panel); return panel;
+ });
+ sandbox.stub(vscode.env, 'openExternal').callsFake(uri => { result = uri; return Promise.resolve(true); });
+ const output = path.join(directory, 'output');
+ const shared = path.join(directory, 'shared assets');
+ for (const resources of [path.join(output, 'assets'), shared]) {
+ fs.mkdirSync(resources, { recursive: true });
+ fs.writeFileSync(path.join(resources, 'data.json'), JSON.stringify({ value: 'authored base' }));
+ fs.writeFileSync(path.join(resources, 'widget.css'), '#widget { min-width:137px; }');
+ fs.writeFileSync(path.join(resources, 'widget.js'), 'window.authoredBaseScriptLoaded = true;');
+ }
+ const cases = [
+ { href: 'assets/', resources: path.join(output, 'assets') },
+ { href: '../shared%20assets/', resources: shared },
+ { href: vscode.Uri.file(shared).toString() + '/', resources: shared },
+ ];
+ for (const [index, fixture] of cases.entries()) {
+ result = undefined;
+ const file = path.join(output, `index-${index}.html`);
+ fs.writeFileSync(file, `