From ddfe42444366e885aecf1a944b6b01d13281a7a0 Mon Sep 17 00:00:00 2001 From: "4111978+Fred-Wu@users.noreply.github.com" <4111978+Fred-Wu@users.noreply.github.com> Date: Thu, 8 Oct 2026 16:30:34 +1100 Subject: [PATCH 01/26] feat(viewer): reuse HTML widget panels per R session --- src/session.ts | 3 +- src/test/suite/dataViewerSessions.test.ts | 21 ++++ src/test/suite/htmlWidgetViewer.test.ts | 138 ++++++++++++++++++++++ src/webViewer/index.ts | 90 +++++++++----- 4 files changed, 223 insertions(+), 29 deletions(-) create mode 100644 src/test/suite/htmlWidgetViewer.test.ts diff --git a/src/session.ts b/src/session.ts index c290f1e2f..b617cd653 100644 --- a/src/session.ts +++ b/src/session.ts @@ -2287,7 +2287,8 @@ async function handleNotification(message: Record, socket: IpcS } } else { if (url.toLowerCase().endsWith('.html') || url.toLowerCase().endsWith('.htm')) { - await showWebView(url, title, viewColumn); + await showWebView(url, title, viewColumn, + method === 'webview' ? socket._sessionId ?? null : null); } else { await showDataView('object', 'txt', title, url, String(viewColumn)); } diff --git a/src/test/suite/dataViewerSessions.test.ts b/src/test/suite/dataViewerSessions.test.ts index b6c0d2cfc..a77a16eab 100644 --- a/src/test/suite/dataViewerSessions.test.ts +++ b/src/test/suite/dataViewerSessions.test.ts @@ -142,6 +142,27 @@ suite('Viewer session ownership', () => { return client; } + test('HTML widget notifications use their source session while another session is active', async () => { + sandbox.stub(util, 'readContent').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(!original.webview.html.includes('viewer-session')); + 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(!panels[1].panel.webview.html.includes('viewer-session')); + assert.ok(original.webview.html.includes('updated-widget.html')); + assert.strictEqual(session.activeSession?.sessionId, second.id); + }); + async function open( client: Client, source: 'list' | 'table', existing?: Panel, stateGeneration = 1, ): Promise { diff --git a/src/test/suite/htmlWidgetViewer.test.ts b/src/test/suite/htmlWidgetViewer.test.ts new file mode 100644 index 000000000..324654d27 --- /dev/null +++ b/src/test/suite/htmlWidgetViewer.test.ts @@ -0,0 +1,138 @@ +import * as assert from 'assert'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import * as vscode from 'vscode'; +import * as session from '../../session'; +import * as util from '../../util'; +import { showWebView } from '../../webViewer'; +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[] = []; + + setup(() => { + sandbox = sinon.createSandbox(); + mockExtensionContext(path.resolve(__dirname, '../../..'), sandbox); + read = sandbox.stub(util, 'readContent').callsFake(file => Promise.resolve( + `
${String(file)}
`)); + sandbox.stub(vscode.window, 'createWebviewPanel').callsFake((_type, title, _column, options) => { + const disposed = new vscode.EventEmitter(); + const received = new vscode.EventEmitter(); + let closed = false; + const webview = { + html: '', options, cspSource: 'webview-test:', + asWebviewUri: (uri: vscode.Uri) => uri, + onDidReceiveMessage: received.event, postMessage: sandbox.stub().resolves(true), + }; + const panel = { + title, viewColumn: vscode.ViewColumn.Two, reveal: sandbox.stub(), + get webview() { + assert.strictEqual(closed, false, 'Disposed panels must not be accessed'); + return webview; + }, + onDidDispose: disposed.event, + dispose: () => { + if (closed) { return; } + closed = true; disposed.fire(); disposed.dispose(); received.dispose(); + }, + } as unknown as vscode.WebviewPanel; + panels.push(panel); + return panel; + }); + }); + + teardown(() => { + panels.splice(0).forEach(panel => { panel.dispose(); }); + 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, source.sessionId); + } + + 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(panels[0].title, 'Updated Viewer'); + assert.ok(panels[0].webview.html.includes('/tmp/widget-c/index.html')); + assert.ok(panels[0].webview.html.includes('/tmp/widget-c/lib/widget.js')); + assert.ok(panels[1].webview.html.includes('/tmp/widget-b/index.html')); + assert.strictEqual(panels[0].webview.options.localResourceRoots?.[0].fsPath, '/tmp/widget-c'); + assert.deepStrictEqual((panels[0].reveal as sinon.SinonStub).lastCall.args, [vscode.ViewColumn.Two, true]); + assert.ok(!panels[0].webview.html.includes('viewer-session')); + assert.ok(!panels[1].webview.html.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(panels[0].title, 'Latest'); + assert.strictEqual(panels[0].webview.options.localResourceRoots?.[0].fsPath, '/tmp/latest'); + }); + + 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; + 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')); + }); + + 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('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); + }); +}); diff --git a/src/webViewer/index.ts b/src/webViewer/index.ts index c9c30172f..25c03b1cd 100644 --- a/src/webViewer/index.ts +++ b/src/webViewer/index.ts @@ -1,49 +1,83 @@ 'use strict'; import * as path from 'path'; -import { Uri, ViewColumn, Webview, window, env } from 'vscode'; +import { Uri, ViewColumn, Webview, WebviewPanel, window, env } from 'vscode'; import { readContent, UriIcon } from '../util'; import { extensionContext } from '../extension'; -export async function showWebView(file: string, title: string, viewer: string | boolean): Promise { +interface WidgetViewer { + panel: WebviewPanel; + revision: number; + disposed: boolean; +} + +const widgetViewers = new Map(); + +export async function showWebView( + file: string, title: string, viewer: string | boolean, sessionId: string | null = null, +): Promise { console.info(`[showWebView] file: ${file}, viewer: ${viewer.toString()}`); 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')) - ], + const resourceRoots = [ + Uri.file(dir), + Uri.file(path.join(extensionContext.extensionPath, 'dist/webviews/webview')), + ]; + let entry = sessionId ? widgetViewers.get(sessionId) : undefined; + if (!entry) { + const panel = window.createWebviewPanel('webview', title, + { + preserveFocus: true, + viewColumn: ViewColumn[String(viewer) as keyof typeof ViewColumn], + }, + { + enableScripts: true, + enableFindWidget: true, + retainContextWhenHidden: true, + localResourceRoots: resourceRoots, + }); + entry = { panel, revision: 0, disposed: false }; + // Register before loading HTML so concurrent requests reuse the panel. + if (sessionId) { widgetViewers.set(sessionId, entry); } + const created = entry; + panel.onDidDispose(() => { + created.disposed = true; + if (sessionId && widgetViewers.get(sessionId) === created) { + widgetViewers.delete(sessionId); + } }); - panel.iconPath = new UriIcon('globe'); - panel.webview.html = await getWebviewHtml(panel.webview, file, title, dir); - - panel.webview.onDidReceiveMessage((msg: { message: string, href?: string }) => { - if (msg.message === 'linkClicked' && msg.href) { - void env.openExternal(Uri.parse(msg.href)); - } - }); + panel.iconPath = new UriIcon('globe'); + panel.webview.onDidReceiveMessage((msg: { message: string, href?: string }) => { + if (msg.message === 'linkClicked' && msg.href) { + void env.openExternal(Uri.parse(msg.href)); + } + }); + } + const revision = ++entry.revision; + const { panel } = entry; + const html = await getWebviewHtml(panel.webview, file, title, dir); + if (!entry.disposed && entry.revision === revision) { + panel.title = title; + panel.webview.options = { ...panel.webview.options, localResourceRoots: resourceRoots }; + panel.webview.html = html; + panel.reveal(panel.viewColumn, true); + } } console.info('[showWebView] Done'); } -export async function getWebviewHtml(webview: Webview, file: string, title: string, dir: string): Promise { - const body = (await readContent(file, 'utf8') || '').toString() - .replace(/<(\w+)(.*)\s+(href|src)="(?!\w+:)/g, - `<$1 $2 $3="${String(webview.asWebviewUri(Uri.file(dir)))}/`); - +export async function getWebviewHtml( + webview: Webview, file: string, title: string, dir: string, +): Promise { + // 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(extensionContext.extensionPath, 'dist/webviews/webview/index.js'))); const styleUri = webview.asWebviewUri(Uri.file(path.join(extensionContext.extensionPath, 'dist/webviews/webview/style.css'))); + const body = (await readContent(file, 'utf8') || '').toString() + .replace(/<(\w+)(.*)\s+(href|src)="(?!\w+:)/g, + `<$1 $2 $3="${baseUri}/`); // define the content security policy for the webview // * whilst it is recommended to be strict as possible, From 46818a619930a61ba19c34ff670324cddc2a572a Mon Sep 17 00:00:00 2001 From: "4111978+Fred-Wu@users.noreply.github.com" <4111978+Fred-Wu@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:01:03 +1100 Subject: [PATCH 02/26] feat: add HTML widget Viewer navigation toolbar - Keep up to 50 HTML outputs per session while the Viewer is open - Add Back/Forward buttons, keyboard shortcuts, and session info - Isolate widget content in an iframe - Handle stale loads, missing files, and Viewer disposal - Add navigation, session ownership, and browser rendering tests --- esbuild.js | 3 +- src/session.ts | 33 ++--- src/test/suite/dataViewerSessions.test.ts | 4 +- src/test/suite/htmlWidgetBrowser.test.ts | 98 +++++++++++++ src/test/suite/htmlWidgetToolbar.test.ts | 64 +++++++++ src/test/suite/htmlWidgetViewer.test.ts | 163 +++++++++++++++++++++- src/viewerSession.ts | 65 ++++++++- src/webViewer/index.ts | 119 +++++++++++++--- src/webViewer/webview/index.ts | 4 + src/webViewer/webview/style.css | 68 +++++++++ src/webViewer/webview/toolbar.ts | 57 ++++++++ src/webViewer/webview/widget.ts | 22 +++ src/webViewer/webviewMessages.ts | 4 +- 13 files changed, 647 insertions(+), 57 deletions(-) create mode 100644 src/test/suite/htmlWidgetBrowser.test.ts create mode 100644 src/test/suite/htmlWidgetToolbar.test.ts create mode 100644 src/webViewer/webview/toolbar.ts create mode 100644 src/webViewer/webview/widget.ts diff --git a/esbuild.js b/esbuild.js index cfa0d554b..ec9779b45 100644 --- a/esbuild.js +++ b/esbuild.js @@ -100,7 +100,8 @@ async function main() { entryPoints: { 'help/index': './src/helpViewer/webview/index.ts', 'httpgd/index': './src/plotViewer/webview/index.ts', - 'webview/index': './src/webViewer/webview/index.ts' + 'webview/index': './src/webViewer/webview/index.ts', + 'webview/widget': './src/webViewer/webview/widget.ts' }, bundle: true, minify: production, diff --git a/src/session.ts b/src/session.ts index b617cd653..e215d96f1 100644 --- a/src/session.ts +++ b/src/session.ts @@ -26,7 +26,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 } from './viewerSession'; export interface SessionInfo { version: string; @@ -1240,33 +1240,16 @@ 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 { + getViewerSessionContext(sessionId)?.attach(panel); +} + +export function getViewerSessionContext(sessionId: string | null): ViewerSessionContext | undefined { 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(); }); + return owner ? createViewerSessionContext(owner, sessionProcessMonitor) : undefined; } export async function getTableHtml( @@ -2288,7 +2271,7 @@ async function handleNotification(message: Record, socket: IpcS } else { if (url.toLowerCase().endsWith('.html') || url.toLowerCase().endsWith('.htm')) { await showWebView(url, title, viewColumn, - method === 'webview' ? socket._sessionId ?? null : null); + method === 'webview' ? getViewerSessionContext(socket._sessionId ?? null) : undefined); } else { await showDataView('object', 'txt', title, url, String(viewColumn)); } diff --git a/src/test/suite/dataViewerSessions.test.ts b/src/test/suite/dataViewerSessions.test.ts index a77a16eab..af64c5fb0 100644 --- a/src/test/suite/dataViewerSessions.test.ts +++ b/src/test/suite/dataViewerSessions.test.ts @@ -151,14 +151,14 @@ suite('Viewer session ownership', () => { 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(!original.webview.html.includes('viewer-session')); + assert.ok(original.webview.html.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(!panels[1].panel.webview.html.includes('viewer-session')); + assert.ok(panels[1].panel.webview.html.includes('R 4.6.2: 12102')); assert.ok(original.webview.html.includes('updated-widget.html')); assert.strictEqual(session.activeSession?.sessionId, second.id); }); diff --git a/src/test/suite/htmlWidgetBrowser.test.ts b/src/test/suite/htmlWidgetBrowser.test.ts new file mode 100644 index 000000000..449d30f1f --- /dev/null +++ b/src/test/suite/htmlWidgetBrowser.test.ts @@ -0,0 +1,98 @@ +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import * as vscode from 'vscode'; +import * as session from '../../session'; +import { showWebView } from '../../webViewer'; +import { mockExtensionContext } from '../common/mockvscode'; +import { waitForValue } from '../common/sessionConnections'; + +suite('HTML widget browser rendering', () => { + test('loads relative dependencies in the iframe and navigates with the real toolbar', async () => { + const sandbox = sinon.createSandbox(); + 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 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.js'), `let reports = 0; const timer = setInterval(() => { + window.parent.postMessage({ message: 'widget/bridge', href: 'https://widget-test.invalid/${name}?width=' + innerWidth + '&height=' + innerHeight }, '*'); + if (++reports === 20) clearInterval(timer); + }, 100);`); + const file = path.join(root, 'index.html'); + fs.writeFileSync(file, `
${name}
`); + 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) => { + await waitForValue(() => count(name) > previous ? true : undefined); + const uri = urls.filter(uri => uri.path === `/${name}`).at(-1)!; + const dimensions = new URLSearchParams(uri.query); + assert.ok(Number(dimensions.get('width')) > 0 && Number(dimensions.get('height')) > 0); + }; + 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]; + let controlRevision = 0; + // A test-only host script drives native button clicks and reports geometry. + const installControls = async (name: string) => { + const previous = count(name); + const token = ++controlRevision; + panel.webview.html = panel.webview.html.replace('', ``); + await waitForValue(() => urls.find(uri => uri.path === '/controls' && uri.query === 'token=' + token)); + await loaded(name, previous); + }; + await installControls('second'); + await panel.webview.postMessage({ message: 'widget-test/inspect' }); + await waitForValue(() => urls.find(uri => uri.path === '/host')); + assert.ok(urls.find(uri => uri.path === '/host')?.query.includes('good=true')); + const firstCount = count('first'); + await panel.webview.postMessage({ message: 'widget-test/navigate', direction: 'back' }); + await loaded('first', firstCount); + assert.strictEqual(panel.title, 'First widget'); + await installControls('first'); + const secondCount = count('second'); + await panel.webview.postMessage({ message: 'widget-test/navigate', direction: 'forward' }); + await loaded('second', secondCount); + assert.strictEqual(panel.title, 'Second widget'); + } finally { + panels.forEach(panel => { panel.dispose(); }); + session.unregisterSessionTransport(source); + sandbox.restore(); + fs.rmSync(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/src/test/suite/htmlWidgetToolbar.test.ts b/src/test/suite/htmlWidgetToolbar.test.ts new file mode 100644 index 000000000..997284bac --- /dev/null +++ b/src/test/suite/htmlWidgetToolbar.test.ts @@ -0,0 +1,64 @@ +import * as assert from 'assert'; +import * as vm from 'vm'; +import { initializeWidgetToolbar } from '../../webViewer/webview/toolbar'; + +suite('HTML widget toolbar controls', () => { + function toolbar(backDisabled = false, forwardDisabled = false) { + const back = { disabled: backDisabled, onclick: undefined as (() => void) | undefined }; + const forward = { disabled: forwardDisabled, onclick: undefined as (() => void) | undefined }; + const frame = { contentWindow: {}, dataset: {} }; + const posted: unknown[] = []; + const events = new Map void>(); + const elements = new Map([ + ['widget-toolbar', { dataset: { generation: '7' } }], + ['widget-back', back], ['widget-forward', forward], ['widget-frame', frame], + ]); + vm.runInNewContext(`(${initializeWidgetToolbar.toString()})(vscode)`, { + document: { + getElementById: (id: string) => elements.get(id), + addEventListener: (name: string, listener: (event: unknown) => void) => events.set(name, listener), + }, + window: { addEventListener: (name: string, listener: (event: unknown) => void) => events.set(name, listener) }, + vscode: { postMessage: (message: unknown) => posted.push(JSON.parse(JSON.stringify(message))) }, + }); + return { back, forward, frame, posted, events }; + } + + test('disabled boundaries do nothing and enabled clicks send the displayed generation once', () => { + const first = toolbar(true, false); + first.back.onclick!(); + assert.strictEqual(first.posted.length, 0); + first.forward.onclick!(); + assert.deepStrictEqual(first.posted, [{ message: 'widget/navigate', direction: 'forward', generation: 7 }]); + assert.ok(first.back.disabled && first.forward.disabled); + first.forward.onclick!(); + assert.strictEqual(first.posted.length, 1); + }); + + test('keyboard and mouse shortcuts navigate in the host and inside the widget', () => { + const keyboard = toolbar(); + let prevented = false; + keyboard.events.get('keydown')!({ altKey: true, key: 'ArrowLeft', preventDefault: () => { prevented = true; } }); + assert.ok(prevented); + assert.deepStrictEqual(keyboard.posted, [{ message: 'widget/navigate', direction: 'back', generation: 7 }]); + const mouse = toolbar(); + mouse.events.get('mousedown')!({ button: 4, preventDefault: () => undefined }); + assert.deepStrictEqual(mouse.posted, [{ message: 'widget/navigate', direction: 'forward', generation: 7 }]); + const widget = toolbar(); + const receive = widget.events.get('message')!; + receive({ source: {}, data: { message: 'widget/bridge', direction: 'back' } }); + assert.strictEqual(widget.posted.length, 0); + receive({ source: widget.frame.contentWindow, data: { message: 'widget/bridge', direction: 'back' } }); + assert.deepStrictEqual(widget.posted, [{ message: 'widget/navigate', direction: 'back', generation: 7 }]); + }); + + test('only the widget frame can forward supported external links', () => { + const widget = toolbar(); + const receive = widget.events.get('message')!; + receive({ source: {}, data: { message: 'widget/bridge', href: 'https://example.com' } }); + receive({ source: widget.frame.contentWindow, data: { message: 'widget/bridge', href: 'javascript:alert(1)' } }); + assert.strictEqual(widget.posted.length, 0); + receive({ source: widget.frame.contentWindow, data: { message: 'widget/bridge', href: 'https://example.com' } }); + assert.deepStrictEqual(widget.posted, [{ message: 'linkClicked', href: 'https://example.com', scrollY: 0 }]); + }); +}); diff --git a/src/test/suite/htmlWidgetViewer.test.ts b/src/test/suite/htmlWidgetViewer.test.ts index 324654d27..089299d17 100644 --- a/src/test/suite/htmlWidgetViewer.test.ts +++ b/src/test/suite/htmlWidgetViewer.test.ts @@ -13,6 +13,7 @@ suite('Session-aware HTML widget Viewer', () => { let read: sinon.SinonStub; const owners: session.Session[] = []; const panels: vscode.WebviewPanel[] = []; + const receivers = new Map Promise>(); setup(() => { sandbox = sinon.createSandbox(); @@ -21,12 +22,16 @@ suite('Session-aware HTML widget Viewer', () => { `
${String(file)}
`)); sandbox.stub(vscode.window, 'createWebviewPanel').callsFake((_type, title, _column, options) => { const disposed = new vscode.EventEmitter(); - const received = new vscode.EventEmitter(); + const listeners: Array<(message: unknown) => unknown> = []; let closed = false; const webview = { html: '', options, cspSource: 'webview-test:', asWebviewUri: (uri: vscode.Uri) => uri, - onDidReceiveMessage: received.event, postMessage: sandbox.stub().resolves(true), + onDidReceiveMessage: (listener: (message: unknown) => unknown) => { + listeners.push(listener); + return { dispose: () => { listeners.splice(listeners.indexOf(listener), 1); } }; + }, + postMessage: sandbox.stub().resolves(true), }; const panel = { title, viewColumn: vscode.ViewColumn.Two, reveal: sandbox.stub(), @@ -37,16 +42,18 @@ suite('Session-aware HTML widget Viewer', () => { onDidDispose: disposed.event, dispose: () => { if (closed) { return; } - closed = true; disposed.fire(); disposed.dispose(); received.dispose(); + closed = true; disposed.fire(); disposed.dispose(); listeners.length = 0; }, } as unknown as vscode.WebviewPanel; panels.push(panel); + receivers.set(panel, async message => { await Promise.all(listeners.map(listener => listener(message))); }); return panel; }); }); teardown(() => { panels.splice(0).forEach(panel => { panel.dispose(); }); + receivers.clear(); owners.splice(0).forEach(owner => session.unregisterSessionTransport(owner)); sandbox.restore(); }); @@ -59,7 +66,26 @@ suite('Session-aware HTML widget Viewer', () => { } function show(file: string, source: session.Session, title = 'Viewer', viewer: string | boolean = 'Two') { - return showWebView(file, title, viewer, source.sessionId); + return showWebView(file, title, viewer, session.getViewerSessionContext(source.sessionId)); + } + + function widgetDocument(panel: vscode.WebviewPanel): string { + const attribute = /data-widget-document="([^"]*)"/.exec(panel.webview.html)?.[1]; + assert.ok(attribute, 'Widgets must render in an iframe separate from the toolbar'); + return attribute.replace(/&(amp|lt|gt|quot|#39);/g, entity => ({ + '&': '&', '<': '<', '>': '>', '"': '"', ''': "'", + })[entity]!); + } + + async function navigate(panel: vscode.WebviewPanel, direction: 'back' | 'forward', generation?: number): Promise { + await receivers.get(panel)!({ + message: 'widget/navigate', direction, + generation: generation ?? Number(/data-generation="(\d+)"/.exec(panel.webview.html)?.[1]), + }); + } + + function disabled(panel: vscode.WebviewPanel, button: 'back' | 'forward'): boolean { + return new RegExp(` + + ${toolbar.index + 1} / ${toolbar.count} + ${toolbar.sessionHtml} + + + + `; + } + + const body = source.replace(/<(\w+)(.*)\s+(href|src)="(?!\w+:)/g, + `<$1 $2 $3="${baseUri}/`); + return ` diff --git a/src/webViewer/webview/index.ts b/src/webViewer/webview/index.ts index 68770826d..4e46a21ed 100644 --- a/src/webViewer/webview/index.ts +++ b/src/webViewer/webview/index.ts @@ -1,6 +1,10 @@ import { acquireVsCodeApi, VsCode } from '../webviewMessages'; +import { initializeViewerSession } from '../../viewerSession'; +import { initializeWidgetToolbar } from './toolbar'; const vscode: VsCode = acquireVsCodeApi(); +initializeViewerSession({ postMessage: message => vscode.postMessage(message) }); +initializeWidgetToolbar(vscode); const replaceReg = /vscode-webview:\/\//; const testReg = /vscode-webview:\/\/.*\.[A-Za-z/0-9_-]*?\/.+/; diff --git a/src/webViewer/webview/style.css b/src/webViewer/webview/style.css index 7cd4164ae..5eb869d52 100644 --- a/src/webViewer/webview/style.css +++ b/src/webViewer/webview/style.css @@ -11,3 +11,71 @@ body { display: block; padding: 10px; } + +html:has(.widget-viewer), .widget-viewer { + height: 100%; + overflow: hidden; +} + +.widget-viewer { + display: flex; + flex-direction: column; +} + +#widget-toolbar { + position: relative; + z-index: 1; + display: flex; + align-items: center; + flex-shrink: 0; + gap: 6px; + padding: 4px 8px; + border-bottom: 1px solid var(--vscode-panel-border); + background: var(--vscode-editor-background); + font: var(--vscode-font-size, 13px) var(--vscode-font-family, sans-serif); +} + +#widget-toolbar button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 4px; + border: 1px solid transparent; + border-radius: 3px; + background: transparent; + color: var(--vscode-foreground); + cursor: pointer; +} + +#widget-toolbar button:hover:not(:disabled) { + background: var(--vscode-toolbar-hoverBackground); +} + +#widget-toolbar button:disabled { + opacity: .45; + cursor: default; +} + +#widget-toolbar button:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +#widget-position { + color: var(--vscode-descriptionForeground); +} + +#widget-session-info { + display: inline-flex; + margin-left: auto; +} + +#widget-frame { + flex: 1; + min-height: 0; + width: 100%; + border: 0; + background: white; +} diff --git a/src/webViewer/webview/toolbar.ts b/src/webViewer/webview/toolbar.ts new file mode 100644 index 000000000..15a6f59e4 --- /dev/null +++ b/src/webViewer/webview/toolbar.ts @@ -0,0 +1,57 @@ +import type { VsCode } from '../webviewMessages'; + +export function initializeWidgetToolbar(vscode: VsCode): void { + const toolbar = document.getElementById('widget-toolbar'); + if (!toolbar) { return; } + const back = document.getElementById('widget-back') as HTMLButtonElement; + const forward = document.getElementById('widget-forward') as HTMLButtonElement; + const frame = document.getElementById('widget-frame') as HTMLIFrameElement; + if (frame.dataset.widgetDocument) { + const html = frame.dataset.widgetDocument; + delete frame.dataset.widgetDocument; + let written = false; + frame.addEventListener('load', () => { + if (written || !frame.contentDocument || !frame.contentDocument.location.pathname.endsWith('/fake.html')) { return; } + written = true; + frame.contentDocument.open(); + frame.contentDocument.write(html); + frame.contentDocument.close(); + }); + // srcdoc cannot load VS Code resources through its service worker. + // Use VS Code's empty frame on the same origin, retaining its webview ID. + const host = new URL(window.location.href); + const bootstrap = new URL('fake.html', host); + bootstrap.searchParams.set('id', host.searchParams.get('id') ?? ''); + if (host.searchParams.has('vscode-coi')) { bootstrap.searchParams.set('vscode-coi', host.searchParams.get('vscode-coi')!); } + frame.src = bootstrap.toString(); + } + const navigate = (direction: 'back' | 'forward') => { + if ((direction === 'back' ? back : forward).disabled) { return; } + back.disabled = true; forward.disabled = true; + vscode.postMessage({ message: 'widget/navigate', direction, generation: Number(toolbar.dataset.generation) }); + }; + back.onclick = () => navigate('back'); + forward.onclick = () => navigate('forward'); + const keydown = (event: KeyboardEvent) => { + if (event.altKey && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) { + event.preventDefault(); + navigate(event.key === 'ArrowLeft' ? 'back' : 'forward'); + } + }; + document.addEventListener('keydown', keydown); + document.addEventListener('mousedown', event => { + if (event.button === 3 || event.button === 4) { + event.preventDefault(); navigate(event.button === 3 ? 'back' : 'forward'); + } + }); + window.addEventListener('message', event => { + if (event.source !== frame.contentWindow) { return; } + const message: unknown = event.data; + if (!message || typeof message !== 'object' || !('message' in message) || message.message !== 'widget/bridge') { return; } + if ('direction' in message && (message.direction === 'back' || message.direction === 'forward')) { + navigate(message.direction); + } else if ('href' in message && typeof message.href === 'string' && /^(https?:|mailto:)/i.test(message.href)) { + vscode.postMessage({ message: 'linkClicked', href: message.href, scrollY: 0 }); + } + }); +} diff --git a/src/webViewer/webview/widget.ts b/src/webViewer/webview/widget.ts new file mode 100644 index 000000000..755311327 --- /dev/null +++ b/src/webViewer/webview/widget.ts @@ -0,0 +1,22 @@ +// The sandboxed widget has no VS Code API; communicate only with its host frame. +document.addEventListener('click', event => { + const anchor = event.target instanceof Element ? event.target.closest('a') : null; + if (!(anchor instanceof HTMLAnchorElement) || anchor.hasAttribute('download')) { return; } + const href = anchor.getAttribute('href') ?? ''; + if (/^(https?:|mailto:)/i.test(href)) { + event.preventDefault(); + window.parent.postMessage({ message: 'widget/bridge', href: anchor.href }, '*'); + } +}); +document.addEventListener('mousedown', event => { + if (event.button === 3 || event.button === 4) { + event.preventDefault(); + window.parent.postMessage({ message: 'widget/bridge', direction: event.button === 3 ? 'back' : 'forward' }, '*'); + } +}); +document.addEventListener('keydown', event => { + if (event.altKey && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) { + event.preventDefault(); + window.parent.postMessage({ message: 'widget/bridge', direction: event.key === 'ArrowLeft' ? 'back' : 'forward' }, '*'); + } +}); diff --git a/src/webViewer/webviewMessages.ts b/src/webViewer/webviewMessages.ts index 8093fd3a3..008ed6c64 100644 --- a/src/webViewer/webviewMessages.ts +++ b/src/webViewer/webviewMessages.ts @@ -27,4 +27,6 @@ export interface LinkClickedMessage extends IMessage { scrollY: number } -export type OutMessage = LogMessage | MouseClickMessage | LinkClickedMessage; +export type OutMessage = LogMessage | MouseClickMessage | LinkClickedMessage + | { message: 'viewer-session/ready' } + | { message: 'widget/navigate'; direction: 'back' | 'forward'; generation: number }; From 6b88d3cdec9226a8fb635277fd45888d8141c8db Mon Sep 17 00:00:00 2001 From: "4111978+Fred-Wu@users.noreply.github.com" <4111978+Fred-Wu@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:18:59 +1100 Subject: [PATCH 03/26] feat: preserve HTML Viewer history and add restore command - Retain session history and selection after the Viewer tab closes - Persist history across VS Code reloads and session reconnects - Add R: Restore HTML Viewer and reuse existing session panels - Clear saved history when the originating R process exits - Add lifecycle, persistence, and browser restoration tests --- package.json | 5 + src/extension.ts | 4 + src/session.ts | 11 +- src/test/node/htmlWidgetHistory.test.ts | 125 +++++++++++++ src/test/suite/dataViewerSessions.test.ts | 1 + src/test/suite/htmlWidgetBrowser.test.ts | 12 +- src/test/suite/htmlWidgetViewer.test.ts | 121 ++++++++++++- src/viewerSession.ts | 8 +- src/webViewer/history.ts | 103 +++++++++++ src/webViewer/index.ts | 210 ++++++++++++++++------ 10 files changed, 526 insertions(+), 74 deletions(-) create mode 100644 src/test/node/htmlWidgetHistory.test.ts create mode 100644 src/webViewer/history.ts diff --git a/package.json b/package.json index e2c3c2dde..f954da34e 100644 --- a/package.json +++ b/package.json @@ -254,6 +254,11 @@ "icon": "$(search)", "category": "R" }, + { + "command": "r.htmlViewer.restore", + "title": "Restore HTML Viewer", + "category": "R" + }, { "title": "Create R terminal", "category": "R", diff --git a/src/extension.ts b/src/extension.ts index b511094a1..91ac248b9 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, shutdownHtmlWidgetViewers } from './webViewer'; // global objects used in other files @@ -66,6 +67,7 @@ export async function activate(context: vscode.ExtensionContext): Promise('sessionWatcher') ?? false; @@ -163,6 +165,7 @@ export async function activate(context: vscode.ExtensionContext): Promise vscode.commands.executeCommand('editor.action.webvieweditor.showFind'), + 'r.htmlViewer.restore': restoreHtmlViewer, // (help related commands are registered in rHelp.initializeHelp) }; @@ -282,6 +285,7 @@ export function ensureWorkspaceViewer(): void { } export async function deactivate(): Promise { + await shutdownHtmlWidgetViewers(); (globalPlotManager as plotViewer.CommonPlotManager)?.dispose(); await session.shutdownSessionWatcher(); } diff --git a/src/session.ts b/src/session.ts index e215d96f1..9564caf33 100644 --- a/src/session.ts +++ b/src/session.ts @@ -26,7 +26,7 @@ import { showWebView } from './webViewer'; import { getListViewerScript, listViewerStyle, ListViewNavigation } from './listViewer'; import { getDataViewerScript, getDataViewerStyle, getDataViewerToolbarHtml } from './dataViewer'; import { getDataViewerColumnPanelHtml, getDataViewerColumnPanelScript, getDataViewerColumnPanelStyle } from './dataViewerColumnPanel'; -import { createViewerSessionContext, formatSessionLabel, getViewerSessionScript, ViewerSessionContext } from './viewerSession'; +import { createViewerSessionContext, formatSessionLabel, getViewerSessionScript, ViewerSessionContext, ViewerSessionSource } from './viewerSession'; export interface SessionInfo { version: string; @@ -115,7 +115,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( @@ -1247,8 +1247,11 @@ function attachViewerSessionBridge(panel: vscode.WebviewPanel, sessionId: string getViewerSessionContext(sessionId)?.attach(panel); } -export function getViewerSessionContext(sessionId: string | null): ViewerSessionContext | undefined { - const owner = sessions.get(sessionId ?? ''); +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 && (!attached || attached.host.toLowerCase() !== saved.host.toLowerCase() || attached.pid !== saved.pid) + ? saved : attached; return owner ? createViewerSessionContext(owner, sessionProcessMonitor) : undefined; } diff --git a/src/test/node/htmlWidgetHistory.test.ts b/src/test/node/htmlWidgetHistory.test.ts new file mode 100644 index 000000000..598089dc6 --- /dev/null +++ b/src/test/node/htmlWidgetHistory.test.ts @@ -0,0 +1,125 @@ +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('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/suite/dataViewerSessions.test.ts b/src/test/suite/dataViewerSessions.test.ts index af64c5fb0..18a26acb2 100644 --- a/src/test/suite/dataViewerSessions.test.ts +++ b/src/test/suite/dataViewerSessions.test.ts @@ -73,6 +73,7 @@ suite('Viewer session ownership', () => { }, }, onDidDispose: disposed.event, + onDidChangeViewState: sandbox.stub(), dispose: () => { if (!closed) { closed = true; diff --git a/src/test/suite/htmlWidgetBrowser.test.ts b/src/test/suite/htmlWidgetBrowser.test.ts index 449d30f1f..af14bafe9 100644 --- a/src/test/suite/htmlWidgetBrowser.test.ts +++ b/src/test/suite/htmlWidgetBrowser.test.ts @@ -5,12 +5,12 @@ import * as path from 'path'; import * as sinon from 'sinon'; import * as vscode from 'vscode'; import * as session from '../../session'; -import { showWebView } from '../../webViewer'; +import { restoreHtmlViewer, showWebView, shutdownHtmlWidgetViewers } from '../../webViewer'; import { mockExtensionContext } from '../common/mockvscode'; import { waitForValue } from '../common/sessionConnections'; suite('HTML widget browser rendering', () => { - test('loads relative dependencies in the iframe and navigates with the real toolbar', async () => { + test('loads relative dependencies, navigates, and restores a closed Viewer with the real toolbar', async () => { const sandbox = sinon.createSandbox(); const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'vscode-r-widget-browser-')); const panels: vscode.WebviewPanel[] = []; @@ -88,8 +88,16 @@ suite('HTML widget browser rendering', () => { await panel.webview.postMessage({ message: 'widget-test/navigate', direction: 'forward' }); await loaded('second', secondCount); assert.strictEqual(panel.title, 'Second widget'); + panel.dispose(); + const restoreCount = count('second'); + await restoreHtmlViewer(source.sessionId); + await loaded('second', restoreCount); + assert.strictEqual(panels.length, 2); + assert.strictEqual(panels[1].title, 'Second widget'); + assert.ok(panels[1].webview.html.includes('2 / 2')); } finally { panels.forEach(panel => { panel.dispose(); }); + await shutdownHtmlWidgetViewers(); session.unregisterSessionTransport(source); sandbox.restore(); fs.rmSync(directory, { recursive: true, force: true }); diff --git a/src/test/suite/htmlWidgetViewer.test.ts b/src/test/suite/htmlWidgetViewer.test.ts index 089299d17..40c696d5b 100644 --- a/src/test/suite/htmlWidgetViewer.test.ts +++ b/src/test/suite/htmlWidgetViewer.test.ts @@ -4,7 +4,9 @@ import * as sinon from 'sinon'; import * as vscode from 'vscode'; import * as session from '../../session'; import * as util from '../../util'; -import { showWebView } from '../../webViewer'; +import { initializeHtmlWidgetViewers, restoreHtmlViewer, showWebView, shutdownHtmlWidgetViewers } from '../../webViewer'; +import { extensionContext } from '../../extension'; +import { widgetHistoryKey, WidgetHistory } from '../../webViewer/history'; import { mockExtensionContext } from '../common/mockvscode'; import { deferred } from '../common/sessionConnections'; @@ -14,10 +16,17 @@ suite('Session-aware HTML widget Viewer', () => { const owners: session.Session[] = []; const panels: vscode.WebviewPanel[] = []; const receivers = new Map Promise>(); + const savedState = new Map(); setup(() => { sandbox = sinon.createSandbox(); 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(); + }); read = sandbox.stub(util, 'readContent').callsFake(file => Promise.resolve( `
${String(file)}
`)); sandbox.stub(vscode.window, 'createWebviewPanel').callsFake((_type, title, _column, options) => { @@ -40,6 +49,7 @@ suite('Session-aware HTML widget Viewer', () => { return webview; }, onDidDispose: disposed.event, + onDidChangeViewState: sandbox.stub(), dispose: () => { if (closed) { return; } closed = true; disposed.fire(); disposed.dispose(); listeners.length = 0; @@ -51,8 +61,9 @@ suite('Session-aware HTML widget Viewer', () => { }); }); - teardown(() => { + teardown(async () => { panels.splice(0).forEach(panel => { panel.dispose(); }); + await shutdownHtmlWidgetViewers(); receivers.clear(); owners.splice(0).forEach(owner => session.unregisterSessionTransport(owner)); sandbox.restore(); @@ -234,18 +245,112 @@ suite('Session-aware HTML widget Viewer', () => { assert.ok(widgetDocument(panels[0]).includes('/tmp/b.html')); }); - test('closing clears history and releases the session update listener', async () => { + 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); - await show('/tmp/b.html', source); + await show('/tmp/a.html', source, 'A'); + await show('/tmp/b.html', source, 'B'); + await navigate(panels[0], 'back'); const webview = panels[0].webview; panels[0].dispose(); - source.processExited = true; + await restoreHtmlViewer(source.sessionId); + assert.strictEqual(panels.length, 2); + assert.strictEqual(panels[1].title, 'A'); + assert.ok(panels[1].webview.html.includes('1 / 2')); + await navigate(panels[1], 'forward'); + assert.strictEqual(panels[1].title, 'B'); assert.strictEqual((webview.postMessage as sinon.SinonStub).callCount, 0); + 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; + const information = sandbox.stub(vscode.window, 'showInformationMessage'); + 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.ok(panels[1].webview.html.includes('1 / 1')); - assert.ok(disabled(panels[1], 'back') && disabled(panels[1], 'forward')); + assert.ok(panels[1].webview.html.includes('3 / 3')); + await navigate(panels[1], 'back'); + assert.strictEqual(panels[1].title, '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); + initializeHtmlWidgetViewers(extensionContext); + const reconnected = owner(source.sessionId); + reconnected.pid = source.pid; + await restoreHtmlViewer(source.sessionId); + assert.strictEqual(panels.length, 2); + assert.strictEqual(panels[1].title, 'A'); + assert.ok(panels[1].webview.html.includes('1 / 2')); + assert.ok(panels[1].webview.html.includes(`R 4.6.1: ${source.pid}`)); + await navigate(panels[1], 'forward'); + assert.strictEqual(panels[1].title, 'B'); + await show('/tmp/c.html', reconnected, 'C'); + assert.strictEqual(panels.length, 2); + assert.ok(panels[1].webview.html.includes('3 / 3')); + }); + + 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(panels[1].title, 'Detached widget'); + assert.ok(panels[1].webview.html.includes(`R 4.6.1: ${source.pid}`)); + assert.ok(!panels[1].webview.html.includes(`R 4.6.1: ${other.pid}`)); + }); + + 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(panels[2].title, 'Second'); + assert.ok(panels[2].webview.html.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(panels[1].title, 'A'); + const stored = savedState.get(widgetHistoryKey) as WidgetHistory[]; + assert.strictEqual(stored[0].index, 0); }); test('session info updates after process exit without reloading the widget or its history', async () => { diff --git a/src/viewerSession.ts b/src/viewerSession.ts index 216411917..c34f88b7f 100644 --- a/src/viewerSession.ts +++ b/src/viewerSession.ts @@ -1,7 +1,7 @@ import type { WebviewPanel } from 'vscode'; import type { SessionProcessMonitor } from './sessionProcessMonitor'; -interface ViewerSessionSource { +export interface ViewerSessionSource { sessionId: string; host: string; pid: string; @@ -11,6 +11,9 @@ interface ViewerSessionSource { export interface ViewerSessionContext { readonly sessionId: string; + readonly source: Readonly; + readonly hasExited: boolean; + observeExit(listener: () => void): { dispose(): void }; getHtml(): string; attach(panel: WebviewPanel): void; } @@ -21,6 +24,9 @@ export function createViewerSessionContext( ): 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 ''; } diff --git a/src/webViewer/history.ts b/src/webViewer/history.ts new file mode 100644 index 000000000..bace7bc9f --- /dev/null +++ b/src/webViewer/history.ts @@ -0,0 +1,103 @@ +import type { Memento, ViewColumn } from 'vscode'; +import type { ViewerSessionContext, ViewerSessionSource } from '../viewerSession'; + +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(); + private readonly subscriptions = new Map(); + private disposed = false; + private writes: Promise = Promise.resolve(); + + constructor( + private readonly state: Memento, + private readonly resolveSession: (source: ViewerSessionSource) => ViewerSessionContext, + ) { + const saved = state.get(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(record, 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 && (record.source.host.toLowerCase() !== session.source.host.toLowerCase() || record.source.pid !== session.source.pid)) { + 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(record, session); + } + return record; + } + + save(record: WidgetHistory): Promise { + 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(record: WidgetHistory, session: ViewerSessionContext): void { + this.subscriptions.get(session.sessionId)?.dispose(); + this.subscriptions.set(session.sessionId, session.observeExit(() => this.forget(session.sessionId))); + } + + private persist(): Promise { + 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 { 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; + 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 24b940918..8fdad3bb6 100644 --- a/src/webViewer/index.ts +++ b/src/webViewer/index.ts @@ -1,22 +1,23 @@ 'use strict'; import * as path from 'path'; -import { Uri, ViewColumn, Webview, WebviewPanel, window, env } from 'vscode'; -import { readContent, UriIcon } from '../util'; +import { ExtensionContext, Uri, ViewColumn, Webview, WebviewPanel, window, env } from 'vscode'; +import { config, readContent, UriIcon } from '../util'; import { extensionContext } from '../extension'; import { ViewerSessionContext, viewerSessionStyle } from '../viewerSession'; +import { activeSession, getViewerSessionContext } from '../session'; +import { WidgetHistory, WidgetHistoryStore, widgetHistoryLimit } from './history'; interface WidgetViewer { panel: WebviewPanel; revision: number; disposed: boolean; session?: ViewerSessionContext; - history: Array<{ file: string; title: string }>; - index: number; + state: Pick; + saved?: WidgetHistory; } -const widgetViewers = new Map(); -const historyLimit = 50; +let manager: HtmlWidgetViewerManager | undefined; interface WidgetToolbar { index: number; @@ -33,7 +34,7 @@ function escapeHtml(text: string): string { async function renderWidget(entry: WidgetViewer): Promise { const generation = ++entry.revision; - const item = entry.history[entry.index]; + const item = entry.state.history[entry.state.index]; const dir = path.dirname(item.file); const { panel } = entry; const resourceRoots = [ @@ -42,7 +43,7 @@ async function renderWidget(entry: WidgetViewer): Promise { Uri.file(path.join(extensionContext.extensionPath, 'dist/resources')), ]; const toolbar = entry.session ? { - index: entry.index, count: entry.history.length, generation, sessionHtml: entry.session.getHtml(), + index: entry.state.index, count: entry.state.history.length, generation, sessionHtml: entry.session.getHtml(), } : undefined; const html = await getWebviewHtml(panel.webview, item.file, item.title, dir, toolbar); if (!entry.disposed && entry.revision === generation) { @@ -53,65 +54,156 @@ async function renderWidget(entry: WidgetViewer): Promise { } } +/** Initialize retained history once per extension host, including closed Viewer tabs. */ +export function initializeHtmlWidgetViewers(context: ExtensionContext): HtmlWidgetViewerManager { + manager?.dispose(); + manager = new HtmlWidgetViewerManager(context); + context.subscriptions.push(manager); + return manager; +} + +function getManager(): HtmlWidgetViewerManager { + if (!manager || manager.disposed || manager.context !== extensionContext) { + return initializeHtmlWidgetViewers(extensionContext); + } + return manager; +} + export async function showWebView( file: string, title: string, viewer: string | boolean, session?: ViewerSessionContext, ): Promise { - console.info(`[showWebView] file: ${file}, viewer: ${viewer.toString()}`); if (viewer === false) { void env.openExternal(Uri.file(file)); - } else { - const dir = path.dirname(file); - const resourceRoots = [ - Uri.file(dir), - Uri.file(path.join(extensionContext.extensionPath, 'dist/webviews/webview')), - ]; - const sessionId = session?.sessionId; - let entry = sessionId ? widgetViewers.get(sessionId) : undefined; - if (!entry) { - const panel = window.createWebviewPanel('webview', title, - { - preserveFocus: true, - viewColumn: ViewColumn[String(viewer) as keyof typeof ViewColumn], - }, - { - enableScripts: true, - enableFindWidget: true, - retainContextWhenHidden: true, - localResourceRoots: resourceRoots, - }); - entry = { panel, revision: 0, disposed: false, session, history: [], index: -1 }; - // Register before loading HTML so concurrent requests reuse the panel. - if (sessionId) { widgetViewers.set(sessionId, entry); } - const created = entry; - panel.onDidDispose(() => { - created.disposed = true; - created.history = []; - if (sessionId && widgetViewers.get(sessionId) === created) { - widgetViewers.delete(sessionId); - } - }); - panel.iconPath = new UriIcon('globe'); - session?.attach(panel); - panel.webview.onDidReceiveMessage(async (msg: { - message: string; href?: string; direction?: string; generation?: number; - }) => { - if (msg.message === 'linkClicked' && msg.href) { - void env.openExternal(Uri.parse(msg.href)); - } else if (msg.message === 'widget/navigate' && session && !created.disposed && - msg.generation === created.revision && (msg.direction === 'back' || msg.direction === 'forward')) { - const index = created.index + (msg.direction === 'back' ? -1 : 1); - if (index < 0 || index >= created.history.length) { return; } - created.index = index; - await renderWidget(created); - } - }); + return; + } + await getManager().show(file, title, viewer, session); +} + +export async function restoreHtmlViewer(sessionId?: string): Promise { + await getManager().restore(sessionId); +} + +export async function shutdownHtmlWidgetViewers(): Promise { + manager?.dispose(); + await manager?.histories.flush(); +} + +class HtmlWidgetViewerManager { + readonly histories: WidgetHistoryStore; + readonly viewers = new Map(); + disposed = false; + + constructor(readonly context: ExtensionContext) { + this.histories = new WidgetHistoryStore(context.workspaceState, + source => getViewerSessionContext(source.sessionId, source)!); + } + + async show(file: string, title: string, viewer: string | boolean, session?: ViewerSessionContext): Promise { + const saved = session ? this.histories.remember(session) : undefined; + const entry = this.open(title, 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 renderWidget(entry); + await pending; + } + + async restore(sessionId?: string): Promise { + // Polling can discover an exit while the session picker is open. + const available = [...this.histories.entries.values()].filter(record => { + if (getViewerSessionContext(record.source.sessionId, record.source)!.hasExited) { + this.histories.forget(record.source.sessionId); + return false; + } + return record.history.length > 0; + }); + let record = sessionId ? this.histories.entries.get(sessionId) : + available.find(item => item.source.sessionId === activeSession?.sessionId); + 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 = getViewerSessionContext(record.source.sessionId, 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; } - entry.history.push({ file, title }); - if (entry.history.length > historyLimit) { entry.history.shift(); } - entry.index = entry.history.length - 1; + const viewer = record.viewColumn ? ViewColumn[record.viewColumn] : + config().get>('session.viewers.viewColumn')?.viewer ?? 'Two'; + // Explicit restoration opens a Viewer even if automatic viewing is disabled. + const entry = this.open(record.history[record.index].title, viewer, session, record); + const pending = this.save(entry); await renderWidget(entry); + await pending; + } + + private open(title: string, viewer: string | boolean, session?: ViewerSessionContext, saved?: WidgetHistory): WidgetViewer { + const sessionId = session?.sessionId; + const existing = sessionId ? this.viewers.get(sessionId) : undefined; + if (existing) { return existing; } + const panel = window.createWebviewPanel('webview', title, + { preserveFocus: true, viewColumn: ViewColumn[String(viewer) as keyof typeof ViewColumn] ?? ViewColumn.Two }, + { enableScripts: true, enableFindWidget: true, retainContextWhenHidden: true }); + const entry: WidgetViewer = { + panel, revision: 0, disposed: false, session, saved, + state: saved ?? { history: [], index: -1 }, + }; + // Register before loading HTML so concurrent requests reuse the panel. + if (sessionId) { this.viewers.set(sessionId, entry); } + panel.onDidDispose(() => { + entry.disposed = true; + if (!this.disposed) { void this.save(entry); } + if (sessionId && this.viewers.get(sessionId) === entry) { this.viewers.delete(sessionId); } + }); + panel.onDidChangeViewState(() => { if (!entry.disposed) { void this.save(entry); } }); + panel.iconPath = new UriIcon('globe'); + session?.attach(panel); + panel.webview.onDidReceiveMessage(async (msg: { + message: string; href?: string; direction?: string; generation?: number; + }) => { + if (msg.message === 'linkClicked' && msg.href) { + void env.openExternal(Uri.parse(msg.href)); + } else if (msg.message === 'widget/navigate' && session && !entry.disposed && + msg.generation === entry.revision && (msg.direction === 'back' || msg.direction === 'forward')) { + const index = entry.state.index + (msg.direction === 'back' ? -1 : 1); + if (index < 0 || index >= entry.state.history.length) { return; } + entry.state.index = index; + const pending = this.save(entry); + await renderWidget(entry); + await pending; + } + }); + return entry; + } + + private save(entry: WidgetViewer): Promise { + if (!entry.disposed) { entry.state.viewColumn = entry.panel.viewColumn; } + return entry.saved ? this.histories.save(entry.saved) : Promise.resolve(); + } + + dispose(): void { + if (this.disposed) { return; } + this.viewers.forEach(entry => { void this.save(entry); }); + this.disposed = true; + this.viewers.forEach(entry => { entry.panel.dispose(); }); + this.viewers.clear(); + this.histories.dispose(); } - console.info('[showWebView] Done'); } export async function getWebviewHtml( From 9ae29e17fc0323039defeaac565d07d5bbf7a1cc Mon Sep 17 00:00:00 2001 From: "4111978+Fred-Wu@users.noreply.github.com" <4111978+Fred-Wu@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:39:07 +1100 Subject: [PATCH 04/26] feat: unify standalone HTML outputs in a session-aware HTML Viewer - Share viewer, page_viewer, and local browser HTML in session history - Include profvis outputs in navigation and restoration - Normalise tab names to HTML Viewer - Preserve output titles and existing HTTP/HTTPS browser routing --- src/session.ts | 4 +- src/test/suite/dataViewerSessions.test.ts | 95 +++++++++++++++++++++++ src/test/suite/htmlWidgetBrowser.test.ts | 6 +- src/test/suite/htmlWidgetViewer.test.ts | 38 +++++---- src/webViewer/index.ts | 11 +-- 5 files changed, 129 insertions(+), 25 deletions(-) diff --git a/src/session.ts b/src/session.ts index 9564caf33..f7fd4de11 100644 --- a/src/session.ts +++ b/src/session.ts @@ -2273,8 +2273,10 @@ async function handleNotification(message: Record, socket: IpcS } } else { if (url.toLowerCase().endsWith('.html') || url.toLowerCase().endsWith('.htm')) { + // 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, - method === 'webview' ? getViewerSessionContext(socket._sessionId ?? null) : undefined); + getViewerSessionContext(socket._sessionId ?? null)); } else { await showDataView('object', 'txt', title, url, String(viewColumn)); } diff --git a/src/test/suite/dataViewerSessions.test.ts b/src/test/suite/dataViewerSessions.test.ts index 18a26acb2..77968d4d5 100644 --- a/src/test/suite/dataViewerSessions.test.ts +++ b/src/test/suite/dataViewerSessions.test.ts @@ -7,6 +7,7 @@ import * as vscode from 'vscode'; import * as extension from '../../extension'; import * as session from '../../session'; import * as util from '../../util'; +import { restoreHtmlViewer, shutdownHtmlWidgetViewers } from '../../webViewer'; import { mockExtensionContext } from '../common/mockvscode'; interface Request { @@ -89,6 +90,7 @@ suite('Viewer session ownership', () => { teardown(async () => { panels.splice(0).forEach(item => { item.panel.dispose(); }); + await shutdownHtmlWidgetViewers(); for (const client of clients.splice(0)) { await session.cleanupSession(client.id); client.socket.destroy(); @@ -164,6 +166,99 @@ suite('Viewer session ownership', () => { assert.strictEqual(session.activeSession?.sessionId, second.id); }); + function outputTitle(panel: vscode.WebviewPanel): string | undefined { + assert.strictEqual(panel.title, 'HTML Viewer'); + return /(.*?)<\/title>/.exec(panel.webview.html)?.[1]; + } + + test('page_viewer and browser HTML share widget history, source ownership, and restoration', async () => { + sandbox.stub(util, 'readContent').callsFake(file => Promise.resolve(`<div>${String(file)}</div>`)); + 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.ok(viewer.panel.webview.html.includes('2 / 2')); + assert.ok(viewer.panel.webview.html.includes('R 4.6.1: 12101')); + + notify(source, 'browser', { url: '/tmp/report.HTM', title: 'Report' }); + await waitFor(() => viewer.panel.webview.html.includes('<title>Report')); + assert.strictEqual(panels.length, 1); + assert.ok(viewer.panel.webview.html.includes('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.ok(panels[1].panel.webview.html.includes('2 / 3')); + assert.ok(panels[1].panel.webview.html.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('Other profile')); + assert.ok(panels[2].panel.webview.html.includes('1 / 1')); + assert.strictEqual(outputTitle(panels[2].panel), 'Other profile'); + assert.ok(panels[2].panel.webview.html.includes('R 4.6.2: 12102')); + }); + + test('server URLs from every HTML route use Simple Browser and stay outside widget history', async () => { + sandbox.stub(util, 'readContent').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('1 / 1')); + 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, + }); + sandbox.stub(util, 'readContent').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('1 / 1')); + 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, '/tmp/profvis.html'); + notify(source, 'browser', { url: '/tmp/report.htm' }); + await waitFor(() => external.callCount === 2); + assert.strictEqual((external.lastCall.args[0] as vscode.Uri).fsPath, '/tmp/report.htm'); + 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 { diff --git a/src/test/suite/htmlWidgetBrowser.test.ts b/src/test/suite/htmlWidgetBrowser.test.ts index af14bafe9..d7b8625cd 100644 --- a/src/test/suite/htmlWidgetBrowser.test.ts +++ b/src/test/suite/htmlWidgetBrowser.test.ts @@ -82,18 +82,18 @@ suite('HTML widget browser rendering', () => { const firstCount = count('first'); await panel.webview.postMessage({ message: 'widget-test/navigate', direction: 'back' }); await loaded('first', firstCount); - assert.strictEqual(panel.title, 'First widget'); + assert.strictEqual(panel.title, 'HTML Viewer'); await installControls('first'); const secondCount = count('second'); await panel.webview.postMessage({ message: 'widget-test/navigate', direction: 'forward' }); await loaded('second', secondCount); - assert.strictEqual(panel.title, 'Second widget'); + assert.strictEqual(panel.title, 'HTML Viewer'); panel.dispose(); const restoreCount = count('second'); await restoreHtmlViewer(source.sessionId); await loaded('second', restoreCount); assert.strictEqual(panels.length, 2); - assert.strictEqual(panels[1].title, 'Second widget'); + assert.strictEqual(panels[1].title, 'HTML Viewer'); assert.ok(panels[1].webview.html.includes('2 / 2')); } finally { panels.forEach(panel => { panel.dispose(); }); diff --git a/src/test/suite/htmlWidgetViewer.test.ts b/src/test/suite/htmlWidgetViewer.test.ts index 40c696d5b..d05e1de49 100644 --- a/src/test/suite/htmlWidgetViewer.test.ts +++ b/src/test/suite/htmlWidgetViewer.test.ts @@ -80,6 +80,11 @@ suite('Session-aware HTML widget Viewer', () => { return showWebView(file, title, viewer, session.getViewerSessionContext(source.sessionId)); } + function outputTitle(panel: vscode.WebviewPanel): string | undefined { + assert.strictEqual(panel.title, 'HTML Viewer'); + return /(.*?)<\/title>/.exec(panel.webview.html)?.[1]; + } + function widgetDocument(panel: vscode.WebviewPanel): string { const attribute = /data-widget-document="([^"]*)"/.exec(panel.webview.html)?.[1]; assert.ok(attribute, 'Widgets must render in an iframe separate from the toolbar'); @@ -106,7 +111,7 @@ suite('Session-aware HTML widget Viewer', () => { 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(panels[0].title, 'Updated Viewer'); + 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('<base href="file:///tmp/widget-c/">')); assert.ok(widgetDocument(panels[0]).includes('src="lib/widget.js"')); @@ -130,7 +135,7 @@ suite('Session-aware HTML widget Viewer', () => { slow.resolve('<div>Old HTML</div>'); await pending; assert.strictEqual(panels[0].webview.html, latest); - assert.strictEqual(panels[0].title, 'Latest'); + assert.strictEqual(outputTitle(panels[0]), 'Latest'); assert.strictEqual(panels[0].webview.options.localResourceRoots?.[0].fsPath, '/tmp/latest'); }); @@ -175,6 +180,7 @@ suite('Session-aware HTML widget Viewer', () => { 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 () => { @@ -189,7 +195,7 @@ suite('Session-aware HTML widget Viewer', () => { assert.ok(!disabled(panel, 'back') && disabled(panel, 'forward')); assert.ok(panel.webview.html.includes('2 / 2')); await navigate(panel, 'back'); - assert.strictEqual(panel.title, 'A'); + assert.strictEqual(outputTitle(panel), 'A'); assert.ok(widgetDocument(panel).includes('/tmp/a/index.html')); assert.strictEqual(panel.webview.options.localResourceRoots?.[0].fsPath, '/tmp/a'); assert.ok(disabled(panel, 'back') && !disabled(panel, 'forward')); @@ -197,7 +203,7 @@ suite('Session-aware HTML widget Viewer', () => { await navigate(panel, 'back'); assert.strictEqual(panel.webview.html, firstHtml); await navigate(panel, 'forward'); - assert.strictEqual(panel.title, 'B'); + 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); @@ -211,9 +217,9 @@ suite('Session-aware HTML widget Viewer', () => { await show('/tmp/c.html', source, 'C'); assert.ok(panels[0].webview.html.includes('3 / 3')); await navigate(panels[0], 'back'); - assert.strictEqual(panels[0].title, 'B'); + assert.strictEqual(outputTitle(panels[0]), 'B'); await navigate(panels[0], 'back'); - assert.strictEqual(panels[0].title, 'A'); + assert.strictEqual(outputTitle(panels[0]), 'A'); }); test('late history loads and stale toolbar messages cannot replace newer output', async () => { @@ -230,7 +236,7 @@ suite('Session-aware HTML widget Viewer', () => { await pending; await navigate(panels[0], 'back', generation); assert.strictEqual(panels[0].webview.html, latest); - assert.strictEqual(panels[0].title, 'C'); + assert.strictEqual(outputTitle(panels[0]), 'C'); }); test('missing historical files show an error and Forward still returns to available output', async () => { @@ -254,10 +260,10 @@ suite('Session-aware HTML widget Viewer', () => { panels[0].dispose(); await restoreHtmlViewer(source.sessionId); assert.strictEqual(panels.length, 2); - assert.strictEqual(panels[1].title, 'A'); + assert.strictEqual(outputTitle(panels[1]), 'A'); assert.ok(panels[1].webview.html.includes('1 / 2')); await navigate(panels[1], 'forward'); - assert.strictEqual(panels[1].title, 'B'); + assert.strictEqual(outputTitle(panels[1]), 'B'); assert.strictEqual((webview.postMessage as sinon.SinonStub).callCount, 0); await restoreHtmlViewer(source.sessionId); assert.strictEqual(panels.length, 2, 'Restoration must reuse an open Viewer'); @@ -285,7 +291,7 @@ suite('Session-aware HTML widget Viewer', () => { assert.strictEqual(panels.length, 2); assert.ok(panels[1].webview.html.includes('3 / 3')); await navigate(panels[1], 'back'); - assert.strictEqual(panels[1].title, 'B'); + assert.strictEqual(outputTitle(panels[1]), 'B'); }); test('restores persisted selection after extension-host recreation and transport reconnect', async () => { @@ -300,11 +306,11 @@ suite('Session-aware HTML widget Viewer', () => { reconnected.pid = source.pid; await restoreHtmlViewer(source.sessionId); assert.strictEqual(panels.length, 2); - assert.strictEqual(panels[1].title, 'A'); + assert.strictEqual(outputTitle(panels[1]), 'A'); assert.ok(panels[1].webview.html.includes('1 / 2')); assert.ok(panels[1].webview.html.includes(`R 4.6.1: ${source.pid}`)); await navigate(panels[1], 'forward'); - assert.strictEqual(panels[1].title, 'B'); + assert.strictEqual(outputTitle(panels[1]), 'B'); await show('/tmp/c.html', reconnected, 'C'); assert.strictEqual(panels.length, 2); assert.ok(panels[1].webview.html.includes('3 / 3')); @@ -318,7 +324,7 @@ suite('Session-aware HTML widget Viewer', () => { const other = owner('html-history-active'); sandbox.stub(session, 'activeSession').value(other); await restoreHtmlViewer(source.sessionId); - assert.strictEqual(panels[1].title, 'Detached widget'); + assert.strictEqual(outputTitle(panels[1]), 'Detached widget'); assert.ok(panels[1].webview.html.includes(`R 4.6.1: ${source.pid}`)); assert.ok(!panels[1].webview.html.includes(`R 4.6.1: ${other.pid}`)); }); @@ -335,7 +341,7 @@ suite('Session-aware HTML widget Viewer', () => { Promise.resolve((items as Array<vscode.QuickPickItem>)[1])); await restoreHtmlViewer(); sinon.assert.calledOnce(pick); - assert.strictEqual(panels[2].title, 'Second'); + assert.strictEqual(outputTitle(panels[2]), 'Second'); assert.ok(panels[2].webview.html.includes(`R 4.6.1: ${second.pid}`)); }); @@ -348,7 +354,7 @@ suite('Session-aware HTML widget Viewer', () => { await restoreHtmlViewer(source.sessionId); assert.ok(widgetDocument(panels[1]).includes('This HTML widget could not be loaded')); await navigate(panels[1], 'back'); - assert.strictEqual(panels[1].title, 'A'); + assert.strictEqual(outputTitle(panels[1]), 'A'); const stored = savedState.get(widgetHistoryKey) as WidgetHistory[]; assert.strictEqual(stored[0].index, 0); }); @@ -386,7 +392,7 @@ suite('Session-aware HTML widget Viewer', () => { for (let i = 0; i < 51; i++) { await show(`/tmp/widget-${i}.html`, source, `Widget ${i}`); } assert.ok(panels[0].webview.html.includes('50 / 50')); for (let i = 0; i < 49; i++) { await navigate(panels[0], 'back'); } - assert.strictEqual(panels[0].title, 'Widget 1'); + assert.strictEqual(outputTitle(panels[0]), 'Widget 1'); assert.ok(disabled(panels[0], 'back')); }); }); diff --git a/src/webViewer/index.ts b/src/webViewer/index.ts index 8fdad3bb6..bc01c4f8c 100644 --- a/src/webViewer/index.ts +++ b/src/webViewer/index.ts @@ -18,6 +18,7 @@ interface WidgetViewer { } let manager: HtmlWidgetViewerManager | undefined; +const htmlViewerTitle = 'HTML Viewer'; interface WidgetToolbar { index: number; @@ -47,7 +48,7 @@ async function renderWidget(entry: WidgetViewer): Promise<void> { } : undefined; const html = await getWebviewHtml(panel.webview, item.file, item.title, dir, toolbar); if (!entry.disposed && entry.revision === generation) { - panel.title = item.title; + panel.title = htmlViewerTitle; panel.webview.options = { ...panel.webview.options, localResourceRoots: resourceRoots }; panel.webview.html = html; panel.reveal(panel.viewColumn, true); @@ -100,7 +101,7 @@ class HtmlWidgetViewerManager { 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(title, viewer, session, saved); + 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; @@ -146,17 +147,17 @@ class HtmlWidgetViewerManager { const viewer = record.viewColumn ? ViewColumn[record.viewColumn] : config().get<Record<string, string>>('session.viewers.viewColumn')?.viewer ?? 'Two'; // Explicit restoration opens a Viewer even if automatic viewing is disabled. - const entry = this.open(record.history[record.index].title, viewer, session, record); + const entry = this.open(viewer, session, record); const pending = this.save(entry); await renderWidget(entry); await pending; } - private open(title: string, viewer: string | boolean, session?: ViewerSessionContext, saved?: WidgetHistory): WidgetViewer { + private open(viewer: string | boolean, session?: ViewerSessionContext, saved?: WidgetHistory): WidgetViewer { const sessionId = session?.sessionId; const existing = sessionId ? this.viewers.get(sessionId) : undefined; if (existing) { return existing; } - const panel = window.createWebviewPanel('webview', title, + const panel = window.createWebviewPanel('webview', htmlViewerTitle, { preserveFocus: true, viewColumn: ViewColumn[String(viewer) as keyof typeof ViewColumn] ?? ViewColumn.Two }, { enableScripts: true, enableFindWidget: true, retainContextWhenHidden: true }); const entry: WidgetViewer = { From 8a9205661ac3beec73d80ec5447d304922c290e5 Mon Sep 17 00:00:00 2001 From: "4111978+Fred-Wu@users.noreply.github.com" <4111978+Fred-Wu@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:00:07 +1100 Subject: [PATCH 05/26] feat: add a remove button to remove outputs from history --- src/test/suite/htmlWidgetBrowser.test.ts | 16 +++- src/test/suite/htmlWidgetToolbar.test.ts | 21 ++++- src/test/suite/htmlWidgetViewer.test.ts | 109 ++++++++++++++++++++++- src/webViewer/index.ts | 31 ++++--- src/webViewer/webview/toolbar.ts | 9 +- src/webViewer/webviewMessages.ts | 3 +- 6 files changed, 169 insertions(+), 20 deletions(-) diff --git a/src/test/suite/htmlWidgetBrowser.test.ts b/src/test/suite/htmlWidgetBrowser.test.ts index d7b8625cd..5c03ccb98 100644 --- a/src/test/suite/htmlWidgetBrowser.test.ts +++ b/src/test/suite/htmlWidgetBrowser.test.ts @@ -10,7 +10,7 @@ import { mockExtensionContext } from '../common/mockvscode'; import { waitForValue } from '../common/sessionConnections'; suite('HTML widget browser rendering', () => { - test('loads relative dependencies, navigates, and restores a closed Viewer with the real toolbar', async () => { + test('loads relative dependencies, navigates, removes an output, and restores with the real toolbar', async () => { const sandbox = sinon.createSandbox(); const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'vscode-r-widget-browser-')); const panels: vscode.WebviewPanel[] = []; @@ -59,6 +59,8 @@ suite('HTML widget browser rendering', () => { window.addEventListener('message', event => { if (event.data?.message === 'widget-test/navigate') { document.getElementById('widget-' + event.data.direction).click(); + } else if (event.data?.message === 'widget-test/remove') { + document.getElementById('widget-remove').click(); } else if (event.data?.message === 'widget-test/inspect') { const toolbar = document.getElementById('widget-toolbar'); const frame = document.getElementById('widget-frame'); @@ -88,13 +90,19 @@ suite('HTML widget browser rendering', () => { await panel.webview.postMessage({ message: 'widget-test/navigate', direction: 'forward' }); await loaded('second', secondCount); assert.strictEqual(panel.title, 'HTML Viewer'); + await installControls('second'); + const removeCount = count('first'); + await panel.webview.postMessage({ message: 'widget-test/remove' }); + await loaded('first', removeCount); + assert.ok(panel.webview.html.includes('1 / 1')); + assert.ok(fs.existsSync(second), 'Removing history must preserve the original HTML file'); panel.dispose(); - const restoreCount = count('second'); + const restoreCount = count('first'); await restoreHtmlViewer(source.sessionId); - await loaded('second', restoreCount); + await loaded('first', restoreCount); assert.strictEqual(panels.length, 2); assert.strictEqual(panels[1].title, 'HTML Viewer'); - assert.ok(panels[1].webview.html.includes('2 / 2')); + assert.ok(panels[1].webview.html.includes('1 / 1')); } finally { panels.forEach(panel => { panel.dispose(); }); await shutdownHtmlWidgetViewers(); diff --git a/src/test/suite/htmlWidgetToolbar.test.ts b/src/test/suite/htmlWidgetToolbar.test.ts index 997284bac..c8d725e41 100644 --- a/src/test/suite/htmlWidgetToolbar.test.ts +++ b/src/test/suite/htmlWidgetToolbar.test.ts @@ -3,15 +3,17 @@ import * as vm from 'vm'; import { initializeWidgetToolbar } from '../../webViewer/webview/toolbar'; suite('HTML widget toolbar controls', () => { - function toolbar(backDisabled = false, forwardDisabled = false) { + function toolbar(backDisabled = false, forwardDisabled = false, removeDisabled = false) { const back = { disabled: backDisabled, onclick: undefined as (() => void) | undefined }; const forward = { disabled: forwardDisabled, onclick: undefined as (() => void) | undefined }; + const remove = { disabled: removeDisabled, onclick: undefined as (() => void) | undefined }; const frame = { contentWindow: {}, dataset: {} }; const posted: unknown[] = []; const events = new Map<string, (event: unknown) => void>(); const elements = new Map<string, unknown>([ ['widget-toolbar', { dataset: { generation: '7' } }], ['widget-back', back], ['widget-forward', forward], ['widget-frame', frame], + ['widget-remove', remove], ]); vm.runInNewContext(`(${initializeWidgetToolbar.toString()})(vscode)`, { document: { @@ -21,7 +23,7 @@ suite('HTML widget toolbar controls', () => { window: { addEventListener: (name: string, listener: (event: unknown) => void) => events.set(name, listener) }, vscode: { postMessage: (message: unknown) => posted.push(JSON.parse(JSON.stringify(message))) }, }); - return { back, forward, frame, posted, events }; + return { back, forward, remove, frame, posted, events }; } test('disabled boundaries do nothing and enabled clicks send the displayed generation once', () => { @@ -30,11 +32,24 @@ suite('HTML widget toolbar controls', () => { assert.strictEqual(first.posted.length, 0); first.forward.onclick!(); assert.deepStrictEqual(first.posted, [{ message: 'widget/navigate', direction: 'forward', generation: 7 }]); - assert.ok(first.back.disabled && first.forward.disabled); + assert.ok(first.back.disabled && first.forward.disabled && first.remove.disabled); + first.remove.onclick!(); first.forward.onclick!(); assert.strictEqual(first.posted.length, 1); }); + test('remove sends the displayed generation once and locks navigation until rendering finishes', () => { + const empty = toolbar(true, true, true); + empty.remove.onclick!(); + assert.strictEqual(empty.posted.length, 0); + const widget = toolbar(); + widget.remove.onclick!(); + assert.deepStrictEqual(widget.posted, [{ message: 'widget/remove', generation: 7 }]); + assert.ok(widget.back.disabled && widget.forward.disabled && widget.remove.disabled); + widget.remove.onclick!(); widget.back.onclick!(); widget.forward.onclick!(); + assert.strictEqual(widget.posted.length, 1); + }); + test('keyboard and mouse shortcuts navigate in the host and inside the widget', () => { const keyboard = toolbar(); let prevented = false; diff --git a/src/test/suite/htmlWidgetViewer.test.ts b/src/test/suite/htmlWidgetViewer.test.ts index d05e1de49..8f70e68b5 100644 --- a/src/test/suite/htmlWidgetViewer.test.ts +++ b/src/test/suite/htmlWidgetViewer.test.ts @@ -100,7 +100,14 @@ suite('Session-aware HTML widget Viewer', () => { }); } - function disabled(panel: vscode.WebviewPanel, button: 'back' | 'forward'): boolean { + async function remove(panel: vscode.WebviewPanel, generation?: number): Promise<void> { + await receivers.get(panel)!({ + message: 'widget/remove', + generation: generation ?? Number(/data-generation="(\d+)"/.exec(panel.webview.html)?.[1]), + }); + } + + function disabled(panel: vscode.WebviewPanel, button: 'back' | 'forward' | 'remove'): boolean { return new RegExp(`<button id="widget-${button}"[^>]*\\bdisabled`).test(panel.webview.html); } @@ -222,6 +229,106 @@ suite('Session-aware HTML widget Viewer', () => { assert.strictEqual(outputTitle(panels[0]), 'A'); }); + 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.ok(panels[0].webview.html.includes('1 / 2')); + assert.ok(disabled(panels[0], 'back') && !disabled(panels[0], 'forward')); + assert.ok(panels[0].webview.html.includes('codicon-error')); + 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.ok(panels[0].webview.html.includes('1 / 2')); + await navigate(panels[0], 'forward'); + await remove(panels[0]); + assert.strictEqual(outputTitle(panels[0]), 'B'); + assert.ok(panels[0].webview.html.includes('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.ok(panels[0].webview.html.includes('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.ok(panels[0].webview.html.includes('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(); + initializeHtmlWidgetViewers(extensionContext); + await restoreHtmlViewer(source.sessionId); + assert.strictEqual(outputTitle(panels[1]), 'A'); + assert.ok(panels[1].webview.html.includes('1 / 1')); + await remove(panels[1]); + panels[1].dispose(); + await shutdownHtmlWidgetViewers(); + initializeHtmlWidgetViewers(extensionContext); + const information = sandbox.stub(vscode.window, 'showInformationMessage'); + await restoreHtmlViewer(source.sessionId); + assert.strictEqual(panels.length, 2, 'Deleted history must not reopen a Viewer'); + sinon.assert.calledOnce(information); + }); + + test('late removal renders and stale remove messages 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 generation = Number(/data-generation="(\d+)"/.exec(panels[0].webview.html)?.[1]); + const slow = deferred<string>(); + 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('<div>A</div>'); + await pending; + await remove(panels[0], generation); + assert.strictEqual(panels[0].webview.html, latest); + assert.strictEqual(outputTitle(panels[0]), 'C'); + assert.ok(panels[0].webview.html.includes('2 / 2')); + await navigate(panels[0], 'back'); + assert.strictEqual(outputTitle(panels[0]), 'A'); + }); + test('late history loads and stale toolbar messages cannot replace newer output', async () => { const source = owner('html-history-stale'); await show('/tmp/a.html', source, 'A'); diff --git a/src/webViewer/index.ts b/src/webViewer/index.ts index bc01c4f8c..a06c53369 100644 --- a/src/webViewer/index.ts +++ b/src/webViewer/index.ts @@ -36,7 +36,7 @@ function escapeHtml(text: string): string { async function renderWidget(entry: WidgetViewer): Promise<void> { const generation = ++entry.revision; const item = entry.state.history[entry.state.index]; - const dir = path.dirname(item.file); + const dir = item ? path.dirname(item.file) : path.join(extensionContext.extensionPath, 'dist/webviews/webview'); const { panel } = entry; const resourceRoots = [ Uri.file(dir), @@ -46,7 +46,7 @@ async function renderWidget(entry: WidgetViewer): Promise<void> { const toolbar = entry.session ? { index: entry.state.index, count: entry.state.history.length, generation, sessionHtml: entry.session.getHtml(), } : undefined; - const html = await getWebviewHtml(panel.webview, item.file, item.title, dir, toolbar); + const html = await getWebviewHtml(panel.webview, item?.file, item?.title ?? htmlViewerTitle, dir, toolbar); if (!entry.disposed && entry.revision === generation) { panel.title = htmlViewerTitle; panel.webview.options = { ...panel.webview.options, localResourceRoots: resourceRoots }; @@ -187,6 +187,14 @@ class HtmlWidgetViewerManager { const pending = this.save(entry); await renderWidget(entry); await pending; + } else if (msg.message === 'widget/remove' && session && !entry.disposed && + msg.generation === entry.revision && entry.state.index >= 0 && entry.state.index < entry.state.history.length) { + 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; + const pending = this.save(entry); + await renderWidget(entry); + await pending; } }); return entry; @@ -208,7 +216,7 @@ class HtmlWidgetViewerManager { } export async function getWebviewHtml( - webview: Webview, file: string, title: string, dir: string, toolbar?: WidgetToolbar, + webview: Webview, file: string | undefined, title: string, dir: string, toolbar?: WidgetToolbar, ): Promise<string> { // Resolve webview URIs before awaiting I/O; the panel may close while loading. const baseUri = String(webview.asWebviewUri(Uri.file(dir))); @@ -216,12 +224,14 @@ export async function getWebviewHtml( const styleUri = webview.asWebviewUri(Uri.file(path.join(extensionContext.extensionPath, 'dist/webviews/webview/style.css'))); const widgetScript = webview.asWebviewUri(Uri.file(path.join(extensionContext.extensionPath, 'dist/webviews/webview/widget.js'))); const codicons = webview.asWebviewUri(Uri.file(path.join(extensionContext.extensionPath, 'dist/resources/codicon.css'))); - let source: string; - try { - source = (await readContent(file, 'utf8') || '').toString(); - } catch (error) { - if (!toolbar) { throw error; } - source = `<p role="alert">This HTML widget could not be loaded. Its original file may no longer be available.</p><pre>${escapeHtml(file)}</pre>`; + let source = '<p>No HTML outputs in this session. New HTML output will appear here.</p>'; + if (file !== undefined) { + try { + source = (await readContent(file, 'utf8') || '').toString(); + } catch (error) { + if (!toolbar) { throw error; } + 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 @@ -257,9 +267,10 @@ export async function getWebviewHtml( </head> <body class="widget-viewer"> <div id="widget-toolbar" role="toolbar" aria-label="HTML widget navigation" data-generation="${toolbar.generation}"> - <button id="widget-back" type="button" title="Back (Alt+Left)" aria-label="Previous HTML widget" ${toolbar.index === 0 ? 'disabled' : ''}><span class="codicon codicon-arrow-left" aria-hidden="true"></span></button> + <button id="widget-back" type="button" title="Back (Alt+Left)" aria-label="Previous HTML widget" ${toolbar.index <= 0 ? 'disabled' : ''}><span class="codicon codicon-arrow-left" aria-hidden="true"></span></button> <button id="widget-forward" type="button" title="Forward (Alt+Right)" aria-label="Next HTML widget" ${toolbar.index === toolbar.count - 1 ? 'disabled' : ''}><span class="codicon codicon-arrow-right" aria-hidden="true"></span></button> <span id="widget-position" role="status">${toolbar.index + 1} / ${toolbar.count}</span> + <button id="widget-remove" type="button" title="Remove current HTML output from history" aria-label="Remove current HTML output from history" ${toolbar.count === 0 ? 'disabled' : ''}><span class="codicon codicon-error" aria-hidden="true"></span></button> <span id="widget-session-info">${toolbar.sessionHtml}</span> </div> <iframe id="widget-frame" title="${escapeHtml(title)}" sandbox="allow-scripts allow-same-origin allow-forms allow-downloads" data-widget-document="${escapeHtml(widget)}"></iframe> diff --git a/src/webViewer/webview/toolbar.ts b/src/webViewer/webview/toolbar.ts index 15a6f59e4..025cbbb0f 100644 --- a/src/webViewer/webview/toolbar.ts +++ b/src/webViewer/webview/toolbar.ts @@ -5,6 +5,7 @@ export function initializeWidgetToolbar(vscode: VsCode): void { if (!toolbar) { return; } const back = document.getElementById('widget-back') as HTMLButtonElement; const forward = document.getElementById('widget-forward') as HTMLButtonElement; + const remove = document.getElementById('widget-remove') as HTMLButtonElement; const frame = document.getElementById('widget-frame') as HTMLIFrameElement; if (frame.dataset.widgetDocument) { const html = frame.dataset.widgetDocument; @@ -25,13 +26,19 @@ export function initializeWidgetToolbar(vscode: VsCode): void { if (host.searchParams.has('vscode-coi')) { bootstrap.searchParams.set('vscode-coi', host.searchParams.get('vscode-coi')!); } frame.src = bootstrap.toString(); } + const lockControls = () => { back.disabled = true; forward.disabled = true; remove.disabled = true; }; const navigate = (direction: 'back' | 'forward') => { if ((direction === 'back' ? back : forward).disabled) { return; } - back.disabled = true; forward.disabled = true; + lockControls(); vscode.postMessage({ message: 'widget/navigate', direction, generation: Number(toolbar.dataset.generation) }); }; back.onclick = () => navigate('back'); forward.onclick = () => navigate('forward'); + remove.onclick = () => { + if (remove.disabled) { return; } + lockControls(); + vscode.postMessage({ message: 'widget/remove', generation: Number(toolbar.dataset.generation) }); + }; const keydown = (event: KeyboardEvent) => { if (event.altKey && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) { event.preventDefault(); diff --git a/src/webViewer/webviewMessages.ts b/src/webViewer/webviewMessages.ts index 008ed6c64..674e92e81 100644 --- a/src/webViewer/webviewMessages.ts +++ b/src/webViewer/webviewMessages.ts @@ -29,4 +29,5 @@ export interface LinkClickedMessage extends IMessage { export type OutMessage = LogMessage | MouseClickMessage | LinkClickedMessage | { message: 'viewer-session/ready' } - | { message: 'widget/navigate'; direction: 'back' | 'forward'; generation: number }; + | { message: 'widget/navigate'; direction: 'back' | 'forward'; generation: number } + | { message: 'widget/remove'; generation: number }; From 59434e2c88375ca22f19636f8aab972736e0d5ee Mon Sep 17 00:00:00 2001 From: "4111978+Fred-Wu@users.noreply.github.com" <4111978+Fred-Wu@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:17:54 +1100 Subject: [PATCH 06/26] fix(test): fix HTML Viewer feature tests across platforms --- src/test/suite/dataViewerSessions.test.ts | 4 +-- src/test/suite/htmlWidgetViewer.test.ts | 6 ++--- src/test/suite/interactiveEditor.test.ts | 31 +++++++++++++++-------- 3 files changed, 25 insertions(+), 16 deletions(-) diff --git a/src/test/suite/dataViewerSessions.test.ts b/src/test/suite/dataViewerSessions.test.ts index 77968d4d5..0f02a60e1 100644 --- a/src/test/suite/dataViewerSessions.test.ts +++ b/src/test/suite/dataViewerSessions.test.ts @@ -251,10 +251,10 @@ suite('Viewer session ownership', () => { 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, '/tmp/profvis.html'); + 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, '/tmp/report.htm'); + 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); }); diff --git a/src/test/suite/htmlWidgetViewer.test.ts b/src/test/suite/htmlWidgetViewer.test.ts index 8f70e68b5..9ed75a3d9 100644 --- a/src/test/suite/htmlWidgetViewer.test.ts +++ b/src/test/suite/htmlWidgetViewer.test.ts @@ -123,7 +123,7 @@ suite('Session-aware HTML widget Viewer', () => { assert.ok(widgetDocument(panels[0]).includes('<base href="file:///tmp/widget-c/">')); assert.ok(widgetDocument(panels[0]).includes('src="lib/widget.js"')); assert.ok(panels[1].webview.html.includes('/tmp/widget-b/index.html')); - assert.strictEqual(panels[0].webview.options.localResourceRoots?.[0].fsPath, '/tmp/widget-c'); + assert.strictEqual(panels[0].webview.options.localResourceRoots?.[0].fsPath, vscode.Uri.file('/tmp/widget-c').fsPath); assert.deepStrictEqual((panels[0].reveal as sinon.SinonStub).lastCall.args, [vscode.ViewColumn.Two, true]); assert.ok(panels[0].webview.html.includes(`R 4.6.1: ${first.pid}`)); assert.ok(panels[1].webview.html.includes(`R 4.6.1: ${second.pid}`)); @@ -143,7 +143,7 @@ suite('Session-aware HTML widget Viewer', () => { await pending; assert.strictEqual(panels[0].webview.html, latest); assert.strictEqual(outputTitle(panels[0]), 'Latest'); - assert.strictEqual(panels[0].webview.options.localResourceRoots?.[0].fsPath, '/tmp/latest'); + assert.strictEqual(panels[0].webview.options.localResourceRoots?.[0].fsPath, vscode.Uri.file('/tmp/latest').fsPath); }); test('closing during a load allows a new panel and discards the old result', async () => { @@ -204,7 +204,7 @@ suite('Session-aware HTML widget Viewer', () => { await navigate(panel, 'back'); assert.strictEqual(outputTitle(panel), 'A'); assert.ok(widgetDocument(panel).includes('/tmp/a/index.html')); - assert.strictEqual(panel.webview.options.localResourceRoots?.[0].fsPath, '/tmp/a'); + assert.strictEqual(panel.webview.options.localResourceRoots?.[0].fsPath, vscode.Uri.file('/tmp/a').fsPath); assert.ok(disabled(panel, 'back') && !disabled(panel, 'forward')); const firstHtml = panel.webview.html; await navigate(panel, 'back'); diff --git a/src/test/suite/interactiveEditor.test.ts b/src/test/suite/interactiveEditor.test.ts index 79716b2b0..2bfa2d733 100644 --- a/src/test/suite/interactiveEditor.test.ts +++ b/src/test/suite/interactiveEditor.test.ts @@ -92,8 +92,17 @@ 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 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 +380,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 +441,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 +503,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 +526,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]); @@ -1171,7 +1180,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 +1212,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 +1295,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 +1341,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 +1979,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 +2012,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)); From 2e0b6dbfacf93a6443d8e80be25000ccd9bfa1e9 Mon Sep 17 00:00:00 2001 From: "4111978+Fred-Wu@users.noreply.github.com" <4111978+Fred-Wu@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:50:03 +1100 Subject: [PATCH 07/26] fix: preserve fragment navigation in HTML Viewer --- src/test/suite/htmlWidgetBrowser.test.ts | 71 ++++++++++++++++++++++++ src/webViewer/webview/widget.ts | 15 ++++- 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/src/test/suite/htmlWidgetBrowser.test.ts b/src/test/suite/htmlWidgetBrowser.test.ts index 5c03ccb98..bcb4bd3f5 100644 --- a/src/test/suite/htmlWidgetBrowser.test.ts +++ b/src/test/suite/htmlWidgetBrowser.test.ts @@ -10,6 +10,77 @@ import { mockExtensionContext } from '../common/mockvscode'; import { waitForValue } from '../common/sessionConnections'; suite('HTML widget browser rendering', () => { + test('fragment links scroll to IDs and named anchors inside the widget without leaving its document', async () => { + const sandbox = sinon.createSandbox(); + 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); + 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, `<!doctype html><html><head><style> + html { scroll-behavior:auto; } body { margin:0; } .gap { height:1500px; } + </style></head><body> + <a id="toc" href="#section"><span>Section</span></a> + <a id="encoded" href="#section%20two">Encoded ID</a> + <a id="named" href="#legacy">Named anchor</a> + <a id="empty" href="#">Top</a><a id="topLink" href="#top">Top</a> + <a id="missingLink" href="#missing">Missing target</a> + <a id="malformed" href="#bad%ZZ">Malformed fragment</a> + <div class="gap"></div><h2 id="section">Section</h2> + <div class="gap"></div><h2 id="section two">Encoded section</h2> + <div class="gap"></div><a name="legacy">Legacy section</a><div class="gap"></div> + <script> + setTimeout(() => { + const results = new URLSearchParams(); + const click = (id, target) => { + let intercepted = false; + document.addEventListener('click', event => { + intercepted = event.defaultPrevented; + // A broken bridge must still return its observations. + event.preventDefault(); + }, {once:true}); + (document.querySelector('#' + id + ' span') || document.getElementById(id)).click(); + results.set(id, String(intercepted && (!target || Math.abs(target.getBoundingClientRect().top) < 2))); + }; + click('toc', document.getElementById('section')); + click('encoded', document.getElementById('section two')); + click('named', document.getElementsByName('legacy')[0]); + click('empty'); results.set('emptyTop', String(scrollY === 0)); + window.scrollTo(0, 500); + click('topLink'); results.set('topTop', String(scrollY === 0)); + window.scrollTo(0, 500); + const retainedScroll = scrollY; + click('missingLink'); results.set('missingStayed', String(scrollY === retainedScroll)); + click('malformed'); results.set('malformedStayed', String(scrollY === retainedScroll)); + parent.postMessage({message:'widget/bridge', href:'https://widget-test.invalid/fragments?' + results}, '*'); + }, 750); + </script> + </body></html>`); + 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('loads relative dependencies, navigates, removes an output, and restores with the real toolbar', async () => { const sandbox = sinon.createSandbox(); const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'vscode-r-widget-browser-')); diff --git a/src/webViewer/webview/widget.ts b/src/webViewer/webview/widget.ts index 755311327..8deba6c44 100644 --- a/src/webViewer/webview/widget.ts +++ b/src/webViewer/webview/widget.ts @@ -1,9 +1,20 @@ // The sandboxed widget has no VS Code API; communicate only with its host frame. 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') ?? ''; - if (/^(https?:|mailto:)/i.test(href)) { + 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 widget 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(); window.parent.postMessage({ message: 'widget/bridge', href: anchor.href }, '*'); } From fbe6e2523c1c5699f5de960dba0ce699a5223af8 Mon Sep 17 00:00:00 2001 From: "4111978+Fred-Wu@users.noreply.github.com" <4111978+Fred-Wu@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:59:53 +1100 Subject: [PATCH 08/26] =?UTF-8?q?fix:=20restore=20VS=20Code=E2=80=99s=20bu?= =?UTF-8?q?ilt-in=20Find=20in=20html=20viewer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/test/suite/htmlWidgetBrowser.test.ts | 65 ++++++++++++++++++++++++ src/test/suite/htmlWidgetToolbar.test.ts | 11 ++++ src/test/suite/htmlWidgetViewer.test.ts | 25 +++++++++ src/webViewer/index.ts | 7 ++- src/webViewer/webview/toolbar.ts | 2 + src/webViewer/webview/widget.ts | 4 ++ src/webViewer/webviewMessages.ts | 1 + 7 files changed, 114 insertions(+), 1 deletion(-) diff --git a/src/test/suite/htmlWidgetBrowser.test.ts b/src/test/suite/htmlWidgetBrowser.test.ts index bcb4bd3f5..7114e4380 100644 --- a/src/test/suite/htmlWidgetBrowser.test.ts +++ b/src/test/suite/htmlWidgetBrowser.test.ts @@ -10,6 +10,71 @@ import { mockExtensionContext } from '../common/mockvscode'; import { waitForValue } from '../common/sessionConnections'; suite('HTML widget browser rendering', () => { + test('Cmd/Ctrl+F inside the widget opens native Find while other keys stay with the widget', async () => { + const sandbox = sinon.createSandbox(); + 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); + 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, `<!doctype html><html><body> + <p>Searchable widget content</p><input id="widget-input"> + <script> + setTimeout(() => { + const input = document.getElementById('widget-input'); + input.focus(); + const results = new URLSearchParams(); + const key = (name, options) => { + const event = new KeyboardEvent('keydown', {key:'f', code:'KeyF', bubbles:true, cancelable:true, ...options}); + input.dispatchEvent(event); + results.set(name, String(event.defaultPrevented)); + }; + key('plain', {}); + key('shift', {ctrlKey:true, shiftKey:true}); + key('alt', {ctrlKey:true, altKey:true}); + input.addEventListener('keydown', event => event.preventDefault(), {once:true}); + key('handled', {ctrlKey:true}); + key('ctrl', {ctrlKey:true}); + key('cmd', {key:'F', metaKey:true}); + parent.postMessage({message:'widget/bridge', href:'https://widget-test.invalid/find?' + results}, '*'); + }, 750); + </script> + </body></html>`); + 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('1 / 1')); + } 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(); const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'vscode-r-widget-fragments-')); diff --git a/src/test/suite/htmlWidgetToolbar.test.ts b/src/test/suite/htmlWidgetToolbar.test.ts index c8d725e41..cd643fcce 100644 --- a/src/test/suite/htmlWidgetToolbar.test.ts +++ b/src/test/suite/htmlWidgetToolbar.test.ts @@ -76,4 +76,15 @@ suite('HTML widget toolbar controls', () => { receive({ source: widget.frame.contentWindow, data: { message: 'widget/bridge', href: 'https://example.com' } }); assert.deepStrictEqual(widget.posted, [{ message: 'linkClicked', href: 'https://example.com', scrollY: 0 }]); }); + + test('only the widget frame can request Find for the displayed generation', () => { + const widget = toolbar(); + const receive = widget.events.get('message')!; + receive({ source: {}, data: { message: 'widget/bridge', command: 'find' } }); + receive({ source: widget.frame.contentWindow, data: { message: 'widget/bridge', command: 'unknown' } }); + assert.strictEqual(widget.posted.length, 0); + receive({ source: widget.frame.contentWindow, data: { message: 'widget/bridge', command: 'find' } }); + assert.deepStrictEqual(widget.posted, [{ message: 'widget/find', generation: 7 }]); + assert.ok(!widget.back.disabled && !widget.forward.disabled && !widget.remove.disabled); + }); }); diff --git a/src/test/suite/htmlWidgetViewer.test.ts b/src/test/suite/htmlWidgetViewer.test.ts index 9ed75a3d9..32b8b50ce 100644 --- a/src/test/suite/htmlWidgetViewer.test.ts +++ b/src/test/suite/htmlWidgetViewer.test.ts @@ -182,6 +182,31 @@ suite('Session-aware HTML widget Viewer', () => { sinon.assert.notCalled(read); }); + test('Find activates its owning panel and ignores stale requests without changing history', async () => { + const command = sandbox.stub(vscode.commands, 'executeCommand').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 generation = 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 }); + sinon.assert.calledOnceWithExactly(command, 'editor.action.webvieweditor.showFind'); + sinon.assert.calledOnceWithExactly(reveal, vscode.ViewColumn.Two, false); + assert.strictEqual(panel.webview.html, html); + assert.ok(panel.webview.html.includes('2 / 2')); + panel.dispose(); + await receivers.get(panel)!({ message: 'widget/find', generation }); + 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'); diff --git a/src/webViewer/index.ts b/src/webViewer/index.ts index a06c53369..e7b21e52f 100644 --- a/src/webViewer/index.ts +++ b/src/webViewer/index.ts @@ -1,7 +1,7 @@ 'use strict'; import * as path from 'path'; -import { ExtensionContext, Uri, ViewColumn, Webview, WebviewPanel, window, env } from 'vscode'; +import { ExtensionContext, Uri, ViewColumn, Webview, WebviewPanel, window, env, commands } from 'vscode'; import { config, readContent, UriIcon } from '../util'; import { extensionContext } from '../extension'; import { ViewerSessionContext, viewerSessionStyle } from '../viewerSession'; @@ -179,6 +179,11 @@ class HtmlWidgetViewerManager { }) => { if (msg.message === 'linkClicked' && msg.href) { void env.openExternal(Uri.parse(msg.href)); + } else if (msg.message === 'widget/find' && session && !entry.disposed && msg.generation === entry.revision) { + // Synthetic key events from the nested iframe are ignored by VS Code. + // Activate its owning panel before opening the built-in Find widget. + panel.reveal(panel.viewColumn, false); + await commands.executeCommand('editor.action.webvieweditor.showFind'); } else if (msg.message === 'widget/navigate' && session && !entry.disposed && msg.generation === entry.revision && (msg.direction === 'back' || msg.direction === 'forward')) { const index = entry.state.index + (msg.direction === 'back' ? -1 : 1); diff --git a/src/webViewer/webview/toolbar.ts b/src/webViewer/webview/toolbar.ts index 025cbbb0f..46cf26ed1 100644 --- a/src/webViewer/webview/toolbar.ts +++ b/src/webViewer/webview/toolbar.ts @@ -57,6 +57,8 @@ export function initializeWidgetToolbar(vscode: VsCode): void { if (!message || typeof message !== 'object' || !('message' in message) || message.message !== 'widget/bridge') { return; } if ('direction' in message && (message.direction === 'back' || message.direction === 'forward')) { navigate(message.direction); + } else if ('command' in message && message.command === 'find') { + vscode.postMessage({ message: 'widget/find', generation: Number(toolbar.dataset.generation) }); } else if ('href' in message && typeof message.href === 'string' && /^(https?:|mailto:)/i.test(message.href)) { vscode.postMessage({ message: 'linkClicked', href: message.href, scrollY: 0 }); } diff --git a/src/webViewer/webview/widget.ts b/src/webViewer/webview/widget.ts index 8deba6c44..5a6be53d9 100644 --- a/src/webViewer/webview/widget.ts +++ b/src/webViewer/webview/widget.ts @@ -26,8 +26,12 @@ document.addEventListener('mousedown', event => { } }); document.addEventListener('keydown', event => { + if (event.defaultPrevented) { return; } if (event.altKey && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) { event.preventDefault(); window.parent.postMessage({ message: 'widget/bridge', direction: event.key === 'ArrowLeft' ? 'back' : 'forward' }, '*'); + } else if ((event.ctrlKey || event.metaKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'f') { + event.preventDefault(); + window.parent.postMessage({ message: 'widget/bridge', command: 'find' }, '*'); } }); diff --git a/src/webViewer/webviewMessages.ts b/src/webViewer/webviewMessages.ts index 674e92e81..6eaa5b47a 100644 --- a/src/webViewer/webviewMessages.ts +++ b/src/webViewer/webviewMessages.ts @@ -30,4 +30,5 @@ export interface LinkClickedMessage extends IMessage { export type OutMessage = LogMessage | MouseClickMessage | LinkClickedMessage | { message: 'viewer-session/ready' } | { message: 'widget/navigate'; direction: 'back' | 'forward'; generation: number } + | { message: 'widget/find'; generation: number } | { message: 'widget/remove'; generation: number }; From d98c791b347323608bad68a311a5d7782982df60 Mon Sep 17 00:00:00 2001 From: "4111978+Fred-Wu@users.noreply.github.com" <4111978+Fred-Wu@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:54:37 +1100 Subject: [PATCH 09/26] refactor: decouple HTML Viewer from session management and extension state --- src/extension.ts | 5 +- src/test/suite/dataViewerSessions.test.ts | 19 ++++-- src/test/suite/htmlWidgetBrowser.test.ts | 15 ++++- src/test/suite/htmlWidgetViewer.test.ts | 73 +++++++++++++++++++++-- src/webViewer/index.ts | 61 +++++++++++-------- 5 files changed, 135 insertions(+), 38 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index 91ac248b9..088c9ec4b 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -67,7 +67,10 @@ export async function activate(context: vscode.ExtensionContext): Promise<apiImp // assign extension context to global variable extensionContext = context; - initializeHtmlWidgetViewers(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; diff --git a/src/test/suite/dataViewerSessions.test.ts b/src/test/suite/dataViewerSessions.test.ts index 0f02a60e1..58e9f3267 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-extra'; import * as net from 'net'; import * as os from 'os'; import * as path from 'path'; @@ -7,7 +8,7 @@ import * as vscode from 'vscode'; import * as extension from '../../extension'; import * as session from '../../session'; import * as util from '../../util'; -import { restoreHtmlViewer, shutdownHtmlWidgetViewers } from '../../webViewer'; +import { initializeHtmlWidgetViewers, restoreHtmlViewer, shutdownHtmlWidgetViewers } from '../../webViewer'; import { mockExtensionContext } from '../common/mockvscode'; interface Request { @@ -47,6 +48,10 @@ suite('Viewer session ownership', () => { sandbox = sinon.createSandbox(); const root = path.join(__dirname, '..', '..', '..'); mockExtensionContext(root, sandbox); + 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, @@ -146,7 +151,8 @@ suite('Viewer session ownership', () => { } test('HTML widget notifications use their source session while another session is active', async () => { - sandbox.stub(util, 'readContent').callsFake(file => Promise.resolve(`<div>${String(file)}</div>`)); + const read: sinon.SinonStub = sandbox.stub(fs, 'readFile'); + read.callsFake(file => Promise.resolve(`<div>${String(file)}</div>`)); 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); @@ -172,7 +178,8 @@ suite('Viewer session ownership', () => { } test('page_viewer and browser HTML share widget history, source ownership, and restoration', async () => { - sandbox.stub(util, 'readContent').callsFake(file => Promise.resolve(`<div>${String(file)}</div>`)); + const read: sinon.SinonStub = sandbox.stub(fs, 'readFile'); + read.callsFake(file => Promise.resolve(`<div>${String(file)}</div>`)); 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' }); @@ -217,7 +224,8 @@ suite('Viewer session ownership', () => { }); test('server URLs from every HTML route use Simple Browser and stay outside widget history', async () => { - sandbox.stub(util, 'readContent').resolves('<div>Widget</div>'); + const read: sinon.SinonStub = sandbox.stub(fs, 'readFile'); + read.resolves('<div>Widget</div>'); 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('1 / 1')); @@ -243,7 +251,8 @@ suite('Viewer session ownership', () => { get: (key: string, fallback: unknown) => key === 'session.viewers.viewColumn' ? { viewer: 'Two', pageViewer: 'Disable', browser: 'Disable' } : fallback, }); - sandbox.stub(util, 'readContent').resolves('<div>Widget</div>'); + const read: sinon.SinonStub = sandbox.stub(fs, 'readFile'); + read.resolves('<div>Widget</div>'); 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' }); diff --git a/src/test/suite/htmlWidgetBrowser.test.ts b/src/test/suite/htmlWidgetBrowser.test.ts index 7114e4380..10731f6d9 100644 --- a/src/test/suite/htmlWidgetBrowser.test.ts +++ b/src/test/suite/htmlWidgetBrowser.test.ts @@ -5,7 +5,8 @@ import * as path from 'path'; import * as sinon from 'sinon'; import * as vscode from 'vscode'; import * as session from '../../session'; -import { restoreHtmlViewer, showWebView, shutdownHtmlWidgetViewers } from '../../webViewer'; +import { initializeHtmlWidgetViewers, restoreHtmlViewer, showWebView, shutdownHtmlWidgetViewers } from '../../webViewer'; +import { extensionContext } from '../../extension'; import { mockExtensionContext } from '../common/mockvscode'; import { waitForValue } from '../common/sessionConnections'; @@ -20,6 +21,10 @@ suite('HTML widget browser rendering', () => { 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; @@ -84,6 +89,10 @@ suite('HTML widget browser rendering', () => { 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; @@ -155,6 +164,10 @@ suite('HTML widget browser rendering', () => { source.pid = '12101'; 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; diff --git a/src/test/suite/htmlWidgetViewer.test.ts b/src/test/suite/htmlWidgetViewer.test.ts index 32b8b50ce..2acfe9b90 100644 --- a/src/test/suite/htmlWidgetViewer.test.ts +++ b/src/test/suite/htmlWidgetViewer.test.ts @@ -1,10 +1,10 @@ import * as assert from 'assert'; +import fs from 'fs-extra'; import * as path from 'path'; import * as sinon from 'sinon'; import * as vscode from 'vscode'; import * as session from '../../session'; -import * as util from '../../util'; -import { initializeHtmlWidgetViewers, restoreHtmlViewer, showWebView, shutdownHtmlWidgetViewers } from '../../webViewer'; +import { HtmlViewerSessionAccess, initializeHtmlWidgetViewers, restoreHtmlViewer, showWebView, shutdownHtmlWidgetViewers } from '../../webViewer'; import { extensionContext } from '../../extension'; import { widgetHistoryKey, WidgetHistory } from '../../webViewer/history'; import { mockExtensionContext } from '../common/mockvscode'; @@ -17,6 +17,10 @@ suite('Session-aware HTML widget Viewer', () => { const panels: vscode.WebviewPanel[] = []; const receivers = new Map<vscode.WebviewPanel, (message: unknown) => Promise<void>>(); const savedState = new Map<string, unknown>(); + const viewerSessions: HtmlViewerSessionAccess = { + resolveSession: source => session.getViewerSessionContext(source.sessionId, source)!, + getActiveSessionId: () => session.activeSession?.sessionId, + }; setup(() => { sandbox = sinon.createSandbox(); @@ -27,7 +31,9 @@ suite('Session-aware HTML widget Viewer', () => { savedState.set(key, JSON.parse(JSON.stringify(value))); return Promise.resolve(); }); - read = sandbox.stub(util, 'readContent').callsFake(file => Promise.resolve( + initializeHtmlWidgetViewers(extensionContext, viewerSessions); + read = sandbox.stub(fs, 'readFile'); + read.callsFake(file => Promise.resolve( `<div>${String(file)}</div><script src="lib/widget.js"></script>`)); sandbox.stub(vscode.window, 'createWebviewPanel').callsFake((_type, title, _column, options) => { const disposed = new vscode.EventEmitter<void>(); @@ -111,6 +117,37 @@ suite('Session-aware HTML widget Viewer', () => { return new RegExp(`<button id="widget-${button}"[^>]*\\bdisabled`).test(panel.webview.html); } + 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); + 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'); @@ -320,14 +357,14 @@ suite('Session-aware HTML widget Viewer', () => { await remove(panels[0]); panels[0].dispose(); await shutdownHtmlWidgetViewers(); - initializeHtmlWidgetViewers(extensionContext); + initializeHtmlWidgetViewers(extensionContext, viewerSessions); await restoreHtmlViewer(source.sessionId); assert.strictEqual(outputTitle(panels[1]), 'A'); assert.ok(panels[1].webview.html.includes('1 / 1')); await remove(panels[1]); panels[1].dispose(); await shutdownHtmlWidgetViewers(); - initializeHtmlWidgetViewers(extensionContext); + initializeHtmlWidgetViewers(extensionContext, viewerSessions); const information = sandbox.stub(vscode.window, 'showInformationMessage'); await restoreHtmlViewer(source.sessionId); assert.strictEqual(panels.length, 2, 'Deleted history must not reopen a Viewer'); @@ -433,7 +470,7 @@ suite('Session-aware HTML widget Viewer', () => { await navigate(panels[0], 'back'); await shutdownHtmlWidgetViewers(); session.unregisterSessionTransport(source); - initializeHtmlWidgetViewers(extensionContext); + initializeHtmlWidgetViewers(extensionContext, viewerSessions); const reconnected = owner(source.sessionId); reconnected.pid = source.pid; await restoreHtmlViewer(source.sessionId); @@ -461,6 +498,30 @@ suite('Session-aware HTML widget Viewer', () => { assert.ok(!panels[1].webview.html.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'); diff --git a/src/webViewer/index.ts b/src/webViewer/index.ts index e7b21e52f..795357018 100644 --- a/src/webViewer/index.ts +++ b/src/webViewer/index.ts @@ -1,13 +1,16 @@ 'use strict'; import * as path from 'path'; -import { ExtensionContext, Uri, ViewColumn, Webview, WebviewPanel, window, env, commands } from 'vscode'; -import { config, readContent, UriIcon } from '../util'; -import { extensionContext } from '../extension'; -import { ViewerSessionContext, viewerSessionStyle } from '../viewerSession'; -import { activeSession, getViewerSessionContext } from '../session'; +import { readFile } from 'fs-extra'; +import { ExtensionContext, Uri, ViewColumn, Webview, WebviewPanel, window, env, commands, workspace } from 'vscode'; +import { ViewerSessionContext, ViewerSessionSource, viewerSessionStyle } from '../viewerSession'; import { WidgetHistory, WidgetHistoryStore, widgetHistoryLimit } from './history'; +export interface HtmlViewerSessionAccess { + resolveSession(source: ViewerSessionSource): ViewerSessionContext; + getActiveSessionId(): string | undefined; +} + interface WidgetViewer { panel: WebviewPanel; revision: number; @@ -34,14 +37,15 @@ function escapeHtml(text: string): string { } async function renderWidget(entry: WidgetViewer): Promise<void> { + const { extensionPath } = getManager().context; const generation = ++entry.revision; const item = entry.state.history[entry.state.index]; - const dir = item ? path.dirname(item.file) : path.join(extensionContext.extensionPath, 'dist/webviews/webview'); + const dir = item ? path.dirname(item.file) : path.join(extensionPath, 'dist/webviews/webview'); const { panel } = entry; const resourceRoots = [ Uri.file(dir), - Uri.file(path.join(extensionContext.extensionPath, 'dist/webviews/webview')), - Uri.file(path.join(extensionContext.extensionPath, 'dist/resources')), + Uri.file(path.join(extensionPath, 'dist/webviews/webview')), + Uri.file(path.join(extensionPath, 'dist/resources')), ]; const toolbar = entry.session ? { index: entry.state.index, count: entry.state.history.length, generation, sessionHtml: entry.session.getHtml(), @@ -56,16 +60,18 @@ async function renderWidget(entry: WidgetViewer): Promise<void> { } /** Initialize retained history once per extension host, including closed Viewer tabs. */ -export function initializeHtmlWidgetViewers(context: ExtensionContext): HtmlWidgetViewerManager { +export function initializeHtmlWidgetViewers( + context: ExtensionContext, sessions: HtmlViewerSessionAccess, +): HtmlWidgetViewerManager { manager?.dispose(); - manager = new HtmlWidgetViewerManager(context); + manager = new HtmlWidgetViewerManager(context, sessions); context.subscriptions.push(manager); return manager; } function getManager(): HtmlWidgetViewerManager { - if (!manager || manager.disposed || manager.context !== extensionContext) { - return initializeHtmlWidgetViewers(extensionContext); + if (!manager || manager.disposed) { + throw new Error('HTML Viewer has not been initialized.'); } return manager; } @@ -94,9 +100,8 @@ class HtmlWidgetViewerManager { readonly viewers = new Map<string, WidgetViewer>(); disposed = false; - constructor(readonly context: ExtensionContext) { - this.histories = new WidgetHistoryStore(context.workspaceState, - source => getViewerSessionContext(source.sessionId, source)!); + constructor(readonly context: ExtensionContext, private readonly sessions: HtmlViewerSessionAccess) { + this.histories = new WidgetHistoryStore(context.workspaceState, source => sessions.resolveSession(source)); } async show(file: string, title: string, viewer: string | boolean, session?: ViewerSessionContext): Promise<void> { @@ -113,14 +118,15 @@ class HtmlWidgetViewerManager { 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 (getViewerSessionContext(record.source.sessionId, record.source)!.hasExited) { + 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 === activeSession?.sessionId); + available.find(item => item.source.sessionId === activeSessionId); if (!record && !sessionId) { if (available.length === 1) { record = available[0]; } else if (available.length > 1) { @@ -138,14 +144,14 @@ class HtmlWidgetViewerManager { void window.showInformationMessage('No retained HTML Viewer history is available for this session.'); return; } - const session = getViewerSessionContext(record.source.sessionId, record.source)!; + 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] : - config().get<Record<string, string>>('session.viewers.viewColumn')?.viewer ?? 'Two'; + 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); @@ -172,7 +178,11 @@ class HtmlWidgetViewerManager { if (sessionId && this.viewers.get(sessionId) === entry) { this.viewers.delete(sessionId); } }); panel.onDidChangeViewState(() => { if (!entry.disposed) { void this.save(entry); } }); - panel.iconPath = new UriIcon('globe'); + 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')), + }; session?.attach(panel); panel.webview.onDidReceiveMessage(async (msg: { message: string; href?: string; direction?: string; generation?: number; @@ -223,16 +233,17 @@ class HtmlWidgetViewerManager { export async function getWebviewHtml( webview: Webview, file: string | undefined, title: string, dir: string, toolbar?: WidgetToolbar, ): Promise<string> { + 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(extensionContext.extensionPath, 'dist/webviews/webview/index.js'))); - const styleUri = webview.asWebviewUri(Uri.file(path.join(extensionContext.extensionPath, 'dist/webviews/webview/style.css'))); - const widgetScript = webview.asWebviewUri(Uri.file(path.join(extensionContext.extensionPath, 'dist/webviews/webview/widget.js'))); - const codicons = webview.asWebviewUri(Uri.file(path.join(extensionContext.extensionPath, 'dist/resources/codicon.css'))); + 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 widgetScript = webview.asWebviewUri(Uri.file(path.join(extensionPath, 'dist/webviews/webview/widget.js'))); + const codicons = webview.asWebviewUri(Uri.file(path.join(extensionPath, 'dist/resources/codicon.css'))); let source = '<p>No HTML outputs in this session. New HTML output will appear here.</p>'; if (file !== undefined) { try { - source = (await readContent(file, 'utf8') || '').toString(); + source = await readFile(file, 'utf8'); } catch (error) { if (!toolbar) { throw error; } source = `<p role="alert">This HTML widget could not be loaded. Its original file may no longer be available.</p><pre>${escapeHtml(file)}</pre>`; From 8d5011b6eb0050e948efdc0873ad8f143d248557 Mon Sep 17 00:00:00 2001 From: "4111978+Fred-Wu@users.noreply.github.com" <4111978+Fred-Wu@users.noreply.github.com> Date: Thu, 8 Oct 2026 21:38:42 +1100 Subject: [PATCH 10/26] fix: use consistent process identity for HTML viewer history and panel reuse --- src/test/node/htmlWidgetHistory.test.ts | 30 +++++++++++++++++++++ src/test/suite/htmlWidgetViewer.test.ts | 36 +++++++++++++++++++++++++ src/webViewer/history.ts | 7 ++++- src/webViewer/index.ts | 10 +++---- 4 files changed, 77 insertions(+), 6 deletions(-) diff --git a/src/test/node/htmlWidgetHistory.test.ts b/src/test/node/htmlWidgetHistory.test.ts index 598089dc6..667c1de1a 100644 --- a/src/test/node/htmlWidgetHistory.test.ts +++ b/src/test/node/htmlWidgetHistory.test.ts @@ -61,6 +61,36 @@ suite('Retained HTML widget history', () => { 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(); diff --git a/src/test/suite/htmlWidgetViewer.test.ts b/src/test/suite/htmlWidgetViewer.test.ts index 2acfe9b90..228e20693 100644 --- a/src/test/suite/htmlWidgetViewer.test.ts +++ b/src/test/suite/htmlWidgetViewer.test.ts @@ -202,6 +202,7 @@ suite('Session-aware HTML widget Viewer', () => { 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'); @@ -210,6 +211,41 @@ suite('Session-aware HTML widget Viewer', () => { 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.ok(panels[1].webview.html.includes('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.ok(panels[1].webview.html.includes('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); diff --git a/src/webViewer/history.ts b/src/webViewer/history.ts index bace7bc9f..2a0ca1ae4 100644 --- a/src/webViewer/history.ts +++ b/src/webViewer/history.ts @@ -4,6 +4,11 @@ import type { ViewerSessionContext, ViewerSessionSource } from '../viewerSession export const widgetHistoryKey = 'r.htmlViewer.histories'; export const widgetHistoryLimit = 50; +/** Session IDs identify a process lifetime; host and PID guard against ID reuse. */ +export function widgetSessionIdentity(source: Readonly<ViewerSessionSource>): string { + return JSON.stringify([source.sessionId, source.host.toLowerCase(), source.pid]); +} + export interface WidgetHistory { source: ViewerSessionSource; history: Array<{ file: string; title: string }>; @@ -41,7 +46,7 @@ export class WidgetHistoryStore { remember(session: ViewerSessionContext): WidgetHistory { let record = this.entries.get(session.sessionId); - if (record && (record.source.host.toLowerCase() !== session.source.host.toLowerCase() || record.source.pid !== session.source.pid)) { + if (record && widgetSessionIdentity(record.source) !== widgetSessionIdentity(session.source)) { this.forget(session.sessionId); record = undefined; } diff --git a/src/webViewer/index.ts b/src/webViewer/index.ts index 795357018..d81b840f0 100644 --- a/src/webViewer/index.ts +++ b/src/webViewer/index.ts @@ -4,7 +4,7 @@ import * as path from 'path'; import { readFile } from 'fs-extra'; import { ExtensionContext, Uri, ViewColumn, Webview, WebviewPanel, window, env, commands, workspace } from 'vscode'; import { ViewerSessionContext, ViewerSessionSource, viewerSessionStyle } from '../viewerSession'; -import { WidgetHistory, WidgetHistoryStore, widgetHistoryLimit } from './history'; +import { WidgetHistory, WidgetHistoryStore, widgetHistoryLimit, widgetSessionIdentity } from './history'; export interface HtmlViewerSessionAccess { resolveSession(source: ViewerSessionSource): ViewerSessionContext; @@ -160,8 +160,8 @@ class HtmlWidgetViewerManager { } private open(viewer: string | boolean, session?: ViewerSessionContext, saved?: WidgetHistory): WidgetViewer { - const sessionId = session?.sessionId; - const existing = sessionId ? this.viewers.get(sessionId) : undefined; + const identity = session ? widgetSessionIdentity(session.source) : undefined; + const existing = identity ? this.viewers.get(identity) : undefined; if (existing) { return existing; } const panel = window.createWebviewPanel('webview', htmlViewerTitle, { preserveFocus: true, viewColumn: ViewColumn[String(viewer) as keyof typeof ViewColumn] ?? ViewColumn.Two }, @@ -171,11 +171,11 @@ class HtmlWidgetViewerManager { state: saved ?? { history: [], index: -1 }, }; // Register before loading HTML so concurrent requests reuse the panel. - if (sessionId) { this.viewers.set(sessionId, entry); } + if (identity) { this.viewers.set(identity, entry); } panel.onDidDispose(() => { entry.disposed = true; if (!this.disposed) { void this.save(entry); } - if (sessionId && this.viewers.get(sessionId) === entry) { this.viewers.delete(sessionId); } + if (identity && this.viewers.get(identity) === entry) { this.viewers.delete(identity); } }); panel.onDidChangeViewState(() => { if (!entry.disposed) { void this.save(entry); } }); const iconPath = this.context.asAbsolutePath('images/icons'); From caefd92c23ce9714edd36b5e4ce9df2369db424d Mon Sep 17 00:00:00 2001 From: "4111978+Fred-Wu@users.noreply.github.com" <4111978+Fred-Wu@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:46:00 +1100 Subject: [PATCH 11/26] refactor: move HTML viewer controls to the native VS Code toolbar - Remove the custom toolbar and internal iframe workaround - Show session information through the tab title - Preserve process identity, exit handling, and history across reloads --- esbuild.js | 3 +- package.json | 65 +++ src/extension.ts | 8 +- src/session.ts | 3 +- src/sessionIdentity.ts | 4 + src/sessionProcessMonitor.ts | 31 +- src/test/node/sessionProcessMonitor.test.ts | 45 ++ src/test/suite/dataViewerSessions.test.ts | 58 ++- src/test/suite/htmlWidgetBrowser.test.ts | 186 ++++++--- src/test/suite/htmlWidgetContent.test.ts | 54 +++ src/test/suite/htmlWidgetToolbar.test.ts | 90 ---- src/test/suite/htmlWidgetViewer.test.ts | 437 +++++++++++++++++--- src/test/suite/session.test.ts | 4 +- src/webViewer/history.ts | 8 +- src/webViewer/index.ts | 381 +++++++++++------ src/webViewer/webview/index.ts | 98 +---- src/webViewer/webview/style.css | 85 +--- src/webViewer/webview/toolbar.ts | 66 --- src/webViewer/webview/widget.ts | 81 ++-- src/webViewer/webviewMessages.ts | 18 +- 20 files changed, 1090 insertions(+), 635 deletions(-) create mode 100644 src/sessionIdentity.ts create mode 100644 src/test/suite/htmlWidgetContent.test.ts delete mode 100644 src/test/suite/htmlWidgetToolbar.test.ts delete mode 100644 src/webViewer/webview/toolbar.ts diff --git a/esbuild.js b/esbuild.js index ec9779b45..cfa0d554b 100644 --- a/esbuild.js +++ b/esbuild.js @@ -100,8 +100,7 @@ async function main() { entryPoints: { 'help/index': './src/helpViewer/webview/index.ts', 'httpgd/index': './src/plotViewer/webview/index.ts', - 'webview/index': './src/webViewer/webview/index.ts', - 'webview/widget': './src/webViewer/webview/widget.ts' + 'webview/index': './src/webViewer/webview/index.ts' }, bundle: true, minify: production, diff --git a/package.json b/package.json index f954da34e..3b9a892a9 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", @@ -259,6 +260,34 @@ "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": "Session information", + "category": "R HTML Viewer", + "icon": "$(info)", + "enablement": "activeWebviewPanelId == 'r.htmlViewer' && r.htmlViewer.canShowInfo" + }, { "title": "Create R terminal", "category": "R", @@ -1025,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" @@ -1198,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": "navigation@3" + }, + { + "command": "r.htmlViewer.info", + "when": "activeWebviewPanelId == 'r.htmlViewer'", + "group": "navigation@4" + }, { "command": "r.rmarkdown.preview.openExternal", "when": "resourceScheme =~ /webview/ && r.rmarkdown.preview.active", diff --git a/src/extension.ts b/src/extension.ts index 088c9ec4b..f9b4fbabf 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -26,7 +26,7 @@ import * as languageService from './languageService'; import { RTaskProvider } from './tasks'; import { showRDebuggerCompatibilityWarningOnce } from './rDebuggerCompatibility'; import { InteractiveManager } from './interactive/manager'; -import { initializeHtmlWidgetViewers, restoreHtmlViewer, shutdownHtmlWidgetViewers } from './webViewer'; +import { initializeHtmlWidgetViewers, restoreHtmlViewer, runHtmlViewerCommand, shutdownHtmlWidgetViewers } from './webViewer'; // global objects used in other files @@ -169,6 +169,10 @@ export async function activate(context: vscode.ExtensionContext): Promise<apiImp '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) }; @@ -288,7 +292,7 @@ export function ensureWorkspaceViewer(): void { } export async function deactivate(): Promise<void> { - await shutdownHtmlWidgetViewers(); + await shutdownHtmlWidgetViewers(true); (globalPlotManager as plotViewer.CommonPlotManager)?.dispose(); await session.shutdownSessionWatcher(); } diff --git a/src/session.ts b/src/session.ts index f7fd4de11..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'; @@ -1250,7 +1251,7 @@ function attachViewerSessionBridge(panel: vscode.WebviewPanel, sessionId: string 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 && (!attached || attached.host.toLowerCase() !== saved.host.toLowerCase() || attached.pid !== saved.pid) + const owner = saved && (saved.processExited || !attached || sessionProcessIdentity(attached) !== sessionProcessIdentity(saved)) ? saved : attached; return owner ? createViewerSessionContext(owner, sessionProcessMonitor) : undefined; } 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<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); @@ -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 { 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 58e9f3267..d17929ef2 100644 --- a/src/test/suite/dataViewerSessions.test.ts +++ b/src/test/suite/dataViewerSessions.test.ts @@ -8,7 +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, shutdownHtmlWidgetViewers } from '../../webViewer'; +import { initializeHtmlWidgetViewers, restoreHtmlViewer, runHtmlViewerCommand, shutdownHtmlWidgetViewers } from '../../webViewer'; import { mockExtensionContext } from '../common/mockvscode'; interface Request { @@ -41,14 +41,18 @@ async function waitFor(condition: () => boolean): Promise<void> { suite('Viewer session ownership', () => { let sandbox: sinon.SinonSandbox; + let htmlViewers: ReturnType<typeof initializeHtmlWidgetViewers>; + 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); - initializeHtmlWidgetViewers(extension.extensionContext, { + htmlViewers = initializeHtmlWidgetViewers(extension.extensionContext, { resolveSession: source => session.getViewerSessionContext(source.sessionId, source)!, getActiveSessionId: () => session.activeSession?.sessionId, }); @@ -150,6 +154,22 @@ suite('Viewer session ownership', () => { return client; } + async function htmlSessionInfo(panel: vscode.WebviewPanel): Promise<string> { + 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(`<div>${String(file)}</div>`)); @@ -160,14 +180,14 @@ suite('Viewer session ownership', () => { 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(original.webview.html.includes('R 4.6.1: 12101')); + 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(panels[1].panel.webview.html.includes('R 4.6.2: 12102')); + 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); }); @@ -190,13 +210,13 @@ suite('Viewer session ownership', () => { await waitFor(() => viewer.panel.webview.html.includes('/tmp/profvis')); assert.strictEqual(panels.length, 1); assert.strictEqual(outputTitle(viewer.panel), 'Page Viewer'); - assert.ok(viewer.panel.webview.html.includes('2 / 2')); - assert.ok(viewer.panel.webview.html.includes('R 4.6.1: 12101')); + 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('<title>Report')); assert.strictEqual(panels.length, 1); - assert.ok(viewer.panel.webview.html.includes('3 / 3')); + 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({ @@ -213,14 +233,14 @@ suite('Viewer session ownership', () => { await restoreHtmlViewer(source.id); assert.strictEqual(panels.length, 2); assert.strictEqual(outputTitle(panels[1].panel), 'Page Viewer'); - assert.ok(panels[1].panel.webview.html.includes('2 / 3')); - assert.ok(panels[1].panel.webview.html.includes('R 4.6.1: 12101')); + 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('Other profile')); - assert.ok(panels[2].panel.webview.html.includes('1 / 1')); + assert.strictEqual(htmlPosition(panels[2].panel), '1 / 1'); assert.strictEqual(outputTitle(panels[2].panel), 'Other profile'); - assert.ok(panels[2].panel.webview.html.includes('R 4.6.2: 12102')); + 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 () => { @@ -228,7 +248,7 @@ suite('Viewer session ownership', () => { 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('1 / 1')); + 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')); @@ -256,7 +276,7 @@ suite('Viewer session ownership', () => { 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('1 / 1')); + 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); @@ -434,6 +454,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 index 10731f6d9..8453e890d 100644 --- a/src/test/suite/htmlWidgetBrowser.test.ts +++ b/src/test/suite/htmlWidgetBrowser.test.ts @@ -1,11 +1,13 @@ 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, showWebView, shutdownHtmlWidgetViewers } from '../../webViewer'; +import { initializeHtmlWidgetViewers, restoreHtmlViewer, runHtmlViewerCommand, showWebView, shutdownHtmlWidgetViewers } from '../../webViewer'; import { extensionContext } from '../../extension'; import { mockExtensionContext } from '../common/mockvscode'; import { waitForValue } from '../common/sessionConnections'; @@ -13,6 +15,7 @@ import { waitForValue } from '../common/sessionConnections'; suite('HTML widget browser rendering', () => { 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; @@ -56,7 +59,7 @@ suite('HTML widget browser rendering', () => { key('handled', {ctrlKey:true}); key('ctrl', {ctrlKey:true}); key('cmd', {key:'F', metaKey:true}); - parent.postMessage({message:'widget/bridge', href:'https://widget-test.invalid/find?' + results}, '*'); + const report = document.createElement('a'); report.href = 'https://widget-test.invalid/find?' + results; document.body.append(report); report.click(); }, 750); `); @@ -70,7 +73,7 @@ suite('HTML widget browser rendering', () => { 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('1 / 1')); + assert.ok(!panels[0].webview.html.includes('widget-frame')); } finally { panels.forEach(panel => { panel.dispose(); }); await shutdownHtmlWidgetViewers(); @@ -82,6 +85,7 @@ suite('HTML widget browser rendering', () => { 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; @@ -134,7 +138,7 @@ suite('HTML widget browser rendering', () => { const retainedScroll = scrollY; click('missingLink'); results.set('missingStayed', String(scrollY === retainedScroll)); click('malformed'); results.set('malformedStayed', String(scrollY === retainedScroll)); - parent.postMessage({message:'widget/bridge', href:'https://widget-test.invalid/fragments?' + results}, '*'); + const report = document.createElement('a'); report.href = 'https://widget-test.invalid/fragments?' + results; document.body.append(report); report.click(); }, 750); `); @@ -155,8 +159,9 @@ suite('HTML widget browser rendering', () => { } }); - test('loads relative dependencies, navigates, removes an output, and restores with the real toolbar', async () => { + 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[] = []; @@ -176,22 +181,42 @@ suite('HTML widget browser rendering', () => { 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.js'), `let reports = 0; const timer = setInterval(() => { - window.parent.postMessage({ message: 'widget/bridge', href: 'https://widget-test.invalid/${name}?width=' + innerWidth + '&height=' + innerHeight }, '*'); - if (++reports === 20) clearInterval(timer); - }, 100);`); + 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}
`); + 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) => { - await waitForValue(() => count(name) > previous ? true : undefined); - const uri = urls.filter(uri => uri.path === `/${name}`).at(-1)!; + 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') ? 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); @@ -199,59 +224,33 @@ suite('HTML widget browser rendering', () => { await loaded('second', 0); assert.strictEqual(panels.length, 1); const panel = panels[0]; - let controlRevision = 0; - // A test-only host script drives native button clicks and reports geometry. - const installControls = async (name: string) => { - const previous = count(name); - const token = ++controlRevision; - panel.webview.html = panel.webview.html.replace('', ``); - await waitForValue(() => urls.find(uri => uri.path === '/controls' && uri.query === 'token=' + token)); - await loaded(name, previous); - }; - await installControls('second'); - await panel.webview.postMessage({ message: 'widget-test/inspect' }); - await waitForValue(() => urls.find(uri => uri.path === '/host')); - assert.ok(urls.find(uri => uri.path === '/host')?.query.includes('good=true')); + assert.strictEqual(panel.viewType, 'r.htmlViewer'); + panel.reveal(panel.viewColumn, false); + await waitForValue(() => panel.active ? true : undefined); const firstCount = count('first'); - await panel.webview.postMessage({ message: 'widget-test/navigate', direction: 'back' }); + await runHtmlViewerCommand('back'); await loaded('first', firstCount); - assert.strictEqual(panel.title, 'HTML Viewer'); - await installControls('first'); const secondCount = count('second'); - await panel.webview.postMessage({ message: 'widget-test/navigate', direction: 'forward' }); + await runHtmlViewerCommand('forward'); await loaded('second', secondCount); - assert.strictEqual(panel.title, 'HTML Viewer'); - await installControls('second'); const removeCount = count('first'); - await panel.webview.postMessage({ message: 'widget-test/remove' }); + await runHtmlViewerCommand('remove'); await loaded('first', removeCount); - assert.ok(panel.webview.html.includes('1 / 1')); 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 runHtmlViewerCommand('info'); + assert.strictEqual(panel.title, `HTML Viewer · R 4.6.1: ${source.pid}`); + assert.strictEqual(panel.webview.html, html); + 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'); - assert.ok(panels[1].webview.html.includes('1 / 1')); } finally { panels.forEach(panel => { panel.dispose(); }); await shutdownHtmlWidgetViewers(); @@ -260,4 +259,87 @@ suite('HTML widget browser rendering', () => { 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[] = []; + 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 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); } + 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); + notify('webview', { url: second, title: 'Second' }); + await waitForValue(() => panel.webview.html.includes('Native second') ? true : undefined); + panel.reveal(panel.viewColumn, false); + await waitForValue(() => panel.active ? true : undefined); + await vscode.commands.executeCommand('r.htmlViewer.back'); + assert.ok(panel.webview.html.includes('Native first')); + await vscode.commands.executeCommand('r.htmlViewer.forward'); + assert.ok(panel.webview.html.includes('Native second')); + const html = panel.webview.html; + await vscode.commands.executeCommand('r.htmlViewer.info'); + assert.strictEqual(panel.title, 'HTML Viewer · R 4.6.1: 12104'); + assert.strictEqual(panel.webview.html, html); + await vscode.commands.executeCommand('r.htmlViewer.info'); + assert.strictEqual(panel.title, 'HTML Viewer'); + sinon.assert.notCalled(information); + await vscode.commands.executeCommand('r.htmlViewer.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')); + panels[1].reveal(panels[1].viewColumn, false); + await waitForValue(() => panels[1].active ? true : undefined); + await vscode.commands.executeCommand('r.htmlViewer.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..89137f7ca --- /dev/null +++ b/src/test/suite/htmlWidgetContent.test.ts @@ -0,0 +1,54 @@ +import * as assert from 'assert'; +import * as vm from 'vm'; +import { initializeWidgetContent, 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' }); + }); +}); diff --git a/src/test/suite/htmlWidgetToolbar.test.ts b/src/test/suite/htmlWidgetToolbar.test.ts deleted file mode 100644 index cd643fcce..000000000 --- a/src/test/suite/htmlWidgetToolbar.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import * as assert from 'assert'; -import * as vm from 'vm'; -import { initializeWidgetToolbar } from '../../webViewer/webview/toolbar'; - -suite('HTML widget toolbar controls', () => { - function toolbar(backDisabled = false, forwardDisabled = false, removeDisabled = false) { - const back = { disabled: backDisabled, onclick: undefined as (() => void) | undefined }; - const forward = { disabled: forwardDisabled, onclick: undefined as (() => void) | undefined }; - const remove = { disabled: removeDisabled, onclick: undefined as (() => void) | undefined }; - const frame = { contentWindow: {}, dataset: {} }; - const posted: unknown[] = []; - const events = new Map void>(); - const elements = new Map([ - ['widget-toolbar', { dataset: { generation: '7' } }], - ['widget-back', back], ['widget-forward', forward], ['widget-frame', frame], - ['widget-remove', remove], - ]); - vm.runInNewContext(`(${initializeWidgetToolbar.toString()})(vscode)`, { - document: { - getElementById: (id: string) => elements.get(id), - addEventListener: (name: string, listener: (event: unknown) => void) => events.set(name, listener), - }, - window: { addEventListener: (name: string, listener: (event: unknown) => void) => events.set(name, listener) }, - vscode: { postMessage: (message: unknown) => posted.push(JSON.parse(JSON.stringify(message))) }, - }); - return { back, forward, remove, frame, posted, events }; - } - - test('disabled boundaries do nothing and enabled clicks send the displayed generation once', () => { - const first = toolbar(true, false); - first.back.onclick!(); - assert.strictEqual(first.posted.length, 0); - first.forward.onclick!(); - assert.deepStrictEqual(first.posted, [{ message: 'widget/navigate', direction: 'forward', generation: 7 }]); - assert.ok(first.back.disabled && first.forward.disabled && first.remove.disabled); - first.remove.onclick!(); - first.forward.onclick!(); - assert.strictEqual(first.posted.length, 1); - }); - - test('remove sends the displayed generation once and locks navigation until rendering finishes', () => { - const empty = toolbar(true, true, true); - empty.remove.onclick!(); - assert.strictEqual(empty.posted.length, 0); - const widget = toolbar(); - widget.remove.onclick!(); - assert.deepStrictEqual(widget.posted, [{ message: 'widget/remove', generation: 7 }]); - assert.ok(widget.back.disabled && widget.forward.disabled && widget.remove.disabled); - widget.remove.onclick!(); widget.back.onclick!(); widget.forward.onclick!(); - assert.strictEqual(widget.posted.length, 1); - }); - - test('keyboard and mouse shortcuts navigate in the host and inside the widget', () => { - const keyboard = toolbar(); - let prevented = false; - keyboard.events.get('keydown')!({ altKey: true, key: 'ArrowLeft', preventDefault: () => { prevented = true; } }); - assert.ok(prevented); - assert.deepStrictEqual(keyboard.posted, [{ message: 'widget/navigate', direction: 'back', generation: 7 }]); - const mouse = toolbar(); - mouse.events.get('mousedown')!({ button: 4, preventDefault: () => undefined }); - assert.deepStrictEqual(mouse.posted, [{ message: 'widget/navigate', direction: 'forward', generation: 7 }]); - const widget = toolbar(); - const receive = widget.events.get('message')!; - receive({ source: {}, data: { message: 'widget/bridge', direction: 'back' } }); - assert.strictEqual(widget.posted.length, 0); - receive({ source: widget.frame.contentWindow, data: { message: 'widget/bridge', direction: 'back' } }); - assert.deepStrictEqual(widget.posted, [{ message: 'widget/navigate', direction: 'back', generation: 7 }]); - }); - - test('only the widget frame can forward supported external links', () => { - const widget = toolbar(); - const receive = widget.events.get('message')!; - receive({ source: {}, data: { message: 'widget/bridge', href: 'https://example.com' } }); - receive({ source: widget.frame.contentWindow, data: { message: 'widget/bridge', href: 'javascript:alert(1)' } }); - assert.strictEqual(widget.posted.length, 0); - receive({ source: widget.frame.contentWindow, data: { message: 'widget/bridge', href: 'https://example.com' } }); - assert.deepStrictEqual(widget.posted, [{ message: 'linkClicked', href: 'https://example.com', scrollY: 0 }]); - }); - - test('only the widget frame can request Find for the displayed generation', () => { - const widget = toolbar(); - const receive = widget.events.get('message')!; - receive({ source: {}, data: { message: 'widget/bridge', command: 'find' } }); - receive({ source: widget.frame.contentWindow, data: { message: 'widget/bridge', command: 'unknown' } }); - assert.strictEqual(widget.posted.length, 0); - receive({ source: widget.frame.contentWindow, data: { message: 'widget/bridge', command: 'find' } }); - assert.deepStrictEqual(widget.posted, [{ message: 'widget/find', generation: 7 }]); - assert.ok(!widget.back.disabled && !widget.forward.disabled && !widget.remove.disabled); - }); -}); diff --git a/src/test/suite/htmlWidgetViewer.test.ts b/src/test/suite/htmlWidgetViewer.test.ts index 228e20693..da39378a0 100644 --- a/src/test/suite/htmlWidgetViewer.test.ts +++ b/src/test/suite/htmlWidgetViewer.test.ts @@ -1,10 +1,11 @@ import * as assert from 'assert'; import fs from 'fs-extra'; +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, showWebView, shutdownHtmlWidgetViewers } from '../../webViewer'; +import { HtmlViewerSessionAccess, initializeHtmlWidgetViewers, restoreHtmlViewer, runHtmlViewerCommand, showWebView, shutdownHtmlWidgetViewers } from '../../webViewer'; import { extensionContext } from '../../extension'; import { widgetHistoryKey, WidgetHistory } from '../../webViewer/history'; import { mockExtensionContext } from '../common/mockvscode'; @@ -16,6 +17,12 @@ suite('Session-aware HTML widget Viewer', () => { 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(); const viewerSessions: HtmlViewerSessionAccess = { resolveSession: source => session.getViewerSessionContext(source.sessionId, source)!, @@ -24,6 +31,12 @@ suite('Session-aware HTML widget Viewer', () => { setup(() => { sandbox = sinon.createSandbox(); + 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); @@ -31,12 +44,13 @@ suite('Session-aware HTML widget Viewer', () => { savedState.set(key, JSON.parse(JSON.stringify(value))); return Promise.resolve(); }); - initializeHtmlWidgetViewers(extensionContext, viewerSessions); + 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; const webview = { @@ -49,19 +63,25 @@ suite('Session-aware HTML widget Viewer', () => { postMessage: sandbox.stub().resolves(true), }; const panel = { - title, viewColumn: vscode.ViewColumn.Two, reveal: sandbox.stub(), + 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: sandbox.stub(), + onDidChangeViewState: viewState.event, dispose: () => { if (closed) { return; } - closed = true; disposed.fire(); disposed.dispose(); listeners.length = 0; + 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; }); @@ -71,6 +91,7 @@ suite('Session-aware HTML widget Viewer', () => { panels.splice(0).forEach(panel => { panel.dispose(); }); await shutdownHtmlWidgetViewers(); receivers.clear(); + viewEvents.clear(); owners.splice(0).forEach(owner => session.unregisterSessionTransport(owner)); sandbox.restore(); }); @@ -92,29 +113,62 @@ suite('Session-aware HTML widget Viewer', () => { } function widgetDocument(panel: vscode.WebviewPanel): string { - const attribute = /data-widget-document="([^"]*)"/.exec(panel.webview.html)?.[1]; - assert.ok(attribute, 'Widgets must render in an iframe separate from the toolbar'); - return attribute.replace(/&(amp|lt|gt|quot|#39);/g, entity => ({ - '&': '&', '<': '<', '>': '>', '"': '"', ''': "'", - })[entity]!); + assert.ok(!panel.webview.html.includes('widget-frame'), 'Outputs must render directly without the private iframe'); + return panel.webview.html; } - async function navigate(panel: vscode.WebviewPanel, direction: 'back' | 'forward', generation?: number): Promise { - await receivers.get(panel)!({ - message: 'widget/navigate', direction, - generation: generation ?? Number(/data-generation="(\d+)"/.exec(panel.webview.html)?.[1]), - }); + function activate(panel?: vscode.WebviewPanel): void { + activePanel = panel; + for (const [viewer, emitter] of viewEvents) { emitter.fire({ webviewPanel: viewer }); } } - async function remove(panel: vscode.WebviewPanel, generation?: number): Promise { - await receivers.get(panel)!({ - message: 'widget/remove', - generation: generation ?? Number(/data-generation="(\d+)"/.exec(panel.webview.html)?.[1]), - }); + 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, oldGeneration?: number): Promise { + if (oldGeneration !== undefined) { + await receivers.get(panel)!({ message: 'widget/remove', generation: oldGeneration }); + } else { + activate(panel); + await runHtmlViewerCommand('remove'); + } } function disabled(panel: vscode.WebviewPanel, button: 'back' | 'forward' | 'remove'): boolean { - return new RegExp(` - - ${toolbar.index + 1} / ${toolbar.count} - - ${toolbar.sessionHtml} - - - - `; - } - - const body = source.replace(/<(\w+)(.*)\s+(href|src)="(?!\w+:)/g, - `<$1 $2 $3="${baseUri}/`); - - return ` - - - - - - - ${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 4e46a21ed..340c35784 100644 --- a/src/webViewer/webview/index.ts +++ b/src/webViewer/webview/index.ts @@ -1,88 +1,10 @@ -import { acquireVsCodeApi, VsCode } from '../webviewMessages'; -import { initializeViewerSession } from '../../viewerSession'; -import { initializeWidgetToolbar } from './toolbar'; - -const vscode: VsCode = acquireVsCodeApi(); -initializeViewerSession({ postMessage: message => vscode.postMessage(message) }); -initializeWidgetToolbar(vscode); - -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, 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'); +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 5eb869d52..2272d6549 100644 --- a/src/webViewer/webview/style.css +++ b/src/webViewer/webview/style.css @@ -1,81 +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; -} - -html:has(.widget-viewer), .widget-viewer { - height: 100%; - overflow: hidden; -} - -.widget-viewer { - display: flex; - flex-direction: column; -} - -#widget-toolbar { - position: relative; - z-index: 1; - display: flex; - align-items: center; - flex-shrink: 0; - gap: 6px; - padding: 4px 8px; - border-bottom: 1px solid var(--vscode-panel-border); - background: var(--vscode-editor-background); - font: var(--vscode-font-size, 13px) var(--vscode-font-family, sans-serif); -} - -#widget-toolbar button { - display: inline-flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - padding: 4px; - border: 1px solid transparent; - border-radius: 3px; - background: transparent; - color: var(--vscode-foreground); - cursor: pointer; -} - -#widget-toolbar button:hover:not(:disabled) { - background: var(--vscode-toolbar-hoverBackground); -} - -#widget-toolbar button:disabled { - opacity: .45; - cursor: default; -} - -#widget-toolbar button:focus-visible { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: -1px; -} - -#widget-position { - color: var(--vscode-descriptionForeground); -} - -#widget-session-info { - display: inline-flex; - margin-left: auto; +code { + color: inherit; + background-color: transparent; } -#widget-frame { - flex: 1; - min-height: 0; - width: 100%; - border: 0; - background: white; +blockquote { + background-color: transparent; + border-color: currentColor; } diff --git a/src/webViewer/webview/toolbar.ts b/src/webViewer/webview/toolbar.ts deleted file mode 100644 index 46cf26ed1..000000000 --- a/src/webViewer/webview/toolbar.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { VsCode } from '../webviewMessages'; - -export function initializeWidgetToolbar(vscode: VsCode): void { - const toolbar = document.getElementById('widget-toolbar'); - if (!toolbar) { return; } - const back = document.getElementById('widget-back') as HTMLButtonElement; - const forward = document.getElementById('widget-forward') as HTMLButtonElement; - const remove = document.getElementById('widget-remove') as HTMLButtonElement; - const frame = document.getElementById('widget-frame') as HTMLIFrameElement; - if (frame.dataset.widgetDocument) { - const html = frame.dataset.widgetDocument; - delete frame.dataset.widgetDocument; - let written = false; - frame.addEventListener('load', () => { - if (written || !frame.contentDocument || !frame.contentDocument.location.pathname.endsWith('/fake.html')) { return; } - written = true; - frame.contentDocument.open(); - frame.contentDocument.write(html); - frame.contentDocument.close(); - }); - // srcdoc cannot load VS Code resources through its service worker. - // Use VS Code's empty frame on the same origin, retaining its webview ID. - const host = new URL(window.location.href); - const bootstrap = new URL('fake.html', host); - bootstrap.searchParams.set('id', host.searchParams.get('id') ?? ''); - if (host.searchParams.has('vscode-coi')) { bootstrap.searchParams.set('vscode-coi', host.searchParams.get('vscode-coi')!); } - frame.src = bootstrap.toString(); - } - const lockControls = () => { back.disabled = true; forward.disabled = true; remove.disabled = true; }; - const navigate = (direction: 'back' | 'forward') => { - if ((direction === 'back' ? back : forward).disabled) { return; } - lockControls(); - vscode.postMessage({ message: 'widget/navigate', direction, generation: Number(toolbar.dataset.generation) }); - }; - back.onclick = () => navigate('back'); - forward.onclick = () => navigate('forward'); - remove.onclick = () => { - if (remove.disabled) { return; } - lockControls(); - vscode.postMessage({ message: 'widget/remove', generation: Number(toolbar.dataset.generation) }); - }; - const keydown = (event: KeyboardEvent) => { - if (event.altKey && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) { - event.preventDefault(); - navigate(event.key === 'ArrowLeft' ? 'back' : 'forward'); - } - }; - document.addEventListener('keydown', keydown); - document.addEventListener('mousedown', event => { - if (event.button === 3 || event.button === 4) { - event.preventDefault(); navigate(event.button === 3 ? 'back' : 'forward'); - } - }); - window.addEventListener('message', event => { - if (event.source !== frame.contentWindow) { return; } - const message: unknown = event.data; - if (!message || typeof message !== 'object' || !('message' in message) || message.message !== 'widget/bridge') { return; } - if ('direction' in message && (message.direction === 'back' || message.direction === 'forward')) { - navigate(message.direction); - } else if ('command' in message && message.command === 'find') { - vscode.postMessage({ message: 'widget/find', generation: Number(toolbar.dataset.generation) }); - } else if ('href' in message && typeof message.href === 'string' && /^(https?:|mailto:)/i.test(message.href)) { - vscode.postMessage({ message: 'linkClicked', href: message.href, scrollY: 0 }); - } - }); -} diff --git a/src/webViewer/webview/widget.ts b/src/webViewer/webview/widget.ts index 5a6be53d9..db127ceb1 100644 --- a/src/webViewer/webview/widget.ts +++ b/src/webViewer/webview/widget.ts @@ -1,37 +1,44 @@ -// The sandboxed widget has no VS Code API; communicate only with its host frame. -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 widget 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(); - window.parent.postMessage({ message: 'widget/bridge', href: anchor.href }, '*'); - } -}); -document.addEventListener('mousedown', event => { - if (event.button === 3 || event.button === 4) { - event.preventDefault(); - window.parent.postMessage({ message: 'widget/bridge', direction: event.button === 3 ? 'back' : 'forward' }, '*'); - } -}); -document.addEventListener('keydown', event => { - if (event.defaultPrevented) { return; } - if (event.altKey && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) { - event.preventDefault(); - window.parent.postMessage({ message: 'widget/bridge', direction: event.key === 'ArrowLeft' ? 'back' : 'forward' }, '*'); - } else if ((event.ctrlKey || event.metaKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'f') { - event.preventDefault(); - window.parent.postMessage({ message: 'widget/bridge', command: 'find' }, '*'); - } -}); +import type { HtmlViewerPanelReference, VsCode } from '../webviewMessages'; + +export function initializeWidgetState(vscode: VsCode, state: HtmlViewerPanelReference): void { + vscode.setState(state); +} + +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, scrollY: window.scrollY }); + } + }); + 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 6eaa5b47a..106f53b6b 100644 --- a/src/webViewer/webviewMessages.ts +++ b/src/webViewer/webviewMessages.ts @@ -1,7 +1,20 @@ +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 @@ -30,5 +43,4 @@ export interface LinkClickedMessage extends IMessage { export type OutMessage = LogMessage | MouseClickMessage | LinkClickedMessage | { message: 'viewer-session/ready' } | { message: 'widget/navigate'; direction: 'back' | 'forward'; generation: number } - | { message: 'widget/find'; generation: number } - | { message: 'widget/remove'; generation: number }; + | { message: 'widget/find'; generation: number }; From 27054326a8ec597fa3d1f7c86f0fccc6d76f4c77 Mon Sep 17 00:00:00 2001 From: "4111978+Fred-Wu@users.noreply.github.com" <4111978+Fred-Wu@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:50:11 +1100 Subject: [PATCH 12/26] chore: remove obsolete HTML viewer code and build artifacts --- esbuild.js | 4 ++++ src/test/suite/htmlWidgetViewer.test.ts | 16 +++++----------- src/webViewer/index.ts | 4 ++-- src/webViewer/webview/widget.ts | 2 +- src/webViewer/webviewMessages.ts | 21 +++------------------ 5 files changed, 15 insertions(+), 32 deletions(-) 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/src/test/suite/htmlWidgetViewer.test.ts b/src/test/suite/htmlWidgetViewer.test.ts index da39378a0..823e1ee4b 100644 --- a/src/test/suite/htmlWidgetViewer.test.ts +++ b/src/test/suite/htmlWidgetViewer.test.ts @@ -156,13 +156,9 @@ suite('Session-aware HTML widget Viewer', () => { } } - async function remove(panel: vscode.WebviewPanel, oldGeneration?: number): Promise { - if (oldGeneration !== undefined) { - await receivers.get(panel)!({ message: 'widget/remove', generation: oldGeneration }); - } else { - activate(panel); - await runHtmlViewerCommand('remove'); - } + async function remove(panel: vscode.WebviewPanel): Promise { + activate(panel); + await runHtmlViewerCommand('remove'); } function disabled(panel: vscode.WebviewPanel, button: 'back' | 'forward' | 'remove'): boolean { @@ -461,11 +457,10 @@ suite('Session-aware HTML widget Viewer', () => { sinon.assert.calledOnce(information); }); - test('late removal renders and stale remove messages cannot replace or delete newer output', async () => { + 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 generation = Number(/data-generation="(\d+)"/.exec(panels[0].webview.html)?.[1]); const slow = deferred(); read.onCall(2).returns(slow.promise); const pending = remove(panels[0]); @@ -473,7 +468,6 @@ suite('Session-aware HTML widget Viewer', () => { const latest = panels[0].webview.html; slow.resolve('
A
'); await pending; - await remove(panels[0], generation); assert.strictEqual(panels[0].webview.html, latest); assert.strictEqual(outputTitle(panels[0]), 'C'); assert.strictEqual(position(panels[0]), '2 / 2'); @@ -481,7 +475,7 @@ suite('Session-aware HTML widget Viewer', () => { assert.strictEqual(outputTitle(panels[0]), 'A'); }); - test('late history loads and stale toolbar messages cannot replace newer output', async () => { + 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'); diff --git a/src/webViewer/index.ts b/src/webViewer/index.ts index bca16e33a..c6cb3cab6 100644 --- a/src/webViewer/index.ts +++ b/src/webViewer/index.ts @@ -238,7 +238,7 @@ class HtmlWidgetViewerManager { if (entry.disposed) { return; } if (msg.message === 'linkClicked' && msg.href) { void env.openExternal(Uri.parse(msg.href)); - } else if (msg.message === 'widget/find' && !entry.disposed && msg.generation === entry.revision) { + } 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 && @@ -386,7 +386,7 @@ class HtmlWidgetViewerManager { } } -export async function getWebviewHtml( +async function getWebviewHtml( webview: Webview, file: string | undefined, title: string, dir: string, sessionOwned = false, generation = 0, state?: HtmlViewerPanelReference, ): Promise { const { extensionPath } = getManager().context; diff --git a/src/webViewer/webview/widget.ts b/src/webViewer/webview/widget.ts index db127ceb1..24fe5ec97 100644 --- a/src/webViewer/webview/widget.ts +++ b/src/webViewer/webview/widget.ts @@ -22,7 +22,7 @@ export function initializeWidgetContent(vscode: VsCode, generation: number, sess 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, scrollY: window.scrollY }); + vscode.postMessage({ message: 'linkClicked', href: anchor.href }); } }); document.addEventListener('mousedown', event => { diff --git a/src/webViewer/webviewMessages.ts b/src/webViewer/webviewMessages.ts index 106f53b6b..68ccc9222 100644 --- a/src/webViewer/webviewMessages.ts +++ b/src/webViewer/webviewMessages.ts @@ -21,26 +21,11 @@ export interface VsCode { */ 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 - | { message: 'viewer-session/ready' } +export type OutMessage = LinkClickedMessage | { message: 'widget/navigate'; direction: 'back' | 'forward'; generation: number } | { message: 'widget/find'; generation: number }; From cc7bbf8ad8f0dcd02a7b5cc0e32562cfd64af9f6 Mon Sep 17 00:00:00 2001 From: "4111978+Fred-Wu@users.noreply.github.com" <4111978+Fred-Wu@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:42:38 +1100 Subject: [PATCH 13/26] fix(test): initialize HTML viewer fixtures and synchronize native commands --- src/test/suite/dataViewerSessions.test.ts | 15 +++++++-- src/test/suite/htmlWidgetBrowser.test.ts | 37 +++++++++++++++-------- src/test/suite/interactiveEditor.test.ts | 29 ++++++++++++++++-- src/test/suite/session.test.ts | 7 +++++ 4 files changed, 71 insertions(+), 17 deletions(-) diff --git a/src/test/suite/dataViewerSessions.test.ts b/src/test/suite/dataViewerSessions.test.ts index d17929ef2..66f4dba97 100644 --- a/src/test/suite/dataViewerSessions.test.ts +++ b/src/test/suite/dataViewerSessions.test.ts @@ -100,9 +100,18 @@ 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(); @@ -136,7 +145,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) => { diff --git a/src/test/suite/htmlWidgetBrowser.test.ts b/src/test/suite/htmlWidgetBrowser.test.ts index 8453e890d..d77191e0e 100644 --- a/src/test/suite/htmlWidgetBrowser.test.ts +++ b/src/test/suite/htmlWidgetBrowser.test.ts @@ -12,6 +12,15 @@ 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('Cmd/Ctrl+F inside the widget opens native Find while other keys stay with the widget', async () => { const sandbox = sinon.createSandbox(); @@ -225,8 +234,7 @@ suite('HTML widget browser rendering', () => { assert.strictEqual(panels.length, 1); const panel = panels[0]; assert.strictEqual(panel.viewType, 'r.htmlViewer'); - panel.reveal(panel.viewColumn, false); - await waitForValue(() => panel.active ? true : undefined); + await focusHtmlViewer(panel); const firstCount = count('first'); await runHtmlViewerCommand('back'); await loaded('first', firstCount); @@ -239,9 +247,11 @@ suite('HTML widget browser rendering', () => { 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); @@ -308,31 +318,34 @@ suite('HTML widget browser rendering', () => { 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') => { + await focusHtmlViewer(viewer); + await vscode.commands.executeCommand(`r.htmlViewer.${action}`); + // Title changes also reach the workbench asynchronously. Finish + // each action before the next one can change focus or Info state. + await waitForValue(() => vscode.window.tabGroups.activeTabGroup.activeTab?.label === viewer.title ? true : undefined); + }; notify('webview', { url: second, title: 'Second' }); await waitForValue(() => panel.webview.html.includes('Native second') ? true : undefined); - panel.reveal(panel.viewColumn, false); - await waitForValue(() => panel.active ? true : undefined); - await vscode.commands.executeCommand('r.htmlViewer.back'); + await run(panel, 'back'); assert.ok(panel.webview.html.includes('Native first')); - await vscode.commands.executeCommand('r.htmlViewer.forward'); + await run(panel, 'forward'); assert.ok(panel.webview.html.includes('Native second')); const html = panel.webview.html; - await vscode.commands.executeCommand('r.htmlViewer.info'); + await run(panel, 'info'); assert.strictEqual(panel.title, 'HTML Viewer · R 4.6.1: 12104'); assert.strictEqual(panel.webview.html, html); - await vscode.commands.executeCommand('r.htmlViewer.info'); + await run(panel, 'info'); assert.strictEqual(panel.title, 'HTML Viewer'); sinon.assert.notCalled(information); - await vscode.commands.executeCommand('r.htmlViewer.remove'); + 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')); - panels[1].reveal(panels[1].viewColumn, false); - await waitForValue(() => panels[1].active ? true : undefined); - await vscode.commands.executeCommand('r.htmlViewer.remove'); + await run(panels[1], 'remove'); assert.ok(panels[1].webview.html.includes('No HTML outputs in this session')); } finally { panels.forEach(panel => { panel.dispose(); }); diff --git a/src/test/suite/interactiveEditor.test.ts b/src/test/suite/interactiveEditor.test.ts index 2bfa2d733..fe0651a16 100644 --- a/src/test/suite/interactiveEditor.test.ts +++ b/src/test/suite/interactiveEditor.test.ts @@ -35,7 +35,10 @@ function treeTooltip(item: vscode.TreeItem): string { let agents: SessionAgent[]; let manifests: SessionManifest[]; let controller: vscode.NotebookController; + let originalTabs: Set; + let originalLanguageClients: Set; 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 +54,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 +82,21 @@ 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))); + // 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(client => client.stop())); 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,6 +111,12 @@ 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; clientUpdates: Map> } { + const service = bundleContext().subscriptions.find(item => + (item as { clients?: unknown }).clients instanceof Map) as ReturnType | 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. @@ -766,9 +791,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 } | undefined; - assert.ok(service); + const service = languageService(); try { for (const manifest of manifests) { await vscode.commands.executeCommand('r.interactive.open', manifest); diff --git a/src/test/suite/session.test.ts b/src/test/suite/session.test.ts index aaf9dd74a..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; From 71b794a33ec53da67f029ea934277e1a06204359 Mon Sep 17 00:00:00 2001 From: "4111978+Fred-Wu@users.noreply.github.com" <4111978+Fred-Wu@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:13:25 +1100 Subject: [PATCH 14/26] fix(test): check HTML viewer title in its owning editor group --- src/test/suite/htmlWidgetBrowser.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/test/suite/htmlWidgetBrowser.test.ts b/src/test/suite/htmlWidgetBrowser.test.ts index d77191e0e..47d7f0fdd 100644 --- a/src/test/suite/htmlWidgetBrowser.test.ts +++ b/src/test/suite/htmlWidgetBrowser.test.ts @@ -321,9 +321,11 @@ suite('HTML widget browser rendering', () => { const run = async (viewer: vscode.WebviewPanel, action: 'back' | 'forward' | 'remove' | 'info') => { await focusHtmlViewer(viewer); await vscode.commands.executeCommand(`r.htmlViewer.${action}`); - // Title changes also reach the workbench asynchronously. Finish - // each action before the next one can change focus or Info state. - await waitForValue(() => vscode.window.tabGroups.activeTabGroup.activeTab?.label === viewer.title ? 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); From a94eea014a6e4a2ab70be0cca09150343ed7b375 Mon Sep 17 00:00:00 2001 From: Fred Wu Date: Fri, 9 Oct 2026 22:05:20 +1100 Subject: [PATCH 15/26] fix: preserve authored HTML and support updated dependencies - Preserve document titles, SVG accessibility, and script content - Honor authored base URLs and allow their local resources - Replace remaining fs-extra imports with Node built-ins - Adapt YAML, EJS, and filesystem APIs to updated dependencies - Update regression tests and timer mocks --- package.json | 94 +- pnpm-lock.yaml | 1525 ++++++++++++++------- src/helpViewer/helpPreviewer.ts | 2 +- src/plotViewer/httpgdViewer.ts | 2 +- src/rTerminal.ts | 11 +- src/rmarkdown/draft.ts | 4 +- src/test/suite/dataViewerSessions.test.ts | 13 +- src/test/suite/htmlWidgetBrowser.test.ts | 141 ++ src/test/suite/htmlWidgetViewer.test.ts | 98 +- src/test/suite/rmdParams.test.ts | 20 + src/test/suite/session.test.ts | 2 +- src/webViewer/index.ts | 71 +- 12 files changed, 1455 insertions(+), 528 deletions(-) diff --git a/package.json b/package.json index 3b9a892a9..fac456039 100644 --- a/package.json +++ b/package.json @@ -2389,7 +2389,9 @@ "markdownDescription": "Path to a custom CSS file to be used when `#r.plot.defaults.colorTheme#` is `vscode`. Replaces the default CSS overwrites!" }, "r.interactive.executionTarget": { - "tags": ["experimental"], + "tags": [ + "experimental" + ], "type": "string", "enum": [ "auto", @@ -2400,7 +2402,9 @@ "description": "Execution target for R source commands. Auto uses the selected Interactive session or R terminal and offers a target chooser when none is available. Use Terminal for R Execution switches back to terminal routing." }, "r.interactive.supervision": { - "tags": ["experimental"], + "tags": [ + "experimental" + ], "type": "string", "ignoreSync": true, "enum": [ @@ -2413,28 +2417,36 @@ "description": "Independent session supervision. Auto prefers tmux on Linux when available, otherwise uses a detached process; macOS uses a detached process. Explicit tmux/systemd selections require their executables on the R host. Detached processes survive editor reload/exit, but host logout policies may stop them. Changes apply to new or restarted sessions." }, "r.interactive.nodePath": { - "tags": ["experimental"], + "tags": [ + "experimental" + ], "type": "string", "ignoreSync": true, "default": "", "description": "Optional standalone Node.js 18+ executable on the R host for persistent session agents. Empty uses VS Code's runtime automatically (VS Code Server on remote hosts). Set an executable name on PATH or a path if the automatic runtime is unavailable in a compatible IDE; supports ~ and ${userHome}/${workspaceFolder}. Changes apply to new or restarted sessions." }, "r.interactive.arfPath": { - "tags": ["experimental"], + "tags": [ + "experimental" + ], "type": "string", "ignoreSync": true, "default": "arf", "description": "arf executable required for managed headless Interactive sessions, resolved on the R host. Use an executable name on PATH or a path (supports ~ and ${userHome}/${workspaceFolder}). Ordinary R terminals do not require arf; if unavailable, the Interactive provider picker offers configuration." }, "r.interactive.storagePath": { - "tags": ["experimental"], + "tags": [ + "experimental" + ], "type": "string", "ignoreSync": true, "default": "", "description": "Absolute, writable path on the R host for persistent Interactive sessions. Empty uses $XDG_STATE_HOME/vscode-r/interactive when set, otherwise ~/Library/Application Support/vscode-r/interactive on macOS or ~/.local/state/vscode-r/interactive on Linux. Existing macOS registries at the Linux-style path are reused. Reload VS Code after changing this setting." }, "r.interactive.plotBackend": { - "tags": ["experimental"], + "tags": [ + "experimental" + ], "type": "string", "ignoreSync": true, "enum": [ @@ -2446,21 +2458,33 @@ "description": "Interactive graphics provider. JGD uses a separate systemfonts R process for font metrics while the editor is disconnected." }, "r.interactive.tableView": { - "tags": ["experimental"], + "tags": [ + "experimental" + ], "type": "string", - "enum": ["table", "text"], - "enumDescriptions": ["Show a paged table preview.", "Show the table's ordinary R printout."], + "enum": [ + "table", + "text" + ], + "enumDescriptions": [ + "Show a paged table preview.", + "Show the table's ordinary R printout." + ], "default": "table", "description": "Default presentation for Interactive table outputs. Each output can switch between table and printed views without rerunning R. Printed snapshots require a session started with the current extension build." }, "r.interactive.restore": { - "tags": ["experimental"], + "tags": [ + "experimental" + ], "type": "boolean", "default": true, "description": "Reconnect the workspace’s Interactive sessions when VS Code reopens." }, "r.interactive.historyLimit": { - "tags": ["experimental"], + "tags": [ + "experimental" + ], "type": "integer", "minimum": 1, "maximum": 1000, @@ -2468,7 +2492,9 @@ "description": "Recent executions and plots restored in an Interactive window." }, "r.interactive.maxOutputSizeMiB": { - "tags": ["experimental"], + "tags": [ + "experimental" + ], "type": "number", "ignoreSync": true, "minimum": 0.0625, @@ -2477,7 +2503,9 @@ "description": "Maximum retained console text output per execution, in MiB (1 MiB = 1,048,576 bytes). Excess output is drained and reported as truncated. Rich outputs have separate limits. Changes apply to new or restarted sessions." }, "r.interactive.maxJournalSizeMiB": { - "tags": ["experimental"], + "tags": [ + "experimental" + ], "type": "number", "ignoreSync": true, "minimum": 8, @@ -2486,7 +2514,9 @@ "description": "Approximate retained output journal limit per R generation, in MiB (1 MiB = 1,048,576 bytes). Old output is removed when journal segments rotate, roughly every 4 MiB. Earlier generations and execution records are retained separately. Changes apply to new or restarted sessions." }, "r.interactive.maxAssetSizeMiB": { - "tags": ["experimental"], + "tags": [ + "experimental" + ], "type": "number", "ignoreSync": true, "default": 1024, @@ -2613,31 +2643,31 @@ "devDependencies": { "@types/ejs": "^3.1.5", "@types/js-yaml": "^4.0.9", - "@types/mocha": "^8.2.3", - "@types/node": "^22.20.5", - "@types/sinon": "^10.0.20", - "@types/vscode": "~1.94.0", + "@types/mocha": "^10.0.10", + "@types/node": "^26.6.4", + "@types/sinon": "^22.0.0", + "@types/vscode": "~1.140.0", "@types/winreg": "^1.2.36", - "@vscode/test-cli": "^0.0.12", + "@vscode/test-cli": "^0.0.15", "@vscode/test-electron": "^3.1.0", - "@vscode/vsce": "^3.6.0", - "esbuild": "^0.28.1", - "git-cliff": "^2.12.0", + "@vscode/vsce": "^4.0.0", + "esbuild": "^0.28.2", + "git-cliff": "^2.14.2", "glob": "^13.0.6", - "mocha": "^11.7.5", - "ovsx": "^0.10.8", - "sinon": "^15.2.0", - "typescript": "~7.0.2", - "oxlint": "^1.86.0", - "oxlint-tsgolint": "~7.0.2003" + "mocha": "^12.0.3", + "ovsx": "^1.2.0", + "oxlint": "^1.87.0", + "oxlint-tsgolint": "~7.0.2003", + "sinon": "^22.1.0", + "typescript": "~7.0.2" }, "dependencies": { "@vscode/codicons": "0.0.46-24", "ag-grid-community": "^36.2.0", - "cheerio": "1.0.0-rc.12", - "ejs": "^3.1.10", - "highlight.js": "^11.11.1", - "js-yaml": "^4.3.2", + "cheerio": "1.2.0", + "ejs": "^7.0.1", + "highlight.js": "^11.12.0", + "js-yaml": "^5.4.3", "vscode-languageclient": "^10.1.2", "winreg": "^1.2.5" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3d53f790a..daaaf9ce4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,17 +18,17 @@ importers: specifier: ^36.2.0 version: 36.2.0 cheerio: - specifier: 1.0.0-rc.12 - version: 1.0.0-rc.12 + specifier: 1.2.0 + version: 1.2.0 ejs: - specifier: ^3.1.10 - version: 3.1.10 + specifier: ^7.0.1 + version: 7.0.1 highlight.js: - specifier: ^11.11.1 - version: 11.11.1 + specifier: ^11.12.0 + version: 11.12.0 js-yaml: - specifier: ^4.3.2 - version: 4.3.2 + specifier: ^5.4.3 + version: 5.4.3 vscode-languageclient: specifier: ^10.1.2 version: 10.1.2 @@ -43,53 +43,53 @@ importers: specifier: ^4.0.9 version: 4.0.9 '@types/mocha': - specifier: ^8.2.3 - version: 8.2.3 + specifier: ^10.0.10 + version: 10.0.10 '@types/node': - specifier: ^22.20.5 - version: 22.20.5 + specifier: ^26.6.4 + version: 26.6.4 '@types/sinon': - specifier: ^10.0.20 - version: 10.0.20 + specifier: ^22.0.0 + version: 22.0.0 '@types/vscode': - specifier: ~1.94.0 - version: 1.94.0 + specifier: ~1.140.0 + version: 1.140.0 '@types/winreg': specifier: ^1.2.36 version: 1.2.36 '@vscode/test-cli': - specifier: ^0.0.12 - version: 0.0.12 + specifier: ^0.0.15 + version: 0.0.15 '@vscode/test-electron': specifier: ^3.1.0 version: 3.1.0(supports-color@8.1.1) '@vscode/vsce': - specifier: ^3.6.0 - version: 3.9.2(supports-color@8.1.1) + specifier: ^4.0.0 + version: 4.0.0(supports-color@8.1.1) esbuild: - specifier: ^0.28.1 + specifier: ^0.28.2 version: 0.28.2 git-cliff: - specifier: ^2.12.0 - version: 2.12.0 + specifier: ^2.14.2 + version: 2.14.2 glob: specifier: ^13.0.6 version: 13.0.6 mocha: - specifier: ^11.7.5 - version: 11.7.5 + specifier: ^12.0.3 + version: 12.0.3 ovsx: - specifier: ^0.10.8 - version: 0.10.12(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1) + specifier: ^1.2.0 + version: 1.2.0(@types/node@26.6.4)(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1) oxlint: - specifier: ^1.86.0 - version: 1.86.0(oxlint-tsgolint@7.0.2003) + specifier: ^1.87.0 + version: 1.87.0(oxlint-tsgolint@7.0.2003) oxlint-tsgolint: specifier: ~7.0.2003 version: 7.0.2003 sinon: - specifier: ^15.2.0 - version: 15.2.0 + specifier: ^22.1.0 + version: 22.1.0 typescript: specifier: ~7.0.2 version: 7.0.2 @@ -138,14 +138,18 @@ packages: resolution: {integrity: sha512-rbAE25KUfjU/s3XHUdJgceoCP5dEOpMx85J04kF+QMdta73XkuG9JGHHinch+XIoKpBdqljin+KqURpJriSzLA==} engines: {node: '>=22.0.0'} - '@azure/msal-browser@5.23.0': - resolution: {integrity: sha512-IRCwkRCK47hBXK+7bu67UZWY7HS0Jx1dZgi4C1HVbjlBum8Jy8fb2l87xFr6HxdQJWy951zRUHtwwDITHFAtaA==} + '@azure/msal-browser@5.25.0': + resolution: {integrity: sha512-7GyXzDUrdLG5zPDCYJbQdJqbl9a2U9SWUSC4lxr7cXjkBq6diTfWcJsi0UjdGWlqy+EevQg9dSC4g3s3BEp19g==} engines: {node: '>=0.8.0'} '@azure/msal-common@16.14.1': resolution: {integrity: sha512-Or6xhPNyi4zHW25158yxBoyxuCqNSPa5YBVqfF1J5Ks4MJWBo/USXdp05DQIPu1Zli00YZu6t0+h6KvHJealxQ==} engines: {node: '>=0.8.0'} + '@azure/msal-common@16.14.2': + resolution: {integrity: sha512-HOBR0Gfo39TMrkxNG8k6cfjRkrRjXZc3JQs3ebMWQc9S4cihuknMYra3sgmzbZB752oQBqBN9+WF7c826p2vIQ==} + engines: {node: '>=0.8.0'} + '@azure/msal-node@6.0.1': resolution: {integrity: sha512-ixSO1Y/kCVRthRs+hSx/5qkwaunX1/RAePhlMN0wIpIQ4WEZ6AREGGnGd1AP0qspHVsAwzWQKGubpjh50JyJIQ==} engines: {node: '>=20'} @@ -318,24 +322,239 @@ packages: cpu: [x64] os: [win32] + '@inquirer/ansi@1.0.2': + resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} + engines: {node: '>=18'} + + '@inquirer/checkbox@4.3.2': + resolution: {integrity: sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/confirm@5.1.21': + resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@10.3.2': + resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/editor@4.2.23': + resolution: {integrity: sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/expand@4.0.23': + resolution: {integrity: sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/external-editor@1.0.3': + resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@1.0.15': + resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} + engines: {node: '>=18'} + + '@inquirer/input@4.3.1': + resolution: {integrity: sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/number@3.0.23': + resolution: {integrity: sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/password@4.0.23': + resolution: {integrity: sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/prompts@7.10.1': + resolution: {integrity: sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/rawlist@4.1.11': + resolution: {integrity: sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/search@3.2.2': + resolution: {integrity: sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/select@4.4.2': + resolution: {integrity: sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/type@3.0.10': + resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} - '@istanbuljs/schema@0.1.3': - resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + '@istanbuljs/schema@0.1.6': + resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==} engines: {node: '>=8'} '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@napi-rs/keyring-darwin-arm64@1.3.0': + resolution: {integrity: sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/keyring-darwin-x64@1.3.0': + resolution: {integrity: sha512-YcJtEV5LA3cvA4z3BurgxH5IhTsW1JfIvcAAcqcecwk06Si9F9NqkxbZVIfDwQ8oRHgaBmT3zZJnLAotCrVahw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/keyring-freebsd-x64@1.3.0': + resolution: {integrity: sha512-vlLf31TGhfRAaxLDBhg8b89ss0HHD/lyNmL5F3UjSaz5CUXElsJmKYq9fqA/B+cZKUEUcLHHGhF0I/CqcFdaVw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@napi-rs/keyring-linux-arm-gnueabihf@1.3.0': + resolution: {integrity: sha512-KiWdMMu/Inz/bHHIAGrnF7r54FZDYXuHO6UFF/rhIrshUsxbMG1Rl9lEymNtqqsVo927G0VYcb02FzWQ3iBQRQ==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/keyring-linux-arm64-gnu@1.3.0': + resolution: {integrity: sha512-eyKGpY40lm9Jvs1aD294XRH4y7+TlJM0YVAryZeXA6TX0mb4gMkxVXwSQv7MCwgah7raeUd0dKUb4BPAYIgcMg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@napi-rs/keyring-linux-arm64-musl@1.3.0': + resolution: {integrity: sha512-iIK6JWHXAJqDrEyLY3TmswwloVyt2vj+04TZnew+uSJ9gnDO8EwRbp3/iw3LpWaXiDO7VomGO6y8I0Id8uBZSw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@napi-rs/keyring-linux-riscv64-gnu@1.3.0': + resolution: {integrity: sha512-/PGqrwn6EwgtK6vccASSXJRfOSP4vN1F4ASsIQ+7MdrK6hNvAJ1FZPrIuD5gGGdxezo3F++To2Wq7DbuGIeuNQ==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@napi-rs/keyring-linux-x64-gnu@1.3.0': + resolution: {integrity: sha512-2PDK1WKWTu9lBGq9VvNEkSlQD3O7YwVpmnyN2M3cy4v7NJ/8gDMd9GXv3G+FVXN13uhp4gnnPBS+ScefmEeD2A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@napi-rs/keyring-linux-x64-musl@1.3.0': + resolution: {integrity: sha512-oJ2HkX8YUo46QBkn0pG+HuIKQNqr523q6vBobCn+P95s4C4K6/kLBqHY/1bg5J4ap31DzsznhnFKcfBNBsjCnw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@napi-rs/keyring-win32-arm64-msvc@1.3.0': + resolution: {integrity: sha512-tOd3c/uAaeoE4ycVlmAdSvygz0Zt3zdca6Y7gokBeIbaRDWpjDIUOpU3MvML59XAaqyuKGsVVu0F/DZb1lHPmw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/keyring-win32-ia32-msvc@1.3.0': + resolution: {integrity: sha512-sPSqeAFZMGqP1R++M2JTza7GQJJ/TpCo6JU6Vcd4jnebvOaEDs9b7eipakU1PJdSvhpC2yXMCNRk9gXfrhuwHQ==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@napi-rs/keyring-win32-x64-msvc@1.3.0': + resolution: {integrity: sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/keyring@1.3.0': + resolution: {integrity: sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA==} + engines: {node: '>= 10'} + '@node-rs/crc32-android-arm-eabi@1.10.8': resolution: {integrity: sha512-Ed2P9uQAOlOSveuPKSlmCctcZ73+9VIBTD84F8MjfKU26q33Y0hYlcl9vlzfc9UQX7LLMGEZhOuU1xxYtx49tg==} engines: {node: '>= 10'} @@ -464,124 +683,124 @@ packages: cpu: [x64] os: [win32] - '@oxlint/binding-android-arm-eabi@1.86.0': - resolution: {integrity: sha512-63Ozq6yQn79B2UxjSZ9huJmI+3/ZPI7hxAe6TyFh9oQL447KnIMoffOdtog+h4KILbQr61ZRvFPW5gYbAUgpgQ==} + '@oxlint/binding-android-arm-eabi@1.87.0': + resolution: {integrity: sha512-22tVIHlB9898OYsBKmYB6He1MCSqdTmK1BVWuSNUTVrpS0KWNege+U9uEYqGJ+KatQag0r/oU44i3OOs46z7HQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxlint/binding-android-arm64@1.86.0': - resolution: {integrity: sha512-JdLGp44phbf/0Du236Dt06yfK6Qw8xhTFeDvHJdjk167r59OfP5+BL31mgMYVnswS8e+j6tFuccSY0QDC/lgCw==} + '@oxlint/binding-android-arm64@1.87.0': + resolution: {integrity: sha512-JfZQ3MWf74n8ov0DojZpkOduIubYJvZL5tbTMMhDutsal6u1xH1QuNpN+lK+EQwF8AeAfgwTR2fgh5U2br27XA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxlint/binding-darwin-arm64@1.86.0': - resolution: {integrity: sha512-h+vkOr4ik6KLFCXdtNVEj9xfnXfNUpPoF95QjORdhiSpLqQePzcN81kGHjAgsw1/G7WBWK5KsFKbI2UK8EyzMA==} + '@oxlint/binding-darwin-arm64@1.87.0': + resolution: {integrity: sha512-rkx6Xvvw93z2GImVOyvbZwAZcoY0Z6MJW177XNsSB+i/CBsH7ANyelTp01VZyIkM3iSXtqKVb4+ptBgPHap5pQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxlint/binding-darwin-x64@1.86.0': - resolution: {integrity: sha512-wbglRuH5eKsp68keTOOeKDHU1astWSRYFsh2bwKQTAPb44rD6kVgzYI1ZrAJRTc6/7PH55ZXFCQx0rQXrj0UdA==} + '@oxlint/binding-darwin-x64@1.87.0': + resolution: {integrity: sha512-cw5nYw7L/pQ2SgGBgOcg/Qo89llQre3vIc/kuBLhSICzu1r5s8L76H3ug6AIFsD98WpROj8/z5JVzkf4daxHaQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxlint/binding-freebsd-x64@1.86.0': - resolution: {integrity: sha512-yTQ7QSOlNfQsrfbeVweTiegQJLKPjUKgXp/JpGiFzza6Hp29S9LAoH77TM3L0RjK39iQRZB5lk5iq2t241Mfag==} + '@oxlint/binding-freebsd-x64@1.87.0': + resolution: {integrity: sha512-x/SuznE6loRZK9gz9AS4ZmzNzSu+5KdeoCS+sVHhnrE8HM6AD8UyTjwldQTTqlXGvkwHASgB0NiTNOc3o3PdAg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxlint/binding-linux-arm-gnueabihf@1.86.0': - resolution: {integrity: sha512-QYJGy0E2Tv8GAayyBIeHFTwQb8u+zrvrOYrifuDGZDr9ad5XsDhiMScpZymhS9fwWjqrgG2ZUwT4vpY0ISqp4Q==} + '@oxlint/binding-linux-arm-gnueabihf@1.87.0': + resolution: {integrity: sha512-+JEDmCAXiiFmKhAjZhgAOwaHWY6WENOQ5QA+lFuY59tgsUCDOTRi46B+lENK6CWvtxsXpwkZXNOgpjAQcpIpnA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm-musleabihf@1.86.0': - resolution: {integrity: sha512-oQo83CafLT8p3qh0AGkN0tSUIh5Hm63mfDsoqUmBQUBnSZLNBAbyRaAftfGY5a0cKZ34dG6jAtMYgcY5kZqt5g==} + '@oxlint/binding-linux-arm-musleabihf@1.87.0': + resolution: {integrity: sha512-/uC9XqXLAC9AHFLNycwmSQEvKqGrOCgU+uXALI7gNW0DG81TJ/3T2eHnKdRvcb5R1gFEfjxDwI4e4zIa29rICQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm64-gnu@1.86.0': - resolution: {integrity: sha512-EM6wy5c2UM12qPb7iDJDBAZvHyfEJGH6iosFgaFiaRZpirz99//EHcUyD2KAskz07x1TULuiAtsojf4nvWWksw==} + '@oxlint/binding-linux-arm64-gnu@1.87.0': + resolution: {integrity: sha512-FTRYPsBy/M728vnUv7l+KAwSAa3Qi4BQbhVvy7NYuMTZ0sUa/OWsbMx79HVFw6u/5KWcaYayDsoOiv7xbevzDg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-arm64-musl@1.86.0': - resolution: {integrity: sha512-vCiUQb9ZNzZalxYRU4mlZwUqT5f+c7Sk9DwUOplWHtik3Dl4r3DVnUB5tBeRuTAUe6MUVCTylNW7TkkN8J1oTw==} + '@oxlint/binding-linux-arm64-musl@1.87.0': + resolution: {integrity: sha512-6sC+N7IY0h1egd8SrYUGNN7CUyKJZ4bIZakKa7V7hfinf4jfi8Kq4+klJvFp5DqXcwMn44I5MeBwmJluO1odFg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxlint/binding-linux-ppc64-gnu@1.86.0': - resolution: {integrity: sha512-T0AzE89yjTYmOhB/hb6i+rWTpe2mYt+xiguBhs9DEPIJTZ1H8OYqsE4VxJHHw2Wlq8Mztwvp2P8NV87ZOuhPRw==} + '@oxlint/binding-linux-ppc64-gnu@1.87.0': + resolution: {integrity: sha512-d5Mnn+ICbhoJPi37Ccjy+86T5qfH2+mSTrTMBXNPfB+wqSWOayBBLLIrCXdUy1YssluCVdGkl+DO9w5r5ODmUA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-gnu@1.86.0': - resolution: {integrity: sha512-s49r0K2f5Oc7fJoMUO1WkdhYK5Ob79uYRwWQ/XNoGysaOq3RUGmGUbHSuXiyydTTA+P9xEdDEnnlZVXiaSJhlw==} + '@oxlint/binding-linux-riscv64-gnu@1.87.0': + resolution: {integrity: sha512-sf5YWuUo1Dx9loqWCgF8P278MycfcD4JHjDC/LV5j11L3Lqa4PFiwCjD6B8kP4AjsgZZkLn3xbpJEtZBaccs4Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-musl@1.86.0': - resolution: {integrity: sha512-JIa2tYR8ayb3nvcMSZzn4O/xTy13S7MHllu7cBD+B5qDQXXSRy/Vwk1aNpiTjZmBJGwKCHOy/NvnY5Xcwg27uQ==} + '@oxlint/binding-linux-riscv64-musl@1.87.0': + resolution: {integrity: sha512-+aCraYbmK3uU3P0rFojjeaK9pGY6JvBDXh/0CM+gPyWKmNmXmnFe0qj4qj02U4ibFpbd9Pa9CmDvIdjJUd29rA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxlint/binding-linux-s390x-gnu@1.86.0': - resolution: {integrity: sha512-XwXMyBsAuWZkhZU74UNcfQKr1CJJAKnS80dZzOnjl6A4fKcf91IjFgidegQugMHA5SN8ulwDXEDV1yPntjXgQQ==} + '@oxlint/binding-linux-s390x-gnu@1.87.0': + resolution: {integrity: sha512-7rzbZQIljo6VLWVPqUyLa2P/WV3VFsJB4M52R4NCUJn1A50yjtUsHxiMHFL9opEKGFIiTbZ3VQzmtcJOPUq4NQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-gnu@1.86.0': - resolution: {integrity: sha512-C1WjukSyMnr66b+w1/tV8RFVv6d9v0MzDf4p9IxVXknqgmTHBgZh1pccN1eHzFr0b9Tbb3OXoPsAAdAuHAQfeA==} + '@oxlint/binding-linux-x64-gnu@1.87.0': + resolution: {integrity: sha512-Dry46Fa+AXE/xt9J/YriujU1pU9mzfNWHGQwtOYibAFH8HLrKWCi93AhIGnuUMiwn2JbuBkRyz51f50Ly4r4ow==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-musl@1.86.0': - resolution: {integrity: sha512-ap6KLmvC38c6MdYzsIh25cXQupqYvjd37tMNftzrX1DCtkX1Gcf2B+S2B17dD4lKa5c5gJog7DQJyDo82PBKyw==} + '@oxlint/binding-linux-x64-musl@1.87.0': + resolution: {integrity: sha512-f/HMZez74gJvnRpGz+b5sdGyWrEgRLmFTXg9sNoqjPWogGxYHUhXKTKyMiYsu9rUGR2ciYqL3fk+Ua4JhMJbRA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxlint/binding-openharmony-arm64@1.86.0': - resolution: {integrity: sha512-996Q9w/2GLwNyy5Djs39Jt09aFlr4b7TPhoj7dvo5FHmcyiF68nPz73HNc5UAYKIYeK+M9JzPQ4Wg1r1dXvP6A==} + '@oxlint/binding-openharmony-arm64@1.87.0': + resolution: {integrity: sha512-lEC6ZfPZm/07JlB7FsOWX9o1RiHlXiBW594LSUGuXmekC8LZmvUPP9XzE2WZcNxJi5cHA/Fw56o02c3JxgMA5g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxlint/binding-win32-arm64-msvc@1.86.0': - resolution: {integrity: sha512-9uTCHYTknNkug5+46ViSoUMuOuGWK61D87QnblU6CXaWKJC630Z7dbls61vdHxya/cR2ycQZ6Mm6ckgF6Cag5g==} + '@oxlint/binding-win32-arm64-msvc@1.87.0': + resolution: {integrity: sha512-afBWKOD36dUiA9AenaiWpCg8xfyUVPYznyAAVXu413brK4RA5UI/Xoc4yfeGIT8G1a44MV+JR7hioryerBDzGQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxlint/binding-win32-ia32-msvc@1.86.0': - resolution: {integrity: sha512-a47ga7EKVfMU8rO0i1m3mGwfNRj3FhQyt1d/ukO/E9u1HHF3KpR3tyb3VhuJBGEVPF8uR4exSyHUyx7xbE+uxw==} + '@oxlint/binding-win32-ia32-msvc@1.87.0': + resolution: {integrity: sha512-daptNsEOuru4/nJTxV4DBHNJ5GepzmaBVQf+jjgKQGi9NRlZKik+RFq4hUL7ewsF1pwjKM+SkYaMbF82UwFGnw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxlint/binding-win32-x64-msvc@1.86.0': - resolution: {integrity: sha512-az4dzifCRzES1crwu6pDFXzJTIanrdxXFL9TbiJWrx9LKcuacbR83aPvxDA1LKLNswi/RoqitC7Afcflhu3zBQ==} + '@oxlint/binding-win32-x64-msvc@1.87.0': + resolution: {integrity: sha512-k+m/EShyXE29J/r1fjk0HlAzhN5JhIvj044oSJkXTEkCLrWZQgXb/3r4q5LTw/eaKW/4GuMXG2xmiJs3s5rPNw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -649,20 +868,11 @@ packages: '@sinonjs/commons@3.0.1': resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} - '@sinonjs/fake-timers@10.3.0': - resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} - - '@sinonjs/fake-timers@11.3.1': - resolution: {integrity: sha512-EVJO7nW5M/F5Tur0Rf2z/QoMo+1Ia963RiMtapiQrEWvY0iBUvADo8Beegwjpnle5BHkyHuoxSTW3jF43H1XRA==} - - '@sinonjs/samsam@8.0.3': - resolution: {integrity: sha512-hw6HbX+GyVZzmaYNh82Ecj1vdGZrqVIn/keDTg63IgAwiQPO+xCz99uG6Woqgb4tM0mUiFENKZ4cqd7IX94AXQ==} + '@sinonjs/fake-timers@15.4.0': + resolution: {integrity: sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==} - '@sinonjs/text-encoding@0.7.3': - resolution: {integrity: sha512-DE427ROAphMQzU4ENbliGYrBSYPXF+TtLg9S8vzeA+OF4ZKzoDdzfL8sxuMUGS/lgRhM6j1URSk9ghf7Xo1tyA==} - deprecated: |- - Deprecated: no longer maintained and no longer used by Sinon packages. See - https://github.com/sinonjs/nise/issues/243 for replacement details. + '@sinonjs/samsam@10.0.2': + resolution: {integrity: sha512-8lVwD1Df1BmzoaOLhMcGGcz/Jyr5QY2KSB75/YK1QgKzoabTeLdIVyhXNZK9ojfSKSdirbXqdbsXXqP9/Ve8+A==} '@textlint/ast-node-types@15.8.0': resolution: {integrity: sha512-5CiH9COYmovWmExQgs7763DzX6Gy9zjkjJ7JxCC95wyTcjwQn/8poNF6fv3qzRlmx8CRRde8DHr9FcgAAiPzgw==} @@ -692,11 +902,8 @@ packages: '@types/mocha@10.0.10': resolution: {integrity: sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==} - '@types/mocha@8.2.3': - resolution: {integrity: sha512-ekGvFhFgrc2zYQoX4JeZPmVzZxw6Dtllga7iGHzfbYIYkAMUx/sAFP2GdFpLff+vdHXu5fl7WX9AT+TtqYcsyw==} - - '@types/node@22.20.5': - resolution: {integrity: sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==} + '@types/node@26.6.4': + resolution: {integrity: sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==} '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} @@ -704,14 +911,14 @@ packages: '@types/sarif@2.1.7': resolution: {integrity: sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ==} - '@types/sinon@10.0.20': - resolution: {integrity: sha512-2APKKruFNCAZgx3daAyACGzWuJ028VVCUDk6o2rw/Z4PXT0ogwdV4KUegW0MwVs0Zu59auPXbbuBJHF12Sx1Eg==} + '@types/sinon@22.0.0': + resolution: {integrity: sha512-TDbVpbccc2HfiqHR09Argj3mHV1KMW7sCCKj52fsl8lbRLkEn7fB1966EWhOKWUBcqfBueZuPoA7/OK1CKiy3g==} '@types/sinonjs__fake-timers@15.0.1': resolution: {integrity: sha512-Ko2tjWJq8oozHzHV+reuvS5KYIRAokHnGbDwGh/J64LntgpbuylF74ipEL24HCyRjf9FOlBiBHWBR1RlVKsI1w==} - '@types/vscode@1.94.0': - resolution: {integrity: sha512-UyQOIUT0pb14XSqJskYnRwD2aG0QrPVefIfrW1djR+/J4KeFQ0i1+hjZoaAmeNf3Z2jleK+R2hv+EboG/m8ruw==} + '@types/vscode@1.140.0': + resolution: {integrity: sha512-KAB5RBZIgnUhpp4mKsVJPPHLOQKu6WtGy2xpfWjaajEXO4xIIC+UESAXLiQ9ewx6QD1uBmvC/S8BJzkbvv4bRA==} '@types/winreg@1.2.36': resolution: {integrity: sha512-DtafHy5A8hbaosXrbr7YdjQZaqVewXmiasRS5J4tYMzt3s1gkh40ixpxgVFfKiQ0JIYetTJABat47v9cpr/sQg==} @@ -843,9 +1050,9 @@ packages: '@vscode/codicons@0.0.46-24': resolution: {integrity: sha512-KIDWUYxG6eh+DHRg+3G4bS487T+g1ZkLfB4HylOBbCcHsvLdfLMRM/m92DS1Ljhi8nglLr9ucwwWhHTEAC+DpA==} - '@vscode/test-cli@0.0.12': - resolution: {integrity: sha512-iYN0fDg29+a2Xelle/Y56Xvv7Nc8Thzq4VwpzAF/SIE6918rDicqfsQxV6w1ttr2+SOm+10laGuY9FG2ptEKsQ==} - engines: {node: '>=18'} + '@vscode/test-cli@0.0.15': + resolution: {integrity: sha512-nAxk2X79wuXS7aOhyFFhFcCqd7EBUoMesu7ZgsYE/4eFjyBMuyIweVE94BxdKH1RieN8eOz2SIrljrZt6Lk9fQ==} + engines: {node: '>=22'} hasBin: true '@vscode/test-electron@3.1.0': @@ -905,6 +1112,11 @@ packages: engines: {node: '>= 20'} hasBin: true + '@vscode/vsce@4.0.0': + resolution: {integrity: sha512-NImwuLaenMmb5D5Jer9/lzi/F9ZQUBOp8Azhj/BVYcTFgixv8KehFXqEUDjQlD2tAiw2E6dDGyjTuAB//di60A==} + engines: {node: '>= 22'} + hasBin: true + ag-charts-types@14.2.0: resolution: {integrity: sha512-4pDV6/4zbzPN7t607Gr1o6DO9RhmPafQGr+hIpTJBaerbaMoC+WOO3v/wkxFbvCNrHKOexEKSfNTH7PaAR4UzA==} @@ -918,8 +1130,8 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} - ajv@8.18.0: - resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} ansi-escapes@7.3.0: resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} @@ -929,8 +1141,8 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} - ansi-regex@6.2.2: - resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + ansi-regex@6.4.0: + resolution: {integrity: sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==} engines: {node: '>=12'} ansi-styles@4.3.0: @@ -941,10 +1153,6 @@ packages: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} - anymatch@3.1.3: - resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} - engines: {node: '>= 8'} - argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -952,9 +1160,6 @@ packages: resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} engines: {node: '>=8'} - async@3.2.6: - resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} - asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -971,10 +1176,6 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - binary-extensions@2.3.0: - resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} - engines: {node: '>=8'} - binaryextensions@6.11.0: resolution: {integrity: sha512-sXnYK/Ij80TO3lcqZVV2YgfKN5QjUWIRk/XSm2J/4bd/lPko3lvk0O4ZppH6m+6hB2/GTu+ptNwVFe1xh+QLQw==} engines: {node: '>=4'} @@ -1015,9 +1216,9 @@ packages: resolution: {integrity: sha512-DdH81/zPLVS11EUgWq3tEu/xn+EzljlMYooDNdzWEnFha3R3NBMpMV1UqYIpjYHV/SgpFKMIX1Oh7o06SjM/oA==} engines: {node: '>=18'} - c8@10.1.3: - resolution: {integrity: sha512-LvcyrOAaOnrrlMpW22n690PUvxiq4Uf9WMhQwNJ9vgagkL/ph1+D4uvjvDA5XCbykrc0sx+ay6pVi9YZ1GnhyA==} - engines: {node: '>=18'} + c8@11.0.0: + resolution: {integrity: sha512-e/uRViGHSVIJv7zsaDKM7VRn2390TgHXqUSvYwPHBQaU6L7E9L0n9JbdkwdYPvshDT0KymBmmlwSpms3yBaMNg==} + engines: {node: 20 || >=22} hasBin: true peerDependencies: monocart-coverage-reports: ^2 @@ -1045,21 +1246,24 @@ packages: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + chardet@2.2.0: + resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==} + cheerio-select@2.1.0: resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} - cheerio@1.0.0-rc.12: - resolution: {integrity: sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==} - engines: {node: '>= 6'} - - chokidar@3.6.0: - resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} - engines: {node: '>= 8.10.0'} + cheerio@1.2.0: + resolution: {integrity: sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==} + engines: {node: '>=20.18.1'} chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} @@ -1074,10 +1278,18 @@ packages: resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} engines: {node: '>=6'} + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} + cliui@9.0.1: + resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} + engines: {node: '>=20'} + cockatiel@3.2.1: resolution: {integrity: sha512-gfrHV6ZPkquExvMh9IOkKsBzNDk6sDuZ6DdBGUBkvFnTCqCxzpuq48RySgP0AnaqQkw2zynOFj9yly6T1Q2G5Q==} engines: {node: '>=16'} @@ -1107,6 +1319,11 @@ packages: core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cross-keychain@1.1.0: + resolution: {integrity: sha512-244DWNdGepLKD5vEn3reZqwzZFiE/LD4U+XV9IaXQbtIXKvQkf0VkRaOj/9vPYauPdR12PSGB3U0cE7jJi3WTQ==} + engines: {node: '>=18'} + hasBin: true + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -1167,14 +1384,14 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - diff@5.2.2: - resolution: {integrity: sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==} - engines: {node: '>=0.3.1'} - diff@7.0.0: resolution: {integrity: sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==} engines: {node: '>=0.3.1'} + diff@9.0.0: + resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==} + engines: {node: '>=0.3.1'} + dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} @@ -1202,9 +1419,9 @@ packages: resolution: {integrity: sha512-UgGlf8IW75je7HZjNDpJdCv4cGJWIi6yumFdZ0R7A8/CIhQiWUjyGLCxdHpd8bmyD1gnkfUNK0oeOXqUS2cpfQ==} engines: {ecmascript: '>= es5', node: '>=4'} - ejs@3.1.10: - resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} - engines: {node: '>=0.10.0'} + ejs@7.0.1: + resolution: {integrity: sha512-TrAb1fY4jNY6aQrNjMJsoWQxGUK47A38JxuUvnt75vDcAA9Ln7ur6FHo+irVa5Bwd3w9rdUT3xLSl5MRgHIugg==} + engines: {node: '>=0.12.18'} hasBin: true emoji-regex@10.6.0: @@ -1216,11 +1433,14 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + encoding-sniffer@0.2.1: + resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==} + end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} - enhanced-resolve@5.20.0: - resolution: {integrity: sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==} + enhanced-resolve@5.26.0: + resolution: {integrity: sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==} engines: {node: '>=10.13.0'} entities@4.5.0: @@ -1231,6 +1451,14 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + entities@8.1.0: + resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==} + engines: {node: '>=20.19.0'} + environment@1.1.0: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} @@ -1243,8 +1471,8 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} - es-object-atoms@1.1.1: - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} es-set-tostringtag@2.1.0: @@ -1282,20 +1510,30 @@ packages: fast-uri@3.1.8: resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} - fastq@1.20.1: - resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + fastq@1.20.3: + resolution: {integrity: sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true figures@6.1.0: resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} engines: {node: '>=18'} - filelist@1.0.6: - resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} - fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + find-up-simple@1.0.1: + resolution: {integrity: sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==} + engines: {node: '>=18'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -1304,8 +1542,8 @@ packages: resolution: {integrity: sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==} hasBin: true - follow-redirects@1.16.0: - resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + follow-redirects@1.16.1: + resolution: {integrity: sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==} engines: {node: '>=4.0'} peerDependencies: debug: '*' @@ -1328,11 +1566,6 @@ packages: resolution: {integrity: sha512-KYAb4c9BJQI6QqGKthV68OHe0badztdXJWKo0WtBA9IuCFPTKvE5ZdUBglP833aMjhaSPNO4A5j/EkzZtGlKjA==} engines: {node: '>=14.14'} - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -1340,8 +1573,8 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} - get-east-asian-width@1.5.0: - resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} + get-east-asian-width@1.7.0: + resolution: {integrity: sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==} engines: {node: '>=18'} get-intrinsic@1.3.0: @@ -1356,39 +1589,39 @@ packages: resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} engines: {node: '>=18'} - git-cliff-darwin-arm64@2.12.0: - resolution: {integrity: sha512-k3jzFDmkjc+6MjpnqvRenzMWRbZN5J+w3iQ8WNt9pSmPewNJIm92O/G6AbAxQaCbSfzQapeZ0e+5wSacVc62GA==} + git-cliff-darwin-arm64@2.14.2: + resolution: {integrity: sha512-Ylk39hv5flEUrXeoGvH6M3WjHBNERFH7a9DVEcGU2a8Q7a7fPtVasNk1fMuJ7CVbZCRIzUNyQV0ejRpxBdFnag==} cpu: [arm64] os: [darwin] - git-cliff-darwin-x64@2.12.0: - resolution: {integrity: sha512-Kkoe+nfmXM/WMcZuC+OaIGA5vj847Ima6NEaaHnyb7Xsri+OAJryPXlABV7q6UeGfiiN2MlL8UsoHgnIEIQLqQ==} + git-cliff-darwin-x64@2.14.2: + resolution: {integrity: sha512-3FZNm0BGCIim/3G6uHSWAakrCaGmC47lcI+Cob9CZPMBYHKrenYk0i6B8MlObSXr2Lr7Ve1BpGbd/foQg4NOiA==} cpu: [x64] os: [darwin] - git-cliff-linux-arm64@2.12.0: - resolution: {integrity: sha512-eTp2gZjV4LmfzdlhFsYFYuWf5mojALU03X/37r3VmnpuabaijuTEQo/zm/0BKP8gPiLKLR4ofdUvE1OSisCE1A==} + git-cliff-linux-arm64@2.14.2: + resolution: {integrity: sha512-3oMDpoyfsy2angD9ulcCBtyWCkzZqN3spmmtb0jic8OKAu1p+CALS8oep7J8AdgkDojuLo6bdHiSZIM9PeTxmQ==} cpu: [arm64] os: [linux] - git-cliff-linux-x64@2.12.0: - resolution: {integrity: sha512-abidFG6dH2N5hPUF245/kRYdwViP11Pz7ZwIW/a86CJLZ/WSE7dJt0f2cUIkxTcFSsp11OwuLc5k1hAbwmiIRw==} + git-cliff-linux-x64@2.14.2: + resolution: {integrity: sha512-4EaBYwBiCupECZ2rzC6CelNSGYvkvtJeja4wXLDGtfKnRhCN/WNoqbM+hvrtm32uf5qovfoNoDA906F8itw+oQ==} cpu: [x64] os: [linux] - git-cliff-windows-arm64@2.12.0: - resolution: {integrity: sha512-rFuI+D/3Yq3jqafazZw5E68HsXEvcwI/B/5IPDIZD+QqZh8vETf4IXs7wVxYWWtHQJDC+G9ZrR3vE5648mdG3A==} + git-cliff-windows-arm64@2.14.2: + resolution: {integrity: sha512-0oc0xD/6A+BvInz8imBJulXVOlR9M0nL+d3ibL0lwJvxUVTMQzvoCnsn9OxpsdzXrEisnTvaf+GExoKVJkUcDw==} cpu: [arm64] os: [win32] - git-cliff-windows-x64@2.12.0: - resolution: {integrity: sha512-jskb3nyVGr4dekHSCDM/J6iho45t37wnmMGkPNq42kOoUp04JS96yMBrNRdXfXV9ViZsaZq3NaNu1e3QkhFlyA==} + git-cliff-windows-x64@2.14.2: + resolution: {integrity: sha512-RFmCMiwGMU8O+rF//wxT7L10ssoDtzI+QFxlbjpA8E1BAEafkmLTQkKalT88ipEweD46P+Sfc20v8RXuE7V/UA==} cpu: [x64] os: [win32] - git-cliff@2.12.0: - resolution: {integrity: sha512-kjTm5439LsvMs/xRxndWBUetrA4aQfLE8DTbR/ER5H7fGn7ioeFG9YNAK1V7dpTtNi6k2uKYY4f3EvT8J1d+1Q==} - engines: {node: '>=18.19 || >=20.6 || >=21'} + git-cliff@2.14.2: + resolution: {integrity: sha512-hlXIq4k2Tdj9C59WsD7FsLE0xRGFDozTUuoeqg9toMyVohaVxXiSiLnhM/IQyCmhOh5M4zuNckr2o8qMeuffbg==} + engines: {node: ^18.19 || >=20.6} hasBin: true github-from-package@0.0.0: @@ -1437,10 +1670,6 @@ packages: resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} engines: {node: '>= 0.4'} - hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} - engines: {node: '>= 0.4'} - hasown@2.0.4: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} @@ -1449,8 +1678,8 @@ packages: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true - highlight.js@11.11.1: - resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} + highlight.js@11.12.0: + resolution: {integrity: sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg==} engines: {node: '>=12.0.0'} hosted-git-info@4.1.0: @@ -1464,8 +1693,8 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} - htmlparser2@8.0.2: - resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} + htmlparser2@10.1.0: + resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} @@ -1479,11 +1708,19 @@ packages: resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} engines: {node: '>=18.18.0'} + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - ignore@7.0.10: - resolution: {integrity: sha512-HpbUakT7xp5miBUywCHf36ZEuAJNklBJDDsGpUIjMzOSmM8ELSfA9Sa/QDPeNeqeoN31u+UTCkL4klCOVvRm4Q==} + ignore@7.0.12: + resolution: {integrity: sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==} engines: {node: '>= 4'} immediate@3.0.6: @@ -1499,10 +1736,6 @@ packages: ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} - is-binary-path@2.1.0: - resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} - engines: {node: '>=8'} - is-ci@2.0.0: resolution: {integrity: sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==} hasBin: true @@ -1545,6 +1778,10 @@ packages: resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} engines: {node: '>=8'} + is-path-inside@4.0.0: + resolution: {integrity: sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==} + engines: {node: '>=12'} + is-plain-obj@2.1.0: resolution: {integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==} engines: {node: '>=8'} @@ -1598,11 +1835,6 @@ packages: jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} - jake@10.9.4: - resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==} - engines: {node: '>=10'} - hasBin: true - js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -1610,6 +1842,10 @@ packages: resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} hasBin: true + js-yaml@5.4.3: + resolution: {integrity: sha512-yzzNtgczwgoVHNigCyKTXcQJU4Y8t/ikU4UxXW0BvrLfHTGWZhsNcOT2s/a7vn81/4KqVnNmHjaUEPlvWnNwVw==} + hasBin: true + json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} @@ -1621,18 +1857,15 @@ packages: jsonc-parser@3.3.1: resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} - jsonfile@6.2.0: - resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} jsonwebtoken@9.0.3: resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} engines: {node: '>=12', npm: '>=6'} - jszip@3.10.1: - resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} - - just-extend@6.2.0: - resolution: {integrity: sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==} + jszip@3.10.2: + resolution: {integrity: sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==} jwa@2.0.1: resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} @@ -1711,6 +1944,11 @@ packages: resolution: {integrity: sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==} hasBin: true + marked@18.1.0: + resolution: {integrity: sha512-PamYXWWWg2nboG3oX5Ffzpy3EFZROqZJK9hSlMmUnekp7TxPs5DcT95vwd46m73/exfKCRVMtgXoPMI4FEJL6w==} + engines: {node: '>= 20'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -1718,6 +1956,10 @@ packages: mdurl@2.1.0: resolution: {integrity: sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==} + meow@14.1.0: + resolution: {integrity: sha512-EDYo6VlmtnumlcBCbh1gLJ//9jvM/ndXHfVXIFrZVr6fGcwTUyCTFNTLCKuY3ffbK8L/+3Mzqnd58RojiZqHVw==} + engines: {node: '>=20'} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -1751,10 +1993,6 @@ packages: resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} engines: {node: 18 || 20 || >=22} - minimatch@5.1.9: - resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} - engines: {node: '>=10'} - minimatch@9.0.9: resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} engines: {node: '>=16 || 14 >=14.17'} @@ -1769,25 +2007,31 @@ packages: mkdirp-classic@0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} - mocha@11.7.5: - resolution: {integrity: sha512-mTT6RgopEYABzXWFx+GcJ+ZQ32kp4fMf0xvpZIIfSq9Z8lC/++MtcCnQ9t5FP2veYEP95FIYSvW+U9fV4xrlig==} + mocha@11.8.0: + resolution: {integrity: sha512-VyCeUdGN3A9lmCTTgG4yuvY9ixxaDk+xt2R/7/+1AP6EqNG+G9OKkzBwhVtVYoNX8YsxNSgAl8mOv3IAeOpFbw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} hasBin: true + mocha@12.0.3: + resolution: {integrity: sha512-beammieDdiWdkjANWeWPpkxUROYGi5FN88fPOkLiticz7HTg3Y+Pho3EYE6SUdcuWnZ3h2WD8EXCA55JF4o6wg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} mute-stream@0.0.8: resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==} + mute-stream@2.0.0: + resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} + engines: {node: ^18.17.0 || >=20.5.0} + napi-build-utils@2.0.0: resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} - nise@5.1.9: - resolution: {integrity: sha512-qOnoujW4SV6e40dYxJOb3uvuoPHtmLzIk4TFo+j0jPJoC+5Z9xja5qH5JZobEPsa8+YYphMrOSwnrshEhG2qww==} - - node-abi@3.96.0: - resolution: {integrity: sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==} + node-abi@3.98.0: + resolution: {integrity: sha512-Llh8noNw7s15m+rmIrZnZQbe4gLyasL9loT74rC92PkmwWKeEedKvc7o6pjp8B3MhrYT/Cl846NOYZbz1wmddw==} engines: {node: '>=10'} node-addon-api@4.3.0: @@ -1801,10 +2045,6 @@ packages: resolution: {integrity: sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==} engines: {node: ^16.14.0 || >=18.0.0} - normalize-path@3.0.0: - resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} - engines: {node: '>=0.10.0'} - npm-run-path@6.0.0: resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} engines: {node: '>=18'} @@ -1835,17 +2075,17 @@ packages: resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} engines: {node: '>=18'} - ovsx@0.10.12: - resolution: {integrity: sha512-WwMj1iQDvCk02029oxPnkFXsPrHZ+WzmoNW5pJ8JGepHtL30i2JE4s3C3wqzQqj6a35vx2hp0gV3TdfefGmvMg==} - engines: {node: '>= 20'} + ovsx@1.2.0: + resolution: {integrity: sha512-12mauBqsLehlJ0cRiroQz4uKYVjblVS5OobgSJ278CdCtkDfRQD+SI3NQ00GNalDqzp5PDN11yx84miFUojeMw==} + engines: {node: '>=22.0.0'} hasBin: true oxlint-tsgolint@7.0.2003: resolution: {integrity: sha512-VnK4zlqgmgq/7ZcjzCk/WpN8kKFsYGcB85Io9qT3wL4K8Un3RKmJkp793crNETieMusPMKOA4a+Mw2wbteI6TQ==} hasBin: true - oxlint@1.86.0: - resolution: {integrity: sha512-og0lhgvZfgGF//gOOmZXvtr+GmBbAGEnbEhv/QUg7UW2Wi4wHMnJbnMD+zuHgPdJxdklfgpPupdlAaAySyxrZg==} + oxlint@1.87.0: + resolution: {integrity: sha512-drxVlaW7Oqq7wSy1CN25znRu00aRq8n57Q2wniJRxCmtYVz07ufCm7EVDPQFZURrTgQIjzKrVEHAD/HcvJXObg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -1889,9 +2129,15 @@ packages: parse5-htmlparser2-tree-adapter@7.1.0: resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==} + parse5-parser-stream@7.1.2: + resolution: {integrity: sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==} + parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -1912,9 +2158,6 @@ packages: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} - path-to-regexp@6.3.0: - resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} - path-type@6.0.0: resolution: {integrity: sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==} engines: {node: '>=18'} @@ -1929,6 +2172,10 @@ packages: resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} engines: {node: '>=8.6'} + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + pluralize@2.0.0: resolution: {integrity: sha512-TqNZzQCD4S42De9IfnnBvILN7HAW7riLqsCyp8lgjXeysyPlX5HhqKAcJHHHb9XskE4/a+7VGC9zzx8Ls0jOAw==} @@ -1942,13 +2189,16 @@ packages: deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. hasBin: true - pretty-ms@9.3.0: - resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} + pretty-ms@9.3.1: + resolution: {integrity: sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA==} engines: {node: '>=18'} process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} @@ -1985,14 +2235,14 @@ packages: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} - readdirp@3.6.0: - resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} - engines: {node: '>=8.10.0'} - readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + readdirp@5.1.1: + resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} + engines: {node: '>= 20.19.0'} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -2005,6 +2255,10 @@ packages: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -2019,6 +2273,12 @@ packages: safe-buffer@5.1.2: resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + sax@1.6.1: resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==} engines: {node: '>=11.0.0'} @@ -2032,11 +2292,6 @@ packages: resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} hasBin: true - semver@7.7.4: - resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} - engines: {node: '>=10'} - hasBin: true - semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} @@ -2073,6 +2328,9 @@ packages: resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} engines: {node: '>= 0.4'} + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -2087,9 +2345,8 @@ packages: resolution: {integrity: sha512-1sbhsxqI+I2tqlmjbz99GXNmZtr6tKIyEgGGnJw/MKGblalqk/XoOYYFJlBzTKZCxx8kLaD3FD5s9BEEjx5Pyg==} engines: {node: '>=10'} - sinon@15.2.0: - resolution: {integrity: sha512-nPS85arNqwBXaIsFCkolHjGIkFo+Oxu9vbgmBJizLAhqe6P2o3Qmj3KCUoRkfhHtvgDhZdWD3risLHAUJ8npjw==} - deprecated: 16.1.1 + sinon@22.1.0: + resolution: {integrity: sha512-n1ajF2rBWMTtEwbKcw4UdFg4nCnDdq/U6RDoxtOd7oapOlRoJ5ynwFx60owROyhDpA9QhMZi0pCO/xtmwFjG7w==} slash@5.1.0: resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} @@ -2108,8 +2365,8 @@ packages: spdx-expression-parse@3.0.1: resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==} - spdx-license-ids@3.0.23: - resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==} + spdx-license-ids@3.0.24: + resolution: {integrity: sha512-cLS9TtWkIQFyLkJ3/5aFQAOHOSKTlOs/7WDut/XPSdjom1fJhUdkepeJAKXa9Y05+FabHd0sti+Et559vDtkpQ==} stdin-discarder@0.2.2: resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} @@ -2127,9 +2384,16 @@ packages: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} + string-width@8.3.0: + resolution: {integrity: sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==} + engines: {node: '>=20'} + string_decoder@1.1.1: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -2150,6 +2414,10 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strip-json-comments@5.0.3: + resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} + engines: {node: '>=14.16'} + structured-source@4.0.0: resolution: {integrity: sha512-qGzRFNJDjFieQkl/sVOI2dUjHKRyL9dAJi2gCPGJLbJHBIkyOHxjuocpIEfbLioX+qSJpvbYdT49/YCdMznKxA==} @@ -2173,8 +2441,8 @@ packages: resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} engines: {node: '>=10.0.0'} - tapable@2.3.0: - resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} tar-fs@2.1.5: @@ -2188,9 +2456,9 @@ packages: resolution: {integrity: sha512-lk+vH+MccxNqgVqSnkMVKx4VLJfnLjDBGzH16JVZjKE2DoxP57s6/vt6JmXV5I3jBcfGrxNrYtC+mPtU7WJztA==} engines: {node: '>=18'} - test-exclude@7.0.2: - resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==} - engines: {node: '>=18'} + test-exclude@8.0.0: + resolution: {integrity: sha512-ZOffsNrXYggvU1mDGHk54I96r26P8SyMjO5slMKSc7+IWmtB/MQKnEC2fP51imB3/pT6YK5cT5E8f+Dd9KdyOQ==} + engines: {node: 20 || >=22} text-table@0.2.0: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} @@ -2199,6 +2467,10 @@ packages: resolution: {integrity: sha512-tXJwSr9355kFJI3lbCkPpUH5cP8/M0GGy2xLO34aZCjMXBaK3SoPnZwr/oWmo1FdCnELcs4npdCIOFtq9W3ruQ==} engines: {node: '>=4'} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + tmp@0.2.7: resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==} engines: {node: '>=14.14'} @@ -2243,8 +2515,12 @@ packages: underscore@1.13.8: resolution: {integrity: sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==} - undici-types@6.21.0: - resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@8.9.0: + resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} + + undici@7.30.0: + resolution: {integrity: sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==} + engines: {node: '>=20.18.1'} unicorn-magic@0.1.0: resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==} @@ -2292,6 +2568,15 @@ packages: vscode-languageserver-types@3.18.4: resolution: {integrity: sha512-nQsacoiijvl0fRf2q28uFDrAVwRhdCa3QveiF7/CX0r18mLR5FU/1WskSsCKOjmEbtteKUnY8x8Yzxop+k0zwg==} + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -2300,9 +2585,16 @@ packages: winreg@1.2.5: resolution: {integrity: sha512-uf7tHf+tw0B1y+x+mKTLHkykBgK2KMs3g+KlzmyMbLvICSHQyB/xOFjTT8qZ3oeTFyU7Bbj4FzXitGG6jvKhYw==} + workerpool@10.0.3: + resolution: {integrity: sha512-6z2Iis68Wqth93/G/wJP9u+R3O+d2XTlgWChGCwuT1qLbBsOYueGRZuJ++v3mtDP5KjYdy+WzvWC+VWETSVXJA==} + workerpool@9.3.4: resolution: {integrity: sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg==} + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -2311,6 +2603,10 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -2337,14 +2633,22 @@ packages: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs-parser@22.0.0: + resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + yargs-unparser@2.0.0: resolution: {integrity: sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==} engines: {node: '>=10'} - yargs@17.7.2: - resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} engines: {node: '>=12'} + yargs@18.2.0: + resolution: {integrity: sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + yauzl-promise@4.0.0: resolution: {integrity: sha512-/HCXpyHXJQQHvFq9noqrjfa/WpQC2XYs3vI7tBiAi4QiIU1knvYhZGaO1QPjwIVMdqflxbmwgMXtYeaRiAE0CA==} engines: {node: '>=16'} @@ -2360,8 +2664,12 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - yoctocolors@2.1.2: - resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} + yoctocolors-cjs@2.1.3: + resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} + engines: {node: '>=18'} + + yoctocolors@2.2.0: + resolution: {integrity: sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==} engines: {node: '>=18'} snapshots: @@ -2432,7 +2740,7 @@ snapshots: '@azure/core-tracing': 1.4.0 '@azure/core-util': 1.14.0(supports-color@8.1.1) '@azure/logger': 1.4.0(supports-color@8.1.1) - '@azure/msal-browser': 5.23.0 + '@azure/msal-browser': 5.25.0 '@azure/msal-node': 6.0.1 open: 10.2.0 tslib: 2.8.1 @@ -2446,12 +2754,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@azure/msal-browser@5.23.0': + '@azure/msal-browser@5.25.0': dependencies: - '@azure/msal-common': 16.14.1 + '@azure/msal-common': 16.14.2 '@azure/msal-common@16.14.1': {} + '@azure/msal-common@16.14.2': {} + '@azure/msal-node@6.0.1': dependencies: '@azure/msal-common': 16.14.1 @@ -2545,6 +2855,131 @@ snapshots: '@esbuild/win32-x64@0.28.2': optional: true + '@inquirer/ansi@1.0.2': {} + + '@inquirer/checkbox@4.3.2(@types/node@26.6.4)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@26.6.4) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@26.6.4) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 26.6.4 + + '@inquirer/confirm@5.1.21(@types/node@26.6.4)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@26.6.4) + '@inquirer/type': 3.0.10(@types/node@26.6.4) + optionalDependencies: + '@types/node': 26.6.4 + + '@inquirer/core@10.3.2(@types/node@26.6.4)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@26.6.4) + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 26.6.4 + + '@inquirer/editor@4.2.23(@types/node@26.6.4)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@26.6.4) + '@inquirer/external-editor': 1.0.3(@types/node@26.6.4) + '@inquirer/type': 3.0.10(@types/node@26.6.4) + optionalDependencies: + '@types/node': 26.6.4 + + '@inquirer/expand@4.0.23(@types/node@26.6.4)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@26.6.4) + '@inquirer/type': 3.0.10(@types/node@26.6.4) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 26.6.4 + + '@inquirer/external-editor@1.0.3(@types/node@26.6.4)': + dependencies: + chardet: 2.2.0 + iconv-lite: 0.7.3 + optionalDependencies: + '@types/node': 26.6.4 + + '@inquirer/figures@1.0.15': {} + + '@inquirer/input@4.3.1(@types/node@26.6.4)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@26.6.4) + '@inquirer/type': 3.0.10(@types/node@26.6.4) + optionalDependencies: + '@types/node': 26.6.4 + + '@inquirer/number@3.0.23(@types/node@26.6.4)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@26.6.4) + '@inquirer/type': 3.0.10(@types/node@26.6.4) + optionalDependencies: + '@types/node': 26.6.4 + + '@inquirer/password@4.0.23(@types/node@26.6.4)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@26.6.4) + '@inquirer/type': 3.0.10(@types/node@26.6.4) + optionalDependencies: + '@types/node': 26.6.4 + + '@inquirer/prompts@7.10.1(@types/node@26.6.4)': + dependencies: + '@inquirer/checkbox': 4.3.2(@types/node@26.6.4) + '@inquirer/confirm': 5.1.21(@types/node@26.6.4) + '@inquirer/editor': 4.2.23(@types/node@26.6.4) + '@inquirer/expand': 4.0.23(@types/node@26.6.4) + '@inquirer/input': 4.3.1(@types/node@26.6.4) + '@inquirer/number': 3.0.23(@types/node@26.6.4) + '@inquirer/password': 4.0.23(@types/node@26.6.4) + '@inquirer/rawlist': 4.1.11(@types/node@26.6.4) + '@inquirer/search': 3.2.2(@types/node@26.6.4) + '@inquirer/select': 4.4.2(@types/node@26.6.4) + optionalDependencies: + '@types/node': 26.6.4 + + '@inquirer/rawlist@4.1.11(@types/node@26.6.4)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@26.6.4) + '@inquirer/type': 3.0.10(@types/node@26.6.4) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 26.6.4 + + '@inquirer/search@3.2.2(@types/node@26.6.4)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@26.6.4) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@26.6.4) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 26.6.4 + + '@inquirer/select@4.4.2(@types/node@26.6.4)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@26.6.4) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@26.6.4) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 26.6.4 + + '@inquirer/type@3.0.10(@types/node@26.6.4)': + optionalDependencies: + '@types/node': 26.6.4 + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -2554,16 +2989,67 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 - '@istanbuljs/schema@0.1.3': {} + '@istanbuljs/schema@0.1.6': {} '@jridgewell/resolve-uri@3.1.2': {} - '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/sourcemap-codec@1.6.0': {} '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@napi-rs/keyring-darwin-arm64@1.3.0': + optional: true + + '@napi-rs/keyring-darwin-x64@1.3.0': + optional: true + + '@napi-rs/keyring-freebsd-x64@1.3.0': + optional: true + + '@napi-rs/keyring-linux-arm-gnueabihf@1.3.0': + optional: true + + '@napi-rs/keyring-linux-arm64-gnu@1.3.0': + optional: true + + '@napi-rs/keyring-linux-arm64-musl@1.3.0': + optional: true + + '@napi-rs/keyring-linux-riscv64-gnu@1.3.0': + optional: true + + '@napi-rs/keyring-linux-x64-gnu@1.3.0': + optional: true + + '@napi-rs/keyring-linux-x64-musl@1.3.0': + optional: true + + '@napi-rs/keyring-win32-arm64-msvc@1.3.0': + optional: true + + '@napi-rs/keyring-win32-ia32-msvc@1.3.0': + optional: true + + '@napi-rs/keyring-win32-x64-msvc@1.3.0': + optional: true + + '@napi-rs/keyring@1.3.0': + optionalDependencies: + '@napi-rs/keyring-darwin-arm64': 1.3.0 + '@napi-rs/keyring-darwin-x64': 1.3.0 + '@napi-rs/keyring-freebsd-x64': 1.3.0 + '@napi-rs/keyring-linux-arm-gnueabihf': 1.3.0 + '@napi-rs/keyring-linux-arm64-gnu': 1.3.0 + '@napi-rs/keyring-linux-arm64-musl': 1.3.0 + '@napi-rs/keyring-linux-riscv64-gnu': 1.3.0 + '@napi-rs/keyring-linux-x64-gnu': 1.3.0 + '@napi-rs/keyring-linux-x64-musl': 1.3.0 + '@napi-rs/keyring-win32-arm64-msvc': 1.3.0 + '@napi-rs/keyring-win32-ia32-msvc': 1.3.0 + '@napi-rs/keyring-win32-x64-msvc': 1.3.0 '@node-rs/crc32-android-arm-eabi@1.10.8': optional: true @@ -2630,7 +3116,7 @@ snapshots: '@nodelib/fs.walk@1.2.8': dependencies: '@nodelib/fs.scandir': 2.1.5 - fastq: 1.20.1 + fastq: 1.20.3 '@oxlint-tsgolint/darwin-arm64@7.0.2003': optional: true @@ -2650,61 +3136,61 @@ snapshots: '@oxlint-tsgolint/win32-x64@7.0.2003': optional: true - '@oxlint/binding-android-arm-eabi@1.86.0': + '@oxlint/binding-android-arm-eabi@1.87.0': optional: true - '@oxlint/binding-android-arm64@1.86.0': + '@oxlint/binding-android-arm64@1.87.0': optional: true - '@oxlint/binding-darwin-arm64@1.86.0': + '@oxlint/binding-darwin-arm64@1.87.0': optional: true - '@oxlint/binding-darwin-x64@1.86.0': + '@oxlint/binding-darwin-x64@1.87.0': optional: true - '@oxlint/binding-freebsd-x64@1.86.0': + '@oxlint/binding-freebsd-x64@1.87.0': optional: true - '@oxlint/binding-linux-arm-gnueabihf@1.86.0': + '@oxlint/binding-linux-arm-gnueabihf@1.87.0': optional: true - '@oxlint/binding-linux-arm-musleabihf@1.86.0': + '@oxlint/binding-linux-arm-musleabihf@1.87.0': optional: true - '@oxlint/binding-linux-arm64-gnu@1.86.0': + '@oxlint/binding-linux-arm64-gnu@1.87.0': optional: true - '@oxlint/binding-linux-arm64-musl@1.86.0': + '@oxlint/binding-linux-arm64-musl@1.87.0': optional: true - '@oxlint/binding-linux-ppc64-gnu@1.86.0': + '@oxlint/binding-linux-ppc64-gnu@1.87.0': optional: true - '@oxlint/binding-linux-riscv64-gnu@1.86.0': + '@oxlint/binding-linux-riscv64-gnu@1.87.0': optional: true - '@oxlint/binding-linux-riscv64-musl@1.86.0': + '@oxlint/binding-linux-riscv64-musl@1.87.0': optional: true - '@oxlint/binding-linux-s390x-gnu@1.86.0': + '@oxlint/binding-linux-s390x-gnu@1.87.0': optional: true - '@oxlint/binding-linux-x64-gnu@1.86.0': + '@oxlint/binding-linux-x64-gnu@1.87.0': optional: true - '@oxlint/binding-linux-x64-musl@1.86.0': + '@oxlint/binding-linux-x64-musl@1.87.0': optional: true - '@oxlint/binding-openharmony-arm64@1.86.0': + '@oxlint/binding-openharmony-arm64@1.87.0': optional: true - '@oxlint/binding-win32-arm64-msvc@1.86.0': + '@oxlint/binding-win32-arm64-msvc@1.87.0': optional: true - '@oxlint/binding-win32-ia32-msvc@1.86.0': + '@oxlint/binding-win32-ia32-msvc@1.87.0': optional: true - '@oxlint/binding-win32-x64-msvc@1.86.0': + '@oxlint/binding-win32-x64-msvc@1.87.0': optional: true '@pkgjs/parseargs@0.11.0': @@ -2721,7 +3207,7 @@ snapshots: '@secretlint/profiler': 10.2.2 '@secretlint/resolver': 10.2.2 '@secretlint/types': 10.2.2 - ajv: 8.18.0 + ajv: 8.20.0 debug: 4.4.3(supports-color@8.1.1) rc-config-loader: 4.1.4(supports-color@8.1.1) transitivePeerDependencies: @@ -2794,21 +3280,15 @@ snapshots: dependencies: type-detect: 4.0.8 - '@sinonjs/fake-timers@10.3.0': + '@sinonjs/fake-timers@15.4.0': dependencies: '@sinonjs/commons': 3.0.1 - '@sinonjs/fake-timers@11.3.1': - dependencies: - '@sinonjs/commons': 3.0.1 - - '@sinonjs/samsam@8.0.3': + '@sinonjs/samsam@10.0.2': dependencies: '@sinonjs/commons': 3.0.1 type-detect: 4.1.0 - '@sinonjs/text-encoding@0.7.3': {} - '@textlint/ast-node-types@15.8.0': {} '@textlint/linter-formatter@15.8.0(supports-color@8.1.1)': @@ -2845,23 +3325,21 @@ snapshots: '@types/mocha@10.0.10': {} - '@types/mocha@8.2.3': {} - - '@types/node@22.20.5': + '@types/node@26.6.4': dependencies: - undici-types: 6.21.0 + undici-types: 8.9.0 '@types/normalize-package-data@2.4.4': {} '@types/sarif@2.1.7': {} - '@types/sinon@10.0.20': + '@types/sinon@22.0.0': dependencies: '@types/sinonjs__fake-timers': 15.0.1 '@types/sinonjs__fake-timers@15.0.1': {} - '@types/vscode@1.94.0': {} + '@types/vscode@1.140.0': {} '@types/winreg@1.2.36': {} @@ -2935,17 +3413,17 @@ snapshots: '@vscode/codicons@0.0.46-24': {} - '@vscode/test-cli@0.0.12': + '@vscode/test-cli@0.0.15': dependencies: '@types/mocha': 10.0.10 - c8: 10.1.3 - chokidar: 3.6.0 - enhanced-resolve: 5.20.0 - glob: 10.5.0 - minimatch: 9.0.9 - mocha: 11.7.5 + c8: 11.0.0 + chokidar: 5.0.0 + enhanced-resolve: 5.26.0 + glob: 13.0.6 + minimatch: 10.2.6 + mocha: 11.8.0 supports-color: 10.2.2 - yargs: 17.7.2 + yargs: 18.2.0 transitivePeerDependencies: - monocart-coverage-reports @@ -2953,9 +3431,9 @@ snapshots: dependencies: http-proxy-agent: 7.0.2(supports-color@8.1.1) https-proxy-agent: 7.0.6(supports-color@8.1.1) - jszip: 3.10.1 + jszip: 3.10.2 ora: 8.2.0 - semver: 7.7.4 + semver: 7.8.5 transitivePeerDependencies: - supports-color @@ -3008,7 +3486,7 @@ snapshots: '@vscode/vsce-sign': 2.1.0 azure-devops-node-api: 12.5.0 chalk: 4.1.2 - cheerio: 1.0.0-rc.12 + cheerio: 1.2.0 cockatiel: 3.2.1 commander: 12.1.0 form-data: 4.0.6 @@ -3022,7 +3500,7 @@ snapshots: parse-semver: 1.1.1 read: 1.0.7 secretlint: 10.2.2(supports-color@8.1.1) - semver: 7.7.4 + semver: 7.8.5 tmp: 0.2.7 typed-rest-client: 1.8.11 url-join: 4.0.1 @@ -3034,6 +3512,37 @@ snapshots: transitivePeerDependencies: - supports-color + '@vscode/vsce@4.0.0(supports-color@8.1.1)': + dependencies: + '@azure/identity': 4.13.3(supports-color@8.1.1) + '@napi-rs/keyring': 1.3.0 + '@secretlint/core': 10.2.2(supports-color@8.1.1) + '@secretlint/secretlint-rule-no-dotenv': 10.2.2 + '@secretlint/secretlint-rule-preset-recommend': 10.2.2 + '@secretlint/source-creator': 10.2.2 + '@secretlint/types': 10.2.2 + '@vscode/vsce-sign': 2.1.0 + azure-devops-node-api: 12.5.0 + cockatiel: 3.2.1 + commander: 12.1.0 + hosted-git-info: 4.1.0 + jsonc-parser: 3.3.1 + marked: 18.1.0 + mime: 1.6.0 + minimatch: 10.2.6 + parse5: 8.0.1 + proper-lockfile: 4.1.2 + read: 1.0.7 + semver: 7.8.5 + tinyglobby: 0.2.17 + typed-rest-client: 1.8.11 + url-join: 4.0.1 + xml2js: 0.5.0 + yauzl: 3.4.0 + yazl: 2.5.1 + transitivePeerDependencies: + - supports-color + ag-charts-types@14.2.0: {} ag-grid-community@36.2.0: @@ -3045,7 +3554,7 @@ snapshots: agent-base@7.1.4: {} - ajv@8.18.0: + ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 fast-uri: 3.1.8 @@ -3058,7 +3567,7 @@ snapshots: ansi-regex@5.0.1: {} - ansi-regex@6.2.2: {} + ansi-regex@6.4.0: {} ansi-styles@4.3.0: dependencies: @@ -3066,17 +3575,10 @@ snapshots: ansi-styles@6.2.3: {} - anymatch@3.1.3: - dependencies: - normalize-path: 3.0.0 - picomatch: 2.3.2 - argparse@2.0.1: {} astral-regex@2.0.0: {} - async@3.2.6: {} - asynckit@0.4.0: {} azure-devops-node-api@12.5.0: @@ -3091,8 +3593,6 @@ snapshots: base64-js@1.5.1: optional: true - binary-extensions@2.3.0: {} - binaryextensions@6.11.0: dependencies: editions: 6.22.0 @@ -3136,18 +3636,18 @@ snapshots: dependencies: run-applescript: 7.1.0 - c8@10.1.3: + c8@11.0.0: dependencies: '@bcoe/v8-coverage': 1.0.2 - '@istanbuljs/schema': 0.1.3 + '@istanbuljs/schema': 0.1.6 find-up: 5.0.0 foreground-child: 3.3.1 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 istanbul-reports: 3.2.0 - test-exclude: 7.0.2 + test-exclude: 8.0.0 v8-to-istanbul: 9.3.0 - yargs: 17.7.2 + yargs: 17.7.3 yargs-parser: 21.1.1 call-bind-apply-helpers@1.0.2: @@ -3169,6 +3669,8 @@ snapshots: chalk@5.6.2: {} + chardet@2.2.0: {} + cheerio-select@2.1.0: dependencies: boolbase: 1.0.0 @@ -3178,32 +3680,28 @@ snapshots: domhandler: 5.0.3 domutils: 3.2.2 - cheerio@1.0.0-rc.12: + cheerio@1.2.0: dependencies: cheerio-select: 2.1.0 dom-serializer: 2.0.0 domhandler: 5.0.3 domutils: 3.2.2 - htmlparser2: 8.0.2 + encoding-sniffer: 0.2.1 + htmlparser2: 10.1.0 parse5: 7.3.0 parse5-htmlparser2-tree-adapter: 7.1.0 - - chokidar@3.6.0: - dependencies: - anymatch: 3.1.3 - braces: 3.0.3 - glob-parent: 5.1.2 - is-binary-path: 2.1.0 - is-glob: 4.0.3 - normalize-path: 3.0.0 - readdirp: 3.6.0 - optionalDependencies: - fsevents: 2.3.3 + parse5-parser-stream: 7.1.2 + undici: 7.30.0 + whatwg-mimetype: 4.0.0 chokidar@4.0.3: dependencies: readdirp: 4.1.2 + chokidar@5.0.0: + dependencies: + readdirp: 5.1.1 + chownr@1.1.4: optional: true @@ -3215,12 +3713,20 @@ snapshots: cli-spinners@2.9.2: {} + cli-width@4.1.0: {} + cliui@8.0.1: dependencies: string-width: 4.2.3 strip-ansi: 6.0.1 wrap-ansi: 7.0.0 + cliui@9.0.1: + dependencies: + string-width: 7.2.0 + strip-ansi: 7.2.0 + wrap-ansi: 9.0.2 + cockatiel@3.2.1: {} color-convert@2.0.1: @@ -3241,6 +3747,15 @@ snapshots: core-util-is@1.0.3: {} + cross-keychain@1.1.0(@types/node@26.6.4): + dependencies: + '@inquirer/prompts': 7.10.1(@types/node@26.6.4) + meow: 14.1.0 + optionalDependencies: + '@napi-rs/keyring': 1.3.0 + transitivePeerDependencies: + - '@types/node' + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -3299,10 +3814,10 @@ snapshots: detect-libc@2.1.2: optional: true - diff@5.2.2: {} - diff@7.0.0: {} + diff@9.0.0: {} + dom-serializer@2.0.0: dependencies: domelementtype: 2.3.0 @@ -3331,15 +3846,13 @@ snapshots: ecdsa-sig-formatter@1.0.11: dependencies: - safe-buffer: 5.1.2 + safe-buffer: 5.2.1 editions@6.22.0: dependencies: version-range: 4.15.0 - ejs@3.1.10: - dependencies: - jake: 10.9.4 + ejs@7.0.1: {} emoji-regex@10.6.0: {} @@ -3347,27 +3860,36 @@ snapshots: emoji-regex@9.2.2: {} + encoding-sniffer@0.2.1: + dependencies: + iconv-lite: 0.6.3 + whatwg-encoding: 3.1.1 + end-of-stream@1.4.5: dependencies: once: 1.4.0 optional: true - enhanced-resolve@5.20.0: + enhanced-resolve@5.26.0: dependencies: graceful-fs: 4.2.11 - tapable: 2.3.0 + tapable: 2.3.3 entities@4.5.0: {} entities@6.0.1: {} + entities@7.0.1: {} + + entities@8.1.0: {} + environment@1.1.0: {} es-define-property@1.0.1: {} es-errors@1.3.0: {} - es-object-atoms@1.1.1: + es-object-atoms@1.1.2: dependencies: es-errors: 1.3.0 @@ -3376,7 +3898,7 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 has-tostringtag: 1.0.2 - hasown: 2.0.2 + hasown: 2.0.4 esbuild@0.28.2: optionalDependencies: @@ -3421,10 +3943,10 @@ snapshots: is-plain-obj: 4.1.0 is-stream: 4.0.1 npm-run-path: 6.0.0 - pretty-ms: 9.3.0 + pretty-ms: 9.3.1 signal-exit: 4.1.0 strip-final-newline: 4.0.0 - yoctocolors: 2.1.2 + yoctocolors: 2.2.0 expand-template@2.0.3: optional: true @@ -3441,22 +3963,24 @@ snapshots: fast-uri@3.1.8: {} - fastq@1.20.1: + fastq@1.20.3: dependencies: reusify: 1.1.0 + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + figures@6.1.0: dependencies: is-unicode-supported: 2.1.0 - filelist@1.0.6: - dependencies: - minimatch: 5.1.9 - fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 + find-up-simple@1.0.1: {} + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -3464,7 +3988,7 @@ snapshots: flat@5.0.2: {} - follow-redirects@1.16.0(debug@4.4.3(supports-color@8.1.1)): + follow-redirects@1.16.1(debug@4.4.3(supports-color@8.1.1)): optionalDependencies: debug: 4.4.3(supports-color@8.1.1) @@ -3487,69 +4011,66 @@ snapshots: fs-extra@11.4.1: dependencies: graceful-fs: 4.2.11 - jsonfile: 6.2.0 + jsonfile: 6.2.1 universalify: 2.0.1 - fsevents@2.3.3: - optional: true - function-bind@1.1.2: {} get-caller-file@2.0.5: {} - get-east-asian-width@1.5.0: {} + get-east-asian-width@1.7.0: {} get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 es-define-property: 1.0.1 es-errors: 1.3.0 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 function-bind: 1.1.2 get-proto: 1.0.1 gopd: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.2 + hasown: 2.0.4 math-intrinsics: 1.1.0 get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 get-stream@9.0.1: dependencies: '@sec-ant/readable-stream': 0.4.1 is-stream: 4.0.1 - git-cliff-darwin-arm64@2.12.0: + git-cliff-darwin-arm64@2.14.2: optional: true - git-cliff-darwin-x64@2.12.0: + git-cliff-darwin-x64@2.14.2: optional: true - git-cliff-linux-arm64@2.12.0: + git-cliff-linux-arm64@2.14.2: optional: true - git-cliff-linux-x64@2.12.0: + git-cliff-linux-x64@2.14.2: optional: true - git-cliff-windows-arm64@2.12.0: + git-cliff-windows-arm64@2.14.2: optional: true - git-cliff-windows-x64@2.12.0: + git-cliff-windows-x64@2.14.2: optional: true - git-cliff@2.12.0: + git-cliff@2.14.2: dependencies: execa: 9.6.1 optionalDependencies: - git-cliff-darwin-arm64: 2.12.0 - git-cliff-darwin-x64: 2.12.0 - git-cliff-linux-arm64: 2.12.0 - git-cliff-linux-x64: 2.12.0 - git-cliff-windows-arm64: 2.12.0 - git-cliff-windows-x64: 2.12.0 + git-cliff-darwin-arm64: 2.14.2 + git-cliff-darwin-x64: 2.14.2 + git-cliff-linux-arm64: 2.14.2 + git-cliff-linux-x64: 2.14.2 + git-cliff-windows-arm64: 2.14.2 + git-cliff-windows-x64: 2.14.2 github-from-package@0.0.0: optional: true @@ -3582,7 +4103,7 @@ snapshots: dependencies: '@sindresorhus/merge-streams': 2.3.0 fast-glob: 3.3.3 - ignore: 7.0.10 + ignore: 7.0.12 path-type: 6.0.0 slash: 5.1.0 unicorn-magic: 0.3.0 @@ -3603,17 +4124,13 @@ snapshots: dependencies: has-symbols: 1.1.0 - hasown@2.0.2: - dependencies: - function-bind: 1.1.2 - hasown@2.0.4: dependencies: function-bind: 1.1.2 he@1.2.0: {} - highlight.js@11.11.1: {} + highlight.js@11.12.0: {} hosted-git-info@4.1.0: dependencies: @@ -3625,12 +4142,12 @@ snapshots: html-escaper@2.0.2: {} - htmlparser2@8.0.2: + htmlparser2@10.1.0: dependencies: domelementtype: 2.3.0 domhandler: 5.0.3 domutils: 3.2.2 - entities: 4.5.0 + entities: 7.0.1 http-proxy-agent@7.0.2(supports-color@8.1.1): dependencies: @@ -3648,10 +4165,18 @@ snapshots: human-signals@8.0.1: {} + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + ieee754@1.2.1: optional: true - ignore@7.0.10: {} + ignore@7.0.12: {} immediate@3.0.6: {} @@ -3662,10 +4187,6 @@ snapshots: ini@1.3.8: optional: true - is-binary-path@2.1.0: - dependencies: - binary-extensions: 2.3.0 - is-ci@2.0.0: dependencies: ci-info: 2.0.0 @@ -3694,6 +4215,8 @@ snapshots: is-path-inside@3.0.3: {} + is-path-inside@4.0.0: {} + is-plain-obj@2.1.0: {} is-plain-obj@4.1.0: {} @@ -3739,25 +4262,23 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 - jake@10.9.4: - dependencies: - async: 3.2.6 - filelist: 1.0.6 - picocolors: 1.1.1 - js-tokens@4.0.0: {} js-yaml@4.3.2: dependencies: argparse: 2.0.1 + js-yaml@5.4.3: + dependencies: + argparse: 2.0.1 + json-schema-traverse@1.0.0: {} json5@2.2.3: {} jsonc-parser@3.3.1: {} - jsonfile@6.2.0: + jsonfile@6.2.1: dependencies: universalify: 2.0.1 optionalDependencies: @@ -3774,27 +4295,25 @@ snapshots: lodash.isstring: 4.0.1 lodash.once: 4.1.1 ms: 2.1.3 - semver: 7.7.4 + semver: 7.8.5 - jszip@3.10.1: + jszip@3.10.2: dependencies: lie: 3.3.0 pako: 1.0.11 readable-stream: 2.3.8 setimmediate: 1.0.5 - just-extend@6.2.0: {} - jwa@2.0.1: dependencies: buffer-equal-constant-time: 1.0.1 ecdsa-sig-formatter: 1.0.11 - safe-buffer: 5.1.2 + safe-buffer: 5.2.1 jws@4.0.1: dependencies: jwa: 2.0.1 - safe-buffer: 5.1.2 + safe-buffer: 5.2.1 keytar@7.9.0: dependencies: @@ -3854,7 +4373,7 @@ snapshots: make-dir@4.0.0: dependencies: - semver: 7.7.4 + semver: 7.8.5 markdown-it@14.3.2: dependencies: @@ -3865,10 +4384,14 @@ snapshots: punycode.js: 2.3.1 uc.micro: 2.1.0 + marked@18.1.0: {} + math-intrinsics@1.1.0: {} mdurl@2.1.0: {} + meow@14.1.0: {} + merge2@1.4.1: {} micromatch@4.0.8: @@ -3893,10 +4416,6 @@ snapshots: dependencies: brace-expansion: 5.0.12 - minimatch@5.1.9: - dependencies: - brace-expansion: 2.1.7 - minimatch@9.0.9: dependencies: brace-expansion: 2.1.7 @@ -3909,7 +4428,7 @@ snapshots: mkdirp-classic@0.5.3: optional: true - mocha@11.7.5: + mocha@11.8.0: dependencies: browser-stdout: 1.3.1 chokidar: 4.0.3 @@ -3929,28 +4448,41 @@ snapshots: strip-json-comments: 3.1.1 supports-color: 8.1.1 workerpool: 9.3.4 - yargs: 17.7.2 + yargs: 17.7.3 yargs-parser: 21.1.1 yargs-unparser: 2.0.0 + mocha@12.0.3: + dependencies: + browser-stdout: 1.3.1 + chokidar: 5.0.0 + debug: 4.4.3(supports-color@8.1.1) + diff: 9.0.0 + find-up-simple: 1.0.1 + glob: 13.0.6 + is-path-inside: 4.0.0 + is-unicode-supported: 0.1.0 + js-yaml: 5.4.3 + minimatch: 10.2.6 + ms: 2.1.3 + picocolors: 1.1.1 + serialize-javascript: 7.1.2 + strip-json-comments: 5.0.3 + supports-color: 8.1.1 + workerpool: 10.0.3 + ms@2.1.3: {} mute-stream@0.0.8: {} + mute-stream@2.0.0: {} + napi-build-utils@2.0.0: optional: true - nise@5.1.9: - dependencies: - '@sinonjs/commons': 3.0.1 - '@sinonjs/fake-timers': 11.3.1 - '@sinonjs/text-encoding': 0.7.3 - just-extend: 6.2.0 - path-to-regexp: 6.3.0 - - node-abi@3.96.0: + node-abi@3.98.0: dependencies: - semver: 7.7.4 + semver: 7.8.5 optional: true node-addon-api@4.3.0: @@ -3964,11 +4496,9 @@ snapshots: normalize-package-data@6.0.2: dependencies: hosted-git-info: 7.0.2 - semver: 7.7.4 + semver: 7.8.5 validate-npm-package-license: 3.0.4 - normalize-path@3.0.0: {} - npm-run-path@6.0.0: dependencies: path-key: 4.0.0 @@ -4010,17 +4540,20 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.2.0 - ovsx@0.10.12(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1): + ovsx@1.2.0(@types/node@26.6.4)(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1): dependencies: + '@inquirer/prompts': 7.10.1(@types/node@26.6.4) '@vscode/vsce': 3.9.2(supports-color@8.1.1) commander: 6.2.1 - follow-redirects: 1.16.0(debug@4.4.3(supports-color@8.1.1)) + cross-keychain: 1.1.0(@types/node@26.6.4) + follow-redirects: 1.16.1(debug@4.4.3(supports-color@8.1.1)) is-ci: 2.0.0 leven: 3.1.0 - semver: 7.7.4 + semver: 7.8.5 tmp: 0.2.7 yauzl-promise: 4.0.0 transitivePeerDependencies: + - '@types/node' - debug - supports-color @@ -4033,27 +4566,27 @@ snapshots: '@oxlint-tsgolint/win32-arm64': 7.0.2003 '@oxlint-tsgolint/win32-x64': 7.0.2003 - oxlint@1.86.0(oxlint-tsgolint@7.0.2003): + oxlint@1.87.0(oxlint-tsgolint@7.0.2003): optionalDependencies: - '@oxlint/binding-android-arm-eabi': 1.86.0 - '@oxlint/binding-android-arm64': 1.86.0 - '@oxlint/binding-darwin-arm64': 1.86.0 - '@oxlint/binding-darwin-x64': 1.86.0 - '@oxlint/binding-freebsd-x64': 1.86.0 - '@oxlint/binding-linux-arm-gnueabihf': 1.86.0 - '@oxlint/binding-linux-arm-musleabihf': 1.86.0 - '@oxlint/binding-linux-arm64-gnu': 1.86.0 - '@oxlint/binding-linux-arm64-musl': 1.86.0 - '@oxlint/binding-linux-ppc64-gnu': 1.86.0 - '@oxlint/binding-linux-riscv64-gnu': 1.86.0 - '@oxlint/binding-linux-riscv64-musl': 1.86.0 - '@oxlint/binding-linux-s390x-gnu': 1.86.0 - '@oxlint/binding-linux-x64-gnu': 1.86.0 - '@oxlint/binding-linux-x64-musl': 1.86.0 - '@oxlint/binding-openharmony-arm64': 1.86.0 - '@oxlint/binding-win32-arm64-msvc': 1.86.0 - '@oxlint/binding-win32-ia32-msvc': 1.86.0 - '@oxlint/binding-win32-x64-msvc': 1.86.0 + '@oxlint/binding-android-arm-eabi': 1.87.0 + '@oxlint/binding-android-arm64': 1.87.0 + '@oxlint/binding-darwin-arm64': 1.87.0 + '@oxlint/binding-darwin-x64': 1.87.0 + '@oxlint/binding-freebsd-x64': 1.87.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.87.0 + '@oxlint/binding-linux-arm-musleabihf': 1.87.0 + '@oxlint/binding-linux-arm64-gnu': 1.87.0 + '@oxlint/binding-linux-arm64-musl': 1.87.0 + '@oxlint/binding-linux-ppc64-gnu': 1.87.0 + '@oxlint/binding-linux-riscv64-gnu': 1.87.0 + '@oxlint/binding-linux-riscv64-musl': 1.87.0 + '@oxlint/binding-linux-s390x-gnu': 1.87.0 + '@oxlint/binding-linux-x64-gnu': 1.87.0 + '@oxlint/binding-linux-x64-musl': 1.87.0 + '@oxlint/binding-openharmony-arm64': 1.87.0 + '@oxlint/binding-win32-arm64-msvc': 1.87.0 + '@oxlint/binding-win32-ia32-msvc': 1.87.0 + '@oxlint/binding-win32-x64-msvc': 1.87.0 oxlint-tsgolint: 7.0.2003 p-limit@3.1.0: @@ -4087,10 +4620,18 @@ snapshots: domhandler: 5.0.3 parse5: 7.3.0 + parse5-parser-stream@7.1.2: + dependencies: + parse5: 7.3.0 + parse5@7.3.0: dependencies: entities: 6.0.1 + parse5@8.0.1: + dependencies: + entities: 8.1.0 + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -4107,8 +4648,6 @@ snapshots: lru-cache: 11.5.3 minipass: 7.1.3 - path-to-regexp@6.3.0: {} - path-type@6.0.0: {} pend@1.2.0: {} @@ -4117,6 +4656,8 @@ snapshots: picomatch@2.3.2: {} + picomatch@4.0.7: {} + pluralize@2.0.0: {} pluralize@8.0.0: {} @@ -4129,7 +4670,7 @@ snapshots: minimist: 1.2.8 mkdirp-classic: 0.5.3 napi-build-utils: 2.0.0 - node-abi: 3.96.0 + node-abi: 3.98.0 pump: 3.0.4 rc: 1.2.8 simple-get: 4.0.1 @@ -4137,12 +4678,18 @@ snapshots: tunnel-agent: 0.6.0 optional: true - pretty-ms@9.3.0: + pretty-ms@9.3.1: dependencies: parse-ms: 4.0.0 process-nextick-args@2.0.1: {} + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + pump@3.0.4: dependencies: end-of-stream: 1.4.5 @@ -4200,16 +4747,14 @@ snapshots: readable-stream@3.6.2: dependencies: inherits: 2.0.4 - string_decoder: 1.1.1 + string_decoder: 1.3.0 util-deprecate: 1.0.2 optional: true - readdirp@3.6.0: - dependencies: - picomatch: 2.3.2 - readdirp@4.1.2: {} + readdirp@5.1.1: {} + require-directory@2.1.1: {} require-from-string@2.0.2: {} @@ -4219,6 +4764,8 @@ snapshots: onetime: 7.0.0 signal-exit: 4.1.0 + retry@0.12.0: {} + reusify@1.1.0: {} run-applescript@7.1.0: {} @@ -4229,6 +4776,10 @@ snapshots: safe-buffer@5.1.2: {} + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + sax@1.6.1: {} secretlint@10.2.2(supports-color@8.1.1): @@ -4245,8 +4796,6 @@ snapshots: semver@5.7.2: {} - semver@7.7.4: {} - semver@7.8.5: {} serialize-javascript@7.1.2: {} @@ -4287,6 +4836,8 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + signal-exit@3.0.7: {} + signal-exit@4.1.0: {} simple-concat@1.0.1: @@ -4301,14 +4852,12 @@ snapshots: simple-invariant@2.0.1: {} - sinon@15.2.0: + sinon@22.1.0: dependencies: '@sinonjs/commons': 3.0.1 - '@sinonjs/fake-timers': 10.3.0 - '@sinonjs/samsam': 8.0.3 - diff: 5.2.2 - nise: 5.1.9 - supports-color: 7.2.0 + '@sinonjs/fake-timers': 15.4.0 + '@sinonjs/samsam': 10.0.2 + diff: 9.0.0 slash@5.1.0: {} @@ -4321,16 +4870,16 @@ snapshots: spdx-correct@3.2.0: dependencies: spdx-expression-parse: 3.0.1 - spdx-license-ids: 3.0.23 + spdx-license-ids: 3.0.24 spdx-exceptions@2.5.0: {} spdx-expression-parse@3.0.1: dependencies: spdx-exceptions: 2.5.0 - spdx-license-ids: 3.0.23 + spdx-license-ids: 3.0.24 - spdx-license-ids@3.0.23: {} + spdx-license-ids@3.0.24: {} stdin-discarder@0.2.2: {} @@ -4349,20 +4898,30 @@ snapshots: string-width@7.2.0: dependencies: emoji-regex: 10.6.0 - get-east-asian-width: 1.5.0 + get-east-asian-width: 1.7.0 + strip-ansi: 7.2.0 + + string-width@8.3.0: + dependencies: + get-east-asian-width: 1.7.0 strip-ansi: 7.2.0 string_decoder@1.1.1: dependencies: safe-buffer: 5.1.2 + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + optional: true + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 strip-ansi@7.2.0: dependencies: - ansi-regex: 6.2.2 + ansi-regex: 6.4.0 strip-final-newline@4.0.0: {} @@ -4371,6 +4930,8 @@ snapshots: strip-json-comments@3.1.1: {} + strip-json-comments@5.0.3: {} + structured-source@4.0.0: dependencies: boundary: 2.0.0 @@ -4392,13 +4953,13 @@ snapshots: table@6.9.0: dependencies: - ajv: 8.18.0 + ajv: 8.20.0 lodash.truncate: 4.4.2 slice-ansi: 4.0.0 string-width: 4.2.3 strip-ansi: 6.0.1 - tapable@2.3.0: {} + tapable@2.3.3: {} tar-fs@2.1.5: dependencies: @@ -4422,10 +4983,10 @@ snapshots: ansi-escapes: 7.3.0 supports-hyperlinks: 3.2.0 - test-exclude@7.0.2: + test-exclude@8.0.0: dependencies: - '@istanbuljs/schema': 0.1.3 - glob: 10.5.0 + '@istanbuljs/schema': 0.1.6 + glob: 13.0.6 minimatch: 10.2.6 text-table@0.2.0: {} @@ -4434,6 +4995,11 @@ snapshots: dependencies: editions: 6.22.0 + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + tmp@0.2.7: {} to-regex-range@5.0.1: @@ -4444,7 +5010,7 @@ snapshots: tunnel-agent@0.6.0: dependencies: - safe-buffer: 5.1.2 + safe-buffer: 5.2.1 optional: true tunnel@0.0.6: {} @@ -4488,7 +5054,9 @@ snapshots: underscore@1.13.8: {} - undici-types@6.21.0: {} + undici-types@8.9.0: {} + + undici@7.30.0: {} unicorn-magic@0.1.0: {} @@ -4531,14 +5099,28 @@ snapshots: vscode-languageserver-types@3.18.4: {} + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + which@2.0.2: dependencies: isexe: 2.0.0 winreg@1.2.5: {} + workerpool@10.0.3: {} + workerpool@9.3.4: {} + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -4551,6 +5133,12 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.2.0 + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.2.0 + wrappy@1.0.2: optional: true @@ -4571,6 +5159,8 @@ snapshots: yargs-parser@21.1.1: {} + yargs-parser@22.0.0: {} + yargs-unparser@2.0.0: dependencies: camelcase: 6.3.0 @@ -4578,7 +5168,7 @@ snapshots: flat: 5.0.2 is-plain-obj: 2.1.0 - yargs@17.7.2: + yargs@17.7.3: dependencies: cliui: 8.0.1 escalade: 3.2.0 @@ -4588,6 +5178,15 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 + yargs@18.2.0: + dependencies: + cliui: 9.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + string-width: 8.3.0 + y18n: 5.0.8 + yargs-parser: 22.0.0 + yauzl-promise@4.0.0: dependencies: '@node-rs/crc32': 1.10.8 @@ -4604,4 +5203,6 @@ snapshots: yocto-queue@0.1.0: {} - yoctocolors@2.1.2: {} + yoctocolors-cjs@2.1.3: {} + + yoctocolors@2.2.0: {} diff --git a/src/helpViewer/helpPreviewer.ts b/src/helpViewer/helpPreviewer.ts index dad6cd4b2..2eb7a8a0a 100644 --- a/src/helpViewer/helpPreviewer.ts +++ b/src/helpViewer/helpPreviewer.ts @@ -3,7 +3,7 @@ import * as path from 'path'; import * as fs from 'fs'; import * as vscode from 'vscode'; import * as rHelp from './index'; -import * as ejs from 'ejs'; +import ejs from 'ejs'; import { isDirSafe, isFileSafe, readFileSyncSafe, config, spawnAsync } from '../util'; import { Topic, TopicType } from './packages'; diff --git a/src/plotViewer/httpgdViewer.ts b/src/plotViewer/httpgdViewer.ts index ff071d86e..df74f1660 100644 --- a/src/plotViewer/httpgdViewer.ts +++ b/src/plotViewer/httpgdViewer.ts @@ -4,7 +4,7 @@ import { HttpgdClient, HttpgdIdResponse, HttpgdPlotId, HttpgdRendererId } from ' import { HttpgdPlot, IHttpgdViewer, HttpgdViewerOptions } from './httpgdTypes'; import * as path from 'path'; import * as fs from 'fs'; -import * as ejs from 'ejs'; +import ejs from 'ejs'; import { asViewColumn, config, setContext, UriIcon, makeWebviewCommandUriString } from '../util'; import { extensionContext } from '../extension'; diff --git a/src/rTerminal.ts b/src/rTerminal.ts index 4919a458e..506d3d91a 100644 --- a/src/rTerminal.ts +++ b/src/rTerminal.ts @@ -66,11 +66,14 @@ const terminalSends = new WeakMap>(); let lastParamsRmdPath: string | undefined; let lastParamsRmdVersion: number | undefined; -const rExprType = new yaml.Type('!r', { - kind: 'scalar', - construct: (data: string) => ({ __rExpr: data }), +const rExprType = yaml.defineScalarTag('!r', { + resolve: source => ({ __rExpr: source }), + identify: () => false, }); -const RMARKDOWN_SCHEMA = yaml.DEFAULT_SCHEMA.extend([rExprType]); +// Retain js-yaml 4's default tag support alongside R Markdown expressions. +const RMARKDOWN_SCHEMA = yaml.CORE_SCHEMA.withTags( + yaml.mergeTag, yaml.timestampTag, yaml.binaryTag, yaml.omapTag, yaml.pairsTag, yaml.setTag, rExprType, +); function valueToR(val: unknown): string { if (val === null || val === undefined) { diff --git a/src/rmarkdown/draft.ts b/src/rmarkdown/draft.ts index e6f40d6a6..8b297fce9 100644 --- a/src/rmarkdown/draft.ts +++ b/src/rmarkdown/draft.ts @@ -136,7 +136,7 @@ export async function newDraft(): Promise { const dir = path.join(parsedPath.dir, parsedPath.name); if (fs.existsSync(dir)) { if (await getConfirmation(`Folder already exists. Are you sure you want to replace the folder?`)) { - fs.rmdirSync(dir, { recursive: true }); + fs.rmSync(dir, { recursive: true }); } else { return; } @@ -157,6 +157,6 @@ export async function newDraft(): Promise { await workspace.openTextDocument({ language: 'rmd', content: text }) .then(document => window.showTextDocument(document)); } - fs.rmdirSync(tempDir, { recursive: true }); + fs.rmSync(tempDir, { recursive: true }); } } diff --git a/src/test/suite/dataViewerSessions.test.ts b/src/test/suite/dataViewerSessions.test.ts index 66f4dba97..195adaf90 100644 --- a/src/test/suite/dataViewerSessions.test.ts +++ b/src/test/suite/dataViewerSessions.test.ts @@ -1,5 +1,5 @@ import * as assert from 'assert'; -import fs from 'fs-extra'; +import fs from 'fs/promises'; import * as net from 'net'; import * as os from 'os'; import * as path from 'path'; @@ -205,7 +205,10 @@ suite('Viewer session ownership', () => { function outputTitle(panel: vscode.WebviewPanel): string | undefined { assert.strictEqual(panel.title, 'HTML Viewer'); - return /(.*?)<\/title>/.exec(panel.webview.html)?.[1]; + const viewer = [...htmlViewers.viewers.values()].find(viewer => viewer.panel === panel)!; + const item = viewer.state.history[viewer.state.index]; + assert.ok(panel.webview.html.includes(`<div>${item.file}</div>`)); + return item.title; } test('page_viewer and browser HTML share widget history, source ownership, and restoration', async () => { @@ -225,7 +228,7 @@ suite('Viewer session ownership', () => { 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('<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'); @@ -248,7 +251,7 @@ suite('Viewer session ownership', () => { 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('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')); @@ -410,7 +413,7 @@ suite('Viewer session ownership', () => { }); test('viewers share polling and release it when the last viewer closes', async () => { - const clock = sandbox.useFakeTimers(); + const clock = sandbox.useFakeTimers({ toFake: ['setInterval', 'clearInterval'], shouldClearNativeTimers: true }); const kill = sandbox.stub(process, 'kill').returns(true); const request = sandbox.stub().resolves({}); const first = session.registerSessionTransport('shared-polling-first', os.hostname(), '/tmp', request); diff --git a/src/test/suite/htmlWidgetBrowser.test.ts b/src/test/suite/htmlWidgetBrowser.test.ts index 47d7f0fdd..bac0c5b72 100644 --- a/src/test/suite/htmlWidgetBrowser.test.ts +++ b/src/test/suite/htmlWidgetBrowser.test.ts @@ -22,6 +22,147 @@ async function focusHtmlViewer(panel: vscode.WebviewPanel): Promise { } suite('HTML widget browser rendering', () => { + 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-')); + 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('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() }); diff --git a/src/test/suite/htmlWidgetViewer.test.ts b/src/test/suite/htmlWidgetViewer.test.ts index 823e1ee4b..f4e175a55 100644 --- a/src/test/suite/htmlWidgetViewer.test.ts +++ b/src/test/suite/htmlWidgetViewer.test.ts @@ -1,5 +1,5 @@ import * as assert from 'assert'; -import fs from 'fs-extra'; +import fs from 'fs/promises'; import { readFileSync } from 'fs'; import * as path from 'path'; import * as sinon from 'sinon'; @@ -8,6 +8,7 @@ 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'; @@ -109,7 +110,12 @@ suite('Session-aware HTML widget Viewer', () => { function outputTitle(panel: vscode.WebviewPanel): string | undefined { assert.strictEqual(panel.title, 'HTML Viewer'); - return /(.*?)<\/title>/.exec(panel.webview.html)?.[1]; + 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(`<div>${item.file}</div>`), 'The retained title must belong to the rendered output'); + return item.title; } function widgetDocument(panel: vscode.WebviewPanel): string { @@ -796,9 +802,9 @@ suite('Session-aware HTML widget Viewer', () => { sinon.assert.calledOnce(dispose); }); - test('full widget documents retain scripts, styles, and escaped titles without an iframe', async () => { + test('full widget documents retain scripts, styles, and authored titles without an iframe', async () => { const source = owner('html-history-document'); - read.resolves('<!doctype html><html><head><style>body { margin: 0 }</style></head><body><script src="lib/widget.js"></script><div>Widget</div></body></html>'); + read.resolves('<!doctype html><html><head><title>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(panels[0].webview.html.includes('<Widget "title">')); + 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: '../shared%20assets/', target: '/tmp/shared assets/', root: '/tmp/shared assets' }, + { href: '', target: '/tmp/output/index.html', root: '/tmp/output' }, + { href: vscode.Uri.file(path.resolve('/tmp/external/assets.html')).toString(), target: '/tmp/external/assets.html', root: '/tmp/external' }, + { href: '/', target: path.parse(file).root, root: path.parse(file).root }, + { 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]; + const expected = vscode.Uri.file(path.resolve(fixture.target)).toString() + (fixture.target.endsWith('/') ? '/' : ''); + 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 local bases use webview URIs while resource roots retain file URIs', async () => { + 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('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 () => { @@ -843,7 +929,7 @@ suite('Session-aware HTML widget Viewer', () => { assert.strictEqual(contexts.get('r.htmlViewer.canShowInfo'), true); await runHtmlViewerCommand('remove'); await runHtmlViewerCommand('forward'); - slow.resolve('<div>A</div>'); + slow.resolve('<div>/tmp/a.html</div>'); await pending; assert.strictEqual(position(panels[0]), '1 / 2'); assert.strictEqual(outputTitle(panels[0]), 'A'); diff --git a/src/test/suite/rmdParams.test.ts b/src/test/suite/rmdParams.test.ts index 5a3792a14..6531bc2e6 100644 --- a/src/test/suite/rmdParams.test.ts +++ b/src/test/suite/rmdParams.test.ts @@ -63,6 +63,26 @@ suite('Rmd Params Test Suite', () => { assert.strictEqual(cmd, 'params <- list(a = 1+1)'); }); + test('getRmdParamsCommand preserves YAML merges, scalar types, and tagged expressions', () => { + const mockDoc = { + languageId: 'rmd', + getText: () => `--- +defaults: &defaults + count: 2 + expression: !r seq_len(3) +params: + <<: *defaults + enabled: false + label: yes + values: [1, null, true] +---`, + uri: vscode.Uri.file('/test_merged_params.Rmd'), + version: 1, + } as vscode.TextDocument; + assert.strictEqual(rTerminal.getRmdParamsCommand(mockDoc), + 'params <- list(count = 2, expression = seq_len(3), enabled = FALSE, label = "yes", values = c(1, NULL, TRUE))'); + }); + test('getRmdParamsCommand respects cache invalidation by version', () => { const mockDoc = { languageId: 'rmd', diff --git a/src/test/suite/session.test.ts b/src/test/suite/session.test.ts index 0933cec94..c297953d6 100644 --- a/src/test/suite/session.test.ts +++ b/src/test/suite/session.test.ts @@ -221,7 +221,7 @@ suite('Session Communication', () => { // error immediately after listening, before initialization is published. const fail = process.platform !== 'win32' ? sandbox.stub(fsp, 'chmod').rejects(failure) - : sandbox.stub(net.Server.prototype, 'emit').callsFake(function (this: net.Server, event: string, ...args: unknown[]) { + : sandbox.stub(net.Server.prototype, 'emit').callsFake(function (this: net.Server, event: string | symbol, ...args: unknown[]) { const result = EventEmitter.prototype.emit.call(this, event, ...args); if (event === 'listening') { EventEmitter.prototype.emit.call(this, 'error', failure); diff --git a/src/webViewer/index.ts b/src/webViewer/index.ts index c6cb3cab6..743cae9e7 100644 --- a/src/webViewer/index.ts +++ b/src/webViewer/index.ts @@ -2,7 +2,9 @@ import * as path from 'path'; import { randomUUID } from 'crypto'; -import { readFile } from 'fs-extra'; +import { pathToFileURL } from 'url'; +import { readFile } from 'fs/promises'; +import { load } from 'cheerio'; import { Disposable, ExtensionContext, Uri, ViewColumn, Webview, WebviewPanel, window, env, commands, workspace } from 'vscode'; import { ViewerSessionContext, ViewerSessionSource, formatSessionLabel } from '../viewerSession'; import { WidgetHistory, WidgetHistoryStore, widgetHistoryLimit, widgetSessionIdentity } from './history'; @@ -345,11 +347,9 @@ class HtmlWidgetViewerManager { const dir = item ? path.dirname(item.file) : path.join(extensionPath, 'dist/webviews/webview'); const { panel } = entry; try { - const html = await getWebviewHtml(panel.webview, item?.file, item?.title ?? htmlViewerTitle, dir, Boolean(entry.session), generation, { id: entry.id }); + const { html, localResourceRoots } = await getWebviewHtml(panel.webview, item?.file, dir, Boolean(entry.session), generation, { id: entry.id }); if (!entry.disposed && entry.revision === generation) { - panel.webview.options = { ...panel.webview.options, localResourceRoots: [ - Uri.file(dir), Uri.file(path.join(extensionPath, 'dist/webviews/webview')), - ] }; + panel.webview.options = { ...panel.webview.options, localResourceRoots }; panel.webview.html = html; if (reveal) { panel.reveal(panel.viewColumn, true); } } @@ -387,13 +387,14 @@ class HtmlWidgetViewerManager { } async function getWebviewHtml( - webview: Webview, file: string | undefined, title: string, dir: string, sessionOwned = false, generation = 0, state?: HtmlViewerPanelReference, -): Promise<string> { + webview: Webview, file: string | undefined, dir: string, sessionOwned = false, generation = 0, state?: HtmlViewerPanelReference, +): Promise<{ html: string; localResourceRoots: Uri[] }> { 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>'; if (file !== undefined) { try { @@ -413,14 +414,56 @@ async function getWebviewHtml( worker-src https: data: filesystem: blob:; frame-src https: data: blob:; `; - const head = `<meta http-equiv="Content-Security-Policy" content="${CSP}"><base href="${escapeHtml(baseUri)}/"><title>${escapeHtml(title)}`; - // Keep the output's document structure; history controls live in the native title bar. - let html = /]*>/i.test(source) - ? source.replace(/]*>[\s\S]*?<\/title\s*>/gi, '').replace(/]*>/i, match => match + head) - : `${head}${source}`; const script = ``; - html = /<\/body\s*>/i.test(html) ? html.replace(/<\/body\s*>/i, script + '') : html + script; - return html; + + // 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; + } | undefined)?.attrs?.href; + if (resolvedBase.protocol === 'file:' && hrefLocation) { + const localBase = Uri.parse(resolvedBase.href); + localResourceRoots.push(Uri.joinPath(localBase, localBase.path.endsWith('/') ? '.' : '..').with({ query: '', fragment: '' })); + edits.push({ start: hrefLocation.startOffset, end: hrefLocation.endOffset, + text: `href="${escapeHtml(String(webview.asWebviewUri(localBase)))}"` }); + } + } + const baseTag = base ? '' : ``; + const head = `${baseTag}`; + const headInsertion = headLocation?.startTag ? 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 }; } function parsePanelState(state: unknown): HtmlViewerPanelState | undefined { From 21be98249b51eb9eda833e6f65433de3c22508ce Mon Sep 17 00:00:00 2001 From: Fred Wu Date: Fri, 9 Oct 2026 22:10:46 +1100 Subject: [PATCH 16/26] fix: align VS Code types with the supported version --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index fac456039..2aa5db0e9 100644 --- a/package.json +++ b/package.json @@ -2646,7 +2646,7 @@ "@types/mocha": "^10.0.10", "@types/node": "^26.6.4", "@types/sinon": "^22.0.0", - "@types/vscode": "~1.140.0", + "@types/vscode": "~1.98.0", "@types/winreg": "^1.2.36", "@vscode/test-cli": "^0.0.15", "@vscode/test-electron": "^3.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index daaaf9ce4..e5a58f1c6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -52,8 +52,8 @@ importers: specifier: ^22.0.0 version: 22.0.0 '@types/vscode': - specifier: ~1.140.0 - version: 1.140.0 + specifier: ~1.98.0 + version: 1.98.0 '@types/winreg': specifier: ^1.2.36 version: 1.2.36 @@ -917,8 +917,8 @@ packages: '@types/sinonjs__fake-timers@15.0.1': resolution: {integrity: sha512-Ko2tjWJq8oozHzHV+reuvS5KYIRAokHnGbDwGh/J64LntgpbuylF74ipEL24HCyRjf9FOlBiBHWBR1RlVKsI1w==} - '@types/vscode@1.140.0': - resolution: {integrity: sha512-KAB5RBZIgnUhpp4mKsVJPPHLOQKu6WtGy2xpfWjaajEXO4xIIC+UESAXLiQ9ewx6QD1uBmvC/S8BJzkbvv4bRA==} + '@types/vscode@1.98.0': + resolution: {integrity: sha512-+KuiWhpbKBaG2egF+51KjbGWatTH5BbmWQjSLMDCssb4xF8FJnW4nGH4nuAdOOfMbpD0QlHtI+C3tPq+DoKElg==} '@types/winreg@1.2.36': resolution: {integrity: sha512-DtafHy5A8hbaosXrbr7YdjQZaqVewXmiasRS5J4tYMzt3s1gkh40ixpxgVFfKiQ0JIYetTJABat47v9cpr/sQg==} @@ -3339,7 +3339,7 @@ snapshots: '@types/sinonjs__fake-timers@15.0.1': {} - '@types/vscode@1.140.0': {} + '@types/vscode@1.98.0': {} '@types/winreg@1.2.36': {} From d7df8303028440a38951d79cb1a162495950c402 Mon Sep 17 00:00:00 2001 From: Fred Wu Date: Fri, 9 Oct 2026 22:18:05 +1100 Subject: [PATCH 17/26] clean-up: removed verified unused pieces: --- package.json | 1 - pnpm-lock.yaml | 8 -------- src/webViewer/history.ts | 8 ++++---- 3 files changed, 4 insertions(+), 13 deletions(-) diff --git a/package.json b/package.json index 2aa5db0e9..308aa0428 100644 --- a/package.json +++ b/package.json @@ -2642,7 +2642,6 @@ }, "devDependencies": { "@types/ejs": "^3.1.5", - "@types/js-yaml": "^4.0.9", "@types/mocha": "^10.0.10", "@types/node": "^26.6.4", "@types/sinon": "^22.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e5a58f1c6..ef1606d3a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,9 +39,6 @@ importers: '@types/ejs': specifier: ^3.1.5 version: 3.1.5 - '@types/js-yaml': - specifier: ^4.0.9 - version: 4.0.9 '@types/mocha': specifier: ^10.0.10 version: 10.0.10 @@ -896,9 +893,6 @@ packages: '@types/istanbul-lib-coverage@2.0.6': resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} - '@types/js-yaml@4.0.9': - resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} - '@types/mocha@10.0.10': resolution: {integrity: sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==} @@ -3321,8 +3315,6 @@ snapshots: '@types/istanbul-lib-coverage@2.0.6': {} - '@types/js-yaml@4.0.9': {} - '@types/mocha@10.0.10': {} '@types/node@26.6.4': diff --git a/src/webViewer/history.ts b/src/webViewer/history.ts index be076db0f..fb1fb3425 100644 --- a/src/webViewer/history.ts +++ b/src/webViewer/history.ts @@ -23,7 +23,7 @@ export class WidgetHistoryStore { constructor( private readonly state: Memento, - private readonly resolveSession: (source: ViewerSessionSource) => ViewerSessionContext, + resolveSession: (source: ViewerSessionSource) => ViewerSessionContext, ) { const saved = state.get(widgetHistoryKey); if (Array.isArray(saved)) { @@ -34,7 +34,7 @@ export class WidgetHistoryStore { const session = resolveSession(record.source); if (!session.hasExited) { this.entries.set(session.sessionId, record); - this.watch(record, session); + this.watch(session); } } // Drop invalid records and any already-confirmed process exits. @@ -51,7 +51,7 @@ export class WidgetHistoryStore { record ??= { source: { ...session.source }, history: [], index: -1 }; if (!session.hasExited && !this.entries.has(session.sessionId)) { this.entries.set(session.sessionId, record); - this.watch(record, session); + this.watch(session); } return record; } @@ -67,7 +67,7 @@ export class WidgetHistoryStore { if (this.entries.delete(sessionId)) { void this.persist(); } } - private watch(record: WidgetHistory, session: ViewerSessionContext): void { + private watch(session: ViewerSessionContext): void { this.subscriptions.get(session.sessionId)?.dispose(); this.subscriptions.set(session.sessionId, session.observeExit(() => this.forget(session.sessionId))); } From 6296d152cee9fbed9d98e999df742db2a3fdcf63 Mon Sep 17 00:00:00 2001 From: Fred Wu Date: Fri, 9 Oct 2026 22:40:16 +1100 Subject: [PATCH 18/26] fix (test): HTML viewer tests and language server cleanup --- src/test/suite/htmlWidgetViewer.test.ts | 5 +- src/test/suite/interactiveEditor.test.ts | 76 +++++++++++++++++++++++- 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/src/test/suite/htmlWidgetViewer.test.ts b/src/test/suite/htmlWidgetViewer.test.ts index f4e175a55..1d766cdf5 100644 --- a/src/test/suite/htmlWidgetViewer.test.ts +++ b/src/test/suite/htmlWidgetViewer.test.ts @@ -848,14 +848,15 @@ suite('Session-aware HTML widget Viewer', () => { { href: '../shared%20assets/', target: '/tmp/shared assets/', root: '/tmp/shared assets' }, { href: '', target: '/tmp/output/index.html', root: '/tmp/output' }, { href: vscode.Uri.file(path.resolve('/tmp/external/assets.html')).toString(), target: '/tmp/external/assets.html', root: '/tmp/external' }, - { href: '/', target: path.parse(file).root, root: path.parse(file).root }, + { href: '/', target: path.parse(file).root.replace(/\\/g, '/'), root: path.parse(file).root }, { 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(`Output`); await showWebView(file, 'Authored base', 'Two'); const panel = panels[panels.length - 1]; - const expected = vscode.Uri.file(path.resolve(fixture.target)).toString() + (fixture.target.endsWith('/') ? '/' : ''); + let expected = vscode.Uri.file(path.resolve(fixture.target)).toString(); + if (fixture.target.endsWith('/') && !expected.endsWith('/')) { expected += '/'; } assert.ok(panel.webview.html.includes(``), fixture.href); assert.ok(panel.webview.html.includes('')); assert.ok(panel.webview.html.includes('')); diff --git a/src/test/suite/interactiveEditor.test.ts b/src/test/suite/interactiveEditor.test.ts index fe0651a16..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 ?? ''; @@ -85,6 +86,8 @@ function treeTooltip(item: vscode.TreeItem): string { 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) @@ -96,7 +99,7 @@ function treeTooltip(item: vscode.TreeItem): string { service.clients.delete(key); } } - await Promise.all([...clients].map(client => client.stop())); + 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 })); @@ -2094,3 +2097,72 @@ par(mfrow=c(1,1))`); }); }); + +async function stopLanguageClient(client: LanguageClient): Promise { + 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(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(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(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); + } + }); +}); From 752621bd031ebc5c0a5af6f25b1a01f2f3aab0a8 Mon Sep 17 00:00:00 2001 From: Fred Wu Date: Sat, 10 Oct 2026 12:56:09 +1100 Subject: [PATCH 19/26] =?UTF-8?q?fix:=20restore=20main=E2=80=99s=20package?= =?UTF-8?q?=20versions=20and=20revert=20unrelated=20upgrade=20changes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 95 +- pnpm-lock.yaml | 1533 +++++++-------------- src/helpViewer/helpPreviewer.ts | 2 +- src/plotViewer/httpgdViewer.ts | 2 +- src/rTerminal.ts | 11 +- src/rmarkdown/draft.ts | 4 +- src/test/suite/dataViewerSessions.test.ts | 2 +- src/test/suite/rmdParams.test.ts | 20 - src/test/suite/session.test.ts | 2 +- 9 files changed, 513 insertions(+), 1158 deletions(-) diff --git a/package.json b/package.json index 308aa0428..3b9a892a9 100644 --- a/package.json +++ b/package.json @@ -2389,9 +2389,7 @@ "markdownDescription": "Path to a custom CSS file to be used when `#r.plot.defaults.colorTheme#` is `vscode`. Replaces the default CSS overwrites!" }, "r.interactive.executionTarget": { - "tags": [ - "experimental" - ], + "tags": ["experimental"], "type": "string", "enum": [ "auto", @@ -2402,9 +2400,7 @@ "description": "Execution target for R source commands. Auto uses the selected Interactive session or R terminal and offers a target chooser when none is available. Use Terminal for R Execution switches back to terminal routing." }, "r.interactive.supervision": { - "tags": [ - "experimental" - ], + "tags": ["experimental"], "type": "string", "ignoreSync": true, "enum": [ @@ -2417,36 +2413,28 @@ "description": "Independent session supervision. Auto prefers tmux on Linux when available, otherwise uses a detached process; macOS uses a detached process. Explicit tmux/systemd selections require their executables on the R host. Detached processes survive editor reload/exit, but host logout policies may stop them. Changes apply to new or restarted sessions." }, "r.interactive.nodePath": { - "tags": [ - "experimental" - ], + "tags": ["experimental"], "type": "string", "ignoreSync": true, "default": "", "description": "Optional standalone Node.js 18+ executable on the R host for persistent session agents. Empty uses VS Code's runtime automatically (VS Code Server on remote hosts). Set an executable name on PATH or a path if the automatic runtime is unavailable in a compatible IDE; supports ~ and ${userHome}/${workspaceFolder}. Changes apply to new or restarted sessions." }, "r.interactive.arfPath": { - "tags": [ - "experimental" - ], + "tags": ["experimental"], "type": "string", "ignoreSync": true, "default": "arf", "description": "arf executable required for managed headless Interactive sessions, resolved on the R host. Use an executable name on PATH or a path (supports ~ and ${userHome}/${workspaceFolder}). Ordinary R terminals do not require arf; if unavailable, the Interactive provider picker offers configuration." }, "r.interactive.storagePath": { - "tags": [ - "experimental" - ], + "tags": ["experimental"], "type": "string", "ignoreSync": true, "default": "", "description": "Absolute, writable path on the R host for persistent Interactive sessions. Empty uses $XDG_STATE_HOME/vscode-r/interactive when set, otherwise ~/Library/Application Support/vscode-r/interactive on macOS or ~/.local/state/vscode-r/interactive on Linux. Existing macOS registries at the Linux-style path are reused. Reload VS Code after changing this setting." }, "r.interactive.plotBackend": { - "tags": [ - "experimental" - ], + "tags": ["experimental"], "type": "string", "ignoreSync": true, "enum": [ @@ -2458,33 +2446,21 @@ "description": "Interactive graphics provider. JGD uses a separate systemfonts R process for font metrics while the editor is disconnected." }, "r.interactive.tableView": { - "tags": [ - "experimental" - ], + "tags": ["experimental"], "type": "string", - "enum": [ - "table", - "text" - ], - "enumDescriptions": [ - "Show a paged table preview.", - "Show the table's ordinary R printout." - ], + "enum": ["table", "text"], + "enumDescriptions": ["Show a paged table preview.", "Show the table's ordinary R printout."], "default": "table", "description": "Default presentation for Interactive table outputs. Each output can switch between table and printed views without rerunning R. Printed snapshots require a session started with the current extension build." }, "r.interactive.restore": { - "tags": [ - "experimental" - ], + "tags": ["experimental"], "type": "boolean", "default": true, "description": "Reconnect the workspace’s Interactive sessions when VS Code reopens." }, "r.interactive.historyLimit": { - "tags": [ - "experimental" - ], + "tags": ["experimental"], "type": "integer", "minimum": 1, "maximum": 1000, @@ -2492,9 +2468,7 @@ "description": "Recent executions and plots restored in an Interactive window." }, "r.interactive.maxOutputSizeMiB": { - "tags": [ - "experimental" - ], + "tags": ["experimental"], "type": "number", "ignoreSync": true, "minimum": 0.0625, @@ -2503,9 +2477,7 @@ "description": "Maximum retained console text output per execution, in MiB (1 MiB = 1,048,576 bytes). Excess output is drained and reported as truncated. Rich outputs have separate limits. Changes apply to new or restarted sessions." }, "r.interactive.maxJournalSizeMiB": { - "tags": [ - "experimental" - ], + "tags": ["experimental"], "type": "number", "ignoreSync": true, "minimum": 8, @@ -2514,9 +2486,7 @@ "description": "Approximate retained output journal limit per R generation, in MiB (1 MiB = 1,048,576 bytes). Old output is removed when journal segments rotate, roughly every 4 MiB. Earlier generations and execution records are retained separately. Changes apply to new or restarted sessions." }, "r.interactive.maxAssetSizeMiB": { - "tags": [ - "experimental" - ], + "tags": ["experimental"], "type": "number", "ignoreSync": true, "default": 1024, @@ -2642,31 +2612,32 @@ }, "devDependencies": { "@types/ejs": "^3.1.5", - "@types/mocha": "^10.0.10", - "@types/node": "^26.6.4", - "@types/sinon": "^22.0.0", - "@types/vscode": "~1.98.0", + "@types/js-yaml": "^4.0.9", + "@types/mocha": "^8.2.3", + "@types/node": "^22.20.5", + "@types/sinon": "^10.0.20", + "@types/vscode": "~1.94.0", "@types/winreg": "^1.2.36", - "@vscode/test-cli": "^0.0.15", + "@vscode/test-cli": "^0.0.12", "@vscode/test-electron": "^3.1.0", - "@vscode/vsce": "^4.0.0", - "esbuild": "^0.28.2", - "git-cliff": "^2.14.2", + "@vscode/vsce": "^3.6.0", + "esbuild": "^0.28.1", + "git-cliff": "^2.12.0", "glob": "^13.0.6", - "mocha": "^12.0.3", - "ovsx": "^1.2.0", - "oxlint": "^1.87.0", - "oxlint-tsgolint": "~7.0.2003", - "sinon": "^22.1.0", - "typescript": "~7.0.2" + "mocha": "^11.7.5", + "ovsx": "^0.10.8", + "sinon": "^15.2.0", + "typescript": "~7.0.2", + "oxlint": "^1.86.0", + "oxlint-tsgolint": "~7.0.2003" }, "dependencies": { "@vscode/codicons": "0.0.46-24", "ag-grid-community": "^36.2.0", - "cheerio": "1.2.0", - "ejs": "^7.0.1", - "highlight.js": "^11.12.0", - "js-yaml": "^5.4.3", + "cheerio": "1.0.0-rc.12", + "ejs": "^3.1.10", + "highlight.js": "^11.11.1", + "js-yaml": "^4.3.2", "vscode-languageclient": "^10.1.2", "winreg": "^1.2.5" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef1606d3a..3d53f790a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,17 +18,17 @@ importers: specifier: ^36.2.0 version: 36.2.0 cheerio: - specifier: 1.2.0 - version: 1.2.0 + specifier: 1.0.0-rc.12 + version: 1.0.0-rc.12 ejs: - specifier: ^7.0.1 - version: 7.0.1 + specifier: ^3.1.10 + version: 3.1.10 highlight.js: - specifier: ^11.12.0 - version: 11.12.0 + specifier: ^11.11.1 + version: 11.11.1 js-yaml: - specifier: ^5.4.3 - version: 5.4.3 + specifier: ^4.3.2 + version: 4.3.2 vscode-languageclient: specifier: ^10.1.2 version: 10.1.2 @@ -39,54 +39,57 @@ importers: '@types/ejs': specifier: ^3.1.5 version: 3.1.5 + '@types/js-yaml': + specifier: ^4.0.9 + version: 4.0.9 '@types/mocha': - specifier: ^10.0.10 - version: 10.0.10 + specifier: ^8.2.3 + version: 8.2.3 '@types/node': - specifier: ^26.6.4 - version: 26.6.4 + specifier: ^22.20.5 + version: 22.20.5 '@types/sinon': - specifier: ^22.0.0 - version: 22.0.0 + specifier: ^10.0.20 + version: 10.0.20 '@types/vscode': - specifier: ~1.98.0 - version: 1.98.0 + specifier: ~1.94.0 + version: 1.94.0 '@types/winreg': specifier: ^1.2.36 version: 1.2.36 '@vscode/test-cli': - specifier: ^0.0.15 - version: 0.0.15 + specifier: ^0.0.12 + version: 0.0.12 '@vscode/test-electron': specifier: ^3.1.0 version: 3.1.0(supports-color@8.1.1) '@vscode/vsce': - specifier: ^4.0.0 - version: 4.0.0(supports-color@8.1.1) + specifier: ^3.6.0 + version: 3.9.2(supports-color@8.1.1) esbuild: - specifier: ^0.28.2 + specifier: ^0.28.1 version: 0.28.2 git-cliff: - specifier: ^2.14.2 - version: 2.14.2 + specifier: ^2.12.0 + version: 2.12.0 glob: specifier: ^13.0.6 version: 13.0.6 mocha: - specifier: ^12.0.3 - version: 12.0.3 + specifier: ^11.7.5 + version: 11.7.5 ovsx: - specifier: ^1.2.0 - version: 1.2.0(@types/node@26.6.4)(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1) + specifier: ^0.10.8 + version: 0.10.12(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1) oxlint: - specifier: ^1.87.0 - version: 1.87.0(oxlint-tsgolint@7.0.2003) + specifier: ^1.86.0 + version: 1.86.0(oxlint-tsgolint@7.0.2003) oxlint-tsgolint: specifier: ~7.0.2003 version: 7.0.2003 sinon: - specifier: ^22.1.0 - version: 22.1.0 + specifier: ^15.2.0 + version: 15.2.0 typescript: specifier: ~7.0.2 version: 7.0.2 @@ -135,18 +138,14 @@ packages: resolution: {integrity: sha512-rbAE25KUfjU/s3XHUdJgceoCP5dEOpMx85J04kF+QMdta73XkuG9JGHHinch+XIoKpBdqljin+KqURpJriSzLA==} engines: {node: '>=22.0.0'} - '@azure/msal-browser@5.25.0': - resolution: {integrity: sha512-7GyXzDUrdLG5zPDCYJbQdJqbl9a2U9SWUSC4lxr7cXjkBq6diTfWcJsi0UjdGWlqy+EevQg9dSC4g3s3BEp19g==} + '@azure/msal-browser@5.23.0': + resolution: {integrity: sha512-IRCwkRCK47hBXK+7bu67UZWY7HS0Jx1dZgi4C1HVbjlBum8Jy8fb2l87xFr6HxdQJWy951zRUHtwwDITHFAtaA==} engines: {node: '>=0.8.0'} '@azure/msal-common@16.14.1': resolution: {integrity: sha512-Or6xhPNyi4zHW25158yxBoyxuCqNSPa5YBVqfF1J5Ks4MJWBo/USXdp05DQIPu1Zli00YZu6t0+h6KvHJealxQ==} engines: {node: '>=0.8.0'} - '@azure/msal-common@16.14.2': - resolution: {integrity: sha512-HOBR0Gfo39TMrkxNG8k6cfjRkrRjXZc3JQs3ebMWQc9S4cihuknMYra3sgmzbZB752oQBqBN9+WF7c826p2vIQ==} - engines: {node: '>=0.8.0'} - '@azure/msal-node@6.0.1': resolution: {integrity: sha512-ixSO1Y/kCVRthRs+hSx/5qkwaunX1/RAePhlMN0wIpIQ4WEZ6AREGGnGd1AP0qspHVsAwzWQKGubpjh50JyJIQ==} engines: {node: '>=20'} @@ -319,239 +318,24 @@ packages: cpu: [x64] os: [win32] - '@inquirer/ansi@1.0.2': - resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} - engines: {node: '>=18'} - - '@inquirer/checkbox@4.3.2': - resolution: {integrity: sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/confirm@5.1.21': - resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/core@10.3.2': - resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/editor@4.2.23': - resolution: {integrity: sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/expand@4.0.23': - resolution: {integrity: sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/external-editor@1.0.3': - resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/figures@1.0.15': - resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} - engines: {node: '>=18'} - - '@inquirer/input@4.3.1': - resolution: {integrity: sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/number@3.0.23': - resolution: {integrity: sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/password@4.0.23': - resolution: {integrity: sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/prompts@7.10.1': - resolution: {integrity: sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/rawlist@4.1.11': - resolution: {integrity: sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/search@3.2.2': - resolution: {integrity: sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/select@4.4.2': - resolution: {integrity: sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/type@3.0.10': - resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} - '@istanbuljs/schema@0.1.6': - resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==} + '@istanbuljs/schema@0.1.3': + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} engines: {node: '>=8'} '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} - '@jridgewell/sourcemap-codec@1.6.0': - resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@napi-rs/keyring-darwin-arm64@1.3.0': - resolution: {integrity: sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [darwin] - - '@napi-rs/keyring-darwin-x64@1.3.0': - resolution: {integrity: sha512-YcJtEV5LA3cvA4z3BurgxH5IhTsW1JfIvcAAcqcecwk06Si9F9NqkxbZVIfDwQ8oRHgaBmT3zZJnLAotCrVahw==} - engines: {node: '>= 10'} - cpu: [x64] - os: [darwin] - - '@napi-rs/keyring-freebsd-x64@1.3.0': - resolution: {integrity: sha512-vlLf31TGhfRAaxLDBhg8b89ss0HHD/lyNmL5F3UjSaz5CUXElsJmKYq9fqA/B+cZKUEUcLHHGhF0I/CqcFdaVw==} - engines: {node: '>= 10'} - cpu: [x64] - os: [freebsd] - - '@napi-rs/keyring-linux-arm-gnueabihf@1.3.0': - resolution: {integrity: sha512-KiWdMMu/Inz/bHHIAGrnF7r54FZDYXuHO6UFF/rhIrshUsxbMG1Rl9lEymNtqqsVo927G0VYcb02FzWQ3iBQRQ==} - engines: {node: '>= 10'} - cpu: [arm] - os: [linux] - - '@napi-rs/keyring-linux-arm64-gnu@1.3.0': - resolution: {integrity: sha512-eyKGpY40lm9Jvs1aD294XRH4y7+TlJM0YVAryZeXA6TX0mb4gMkxVXwSQv7MCwgah7raeUd0dKUb4BPAYIgcMg==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@napi-rs/keyring-linux-arm64-musl@1.3.0': - resolution: {integrity: sha512-iIK6JWHXAJqDrEyLY3TmswwloVyt2vj+04TZnew+uSJ9gnDO8EwRbp3/iw3LpWaXiDO7VomGO6y8I0Id8uBZSw==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@napi-rs/keyring-linux-riscv64-gnu@1.3.0': - resolution: {integrity: sha512-/PGqrwn6EwgtK6vccASSXJRfOSP4vN1F4ASsIQ+7MdrK6hNvAJ1FZPrIuD5gGGdxezo3F++To2Wq7DbuGIeuNQ==} - engines: {node: '>= 10'} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@napi-rs/keyring-linux-x64-gnu@1.3.0': - resolution: {integrity: sha512-2PDK1WKWTu9lBGq9VvNEkSlQD3O7YwVpmnyN2M3cy4v7NJ/8gDMd9GXv3G+FVXN13uhp4gnnPBS+ScefmEeD2A==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@napi-rs/keyring-linux-x64-musl@1.3.0': - resolution: {integrity: sha512-oJ2HkX8YUo46QBkn0pG+HuIKQNqr523q6vBobCn+P95s4C4K6/kLBqHY/1bg5J4ap31DzsznhnFKcfBNBsjCnw==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - libc: [musl] - - '@napi-rs/keyring-win32-arm64-msvc@1.3.0': - resolution: {integrity: sha512-tOd3c/uAaeoE4ycVlmAdSvygz0Zt3zdca6Y7gokBeIbaRDWpjDIUOpU3MvML59XAaqyuKGsVVu0F/DZb1lHPmw==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [win32] - - '@napi-rs/keyring-win32-ia32-msvc@1.3.0': - resolution: {integrity: sha512-sPSqeAFZMGqP1R++M2JTza7GQJJ/TpCo6JU6Vcd4jnebvOaEDs9b7eipakU1PJdSvhpC2yXMCNRk9gXfrhuwHQ==} - engines: {node: '>= 10'} - cpu: [ia32] - os: [win32] - - '@napi-rs/keyring-win32-x64-msvc@1.3.0': - resolution: {integrity: sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg==} - engines: {node: '>= 10'} - cpu: [x64] - os: [win32] - - '@napi-rs/keyring@1.3.0': - resolution: {integrity: sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA==} - engines: {node: '>= 10'} - '@node-rs/crc32-android-arm-eabi@1.10.8': resolution: {integrity: sha512-Ed2P9uQAOlOSveuPKSlmCctcZ73+9VIBTD84F8MjfKU26q33Y0hYlcl9vlzfc9UQX7LLMGEZhOuU1xxYtx49tg==} engines: {node: '>= 10'} @@ -680,124 +464,124 @@ packages: cpu: [x64] os: [win32] - '@oxlint/binding-android-arm-eabi@1.87.0': - resolution: {integrity: sha512-22tVIHlB9898OYsBKmYB6He1MCSqdTmK1BVWuSNUTVrpS0KWNege+U9uEYqGJ+KatQag0r/oU44i3OOs46z7HQ==} + '@oxlint/binding-android-arm-eabi@1.86.0': + resolution: {integrity: sha512-63Ozq6yQn79B2UxjSZ9huJmI+3/ZPI7hxAe6TyFh9oQL447KnIMoffOdtog+h4KILbQr61ZRvFPW5gYbAUgpgQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxlint/binding-android-arm64@1.87.0': - resolution: {integrity: sha512-JfZQ3MWf74n8ov0DojZpkOduIubYJvZL5tbTMMhDutsal6u1xH1QuNpN+lK+EQwF8AeAfgwTR2fgh5U2br27XA==} + '@oxlint/binding-android-arm64@1.86.0': + resolution: {integrity: sha512-JdLGp44phbf/0Du236Dt06yfK6Qw8xhTFeDvHJdjk167r59OfP5+BL31mgMYVnswS8e+j6tFuccSY0QDC/lgCw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxlint/binding-darwin-arm64@1.87.0': - resolution: {integrity: sha512-rkx6Xvvw93z2GImVOyvbZwAZcoY0Z6MJW177XNsSB+i/CBsH7ANyelTp01VZyIkM3iSXtqKVb4+ptBgPHap5pQ==} + '@oxlint/binding-darwin-arm64@1.86.0': + resolution: {integrity: sha512-h+vkOr4ik6KLFCXdtNVEj9xfnXfNUpPoF95QjORdhiSpLqQePzcN81kGHjAgsw1/G7WBWK5KsFKbI2UK8EyzMA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxlint/binding-darwin-x64@1.87.0': - resolution: {integrity: sha512-cw5nYw7L/pQ2SgGBgOcg/Qo89llQre3vIc/kuBLhSICzu1r5s8L76H3ug6AIFsD98WpROj8/z5JVzkf4daxHaQ==} + '@oxlint/binding-darwin-x64@1.86.0': + resolution: {integrity: sha512-wbglRuH5eKsp68keTOOeKDHU1astWSRYFsh2bwKQTAPb44rD6kVgzYI1ZrAJRTc6/7PH55ZXFCQx0rQXrj0UdA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxlint/binding-freebsd-x64@1.87.0': - resolution: {integrity: sha512-x/SuznE6loRZK9gz9AS4ZmzNzSu+5KdeoCS+sVHhnrE8HM6AD8UyTjwldQTTqlXGvkwHASgB0NiTNOc3o3PdAg==} + '@oxlint/binding-freebsd-x64@1.86.0': + resolution: {integrity: sha512-yTQ7QSOlNfQsrfbeVweTiegQJLKPjUKgXp/JpGiFzza6Hp29S9LAoH77TM3L0RjK39iQRZB5lk5iq2t241Mfag==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxlint/binding-linux-arm-gnueabihf@1.87.0': - resolution: {integrity: sha512-+JEDmCAXiiFmKhAjZhgAOwaHWY6WENOQ5QA+lFuY59tgsUCDOTRi46B+lENK6CWvtxsXpwkZXNOgpjAQcpIpnA==} + '@oxlint/binding-linux-arm-gnueabihf@1.86.0': + resolution: {integrity: sha512-QYJGy0E2Tv8GAayyBIeHFTwQb8u+zrvrOYrifuDGZDr9ad5XsDhiMScpZymhS9fwWjqrgG2ZUwT4vpY0ISqp4Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm-musleabihf@1.87.0': - resolution: {integrity: sha512-/uC9XqXLAC9AHFLNycwmSQEvKqGrOCgU+uXALI7gNW0DG81TJ/3T2eHnKdRvcb5R1gFEfjxDwI4e4zIa29rICQ==} + '@oxlint/binding-linux-arm-musleabihf@1.86.0': + resolution: {integrity: sha512-oQo83CafLT8p3qh0AGkN0tSUIh5Hm63mfDsoqUmBQUBnSZLNBAbyRaAftfGY5a0cKZ34dG6jAtMYgcY5kZqt5g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm64-gnu@1.87.0': - resolution: {integrity: sha512-FTRYPsBy/M728vnUv7l+KAwSAa3Qi4BQbhVvy7NYuMTZ0sUa/OWsbMx79HVFw6u/5KWcaYayDsoOiv7xbevzDg==} + '@oxlint/binding-linux-arm64-gnu@1.86.0': + resolution: {integrity: sha512-EM6wy5c2UM12qPb7iDJDBAZvHyfEJGH6iosFgaFiaRZpirz99//EHcUyD2KAskz07x1TULuiAtsojf4nvWWksw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-arm64-musl@1.87.0': - resolution: {integrity: sha512-6sC+N7IY0h1egd8SrYUGNN7CUyKJZ4bIZakKa7V7hfinf4jfi8Kq4+klJvFp5DqXcwMn44I5MeBwmJluO1odFg==} + '@oxlint/binding-linux-arm64-musl@1.86.0': + resolution: {integrity: sha512-vCiUQb9ZNzZalxYRU4mlZwUqT5f+c7Sk9DwUOplWHtik3Dl4r3DVnUB5tBeRuTAUe6MUVCTylNW7TkkN8J1oTw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxlint/binding-linux-ppc64-gnu@1.87.0': - resolution: {integrity: sha512-d5Mnn+ICbhoJPi37Ccjy+86T5qfH2+mSTrTMBXNPfB+wqSWOayBBLLIrCXdUy1YssluCVdGkl+DO9w5r5ODmUA==} + '@oxlint/binding-linux-ppc64-gnu@1.86.0': + resolution: {integrity: sha512-T0AzE89yjTYmOhB/hb6i+rWTpe2mYt+xiguBhs9DEPIJTZ1H8OYqsE4VxJHHw2Wlq8Mztwvp2P8NV87ZOuhPRw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-gnu@1.87.0': - resolution: {integrity: sha512-sf5YWuUo1Dx9loqWCgF8P278MycfcD4JHjDC/LV5j11L3Lqa4PFiwCjD6B8kP4AjsgZZkLn3xbpJEtZBaccs4Q==} + '@oxlint/binding-linux-riscv64-gnu@1.86.0': + resolution: {integrity: sha512-s49r0K2f5Oc7fJoMUO1WkdhYK5Ob79uYRwWQ/XNoGysaOq3RUGmGUbHSuXiyydTTA+P9xEdDEnnlZVXiaSJhlw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-musl@1.87.0': - resolution: {integrity: sha512-+aCraYbmK3uU3P0rFojjeaK9pGY6JvBDXh/0CM+gPyWKmNmXmnFe0qj4qj02U4ibFpbd9Pa9CmDvIdjJUd29rA==} + '@oxlint/binding-linux-riscv64-musl@1.86.0': + resolution: {integrity: sha512-JIa2tYR8ayb3nvcMSZzn4O/xTy13S7MHllu7cBD+B5qDQXXSRy/Vwk1aNpiTjZmBJGwKCHOy/NvnY5Xcwg27uQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxlint/binding-linux-s390x-gnu@1.87.0': - resolution: {integrity: sha512-7rzbZQIljo6VLWVPqUyLa2P/WV3VFsJB4M52R4NCUJn1A50yjtUsHxiMHFL9opEKGFIiTbZ3VQzmtcJOPUq4NQ==} + '@oxlint/binding-linux-s390x-gnu@1.86.0': + resolution: {integrity: sha512-XwXMyBsAuWZkhZU74UNcfQKr1CJJAKnS80dZzOnjl6A4fKcf91IjFgidegQugMHA5SN8ulwDXEDV1yPntjXgQQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-gnu@1.87.0': - resolution: {integrity: sha512-Dry46Fa+AXE/xt9J/YriujU1pU9mzfNWHGQwtOYibAFH8HLrKWCi93AhIGnuUMiwn2JbuBkRyz51f50Ly4r4ow==} + '@oxlint/binding-linux-x64-gnu@1.86.0': + resolution: {integrity: sha512-C1WjukSyMnr66b+w1/tV8RFVv6d9v0MzDf4p9IxVXknqgmTHBgZh1pccN1eHzFr0b9Tbb3OXoPsAAdAuHAQfeA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-musl@1.87.0': - resolution: {integrity: sha512-f/HMZez74gJvnRpGz+b5sdGyWrEgRLmFTXg9sNoqjPWogGxYHUhXKTKyMiYsu9rUGR2ciYqL3fk+Ua4JhMJbRA==} + '@oxlint/binding-linux-x64-musl@1.86.0': + resolution: {integrity: sha512-ap6KLmvC38c6MdYzsIh25cXQupqYvjd37tMNftzrX1DCtkX1Gcf2B+S2B17dD4lKa5c5gJog7DQJyDo82PBKyw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxlint/binding-openharmony-arm64@1.87.0': - resolution: {integrity: sha512-lEC6ZfPZm/07JlB7FsOWX9o1RiHlXiBW594LSUGuXmekC8LZmvUPP9XzE2WZcNxJi5cHA/Fw56o02c3JxgMA5g==} + '@oxlint/binding-openharmony-arm64@1.86.0': + resolution: {integrity: sha512-996Q9w/2GLwNyy5Djs39Jt09aFlr4b7TPhoj7dvo5FHmcyiF68nPz73HNc5UAYKIYeK+M9JzPQ4Wg1r1dXvP6A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxlint/binding-win32-arm64-msvc@1.87.0': - resolution: {integrity: sha512-afBWKOD36dUiA9AenaiWpCg8xfyUVPYznyAAVXu413brK4RA5UI/Xoc4yfeGIT8G1a44MV+JR7hioryerBDzGQ==} + '@oxlint/binding-win32-arm64-msvc@1.86.0': + resolution: {integrity: sha512-9uTCHYTknNkug5+46ViSoUMuOuGWK61D87QnblU6CXaWKJC630Z7dbls61vdHxya/cR2ycQZ6Mm6ckgF6Cag5g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxlint/binding-win32-ia32-msvc@1.87.0': - resolution: {integrity: sha512-daptNsEOuru4/nJTxV4DBHNJ5GepzmaBVQf+jjgKQGi9NRlZKik+RFq4hUL7ewsF1pwjKM+SkYaMbF82UwFGnw==} + '@oxlint/binding-win32-ia32-msvc@1.86.0': + resolution: {integrity: sha512-a47ga7EKVfMU8rO0i1m3mGwfNRj3FhQyt1d/ukO/E9u1HHF3KpR3tyb3VhuJBGEVPF8uR4exSyHUyx7xbE+uxw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxlint/binding-win32-x64-msvc@1.87.0': - resolution: {integrity: sha512-k+m/EShyXE29J/r1fjk0HlAzhN5JhIvj044oSJkXTEkCLrWZQgXb/3r4q5LTw/eaKW/4GuMXG2xmiJs3s5rPNw==} + '@oxlint/binding-win32-x64-msvc@1.86.0': + resolution: {integrity: sha512-az4dzifCRzES1crwu6pDFXzJTIanrdxXFL9TbiJWrx9LKcuacbR83aPvxDA1LKLNswi/RoqitC7Afcflhu3zBQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -865,11 +649,20 @@ packages: '@sinonjs/commons@3.0.1': resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} - '@sinonjs/fake-timers@15.4.0': - resolution: {integrity: sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==} + '@sinonjs/fake-timers@10.3.0': + resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + + '@sinonjs/fake-timers@11.3.1': + resolution: {integrity: sha512-EVJO7nW5M/F5Tur0Rf2z/QoMo+1Ia963RiMtapiQrEWvY0iBUvADo8Beegwjpnle5BHkyHuoxSTW3jF43H1XRA==} + + '@sinonjs/samsam@8.0.3': + resolution: {integrity: sha512-hw6HbX+GyVZzmaYNh82Ecj1vdGZrqVIn/keDTg63IgAwiQPO+xCz99uG6Woqgb4tM0mUiFENKZ4cqd7IX94AXQ==} - '@sinonjs/samsam@10.0.2': - resolution: {integrity: sha512-8lVwD1Df1BmzoaOLhMcGGcz/Jyr5QY2KSB75/YK1QgKzoabTeLdIVyhXNZK9ojfSKSdirbXqdbsXXqP9/Ve8+A==} + '@sinonjs/text-encoding@0.7.3': + resolution: {integrity: sha512-DE427ROAphMQzU4ENbliGYrBSYPXF+TtLg9S8vzeA+OF4ZKzoDdzfL8sxuMUGS/lgRhM6j1URSk9ghf7Xo1tyA==} + deprecated: |- + Deprecated: no longer maintained and no longer used by Sinon packages. See + https://github.com/sinonjs/nise/issues/243 for replacement details. '@textlint/ast-node-types@15.8.0': resolution: {integrity: sha512-5CiH9COYmovWmExQgs7763DzX6Gy9zjkjJ7JxCC95wyTcjwQn/8poNF6fv3qzRlmx8CRRde8DHr9FcgAAiPzgw==} @@ -893,11 +686,17 @@ packages: '@types/istanbul-lib-coverage@2.0.6': resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + '@types/js-yaml@4.0.9': + resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + '@types/mocha@10.0.10': resolution: {integrity: sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==} - '@types/node@26.6.4': - resolution: {integrity: sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==} + '@types/mocha@8.2.3': + resolution: {integrity: sha512-ekGvFhFgrc2zYQoX4JeZPmVzZxw6Dtllga7iGHzfbYIYkAMUx/sAFP2GdFpLff+vdHXu5fl7WX9AT+TtqYcsyw==} + + '@types/node@22.20.5': + resolution: {integrity: sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==} '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} @@ -905,14 +704,14 @@ packages: '@types/sarif@2.1.7': resolution: {integrity: sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ==} - '@types/sinon@22.0.0': - resolution: {integrity: sha512-TDbVpbccc2HfiqHR09Argj3mHV1KMW7sCCKj52fsl8lbRLkEn7fB1966EWhOKWUBcqfBueZuPoA7/OK1CKiy3g==} + '@types/sinon@10.0.20': + resolution: {integrity: sha512-2APKKruFNCAZgx3daAyACGzWuJ028VVCUDk6o2rw/Z4PXT0ogwdV4KUegW0MwVs0Zu59auPXbbuBJHF12Sx1Eg==} '@types/sinonjs__fake-timers@15.0.1': resolution: {integrity: sha512-Ko2tjWJq8oozHzHV+reuvS5KYIRAokHnGbDwGh/J64LntgpbuylF74ipEL24HCyRjf9FOlBiBHWBR1RlVKsI1w==} - '@types/vscode@1.98.0': - resolution: {integrity: sha512-+KuiWhpbKBaG2egF+51KjbGWatTH5BbmWQjSLMDCssb4xF8FJnW4nGH4nuAdOOfMbpD0QlHtI+C3tPq+DoKElg==} + '@types/vscode@1.94.0': + resolution: {integrity: sha512-UyQOIUT0pb14XSqJskYnRwD2aG0QrPVefIfrW1djR+/J4KeFQ0i1+hjZoaAmeNf3Z2jleK+R2hv+EboG/m8ruw==} '@types/winreg@1.2.36': resolution: {integrity: sha512-DtafHy5A8hbaosXrbr7YdjQZaqVewXmiasRS5J4tYMzt3s1gkh40ixpxgVFfKiQ0JIYetTJABat47v9cpr/sQg==} @@ -1044,9 +843,9 @@ packages: '@vscode/codicons@0.0.46-24': resolution: {integrity: sha512-KIDWUYxG6eh+DHRg+3G4bS487T+g1ZkLfB4HylOBbCcHsvLdfLMRM/m92DS1Ljhi8nglLr9ucwwWhHTEAC+DpA==} - '@vscode/test-cli@0.0.15': - resolution: {integrity: sha512-nAxk2X79wuXS7aOhyFFhFcCqd7EBUoMesu7ZgsYE/4eFjyBMuyIweVE94BxdKH1RieN8eOz2SIrljrZt6Lk9fQ==} - engines: {node: '>=22'} + '@vscode/test-cli@0.0.12': + resolution: {integrity: sha512-iYN0fDg29+a2Xelle/Y56Xvv7Nc8Thzq4VwpzAF/SIE6918rDicqfsQxV6w1ttr2+SOm+10laGuY9FG2ptEKsQ==} + engines: {node: '>=18'} hasBin: true '@vscode/test-electron@3.1.0': @@ -1106,11 +905,6 @@ packages: engines: {node: '>= 20'} hasBin: true - '@vscode/vsce@4.0.0': - resolution: {integrity: sha512-NImwuLaenMmb5D5Jer9/lzi/F9ZQUBOp8Azhj/BVYcTFgixv8KehFXqEUDjQlD2tAiw2E6dDGyjTuAB//di60A==} - engines: {node: '>= 22'} - hasBin: true - ag-charts-types@14.2.0: resolution: {integrity: sha512-4pDV6/4zbzPN7t607Gr1o6DO9RhmPafQGr+hIpTJBaerbaMoC+WOO3v/wkxFbvCNrHKOexEKSfNTH7PaAR4UzA==} @@ -1124,8 +918,8 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} - ajv@8.20.0: - resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} ansi-escapes@7.3.0: resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} @@ -1135,8 +929,8 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} - ansi-regex@6.4.0: - resolution: {integrity: sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==} + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} engines: {node: '>=12'} ansi-styles@4.3.0: @@ -1147,6 +941,10 @@ packages: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -1154,6 +952,9 @@ packages: resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} engines: {node: '>=8'} + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -1170,6 +971,10 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + binaryextensions@6.11.0: resolution: {integrity: sha512-sXnYK/Ij80TO3lcqZVV2YgfKN5QjUWIRk/XSm2J/4bd/lPko3lvk0O4ZppH6m+6hB2/GTu+ptNwVFe1xh+QLQw==} engines: {node: '>=4'} @@ -1210,9 +1015,9 @@ packages: resolution: {integrity: sha512-DdH81/zPLVS11EUgWq3tEu/xn+EzljlMYooDNdzWEnFha3R3NBMpMV1UqYIpjYHV/SgpFKMIX1Oh7o06SjM/oA==} engines: {node: '>=18'} - c8@11.0.0: - resolution: {integrity: sha512-e/uRViGHSVIJv7zsaDKM7VRn2390TgHXqUSvYwPHBQaU6L7E9L0n9JbdkwdYPvshDT0KymBmmlwSpms3yBaMNg==} - engines: {node: 20 || >=22} + c8@10.1.3: + resolution: {integrity: sha512-LvcyrOAaOnrrlMpW22n690PUvxiq4Uf9WMhQwNJ9vgagkL/ph1+D4uvjvDA5XCbykrc0sx+ay6pVi9YZ1GnhyA==} + engines: {node: '>=18'} hasBin: true peerDependencies: monocart-coverage-reports: ^2 @@ -1240,24 +1045,21 @@ packages: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} - chardet@2.2.0: - resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==} - cheerio-select@2.1.0: resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} - cheerio@1.2.0: - resolution: {integrity: sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==} - engines: {node: '>=20.18.1'} + cheerio@1.0.0-rc.12: + resolution: {integrity: sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==} + engines: {node: '>= 6'} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} - chokidar@5.0.0: - resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} - engines: {node: '>= 20.19.0'} - chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} @@ -1272,18 +1074,10 @@ packages: resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} engines: {node: '>=6'} - cli-width@4.1.0: - resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} - engines: {node: '>= 12'} - cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} - cliui@9.0.1: - resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} - engines: {node: '>=20'} - cockatiel@3.2.1: resolution: {integrity: sha512-gfrHV6ZPkquExvMh9IOkKsBzNDk6sDuZ6DdBGUBkvFnTCqCxzpuq48RySgP0AnaqQkw2zynOFj9yly6T1Q2G5Q==} engines: {node: '>=16'} @@ -1313,11 +1107,6 @@ packages: core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} - cross-keychain@1.1.0: - resolution: {integrity: sha512-244DWNdGepLKD5vEn3reZqwzZFiE/LD4U+XV9IaXQbtIXKvQkf0VkRaOj/9vPYauPdR12PSGB3U0cE7jJi3WTQ==} - engines: {node: '>=18'} - hasBin: true - cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -1378,12 +1167,12 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - diff@7.0.0: - resolution: {integrity: sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==} + diff@5.2.2: + resolution: {integrity: sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==} engines: {node: '>=0.3.1'} - diff@9.0.0: - resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==} + diff@7.0.0: + resolution: {integrity: sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==} engines: {node: '>=0.3.1'} dom-serializer@2.0.0: @@ -1413,9 +1202,9 @@ packages: resolution: {integrity: sha512-UgGlf8IW75je7HZjNDpJdCv4cGJWIi6yumFdZ0R7A8/CIhQiWUjyGLCxdHpd8bmyD1gnkfUNK0oeOXqUS2cpfQ==} engines: {ecmascript: '>= es5', node: '>=4'} - ejs@7.0.1: - resolution: {integrity: sha512-TrAb1fY4jNY6aQrNjMJsoWQxGUK47A38JxuUvnt75vDcAA9Ln7ur6FHo+irVa5Bwd3w9rdUT3xLSl5MRgHIugg==} - engines: {node: '>=0.12.18'} + ejs@3.1.10: + resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} + engines: {node: '>=0.10.0'} hasBin: true emoji-regex@10.6.0: @@ -1427,14 +1216,11 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - encoding-sniffer@0.2.1: - resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==} - end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} - enhanced-resolve@5.26.0: - resolution: {integrity: sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==} + enhanced-resolve@5.20.0: + resolution: {integrity: sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==} engines: {node: '>=10.13.0'} entities@4.5.0: @@ -1445,14 +1231,6 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} - entities@7.0.1: - resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} - engines: {node: '>=0.12'} - - entities@8.1.0: - resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==} - engines: {node: '>=20.19.0'} - environment@1.1.0: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} @@ -1465,8 +1243,8 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} - es-object-atoms@1.1.2: - resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} es-set-tostringtag@2.1.0: @@ -1504,30 +1282,20 @@ packages: fast-uri@3.1.8: resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} - fastq@1.20.3: - resolution: {integrity: sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==} - - fdir@6.5.0: - resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} - engines: {node: '>=12.0.0'} - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} figures@6.1.0: resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} engines: {node: '>=18'} + filelist@1.0.6: + resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} - find-up-simple@1.0.1: - resolution: {integrity: sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==} - engines: {node: '>=18'} - find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -1536,8 +1304,8 @@ packages: resolution: {integrity: sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==} hasBin: true - follow-redirects@1.16.1: - resolution: {integrity: sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==} + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} engines: {node: '>=4.0'} peerDependencies: debug: '*' @@ -1560,6 +1328,11 @@ packages: resolution: {integrity: sha512-KYAb4c9BJQI6QqGKthV68OHe0badztdXJWKo0WtBA9IuCFPTKvE5ZdUBglP833aMjhaSPNO4A5j/EkzZtGlKjA==} engines: {node: '>=14.14'} + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -1567,8 +1340,8 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} - get-east-asian-width@1.7.0: - resolution: {integrity: sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==} + get-east-asian-width@1.5.0: + resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} engines: {node: '>=18'} get-intrinsic@1.3.0: @@ -1583,39 +1356,39 @@ packages: resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} engines: {node: '>=18'} - git-cliff-darwin-arm64@2.14.2: - resolution: {integrity: sha512-Ylk39hv5flEUrXeoGvH6M3WjHBNERFH7a9DVEcGU2a8Q7a7fPtVasNk1fMuJ7CVbZCRIzUNyQV0ejRpxBdFnag==} + git-cliff-darwin-arm64@2.12.0: + resolution: {integrity: sha512-k3jzFDmkjc+6MjpnqvRenzMWRbZN5J+w3iQ8WNt9pSmPewNJIm92O/G6AbAxQaCbSfzQapeZ0e+5wSacVc62GA==} cpu: [arm64] os: [darwin] - git-cliff-darwin-x64@2.14.2: - resolution: {integrity: sha512-3FZNm0BGCIim/3G6uHSWAakrCaGmC47lcI+Cob9CZPMBYHKrenYk0i6B8MlObSXr2Lr7Ve1BpGbd/foQg4NOiA==} + git-cliff-darwin-x64@2.12.0: + resolution: {integrity: sha512-Kkoe+nfmXM/WMcZuC+OaIGA5vj847Ima6NEaaHnyb7Xsri+OAJryPXlABV7q6UeGfiiN2MlL8UsoHgnIEIQLqQ==} cpu: [x64] os: [darwin] - git-cliff-linux-arm64@2.14.2: - resolution: {integrity: sha512-3oMDpoyfsy2angD9ulcCBtyWCkzZqN3spmmtb0jic8OKAu1p+CALS8oep7J8AdgkDojuLo6bdHiSZIM9PeTxmQ==} + git-cliff-linux-arm64@2.12.0: + resolution: {integrity: sha512-eTp2gZjV4LmfzdlhFsYFYuWf5mojALU03X/37r3VmnpuabaijuTEQo/zm/0BKP8gPiLKLR4ofdUvE1OSisCE1A==} cpu: [arm64] os: [linux] - git-cliff-linux-x64@2.14.2: - resolution: {integrity: sha512-4EaBYwBiCupECZ2rzC6CelNSGYvkvtJeja4wXLDGtfKnRhCN/WNoqbM+hvrtm32uf5qovfoNoDA906F8itw+oQ==} + git-cliff-linux-x64@2.12.0: + resolution: {integrity: sha512-abidFG6dH2N5hPUF245/kRYdwViP11Pz7ZwIW/a86CJLZ/WSE7dJt0f2cUIkxTcFSsp11OwuLc5k1hAbwmiIRw==} cpu: [x64] os: [linux] - git-cliff-windows-arm64@2.14.2: - resolution: {integrity: sha512-0oc0xD/6A+BvInz8imBJulXVOlR9M0nL+d3ibL0lwJvxUVTMQzvoCnsn9OxpsdzXrEisnTvaf+GExoKVJkUcDw==} + git-cliff-windows-arm64@2.12.0: + resolution: {integrity: sha512-rFuI+D/3Yq3jqafazZw5E68HsXEvcwI/B/5IPDIZD+QqZh8vETf4IXs7wVxYWWtHQJDC+G9ZrR3vE5648mdG3A==} cpu: [arm64] os: [win32] - git-cliff-windows-x64@2.14.2: - resolution: {integrity: sha512-RFmCMiwGMU8O+rF//wxT7L10ssoDtzI+QFxlbjpA8E1BAEafkmLTQkKalT88ipEweD46P+Sfc20v8RXuE7V/UA==} + git-cliff-windows-x64@2.12.0: + resolution: {integrity: sha512-jskb3nyVGr4dekHSCDM/J6iho45t37wnmMGkPNq42kOoUp04JS96yMBrNRdXfXV9ViZsaZq3NaNu1e3QkhFlyA==} cpu: [x64] os: [win32] - git-cliff@2.14.2: - resolution: {integrity: sha512-hlXIq4k2Tdj9C59WsD7FsLE0xRGFDozTUuoeqg9toMyVohaVxXiSiLnhM/IQyCmhOh5M4zuNckr2o8qMeuffbg==} - engines: {node: ^18.19 || >=20.6} + git-cliff@2.12.0: + resolution: {integrity: sha512-kjTm5439LsvMs/xRxndWBUetrA4aQfLE8DTbR/ER5H7fGn7ioeFG9YNAK1V7dpTtNi6k2uKYY4f3EvT8J1d+1Q==} + engines: {node: '>=18.19 || >=20.6 || >=21'} hasBin: true github-from-package@0.0.0: @@ -1664,6 +1437,10 @@ packages: resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} engines: {node: '>= 0.4'} + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + hasown@2.0.4: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} @@ -1672,8 +1449,8 @@ packages: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true - highlight.js@11.12.0: - resolution: {integrity: sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg==} + highlight.js@11.11.1: + resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} engines: {node: '>=12.0.0'} hosted-git-info@4.1.0: @@ -1687,8 +1464,8 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} - htmlparser2@10.1.0: - resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + htmlparser2@8.0.2: + resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} @@ -1702,19 +1479,11 @@ packages: resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} engines: {node: '>=18.18.0'} - iconv-lite@0.6.3: - resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} - engines: {node: '>=0.10.0'} - - iconv-lite@0.7.3: - resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} - engines: {node: '>=0.10.0'} - ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - ignore@7.0.12: - resolution: {integrity: sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==} + ignore@7.0.10: + resolution: {integrity: sha512-HpbUakT7xp5miBUywCHf36ZEuAJNklBJDDsGpUIjMzOSmM8ELSfA9Sa/QDPeNeqeoN31u+UTCkL4klCOVvRm4Q==} engines: {node: '>= 4'} immediate@3.0.6: @@ -1730,6 +1499,10 @@ packages: ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + is-ci@2.0.0: resolution: {integrity: sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==} hasBin: true @@ -1772,10 +1545,6 @@ packages: resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} engines: {node: '>=8'} - is-path-inside@4.0.0: - resolution: {integrity: sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==} - engines: {node: '>=12'} - is-plain-obj@2.1.0: resolution: {integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==} engines: {node: '>=8'} @@ -1829,6 +1598,11 @@ packages: jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jake@10.9.4: + resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==} + engines: {node: '>=10'} + hasBin: true + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -1836,10 +1610,6 @@ packages: resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} hasBin: true - js-yaml@5.4.3: - resolution: {integrity: sha512-yzzNtgczwgoVHNigCyKTXcQJU4Y8t/ikU4UxXW0BvrLfHTGWZhsNcOT2s/a7vn81/4KqVnNmHjaUEPlvWnNwVw==} - hasBin: true - json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} @@ -1851,15 +1621,18 @@ packages: jsonc-parser@3.3.1: resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} - jsonfile@6.2.1: - resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + jsonfile@6.2.0: + resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} jsonwebtoken@9.0.3: resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} engines: {node: '>=12', npm: '>=6'} - jszip@3.10.2: - resolution: {integrity: sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==} + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + + just-extend@6.2.0: + resolution: {integrity: sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==} jwa@2.0.1: resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} @@ -1938,11 +1711,6 @@ packages: resolution: {integrity: sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==} hasBin: true - marked@18.1.0: - resolution: {integrity: sha512-PamYXWWWg2nboG3oX5Ffzpy3EFZROqZJK9hSlMmUnekp7TxPs5DcT95vwd46m73/exfKCRVMtgXoPMI4FEJL6w==} - engines: {node: '>= 20'} - hasBin: true - math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -1950,10 +1718,6 @@ packages: mdurl@2.1.0: resolution: {integrity: sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==} - meow@14.1.0: - resolution: {integrity: sha512-EDYo6VlmtnumlcBCbh1gLJ//9jvM/ndXHfVXIFrZVr6fGcwTUyCTFNTLCKuY3ffbK8L/+3Mzqnd58RojiZqHVw==} - engines: {node: '>=20'} - merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -1987,6 +1751,10 @@ packages: resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} engines: {node: 18 || 20 || >=22} + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + minimatch@9.0.9: resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} engines: {node: '>=16 || 14 >=14.17'} @@ -2001,31 +1769,25 @@ packages: mkdirp-classic@0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} - mocha@11.8.0: - resolution: {integrity: sha512-VyCeUdGN3A9lmCTTgG4yuvY9ixxaDk+xt2R/7/+1AP6EqNG+G9OKkzBwhVtVYoNX8YsxNSgAl8mOv3IAeOpFbw==} + mocha@11.7.5: + resolution: {integrity: sha512-mTT6RgopEYABzXWFx+GcJ+ZQ32kp4fMf0xvpZIIfSq9Z8lC/++MtcCnQ9t5FP2veYEP95FIYSvW+U9fV4xrlig==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} hasBin: true - mocha@12.0.3: - resolution: {integrity: sha512-beammieDdiWdkjANWeWPpkxUROYGi5FN88fPOkLiticz7HTg3Y+Pho3EYE6SUdcuWnZ3h2WD8EXCA55JF4o6wg==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} mute-stream@0.0.8: resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==} - mute-stream@2.0.0: - resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} - engines: {node: ^18.17.0 || >=20.5.0} - napi-build-utils@2.0.0: resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} - node-abi@3.98.0: - resolution: {integrity: sha512-Llh8noNw7s15m+rmIrZnZQbe4gLyasL9loT74rC92PkmwWKeEedKvc7o6pjp8B3MhrYT/Cl846NOYZbz1wmddw==} + nise@5.1.9: + resolution: {integrity: sha512-qOnoujW4SV6e40dYxJOb3uvuoPHtmLzIk4TFo+j0jPJoC+5Z9xja5qH5JZobEPsa8+YYphMrOSwnrshEhG2qww==} + + node-abi@3.96.0: + resolution: {integrity: sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==} engines: {node: '>=10'} node-addon-api@4.3.0: @@ -2039,6 +1801,10 @@ packages: resolution: {integrity: sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==} engines: {node: ^16.14.0 || >=18.0.0} + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + npm-run-path@6.0.0: resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} engines: {node: '>=18'} @@ -2069,17 +1835,17 @@ packages: resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} engines: {node: '>=18'} - ovsx@1.2.0: - resolution: {integrity: sha512-12mauBqsLehlJ0cRiroQz4uKYVjblVS5OobgSJ278CdCtkDfRQD+SI3NQ00GNalDqzp5PDN11yx84miFUojeMw==} - engines: {node: '>=22.0.0'} + ovsx@0.10.12: + resolution: {integrity: sha512-WwMj1iQDvCk02029oxPnkFXsPrHZ+WzmoNW5pJ8JGepHtL30i2JE4s3C3wqzQqj6a35vx2hp0gV3TdfefGmvMg==} + engines: {node: '>= 20'} hasBin: true oxlint-tsgolint@7.0.2003: resolution: {integrity: sha512-VnK4zlqgmgq/7ZcjzCk/WpN8kKFsYGcB85Io9qT3wL4K8Un3RKmJkp793crNETieMusPMKOA4a+Mw2wbteI6TQ==} hasBin: true - oxlint@1.87.0: - resolution: {integrity: sha512-drxVlaW7Oqq7wSy1CN25znRu00aRq8n57Q2wniJRxCmtYVz07ufCm7EVDPQFZURrTgQIjzKrVEHAD/HcvJXObg==} + oxlint@1.86.0: + resolution: {integrity: sha512-og0lhgvZfgGF//gOOmZXvtr+GmBbAGEnbEhv/QUg7UW2Wi4wHMnJbnMD+zuHgPdJxdklfgpPupdlAaAySyxrZg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -2123,15 +1889,9 @@ packages: parse5-htmlparser2-tree-adapter@7.1.0: resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==} - parse5-parser-stream@7.1.2: - resolution: {integrity: sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==} - parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} - parse5@8.0.1: - resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} - path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -2152,6 +1912,9 @@ packages: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + path-type@6.0.0: resolution: {integrity: sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==} engines: {node: '>=18'} @@ -2166,10 +1929,6 @@ packages: resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} engines: {node: '>=8.6'} - picomatch@4.0.7: - resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} - engines: {node: '>=12'} - pluralize@2.0.0: resolution: {integrity: sha512-TqNZzQCD4S42De9IfnnBvILN7HAW7riLqsCyp8lgjXeysyPlX5HhqKAcJHHHb9XskE4/a+7VGC9zzx8Ls0jOAw==} @@ -2183,16 +1942,13 @@ packages: deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. hasBin: true - pretty-ms@9.3.1: - resolution: {integrity: sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA==} + pretty-ms@9.3.0: + resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} engines: {node: '>=18'} process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} - proper-lockfile@4.1.2: - resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} - pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} @@ -2229,14 +1985,14 @@ packages: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} - readdirp@5.1.1: - resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} - engines: {node: '>= 20.19.0'} - require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -2249,10 +2005,6 @@ packages: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} - retry@0.12.0: - resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} - engines: {node: '>= 4'} - reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -2267,12 +2019,6 @@ packages: safe-buffer@5.1.2: resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} - safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - - safer-buffer@2.1.2: - resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - sax@1.6.1: resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==} engines: {node: '>=11.0.0'} @@ -2286,6 +2032,11 @@ packages: resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} hasBin: true + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} @@ -2322,9 +2073,6 @@ packages: resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} engines: {node: '>= 0.4'} - signal-exit@3.0.7: - resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} - signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -2339,8 +2087,9 @@ packages: resolution: {integrity: sha512-1sbhsxqI+I2tqlmjbz99GXNmZtr6tKIyEgGGnJw/MKGblalqk/XoOYYFJlBzTKZCxx8kLaD3FD5s9BEEjx5Pyg==} engines: {node: '>=10'} - sinon@22.1.0: - resolution: {integrity: sha512-n1ajF2rBWMTtEwbKcw4UdFg4nCnDdq/U6RDoxtOd7oapOlRoJ5ynwFx60owROyhDpA9QhMZi0pCO/xtmwFjG7w==} + sinon@15.2.0: + resolution: {integrity: sha512-nPS85arNqwBXaIsFCkolHjGIkFo+Oxu9vbgmBJizLAhqe6P2o3Qmj3KCUoRkfhHtvgDhZdWD3risLHAUJ8npjw==} + deprecated: 16.1.1 slash@5.1.0: resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} @@ -2359,8 +2108,8 @@ packages: spdx-expression-parse@3.0.1: resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==} - spdx-license-ids@3.0.24: - resolution: {integrity: sha512-cLS9TtWkIQFyLkJ3/5aFQAOHOSKTlOs/7WDut/XPSdjom1fJhUdkepeJAKXa9Y05+FabHd0sti+Et559vDtkpQ==} + spdx-license-ids@3.0.23: + resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==} stdin-discarder@0.2.2: resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} @@ -2378,16 +2127,9 @@ packages: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} - string-width@8.3.0: - resolution: {integrity: sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==} - engines: {node: '>=20'} - string_decoder@1.1.1: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} - string_decoder@1.3.0: - resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} - strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -2408,10 +2150,6 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} - strip-json-comments@5.0.3: - resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} - engines: {node: '>=14.16'} - structured-source@4.0.0: resolution: {integrity: sha512-qGzRFNJDjFieQkl/sVOI2dUjHKRyL9dAJi2gCPGJLbJHBIkyOHxjuocpIEfbLioX+qSJpvbYdT49/YCdMznKxA==} @@ -2435,8 +2173,8 @@ packages: resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} engines: {node: '>=10.0.0'} - tapable@2.3.3: - resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + tapable@2.3.0: + resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} tar-fs@2.1.5: @@ -2450,9 +2188,9 @@ packages: resolution: {integrity: sha512-lk+vH+MccxNqgVqSnkMVKx4VLJfnLjDBGzH16JVZjKE2DoxP57s6/vt6JmXV5I3jBcfGrxNrYtC+mPtU7WJztA==} engines: {node: '>=18'} - test-exclude@8.0.0: - resolution: {integrity: sha512-ZOffsNrXYggvU1mDGHk54I96r26P8SyMjO5slMKSc7+IWmtB/MQKnEC2fP51imB3/pT6YK5cT5E8f+Dd9KdyOQ==} - engines: {node: 20 || >=22} + test-exclude@7.0.2: + resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==} + engines: {node: '>=18'} text-table@0.2.0: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} @@ -2461,10 +2199,6 @@ packages: resolution: {integrity: sha512-tXJwSr9355kFJI3lbCkPpUH5cP8/M0GGy2xLO34aZCjMXBaK3SoPnZwr/oWmo1FdCnELcs4npdCIOFtq9W3ruQ==} engines: {node: '>=4'} - tinyglobby@0.2.17: - resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} - engines: {node: '>=12.0.0'} - tmp@0.2.7: resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==} engines: {node: '>=14.14'} @@ -2509,12 +2243,8 @@ packages: underscore@1.13.8: resolution: {integrity: sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==} - undici-types@8.9.0: - resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} - - undici@7.30.0: - resolution: {integrity: sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==} - engines: {node: '>=20.18.1'} + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} unicorn-magic@0.1.0: resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==} @@ -2562,15 +2292,6 @@ packages: vscode-languageserver-types@3.18.4: resolution: {integrity: sha512-nQsacoiijvl0fRf2q28uFDrAVwRhdCa3QveiF7/CX0r18mLR5FU/1WskSsCKOjmEbtteKUnY8x8Yzxop+k0zwg==} - whatwg-encoding@3.1.1: - resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} - engines: {node: '>=18'} - deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation - - whatwg-mimetype@4.0.0: - resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} - engines: {node: '>=18'} - which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -2579,16 +2300,9 @@ packages: winreg@1.2.5: resolution: {integrity: sha512-uf7tHf+tw0B1y+x+mKTLHkykBgK2KMs3g+KlzmyMbLvICSHQyB/xOFjTT8qZ3oeTFyU7Bbj4FzXitGG6jvKhYw==} - workerpool@10.0.3: - resolution: {integrity: sha512-6z2Iis68Wqth93/G/wJP9u+R3O+d2XTlgWChGCwuT1qLbBsOYueGRZuJ++v3mtDP5KjYdy+WzvWC+VWETSVXJA==} - workerpool@9.3.4: resolution: {integrity: sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg==} - wrap-ansi@6.2.0: - resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} - engines: {node: '>=8'} - wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -2597,10 +2311,6 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} - wrap-ansi@9.0.2: - resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} - engines: {node: '>=18'} - wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -2627,22 +2337,14 @@ packages: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} - yargs-parser@22.0.0: - resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} - engines: {node: ^20.19.0 || ^22.12.0 || >=23} - yargs-unparser@2.0.0: resolution: {integrity: sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==} engines: {node: '>=10'} - yargs@17.7.3: - resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} - yargs@18.2.0: - resolution: {integrity: sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w==} - engines: {node: ^20.19.0 || ^22.12.0 || >=23} - yauzl-promise@4.0.0: resolution: {integrity: sha512-/HCXpyHXJQQHvFq9noqrjfa/WpQC2XYs3vI7tBiAi4QiIU1knvYhZGaO1QPjwIVMdqflxbmwgMXtYeaRiAE0CA==} engines: {node: '>=16'} @@ -2658,12 +2360,8 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - yoctocolors-cjs@2.1.3: - resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} - engines: {node: '>=18'} - - yoctocolors@2.2.0: - resolution: {integrity: sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==} + yoctocolors@2.1.2: + resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} snapshots: @@ -2734,7 +2432,7 @@ snapshots: '@azure/core-tracing': 1.4.0 '@azure/core-util': 1.14.0(supports-color@8.1.1) '@azure/logger': 1.4.0(supports-color@8.1.1) - '@azure/msal-browser': 5.25.0 + '@azure/msal-browser': 5.23.0 '@azure/msal-node': 6.0.1 open: 10.2.0 tslib: 2.8.1 @@ -2748,14 +2446,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@azure/msal-browser@5.25.0': + '@azure/msal-browser@5.23.0': dependencies: - '@azure/msal-common': 16.14.2 + '@azure/msal-common': 16.14.1 '@azure/msal-common@16.14.1': {} - '@azure/msal-common@16.14.2': {} - '@azure/msal-node@6.0.1': dependencies: '@azure/msal-common': 16.14.1 @@ -2849,131 +2545,6 @@ snapshots: '@esbuild/win32-x64@0.28.2': optional: true - '@inquirer/ansi@1.0.2': {} - - '@inquirer/checkbox@4.3.2(@types/node@26.6.4)': - dependencies: - '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@26.6.4) - '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@26.6.4) - yoctocolors-cjs: 2.1.3 - optionalDependencies: - '@types/node': 26.6.4 - - '@inquirer/confirm@5.1.21(@types/node@26.6.4)': - dependencies: - '@inquirer/core': 10.3.2(@types/node@26.6.4) - '@inquirer/type': 3.0.10(@types/node@26.6.4) - optionalDependencies: - '@types/node': 26.6.4 - - '@inquirer/core@10.3.2(@types/node@26.6.4)': - dependencies: - '@inquirer/ansi': 1.0.2 - '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@26.6.4) - cli-width: 4.1.0 - mute-stream: 2.0.0 - signal-exit: 4.1.0 - wrap-ansi: 6.2.0 - yoctocolors-cjs: 2.1.3 - optionalDependencies: - '@types/node': 26.6.4 - - '@inquirer/editor@4.2.23(@types/node@26.6.4)': - dependencies: - '@inquirer/core': 10.3.2(@types/node@26.6.4) - '@inquirer/external-editor': 1.0.3(@types/node@26.6.4) - '@inquirer/type': 3.0.10(@types/node@26.6.4) - optionalDependencies: - '@types/node': 26.6.4 - - '@inquirer/expand@4.0.23(@types/node@26.6.4)': - dependencies: - '@inquirer/core': 10.3.2(@types/node@26.6.4) - '@inquirer/type': 3.0.10(@types/node@26.6.4) - yoctocolors-cjs: 2.1.3 - optionalDependencies: - '@types/node': 26.6.4 - - '@inquirer/external-editor@1.0.3(@types/node@26.6.4)': - dependencies: - chardet: 2.2.0 - iconv-lite: 0.7.3 - optionalDependencies: - '@types/node': 26.6.4 - - '@inquirer/figures@1.0.15': {} - - '@inquirer/input@4.3.1(@types/node@26.6.4)': - dependencies: - '@inquirer/core': 10.3.2(@types/node@26.6.4) - '@inquirer/type': 3.0.10(@types/node@26.6.4) - optionalDependencies: - '@types/node': 26.6.4 - - '@inquirer/number@3.0.23(@types/node@26.6.4)': - dependencies: - '@inquirer/core': 10.3.2(@types/node@26.6.4) - '@inquirer/type': 3.0.10(@types/node@26.6.4) - optionalDependencies: - '@types/node': 26.6.4 - - '@inquirer/password@4.0.23(@types/node@26.6.4)': - dependencies: - '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@26.6.4) - '@inquirer/type': 3.0.10(@types/node@26.6.4) - optionalDependencies: - '@types/node': 26.6.4 - - '@inquirer/prompts@7.10.1(@types/node@26.6.4)': - dependencies: - '@inquirer/checkbox': 4.3.2(@types/node@26.6.4) - '@inquirer/confirm': 5.1.21(@types/node@26.6.4) - '@inquirer/editor': 4.2.23(@types/node@26.6.4) - '@inquirer/expand': 4.0.23(@types/node@26.6.4) - '@inquirer/input': 4.3.1(@types/node@26.6.4) - '@inquirer/number': 3.0.23(@types/node@26.6.4) - '@inquirer/password': 4.0.23(@types/node@26.6.4) - '@inquirer/rawlist': 4.1.11(@types/node@26.6.4) - '@inquirer/search': 3.2.2(@types/node@26.6.4) - '@inquirer/select': 4.4.2(@types/node@26.6.4) - optionalDependencies: - '@types/node': 26.6.4 - - '@inquirer/rawlist@4.1.11(@types/node@26.6.4)': - dependencies: - '@inquirer/core': 10.3.2(@types/node@26.6.4) - '@inquirer/type': 3.0.10(@types/node@26.6.4) - yoctocolors-cjs: 2.1.3 - optionalDependencies: - '@types/node': 26.6.4 - - '@inquirer/search@3.2.2(@types/node@26.6.4)': - dependencies: - '@inquirer/core': 10.3.2(@types/node@26.6.4) - '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@26.6.4) - yoctocolors-cjs: 2.1.3 - optionalDependencies: - '@types/node': 26.6.4 - - '@inquirer/select@4.4.2(@types/node@26.6.4)': - dependencies: - '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@26.6.4) - '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@26.6.4) - yoctocolors-cjs: 2.1.3 - optionalDependencies: - '@types/node': 26.6.4 - - '@inquirer/type@3.0.10(@types/node@26.6.4)': - optionalDependencies: - '@types/node': 26.6.4 - '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -2983,67 +2554,16 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 - '@istanbuljs/schema@0.1.6': {} + '@istanbuljs/schema@0.1.3': {} '@jridgewell/resolve-uri@3.1.2': {} - '@jridgewell/sourcemap-codec@1.6.0': {} + '@jridgewell/sourcemap-codec@1.5.5': {} '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.6.0 - - '@napi-rs/keyring-darwin-arm64@1.3.0': - optional: true - - '@napi-rs/keyring-darwin-x64@1.3.0': - optional: true - - '@napi-rs/keyring-freebsd-x64@1.3.0': - optional: true - - '@napi-rs/keyring-linux-arm-gnueabihf@1.3.0': - optional: true - - '@napi-rs/keyring-linux-arm64-gnu@1.3.0': - optional: true - - '@napi-rs/keyring-linux-arm64-musl@1.3.0': - optional: true - - '@napi-rs/keyring-linux-riscv64-gnu@1.3.0': - optional: true - - '@napi-rs/keyring-linux-x64-gnu@1.3.0': - optional: true - - '@napi-rs/keyring-linux-x64-musl@1.3.0': - optional: true - - '@napi-rs/keyring-win32-arm64-msvc@1.3.0': - optional: true - - '@napi-rs/keyring-win32-ia32-msvc@1.3.0': - optional: true - - '@napi-rs/keyring-win32-x64-msvc@1.3.0': - optional: true - - '@napi-rs/keyring@1.3.0': - optionalDependencies: - '@napi-rs/keyring-darwin-arm64': 1.3.0 - '@napi-rs/keyring-darwin-x64': 1.3.0 - '@napi-rs/keyring-freebsd-x64': 1.3.0 - '@napi-rs/keyring-linux-arm-gnueabihf': 1.3.0 - '@napi-rs/keyring-linux-arm64-gnu': 1.3.0 - '@napi-rs/keyring-linux-arm64-musl': 1.3.0 - '@napi-rs/keyring-linux-riscv64-gnu': 1.3.0 - '@napi-rs/keyring-linux-x64-gnu': 1.3.0 - '@napi-rs/keyring-linux-x64-musl': 1.3.0 - '@napi-rs/keyring-win32-arm64-msvc': 1.3.0 - '@napi-rs/keyring-win32-ia32-msvc': 1.3.0 - '@napi-rs/keyring-win32-x64-msvc': 1.3.0 + '@jridgewell/sourcemap-codec': 1.5.5 '@node-rs/crc32-android-arm-eabi@1.10.8': optional: true @@ -3110,7 +2630,7 @@ snapshots: '@nodelib/fs.walk@1.2.8': dependencies: '@nodelib/fs.scandir': 2.1.5 - fastq: 1.20.3 + fastq: 1.20.1 '@oxlint-tsgolint/darwin-arm64@7.0.2003': optional: true @@ -3130,61 +2650,61 @@ snapshots: '@oxlint-tsgolint/win32-x64@7.0.2003': optional: true - '@oxlint/binding-android-arm-eabi@1.87.0': + '@oxlint/binding-android-arm-eabi@1.86.0': optional: true - '@oxlint/binding-android-arm64@1.87.0': + '@oxlint/binding-android-arm64@1.86.0': optional: true - '@oxlint/binding-darwin-arm64@1.87.0': + '@oxlint/binding-darwin-arm64@1.86.0': optional: true - '@oxlint/binding-darwin-x64@1.87.0': + '@oxlint/binding-darwin-x64@1.86.0': optional: true - '@oxlint/binding-freebsd-x64@1.87.0': + '@oxlint/binding-freebsd-x64@1.86.0': optional: true - '@oxlint/binding-linux-arm-gnueabihf@1.87.0': + '@oxlint/binding-linux-arm-gnueabihf@1.86.0': optional: true - '@oxlint/binding-linux-arm-musleabihf@1.87.0': + '@oxlint/binding-linux-arm-musleabihf@1.86.0': optional: true - '@oxlint/binding-linux-arm64-gnu@1.87.0': + '@oxlint/binding-linux-arm64-gnu@1.86.0': optional: true - '@oxlint/binding-linux-arm64-musl@1.87.0': + '@oxlint/binding-linux-arm64-musl@1.86.0': optional: true - '@oxlint/binding-linux-ppc64-gnu@1.87.0': + '@oxlint/binding-linux-ppc64-gnu@1.86.0': optional: true - '@oxlint/binding-linux-riscv64-gnu@1.87.0': + '@oxlint/binding-linux-riscv64-gnu@1.86.0': optional: true - '@oxlint/binding-linux-riscv64-musl@1.87.0': + '@oxlint/binding-linux-riscv64-musl@1.86.0': optional: true - '@oxlint/binding-linux-s390x-gnu@1.87.0': + '@oxlint/binding-linux-s390x-gnu@1.86.0': optional: true - '@oxlint/binding-linux-x64-gnu@1.87.0': + '@oxlint/binding-linux-x64-gnu@1.86.0': optional: true - '@oxlint/binding-linux-x64-musl@1.87.0': + '@oxlint/binding-linux-x64-musl@1.86.0': optional: true - '@oxlint/binding-openharmony-arm64@1.87.0': + '@oxlint/binding-openharmony-arm64@1.86.0': optional: true - '@oxlint/binding-win32-arm64-msvc@1.87.0': + '@oxlint/binding-win32-arm64-msvc@1.86.0': optional: true - '@oxlint/binding-win32-ia32-msvc@1.87.0': + '@oxlint/binding-win32-ia32-msvc@1.86.0': optional: true - '@oxlint/binding-win32-x64-msvc@1.87.0': + '@oxlint/binding-win32-x64-msvc@1.86.0': optional: true '@pkgjs/parseargs@0.11.0': @@ -3201,7 +2721,7 @@ snapshots: '@secretlint/profiler': 10.2.2 '@secretlint/resolver': 10.2.2 '@secretlint/types': 10.2.2 - ajv: 8.20.0 + ajv: 8.18.0 debug: 4.4.3(supports-color@8.1.1) rc-config-loader: 4.1.4(supports-color@8.1.1) transitivePeerDependencies: @@ -3274,15 +2794,21 @@ snapshots: dependencies: type-detect: 4.0.8 - '@sinonjs/fake-timers@15.4.0': + '@sinonjs/fake-timers@10.3.0': dependencies: '@sinonjs/commons': 3.0.1 - '@sinonjs/samsam@10.0.2': + '@sinonjs/fake-timers@11.3.1': + dependencies: + '@sinonjs/commons': 3.0.1 + + '@sinonjs/samsam@8.0.3': dependencies: '@sinonjs/commons': 3.0.1 type-detect: 4.1.0 + '@sinonjs/text-encoding@0.7.3': {} + '@textlint/ast-node-types@15.8.0': {} '@textlint/linter-formatter@15.8.0(supports-color@8.1.1)': @@ -3315,23 +2841,27 @@ snapshots: '@types/istanbul-lib-coverage@2.0.6': {} + '@types/js-yaml@4.0.9': {} + '@types/mocha@10.0.10': {} - '@types/node@26.6.4': + '@types/mocha@8.2.3': {} + + '@types/node@22.20.5': dependencies: - undici-types: 8.9.0 + undici-types: 6.21.0 '@types/normalize-package-data@2.4.4': {} '@types/sarif@2.1.7': {} - '@types/sinon@22.0.0': + '@types/sinon@10.0.20': dependencies: '@types/sinonjs__fake-timers': 15.0.1 '@types/sinonjs__fake-timers@15.0.1': {} - '@types/vscode@1.98.0': {} + '@types/vscode@1.94.0': {} '@types/winreg@1.2.36': {} @@ -3405,17 +2935,17 @@ snapshots: '@vscode/codicons@0.0.46-24': {} - '@vscode/test-cli@0.0.15': + '@vscode/test-cli@0.0.12': dependencies: '@types/mocha': 10.0.10 - c8: 11.0.0 - chokidar: 5.0.0 - enhanced-resolve: 5.26.0 - glob: 13.0.6 - minimatch: 10.2.6 - mocha: 11.8.0 + c8: 10.1.3 + chokidar: 3.6.0 + enhanced-resolve: 5.20.0 + glob: 10.5.0 + minimatch: 9.0.9 + mocha: 11.7.5 supports-color: 10.2.2 - yargs: 18.2.0 + yargs: 17.7.2 transitivePeerDependencies: - monocart-coverage-reports @@ -3423,9 +2953,9 @@ snapshots: dependencies: http-proxy-agent: 7.0.2(supports-color@8.1.1) https-proxy-agent: 7.0.6(supports-color@8.1.1) - jszip: 3.10.2 + jszip: 3.10.1 ora: 8.2.0 - semver: 7.8.5 + semver: 7.7.4 transitivePeerDependencies: - supports-color @@ -3478,7 +3008,7 @@ snapshots: '@vscode/vsce-sign': 2.1.0 azure-devops-node-api: 12.5.0 chalk: 4.1.2 - cheerio: 1.2.0 + cheerio: 1.0.0-rc.12 cockatiel: 3.2.1 commander: 12.1.0 form-data: 4.0.6 @@ -3492,7 +3022,7 @@ snapshots: parse-semver: 1.1.1 read: 1.0.7 secretlint: 10.2.2(supports-color@8.1.1) - semver: 7.8.5 + semver: 7.7.4 tmp: 0.2.7 typed-rest-client: 1.8.11 url-join: 4.0.1 @@ -3504,37 +3034,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@vscode/vsce@4.0.0(supports-color@8.1.1)': - dependencies: - '@azure/identity': 4.13.3(supports-color@8.1.1) - '@napi-rs/keyring': 1.3.0 - '@secretlint/core': 10.2.2(supports-color@8.1.1) - '@secretlint/secretlint-rule-no-dotenv': 10.2.2 - '@secretlint/secretlint-rule-preset-recommend': 10.2.2 - '@secretlint/source-creator': 10.2.2 - '@secretlint/types': 10.2.2 - '@vscode/vsce-sign': 2.1.0 - azure-devops-node-api: 12.5.0 - cockatiel: 3.2.1 - commander: 12.1.0 - hosted-git-info: 4.1.0 - jsonc-parser: 3.3.1 - marked: 18.1.0 - mime: 1.6.0 - minimatch: 10.2.6 - parse5: 8.0.1 - proper-lockfile: 4.1.2 - read: 1.0.7 - semver: 7.8.5 - tinyglobby: 0.2.17 - typed-rest-client: 1.8.11 - url-join: 4.0.1 - xml2js: 0.5.0 - yauzl: 3.4.0 - yazl: 2.5.1 - transitivePeerDependencies: - - supports-color - ag-charts-types@14.2.0: {} ag-grid-community@36.2.0: @@ -3546,7 +3045,7 @@ snapshots: agent-base@7.1.4: {} - ajv@8.20.0: + ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 fast-uri: 3.1.8 @@ -3559,7 +3058,7 @@ snapshots: ansi-regex@5.0.1: {} - ansi-regex@6.4.0: {} + ansi-regex@6.2.2: {} ansi-styles@4.3.0: dependencies: @@ -3567,10 +3066,17 @@ snapshots: ansi-styles@6.2.3: {} + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + argparse@2.0.1: {} astral-regex@2.0.0: {} + async@3.2.6: {} + asynckit@0.4.0: {} azure-devops-node-api@12.5.0: @@ -3585,6 +3091,8 @@ snapshots: base64-js@1.5.1: optional: true + binary-extensions@2.3.0: {} + binaryextensions@6.11.0: dependencies: editions: 6.22.0 @@ -3628,18 +3136,18 @@ snapshots: dependencies: run-applescript: 7.1.0 - c8@11.0.0: + c8@10.1.3: dependencies: '@bcoe/v8-coverage': 1.0.2 - '@istanbuljs/schema': 0.1.6 + '@istanbuljs/schema': 0.1.3 find-up: 5.0.0 foreground-child: 3.3.1 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 istanbul-reports: 3.2.0 - test-exclude: 8.0.0 + test-exclude: 7.0.2 v8-to-istanbul: 9.3.0 - yargs: 17.7.3 + yargs: 17.7.2 yargs-parser: 21.1.1 call-bind-apply-helpers@1.0.2: @@ -3661,8 +3169,6 @@ snapshots: chalk@5.6.2: {} - chardet@2.2.0: {} - cheerio-select@2.1.0: dependencies: boolbase: 1.0.0 @@ -3672,27 +3178,31 @@ snapshots: domhandler: 5.0.3 domutils: 3.2.2 - cheerio@1.2.0: + cheerio@1.0.0-rc.12: dependencies: cheerio-select: 2.1.0 dom-serializer: 2.0.0 domhandler: 5.0.3 domutils: 3.2.2 - encoding-sniffer: 0.2.1 - htmlparser2: 10.1.0 + htmlparser2: 8.0.2 parse5: 7.3.0 parse5-htmlparser2-tree-adapter: 7.1.0 - parse5-parser-stream: 7.1.2 - undici: 7.30.0 - whatwg-mimetype: 4.0.0 - chokidar@4.0.3: + chokidar@3.6.0: dependencies: - readdirp: 4.1.2 + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 - chokidar@5.0.0: + chokidar@4.0.3: dependencies: - readdirp: 5.1.1 + readdirp: 4.1.2 chownr@1.1.4: optional: true @@ -3705,20 +3215,12 @@ snapshots: cli-spinners@2.9.2: {} - cli-width@4.1.0: {} - cliui@8.0.1: dependencies: string-width: 4.2.3 strip-ansi: 6.0.1 wrap-ansi: 7.0.0 - cliui@9.0.1: - dependencies: - string-width: 7.2.0 - strip-ansi: 7.2.0 - wrap-ansi: 9.0.2 - cockatiel@3.2.1: {} color-convert@2.0.1: @@ -3739,15 +3241,6 @@ snapshots: core-util-is@1.0.3: {} - cross-keychain@1.1.0(@types/node@26.6.4): - dependencies: - '@inquirer/prompts': 7.10.1(@types/node@26.6.4) - meow: 14.1.0 - optionalDependencies: - '@napi-rs/keyring': 1.3.0 - transitivePeerDependencies: - - '@types/node' - cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -3806,9 +3299,9 @@ snapshots: detect-libc@2.1.2: optional: true - diff@7.0.0: {} + diff@5.2.2: {} - diff@9.0.0: {} + diff@7.0.0: {} dom-serializer@2.0.0: dependencies: @@ -3838,13 +3331,15 @@ snapshots: ecdsa-sig-formatter@1.0.11: dependencies: - safe-buffer: 5.2.1 + safe-buffer: 5.1.2 editions@6.22.0: dependencies: version-range: 4.15.0 - ejs@7.0.1: {} + ejs@3.1.10: + dependencies: + jake: 10.9.4 emoji-regex@10.6.0: {} @@ -3852,36 +3347,27 @@ snapshots: emoji-regex@9.2.2: {} - encoding-sniffer@0.2.1: - dependencies: - iconv-lite: 0.6.3 - whatwg-encoding: 3.1.1 - end-of-stream@1.4.5: dependencies: once: 1.4.0 optional: true - enhanced-resolve@5.26.0: + enhanced-resolve@5.20.0: dependencies: graceful-fs: 4.2.11 - tapable: 2.3.3 + tapable: 2.3.0 entities@4.5.0: {} entities@6.0.1: {} - entities@7.0.1: {} - - entities@8.1.0: {} - environment@1.1.0: {} es-define-property@1.0.1: {} es-errors@1.3.0: {} - es-object-atoms@1.1.2: + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -3890,7 +3376,7 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 has-tostringtag: 1.0.2 - hasown: 2.0.4 + hasown: 2.0.2 esbuild@0.28.2: optionalDependencies: @@ -3935,10 +3421,10 @@ snapshots: is-plain-obj: 4.1.0 is-stream: 4.0.1 npm-run-path: 6.0.0 - pretty-ms: 9.3.1 + pretty-ms: 9.3.0 signal-exit: 4.1.0 strip-final-newline: 4.0.0 - yoctocolors: 2.2.0 + yoctocolors: 2.1.2 expand-template@2.0.3: optional: true @@ -3955,24 +3441,22 @@ snapshots: fast-uri@3.1.8: {} - fastq@1.20.3: + fastq@1.20.1: dependencies: reusify: 1.1.0 - fdir@6.5.0(picomatch@4.0.7): - optionalDependencies: - picomatch: 4.0.7 - figures@6.1.0: dependencies: is-unicode-supported: 2.1.0 + filelist@1.0.6: + dependencies: + minimatch: 5.1.9 + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 - find-up-simple@1.0.1: {} - find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -3980,7 +3464,7 @@ snapshots: flat@5.0.2: {} - follow-redirects@1.16.1(debug@4.4.3(supports-color@8.1.1)): + follow-redirects@1.16.0(debug@4.4.3(supports-color@8.1.1)): optionalDependencies: debug: 4.4.3(supports-color@8.1.1) @@ -4003,66 +3487,69 @@ snapshots: fs-extra@11.4.1: dependencies: graceful-fs: 4.2.11 - jsonfile: 6.2.1 + jsonfile: 6.2.0 universalify: 2.0.1 + fsevents@2.3.3: + optional: true + function-bind@1.1.2: {} get-caller-file@2.0.5: {} - get-east-asian-width@1.7.0: {} + get-east-asian-width@1.5.0: {} get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 es-define-property: 1.0.1 es-errors: 1.3.0 - es-object-atoms: 1.1.2 + es-object-atoms: 1.1.1 function-bind: 1.1.2 get-proto: 1.0.1 gopd: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.4 + hasown: 2.0.2 math-intrinsics: 1.1.0 get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 - es-object-atoms: 1.1.2 + es-object-atoms: 1.1.1 get-stream@9.0.1: dependencies: '@sec-ant/readable-stream': 0.4.1 is-stream: 4.0.1 - git-cliff-darwin-arm64@2.14.2: + git-cliff-darwin-arm64@2.12.0: optional: true - git-cliff-darwin-x64@2.14.2: + git-cliff-darwin-x64@2.12.0: optional: true - git-cliff-linux-arm64@2.14.2: + git-cliff-linux-arm64@2.12.0: optional: true - git-cliff-linux-x64@2.14.2: + git-cliff-linux-x64@2.12.0: optional: true - git-cliff-windows-arm64@2.14.2: + git-cliff-windows-arm64@2.12.0: optional: true - git-cliff-windows-x64@2.14.2: + git-cliff-windows-x64@2.12.0: optional: true - git-cliff@2.14.2: + git-cliff@2.12.0: dependencies: execa: 9.6.1 optionalDependencies: - git-cliff-darwin-arm64: 2.14.2 - git-cliff-darwin-x64: 2.14.2 - git-cliff-linux-arm64: 2.14.2 - git-cliff-linux-x64: 2.14.2 - git-cliff-windows-arm64: 2.14.2 - git-cliff-windows-x64: 2.14.2 + git-cliff-darwin-arm64: 2.12.0 + git-cliff-darwin-x64: 2.12.0 + git-cliff-linux-arm64: 2.12.0 + git-cliff-linux-x64: 2.12.0 + git-cliff-windows-arm64: 2.12.0 + git-cliff-windows-x64: 2.12.0 github-from-package@0.0.0: optional: true @@ -4095,7 +3582,7 @@ snapshots: dependencies: '@sindresorhus/merge-streams': 2.3.0 fast-glob: 3.3.3 - ignore: 7.0.12 + ignore: 7.0.10 path-type: 6.0.0 slash: 5.1.0 unicorn-magic: 0.3.0 @@ -4116,13 +3603,17 @@ snapshots: dependencies: has-symbols: 1.1.0 + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + hasown@2.0.4: dependencies: function-bind: 1.1.2 he@1.2.0: {} - highlight.js@11.12.0: {} + highlight.js@11.11.1: {} hosted-git-info@4.1.0: dependencies: @@ -4134,12 +3625,12 @@ snapshots: html-escaper@2.0.2: {} - htmlparser2@10.1.0: + htmlparser2@8.0.2: dependencies: domelementtype: 2.3.0 domhandler: 5.0.3 domutils: 3.2.2 - entities: 7.0.1 + entities: 4.5.0 http-proxy-agent@7.0.2(supports-color@8.1.1): dependencies: @@ -4157,18 +3648,10 @@ snapshots: human-signals@8.0.1: {} - iconv-lite@0.6.3: - dependencies: - safer-buffer: 2.1.2 - - iconv-lite@0.7.3: - dependencies: - safer-buffer: 2.1.2 - ieee754@1.2.1: optional: true - ignore@7.0.12: {} + ignore@7.0.10: {} immediate@3.0.6: {} @@ -4179,6 +3662,10 @@ snapshots: ini@1.3.8: optional: true + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + is-ci@2.0.0: dependencies: ci-info: 2.0.0 @@ -4207,8 +3694,6 @@ snapshots: is-path-inside@3.0.3: {} - is-path-inside@4.0.0: {} - is-plain-obj@2.1.0: {} is-plain-obj@4.1.0: {} @@ -4254,23 +3739,25 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 + jake@10.9.4: + dependencies: + async: 3.2.6 + filelist: 1.0.6 + picocolors: 1.1.1 + js-tokens@4.0.0: {} js-yaml@4.3.2: dependencies: argparse: 2.0.1 - js-yaml@5.4.3: - dependencies: - argparse: 2.0.1 - json-schema-traverse@1.0.0: {} json5@2.2.3: {} jsonc-parser@3.3.1: {} - jsonfile@6.2.1: + jsonfile@6.2.0: dependencies: universalify: 2.0.1 optionalDependencies: @@ -4287,25 +3774,27 @@ snapshots: lodash.isstring: 4.0.1 lodash.once: 4.1.1 ms: 2.1.3 - semver: 7.8.5 + semver: 7.7.4 - jszip@3.10.2: + jszip@3.10.1: dependencies: lie: 3.3.0 pako: 1.0.11 readable-stream: 2.3.8 setimmediate: 1.0.5 + just-extend@6.2.0: {} + jwa@2.0.1: dependencies: buffer-equal-constant-time: 1.0.1 ecdsa-sig-formatter: 1.0.11 - safe-buffer: 5.2.1 + safe-buffer: 5.1.2 jws@4.0.1: dependencies: jwa: 2.0.1 - safe-buffer: 5.2.1 + safe-buffer: 5.1.2 keytar@7.9.0: dependencies: @@ -4365,7 +3854,7 @@ snapshots: make-dir@4.0.0: dependencies: - semver: 7.8.5 + semver: 7.7.4 markdown-it@14.3.2: dependencies: @@ -4376,14 +3865,10 @@ snapshots: punycode.js: 2.3.1 uc.micro: 2.1.0 - marked@18.1.0: {} - math-intrinsics@1.1.0: {} mdurl@2.1.0: {} - meow@14.1.0: {} - merge2@1.4.1: {} micromatch@4.0.8: @@ -4408,6 +3893,10 @@ snapshots: dependencies: brace-expansion: 5.0.12 + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.7 + minimatch@9.0.9: dependencies: brace-expansion: 2.1.7 @@ -4420,7 +3909,7 @@ snapshots: mkdirp-classic@0.5.3: optional: true - mocha@11.8.0: + mocha@11.7.5: dependencies: browser-stdout: 1.3.1 chokidar: 4.0.3 @@ -4440,41 +3929,28 @@ snapshots: strip-json-comments: 3.1.1 supports-color: 8.1.1 workerpool: 9.3.4 - yargs: 17.7.3 + yargs: 17.7.2 yargs-parser: 21.1.1 yargs-unparser: 2.0.0 - mocha@12.0.3: - dependencies: - browser-stdout: 1.3.1 - chokidar: 5.0.0 - debug: 4.4.3(supports-color@8.1.1) - diff: 9.0.0 - find-up-simple: 1.0.1 - glob: 13.0.6 - is-path-inside: 4.0.0 - is-unicode-supported: 0.1.0 - js-yaml: 5.4.3 - minimatch: 10.2.6 - ms: 2.1.3 - picocolors: 1.1.1 - serialize-javascript: 7.1.2 - strip-json-comments: 5.0.3 - supports-color: 8.1.1 - workerpool: 10.0.3 - ms@2.1.3: {} mute-stream@0.0.8: {} - mute-stream@2.0.0: {} - napi-build-utils@2.0.0: optional: true - node-abi@3.98.0: + nise@5.1.9: dependencies: - semver: 7.8.5 + '@sinonjs/commons': 3.0.1 + '@sinonjs/fake-timers': 11.3.1 + '@sinonjs/text-encoding': 0.7.3 + just-extend: 6.2.0 + path-to-regexp: 6.3.0 + + node-abi@3.96.0: + dependencies: + semver: 7.7.4 optional: true node-addon-api@4.3.0: @@ -4488,9 +3964,11 @@ snapshots: normalize-package-data@6.0.2: dependencies: hosted-git-info: 7.0.2 - semver: 7.8.5 + semver: 7.7.4 validate-npm-package-license: 3.0.4 + normalize-path@3.0.0: {} + npm-run-path@6.0.0: dependencies: path-key: 4.0.0 @@ -4532,20 +4010,17 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.2.0 - ovsx@1.2.0(@types/node@26.6.4)(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1): + ovsx@0.10.12(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1): dependencies: - '@inquirer/prompts': 7.10.1(@types/node@26.6.4) '@vscode/vsce': 3.9.2(supports-color@8.1.1) commander: 6.2.1 - cross-keychain: 1.1.0(@types/node@26.6.4) - follow-redirects: 1.16.1(debug@4.4.3(supports-color@8.1.1)) + follow-redirects: 1.16.0(debug@4.4.3(supports-color@8.1.1)) is-ci: 2.0.0 leven: 3.1.0 - semver: 7.8.5 + semver: 7.7.4 tmp: 0.2.7 yauzl-promise: 4.0.0 transitivePeerDependencies: - - '@types/node' - debug - supports-color @@ -4558,27 +4033,27 @@ snapshots: '@oxlint-tsgolint/win32-arm64': 7.0.2003 '@oxlint-tsgolint/win32-x64': 7.0.2003 - oxlint@1.87.0(oxlint-tsgolint@7.0.2003): + oxlint@1.86.0(oxlint-tsgolint@7.0.2003): optionalDependencies: - '@oxlint/binding-android-arm-eabi': 1.87.0 - '@oxlint/binding-android-arm64': 1.87.0 - '@oxlint/binding-darwin-arm64': 1.87.0 - '@oxlint/binding-darwin-x64': 1.87.0 - '@oxlint/binding-freebsd-x64': 1.87.0 - '@oxlint/binding-linux-arm-gnueabihf': 1.87.0 - '@oxlint/binding-linux-arm-musleabihf': 1.87.0 - '@oxlint/binding-linux-arm64-gnu': 1.87.0 - '@oxlint/binding-linux-arm64-musl': 1.87.0 - '@oxlint/binding-linux-ppc64-gnu': 1.87.0 - '@oxlint/binding-linux-riscv64-gnu': 1.87.0 - '@oxlint/binding-linux-riscv64-musl': 1.87.0 - '@oxlint/binding-linux-s390x-gnu': 1.87.0 - '@oxlint/binding-linux-x64-gnu': 1.87.0 - '@oxlint/binding-linux-x64-musl': 1.87.0 - '@oxlint/binding-openharmony-arm64': 1.87.0 - '@oxlint/binding-win32-arm64-msvc': 1.87.0 - '@oxlint/binding-win32-ia32-msvc': 1.87.0 - '@oxlint/binding-win32-x64-msvc': 1.87.0 + '@oxlint/binding-android-arm-eabi': 1.86.0 + '@oxlint/binding-android-arm64': 1.86.0 + '@oxlint/binding-darwin-arm64': 1.86.0 + '@oxlint/binding-darwin-x64': 1.86.0 + '@oxlint/binding-freebsd-x64': 1.86.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.86.0 + '@oxlint/binding-linux-arm-musleabihf': 1.86.0 + '@oxlint/binding-linux-arm64-gnu': 1.86.0 + '@oxlint/binding-linux-arm64-musl': 1.86.0 + '@oxlint/binding-linux-ppc64-gnu': 1.86.0 + '@oxlint/binding-linux-riscv64-gnu': 1.86.0 + '@oxlint/binding-linux-riscv64-musl': 1.86.0 + '@oxlint/binding-linux-s390x-gnu': 1.86.0 + '@oxlint/binding-linux-x64-gnu': 1.86.0 + '@oxlint/binding-linux-x64-musl': 1.86.0 + '@oxlint/binding-openharmony-arm64': 1.86.0 + '@oxlint/binding-win32-arm64-msvc': 1.86.0 + '@oxlint/binding-win32-ia32-msvc': 1.86.0 + '@oxlint/binding-win32-x64-msvc': 1.86.0 oxlint-tsgolint: 7.0.2003 p-limit@3.1.0: @@ -4612,18 +4087,10 @@ snapshots: domhandler: 5.0.3 parse5: 7.3.0 - parse5-parser-stream@7.1.2: - dependencies: - parse5: 7.3.0 - parse5@7.3.0: dependencies: entities: 6.0.1 - parse5@8.0.1: - dependencies: - entities: 8.1.0 - path-exists@4.0.0: {} path-key@3.1.1: {} @@ -4640,6 +4107,8 @@ snapshots: lru-cache: 11.5.3 minipass: 7.1.3 + path-to-regexp@6.3.0: {} + path-type@6.0.0: {} pend@1.2.0: {} @@ -4648,8 +4117,6 @@ snapshots: picomatch@2.3.2: {} - picomatch@4.0.7: {} - pluralize@2.0.0: {} pluralize@8.0.0: {} @@ -4662,7 +4129,7 @@ snapshots: minimist: 1.2.8 mkdirp-classic: 0.5.3 napi-build-utils: 2.0.0 - node-abi: 3.98.0 + node-abi: 3.96.0 pump: 3.0.4 rc: 1.2.8 simple-get: 4.0.1 @@ -4670,18 +4137,12 @@ snapshots: tunnel-agent: 0.6.0 optional: true - pretty-ms@9.3.1: + pretty-ms@9.3.0: dependencies: parse-ms: 4.0.0 process-nextick-args@2.0.1: {} - proper-lockfile@4.1.2: - dependencies: - graceful-fs: 4.2.11 - retry: 0.12.0 - signal-exit: 3.0.7 - pump@3.0.4: dependencies: end-of-stream: 1.4.5 @@ -4739,13 +4200,15 @@ snapshots: readable-stream@3.6.2: dependencies: inherits: 2.0.4 - string_decoder: 1.3.0 + string_decoder: 1.1.1 util-deprecate: 1.0.2 optional: true - readdirp@4.1.2: {} + readdirp@3.6.0: + dependencies: + picomatch: 2.3.2 - readdirp@5.1.1: {} + readdirp@4.1.2: {} require-directory@2.1.1: {} @@ -4756,8 +4219,6 @@ snapshots: onetime: 7.0.0 signal-exit: 4.1.0 - retry@0.12.0: {} - reusify@1.1.0: {} run-applescript@7.1.0: {} @@ -4768,10 +4229,6 @@ snapshots: safe-buffer@5.1.2: {} - safe-buffer@5.2.1: {} - - safer-buffer@2.1.2: {} - sax@1.6.1: {} secretlint@10.2.2(supports-color@8.1.1): @@ -4788,6 +4245,8 @@ snapshots: semver@5.7.2: {} + semver@7.7.4: {} + semver@7.8.5: {} serialize-javascript@7.1.2: {} @@ -4828,8 +4287,6 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 - signal-exit@3.0.7: {} - signal-exit@4.1.0: {} simple-concat@1.0.1: @@ -4844,12 +4301,14 @@ snapshots: simple-invariant@2.0.1: {} - sinon@22.1.0: + sinon@15.2.0: dependencies: '@sinonjs/commons': 3.0.1 - '@sinonjs/fake-timers': 15.4.0 - '@sinonjs/samsam': 10.0.2 - diff: 9.0.0 + '@sinonjs/fake-timers': 10.3.0 + '@sinonjs/samsam': 8.0.3 + diff: 5.2.2 + nise: 5.1.9 + supports-color: 7.2.0 slash@5.1.0: {} @@ -4862,16 +4321,16 @@ snapshots: spdx-correct@3.2.0: dependencies: spdx-expression-parse: 3.0.1 - spdx-license-ids: 3.0.24 + spdx-license-ids: 3.0.23 spdx-exceptions@2.5.0: {} spdx-expression-parse@3.0.1: dependencies: spdx-exceptions: 2.5.0 - spdx-license-ids: 3.0.24 + spdx-license-ids: 3.0.23 - spdx-license-ids@3.0.24: {} + spdx-license-ids@3.0.23: {} stdin-discarder@0.2.2: {} @@ -4890,30 +4349,20 @@ snapshots: string-width@7.2.0: dependencies: emoji-regex: 10.6.0 - get-east-asian-width: 1.7.0 - strip-ansi: 7.2.0 - - string-width@8.3.0: - dependencies: - get-east-asian-width: 1.7.0 + get-east-asian-width: 1.5.0 strip-ansi: 7.2.0 string_decoder@1.1.1: dependencies: safe-buffer: 5.1.2 - string_decoder@1.3.0: - dependencies: - safe-buffer: 5.2.1 - optional: true - strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 strip-ansi@7.2.0: dependencies: - ansi-regex: 6.4.0 + ansi-regex: 6.2.2 strip-final-newline@4.0.0: {} @@ -4922,8 +4371,6 @@ snapshots: strip-json-comments@3.1.1: {} - strip-json-comments@5.0.3: {} - structured-source@4.0.0: dependencies: boundary: 2.0.0 @@ -4945,13 +4392,13 @@ snapshots: table@6.9.0: dependencies: - ajv: 8.20.0 + ajv: 8.18.0 lodash.truncate: 4.4.2 slice-ansi: 4.0.0 string-width: 4.2.3 strip-ansi: 6.0.1 - tapable@2.3.3: {} + tapable@2.3.0: {} tar-fs@2.1.5: dependencies: @@ -4975,10 +4422,10 @@ snapshots: ansi-escapes: 7.3.0 supports-hyperlinks: 3.2.0 - test-exclude@8.0.0: + test-exclude@7.0.2: dependencies: - '@istanbuljs/schema': 0.1.6 - glob: 13.0.6 + '@istanbuljs/schema': 0.1.3 + glob: 10.5.0 minimatch: 10.2.6 text-table@0.2.0: {} @@ -4987,11 +4434,6 @@ snapshots: dependencies: editions: 6.22.0 - tinyglobby@0.2.17: - dependencies: - fdir: 6.5.0(picomatch@4.0.7) - picomatch: 4.0.7 - tmp@0.2.7: {} to-regex-range@5.0.1: @@ -5002,7 +4444,7 @@ snapshots: tunnel-agent@0.6.0: dependencies: - safe-buffer: 5.2.1 + safe-buffer: 5.1.2 optional: true tunnel@0.0.6: {} @@ -5046,9 +4488,7 @@ snapshots: underscore@1.13.8: {} - undici-types@8.9.0: {} - - undici@7.30.0: {} + undici-types@6.21.0: {} unicorn-magic@0.1.0: {} @@ -5091,28 +4531,14 @@ snapshots: vscode-languageserver-types@3.18.4: {} - whatwg-encoding@3.1.1: - dependencies: - iconv-lite: 0.6.3 - - whatwg-mimetype@4.0.0: {} - which@2.0.2: dependencies: isexe: 2.0.0 winreg@1.2.5: {} - workerpool@10.0.3: {} - workerpool@9.3.4: {} - wrap-ansi@6.2.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -5125,12 +4551,6 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.2.0 - wrap-ansi@9.0.2: - dependencies: - ansi-styles: 6.2.3 - string-width: 7.2.0 - strip-ansi: 7.2.0 - wrappy@1.0.2: optional: true @@ -5151,8 +4571,6 @@ snapshots: yargs-parser@21.1.1: {} - yargs-parser@22.0.0: {} - yargs-unparser@2.0.0: dependencies: camelcase: 6.3.0 @@ -5160,7 +4578,7 @@ snapshots: flat: 5.0.2 is-plain-obj: 2.1.0 - yargs@17.7.3: + yargs@17.7.2: dependencies: cliui: 8.0.1 escalade: 3.2.0 @@ -5170,15 +4588,6 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 - yargs@18.2.0: - dependencies: - cliui: 9.0.1 - escalade: 3.2.0 - get-caller-file: 2.0.5 - string-width: 8.3.0 - y18n: 5.0.8 - yargs-parser: 22.0.0 - yauzl-promise@4.0.0: dependencies: '@node-rs/crc32': 1.10.8 @@ -5195,6 +4604,4 @@ snapshots: yocto-queue@0.1.0: {} - yoctocolors-cjs@2.1.3: {} - - yoctocolors@2.2.0: {} + yoctocolors@2.1.2: {} diff --git a/src/helpViewer/helpPreviewer.ts b/src/helpViewer/helpPreviewer.ts index 2eb7a8a0a..dad6cd4b2 100644 --- a/src/helpViewer/helpPreviewer.ts +++ b/src/helpViewer/helpPreviewer.ts @@ -3,7 +3,7 @@ import * as path from 'path'; import * as fs from 'fs'; import * as vscode from 'vscode'; import * as rHelp from './index'; -import ejs from 'ejs'; +import * as ejs from 'ejs'; import { isDirSafe, isFileSafe, readFileSyncSafe, config, spawnAsync } from '../util'; import { Topic, TopicType } from './packages'; diff --git a/src/plotViewer/httpgdViewer.ts b/src/plotViewer/httpgdViewer.ts index df74f1660..ff071d86e 100644 --- a/src/plotViewer/httpgdViewer.ts +++ b/src/plotViewer/httpgdViewer.ts @@ -4,7 +4,7 @@ import { HttpgdClient, HttpgdIdResponse, HttpgdPlotId, HttpgdRendererId } from ' import { HttpgdPlot, IHttpgdViewer, HttpgdViewerOptions } from './httpgdTypes'; import * as path from 'path'; import * as fs from 'fs'; -import ejs from 'ejs'; +import * as ejs from 'ejs'; import { asViewColumn, config, setContext, UriIcon, makeWebviewCommandUriString } from '../util'; import { extensionContext } from '../extension'; diff --git a/src/rTerminal.ts b/src/rTerminal.ts index 506d3d91a..4919a458e 100644 --- a/src/rTerminal.ts +++ b/src/rTerminal.ts @@ -66,14 +66,11 @@ const terminalSends = new WeakMap>(); let lastParamsRmdPath: string | undefined; let lastParamsRmdVersion: number | undefined; -const rExprType = yaml.defineScalarTag('!r', { - resolve: source => ({ __rExpr: source }), - identify: () => false, +const rExprType = new yaml.Type('!r', { + kind: 'scalar', + construct: (data: string) => ({ __rExpr: data }), }); -// Retain js-yaml 4's default tag support alongside R Markdown expressions. -const RMARKDOWN_SCHEMA = yaml.CORE_SCHEMA.withTags( - yaml.mergeTag, yaml.timestampTag, yaml.binaryTag, yaml.omapTag, yaml.pairsTag, yaml.setTag, rExprType, -); +const RMARKDOWN_SCHEMA = yaml.DEFAULT_SCHEMA.extend([rExprType]); function valueToR(val: unknown): string { if (val === null || val === undefined) { diff --git a/src/rmarkdown/draft.ts b/src/rmarkdown/draft.ts index 8b297fce9..e6f40d6a6 100644 --- a/src/rmarkdown/draft.ts +++ b/src/rmarkdown/draft.ts @@ -136,7 +136,7 @@ export async function newDraft(): Promise { const dir = path.join(parsedPath.dir, parsedPath.name); if (fs.existsSync(dir)) { if (await getConfirmation(`Folder already exists. Are you sure you want to replace the folder?`)) { - fs.rmSync(dir, { recursive: true }); + fs.rmdirSync(dir, { recursive: true }); } else { return; } @@ -157,6 +157,6 @@ export async function newDraft(): Promise { await workspace.openTextDocument({ language: 'rmd', content: text }) .then(document => window.showTextDocument(document)); } - fs.rmSync(tempDir, { recursive: true }); + fs.rmdirSync(tempDir, { recursive: true }); } } diff --git a/src/test/suite/dataViewerSessions.test.ts b/src/test/suite/dataViewerSessions.test.ts index 195adaf90..e136fee66 100644 --- a/src/test/suite/dataViewerSessions.test.ts +++ b/src/test/suite/dataViewerSessions.test.ts @@ -413,7 +413,7 @@ suite('Viewer session ownership', () => { }); test('viewers share polling and release it when the last viewer closes', async () => { - const clock = sandbox.useFakeTimers({ toFake: ['setInterval', 'clearInterval'], shouldClearNativeTimers: true }); + const clock = sandbox.useFakeTimers(); const kill = sandbox.stub(process, 'kill').returns(true); const request = sandbox.stub().resolves({}); const first = session.registerSessionTransport('shared-polling-first', os.hostname(), '/tmp', request); diff --git a/src/test/suite/rmdParams.test.ts b/src/test/suite/rmdParams.test.ts index 6531bc2e6..5a3792a14 100644 --- a/src/test/suite/rmdParams.test.ts +++ b/src/test/suite/rmdParams.test.ts @@ -63,26 +63,6 @@ suite('Rmd Params Test Suite', () => { assert.strictEqual(cmd, 'params <- list(a = 1+1)'); }); - test('getRmdParamsCommand preserves YAML merges, scalar types, and tagged expressions', () => { - const mockDoc = { - languageId: 'rmd', - getText: () => `--- -defaults: &defaults - count: 2 - expression: !r seq_len(3) -params: - <<: *defaults - enabled: false - label: yes - values: [1, null, true] ----`, - uri: vscode.Uri.file('/test_merged_params.Rmd'), - version: 1, - } as vscode.TextDocument; - assert.strictEqual(rTerminal.getRmdParamsCommand(mockDoc), - 'params <- list(count = 2, expression = seq_len(3), enabled = FALSE, label = "yes", values = c(1, NULL, TRUE))'); - }); - test('getRmdParamsCommand respects cache invalidation by version', () => { const mockDoc = { languageId: 'rmd', diff --git a/src/test/suite/session.test.ts b/src/test/suite/session.test.ts index c297953d6..0933cec94 100644 --- a/src/test/suite/session.test.ts +++ b/src/test/suite/session.test.ts @@ -221,7 +221,7 @@ suite('Session Communication', () => { // error immediately after listening, before initialization is published. const fail = process.platform !== 'win32' ? sandbox.stub(fsp, 'chmod').rejects(failure) - : sandbox.stub(net.Server.prototype, 'emit').callsFake(function (this: net.Server, event: string | symbol, ...args: unknown[]) { + : sandbox.stub(net.Server.prototype, 'emit').callsFake(function (this: net.Server, event: string, ...args: unknown[]) { const result = EventEmitter.prototype.emit.call(this, event, ...args); if (event === 'listening') { EventEmitter.prototype.emit.call(this, 'error', failure); From d6e71194e573bfb270afd4b040d03bfaba29d64a Mon Sep 17 00:00:00 2001 From: Fred Wu Date: Sat, 10 Oct 2026 15:02:38 +1100 Subject: [PATCH 20/26] Restrict HTML viewer resource access and move secondary actions to the overflow menu --- package.json | 6 +-- src/test/suite/htmlWidgetBrowser.test.ts | 63 ++++++++++++++++++++++++ src/test/suite/htmlWidgetViewer.test.ts | 46 +++++++++++++++-- src/webViewer/index.ts | 12 ++++- 4 files changed, 118 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index 3b9a892a9..471eff3d6 100644 --- a/package.json +++ b/package.json @@ -283,7 +283,7 @@ }, { "command": "r.htmlViewer.info", - "title": "Session information", + "title": "Toggle Session Information in Tab Title", "category": "R HTML Viewer", "icon": "$(info)", "enablement": "activeWebviewPanelId == 'r.htmlViewer' && r.htmlViewer.canShowInfo" @@ -1256,12 +1256,12 @@ { "command": "r.htmlViewer.remove", "when": "activeWebviewPanelId == 'r.htmlViewer'", - "group": "navigation@3" + "group": "htmlViewer@1" }, { "command": "r.htmlViewer.info", "when": "activeWebviewPanelId == 'r.htmlViewer'", - "group": "navigation@4" + "group": "htmlViewer@2" }, { "command": "r.rmarkdown.preview.openExternal", diff --git a/src/test/suite/htmlWidgetBrowser.test.ts b/src/test/suite/htmlWidgetBrowser.test.ts index bac0c5b72..12cfb7290 100644 --- a/src/test/suite/htmlWidgetBrowser.test.ts +++ b/src/test/suite/htmlWidgetBrowser.test.ts @@ -93,6 +93,10 @@ suite('HTML widget browser rendering', () => { 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 { @@ -163,6 +167,65 @@ suite('HTML widget browser rendering', () => { } }); + 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 outside = path.join(directory, 'outside'); + const panels: vscode.WebviewPanel[] = []; + let result: vscode.Uri | undefined; + try { + fs.mkdirSync(output, { recursive: true }); + 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; + const file = path.join(output, 'index.html'); + fs.writeFileSync(file, ``); + await showWebView(file, 'Resource scope', 'Two'); + 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, 2); + panels[panels.length - 1].dispose(); + } + } finally { + panels.forEach(panel => { panel.dispose(); }); + await shutdownHtmlWidgetViewers(); + 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() }); diff --git a/src/test/suite/htmlWidgetViewer.test.ts b/src/test/suite/htmlWidgetViewer.test.ts index 1d766cdf5..f63be713f 100644 --- a/src/test/suite/htmlWidgetViewer.test.ts +++ b/src/test/suite/htmlWidgetViewer.test.ts @@ -25,6 +25,8 @@ suite('Session-aware HTML widget Viewer', () => { const contexts = new Map(); const viewEvents = new Map>(); const savedState = new Map(); + let trustedWorkspace: boolean; + let workspaceRoots: string[]; const viewerSessions: HtmlViewerSessionAccess = { resolveSession: source => session.getViewerSessionContext(source.sessionId, source)!, getActiveSessionId: () => session.activeSession?.sessionId, @@ -32,6 +34,12 @@ suite('Session-aware HTML widget Viewer', () => { setup(() => { sandbox = sinon.createSandbox(); + trustedWorkspace = false; + workspaceRoots = []; + 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(); @@ -845,10 +853,8 @@ suite('Session-aware HTML widget Viewer', () => { const file = path.resolve('/tmp/output/index.html'); for (const fixture of [ { href: 'assets/', target: '/tmp/output/assets/', root: '/tmp/output/assets' }, - { href: '../shared%20assets/', target: '/tmp/shared assets/', root: '/tmp/shared assets' }, { href: '', target: '/tmp/output/index.html', root: '/tmp/output' }, - { href: vscode.Uri.file(path.resolve('/tmp/external/assets.html')).toString(), target: '/tmp/external/assets.html', root: '/tmp/external' }, - { href: '/', target: path.parse(file).root.replace(/\\/g, '/'), root: path.parse(file).root }, + { 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' }, ]) { @@ -865,7 +871,37 @@ suite('Session-aware HTML widget Viewer', () => { } }); + 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(`Output`); + 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('Output'); + 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); @@ -998,10 +1034,10 @@ suite('Session-aware HTML widget Viewer', () => { 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, 'Session information'); + 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, `navigation@${index + 1}`); + 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)); diff --git a/src/webViewer/index.ts b/src/webViewer/index.ts index 743cae9e7..cd19445fa 100644 --- a/src/webViewer/index.ts +++ b/src/webViewer/index.ts @@ -450,7 +450,17 @@ async function getWebviewHtml( } | undefined)?.attrs?.href; if (resolvedBase.protocol === 'file:' && hrefLocation) { const localBase = Uri.parse(resolvedBase.href); - localResourceRoots.push(Uri.joinPath(localBase, localBase.path.endsWith('/') ? '.' : '..').with({ query: '', fragment: '' })); + 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. + const trustedRoots = [Uri.file(dir), ...(workspace.isTrusted ? workspace.workspaceFolders?.map(folder => folder.uri) ?? [] : [])]; + if (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}`)); + })) { + localResourceRoots.push(resourceRoot); + } edits.push({ start: hrefLocation.startOffset, end: hrefLocation.endOffset, text: `href="${escapeHtml(String(webview.asWebviewUri(localBase)))}"` }); } From da32336ffd2b385435b973e25f363bf33e919d49 Mon Sep 17 00:00:00 2001 From: Fred Wu Date: Sat, 10 Oct 2026 15:45:33 +1100 Subject: [PATCH 21/26] fix: Make HTML viewer history navigation smoother - Keep the current output visible while the next page loads - Disable navigation buttons until the selected page is ready - Preserve resource-access restrictions - Add regression tests for smooth transitions and button states --- src/test/suite/dataViewerSessions.test.ts | 11 +- src/test/suite/htmlWidgetBrowser.test.ts | 135 +++++++++++++++++++++- src/test/suite/htmlWidgetContent.test.ts | 27 ++++- src/test/suite/htmlWidgetViewer.test.ts | 86 +++++++++++++- src/webViewer/index.ts | 46 ++++++-- src/webViewer/webview/index.ts | 3 +- src/webViewer/webview/widget.ts | 10 ++ src/webViewer/webviewMessages.ts | 1 + 8 files changed, 296 insertions(+), 23 deletions(-) diff --git a/src/test/suite/dataViewerSessions.test.ts b/src/test/suite/dataViewerSessions.test.ts index e136fee66..7a6582a9c 100644 --- a/src/test/suite/dataViewerSessions.test.ts +++ b/src/test/suite/dataViewerSessions.test.ts @@ -61,10 +61,11 @@ suite('Viewer session ownership', () => { 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: [], @@ -72,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); } }; diff --git a/src/test/suite/htmlWidgetBrowser.test.ts b/src/test/suite/htmlWidgetBrowser.test.ts index 12cfb7290..c64343055 100644 --- a/src/test/suite/htmlWidgetBrowser.test.ts +++ b/src/test/suite/htmlWidgetBrowser.test.ts @@ -173,11 +173,17 @@ suite('HTML widget browser rendering', () => { 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' })); @@ -202,6 +208,7 @@ suite('HTML widget browser rendering', () => { { 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'); + 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, 2); + 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 }); } @@ -372,6 +381,110 @@ suite('HTML widget browser rendering', () => { } }); + 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() }); @@ -382,7 +495,7 @@ suite('HTML widget browser rendering', () => { source.pid = '12101'; source.rVer = '4.6.1'; try { mockExtensionContext(path.resolve(__dirname, '../../..'), sandbox); - initializeHtmlWidgetViewers(extensionContext, { + const viewerManager = initializeHtmlWidgetViewers(extensionContext, { resolveSession: source => session.getViewerSessionContext(source.sessionId, source)!, getActiveSessionId: () => session.activeSession?.sessionId, }); @@ -423,7 +536,8 @@ suite('HTML widget browser rendering', () => { 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') ? latest : undefined; + 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); @@ -478,6 +592,7 @@ suite('HTML widget browser rendering', () => { 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(); let client: net.Socket | undefined; try { const extension = vscode.extensions.getExtension('REditorSupport.r'); @@ -488,7 +603,14 @@ suite('HTML widget browser rendering', () => { 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); } + 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(); @@ -524,7 +646,10 @@ suite('HTML widget browser rendering', () => { 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') => { await focusHtmlViewer(viewer); + const ready = () => loaded.get(viewer) === Number(/data-generation="(\d+)"/.exec(viewer.webview.html)?.[1]); + await waitForValue(() => ready() ? true : undefined); await vscode.commands.executeCommand(`r.htmlViewer.${action}`); + await waitForValue(() => ready() ? 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. diff --git a/src/test/suite/htmlWidgetContent.test.ts b/src/test/suite/htmlWidgetContent.test.ts index 89137f7ca..4ce62e1f3 100644 --- a/src/test/suite/htmlWidgetContent.test.ts +++ b/src/test/suite/htmlWidgetContent.test.ts @@ -1,6 +1,6 @@ import * as assert from 'assert'; import * as vm from 'vm'; -import { initializeWidgetContent, initializeWidgetState } from '../../webViewer/webview/widget'; +import { initializeWidgetContent, initializeWidgetLoad, initializeWidgetState } from '../../webViewer/webview/widget'; suite('HTML output content controls', () => { function content(sessionOwned = true) { @@ -51,4 +51,29 @@ suite('HTML output content controls', () => { 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 index f63be713f..592c31d55 100644 --- a/src/test/suite/htmlWidgetViewer.test.ts +++ b/src/test/suite/htmlWidgetViewer.test.ts @@ -27,6 +27,7 @@ suite('Session-aware HTML widget Viewer', () => { const savedState = new Map(); let trustedWorkspace: boolean; let workspaceRoots: string[]; + let autoLoad: boolean; const viewerSessions: HtmlViewerSessionAccess = { resolveSession: source => session.getViewerSessionContext(source.sessionId, source)!, getActiveSessionId: () => session.activeSession?.sessionId, @@ -36,6 +37,7 @@ suite('Session-aware HTML widget Viewer', () => { sandbox = sinon.createSandbox(); trustedWorkspace = false; workspaceRoots = []; + autoLoad = true; 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, @@ -62,8 +64,17 @@ suite('Session-aware HTML widget Viewer', () => { const viewState = new vscode.EventEmitter(); const listeners: Array<(message: unknown) => unknown> = []; let closed = false; + let html = ''; const webview = { - html: '', options, cspSource: 'webview-test:', + 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); @@ -224,7 +235,7 @@ suite('Session-aware HTML widget Viewer', () => { 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.strictEqual(panels[0].webview.options.localResourceRoots?.[0].fsPath, vscode.Uri.file('/tmp/widget-c').fsPath); + 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}`)); @@ -244,7 +255,7 @@ suite('Session-aware HTML widget Viewer', () => { await pending; assert.strictEqual(panels[0].webview.html, latest); assert.strictEqual(outputTitle(panels[0]), 'Latest'); - assert.strictEqual(panels[0].webview.options.localResourceRoots?.[0].fsPath, vscode.Uri.file('/tmp/latest').fsPath); + 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 () => { @@ -363,15 +374,19 @@ suite('Session-aware HTML widget Viewer', () => { 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.strictEqual(panel.webview.options.localResourceRoots?.[0].fsPath, vscode.Uri.file('/tmp/a').fsPath); + 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); @@ -391,6 +406,21 @@ suite('Session-aware HTML widget Viewer', () => { 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'); @@ -913,6 +943,22 @@ suite('Session-aware HTML widget Viewer', () => { 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('Output'); + 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('Next output'); + 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 = ""; read.resolves(`${original}Output`); @@ -959,6 +1005,8 @@ suite('Session-aware HTML widget Viewer', () => { await show('/tmp/a.html', source, 'A'); await show('/tmp/b.html', source, 'B'); const slow = deferred(); + 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'); @@ -970,9 +1018,35 @@ suite('Session-aware HTML widget Viewer', () => { 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('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'); @@ -1046,8 +1120,10 @@ suite('Session-aware HTML widget Viewer', () => { 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}.html`, source, `Widget ${i}`); } + 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/webViewer/index.ts b/src/webViewer/index.ts index cd19445fa..9a7f5f90f 100644 --- a/src/webViewer/index.ts +++ b/src/webViewer/index.ts @@ -23,6 +23,7 @@ interface WidgetViewer { loading: boolean; showSessionInfo: boolean; subscriptions: Array<{ dispose(): void }>; + resourceRoots: Map; session?: ViewerSessionContext; state: Pick; saved?: WidgetHistory; @@ -201,7 +202,7 @@ class HtmlWidgetViewerManager { 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: [], + id, panel, revision: 0, disposed: false, loading: false, showSessionInfo, session, saved, subscriptions: [], resourceRoots: new Map(), state: saved ?? { history: [], index: -1 }, }; this.panels.add(entry); @@ -240,6 +241,9 @@ class HtmlWidgetViewerManager { 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.loading) { + entry.loading = false; + this.updateToolbar(); } else if (msg.message === 'widget/find' && msg.generation === entry.revision) { panel.reveal(panel.viewColumn, false); await commands.executeCommand('editor.action.webvieweditor.showFind'); @@ -349,15 +353,35 @@ class HtmlWidgetViewerManager { try { const { html, localResourceRoots } = await getWebviewHtml(panel.webview, item?.file, dir, Boolean(entry.session), generation, { id: entry.id }); if (!entry.disposed && entry.revision === generation) { - panel.webview.options = { ...panel.webview.options, localResourceRoots }; + 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 (reveal) { panel.reveal(panel.viewColumn, true); } } - } finally { + } catch (error) { if (!entry.disposed && entry.revision === generation) { entry.loading = false; this.updateToolbar(); } + throw error; } } @@ -453,12 +477,7 @@ async function getWebviewHtml( 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. - const trustedRoots = [Uri.file(dir), ...(workspace.isTrusted ? workspace.workspaceFolders?.map(folder => folder.uri) ?? [] : [])]; - if (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}`)); - })) { + if (isAllowedResourceRoot(resourceRoot, dir)) { localResourceRoots.push(resourceRoot); } edits.push({ start: hrefLocation.startOffset, end: hrefLocation.endOffset, @@ -476,6 +495,15 @@ async function getWebviewHtml( return { html, localResourceRoots }; } +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}`)); + }); +} + function parsePanelState(state: unknown): HtmlViewerPanelState | undefined { if (!state || typeof state !== 'object') { return; } const value = state as Partial; diff --git a/src/webViewer/webview/index.ts b/src/webViewer/webview/index.ts index 340c35784..13dd9cf01 100644 --- a/src/webViewer/webview/index.ts +++ b/src/webViewer/webview/index.ts @@ -1,10 +1,11 @@ import { acquireVsCodeApi } from '../webviewMessages'; -import { initializeWidgetContent, initializeWidgetState } from './widget'; +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/widget.ts b/src/webViewer/webview/widget.ts index 24fe5ec97..2b8a08e69 100644 --- a/src/webViewer/webview/widget.ts +++ b/src/webViewer/webview/widget.ts @@ -4,6 +4,16 @@ export function initializeWidgetState(vscode: VsCode, state: HtmlViewerPanelRefe 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; } diff --git a/src/webViewer/webviewMessages.ts b/src/webViewer/webviewMessages.ts index 68ccc9222..4cd3e804b 100644 --- a/src/webViewer/webviewMessages.ts +++ b/src/webViewer/webviewMessages.ts @@ -27,5 +27,6 @@ export interface LinkClickedMessage { } export type OutMessage = LinkClickedMessage + | { message: 'widget/loaded'; generation: number } | { message: 'widget/navigate'; direction: 'back' | 'forward'; generation: number } | { message: 'widget/find'; generation: number }; From 27cdc1f0fecd0d2884cdf651ae945817cc1931b7 Mon Sep 17 00:00:00 2001 From: Fred Wu Date: Sat, 10 Oct 2026 16:00:56 +1100 Subject: [PATCH 22/26] fix (test): wait for viewer readiness and a newly loaded document after navigation --- src/test/suite/htmlWidgetBrowser.test.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/test/suite/htmlWidgetBrowser.test.ts b/src/test/suite/htmlWidgetBrowser.test.ts index c64343055..5f9e8a0ca 100644 --- a/src/test/suite/htmlWidgetBrowser.test.ts +++ b/src/test/suite/htmlWidgetBrowser.test.ts @@ -593,6 +593,7 @@ suite('HTML widget browser rendering', () => { 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'); @@ -600,6 +601,10 @@ suite('HTML widget browser rendering', () => { 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); @@ -645,11 +650,19 @@ suite('HTML widget browser rendering', () => { 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') => { - await focusHtmlViewer(viewer); - const ready = () => loaded.get(viewer) === Number(/data-generation="(\d+)"/.exec(viewer.webview.html)?.[1]); + 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}`); - await waitForValue(() => ready() ? true : undefined); + // 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. From 05943748f38c02a3d4c35b4e6dde3c0f8769c0c6 Mon Sep 17 00:00:00 2001 From: Fred Wu Date: Sat, 10 Oct 2026 21:35:34 +1100 Subject: [PATCH 23/26] fix: unblock HTML viewer navigation when load confirmation is missing - Show loading progress in the VS Code status bar - Unlock navigation after five seconds while retaining the loading message - Clear progress on completion, failure, replacement, or disposal - Cover timeouts, stale loads, and CSP-blocked confirmations --- src/test/suite/htmlWidgetBrowser.test.ts | 62 ++++++++++++ src/test/suite/htmlWidgetViewer.test.ts | 119 +++++++++++++++++++++++ src/webViewer/index.ts | 48 +++++++-- 3 files changed, 220 insertions(+), 9 deletions(-) diff --git a/src/test/suite/htmlWidgetBrowser.test.ts b/src/test/suite/htmlWidgetBrowser.test.ts index 5f9e8a0ca..b2cb3f9c8 100644 --- a/src/test/suite/htmlWidgetBrowser.test.ts +++ b/src/test/suite/htmlWidgetBrowser.test.ts @@ -22,6 +22,68 @@ async function focusHtmlViewer(panel: vscode.WebviewPanel): Promise { } suite('HTML widget browser rendering', () => { + test('CSP-blocked acknowledgements release navigation after five seconds in a real webview', 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-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 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); + 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() }); diff --git a/src/test/suite/htmlWidgetViewer.test.ts b/src/test/suite/htmlWidgetViewer.test.ts index 592c31d55..60787794a 100644 --- a/src/test/suite/htmlWidgetViewer.test.ts +++ b/src/test/suite/htmlWidgetViewer.test.ts @@ -28,6 +28,7 @@ suite('Session-aware HTML widget Viewer', () => { let trustedWorkspace: boolean; let workspaceRoots: string[]; let autoLoad: boolean; + const progressTasks: Array<{ messages: string[]; done: boolean }> = []; const viewerSessions: HtmlViewerSessionAccess = { resolveSession: source => session.getViewerSessionContext(source.sessionId, source)!, getActiveSessionId: () => session.activeSession?.sessionId, @@ -38,6 +39,17 @@ suite('Session-aware HTML widget Viewer', () => { trustedWorkspace = false; workspaceRoots = []; autoLoad = true; + progressTasks.length = 0; + sandbox.stub(vscode.window, 'withProgress').callsFake((options, task) => { + assert.strictEqual(options.location, vscode.ProgressLocation.Window); + const state = { messages: [] as string[], done: false }; + progressTasks.push(state); + return task({ report: update => { if (update.message) { state.messages.push(update.message); } } }, + { isCancellationRequested: false, onCancellationRequested: sandbox.stub() }).then(result => { + state.done = true; + return result; + }); + }); 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, @@ -1029,6 +1041,113 @@ suite('Session-aware HTML widget Viewer', () => { 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('Blocked acknowledgement'); + await show('/tmp/b.html', source, 'B'); + const panel = panels[0]; + const html = panel.webview.html; + const task = progressTasks.at(-1)!; + assert.deepStrictEqual(task.messages, ['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(task.messages.at(-1), 'HTML Viewer: Still loading. Navigation is available.'); + assert.strictEqual(task.done, false, '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(task.done, true, '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('a stalled HTML read keeps the previous output and can be superseded after the timeout', 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(); + read.onCall(1).returns(slow.promise); + const pending = show('/tmp/b.html', source, 'B'); + const task = progressTasks.at(-1)!; + assert.strictEqual(panels[0].webview.html, previous); + clock.tick(5000); + await navigate(panels[0], 'back'); + assert.strictEqual(task.done, true); + slow.resolve('
Obsolete slow output
'); + await pending; + assert.strictEqual(outputTitle(panels[0]), 'A'); + assert.ok(progressTasks.every(task => task.done)); + 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'); + const task = progressTasks.at(-1)!; + assert.strictEqual(progressTasks[0].done, true); + clock.tick(1000); + await receivers.get(panels[0])!({ message: 'widget/loaded', generation }); + assert.strictEqual(task.done, false); + assert.strictEqual(disabled(panels[0], 'back'), true); + clock.tick(4000); + assert.strictEqual(disabled(panels[0], 'back'), false); + panels[0].dispose(); + await Promise.resolve(); + assert.strictEqual(task.done, true); + 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(progressTasks[0].done, true); + 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.ok(progressTasks.every(task => task.done)); + 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.ok(progressTasks.every(task => task.done)); + 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(); + await Promise.resolve(); + assert.strictEqual(progressTasks[0].done, true); + assert.strictEqual(clock.countTimers(), 0); + await show('/tmp/b.html', owner('html-shutdown-progress'), 'B'); + await shutdownHtmlWidgetViewers(true); + assert.ok(progressTasks.every(task => task.done)); + 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'); diff --git a/src/webViewer/index.ts b/src/webViewer/index.ts index 9a7f5f90f..9fb18b026 100644 --- a/src/webViewer/index.ts +++ b/src/webViewer/index.ts @@ -5,7 +5,7 @@ import { randomUUID } from 'crypto'; import { pathToFileURL } from 'url'; import { readFile } from 'fs/promises'; import { load } from 'cheerio'; -import { Disposable, ExtensionContext, Uri, ViewColumn, Webview, WebviewPanel, window, env, commands, workspace } from 'vscode'; +import { Disposable, ExtensionContext, ProgressLocation, 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'; @@ -21,6 +21,7 @@ interface WidgetViewer { revision: number; disposed: boolean; loading: boolean; + finishLoading?: () => void; showSessionInfo: boolean; subscriptions: Array<{ dispose(): void }>; resourceRoots: Map; @@ -241,8 +242,8 @@ class HtmlWidgetViewerManager { 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.loading) { - entry.loading = false; + } 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); @@ -257,6 +258,7 @@ class HtmlWidgetViewerManager { } private releasePanel(entry: WidgetViewer): void { + entry.finishLoading?.(); entry.subscriptions.splice(0).forEach(subscription => subscription?.dispose()); } @@ -342,16 +344,38 @@ class HtmlWidgetViewerManager { await pending; } + private startLoading(entry: WidgetViewer, generation: number): void { + entry.finishLoading?.(); + entry.loading = true; + void window.withProgress({ location: ProgressLocation.Window }, progress => new Promise(resolve => { + progress.report({ message: 'HTML Viewer: Loading HTML output…' }); + // Run in the extension host: the output's CSP may block our script, + // or a resource may prevent the window load event from completing. + const timeout = setTimeout(() => { + if (entry.disposed || entry.revision !== generation) { return; } + entry.loading = false; + progress.report({ message: 'HTML Viewer: Still loading. Navigation is available.' }); + this.updateToolbar(); + }, 5000); + entry.finishLoading = () => { + clearTimeout(timeout); + entry.loading = false; + entry.finishLoading = undefined; + resolve(); + }; + })); + this.updateToolbar(); + } + private async render(entry: WidgetViewer, reveal = true): Promise { const { extensionPath } = this.context; const generation = ++entry.revision; - entry.loading = true; - this.updateToolbar(); + 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 } = await getWebviewHtml(panel.webview, item?.file, dir, Boolean(entry.session), generation, { id: entry.id }); + 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)); @@ -374,11 +398,15 @@ class HtmlWidgetViewerManager { panel.webview.options = { ...panel.webview.options, localResourceRoots: resourceRoots }; } panel.webview.html = html; + if (failed) { + entry.finishLoading?.(); + this.updateToolbar(); + } if (reveal) { panel.reveal(panel.viewColumn, true); } } } catch (error) { if (!entry.disposed && entry.revision === generation) { - entry.loading = false; + entry.finishLoading?.(); this.updateToolbar(); } throw error; @@ -412,7 +440,7 @@ class HtmlWidgetViewerManager { async function getWebviewHtml( webview: Webview, file: string | undefined, dir: string, sessionOwned = false, generation = 0, state?: HtmlViewerPanelReference, -): Promise<{ html: string; localResourceRoots: Uri[] }> { +): 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))); @@ -420,11 +448,13 @@ async function getWebviewHtml( 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 = '

No HTML outputs in this session. New HTML output will appear here.

'; + let failed = false; if (file !== undefined) { try { source = await readFile(file, 'utf8'); } catch (error) { if (!sessionOwned) { throw error; } + failed = true; source = `

This HTML widget could not be loaded. Its original file may no longer be available.

${escapeHtml(file)}
`; } } @@ -492,7 +522,7 @@ async function getWebviewHtml( 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 }; + return { html, localResourceRoots, failed }; } function isAllowedResourceRoot(resourceRoot: Uri, dir: string): boolean { From e2c6b5aee52e50921411abc6560c7813c973fa49 Mon Sep 17 00:00:00 2001 From: Fred Wu Date: Sat, 10 Oct 2026 22:19:11 +1100 Subject: [PATCH 24/26] fix: start HTML viewer timeout after assigning the new document --- src/test/suite/htmlWidgetViewer.test.ts | 53 ++++++++++++++++--------- src/webViewer/index.ts | 25 ++++++++---- 2 files changed, 51 insertions(+), 27 deletions(-) diff --git a/src/test/suite/htmlWidgetViewer.test.ts b/src/test/suite/htmlWidgetViewer.test.ts index 60787794a..d84380d3e 100644 --- a/src/test/suite/htmlWidgetViewer.test.ts +++ b/src/test/suite/htmlWidgetViewer.test.ts @@ -1074,25 +1074,40 @@ suite('Session-aware HTML widget Viewer', () => { assert.strictEqual(position(panel), '1 / 1'); }); - test('a stalled HTML read keeps the previous output and can be superseded after the timeout', 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(); - read.onCall(1).returns(slow.promise); - const pending = show('/tmp/b.html', source, 'B'); - const task = progressTasks.at(-1)!; - assert.strictEqual(panels[0].webview.html, previous); - clock.tick(5000); - await navigate(panels[0], 'back'); - assert.strictEqual(task.done, true); - slow.resolve('
Obsolete slow output
'); - await pending; - assert.strictEqual(outputTitle(panels[0]), 'A'); - assert.ok(progressTasks.every(task => task.done)); - assert.strictEqual(clock.countTimers(), 0); - }); + 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(); + autoLoad = false; + read.onCall(1).returns(slow.promise); + const pending = show('/tmp/b.html', source, 'B'); + const task = progressTasks.at(-1)!; + clock.tick(preparationTime); + assert.strictEqual(panels[0].webview.html, previous); + assert.ok((['back', 'forward', 'remove'] as const).every(button => disabled(panels[0], button))); + assert.deepStrictEqual(task.messages, ['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('
/tmp/b.html
'); + 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(task.messages.at(-1), 'HTML Viewer: Still loading. Navigation is available.'); + autoLoad = true; + await navigate(panels[0], 'back'); + assert.strictEqual(outputTitle(panels[0]), 'A'); + assert.ok(progressTasks.every(task => task.done)); + 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'] }); diff --git a/src/webViewer/index.ts b/src/webViewer/index.ts index 9fb18b026..a0c90d46d 100644 --- a/src/webViewer/index.ts +++ b/src/webViewer/index.ts @@ -344,19 +344,25 @@ class HtmlWidgetViewerManager { await pending; } - private startLoading(entry: WidgetViewer, generation: number): void { + private startLoading(entry: WidgetViewer, generation: number): () => void { entry.finishLoading?.(); entry.loading = true; + let startBrowserTimeout!: () => void; void window.withProgress({ location: ProgressLocation.Window }, progress => new Promise(resolve => { progress.report({ message: 'HTML Viewer: Loading HTML output…' }); + let timeout: ReturnType | undefined; // Run in the extension host: the output's CSP may block our script, // or a resource may prevent the window load event from completing. - const timeout = setTimeout(() => { - if (entry.disposed || entry.revision !== generation) { return; } - entry.loading = false; - progress.report({ message: 'HTML Viewer: Still loading. Navigation is available.' }); - this.updateToolbar(); - }, 5000); + // Preparation keeps navigation disabled; give the browser its full + // five seconds only after the new document has been assigned. + startBrowserTimeout = () => { + timeout = setTimeout(() => { + if (entry.disposed || entry.revision !== generation) { return; } + entry.loading = false; + progress.report({ message: 'HTML Viewer: Still loading. Navigation is available.' }); + this.updateToolbar(); + }, 5000); + }; entry.finishLoading = () => { clearTimeout(timeout); entry.loading = false; @@ -365,12 +371,13 @@ class HtmlWidgetViewerManager { }; })); this.updateToolbar(); + return startBrowserTimeout; } private async render(entry: WidgetViewer, reveal = true): Promise { const { extensionPath } = this.context; const generation = ++entry.revision; - this.startLoading(entry, generation); + 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; @@ -401,6 +408,8 @@ class HtmlWidgetViewerManager { if (failed) { entry.finishLoading?.(); this.updateToolbar(); + } else { + startBrowserTimeout(); } if (reveal) { panel.reveal(panel.viewColumn, true); } } From c25e3219f9a4a31b8449097bbc6096f1c063a434 Mon Sep 17 00:00:00 2001 From: Fred Wu Date: Sun, 11 Oct 2026 15:08:54 +1100 Subject: [PATCH 25/26] Stop HTML Viewer loading spinner after ten seconds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Show “Load not confirmed. Please wait or retry.” after ten seconds - Keep the five-second navigation fallback after HTML assignment - Clear loading status on completion, replacement, or viewer closure - Remove unused test configuration and obsolete test mocks - Update loading and CSP regression tests --- .vscode-test.mjs | 3 - src/test/suite/htmlWidgetBrowser.test.ts | 8 +- src/test/suite/htmlWidgetViewer.test.ts | 114 ++++++++++++++++------- src/webViewer/index.ts | 81 +++++++++++----- 4 files changed, 143 insertions(+), 63 deletions(-) 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/src/test/suite/htmlWidgetBrowser.test.ts b/src/test/suite/htmlWidgetBrowser.test.ts index b2cb3f9c8..a5b479532 100644 --- a/src/test/suite/htmlWidgetBrowser.test.ts +++ b/src/test/suite/htmlWidgetBrowser.test.ts @@ -22,8 +22,10 @@ async function focusHtmlViewer(panel: vscode.WebviewPanel): Promise { } suite('HTML widget browser rendering', () => { - test('CSP-blocked acknowledgements release navigation after five seconds in a real webview', async () => { + 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[] = []; @@ -36,6 +38,7 @@ suite('HTML widget browser rendering', () => { 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); @@ -60,6 +63,9 @@ suite('HTML widget browser rendering', () => { 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); diff --git a/src/test/suite/htmlWidgetViewer.test.ts b/src/test/suite/htmlWidgetViewer.test.ts index d84380d3e..9ce3e55ca 100644 --- a/src/test/suite/htmlWidgetViewer.test.ts +++ b/src/test/suite/htmlWidgetViewer.test.ts @@ -28,7 +28,9 @@ suite('Session-aware HTML widget Viewer', () => { let trustedWorkspace: boolean; let workspaceRoots: string[]; let autoLoad: boolean; - const progressTasks: Array<{ messages: string[]; done: 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, @@ -39,16 +41,18 @@ suite('Session-aware HTML widget Viewer', () => { trustedWorkspace = false; workspaceRoots = []; autoLoad = true; - progressTasks.length = 0; - sandbox.stub(vscode.window, 'withProgress').callsFake((options, task) => { - assert.strictEqual(options.location, vscode.ProgressLocation.Window); - const state = { messages: [] as string[], done: false }; - progressTasks.push(state); - return task({ report: update => { if (update.message) { state.messages.push(update.message); } } }, - { isCancellationRequested: false, onCancellationRequested: sandbox.stub() }).then(result => { - state.done = true; - return result; - }); + 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) => ({ @@ -92,7 +96,6 @@ suite('Session-aware HTML widget Viewer', () => { listeners.push(listener); return { dispose: () => { listeners.splice(listeners.indexOf(listener), 1); } }; }, - postMessage: sandbox.stub().resolves(true), }; const panel = { title, viewType: 'r.htmlViewer', viewColumn: vscode.ViewColumn.Two, @@ -565,7 +568,6 @@ suite('Session-aware HTML widget Viewer', () => { await show('/tmp/a.html', source, 'A'); await show('/tmp/b.html', source, 'B'); await navigate(panels[0], 'back'); - const webview = panels[0].webview; panels[0].dispose(); await restoreHtmlViewer(source.sessionId); assert.strictEqual(panels.length, 2); @@ -573,7 +575,6 @@ suite('Session-aware HTML widget Viewer', () => { assert.strictEqual(position(panels[1]), '1 / 2'); await navigate(panels[1], 'forward'); assert.strictEqual(outputTitle(panels[1]), 'B'); - assert.strictEqual((webview.postMessage as sinon.SinonStub).callCount, 0); await restoreHtmlViewer(source.sessionId); assert.strictEqual(panels.length, 2, 'Restoration must reuse an open Viewer'); }); @@ -1050,19 +1051,18 @@ suite('Session-aware HTML widget Viewer', () => { await show('/tmp/b.html', source, 'B'); const panel = panels[0]; const html = panel.webview.html; - const task = progressTasks.at(-1)!; - assert.deepStrictEqual(task.messages, ['HTML Viewer: Loading HTML output…']); + 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(task.messages.at(-1), 'HTML Viewer: Still loading. Navigation is available.'); - assert.strictEqual(task.done, false, 'Timeout releases navigation without claiming loading completed'); + 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(task.done, true, 'Readiness after the timeout must still dismiss progress'); + assert.strictEqual(statusVisible, false, 'Readiness after the timeout must still dismiss progress'); assert.strictEqual(clock.countTimers(), 0); await navigate(panel, 'back'); clock.tick(5000); @@ -1074,6 +1074,56 @@ suite('Session-aware HTML widget Viewer', () => { 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'] }); @@ -1084,11 +1134,10 @@ suite('Session-aware HTML widget Viewer', () => { autoLoad = false; read.onCall(1).returns(slow.promise); const pending = show('/tmp/b.html', source, 'B'); - const task = progressTasks.at(-1)!; clock.tick(preparationTime); assert.strictEqual(panels[0].webview.html, previous); assert.ok((['back', 'forward', 'remove'] as const).every(button => disabled(panels[0], button))); - assert.deepStrictEqual(task.messages, ['HTML Viewer: Loading HTML output…']); + 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]); @@ -1100,11 +1149,11 @@ suite('Session-aware HTML widget Viewer', () => { 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(task.messages.at(-1), 'HTML Viewer: Still loading. Navigation is available.'); + 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.ok(progressTasks.every(task => task.done)); + assert.strictEqual(statusVisible, false); assert.strictEqual(clock.countTimers(), 0); }); } @@ -1117,17 +1166,14 @@ suite('Session-aware HTML widget Viewer', () => { const generation = Number(/data-generation="(\d+)"/.exec(panels[0].webview.html)?.[1]); clock.tick(4000); await show('/tmp/b.html', source, 'B'); - const task = progressTasks.at(-1)!; - assert.strictEqual(progressTasks[0].done, true); clock.tick(1000); await receivers.get(panels[0])!({ message: 'widget/loaded', generation }); - assert.strictEqual(task.done, false); + 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(); - await Promise.resolve(); - assert.strictEqual(task.done, true); + assert.strictEqual(statusVisible, false); assert.strictEqual(clock.countTimers(), 0); }); @@ -1135,17 +1181,17 @@ suite('Session-aware HTML widget Viewer', () => { const clock = sandbox.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); const source = owner('html-load-progress'); await show('/tmp/a.html', source, 'A'); - assert.strictEqual(progressTasks[0].done, true); + 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.ok(progressTasks.every(task => task.done)); + 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.ok(progressTasks.every(task => task.done)); + assert.strictEqual(statusVisible, false); assert.strictEqual(clock.countTimers(), 0); }); @@ -1154,12 +1200,12 @@ suite('Session-aware HTML widget Viewer', () => { autoLoad = false; await show('/tmp/a.html', owner('html-dispose-progress'), 'A'); panels[0].dispose(); - await Promise.resolve(); - assert.strictEqual(progressTasks[0].done, true); + assert.strictEqual(statusVisible, false); assert.strictEqual(clock.countTimers(), 0); await show('/tmp/b.html', owner('html-shutdown-progress'), 'B'); await shutdownHtmlWidgetViewers(true); - assert.ok(progressTasks.every(task => task.done)); + assert.strictEqual(statusVisible, false); + sinon.assert.calledOnce(disposeLoadingStatus); assert.strictEqual(clock.countTimers(), 0); }); diff --git a/src/webViewer/index.ts b/src/webViewer/index.ts index a0c90d46d..1b13a6edd 100644 --- a/src/webViewer/index.ts +++ b/src/webViewer/index.ts @@ -5,7 +5,7 @@ import { randomUUID } from 'crypto'; import { pathToFileURL } from 'url'; import { readFile } from 'fs/promises'; import { load } from 'cheerio'; -import { Disposable, ExtensionContext, ProgressLocation, Uri, ViewColumn, Webview, WebviewPanel, window, env, commands, workspace } from 'vscode'; +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'; @@ -21,6 +21,7 @@ interface WidgetViewer { revision: number; disposed: boolean; loading: boolean; + loadStatus?: 'loading' | 'slow' | 'unconfirmed'; finishLoading?: () => void; showSessionInfo: boolean; subscriptions: Array<{ dispose(): void }>; @@ -88,6 +89,7 @@ class HtmlWidgetViewerManager { disposed = false; private readonly panels = new Set(); private readonly serializer: Disposable; + private readonly loadingStatus: StatusBarItem; private readonly panelStates = new Map(); private panelWrites: Promise = Promise.resolve(); @@ -101,6 +103,8 @@ class HtmlWidgetViewerManager { } } 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 { @@ -294,6 +298,7 @@ class HtmlWidgetViewerManager { private updateToolbar(): void { const entry = this.activeViewer(); + this.updateLoadingStatus(entry); const session = entry?.session; const ready = Boolean(entry?.session && !entry.loading); void Promise.all([ @@ -305,6 +310,25 @@ class HtmlWidgetViewerManager { ]).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; @@ -347,31 +371,37 @@ class HtmlWidgetViewerManager { private startLoading(entry: WidgetViewer, generation: number): () => void { entry.finishLoading?.(); entry.loading = true; - let startBrowserTimeout!: () => void; - void window.withProgress({ location: ProgressLocation.Window }, progress => new Promise(resolve => { - progress.report({ message: 'HTML Viewer: Loading HTML output…' }); - let timeout: ReturnType | undefined; - // Run in the extension host: the output's CSP may block our script, - // or a resource may prevent the window load event from completing. - // Preparation keeps navigation disabled; give the browser its full - // five seconds only after the new document has been assigned. - startBrowserTimeout = () => { - timeout = setTimeout(() => { - if (entry.disposed || entry.revision !== generation) { return; } - entry.loading = false; - progress.report({ message: 'HTML Viewer: Still loading. Navigation is available.' }); - this.updateToolbar(); - }, 5000); - }; - entry.finishLoading = () => { - clearTimeout(timeout); - entry.loading = false; - entry.finishLoading = undefined; - resolve(); - }; - })); + entry.loadStatus = 'loading'; + let navigationTimeout: ReturnType | undefined; + let confirmationTimeout: ReturnType | undefined; + entry.finishLoading = () => { + clearTimeout(navigationTimeout); + clearTimeout(confirmationTimeout); + entry.loading = false; + entry.loadStatus = undefined; + entry.finishLoading = undefined; + }; this.updateToolbar(); - return startBrowserTimeout; + // 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 { @@ -435,6 +465,7 @@ class HtmlWidgetViewerManager { 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(); } From 4a8057d2c8060c9409c12bc70325bdd902c34f4d Mon Sep 17 00:00:00 2001 From: Fred Wu Date: Sun, 11 Oct 2026 15:20:43 +1100 Subject: [PATCH 26/26] fix(test): fix HTML Viewer status assertion when viewer is inactive --- src/test/suite/htmlWidgetBrowser.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/test/suite/htmlWidgetBrowser.test.ts b/src/test/suite/htmlWidgetBrowser.test.ts index a5b479532..3e0126355 100644 --- a/src/test/suite/htmlWidgetBrowser.test.ts +++ b/src/test/suite/htmlWidgetBrowser.test.ts @@ -64,11 +64,13 @@ suite('HTML widget browser rendering', () => { assert.strictEqual(entry.loading, true); await waitForValue(() => !entry.loading ? true : undefined); await waitForValue(() => entry.loadStatus === 'unconfirmed' ? true : undefined); + // Only the active viewer updates the status item; hidden items retain their text. + await focusHtmlViewer(entry.panel); + await waitForValue(() => loadingStatus.text === 'HTML Viewer: Load not confirmed. Please wait or retry.' ? 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);