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 = `Accessible chart + + `; + 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'), '', structure); + assert.strictEqual(observations.get('markup'), 'Template title', structure); + assert.strictEqual(observations.get('head'), '', 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, ` + + + +
Widget
`); + await showWebView(file, 'Authored base', 'Two'); + const response = await waitForValue(() => result); + assert.strictEqual(response.path, '/base'); + const observations = new URLSearchParams(response.query); + assert.strictEqual(observations.get('value'), 'authored base', fixture.href); + assert.strictEqual(observations.get('css'), 'true', fixture.href); + assert.strictEqual(observations.get('script'), 'true', fixture.href); + assert.strictEqual(observations.get('authored'), 'yes', fixture.href); + const panel = panels[panels.length - 1]; + // File URLs normalize Windows drive casing during resolution. + const normalizeBase = (url: string) => new URL(url).href.replace(/\/([a-z])%3A\//i, (_match, drive: string) => `/${drive.toLowerCase()}%3A/`); + assert.strictEqual(normalizeBase(observations.get('base')!), normalizeBase(String(panel.webview.asWebviewUri(vscode.Uri.file(fixture.resources))) + '/')); + assert.ok(panel.webview.html.includes('')); + panel.dispose(); + } + } finally { + panels.forEach(panel => { panel.dispose(); }); + await shutdownHtmlWidgetViewers(); + sandbox.restore(); + fs.rmSync(directory, { recursive: true, force: true }); + } + }); + + test('authored bases cannot read files outside the output and trusted workspace', 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-resource-scope-')); + const trusted = path.join(directory, 'workspace'); + const output = path.join(trusted, 'output'); + const previous = path.join(trusted, 'previous-output'); + const outside = path.join(directory, 'outside'); + const panels: vscode.WebviewPanel[] = []; + const source = session.registerSessionTransport('html-browser-resource-scope', 'widget-test-host', directory, () => Promise.resolve({})); + source.pid = '12106'; source.rVer = '4.6.1'; + let result: vscode.Uri | undefined; + try { + fs.mkdirSync(output, { recursive: true }); + fs.mkdirSync(previous); + const previousFile = path.join(previous, 'index.html'); + fs.writeFileSync(previousFile, 'Previously registered output'); + fs.mkdirSync(outside); + const privateFile = path.join(outside, 'private.json'); + fs.writeFileSync(privateFile, JSON.stringify({ value: 'outside permitted resources' })); + sandbox.stub(vscode.workspace, 'isTrusted').get(() => true); + sandbox.stub(vscode.workspace, 'workspaceFolders').get(() => [{ + uri: vscode.Uri.file(trusted), name: 'Trusted HTML output workspace', index: 0, + }]); + 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 rootRelative = path.relative(path.parse(privateFile).root, privateFile).split(path.sep).map(encodeURIComponent).join('/'); + for (const fixture of [ + { href: '/', resource: rootRelative }, + { href: '../../outside/', resource: 'private.json' }, + { href: vscode.Uri.file(outside).toString() + '/', resource: 'private.json' }, + ]) { + result = undefined; + await showWebView(previousFile, 'Previous output', 'Two', session.getViewerSessionContext(source.sessionId)); + const file = path.join(output, 'index.html'); + fs.writeFileSync(file, ``); + await showWebView(file, 'Resource scope', 'Two', session.getViewerSessionContext(source.sessionId)); + const response = await waitForValue(() => result); + assert.strictEqual(new URLSearchParams(response.query).get('access'), 'blocked', fixture.href); + assert.strictEqual(panels[panels.length - 1].webview.options.localResourceRoots?.length, 3, + 'Retain only the two registered output directories and viewer assets'); + panels[panels.length - 1].dispose(); + } + } finally { + panels.forEach(panel => { panel.dispose(); }); + await shutdownHtmlWidgetViewers(); + session.unregisterSessionTransport(source); + sandbox.restore(); + fs.rmSync(directory, { recursive: true, force: true }); + } + }); + + test('Cmd/Ctrl+F inside the widget opens native Find while other keys stay with the widget', 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-find-')); + const panels: vscode.WebviewPanel[] = []; + let result: vscode.Uri | undefined; + let findCommands = 0; + const source = session.registerSessionTransport('html-browser-find', 'widget-test-host', directory, () => Promise.resolve({})); + source.pid = '12103'; source.rVer = '4.6.1'; + 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; + }); + const executeCommand = vscode.commands.executeCommand.bind(vscode.commands); + const command = sandbox.stub(vscode.commands, 'executeCommand').callThrough(); + command.withArgs('editor.action.webvieweditor.showFind').callsFake(async () => { + await executeCommand('editor.action.webvieweditor.showFind'); + findCommands++; + }); + sandbox.stub(vscode.env, 'openExternal').callsFake(uri => { result = uri; return Promise.resolve(true); }); + const file = path.join(directory, 'index.html'); + fs.writeFileSync(file, ` +

Searchable widget content

+ + `); + await showWebView(file, 'Find in widget', 'Two', session.getViewerSessionContext(source.sessionId)); + const response = await waitForValue(() => result); + assert.strictEqual(response.path, '/find'); + const observations = new URLSearchParams(response.query); + for (const name of ['ctrl', 'cmd', 'handled']) { assert.strictEqual(observations.get(name), 'true', name); } + for (const name of ['plain', 'shift', 'alt']) { assert.strictEqual(observations.get(name), 'false', name); } + await waitForValue(() => findCommands === 2 ? true : undefined); + assert.strictEqual(command.withArgs('editor.action.webvieweditor.showFind').callCount, 2); + assert.strictEqual(panels.length, 1); + assert.strictEqual(vscode.window.tabGroups.activeTabGroup.activeTab?.label, 'HTML Viewer'); + assert.ok(!panels[0].webview.html.includes('widget-frame')); + } finally { + panels.forEach(panel => { panel.dispose(); }); + await shutdownHtmlWidgetViewers(); + session.unregisterSessionTransport(source); + sandbox.restore(); + fs.rmSync(directory, { recursive: true, force: true }); + } + }); + + test('fragment links scroll to IDs and named anchors inside the widget without leaving its document', 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-fragments-')); + const panels: vscode.WebviewPanel[] = []; + let result: vscode.Uri | undefined; + const source = session.registerSessionTransport('html-browser-fragments', 'widget-test-host', directory, () => Promise.resolve({})); + source.pid = '12102'; source.rVer = '4.6.1'; + 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 file = path.join(directory, 'index.html'); + fs.writeFileSync(file, ` + Section + Encoded ID + Named anchor + TopTop + Missing target + Malformed fragment +

Section

+

Encoded section

+
Legacy section
+ + `); + await showWebView(file, 'Report fragments', 'Two', session.getViewerSessionContext(source.sessionId)); + const response = await waitForValue(() => result); + assert.strictEqual(response.path, '/fragments'); + const observations = new URLSearchParams(response.query); + for (const key of ['toc', 'encoded', 'named', 'empty', 'emptyTop', 'topLink', 'topTop', 'missingLink', 'missingStayed', 'malformed', 'malformedStayed']) { + assert.strictEqual(observations.get(key), 'true', `${key} must stay within the displayed HTML document`); + } + assert.strictEqual(panels.length, 1); + } finally { + panels.forEach(panel => { panel.dispose(); }); + await shutdownHtmlWidgetViewers(); + session.unregisterSessionTransport(source); + sandbox.restore(); + fs.rmSync(directory, { recursive: true, force: true }); + } + }); + + test('history navigation keeps the previous HTML visible without rerunning it', 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-switch-')); + const panels: vscode.WebviewPanel[] = []; + const reports: vscode.Uri[] = []; + const writes: Array> = []; + let clears = 0; + const contexts = new Map(); + const source = session.registerSessionTransport('html-browser-switch', 'widget-test-host', directory, () => Promise.resolve({})); + source.pid = '12105'; source.rVer = '4.6.1'; + try { + mockExtensionContext(path.resolve(__dirname, '../../..'), sandbox); + const viewerManager = initializeHtmlWidgetViewers(extensionContext, { + resolveSession: source => session.getViewerSessionContext(source.sessionId, source)!, + getActiveSessionId: () => session.activeSession?.sessionId, + }); + const executeCommand = vscode.commands.executeCommand.bind(vscode.commands); + sandbox.stub(vscode.commands, 'executeCommand').callThrough().withArgs('setContext') + .callsFake((_command: string, key: string, value: unknown) => { + contexts.set(key, value); return executeCommand('setContext', key, value); + }); + const createPanel = vscode.window.createWebviewPanel.bind(vscode.window); + sandbox.stub(vscode.window, 'createWebviewPanel').callsFake((...args) => { + const panel = createPanel(...args); + panels.push(panel); + // Delay replacement delivery to exercise the interval between the + // options and HTML workbench messages, without delaying a clear. + const webview = panel.webview; + const descriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(webview), 'html')!; + sandbox.stub(webview, 'html').get(() => descriptor.get!.call(webview) as string).set((html: string) => { + if (!html) { clears++; descriptor.set!.call(webview, html); return; } + writes.push(new Promise(resolve => setTimeout(() => { + descriptor.set!.call(webview, html); resolve(); + }, 500))); + }); + return panel; + }); + sandbox.stub(vscode.env, 'openExternal').callsFake(uri => { reports.push(uri); return Promise.resolve(true); }); + const fixture = (name: string) => { + const root = path.join(directory, name); + fs.mkdirSync(root); + fs.writeFileSync(path.join(root, 'style.css'), 'table { width:200px; margin-left:auto; margin-right:auto; }'); + const file = path.join(root, 'index.html'); + fs.writeFileSync(file, ` + ${name === 'table' ? '
Centered flextable output
' : '

HTML file output

'} + `); + return file; + }; + const table = fixture('table'); + const html = fixture('html'); + const loaded = async (count: number) => { + await Promise.all(writes); + await waitForValue(() => reports.length >= count && + [...viewerManager.viewers.values()].some(entry => entry.panel === panels[0] && !entry.loading) ? true : undefined); + }; + const disabled = () => ['canGoBack', 'canGoForward', 'canRemove'] + .every(key => contexts.get(`r.htmlViewer.${key}`) === false); + await showWebView(table, 'Table', 'Two', session.getViewerSessionContext(source.sessionId)); + await loaded(1); + await showWebView(html, 'HTML', 'Two', session.getViewerSessionContext(source.sessionId)); + await loaded(2); + const previousClears = clears; + const roots = panels[0].webview.options.localResourceRoots!.map(root => root.toString()); + await focusHtmlViewer(panels[0]); + await runHtmlViewerCommand('back'); + assert.ok(disabled(), 'Buttons must stay disabled while replacement delivery is pending'); + assert.ok(panels[0].webview.html.includes('HTML file output'), 'Keep the current output until replacement arrives'); + assert.strictEqual(clears, previousClears, 'Back must not insert a blank document'); + assert.deepStrictEqual(panels[0].webview.options.localResourceRoots!.map(root => root.toString()), roots); + const pendingBackWrites = writes.length; + await runHtmlViewerCommand('forward'); + assert.strictEqual(writes.length, pendingBackWrites, 'Ignore repeated clicks during the transition'); + await loaded(3); + assert.strictEqual(contexts.get('r.htmlViewer.canGoBack'), false); + assert.strictEqual(contexts.get('r.htmlViewer.canGoForward'), true); + await focusHtmlViewer(panels[0]); + await runHtmlViewerCommand('forward'); + assert.ok(disabled(), 'Forward must wait for the selected document to load'); + assert.ok(panels[0].webview.html.includes('Centered flextable output'), 'Keep the current table until replacement arrives'); + assert.strictEqual(clears, previousClears, 'Forward must not insert a blank document'); + assert.deepStrictEqual(panels[0].webview.options.localResourceRoots!.map(root => root.toString()), roots); + await loaded(4); + assert.strictEqual(contexts.get('r.htmlViewer.canGoBack'), true); + assert.strictEqual(contexts.get('r.htmlViewer.canGoForward'), false); + assert.deepStrictEqual(reports.map(uri => uri.path), ['/table', '/html', '/table', '/html']); + assert.ok(reports.every(uri => new URLSearchParams(uri.query).get('centered') === 'true'), + 'The table must retain its authored centering on every load'); + } finally { + await Promise.all(writes); + panels.forEach(panel => { panel.dispose(); }); + await shutdownHtmlWidgetViewers(); + session.unregisterSessionTransport(source); + sandbox.restore(); + fs.rmSync(directory, { recursive: true, force: true }); + } + }); + + test('native actions navigate, remove, and restore full documents with relative resources', 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-browser-')); + const panels: vscode.WebviewPanel[] = []; + const urls: vscode.Uri[] = []; + const source = session.registerSessionTransport('html-browser-rendering', 'widget-test-host', directory, () => Promise.resolve({})); + source.pid = '12101'; source.rVer = '4.6.1'; + try { + mockExtensionContext(path.resolve(__dirname, '../../..'), sandbox); + const viewerManager = 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 => { urls.push(uri); return Promise.resolve(true); }); + const fixture = (name: string) => { + const root = path.join(directory, name); + fs.mkdirSync(path.join(root, 'lib'), { recursive: true }); + fs.writeFileSync(path.join(root, 'lib/widget.css'), '#widget { min-width:137px; }' + + (name === 'first' ? 'body { background:white; }' : 'body { background:#123; color:#eee; } code { color:#abc; background:#234; }')); + fs.writeFileSync(path.join(root, 'lib/pixel.svg'), ''); + fs.writeFileSync(path.join(root, 'lib/data.json'), JSON.stringify({ name })); + fs.writeFileSync(path.join(root, 'lib/widget.js'), `let reports = 0; let fetched = false; + fetch('lib/data.json').then(response => response.json()).then(data => { fetched = data.name === '${name}'; }); + const report = document.createElement('a'); document.body.append(report); + const timer = setInterval(() => { + const params = new URLSearchParams({ width: String(innerWidth), height: String(innerHeight), + css: String(getComputedStyle(document.getElementById('widget')).minWidth === '137px'), + image: String(document.getElementById('widget-image').naturalWidth === 1), fetch: String(fetched), + direct: String(!document.getElementById('widget-frame') && !document.getElementById('widget-toolbar')), + textColor: getComputedStyle(document.getElementById('markdown-text')).color, + codeColor: getComputedStyle(document.getElementById('markdown-code')).color }); + report.href = 'https://widget-test.invalid/${name}?' + params; report.click(); + if (++reports === 30) clearInterval(timer); + }, 100);`); + const file = path.join(root, 'index.html'); + fs.writeFileSync(file, `
${name}

Rendered Markdown inline code

`); + return file; + }; + const first = fixture('first'); + const second = fixture('second'); + const count = (name: string) => urls.filter(uri => uri.path === `/${name}`).length; + const loaded = async (name: string, previous: number) => { + const uri = await waitForValue(() => { + const reports = urls.filter(uri => uri.path === `/${name}`); + const latest = reports.at(-1); + const params = new URLSearchParams(latest?.query); + return reports.length > previous && ['css', 'image', 'fetch', 'direct'].every(key => params.get(key) === 'true') && + [...viewerManager.viewers.values()].some(entry => entry.panel === panels.at(-1) && !entry.loading) ? latest : undefined; + }); + const dimensions = new URLSearchParams(uri.query); + assert.ok(Number(dimensions.get('width')) > 0 && Number(dimensions.get('height')) > 0); + assert.strictEqual(dimensions.get('textColor'), name === 'first' ? 'rgb(0, 0, 0)' : 'rgb(238, 238, 238)'); + assert.strictEqual(dimensions.get('codeColor'), name === 'first' ? 'rgb(0, 0, 0)' : 'rgb(170, 187, 204)'); + assert.ok(!panels.at(-1)!.webview.html.includes('r-html-viewer-info')); + }; + await showWebView(first, 'First widget', 'Two', session.getViewerSessionContext(source.sessionId)); + await loaded('first', 0); + await showWebView(second, 'Second widget', 'Two', session.getViewerSessionContext(source.sessionId)); + await loaded('second', 0); + assert.strictEqual(panels.length, 1); + const panel = panels[0]; + assert.strictEqual(panel.viewType, 'r.htmlViewer'); + await focusHtmlViewer(panel); + const firstCount = count('first'); + await runHtmlViewerCommand('back'); + await loaded('first', firstCount); + const secondCount = count('second'); + await runHtmlViewerCommand('forward'); + await loaded('second', secondCount); + const removeCount = count('first'); + await runHtmlViewerCommand('remove'); + await loaded('first', removeCount); + assert.ok(fs.existsSync(second), 'Removing history must preserve the original HTML file'); + const information = sandbox.stub(vscode.window, 'showInformationMessage').resolves(); + const html = panel.webview.html; + await focusHtmlViewer(panel); + await runHtmlViewerCommand('info'); + assert.strictEqual(panel.title, `HTML Viewer · R 4.6.1: ${source.pid}`); + assert.strictEqual(panel.webview.html, html); + await focusHtmlViewer(panel); + await runHtmlViewerCommand('info'); + assert.strictEqual(panel.title, 'HTML Viewer'); + sinon.assert.notCalled(information); + panel.dispose(); + const restoreCount = count('first'); + await restoreHtmlViewer(source.sessionId); + await loaded('first', restoreCount); + assert.strictEqual(panels.length, 2); + assert.strictEqual(panels[1].title, 'HTML Viewer'); + } finally { + panels.forEach(panel => { panel.dispose(); }); + await shutdownHtmlWidgetViewers(); + session.unregisterSessionTransport(source); + sandbox.restore(); + fs.rmSync(directory, { recursive: true, force: true }); + } + }); + test('registered native commands operate on HTML outputs received by the activated extension', async () => { + const sandbox = sinon.createSandbox(); + sandbox.stub(vscode.window, 'registerWebviewPanelSerializer').returns({ dispose: sandbox.stub() }); + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'vscode-r-native-commands-')); + const panels: vscode.WebviewPanel[] = []; + const loaded = new Map(); + const contexts = new Map(); + let client: net.Socket | undefined; + try { + const extension = vscode.extensions.getExtension('REditorSupport.r'); + assert.ok(extension); + const api = await extension.activate(); + const connection = await api.session.getConnectionInfo(); + assert.ok(connection, 'The activated extension must expose its session endpoint'); + const executeCommand = vscode.commands.executeCommand.bind(vscode.commands); + sandbox.stub(vscode.commands, 'executeCommand').callThrough().withArgs('setContext') + .callsFake((_command: string, key: string, value: unknown) => + executeCommand('setContext', key, value).then(() => { contexts.set(key, value); })); + const createPanel = vscode.window.createWebviewPanel.bind(vscode.window); + sandbox.stub(vscode.window, 'createWebviewPanel').callsFake((...args) => { + const panel = createPanel(...args); + if (panel.viewType === 'r.htmlViewer') { + panels.push(panel); + panel.webview.onDidReceiveMessage((message: { message?: string; generation?: number }) => { + if (message.message === 'widget/loaded' && typeof message.generation === 'number') { + loaded.set(panel, message.generation); + } + }); + } + return panel; + }); + const information = sandbox.stub(vscode.window, 'showInformationMessage').resolves(); + const id = `native-toolbar-${Date.now()}`; + client = net.createConnection(connection.endpoint); + let attached = false; + let buffer = ''; + const socket = client; + socket.on('data', data => { + buffer += data.toString(); + let newline: number; + while ((newline = buffer.indexOf('\n')) >= 0) { + const request = JSON.parse(buffer.slice(0, newline)) as { id?: number; method?: string }; + buffer = buffer.slice(newline + 1); + if (request.id === undefined) { continue; } + if (request.method === 'workspace') { attached = true; } + socket.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, + result: request.method === 'workspace' ? { globalenv: {}, search: [], loaded_namespaces: [] } : true }) + '\n'); + } + }); + await new Promise((resolve, reject) => { socket.once('connect', resolve); socket.once('error', reject); }); + const notify = (method: string, params: Record) => { + socket.write(JSON.stringify({ jsonrpc: '2.0', method, params }) + '\n'); + }; + notify('attach', { protocol_version: connection.protocolVersion, session_id: id, + host: 'native-toolbar-test-host', pid: '12104', version: '4.6.1', tempdir: directory, wd: directory }); + await waitForValue(() => attached ? true : undefined); + const first = path.join(directory, 'first.html'); + const second = path.join(directory, 'second.html'); + fs.writeFileSync(first, 'Native first'); + fs.writeFileSync(second, 'Native second'); + notify('webview', { url: first, title: 'First' }); + const panel = await waitForValue(() => panels[0]?.webview.html.includes('Native first') ? panels[0] : undefined); + const run = async (viewer: vscode.WebviewPanel, action: 'back' | 'forward' | 'remove' | 'info') => { + const generation = () => Number(/data-generation="(\d+)"/.exec(viewer.webview.html)?.[1]); + const ready = () => loaded.get(viewer) === generation(); + await waitForValue(() => ready() ? true : undefined); + await focusHtmlViewer(viewer); + const key = { back: 'canGoBack', forward: 'canGoForward', remove: 'canRemove', info: 'canShowInfo' }[action]; + // A browser acknowledgement can precede the workbench's toolbar + // update. Wait for the native command to be enabled as well. + await waitForValue(() => ready() && viewer.active && contexts.get(`r.htmlViewer.${key}`) === true ? true : undefined); + const previousGeneration = generation(); + await vscode.commands.executeCommand(`r.htmlViewer.${action}`); + // Navigation must load a new document, rather than accepting the + // previous page's acknowledgement if the command was ignored. + await waitForValue(() => ready() && (action === 'info' || generation() > previousGeneration) ? true : undefined); + // Title changes reach the workbench asynchronously. Navigation + // preserves focus, so check the viewer's group even if another + // group becomes active before the next action refocuses it. + await waitForValue(() => vscode.window.tabGroups.all.find(group => + group.viewColumn === viewer.viewColumn)?.activeTab?.label === viewer.title ? true : undefined); + }; + notify('webview', { url: second, title: 'Second' }); + await waitForValue(() => panel.webview.html.includes('Native second') ? true : undefined); + await run(panel, 'back'); + assert.ok(panel.webview.html.includes('Native first')); + await run(panel, 'forward'); + assert.ok(panel.webview.html.includes('Native second')); + const html = panel.webview.html; + await run(panel, 'info'); + assert.strictEqual(panel.title, 'HTML Viewer · R 4.6.1: 12104'); + assert.strictEqual(panel.webview.html, html); + await run(panel, 'info'); + assert.strictEqual(panel.title, 'HTML Viewer'); + sinon.assert.notCalled(information); + await run(panel, 'remove'); + assert.ok(panel.webview.html.includes('Native first')); + assert.ok(fs.existsSync(second)); + panel.dispose(); + await vscode.commands.executeCommand('r.htmlViewer.restore', id); + assert.strictEqual(panels.length, 2); + assert.ok(panels[1].webview.html.includes('Native first')); + await run(panels[1], 'remove'); + assert.ok(panels[1].webview.html.includes('No HTML outputs in this session')); + } finally { + panels.forEach(panel => { panel.dispose(); }); + client?.destroy(); + sandbox.restore(); + fs.rmSync(directory, { recursive: true, force: true }); + } + }); + +}); diff --git a/src/test/suite/htmlWidgetContent.test.ts b/src/test/suite/htmlWidgetContent.test.ts new file mode 100644 index 000000000..4ce62e1f3 --- /dev/null +++ b/src/test/suite/htmlWidgetContent.test.ts @@ -0,0 +1,79 @@ +import * as assert from 'assert'; +import * as vm from 'vm'; +import { initializeWidgetContent, initializeWidgetLoad, initializeWidgetState } from '../../webViewer/webview/widget'; + +suite('HTML output content controls', () => { + function content(sessionOwned = true) { + const posted: unknown[] = []; + const events = new Map void>(); + vm.runInNewContext(`(${initializeWidgetContent.toString()})(vscode, 7, sessionOwned)`, { + document: { addEventListener: (name: string, listener: (event: unknown) => void) => events.set(name, listener) }, + window: {}, sessionOwned, + vscode: { postMessage: (message: unknown) => posted.push(JSON.parse(JSON.stringify(message))) }, + }); + return { posted, events }; + } + + test('Alt arrows and mouse navigation send the owning document generation', () => { + const widget = content(); + let prevented = 0; + widget.events.get('keydown')!({ altKey: true, key: 'ArrowLeft', preventDefault: () => { prevented++; } }); + widget.events.get('mousedown')!({ button: 4, preventDefault: () => { prevented++; } }); + assert.strictEqual(prevented, 2); + assert.deepStrictEqual(widget.posted, [ + { message: 'widget/navigate', direction: 'back', generation: 7 }, + { message: 'widget/navigate', direction: 'forward', generation: 7 }, + ]); + }); + + test('unowned outputs do not intercept history shortcuts or keys handled by a widget', () => { + for (const owned of [false, true]) { + const widget = content(owned); + widget.events.get('keydown')!({ defaultPrevented: true, altKey: true, key: 'ArrowLeft' }); + if (!owned) { + widget.events.get('keydown')!({ altKey: true, key: 'ArrowLeft' }); + widget.events.get('mousedown')!({ button: 3 }); + } + assert.deepStrictEqual(widget.posted, []); + } + }); + + test('Find works directly in the webview without a parent-frame bridge', () => { + const widget = content(); + let prevented = false; + widget.events.get('keydown')!({ ctrlKey: true, key: 'f', preventDefault: () => { prevented = true; } }); + assert.ok(prevented); + assert.deepStrictEqual(widget.posted, [{ message: 'widget/find', generation: 7 }]); + }); + + test('reload state saves only the extension-owned panel reference', () => { + let saved: unknown; + initializeWidgetState({ setState: state => { saved = state; }, postMessage: () => {} }, { id: 'original-panel' }); + assert.deepStrictEqual(saved, { id: 'original-panel' }); + }); + + test('readiness waits for document load and a paint, and reports its generation', () => { + for (const readyState of ['loading', 'complete']) { + const posted: unknown[] = []; + const frames: Array<() => void> = []; + let load: (() => void) | undefined; + vm.runInNewContext(`(${initializeWidgetLoad.toString()})(vscode, 7)`, { + document: { readyState }, + window: { addEventListener: (event: string, callback: () => void, options: { once: boolean }) => { + assert.strictEqual(event, 'load'); assert.strictEqual(options.once, true); load = callback; + } }, + requestAnimationFrame: (callback: () => void) => frames.push(callback), + vscode: { postMessage: (message: unknown) => posted.push(JSON.parse(JSON.stringify(message))) }, + }); + if (readyState === 'loading') { + assert.strictEqual(frames.length, 0, 'Do not acknowledge before resources load'); + load!(); + } + assert.deepStrictEqual(posted, []); + frames.shift()!(); + assert.deepStrictEqual(posted, [], 'Wait until the browser has had a paint opportunity'); + frames.shift()!(); + assert.deepStrictEqual(posted, [{ message: 'widget/loaded', generation: 7 }]); + } + }); +}); diff --git a/src/test/suite/htmlWidgetViewer.test.ts b/src/test/suite/htmlWidgetViewer.test.ts new file mode 100644 index 000000000..9ce3e55ca --- /dev/null +++ b/src/test/suite/htmlWidgetViewer.test.ts @@ -0,0 +1,1311 @@ +import * as assert from 'assert'; +import fs from 'fs/promises'; +import { readFileSync } from 'fs'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import * as vscode from 'vscode'; +import * as session from '../../session'; +import { HtmlViewerSessionAccess, initializeHtmlWidgetViewers, restoreHtmlViewer, runHtmlViewerCommand, showWebView, shutdownHtmlWidgetViewers } from '../../webViewer'; +import { extensionContext } from '../../extension'; +import { widgetHistoryKey, WidgetHistory } from '../../webViewer/history'; +import type { HtmlViewerPanelState } from '../../webViewer/webviewMessages'; +import { mockExtensionContext } from '../common/mockvscode'; +import { deferred } from '../common/sessionConnections'; + +suite('Session-aware HTML widget Viewer', () => { + let sandbox: sinon.SinonSandbox; + let read: sinon.SinonStub; + const owners: session.Session[] = []; + const panels: vscode.WebviewPanel[] = []; + const receivers = new Map Promise>(); + let viewerManager: ReturnType; + let activePanel: vscode.WebviewPanel | undefined; + let execute: sinon.SinonStub; + let information: sinon.SinonStub; + const contexts = new Map(); + const viewEvents = new Map>(); + const savedState = new Map(); + let trustedWorkspace: boolean; + let workspaceRoots: string[]; + let autoLoad: boolean; + let loadingStatus: vscode.StatusBarItem; + let disposeLoadingStatus: sinon.SinonStub; + let statusVisible: boolean; + const viewerSessions: HtmlViewerSessionAccess = { + resolveSession: source => session.getViewerSessionContext(source.sessionId, source)!, + getActiveSessionId: () => session.activeSession?.sessionId, + }; + + setup(() => { + sandbox = sinon.createSandbox(); + trustedWorkspace = false; + workspaceRoots = []; + autoLoad = true; + statusVisible = false; + sandbox.stub(vscode.window, 'createStatusBarItem').callThrough().withArgs(sinon.match.same('r.htmlViewer.loading')).callsFake((id, alignment) => { + assert.strictEqual(id, 'r.htmlViewer.loading'); + assert.strictEqual(alignment, vscode.StatusBarAlignment.Right); + disposeLoadingStatus = sandbox.stub().callsFake(() => { statusVisible = false; }); + loadingStatus = { + text: '', tooltip: undefined, command: undefined, + show: sandbox.stub().callsFake(() => { statusVisible = true; }), + hide: sandbox.stub().callsFake(() => { statusVisible = false; }), + dispose: disposeLoadingStatus, + } as unknown as vscode.StatusBarItem; + return loadingStatus; + }); + sandbox.stub(vscode.workspace, 'isTrusted').get(() => trustedWorkspace); + sandbox.stub(vscode.workspace, 'workspaceFolders').get(() => workspaceRoots.map((root, index) => ({ + uri: vscode.Uri.file(path.resolve(root)), name: 'HTML viewer test workspace', index, + }))); + activePanel = undefined; + contexts.clear(); + execute = sandbox.stub(vscode.commands, 'executeCommand').callThrough(); + execute.withArgs('setContext').callsFake((_command: string, key: string, value: unknown) => { contexts.set(key, value); return Promise.resolve(); }); + information = sandbox.stub(vscode.window, 'showInformationMessage').resolves(); + sandbox.stub(vscode.window, 'registerWebviewPanelSerializer').returns({ dispose: sandbox.stub() }); + mockExtensionContext(path.resolve(__dirname, '../../..'), sandbox); + savedState.clear(); + (extensionContext.workspaceState.get as sinon.SinonStub).callsFake((key: string, fallback: unknown) => savedState.get(key) ?? fallback); + (extensionContext.workspaceState.update as sinon.SinonStub).callsFake((key: string, value: unknown) => { + savedState.set(key, JSON.parse(JSON.stringify(value))); + return Promise.resolve(); + }); + viewerManager = initializeHtmlWidgetViewers(extensionContext, viewerSessions); + read = sandbox.stub(fs, 'readFile'); + read.callsFake(file => Promise.resolve( + `
${String(file)}
`)); + sandbox.stub(vscode.window, 'createWebviewPanel').callsFake((_type, title, _column, options) => { + const disposed = new vscode.EventEmitter(); + const viewState = new vscode.EventEmitter(); + const listeners: Array<(message: unknown) => unknown> = []; + let closed = false; + let html = ''; + const webview = { + get html() { return html; }, + set html(value: string) { + html = value; + const generation = Number(/data-generation="(\d+)"/.exec(value)?.[1]); + if (autoLoad && generation) { + queueMicrotask(() => { void receivers.get(panel)?.({ message: 'widget/loaded', generation }); }); + } + }, + options, cspSource: 'webview-test:', + asWebviewUri: (uri: vscode.Uri) => uri, + onDidReceiveMessage: (listener: (message: unknown) => unknown) => { + listeners.push(listener); + return { dispose: () => { listeners.splice(listeners.indexOf(listener), 1); } }; + }, + }; + const panel = { + title, viewType: 'r.htmlViewer', viewColumn: vscode.ViewColumn.Two, + get active() { return activePanel === panel; }, + reveal: sandbox.stub().callsFake((_column: vscode.ViewColumn, preserveFocus: boolean) => { + if (!preserveFocus) { activate(panel); } + }), + get webview() { + assert.strictEqual(closed, false, 'Disposed panels must not be accessed'); + return webview; + }, + onDidDispose: disposed.event, + onDidChangeViewState: viewState.event, + dispose: () => { + if (closed) { return; } + closed = true; disposed.fire(); disposed.dispose(); viewState.dispose(); listeners.length = 0; + }, + } as unknown as vscode.WebviewPanel; + panels.push(panel); + activePanel = panel; + viewEvents.set(panel, viewState); + receivers.set(panel, async message => { await Promise.all(listeners.map(listener => listener(message))); }); + return panel; + }); + }); + + teardown(async () => { + panels.splice(0).forEach(panel => { panel.dispose(); }); + await shutdownHtmlWidgetViewers(); + receivers.clear(); + viewEvents.clear(); + owners.splice(0).forEach(owner => session.unregisterSessionTransport(owner)); + sandbox.restore(); + }); + + function owner(id: string): session.Session { + const result = session.registerSessionTransport(id, 'widget-test-host', '/tmp', sandbox.stub().resolves({})); + result.pid = String(1000 + owners.length); result.rVer = '4.6.1'; + owners.push(result); + return result; + } + + function show(file: string, source: session.Session, title = 'Viewer', viewer: string | boolean = 'Two') { + return showWebView(file, title, viewer, session.getViewerSessionContext(source.sessionId)); + } + + function outputTitle(panel: vscode.WebviewPanel): string | undefined { + assert.strictEqual(panel.title, 'HTML Viewer'); + const reference = panelReference(panel); + const states = savedState.get('r.htmlViewer.panels') as HtmlViewerPanelState[]; + const state = states.find(state => state.id === reference.id)!; + const item = state.history[state.index]; + assert.ok(panel.webview.html.includes(`
${item.file}
`), 'The retained title must belong to the rendered output'); + return item.title; + } + + function widgetDocument(panel: vscode.WebviewPanel): string { + assert.ok(!panel.webview.html.includes('widget-frame'), 'Outputs must render directly without the private iframe'); + return panel.webview.html; + } + + function activate(panel?: vscode.WebviewPanel): void { + activePanel = panel; + for (const [viewer, emitter] of viewEvents) { emitter.fire({ webviewPanel: viewer }); } + } + + function position(panel: vscode.WebviewPanel): string { + const entry = [...viewerManager.viewers.values()].find(entry => entry.panel === panel)!; + return `${entry.state.index + 1} / ${entry.state.history.length}`; + } + + function panelReference(panel: vscode.WebviewPanel): { id: string } { + const value = /data-viewer-state="([^"]*)"/.exec(panel.webview.html)![1].replace(/"/g, '"'); + return JSON.parse(value) as { id: string }; + } + + async function reloadPanel(panel: vscode.WebviewPanel, reference = panelReference(panel)): Promise { + await shutdownHtmlWidgetViewers(true); + viewerManager = initializeHtmlWidgetViewers(extensionContext, viewerSessions); + const serializer = (vscode.window.registerWebviewPanelSerializer as sinon.SinonStub).lastCall.args[1] as vscode.WebviewPanelSerializer; + await serializer.deserializeWebviewPanel(panel, reference); + } + + async function info(panel: vscode.WebviewPanel): Promise { + activate(panel); + await runHtmlViewerCommand('info'); + const label = panel.title.startsWith('HTML Viewer · ') ? panel.title.slice('HTML Viewer · '.length) : ''; + await runHtmlViewerCommand('info'); + return label; + } + + async function navigate(panel: vscode.WebviewPanel, direction: 'back' | 'forward', oldGeneration?: number): Promise { + if (oldGeneration !== undefined) { + await receivers.get(panel)!({ message: 'widget/navigate', direction, generation: oldGeneration }); + } else { + activate(panel); + await runHtmlViewerCommand(direction); + } + } + + async function remove(panel: vscode.WebviewPanel): Promise { + activate(panel); + await runHtmlViewerCommand('remove'); + } + + function disabled(panel: vscode.WebviewPanel, button: 'back' | 'forward' | 'remove'): boolean { + activate(panel); + const key = { back: 'canGoBack', forward: 'canGoForward', remove: 'canRemove' }[button]; + return contexts.get(`r.htmlViewer.${key}`) !== true; + } + + test('uses the initialized extension context for both globe icon themes', async () => { + const context = { + ...extensionContext, + asAbsolutePath: (relative: string) => path.join('/viewer-extension', relative), + }; + initializeHtmlWidgetViewers(context, viewerSessions); + await show('/tmp/icon.html', owner('html-icon')); + assert.deepStrictEqual(panels[0].iconPath, { + dark: vscode.Uri.file(path.join('/viewer-extension', 'images/icons/dark/globe.svg')), + light: vscode.Uri.file(path.join('/viewer-extension', 'images/icons/light/globe.svg')), + }); + }); + + test('restoration reads the configured editor group and defaults to Two when none is set', async () => { + const source = owner('html-restore-view-column'); + const configuration = sandbox.stub(vscode.workspace, 'getConfiguration'); + await show('/tmp/view-column.html', source); + for (const viewer of [undefined, 'Three']) { + await shutdownHtmlWidgetViewers(); + const records = savedState.get(widgetHistoryKey) as WidgetHistory[]; + delete records[0].viewColumn; + configuration.withArgs('r').returns({ + get: (key: string) => key === 'session.viewers.viewColumn' && viewer ? { viewer } : undefined, + } as vscode.WorkspaceConfiguration); + viewerManager = initializeHtmlWidgetViewers(extensionContext, viewerSessions); + await restoreHtmlViewer(source.sessionId); + const options = (vscode.window.createWebviewPanel as sinon.SinonStub).lastCall.args[2] as { viewColumn: vscode.ViewColumn }; + assert.strictEqual(options.viewColumn, viewer ? vscode.ViewColumn.Three : vscode.ViewColumn.Two); + } + }); + + test('reuses one panel per session and updates widget dependencies without moving its editor group', async () => { + const first = owner('html-first'); + const second = owner('html-second'); + await show('/tmp/widget-a/index.html', first); + await show('/tmp/widget-b/index.html', second); + await show('/tmp/widget-c/index.html', first, 'Updated Viewer', 'Beside'); + assert.strictEqual(panels.length, 2); + assert.strictEqual(outputTitle(panels[0]), 'Updated Viewer'); + assert.ok(panels[0].webview.html.includes('/tmp/widget-c/index.html')); + assert.ok(widgetDocument(panels[0]).includes('')); + assert.ok(widgetDocument(panels[0]).includes('src="lib/widget.js"')); + assert.ok(panels[1].webview.html.includes('/tmp/widget-b/index.html')); + assert.ok(panels[0].webview.options.localResourceRoots?.some(root => root.fsPath === vscode.Uri.file('/tmp/widget-c').fsPath)); + assert.deepStrictEqual((panels[0].reveal as sinon.SinonStub).lastCall.args, [vscode.ViewColumn.Two, true]); + assert.ok((await info(panels[0])).includes(`R 4.6.1: ${first.pid}`)); + assert.ok((await info(panels[1])).includes(`R 4.6.1: ${second.pid}`)); + assert.ok(!widgetDocument(panels[0]).includes('widget-toolbar')); + assert.ok(!widgetDocument(panels[0]).includes('viewer-session')); + }); + + test('concurrent requests share a panel and the newest request wins', async () => { + const source = owner('html-concurrent'); + const slow = deferred(); + read.onFirstCall().returns(slow.promise); + const pending = show('/tmp/slow/index.html', source, 'Old'); + await show('/tmp/latest/index.html', source, 'Latest'); + assert.strictEqual(panels.length, 1); + const latest = panels[0].webview.html; + slow.resolve('
Old HTML
'); + await pending; + assert.strictEqual(panels[0].webview.html, latest); + assert.strictEqual(outputTitle(panels[0]), 'Latest'); + assert.ok(panels[0].webview.options.localResourceRoots?.some(root => root.fsPath === vscode.Uri.file('/tmp/latest').fsPath)); + }); + + test('closing during a load allows a new panel and discards the old result', async () => { + const source = owner('html-close'); + const slow = deferred(); + read.onFirstCall().returns(slow.promise); + const pending = show('/tmp/closed/index.html', source); + panels[0].dispose(); + await show('/tmp/new/index.html', source); + slow.resolve('
Closed output
'); + await pending; + assert.strictEqual(panels.length, 2); + assert.ok(panels[1].webview.html.includes('/tmp/new/index.html')); + }); + + test('reconnecting the same session reuses its panel while a fresh process has its own', async () => { + const source = owner('html-reconnect'); + await show('/tmp/before/index.html', source); + session.unregisterSessionTransport(source); + const reconnected = owner('html-reconnect'); + reconnected.pid = source.pid; + reconnected.host = source.host.toUpperCase(); + await show('/tmp/after/index.html', reconnected); + assert.strictEqual(panels.length, 1); + const restarted = owner('html-restarted'); + await show('/tmp/restarted/index.html', restarted); + assert.strictEqual(panels.length, 2); + assert.ok(panels[0].webview.html.includes('/tmp/after/index.html')); + }); + + for (const change of ['host', 'pid'] as const) { + test(`a reused session ID with a different ${change} gets its own panel and retained history`, async () => { + const source = owner(`html-reused-${change}`); + await show('/tmp/old.html', source, 'Old'); + const originalHtml = panels[0].webview.html; + session.unregisterSessionTransport(source); + const replacement = owner(source.sessionId); + replacement.pid = change === 'pid' ? '98765' : source.pid; + replacement.host = change === 'host' ? 'replacement-host' : source.host; + await show('/tmp/new.html', replacement, 'New'); + assert.strictEqual(panels.length, 2); + assert.strictEqual(panels[0].webview.html, originalHtml); + assert.strictEqual(outputTitle(panels[1]), 'New'); + assert.strictEqual(position(panels[1]), '1 / 1'); + const retained = savedState.get(widgetHistoryKey) as WidgetHistory[]; + assert.strictEqual(retained.length, 1); + assert.strictEqual(retained[0].source.host, replacement.host); + assert.strictEqual(retained[0].source.pid, replacement.pid); + assert.deepStrictEqual(retained[0].history.map(item => item.title), ['New']); + + // Closing the original panel must not overwrite the replacement's + // history or remove it from the panel-reuse map. + panels[0].dispose(); + await show('/tmp/next.html', replacement, 'Next'); + assert.strictEqual(panels.length, 2); + assert.strictEqual(position(panels[1]), '2 / 2'); + panels[1].dispose(); + await restoreHtmlViewer(replacement.sessionId); + assert.strictEqual(panels.length, 3); + assert.strictEqual(outputTitle(panels[2]), 'Next'); + await navigate(panels[2], 'back'); + assert.strictEqual(outputTitle(panels[2]), 'New'); + }); + } + + test('disabled viewing opens externally without creating or changing a panel', async () => { + const source = owner('html-disabled'); + const external = sandbox.stub(vscode.env, 'openExternal').resolves(true); + await show('/tmp/widget/index.html', source, 'Viewer', false); + assert.strictEqual(panels.length, 0); + sinon.assert.calledOnce(external); + sinon.assert.notCalled(read); + }); + + test('Find activates its owning panel and ignores stale requests without changing history', async () => { + const command = execute.withArgs('editor.action.webvieweditor.showFind').resolves(); + const source = owner('html-find'); + await show('/tmp/a.html', source, 'A'); + const panel = panels[0]; + const previousGeneration = Number(/data-generation="(\d+)"/.exec(panel.webview.html)?.[1]); + await show('/tmp/b.html', source, 'B'); + await show('/tmp/other.html', owner('html-find-other'), 'Other'); + const html = panel.webview.html; + const currentGeneration = Number(/data-generation="(\d+)"/.exec(html)?.[1]); + await receivers.get(panel)!({ message: 'widget/find', generation: previousGeneration }); + await receivers.get(panel)!({ message: 'widget/find' }); + sinon.assert.notCalled(command); + const reveal = (panel as unknown as { reveal: sinon.SinonStub }).reveal; + reveal.resetHistory(); + await receivers.get(panel)!({ message: 'widget/find', generation: currentGeneration }); + sinon.assert.calledOnceWithExactly(command, 'editor.action.webvieweditor.showFind'); + sinon.assert.calledOnceWithExactly(reveal, vscode.ViewColumn.Two, false); + assert.strictEqual(panel.webview.html, html); + assert.strictEqual(position(panel), '2 / 2'); + panel.dispose(); + await receivers.get(panel)!({ message: 'widget/find', generation: currentGeneration }); + sinon.assert.calledOnce(command); + }); + + test('unowned HTML pages retain independent panels', async () => { + await showWebView('/tmp/page-a/index.html', 'Page Viewer', 'Two'); + await showWebView('/tmp/page-b/index.html', 'Page Viewer', 'Two'); + assert.strictEqual(panels.length, 2); + assert.ok(!panels[0].webview.html.includes('widget-toolbar')); + assert.ok(panels.every(panel => panel.title === 'HTML Viewer')); + }); + + test('Back and Forward browse only the owning session with correct boundary states', async () => { + const first = owner('html-history-first'); + const second = owner('html-history-second'); + await show('/tmp/a/index.html', first, 'A'); + const panel = panels[0]; + assert.ok(disabled(panel, 'back') && disabled(panel, 'forward')); + await show('/tmp/b/index.html', first, 'B'); + await show('/tmp/other/index.html', second, 'Other'); + const other = panels[1].webview.html; + assert.ok(!disabled(panel, 'back') && disabled(panel, 'forward')); + assert.strictEqual(position(panel), '2 / 2'); + const roots = panel.webview.options.localResourceRoots; + assert.ok(!roots?.some(root => root.fsPath === vscode.Uri.file('/tmp/other').fsPath)); + await navigate(panel, 'back'); + assert.strictEqual(panel.webview.options.localResourceRoots, roots, 'Back must not change permissions or reload the previous document'); + assert.strictEqual(outputTitle(panel), 'A'); + assert.ok(widgetDocument(panel).includes('/tmp/a/index.html')); + assert.ok(panel.webview.options.localResourceRoots?.some(root => root.fsPath === vscode.Uri.file('/tmp/a').fsPath)); + assert.ok(disabled(panel, 'back') && !disabled(panel, 'forward')); + const firstHtml = panel.webview.html; + await navigate(panel, 'back'); + assert.strictEqual(panel.webview.html, firstHtml); + await navigate(panel, 'forward'); + assert.strictEqual(panel.webview.options.localResourceRoots, roots, 'Forward must not change permissions or reload the previous document'); + assert.strictEqual(outputTitle(panel), 'B'); + assert.ok(!disabled(panel, 'back') && disabled(panel, 'forward')); + assert.strictEqual(panels.length, 2); + assert.strictEqual(panels[1].webview.html, other); + }); + + test('new output while browsing history appends without losing existing forward entries', async () => { + const source = owner('html-history-append'); + await show('/tmp/a.html', source, 'A'); + await show('/tmp/b.html', source, 'B'); + await navigate(panels[0], 'back'); + await show('/tmp/c.html', source, 'C'); + assert.strictEqual(position(panels[0]), '3 / 3'); + await navigate(panels[0], 'back'); + assert.strictEqual(outputTitle(panels[0]), 'B'); + await navigate(panels[0], 'back'); + assert.strictEqual(outputTitle(panels[0]), 'A'); + }); + + test('removing output revokes its resource directory without affecting another session', async () => { + const source = owner('html-remove-resource-root'); + await show('/tmp/a/index.html', source, 'A'); + await show('/tmp/b/index.html', source, 'B'); + await show('/tmp/other/index.html', owner('html-resource-other'), 'Other'); + const otherRoots = panels[1].webview.options.localResourceRoots; + await remove(panels[0]); + const roots = panels[0].webview.options.localResourceRoots!; + assert.ok(roots.some(root => root.fsPath === vscode.Uri.file('/tmp/a').fsPath)); + assert.ok(!roots.some(root => root.fsPath === vscode.Uri.file('/tmp/b').fsPath)); + assert.strictEqual(panels[1].webview.options.localResourceRoots, otherRoots); + await remove(panels[0]); + assert.ok(!panels[0].webview.options.localResourceRoots?.some(root => root.fsPath === vscode.Uri.file('/tmp/a').fsPath)); + }); + + test('removing a middle output preserves other sessions and remaining Back/Forward history', async () => { + const source = owner('html-remove-middle'); + const other = owner('html-remove-other'); + await show('/tmp/a.html', source, 'A'); + await show('/tmp/b.html', source, 'B'); + await show('/tmp/c.html', source, 'C'); + await show('/tmp/other.html', other, 'Other'); + const otherHtml = panels[1].webview.html; + await navigate(panels[0], 'back'); + await remove(panels[0]); + assert.strictEqual(outputTitle(panels[0]), 'A'); + assert.strictEqual(position(panels[0]), '1 / 2'); + assert.ok(disabled(panels[0], 'back') && !disabled(panels[0], 'forward')); + assert.ok(!disabled(panels[0], 'remove')); + await navigate(panels[0], 'forward'); + assert.strictEqual(outputTitle(panels[0]), 'C'); + assert.strictEqual(panels[1].webview.html, otherHtml); + const stored = savedState.get(widgetHistoryKey) as WidgetHistory[]; + assert.deepStrictEqual(stored.find(record => record.source.sessionId === source.sessionId)?.history.map(item => item.title), ['A', 'C']); + }); + + test('removing the first and last outputs selects the remaining neighbor', async () => { + const source = owner('html-remove-boundaries'); + await show('/tmp/a.html', source, 'A'); + await show('/tmp/b.html', source, 'B'); + await show('/tmp/c.html', source, 'C'); + await navigate(panels[0], 'back'); + await navigate(panels[0], 'back'); + await remove(panels[0]); + assert.strictEqual(outputTitle(panels[0]), 'B'); + assert.strictEqual(position(panels[0]), '1 / 2'); + await navigate(panels[0], 'forward'); + await remove(panels[0]); + assert.strictEqual(outputTitle(panels[0]), 'B'); + assert.strictEqual(position(panels[0]), '1 / 1'); + assert.ok(disabled(panels[0], 'back') && disabled(panels[0], 'forward')); + }); + + test('removing the last entry clears persisted history and keeps an empty Viewer ready for new output', async () => { + const source = owner('html-remove-empty'); + await show('/tmp/a.html', source, 'A'); + await remove(panels[0]); + assert.strictEqual(panels[0].title, 'HTML Viewer'); + assert.ok(widgetDocument(panels[0]).includes('No HTML outputs in this session')); + assert.strictEqual(position(panels[0]), '0 / 0'); + assert.ok((['back', 'forward', 'remove'] as const).every(button => disabled(panels[0], button))); + assert.deepStrictEqual(savedState.get(widgetHistoryKey), []); + assert.strictEqual(read.callCount, 1, 'Empty history must not reload the deleted file'); + await remove(panels[0]); + await navigate(panels[0], 'back'); + await navigate(panels[0], 'forward'); + assert.strictEqual(read.callCount, 1); + await show('/tmp/b.html', source, 'B'); + assert.strictEqual(panels.length, 1); + assert.strictEqual(outputTitle(panels[0]), 'B'); + assert.strictEqual(position(panels[0]), '1 / 1'); + assert.ok(!disabled(panels[0], 'remove')); + }); + + test('removed entries stay removed after closing, extension-host recreation, and restoration', async () => { + const source = owner('html-remove-reload'); + await show('/tmp/a.html', source, 'A'); + await show('/tmp/b.html', source, 'B'); + await remove(panels[0]); + panels[0].dispose(); + await shutdownHtmlWidgetViewers(); + viewerManager = initializeHtmlWidgetViewers(extensionContext, viewerSessions); + await restoreHtmlViewer(source.sessionId); + assert.strictEqual(outputTitle(panels[1]), 'A'); + assert.strictEqual(position(panels[1]), '1 / 1'); + await remove(panels[1]); + panels[1].dispose(); + await shutdownHtmlWidgetViewers(); + viewerManager = initializeHtmlWidgetViewers(extensionContext, viewerSessions); + information.resetHistory(); + await restoreHtmlViewer(source.sessionId); + assert.strictEqual(panels.length, 2, 'Deleted history must not reopen a Viewer'); + sinon.assert.calledOnce(information); + }); + + test('late removal renders cannot replace or delete newer output', async () => { + const source = owner('html-remove-stale'); + await show('/tmp/a.html', source, 'A'); + await show('/tmp/b.html', source, 'B'); + const slow = deferred(); + read.onCall(2).returns(slow.promise); + const pending = remove(panels[0]); + await show('/tmp/c.html', source, 'C'); + const latest = panels[0].webview.html; + slow.resolve('
A
'); + await pending; + assert.strictEqual(panels[0].webview.html, latest); + assert.strictEqual(outputTitle(panels[0]), 'C'); + assert.strictEqual(position(panels[0]), '2 / 2'); + await navigate(panels[0], 'back'); + assert.strictEqual(outputTitle(panels[0]), 'A'); + }); + + test('late history loads and stale document navigation cannot replace newer output', async () => { + const source = owner('html-history-stale'); + await show('/tmp/a.html', source, 'A'); + await show('/tmp/b.html', source, 'B'); + const generation = Number(/data-generation="(\d+)"/.exec(panels[0].webview.html)?.[1]); + const slow = deferred(); + read.onCall(2).returns(slow.promise); + const pending = navigate(panels[0], 'back'); + await show('/tmp/c.html', source, 'C'); + const latest = panels[0].webview.html; + slow.resolve('
A
'); + await pending; + await navigate(panels[0], 'back', generation); + assert.strictEqual(panels[0].webview.html, latest); + assert.strictEqual(outputTitle(panels[0]), 'C'); + }); + + test('missing historical files show an error and Forward still returns to available output', async () => { + const source = owner('html-history-missing'); + await show('/tmp/a.html', source, 'A'); + await show('/tmp/b.html', source, 'B'); + read.onCall(2).rejects(Object.assign(new Error('Missing file'), { code: 'ENOENT' })); + await navigate(panels[0], 'back'); + assert.ok(widgetDocument(panels[0]).includes('This HTML widget could not be loaded')); + assert.ok(!disabled(panels[0], 'forward')); + await navigate(panels[0], 'forward'); + assert.ok(widgetDocument(panels[0]).includes('/tmp/b.html')); + }); + + test('closing releases the panel bridge but retains history and the last selected widget for restore', async () => { + const source = owner('html-history-close'); + await show('/tmp/a.html', source, 'A'); + await show('/tmp/b.html', source, 'B'); + await navigate(panels[0], 'back'); + panels[0].dispose(); + await restoreHtmlViewer(source.sessionId); + assert.strictEqual(panels.length, 2); + assert.strictEqual(outputTitle(panels[1]), 'A'); + assert.strictEqual(position(panels[1]), '1 / 2'); + await navigate(panels[1], 'forward'); + assert.strictEqual(outputTitle(panels[1]), 'B'); + await restoreHtmlViewer(source.sessionId); + assert.strictEqual(panels.length, 2, 'Restoration must reuse an open Viewer'); + }); + + test('confirmed session exit clears retained history even with its Viewer closed', async () => { + const source = owner('html-history-closed-exit'); + await show('/tmp/a.html', source); + panels[0].dispose(); + source.processExited = true; + information.resetHistory(); + await restoreHtmlViewer(source.sessionId); + await shutdownHtmlWidgetViewers(); + assert.strictEqual(panels.length, 1); + assert.deepStrictEqual(savedState.get(widgetHistoryKey), []); + sinon.assert.calledOnce(information); + }); + + test('new output after closing appends to retained history', async () => { + const source = owner('html-history-append-after-close'); + await show('/tmp/a.html', source, 'A'); + await show('/tmp/b.html', source, 'B'); + panels[0].dispose(); + await show('/tmp/c.html', source); + assert.strictEqual(panels.length, 2); + assert.strictEqual(position(panels[1]), '3 / 3'); + await navigate(panels[1], 'back'); + assert.strictEqual(outputTitle(panels[1]), 'B'); + }); + + test('restores persisted selection after extension-host recreation and transport reconnect', async () => { + const source = owner('html-history-reload'); + await show('/tmp/a.html', source, 'A'); + await show('/tmp/b.html', source, 'B'); + await navigate(panels[0], 'back'); + await shutdownHtmlWidgetViewers(); + session.unregisterSessionTransport(source); + viewerManager = initializeHtmlWidgetViewers(extensionContext, viewerSessions); + const reconnected = owner(source.sessionId); + reconnected.pid = source.pid; + await restoreHtmlViewer(source.sessionId); + assert.strictEqual(panels.length, 2); + assert.strictEqual(outputTitle(panels[1]), 'A'); + assert.strictEqual(position(panels[1]), '1 / 2'); + assert.ok((await info(panels[1])).includes(`R 4.6.1: ${source.pid}`)); + await navigate(panels[1], 'forward'); + assert.strictEqual(outputTitle(panels[1]), 'B'); + await show('/tmp/c.html', reconnected, 'C'); + assert.strictEqual(panels.length, 2); + assert.strictEqual(position(panels[1]), '3 / 3'); + }); + + test('reload rebinds the original tab, history selection, Info state, and exit listener', async () => { + const source = owner('html-reload-original'); + await show('/tmp/a.html', source, 'A'); + await show('/tmp/b.html', source, 'B'); + await navigate(panels[0], 'back'); + await runHtmlViewerCommand('info'); + const panel = panels[0]; + (panel.reveal as sinon.SinonStub).resetHistory(); + await reloadPanel(panel); + assert.strictEqual(panels.length, 1, 'Reload must rebind the original panel'); + assert.strictEqual(position(panel), '1 / 2'); + assert.ok(panel.webview.html.includes('/tmp/a.html')); + assert.strictEqual(panel.title, `HTML Viewer · R 4.6.1: ${source.pid}`); + assert.strictEqual((panel.reveal as sinon.SinonStub).callCount, 0); + await runHtmlViewerCommand('forward'); + assert.strictEqual(position(panel), '2 / 2'); + source.processExited = true; + assert.strictEqual(panel.title, 'HTML Viewer · R: (not attached)'); + }); + + test('reload cannot bring back an output removed from the last history entry', async () => { + const source = owner('html-reload-empty'); + await show('/tmp/a.html', source, 'A'); + const reference = panelReference(panels[0]); + await remove(panels[0]); + const reads = read.callCount; + await reloadPanel(panels[0], reference); + assert.strictEqual(position(panels[0]), '0 / 0'); + assert.ok(panels[0].webview.html.includes('No HTML outputs')); + assert.strictEqual(read.callCount, reads); + await show('/tmp/b.html', source, 'B'); + assert.strictEqual(panels.length, 1); + assert.strictEqual(position(panels[0]), '1 / 1'); + }); + + test('reload preserves an open exited viewer without restoring its closed-session history', async () => { + const source = owner('html-reload-exited'); + await show('/tmp/a.html', source, 'A'); + await runHtmlViewerCommand('info'); + source.processExited = true; + source.pid = ''; source.rVer = ''; + await reloadPanel(panels[0]); + assert.strictEqual(panels[0].title, 'HTML Viewer · R: (not attached)'); + assert.ok(panels[0].webview.html.includes('/tmp/a.html')); + assert.deepStrictEqual(savedState.get(widgetHistoryKey), []); + panels[0].dispose(); + await restoreHtmlViewer(source.sessionId); + assert.strictEqual(panels.length, 1, 'An exited process must not regain retained history'); + }); + + test('reload keeps reused session IDs with different PIDs bound to their own processes', async () => { + const original = owner('html-reload-reused'); + await show('/tmp/old.html', original, 'Old'); + await runHtmlViewerCommand('info'); + const reference = panelReference(panels[0]); + session.unregisterSessionTransport(original); + const replacement = owner(original.sessionId); + replacement.pid = '98765'; + await show('/tmp/new.html', replacement, 'New'); + await reloadPanel(panels[0], reference); + activate(panels[0]); + assert.strictEqual(panels[0].title, `HTML Viewer · R 4.6.1: ${original.pid}`); + assert.ok(panels[0].webview.html.includes('/tmp/old.html')); + assert.strictEqual(viewerManager.histories.entries.get(replacement.sessionId)?.source.pid, replacement.pid); + original.processExited = true; + assert.strictEqual(panels[0].title, 'HTML Viewer · R: (not attached)'); + assert.strictEqual(viewerManager.histories.entries.get(replacement.sessionId)?.source.pid, replacement.pid); + }); + + test('reload ignores output-supplied paths and restores standalone HTML into its original panel', async () => { + await showWebView('/tmp/standalone.html', 'Standalone', 'Two'); + const reference = { ...panelReference(panels[0]), history: [{ file: '/tmp/forged.html', title: 'Forged' }], index: 0 }; + await reloadPanel(panels[0], reference); + assert.strictEqual(panels.length, 1); + assert.ok(panels[0].webview.html.includes('/tmp/standalone.html')); + assert.ok(!panels[0].webview.html.includes('/tmp/forged.html')); + assert.strictEqual(panels[0].title, 'HTML Viewer'); + }); + + test('a pending pre-reload render cannot overwrite the rebound original panel', async () => { + const source = owner('html-reload-pending'); + await show('/tmp/a.html', source, 'A'); + const slow = deferred(); + read.onCall(1).returns(slow.promise); + const pending = show('/tmp/b.html', source, 'B'); + await reloadPanel(panels[0]); + const latest = panels[0].webview.html; + assert.ok(latest.includes('/tmp/b.html')); + slow.resolve('
Obsolete pre-reload render
'); + await pending; + assert.strictEqual(panels[0].webview.html, latest); + assert.strictEqual(position(panels[0]), '2 / 2'); + }); + + test('output arriving before deserialization merges into the original tab without leaving a duplicate viewer', async () => { + const source = owner('html-reload-notification'); + await show('/tmp/a.html', source, 'A'); + const original = panels[0]; + const reference = panelReference(original); + await shutdownHtmlWidgetViewers(true); + viewerManager = initializeHtmlWidgetViewers(extensionContext, viewerSessions); + await show('/tmp/b.html', source, 'B'); + const serializer = (vscode.window.registerWebviewPanelSerializer as sinon.SinonStub).lastCall.args[1] as vscode.WebviewPanelSerializer; + await serializer.deserializeWebviewPanel(original, reference); + activate(original); + assert.strictEqual(viewerManager.viewers.size, 1); + assert.strictEqual([...viewerManager.viewers.values()][0].panel, original); + assert.strictEqual(position(original), '2 / 2'); + assert.ok(original.webview.html.includes('/tmp/b.html')); + await navigate(original, 'back'); + assert.ok(original.webview.html.includes('/tmp/a.html')); + }); + + test('restores a detached session without attaching its Viewer to the active session', async () => { + const source = owner('html-history-detached'); + await show('/tmp/a.html', source, 'Detached widget'); + panels[0].dispose(); + session.unregisterSessionTransport(source); + const other = owner('html-history-active'); + sandbox.stub(session, 'activeSession').value(other); + await restoreHtmlViewer(source.sessionId); + assert.strictEqual(outputTitle(panels[1]), 'Detached widget'); + assert.ok((await info(panels[1])).includes(`R 4.6.1: ${source.pid}`)); + assert.ok(!(await info(panels[1])).includes(`R 4.6.1: ${other.pid}`)); + }); + + test('restoration follows the injected active session and resolves the saved process', async () => { + const first = owner('html-injected-first'); + const second = owner('html-injected-second'); + await show('/tmp/first.html', first, 'First'); + await show('/tmp/second.html', second, 'Second'); + panels.forEach(panel => { panel.dispose(); }); + const active = sandbox.stub(viewerSessions, 'getActiveSessionId').returns(first.sessionId); + const resolve = sandbox.spy(viewerSessions, 'resolveSession'); + const pick = sandbox.stub(vscode.window, 'showQuickPick'); + sandbox.stub(session, 'activeSession').value(undefined); + + await restoreHtmlViewer(); + assert.strictEqual(outputTitle(panels[2]), 'First'); + assert.strictEqual(resolve.lastCall.args[0].sessionId, first.sessionId); + assert.strictEqual(resolve.lastCall.args[0].pid, first.pid); + + active.returns(second.sessionId); + await restoreHtmlViewer(); + assert.strictEqual(outputTitle(panels[3]), 'Second'); + assert.strictEqual(resolve.lastCall.args[0].sessionId, second.sessionId); + assert.strictEqual(resolve.lastCall.args[0].pid, second.pid); + sinon.assert.notCalled(pick); + }); + + test('command chooses among retained sessions when none is active', async () => { + const first = owner('html-history-pick-first'); + const second = owner('html-history-pick-second'); + await show('/tmp/first.html', first, 'First'); + await show('/tmp/second.html', second, 'Second'); + panels.forEach(panel => { panel.dispose(); }); + session.unregisterSessionTransport(first); + session.unregisterSessionTransport(second); + const pick = sandbox.stub(vscode.window, 'showQuickPick').callsFake((items: unknown) => + Promise.resolve((items as Array)[1])); + await restoreHtmlViewer(); + sinon.assert.calledOnce(pick); + assert.strictEqual(outputTitle(panels[2]), 'Second'); + assert.ok((await info(panels[2])).includes(`R 4.6.1: ${second.pid}`)); + }); + + test('restoring unavailable HTML keeps history navigation available', async () => { + const source = owner('html-history-restore-missing'); + await show('/tmp/a.html', source, 'A'); + await show('/tmp/b.html', source, 'B'); + panels[0].dispose(); + read.onCall(2).rejects(Object.assign(new Error('Missing file'), { code: 'ENOENT' })); + await restoreHtmlViewer(source.sessionId); + assert.ok(widgetDocument(panels[1]).includes('This HTML widget could not be loaded')); + await navigate(panels[1], 'back'); + assert.strictEqual(outputTitle(panels[1]), 'A'); + const stored = savedState.get(widgetHistoryKey) as WidgetHistory[]; + assert.strictEqual(stored[0].index, 0); + }); + + test('visible session info updates immediately after process exit without reloading the widget', async () => { + const source = owner('html-history-exit'); + await show('/tmp/a.html', source); + await show('/tmp/b.html', source); + activate(panels[0]); + await runHtmlViewerCommand('info'); + const html = panels[0].webview.html; + const reads = read.callCount; + source.processExited = true; + assert.strictEqual(panels[0].title, 'HTML Viewer · R: (not attached)'); + assert.strictEqual(panels[0].webview.html, html); + assert.strictEqual(read.callCount, reads); + await navigate(panels[0], 'back'); + assert.ok(widgetDocument(panels[0]).includes('/tmp/a.html')); + assert.strictEqual(panels[0].title, 'HTML Viewer · R: (not attached)'); + await runHtmlViewerCommand('info'); + assert.strictEqual(panels[0].title, 'HTML Viewer'); + assert.strictEqual(await info(panels[0]), 'R: (not attached)'); + }); + + test('process exit respects hidden Info and updates only its originating background tab', async () => { + const source = owner('html-background-exit'); + await show('/tmp/a.html', source); + activate(panels[0]); + await runHtmlViewerCommand('info'); + const other = owner('html-background-alive'); + await show('/tmp/other.html', other); + await runHtmlViewerCommand('info'); + source.processExited = true; + assert.strictEqual(panels[0].title, 'HTML Viewer · R: (not attached)'); + assert.strictEqual(panels[1].title, `HTML Viewer · R 4.6.1: ${other.pid}`); + await runHtmlViewerCommand('info'); + other.processExited = true; + assert.strictEqual(panels[1].title, 'HTML Viewer'); + assert.strictEqual(await info(panels[1]), 'R: (not attached)'); + }); + + test('closing a panel releases its title exit subscription', async () => { + const source = owner('html-title-exit-dispose'); + const context = session.getViewerSessionContext(source.sessionId)!; + const observe = sandbox.spy(context, 'observeExit'); + await showWebView('/tmp/a.html', 'A', 'Two', context); + const dispose = sandbox.spy(observe.lastCall.returnValue, 'dispose'); + panels[0].dispose(); + sinon.assert.calledOnce(dispose); + }); + + test('full widget documents retain scripts, styles, and authored titles without an iframe', async () => { + const source = owner('html-history-document'); + read.resolves('Authored & original
Widget
'); + await show('/tmp/widget/index.html', source, ''); + const document = widgetDocument(panels[0]); + assert.ok(document.startsWith('')); + assert.ok(document.includes('')); + assert.ok(document.includes('src="lib/widget.js"')); + assert.ok(document.includes('/dist/webviews/webview/index.js')); + assert.ok(document.includes('Authored & original')); + assert.ok(!document.includes('<Widget "title">')); + assert.strictEqual(panels[0].title, 'HTML Viewer'); + }); + + test('viewer resources are inserted without rewriting the original HTML source', async () => { + const original = ` + Original title + Accessible chart + + `; + read.resolves(original); + await showWebView('/tmp/document.html', 'History title', 'Two'); + const rendered = panels[0].webview.html; + const headStart = original.indexOf('') + ''.length; + const headEnd = rendered.indexOf('\n Original title', headStart); + const scriptStart = rendered.lastIndexOf(''.length; + assert.ok(rendered.slice(headStart, headEnd).startsWith(' { + await showWebView('/tmp/fragment.html', 'History title', 'Two'); + assert.ok(panels[0].webview.html.includes('
/tmp/fragment.html
')); + assert.ok(!panels[0].webview.html.includes('')); + }); + + test('local authored bases resolve against the HTML file and allow their resource directories', async () => { + const file = path.resolve('/tmp/output/index.html'); + for (const fixture of [ + { href: 'assets/', target: '/tmp/output/assets/', root: '/tmp/output/assets' }, + { href: '', target: '/tmp/output/index.html', root: '/tmp/output' }, + { href: vscode.Uri.file(path.resolve('/tmp/output/assets.html')).toString(), target: '/tmp/output/assets.html', root: '/tmp/output' }, + { href: 'data:text/plain,ignored', target: '/tmp/output/index.html', root: '/tmp/output' }, + { href: 'https://[', target: '/tmp/output/index.html', root: '/tmp/output' }, + ]) { + read.resolves(`<!doctype html><html><head><base target="_self"><base HREF='${fixture.href}' data-authored="yes"><base href="ignored/"></head><body>Output</body></html>`); + await showWebView(file, 'Authored base', 'Two'); + const panel = panels[panels.length - 1]; + let expected = vscode.Uri.file(path.resolve(fixture.target)).toString(); + if (fixture.target.endsWith('/') && !expected.endsWith('/')) { expected += '/'; } + assert.ok(panel.webview.html.includes(`<base href="${expected}" data-authored="yes">`), fixture.href); + assert.ok(panel.webview.html.includes('<base target="_self">')); + assert.ok(panel.webview.html.includes('<base href="ignored/">')); + assert.strictEqual((panel.webview.html.match(/<base /g) ?? []).length, 3); + assert.ok(panel.webview.options.localResourceRoots?.some(uri => uri.fsPath === vscode.Uri.file(path.resolve(fixture.root)).fsPath), fixture.href); + } + }); + + test('authored bases cannot grant access to the filesystem root or unrelated directories', async () => { + const file = path.resolve('/tmp/output/index.html'); + for (const href of ['/', '../', '../../', '../output-other/', '../shared%20assets/', + vscode.Uri.file(path.resolve('/tmp/external/')).toString() + '/']) { + read.resolves(`<html><head><base href="${href}"></head><body>Output</body></html>`); + await showWebView(file, 'Untrusted base', 'Two'); + assert.deepStrictEqual(panels[panels.length - 1].webview.options.localResourceRoots?.map(uri => uri.fsPath), [ + vscode.Uri.file(path.dirname(file)).fsPath, + vscode.Uri.file(path.join(extensionContext.extensionPath, 'dist/webviews/webview')).fsPath, + ], href); + } + }); + + test('shared authored dependencies require a trusted workspace containing their directory', async () => { + workspaceRoots = ['/tmp']; + for (const trusted of [false, true]) { + trustedWorkspace = trusted; + read.resolves('<html><head><base href="../shared%20assets/"></head><body>Output</body></html>'); + await showWebView(path.resolve('/tmp/output/index.html'), 'Shared dependencies', 'Two'); + const roots = panels[panels.length - 1].webview.options.localResourceRoots!; + assert.strictEqual(roots.some(uri => uri.fsPath === vscode.Uri.file(path.resolve('/tmp/shared assets')).fsPath), trusted); + } + trustedWorkspace = true; + workspaceRoots = ['/tmp/output']; + await showWebView(path.resolve('/tmp/output/index.html'), 'Outside trusted workspace', 'Two'); + assert.strictEqual(panels[panels.length - 1].webview.options.localResourceRoots?.length, 2); + }); + + test('authored local bases use webview URIs while resource roots retain file URIs', async () => { + trustedWorkspace = true; + workspaceRoots = ['/tmp']; + const source = owner('html-authored-base-uri'); + const file = path.resolve('/tmp/output/index.html'); + await show(file, source); + sandbox.stub(panels[0].webview, 'asWebviewUri').callsFake(uri => uri.with({ scheme: 'https', authority: 'widget-local.test' })); + read.resolves('<html><head><base href="../shared%20assets/" target="_self"></head><body>Output</body></html>'); + await show(file, source); + const expected = vscode.Uri.file(path.resolve('/tmp/shared assets')).with({ scheme: 'https', authority: 'widget-local.test' }).toString() + '/'; + assert.ok(panels[0].webview.html.includes(`<base href="${expected}" target="_self">`)); + assert.ok(panels[0].webview.options.localResourceRoots?.some(uri => uri.scheme === 'file' && uri.fsPath === vscode.Uri.file(path.resolve('/tmp/shared assets')).fsPath)); + }); + + test('retained authored resource roots are rechecked when workspace trust changes', async () => { + const source = owner('html-authored-base-trust'); + trustedWorkspace = true; + workspaceRoots = ['/tmp']; + read.resolves('<html><head><base href="../shared assets/"></head><body>Output</body></html>'); + await show('/tmp/output/index.html', source); + const shared = vscode.Uri.file(path.resolve('/tmp/shared assets')).fsPath; + assert.ok(panels[0].webview.options.localResourceRoots?.some(root => root.fsPath === shared)); + trustedWorkspace = false; + read.resolves('<html><body>Next output</body></html>'); + await show('/tmp/next/index.html', source); + assert.ok(!panels[0].webview.options.localResourceRoots?.some(root => root.fsPath === shared)); + await navigate(panels[0], 'back'); + assert.ok(!panels[0].webview.options.localResourceRoots?.some(root => root.fsPath === shared)); + }); + + test('absolute web bases and their authored attributes are preserved', async () => { + const original = "<base HREF='https://cdn.example.test/assets/?v=1&mode=all' target='_self' data-authored='yes'>"; + read.resolves(`<html><head>${original}</head><body>Output</body></html>`); + await showWebView('/tmp/output/index.html', 'Remote base', 'Two'); + assert.ok(panels[0].webview.html.includes(original)); + assert.strictEqual((panels[0].webview.html.match(/<base /g) ?? []).length, 1); + assert.strictEqual(panels[0].webview.options.localResourceRoots?.length, 2); + }); + + test('bases in templates, SVGs, comments, and script strings do not suppress the directory base', async () => { + const content = `<!-- <base href="comment/"> --><template><base href="template/"></template> + <svg><base href="svg/"></base></svg><script>const example = '<base href="script/">';</script>`; + read.resolves(`<html><head><base target="_self"></head><body>${content}</body></html>`); + await showWebView('/tmp/output/index.html', 'No effective base', 'Two'); + assert.ok(panels[0].webview.html.includes('<base href="file:///tmp/output/">')); + assert.ok(panels[0].webview.html.includes(content)); + assert.ok(panels[0].webview.html.includes('<base target="_self">')); + }); + + test('native commands target the active HTML panel and clear enablement when focus leaves it', async () => { + const first = owner('native-first'); + await show('/tmp/a.html', first, 'A'); + await show('/tmp/b.html', first, 'B'); + await show('/tmp/other.html', owner('native-other'), 'Other'); + assert.ok(disabled(panels[1], 'back')); + await runHtmlViewerCommand('back'); + assert.strictEqual(outputTitle(panels[0]), 'B'); + assert.strictEqual(outputTitle(panels[1]), 'Other'); + activate(panels[0]); + assert.strictEqual(contexts.get('r.htmlViewer.canGoBack'), true); + await runHtmlViewerCommand('back'); + assert.strictEqual(outputTitle(panels[0]), 'A'); + assert.strictEqual(outputTitle(panels[1]), 'Other'); + assert.strictEqual(contexts.get('r.htmlViewer.canGoBack'), false); + assert.strictEqual(contexts.get('r.htmlViewer.canGoForward'), true); + activate(); + assert.ok([...contexts.values()].every(value => value === false)); + await runHtmlViewerCommand('remove'); + assert.strictEqual(position(panels[0]), '1 / 2'); + }); + + test('native actions are disabled while rendering and repeated actions cannot skip outputs', async () => { + const source = owner('native-loading'); + await show('/tmp/a.html', source, 'A'); + await show('/tmp/b.html', source, 'B'); + const slow = deferred<string>(); + autoLoad = false; + const previousGeneration = Number(/data-generation="(\d+)"/.exec(panels[0].webview.html)?.[1]); + read.onCall(2).returns(slow.promise); + activate(panels[0]); + const pending = runHtmlViewerCommand('back'); + assert.ok((['back', 'forward', 'remove'] as const).every(button => disabled(panels[0], button))); + assert.strictEqual(contexts.get('r.htmlViewer.canShowInfo'), true); + await runHtmlViewerCommand('remove'); + await runHtmlViewerCommand('forward'); + slow.resolve('<div>/tmp/a.html</div>'); + await pending; + assert.strictEqual(position(panels[0]), '1 / 2'); + assert.strictEqual(outputTitle(panels[0]), 'A'); + assert.ok((['back', 'forward', 'remove'] as const).every(button => disabled(panels[0], button)), + 'Sending HTML does not mean the browser has loaded it'); + await receivers.get(panels[0])!({ message: 'widget/loaded', generation: previousGeneration }); + assert.strictEqual(contexts.get('r.htmlViewer.canGoForward'), false, 'Ignore readiness from the previous document'); + await runHtmlViewerCommand('forward'); + assert.strictEqual(position(panels[0]), '1 / 2', 'Ignore clicks until the replacement has loaded'); + const generation = Number(/data-generation="(\d+)"/.exec(panels[0].webview.html)?.[1]); + await receivers.get(panels[0])!({ message: 'widget/loaded', generation }); + assert.strictEqual(contexts.get('r.htmlViewer.canGoForward'), true); + }); + + test('missing acknowledgements unlock navigation after five seconds and late readiness clears progress', async () => { + const clock = sandbox.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + const source = owner('html-load-timeout'); + await show('/tmp/a.html', source, 'A'); + autoLoad = false; + read.onCall(1).resolves('<html><head><meta http-equiv="Content-Security-Policy" content="script-src \'none\'"></head><body>Blocked acknowledgement</body></html>'); + await show('/tmp/b.html', source, 'B'); + const panel = panels[0]; + const html = panel.webview.html; + assert.strictEqual(loadingStatus.text, '$(loading~spin) HTML Viewer: Loading HTML output…'); + clock.tick(4999); + assert.ok((['back', 'forward', 'remove'] as const).every(button => disabled(panel, button))); + clock.tick(1); + assert.strictEqual(disabled(panel, 'back'), false); + assert.strictEqual(disabled(panel, 'remove'), false); + assert.strictEqual(panel.webview.html, html, 'Timeout must preserve the rendered content'); + assert.strictEqual(loadingStatus.text, '$(loading~spin) HTML Viewer: Still loading. Please wait.'); + assert.strictEqual(statusVisible, true, 'Timeout releases navigation without claiming loading completed'); + const generation = Number(/data-generation="(\d+)"/.exec(html)?.[1]); + await receivers.get(panel)!({ message: 'widget/loaded', generation }); + assert.strictEqual(statusVisible, false, 'Readiness after the timeout must still dismiss progress'); + assert.strictEqual(clock.countTimers(), 0); + await navigate(panel, 'back'); + clock.tick(5000); + assert.strictEqual(disabled(panel, 'forward'), false); + autoLoad = true; + await navigate(panel, 'forward'); + assert.strictEqual(position(panel), '2 / 2'); + await remove(panel); + assert.strictEqual(position(panel), '1 / 1'); + }); + + test('ten seconds replaces the spinner with a static message until late readiness or closing', async () => { + const clock = sandbox.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + autoLoad = false; + await show('/tmp/a.html', owner('html-unconfirmed'), 'A'); + const panel = panels[0]; + const html = panel.webview.html; + clock.tick(9999); + assert.strictEqual(loadingStatus.text, '$(loading~spin) HTML Viewer: Still loading. Please wait.'); + clock.tick(1); + assert.strictEqual(loadingStatus.text, 'HTML Viewer: Load not confirmed. Please wait or retry.'); + assert.strictEqual(loadingStatus.command, undefined); + assert.strictEqual(statusVisible, true); + assert.strictEqual(panel.webview.html, html); + assert.strictEqual(disabled(panel, 'remove'), false); + clock.tick(60000); + assert.strictEqual(clock.countTimers(), 0); + assert.strictEqual(loadingStatus.text, 'HTML Viewer: Load not confirmed. Please wait or retry.'); + const generation = Number(/data-generation="(\d+)"/.exec(html)?.[1]); + await receivers.get(panel)!({ message: 'widget/loaded', generation }); + assert.strictEqual(statusVisible, false); + await show('/tmp/b.html', owners[0], 'B'); + clock.tick(10000); + panel.dispose(); + assert.strictEqual(statusVisible, false); + }); + + test('only the active viewer shows status and output metadata remains plain text', async () => { + const clock = sandbox.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + autoLoad = false; + const first = owner('html-status-first'); + await show('/tmp/a.html', first, '[run](command:evil)'); + clock.tick(10000); + const firstTooltip = loadingStatus.tooltip; + assert.strictEqual(typeof firstTooltip, 'string'); + assert.ok((firstTooltip as string).includes(first.pid)); + assert.ok((firstTooltip as string).includes('[run](command:evil)')); + assert.strictEqual(loadingStatus.command, undefined); + await show('/tmp/b.html', owner('html-status-second'), 'B'); + const secondTooltip = loadingStatus.tooltip; + assert.ok((secondTooltip as string).includes('/tmp/b.html')); + clock.tick(10000); + activate(panels[0]); + assert.strictEqual(loadingStatus.tooltip, firstTooltip); + activate(); + assert.strictEqual(statusVisible, false); + activate(panels[1]); + assert.strictEqual(loadingStatus.tooltip, secondTooltip); + assert.strictEqual(statusVisible, true); + }); + + for (const preparationTime of [4500, 10000]) { + test(`a ${preparationTime}ms HTML read keeps navigation disabled and leaves five seconds for browser loading`, async () => { + const clock = sandbox.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + const source = owner('html-read-timeout'); + await show('/tmp/a.html', source, 'A'); + const previous = panels[0].webview.html; + const slow = deferred<string>(); + autoLoad = false; + read.onCall(1).returns(slow.promise); + const pending = show('/tmp/b.html', source, 'B'); + clock.tick(preparationTime); + assert.strictEqual(panels[0].webview.html, previous); + assert.ok((['back', 'forward', 'remove'] as const).every(button => disabled(panels[0], button))); + assert.strictEqual(loadingStatus.text, '$(loading~spin) HTML Viewer: Loading HTML output…'); + assert.strictEqual(clock.countTimers(), 0, 'Preparation must not start the browser timeout'); + await navigate(panels[0], 'back'); + await remove(panels[0]); + assert.strictEqual(position(panels[0]), '2 / 2'); + slow.resolve('<div>/tmp/b.html</div>'); + await pending; + assert.strictEqual(outputTitle(panels[0]), 'B'); + clock.tick(4999); + assert.ok((['back', 'forward', 'remove'] as const).every(button => disabled(panels[0], button))); + clock.tick(1); + assert.strictEqual(disabled(panels[0], 'back'), false); + assert.strictEqual(loadingStatus.text, '$(loading~spin) HTML Viewer: Still loading. Please wait.'); + autoLoad = true; + await navigate(panels[0], 'back'); + assert.strictEqual(outputTitle(panels[0]), 'A'); + assert.strictEqual(statusVisible, false); + assert.strictEqual(clock.countTimers(), 0); + }); + } + + test('superseded loads get their own deadline and stale readiness cannot dismiss current progress', async () => { + const clock = sandbox.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + const source = owner('html-timeout-generation'); + autoLoad = false; + await show('/tmp/a.html', source, 'A'); + const generation = Number(/data-generation="(\d+)"/.exec(panels[0].webview.html)?.[1]); + clock.tick(4000); + await show('/tmp/b.html', source, 'B'); + clock.tick(1000); + await receivers.get(panels[0])!({ message: 'widget/loaded', generation }); + assert.strictEqual(loadingStatus.text, '$(loading~spin) HTML Viewer: Loading HTML output…'); + assert.strictEqual(disabled(panels[0], 'back'), true); + clock.tick(4000); + assert.strictEqual(disabled(panels[0], 'back'), false); + panels[0].dispose(); + assert.strictEqual(statusVisible, false); + assert.strictEqual(clock.countTimers(), 0); + }); + + test('successful loads and definitive file failures stop progress and cancel the timeout', async () => { + const clock = sandbox.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + const source = owner('html-load-progress'); + await show('/tmp/a.html', source, 'A'); + assert.strictEqual(statusVisible, false); + autoLoad = false; + read.onCall(1).rejects(new Error('missing file')); + await show('/tmp/missing.html', source, 'Missing'); + assert.ok(widgetDocument(panels[0]).includes('This HTML widget could not be loaded')); + assert.strictEqual(disabled(panels[0], 'back'), false); + assert.strictEqual(statusVisible, false); + assert.strictEqual(clock.countTimers(), 0); + read.onCall(2).rejects(new Error('unowned file failure')); + await assert.rejects(showWebView('/tmp/unowned.html', 'Unowned', 'Two'), /unowned file failure/); + assert.strictEqual(statusVisible, false); + assert.strictEqual(clock.countTimers(), 0); + }); + + test('disposing a viewer or shutting down during loading cancels progress and its timer', async () => { + const clock = sandbox.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + autoLoad = false; + await show('/tmp/a.html', owner('html-dispose-progress'), 'A'); + panels[0].dispose(); + assert.strictEqual(statusVisible, false); + assert.strictEqual(clock.countTimers(), 0); + await show('/tmp/b.html', owner('html-shutdown-progress'), 'B'); + await shutdownHtmlWidgetViewers(true); + assert.strictEqual(statusVisible, false); + sinon.assert.calledOnce(disposeLoadingStatus); + assert.strictEqual(clock.countTimers(), 0); + }); + + test('late load acknowledgements cannot enable navigation for a newer document', async () => { + const source = owner('html-loading-generation'); + await show('/tmp/a.html', source, 'A'); + await show('/tmp/b.html', source, 'B'); + autoLoad = false; + await navigate(panels[0], 'back'); + const previousGeneration = Number(/data-generation="(\d+)"/.exec(panels[0].webview.html)?.[1]); + await show('/tmp/c.html', source, 'C'); + await receivers.get(panels[0])!({ message: 'widget/loaded', generation: previousGeneration }); + assert.ok((['back', 'forward', 'remove'] as const).every(button => disabled(panels[0], button))); + await navigate(panels[0], 'back'); + assert.strictEqual(outputTitle(panels[0]), 'C'); + const generation = Number(/data-generation="(\d+)"/.exec(panels[0].webview.html)?.[1]); + await receivers.get(panels[0])!({ message: 'widget/loaded', generation }); + assert.strictEqual(contexts.get('r.htmlViewer.canGoBack'), true); + assert.strictEqual(contexts.get('r.htmlViewer.canGoForward'), false); + }); + + test('Info contains only the originating R version and PID without a popup or output reload', async () => { + const source = owner('native-info-source'); + await show('/tmp/a.html', source, 'A'); + await show('/tmp/other.html', owner('native-info-other'), 'Other'); + const html = panels[0].webview.html; + let details = await info(panels[0]); + assert.strictEqual(details, `R 4.6.1: ${source.pid}`); + source.processExited = true; + details = await info(panels[0]); + assert.strictEqual(details, 'R: (not attached)'); + sinon.assert.notCalled(information); + assert.strictEqual(panels[0].webview.html, html); + }); + + test('Info toggles each tab independently and stays visible through navigation and new output', async () => { + const source = owner('native-info-toggle'); + await show('/tmp/a.html', source, 'A'); + await show('/tmp/b.html', source, 'B'); + await show('/tmp/other.html', owner('native-info-toggle-other'), 'Other'); + const panel = panels[0]; + activate(panel); + const html = panel.webview.html; + const reads = read.callCount; + await runHtmlViewerCommand('info'); + const title = `HTML Viewer · R 4.6.1: ${source.pid}`; + assert.strictEqual(panel.title, title); + assert.strictEqual(panels[1].title, 'HTML Viewer'); + assert.strictEqual(panel.webview.html, html); + assert.strictEqual(read.callCount, reads); + await runHtmlViewerCommand('back'); + assert.strictEqual(panel.title, title); + await show('/tmp/c.html', source, 'C'); + assert.strictEqual(panel.title, title); + await runHtmlViewerCommand('remove'); + assert.strictEqual(panel.title, title); + await runHtmlViewerCommand('info'); + assert.strictEqual(panel.title, 'HTML Viewer'); + sinon.assert.notCalled(information); + }); + + test('unowned output disables history and Info actions without changing its title', async () => { + await showWebView('/tmp/unowned.html', 'Unowned', 'Two'); + activate(panels[0]); + assert.ok([...contexts.values()].every(value => value === false)); + assert.strictEqual(await info(panels[0]), ''); + sinon.assert.notCalled(information); + const html = panels[0].webview.html; + await runHtmlViewerCommand('remove'); + assert.strictEqual(panels[0].webview.html, html); + }); + + test('native toolbar contributes four HTML-specific commands with distinct icons and ordering', () => { + const manifest = JSON.parse(readFileSync(path.resolve(__dirname, '../../../package.json'), 'utf8')) as { + contributes: { + menus: { 'editor/title': Array<{ command?: string; when: string; group: string }> }; + commands: Array<{ command: string; title: string; icon: string }>; + }; + }; + const entries = manifest.contributes.menus['editor/title'].filter(item => item.command?.startsWith('r.htmlViewer.')); + assert.deepStrictEqual(entries.map(item => item.command), + ['r.htmlViewer.back', 'r.htmlViewer.forward', 'r.htmlViewer.remove', 'r.htmlViewer.info']); + assert.strictEqual(manifest.contributes.commands.find(command => command.command === 'r.htmlViewer.info')?.title, 'Toggle Session Information in Tab Title'); + for (const [index, item] of entries.entries()) { + assert.strictEqual(item.when, "activeWebviewPanelId == 'r.htmlViewer'"); + assert.strictEqual(item.group, index < 2 ? `navigation@${index + 1}` : `htmlViewer@${index - 1}`); + const command = manifest.contributes.commands.find(command => command.command === item.command)!; + if (item.command !== 'r.htmlViewer.info') { assert.ok(command.title.includes('HTML')); } + assert.ok(['$(arrow-circle-left)', '$(arrow-circle-right)', '$(trash)', '$(info)'].includes(command.icon)); + } + }); + + test('history is bounded to the latest 50 outputs', async () => { + const source = owner('html-history-limit'); + for (let i = 0; i < 51; i++) { await show(`/tmp/widget-${i}/index.html`, source, `Widget ${i}`); } + assert.strictEqual(position(panels[0]), '50 / 50'); + assert.ok(!panels[0].webview.options.localResourceRoots?.some(root => root.fsPath === vscode.Uri.file('/tmp/widget-0').fsPath)); + assert.strictEqual(panels[0].webview.options.localResourceRoots?.length, 51, 'Only 50 output directories and viewer assets remain'); + for (let i = 0; i < 49; i++) { await navigate(panels[0], 'back'); } + assert.strictEqual(outputTitle(panels[0]), 'Widget 1'); + assert.ok(disabled(panels[0], 'back')); + }); +}); diff --git a/src/test/suite/interactiveEditor.test.ts b/src/test/suite/interactiveEditor.test.ts index 79716b2b0..ea094ed75 100644 --- a/src/test/suite/interactiveEditor.test.ts +++ b/src/test/suite/interactiveEditor.test.ts @@ -2,6 +2,7 @@ import * as assert from 'assert'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; +import * as net from 'net'; import * as vscode from 'vscode'; import * as sinon from 'sinon'; import { execFile } from 'child_process'; @@ -18,7 +19,7 @@ import { shellQuote } from '../../interactive/launcher'; import type { InteractiveManager } from '../../interactive/manager'; import type { GlobalEnvItem, WorkspaceDataProvider } from '../../workspaceViewer'; import type { WorkspaceData } from '../../session'; -import type { LanguageClient } from 'vscode-languageclient/node'; +import { LanguageClient, State, StreamMessageReader, StreamMessageWriter, createMessageConnection } from 'vscode-languageclient/node'; function treeTooltip(item: vscode.TreeItem): string { return typeof item.tooltip === 'string' ? item.tooltip : item.tooltip?.value ?? ''; @@ -35,7 +36,10 @@ function treeTooltip(item: vscode.TreeItem): string { let agents: SessionAgent[]; let manifests: SessionManifest[]; let controller: vscode.NotebookController; + let originalTabs: Set<vscode.Tab>; + let originalLanguageClients: Set<LanguageClient>; suiteSetup(async () => { + originalTabs = new Set(vscode.window.tabGroups.all.flatMap(group => group.tabs)); // Match R's cwd and temporary root on macOS as well as Linux. Symlinked // /var or /tmp paths otherwise hide languageserver's temp-file filter. previousTmpdir = process.env.TMPDIR; @@ -51,6 +55,7 @@ function treeTooltip(item: vscode.TreeItem): string { fs.cpSync(path.join(process.cwd(), 'sess'), path.join(root, 'sess'), { recursive: true }); await promisify(execFile)('R', ['CMD', 'INSTALL', '--clean', `--library=${path.join(root, 'library')}`, path.join(root, 'sess')]); await vscode.extensions.getExtension('REditorSupport.r')?.activate(); + originalLanguageClients = new Set(languageService().clients.values()); // Startup restoration may activate R before this suite configures its private // registry. Recreate only the manager, just as a host reload would do. const context = bundleContext(); @@ -78,6 +83,23 @@ function treeTooltip(item: vscode.TreeItem): string { await vscode.workspace.getConfiguration('r').update('interactive.storagePath', previousStorage, vscode.ConfigurationTarget.Global); await bundleContext().workspaceState.update('r.interactive.connections', previousConnections); await Promise.all(agents?.map(agent => agent.close()) ?? []); + const service = languageService(); + await Promise.all(service.clientUpdates.values()); + const clients = new Set([...service.clients.values()].filter(client => !originalLanguageClients.has(client))); + // Await shutdown before tab-close handlers can start an unobserved stop. + await Promise.all([...clients].map(stopLanguageClient)); + // Detach retains editors. Close this suite's tabs and await its language + // servers before deleting directories that those R processes use as cwd. + await vscode.window.tabGroups.close(vscode.window.tabGroups.all.flatMap(group => group.tabs) + .filter(tab => !originalTabs.has(tab)), true); + await Promise.all(service.clientUpdates.values()); + for (const [key, client] of service.clients) { + if (!originalLanguageClients.has(client)) { + clients.add(client); + service.clients.delete(key); + } + } + await Promise.all([...clients].map(stopLanguageClient)); await new Promise(resolve => setTimeout(resolve, 200)); fs.rmSync(root, { recursive: true, force: true }); sourceDirectories.forEach(directory => fs.rmSync(directory, { recursive: true, force: true })); @@ -92,8 +114,23 @@ function treeTooltip(item: vscode.TreeItem): string { function bundleContext(): vscode.ExtensionContext { return (createRequire(__filename)(path.join(process.cwd(), 'dist/extension')) as { extensionContext: vscode.ExtensionContext }).extensionContext; } + function languageService(): { clients: Map<string, LanguageClient>; clientUpdates: Map<string, Promise<void>> } { + const service = bundleContext().subscriptions.find(item => + (item as { clients?: unknown }).clients instanceof Map) as ReturnType<typeof languageService> | undefined; + assert.ok(service, 'Language service must be registered'); + return service; + } + function interactiveManager(context = bundleContext()): InteractiveManager { + // The HTML Viewer manager also exposes open(); identify the Interactive + // manager by its session tree as well, including after it is recreated. + const manager = context.subscriptions.find(item => + typeof (item as InteractiveManager).open === 'function' + && typeof (item as InteractiveManager).getChildren === 'function') as InteractiveManager | undefined; + assert.ok(manager, 'Interactive manager must be registered'); + return manager; + } function recreateManager(context: vscode.ExtensionContext): void { - const manager = context.subscriptions.find(item => typeof (item as InteractiveManager).open === 'function') as InteractiveManager; + const manager = interactiveManager(context); assert.ok(manager); const Manager = (Object.getPrototypeOf(manager) as { constructor: typeof InteractiveManager }).constructor; manager.dispose(); context.subscriptions.splice(context.subscriptions.indexOf(manager), 1); @@ -371,7 +408,7 @@ function treeTooltip(item: vscode.TreeItem): string { assert.deepStrictEqual(selectionEvents, [], 'Presentation refresh must not deselect the execution kernel'); affinity.resetHistory(); // Repeated unchanged status updates must not continually rebuild the picker. - const manager = bundleContext().subscriptions.find(item => typeof (item as InteractiveManager).open === 'function'); + const manager = interactiveManager(); const status = manager as unknown as { updateStatus(): void }; status.updateStatus(); status.updateStatus(); await new Promise<void>(resolve => setImmediate(resolve)); @@ -432,7 +469,7 @@ function treeTooltip(item: vscode.TreeItem): string { // Load the activated bundle's context/constructor, so execution routing and // command registration use the same module instances as the real extension. const context = bundleContext(); - const manager = context.subscriptions.find(item => typeof (item as InteractiveManager).open === 'function') as InteractiveManager; + const manager = interactiveManager(context) as InteractiveManager; assert.ok(manager); const Manager = (Object.getPrototypeOf(manager) as { constructor: typeof InteractiveManager }).constructor; for (const manifest of manifests) { await vscode.commands.executeCommand('r.interactive.open', manifest); } @@ -494,7 +531,7 @@ function treeTooltip(item: vscode.TreeItem): string { } }); test('refreshes session tree connection and control details after reconnect and Take Control', async () => { - const manager = bundleContext().subscriptions.find(item => typeof (item as InteractiveManager).open === 'function') as InteractiveManager; + const manager = interactiveManager() as InteractiveManager; const view = (manager as unknown as { views: Map<string, { client: AgentClient }> }).views.get(`${manifests[0].id}:${manifests[0].generation}`); assert.ok(view); let refreshes = 0; @@ -517,7 +554,7 @@ function treeTooltip(item: vscode.TreeItem): string { }); test('observer execution consistently reports errors from native input, cells and source commands', async () => { await vscode.commands.executeCommand('r.interactive.open', manifests[0]); - const manager = bundleContext().subscriptions.find(item => typeof (item as InteractiveManager).open === 'function') as InteractiveManager; + const manager = interactiveManager() as InteractiveManager; const view = (manager as unknown as { views: Map<string, { client: AgentClient; controller: vscode.NotebookController; notebook: vscode.NotebookDocument }> }).views.get(`${manifests[0].id}:${manifests[0].generation}`); assert.ok(view); const other = new AgentClient(manifests[0]); @@ -757,9 +794,7 @@ function treeTooltip(item: vscode.TreeItem): string { } finally { sourceDirectories.push(directory); } }); test('starts each Interactive input language server in its owning session directory', async () => { - const service = bundleContext().subscriptions.find(item => - (item as { clients?: unknown }).clients instanceof Map) as { clients: Map<string, LanguageClient> } | undefined; - assert.ok(service); + const service = languageService(); try { for (const manifest of manifests) { await vscode.commands.executeCommand('r.interactive.open', manifest); @@ -1171,7 +1206,7 @@ cat("\n")`; const plain = report.cells.at(-1)?.outputs?.flatMap(output => output.items).find(item => item.mime === 'text/plain'); assert.ok(plain); assert.strictEqual(Buffer.from(plain.data).toString(), table.printedText); const context = bundleContext(); - const manager = context.subscriptions.find(item => typeof (item as InteractiveManager).open === 'function') as unknown as { + const manager = interactiveManager(context) as unknown as { rendererMessage(editor: vscode.NotebookEditor, message: Record<string, unknown>): Promise<void>; }; const editor = vscode.window.visibleNotebookEditors.find(editor => editor.notebook === notebook); assert.ok(editor); @@ -1203,7 +1238,7 @@ cat("\n")`; const plain = notebook.cellAt(index).outputs.flatMap(output => output.items).find(item => item.mime === 'text/plain'); assert.ok(plain); assert.strictEqual(Buffer.from(plain.data).toString(), list.printedText); const context = bundleContext(); - const manager = context.subscriptions.find(item => typeof (item as InteractiveManager).open === 'function') as unknown as { + const manager = interactiveManager(context) as unknown as { messages: vscode.NotebookRendererMessaging; rendererMessage(editor: vscode.NotebookEditor, message: Record<string, unknown>): Promise<void>; }; @@ -1286,7 +1321,7 @@ cat("\n")`; try { await vscode.commands.executeCommand('r.interactive.export', notebook.uri); assert.ok(fs.readFileSync(file, 'utf8').includes('Snapshot: first 1,000 of 832,976,871 rows')); - const manager = bundleContext().subscriptions.find(item => typeof (item as InteractiveManager).open === 'function') as unknown as { + const manager = interactiveManager() as unknown as { messages: vscode.NotebookRendererMessaging; rendererMessage(editor: vscode.NotebookEditor, message: Record<string, unknown>): Promise<void>; }; @@ -1332,7 +1367,7 @@ par(mfrow=c(1,1))`); const gallery = displays().find(display => display.kind === 'plot'); assert.ok(gallery); const pages = gallery.pages as Record<string, unknown>[]; const context = bundleContext(); - const manager = context.subscriptions.find(item => typeof (item as InteractiveManager).open === 'function') as unknown as { + const manager = interactiveManager(context) as unknown as { rendererMessage(editor: vscode.NotebookEditor, message: Record<string, unknown>): Promise<void>; }; const editor = vscode.window.visibleNotebookEditors.find(editor => editor.notebook === notebook); assert.ok(editor); @@ -1970,7 +2005,7 @@ par(mfrow=c(1,1))`); assert.strictEqual(bulk[3].status, 'idle'); assert.strictEqual((await other.request<{ control: boolean }>('heartbeat')).control, true); assert.match(confirm.lastCall.args[0], /Stopped 1 of 2.*1 could not be stopped/); - const manager = bundleContext().subscriptions.find(item => typeof (item as InteractiveManager).open === 'function') as InteractiveManager; + const manager = interactiveManager() as InteractiveManager; assert.ok(!manager.getChildren().some(manifest => manifest.id === bulk[1].id), 'Stopped unopened sessions leave no dead tree link'); assert.ok(manager.getChildren().some(manifest => manifest.id === bulk[0].id), 'An open stopped transcript stays in the tree'); assert.strictEqual(vscode.workspace.notebookDocuments.length, windowCount); @@ -2003,7 +2038,7 @@ par(mfrow=c(1,1))`); }); }); test('a stale saved URI cannot reconnect one session into another session window', async () => { - const manager = bundleContext().subscriptions.find(item => typeof (item as InteractiveManager).open === 'function') as InteractiveManager; + const manager = interactiveManager() as InteractiveManager; const saved = (manager as unknown as { savedConnections: Map<string, { id: string; notebookUri?: string }> }).savedConnections; const temporary: { agent: SessionAgent; manifest: SessionManifest }[] = []; const before = new Set(vscode.window.tabGroups.all.flatMap(group => group.tabs)); @@ -2062,3 +2097,72 @@ par(mfrow=c(1,1))`); }); }); + +async function stopLanguageClient(client: LanguageClient): Promise<void> { + try { + await client.stop(); + } catch (error) { + // R's language server can close on shutdown before the client's exit + // notification is written. The client has already completed cleanup. + if (client.state === State.Stopped && error instanceof Error && 'code' in error && error.code === 'EPIPE') { return; } + throw error; + } +} + +suite('Interactive language client cleanup', () => { + test('accepts a completed shutdown when the server closes before the exit notification', async () => { + const connections: Array<{ dispose(): void }> = []; + let serverSocket: net.Socket | undefined; + const server = net.createServer(socket => { + serverSocket = socket; + const connection = createMessageConnection(new StreamMessageReader(socket), new StreamMessageWriter(socket)); + connections.push(connection); + connection.onRequest('initialize', () => ({ capabilities: {} })); + connection.onRequest('shutdown', () => null); + connection.listen(); + }); + await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve)); + const socket = net.connect((server.address() as net.AddressInfo).port, '127.0.0.1'); + const ended = new Promise<void>(resolve => socket.once('end', resolve)); + const writer = new StreamMessageWriter(socket); + const write = writer.write.bind(writer); + let exitErrorCode: string | undefined; + // Force the same ordering that macOS can produce during R shutdown. + writer.write = async message => { + const isExit = 'method' in message && message.method === 'exit'; + if (isExit) { + serverSocket!.end(); + await ended; + } + try { + return await write(message); + } catch (error) { + if (isExit) { exitErrorCode = (error as NodeJS.ErrnoException).code; } + throw error; + } + }; + const channel = vscode.window.createOutputChannel('Test language client cleanup', { log: true }); + const client = new LanguageClient('cleanup-test', 'Cleanup test', () => Promise.resolve({ + reader: new StreamMessageReader(socket), writer, + }), { documentSelector: [], outputChannel: channel }); + try { + await client.start(); + await stopLanguageClient(client); + assert.strictEqual(client.state, State.Stopped); + assert.strictEqual(exitErrorCode, 'EPIPE'); + } finally { + writer.dispose(); socket.destroy(); + connections.forEach(connection => connection.dispose()); + await new Promise<void>(resolve => server.close(() => resolve())); + channel.dispose(); + } + }); + + test('does not suppress unrelated shutdown failures or an unstopped client', async () => { + const error = Object.assign(new Error('Socket shutdown failed'), { code: 'EPIPE' }); + for (const [state, failure] of [[State.Running, error], [State.Stopped, new Error('Unexpected shutdown failure')]] as const) { + const client = { state, stop: () => Promise.reject(failure) } as unknown as LanguageClient; + await assert.rejects(stopLanguageClient(client), thrown => thrown === failure); + } + }); +}); diff --git a/src/test/suite/session.test.ts b/src/test/suite/session.test.ts index 4fc0ad3ff..0933cec94 100644 --- a/src/test/suite/session.test.ts +++ b/src/test/suite/session.test.ts @@ -15,6 +15,7 @@ import * as session from '../../session'; import * as processTree from '../../processTree'; import * as extension from '../../extension'; import * as plotViewer from '../../plotViewer'; +import { initializeHtmlWidgetViewers, shutdownHtmlWidgetViewers } from '../../webViewer'; import type { RSessionApi } from '../../api'; const extension_root: string = path.join(__dirname, '..', '..', '..'); @@ -41,11 +42,17 @@ suite('Session Communication', () => { sandbox.stub(processTree, 'getProcessAncestors').resolves([]); sandbox.stub(vscode.commands, 'registerCommand'); // prevent "command already exists" error mockExtensionContext(extension_root, sandbox); + sandbox.stub(vscode.window, 'registerWebviewPanelSerializer').returns({ dispose: sandbox.stub() }); + initializeHtmlWidgetViewers(extension.extensionContext, { + resolveSession: source => session.getViewerSessionContext(source.sessionId, source)!, + getActiveSessionId: () => session.activeSession?.sessionId, + }); session.deploySessionWatcher(extension_root); sandbox.stub(extension, 'globalPlotManager').value(plotViewer.initializePlotManager()); }); teardown(async () => { + await shutdownHtmlWidgetViewers(); plotPanelCleanup?.(); plotPanelCleanup = undefined; const plotManager = extension.globalPlotManager as plotViewer.CommonPlotManager | undefined; @@ -628,9 +635,9 @@ suite('Session Communication', () => { // 4. Test webview term.sendText('tf <- tempfile(fileext=".html"); writeLines("test", tf); getOption("viewer")(tf)\n'); - await waitFor(() => createWebviewPanelSpy.calledWith('webview'), 10000, 200); + await waitFor(() => createWebviewPanelSpy.calledWith('r.htmlViewer'), 10000, 200); - assert.ok(createWebviewPanelSpy.calledWith('webview'), 'webview should be triggered for html file'); + assert.ok(createWebviewPanelSpy.calledWith('r.htmlViewer'), 'webview should be triggered for html file'); }).timeout(85000); diff --git a/src/viewerSession.ts b/src/viewerSession.ts index 8e712552a..c34f88b7f 100644 --- a/src/viewerSession.ts +++ b/src/viewerSession.ts @@ -1,14 +1,76 @@ -// Shared by the standalone list and data viewers. +import type { WebviewPanel } from 'vscode'; +import type { SessionProcessMonitor } from './sessionProcessMonitor'; + +export interface ViewerSessionSource { + sessionId: string; + host: string; + pid: string; + rVer: string; + processExited: boolean; +} + +export interface ViewerSessionContext { + readonly sessionId: string; + readonly source: Readonly<ViewerSessionSource>; + readonly hasExited: boolean; + observeExit(listener: () => void): { dispose(): void }; + getHtml(): string; + attach(panel: WebviewPanel): void; +} + +// Shared by the standalone HTML, list and data viewers. +export function createViewerSessionContext<Session extends ViewerSessionSource>( + owner: Session, monitor: SessionProcessMonitor<Session>, +): ViewerSessionContext { + return { + sessionId: owner.sessionId, + source: { sessionId: owner.sessionId, host: owner.host, pid: owner.pid, rVer: owner.rVer, processExited: owner.processExited }, + get hasExited() { return monitor.hasExited(owner); }, + observeExit: listener => monitor.observe(owner, listener), + getHtml: () => { + const exited = monitor.hasExited(owner); + if (!exited && (!owner.pid || !owner.rVer)) { return ''; } + const text = exited ? 'R: (not attached)' : formatSessionLabel(owner.rVer, owner.pid); + const info = text.replace(/[&<>"']/g, character => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', + })[character]!); + return `<span class="viewer-session" role="img" tabindex="0" aria-label="${info}" aria-describedby="viewer-session-tooltip"><span class="codicon codicon-info" aria-hidden="true"></span><span id="viewer-session-tooltip" class="viewer-session-tooltip" role="tooltip">${info}</span></span>`; + }, + attach: panel => { + if (!monitor.hasExited(owner) && (!owner.pid || !owner.rVer)) { return; } + const attachedLabel = formatSessionLabel(owner.rVer, owner.pid); + 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 }); + } + }; + // Retain the originating process identity through detach and reconnect. + const source = monitor.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(); }); + }, + }; +} + export function formatSessionLabel(version: string, pid: string): string { const normalized = version.replace(/^R (?:version )?/, '').replace(/\s+\(.*/, ''); return `R ${normalized}: ${pid}`; } export function getViewerSessionScript(): string { - return ` + return `(${initializeViewerSession.toString()})(vscode);`; +} + +export function initializeViewerSession(vscode: { postMessage?(message: { message: 'viewer-session/ready' }): unknown }): void { window.addEventListener('message', event => { - const message = event.data; - if (message?.message !== 'viewer-session/update' || typeof message.text !== 'string') { return; } + const message: unknown = event.data; + if (!message || typeof message !== 'object' || !('message' in message) || + message.message !== 'viewer-session/update' || !('text' in message) || typeof message.text !== 'string') { return; } const icon = document.querySelector('.viewer-session'); const tooltip = document.getElementById('viewer-session-tooltip'); if (icon && tooltip) { @@ -38,7 +100,6 @@ export function getViewerSessionScript(): string { } else { initializeViewerSessionInfo(); } - `; } export const viewerSessionStyle = ` diff --git a/src/webViewer/history.ts b/src/webViewer/history.ts new file mode 100644 index 000000000..fb1fb3425 --- /dev/null +++ b/src/webViewer/history.ts @@ -0,0 +1,106 @@ +import type { Memento, ViewColumn } from 'vscode'; +import type { ViewerSessionContext, ViewerSessionSource } from '../viewerSession'; +import { sessionProcessIdentity as widgetSessionIdentity } from '../sessionIdentity'; + +export { widgetSessionIdentity }; + +export const widgetHistoryKey = 'r.htmlViewer.histories'; +export const widgetHistoryLimit = 50; + +export interface WidgetHistory { + source: ViewerSessionSource; + history: Array<{ file: string; title: string }>; + index: number; + viewColumn?: ViewColumn; +} + +/** Retain output paths for a process lifetime, independently of its Viewer tab. */ +export class WidgetHistoryStore { + readonly entries = new Map<string, WidgetHistory>(); + private readonly subscriptions = new Map<string, { dispose(): void }>(); + private disposed = false; + private writes: Promise<void> = Promise.resolve(); + + constructor( + private readonly state: Memento, + resolveSession: (source: ViewerSessionSource) => ViewerSessionContext, + ) { + const saved = state.get<unknown>(widgetHistoryKey); + if (Array.isArray(saved)) { + for (const value of saved) { + if (!isHistory(value)) { continue; } + const removed = Math.max(0, value.history.length - widgetHistoryLimit); + const record = { ...value, history: value.history.slice(-widgetHistoryLimit), index: Math.max(0, value.index - removed) }; + const session = resolveSession(record.source); + if (!session.hasExited) { + this.entries.set(session.sessionId, record); + this.watch(session); + } + } + // Drop invalid records and any already-confirmed process exits. + void this.persist(); + } + } + + remember(session: ViewerSessionContext): WidgetHistory { + let record = this.entries.get(session.sessionId); + if (record && widgetSessionIdentity(record.source) !== widgetSessionIdentity(session.source)) { + this.forget(session.sessionId); + record = undefined; + } + record ??= { source: { ...session.source }, history: [], index: -1 }; + if (!session.hasExited && !this.entries.has(session.sessionId)) { + this.entries.set(session.sessionId, record); + this.watch(session); + } + return record; + } + + save(record: WidgetHistory): Promise<void> { + if (this.entries.get(record.source.sessionId) !== record) { return this.writes; } + return this.persist(); + } + + forget(sessionId: string): void { + this.subscriptions.get(sessionId)?.dispose(); + this.subscriptions.delete(sessionId); + if (this.entries.delete(sessionId)) { void this.persist(); } + } + + private watch(session: ViewerSessionContext): void { + this.subscriptions.get(session.sessionId)?.dispose(); + this.subscriptions.set(session.sessionId, session.observeExit(() => this.forget(session.sessionId))); + } + + private persist(): Promise<void> { + if (this.disposed) { return this.writes; } + // Capture each revision before queuing: later mutations must not alter it. + const saved = [...this.entries.values()].filter(record => record.history.length).map(record => ({ + ...record, source: { ...record.source }, history: record.history.map(item => ({ ...item })), + })); + this.writes = this.writes.then(() => this.state.update(widgetHistoryKey, saved)).catch(error => { + console.warn('[HTML Viewer] Could not save widget history', error); + }); + return this.writes; + } + + flush(): Promise<void> { return this.writes; } + + dispose(): void { + this.disposed = true; + this.subscriptions.forEach(subscription => subscription.dispose()); + this.subscriptions.clear(); + } +} + +function isHistory(value: unknown): value is WidgetHistory { + if (!value || typeof value !== 'object') { return false; } + const record = value as Partial<WidgetHistory>; + const source = record.source; + return Boolean(source && typeof source.sessionId === 'string' && source.sessionId && + typeof source.host === 'string' && typeof source.pid === 'string' && typeof source.rVer === 'string' && + typeof source.processExited === 'boolean' && Array.isArray(record.history) && record.history.length && + record.history.every(item => item && typeof item.file === 'string' && typeof item.title === 'string') && + Number.isInteger(record.index) && record.index! >= 0 && record.index! < record.history.length && + (record.viewColumn === undefined || Number.isInteger(record.viewColumn) && record.viewColumn >= 1 && record.viewColumn <= 9)); +} diff --git a/src/webViewer/index.ts b/src/webViewer/index.ts index c9c30172f..1b13a6edd 100644 --- a/src/webViewer/index.ts +++ b/src/webViewer/index.ts @@ -1,76 +1,589 @@ 'use strict'; import * as path from 'path'; -import { Uri, ViewColumn, Webview, window, env } from 'vscode'; -import { readContent, UriIcon } from '../util'; -import { extensionContext } from '../extension'; +import { randomUUID } from 'crypto'; +import { pathToFileURL } from 'url'; +import { readFile } from 'fs/promises'; +import { load } from 'cheerio'; +import { Disposable, ExtensionContext, StatusBarAlignment, StatusBarItem, Uri, ViewColumn, Webview, WebviewPanel, window, env, commands, workspace } from 'vscode'; +import { ViewerSessionContext, ViewerSessionSource, formatSessionLabel } from '../viewerSession'; +import { WidgetHistory, WidgetHistoryStore, widgetHistoryLimit, widgetSessionIdentity } from './history'; +import type { HtmlViewerPanelReference, HtmlViewerPanelState } from './webviewMessages'; -export async function showWebView(file: string, title: string, viewer: string | boolean): Promise<void> { - console.info(`[showWebView] file: ${file}, viewer: ${viewer.toString()}`); +export interface HtmlViewerSessionAccess { + resolveSession(source: ViewerSessionSource): ViewerSessionContext; + getActiveSessionId(): string | undefined; +} + +interface WidgetViewer { + id: string; + panel: WebviewPanel; + revision: number; + disposed: boolean; + loading: boolean; + loadStatus?: 'loading' | 'slow' | 'unconfirmed'; + finishLoading?: () => void; + showSessionInfo: boolean; + subscriptions: Array<{ dispose(): void }>; + resourceRoots: Map<string, Uri[]>; + session?: ViewerSessionContext; + state: Pick<WidgetHistory, 'history' | 'index' | 'viewColumn'>; + saved?: WidgetHistory; +} + +let manager: HtmlWidgetViewerManager | undefined; +const htmlViewerTitle = 'HTML Viewer'; +const panelStateKey = 'r.htmlViewer.panels'; + +function escapeHtml(text: string): string { + return text.replace(/[&<>"']/g, character => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', + })[character]!); +} + +/** Initialize retained history once per extension host, including closed Viewer tabs. */ +export function initializeHtmlWidgetViewers( + context: ExtensionContext, sessions: HtmlViewerSessionAccess, +): HtmlWidgetViewerManager { + manager?.dispose(); + manager = new HtmlWidgetViewerManager(context, sessions); + context.subscriptions.push(manager); + return manager; +} + +function getManager(): HtmlWidgetViewerManager { + if (!manager || manager.disposed) { + throw new Error('HTML Viewer has not been initialized.'); + } + return manager; +} + +export async function showWebView( + file: string, title: string, viewer: string | boolean, session?: ViewerSessionContext, +): Promise<void> { if (viewer === false) { void env.openExternal(Uri.file(file)); - } else { - const dir = path.dirname(file); - const panel = window.createWebviewPanel('webview', title, - { - preserveFocus: true, - viewColumn: ViewColumn[String(viewer) as keyof typeof ViewColumn], - }, - { - enableScripts: true, - enableFindWidget: true, - retainContextWhenHidden: true, - localResourceRoots: [ - Uri.file(dir), - Uri.file(path.join(extensionContext.extensionPath, 'dist/webviews/webview')) - ], - }); - panel.iconPath = new UriIcon('globe'); - panel.webview.html = await getWebviewHtml(panel.webview, file, title, dir); - - panel.webview.onDidReceiveMessage((msg: { message: string, href?: string }) => { + return; + } + await getManager().show(file, title, viewer, session); +} + +export async function restoreHtmlViewer(sessionId?: string): Promise<void> { + await getManager().restore(sessionId); +} + +export type HtmlViewerAction = 'back' | 'forward' | 'remove' | 'info'; + +export async function runHtmlViewerCommand(action: HtmlViewerAction): Promise<void> { + await getManager().runCommand(action); +} + +export async function shutdownHtmlWidgetViewers(preservePanels = false): Promise<void> { + manager?.dispose(preservePanels); + await manager?.flush(); +} + +class HtmlWidgetViewerManager { + readonly histories: WidgetHistoryStore; + readonly viewers = new Map<string, WidgetViewer>(); + disposed = false; + private readonly panels = new Set<WidgetViewer>(); + private readonly serializer: Disposable; + private readonly loadingStatus: StatusBarItem; + private readonly panelStates = new Map<string, HtmlViewerPanelState>(); + private panelWrites: Promise<void> = Promise.resolve(); + + constructor(readonly context: ExtensionContext, private readonly sessions: HtmlViewerSessionAccess) { + this.histories = new WidgetHistoryStore(context.workspaceState, source => sessions.resolveSession(source)); + const savedPanels = context.workspaceState.get<unknown>(panelStateKey); + if (Array.isArray(savedPanels)) { + for (const value of savedPanels) { + const state = parsePanelState(value); + if (state) { this.panelStates.set(state.id, state); } + } + } + this.serializer = window.registerWebviewPanelSerializer('r.htmlViewer', this); + this.loadingStatus = window.createStatusBarItem('r.htmlViewer.loading', StatusBarAlignment.Right, 10); + this.loadingStatus.name = 'R HTML Viewer Loading'; + } + + async deserializeWebviewPanel(panel: WebviewPanel, state: unknown): Promise<void> { + if (this.disposed) { panel.dispose(); return; } + // Webview state contains only a reference. Paths and ownership come from + // extension-owned storage, so output scripts cannot supply arbitrary files. + const id = state && typeof state === 'object' && 'id' in state && typeof state.id === 'string' ? state.id : undefined; + const restored = id ? this.panelStates.get(id) : undefined; + const resolved = restored?.source ? this.sessions.resolveSession(restored.source) : undefined; + const session = resolved && restored?.source && + widgetSessionIdentity(resolved.source) === widgetSessionIdentity(restored.source) ? resolved : undefined; + const identity = session ? widgetSessionIdentity(session.source) : undefined; + const retained = session ? this.histories.entries.get(session.sessionId) : undefined; + // The restored tab must belong to the same process as its retained history. + const saved = retained && widgetSessionIdentity(retained.source) === identity ? retained : + session && !retained && !session.hasExited ? this.histories.remember(session) : undefined; + if (saved && !retained && restored) { + saved.history = restored.history.map(item => ({ ...item })); + saved.index = restored.index; + } + const existing = identity ? this.viewers.get(identity) : undefined; + // A notification may open a new panel before VS Code restores the original tab. + // Keep the restored tab and the latest retained history as the canonical viewer. + existing?.panel.dispose(); + const entry = this.attachPanel(panel, session, saved, restored?.showSessionInfo, restored?.id); + if (!saved && restored) { + entry.state = { history: restored.history.map(item => ({ ...item })), index: restored.index, viewColumn: panel.viewColumn }; + } + panel.webview.options = { ...panel.webview.options, enableScripts: true }; + const pending = this.save(entry); + await this.render(entry, false); + await pending; + } + + async show(file: string, title: string, viewer: string | boolean, session?: ViewerSessionContext): Promise<void> { + const saved = session ? this.histories.remember(session) : undefined; + const entry = this.open(viewer, session, saved); + entry.state.history.push({ file, title }); + if (entry.state.history.length > widgetHistoryLimit) { entry.state.history.shift(); } + entry.state.index = entry.state.history.length - 1; + const pending = this.save(entry); + await this.render(entry); + await pending; + } + + async restore(sessionId?: string): Promise<void> { + // Polling can discover an exit while the session picker is open. + const available = [...this.histories.entries.values()].filter(record => { + if (this.sessions.resolveSession(record.source).hasExited) { + this.histories.forget(record.source.sessionId); + return false; + } + return record.history.length > 0; + }); + const activeSessionId = sessionId ? undefined : this.sessions.getActiveSessionId(); + let record = sessionId ? this.histories.entries.get(sessionId) : + available.find(item => item.source.sessionId === activeSessionId); + if (!record && !sessionId) { + if (available.length === 1) { record = available[0]; } + else if (available.length > 1) { + const picked = await window.showQuickPick(available.map(item => ({ + label: item.history[item.index].title, + description: `R ${item.source.rVer}: ${item.source.pid} · ${item.source.host}`, + detail: `${item.history.length} HTML outputs · ${item.source.sessionId}`, + record: item, + })), { title: 'Restore HTML Viewer', matchOnDescription: true, matchOnDetail: true }); + if (!picked) { return; } + record = picked.record; + } + } + if (!record || this.histories.entries.get(record.source.sessionId) !== record) { + void window.showInformationMessage('No retained HTML Viewer history is available for this session.'); + return; + } + const session = this.sessions.resolveSession(record.source); + if (session.hasExited) { + this.histories.forget(session.sessionId); + void window.showInformationMessage('This R session has exited; its HTML Viewer history is no longer available.'); + return; + } + const viewer = record.viewColumn ? ViewColumn[record.viewColumn] : + workspace.getConfiguration('r').get<Record<string, string>>('session.viewers.viewColumn')?.viewer ?? 'Two'; + // Explicit restoration opens a Viewer even if automatic viewing is disabled. + const entry = this.open(viewer, session, record); + const pending = this.save(entry); + await this.render(entry); + await pending; + } + + private open(viewer: string | boolean, session?: ViewerSessionContext, saved?: WidgetHistory): WidgetViewer { + const identity = session ? widgetSessionIdentity(session.source) : undefined; + const existing = identity ? this.viewers.get(identity) : undefined; + if (existing) { return existing; } + const panel = window.createWebviewPanel('r.htmlViewer', htmlViewerTitle, + { preserveFocus: true, viewColumn: ViewColumn[String(viewer) as keyof typeof ViewColumn] ?? ViewColumn.Two }, + { enableScripts: true, enableFindWidget: true, retainContextWhenHidden: true }); + return this.attachPanel(panel, session, saved); + } + + private attachPanel(panel: WebviewPanel, session?: ViewerSessionContext, saved?: WidgetHistory, showSessionInfo = false, id: string = randomUUID()): WidgetViewer { + const identity = session ? widgetSessionIdentity(session.source) : undefined; + const entry: WidgetViewer = { + id, panel, revision: 0, disposed: false, loading: false, showSessionInfo, session, saved, subscriptions: [], resourceRoots: new Map(), + state: saved ?? { history: [], index: -1 }, + }; + this.panels.add(entry); + // Register before loading HTML so concurrent requests reuse the panel. + if (identity) { this.viewers.set(identity, entry); } + const exitObservation = session?.observeExit(() => { + this.updateTitle(entry); + this.updateToolbar(); + void this.updatePanelState(entry); + }); + if (exitObservation) { entry.subscriptions.push(exitObservation); } + entry.subscriptions.push(panel.onDidDispose(() => { + entry.disposed = true; + this.releasePanel(entry); + this.panelStates.delete(entry.id); + this.panels.delete(entry); + this.updateToolbar(); + if (!this.disposed) { + if (entry.saved) { void this.histories.save(entry.saved); } + void this.persistPanelStates(); + } + if (identity && this.viewers.get(identity) === entry) { this.viewers.delete(identity); } + })); + entry.subscriptions.push(panel.onDidChangeViewState(() => { + if (!entry.disposed) { void this.save(entry); } + this.updateToolbar(); + })); + const iconPath = this.context.asAbsolutePath('images/icons'); + panel.iconPath = { + dark: Uri.file(path.join(iconPath, 'dark', 'globe.svg')), + light: Uri.file(path.join(iconPath, 'light', 'globe.svg')), + }; + entry.subscriptions.push(panel.webview.onDidReceiveMessage(async (msg: { + message: string; href?: string; direction?: string; generation?: number; + }) => { + if (entry.disposed) { return; } if (msg.message === 'linkClicked' && msg.href) { void env.openExternal(Uri.parse(msg.href)); + } else if (msg.message === 'widget/loaded' && msg.generation === entry.revision) { + entry.finishLoading?.(); + this.updateToolbar(); + } else if (msg.message === 'widget/find' && msg.generation === entry.revision) { + panel.reveal(panel.viewColumn, false); + await commands.executeCommand('editor.action.webvieweditor.showFind'); + } else if (msg.message === 'widget/navigate' && msg.generation === entry.revision && + (msg.direction === 'back' || msg.direction === 'forward')) { + await this.changeHistory(entry, msg.direction); + } + })); + this.updateTitle(entry); + return entry; + } + + private releasePanel(entry: WidgetViewer): void { + entry.finishLoading?.(); + entry.subscriptions.splice(0).forEach(subscription => subscription?.dispose()); + } + + private panelState(entry: WidgetViewer): HtmlViewerPanelState { + return { + id: entry.id, version: 1, + source: entry.session ? { ...entry.session.source, processExited: entry.session.hasExited } : undefined, + history: entry.state.history.map(item => ({ ...item })), index: entry.state.index, + showSessionInfo: entry.showSessionInfo, + }; + } + + private updatePanelState(entry: WidgetViewer): Promise<void> { + if (entry.disposed || this.disposed) { return this.panelWrites; } + this.panelStates.set(entry.id, this.panelState(entry)); + return this.persistPanelStates(); + } + + private persistPanelStates(): Promise<void> { + const states = [...this.panelStates.values()]; + this.panelWrites = this.panelWrites.then(() => this.context.workspaceState.update(panelStateKey, states)) + .catch(error => console.warn('[HTML Viewer] Could not save panel state', error)); + return this.panelWrites; + } + + async flush(): Promise<void> { + await Promise.all([this.histories.flush(), this.panelWrites]); + } + + private activeViewer(): WidgetViewer | undefined { + return [...this.panels].find(entry => !entry.disposed && entry.panel.active); + } + + private updateToolbar(): void { + const entry = this.activeViewer(); + this.updateLoadingStatus(entry); + const session = entry?.session; + const ready = Boolean(entry?.session && !entry.loading); + void Promise.all([ + commands.executeCommand('setContext', 'r.htmlViewer.canGoBack', ready && entry!.state.index > 0), + commands.executeCommand('setContext', 'r.htmlViewer.canGoForward', ready && entry!.state.index < entry!.state.history.length - 1), + commands.executeCommand('setContext', 'r.htmlViewer.canRemove', ready && entry!.state.index >= 0), + commands.executeCommand('setContext', 'r.htmlViewer.canShowInfo', Boolean(session && + (session.hasExited || (session.source.rVer && session.source.pid)))), + ]).catch(error => console.warn('[HTML Viewer] Could not update toolbar', error)); + } + + private updateLoadingStatus(entry?: WidgetViewer): void { + if (this.disposed) { return; } + const item = entry?.state.history[entry.state.index]; + if (!entry?.loadStatus || !item) { + this.loadingStatus.hide(); + return; + } + this.loadingStatus.text = { + loading: '$(loading~spin) HTML Viewer: Loading HTML output…', + slow: '$(loading~spin) HTML Viewer: Still loading. Please wait.', + unconfirmed: 'HTML Viewer: Load not confirmed. Please wait or retry.', + }[entry.loadStatus]; + // Plain text only: output titles must not become trusted Markdown or commands. + this.loadingStatus.tooltip = [item.title, item.file, + entry.session ? `${formatSessionLabel(entry.session.source.rVer, entry.session.source.pid)} · ${entry.session.source.host}` : '', + ].filter(Boolean).join('\n'); + this.loadingStatus.show(); + } + + private updateTitle(entry: WidgetViewer): void { + if (entry.disposed) { return; } + const session = entry.session; + const label = session?.hasExited ? 'R: (not attached)' : + session ? formatSessionLabel(session.source.rVer, session.source.pid) : ''; + entry.panel.title = entry.showSessionInfo && label ? `${htmlViewerTitle} · ${label}` : htmlViewerTitle; + } + + async runCommand(action: HtmlViewerAction): Promise<void> { + const entry = this.activeViewer(); + if (!entry) { return; } + if (action === 'info') { + const session = entry.session; + if (!session || (!session.hasExited && (!session.source.rVer || !session.source.pid))) { return; } + entry.showSessionInfo = !entry.showSessionInfo; + this.updateTitle(entry); + await this.updatePanelState(entry); + } else { + await this.changeHistory(entry, action); + } + } + + private async changeHistory(entry: WidgetViewer, action: 'back' | 'forward' | 'remove'): Promise<void> { + if (entry.disposed || entry.loading || !entry.session) { return; } + if (action === 'remove') { + if (entry.state.index < 0 || entry.state.index >= entry.state.history.length) { return; } + entry.state.history.splice(entry.state.index, 1); + // Prefer the previous output; deleting the first selects the next. + entry.state.index = entry.state.history.length ? Math.max(0, entry.state.index - 1) : -1; + } else { + const index = entry.state.index + (action === 'back' ? -1 : 1); + if (index < 0 || index >= entry.state.history.length) { return; } + entry.state.index = index; + } + const pending = this.save(entry); + await this.render(entry); + await pending; + } + + private startLoading(entry: WidgetViewer, generation: number): () => void { + entry.finishLoading?.(); + entry.loading = true; + entry.loadStatus = 'loading'; + let navigationTimeout: ReturnType<typeof setTimeout> | undefined; + let confirmationTimeout: ReturnType<typeof setTimeout> | undefined; + entry.finishLoading = () => { + clearTimeout(navigationTimeout); + clearTimeout(confirmationTimeout); + entry.loading = false; + entry.loadStatus = undefined; + entry.finishLoading = undefined; + }; + this.updateToolbar(); + // Both deadlines run in the host and start after HTML assignment, so + // blocked scripts cannot leave navigation or the spinner stuck forever. + return () => { + navigationTimeout = setTimeout(() => { + if (entry.disposed || entry.revision !== generation) { return; } + entry.loading = false; + entry.loadStatus = 'slow'; + this.updateToolbar(); + }, 5000); + confirmationTimeout = setTimeout(() => { + if (entry.disposed || entry.revision !== generation) { return; } + // Keep this static message for the current output until loading is acknowledged. + // Navigating away, removing this output, rendering it again, or closing the + // viewer clears this output's loading state and message. Switching editor + // tabs only hides the message; returning shows it again if this output + // remains unconfirmed. + entry.loadStatus = 'unconfirmed'; + this.updateToolbar(); + }, 10000); + }; + } + + private async render(entry: WidgetViewer, reveal = true): Promise<void> { + const { extensionPath } = this.context; + const generation = ++entry.revision; + const startBrowserTimeout = this.startLoading(entry, generation); + const item = entry.state.history[entry.state.index]; + const dir = item ? path.dirname(item.file) : path.join(extensionPath, 'dist/webviews/webview'); + const { panel } = entry; + try { + const { html, localResourceRoots, failed } = await getWebviewHtml(panel.webview, item?.file, dir, Boolean(entry.session), generation, { id: entry.id }); + if (!entry.disposed && entry.revision === generation) { + if (item) { entry.resourceRoots.set(item.file, localResourceRoots); } + const files = new Set(entry.state.history.map(output => output.file)); + for (const file of entry.resourceRoots.keys()) { + if (!files.has(file)) { entry.resourceRoots.delete(file); } + } + // Keep permissions stable while browsing already rendered outputs. + // Recheck authored bases against current workspace trust, and forget + // directories when their output leaves this viewer's bounded history. + const assetRoot = Uri.file(path.join(extensionPath, 'dist/webviews/webview')).toString(); + const roots = entry.state.history.flatMap(output => (entry.resourceRoots.get(output.file) ?? []) + .filter(root => root.toString() === assetRoot || isAllowedResourceRoot(root, path.dirname(output.file)))); + const resourceRoots = [...new Map((roots.length ? roots : localResourceRoots).map(root => [root.toString(), root])).values()]; + const previousRoots = panel.webview.options.localResourceRoots; + if (!previousRoots || previousRoots.length !== resourceRoots.length || + previousRoots.some((root, index) => root.toString() !== resourceRoots[index].toString())) { + // Changing resource roots reloads the current HTML in VS Code. + // Clear only for permission changes, never ordinary Back/Forward. + panel.webview.html = ''; + panel.webview.options = { ...panel.webview.options, localResourceRoots: resourceRoots }; + } + panel.webview.html = html; + if (failed) { + entry.finishLoading?.(); + this.updateToolbar(); + } else { + startBrowserTimeout(); + } + if (reveal) { panel.reveal(panel.viewColumn, true); } + } + } catch (error) { + if (!entry.disposed && entry.revision === generation) { + entry.finishLoading?.(); + this.updateToolbar(); } + throw error; + } + } + + private save(entry: WidgetViewer): Promise<void> { + if (!entry.disposed) { entry.state.viewColumn = entry.panel.viewColumn; } + return Promise.all([ + entry.saved ? this.histories.save(entry.saved) : Promise.resolve(), + this.updatePanelState(entry), + ]).then(() => {}); + } + + dispose(preservePanels = false): void { + if (this.disposed) { return; } + this.panels.forEach(entry => { void this.save(entry); }); + this.disposed = true; + this.serializer.dispose(); + this.loadingStatus.dispose(); + this.panels.forEach(entry => { + if (preservePanels) { entry.disposed = true; this.releasePanel(entry); } + else { entry.panel.dispose(); } }); + if (!preservePanels) { void this.persistPanelStates(); } + this.panels.clear(); + this.viewers.clear(); + this.histories.dispose(); + this.updateToolbar(); } - console.info('[showWebView] Done'); } -export async function getWebviewHtml(webview: Webview, file: string, title: string, dir: string): Promise<string> { - const body = (await readContent(file, 'utf8') || '').toString() - .replace(/<(\w+)(.*)\s+(href|src)="(?!\w+:)/g, - `<$1 $2 $3="${String(webview.asWebviewUri(Uri.file(dir)))}/`); - - const scriptUri = webview.asWebviewUri(Uri.file(path.join(extensionContext.extensionPath, 'dist/webviews/webview/index.js'))); - const styleUri = webview.asWebviewUri(Uri.file(path.join(extensionContext.extensionPath, 'dist/webviews/webview/style.css'))); +async function getWebviewHtml( + webview: Webview, file: string | undefined, dir: string, sessionOwned = false, generation = 0, state?: HtmlViewerPanelReference, +): Promise<{ html: string; localResourceRoots: Uri[]; failed: boolean }> { + const { extensionPath } = getManager().context; + // Resolve webview URIs before awaiting I/O; the panel may close while loading. + const baseUri = String(webview.asWebviewUri(Uri.file(dir))); + const scriptUri = webview.asWebviewUri(Uri.file(path.join(extensionPath, 'dist/webviews/webview/index.js'))); + const styleUri = webview.asWebviewUri(Uri.file(path.join(extensionPath, 'dist/webviews/webview/style.css'))); + const localResourceRoots = [Uri.file(dir), Uri.file(path.join(extensionPath, 'dist/webviews/webview'))]; + let source = '<p>No HTML outputs in this session. New HTML output will appear here.</p>'; + let failed = false; + if (file !== undefined) { + try { + source = await readFile(file, 'utf8'); + } catch (error) { + if (!sessionOwned) { throw error; } + failed = true; + source = `<p role="alert">This HTML widget could not be loaded. Its original file may no longer be available.</p><pre>${escapeHtml(file)}</pre>`; + } + } - // define the content security policy for the webview - // * whilst it is recommended to be strict as possible, - // * there are several packages that require unsafe requests + // Some R widgets require inline scripts, evaluation, and external resources. const CSP = ` upgrade-insecure-requests; - default-src https: data: filesystem:; + default-src https: data: filesystem: ${webview.cspSource}; style-src https: data: filesystem: 'unsafe-inline' ${webview.cspSource}; script-src https: data: filesystem: 'unsafe-inline' 'unsafe-eval' ${webview.cspSource}; worker-src https: data: filesystem: blob:; + frame-src https: data: blob:; `; + const script = `<script src="${escapeHtml(String(scriptUri))}" data-generation="${generation}" data-session-owned="${sessionOwned}" data-viewer-state="${escapeHtml(JSON.stringify(state ?? null))}"></script>`; + + // Parse only to locate real document boundaries, then splice the original + // source without reserializing it or changing authored titles and scripts. + const document = load(source, { sourceCodeLocationInfo: true }); + const htmlLocation = document('html')[0]?.sourceCodeLocation; + const headLocation = document('html > head')[0]?.sourceCodeLocation; + const bodyLocation = document('html > body')[0]?.sourceCodeLocation; + const headOffset = headLocation?.startTag?.endOffset ?? htmlLocation?.startTag?.endOffset ?? + document.root()[0].children.find(node => node.type === 'directive')?.sourceCodeLocation?.endOffset ?? 0; + const bodyOffset = bodyLocation?.endTag?.startOffset ?? htmlLocation?.endTag?.startOffset ?? source.length; + const base = document('base[href]').toArray().find(element => { + if (element.namespace !== 'http://www.w3.org/1999/xhtml') { return false; } + // Template contents live in a separate document fragment, so their + // bases do not participate in the output's document base URL. + for (let parent = element.parent; parent; parent = parent.parent) { + if (parent.type === 'tag' && parent.name === 'template') { return false; } + } + return true; + }); + const edits = [{ start: bodyOffset, end: bodyOffset, text: script }]; + if (base) { + const fileUrl = pathToFileURL(file ?? path.join(dir, 'index.html')); + let resolvedBase: URL; + try { + resolvedBase = new URL(base.attribs.href, fileUrl); + } catch { + resolvedBase = fileUrl; + } + // Invalid, data:, and javascript: bases fall back to the original file. + if (resolvedBase.protocol === 'data:' || resolvedBase.protocol === 'javascript:') { resolvedBase = fileUrl; } + const hrefLocation = (base.sourceCodeLocation as { + attrs?: Record<string, { startOffset: number; endOffset: number }>; + } | undefined)?.attrs?.href; + if (resolvedBase.protocol === 'file:' && hrefLocation) { + const localBase = Uri.parse(resolvedBase.href); + const resourceRoot = Uri.joinPath(localBase, localBase.path.endsWith('/') ? '.' : '..').with({ query: '', fragment: '' }); + // Authored HTML may select a base URL, but cannot grant itself access + // outside the output directory or an independently trusted workspace. + if (isAllowedResourceRoot(resourceRoot, dir)) { + localResourceRoots.push(resourceRoot); + } + edits.push({ start: hrefLocation.startOffset, end: hrefLocation.endOffset, + text: `href="${escapeHtml(String(webview.asWebviewUri(localBase)))}"` }); + } + } + const baseTag = base ? '' : `<base href="${escapeHtml(baseUri)}/">`; + const head = `<meta http-equiv="Content-Security-Policy" content="${CSP}">${baseTag}<link rel="stylesheet" href="${escapeHtml(String(styleUri))}">`; + const headInsertion = headLocation?.startTag ? head : `<head>${head}</head>`; + edits.push({ start: headOffset, end: headOffset, text: headInsertion }); + let html = source; + for (const edit of edits.sort((left, right) => right.start - left.start)) { + html = html.slice(0, edit.start) + edit.text + html.slice(edit.end); + } + return { html, localResourceRoots, failed }; +} + +function isAllowedResourceRoot(resourceRoot: Uri, dir: string): boolean { + const trustedRoots = [Uri.file(dir), ...(workspace.isTrusted ? workspace.workspaceFolders?.map(folder => folder.uri) ?? [] : [])]; + return trustedRoots.some(root => { + if (root.scheme !== 'file' || root.authority !== resourceRoot.authority) { return false; } + const relative = path.relative(root.fsPath, resourceRoot.fsPath); + return relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`)); + }); +} - return ` - <!DOCTYPE html> - <html lang="en"> - <head> - <meta charset="UTF-8"> - <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <meta http-equiv="Content-Security-Policy" content="${CSP}"> - <title>${title} - - - - - ${body} - - - - `; +function parsePanelState(state: unknown): HtmlViewerPanelState | undefined { + if (!state || typeof state !== 'object') { return; } + const value = state as Partial; + const source = value.source; + if (typeof value.id !== 'string' || !value.id || value.version !== 1 || typeof value.showSessionInfo !== 'boolean' || + !Array.isArray(value.history) || value.history.length > widgetHistoryLimit || + !value.history.every(item => item && typeof item.file === 'string' && typeof item.title === 'string') || + !Number.isInteger(value.index) || value.index! < (value.history.length ? 0 : -1) || value.index! >= value.history.length || + (source !== undefined && (!source || typeof source.sessionId !== 'string' || !source.sessionId || + typeof source.host !== 'string' || typeof source.pid !== 'string' || typeof source.rVer !== 'string' || + typeof source.processExited !== 'boolean'))) { return; } + return value as HtmlViewerPanelState; } diff --git a/src/webViewer/webview/index.ts b/src/webViewer/webview/index.ts index 68770826d..13dd9cf01 100644 --- a/src/webViewer/webview/index.ts +++ b/src/webViewer/webview/index.ts @@ -1,84 +1,11 @@ -import { acquireVsCodeApi, VsCode } from '../webviewMessages'; - -const vscode: VsCode = acquireVsCodeApi(); - -const replaceReg = /vscode-webview:\/\//; -const testReg = /vscode-webview:\/\/.*\.[A-Za-z/0-9_-]*?\/.+/; -const watchedTags = [ - 'IMG', - 'A', - 'LINK', - 'SCRIPT' -]; - -function handleMutation(mutation: MutationRecord) { - for (const node of Array.from(mutation.addedNodes)) { - if (node instanceof HTMLElement) { - if (watchedTags.includes(node.tagName)) { - processElement(node); - } - node.querySelectorAll(watchedTags.join(',')).forEach(processElement); - } - } -} - -function processElement(el: Element) { - if (el instanceof HTMLImageElement || el instanceof HTMLScriptElement) { - if (testReg.test(el.src)) { - const newSrc = el.src.replace(replaceReg, 'https://'); - el.src = newSrc; - } - } else if (el instanceof HTMLAnchorElement || el instanceof HTMLLinkElement) { - if (testReg.test(el.href)) { - const newHref = el.href.replace(replaceReg, 'https://'); - el.href = newHref; - } - } -} - -// Hijack links -function setupLinks() { - const hyperLinks = document.getElementsByTagName('a'); - for (let i = 0; i < hyperLinks.length; i++) { - const hrefAbs = hyperLinks[i].href; - const hrefRel = hyperLinks[i].getAttribute('href') || ''; - - if (hrefRel.startsWith('#')) { - hyperLinks[i].onclick = () => { - document.location.hash = hrefRel; - }; - } else if (hrefAbs && hrefAbs.startsWith('vscode-webview://')) { - hyperLinks[i].onclick = (ev) => { - ev.preventDefault(); - vscode.postMessage({ - message: 'linkClicked', - href: hrefAbs, - scrollY: window.scrollY - }); - }; - } - } -} - -// Hijack mouse clicks -window.onmousedown = (ev) => { - vscode.postMessage({ - message: 'mouseClick', - button: Number(ev.button), - scrollY: window.scrollY - }); -}; - -window.addEventListener('load', () => { - setupLinks(); - - const observer = new MutationObserver((mutations) => { - mutations.forEach(handleMutation); - setupLinks(); - }); - - observer.observe(document.body, { - childList: true, - subtree: true - }); -}); +import { acquireVsCodeApi } from '../webviewMessages'; +import { initializeWidgetContent, initializeWidgetLoad, initializeWidgetState } from './widget'; +import type { HtmlViewerPanelReference } from '../webviewMessages'; + +const script = document.currentScript as HTMLScriptElement; +const vscode = acquireVsCodeApi(); +const generation = Number(script.dataset.generation); +initializeWidgetContent(vscode, generation, script.dataset.sessionOwned === 'true'); +initializeWidgetLoad(vscode, generation); +const state = JSON.parse(script.dataset.viewerState ?? 'null') as HtmlViewerPanelReference | null; +if (state) { initializeWidgetState(vscode, state); } diff --git a/src/webViewer/webview/style.css b/src/webViewer/webview/style.css index 7cd4164ae..2272d6549 100644 --- a/src/webViewer/webview/style.css +++ b/src/webViewer/webview/style.css @@ -1,13 +1,24 @@ +:root { + color-scheme: light; +} + +/* Match the former iframe's browser colors instead of inheriting editor colors. + This stylesheet precedes the output's styles, so authored themes still win. */ body { padding: 0; margin: 0; - background-color: transparent; - color: var(--vscode-editor-foreground); + background-color: white; + color: black; font-family: var(--vscode-editor-font-family); font-size: var(--vscode-editor-font-size); } -#webview-content { - display: block; - padding: 10px; +code { + color: inherit; + background-color: transparent; +} + +blockquote { + background-color: transparent; + border-color: currentColor; } diff --git a/src/webViewer/webview/widget.ts b/src/webViewer/webview/widget.ts new file mode 100644 index 000000000..2b8a08e69 --- /dev/null +++ b/src/webViewer/webview/widget.ts @@ -0,0 +1,54 @@ +import type { HtmlViewerPanelReference, VsCode } from '../webviewMessages'; + +export function initializeWidgetState(vscode: VsCode, state: HtmlViewerPanelReference): void { + vscode.setState(state); +} + +export function initializeWidgetLoad(vscode: VsCode, generation: number): void { + // The webview HTML setter only queues a document replacement. Wait for + // its resources and a paint before enabling navigation in the host. + const loaded = () => requestAnimationFrame(() => requestAnimationFrame(() => { + vscode.postMessage({ message: 'widget/loaded', generation }); + })); + if (document.readyState === 'complete') { loaded(); } + else { window.addEventListener('load', loaded, { once: true }); } +} + +export function initializeWidgetContent(vscode: VsCode, generation: number, sessionOwned: boolean): void { + document.addEventListener('click', event => { + if (event.defaultPrevented) { return; } + const anchor = event.target instanceof Element ? event.target.closest('a') : null; + if (!(anchor instanceof HTMLAnchorElement) || anchor.hasAttribute('download')) { return; } + const href = (anchor.getAttribute('href') ?? '').trim(); + if (href.startsWith('#')) { + // The resource base is the output directory, so native fragment navigation + // would leave this document. Resolve targets within the displayed document instead. + event.preventDefault(); + let fragment = href.slice(1); + try { fragment = decodeURIComponent(fragment); } catch { /* Keep malformed fragments literal. */ } + const target = document.getElementById(fragment) + ?? Array.from(document.getElementsByName(fragment)).find(element => element instanceof HTMLAnchorElement); + if (target) { target.scrollIntoView(); } + else if (!fragment || fragment.toLowerCase() === 'top') { window.scrollTo(0, 0); } + } else if (/^(https?:|mailto:)/i.test(href)) { + event.preventDefault(); + vscode.postMessage({ message: 'linkClicked', href: anchor.href }); + } + }); + document.addEventListener('mousedown', event => { + if (sessionOwned && (event.button === 3 || event.button === 4)) { + event.preventDefault(); + vscode.postMessage({ message: 'widget/navigate', direction: event.button === 3 ? 'back' : 'forward', generation }); + } + }); + document.addEventListener('keydown', event => { + if (event.defaultPrevented) { return; } + if (sessionOwned && event.altKey && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) { + event.preventDefault(); + vscode.postMessage({ message: 'widget/navigate', direction: event.key === 'ArrowLeft' ? 'back' : 'forward', generation }); + } else if ((event.ctrlKey || event.metaKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'f') { + event.preventDefault(); + vscode.postMessage({ message: 'widget/find', generation }); + } + }); +} diff --git a/src/webViewer/webviewMessages.ts b/src/webViewer/webviewMessages.ts index 8093fd3a3..4cd3e804b 100644 --- a/src/webViewer/webviewMessages.ts +++ b/src/webViewer/webviewMessages.ts @@ -1,30 +1,32 @@ +import type { ViewerSessionSource } from '../viewerSession'; + +export interface HtmlViewerPanelState { + id: string; + version: 1; + source?: ViewerSessionSource; + history: Array<{ file: string; title: string }>; + index: number; + showSessionInfo: boolean; +} + +export type HtmlViewerPanelReference = Pick; + export interface VsCode { postMessage: (msg: OutMessage) => void; - setState: (state: string) => void; + setState: (state: unknown) => void; } /** * Function declared by VS Code in Webview */ export const acquireVsCodeApi: () => VsCode = (globalThis as { acquireVsCodeApi?: () => VsCode }).acquireVsCodeApi || (() => ({} as VsCode)); -export interface IMessage { - message: string; -} - -export interface LogMessage extends IMessage { - message: 'log', - body: any -} -export interface MouseClickMessage extends IMessage { - message: 'mouseClick', - button: number, - scrollY: number -} -export interface LinkClickedMessage extends IMessage { +export interface LinkClickedMessage { message: 'linkClicked', - href: string, - scrollY: number + href: string } -export type OutMessage = LogMessage | MouseClickMessage | LinkClickedMessage; +export type OutMessage = LinkClickedMessage + | { message: 'widget/loaded'; generation: number } + | { message: 'widget/navigate'; direction: 'back' | 'forward'; generation: number } + | { message: 'widget/find'; generation: number };