Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
ddfe424
feat(viewer): reuse HTML widget panels per R session
Fred-Wu Oct 8, 2026
46818a6
feat: add HTML widget Viewer navigation toolbar
Fred-Wu Oct 8, 2026
6b88d3c
feat: preserve HTML Viewer history and add restore command
Fred-Wu Oct 8, 2026
9ae29e1
feat: unify standalone HTML outputs in a session-aware HTML Viewer
Fred-Wu Oct 8, 2026
8a92056
feat: add a remove button to remove outputs from history
Fred-Wu Oct 8, 2026
59434e2
fix(test): fix HTML Viewer feature tests across platforms
Fred-Wu Oct 8, 2026
2e0b6db
fix: preserve fragment navigation in HTML Viewer
Fred-Wu Oct 8, 2026
fbe6e25
fix: restore VS Code’s built-in Find in html viewer
Fred-Wu Oct 8, 2026
d98c791
refactor: decouple HTML Viewer from session management and extension …
Fred-Wu Oct 8, 2026
8d5011b
fix: use consistent process identity for HTML viewer history and pane…
Fred-Wu Oct 8, 2026
caefd92
refactor: move HTML viewer controls to the native VS Code toolbar
Fred-Wu Oct 8, 2026
2705432
chore: remove obsolete HTML viewer code and build artifacts
Fred-Wu Oct 8, 2026
cc7bbf8
fix(test): initialize HTML viewer fixtures and synchronize native com…
Fred-Wu Oct 8, 2026
71b794a
fix(test): check HTML viewer title in its owning editor group
Fred-Wu Oct 8, 2026
a94eea0
fix: preserve authored HTML and support updated dependencies
Oct 9, 2026
21be982
fix: align VS Code types with the supported version
Oct 9, 2026
d7df830
clean-up: removed verified unused pieces:
Oct 9, 2026
6296d15
fix (test): HTML viewer tests and language server cleanup
Oct 9, 2026
752621b
fix: restore main’s package versions and revert unrelated upgrade cha…
Oct 10, 2026
d6e7119
Restrict HTML viewer resource access and move secondary actions to th…
Oct 10, 2026
da32336
fix: Make HTML viewer history navigation smoother
Oct 10, 2026
27cdc1f
fix (test): wait for viewer readiness and a newly loaded document aft…
Oct 10, 2026
0594374
fix: unblock HTML viewer navigation when load confirmation is missing
Oct 10, 2026
e2c6b5a
fix: start HTML viewer timeout after assigning the new document
Oct 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions esbuild.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
70 changes: 70 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand Down
11 changes: 11 additions & 0 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -66,6 +67,10 @@ export async function activate(context: vscode.ExtensionContext): Promise<apiImp

// assign extension context to global variable
extensionContext = context;
initializeHtmlWidgetViewers(context, {
resolveSession: source => session.getViewerSessionContext(source.sessionId, source)!,
getActiveSessionId: () => session.activeSession?.sessionId,
});

// assign session watcher setting to global variable
enableSessionWatcher = util.config().get<boolean>('sessionWatcher') ?? false;
Expand Down Expand Up @@ -163,6 +168,11 @@ export async function activate(context: vscode.ExtensionContext): Promise<apiImp
'r.browser.refresh': session.refreshBrowser,
'r.browser.openExternal': session.openExternalBrowser,
'r.webview.find': () => 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)
};
Expand Down Expand Up @@ -282,6 +292,7 @@ export function ensureWorkspaceViewer(): void {
}

export async function deactivate(): Promise<void> {
await shutdownHtmlWidgetViewers(true);
(globalPlotManager as plotViewer.CommonPlotManager)?.dispose();
await session.shutdownSessionWatcher();
}
44 changes: 17 additions & 27 deletions src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -115,7 +116,7 @@ export let workspaceFile: string;
const SESS_PROTOCOL_VERSION = 2;

const sessions = new Map<string, Session>();
const sessionProcessMonitor = new SessionProcessMonitor<Session>(isLocalHost);
const sessionProcessMonitor = new SessionProcessMonitor<ViewerSessionSource>(isLocalHost);
// Only the newest handshake for a stable ID may commit after terminal discovery.
const pendingSessionAttachments = new Map<string, IpcSocket>();
const terminalRegistry = new TerminalSessionRegistry<Session, vscode.Terminal>(
Expand Down Expand Up @@ -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 `<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>`;
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(
Expand Down Expand Up @@ -2287,7 +2274,10 @@ async function handleNotification(message: Record<string, unknown>, 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));
}
Expand Down
4 changes: 4 additions & 0 deletions src/sessionIdentity.ts
Original file line number Diff line number Diff line change
@@ -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]);
}
31 changes: 21 additions & 10 deletions src/sessionProcessMonitor.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { sessionProcessIdentity } from './sessionIdentity';

interface ProcessSession {
readonly sessionId: string;
readonly pid: string;
Expand All @@ -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>;
Expand All @@ -22,20 +26,21 @@ export class SessionProcessMonitor<Session extends ProcessSession> {
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);
Expand Down Expand Up @@ -63,13 +68,19 @@ export class SessionProcessMonitor<Session extends ProcessSession> {

/** 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 {
Expand Down
Loading
Loading