diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b9daa9552..af863e8bb 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -128,6 +128,18 @@ jobs: with: name: ${{ env.VSIX_FILE }} path: ${{ env.VSIX_FILE }} + format: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24 + cache: 'pnpm' + - run: pnpm install --frozen-lockfile + - name: Check TypeScript formatting + run: pnpm run format:check lint: runs-on: ubuntu-latest env: diff --git a/.oxfmtrc.jsonc b/.oxfmtrc.jsonc new file mode 100644 index 000000000..7ecaa6c30 --- /dev/null +++ b/.oxfmtrc.jsonc @@ -0,0 +1,14 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "printWidth": 100, + "tabWidth": 4, + "singleQuote": true, + "endOfLine": "lf", + // Keep imports and package metadata in their existing order. + "sortImports": false, + "sortPackageJson": false, + // Format TypeScript files only for now: ignore everything, then re-include + // directories and TypeScript sources. The CLI and the Oxc VS Code extension + // both read this file. + "ignorePatterns": ["*", "!*/", "!*.ts", "!*.tsx", "!*.mts", "!*.cts"], +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 39abd0948..e97e44e69 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,6 +7,10 @@ "out": true // set this to false to include "out" folder in search results }, "r.lsp.diagnostics": true, + "[typescript][typescriptreact]": { + "editor.defaultFormatter": "oxc.oxc-vscode", + "editor.formatOnSave": true + }, "editor.codeActionsOnSave": { "source.fixAll.markdownlint": "explicit" } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2ab3958c2..e4deb75b1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,6 +25,24 @@ If launching reports `Configured debug type 'extensionHost' is not supported`, e On VS Code 1.139, the regression can leave the development host paused before any extension activates; the launching window's extension host log shows `ECONNREFUSED ::1` and `Could not find any debuggable target`. Upgrade to VS Code 1.140.0 or newer, or use Microsoft's [JavaScript Debugger Nightly](https://marketplace.visualstudio.com/items?itemName=ms-vscode.js-debug-nightly) as a temporary workaround: disable the built-in **JavaScript Debugger**, enable Nightly, and reload VS Code. +## Formatting and linting + +Run `pnpm run format` to format TypeScript files with Oxfmt, and +`pnpm run format:check` to check formatting without changing files. CI runs the +same check for pull requests and pushes to `main`. Formatting currently applies +to TypeScript files only (`.ts`, `.tsx`, `.mts`, and `.cts`); other file types, +including JavaScript, JSON, Markdown, YAML, R, C, and EJS files, are not formatted. + +The checked-in `.oxfmtrc.jsonc` is shared by the CLI and the recommended Oxc +VS Code extension, and its `ignorePatterns` restrict both to TypeScript files. +TypeScript files use four-space indentation and single quotes. Import and +`package.json` sorting are disabled. Oxfmt respects the repository's `.gitignore` +files, so generated bundles, compiled tests, and dependencies are excluded. + +Workspace settings select Oxc as the formatter and enable format on save for +TypeScript. Run `pnpm run lint` and `pnpm run typecheck` separately to check +TypeScript code. + ## Building bundled sess Builds require Git and a checkout with HEAD. `scripts/prepare-sess.js` fingerprints diff --git a/html/help/script.ts b/html/help/script.ts index 8499bc456..b19d0e8af 100644 --- a/html/help/script.ts +++ b/html/help/script.ts @@ -8,30 +8,27 @@ window.onmousedown = (ev) => { vscode.postMessage({ message: 'mouseClick', button: Number(ev.button), - scrollY: window.scrollY + scrollY: window.scrollY, }); }; - // handle requests from vscode ui window.addEventListener('message', (ev: MessageEvent) => { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const message = ev.data; // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - if(message.command === 'getScrollY'){ + if (message.command === 'getScrollY') { vscode.postMessage({ message: 'getScrollY', - scrollY: window.scrollY + scrollY: window.scrollY, }); } }); - // do everything after loading the body window.document.body.onload = () => { - // make relative path for hyperlinks - const relPath = (document.body.getAttribute('relPath') || ''); + const relPath = document.body.getAttribute('relPath') || ''; // notify vscode, used to restore help panels between sessions vscode.setState(relPath); @@ -42,42 +39,41 @@ window.document.body.onload = () => { // scroll to desired position: const scrollYTo = Number(document.body.getAttribute('scrollYTo') ?? -1); - if(scrollYTo >= 0){ - window.scrollTo(0,scrollYTo); - } else if(url1.hash){ + if (scrollYTo >= 0) { + window.scrollTo(0, scrollYTo); + } else if (url1.hash) { document.location.hash = url1.hash; } // notify vscode when links are clicked: const hyperLinks = document.getElementsByTagName('a'); - for(let i=0; i { document.location.hash = hrefRel; }; - } else if(hrefAbs && hrefAbs.startsWith('vscode-webview://')){ + } else if (hrefAbs && hrefAbs.startsWith('vscode-webview://')) { hyperLinks[i].onclick = () => { - const url2 = new URL(hrefRel, url1); const finalHref = url2.toString(); vscode.postMessage({ message: 'linkClicked', href: finalHref, - scrollY: window.scrollY + scrollY: window.scrollY, }); }; } } // notify vscode when code is clicked: - if(document.body.classList.contains('preClickable')){ + if (document.body.classList.contains('preClickable')) { const codeElements = document.getElementsByTagName('pre'); - for(let i=0; i { vscode.postMessage({ @@ -88,10 +84,9 @@ window.document.body.onload = () => { ctrlKey: me.ctrlKey, shiftKey: me.shiftKey, metaKey: me.metaKey, - } + }, }); }; } } }; - diff --git a/html/help/webviewMessages.d.ts b/html/help/webviewMessages.d.ts index 19e5ceaaa..d70e342c3 100644 --- a/html/help/webviewMessages.d.ts +++ b/html/help/webviewMessages.d.ts @@ -5,38 +5,41 @@ interface VsCode { setState: (state: string) => void; } - interface IOutMessage { message: string; } interface LogMessage extends IOutMessage { - message: 'log', - body: any + message: 'log'; + body: any; } interface MouseClickMessage extends IOutMessage { - message: 'mouseClick', - button: number, - scrollY: number + message: 'mouseClick'; + button: number; + scrollY: number; } interface LinkClickedMessage extends IOutMessage { - message: 'linkClicked', - href: string, - scrollY: number + message: 'linkClicked'; + href: string; + scrollY: number; } interface CodeClickedMessage extends IOutMessage { - message: 'codeClicked', - code: string, + message: 'codeClicked'; + code: string; modifiers: { - altKey: boolean, - ctrlKey: boolean, - shiftKey: boolean, - metaKey: boolean, - } + altKey: boolean; + ctrlKey: boolean; + shiftKey: boolean; + metaKey: boolean; + }; } interface GetScrollYMessage extends IOutMessage { - message: 'getScrollY', - scrollY: number + message: 'getScrollY'; + scrollY: number; } -type OutMessage = LogMessage | MouseClickMessage | LinkClickedMessage | CodeClickedMessage | GetScrollYMessage; - +type OutMessage = + | LogMessage + | MouseClickMessage + | LinkClickedMessage + | CodeClickedMessage + | GetScrollYMessage; diff --git a/html/httpgd/index.ts b/html/httpgd/index.ts index 401505733..71242128d 100644 --- a/html/httpgd/index.ts +++ b/html/httpgd/index.ts @@ -4,7 +4,6 @@ /* eslint-disable @typescript-eslint/no-unsafe-call */ /* eslint-disable @typescript-eslint/no-explicit-any */ - interface Plot { // unique ID for this plot (w.r.t. this connection/device) id: string; @@ -24,17 +23,15 @@ const vscode = acquireVsCodeApi(); let oldHeight = -1; let oldWidth = -1; - const handler = document.querySelector('#handler') as HTMLDivElement; const largePlotDiv = document.querySelector('#largePlot') as HTMLDivElement; const largeSvg = largePlotDiv.querySelector('svg') as SVGElement; const cssLink = document.querySelector('link.overwrites') as HTMLLinkElement; const smallPlotDiv = document.querySelector('#smallPlots') as HTMLDivElement; - function getSmallPlots(): HTMLAnchorElement[] { const smallPlots: HTMLAnchorElement[] = []; - document.querySelectorAll('a.focusPlot').forEach(elm => { + document.querySelectorAll('a.focusPlot').forEach((elm) => { smallPlots.push(elm as HTMLAnchorElement); }); return smallPlots; @@ -42,22 +39,21 @@ function getSmallPlots(): HTMLAnchorElement[] { let isHandlerDragging = false; - let isFullWindow = false; -function postResizeMessage(userTriggered: boolean = false){ +function postResizeMessage(userTriggered: boolean = false) { let newHeight = largePlotDiv.clientHeight; let newWidth = largePlotDiv.clientWidth; - if(isFullWindow){ + if (isFullWindow) { newHeight = window.innerHeight; newWidth = window.innerWidth; } - if(userTriggered || newHeight !== oldHeight || newWidth !== oldWidth){ + if (userTriggered || newHeight !== oldHeight || newWidth !== oldWidth) { const msg: ResizeMessage = { message: 'resize', height: newHeight, width: newWidth, - userTriggered: userTriggered + userTriggered: userTriggered, }; vscode.postMessage(msg); oldHeight = newHeight; @@ -65,37 +61,37 @@ function postResizeMessage(userTriggered: boolean = false){ } } -function postLogMessage(content: any){ +function postLogMessage(content: any) { console.log(content); vscode.postMessage({ message: 'log', - body: content + body: content, }); } window.addEventListener('message', (ev: MessageEvent) => { const msg = ev.data; - if(msg.message === 'updatePlot'){ + if (msg.message === 'updatePlot') { updatePlot({ id: String(msg.plotId), - svg: msg.svg + svg: msg.svg, }); - } else if(msg.message === 'focusPlot'){ + } else if (msg.message === 'focusPlot') { focusPlot(String(msg.plotId)); - } else if(msg.message === 'toggleStyle'){ + } else if (msg.message === 'toggleStyle') { toggleStyle(msg.useOverwrites); - } else if(msg.message === 'hidePlot'){ + } else if (msg.message === 'hidePlot') { hidePlot(msg.plotId); - } else if(msg.message === 'addPlot'){ + } else if (msg.message === 'addPlot') { addPlot(msg.html); - } else if(msg.message === 'togglePreviewPlotLayout'){ + } else if (msg.message === 'togglePreviewPlotLayout') { togglePreviewPlotLayout(msg.style); - } else if(msg.message === 'toggleFullWindow'){ + } else if (msg.message === 'toggleFullWindow') { toggleFullWindowMode(msg.useFullWindow); } }); -function addPlot(html: string){ +function addPlot(html: string) { const wrapper = document.createElement('div'); wrapper.classList.add('wrapper'); wrapper.innerHTML = html; @@ -103,15 +99,14 @@ function addPlot(html: string){ } function focusPlot(plotId: string): void { - const smallPlots = getSmallPlots(); const ind = findIndex(plotId, smallPlots); - if(ind < 0){ + if (ind < 0) { return; } - for(const elm of smallPlots){ + for (const elm of smallPlots) { elm.classList.remove('active'); } @@ -123,17 +118,16 @@ function focusPlot(plotId: string): void { } function updatePlot(plt: Plot): void { - const smallPlots = getSmallPlots(); const ind = findIndex(plt.id, smallPlots); - if(ind<0){ + if (ind < 0) { return; } smallPlots[ind].innerHTML = plt.svg; - if(smallPlots[ind].classList.contains('active')){ + if (smallPlots[ind].classList.contains('active')) { largePlotDiv.innerHTML = plt.svg; } } @@ -142,11 +136,11 @@ function hidePlot(plotId: string): void { const smallPlots = getSmallPlots(); const ind = findIndex(plotId, smallPlots); - if(ind<0){ + if (ind < 0) { return; } - if(smallPlots[ind].classList.contains('active')){ + if (smallPlots[ind].classList.contains('active')) { largePlotDiv.innerHTML = ''; } @@ -155,8 +149,8 @@ function hidePlot(plotId: string): void { function findIndex(plotId: string, smallPlots?: Element[]): number { smallPlots ||= getSmallPlots(); - const ind = smallPlots.findIndex(elm => elm.getAttribute('plotId') === String(plotId)); - if(ind<0){ + const ind = smallPlots.findIndex((elm) => elm.getAttribute('plotId') === String(plotId)); + if (ind < 0) { console.warn(`plotId not found: ${plotId}`); } return ind; @@ -173,7 +167,7 @@ function togglePreviewPlotLayout(newStyle: PreviewPlotLayout): void { function toggleFullWindowMode(useFullWindow: boolean): void { isFullWindow = useFullWindow; - if(useFullWindow){ + if (useFullWindow) { document.body.classList.add('fullWindow'); window.scrollTo(0, 0); } else { @@ -191,12 +185,10 @@ window.onload = () => { postResizeMessage(true); }; - //// // Resize bar //// - document.addEventListener('mousedown', (e) => { // If mousedown event is fired from .handler, toggle flag to true if (!isFullWindow && e.target === handler) { @@ -227,7 +219,7 @@ document.addEventListener('mousemove', (e) => { const newHeight = Math.max(largePlotMinHeight, pointerRelativeYpos - 5); // <- why 5? const newHeightString = `${newHeight}px`; - if(largePlotDiv.style.height !== newHeightString){ + if (largePlotDiv.style.height !== newHeightString) { largePlotDiv.style.height = newHeightString; postResizeMessage(); } @@ -237,11 +229,10 @@ window.onresize = () => postResizeMessage(); document.addEventListener('mouseup', () => { // Turn off dragging flag when user mouse is up - if(isHandlerDragging){ + if (isHandlerDragging) { postResizeMessage(true); document.body.style.cursor = ''; } handler.classList.remove('dragging'); isHandlerDragging = false; }); - diff --git a/html/httpgd/webviewMessages.d.ts b/html/httpgd/webviewMessages.d.ts index 728f482a5..9f383df7a 100644 --- a/html/httpgd/webviewMessages.d.ts +++ b/html/httpgd/webviewMessages.d.ts @@ -5,62 +5,64 @@ interface VsCode { setState: (state: string) => void; } - interface IMessage { message: string; } interface ResizeMessage extends IMessage { - message: 'resize', - height: number, - width: number, - userTriggered: boolean + message: 'resize'; + height: number; + width: number; + userTriggered: boolean; } interface LogMessage extends IMessage { - message: 'log', - body: any + message: 'log'; + body: any; } type OutMessage = ResizeMessage | LogMessage; - - - interface UpdatePlotMessage extends IMessage { - message: 'updatePlot', - svg: string, - plotId: string + message: 'updatePlot'; + svg: string; + plotId: string; } interface FocusPlotMessage extends IMessage { - message: 'focusPlot', - plotId: string + message: 'focusPlot'; + plotId: string; } interface ToggleStyleMessage extends IMessage { - message: 'toggleStyle', - useOverwrites: boolean + message: 'toggleStyle'; + useOverwrites: boolean; } interface ToggleFullWindowMessage extends IMessage { - message: 'toggleFullWindow', - useFullWindow: boolean + message: 'toggleFullWindow'; + useFullWindow: boolean; } type PreviewPlotLayout = 'multirow' | 'scroll' | 'hidden'; interface PreviewPlotLayoutMessage extends IMessage { - message: 'togglePreviewPlotLayout', - style: PreviewPlotLayout + message: 'togglePreviewPlotLayout'; + style: PreviewPlotLayout; } interface HidePlotMessage extends IMessage { - message: 'hidePlot', - plotId: string + message: 'hidePlot'; + plotId: string; } interface AddPlotMessage extends IMessage { - message: 'addPlot', - html: string + message: 'addPlot'; + html: string; } -type InMessage = UpdatePlotMessage | FocusPlotMessage | ToggleStyleMessage | HidePlotMessage | AddPlotMessage | PreviewPlotLayoutMessage | ToggleFullWindowMessage; - +type InMessage = + | UpdatePlotMessage + | FocusPlotMessage + | ToggleStyleMessage + | HidePlotMessage + | AddPlotMessage + | PreviewPlotLayoutMessage + | ToggleFullWindowMessage; diff --git a/package.json b/package.json index 7280273da..ffdcad25b 100644 --- a/package.json +++ b/package.json @@ -2604,6 +2604,8 @@ "test:node": "mocha \"out/test/node/**/*.test.js\" --config .mocharc.interactive.cjs", "test:extension": "vscode-test", "lint": "oxlint src", + "format": "oxfmt .", + "format:check": "oxfmt --check .", "typecheck": "tsc --noEmit -p ./", "test:sess-identity": "Rscript --vanilla R/tests/sess_source.R", "test:sess-attach": "Rscript --vanilla R/tests/attach_sess.R", @@ -2627,6 +2629,7 @@ "glob": "^13.0.6", "mocha": "^11.7.5", "ovsx": "^0.10.8", + "oxfmt": "0.73.0", "sinon": "^15.2.0", "typescript": "~7.0.2", "oxlint": "^1.86.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3d53f790a..6d42aaac3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -81,6 +81,9 @@ importers: ovsx: specifier: ^0.10.8 version: 0.10.12(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1) + oxfmt: + specifier: 0.73.0 + version: 0.73.0 oxlint: specifier: ^1.86.0 version: 1.86.0(oxlint-tsgolint@7.0.2003) @@ -434,6 +437,128 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@oxfmt/binding-android-arm-eabi@0.73.0': + resolution: {integrity: sha512-dhunAdMbi/4o0Y66/+Wl6rpsNqpvE4i26Qw6bB77A97L2lJoktWg6m+X9QOon8iOEEqzD9rEZqFi5uZ4oIzhCA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxfmt/binding-android-arm64@0.73.0': + resolution: {integrity: sha512-dBoypjC2/xeyKyIhN2waX0/XeoccH9k0lVVe+sGRc4X8HSJVRtqyZDoXdr7bGY9A75c51dX0z0dLDpGQKwCdCg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxfmt/binding-darwin-arm64@0.73.0': + resolution: {integrity: sha512-Tn7Y+qWIpCQK+qdYwBcn6YvqeBuPiuG3tcNlYg16a5QL2o3rSmrgBB1nCyYqo0uvc1LNrfQW9/CK2tzFuID/Fg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxfmt/binding-darwin-x64@0.73.0': + resolution: {integrity: sha512-yVb0E0tg/+9Xc9YJCBq66qF1DC/aN5mhb5nnHz0k4ikwkMANtSul2VPDSwjYdXR6/DFimc+pPUgLBqkIa5Ctuw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxfmt/binding-freebsd-x64@0.73.0': + resolution: {integrity: sha512-IKR4lFnBkYNL9Uqdxa5GMTQLvnLj+3sDwTvh2atHCzah0xeo+1xR5bigRU8uyXlOsA4VpXUn0UAimq4E4RBGmQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxfmt/binding-linux-arm-gnueabihf@0.73.0': + resolution: {integrity: sha512-7qnZ4g0ZCO3aZevOOQpZIABYwwXJW1lLDo2elviGdPQ0i11255xKEMFsi97fC1/nauQFseCJfELUy/k2JoOP+g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm-musleabihf@0.73.0': + resolution: {integrity: sha512-36eBico2j/WPB84gb4FYd/jjqGCKOwU4M+TWLBUIqlnzTozQueLy0joA5BwsSp2OZFVkOlpCwvA3ndPamdiyLw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm64-gnu@0.73.0': + resolution: {integrity: sha512-VZ3cSgCAT18knKo52DcACmAuIoqG6qVEyeZRFbJd0xmYQ8GyYjuBc6IAh6MZHijT6q7p4/HPI+6ZWsCuhdV6iw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-arm64-musl@0.73.0': + resolution: {integrity: sha512-8YVzH59XSHcqwXNgc5qK851zCqwpmBgOifARXEPPe5/xTsIbHceYoPZAyqfpYV0CfKmzSaEDBgYHFza4rhx+Zw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-ppc64-gnu@0.73.0': + resolution: {integrity: sha512-MqRJnmCys0rwOgpWyEidSA7v/bvOqfNDJhPkQoR9mPVYrvSmMaW2Xhdpl28cbTA+brOM1is62GjIvJplVlUVpw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-gnu@0.73.0': + resolution: {integrity: sha512-4t2F3SWJSK/O/kRRFnL0elJzn75fJGGu71gs1HtiWcS80SmJOpkHiwwLnTp9suGdns8VHEPxYS0GTcyVwHKWBA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-musl@0.73.0': + resolution: {integrity: sha512-D8b4I8I1kLdJQFwdFXNV+cwh3xWSiNKofjdGUk0nDpc0AHOds8T+poGbP9RWQMvgsvMb9OMgp/aV7ahBR37TbQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-s390x-gnu@0.73.0': + resolution: {integrity: sha512-QrC1S5VL1+N5rvorB15jH7CPvjVHA7GyUI5oVEd8Bd9m5605RDBQYTxDQ920DuylRoZOh2V7/xXpfCtFUrojUQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-gnu@0.73.0': + resolution: {integrity: sha512-dFcUKQPltCEPa+YlkF3zqufn6nJPZdG73LpnjBvZFFsU3laCMssDXSYB99mVaj43FqdEgjpz5TO6gbYXcczd9g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-musl@0.73.0': + resolution: {integrity: sha512-LmQe+nV8e+eUwfPNCxDF+owbsg88toBiR/8rZRgVOeKQP3MaGefXZRvBoc1UG37iWEQ/9JlCXG9Mc4nf4N3agw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-openharmony-arm64@0.73.0': + resolution: {integrity: sha512-8weOr1OKknE0G2DbR6ejJUT9AOsoBU6cXlsetmbihHv1K+bK7qP61T6lJxY0k/aAO7GyOJiRGNlL+04r4eH8Xg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxfmt/binding-win32-arm64-msvc@0.73.0': + resolution: {integrity: sha512-mZV+QxXYgb8E18gzAFItb937VIsE4dqWdStrD3tRRC52UubOYEuU+e7NaIqUsGdB5YsJqNeuck3Ue/af2r9wyA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxfmt/binding-win32-ia32-msvc@0.73.0': + resolution: {integrity: sha512-QJPUsbgUb97ZPPMkFUq6VQq0fLhC4Np19KL9aB/qKVwXnpKE6MIiILXqzhKnQ/UbnEDjKJ7Xv1d6zH4bYPCUdQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxfmt/binding-win32-x64-msvc@0.73.0': + resolution: {integrity: sha512-cGcze2fu+ykIaFtkEvZlkZ3HvnCLk+WISthtq99zWTWn0x13t5L9TxvGdLZMUMvtfYR+ICkiCwKnguVubdcw0w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@oxlint-tsgolint/darwin-arm64@7.0.2003': resolution: {integrity: sha512-TgV33rXr6ueXBwvc+0nssUkTBSXHJxv77I8p4RCjDjCnvexHtmoPiudVRDfj3S3puMDdeQdHW6jdUgUPy3nr/w==} cpu: [arm64] @@ -1840,6 +1965,22 @@ packages: engines: {node: '>= 20'} hasBin: true + oxfmt@0.73.0: + resolution: {integrity: sha512-Ke4xbpuzqIBP4bkd48q815RhqWNJqOBzxVA9199M4qcF0y0LtpUv7UTg1dJ5ea6jw0RBUDZvX3qOUMNmmxfxbw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@astrojs/compiler-rs': ^0.4.0 || ^0.5.0 + svelte: ^5.0.0 + vite-plus: '*' + peerDependenciesMeta: + '@astrojs/compiler-rs': + optional: true + svelte: + optional: true + vite-plus: + optional: true + oxlint-tsgolint@7.0.2003: resolution: {integrity: sha512-VnK4zlqgmgq/7ZcjzCk/WpN8kKFsYGcB85Io9qT3wL4K8Un3RKmJkp793crNETieMusPMKOA4a+Mw2wbteI6TQ==} hasBin: true @@ -2199,6 +2340,10 @@ packages: resolution: {integrity: sha512-tXJwSr9355kFJI3lbCkPpUH5cP8/M0GGy2xLO34aZCjMXBaK3SoPnZwr/oWmo1FdCnELcs4npdCIOFtq9W3ruQ==} engines: {node: '>=4'} + tinypool@2.2.0: + resolution: {integrity: sha512-jBrmx4lYmaC9k/mgPbylxs7kBUxHtD8256up+HjLaDFfXScKJQyil+SWXSvhAtT7XHo+yTVlpB81PGmHP8oLSQ==} + engines: {node: ^20.0.0 || >=22.0.0} + tmp@0.2.7: resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==} engines: {node: '>=14.14'} @@ -2632,6 +2777,63 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 + '@oxfmt/binding-android-arm-eabi@0.73.0': + optional: true + + '@oxfmt/binding-android-arm64@0.73.0': + optional: true + + '@oxfmt/binding-darwin-arm64@0.73.0': + optional: true + + '@oxfmt/binding-darwin-x64@0.73.0': + optional: true + + '@oxfmt/binding-freebsd-x64@0.73.0': + optional: true + + '@oxfmt/binding-linux-arm-gnueabihf@0.73.0': + optional: true + + '@oxfmt/binding-linux-arm-musleabihf@0.73.0': + optional: true + + '@oxfmt/binding-linux-arm64-gnu@0.73.0': + optional: true + + '@oxfmt/binding-linux-arm64-musl@0.73.0': + optional: true + + '@oxfmt/binding-linux-ppc64-gnu@0.73.0': + optional: true + + '@oxfmt/binding-linux-riscv64-gnu@0.73.0': + optional: true + + '@oxfmt/binding-linux-riscv64-musl@0.73.0': + optional: true + + '@oxfmt/binding-linux-s390x-gnu@0.73.0': + optional: true + + '@oxfmt/binding-linux-x64-gnu@0.73.0': + optional: true + + '@oxfmt/binding-linux-x64-musl@0.73.0': + optional: true + + '@oxfmt/binding-openharmony-arm64@0.73.0': + optional: true + + '@oxfmt/binding-win32-arm64-msvc@0.73.0': + optional: true + + '@oxfmt/binding-win32-ia32-msvc@0.73.0': + optional: true + + '@oxfmt/binding-win32-x64-msvc@0.73.0': + optional: true + '@oxlint-tsgolint/darwin-arm64@7.0.2003': optional: true @@ -4024,6 +4226,30 @@ snapshots: - debug - supports-color + oxfmt@0.73.0: + dependencies: + tinypool: 2.2.0 + optionalDependencies: + '@oxfmt/binding-android-arm-eabi': 0.73.0 + '@oxfmt/binding-android-arm64': 0.73.0 + '@oxfmt/binding-darwin-arm64': 0.73.0 + '@oxfmt/binding-darwin-x64': 0.73.0 + '@oxfmt/binding-freebsd-x64': 0.73.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.73.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.73.0 + '@oxfmt/binding-linux-arm64-gnu': 0.73.0 + '@oxfmt/binding-linux-arm64-musl': 0.73.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.73.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.73.0 + '@oxfmt/binding-linux-riscv64-musl': 0.73.0 + '@oxfmt/binding-linux-s390x-gnu': 0.73.0 + '@oxfmt/binding-linux-x64-gnu': 0.73.0 + '@oxfmt/binding-linux-x64-musl': 0.73.0 + '@oxfmt/binding-openharmony-arm64': 0.73.0 + '@oxfmt/binding-win32-arm64-msvc': 0.73.0 + '@oxfmt/binding-win32-ia32-msvc': 0.73.0 + '@oxfmt/binding-win32-x64-msvc': 0.73.0 + oxlint-tsgolint@7.0.2003: optionalDependencies: '@oxlint-tsgolint/darwin-arm64': 7.0.2003 @@ -4434,6 +4660,8 @@ snapshots: dependencies: editions: 6.22.0 + tinypool@2.2.0: {} + tmp@0.2.7: {} to-regex-range@5.0.1: diff --git a/src/api.d.ts b/src/api.d.ts index cd0fea7d4..49dee0014 100644 --- a/src/api.d.ts +++ b/src/api.d.ts @@ -1,4 +1,3 @@ - // declaration of the api exported by the extension // implemented in apiImplementation.ts // used e.g. by vscode-r-debugger to show the help panel from within debug sessions @@ -113,7 +112,9 @@ export interface RSessionApi { * During script execution, R may wait up to 180 seconds for install consent or 300 seconds * for another setup's lock. These are R-side waits, not a total startup timeout. */ - prepareAttachment(options?: RSessionPreparationOptions): Promise; + prepareAttachment( + options?: RSessionPreparationOptions, + ): Promise; /** * Sess connection history is delivered as immutable snapshots in FIFO order after each * transition is committed. During replacement, `disconnected(old)` is followed by @@ -154,7 +155,10 @@ export interface RSessionApi { * recalled; the consumer must retain its preparation while the runtime keeps its submission * queue occupied until execution finishes. */ - registerTerminalIntegration(terminal: vscode.Terminal, integration: RExternalTerminalIntegration): vscode.Disposable; + registerTerminalIntegration( + terminal: vscode.Terminal, + integration: RExternalTerminalIntegration, + ): vscode.Disposable; } export type HelpSubMenu = 'doc' | 'pkgList' | 'refresh' | '?' | '??'; diff --git a/src/apiImplementation.ts b/src/apiImplementation.ts index 67642d3d6..caf8e38f7 100644 --- a/src/apiImplementation.ts +++ b/src/apiImplementation.ts @@ -3,12 +3,10 @@ import { RExtension, RSessionApi } from './api'; import { getRpath } from './util'; import * as vscode from 'vscode'; -export class RExtensionImplementation implements RExtension { +export class RExtensionImplementation implements RExtension { public helpPanel?: RHelp; public session!: RSessionApi; public async getRExecutablePath(resource?: vscode.Uri): Promise { return getRpath(false, resource); } } - - diff --git a/src/completionMiddleware.ts b/src/completionMiddleware.ts index 5c3f44026..d5e3d49fb 100644 --- a/src/completionMiddleware.ts +++ b/src/completionMiddleware.ts @@ -4,8 +4,13 @@ import type { CompletionMiddleware } from 'vscode-languageclient/node'; export const completionMiddleware: CompletionMiddleware = { provideCompletionItem: async (document, position, context, token, next) => { const result = await next(document, position, context, token); - if (!result || token.isCancellationRequested - || !workspace.getConfiguration('editor', document).get('parameterHints.enabled', true)) { + if ( + !result || + token.isCancellationRequested || + !workspace + .getConfiguration('editor', document) + .get('parameterHints.enabled', true) + ) { return result; } @@ -13,18 +18,20 @@ export const completionMiddleware: CompletionMiddleware = { for (const item of items) { // languageserver inserts a call with the cursor inside its parentheses. // Snippet insertion does not fire VS Code's signature-help triggers. - if (!item.command - && (item.kind === CompletionItemKind.Function - || item.kind === CompletionItemKind.Method - || item.kind === CompletionItemKind.Constructor) - && item.insertText instanceof SnippetString - && item.insertText.value.endsWith('($0)')) { + if ( + !item.command && + (item.kind === CompletionItemKind.Function || + item.kind === CompletionItemKind.Method || + item.kind === CompletionItemKind.Constructor) && + item.insertText instanceof SnippetString && + item.insertText.value.endsWith('($0)') + ) { item.command = { title: 'Trigger parameter hints', - command: 'editor.action.triggerParameterHints' + command: 'editor.action.triggerParameterHints', }; } } return result; - } + }, }; diff --git a/src/completions.ts b/src/completions.ts index e5992c610..ac5ec8c8f 100644 --- a/src/completions.ts +++ b/src/completions.ts @@ -9,65 +9,126 @@ import { config } from './util'; import { getChunks } from './rmarkdown'; import { CompletionItemKind } from 'vscode-languageclient'; - // Get with names(roxygen2:::default_tags()) const roxygenTagCompletionItems = [ - 'export', 'exportClass', 'exportMethod', 'exportPattern', 'import', 'importClassesFrom', - 'importFrom', 'importMethodsFrom', 'rawNamespace', 'S3method', 'useDynLib', 'aliases', - 'author', 'backref', 'concept', 'describeIn', 'description', 'details', - 'docType', 'encoding', 'evalRd', 'example', 'examples', 'family', - 'field', 'format', 'inherit', 'inheritParams', 'inheritDotParams', 'inheritSection', - 'keywords', 'method', 'name', 'md', 'noMd', 'noRd', - 'note', 'param', 'rdname', 'rawRd', 'references', 'return', - 'section', 'seealso', 'slot', 'source', 'template', 'templateVar', - 'title', 'usage' + 'export', + 'exportClass', + 'exportMethod', + 'exportPattern', + 'import', + 'importClassesFrom', + 'importFrom', + 'importMethodsFrom', + 'rawNamespace', + 'S3method', + 'useDynLib', + 'aliases', + 'author', + 'backref', + 'concept', + 'describeIn', + 'description', + 'details', + 'docType', + 'encoding', + 'evalRd', + 'example', + 'examples', + 'family', + 'field', + 'format', + 'inherit', + 'inheritParams', + 'inheritDotParams', + 'inheritSection', + 'keywords', + 'method', + 'name', + 'md', + 'noMd', + 'noRd', + 'note', + 'param', + 'rdname', + 'rawRd', + 'references', + 'return', + 'section', + 'seealso', + 'slot', + 'source', + 'template', + 'templateVar', + 'title', + 'usage', ].map((x: string) => new vscode.CompletionItem(`${x} `)); - export class HoverProvider implements vscode.HoverProvider { - async provideHover(document: vscode.TextDocument, position: vscode.Position): Promise { + async provideHover( + document: vscode.TextDocument, + position: vscode.Position, + ): Promise { const target = session.sessionForDocument(document.uri); - if(!target?.workspaceData?.globalenv){ + if (!target?.workspaceData?.globalenv) { return null; } if (document.languageId === 'rmd') { const chunks = getChunks(document); - const chunk = chunks.find((chunk) => chunk.language === 'r' && chunk.startLine < position.line && chunk.endLine > position.line); + const chunk = chunks.find( + (chunk) => + chunk.language === 'r' && + chunk.startLine < position.line && + chunk.endLine > position.line, + ); if (!chunk) { return null; } } let hoverRange = document.getWordRangeAtPosition(position); - if (!hoverRange || target.workspaceUnavailable) { return null; } + if (!hoverRange || target.workspaceUnavailable) { + return null; + } let hoverText = null; const symbol = document.getText(hoverRange); - const prefix = document.lineAt(position.line).text.slice(0, hoverRange.start.character).trimEnd(); + const prefix = document + .lineAt(position.line) + .text.slice(0, hoverRange.start.character) + .trimEnd(); // Workspace snapshots already contain simple-symbol summaries and function // formals. Use those while R is busy, and avoid an IPC round trip on every hover. - const cached = /[$@:]/.test(prefix.slice(-1)) ? undefined : target.workspaceData.globalenv[symbol]; + const cached = /[$@:]/.test(prefix.slice(-1)) + ? undefined + : target.workspaceData.globalenv[symbol]; if (cached && ['closure', 'builtin'].includes(cached.type)) { - return new vscode.Hover(new vscode.MarkdownString().appendCodeblock(cached.str, 'r'), hoverRange); + return new vscode.Hover( + new vscode.MarkdownString().appendCodeblock(cached.str, 'r'), + hoverRange, + ); } if (session.globalPipePath || target.requester) { const exprRegex = /([a-zA-Z0-9._$@ ])+(? { - if(!config().get('helpPanel.enableHoverLinks')){ + async provideHover( + document: vscode.TextDocument, + position: vscode.Position, + ): Promise { + if (!config().get('helpPanel.enableHoverLinks')) { return null; } if (document.languageId === 'rmd') { const chunks = getChunks(document); - const chunk = chunks.find((chunk) => chunk.language === 'r' && chunk.startLine < position.line && chunk.endLine > position.line); + const chunk = chunks.find( + (chunk) => + chunk.language === 'r' && + chunk.startLine < position.line && + chunk.endLine > position.line, + ); if (!chunk) { return null; } @@ -102,8 +171,8 @@ export class HelpLinkHoverProvider implements vscode.HoverProvider { const re = /([a-zA-Z0-9._:])+/; const wordRange = document.getWordRangeAtPosition(position, re); const token = document.getText(wordRange); - const aliases = await globalRHelp?.getMatchingAliases(token) || []; - const mds = aliases.map(a => { + const aliases = (await globalRHelp?.getMatchingAliases(token)) || []; + const mds = aliases.map((a) => { const cmdText = `${a.package}::${a.alias}`; const args = [`/library/${a.package}/html/${a.name}.html`]; const encodedArgs = encodeURIComponent(JSON.stringify(args)); @@ -117,18 +186,25 @@ export class HelpLinkHoverProvider implements vscode.HoverProvider { } } - export class StaticCompletionItemProvider implements vscode.CompletionItemProvider { - provideCompletionItems(document: vscode.TextDocument, position: vscode.Position): vscode.CompletionItem[] | undefined { + provideCompletionItems( + document: vscode.TextDocument, + position: vscode.Position, + ): vscode.CompletionItem[] | undefined { if (document.languageId === 'rmd') { const chunks = getChunks(document); - const chunk = chunks.find((chunk) => chunk.language === 'r' && chunk.startLine < position.line && chunk.endLine > position.line); + const chunk = chunks.find( + (chunk) => + chunk.language === 'r' && + chunk.startLine < position.line && + chunk.endLine > position.line, + ); if (!chunk) { return undefined; } } - if (document.lineAt(position).text.startsWith('#\'')) { + if (document.lineAt(position).text.startsWith("#'")) { return roxygenTagCompletionItems; } @@ -136,13 +212,12 @@ export class StaticCompletionItemProvider implements vscode.CompletionItemProvid } } - export class LiveCompletionItemProvider implements vscode.CompletionItemProvider { async provideCompletionItems( document: vscode.TextDocument, position: vscode.Position, token: vscode.CancellationToken, - completionContext: vscode.CompletionContext + completionContext: vscode.CompletionContext, ): Promise { const items: vscode.CompletionItem[] = []; const activeSession = session.sessionForDocument(document.uri); @@ -152,7 +227,12 @@ export class LiveCompletionItemProvider implements vscode.CompletionItemProvider if (document.languageId === 'rmd') { const chunks = getChunks(document); - const chunk = chunks.find((chunk) => chunk.language === 'r' && chunk.startLine < position.line && chunk.endLine > position.line); + const chunk = chunks.find( + (chunk) => + chunk.language === 'r' && + chunk.startLine < position.line && + chunk.endLine > position.line, + ); if (!chunk) { return items; } @@ -166,25 +246,30 @@ export class LiveCompletionItemProvider implements vscode.CompletionItemProvider const obj = globalenv[key]; const item = new vscode.CompletionItem( key, - obj.type === 'closure' || obj.type === 'builtin' + obj.type === 'closure' || obj.type === 'builtin' ? vscode.CompletionItemKind.Function - : vscode.CompletionItemKind.Field + : vscode.CompletionItemKind.Field, ); item.detail = '[session]'; item.documentation = new vscode.MarkdownString(`\`\`\`r\n${obj.str}\n\`\`\``); items.push(item); }); - } else if(trigger === '$' || trigger === '@') { + } else if (trigger === '$' || trigger === '@') { const symbolPosition = new vscode.Position(position.line, position.character - 1); if (session.globalPipePath || activeSession.requester) { const re = /([a-zA-Z0-9._$@ ])+(? { - const item = new vscode.CompletionItem(e.name, (e.type === 'closure' || e.type === 'builtin') ? CompletionItemKind.Function : vscode.CompletionItemKind.Variable); + const item = new vscode.CompletionItem( + e.name, + e.type === 'closure' || e.type === 'builtin' + ? CompletionItemKind.Function + : vscode.CompletionItemKind.Variable, + ); item.detail = detail; item.documentation = new vscode.MarkdownString(`\`\`\`r\n${e.str}\n\`\`\``); item.sortText = `0-${index.toString().padStart(len, '0')}`; @@ -242,7 +347,12 @@ function getCompletionItemsFromElements(elements: RObjectElement[], detail: stri }); } -function getCompletionItems(names: string[], kind: vscode.CompletionItemKind, detail: string, documentation: vscode.MarkdownString): vscode.CompletionItem[] { +function getCompletionItems( + names: string[], + kind: vscode.CompletionItemKind, + detail: string, + documentation: vscode.MarkdownString, +): vscode.CompletionItem[] { const len = names.length.toString().length; let index = 0; return names.map((name) => { @@ -255,14 +365,24 @@ function getCompletionItems(names: string[], kind: vscode.CompletionItemKind, de }); } -function getBracketCompletionItems(document: vscode.TextDocument, position: vscode.Position, token: vscode.CancellationToken, activeSession: session.Session): vscode.CompletionItem[] { +function getBracketCompletionItems( + document: vscode.TextDocument, + position: vscode.Position, + token: vscode.CancellationToken, + activeSession: session.Session, +): vscode.CompletionItem[] { const items: vscode.CompletionItem[] = []; - let range: vscode.Range | undefined = new vscode.Range(new vscode.Position(position.line, 0), position); + let range: vscode.Range | undefined = new vscode.Range( + new vscode.Position(position.line, 0), + position, + ); let expectOpenBrackets = 0; let symbol: string | undefined = undefined; while (range) { - if (token.isCancellationRequested) { return []; } + if (token.isCancellationRequested) { + return []; + } const text = document.getText(range); for (let i = text.length - 1; i >= 0; i -= 1) { const chr = text.charAt(i); @@ -291,15 +411,31 @@ function getBracketCompletionItems(document: vscode.TextDocument, position: vsco const obj = activeSession.workspaceData.globalenv[symbol]; if (obj !== undefined && obj.names !== undefined) { const doc = new vscode.MarkdownString('Element of `' + symbol + '`'); - items.push(...getCompletionItems(obj.names, vscode.CompletionItemKind.Variable, '[session]', doc)); + items.push( + ...getCompletionItems( + obj.names, + vscode.CompletionItemKind.Variable, + '[session]', + doc, + ), + ); } } return items; } -function getPipelineCompletionItems(document: vscode.TextDocument, position: vscode.Position, token: vscode.CancellationToken, activeSession: session.Session): vscode.CompletionItem[] { +function getPipelineCompletionItems( + document: vscode.TextDocument, + position: vscode.Position, + token: vscode.CancellationToken, + activeSession: session.Session, +): vscode.CompletionItem[] { const items: vscode.CompletionItem[] = []; - const range = extendSelection(position.line, (x) => document.lineAt(x).text, document.lineCount); + const range = extendSelection( + position.line, + (x) => document.lineAt(x).text, + document.lineCount, + ); let symbol: string | undefined = undefined; for (let i = range.startLine; i <= range.endLine; i++) { @@ -336,7 +472,14 @@ function getPipelineCompletionItems(document: vscode.TextDocument, position: vsc const obj = activeSession.workspaceData.globalenv[symbol]; if (obj !== undefined && obj.names !== undefined) { const doc = new vscode.MarkdownString('Element of `' + symbol + '`'); - items.push(...getCompletionItems(obj.names, vscode.CompletionItemKind.Variable, '[session]', doc)); + items.push( + ...getCompletionItems( + obj.names, + vscode.CompletionItemKind.Variable, + '[session]', + doc, + ), + ); } } return items; diff --git a/src/configuration.ts b/src/configuration.ts index bf802ff55..267e81d94 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -1,10 +1,12 @@ export interface ConfigurationReader { get: (key: string) => T | undefined; - inspect?: (key: string) => { - workspaceFolderValue?: T; - workspaceValue?: T; - globalValue?: T; - } | undefined; + inspect?: (key: string) => + | { + workspaceFolderValue?: T; + workspaceValue?: T; + globalValue?: T; + } + | undefined; } // Resolve renamed settings by scope, preserving the winning key for diagnostics. @@ -12,7 +14,7 @@ export function getMigratedSetting( configuration: ConfigurationReader, canonicalKey: string, legacyKey: string, - isConfigured: (value: T) => boolean = () => true + isConfigured: (value: T) => boolean = () => true, ): { key: string; value: T } | undefined { const canonical = configuration.inspect?.(canonicalKey); const legacy = configuration.inspect?.(legacyKey); @@ -22,7 +24,7 @@ export function getMigratedSetting( } candidates.push( [canonicalKey, configuration.get(canonicalKey)], - [legacyKey, configuration.get(legacyKey)] + [legacyKey, configuration.get(legacyKey)], ); const seen = new Set(); for (const [key, value] of candidates) { diff --git a/src/cppProperties.ts b/src/cppProperties.ts index ea83f71af..bdbaf3de9 100644 --- a/src/cppProperties.ts +++ b/src/cppProperties.ts @@ -10,14 +10,17 @@ import { extensionContext } from './extension'; export async function generateCppProperties(): Promise { const currentWorkspaceFolder = getCurrentWorkspaceFolder()?.uri.fsPath; if (currentWorkspaceFolder === undefined) { - void window.showWarningMessage('Please open a workspace folder to create c_cpp_properties.json'); + void window.showWarningMessage( + 'Please open a workspace folder to create c_cpp_properties.json', + ); return; } const outFilePath = path.join(currentWorkspaceFolder, '.vscode', 'c_cpp_properties.json'); if (fs.existsSync(outFilePath)) { const overwrite = await window.showWarningMessage( '"c_cpp_properties.json" file already exists. Do you want to overwrite?', - 'Yes', 'No' + 'Yes', + 'No', ); if (overwrite !== 'Yes') { return; @@ -28,9 +31,7 @@ export async function generateCppProperties(): Promise { /** Helper: Return object depending on current process platform */ function platformChoose(win32: A, darwin: B, other: C): A | B | C { - return process.platform === 'win32' ? win32 : - process.platform === 'darwin' ? darwin : - other; + return process.platform === 'win32' ? win32 : process.platform === 'darwin' ? darwin : other; } // See: https://code.visualstudio.com/docs/cpp/c-cpp-properties-schema-reference @@ -65,13 +66,19 @@ async function generateCppPropertiesProc(workspaceFolder: string) { const compileStdCpp = extractCompilerStd(compileOutputCpp); const compileStdC = extractCompilerStd(compileOutputC); const compileCall = extractCompilerCall(compileOutputCpp); - const compilerPath = compileCall ? await executeRCommand(`cat(Sys.which("${compileCall}"))`, workspaceFolder, (e: Error) => { - void window.showErrorMessage(e.message); - return ''; - }) : ''; + const compilerPath = compileCall + ? await executeRCommand(`cat(Sys.which("${compileCall}"))`, workspaceFolder, (e: Error) => { + void window.showErrorMessage(e.message); + return ''; + }) + : ''; const intelliSensePlatform = platformChoose('windows', 'macos', 'linux'); - const intelliSenseComp = compileCall ? (compileCall.includes('clang') ? 'clang' : 'gcc') : 'gcc'; + const intelliSenseComp = compileCall + ? compileCall.includes('clang') + ? 'clang' + : 'gcc' + : 'gcc'; const intelliSense = `${intelliSensePlatform}-${intelliSenseComp}-${process.arch}`; // Collect information from 'DESCRIPTION' @@ -79,28 +86,34 @@ async function generateCppPropertiesProc(workspaceFolder: string) { // Combine information const envIncludes: string[] = ['${workspaceFolder}/src']; - envIncludes.push(...compileInfo.compIncludes.map((v) => path.isAbsolute(v) ? v : `\${workspaceFolder}/${path.join('src', v)}`)); + envIncludes.push( + ...compileInfo.compIncludes.map((v) => + path.isAbsolute(v) ? v : `\${workspaceFolder}/${path.join('src', v)}`, + ), + ); envIncludes.push(...linkingToIncludes); const envDefines = compileInfo.compDefines; // If no standard is set on linux, the C standard seems to default to the c++ one. - const envCStd = (!compileStdC || compileStdC.includes('++')) ? '${default}' : compileStdC; + const envCStd = !compileStdC || compileStdC.includes('++') ? '${default}' : compileStdC; const platformName = platformChoose('Win32', 'Mac', 'Linux'); // Build json const re = { - 'configurations': [{ - 'name': platformName, - 'defines': envDefines, - 'includePath': envIncludes, - 'compilerPath': compilerPath, - 'cStandard': envCStd, - 'cppStandard': compileStdCpp, - 'intelliSenseMode': intelliSense - }], - 'version': 4 + configurations: [ + { + name: platformName, + defines: envDefines, + includePath: envIncludes, + compilerPath: compilerPath, + cStandard: envCStd, + cppStandard: compileStdCpp, + intelliSenseMode: intelliSense, + }, + ], + version: 4, }; const ser = JSON.stringify(re, null, 2); @@ -117,11 +130,15 @@ async function collectRLinkingTo(workspaceFolder: string): Promise { return []; } - const rScript = extensionContext.asAbsolutePath('R/cppProperties/extractLinkingTo.R').replace(/\\/g, '/'); - const linkingToIncludesStr = (await executeRCommand(`source('${rScript}')`, workspaceFolder, (e: Error) => { - void window.showErrorMessage(e.message); - return ''; - }))?.trim(); + const rScript = extensionContext + .asAbsolutePath('R/cppProperties/extractLinkingTo.R') + .replace(/\\/g, '/'); + const linkingToIncludesStr = ( + await executeRCommand(`source('${rScript}')`, workspaceFolder, (e: Error) => { + void window.showErrorMessage(e.message); + return ''; + }) + )?.trim(); if (!linkingToIncludesStr || linkingToIncludesStr === '') { return []; } @@ -140,7 +157,7 @@ function extractCompilerInfo(compileOutput: string) { const compDefines: string[] = []; const compIncludes: string[] = []; - const compLookup = { 'D': compDefines, 'I': compIncludes }; + const compLookup = { D: compDefines, I: compIncludes }; let m: RegExpExecArray | null; while ((m = rxCompArg.exec(compileOutput)) !== null) { @@ -149,12 +166,12 @@ function extractCompilerInfo(compileOutput: string) { } // The regex guarantees that the first group is 'I' or 'D' - compLookup[(m[1] as 'D' | 'I')].push(ensureUnquoted(m[2])); + compLookup[m[1] as 'D' | 'I'].push(ensureUnquoted(m[2])); } return { compDefines: compDefines, - compIncludes: compIncludes + compIncludes: compIncludes, }; } @@ -177,7 +194,6 @@ function extractCompilerCall(compileOutput: string): string | undefined { } function collectCompilerOutput(rPath: string, workspaceFolder: string, testExtension: 'cpp' | 'c') { - const makevarsFiles = ['Makevars', 'Makevars.win', 'Makevars.ucrt']; const srcFolder = path.join(workspaceFolder, 'src'); @@ -185,7 +201,9 @@ function collectCompilerOutput(rPath: string, workspaceFolder: string, testExten // Copy makevars if (fs.existsSync(srcFolder)) { - const projectMakevarsFiles = fs.readdirSync(srcFolder).filter(fn => makevarsFiles.includes(fn)); + const projectMakevarsFiles = fs + .readdirSync(srcFolder) + .filter((fn) => makevarsFiles.includes(fn)); for (const f of projectMakevarsFiles) { fs.copyFileSync(path.join(srcFolder, f), path.join(tempFolder, f)); } @@ -198,7 +216,7 @@ function collectCompilerOutput(rPath: string, workspaceFolder: string, testExten // Compile dummy const command = `"${rPath}" CMD SHLIB ${testFile}`; const compileOutput = execSync(command, { - cwd: tempFolder + cwd: tempFolder, }).toString(); // Cleanup diff --git a/src/extension.ts b/src/extension.ts index ce861eac7..7dc94f7fe 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -26,8 +26,12 @@ import * as languageService from './languageService'; import { RTaskProvider } from './tasks'; import { showRDebuggerCompatibilityWarningOnce } from './rDebuggerCompatibility'; import { InteractiveManager } from './interactive/manager'; -import { initializeHtmlWidgetViewers, restoreHtmlViewer, runHtmlViewerCommand, shutdownHtmlWidgetViewers } from './webViewer'; - +import { + initializeHtmlWidgetViewers, + restoreHtmlViewer, + runHtmlViewerCommand, + shutdownHtmlWidgetViewers, +} from './webViewer'; // global objects used in other files export const homeExtDir = (): string => util.getDir(path.join(os.homedir(), '.vscode-R')); @@ -43,16 +47,22 @@ export let rmdKnitManager: rmarkdown.RMarkdownKnitManager | undefined = undefine export let sessionStatusBarItem: vscode.StatusBarItem | undefined = undefined; // Called (once) when the extension is activated -export async function activate(context: vscode.ExtensionContext): Promise { +export async function activate( + context: vscode.ExtensionContext, +): Promise { const rDebugger = vscode.extensions.getExtension('RDebugger.r-debugger'); if (rDebugger) { - void showRDebuggerCompatibilityWarningOnce(context.globalState, rDebugger.packageJSON, message => - vscode.window.showWarningMessage(message) + void showRDebuggerCompatibilityWarningOnce( + context.globalState, + rDebugger.packageJSON, + (message) => vscode.window.showWarningMessage(message), ); } if (vscode.extensions.getExtension('mikhail-arkhipov.r')) { - void vscode.window.showInformationMessage('The R Tools (Mikhail-Arkhipov.r) extension is enabled and will have conflicts with vscode-R. To use vscode-R, please disable or uninstall the extension.'); + void vscode.window.showInformationMessage( + 'The R Tools (Mikhail-Arkhipov.r) extension is enabled and will have conflicts with vscode-R. To use vscode-R, please disable or uninstall the extension.', + ); void vscode.commands.executeCommand('workbench.extensions.search', '@installed R Tools'); } @@ -72,7 +82,7 @@ export async function activate(context: vscode.ExtensionContext): Promise session.getViewerSessionContext(source.sessionId, source)!, + resolveSession: (source) => session.getViewerSessionContext(source.sessionId, source)!, getActiveSessionId: () => session.activeSession?.sessionId, }); @@ -81,7 +91,6 @@ export async function activate(context: vscode.ExtensionContext): Promise rTerminal.runSelectionOrWord(['names']), 'r.view': () => rTerminal.runSelectionOrWord(['View']), 'r.runSource': () => rTerminal.runSource(false), - 'r.runSelection': (code?: string) => code ? rTerminal.runTextInTerm(code) : rTerminal.runSelection(), + 'r.runSelection': (code?: string) => + code ? rTerminal.runTextInTerm(code) : rTerminal.runSelection(), 'r.runFromLineToEnd': rTerminal.runFromLineToEnd, 'r.runFromBeginningToLine': rTerminal.runFromBeginningToLine, 'r.runSelectionRetainCursor': rTerminal.runSelectionRetainCursor, @@ -119,15 +129,25 @@ export async function activate(context: vscode.ExtensionContext): Promise { void rmdKnitManager?.knitRmd(false, undefined); }, - 'r.knitRmdToPdf': () => { void rmdKnitManager?.knitRmd(false, 'pdf_document'); }, - 'r.knitRmdToHtml': () => { void rmdKnitManager?.knitRmd(false, 'html_document'); }, - 'r.knitRmdToAll': () => { void rmdKnitManager?.knitRmd(false, 'all'); }, + 'r.knitRmd': () => { + void rmdKnitManager?.knitRmd(false, undefined); + }, + 'r.knitRmdToPdf': () => { + void rmdKnitManager?.knitRmd(false, 'pdf_document'); + }, + 'r.knitRmdToHtml': () => { + void rmdKnitManager?.knitRmd(false, 'html_document'); + }, + 'r.knitRmdToAll': () => { + void rmdKnitManager?.knitRmd(false, 'all'); + }, 'r.rmarkdown.newDraft': () => rmarkdown.newDraft(), 'r.rmarkdown.setKnitDirectory': () => rmdKnitManager?.setKnitDir(), - 'r.rmarkdown.showPreviewToSide': () => rmdPreviewManager?.previewRmd(vscode.ViewColumn.Beside), - 'r.rmarkdown.showPreview': (uri: vscode.Uri) => rmdPreviewManager?.previewRmd(vscode.ViewColumn.Active, uri), + 'r.rmarkdown.showPreviewToSide': () => + rmdPreviewManager?.previewRmd(vscode.ViewColumn.Beside), + 'r.rmarkdown.showPreview': (uri: vscode.Uri) => + rmdPreviewManager?.previewRmd(vscode.ViewColumn.Active, uri), 'r.rmarkdown.preview.refresh': () => rmdPreviewManager?.updatePreview(), 'r.rmarkdown.preview.openExternal': () => void rmdPreviewManager?.openExternalBrowser(), 'r.rmarkdown.preview.showSource': () => rmdPreviewManager?.showSource(), @@ -137,7 +157,10 @@ export async function activate(context: vscode.ExtensionContext): Promise rmarkdown.newDraft(), - 'r.newFileDocument': () => vscode.workspace.openTextDocument({language: 'r'}).then((v) => vscode.window.showTextDocument(v)), + 'r.newFileDocument': () => + vscode.workspace + .openTextDocument({ language: 'r' }) + .then((v) => vscode.window.showTextDocument(v)), // editor independent commands 'r.createGitignore': rGitignore.createGitignore, @@ -146,11 +169,16 @@ export async function activate(context: vscode.ExtensionContext): Promise rTerminal.runTextInTerm('devtools::load_all()'), // environment independent commands. this is a workaround for using the Tasks API: https://github.com/microsoft/vscode/issues/40758 - 'r.build': () => vscode.commands.executeCommand('workbench.action.tasks.runTask', 'R: Build'), - 'r.buildBinary': () => vscode.commands.executeCommand('workbench.action.tasks.runTask', 'R: Build Binary'), - 'r.check': () => vscode.commands.executeCommand('workbench.action.tasks.runTask', 'R: Check'), - 'r.document': () => vscode.commands.executeCommand('workbench.action.tasks.runTask', 'R: Document'), - 'r.install': () => vscode.commands.executeCommand('workbench.action.tasks.runTask', 'R: Install'), + 'r.build': () => + vscode.commands.executeCommand('workbench.action.tasks.runTask', 'R: Build'), + 'r.buildBinary': () => + vscode.commands.executeCommand('workbench.action.tasks.runTask', 'R: Build Binary'), + 'r.check': () => + vscode.commands.executeCommand('workbench.action.tasks.runTask', 'R: Check'), + 'r.document': () => + vscode.commands.executeCommand('workbench.action.tasks.runTask', 'R: Document'), + 'r.install': () => + vscode.commands.executeCommand('workbench.action.tasks.runTask', 'R: Install'), 'r.test': () => vscode.commands.executeCommand('workbench.action.tasks.runTask', 'R: Test'), // interaction with R sessions @@ -162,8 +190,10 @@ export async function activate(context: vscode.ExtensionContext): Promise node && workspaceViewer.viewItem(node), - 'r.workspaceViewer.remove': (node: workspaceViewer.GlobalEnvItem) => node?.label && workspaceViewer.removeItem(node), + 'r.workspaceViewer.view': (node: workspaceViewer.GlobalEnvItem) => + node && workspaceViewer.viewItem(node), + 'r.workspaceViewer.remove': (node: workspaceViewer.GlobalEnvItem) => + node?.label && workspaceViewer.removeItem(node), 'r.workspaceViewer.clear': workspaceViewer.clearWorkspace, 'r.workspaceViewer.load': workspaceViewer.loadWorkspace, 'r.workspaceViewer.save': workspaceViewer.saveWorkspace, @@ -171,7 +201,8 @@ export async function activate(context: vscode.ExtensionContext): Promise vscode.commands.executeCommand('editor.action.webvieweditor.showFind'), + 'r.webview.find': () => + vscode.commands.executeCommand('editor.action.webvieweditor.showFind'), 'r.htmlViewer.restore': restoreHtmlViewer, 'r.htmlViewer.back': () => runHtmlViewerCommand('back'), 'r.htmlViewer.forward': () => runHtmlViewerCommand('forward'), @@ -184,21 +215,26 @@ export async function activate(context: vscode.ExtensionContext): Promise { - void session.updateTerminalDiscovery(terminal).catch(error => { - console.warn('[session discovery] Failed to update terminal discovery file', error); - }); - })); - context.subscriptions.push(vscode.window.onDidChangeActiveTerminal(session.switchSessionByTerminal)); + context.subscriptions.push( + vscode.window.onDidOpenTerminal((terminal) => { + void session.updateTerminalDiscovery(terminal).catch((error) => { + console.warn('[session discovery] Failed to update terminal discovery file', error); + }); + }), + ); + context.subscriptions.push( + vscode.window.onDidChangeActiveTerminal(session.switchSessionByTerminal), + ); // start language service if (util.config().get('lsp.enabled')) { const lsp = vscode.extensions.getExtension('reditorsupport.r-lsp'); if (lsp) { - void vscode.window.showInformationMessage('The R language server extension has been integrated into vscode-R. You need to disable or uninstall REditorSupport.r-lsp and reload window to use the new version.'); + void vscode.window.showInformationMessage( + 'The R language server extension has been integrated into vscode-R. You need to disable or uninstall REditorSupport.r-lsp and reload window to use the new version.', + ); void vscode.commands.executeCommand('workbench.extensions.search', '@installed r-lsp'); } else { context.subscriptions.push(new languageService.LanguageService()); @@ -211,12 +247,12 @@ export async function activate(context: vscode.ExtensionContext): Promise { // Match fs-extra: an inaccessible path is treated as absent. - return access(path).then(() => true, () => false); + return access(path).then( + () => true, + () => false, + ); } export async function readJson(path: PathLike): Promise { diff --git a/src/helpViewer/cran.ts b/src/helpViewer/cran.ts index 26b76c064..657697175 100644 --- a/src/helpViewer/cran.ts +++ b/src/helpViewer/cran.ts @@ -1,12 +1,11 @@ - import * as cheerio from 'cheerio'; -import { Package} from './packages'; +import { Package } from './packages'; import { getHttpText } from '../http'; type ParseFunction = (html: string, baseUrl: string) => Package[]; export async function getPackagesFromCran(cranUrl: string): Promise { - const cranSites: {url: string, parseFunction: ParseFunction}[] = [ + const cranSites: { url: string; parseFunction: ParseFunction }[] = [ // NOTE: Not working any more // { // url: new URL('stats/descriptions', cranUrl).toString(), @@ -14,16 +13,16 @@ export async function getPackagesFromCran(cranUrl: string): Promise { // }, { url: new URL('web/packages/available_packages_by_date.html', cranUrl).toString(), - parseFunction: parseCranTable + parseFunction: parseCranTable, }, { url: new URL('src/contrib/PACKAGES', cranUrl).toString(), - parseFunction: parseCranPackagesFile - } + parseFunction: parseCranPackagesFile, + }, ]; let packages: Package[] = []; - for(const site of cranSites){ - try{ + for (const site of cranSites) { + try { // fetch html const res = await getHttpText(site.url); const html = res.text; @@ -35,7 +34,7 @@ export async function getPackagesFromCran(cranUrl: string): Promise { } // break if successfully fetched & parsed - if(packages?.length){ + if (packages?.length) { break; } } @@ -43,18 +42,18 @@ export async function getPackagesFromCran(cranUrl: string): Promise { } function parseCranPackagesFile(html: string): Package[] { - const packageNames = html.match(/^Package: .*$/gm)?.map(s => s.replace(/^Package: /, '')) || []; - const packages: Package[] = packageNames.map(s => ({ + const packageNames = + html.match(/^Package: .*$/gm)?.map((s) => s.replace(/^Package: /, '')) || []; + const packages: Package[] = packageNames.map((s) => ({ name: s, description: '', - isCran: true + isCran: true, })); return packages; } - function parseCranTable(html: string, baseUrl: string): Package[] { - if(!html){ + if (!html) { return []; } const $ = cheerio.load(html); @@ -66,7 +65,9 @@ function parseCranTable(html: string, baseUrl: string): Package[] { tables.each((tableIndex, table) => { const rows = $('tr', table); rows.each((rowIndex, row) => { - if (rowIndex === 0) {return;} // Skip the header row + if (rowIndex === 0) { + return; + } // Skip the header row const date = $(row).find('td:nth-child(1)').text().trim(); const href = $(row).find('td:nth-child(2) a').attr('href'); const url = href ? new URL(href, baseUrl).toString() : undefined; @@ -77,7 +78,7 @@ function parseCranTable(html: string, baseUrl: string): Package[] { name: name, href: url, description: title, - isCran: true + isCran: true, }); }); }); diff --git a/src/helpViewer/helpPreviewer.ts b/src/helpViewer/helpPreviewer.ts index dad6cd4b2..462f1f990 100644 --- a/src/helpViewer/helpPreviewer.ts +++ b/src/helpViewer/helpPreviewer.ts @@ -1,4 +1,3 @@ - import * as path from 'path'; import * as fs from 'fs'; import * as vscode from 'vscode'; @@ -7,7 +6,6 @@ import * as ejs from 'ejs'; import { isDirSafe, isFileSafe, readFileSyncSafe, config, spawnAsync } from '../util'; import { Topic, TopicType } from './packages'; - // Information from the corresponding fields in DESCRIPION interface LocalPackageInfo { name?: string; @@ -54,7 +52,7 @@ export interface RHelpPreviewerOptions { // path to .ejs file to be used as 00Index.html in previewed packages indexTemplatePath: string; // path of the script used to convert .Rd to html - rdToHtmlScriptFile: string + rdToHtmlScriptFile: string; } export function makePreviewerList(options: RHelpPreviewerOptions): RLocalHelpPreviewer[] { @@ -63,14 +61,14 @@ export function makePreviewerList(options: RHelpPreviewerOptions): RLocalHelpPre const ret: RLocalHelpPreviewer[] = []; let previewCounter = 1; for (const workspace of workspaces) { - for(const subDir of subDirs){ + for (const subDir of subDirs) { const dir = vscode.Uri.joinPath(workspace.uri, subDir); const dirPath = dir.fsPath; const tmpPreviewer = new RLocalHelpPreviewer(options, dirPath, previewCounter); - if(tmpPreviewer.isPackageDir){ + if (tmpPreviewer.isPackageDir) { ret.push(tmpPreviewer); previewCounter = previewCounter + 1; - } else{ + } else { tmpPreviewer.dispose(); } } @@ -83,7 +81,6 @@ const DUMMY_TOPIC_VERSION = '?.?.?'; const DUMMY_PACKAGE_TITLE = ''; export class RLocalHelpPreviewer { - public readonly packageDir: string; private readonly descriptionPath: string; private readonly manDir: string; @@ -120,11 +117,11 @@ export class RLocalHelpPreviewer { } public dispose(callListener = false): void { this.isPackageDir = false; - while(this.fileWatchers.length){ + while (this.fileWatchers.length) { this.fileWatchers.pop()?.close(); } this.isDisposed = true; - if(callListener){ + if (callListener) { this.callPreviewListener(); } } @@ -133,7 +130,7 @@ export class RLocalHelpPreviewer { // It is expected that this instance will be disposed if this method returns false private watchFiles(): boolean { // Only watch any files, if both man dir and DESCRIPTION exist - if(!isFileSafe(this.descriptionPath) || !isDirSafe(this.manDir)){ + if (!isFileSafe(this.descriptionPath) || !isDirSafe(this.manDir)) { return false; } @@ -146,11 +143,14 @@ export class RLocalHelpPreviewer { this.cachedPackageInfo = undefined; this.callPreviewListener(); }; - const manDirListener: fs.WatchListener = (event: fs.WatchEventType, filename: string | null) => { - if(this.isDisposed){ + const manDirListener: fs.WatchListener = ( + event: fs.WatchEventType, + filename: string | null, + ) => { + if (this.isDisposed) { return; } - if(!isDirSafe(this.manDir)){ + if (!isDirSafe(this.manDir)) { this.dispose(true); return; } @@ -159,11 +159,11 @@ export class RLocalHelpPreviewer { } const fullPath = path.join(this.manDir, filename); // The cache is only initialized when it is needed for the first time: - if(this.cachedRdAliases){ + if (this.cachedRdAliases) { const rdAlias = getRdAlias(fullPath); - if(rdAlias){ + if (rdAlias) { this.cachedRdAliases.set(fullPath, rdAlias); - } else{ + } else { this.cachedRdAliases.delete(fullPath); } } @@ -171,12 +171,12 @@ export class RLocalHelpPreviewer { }; // Watch man dir/DESCRIPTION - const descWatcher = fs.watch(this.descriptionPath, {encoding: 'utf-8'}); + const descWatcher = fs.watch(this.descriptionPath, { encoding: 'utf-8' }); descWatcher.on('change', descriptionListener); descWatcher.on('error', errorListener); this.fileWatchers.push(descWatcher); - const manDirWatcher = fs.watch(this.manDir, {encoding: 'utf-8'}); + const manDirWatcher = fs.watch(this.manDir, { encoding: 'utf-8' }); manDirWatcher.on('change', manDirListener); manDirWatcher.on('error', errorListener); this.fileWatchers.push(manDirWatcher); @@ -185,11 +185,11 @@ export class RLocalHelpPreviewer { } public getPackageInfo(): LocalPackageInfo | undefined { - if(this.cachedPackageInfo){ + if (this.cachedPackageInfo) { return this.cachedPackageInfo; } const desc = readFileSyncSafe(this.descriptionPath, 'utf-8'); - if(!desc){ + if (!desc) { return undefined; } const packageInfo: LocalPackageInfo = {}; @@ -205,34 +205,43 @@ export class RLocalHelpPreviewer { public getPackageName(safe?: boolean): string { const packageName = this.getPackageInfo()?.name; - if(!packageName || (safe && !isValidPackageName(packageName))){ + if (!packageName || (safe && !isValidPackageName(packageName))) { return this.dummyPackageName; } return packageName; } // Methods that imitate the HelpProvider - public async getHelpFileFromRequestPath(requestPath: string): Promise { - if(this.isDisposed){ + public async getHelpFileFromRequestPath( + requestPath: string, + ): Promise { + if (this.isDisposed) { return undefined; } - const {pkg, topic} = parseRequestPath(requestPath); - if(!topic || !pkg || (pkg !== this.getPackageName() && pkg !== this.getPackageName(true))){ + const { pkg, topic } = parseRequestPath(requestPath); + if ( + !topic || + !pkg || + (pkg !== this.getPackageName() && pkg !== this.getPackageName(true)) + ) { return undefined; } - if(topic === '00Index'){ + if (topic === '00Index') { return this.getHelpForIndex(requestPath); } - if(topic === 'DESCRIPTION'){ + if (topic === 'DESCRIPTION') { return this.getHelpForDescription(requestPath); } return await this.getHelpForTopic(topic, requestPath); } - private async getHelpForTopic(topic: string, requestPath: string): Promise { + private async getHelpForTopic( + topic: string, + requestPath: string, + ): Promise { // Make sure the topic has a valid .Rd file const rdFileName = this.getRdPathForTopic(topic); - if(!rdFileName || !isFileSafe(rdFileName)){ + if (!rdFileName || !isFileSafe(rdFileName)) { return undefined; } @@ -248,10 +257,10 @@ export class RLocalHelpPreviewer { rdFileName, this.getPackageName(true), this.getPackageInfo()?.version || DUMMY_TOPIC_VERSION, - this.packageDir + this.packageDir, ]; const spawnRet = await spawnAsync(this.rPath, args); - if(spawnRet.status){ + if (spawnRet.status) { // The user expects this to work, so we show a warning if it doesn't: const msg = `Failed to convert .Rd file ${rdFileName} (status: ${spawnRet.status}): ${spawnRet.stderr}`; void vscode.window.showWarningMessage(msg); @@ -267,20 +276,20 @@ export class RLocalHelpPreviewer { requestPath: requestPath, isPreview: true, rdPath: rdFileName, - packageDir: this.packageDir + packageDir: this.packageDir, }; // Add path of .R containing Roxygen documentation const rdTxt = fs.readFileSync(rdFileName, 'utf-8').replaceAll(/\r/g, ''); const localRPaths = extractRPaths(rdTxt); - helpFile.rPaths = localRPaths?.map(p => path.join(this.packageDir, p)); + helpFile.rPaths = localRPaths?.map((p) => path.join(this.packageDir, p)); return helpFile; } private getRdPathForTopic(topic: string): string | undefined { const rdAliases = this.getRdAliases(); - for(const [fullPath, rdAlias] of rdAliases){ - if(rdAlias.aliases.includes(topic)){ + for (const [fullPath, rdAlias] of rdAliases) { + if (rdAlias.aliases.includes(topic)) { return fullPath; } } @@ -289,7 +298,7 @@ export class RLocalHelpPreviewer { private getHelpForDescription(requestPath: string): rHelp.HelpFile | undefined { const desc = readFileSyncSafe(this.descriptionPath); - if(!desc){ + if (!desc) { return undefined; } // might need to be handled differently if the handling in index.ts changes: @@ -298,14 +307,14 @@ export class RLocalHelpPreviewer { requestPath: requestPath, isPreview: true, rdPath: this.descriptionPath, - packageDir: this.packageDir + packageDir: this.packageDir, }; return helpFile; } private getHelpForIndex(requestPath: string): rHelp.HelpFile | undefined { const html = this.makeIndexHtml(); - if(!html){ + if (!html) { return undefined; } const helpFile: rHelp.HelpFile = { @@ -314,7 +323,7 @@ export class RLocalHelpPreviewer { isPreview: true, isIndex: true, rdPath: undefined, - packageDir: this.packageDir + packageDir: this.packageDir, }; return helpFile; @@ -322,36 +331,36 @@ export class RLocalHelpPreviewer { private makeIndexHtml(): string | undefined { const pkgInfo = this.getPackageInfo(); - if(!pkgInfo){ + if (!pkgInfo) { return undefined; } const aliases = this.getAliases(); - const topics = aliases.map(alias => ({ + const topics = aliases.map((alias) => ({ name: alias.alias, title: alias.title || DUMMY_TOPIC_TITLE, - href: `${alias.name}.html` + href: `${alias.name}.html`, })); const ejsData: IndexEjsData = { packageName: pkgInfo.name || this.dummyPackageName, packageTitle: pkgInfo.title || DUMMY_PACKAGE_TITLE, packageVersion: pkgInfo.version || DUMMY_TOPIC_VERSION, - topics: topics + topics: topics, }; const html = ejs.render(this.indexTemplate, ejsData); return html; } // Method that imitates the AliasProvider - public getAliases(): AliasExtra[] { + public getAliases(): AliasExtra[] { const rdAliases = this.getRdAliases().values(); const pkgName = this.getPackageName(); - const aliases = [...rdAliases].flatMap(rdAlias => rdAliasToAliases(rdAlias, pkgName)); + const aliases = [...rdAliases].flatMap((rdAlias) => rdAliasToAliases(rdAlias, pkgName)); return aliases; } private getRdAliases(): Map { // Return cache if exists (is updated by file watchers) - if(this.cachedRdAliases){ + if (this.cachedRdAliases) { return this.cachedRdAliases; } // Else, initialize and populate cache @@ -367,18 +376,18 @@ export class RLocalHelpPreviewer { public getTreeViewTopics(summarize: boolean = false): Topic[] { const pkgName = this.getPackageName(true); let topics: Topic[]; - if(summarize){ + if (summarize) { const rdAliases = getRdAliases(this.manDir); - topics = rdAliases.map(rdAlias => rdAliasToTreeViewTopic(rdAlias, pkgName)); - } else{ + topics = rdAliases.map((rdAlias) => rdAliasToTreeViewTopic(rdAlias, pkgName)); + } else { const aliases = this.getAliases(); - topics = aliases.map(alias => { + topics = aliases.map((alias) => { const helpPath = `/library/${pkgName}/html/${alias.alias}.html`; const topic: Topic = { name: alias.alias, description: alias.title || DUMMY_TOPIC_TITLE, type: TopicType.NORMAL, - helpPath: helpPath + helpPath: helpPath, }; return topic; }); @@ -388,54 +397,51 @@ export class RLocalHelpPreviewer { name: 'DESCRIPTION', description: '', helpPath: `/library/${pkgName}/DESCRIPTION`, - type: TopicType.META + type: TopicType.META, }; const indexTopic: Topic = { name: 'Index', description: '', helpPath: `/library/${pkgName}/html/00Index.html`, - type: TopicType.INDEX + type: TopicType.INDEX, }; topics.unshift(indexTopic, descriptionTopic); return topics; } - } - // Helper function to extract the names of R files referenced in an .Rd file function extractRPaths(rdTxt: string): string[] | undefined { // Find the commented lines at the begining of the document const lines = rdTxt.replaceAll(/\r/g, '').split('\n'); - const firstRealLine = lines.findIndex(lines => !lines.startsWith('% ')); - if(firstRealLine >= 0){ + const firstRealLine = lines.findIndex((lines) => !lines.startsWith('% ')); + if (firstRealLine >= 0) { lines.splice(firstRealLine); } - + // Join lines that were split (these start with "% ") const CONTINUED_LINE_START = '% '; const longLines = []; - for(const line of lines){ - if(line.startsWith(CONTINUED_LINE_START) && longLines.length){ + for (const line of lines) { + if (line.startsWith(CONTINUED_LINE_START) && longLines.length) { longLines[longLines.length - 1] += ' ' + line.substring(CONTINUED_LINE_START.length); - } else{ + } else { longLines.push(line); } } - + // Find the line that references R files - for(const line of longLines){ + for (const line of longLines) { const rFileMatch = line.match(/^% Please edit documentation in (.*)$/); - if(rFileMatch){ - const localRPaths = rFileMatch?.[1].split(',').map(s => s.trim()); + if (rFileMatch) { + const localRPaths = rFileMatch?.[1].split(',').map((s) => s.trim()); return localRPaths; } } return undefined; } - // Helper function to parse a request path // Accepts e.g. paths of the forms // - library/PKG/html/TOPIC @@ -443,14 +449,14 @@ function extractRPaths(rdTxt: string): string[] | undefined { // - library/PKG/help/TOPIC.html/.... // - library/PKG/TOPIC function parseRequestPath(requestPath: string): { - pkg?: string, - topic?: string + pkg?: string; + topic?: string; } { const re = /^\/?library\/([^/]*)\/(?:html|help)?\/?([^/]*?)(?:\.html.*)?$/; const m = re.exec(requestPath); return { pkg: m?.[1], - topic: m?.[2].replace(/^dot-/, '.') + topic: m?.[2].replace(/^dot-/, '.'), }; } @@ -461,12 +467,11 @@ function rdAliasToTreeViewTopic(rdAlias: RdAlias, pkgName: string): Topic { name: rdAlias.title || rdAlias.name || DUMMY_TOPIC_TITLE, type: TopicType.NORMAL, aliases: rdAlias.aliases, - description: rdAlias.title || DUMMY_TOPIC_TITLE + description: rdAlias.title || DUMMY_TOPIC_TITLE, }; return ret; } - // Check if a package name is valid function isValidPackageName(pkgName: string): boolean { // regex to chekc pkgName (length >=2 implied): @@ -475,29 +480,28 @@ function isValidPackageName(pkgName: string): boolean { return !!re.exec(pkgName); } - // Helper functions to read/convert rdAliases and aliases function rdAliasToAliases(rdAlias: RdAlias, pkgName: string): AliasExtra[] { - return rdAlias.aliases.map(alias => ({ + return rdAlias.aliases.map((alias) => ({ package: pkgName, name: rdAlias.name, alias: alias, title: rdAlias.title, - rdPath: rdAlias.filepath + rdPath: rdAlias.filepath, })); } function getRdAliases(manDir: string): RdAlias[] { const manFiles = fs.readdirSync(manDir) || []; const aliases: RdAlias[] = []; - manFiles.forEach(filename => { - if(!filename.match(/\.[Rr][Dd]$/)){ + manFiles.forEach((filename) => { + if (!filename.match(/\.[Rr][Dd]$/)) { return; } const fullPath = path.join(manDir, filename); const rdAlias = getRdAlias(fullPath); - if(rdAlias){ + if (rdAlias) { aliases.push(rdAlias); } }); @@ -506,18 +510,18 @@ function getRdAliases(manDir: string): RdAlias[] { function getRdAlias(rdFile: string): RdAlias | undefined { const txt = readFileSyncSafe(rdFile, 'utf-8'); - if(!txt){ + if (!txt) { return undefined; } const nameMatch = txt.match(/\\name\{(.*)\}/); const name = nameMatch?.[1]; - if(!name){ + if (!name) { return undefined; } const ret: RdAlias = { filepath: rdFile, name: name, - aliases: [] + aliases: [], }; const titleMatch = txt.match(/\\title\{(.*)\}/); ret.title = titleMatch?.[1]; diff --git a/src/helpViewer/helpProvider.ts b/src/helpViewer/helpProvider.ts index fe42baa0f..8c81d7b15 100644 --- a/src/helpViewer/helpProvider.ts +++ b/src/helpViewer/helpProvider.ts @@ -26,7 +26,7 @@ export class HelpProvider { private readonly cwd?: string; private readonly pkgListener?: () => void; - public constructor(options: RHelpProviderOptions){ + public constructor(options: RHelpProviderOptions) { this.rPath = options.rPath || 'R'; this.cwd = options.cwd; this.pkgListener = options.pkgListener; @@ -39,7 +39,7 @@ export class HelpProvider { await this.cp.port; } - public launchRHelpServer(): ChildProcessWithPort{ + public launchRHelpServer(): ChildProcessWithPort { const lim = '---vsc---'; const portRegex = new RegExp(`.*${lim}(.*)${lim}.*`, 'ms'); @@ -55,7 +55,7 @@ export class HelpProvider { '-e', 'base::source(base::commandArgs(TRUE))', '--args', - scriptPath + scriptPath, ]; const cpOptions = { cwd: this.cwd, @@ -63,7 +63,7 @@ export class HelpProvider { ...process.env, VSCR_LIB_PATHS: getRLibPaths(), VSCR_LIM: lim, - VSCR_USE_RENV_LIB_PATH: config().get('useRenvLibPath') ? 'TRUE' : 'FALSE' + VSCR_USE_RENV_LIB_PATH: config().get('useRenvLibPath') ? 'TRUE' : 'FALSE', }, }; @@ -73,16 +73,16 @@ export class HelpProvider { // promise containing the port number of the process (or 0) const portPromise = new Promise((resolve) => { childProcess.stdout?.on('data', (data: Buffer) => { - try{ + try { str += data.toString(); } catch { resolve(0); } - if(portRegex.exec(str)){ + if (portRegex.exec(str)) { resolve(Number(str.replace(portRegex, '$1'))); str = str.replace(portRegex, ''); } - if(newPackageRegex.exec(str)){ + if (newPackageRegex.exec(str)) { this.pkgListener?.(); str = str.replace(newPackageRegex, ''); } @@ -105,15 +105,16 @@ export class HelpProvider { return childProcess; } - public async getHelpFileFromRequestPath(requestPath: string): Promise { - + public async getHelpFileFromRequestPath( + requestPath: string, + ): Promise { const port = await this.cp?.port; - if(!port || typeof port !== 'number'){ + if (!port || typeof port !== 'number') { return undefined; } // remove leading '/' - while(requestPath.startsWith('/')){ + while (requestPath.startsWith('/')) { requestPath = requestPath.slice(1); } @@ -123,7 +124,7 @@ export class HelpProvider { // or manual is not installed locally (src/library/tools/R/dynamicHelp.R). // The initial request is loopback, but HTTP(S) redirects may be external. const rep = await getHttpText(url); - if(rep.status !== 200){ + if (rep.status !== 200) { return undefined; } const html = rep.text; @@ -138,7 +139,7 @@ export class HelpProvider { requestPath: requestPath1, html: html, isRealFile: false, - url: url.href + url: url.href, }; return ret; } @@ -148,7 +149,6 @@ export class HelpProvider { } } - export interface AliasProviderArgs { // R path, must be vanilla R rPath: string; @@ -166,23 +166,22 @@ interface PackageAliases { aliasFile?: string; aliases?: { [key: string]: string; - }, - error?: string + }; + error?: string; } interface AllPackageAliases { - [key: string]: PackageAliases + [key: string]: PackageAliases; } // Implements the aliasProvider required by the help panel export class AliasProvider { - private readonly rPath: string; private readonly cwd?: string; private readonly rScriptFile: string; private aliases?: undefined | rHelp.Alias[]; private readonly persistentState?: Memento; - constructor(args: AliasProviderArgs){ + constructor(args: AliasProviderArgs) { this.rPath = args.rPath; this.cwd = args.cwd; this.rScriptFile = args.rScriptFile; @@ -199,13 +198,13 @@ export class AliasProvider { // get a list of all aliases public async getAllAliases(): Promise { // try this.aliases: - if(this.aliases){ + if (this.aliases) { return this.aliases; } // try cached aliases: const cachedAliases = this.persistentState?.get('r.helpPanel.cachedAliases'); - if(cachedAliases){ + if (cachedAliases) { this.aliases = cachedAliases; return cachedAliases; } @@ -221,7 +220,7 @@ export class AliasProvider { private async makeAllAliases(): Promise { // get aliases from R (nested format) const allPackageAliases = await this.getAliasesFromR(); - if(!allPackageAliases){ + if (!allPackageAliases) { return undefined; } @@ -232,16 +231,18 @@ export class AliasProvider { const pkgName = item.package || pkg; if (item.error) { - void window.showErrorMessage(`An error occurred while reading the aliases file for package ${pkgName}: ${item.error}. The package files may be corrupted. Try reinstalling the package.`); + void window.showErrorMessage( + `An error occurred while reading the aliases file for package ${pkgName}: ${item.error}. The package files may be corrupted. Try reinstalling the package.`, + ); continue; } const pkgAliases = item.aliases || {}; - for(const fncName in pkgAliases){ + for (const fncName in pkgAliases) { allAliases.push({ name: pkgAliases[fncName], alias: fncName, - package: pkgName + package: pkgName, }); } } @@ -257,18 +258,11 @@ export class AliasProvider { ...process.env, VSCR_LIB_PATHS: getRLibPaths(), VSCR_LIM: lim, - VSCR_USE_RENV_LIB_PATH: config().get('useRenvLibPath') ? 'TRUE' : 'FALSE' - } + VSCR_USE_RENV_LIB_PATH: config().get('useRenvLibPath') ? 'TRUE' : 'FALSE', + }, }; - const args = [ - '--silent', - '--no-echo', - '--no-save', - '--no-restore', - '-f', - this.rScriptFile - ]; + const args = ['--silent', '--no-echo', '--no-save', '--no-restore', '-f', this.rScriptFile]; try { const result = await spawnAsync(this.rPath, args, options); diff --git a/src/helpViewer/index.ts b/src/helpViewer/index.ts index 97ccc9c03..be12481c6 100644 --- a/src/helpViewer/index.ts +++ b/src/helpViewer/index.ts @@ -15,23 +15,23 @@ import { uniqueEntries, isFileSafe, } from '../util'; -import {HelpPanel} from './panel'; -import {HelpProvider, AliasProvider} from './helpProvider'; -import {HelpTreeWrapper} from './treeView'; -import {PackageManager} from './packages'; +import { HelpPanel } from './panel'; +import { HelpProvider, AliasProvider } from './helpProvider'; +import { HelpTreeWrapper } from './treeView'; +import { PackageManager } from './packages'; import { makePreviewerList, RHelpPreviewerOptions, RLocalHelpPreviewer } from './helpPreviewer'; hljs.registerLanguage('r', r); export type CodeClickAction = 'Ignore' | 'Copy' | 'Run'; export interface CodeClickConfig { - 'Click': CodeClickAction, - 'Ctrl+Click': CodeClickAction, - 'Shift+Click': CodeClickAction, + Click: CodeClickAction; + 'Ctrl+Click': CodeClickAction; + 'Shift+Click': CodeClickAction; } const CODE_CLICKS: (keyof CodeClickConfig)[] = ['Click', 'Ctrl+Click', 'Shift+Click']; export const codeClickConfigDefault = { - 'Click': 'Copy', + Click: 'Copy', 'Ctrl+Click': 'Run', 'Shift+Click': 'Ignore', }; @@ -46,7 +46,7 @@ export async function initializeHelp( // get the "vanilla" R path from config const rPath = await getRpath(); - if(!rPath){ + if (!rPath) { return undefined; } @@ -56,15 +56,13 @@ export async function initializeHelp( : undefined; // get the Memento for storing cached help files (or create a dummy for this session) - const cacheConfig = config().get<'None' | 'Workspace' | 'Global'>( - 'helpPanel.cacheIndexFiles', - ); + const cacheConfig = config().get<'None' | 'Workspace' | 'Global'>('helpPanel.cacheIndexFiles'); const persistentState = cacheConfig === 'Workspace' ? context.workspaceState : cacheConfig === 'Global' - ? context.globalState - : new DummyMemento(); + ? context.globalState + : new DummyMemento(); // Gather options used in r help related files const rHelpOptions: HelpOptions = { @@ -110,28 +108,26 @@ export async function initializeHelp( ), vscode.commands.registerCommand( 'r.helpPanel.openForSelection', - (preserveFocus: boolean = false) => - rHelp?.openHelpForSelection(!!preserveFocus), - ), - vscode.commands.registerCommand( - 'r.helpPanel.openForPath', - (path?: string) => { - if (path) { - void rHelp?.showHelpForPath(path); - } - }, + (preserveFocus: boolean = false) => rHelp?.openHelpForSelection(!!preserveFocus), ), + vscode.commands.registerCommand('r.helpPanel.openForPath', (path?: string) => { + if (path) { + void rHelp?.showHelpForPath(path); + } + }), vscode.commands.registerCommand( 'r.helpPanel.openFileByPath', async (filepath: string, warn?: boolean) => { - if(isFileSafe(filepath)){ + if (isFileSafe(filepath)) { const uri = vscode.Uri.file(filepath); await vscode.window.showTextDocument(uri); - } else if(warn){ - await vscode.window.showWarningMessage(`The file does not exist: ${filepath}`); + } else if (warn) { + await vscode.window.showWarningMessage( + `The file does not exist: ${filepath}`, + ); } - } - ) + }, + ), ); vscode.window.registerWebviewPanelSerializer('rhelp', rHelp); @@ -143,26 +139,26 @@ export async function initializeHelp( // Internal representation of a help file export interface HelpFile { // content of the file - html: string + html: string; // whether the html has been modified already (syntax highlighting etc.) - isModified?: boolean + isModified?: boolean; // original content of the file (only used if isModified===true) - html0?: string + html0?: string; // flag indicating whether the original file content is html - isHtml?: boolean + isHtml?: boolean; // path as used by help server. Uses '/' as separator! - requestPath: string + requestPath: string; // hash-part of the requested URL - hash?: string + hash?: string; // if the file is a real file - isRealFile?: boolean + isRealFile?: boolean; // can be set to true to indicate that the file is a (virtual) 00Index.html file - isIndex?: boolean + isIndex?: boolean; // can be used to scroll the document to a certain position when loading // useful to remember scroll position when going back/forward - scrollY?: number + scrollY?: number; // used to open the file in an external browser - url?: string + url?: string; // indicates that this is a preview generated from a .Rd file isPreview?: boolean; // the .Rd file that this is based on (if it is a preview) @@ -175,78 +171,77 @@ export interface HelpFile { // Internal representation of an "Alias" export interface Alias { - // main name of a help topic - name: string + // main name of a help topic + name: string; // one of possibly many aliases of the same help topic - alias: string + alias: string; // name of the package the alias is from - package: string + package: string; } // Options to be specified when creating a new rHelp instance (used only once per session) export interface HelpOptions { /* Local path of script.js, used to send messages to vs code */ - webviewScriptPath: string + webviewScriptPath: string; /* Local path of theme.css, used to actually format the highlighted syntax */ - webviewStylePath: string + webviewStylePath: string; // path of the R executable - rPath: string + rPath: string; // directory in which to launch R processes - cwd?: string + cwd?: string; // path of getAliases.R - rScriptFile: string + rScriptFile: string; // path of the script used to convert .Rd to html - rdToHtmlScriptFile: string + rdToHtmlScriptFile: string; // persistent state, either global or workspace specific - persistentState: vscode.Memento + persistentState: vscode.Memento; // used by some helper classes: - rHelp?: RHelp + rHelp?: RHelp; // path to .ejs file to be used as 00Index.html in previewed packages indexTemplatePath: string; } // The name api.HelpPanel is a bit misleading // This class manages all R-help and R-packages related functions -export class RHelp implements api.HelpPanel, vscode.WebviewPanelSerializer -{ +export class RHelp implements api.HelpPanel, vscode.WebviewPanelSerializer { // Path of a vanilla R installation - readonly rPath: string + readonly rPath: string; // If applicable, the currently opened wd. // Used to read the correct .Rprofile when launching R - readonly cwd?: string + readonly cwd?: string; // Provides the content of help pages: - readonly helpProvider: HelpProvider + readonly helpProvider: HelpProvider; // Provides a list of aliases: - readonly aliasProvider: AliasProvider - + readonly aliasProvider: AliasProvider; + // Provides previews of local help pages: - readonly previewProviders: RLocalHelpPreviewer[] + readonly previewProviders: RLocalHelpPreviewer[]; // Show/Install/Remove packages: - readonly packageManager: PackageManager + readonly packageManager: PackageManager; // The tree view that shows available packages and help topics - readonly treeViewWrapper: HelpTreeWrapper + readonly treeViewWrapper: HelpTreeWrapper; // the webview panel(s) where the help is shown - public readonly helpPanels: HelpPanel[] = [] + public readonly helpPanels: HelpPanel[] = []; // locations on disk, only changed on construction - readonly webviewScriptFile: vscode.Uri // the javascript added to help pages - readonly webviewStyleFile: vscode.Uri // the css file applied to help pages + readonly webviewScriptFile: vscode.Uri; // the javascript added to help pages + readonly webviewStyleFile: vscode.Uri; // the css file applied to help pages // cache for modified help files (syntax highlighting etc.) private cachedHelpFiles: Map = new Map< string, HelpFile | undefined - >() + >(); // The options used when creating this instance - private helpPanelOptions: HelpOptions - private helpPreviewerOptions: RHelpPreviewerOptions + private helpPanelOptions: HelpOptions; + private helpPreviewerOptions: RHelpPreviewerOptions; constructor(options: HelpOptions) { this.rPath = options.rPath; @@ -269,18 +264,15 @@ export class RHelp implements api.HelpPanel, vscode.WebviewPanelSerializer { + async deserializeWebviewPanel(webviewPanel: vscode.WebviewPanel, path: string): Promise { const panel = this.makeNewHelpPanel(webviewPanel); await this.showHelpForPath(path, undefined, true, panel); return; @@ -294,14 +286,10 @@ export class RHelp implements api.HelpPanel, vscode.WebviewPanelSerializer= 0){ + if (ind >= 0) { this.previewProviders.splice(ind, 1); this.treeViewWrapper.refreshRootNode(); } - void vscode.window.showWarningMessage(`Disposing R-Help Previewer for: ${previewer.packageDir}`); - } else{ + void vscode.window.showWarningMessage( + `Disposing R-Help Previewer for: ${previewer.packageDir}`, + ); + } else { for (const panel of this.helpPanels) { void panel.refreshPreview(previewer.packageDir); } @@ -374,8 +364,8 @@ export class RHelp implements api.HelpPanel, vscode.WebviewPanelSerializer { + public async openHelpForSelection(preserveFocus: boolean = false): Promise { // only use if we failed to show help page: let errMsg = ''; @@ -433,10 +417,7 @@ export class RHelp implements api.HelpPanel, vscode.WebviewPanelSerializer find word at current cursor position // use regex including ":" to capture package/namespace (e.g. base::print) const re = /([a-zA-Z0-9._:])+/; - const range = editor.document.getWordRangeAtPosition( - editor.selection.start, - re, - ); + const range = editor.document.getWordRangeAtPosition(editor.selection.start, re); // check if the cursor is at a word (else: whitespace -> ignore) if (range) { txt = editor.document.getText(range); @@ -489,11 +470,9 @@ export class RHelp implements api.HelpPanel, vscode.WebviewPanelSerializer { + public async getMatchingAliases(token: string): Promise { const aliases = await this.getAllAliases(true); - if(!aliases){ + if (!aliases) { return undefined; } @@ -503,12 +482,11 @@ export class RHelp implements api.HelpPanel, vscode.WebviewPanelSerializer ( - a1.package === a2.package - && a1.name.replace(/^dot-/, '.') === a2.name.replace(/^dot-/, '.') - ); + const aliasesIdentical = (a1: Alias, a2: Alias) => + a1.package === a2.package && + a1.name.replace(/^dot-/, '.') === a2.name.replace(/^dot-/, '.'); const uniqueAliases = uniqueEntries(matchingAliases, aliasesIdentical); return uniqueAliases; @@ -527,7 +505,7 @@ export class RHelp implements api.HelpPanel, vscode.WebviewPanelSerializer { const aliases = await doWithProgress( () => this.aliasProvider.getAllAliases(), - vscode.ProgressLocation.Window + vscode.ProgressLocation.Window, ); if (!aliases) { void vscode.window.showErrorMessage( @@ -535,8 +513,8 @@ export class RHelp implements api.HelpPanel, vscode.WebviewPanelSerializer { + if (includePreview) { + const previewAliases: Alias[] = this.previewProviders.flatMap((previewer) => { return previewer.getAliases() || []; }); aliases.push(...previewAliases); @@ -546,10 +524,7 @@ export class RHelp implements api.HelpPanel, vscode.WebviewPanelSerializer { + private async pickAlias(aliases?: Alias[], prompt?: string): Promise { prompt ||= 'Please type a function name/documentation entry'; aliases ||= await this.getAllAliases(); if (!aliases) { @@ -570,10 +545,7 @@ export class RHelp implements api.HelpPanel, vscode.WebviewPanelSerializer { + private async showHelpForAlias(alias: Alias, preserveFocus: boolean = false): Promise { return this.showHelpForPath( `/library/${alias.package}/html/${alias.name}.html`, undefined, @@ -603,11 +575,11 @@ export class RHelp implements api.HelpPanel, vscode.WebviewPanelSerializer { // try to get a preview first const preview = await this.getHelpPreviewForPath(requestPath); - if(preview){ + if (preview) { pimpMyHelp(preview); return preview; } @@ -634,11 +606,11 @@ export class RHelp implements api.HelpPanel, vscode.WebviewPanelSerializer { for (const previewer of this.previewProviders) { const ret = await previewer.getHelpFileFromRequestPath(requestPath); - if(ret){ + if (ret) { return ret; } } @@ -653,13 +625,7 @@ export class RHelp implements api.HelpPanel, vscode.WebviewPanelSerializer { panel ||= this.getNewestHelpPanel(); - return await panel.showHelpFile( - helpFile, - undefined, - undefined, - viewer, - preserveFocus, - ); + return await panel.showHelpFile(helpFile, undefined, undefined, viewer, preserveFocus); } } @@ -678,18 +644,18 @@ function pimpMyHelp(helpFile: HelpFile): HelpFile { // Make sure the helpfile content is actually html const re = /^.*', 'ms'); - helpFile.isHtml = (!!re.exec(helpFile.html) || !!re2.exec(helpFile.html)); + helpFile.isHtml = !!re.exec(helpFile.html) || !!re2.exec(helpFile.html); if (!helpFile.isHtml) { const html = escapeHtml(helpFile.html); helpFile.html = `
${html}
`; helpFile.isModified = true; } - + // parse the html string for futher modifications const $ = cheerio.load(helpFile.html); // use .isHtml as proxy for syntax highlighting, clickable
 etc.
-    if(helpFile.isHtml){
+    if (helpFile.isHtml) {
         // Remove style elements specified in the html itself (replaced with custom CSS)
         $('head style').remove();
 
@@ -701,21 +667,23 @@ function pimpMyHelp(helpFile: HelpFile): HelpFile {
 
         // Split code examples at empty lines:
         const codeClickConfig = config().get('helpPanel.clickCodeExamples');
-        const isEnabled = CODE_CLICKS.some(k => codeClickConfig?.[k] !== 'Ignore');
-        if(isEnabled){
+        const isEnabled = CODE_CLICKS.some((k) => codeClickConfig?.[k] !== 'Ignore');
+        if (isEnabled) {
             $('body').addClass('preClickable');
             const codeSections = $('pre');
             codeSections.each((i, section) => {
                 const innerHtml = $(section).html();
-                if(!innerHtml){
+                if (!innerHtml) {
                     return;
                 }
-                const newPres = innerHtml.split('\n\n').map(s => s && `
${s}
`); + const newPres = innerHtml + .split('\n\n') + .map((s) => s && `
${s}
`); const newHtml = '
' + newPres.join('\n') + '
'; $(section).replaceWith(newHtml); }); } - if(codeClickConfig?.Click !== 'Ignore'){ + if (codeClickConfig?.Click !== 'Ignore') { $('body').addClass('preHoverPointer'); } @@ -735,26 +703,35 @@ function pimpMyHelp(helpFile: HelpFile): HelpFile { } // Highlight help preview: - if(helpFile.isPreview){ + if (helpFile.isPreview) { let rdInfo: string; - if(helpFile.isIndex){ - rdInfo = 'local .Rd files. Might containt non-exported entries that will not be present in the installed Index'; - } else if(helpFile.rdPath && isFileSafe(helpFile.rdPath)){ + if (helpFile.isIndex) { + rdInfo = + 'local .Rd files. Might containt non-exported entries that will not be present in the installed Index'; + } else if (helpFile.rdPath && isFileSafe(helpFile.rdPath)) { const localRdPath = vscode.workspace.asRelativePath(helpFile.rdPath); const rdUri = vscode.Uri.file(helpFile.rdPath); - const cmdUri = makeWebviewCommandUriString('r.helpPanel.openFileByPath', rdUri.fsPath, true); + const cmdUri = makeWebviewCommandUriString( + 'r.helpPanel.openFileByPath', + rdUri.fsPath, + true, + ); rdInfo = `${localRdPath}`; - } else{ + } else { rdInfo = `a local file`; } - if(helpFile.rPaths?.length){ - const rHrefs = helpFile.rPaths.map(rPath => { + if (helpFile.rPaths?.length) { + const rHrefs = helpFile.rPaths.map((rPath) => { const localRPath = vscode.workspace.asRelativePath(rPath); - if(isFileSafe(rPath)){ + if (isFileSafe(rPath)) { const rUri = vscode.Uri.file(rPath); - const cmdUri = makeWebviewCommandUriString('r.helpPanel.openFileByPath', rUri.fsPath, true); + const cmdUri = makeWebviewCommandUriString( + 'r.helpPanel.openFileByPath', + rUri.fsPath, + true, + ); return `${localRPath}`; - } else{ + } else { return localRPath; } }); diff --git a/src/helpViewer/packages.ts b/src/helpViewer/packages.ts index 87f6be74b..77610865c 100644 --- a/src/helpViewer/packages.ts +++ b/src/helpViewer/packages.ts @@ -1,4 +1,3 @@ - import * as cheerio from 'cheerio'; import * as vscode from 'vscode'; @@ -6,7 +5,6 @@ import { RHelp } from '.'; import { getConfirmation, executeAsTask, doWithProgress, getCranUrl } from '../util'; import { getPackagesFromCran } from './cran'; - // This file implements a rudimentary 'package manager' // The exported class PackageManager contains methods to // * list installed packages @@ -15,7 +13,6 @@ import { getPackagesFromCran } from './cran'; // * remove installed packages // * install packages, selected from CRAN using a quickpick - // Types of help topics export enum TopicType { // "Home page" of a package, e.g. .../base-package.html @@ -25,10 +22,9 @@ export enum TopicType { // E.g. DESCRIPTION META, // Regular help topic containing help about an R function etc. - NORMAL + NORMAL, } - // interface containing information about an individual help topic export interface Topic { name: string; @@ -41,7 +37,6 @@ interface TopicExtra extends Topic { href: string; } - // interface containing info about a package // can be either installed locally or parsed from the CRAN website export interface Package { @@ -66,19 +61,16 @@ export interface IndexEntry { href?: string; } -type CachedIndexFiles = {path: string, items: IndexEntry[] | undefined}[]; - +type CachedIndexFiles = { path: string; items: IndexEntry[] | undefined }[]; export interface PackageManagerOptions { - rPath: string, - rHelp: RHelp, - persistentState: vscode.Memento, - cwd?: string + rPath: string; + rHelp: RHelp; + persistentState: vscode.Memento; + cwd?: string; } - export class PackageManager { - readonly rHelp: RHelp; readonly state: vscode.Memento; @@ -90,8 +82,7 @@ export class PackageManager { public favoriteNames: Set = new Set(); - - constructor(args: PackageManagerOptions){ + constructor(args: PackageManagerOptions) { this.rHelp = args.rHelp; this.state = args.persistentState; this.cwd = args.cwd; @@ -106,15 +97,18 @@ export class PackageManager { } // Funciton to clear only the cached files regarding an individual package etc. - public async clearCachedFiles(re?: string|RegExp): Promise { + public async clearCachedFiles(re?: string | RegExp): Promise { let cache: CachedIndexFiles | undefined; - if(re){ + if (re) { const oldCache = this.state.get('r.helpPanel.cachedIndexFiles', []); - cache = oldCache.filter(v => !( - (typeof re === 'string' && v.path === re) - || (typeof re !== 'string' && re.exec(v.path)) - )); - } else{ + cache = oldCache.filter( + (v) => + !( + (typeof re === 'string' && v.path === re) || + (typeof re !== 'string' && re.exec(v.path)) + ), + ); + } else { cache = undefined; } await this.state.update('r.helpPanel.cachedIndexFiles', cache); @@ -137,24 +131,27 @@ export class PackageManager { // return the index file if cached, else undefined private getCachedIndexFile(path: string): IndexEntry[] | undefined { const cache = this.state.get('r.helpPanel.cachedIndexFiles', []); - const ind = cache.findIndex(v => v.path === path); - if(ind < 0){ + const ind = cache.findIndex((v) => v.path === path); + if (ind < 0) { return undefined; - } else{ + } else { return cache[ind].items; } } // Save a new file to the cache (or update existing entry) - private async updateCachedIndexFile(path: string, items: IndexEntry[] | undefined): Promise{ + private async updateCachedIndexFile( + path: string, + items: IndexEntry[] | undefined, + ): Promise { const cache = this.state.get('r.helpPanel.cachedIndexFiles', []); - const ind = cache.findIndex(v => v.path === path); - if(ind < 0){ + const ind = cache.findIndex((v) => v.path === path); + if (ind < 0) { cache.push({ path: path, - items: items + items: items, }); - } else{ + } else { cache[ind].items = items; } await this.state.update('r.helpPanel.cachedIndexFiles', cache); @@ -162,28 +159,35 @@ export class PackageManager { // Private functions used to sync favoriteNames with global state / workspace state // Is used frequently when list of favorites is shared globally to sync between sessions - private pullFavoriteNames(){ - if(this.state){ - this.favoriteNames = this.state.get('r.helpPanel.favoriteNamesSet') || this.favoriteNames; + private pullFavoriteNames() { + if (this.state) { + this.favoriteNames = + this.state.get('r.helpPanel.favoriteNamesSet') || this.favoriteNames; } } - private pushFavoriteNames(){ - if(this.state){ + private pushFavoriteNames() { + if (this.state) { void this.state.update('r.helpPanel.favoriteNamesSet', this.favoriteNames); } } // let the user pick and install a package from CRAN public async pickAndInstallPackages(pickMany: boolean = false): Promise { - const packages = await doWithProgress(() => this.getPackages(true), this.rHelp.treeViewWrapper.viewId); - if(!packages?.length){ + const packages = await doWithProgress( + () => this.getPackages(true), + this.rHelp.treeViewWrapper.viewId, + ); + if (!packages?.length) { return false; } const pkgs = await pickPackages(packages, 'Please select a package.', pickMany); - if(pkgs?.length){ - const pkgsConfirmed = await confirmPackages('Are you sure you want to install these packages?', pkgs); - if(pkgsConfirmed?.length){ - const names = pkgsConfirmed.map(v => v.name); + if (pkgs?.length) { + const pkgsConfirmed = await confirmPackages( + 'Are you sure you want to install these packages?', + pkgs, + ); + if (pkgsConfirmed?.length) { + const names = pkgsConfirmed.map((v) => v.name); return await this.installPackages(names, true); } } @@ -193,31 +197,46 @@ export class PackageManager { // remove a specified package. The packagename is selected e.g. in the help tree-view public async removePackage(pkgName: string): Promise { const rPath = this.rHelp.rPath; - const args = ['--silent', '--no-echo', '--no-save', '--no-restore', '-e', `remove.packages('${pkgName}')`]; + const args = [ + '--silent', + '--no-echo', + '--no-save', + '--no-restore', + '-e', + `remove.packages('${pkgName}')`, + ]; const cmd = `${rPath} ${args.join(' ')}`; const confirmation = 'Yes, remove package!'; const prompt = `Are you sure you want to remove package ${pkgName}?`; - if(await getConfirmation(prompt, confirmation, cmd)){ + if (await getConfirmation(prompt, confirmation, cmd)) { await executeAsTask('Remove Package', rPath, args, true); return true; - } else{ + } else { return false; } } // actually install packages // confirmation can be skipped (e.g. if the user has confimred before) - public async installPackages(pkgNames: string[], skipConfirmation: boolean = false): Promise { + public async installPackages( + pkgNames: string[], + skipConfirmation: boolean = false, + ): Promise { const rPath = this.rHelp.rPath; const cranUrl = await getCranUrl('', this.cwd); - const args = [`--silent`, '--no-echo', `-e`, `install.packages(c(${pkgNames.map(v => `'${v}'`).join(',')}),repos='${cranUrl}')`]; + const args = [ + `--silent`, + '--no-echo', + `-e`, + `install.packages(c(${pkgNames.map((v) => `'${v}'`).join(',')}),repos='${cranUrl}')`, + ]; const cmd = `${rPath} ${args.join(' ')}`; - const pluralS = pkgNames.length > 1? 's' : ''; + const pluralS = pkgNames.length > 1 ? 's' : ''; const confirmation = `Yes, install package${pluralS}!`; const prompt = `Are you sure you want to install package${pluralS}: ${pkgNames.join(', ')}?`; - if(skipConfirmation || await getConfirmation(prompt, confirmation, cmd)){ + if (skipConfirmation || (await getConfirmation(prompt, confirmation, cmd))) { await executeAsTask('Install Package', rPath, args, true); return true; } @@ -227,84 +246,91 @@ export class PackageManager { public async updatePackages(skipConfirmation: boolean = false): Promise { const rPath = this.rHelp.rPath; const cranUrl = await getCranUrl('', this.cwd); - const args = ['--silent', '--no-echo', '--no-save', '--no-restore', '-e', `update.packages(ask=FALSE,repos='${cranUrl}')`]; + const args = [ + '--silent', + '--no-echo', + '--no-save', + '--no-restore', + '-e', + `update.packages(ask=FALSE,repos='${cranUrl}')`, + ]; const cmd = `${rPath} ${args.join(' ')}`; const confirmation = 'Yes, update all packages!'; - const prompt = 'Are you sure you want to update all installed packages? This might take some time!'; + const prompt = + 'Are you sure you want to update all installed packages? This might take some time!'; - if(skipConfirmation || await getConfirmation(prompt, confirmation, cmd)){ + if (skipConfirmation || (await getConfirmation(prompt, confirmation, cmd))) { await executeAsTask('Update Packages', rPath, args, true); return true; - } else{ + } else { return false; } } - public async getPackages(fromCran: boolean = false): Promise { - let packages: Package[]|undefined; + public async getPackages(fromCran: boolean = false): Promise { + let packages: Package[] | undefined; this.pullFavoriteNames(); - if(fromCran){ + if (fromCran) { // Use a placeholder, since multiple different urls are attempted const CRAN_PATH_PLACEHOLDER = 'CRAN_PATH_PLACEHOLDER'; packages = this.getCachedIndexFile(CRAN_PATH_PLACEHOLDER); - if(!packages?.length){ + if (!packages?.length) { const cranUrl = await getCranUrl('', this.cwd); packages = await getPackagesFromCran(cranUrl); await this.updateCachedIndexFile(CRAN_PATH_PLACEHOLDER, packages); } - } else{ + } else { packages = await this.getParsedIndexFile(`/doc/html/packages.html`); - if(!packages?.length){ + if (!packages?.length) { void vscode.window.showErrorMessage('Help provider not available!'); } } - if(packages){ - for(const pkg of packages){ + if (packages) { + for (const pkg of packages) { pkg.isFavorite = this.favoriteNames.has(pkg.name); - pkg.helpPath = ( - pkg.name === 'doc' ? - '/doc/html/packages.html' : - `/library/${pkg.name}/html/00Index.html` - ); + pkg.helpPath = + pkg.name === 'doc' + ? '/doc/html/packages.html' + : `/library/${pkg.name}/html/00Index.html`; } } return packages; } - // parses a package's index file to produce a list of help topics // highlights ths 'home' topic and adds entries for the package index and DESCRIPTION file - public async getTopics(pkgName: string, summarize: boolean = false): Promise { - + public async getTopics( + pkgName: string, + summarize: boolean = false, + ): Promise { const indexEntries = await this.getParsedIndexFile(`/library/${pkgName}/html/00Index.html`); - if(!indexEntries){ + if (!indexEntries) { return undefined; } - const topics: TopicExtra[] = indexEntries.map(v => { + const topics: TopicExtra[] = indexEntries.map((v) => { const topic: TopicExtra = { name: v.name, description: v.description, href: v.href || v.name, type: TopicType.NORMAL, //replaced below - helpPath: '' // replaced below + helpPath: '', // replaced below }; - topic.type = (topic.name === `${pkgName}-package` ? TopicType.HOME : TopicType.NORMAL); + topic.type = topic.name === `${pkgName}-package` ? TopicType.HOME : TopicType.NORMAL; - topic.helpPath = ( - pkgName === 'doc' ? - `/doc/html/${topic.href}` : - `/library/${pkgName}/html/${topic.href}` - ); + topic.helpPath = + pkgName === 'doc' + ? `/doc/html/${topic.href}` + : `/library/${pkgName}/html/${topic.href}`; return topic; }); - const ind = topics.findIndex(v => v.type === TopicType.HOME); + const ind = topics.findIndex((v) => v.type === TopicType.HOME); let homeTopic: TopicExtra | undefined = undefined; - if(ind >= 0){ + if (ind >= 0) { homeTopic = topics.splice(ind, 1)[0]; } @@ -313,7 +339,7 @@ export class PackageManager { description: '', href: '00Index.html', helpPath: `/library/${pkgName}/html/00Index.html`, - type: TopicType.INDEX + type: TopicType.INDEX, }; const descriptionTopic: TopicExtra = { @@ -321,20 +347,20 @@ export class PackageManager { description: '', href: '../DESCRIPTION', helpPath: `/library/${pkgName}/DESCRIPTION`, - type: TopicType.META + type: TopicType.META, }; topics.unshift(indexTopic, descriptionTopic); - if(homeTopic){ + if (homeTopic) { topics.unshift(homeTopic); } - const ret = (summarize ? summarizeTopics(topics) : topics); + const ret = summarize ? summarizeTopics(topics) : topics; ret.sort((a, b) => { - if(a.type === b.type){ + if (a.type === b.type) { return a.name.localeCompare(b.name); - } else{ + } else { return a.type - b.type; } }); @@ -344,17 +370,16 @@ export class PackageManager { // retrieve and parse an index file // (either list of all packages, or documentation entries of a package) - private async getParsedIndexFile(path: string): Promise { - + private async getParsedIndexFile(path: string): Promise { let indexItems = this.getCachedIndexFile(path); // only read and parse file if not cached yet - if(!indexItems){ + if (!indexItems) { const helpFile = await this.rHelp.getHelpFileForPath(path, false); - if(!helpFile?.html){ + if (!helpFile?.html) { // set missing files to null indexItems = undefined; - } else{ + } else { // parse and cache file indexItems = parseIndexFile(helpFile.html); } @@ -363,7 +388,7 @@ export class PackageManager { // return cache entry. make new array to avoid messing with the cache let ret: IndexEntry[] | undefined = undefined; - if(indexItems){ + if (indexItems) { ret = []; ret.push(...indexItems); } @@ -371,9 +396,7 @@ export class PackageManager { } } - function parseIndexFile(html: string): IndexEntry[] { - const $ = cheerio.load(html); const tables = $('table'); @@ -386,18 +409,16 @@ function parseIndexFile(html: string): IndexEntry[] { const rows = $('tr', table); rows.each((rowIndex, row) => { const elements = $('td', row); - if(elements.length === 2){ + if (elements.length === 2) { const e0 = elements[0]; const e1 = elements[1]; - if( - e0.type === 'tag' && e1.type === 'tag' && - e0.firstChild?.type === 'tag' - ){ + if (e0.type === 'tag' && e1.type === 'tag' && e0.firstChild?.type === 'tag') { const href = e0.firstChild.attribs['href']; const nameNode = e0.firstChild.firstChild; const descriptionNode = e1.firstChild; const name = nameNode && 'data' in nameNode ? nameNode.data : ''; - const description = descriptionNode && 'data' in descriptionNode ? descriptionNode.data : ''; + const description = + descriptionNode && 'data' in descriptionNode ? descriptionNode.data : ''; ret.push({ name: name, description: description, @@ -413,49 +434,52 @@ function parseIndexFile(html: string): IndexEntry[] { return retSorted; } - // Used to let the user confirm their choice when installing/removing packages async function confirmPackages(placeHolder: string, packages: Package[]): Promise { - const qpItems: (vscode.QuickPickItem & {package: Package})[] = packages.map(pkg => ({ + const qpItems: (vscode.QuickPickItem & { package: Package })[] = packages.map((pkg) => ({ label: pkg.name, detail: pkg.description, package: pkg, - picked: true + picked: true, })); const qpOptions: vscode.QuickPickOptions = { matchOnDescription: true, matchOnDetail: true, - placeHolder: placeHolder + placeHolder: placeHolder, }; - const qp = await vscode.window.showQuickPick(qpItems, {...qpOptions, canPickMany: true}); - const ret = qp?.map(v => v.package) || []; + const qp = await vscode.window.showQuickPick(qpItems, { ...qpOptions, canPickMany: true }); + const ret = qp?.map((v) => v.package) || []; return ret; } // Let the user pick a package, either from local installation or CRAN -async function pickPackages(packages: Package[], placeHolder: string, pickMany: boolean = false): Promise { - if(!packages?.length){ +async function pickPackages( + packages: Package[], + placeHolder: string, + pickMany: boolean = false, +): Promise { + if (!packages?.length) { return undefined; } - const qpItems: (vscode.QuickPickItem & {package: Package})[] = packages.map(pkg => ({ + const qpItems: (vscode.QuickPickItem & { package: Package })[] = packages.map((pkg) => ({ label: pkg.name, detail: pkg.description, - package: pkg + package: pkg, })); const qpOptions: vscode.QuickPickOptions = { matchOnDescription: true, matchOnDetail: true, - placeHolder: placeHolder + placeHolder: placeHolder, }; let ret: Package | Package[] | undefined; - if(pickMany){ - const qp = await vscode.window.showQuickPick(qpItems, {...qpOptions, canPickMany: true}); - ret = qp?.map(v => v.package); - } else{ + if (pickMany) { + const qp = await vscode.window.showQuickPick(qpItems, { ...qpOptions, canPickMany: true }); + ret = qp?.map((v) => v.package); + } else { const qp = await vscode.window.showQuickPick(qpItems, qpOptions); - ret = (qp ? [qp.package] : undefined); + ret = qp ? [qp.package] : undefined; } return ret; @@ -464,19 +488,19 @@ async function pickPackages(packages: Package[], placeHolder: string, pickMany: // Used to summarize index-entries that point to the same help file function summarizeTopics(topics: Topic[]): Topic[] { const topicMap = new Map(); - for(const topic of topics){ - if(topicMap.has(topic.helpPath)){ + for (const topic of topics) { + if (topicMap.has(topic.helpPath)) { const newTopic = topicMap.get(topic.helpPath); // checked above that key is present - if(newTopic.aliases){ + if (newTopic.aliases) { newTopic.aliases.push(topic.name); } // newTopic.topicType ||= topic.topicType; - newTopic.type = (newTopic.type === TopicType.NORMAL ? topic.type : newTopic.type); - } else{ + newTopic.type = newTopic.type === TopicType.NORMAL ? topic.type : newTopic.type; + } else { const newTopic: Topic = { ...topic, }; - if(newTopic.type === TopicType.NORMAL && newTopic.description){ + if (newTopic.type === TopicType.NORMAL && newTopic.description) { newTopic.aliases = [newTopic.name]; [newTopic.name, newTopic.description] = [newTopic.description, newTopic.name]; } diff --git a/src/helpViewer/panel.ts b/src/helpViewer/panel.ts index 69cd1bdc2..b6e40ad11 100644 --- a/src/helpViewer/panel.ts +++ b/src/helpViewer/panel.ts @@ -1,4 +1,3 @@ - import * as vscode from 'vscode'; import * as cheerio from 'cheerio'; @@ -23,7 +22,6 @@ interface HistoryEntry { } export class HelpPanel { - private readonly rHelp: RHelp; // the webview panel where the help is shown @@ -68,19 +66,23 @@ export class HelpPanel { } await this.refreshCurrentEntry(); } - + public async refreshPreview(packageDir: string): Promise { - if(this.currentEntry?.helpFile.packageDir === packageDir){ + if (this.currentEntry?.helpFile.packageDir === packageDir) { await this.refreshCurrentEntry(); } } - + private async refreshCurrentEntry(): Promise { - if(!this.currentEntry){ + if (!this.currentEntry) { return; } - const newHelpFile = await this.rHelp.getHelpFileForPath(this.currentEntry.helpFile.requestPath, undefined, true); - if(!newHelpFile){ + const newHelpFile = await this.rHelp.getHelpFileForPath( + this.currentEntry.helpFile.requestPath, + undefined, + true, + ); + if (!newHelpFile) { return; } newHelpFile.scrollY = await this.getScrollY(); @@ -88,20 +90,28 @@ export class HelpPanel { } // retrieves the stored webview or creates a new one if the webview was closed - private getWebview(preserveFocus: boolean = false, viewColumn: vscode.ViewColumn = vscode.ViewColumn.Two): vscode.Webview { + private getWebview( + preserveFocus: boolean = false, + viewColumn: vscode.ViewColumn = vscode.ViewColumn.Two, + ): vscode.Webview { // create webview if necessary if (!this.panel) { const webViewOptions: vscode.WebviewOptions & vscode.WebviewPanelOptions = { enableScripts: true, enableFindWidget: true, enableCommandUris: true, - retainContextWhenHidden: true // keep scroll position when not focussed + retainContextWhenHidden: true, // keep scroll position when not focussed }; const showOptions = { viewColumn: viewColumn, - preserveFocus: preserveFocus + preserveFocus: preserveFocus, }; - this.panel = vscode.window.createWebviewPanel('rhelp', 'R Help', showOptions, webViewOptions); + this.panel = vscode.window.createWebviewPanel( + 'rhelp', + 'R Help', + showOptions, + webViewOptions, + ); this.initializePanel(); } @@ -142,7 +152,6 @@ export class HelpPanel { }); } - public async setContextValues(): Promise { await setContext('r.helpPanel.canOpenExternal', !!this.currentEntry?.helpFile.url); await setContext('r.helpPanel.active', !!this.panel?.active); @@ -151,8 +160,13 @@ export class HelpPanel { } // shows (internal) help file object in webview - public async showHelpFile(helpFile: HelpFile | Promise, updateHistory = true, currentScrollY = 0, viewer?: vscode.ViewColumn | string, preserveFocus: boolean = false): Promise { - + public async showHelpFile( + helpFile: HelpFile | Promise, + updateHistory = true, + currentScrollY = 0, + viewer?: vscode.ViewColumn | string, + preserveFocus: boolean = false, + ): Promise { viewer ||= config().get('session.viewers.viewColumn.helpPanel'); const viewColumn = asViewColumn(viewer); @@ -180,7 +194,7 @@ export class HelpPanel { } this.currentEntry = { helpFile: helpFile, - isStale: helpFile.isPreview + isStale: helpFile.isPreview, }; await this.setContextValues(); @@ -207,12 +221,12 @@ export class HelpPanel { public async goBack(): Promise { const scrollY = await this.getScrollY(); this._goBack(scrollY); - } private _goBack(currentScrollY = 0): void { const entry = this.history.pop(); if (entry) { - if (this.currentEntry) { // should always be true + if (this.currentEntry) { + // should always be true this.currentEntry.helpFile.scrollY = currentScrollY; this.forwardHistory.push(this.currentEntry); } @@ -222,12 +236,12 @@ export class HelpPanel { public async goForward(): Promise { const scrollY = await this.getScrollY(); this._goForward(scrollY); - } private _goForward(currentScrollY = 0): void { const entry = this.forwardHistory.pop(); if (entry) { - if (this.currentEntry) { // should always be true + if (this.currentEntry) { + // should always be true this.currentEntry.helpFile.scrollY = currentScrollY; this.history.push(this.currentEntry); } @@ -239,7 +253,11 @@ export class HelpPanel { if (entry.isStale) { // Fallback to stale helpFile. // Handle differently? - const newHelpFile = await this.rHelp.getHelpFileForPath(entry.helpFile.requestPath, true, true); + const newHelpFile = await this.rHelp.getHelpFileForPath( + entry.helpFile.requestPath, + true, + true, + ); helpFile = newHelpFile || entry.helpFile; helpFile.scrollY = entry.helpFile.scrollY; } else { @@ -301,14 +319,16 @@ export class HelpPanel { } else if (uri.path.endsWith('.R')) { const doc = await vscode.workspace.openTextDocument({ language: 'r', - content: helpFile.html0 + content: helpFile.html0, }); void vscode.window.showTextDocument(doc); } else { void this.showHelpFile(helpFile, true, currentScrollY); } - } else{ - void vscode.window.showWarningMessage(`Did not find help page for path ${requestPath}`); + } else { + void vscode.window.showWarningMessage( + `Did not find help page for path ${requestPath}`, + ); } } else if (msg.message === 'mouseClick') { // use the additional mouse buttons to go forward/backwards @@ -330,16 +350,14 @@ export class HelpPanel { // Check wheter to copy or run the code (or both or none) const codeClickConfig = config().get('helpPanel.clickCodeExamples'); - const runCode = ( - isCtrlClick && codeClickConfig?.['Ctrl+Click'] === 'Run' - || isShiftClick && codeClickConfig?.['Shift+Click'] === 'Run' - || isNormalClick && codeClickConfig?.['Click'] === 'Run' - ); - const copyCode = ( - isCtrlClick && codeClickConfig?.['Ctrl+Click'] === 'Copy' - || isShiftClick && codeClickConfig?.['Shift+Click'] === 'Copy' - || isNormalClick && codeClickConfig?.['Click'] === 'Copy' - ); + const runCode = + (isCtrlClick && codeClickConfig?.['Ctrl+Click'] === 'Run') || + (isShiftClick && codeClickConfig?.['Shift+Click'] === 'Run') || + (isNormalClick && codeClickConfig?.['Click'] === 'Run'); + const copyCode = + (isCtrlClick && codeClickConfig?.['Ctrl+Click'] === 'Copy') || + (isShiftClick && codeClickConfig?.['Shift+Click'] === 'Copy') || + (isNormalClick && codeClickConfig?.['Click'] === 'Copy'); // Execute action: if (copyCode) { @@ -357,8 +375,11 @@ export class HelpPanel { } // improves the help display by applying syntax highlighting and adjusting hyperlinks: - private async pimpMyHelp(helpFile: HelpFile, styleUri?: vscode.Uri | string, scriptUri?: vscode.Uri | string): Promise { - + private async pimpMyHelp( + helpFile: HelpFile, + styleUri?: vscode.Uri | string, + scriptUri?: vscode.Uri | string, + ): Promise { // get requestpath of helpfile const relPath = helpFile.requestPath + (helpFile.hash || ''); @@ -381,7 +402,9 @@ export class HelpPanel { if (linkUrl) { if (linkUrl.includes('katex')) { const newUrl = new URL(linkUrl, url.origin); - const newUri = await vscode.env.asExternalUri(vscode.Uri.parse(newUrl.toString())); + const newUri = await vscode.env.asExternalUri( + vscode.Uri.parse(newUrl.toString()), + ); obj.attr('href', newUri.toString(true)); } else { obj.remove(); @@ -395,7 +418,9 @@ export class HelpPanel { if (scriptUrl) { if (scriptUrl.includes('katex')) { const newUrl = new URL(scriptUrl, url.origin); - const newUri = await vscode.env.asExternalUri(vscode.Uri.parse(newUrl.toString())); + const newUri = await vscode.env.asExternalUri( + vscode.Uri.parse(newUrl.toString()), + ); obj.attr('src', newUri.toString(true)); } else { obj.remove(); @@ -411,12 +436,10 @@ export class HelpPanel { $('body').append(`\n`); } - // convert to string helpFile.html = $.html(); // return the html of the modified page: return helpFile; } - } diff --git a/src/helpViewer/treeView.ts b/src/helpViewer/treeView.ts index 7930d9507..e34e62586 100644 --- a/src/helpViewer/treeView.ts +++ b/src/helpViewer/treeView.ts @@ -9,7 +9,6 @@ import { Package, Topic, TopicType } from './packages'; // this enum is re-assigned just for code readability const CollapsibleState = vscode.TreeItemCollapsibleState; - // the commands contributed in package.json for the tree view // the commands are registered in HelpTreeWrapper.constructor // the node-objects only need to handle the keys ('QUICKPICK' etc.) in Node.handleCommand() @@ -29,19 +28,17 @@ const nodeCommands = { summarizeTopics: 'r.helpPanel.summarizeTopics', unsummarizeTopics: 'r.helpPanel.unsummarizeTopics', installPackages: 'r.helpPanel.installPackages', - updateInstalledPackages: 'r.helpPanel.updateInstalledPackages' + updateInstalledPackages: 'r.helpPanel.updateInstalledPackages', } as const; // used to avoid typos when handling commands type cmdName = keyof typeof nodeCommands; - //////////////////// // The following classes are mostly just an 'adapter layer' between vscode's treeview interface // and the object oriented approach used here to present nodes of the treeview. // The 'interesting' part of the nodes is implemented below - // wrapper around vscode.window.createTreeView() // necessary to implement Node.refresh(), // which is used to signal from a node that its contents/children have changed @@ -51,31 +48,33 @@ export class HelpTreeWrapper { public helpView: vscode.TreeView; public helpViewProvider: HelpViewProvider; - constructor(rHelp: RHelp){ + constructor(rHelp: RHelp) { this.rHelp = rHelp; this.helpViewProvider = new HelpViewProvider(this); - this.helpView = vscode.window.createTreeView( - this.viewId, - { - treeDataProvider: this.helpViewProvider, - showCollapseAll: true - } - ); + this.helpView = vscode.window.createTreeView(this.viewId, { + treeDataProvider: this.helpViewProvider, + showCollapseAll: true, + }); // register the commands defined in `nodeCommands` // they still need to be defined in package.json (apart from CALLBACK) for (const cmd in nodeCommands) { const cmdTyped = cmd as cmdName; // Ok since `cmdName` is defiend as `keyof typeof nodeCommands` - extensionContext.subscriptions.push(vscode.commands.registerCommand(nodeCommands[cmdTyped], (node: Node | undefined) => { - // treeview-root is represented by `undefined`: - node ||= this.helpViewProvider.rootItem; - node.handleCommand(cmdTyped); - })); + extensionContext.subscriptions.push( + vscode.commands.registerCommand( + nodeCommands[cmdTyped], + (node: Node | undefined) => { + // treeview-root is represented by `undefined`: + node ||= this.helpViewProvider.rootItem; + node.handleCommand(cmdTyped); + }, + ), + ); } } public refreshNode(node: Node | undefined): void { - for(const listener of this.helpViewProvider.listeners){ + for (const listener of this.helpViewProvider.listeners) { listener(node); } } @@ -83,10 +82,10 @@ export class HelpTreeWrapper { public refreshPackageRootNode(): void { this.helpViewProvider.rootItem?.pkgRootNode?.refresh(); } - + public refreshPreviewNode(packageDir: string): void { - this.helpViewProvider.rootItem.previewChildren?.forEach(node => { - if(node.packageDir === packageDir){ + this.helpViewProvider.rootItem.previewChildren?.forEach((node) => { + if (node.packageDir === packageDir) { node.refresh(true); } }); @@ -97,14 +96,13 @@ export class HelpTreeWrapper { } } - // mostly just a wrapper to implement vscode.TreeDataProvider export class HelpViewProvider implements vscode.TreeDataProvider { public rootItem: RootNode; public listeners: ((e: Node | undefined) => void)[] = []; - constructor(wrapper: HelpTreeWrapper){ + constructor(wrapper: HelpTreeWrapper) { this.rootItem = new RootNode(wrapper); } @@ -115,7 +113,7 @@ export class HelpViewProvider implements vscode.TreeDataProvider { }); } - getChildren(element?: Node): vscode.ProviderResult{ + getChildren(element?: Node): vscode.ProviderResult { element ||= this.rootItem; return element.getChildren(); } @@ -132,7 +130,7 @@ export class HelpViewProvider implements vscode.TreeDataProvider { // All the 'interesting' stuff hapens in the derived classes // New commands should (if possible) be implemented by defining a new derived class, // rather than modifying this class! -abstract class Node extends vscode.TreeItem{ +abstract class Node extends vscode.TreeItem { // TreeItem (defaults for this usecase) declare public description?: string; public collapsibleState: vscode.TreeItemCollapsibleState = vscode.TreeItemCollapsibleState.None; @@ -144,7 +142,7 @@ abstract class Node extends vscode.TreeItem{ public command = { title: 'treeNodeCallback', // is this title used anywhere? command: nodeCommands.CALLBACK, - arguments: [this] + arguments: [this], } as vscode.Command | undefined; // Node @@ -166,10 +164,10 @@ abstract class Node extends vscode.TreeItem{ static newId: number = 0; // The default constructor just copies some info from parent - constructor(parent: Node | undefined, wrapper: HelpTreeWrapper){ + constructor(parent: Node | undefined, wrapper: HelpTreeWrapper) { super(''); this.wrapper = wrapper; - if(parent){ + if (parent) { this.parent = parent; this.rootNode = parent.rootNode; } @@ -178,15 +176,15 @@ abstract class Node extends vscode.TreeItem{ // Called when a node or command-button on a node is clicked // Only internal commands are handled here, custom commands are implemented in _handleCommand! - public handleCommand(cmd: cmdName){ - if(cmd === 'CALLBACK' && this.callBack){ + public handleCommand(cmd: cmdName) { + if (cmd === 'CALLBACK' && this.callBack) { void this.callBack(); - } else if(cmd === 'QUICKPICK'){ - if(this.quickPickCommand){ + } else if (cmd === 'QUICKPICK') { + if (this.quickPickCommand) { this._handleCommand(this.quickPickCommand); - } else if(this.collapsibleState !== CollapsibleState.None){ + } else if (this.collapsibleState !== CollapsibleState.None) { void this.showQuickPick(); - } else{ + } else { this.handleCommand('CALLBACK'); } } else { @@ -196,7 +194,7 @@ abstract class Node extends vscode.TreeItem{ // overwrite this in derived classes to handle custom commands protected _handleCommand(cmd: cmdName): void; - protected _handleCommand(){ + protected _handleCommand() { // to be overwritten } @@ -207,26 +205,26 @@ abstract class Node extends vscode.TreeItem{ // Shows a quickpick containing the children of a node // If the picked child has children itself, another quickpick is shown // Otherwise, its QUICKPICK or CALLBACK command is executed - public async showQuickPick(){ + public async showQuickPick() { const children = await this.makeChildren(true); - if(!children){ + if (!children) { return undefined; } - const qpItems: (vscode.QuickPickItem & {child: Node})[] = children.map(v => { + const qpItems: (vscode.QuickPickItem & { child: Node })[] = children.map((v) => { let label = v.label || ''; - if(typeof v.iconPath === 'object' && 'id' in v.iconPath){ + if (typeof v.iconPath === 'object' && 'id' in v.iconPath) { label = `$(${v.iconPath.id}) ${label}`; } return { label: v.qpLabel ?? label, detail: v.qpDetail ?? v.description ?? v.tooltip, - child: v + child: v, }; }); const qp = await vscode.window.showQuickPick(qpItems, { - placeHolder: this.qpPrompt + placeHolder: this.qpPrompt, }); - if(qp){ + if (qp) { const child = qp.child; child.handleCommand('QUICKPICK'); } @@ -234,59 +232,61 @@ abstract class Node extends vscode.TreeItem{ // Called by vscode etc. to get the children of a node // Not meant to be modified in derived classes! - public async getChildren(): Promise { - if(this.children === undefined){ + public async getChildren(): Promise { + if (this.children === undefined) { this.children = await this.makeChildren(); } return this.children; } // to be overwritten, if the node has any children - protected makeChildren(forQuickPick?: boolean): Promise | Node[] | undefined; - protected makeChildren(): Promise | Node[] | undefined { + protected makeChildren( + forQuickPick?: boolean, + ): Promise | Node[] | undefined; + protected makeChildren(): Promise | Node[] | undefined { return []; } // Can be called by a method from the node itself or externally to refresh the node in the treeview - public refresh(refreshChildren: boolean = true){ - if(refreshChildren){ + public refresh(refreshChildren: boolean = true) { + if (refreshChildren) { this.children = undefined; } this.wrapper.refreshNode(this); } // Clear 'grandchildren' without triggering the treeview to update too often - public refreshChildren(){ - if(this.children){ - for(const child of this.children){ + public refreshChildren() { + if (this.children) { + for (const child of this.children) { child.children = undefined; } } } // show/focus the node in the treeview - public reveal(options?: { select?: boolean, focus?: boolean, expand?: boolean | number }){ + public reveal(options?: { select?: boolean; focus?: boolean; expand?: boolean | number }) { void this.wrapper.helpView.reveal(this, options); } // These methods are used to update this.contextValue with possible command names // The constructed contextValue contains the command names of the commands applying to this node - static makeContextValue(...args: cmdName[]){ - return args.map(v => `_${v}_`).join(''); + static makeContextValue(...args: cmdName[]) { + return args.map((v) => `_${v}_`).join(''); } - public addContextValues(...args: cmdName[]){ - args.forEach(val => { + public addContextValues(...args: cmdName[]) { + args.forEach((val) => { this.contextValue += `_${val}_`; }); return this.contextValue; } - public removeContextValues(...args: cmdName[]){ - args.forEach(val => { + public removeContextValues(...args: cmdName[]) { + args.forEach((val) => { this.contextValue = this.contextValue.replace(new RegExp(`_${val}_`), ''); }); return this.contextValue; } - public replaceContextValue(oldCmd: cmdName, newCmd: cmdName){ + public replaceContextValue(oldCmd: cmdName, newCmd: cmdName) { this.removeContextValues(oldCmd); return this.addContextValues(newCmd); } @@ -295,23 +295,18 @@ abstract class Node extends vscode.TreeItem{ abstract class NonRootNode extends Node { parent: RootNode | NonRootNode; rootNode: RootNode; - public constructor(parent: RootNode | NonRootNode){ + public constructor(parent: RootNode | NonRootNode) { super(parent, parent.wrapper); this.parent = parent; this.rootNode = parent.rootNode; } } - - - /////////////////////////////////// // The following classes contain the implementation of the help-view-specific behaviour // PkgRootNode, PackageNode, and TopicNode are a bit more complex // The remaining nodes mostly just contain an icon and a callback - - // Root of the node. Is not actually used by vscode, but as 'imaginary' root item. class RootNode extends Node { public collapsibleState = vscode.TreeItemCollapsibleState.Expanded; @@ -322,10 +317,10 @@ class RootNode extends Node { public staticChildren?: NonRootNode[]; public previewChildren?: PreviewPackageNode[]; - constructor(wrapper: HelpTreeWrapper){ + constructor(wrapper: HelpTreeWrapper) { super(undefined, wrapper); } - makeChildren(){ + makeChildren() { this.pkgRootNode ||= new PkgRootNode(this); this.staticChildren ||= [ new HomeNode(this), @@ -337,12 +332,12 @@ class RootNode extends Node { this.pkgRootNode, ]; this.previewChildren ||= this.wrapper.rHelp.previewProviders.map( - previewer => new PreviewPackageNode(this, previewer) + (previewer) => new PreviewPackageNode(this, previewer), ); return [...this.staticChildren, ...this.previewChildren]; } - public refresh(refreshChildren: boolean = true){ - if(refreshChildren){ + public refresh(refreshChildren: boolean = true) { + if (refreshChildren) { this.children = undefined; this.previewChildren = undefined; } @@ -358,7 +353,13 @@ class PkgRootNode extends NonRootNode { public description = ''; public command = undefined; public collapsibleState = CollapsibleState.Collapsed; - public contextValue = Node.makeContextValue('QUICKPICK', 'clearCache', 'filterPackages', 'showOnlyFavorites', 'unsummarizeTopics'); + public contextValue = Node.makeContextValue( + 'QUICKPICK', + 'clearCache', + 'filterPackages', + 'showOnlyFavorites', + 'unsummarizeTopics', + ); // Node declare public children?: PackageNode[]; @@ -371,21 +372,21 @@ class PkgRootNode extends NonRootNode { public filterText?: string; public summarizeTopics: boolean = true; - async _handleCommand(cmd: cmdName){ - if(cmd === 'clearCache'){ + async _handleCommand(cmd: cmdName) { + if (cmd === 'clearCache') { // used e.g. after manually installing/removing a package this.refresh(true); - } else if(cmd === 'showOnlyFavorites'){ + } else if (cmd === 'showOnlyFavorites') { this.showOnlyFavorites = true; this.iconPath = new vscode.ThemeIcon('star-full'); this.replaceContextValue('showOnlyFavorites', 'showAllPackages'); this.refresh(); - } else if(cmd === 'showAllPackages'){ + } else if (cmd === 'showAllPackages') { this.showOnlyFavorites = false; this.iconPath = new vscode.ThemeIcon('list-unordered'); this.replaceContextValue('showAllPackages', 'showOnlyFavorites'); this.refresh(); - } else if(cmd === 'filterPackages'){ + } else if (cmd === 'filterPackages') { // use validation function to continuously update filtered packages const validateInput = (value: string) => { this.filterText = value; @@ -397,14 +398,14 @@ class PkgRootNode extends NonRootNode { validateInput: validateInput, value: this.filterText, }); - this.description = (this.filterText ? `"${this.filterText}"` : ''); + this.description = this.filterText ? `"${this.filterText}"` : ''; this.refresh(); - } else if(cmd === 'unsummarizeTopics'){ + } else if (cmd === 'unsummarizeTopics') { this.summarizeTopics = false; this.replaceContextValue('unsummarizeTopics', 'summarizeTopics'); this.refreshChildren(); // clears the 'grandchildren' this.refresh(false, false); - } else if(cmd === 'summarizeTopics'){ + } else if (cmd === 'summarizeTopics') { this.summarizeTopics = true; this.replaceContextValue('summarizeTopics', 'unsummarizeTopics'); this.refreshChildren(); // clears the 'grandchildren' @@ -412,8 +413,8 @@ class PkgRootNode extends NonRootNode { } } - refresh(clearCache: boolean = false, refreshChildren: boolean = true){ - if(clearCache){ + refresh(clearCache: boolean = false, refreshChildren: boolean = true) { + if (clearCache) { this.wrapper.rHelp.clearCachedFiles(`/doc/html/packages.html`); void this.wrapper.rHelp.packageManager.clearCachedFiles(`/doc/html/packages.html`); } @@ -423,37 +424,39 @@ class PkgRootNode extends NonRootNode { async makeChildren() { let packages = await this.wrapper.rHelp.packageManager.getPackages(false); - if(!packages){ + if (!packages) { return []; } - if(this.filterText){ + if (this.filterText) { const re = new RegExp(this.filterText); - packages = packages.filter(pkg => re.exec(pkg.name)); + packages = packages.filter((pkg) => re.exec(pkg.name)); } // favorites at the top - const children = packages.filter(pkg => pkg.isFavorite); + const children = packages.filter((pkg) => pkg.isFavorite); // nonFavorites below (if shown) - if(!this.showOnlyFavorites){ - children.push(...packages.filter(pkg => !pkg.isFavorite)); + if (!this.showOnlyFavorites) { + children.push(...packages.filter((pkg) => !pkg.isFavorite)); } // make packageNode for each child - return children.map( - pkg => new PackageNode(this, pkg) - ); + return children.map((pkg) => new PackageNode(this, pkg)); } } - // contains the topics belonging to an individual package export class PackageNode extends NonRootNode { // TreeItem public command = undefined; public collapsibleState = CollapsibleState.Collapsed; - public contextValue = Node.makeContextValue('QUICKPICK', 'clearCache', 'removePackage', 'updatePackage'); + public contextValue = Node.makeContextValue( + 'QUICKPICK', + 'clearCache', + 'removePackage', + 'updatePackage', + ); // QuickPick public qpPrompt = 'Please select a Topic.'; @@ -461,56 +464,61 @@ export class PackageNode extends NonRootNode { // Package public pkg: Package; - constructor(parent: PkgRootNode, pkg: Package){ + constructor(parent: PkgRootNode, pkg: Package) { super(parent); this.pkg = pkg; this.label = pkg.name; this.tooltip = pkg.description; this.qpDetail = pkg.description; - if(this.pkg.isFavorite){ + if (this.pkg.isFavorite) { this.addContextValues('removeFromFavorites'); - } else{ + } else { this.addContextValues('addToFavorites'); } - if(this.pkg.isFavorite && !this.rootNode.pkgRootNode?.showOnlyFavorites){ + if (this.pkg.isFavorite && !this.rootNode.pkgRootNode?.showOnlyFavorites) { this.iconPath = new vscode.ThemeIcon('star-full'); } } public async _handleCommand(cmd: cmdName): Promise { - if(cmd === 'clearCache'){ + if (cmd === 'clearCache') { // useful e.g. when working on a package this.wrapper.rHelp.clearCachedFiles(new RegExp(`^/library/${this.pkg.name}/`)); this.refresh(); - } else if(cmd === 'addToFavorites'){ + } else if (cmd === 'addToFavorites') { this.wrapper.rHelp.packageManager.addFavorite(this.pkg.name); this.parent.refresh(); - } else if(cmd === 'removeFromFavorites'){ + } else if (cmd === 'removeFromFavorites') { this.wrapper.rHelp.packageManager.removeFavorite(this.pkg.name); this.parent.refresh(); - } else if(cmd === 'updatePackage'){ - const success = await this.wrapper.rHelp.packageManager.installPackages([this.pkg.name]); + } else if (cmd === 'updatePackage') { + const success = await this.wrapper.rHelp.packageManager.installPackages([ + this.pkg.name, + ]); // only reinstall if user confirmed removing the package (success === true) // might still refresh if install was attempted but failed - if(success){ + if (success) { this.parent.refresh(true); } - } else if(cmd === 'removePackage'){ + } else if (cmd === 'removePackage') { const success = await this.wrapper.rHelp.packageManager.removePackage(this.pkg.name); // only refresh if user confirmed removing the package (success === true) // might still refresh if removing was attempted but failed - if(success){ + if (success) { this.parent.refresh(true); } } } async makeChildren(forQuickPick: boolean = false): Promise { - const summarizeTopics = ( - forQuickPick ? false : (this.rootNode.pkgRootNode?.summarizeTopics ?? true) + const summarizeTopics = forQuickPick + ? false + : (this.rootNode.pkgRootNode?.summarizeTopics ?? true); + const topics = await this.wrapper.rHelp.packageManager.getTopics( + this.pkg.name, + summarizeTopics, ); - const topics = await this.wrapper.rHelp.packageManager.getTopics(this.pkg.name, summarizeTopics); - const ret = topics?.map(topic => new TopicNode(this, topic)) || []; + const ret = topics?.map((topic) => new TopicNode(this, topic)) || []; return ret; } } @@ -528,29 +536,31 @@ class TopicNode extends NonRootNode { [TopicType.HOME, 'home'], [TopicType.INDEX, 'list-unordered'], [TopicType.META, 'file-code'], - [TopicType.NORMAL, 'circle-filled'] + [TopicType.NORMAL, 'circle-filled'], ]); - protected _handleCommand(cmd: cmdName){ - if(cmd === 'CALLBACK'){ + protected _handleCommand(cmd: cmdName) { + if (cmd === 'CALLBACK') { void this.wrapper.rHelp.showHelpForPath(this.topic.helpPath); - } else if(cmd === 'openInNewPanel'){ + } else if (cmd === 'openInNewPanel') { void this.wrapper.rHelp.makeNewHelpPanel(); void this.wrapper.rHelp.showHelpForPath(this.topic.helpPath); } } - constructor(parent: NonRootNode, topic: Topic){ + constructor(parent: NonRootNode, topic: Topic) { super(parent); this.topic = topic; this.label = topic.name; - this.iconPath = new vscode.ThemeIcon(TopicNode.iconPaths.get(this.topic.type) || 'circle-filled'); - if(this.topic.type === TopicType.NORMAL){ + this.iconPath = new vscode.ThemeIcon( + TopicNode.iconPaths.get(this.topic.type) || 'circle-filled', + ); + if (this.topic.type === TopicType.NORMAL) { this.qpLabel = this.topic.name; } - if(this.topic.aliases){ + if (this.topic.aliases) { this.tooltip = `Aliases:\n - ${this.topic.aliases.join('\n - ')}`; - } else{ + } else { this.tooltip = this.topic.description; } } @@ -564,7 +574,7 @@ class PreviewPackageNode extends NonRootNode { public iconPath = new vscode.ThemeIcon('eye'); public command = undefined; public contextValue = Node.makeContextValue('QUICKPICK', 'unsummarizeTopics'); - public qpPrompt = 'Please select a Topic.' + public qpPrompt = 'Please select a Topic.'; public summarizeTopics: boolean = true; @@ -572,20 +582,20 @@ class PreviewPackageNode extends NonRootNode { private helpPreview: RLocalHelpPreviewer; - constructor(parent: RootNode, helpPreview: RLocalHelpPreviewer){ + constructor(parent: RootNode, helpPreview: RLocalHelpPreviewer) { super(parent); this.helpPreview = helpPreview; this.packageDir = helpPreview?.packageDir; this.refreshMetaInfo(); } - _handleCommand(cmd: cmdName){ - if(cmd === 'unsummarizeTopics'){ + _handleCommand(cmd: cmdName) { + if (cmd === 'unsummarizeTopics') { this.summarizeTopics = false; this.replaceContextValue('unsummarizeTopics', 'summarizeTopics'); this.refreshChildren(); // clears the 'grandchildren' this.refresh(true); - } else if(cmd === 'summarizeTopics'){ + } else if (cmd === 'summarizeTopics') { this.summarizeTopics = true; this.replaceContextValue('summarizeTopics', 'unsummarizeTopics'); this.refreshChildren(); // clears the 'grandchildren' @@ -594,39 +604,35 @@ class PreviewPackageNode extends NonRootNode { } makeChildren(forQuickPick: boolean = false): TopicNode[] { - const summarizeTopics = ( - forQuickPick ? false : (this.summarizeTopics ?? true) - ); + const summarizeTopics = forQuickPick ? false : (this.summarizeTopics ?? true); const topics = this.helpPreview?.getTreeViewTopics(summarizeTopics) || []; - const ret = topics.map(topic => new TopicNode(this, topic)) || []; + const ret = topics.map((topic) => new TopicNode(this, topic)) || []; return ret; } - + private refreshMetaInfo(): void { this.label = `Preview: ${this.helpPreview.getPackageName()}`; const pkgInfo = this.helpPreview.getPackageInfo(); const toolTipParts: string[] = []; - if(pkgInfo?.version){ + if (pkgInfo?.version) { toolTipParts.push('v' + pkgInfo.version); } - if(pkgInfo?.title){ + if (pkgInfo?.title) { toolTipParts.push(pkgInfo.title); } this.tooltip = toolTipParts.join(' - '); } - + // Can be called by a method from the node itself or externally to refresh the node in the treeview - public refresh(refreshChildren: boolean = true){ + public refresh(refreshChildren: boolean = true) { this.refreshMetaInfo(); - if(refreshChildren){ + if (refreshChildren) { this.children = undefined; } this.wrapper.refreshNode(this); } } - - ///////////// // The following nodes only implement an individual command each @@ -636,14 +642,14 @@ class HomeNode extends NonRootNode { iconPath = new vscode.ThemeIcon('home'); contextValue = Node.makeContextValue('openInNewPanel'); - _handleCommand(cmd: cmdName){ - if(cmd === 'openInNewPanel'){ + _handleCommand(cmd: cmdName) { + if (cmd === 'openInNewPanel') { void this.wrapper.rHelp.makeNewHelpPanel(); void this.wrapper.rHelp.showHelpForPath('doc/html/index.html'); } } - callBack(){ + callBack() { void this.wrapper.rHelp.showHelpForPath('doc/html/index.html'); } } @@ -652,7 +658,7 @@ class Search1Node extends NonRootNode { label = 'Open Help Topic using `?`'; iconPath = new vscode.ThemeIcon('zap'); - callBack(){ + callBack() { void this.wrapper.rHelp.searchHelpByAlias(); } } @@ -661,7 +667,7 @@ class Search2Node extends NonRootNode { label = 'Search Help Topics using `??`'; iconPath = new vscode.ThemeIcon('search'); - callBack(){ + callBack() { void this.wrapper.rHelp.searchHelpByText(); } } @@ -670,7 +676,7 @@ class RefreshNode extends NonRootNode { label = 'Clear Cache & Restart Help Server'; iconPath = new vscode.ThemeIcon('refresh'); - async callBack(){ + async callBack() { await doWithProgress(() => this.wrapper.rHelp.refresh(), this.wrapper.viewId); this.rootNode.pkgRootNode?.refresh(); this.rootNode.refresh(); @@ -681,7 +687,7 @@ class OpenForSelectionNode extends NonRootNode { label = 'Open Help Page for Selected Text'; iconPath = new vscode.ThemeIcon('symbol-key'); - callBack(){ + callBack() { void this.wrapper.rHelp.openHelpForSelection(); } } @@ -692,25 +698,22 @@ class InstallPackageNode extends NonRootNode { contextValue = Node.makeContextValue('installPackages', 'updateInstalledPackages'); - public async _handleCommand(cmd: cmdName){ - if(cmd === 'installPackages'){ + public async _handleCommand(cmd: cmdName) { + if (cmd === 'installPackages') { const ret = await this.wrapper.rHelp.packageManager.pickAndInstallPackages(true); - if(ret){ + if (ret) { this.rootNode.pkgRootNode?.refresh(true); } - } else if(cmd === 'updateInstalledPackages'){ + } else if (cmd === 'updateInstalledPackages') { const ret = await this.wrapper.rHelp.packageManager.updatePackages(); - if(ret){ + if (ret) { this.rootNode.pkgRootNode?.refresh(true); } } } - async callBack(){ + async callBack() { await this.wrapper.rHelp.packageManager.pickAndInstallPackages(); this.rootNode.pkgRootNode?.refresh(true); } } - - - diff --git a/src/helpViewer/webview/index.ts b/src/helpViewer/webview/index.ts index ff9fc3369..1b7b3a448 100644 --- a/src/helpViewer/webview/index.ts +++ b/src/helpViewer/webview/index.ts @@ -8,30 +8,27 @@ window.onmousedown = (ev) => { vscode.postMessage({ message: 'mouseClick', button: Number(ev.button), - scrollY: window.scrollY + scrollY: window.scrollY, }); }; - // handle requests from vscode ui window.addEventListener('message', (ev: MessageEvent) => { // oxlint-disable-next-line typescript/no-unsafe-assignment const message = ev.data; // oxlint-disable-next-line typescript/no-unsafe-member-access - if(message.command === 'getScrollY'){ + if (message.command === 'getScrollY') { vscode.postMessage({ message: 'getScrollY', - scrollY: window.scrollY + scrollY: window.scrollY, }); } }); - // do everything after loading the body window.document.body.onload = () => { - // make relative path for hyperlinks - const relPath = (document.body.getAttribute('relPath') || ''); + const relPath = document.body.getAttribute('relPath') || ''; // notify vscode, used to restore help panels between sessions vscode.setState(relPath); @@ -42,42 +39,41 @@ window.document.body.onload = () => { // scroll to desired position: const scrollYTo = Number(document.body.getAttribute('scrollYTo') ?? -1); - if(scrollYTo >= 0){ - window.scrollTo(0,scrollYTo); - } else if(url1.hash){ + if (scrollYTo >= 0) { + window.scrollTo(0, scrollYTo); + } else if (url1.hash) { document.location.hash = url1.hash; } // notify vscode when links are clicked: const hyperLinks = document.getElementsByTagName('a'); - for(let i=0; i { document.location.hash = hrefRel; }; - } else if(hrefAbs && hrefAbs.startsWith('vscode-webview://')){ + } else if (hrefAbs && hrefAbs.startsWith('vscode-webview://')) { hyperLinks[i].onclick = () => { - const url2 = new URL(hrefRel, url1); const finalHref = url2.toString(); vscode.postMessage({ message: 'linkClicked', href: finalHref, - scrollY: window.scrollY + scrollY: window.scrollY, }); }; } } // notify vscode when code is clicked: - if(document.body.classList.contains('preClickable')){ + if (document.body.classList.contains('preClickable')) { const codeElements = document.getElementsByTagName('pre'); - for(let i=0; i { vscode.postMessage({ @@ -88,7 +84,7 @@ window.document.body.onload = () => { ctrlKey: me.ctrlKey, shiftKey: me.shiftKey, metaKey: me.metaKey, - } + }, }); }; } diff --git a/src/helpViewer/webviewMessages.d.ts b/src/helpViewer/webviewMessages.d.ts index 527505c91..c3b187ac3 100644 --- a/src/helpViewer/webviewMessages.d.ts +++ b/src/helpViewer/webviewMessages.d.ts @@ -1,48 +1,47 @@ - // Make sure these files contain the same interfaces as ./html/XXX/webviewMessages.d.ts! - interface VsCode { postMessage: (msg: OutMessage) => void; setState: (state: string) => void; } declare function acquireVsCodeApi(): VsCode; - - export interface IMessage { message: string; } export interface LogMessage extends IMessage { - message: 'log', - body: any + message: 'log'; + body: any; } export interface MouseClickMessage extends IMessage { - message: 'mouseClick', - button: number, - scrollY: number + message: 'mouseClick'; + button: number; + scrollY: number; } export interface LinkClickedMessage extends IMessage { - message: 'linkClicked', - href: string, - scrollY: number + message: 'linkClicked'; + href: string; + scrollY: number; } export interface CodeClickedMessage extends IMessage { - message: 'codeClicked', - code: string, + message: 'codeClicked'; + code: string; modifiers: { - altKey: boolean, - ctrlKey: boolean, - shiftKey: boolean, - metaKey: boolean, - } + altKey: boolean; + ctrlKey: boolean; + shiftKey: boolean; + metaKey: boolean; + }; } export interface GetScrollYMessage extends IMessage { - message: 'getScrollY', - scrollY: number + message: 'getScrollY'; + scrollY: number; } -export type OutMessage = LogMessage | MouseClickMessage | LinkClickedMessage | CodeClickedMessage | GetScrollYMessage; - - +export type OutMessage = + | LogMessage + | MouseClickMessage + | LinkClickedMessage + | CodeClickedMessage + | GetScrollYMessage; diff --git a/src/helpViewer/webviewMessages.ts b/src/helpViewer/webviewMessages.ts index 7bab37ebb..f70ec5aee 100644 --- a/src/helpViewer/webviewMessages.ts +++ b/src/helpViewer/webviewMessages.ts @@ -1,4 +1,3 @@ - export interface VsCode { postMessage: (msg: OutMessage) => void; setState: (state: string) => void; @@ -6,39 +5,45 @@ export interface VsCode { /** * Function declared by VS Code in Webview */ -export const acquireVsCodeApi: () => VsCode = (globalThis as { acquireVsCodeApi?: () => VsCode }).acquireVsCodeApi || (() => ({} as 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 + message: 'log'; + body: any; } export interface MouseClickMessage extends IMessage { - message: 'mouseClick', - button: number, - scrollY: number + message: 'mouseClick'; + button: number; + scrollY: number; } export interface LinkClickedMessage extends IMessage { - message: 'linkClicked', - href: string, - scrollY: number + message: 'linkClicked'; + href: string; + scrollY: number; } export interface CodeClickedMessage extends IMessage { - message: 'codeClicked', - code: string, + message: 'codeClicked'; + code: string; modifiers: { - altKey: boolean, - ctrlKey: boolean, - shiftKey: boolean, - metaKey: boolean, - } + altKey: boolean; + ctrlKey: boolean; + shiftKey: boolean; + metaKey: boolean; + }; } export interface GetScrollYMessage extends IMessage { - message: 'getScrollY', - scrollY: number + message: 'getScrollY'; + scrollY: number; } -export type OutMessage = LogMessage | MouseClickMessage | LinkClickedMessage | CodeClickedMessage | GetScrollYMessage; +export type OutMessage = + | LogMessage + | MouseClickMessage + | LinkClickedMessage + | CodeClickedMessage + | GetScrollYMessage; diff --git a/src/http.ts b/src/http.ts index ba551fb20..de30414be 100644 --- a/src/http.ts +++ b/src/http.ts @@ -28,14 +28,24 @@ const decompressBrotli = promisify(brotliDecompress); // R help/httpgd servers and private CRAN repositories can use Fetch-blocked ports // or Basic Auth URLs. Node's HTTP(S) transport preserves access to those URLs. -export async function getHttpText(input: URL | string, redirectsLeft = 20): Promise { +export async function getHttpText( + input: URL | string, + redirectsLeft = 20, +): Promise { const response = await getHttpResponse(input, {}, redirectsLeft); return { status: response.status, url: response.url, text: response.body.toString('utf8') }; } -export async function getHttpResponse(input: URL | string, options: HttpRequestOptions = {}, redirectsLeft = 20): Promise { +export async function getHttpResponse( + input: URL | string, + options: HttpRequestOptions = {}, + redirectsLeft = 20, +): Promise { let current = new URL(input); - const headers: Record = { accept: '*/*', 'accept-encoding': 'gzip, deflate, br' }; + const headers: Record = { + accept: '*/*', + 'accept-encoding': 'gzip, deflate, br', + }; for (const [name, value] of Object.entries(options.headers ?? {})) { headers[name.toLowerCase()] = value; } @@ -55,7 +65,9 @@ export async function getHttpResponse(input: URL | string, options: HttpRequestO const request = transport.get(current, { headers, signal: options.signal }, resolve); request.on('error', reject); if (options.timeoutMs) { - request.setTimeout(options.timeoutMs, () => request.destroy(new Error('HTTP request timed out'))); + request.setTimeout(options.timeoutMs, () => + request.destroy(new Error('HTTP request timed out')), + ); } }); const status = response.statusCode ?? 0; @@ -92,7 +104,9 @@ export async function getHttpResponse(input: URL | string, options: HttpRequestO case 'deflate': case 'x-deflate': // Like node-fetch, accept both zlib-wrapped and raw deflate. - body = await ((body[0] & 0x0f) === 0x08 ? decompressDeflate(body) : decompressRawDeflate(body)); + body = await ((body[0] & 0x0f) === 0x08 + ? decompressDeflate(body) + : decompressRawDeflate(body)); break; case 'br': body = await decompressBrotli(body); diff --git a/src/interactive/agent.ts b/src/interactive/agent.ts index 7dc9e1790..91898a6a1 100644 --- a/src/interactive/agent.ts +++ b/src/interactive/agent.ts @@ -8,11 +8,26 @@ import { JsonLines } from './framing'; import { SessionJournal, atomicJson, retainedAssetIds } from './journal'; import { AssetStore } from './assets'; import { BackendEvent, BackendFactory, SessionBackend, inspectionMethod } from './backend'; -import { AGENT_PROTOCOL, AgentConfig, AgentSnapshot, ExecutionRecord, SessionEvent, - SessionManifest, object, submission, identifier, sessionLabel } from './protocol'; +import { + AGENT_PROTOCOL, + AgentConfig, + AgentSnapshot, + ExecutionRecord, + SessionEvent, + SessionManifest, + object, + submission, + identifier, + sessionLabel, +} from './protocol'; import { searchHistory } from './history'; -interface Client { socket: net.Socket; authenticated: boolean; subscribed: boolean; id: string } +interface Client { + socket: net.Socket; + authenticated: boolean; + subscribed: boolean; + id: string; +} /** This process never imports vscode and is supervised independently of the editor. */ export class SessionAgent { private journal: SessionJournal; @@ -42,21 +57,49 @@ export class SessionAgent { private externalBytes = 0; private externalTimer?: NodeJS.Timeout; - constructor(private config: AgentConfig, factory: BackendFactory) { - identifier(config.id); identifier(config.generation); + constructor( + private config: AgentConfig, + factory: BackendFactory, + ) { + identifier(config.id); + identifier(config.generation); this.runtime = fs.mkdtempSync(path.join(os.tmpdir(), 'r-i-')); fs.chmodSync(this.runtime, 0o700); fs.mkdirSync(config.storage, { recursive: true, mode: 0o700 }); - this.journal = new SessionJournal(path.join(config.storage, config.generation), config.generation, config.maxJournalBytes); - this.assets = new AssetStore(path.join(config.storage, 'assets'), config.maxAssetBytes, () => retainedAssetIds(config.storage)); + this.journal = new SessionJournal( + path.join(config.storage, config.generation), + config.generation, + config.maxJournalBytes, + ); + this.assets = new AssetStore( + path.join(config.storage, 'assets'), + config.maxAssetBytes, + () => retainedAssetIds(config.storage), + ); this.backend = factory(config); this.manifest = { - protocol: AGENT_PROTOCOL, id: config.id, generation: config.generation, - label: config.label, host: os.hostname(), directory: config.directory, - endpoint: path.join(this.runtime, 'control.sock'), token: randomBytes(32).toString('hex'), - agentPid: process.pid, provider: config.provider, backend: config.backend?.kind, ownership: this.backend.ownership, created: Date.now(), status: 'starting', - capabilities: { ...this.backend.capabilities, persistent: true, - history: true, rename: true, cancelQueued: true, assetStorage: true }, + protocol: AGENT_PROTOCOL, + id: config.id, + generation: config.generation, + label: config.label, + host: os.hostname(), + directory: config.directory, + endpoint: path.join(this.runtime, 'control.sock'), + token: randomBytes(32).toString('hex'), + agentPid: process.pid, + provider: config.provider, + backend: config.backend?.kind, + ownership: this.backend.ownership, + created: Date.now(), + status: 'starting', + capabilities: { + ...this.backend.capabilities, + persistent: true, + history: true, + rename: true, + cancelQueued: true, + assetStorage: true, + }, supervision: config.supervision, }; // A previous agent's accepted/running work must never be replayed automatically. @@ -65,21 +108,29 @@ export class SessionAgent { this.journal.update({ ...entry, state: 'unknown', ended: Date.now() }); } } - this.unsubscribe = this.backend.onEvent(event => this.backendEvent(event)); + this.unsubscribe = this.backend.onEvent((event) => this.backendEvent(event)); } async start(): Promise { try { await this.assets.start(); - if (this.stopped) { this.assets.close(); throw new Error('Agent stopped during startup'); } + if (this.stopped) { + this.assets.close(); + throw new Error('Agent stopped during startup'); + } this.manifest.assetBase = this.assets.base; - await this.listen(this.manifest.endpoint, socket => this.connectClient(socket)); + await this.listen(this.manifest.endpoint, (socket) => this.connectClient(socket)); this.saveManifest(); await this.backend.start(); - if (this.stopped) { throw new Error('Agent stopped during startup'); } + if (this.stopped) { + throw new Error('Agent stopped during startup'); + } Object.assign(this.manifest.capabilities, this.backend.capabilities); this.saveManifest(); - } catch (error) { await this.close(); throw error; } + } catch (error) { + await this.close(); + throw error; + } return this.manifest; } @@ -93,34 +144,67 @@ export class SessionAgent { fs.chmodSync(endpoint, 0o600); } - private saveManifest(): void { atomicJson(path.join(this.config.storage, 'manifest.json'), this.manifest); } + private saveManifest(): void { + atomicJson(path.join(this.config.storage, 'manifest.json'), this.manifest); + } - private event(type: string, data: Record = {}, executionId?: string, durable = false): SessionEvent { - if (this.stopped) { return { seq: this.journal.seq, generation: this.config.generation, type, data, executionId, time: Date.now() }; } + private event( + type: string, + data: Record = {}, + executionId?: string, + durable = false, + ): SessionEvent { + if (this.stopped) { + return { + seq: this.journal.seq, + generation: this.config.generation, + type, + data, + executionId, + time: Date.now(), + }; + } const event = this.journal.append(type, data, executionId, durable); for (const client of this.clients) { - if (client.subscribed) { this.send(client.socket, { event }); } + if (client.subscribed) { + this.send(client.socket, { event }); + } } return event; } private state(status: SessionManifest['status']): void { - if (this.stopped) { return; } - if (this.stopping && status !== 'exited') { status = 'stopping'; } + if (this.stopped) { + return; + } + if (this.stopping && status !== 'exited') { + status = 'stopping'; + } this.manifest.status = status; if (status === 'exited') { this.manifest.ended ??= Date.now(); - this.cancelQueued(); this.input = undefined; + this.cancelQueued(); + this.input = undefined; } this.saveManifest(); - this.event('state', { status, rPid: this.manifest.rPid, rVersion: this.manifest.rVersion, ended: this.manifest.ended, - rPath: this.manifest.rPath, libraryPaths: this.manifest.libraryPaths, runtimeSessionId: this.manifest.runtimeSessionId, - capabilities: this.manifest.capabilities }); + this.event('state', { + status, + rPid: this.manifest.rPid, + rVersion: this.manifest.rVersion, + ended: this.manifest.ended, + rPath: this.manifest.rPath, + libraryPaths: this.manifest.libraryPaths, + runtimeSessionId: this.manifest.runtimeSessionId, + capabilities: this.manifest.capabilities, + }); } private send(socket: net.Socket, message: unknown): void { if (!socket.destroyed) { - if (socket.writableLength > 8 * 1024 * 1024) { socket.destroy(); return; } + if (socket.writableLength > 8 * 1024 * 1024) { + socket.destroy(); + return; + } socket.write(JSON.stringify(message) + '\n'); } } @@ -128,59 +212,105 @@ export class SessionAgent { private connectClient(socket: net.Socket): void { const client: Client = { socket, authenticated: false, subscribed: false, id: '' }; this.clients.add(client); - const authTimer = setTimeout(() => { if (!client.authenticated) { socket.destroy(); } }, 5000); - const parser = new JsonLines(message => { - void this.handleClient(client, message).catch(error => { - this.send(socket, { id: message.id, error: error instanceof Error ? error.message : String(error) }); + const authTimer = setTimeout(() => { + if (!client.authenticated) { + socket.destroy(); + } + }, 5000); + const parser = new JsonLines((message) => { + void this.handleClient(client, message).catch((error) => { + this.send(socket, { + id: message.id, + error: error instanceof Error ? error.message : String(error), + }); }); }); - socket.on('data', (chunk: Buffer) => { try { parser.push(chunk); } catch { socket.destroy(); } }); + socket.on('data', (chunk: Buffer) => { + try { + parser.push(chunk); + } catch { + socket.destroy(); + } + }); socket.on('error', () => socket.destroy()); socket.on('close', () => { clearTimeout(authTimer); this.clients.delete(client); - if (this.manifest.status === 'exited' && !this.clients.size && !this.stopped) { void this.close(); } + if (this.manifest.status === 'exited' && !this.clients.size && !this.stopped) { + void this.close(); + } // Keep the lease briefly for reconnect; accepted executions keep running. }); } private requireControl(client: Client): void { if (!this.lease || this.lease.client !== client.id || this.lease.until < Date.now()) { - throw new Error('This window is observing the session. Take control before changing it.'); + throw new Error( + 'This window is observing the session. Take control before changing it.', + ); } this.lease.until = Date.now() + 60000; } private async handleClient(client: Client, request: Record): Promise { - if (typeof request.id !== 'number' || typeof request.method !== 'string') { throw new Error('Invalid request'); } + if (typeof request.id !== 'number' || typeof request.method !== 'string') { + throw new Error('Invalid request'); + } const params = object(request.params ?? {}); if (!client.authenticated) { - if (request.method !== 'hello' || params.token !== this.manifest.token || params.protocol !== AGENT_PROTOCOL) { - client.socket.destroy(); return; + if ( + request.method !== 'hello' || + params.token !== this.manifest.token || + params.protocol !== AGENT_PROTOCOL + ) { + client.socket.destroy(); + return; } client.id = identifier(params.clientId); client.authenticated = true; this.send(client.socket, { id: request.id, result: this.manifest }); return; } - if (params.generation !== this.manifest.generation) { throw new Error('R session generation changed; reconnect before executing'); } + if (params.generation !== this.manifest.generation) { + throw new Error('R session generation changed; reconnect before executing'); + } let result: unknown = true; switch (request.method) { case 'claim': - if (!this.lease || this.lease.until < Date.now() || this.lease.client === client.id || params.force === true || - ![...this.clients].some(other => other.authenticated && other.id === this.lease?.client && !other.socket.destroyed)) { + if ( + !this.lease || + this.lease.until < Date.now() || + this.lease.client === client.id || + params.force === true || + ![...this.clients].some( + (other) => + other.authenticated && + other.id === this.lease?.client && + !other.socket.destroyed, + ) + ) { this.lease = { client: client.id, until: Date.now() + 60000 }; result = true; - } else { result = false; } + } else { + result = false; + } break; case 'heartbeat': - if (this.lease?.client === client.id) { this.lease.until = Date.now() + 60000; } - result = { status: this.manifest.status, control: this.lease?.client === client.id, queued: this.queue.length }; + if (this.lease?.client === client.id) { + this.lease.until = Date.now() + 60000; + } + result = { + status: this.manifest.status, + control: this.lease?.client === client.id, + queued: this.queue.length, + }; break; case 'history': { const query = typeof params.query === 'string' ? params.query.slice(0, 1000) : ''; const before = params.before ?? Number.MAX_SAFE_INTEGER; - if (!Number.isSafeInteger(before) || Number(before) < 1) { throw new Error('Invalid history cursor'); } + if (!Number.isSafeInteger(before) || Number(before) < 1) { + throw new Error('Invalid history cursor'); + } result = searchHistory(this.journal.executions.values(), query, Number(before)); break; } @@ -189,7 +319,9 @@ export class SessionAgent { const label = sessionLabel(params.label); const config = { ...this.config, label }; atomicJson(path.join(config.storage, 'config.json'), config); - this.config = config; this.manifest.label = label; this.saveManifest(); + this.config = config; + this.manifest.label = label; + this.saveManifest(); this.event('session', { label }); result = label; break; @@ -199,46 +331,82 @@ export class SessionAgent { result = this.cancelQueued(); break; case 'snapshot': { - const recent = this.journal.recent(Math.max(1, Math.min(Number(params.limit) || 50, this.config.historyLimit))); + const recent = this.journal.recent( + Math.max(1, Math.min(Number(params.limit) || 50, this.config.historyLimit)), + ); // Bound one protocol response. Older/full output remains in the journal. let size = 0; const executions: ExecutionRecord[] = []; for (const record of recent.executions.slice().reverse()) { const bytes = Buffer.byteLength(JSON.stringify(record)); - if (size + bytes > 1400 * 1024) { break; } - size += bytes; executions.unshift(record); + if (size + bytes > 1400 * 1024) { + break; + } + size += bytes; + executions.unshift(record); } - const ids = new Set(executions.map(record => record.id)); + const ids = new Set(executions.map((record) => record.id)); const events: SessionEvent[] = []; for (const event of recent.events.slice().reverse()) { - if (!event.executionId || !ids.has(event.executionId)) { continue; } + if (!event.executionId || !ids.has(event.executionId)) { + continue; + } size += Buffer.byteLength(JSON.stringify(event)); - if (size > 3 * 1024 * 1024) { break; } + if (size > 3 * 1024 * 1024) { + break; + } events.unshift(event); } - result = { manifest: this.manifest, seq: this.journal.seq, - executions, events, workspace: this.workspace, input: this.input, - truncated: executions.filter(record => !events.some(event => event.type === 'accepted' && event.executionId === record.id)).map(record => record.id) } satisfies AgentSnapshot; + result = { + manifest: this.manifest, + seq: this.journal.seq, + executions, + events, + workspace: this.workspace, + input: this.input, + truncated: executions + .filter( + (record) => + !events.some( + (event) => + event.type === 'accepted' && + event.executionId === record.id, + ), + ) + .map((record) => record.id), + } satisfies AgentSnapshot; break; } case 'subscribe': { const after = Number(params.after ?? 0); - if (!Number.isSafeInteger(after) || after < 0) { throw new Error('Invalid event cursor'); } + if (!Number.isSafeInteger(after) || after < 0) { + throw new Error('Invalid event cursor'); + } const replay = this.journal.replay(after, 500); - replay.events = replay.events.map(event => { - if ((event.type === 'clientRequest' && !this.frontendRequests.has(Number(event.data.id))) || - (event.type === 'input' && event.data.inputId !== this.input?.inputId)) { + replay.events = replay.events.map((event) => { + if ( + (event.type === 'clientRequest' && + !this.frontendRequests.has(Number(event.data.id))) || + (event.type === 'input' && event.data.inputId !== this.input?.inputId) + ) { return { ...event, type: 'noop', data: {} }; } return event; }); this.send(client.socket, { id: request.id, result: replay }); - client.subscribed = !replay.reset && (replay.events.at(-1)?.seq ?? after) >= replay.seq; + client.subscribed = + !replay.reset && (replay.events.at(-1)?.seq ?? after) >= replay.seq; return; } case 'submit': { this.requireControl(client); - if (this.stopping || this.manifest.status === 'exited' || this.manifest.status === 'unknown') { throw new Error('R is not available'); } + if ( + this.stopping || + this.manifest.status === 'exited' || + this.manifest.status === 'unknown' + ) { + throw new Error('R is not available'); + } const accepted = this.journal.accept(submission(params.submission)); result = accepted.record; if (!accepted.duplicate) { @@ -248,30 +416,46 @@ export class SessionAgent { } break; } - case 'execution': result = this.journal.executions.get(identifier(params.id)) ?? null; break; + case 'execution': + result = this.journal.executions.get(identifier(params.id)) ?? null; + break; case 'cancel': { this.requireControl(client); const id = identifier(params.id); const record = this.journal.executions.get(id); - if (record?.state !== 'queued' || this.current === id) { throw new Error('Only undispatched queued executions can be cancelled'); } - this.queue = this.queue.filter(entry => entry !== id); + if (record?.state !== 'queued' || this.current === id) { + throw new Error('Only undispatched queued executions can be cancelled'); + } + this.queue = this.queue.filter((entry) => entry !== id); this.finish(record, 'cancelled'); break; } case 'interrupt': this.requireControl(client); - if (this.backend.capabilities.interrupt === false) { throw new Error('This runtime does not support interruption'); } - if (params.id && params.id !== this.current) { throw new Error('Execution is no longer running'); } + if (this.backend.capabilities.interrupt === false) { + throw new Error('This runtime does not support interruption'); + } + if (params.id && params.id !== this.current) { + throw new Error('Execution is no longer running'); + } // Inspection can still be running after its request timed out, // without a submitted execution in `current`. await this.backend.interrupt(); break; case 'input': this.requireControl(client); - if (!this.input || params.id !== this.input.inputId || params.executionId !== this.input.executionId) { + if ( + !this.input || + params.id !== this.input.inputId || + params.executionId !== this.input.executionId + ) { throw new Error('Input request is no longer active'); } - if (typeof params.value !== 'string' || /[\r\n\0]/.test(params.value) || Buffer.byteLength(params.value) > Number(this.input.maxLength ?? 4094)) { + if ( + typeof params.value !== 'string' || + /[\r\n\0]/.test(params.value) || + Buffer.byteLength(params.value) > Number(this.input.maxLength ?? 4094) + ) { throw new Error('Input must be a single line'); } await this.backend.replyInput({ value: params.value }); @@ -279,12 +463,21 @@ export class SessionAgent { this.state('busy'); break; case 'inspect': - if (this.backend.capabilities.inspection === false) { throw new Error('This runtime does not support inspection'); } + if (this.backend.capabilities.inspection === false) { + throw new Error('This runtime does not support inspection'); + } if (this.current) { - if (params.method === 'workspace') { result = this.workspace ?? {}; break; } + if (params.method === 'workspace') { + result = this.workspace ?? {}; + break; + } throw new Error('R is busy; cached data remains available. Retry when idle.'); } - result = await this.backend.inspect({ method: inspectionMethod(params.method), params: object(params.params ?? {}), timeout: 5000 }); + result = await this.backend.inspect({ + method: inspectionMethod(params.method), + params: object(params.params ?? {}), + timeout: 5000, + }); break; case 'asset': { result = this.assets.read(String(params.id), 2 * 1024 * 1024).toString('base64'); @@ -297,30 +490,52 @@ export class SessionAgent { this.config.maxAssetBytes = this.assets.stats().limitBytes; atomicJson(path.join(this.config.storage, 'config.json'), this.config); } - if (params.compact === true) { this.requireControl(client); result = this.assets.compact(); } - else { result = this.assets.stats(); } + if (params.compact === true) { + this.requireControl(client); + result = this.assets.compact(); + } else { + result = this.assets.stats(); + } break; case 'resize': this.requireControl(client); - if (this.current) { throw new Error('R is busy; the retained plot can be scaled locally'); } - if (typeof params.device !== 'string' || typeof params.plot !== 'number') { throw new Error('Invalid plot'); } - if (!this.backend.resizePlot || !this.backend.capabilities.plotResize) { throw new Error('Live plot resizing is unavailable'); } - await this.backend.resizePlot({ device: params.device, plot: params.plot, + if (this.current) { + throw new Error('R is busy; the retained plot can be scaled locally'); + } + if (typeof params.device !== 'string' || typeof params.plot !== 'number') { + throw new Error('Invalid plot'); + } + if (!this.backend.resizePlot || !this.backend.capabilities.plotResize) { + throw new Error('Live plot resizing is unavailable'); + } + await this.backend.resizePlot({ + device: params.device, + plot: params.plot, width: Math.max(100, Math.min(4096, Number(params.width) || 800)), - height: Math.max(100, Math.min(4096, Number(params.height) || 600)) }); + height: Math.max(100, Math.min(4096, Number(params.height) || 600)), + }); break; case 'clientReply': { this.requireControl(client); const pending = this.frontendRequests.get(Number(params.id)); if (pending) { this.frontendRequests.delete(Number(params.id)); - await this.backend.replyClientRequest(pending, { error: params.error ? (typeof params.error === 'string' ? params.error : JSON.stringify(params.error)) : undefined, result: params.result }); + await this.backend.replyClientRequest(pending, { + error: params.error + ? typeof params.error === 'string' + ? params.error + : JSON.stringify(params.error) + : undefined, + result: params.result, + }); } break; } case 'detach': client.subscribed = false; - if (this.lease?.client === client.id) { this.lease = undefined; } + if (this.lease?.client === client.id) { + this.lease = undefined; + } break; case 'stop': this.requireControl(client); @@ -328,103 +543,196 @@ export class SessionAgent { break; case 'shutdown': this.requireControl(client); - if (this.manifest.status !== 'exited') { throw new Error('Stop R before shutting down its agent'); } + if (this.manifest.status !== 'exited') { + throw new Error('Stop R before shutting down its agent'); + } this.send(client.socket, { id: request.id, result: true }); - setTimeout(() => { void this.close(); }, 100); + setTimeout(() => { + void this.close(); + }, 100); return; - default: throw new Error(`Unknown agent method: ${request.method}`); + default: + throw new Error(`Unknown agent method: ${request.method}`); } this.send(client.socket, { id: request.id, result }); } private backendEvent(event: BackendEvent): void { - if (this.stopped) { return; } - try { this.handleBackendEvent(event); } - catch (error) { + if (this.stopped) { + return; + } + try { + this.handleBackendEvent(event); + } catch (error) { // Storage failures must never stop draining runtime output. if (event.type === 'display') { const key = event.executionId ?? 'session'; - if (this.retentionWarnings.has(key)) { return; } + if (this.retentionWarnings.has(key)) { + return; + } this.retentionWarnings.add(key); - if (this.retentionWarnings.size > 1000) { this.retentionWarnings.delete(this.retentionWarnings.values().next().value as string); } + if (this.retentionWarnings.size > 1000) { + this.retentionWarnings.delete( + this.retentionWarnings.values().next().value as string, + ); + } } process.stderr.write(`Interactive event error: ${String(error)}\n`); - try { this.event('truncated', { message: `Output could not be retained: ${String(error)}` }, event.executionId); } - catch { this.manifest.status = 'unknown'; } + try { + this.event( + 'truncated', + { message: `Output could not be retained: ${String(error)}` }, + event.executionId, + ); + } catch { + this.manifest.status = 'unknown'; + } } } private handleBackendEvent(event: BackendEvent): void { const executionId = event.executionId; switch (event.type) { - case 'metadata': Object.assign(this.manifest, event.metadata); this.saveManifest(); break; + case 'metadata': + Object.assign(this.manifest, event.metadata); + this.saveManifest(); + break; case 'ready': - if (this.manifest.status === 'exited') { break; } + if (this.manifest.status === 'exited') { + break; + } this.backendReady = true; Object.assign(this.manifest, event.metadata); Object.assign(this.manifest.capabilities, event.capabilities); - this.state('idle'); void this.refreshWorkspace(); void this.pump(); break; + this.state('idle'); + void this.refreshWorkspace(); + void this.pump(); + break; case 'started': { const record = executionId && this.journal.executions.get(executionId); - if (!record || this.current !== executionId || record.started) { break; } + if (!record || this.current !== executionId || record.started) { + break; + } this.journal.update({ ...record, state: 'running', started: Date.now() }); - this.event('started', { order: record.order }, executionId, true); this.state('busy'); break; + this.event('started', { order: record.order }, executionId, true); + this.state('busy'); + break; } case 'stream': if (event.external) { if (this.externalBytes < this.config.maxOutputBytes) { - this.externalOutput.push({ text: event.text, channel: event.channel }); this.externalBytes += Buffer.byteLength(event.text); + this.externalOutput.push({ text: event.text, channel: event.channel }); + this.externalBytes += Buffer.byteLength(event.text); } this.externalTimer ??= setTimeout(() => this.flushExternal(), 100); - } else { this.stream(event.text, event.channel, executionId); } + } else { + this.stream(event.text, event.channel, executionId); + } + break; + case 'condition': + this.event('condition', event.data, executionId); + break; + case 'truncated': + this.event('truncated', { message: event.message }, executionId); break; - case 'condition': this.event('condition', event.data, executionId); break; - case 'truncated': this.event('truncated', { message: event.message }, executionId); break; case 'input': this.input = { ...event.data, executionId: executionId ?? this.current }; - this.event('input', this.input, executionId); this.state('input'); break; - case 'display': this.display(event.data, executionId); break; - case 'notification': case 'viewer': this.event(event.type, { method: event.method, params: event.params }, executionId); break; - case 'workspaceChanged': if (!this.current) { void this.refreshWorkspace(); } break; + this.event('input', this.input, executionId); + this.state('input'); + break; + case 'display': + this.display(event.data, executionId); + break; + case 'notification': + case 'viewer': + this.event(event.type, { method: event.method, params: event.params }, executionId); + break; + case 'workspaceChanged': + if (!this.current) { + void this.refreshWorkspace(); + } + break; case 'clientRequest': { const id = this.nextRequest++; this.frontendRequests.set(id, event.id); - this.event('clientRequest', { id, method: event.method, params: event.params }, executionId); break; + this.event( + 'clientRequest', + { id, method: event.method, params: event.params }, + executionId, + ); + break; } case 'clientRequestExpired': - for (const [id, backendId] of this.frontendRequests) { if (backendId === event.id) { this.frontendRequests.delete(id); } } + for (const [id, backendId] of this.frontendRequests) { + if (backendId === event.id) { + this.frontendRequests.delete(id); + } + } break; case 'finished': { const record = executionId && this.journal.executions.get(executionId); - if (!record || this.current !== executionId) { break; } - this.finish(record, event.state); this.state(this.backendReady ? 'idle' : 'unknown'); - void this.refreshWorkspace().finally(() => { void this.pump(); }); break; + if (!record || this.current !== executionId) { + break; + } + this.finish(record, event.state); + this.state(this.backendReady ? 'idle' : 'unknown'); + void this.refreshWorkspace().finally(() => { + void this.pump(); + }); + break; } - case 'external': this.flushExternal(event.code, event.success); void this.refreshWorkspace(); break; - case 'warning': case 'error': this.event(event.type === 'warning' ? 'agentWarning' : 'agentError', { message: event.message }); break; - case 'provider': this.event('provider', event.data); break; + case 'external': + this.flushExternal(event.code, event.success); + void this.refreshWorkspace(); + break; + case 'warning': + case 'error': + this.event(event.type === 'warning' ? 'agentWarning' : 'agentError', { + message: event.message, + }); + break; + case 'provider': + this.event('provider', event.data); + break; case 'unavailable': - if (this.manifest.status === 'exited') { break; } + if (this.manifest.status === 'exited') { + break; + } this.backendReady = false; - if (this.current) { this.uncertain(this.current, event.message); } - this.state('unknown'); break; + if (this.current) { + this.uncertain(this.current, event.message); + } + this.state('unknown'); + break; case 'exit': this.backendReady = false; this.event('exit', { code: event.code, signal: event.signal }); - if (this.current) { this.finish(this.journal.executions.get(this.current)!, 'unknown'); } - this.state('exited'); break; + if (this.current) { + this.finish(this.journal.executions.get(this.current)!, 'unknown'); + } + this.state('exited'); + break; } } private flushExternal(code?: string, success = true): void { - clearTimeout(this.externalTimer); this.externalTimer = undefined; - if (this.stopped || (!code && !this.externalOutput.length)) { return; } + clearTimeout(this.externalTimer); + this.externalTimer = undefined; + if (this.stopped || (!code && !this.externalOutput.length)) { + return; + } // Some frontends cannot report terminal execution boundaries. Preserve their // output without claiming to know the source or execution boundary. - const record = this.journal.accept({ id: randomUUID(), code: code ?? '# Output from attached R terminal (source unavailable)' }).record; + const record = this.journal.accept({ + id: randomUUID(), + code: code ?? '# Output from attached R terminal (source unavailable)', + }).record; this.event('accepted', { record, origin: 'terminal' }, record.id, true); - for (const output of this.externalOutput) { this.stream(String(output.text), String(output.channel), record.id); } - this.externalOutput = []; this.externalBytes = 0; + for (const output of this.externalOutput) { + this.stream(String(output.text), String(output.channel), record.id); + } + this.externalOutput = []; + this.externalBytes = 0; this.finish(record, success ? 'success' : 'error'); } @@ -435,7 +743,11 @@ export class SessionAgent { if (remaining <= 0) { if (!this.truncated.has(key)) { this.truncated.add(key); - this.event('truncated', { message: 'Output exceeded the configured per-execution limit.' }, executionId); + this.event( + 'truncated', + { message: 'Output exceeded the configured per-execution limit.' }, + executionId, + ); } return; } @@ -444,74 +756,140 @@ export class SessionAgent { // Bound event sizes even for a single very large console write. const decoder = new StringDecoder('utf8'); for (let start = 0; start < Math.min(bytes.length, remaining); start += 32768) { - this.event('stream', { text: decoder.write(bytes.subarray(start, Math.min(start + 32768, remaining))), channel }, executionId); + this.event( + 'stream', + { + text: decoder.write(bytes.subarray(start, Math.min(start + 32768, remaining))), + channel, + }, + executionId, + ); + } + if (bytes.length > remaining) { + this.event( + 'truncated', + { message: 'Output exceeded the configured per-execution limit.' }, + executionId, + ); + this.truncated.add(key); } - if (bytes.length > remaining) { this.event('truncated', { message: 'Output exceeded the configured per-execution limit.' }, executionId); this.truncated.add(key); } } private display(data: Record, executionId?: string): void { - const display: Record = { ...data, displayId: typeof data.displayId === 'string' ? data.displayId : randomUUID() }; - if (data.kind === 'plot' && typeof data.svg === 'string') { display.svg = this.assets.put(data.svg, '.svg'); } + const display: Record = { + ...data, + displayId: typeof data.displayId === 'string' ? data.displayId : randomUUID(), + }; + if (data.kind === 'plot' && typeof data.svg === 'string') { + display.svg = this.assets.put(data.svg, '.svg'); + } if (data.kind === 'html' && typeof data.file === 'string') { display.asset = this.assets.importHtml(data.file); delete display.file; } if (data.kind === 'image' && typeof data.data === 'string') { - display.asset = this.assets.put(Buffer.from(data.data, 'base64'), data.mime === 'image/png' ? '.png' : '.svg'); + display.asset = this.assets.put( + Buffer.from(data.data, 'base64'), + data.mime === 'image/png' ? '.png' : '.svg', + ); delete display.data; } if (Buffer.byteLength(JSON.stringify(display)) > 2 * 1024 * 1024) { - this.event('truncated', { message: 'Rich output exceeds 2 MiB; display a smaller preview or save it to a file.' }, executionId); + this.event( + 'truncated', + { + message: + 'Rich output exceeds 2 MiB; display a smaller preview or save it to a file.', + }, + executionId, + ); return; } this.event('display', display, executionId); } private async refreshWorkspace(): Promise { - if (this.stopped || !this.backendReady || this.current || this.manifest.capabilities.inspection === false) { return; } + if ( + this.stopped || + !this.backendReady || + this.current || + this.manifest.capabilities.inspection === false + ) { + return; + } try { - const workspace = object(await this.backend.inspect({ method: 'workspace', params: {}, timeout: 2000 })); - if (this.stopped) { return; } + const workspace = object( + await this.backend.inspect({ method: 'workspace', params: {}, timeout: 2000 }), + ); + if (this.stopped) { + return; + } this.workspace = workspace; this.event('workspace', this.workspace, undefined); - } catch { /* A busy or disconnected process retains the last workspace snapshot. */ } + } catch { + /* A busy or disconnected process retains the last workspace snapshot. */ + } } private cancelQueued(): number { const queued = this.queue.splice(0); for (const id of queued) { const record = this.journal.executions.get(id); - if (record?.state === 'queued') { this.finish(record, 'cancelled'); } + if (record?.state === 'queued') { + this.finish(record, 'cancelled'); + } } return queued.length; } private finish(record: ExecutionRecord, state: ExecutionRecord['state']): void { - if (this.stopped) { return; } + if (this.stopped) { + return; + } const final = { ...record, state, ended: Date.now() }; this.journal.update(final); this.event('finished', { state, record: final }, record.id, true); - if (this.current === record.id) { this.current = undefined; this.input = undefined; } + if (this.current === record.id) { + this.current = undefined; + this.input = undefined; + } } private async pump(): Promise { - if (this.stopped || this.stopping || !this.backendReady || this.dispatching || this.current || !this.queue.length || - this.manifest.status === 'exited' || this.manifest.status === 'unknown') { return; } + if ( + this.stopped || + this.stopping || + !this.backendReady || + this.dispatching || + this.current || + !this.queue.length || + this.manifest.status === 'exited' || + this.manifest.status === 'unknown' + ) { + return; + } const id = this.queue.shift()!; const record = this.journal.executions.get(id)!; - if (record.state !== 'queued') { void this.pump(); return; } + if (record.state !== 'queued') { + void this.pump(); + return; + } this.current = id; this.dispatching = true; try { await this.backend.dispatch(record); } catch (error) { - if (this.stopped || this.current !== id) { return; } + if (this.stopped || this.current !== id) { + return; + } this.uncertain(id, String(error)); this.state('unknown'); // Keep current until completion or confirmed process exit establishes the outcome. } finally { this.dispatching = false; - if (!this.current) { void this.pump(); } + if (!this.current) { + void this.pump(); + } } } @@ -522,21 +900,32 @@ export class SessionAgent { } private async stopWorker(): Promise { - if (this.stopping || this.manifest.status === 'exited') { return; } - this.stopping = true; this.cancelQueued(); this.state('stopping'); + if (this.stopping || this.manifest.status === 'exited') { + return; + } + this.stopping = true; + this.cancelQueued(); + this.state('stopping'); await this.backend.stop(); } close(): Promise { - if (this.closing) { return this.closing; } - this.stopped = true; this.unsubscribe(); + if (this.closing) { + return this.closing; + } + this.stopped = true; + this.unsubscribe(); clearTimeout(this.externalTimer); this.assets.close(); - for (const client of this.clients) { client.socket.destroy(); } - for (const server of this.servers) { server.close(); } + for (const client of this.clients) { + client.socket.destroy(); + } + for (const server of this.servers) { + server.close(); + } this.frontendRequests.clear(); this.journal.close(); fs.rmSync(this.runtime, { recursive: true, force: true }); - return this.closing = this.backend.dispose(); + return (this.closing = this.backend.dispose()); } } diff --git a/src/interactive/agentMain.ts b/src/interactive/agentMain.ts index 73a8e6b27..1381e3c9b 100644 --- a/src/interactive/agentMain.ts +++ b/src/interactive/agentMain.ts @@ -5,7 +5,9 @@ import { createBackend } from './backendRegistry'; /** The executable composes the agent with the shipped backend definitions. */ export class SessionAgent extends Agent { - constructor(config: AgentConfig) { super(config, () => createBackend(config)); } + constructor(config: AgentConfig) { + super(config, () => createBackend(config)); + } } if (require.main === module) { @@ -14,9 +16,14 @@ if (require.main === module) { delete process.env.ELECTRON_RUN_AS_NODE; const config = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')) as AgentConfig; const agent = new SessionAgent(config); - process.once('SIGTERM', () => { void agent.close().finally(() => process.exit(0)); }); - process.once('SIGINT', () => { void agent.close().finally(() => process.exit(0)); }); - void agent.start().catch(error => { - process.stderr.write(String(error) + '\n'); void agent.close().finally(() => process.exit(1)); + process.once('SIGTERM', () => { + void agent.close().finally(() => process.exit(0)); + }); + process.once('SIGINT', () => { + void agent.close().finally(() => process.exit(0)); + }); + void agent.start().catch((error) => { + process.stderr.write(String(error) + '\n'); + void agent.close().finally(() => process.exit(1)); }); } diff --git a/src/interactive/arf.ts b/src/interactive/arf.ts index 78f9fb2db..e4ba799ff 100644 --- a/src/interactive/arf.ts +++ b/src/interactive/arf.ts @@ -14,30 +14,60 @@ export interface ArfSession { } /** arf speaks HTTP JSON-RPC, not sess's JSON Lines framing. */ -export function arfRequest(endpoint: string, method: string, params: Record = {}, - timeout = 30000, signal?: AbortSignal): Promise { +export function arfRequest( + endpoint: string, + method: string, + params: Record = {}, + timeout = 30000, + signal?: AbortSignal, +): Promise { return new Promise((resolve, reject) => { const body = JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }); - const request = http.request({ socketPath: endpoint, path: '/', method: 'POST', agent: false, signal, - headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body), Connection: 'close' } }, response => { - const chunks: Buffer[] = []; - let size = 0; - response.on('data', (chunk: Buffer) => { - size += chunk.length; - if (size > MAX_MESSAGE_BYTES) { request.destroy(new Error('arf response exceeds size limit')); } - else { chunks.push(chunk); } - }); - response.on('error', reject); - response.on('end', () => { - try { - const result = object(JSON.parse(Buffer.concat(chunks).toString('utf8'))); - if (result.error) { reject(new Error(JSON.stringify(result.error))); } - else { resolve(result.result); } - } catch (error) { reject(error); } - }); - }); + const request = http.request( + { + socketPath: endpoint, + path: '/', + method: 'POST', + agent: false, + signal, + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + Connection: 'close', + }, + }, + (response) => { + const chunks: Buffer[] = []; + let size = 0; + response.on('data', (chunk: Buffer) => { + size += chunk.length; + if (size > MAX_MESSAGE_BYTES) { + request.destroy(new Error('arf response exceeds size limit')); + } else { + chunks.push(chunk); + } + }); + response.on('error', reject); + response.on('end', () => { + try { + const result = object(JSON.parse(Buffer.concat(chunks).toString('utf8'))); + if (result.error) { + reject(new Error(JSON.stringify(result.error))); + } else { + resolve(result.result); + } + } catch (error) { + reject(error); + } + }); + }, + ); if (timeout > 0) { - request.setTimeout(timeout, () => request.destroy(new Error('arf request timed out; evaluation may still be running'))); + request.setTimeout(timeout, () => + request.destroy( + new Error('arf request timed out; evaluation may still be running'), + ), + ); } request.on('error', reject); request.end(body); @@ -45,22 +75,34 @@ export function arfRequest(endpoint: string, method: string, params: Record compressedAsset.test(id) ? id.slice(0, -3) : id; +export const exportedAssetName = (id: string): string => + compressedAsset.test(id) ? id.slice(0, -3) : id; function resolveAsset(directory: string, id: string): string { const relative = path.normalize(id); - if (path.isAbsolute(relative) || relative.startsWith('..') || relative.includes('\0')) { throw new Error('Invalid asset path'); } + if (path.isAbsolute(relative) || relative.startsWith('..') || relative.includes('\0')) { + throw new Error('Invalid asset path'); + } const file = fs.realpathSync(path.join(directory, relative)); - if (!file.startsWith(fs.realpathSync(directory) + path.sep)) { throw new Error('Invalid asset path'); } + if (!file.startsWith(fs.realpathSync(directory) + path.sep)) { + throw new Error('Invalid asset path'); + } return file; } /** Decode only our compressed assets; widget dependency files retain their original encoding. */ export function readAsset(directory: string, id: string, maxBytes = 256 * 1024 * 1024): Buffer { const file = resolveAsset(directory, id); - if (fs.statSync(file).size > maxBytes) { throw new Error('Use the asset URL for large files'); } + if (fs.statSync(file).size > maxBytes) { + throw new Error('Use the asset URL for large files'); + } const bytes = fs.readFileSync(file); return compressedAsset.test(id) ? gunzipSync(bytes, { maxOutputLength: maxBytes }) : bytes; } function fileBytes(file: string): number { const stat = fs.lstatSync(file); - return stat.isDirectory() ? fs.readdirSync(file).reduce((sum, entry) => sum + fileBytes(path.join(file, entry)), 0) : stat.size; + return stat.isDirectory() + ? fs.readdirSync(file).reduce((sum, entry) => sum + fileBytes(path.join(file, entry)), 0) + : stat.size; } const mimeTypes: Record = { - '.html': 'text/html', '.htm': 'text/html', '.js': 'application/javascript', - '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.json': 'application/json', - '.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf', '.txt': 'text/plain', + '.html': 'text/html', + '.htm': 'text/html', + '.js': 'application/javascript', + '.css': 'text/css', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.json': 'application/json', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.ttf': 'font/ttf', + '.txt': 'text/plain', }; /** Immutable, session-private assets. The HTTP service exposes assets only, never execution. */ @@ -47,29 +70,46 @@ export class AssetStore { private lastCollection = 0; private lastCollectionBytes = -1; - constructor(readonly directory: string, private limit = DEFAULT_MAX_ASSET_BYTES, - private retained?: () => Set) { + constructor( + readonly directory: string, + private limit = DEFAULT_MAX_ASSET_BYTES, + private retained?: () => Set, + ) { fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); this.bytes = fileBytes(directory); } - stats(): AssetStorageStats { return { usedBytes: this.bytes, limitBytes: this.limit, reclaimedBytes: 0 }; } + stats(): AssetStorageStats { + return { usedBytes: this.bytes, limitBytes: this.limit, reclaimedBytes: 0 }; + } setLimit(limit: number): void { - if (!Number.isSafeInteger(limit) || limit < 1024 * 1024) { throw new Error('Asset limit must be an integer of at least 1 MiB'); } - if (limit < this.bytes) { this.compact(); } - if (limit < this.bytes) { throw new Error('Retained assets exceed this limit. Choose a higher limit to preserve existing output.'); } + if (!Number.isSafeInteger(limit) || limit < 1024 * 1024) { + throw new Error('Asset limit must be an integer of at least 1 MiB'); + } + if (limit < this.bytes) { + this.compact(); + } + if (limit < this.bytes) { + throw new Error( + 'Retained assets exceed this limit. Choose a higher limit to preserve existing output.', + ); + } this.limit = limit; } compact(): AssetStorageStats { - if (!this.retained) { return this.stats(); } + if (!this.retained) { + return this.stats(); + } // The collector must finish before deleting anything: unreadable/corrupt // journals must never be interpreted as proof that assets are unused. - const retained = new Set([...this.retained()].map(id => id.split('/')[0])); + const retained = new Set([...this.retained()].map((id) => id.split('/')[0])); let reclaimedBytes = 0; for (const name of fs.readdirSync(this.directory)) { - if (retained.has(name) || !/^[a-f0-9]{64}(?:\.[a-z0-9]+(?:\.gz)?)?$/.test(name)) { continue; } + if (retained.has(name) || !/^[a-f0-9]{64}(?:\.[a-z0-9]+(?:\.gz)?)?$/.test(name)) { + continue; + } const file = path.join(this.directory, name); const bytes = fileBytes(file); fs.rmSync(file, { recursive: true }); @@ -82,12 +122,17 @@ export class AssetStore { } put(content: Buffer | string, extension: string): string { - if (!/^\.[a-z0-9]+$/.test(extension)) { throw new Error('Invalid asset extension'); } + if (!/^\.[a-z0-9]+$/.test(extension)) { + throw new Error('Invalid asset extension'); + } let encoded = Buffer.isBuffer(content) ? content : Buffer.from(content); let suffix = extension; if ((extension === '.svg' || extension === '.json') && encoded.length >= 4096) { const compressed = gzipSync(encoded); - if (compressed.length < encoded.length) { encoded = compressed; suffix += '.gz'; } + if (compressed.length < encoded.length) { + encoded = compressed; + suffix += '.gz'; + } } const id = createHash('sha256').update(content).digest('hex') + suffix; const file = path.join(this.directory, id); @@ -100,50 +145,84 @@ export class AssetStore { fs.renameSync(temporary, file); this.bytes += bytes; } catch (error) { - fs.rmSync(temporary, { force: true }); throw error; + fs.rmSync(temporary, { force: true }); + throw error; } } return id; } importHtml(file: string): string { - if (fs.lstatSync(file).isSymbolicLink()) { throw new Error('HTML bundles cannot contain symbolic links'); } + if (fs.lstatSync(file).isSymbolicLink()) { + throw new Error('HTML bundles cannot contain symbolic links'); + } const root = path.dirname(fs.realpathSync(file)); const entries: { relative: string; bytes: Buffer }[] = []; const visited = new Set(); let total = 0; const collect = (absolute: string): void => { - if (visited.has(absolute)) { return; } + if (visited.has(absolute)) { + return; + } visited.add(absolute); const stat = fs.lstatSync(absolute); - if (stat.isSymbolicLink()) { throw new Error('HTML bundles cannot contain symbolic links'); } + if (stat.isSymbolicLink()) { + throw new Error('HTML bundles cannot contain symbolic links'); + } if (stat.isDirectory()) { - for (const entry of fs.readdirSync(absolute)) { collect(path.join(absolute, entry)); } + for (const entry of fs.readdirSync(absolute)) { + collect(path.join(absolute, entry)); + } + return; + } + if (!stat.isFile()) { return; } - if (!stat.isFile()) { return; } total += stat.size; - if (total > 64 * 1024 * 1024 || entries.length >= 5000) { throw new Error('HTML bundle exceeds the 64 MiB / 5000 file limit'); } + if (total > 64 * 1024 * 1024 || entries.length >= 5000) { + throw new Error('HTML bundle exceeds the 64 MiB / 5000 file limit'); + } const bytes = fs.readFileSync(absolute); entries.push({ relative: path.relative(root, absolute), bytes }); - if (!/\.(html?|css)$/i.test(absolute)) { return; } + if (!/\.(html?|css)$/i.test(absolute)) { + return; + } // Copy referenced dependencies, never unrelated siblings of an HTML file. - const references = bytes.toString('utf8').matchAll(/(?:\b(?:src|href)\s*=\s*["']([^"']+)["']|url\(\s*["']?([^"')]+)["']?\s*\))/gi); + const references = bytes + .toString('utf8') + .matchAll( + /(?:\b(?:src|href)\s*=\s*["']([^"']+)["']|url\(\s*["']?([^"')]+)["']?\s*\))/gi, + ); for (const match of references) { const reference = (match[1] ?? match[2]).trim().replace(/&/g, '&'); - if (!reference || /^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i.test(reference)) { continue; } - const target = path.resolve(path.dirname(absolute), decodeURIComponent(reference.split(/[?#]/)[0])); - if (!target.startsWith(root + path.sep)) { throw new Error('HTML dependencies must be inside the bundle directory'); } - if (!fs.existsSync(target)) { continue; } + if (!reference || /^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i.test(reference)) { + continue; + } + const target = path.resolve( + path.dirname(absolute), + decodeURIComponent(reference.split(/[?#]/)[0]), + ); + if (!target.startsWith(root + path.sep)) { + throw new Error('HTML dependencies must be inside the bundle directory'); + } + if (!fs.existsSync(target)) { + continue; + } const relative = path.relative(root, target); // Widget libraries may load additional files dynamically inside their own directory. - collect(relative.includes(path.sep) ? path.join(root, relative.split(path.sep)[0]) : target); + collect( + relative.includes(path.sep) + ? path.join(root, relative.split(path.sep)[0]) + : target, + ); } }; collect(fs.realpathSync(file)); entries.sort((a, b) => a.relative.localeCompare(b.relative)); const hash = createHash('sha256'); - for (const entry of entries) { hash.update(entry.relative).update('\0').update(entry.bytes); } + for (const entry of entries) { + hash.update(entry.relative).update('\0').update(entry.bytes); + } const id = hash.digest('hex'); const destination = path.join(this.directory, id); if (!fs.existsSync(destination)) { @@ -170,25 +249,39 @@ export class AssetStore { return resolveAsset(this.directory, id); } - read(id: string, maxBytes?: number): Buffer { return readAsset(this.directory, id, maxBytes); } + read(id: string, maxBytes?: number): Buffer { + return readAsset(this.directory, id, maxBytes); + } exportTo(destination: string, ids: Iterable): void { - const source = path.resolve(this.directory), target = path.resolve(destination); - if (target === source || target.startsWith(source + path.sep)) { throw new Error('Export outside the session asset directory'); } + const source = path.resolve(this.directory), + target = path.resolve(destination); + if (target === source || target.startsWith(source + path.sep)) { + throw new Error('Export outside the session asset directory'); + } fs.mkdirSync(destination, { recursive: true }); - for (const id of new Set([...ids].map(id => id.split('/')[0]))) { - if (compressedAsset.test(id)) { fs.writeFileSync(path.join(destination, exportedAssetName(id)), this.read(id)); } - else { fs.cpSync(this.resolve(id), path.join(destination, id), { recursive: true }); } + for (const id of new Set([...ids].map((id) => id.split('/')[0]))) { + if (compressedAsset.test(id)) { + fs.writeFileSync(path.join(destination, exportedAssetName(id)), this.read(id)); + } else { + fs.cpSync(this.resolve(id), path.join(destination, id), { recursive: true }); + } } } private reserve(bytes: number): void { // Start reclaiming superseded/unreferenced assets at 80% of the hard cap. // Repeated failures must not rescan every journal for every drawing frame. - if (this.bytes + bytes > this.limit * 0.8 && - (this.bytes !== this.lastCollectionBytes || Date.now() - this.lastCollection > 1000)) { this.compact(); } + if ( + this.bytes + bytes > this.limit * 0.8 && + (this.bytes !== this.lastCollectionBytes || Date.now() - this.lastCollection > 1000) + ) { + this.compact(); + } if (this.bytes + bytes > this.limit) { - throw new Error(`Session asset storage limit reached (${Math.round(this.limit / 1024 / 1024)} MiB). Retained outputs were preserved. Increase r.interactive.maxAssetSizeMiB or use R: Clean Up Interactive Assets.`); + throw new Error( + `Session asset storage limit reached (${Math.round(this.limit / 1024 / 1024)} MiB). Retained outputs were preserved. Increase r.interactive.maxAssetSizeMiB or use R: Clean Up Interactive Assets.`, + ); } } @@ -196,17 +289,26 @@ export class AssetStore { this.server = http.createServer((request, response) => { try { if (request.method !== 'GET' && request.method !== 'HEAD') { - response.writeHead(405).end(); return; + response.writeHead(405).end(); + return; } const url = new URL(request.url ?? '/', 'http://localhost'); const prefix = `/${this.token}/`; - if (!url.pathname.startsWith(prefix)) { response.writeHead(404).end(); return; } + if (!url.pathname.startsWith(prefix)) { + response.writeHead(404).end(); + return; + } const id = decodeURIComponent(url.pathname.slice(prefix.length)); const file = this.resolve(id); const stat = fs.statSync(file); - if (!stat.isFile()) { response.writeHead(404).end(); return; } + if (!stat.isFile()) { + response.writeHead(404).end(); + return; + } response.writeHead(200, { - 'Content-Type': mimeTypes[path.extname(exportedAssetName(id)).toLowerCase()] ?? 'application/octet-stream', + 'Content-Type': + mimeTypes[path.extname(exportedAssetName(id)).toLowerCase()] ?? + 'application/octet-stream', ...(compressedAsset.test(id) ? { 'Content-Encoding': 'gzip' } : {}), 'Content-Length': stat.size, 'X-Content-Type-Options': 'nosniff', @@ -214,10 +316,16 @@ export class AssetStore { 'Access-Control-Allow-Origin': '*', // CSP's wildcard does not include VS Code's custom desktop schemes. // Both the notebook webview and its top-level editor are ancestors. - 'Content-Security-Policy': 'default-src \'self\' data: blob: https:; script-src \'self\' \'unsafe-inline\' \'unsafe-eval\' https:; style-src \'self\' \'unsafe-inline\' https:; connect-src \'self\' https: wss:; object-src \'none\'; base-uri \'none\'; frame-ancestors * vscode-webview: vscode-file:', + 'Content-Security-Policy': + "default-src 'self' data: blob: https:; script-src 'self' 'unsafe-inline' 'unsafe-eval' https:; style-src 'self' 'unsafe-inline' https:; connect-src 'self' https: wss:; object-src 'none'; base-uri 'none'; frame-ancestors * vscode-webview: vscode-file:", }); - if (request.method === 'HEAD') { response.end(); } - else { fs.createReadStream(file).on('error', () => response.destroy()).pipe(response); } + if (request.method === 'HEAD') { + response.end(); + } else { + fs.createReadStream(file) + .on('error', () => response.destroy()) + .pipe(response); + } } catch { response.writeHead(404).end(); } @@ -227,9 +335,13 @@ export class AssetStore { this.server!.listen(0, '127.0.0.1', resolve); }); const address = this.server.address(); - if (!address || typeof address === 'string') { throw new Error('Could not open asset server'); } + if (!address || typeof address === 'string') { + throw new Error('Could not open asset server'); + } this.base = `http://127.0.0.1:${address.port}/${this.token}/`; } - close(): void { this.server?.close(); } + close(): void { + this.server?.close(); + } } diff --git a/src/interactive/backend.ts b/src/interactive/backend.ts index a4fd57403..3192841da 100644 --- a/src/interactive/backend.ts +++ b/src/interactive/backend.ts @@ -9,30 +9,53 @@ export interface RuntimeMetadata { rPath?: string; libraryPaths?: string[]; } -export type InspectionMethod = 'workspace' | 'workspace_children' | 'workspace_view' | 'listview_navigate' | 'listview_view' | 'hover' | 'completion' | - 'dataview_init' | 'dataview_page' | 'dataview_dispose'; -export interface InspectionRequest { method: InspectionMethod; params: Record; timeout?: number } -export interface InputReply { value: string } -export interface ClientReply { result?: unknown; error?: string } -export interface ResizePlotRequest { device: string; plot: number; width: number; height: number } +export type InspectionMethod = + | 'workspace' + | 'workspace_children' + | 'workspace_view' + | 'listview_navigate' + | 'listview_view' + | 'hover' + | 'completion' + | 'dataview_init' + | 'dataview_page' + | 'dataview_dispose'; +export interface InspectionRequest { + method: InspectionMethod; + params: Record; + timeout?: number; +} +export interface InputReply { + value: string; +} +export interface ClientReply { + result?: unknown; + error?: string; +} +export interface ResizePlotRequest { + device: string; + plot: number; + width: number; + height: number; +} /** Decoded application events; no provider sockets, wire packets or RPC identifiers. */ export type BackendEvent = ( - { type: 'ready'; metadata: RuntimeMetadata; capabilities: BackendCapabilities } | - { type: 'metadata'; metadata: RuntimeMetadata } | - { type: 'started' } | - { type: 'finished'; state: 'success' | 'error' | 'interrupted' } | - { type: 'stream'; text: string; channel: string; external?: boolean } | - { type: 'condition' | 'input' | 'display'; data: Record } | - { type: 'workspaceChanged' } | - { type: 'notification' | 'viewer'; method: string; params: Record } | - { type: 'clientRequest'; id: string; method: string; params: unknown } | - { type: 'clientRequestExpired'; id: string } | - { type: 'external'; code: string; success: boolean } | - { type: 'warning' | 'error' | 'truncated'; message: string } | - { type: 'provider'; data: Record } | - { type: 'unavailable'; message: string } | - { type: 'exit'; code?: number | null; signal?: string | null } + | { type: 'ready'; metadata: RuntimeMetadata; capabilities: BackendCapabilities } + | { type: 'metadata'; metadata: RuntimeMetadata } + | { type: 'started' } + | { type: 'finished'; state: 'success' | 'error' | 'interrupted' } + | { type: 'stream'; text: string; channel: string; external?: boolean } + | { type: 'condition' | 'input' | 'display'; data: Record } + | { type: 'workspaceChanged' } + | { type: 'notification' | 'viewer'; method: string; params: Record } + | { type: 'clientRequest'; id: string; method: string; params: unknown } + | { type: 'clientRequestExpired'; id: string } + | { type: 'external'; code: string; success: boolean } + | { type: 'warning' | 'error' | 'truncated'; message: string } + | { type: 'provider'; data: Record } + | { type: 'unavailable'; message: string } + | { type: 'exit'; code?: number | null; signal?: string | null } ) & { executionId?: string }; export interface SessionBackend { @@ -56,7 +79,20 @@ export interface SessionBackend { export type BackendFactory = (settings: AgentSettings) => SessionBackend; export function inspectionMethod(value: unknown): InspectionMethod { - if (!['workspace', 'workspace_children', 'workspace_view', 'listview_navigate', 'listview_view', 'hover', 'completion', 'dataview_init', 'dataview_page', 'dataview_dispose'].includes(String(value))) { + if ( + ![ + 'workspace', + 'workspace_children', + 'workspace_view', + 'listview_navigate', + 'listview_view', + 'hover', + 'completion', + 'dataview_init', + 'dataview_page', + 'dataview_dispose', + ].includes(String(value)) + ) { throw new Error('Unsupported inspection method'); } return value as InspectionMethod; diff --git a/src/interactive/backendRegistry.ts b/src/interactive/backendRegistry.ts index ffd6c1e02..a5f77f413 100644 --- a/src/interactive/backendRegistry.ts +++ b/src/interactive/backendRegistry.ts @@ -19,14 +19,26 @@ export interface BackendDefinition { function sessOptions(descriptor: BackendDescriptor, prepared = true): SessOptions { const options = object(descriptor.options); for (const key of prepared ? ['rPath', 'library', 'resources'] : ['rPath']) { - if (typeof options[key] !== 'string' || !options[key]) { throw new Error(`Missing sess backend ${key}`); } + if (typeof options[key] !== 'string' || !options[key]) { + throw new Error(`Missing sess backend ${key}`); + } } - if (options.frontend === 'r') { throw new Error('R Interactive now requires arf. Start an arf session or use an ordinary R terminal.'); } - if (options.frontend !== 'arf' || !['managed', 'adopted'].includes(String(options.ownership)) || - !['auto', 'jgd', 'standard'].includes(String(options.plotBackend))) { + if (options.frontend === 'r') { + throw new Error( + 'R Interactive now requires arf. Start an arf session or use an ordinary R terminal.', + ); + } + if ( + options.frontend !== 'arf' || + !['managed', 'adopted'].includes(String(options.ownership)) || + !['auto', 'jgd', 'standard'].includes(String(options.plotBackend)) + ) { throw new Error('Invalid sess backend configuration'); } - if (options.ownership === 'adopted' && (typeof options.arfEndpoint !== 'string' || !options.arfEndpoint)) { + if ( + options.ownership === 'adopted' && + (typeof options.arfEndpoint !== 'string' || !options.arfEndpoint) + ) { throw new Error('Missing arf endpoint'); } return options as unknown as SessOptions; @@ -38,46 +50,95 @@ const definitions: Readonly> = { const options = sessOptions(descriptor, false); if (options.frontend === 'arf' && options.ownership === 'managed') { const arfPath = resolveArfExecutable(options.arfPath ?? 'arf', settings.directory); - if (!arfPath) { throw new Error('Cannot start Headless arf. Install arf or set r.interactive.arfPath to its executable.'); } + if (!arfPath) { + throw new Error( + 'Cannot start Headless arf. Install arf or set r.interactive.arfPath to its executable.', + ); + } return { kind: 'sess', options: { ...options, arfPath } }; } return descriptor; }, async prepare(descriptor, context) { const options = sessOptions(descriptor, false); - const runtime = await installSessRuntime(context.extensionPath, context.root, options.rPath, text => context.log(text)); + const runtime = await installSessRuntime( + context.extensionPath, + context.root, + options.rPath, + (text) => context.log(text), + ); return { kind: 'sess', options: { ...options, ...runtime } }; }, - create(descriptor, settings) { return new SessBackend(settings, sessOptions(descriptor)); }, + create(descriptor, settings) { + return new SessBackend(settings, sessOptions(descriptor)); + }, }, }; export function backendDefinition(kind: string): BackendDefinition { const definition = Object.hasOwn(definitions, kind) && definitions[kind]; - if (!definition) { throw new Error(`Unsupported Interactive backend: ${kind}`); } + if (!definition) { + throw new Error(`Unsupported Interactive backend: ${kind}`); + } return definition; } /** The only conversion from flat, pre-abstraction agent configs. */ export function backendDescriptor(config: AgentConfig): BackendDescriptor { - if (config.backend) { return config.backend; } - if (!['r', 'arf', 'arf-existing'].includes(config.provider)) { throw new Error('Unsupported Interactive provider'); } - return { kind: 'sess', options: { - frontend: config.provider === 'r' ? 'r' : 'arf', ownership: config.provider === 'arf-existing' ? 'adopted' : 'managed', - rPath: config.rPath, library: config.library, resources: config.resources, - arfPath: config.arfPath, arfEndpoint: config.arfEndpoint, plotBackend: config.plotBackend ?? 'auto', - } }; + if (config.backend) { + return config.backend; + } + if (!['r', 'arf', 'arf-existing'].includes(config.provider)) { + throw new Error('Unsupported Interactive provider'); + } + return { + kind: 'sess', + options: { + frontend: config.provider === 'r' ? 'r' : 'arf', + ownership: config.provider === 'arf-existing' ? 'adopted' : 'managed', + rPath: config.rPath, + library: config.library, + resources: config.resources, + arfPath: config.arfPath, + arfEndpoint: config.arfEndpoint, + plotBackend: config.plotBackend ?? 'auto', + }, + }; } /** Keep old extension readers working, deriving all legacy fields from the descriptor. */ export function withBackend(config: AgentConfig, backend: BackendDescriptor): AgentConfig { const common = { ...config }; - for (const key of ['rPath', 'library', 'resources', 'arfPath', 'arfEndpoint', 'plotBackend'] as const) { delete common[key]; } - if (backend.kind !== 'sess') { return { ...common, backend }; } + for (const key of [ + 'rPath', + 'library', + 'resources', + 'arfPath', + 'arfEndpoint', + 'plotBackend', + ] as const) { + delete common[key]; + } + if (backend.kind !== 'sess') { + return { ...common, backend }; + } const options = sessOptions(backend); - return { ...common, backend, rPath: options.rPath, library: options.library, resources: options.resources, - arfPath: options.arfPath, arfEndpoint: options.arfEndpoint, plotBackend: options.plotBackend, - provider: options.frontend === 'r' ? 'r' : options.ownership === 'adopted' ? 'arf-existing' : 'arf' }; + return { + ...common, + backend, + rPath: options.rPath, + library: options.library, + resources: options.resources, + arfPath: options.arfPath, + arfEndpoint: options.arfEndpoint, + plotBackend: options.plotBackend, + provider: + options.frontend === 'r' + ? 'r' + : options.ownership === 'adopted' + ? 'arf-existing' + : 'arf', + }; } export function createBackend(config: AgentConfig): SessionBackend { @@ -86,8 +147,11 @@ export function createBackend(config: AgentConfig): SessionBackend { } /** Selection is fixed internally; the optional definition is a test injection seam. */ -export async function prepareBackendRuntime(config: AgentConfig, context: PreparationContext, - definition = backendDefinition(backendDescriptor(config).kind)): Promise<{ config: AgentConfig; agent: string }> { +export async function prepareBackendRuntime( + config: AgentConfig, + context: PreparationContext, + definition = backendDefinition(backendDescriptor(config).kind), +): Promise<{ config: AgentConfig; agent: string }> { const selected = definition.preflight(backendDescriptor(config), config); const backend = definition.preflight(await definition.prepare(selected, context), config); const agent = installAgentBundle(context.extensionPath, context.root); diff --git a/src/interactive/backends/arf.ts b/src/interactive/backends/arf.ts index ff8b1b31d..064dfa4e1 100644 --- a/src/interactive/backends/arf.ts +++ b/src/interactive/backends/arf.ts @@ -10,53 +10,126 @@ export class Arf { ready = false; private abort = new AbortController(); private started = false; - constructor(private command: string, private directory: string, private endpoint: string | undefined, - adopted: boolean, private emit: (event: BackendEvent) => void, - private token: string, private event: (message: Record, channel: string) => void) { + constructor( + private command: string, + private directory: string, + private endpoint: string | undefined, + adopted: boolean, + private emit: (event: BackendEvent) => void, + private token: string, + private event: (message: Record, channel: string) => void, + ) { this.process = new RuntimeProcess(adopted, emit); } - markStarted(): void { this.started = true; } + markStarted(): void { + this.started = true; + } async start(bootstrap: string, env: NodeJS.ProcessEnv): Promise { if (!this.process.adopted) { - const output = new Map(['stdout', 'stderr'].map(channel => [channel, new ArfOutput(this.token, - text => { if (text) { this.emit({ type: 'stream', text, channel }); } }, - message => this.event(message, channel))])); + const output = new Map( + ['stdout', 'stderr'].map((channel) => [ + channel, + new ArfOutput( + this.token, + (text) => { + if (text) { + this.emit({ type: 'stream', text, channel }); + } + }, + (message) => this.event(message, channel), + ), + ]), + ); this.endpoint = await new Promise((resolve, reject) => { let readiness = ''; - const timer = setTimeout(() => reject(new Error('arf did not report its IPC endpoint')), 30000); - const child = this.process.launch(this.command, ['headless', '--json'], this.directory, env, (text, channel) => { - if (channel === 'stdout' && !this.endpoint) { - readiness += text; - let newline: number; - while (!this.endpoint && (newline = readiness.indexOf('\n')) >= 0) { - const line = readiness.slice(0, newline); readiness = readiness.slice(newline + 1); - let info: Record | undefined; - try { info = object(JSON.parse(line)); } catch { /* Profiles can print before readiness. */ } - if (typeof info?.socket_path === 'string') { - this.endpoint = info.socket_path; clearTimeout(timer); resolve(this.endpoint); - } else { this.emit({ type: 'stream', text: line + '\n', channel }); } - } - if (this.endpoint || readiness.length > 65536) { - if (readiness) { output.get(channel)!.push(readiness); } - readiness = ''; + const timer = setTimeout( + () => reject(new Error('arf did not report its IPC endpoint')), + 30000, + ); + const child = this.process.launch( + this.command, + ['headless', '--json'], + this.directory, + env, + (text, channel) => { + if (channel === 'stdout' && !this.endpoint) { + readiness += text; + let newline: number; + while (!this.endpoint && (newline = readiness.indexOf('\n')) >= 0) { + const line = readiness.slice(0, newline); + readiness = readiness.slice(newline + 1); + let info: Record | undefined; + try { + info = object(JSON.parse(line)); + } catch { + /* Profiles can print before readiness. */ + } + if (typeof info?.socket_path === 'string') { + this.endpoint = info.socket_path; + clearTimeout(timer); + resolve(this.endpoint); + } else { + this.emit({ type: 'stream', text: line + '\n', channel }); + } + } + if (this.endpoint || readiness.length > 65536) { + if (readiness) { + output.get(channel)!.push(readiness); + } + readiness = ''; + } + } else { + try { + output.get(channel)!.push(text); + } catch (error) { + this.emit({ type: 'unavailable', message: String(error) }); + } } - } else { - try { output.get(channel)!.push(text); } - catch (error) { this.emit({ type: 'unavailable', message: String(error) }); } - } + }, + ); + child.once('error', (error) => { + clearTimeout(timer); + reject(error); + }); + child.once('exit', () => { + clearTimeout(timer); + reject(new Error('arf exited during startup')); }); - child.once('error', error => { clearTimeout(timer); reject(error); }); - child.once('exit', () => { clearTimeout(timer); reject(new Error('arf exited during startup')); }); }); } - if (!this.endpoint) { throw new Error('Missing arf endpoint'); } - const metadata = object(await arfRequest(this.endpoint, 'session', {}, 30000, this.abort.signal)); - this.emit({ type: 'provider', data: { provider: this.process.adopted ? 'arf-existing' : 'arf', policy: metadata.ipc_policy } }); + if (!this.endpoint) { + throw new Error('Missing arf endpoint'); + } + const metadata = object( + await arfRequest(this.endpoint, 'session', {}, 30000, this.abort.signal), + ); + this.emit({ + type: 'provider', + data: { + provider: this.process.adopted ? 'arf-existing' : 'arf', + policy: metadata.ipc_policy, + }, + }); // Visible evaluation respects arf's advertised IPC policy, including bootstrap. - const result = object(await arfRequest(this.endpoint, 'evaluate', { code: bootstrap, visible: true }, 30000, this.abort.signal)); - if (result.error) { throw new Error(typeof result.error === 'string' ? result.error : JSON.stringify(result.error)); } + const result = object( + await arfRequest( + this.endpoint, + 'evaluate', + { code: bootstrap, visible: true }, + 30000, + this.abort.signal, + ), + ); + if (result.error) { + throw new Error( + typeof result.error === 'string' ? result.error : JSON.stringify(result.error), + ); + } + } + dispose(): void { + this.abort.abort(); + this.process.dispose(); } - dispose(): void { this.abort.abort(); this.process.dispose(); } async dispatch(submission: Submission): Promise { const code = `sess:::interactive_execute(${rString(submission.id)}, ${rString(submission.code)}, jsonlite::fromJSON(${rString(JSON.stringify(submission.source ?? null))}, simplifyVector=FALSE))`; this.started = false; @@ -64,12 +137,21 @@ export class Arf { // R's ordered events establish the outcome even if arf's reply times out. let result: Record; try { - result = object(await arfRequest(this.endpoint!, this.process.adopted ? 'evaluate' : 'user_input', - { code, visible: true, timeout_ms: 86400000 }, 0, this.abort.signal)); + result = object( + await arfRequest( + this.endpoint!, + this.process.adopted ? 'evaluate' : 'user_input', + { code, visible: true, timeout_ms: 86400000 }, + 0, + this.abort.signal, + ), + ); } catch (error) { // A server timeout does not cancel R. Keep accepting its event stream. // Other transport failures remain ambiguous and must block dispatch. - if (!this.process.adopted && this.started && /timed out|timeout/i.test(String(error))) { return; } + if (!this.process.adopted && this.started && /timed out|timeout/i.test(String(error))) { + return; + } throw error; } if (this.process.adopted) { @@ -80,7 +162,11 @@ export class Arf { } } if (result.error || result.accepted === false) { - throw new Error(typeof result.error === 'string' ? result.error : 'arf rejected Interactive execution'); + throw new Error( + typeof result.error === 'string' + ? result.error + : 'arf rejected Interactive execution', + ); } } } diff --git a/src/interactive/backends/arfOutput.ts b/src/interactive/backends/arfOutput.ts index 492e37eec..312863a0f 100644 --- a/src/interactive/backends/arfOutput.ts +++ b/src/interactive/backends/arfOutput.ts @@ -4,28 +4,42 @@ import { MAX_MESSAGE_BYTES, object } from '../protocol'; export class ArfOutput { private pending = ''; private prefix: string; - constructor(token: string, private output: (text: string) => void, - private event: (message: Record) => void) { this.prefix = `\x1e${token}:`; } + constructor( + token: string, + private output: (text: string) => void, + private event: (message: Record) => void, + ) { + this.prefix = `\x1e${token}:`; + } push(text: string): void { this.pending += text; for (;;) { const start = this.pending.indexOf(this.prefix); if (start < 0) { let keep = Math.min(this.pending.length, this.prefix.length - 1); - while (keep && !this.prefix.startsWith(this.pending.slice(-keep))) { keep--; } + while (keep && !this.prefix.startsWith(this.pending.slice(-keep))) { + keep--; + } this.output(this.pending.slice(0, this.pending.length - keep)); this.pending = this.pending.slice(this.pending.length - keep); return; } - if (start) { this.output(this.pending.slice(0, start)); this.pending = this.pending.slice(start); } + if (start) { + this.output(this.pending.slice(0, start)); + this.pending = this.pending.slice(start); + } const end = this.pending.indexOf('\x1f', this.prefix.length); if (end < 0) { - if (this.pending.length > MAX_MESSAGE_BYTES) { throw new Error('arf event exceeds size limit'); } + if (this.pending.length > MAX_MESSAGE_BYTES) { + throw new Error('arf event exceeds size limit'); + } return; } const encoded = this.pending.slice(this.prefix.length, end); this.pending = this.pending.slice(end + 1); - if (encoded.length > MAX_MESSAGE_BYTES || !/^[A-Za-z0-9+/]*={0,2}$/.test(encoded)) { throw new Error('Invalid arf event'); } + if (encoded.length > MAX_MESSAGE_BYTES || !/^[A-Za-z0-9+/]*={0,2}$/.test(encoded)) { + throw new Error('Invalid arf event'); + } this.event(object(JSON.parse(Buffer.from(encoded, 'base64').toString('utf8')))); } } diff --git a/src/interactive/backends/process.ts b/src/interactive/backends/process.ts index fc52eebe4..8263df2c3 100644 --- a/src/interactive/backends/process.ts +++ b/src/interactive/backends/process.ts @@ -10,67 +10,130 @@ export class RuntimeProcess { private closed = false; private monitor?: NodeJS.Timeout; private stopping?: Promise; - constructor(readonly adopted: boolean, private emit: (event: BackendEvent) => void) { } - launch(command: string, args: string[], directory: string, env: NodeJS.ProcessEnv, - output: (text: string, channel: string) => void): ChildProcess { - this.child = spawn(command, args, { cwd: directory, env, stdio: ['ignore', 'pipe', 'pipe'] }); + constructor( + readonly adopted: boolean, + private emit: (event: BackendEvent) => void, + ) {} + launch( + command: string, + args: string[], + directory: string, + env: NodeJS.ProcessEnv, + output: (text: string, channel: string) => void, + ): ChildProcess { + this.child = spawn(command, args, { + cwd: directory, + env, + stdio: ['ignore', 'pipe', 'pipe'], + }); this.pid = this.child.pid; - this.child.on('error', error => { this.emit({ type: 'error', message: error.message }); this.exit(); }); + this.child.on('error', (error) => { + this.emit({ type: 'error', message: error.message }); + this.exit(); + }); this.child.on('exit', (code, signal) => this.exit(code, signal)); for (const channel of ['stdout', 'stderr'] as const) { const decoder = new StringDecoder('utf8'); - this.child[channel]!.on('data', (chunk: Buffer) => { if (!this.closed) { output(decoder.write(chunk), channel); } }); + this.child[channel]!.on('data', (chunk: Buffer) => { + if (!this.closed) { + output(decoder.write(chunk), channel); + } + }); } return this.child; } setPid(pid: number): void { this.pid = pid; if (this.adopted && !this.monitor) { - this.monitor = setInterval(() => { if (!this.alive()) { this.exit(); } }, 500); + this.monitor = setInterval(() => { + if (!this.alive()) { + this.exit(); + } + }, 500); this.monitor.unref(); } } private alive(): boolean { - if (this.exited || !this.pid) { return false; } - if (this.child) { return this.child.exitCode === null && this.child.signalCode === null; } - try { process.kill(this.pid, 0); return true; } - catch (error) { return (error as NodeJS.ErrnoException).code !== 'ESRCH'; } + if (this.exited || !this.pid) { + return false; + } + if (this.child) { + return this.child.exitCode === null && this.child.signalCode === null; + } + try { + process.kill(this.pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code !== 'ESRCH'; + } } private exit(code?: number | null, signal?: string | null): void { - if (this.exited) { return; } - this.exited = true; clearInterval(this.monitor); - if (!this.closed) { this.emit({ type: 'exit', code, signal }); } + if (this.exited) { + return; + } + this.exited = true; + clearInterval(this.monitor); + if (!this.closed) { + this.emit({ type: 'exit', code, signal }); + } } private signal(signal: NodeJS.Signals): void { - if (!this.alive()) { this.exit(); return; } - try { process.kill(this.pid!, signal); } - catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ESRCH') { this.exit(); } - else { throw error; } + if (!this.alive()) { + this.exit(); + return; } + try { + process.kill(this.pid!, signal); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ESRCH') { + this.exit(); + } else { + throw error; + } + } + } + interrupt(): void { + this.signal('SIGINT'); } - interrupt(): void { this.signal('SIGINT'); } stop(force = false): Promise { - return this.stopping ??= this.stopProcess(force); + return (this.stopping ??= this.stopProcess(force)); } private async stopProcess(force: boolean): Promise { - if (!this.pid) { return; } + if (!this.pid) { + return; + } this.signal(force ? 'SIGKILL' : 'SIGINT'); const start = Date.now(); - let terminated = force, killed = force; + let terminated = force, + killed = force; while (this.alive()) { - if (this.closed) { return; } + if (this.closed) { + return; + } const elapsed = Date.now() - start; - if (!terminated && elapsed >= 100) { this.signal('SIGTERM'); terminated = true; } - if (!killed && elapsed >= 3000) { this.signal('SIGKILL'); killed = true; } - if (elapsed > 5000) { throw new Error('R has not exited after Stop'); } - await new Promise(resolve => setTimeout(resolve, 25)); + if (!terminated && elapsed >= 100) { + this.signal('SIGTERM'); + terminated = true; + } + if (!killed && elapsed >= 3000) { + this.signal('SIGKILL'); + killed = true; + } + if (elapsed > 5000) { + throw new Error('R has not exited after Stop'); + } + await new Promise((resolve) => setTimeout(resolve, 25)); } this.exit(); } dispose(): void { - if (this.closed) { return; } - if (!this.adopted) { this.signal('SIGKILL'); } - this.closed = true; clearInterval(this.monitor); + if (this.closed) { + return; + } + if (!this.adopted) { + this.signal('SIGKILL'); + } + this.closed = true; + clearInterval(this.monitor); } } diff --git a/src/interactive/backends/rCode.ts b/src/interactive/backends/rCode.ts index cfaac92eb..0ebaee6f3 100644 --- a/src/interactive/backends/rCode.ts +++ b/src/interactive/backends/rCode.ts @@ -1,3 +1,5 @@ export function rString(value: string): string { - return JSON.stringify(value).replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029'); + return JSON.stringify(value) + .replace(/\u2028/g, '\\u2028') + .replace(/\u2029/g, '\\u2029'); } diff --git a/src/interactive/backends/sessBackend.ts b/src/interactive/backends/sessBackend.ts index 1d43d81a4..39736be14 100644 --- a/src/interactive/backends/sessBackend.ts +++ b/src/interactive/backends/sessBackend.ts @@ -6,7 +6,15 @@ import { promisify } from 'util'; import { randomBytes } from 'crypto'; import { atomicJson } from '../journal'; import { AgentSettings, object, Submission } from '../protocol'; -import { BackendCapabilities, BackendEvent, ClientReply, InputReply, InspectionRequest, ResizePlotRequest, SessionBackend } from '../backend'; +import { + BackendCapabilities, + BackendEvent, + ClientReply, + InputReply, + InspectionRequest, + ResizePlotRequest, + SessionBackend, +} from '../backend'; import { SessBridge } from './sessBridge'; import { SessGraphics } from './sessGraphics'; import { Arf } from './arf'; @@ -45,136 +53,298 @@ export class SessBackend implements SessionBackend { private closed = false; private disposing?: Promise; private startup?: Promise; - constructor(private settings: AgentSettings, private options: SessOptions) { - if (options.frontend !== 'arf') { throw new Error('R Interactive now requires arf. Start an arf session or use an ordinary R terminal.'); } + constructor( + private settings: AgentSettings, + private options: SessOptions, + ) { + if (options.frontend !== 'arf') { + throw new Error( + 'R Interactive now requires arf. Start an arf session or use an ordinary R terminal.', + ); + } this.ownership = options.ownership; - this.capabilities = { streaming: options.ownership === 'managed', stdin: false, debugger: false, tables: true, html: true, - inspection: true, interrupt: process.platform !== 'win32', jgd: false, plotResize: false, restart: options.ownership === 'managed' }; + this.capabilities = { + streaming: options.ownership === 'managed', + stdin: false, + debugger: false, + tables: true, + html: true, + inspection: true, + interrupt: process.platform !== 'win32', + jgd: false, + plotResize: false, + restart: options.ownership === 'managed', + }; this.runtime = fs.mkdtempSync(path.join(os.tmpdir(), 'r-b-')); fs.chmodSync(this.runtime, 0o700); - this.bridge = new SessBridge(this.runtime, this.token, event => this.emit(event), - message => this.receive(message), (method, params) => this.notification(method, params, this.current)); - this.graphics = new SessGraphics({ ...options, historyLimit: settings.historyLimit }, event => this.emit(event)); + this.bridge = new SessBridge( + this.runtime, + this.token, + (event) => this.emit(event), + (message) => this.receive(message), + (method, params) => this.notification(method, params, this.current), + ); + this.graphics = new SessGraphics( + { ...options, historyLimit: settings.historyLimit }, + (event) => this.emit(event), + ); const output = (event: BackendEvent): void => { - if (this.closed || this.outputFailed && event.type === 'stream') { return; } - if (event.type === 'stream' && event.channel === 'stdout' && this.ownership === 'managed') { - this.orderedOutput.push({ text: event.text }); this.bufferedBytes += Buffer.byteLength(event.text); + if (this.closed || (this.outputFailed && event.type === 'stream')) { + return; + } + if ( + event.type === 'stream' && + event.channel === 'stdout' && + this.ownership === 'managed' + ) { + this.orderedOutput.push({ text: event.text }); + this.bufferedBytes += Buffer.byteLength(event.text); this.drainEvents(); - } else { this.emit(event.type === 'stream' ? { ...event, executionId: this.current } : event); } + } else { + this.emit( + event.type === 'stream' ? { ...event, executionId: this.current } : event, + ); + } }; - this.frontend = new Arf(options.arfPath ?? 'arf', settings.directory, options.arfEndpoint, this.ownership === 'adopted', output, - this.token, (message, channel) => this.runtimeEvent(message, channel)); + this.frontend = new Arf( + options.arfPath ?? 'arf', + settings.directory, + options.arfEndpoint, + this.ownership === 'adopted', + output, + this.token, + (message, channel) => this.runtimeEvent(message, channel), + ); } onEvent(listener: (event: BackendEvent) => void): () => void { - this.listeners.add(listener); return () => this.listeners.delete(listener); + this.listeners.add(listener); + return () => this.listeners.delete(listener); } private emit(event: BackendEvent): void { - if (this.closed) { return; } - if ((event.type === 'metadata' || event.type === 'ready') && event.metadata.rPid) { this.frontend.process.setPid(event.metadata.rPid); } - if (event.type === 'clientRequest' && !event.executionId) { event = { ...event, executionId: this.current }; } - if (event.type === 'exit') { this.graphics.flush(); this.graphics.dispose(); } - for (const listener of this.listeners) { listener(event); } + if (this.closed) { + return; + } + if ((event.type === 'metadata' || event.type === 'ready') && event.metadata.rPid) { + this.frontend.process.setPid(event.metadata.rPid); + } + if (event.type === 'clientRequest' && !event.executionId) { + event = { ...event, executionId: this.current }; + } + if (event.type === 'exit') { + this.graphics.flush(); + this.graphics.dispose(); + } + for (const listener of this.listeners) { + listener(event); + } + } + start(): Promise { + return (this.startup ??= this.startRuntime()); } - start(): Promise { return this.startup ??= this.startRuntime(); } private async startRuntime(): Promise { - const libraries = await run(this.options.rPath, ['--vanilla', '--slave', '-e', 'cat(.libPaths(), sep="\\n")'], { timeout: 15000 }); - if (this.closed) { throw new Error('Backend disposed during startup'); } + const libraries = await run( + this.options.rPath, + ['--vanilla', '--slave', '-e', 'cat(.libPaths(), sep="\\n")'], + { timeout: 15000 }, + ); + if (this.closed) { + throw new Error('Backend disposed during startup'); + } const support = libraries.stdout.trim().split(/\r?\n/).filter(Boolean); await this.bridge.start(); - if (this.closed) { throw new Error('Backend disposed during startup'); } + if (this.closed) { + throw new Error('Backend disposed during startup'); + } await this.graphics.start(); - if (this.closed) { this.graphics.dispose(); throw new Error('Backend disposed during startup'); } - this.capabilities.jgd = this.graphics.enabled; this.capabilities.plotResize = this.graphics.enabled; + if (this.closed) { + this.graphics.dispose(); + throw new Error('Backend disposed during startup'); + } + this.capabilities.jgd = this.graphics.enabled; + this.capabilities.plotResize = this.graphics.enabled; const bootstrap = path.join(this.runtime, 'bootstrap.json'); - atomicJson(bootstrap, { sess: path.join(this.runtime, 'sess.sock'), - jgd: this.graphics.endpoint, token: this.token, useJgd: this.graphics.enabled }); - const code = `base::source(${rString(path.join(this.options.resources, 'interactive-worker.R'))}, local=base::new.env(parent=base::baseenv()))$value(` + + atomicJson(bootstrap, { + sess: path.join(this.runtime, 'sess.sock'), + jgd: this.graphics.endpoint, + token: this.token, + useJgd: this.graphics.enabled, + }); + const code = + `base::source(${rString(path.join(this.options.resources, 'interactive-worker.R'))}, local=base::new.env(parent=base::baseenv()))$value(` + `${rString(this.options.library)}, ${rString(bootstrap)}, c(${support.map(rString).join(',')}), managed=${this.ownership === 'managed' ? 'TRUE' : 'FALSE'})`; - await this.frontend.start(code, { ...process.env, SESS_ENDPOINT: path.join(this.runtime, 'sess.sock'), JGD_SOCKET: this.graphics.endpoint }); + await this.frontend.start(code, { + ...process.env, + SESS_ENDPOINT: path.join(this.runtime, 'sess.sock'), + JGD_SOCKET: this.graphics.endpoint, + }); } async dispatch(submission: Submission): Promise { await this.startup; - if (this.closed) { throw new Error('Backend is disposed'); } + if (this.closed) { + throw new Error('Backend is disposed'); + } this.current = submission.id; - this.completion = undefined; this.stderrFlushed = false; this.outputDrained = false; + this.completion = undefined; + this.stderrFlushed = false; + this.outputDrained = false; await this.frontend.dispatch(submission); this.outputDrained = true; - if (this.ownership === 'adopted' && this.completion) { this.finish(this.completion); } + if (this.ownership === 'adopted' && this.completion) { + this.finish(this.completion); + } + } + inspect(request: InspectionRequest): Promise { + return this.bridge.request(request.method, request.params, request.timeout ?? 5000); + } + replyInput(_reply: InputReply): Promise { + return Promise.reject( + new Error( + 'Notebook console input is not supported by arf IPC. Use an ordinary R terminal.', + ), + ); + } + replyClientRequest(id: string, reply: ClientReply): Promise { + this.bridge.reply(id, reply); + return Promise.resolve(); + } + interrupt(): Promise { + this.frontend.process.interrupt(); + return Promise.resolve(); } - inspect(request: InspectionRequest): Promise { return this.bridge.request(request.method, request.params, request.timeout ?? 5000); } - replyInput(_reply: InputReply): Promise { return Promise.reject(new Error('Notebook console input is not supported by arf IPC. Use an ordinary R terminal.')); } - replyClientRequest(id: string, reply: ClientReply): Promise { this.bridge.reply(id, reply); return Promise.resolve(); } - interrupt(): Promise { this.frontend.process.interrupt(); return Promise.resolve(); } async stop(options?: { force?: boolean }): Promise { // Stop may arrive after the control endpoint opens but before R is spawned. await this.startup; await this.frontend.process.stop(options?.force); } - resizePlot(request: ResizePlotRequest): Promise { this.graphics.resize(request); return Promise.resolve(); } - dispose(): Promise { return this.disposing ??= this.disposeRuntime(); } + resizePlot(request: ResizePlotRequest): Promise { + this.graphics.resize(request); + return Promise.resolve(); + } + dispose(): Promise { + return (this.disposing ??= this.disposeRuntime()); + } private async disposeRuntime(): Promise { - this.closed = true; this.listeners.clear(); - this.orderedOutput = []; this.orderedEvents.clear(); this.bufferedBytes = 0; this.bufferedEventBytes = 0; + this.closed = true; + this.listeners.clear(); + this.orderedOutput = []; + this.orderedEvents.clear(); + this.bufferedBytes = 0; + this.bufferedEventBytes = 0; try { if (this.ownership === 'adopted') { // Best effort through the existing bridge, without evaluating user code or interrupting R. await this.bridge.request('interactive_stop', {}, 500).catch(() => undefined); } } finally { - this.frontend.dispose(); this.graphics.dispose(); this.bridge.dispose(); + this.frontend.dispose(); + this.graphics.dispose(); + this.bridge.dispose(); fs.rmSync(this.runtime, { recursive: true, force: true }); } } private runtimeEvent(message: Record, channel?: string): void { - if (this.closed || this.outputFailed) { return; } - const executionId = typeof message.executionId === 'string' && message.executionId ? message.executionId : undefined; + if (this.closed || this.outputFailed) { + return; + } + const executionId = + typeof message.executionId === 'string' && message.executionId + ? message.executionId + : undefined; switch (message.type) { case 'event': if (channel === 'stdout' && Number.isSafeInteger(message.eventId)) { - this.orderedOutput.push({ eventId: Number(message.eventId) }); this.drainEvents(); + this.orderedOutput.push({ eventId: Number(message.eventId) }); + this.drainEvents(); } break; case 'ready': this.frontend.ready = true; - this.emit({ type: 'ready', capabilities: { ...this.capabilities }, metadata: { - rPid: Number(message.pid), rVersion: String(message.version), runtimeSessionId: String(message.sessionId), - rPath: typeof message.rPath === 'string' ? message.rPath : this.options.rPath, - libraryPaths: Array.isArray(message.libraryPaths) ? message.libraryPaths.map(String) : [] } }); + this.emit({ + type: 'ready', + capabilities: { ...this.capabilities }, + metadata: { + rPid: Number(message.pid), + rVersion: String(message.version), + runtimeSessionId: String(message.sessionId), + rPath: + typeof message.rPath === 'string' ? message.rPath : this.options.rPath, + libraryPaths: Array.isArray(message.libraryPaths) + ? message.libraryPaths.map(String) + : [], + }, + }); + break; + case 'started': + this.frontend.markStarted(); + this.emit({ type: 'started', executionId }); break; - case 'started': this.frontend.markStarted(); this.emit({ type: 'started', executionId }); break; case 'flush': if (channel === 'stderr') { this.stderrFlushed = true; - if (this.completion) { this.finish(this.completion); } + if (this.completion) { + this.finish(this.completion); + } } break; case 'finished': this.completion = message; - if (this.ownership === 'managed' ? this.stderrFlushed : this.outputDrained) { this.finish(message); } + if (this.ownership === 'managed' ? this.stderrFlushed : this.outputDrained) { + this.finish(message); + } + break; + case 'stream': + this.emit({ + type: 'stream', + executionId, + text: typeof message.text === 'string' ? message.text : '', + channel: typeof message.channel === 'string' ? message.channel : 'stdout', + external: !executionId && this.options.frontend === 'arf', + }); + break; + case 'condition': + case 'input': + case 'display': + this.emit({ type: message.type, data: message, executionId }); + break; + case 'truncated': + this.emit({ type: 'truncated', message: String(message.message), executionId }); break; - case 'stream': this.emit({ type: 'stream', executionId, text: typeof message.text === 'string' ? message.text : '', - channel: typeof message.channel === 'string' ? message.channel : 'stdout', external: !executionId && this.options.frontend === 'arf' }); break; - case 'condition': case 'input': case 'display': this.emit({ type: message.type, data: message, executionId }); break; - case 'truncated': this.emit({ type: 'truncated', message: String(message.message), executionId }); break; case 'notification': { // R encodes an empty list as []; task callbacks send notifications // such as workspace_updated without any parameters. const params = message.params; - this.notification(String(message.method), object(Array.isArray(params) && !params.length ? {} : params ?? {}), executionId); + this.notification( + String(message.method), + object(Array.isArray(params) && !params.length ? {} : (params ?? {})), + executionId, + ); break; } - case 'external': this.emit({ type: 'external', code: typeof message.code === 'string' ? message.code : '# Terminal output', success: Boolean(message.success) }); break; + case 'external': + this.emit({ + type: 'external', + code: typeof message.code === 'string' ? message.code : '# Terminal output', + success: Boolean(message.success), + }); + break; } } private receive(message: Record): void { - if (this.closed || this.outputFailed) { return; } - if (this.ownership === 'adopted') { this.runtimeEvent(message); return; } + if (this.closed || this.outputFailed) { + return; + } + if (this.ownership === 'adopted') { + this.runtimeEvent(message); + return; + } if (Number.isSafeInteger(message.eventId)) { - const eventId = Number(message.eventId), event = object(message.event); + const eventId = Number(message.eventId), + event = object(message.event); const previous = this.orderedEvents.get(eventId); const bytes = Buffer.byteLength(JSON.stringify(event)); this.bufferedEventBytes += bytes - (previous?.bytes ?? 0); - this.orderedEvents.set(eventId, { event, bytes }); this.drainEvents(); + this.orderedEvents.set(eventId, { event, bytes }); + this.drainEvents(); } } private drainEvents(): void { @@ -182,34 +352,84 @@ export class SessBackend implements SessionBackend { const item = this.orderedOutput[0]; if ('eventId' in item) { const event = this.orderedEvents.get(item.eventId); - if (!event) { break; } - this.orderedOutput.shift(); this.orderedEvents.delete(item.eventId); - this.bufferedEventBytes -= event.bytes; this.runtimeEvent(event.event); + if (!event) { + break; + } + this.orderedOutput.shift(); + this.orderedEvents.delete(item.eventId); + this.bufferedEventBytes -= event.bytes; + this.runtimeEvent(event.event); } else { - this.orderedOutput.shift(); this.bufferedBytes -= Buffer.byteLength(item.text); - this.emit({ type: 'stream', text: item.text, channel: 'stdout', executionId: this.current }); + this.orderedOutput.shift(); + this.bufferedBytes -= Buffer.byteLength(item.text); + this.emit({ + type: 'stream', + text: item.text, + channel: 'stdout', + executionId: this.current, + }); } } - if (this.bufferedBytes > 4 * 1024 * 1024 || this.bufferedEventBytes > 16 * 1024 * 1024 || - this.orderedOutput.length > 1024 || this.orderedEvents.size > 1024) { + if ( + this.bufferedBytes > 4 * 1024 * 1024 || + this.bufferedEventBytes > 16 * 1024 * 1024 || + this.orderedOutput.length > 1024 || + this.orderedEvents.size > 1024 + ) { this.outputFailed = true; - this.orderedOutput = []; this.orderedEvents.clear(); this.bufferedBytes = 0; this.bufferedEventBytes = 0; - this.emit({ type: 'unavailable', message: 'R output references missing Interactive events; restart the session before submitting more code.' }); + this.orderedOutput = []; + this.orderedEvents.clear(); + this.bufferedBytes = 0; + this.bufferedEventBytes = 0; + this.emit({ + type: 'unavailable', + message: + 'R output references missing Interactive events; restart the session before submitting more code.', + }); } } private finish(message: Record): void { const executionId = String(message.executionId); - if (this.current !== executionId) { return; } + if (this.current !== executionId) { + return; + } this.graphics.flush(executionId); - this.current = undefined; this.completion = undefined; - this.emit({ type: 'finished', executionId, - state: message.state === 'success' || message.state === 'interrupted' ? message.state : 'error' }); - } - private notification(method: string, params: Record, executionId?: string): void { - if (['webview', 'page_viewer', 'browser'].includes(method) && typeof params.url === 'string') { - this.emit({ type: 'display', executionId, data: /^https?:\/\//.test(params.url) - ? { kind: 'url', url: params.url } : { kind: 'html', file: params.url } }); - } else if (method === 'workspace_updated') { this.emit({ type: 'workspaceChanged' }); } - else { this.emit({ type: method === 'dataview' ? 'viewer' : 'notification', method, params, executionId }); } + this.current = undefined; + this.completion = undefined; + this.emit({ + type: 'finished', + executionId, + state: + message.state === 'success' || message.state === 'interrupted' + ? message.state + : 'error', + }); + } + private notification( + method: string, + params: Record, + executionId?: string, + ): void { + if ( + ['webview', 'page_viewer', 'browser'].includes(method) && + typeof params.url === 'string' + ) { + this.emit({ + type: 'display', + executionId, + data: /^https?:\/\//.test(params.url) + ? { kind: 'url', url: params.url } + : { kind: 'html', file: params.url }, + }); + } else if (method === 'workspace_updated') { + this.emit({ type: 'workspaceChanged' }); + } else { + this.emit({ + type: method === 'dataview' ? 'viewer' : 'notification', + method, + params, + executionId, + }); + } } } diff --git a/src/interactive/backends/sessBridge.ts b/src/interactive/backends/sessBridge.ts index ff5ec0a12..4cc9edd2a 100644 --- a/src/interactive/backends/sessBridge.ts +++ b/src/interactive/backends/sessBridge.ts @@ -6,7 +6,11 @@ import { JsonLines } from '../framing'; import { object } from '../protocol'; import { BackendEvent, ClientReply } from '../backend'; -interface Pending { resolve(value: unknown): void; reject(error: Error): void; timer: NodeJS.Timeout } +interface Pending { + resolve(value: unknown): void; + reject(error: Error): void; + timer: NodeJS.Timeout; +} /** Pure R sess transport for inspection, editor requests, and adopted events. */ export class SessBridge { @@ -14,109 +18,209 @@ export class SessBridge { private sockets = new Set(); private servers: net.Server[] = []; private pending = new Map(); - private requests = new Map(); + private requests = new Map< + string, + { socket: net.Socket; id: unknown; timer: NodeJS.Timeout } + >(); private counter = 0; private closed = false; - constructor(private directory: string, private token: string, + constructor( + private directory: string, + private token: string, private emit: (event: BackendEvent) => void, private interactiveEvent: (message: Record) => void, - private notification: (method: string, params: Record) => void) { } + private notification: (method: string, params: Record) => void, + ) {} async start(): Promise { - await this.listen('sess.sock', socket => this.connectSess(socket)); + await this.listen('sess.sock', (socket) => this.connectSess(socket)); } private async listen(name: string, connect: (socket: net.Socket) => void): Promise { - if (this.closed) { throw new Error('Bridge disposed during startup'); } + if (this.closed) { + throw new Error('Bridge disposed during startup'); + } const endpoint = path.join(this.directory, name); - const server = net.createServer(socket => { + const server = net.createServer((socket) => { this.sockets.add(socket); - const timer = setTimeout(() => { if (socket !== this.sess) { socket.destroy(); } }, 5000); - socket.on('close', () => { this.sockets.delete(socket); clearTimeout(timer); }); + const timer = setTimeout(() => { + if (socket !== this.sess) { + socket.destroy(); + } + }, 5000); + socket.on('close', () => { + this.sockets.delete(socket); + clearTimeout(timer); + }); socket.on('error', () => socket.destroy()); connect(socket); }); this.servers.push(server); - await new Promise((resolve, reject) => { server.once('error', reject); server.listen(endpoint, resolve); }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(endpoint, resolve); + }); fs.chmodSync(endpoint, 0o600); } private send(socket: net.Socket, message: unknown): void { - if (!socket.destroyed) { socket.write(JSON.stringify(message) + '\n'); } + if (!socket.destroyed) { + socket.write(JSON.stringify(message) + '\n'); + } } private parse(socket: net.Socket, callback: (message: Record) => void): void { - const parser = new JsonLines(message => { if (!this.closed) { callback(message); } }); - socket.on('data', (chunk: Buffer) => { try { parser.push(chunk); } catch { socket.destroy(); } }); + const parser = new JsonLines((message) => { + if (!this.closed) { + callback(message); + } + }); + socket.on('data', (chunk: Buffer) => { + try { + parser.push(chunk); + } catch { + socket.destroy(); + } + }); } private connectSess(socket: net.Socket): void { let attached = false; - this.parse(socket, message => { + this.parse(socket, (message) => { if (message.method === 'attach') { const params = object(message.params); - if (params.protocol_version !== 2 || params.interactive_token !== this.token || - (this.sess && this.sess !== socket)) { socket.destroy(); return; } - this.sess = socket; attached = true; - this.emit({ type: 'metadata', metadata: { rPid: Number(params.pid), rVersion: String(params.version), - runtimeSessionId: String(params.session_id) } }); + if ( + params.protocol_version !== 2 || + params.interactive_token !== this.token || + (this.sess && this.sess !== socket) + ) { + socket.destroy(); + return; + } + this.sess = socket; + attached = true; + this.emit({ + type: 'metadata', + metadata: { + rPid: Number(params.pid), + rVersion: String(params.version), + runtimeSessionId: String(params.session_id), + }, + }); + return; + } + if (!attached || this.sess !== socket) { return; } - if (!attached || this.sess !== socket) { return; } if (typeof message.id === 'number' && !message.method) { const pending = this.pending.get(message.id); - if (!pending) { return; } - clearTimeout(pending.timer); this.pending.delete(message.id); - if (message.error) { pending.reject(new Error(JSON.stringify(message.error))); } - else { pending.resolve(message.result); } + if (!pending) { + return; + } + clearTimeout(pending.timer); + this.pending.delete(message.id); + if (message.error) { + pending.reject(new Error(JSON.stringify(message.error))); + } else { + pending.resolve(message.result); + } } else if (message.method === 'interactive_event' && message.id === undefined) { this.interactiveEvent(object(message.params)); - } else if (typeof message.method === 'string' && message.method && message.id !== undefined) { + } else if ( + typeof message.method === 'string' && + message.method && + message.id !== undefined + ) { const id = randomUUID(); const timer = setTimeout(() => { this.requests.delete(id); this.emit({ type: 'clientRequestExpired', id }); - this.send(socket, { jsonrpc: '2.0', id: message.id, - error: { code: -32000, message: 'No controlling editor replied within 30 seconds' } }); + this.send(socket, { + jsonrpc: '2.0', + id: message.id, + error: { + code: -32000, + message: 'No controlling editor replied within 30 seconds', + }, + }); }, 30000); this.requests.set(id, { socket, id: message.id, timer }); - this.emit({ type: 'clientRequest', id, method: message.method, params: message.params }); - } else if (typeof message.method === 'string' && message.method) { this.notification(message.method, object(message.params ?? {})); } + this.emit({ + type: 'clientRequest', + id, + method: message.method, + params: message.params, + }); + } else if (typeof message.method === 'string' && message.method) { + this.notification(message.method, object(message.params ?? {})); + } }); socket.on('close', () => { - if (this.sess !== socket) { return; } + if (this.sess !== socket) { + return; + } this.sess = undefined; this.rejectPending(); for (const [id, request] of this.requests) { - clearTimeout(request.timer); this.requests.delete(id); - if (!this.closed) { this.emit({ type: 'clientRequestExpired', id }); } + clearTimeout(request.timer); + this.requests.delete(id); + if (!this.closed) { + this.emit({ type: 'clientRequestExpired', id }); + } + } + if (!this.closed) { + this.emit({ type: 'unavailable', message: 'R inspection transport disconnected' }); } - if (!this.closed) { this.emit({ type: 'unavailable', message: 'R inspection transport disconnected' }); } }); } request(method: string, params: Record, timeout = 10000): Promise { const socket = this.sess; - if (this.closed || !socket || socket.destroyed) { return Promise.reject(new Error('R session is disconnected')); } + if (this.closed || !socket || socket.destroyed) { + return Promise.reject(new Error('R session is disconnected')); + } return new Promise((resolve, reject) => { const id = ++this.counter; - const timer = setTimeout(() => { this.pending.delete(id); reject(new Error('R request timed out')); }, timeout); + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error('R request timed out')); + }, timeout); this.pending.set(id, { resolve, reject, timer }); this.send(socket, { jsonrpc: '2.0', id, method, params }); }); } reply(id: string, reply: ClientReply): void { const request = this.requests.get(id); - if (!request) { return; } - clearTimeout(request.timer); this.requests.delete(id); - this.send(request.socket, { jsonrpc: '2.0', id: request.id, - ...(reply.error ? { error: { code: -32000, message: reply.error } } : { result: reply.result ?? null }) }); + if (!request) { + return; + } + clearTimeout(request.timer); + this.requests.delete(id); + this.send(request.socket, { + jsonrpc: '2.0', + id: request.id, + ...(reply.error + ? { error: { code: -32000, message: reply.error } } + : { result: reply.result ?? null }), + }); } private rejectPending(): void { - for (const pending of this.pending.values()) { clearTimeout(pending.timer); pending.reject(new Error('R disconnected')); } + for (const pending of this.pending.values()) { + clearTimeout(pending.timer); + pending.reject(new Error('R disconnected')); + } this.pending.clear(); } dispose(): void { - if (this.closed) { return; } - this.closed = true; this.rejectPending(); - for (const request of this.requests.values()) { clearTimeout(request.timer); } + if (this.closed) { + return; + } + this.closed = true; + this.rejectPending(); + for (const request of this.requests.values()) { + clearTimeout(request.timer); + } this.requests.clear(); - for (const socket of this.sockets) { socket.destroy(); } - for (const server of this.servers) { server.close(); } + for (const socket of this.sockets) { + socket.destroy(); + } + for (const server of this.servers) { + server.close(); + } } } diff --git a/src/interactive/backends/sessGraphics.ts b/src/interactive/backends/sessGraphics.ts index 5ab43e722..5cd6f5396 100644 --- a/src/interactive/backends/sessGraphics.ts +++ b/src/interactive/backends/sessGraphics.ts @@ -14,71 +14,153 @@ export class SessGraphics { private metrics?: ChildProcess; private jgd: JgdSocketServer; private history: PlotHistory; - private metricPending = new Map(); + private metricPending = new Map< + number, + { resolve(value: unknown): void; reject(error: Error): void; timer: NodeJS.Timeout } + >(); private metricCounter = 0; private stopped = false; private plotContexts = new Map(); - private pendingPlots = new Map(); + private pendingPlots = new Map< + string, + { frame: PlotFrame; count: number; device: string; plot: number; executionId?: string } + >(); private plotTimer?: NodeJS.Timeout; - constructor(private config: { rPath: string; resources: string; plotBackend: string; historyLimit: number }, - private emit: (event: BackendEvent) => void) { + constructor( + private config: { + rPath: string; + resources: string; + plotBackend: string; + historyLimit: number; + }, + private emit: (event: BackendEvent) => void, + ) { this.history = new PlotHistory(config.historyLimit); this.jgd = new JgdSocketServer(this.history); } - get endpoint(): string | undefined { return this.jgd.getSocketPath(); } + get endpoint(): string | undefined { + return this.jgd.getSocketPath(); + } resize(request: ResizePlotRequest): void { - if (!this.enabled) { throw new Error('Live plot resizing is unavailable'); } - this.jgd.sendToSession(request.device, { type: 'resize', plotIndex: request.plot, width: request.width, height: request.height }); + if (!this.enabled) { + throw new Error('Live plot resizing is unavailable'); + } + this.jgd.sendToSession(request.device, { + type: 'resize', + plotIndex: request.plot, + width: request.width, + height: request.height, + }); } dispose(): void { - this.stopped = true; this.enabled = false; - clearTimeout(this.plotTimer); this.pendingPlots.clear(); - this.metrics?.kill(); this.jgd.stop(); - for (const pending of this.metricPending.values()) { clearTimeout(pending.timer); pending.reject(new Error('Graphics stopped')); } + this.stopped = true; + this.enabled = false; + clearTimeout(this.plotTimer); + this.pendingPlots.clear(); + this.metrics?.kill(); + this.jgd.stop(); + for (const pending of this.metricPending.values()) { + clearTimeout(pending.timer); + pending.reject(new Error('Graphics stopped')); + } this.metricPending.clear(); } async start(): Promise { - if (this.config.plotBackend === 'standard') { return; } + if (this.config.plotBackend === 'standard') { + return; + } try { - const probe = await run(this.config.rPath, ['--vanilla', '--slave', '-e', - 'cat(requireNamespace("jgd",quietly=TRUE) && requireNamespace("systemfonts",quietly=TRUE))'], { timeout: 15000 }); + const probe = await run( + this.config.rPath, + [ + '--vanilla', + '--slave', + '-e', + 'cat(requireNamespace("jgd",quietly=TRUE) && requireNamespace("systemfonts",quietly=TRUE))', + ], + { timeout: 15000 }, + ); if (probe.stdout.trim() !== 'TRUE') { - if (this.config.plotBackend === 'jgd') { throw new Error('JGD requires the jgd and systemfonts R packages'); } + if (this.config.plotBackend === 'jgd') { + throw new Error('JGD requires the jgd and systemfonts R packages'); + } return; } - this.metrics = spawn(this.config.rPath, ['--vanilla', '--slave', '-f', path.join(this.config.resources, 'interactive-metrics.R')], { stdio: ['pipe', 'pipe', 'pipe'] }); - const parser = new JsonLines(message => { + this.metrics = spawn( + this.config.rPath, + [ + '--vanilla', + '--slave', + '-f', + path.join(this.config.resources, 'interactive-metrics.R'), + ], + { stdio: ['pipe', 'pipe', 'pipe'] }, + ); + const parser = new JsonLines((message) => { const pending = this.metricPending.get(Number(message.id)); - if (!pending) { return; } - clearTimeout(pending.timer); this.metricPending.delete(Number(message.id)); - if (message.type === 'metrics_error') { pending.reject(new Error(String(message.message))); } - else { pending.resolve(message); } + if (!pending) { + return; + } + clearTimeout(pending.timer); + this.metricPending.delete(Number(message.id)); + if (message.type === 'metrics_error') { + pending.reject(new Error(String(message.message))); + } else { + pending.resolve(message); + } + }); + this.metrics.stdout!.on('data', (chunk: Buffer) => { + try { + parser.push(chunk); + } catch { + this.metrics?.kill(); + } }); - this.metrics.stdout!.on('data', (chunk: Buffer) => { try { parser.push(chunk); } catch { this.metrics?.kill(); } }); this.metrics.stderr!.on('data', (chunk: Buffer) => process.stderr.write(chunk)); - this.metrics.on('error', error => process.stderr.write(error.message + '\n')); + this.metrics.on('error', (error) => process.stderr.write(error.message + '\n')); this.jgd.setGetDimensions(() => ({ width: 800, height: 600 })); this.jgd.setMeasureText((request, dpi) => this.measure(request, dpi)); this.jgd.setOnFrame((device, message) => { - try { this.plot(device, message); } - catch (error) { this.emit({ type: 'truncated', message: `Plot could not be retained: ${String(error)}` }); } + try { + this.plot(device, message); + } catch (error) { + this.emit({ + type: 'truncated', + message: `Plot could not be retained: ${String(error)}`, + }); + } + }); + await new Promise((resolve) => { + this.jgd.onReady(resolve); + this.jgd.start(); }); - await new Promise(resolve => { this.jgd.onReady(resolve); this.jgd.start(); }); this.enabled = true; } catch (error) { - if (this.config.plotBackend === 'jgd') { throw error; } + if (this.config.plotBackend === 'jgd') { + throw error; + } this.dispose(); - this.emit({ type: 'warning', message: `JGD unavailable: ${String(error)}. Using static graphics.` }); + this.emit({ + type: 'warning', + message: `JGD unavailable: ${String(error)}. Using static graphics.`, + }); } } private measure(request: JgdMessage, dpi: number): Promise { return new Promise((resolve, reject) => { const id = this.metricCounter++; - const timer = setTimeout(() => { this.metricPending.delete(id); reject(new Error('Font metrics timed out')); }, 2000); - this.metricPending.set(id, { resolve: value => { - resolve({ ...object(value), id: request.id }); - }, reject, timer }); + const timer = setTimeout(() => { + this.metricPending.delete(id); + reject(new Error('Font metrics timed out')); + }, 2000); + this.metricPending.set(id, { + resolve: (value) => { + resolve({ ...object(value), id: request.id }); + }, + reject, + timer, + }); this.metrics?.stdin?.write(JSON.stringify({ ...request, id, dpi }) + '\n'); }); } @@ -88,42 +170,90 @@ export class SessGraphics { // latest plot. Its context must not replace the live drawing context. const replay = message.resizeReplay === true; const frame = replay ? message.plot : this.history.latestPlot(device); - if (!frame) { return; } - const operations = (message.plot?.ops ?? []) as { op: string; ext?: { executionId?: string } }[]; - if (message.newPage && !replay) { this.plotContexts.delete(device); } - let context = replay ? frame.frameExt?.executionId : this.plotContexts.get(device) ?? frame.frameExt?.executionId; + if (!frame) { + return; + } + const operations = (message.plot?.ops ?? []) as { + op: string; + ext?: { executionId?: string }; + }[]; + if (message.newPage && !replay) { + this.plotContexts.delete(device); + } + let context = replay + ? frame.frameExt?.executionId + : (this.plotContexts.get(device) ?? frame.frameExt?.executionId); let drawing = false; let drawingContext = context; for (const operation of operations) { if (operation.op === 'beginGroup' && operation.ext?.executionId) { context = operation.ext.executionId; - if (!replay) { this.plotContexts.set(device, operation.ext.executionId); } - } else if (!['clip', 'beginGroup', 'endGroup'].includes(operation.op)) { drawing = true; drawingContext = context; } + if (!replay) { + this.plotContexts.set(device, operation.ext.executionId); + } + } else if (!['clip', 'beginGroup', 'endGroup'].includes(operation.op)) { + drawing = true; + drawingContext = context; + } + } + if (!drawing) { + return; } - if (!drawing) { return; } - const executionId = typeof drawingContext === 'string' && drawingContext ? drawingContext : undefined; + const executionId = + typeof drawingContext === 'string' && drawingContext ? drawingContext : undefined; const plot = frame.rIndex ?? message.plotNumber ?? 0; const displayId = `plot-${executionId ?? 'session'}-${device}-${plot}`; // JGD may emit hundreds of incremental frames for one ggplot. Keep only // the latest pending frame, preserving its operation boundary if another // cell later appends to the same device before this batch is flushed. - this.pendingPlots.set(displayId, { frame: { ...frame }, count: frame.ops.length, device, plot, executionId }); - if (!this.plotTimer) { this.plotTimer = setTimeout(() => this.flush(), 200); } + this.pendingPlots.set(displayId, { + frame: { ...frame }, + count: frame.ops.length, + device, + plot, + executionId, + }); + if (!this.plotTimer) { + this.plotTimer = setTimeout(() => this.flush(), 200); + } } flush(executionId?: string): void { - if (this.stopped) { return; } - if (!executionId) { clearTimeout(this.plotTimer); this.plotTimer = undefined; } + if (this.stopped) { + return; + } + if (!executionId) { + clearTimeout(this.plotTimer); + this.plotTimer = undefined; + } for (const [displayId, pending] of this.pendingPlots) { - if (executionId && pending.executionId !== executionId) { continue; } + if (executionId && pending.executionId !== executionId) { + continue; + } this.pendingPlots.delete(displayId); try { const { frame, count, device, plot } = pending; const svg = plotToSvg({ ...frame, ops: frame.ops.slice(0, count) }); - this.emit({ type: 'display', executionId: pending.executionId, data: { kind: 'plot', displayId, svg, device, plot, - width: frame.device.width, height: frame.device.height } }); - } catch (error) { this.emit({ type: 'truncated', executionId: pending.executionId, message: `Plot could not be retained: ${String(error)}` }); } + this.emit({ + type: 'display', + executionId: pending.executionId, + data: { + kind: 'plot', + displayId, + svg, + device, + plot, + width: frame.device.width, + height: frame.device.height, + }, + }); + } catch (error) { + this.emit({ + type: 'truncated', + executionId: pending.executionId, + message: `Plot could not be retained: ${String(error)}`, + }); + } } } - } diff --git a/src/interactive/backends/sessPreparation.ts b/src/interactive/backends/sessPreparation.ts index ed914cee1..fd8b70518 100644 --- a/src/interactive/backends/sessPreparation.ts +++ b/src/interactive/backends/sessPreparation.ts @@ -7,25 +7,47 @@ import { prepareStorage } from '../storage'; const run = promisify(execFile); -export async function installSessRuntime(extensionPath: string, root: string, rPath: string, - log: (text: string) => void): Promise<{ library: string; resources: string }> { +export async function installSessRuntime( + extensionPath: string, + root: string, + rPath: string, + log: (text: string) => void, +): Promise<{ library: string; resources: string }> { prepareStorage(root); const hash = createHash('sha256'); const bundledSess = 'dist/resources/sess'; - const sources = [`${bundledSess}/DESCRIPTION`, `${bundledSess}/NAMESPACE`, - 'R/interactive-worker.R', 'R/interactive-metrics.R', 'R/install_sess.R', 'R/sess-package-install.R', 'R/sess_source.R']; + const sources = [ + `${bundledSess}/DESCRIPTION`, + `${bundledSess}/NAMESPACE`, + 'R/interactive-worker.R', + 'R/interactive-metrics.R', + 'R/install_sess.R', + 'R/sess-package-install.R', + 'R/sess_source.R', + ]; for (const directory of [`${bundledSess}/R`]) { for (const name of fs.readdirSync(path.join(extensionPath, directory)).sort()) { - if (/\.(R|c|h)$/.test(name)) { sources.push(`${directory}/${name}`); } + if (/\.(R|c|h)$/.test(name)) { + sources.push(`${directory}/${name}`); + } } } - for (const file of sources) { hash.update(file).update(fs.readFileSync(path.join(extensionPath, file))); } - const version = await run(rPath, ['--vanilla', '--slave', '-e', 'cat(R.version$platform, R.version$major, R.version$minor)']); + for (const file of sources) { + hash.update(file).update(fs.readFileSync(path.join(extensionPath, file))); + } + const version = await run(rPath, [ + '--vanilla', + '--slave', + '-e', + 'cat(R.version$platform, R.version$major, R.version$minor)', + ]); hash.update(version.stdout); const runtime = path.join(root, 'runtimes', hash.digest('hex').slice(0, 20)); const library = path.join(runtime, 'library'); const resources = path.join(runtime, 'R'); - if (fs.existsSync(path.join(runtime, 'ready'))) { return { library, resources }; } + if (fs.existsSync(path.join(runtime, 'ready'))) { + return { library, resources }; + } const lock = `${runtime}.lock`; // Keep lock ownership rules in sync with R/attach_sess.R: mkdir claims atomically, // only the owner releases in finally, and timeout never clears a stale lock. @@ -34,34 +56,71 @@ export async function installSessRuntime(extensionPath: string, root: string, rP // source revision after namespace load. R followers do not prompt again after contention. const deadline = Date.now() + 120000; for (;;) { - try { fs.mkdirSync(lock, { mode: 0o700 }); break; } - catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'EEXIST') { throw error; } - if (fs.existsSync(path.join(runtime, 'ready'))) { return { library, resources }; } - if (Date.now() >= deadline) { throw new Error(`Runtime installation is locked. If its installer has exited, remove ${lock} and retry.`); } - await new Promise(resolve => setTimeout(resolve, 250)); + try { + fs.mkdirSync(lock, { mode: 0o700 }); + break; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') { + throw error; + } + if (fs.existsSync(path.join(runtime, 'ready'))) { + return { library, resources }; + } + if (Date.now() >= deadline) { + throw new Error( + `Runtime installation is locked. If its installer has exited, remove ${lock} and retry.`, + ); + } + await new Promise((resolve) => setTimeout(resolve, 250)); } } try { fs.mkdirSync(library, { recursive: true, mode: 0o700 }); fs.mkdirSync(resources, { recursive: true, mode: 0o700 }); const packagePath = path.join(runtime, 'sess'); - fs.cpSync(path.join(extensionPath, bundledSess), packagePath, { recursive: true, - filter: source => !/\.(o|so|dll)$/.test(source) }); - for (const file of ['interactive-worker.R', 'interactive-metrics.R', 'install_sess.R', 'sess-package-install.R', 'sess_source.R']) { + fs.cpSync(path.join(extensionPath, bundledSess), packagePath, { + recursive: true, + filter: (source) => !/\.(o|so|dll)$/.test(source), + }); + for (const file of [ + 'interactive-worker.R', + 'interactive-metrics.R', + 'install_sess.R', + 'sess-package-install.R', + 'sess_source.R', + ]) { fs.copyFileSync(path.join(extensionPath, 'R', file), path.join(resources, file)); } await new Promise((resolve, reject) => { - const child = spawn(rPath, ['--vanilla', '--slave', `--file=${path.join(resources, 'install_sess.R')}`], { - stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, - VSCODE_R_SESS_PKG_PATH: packagePath, VSCODE_R_SESS_LIBRARY: library, VSCODE_R_SESS_INTERACTIVE: '1' }, - }); + const child = spawn( + rPath, + ['--vanilla', '--slave', `--file=${path.join(resources, 'install_sess.R')}`], + { + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + VSCODE_R_SESS_PKG_PATH: packagePath, + VSCODE_R_SESS_LIBRARY: library, + VSCODE_R_SESS_INTERACTIVE: '1', + }, + }, + ); child.stdout.on('data', (data: Buffer) => log(data.toString())); child.stderr.on('data', (data: Buffer) => log(data.toString())); child.on('error', reject); - child.on('exit', code => code === 0 ? resolve() : reject(new Error('Could not install the private sess runtime from the bundled package. See R Interactive output for details.'))); + child.on('exit', (code) => + code === 0 + ? resolve() + : reject( + new Error( + 'Could not install the private sess runtime from the bundled package. See R Interactive output for details.', + ), + ), + ); }); fs.writeFileSync(path.join(runtime, 'ready'), version.stdout, { mode: 0o600 }); return { library, resources }; - } finally { fs.rmSync(lock, { recursive: true, force: true }); } + } finally { + fs.rmSync(lock, { recursive: true, force: true }); + } } diff --git a/src/interactive/client.ts b/src/interactive/client.ts index 4cab8a12b..43a78761f 100644 --- a/src/interactive/client.ts +++ b/src/interactive/client.ts @@ -7,66 +7,128 @@ import { AGENT_PROTOCOL, AgentSnapshot, SessionEvent, SessionManifest } from './ export class AgentClient extends EventEmitter { private socket?: net.Socket; private counter = 0; - private pending = new Map(); + private pending = new Map< + number, + { resolve(value: unknown): void; reject(error: Error): void; timer: NodeJS.Timeout } + >(); private heartbeat?: NodeJS.Timeout; private replaying?: SessionEvent[]; private closing = false; private ready = false; control = false; - get connected(): boolean { return this.ready && this.socket !== undefined && !this.socket.destroyed; } + get connected(): boolean { + return this.ready && this.socket !== undefined && !this.socket.destroyed; + } - constructor(public manifest: SessionManifest, readonly clientId: string = randomUUID()) { super(); } + constructor( + public manifest: SessionManifest, + readonly clientId: string = randomUUID(), + ) { + super(); + } async connect(options: { claim?: boolean; timeout?: number } = {}): Promise { this.close(); this.closing = false; const socket = net.createConnection(this.manifest.endpoint); this.socket = socket; - const parser = new JsonLines(message => { + const parser = new JsonLines((message) => { if (message.event) { - if (this.replaying) { this.replaying.push(message.event as SessionEvent); } - else { this.emitEvent(message.event as SessionEvent); } + if (this.replaying) { + this.replaying.push(message.event as SessionEvent); + } else { + this.emitEvent(message.event as SessionEvent); + } return; } const id = Number(message.id); const pending = this.pending.get(id); - if (!pending) { return; } - clearTimeout(pending.timer); this.pending.delete(id); - if (message.error) { pending.reject(new Error(typeof message.error === 'string' ? message.error : JSON.stringify(message.error))); } - else { pending.resolve(message.result); } + if (!pending) { + return; + } + clearTimeout(pending.timer); + this.pending.delete(id); + if (message.error) { + pending.reject( + new Error( + typeof message.error === 'string' + ? message.error + : JSON.stringify(message.error), + ), + ); + } else { + pending.resolve(message.result); + } }); socket.on('data', (chunk: Buffer) => { - try { parser.push(chunk); } catch (error) { socket.destroy(error instanceof Error ? error : undefined); } + try { + parser.push(chunk); + } catch (error) { + socket.destroy(error instanceof Error ? error : undefined); + } + }); + socket.on('error', () => { + /* close rejects every outstanding request. */ }); - socket.on('error', () => { /* close rejects every outstanding request. */ }); socket.on('close', () => { - if (this.socket !== socket) { return; } - this.ready = false; this.control = false; + if (this.socket !== socket) { + return; + } + this.ready = false; + this.control = false; clearInterval(this.heartbeat); for (const pending of this.pending.values()) { - clearTimeout(pending.timer); pending.reject(new Error('Session agent disconnected')); + clearTimeout(pending.timer); + pending.reject(new Error('Session agent disconnected')); } this.pending.clear(); - if (!this.closing) { this.emit('disconnect'); } + if (!this.closing) { + this.emit('disconnect'); + } }); await new Promise((resolve, reject) => { - const timer = setTimeout(() => { socket.destroy(); reject(new Error('Agent connection timed out')); }, options.timeout ?? 5000); - socket.once('connect', () => { clearTimeout(timer); resolve(); }); - socket.once('error', error => { clearTimeout(timer); reject(error); }); + const timer = setTimeout(() => { + socket.destroy(); + reject(new Error('Agent connection timed out')); + }, options.timeout ?? 5000); + socket.once('connect', () => { + clearTimeout(timer); + resolve(); + }); + socket.once('error', (error) => { + clearTimeout(timer); + reject(error); + }); }); - const manifest = await this.request('hello', { - protocol: AGENT_PROTOCOL, token: this.manifest.token, clientId: this.clientId, - }, options.timeout); + const manifest = await this.request( + 'hello', + { + protocol: AGENT_PROTOCOL, + token: this.manifest.token, + clientId: this.clientId, + }, + options.timeout, + ); if (manifest.id !== this.manifest.id || manifest.generation !== this.manifest.generation) { - throw new Error('R session identity changed; refresh the session list before connecting'); + throw new Error( + 'R session identity changed; refresh the session list before connecting', + ); } this.manifest = manifest; - this.control = options.claim === false ? false : await this.request('claim', {}, options.timeout); - this.ready = true; this.emit('activity'); + this.control = + options.claim === false + ? false + : await this.request('claim', {}, options.timeout); + this.ready = true; + this.emit('activity'); this.heartbeat = setInterval(() => { - void this.request<{ control: boolean; status: SessionManifest['status'] }>('heartbeat').then(value => { - this.control = value.control; this.manifest.status = value.status; this.emit('activity'); - }).catch(() => socket.destroy()); + void this.request<{ control: boolean; status: SessionManifest['status'] }>('heartbeat') + .then((value) => { + this.control = value.control; + this.manifest.status = value.status; + this.emit('activity'); + }) + .catch(() => socket.destroy()); }, 20000); this.heartbeat.unref(); } @@ -75,13 +137,27 @@ export class AgentClient extends EventEmitter { if (event.generation === this.manifest.generation && event.type === 'state') { const data = event.data; this.manifest.status = data.status as SessionManifest['status']; - if (typeof data.rPid === 'number') { this.manifest.rPid = data.rPid; } - if (typeof data.rVersion === 'string') { this.manifest.rVersion = data.rVersion; } - if (typeof data.rPath === 'string') { this.manifest.rPath = data.rPath; } - if (typeof data.runtimeSessionId === 'string') { this.manifest.runtimeSessionId = data.runtimeSessionId; } - if (Array.isArray(data.libraryPaths)) { this.manifest.libraryPaths = data.libraryPaths.map(String); } - if (data.capabilities && typeof data.capabilities === 'object') { Object.assign(this.manifest.capabilities, data.capabilities); } - if (typeof data.ended === 'number') { this.manifest.ended = data.ended; } + if (typeof data.rPid === 'number') { + this.manifest.rPid = data.rPid; + } + if (typeof data.rVersion === 'string') { + this.manifest.rVersion = data.rVersion; + } + if (typeof data.rPath === 'string') { + this.manifest.rPath = data.rPath; + } + if (typeof data.runtimeSessionId === 'string') { + this.manifest.runtimeSessionId = data.runtimeSessionId; + } + if (Array.isArray(data.libraryPaths)) { + this.manifest.libraryPaths = data.libraryPaths.map(String); + } + if (data.capabilities && typeof data.capabilities === 'object') { + Object.assign(this.manifest.capabilities, data.capabilities); + } + if (typeof data.ended === 'number') { + this.manifest.ended = data.ended; + } } this.emit('event', event); } @@ -92,54 +168,98 @@ export class AgentClient extends EventEmitter { this.replaying = live; try { for (;;) { - const replay = await this.request<{ events: SessionEvent[]; reset: boolean; seq: number }>('subscribe', { after: cursor }); - if (replay.reset) { return false; } - for (const event of replay.events) { this.emitEvent(event); cursor = event.seq; } + const replay = await this.request<{ + events: SessionEvent[]; + reset: boolean; + seq: number; + }>('subscribe', { after: cursor }); + if (replay.reset) { + return false; + } + for (const event of replay.events) { + this.emitEvent(event); + cursor = event.seq; + } if (cursor >= replay.seq) { // A socket read can contain both the response and newer events. // Deliver replay first, before the transcript advances its cursor. for (const event of live) { - if (event.seq > cursor) { this.emitEvent(event); cursor = event.seq; } + if (event.seq > cursor) { + this.emitEvent(event); + cursor = event.seq; + } } return true; } } - } finally { if (this.replaying === live) { this.replaying = undefined; } } + } finally { + if (this.replaying === live) { + this.replaying = undefined; + } + } } - snapshot(limit = 50): Promise { return this.request('snapshot', { limit }); } + snapshot(limit = 50): Promise { + return this.request('snapshot', { limit }); + } - request(method: string, params: Record = {}, timeout = 15000): Promise { + request( + method: string, + params: Record = {}, + timeout = 15000, + ): Promise { const socket = this.socket; - if (!socket || socket.destroyed) { return Promise.reject(new Error('Session agent is disconnected')); } + if (!socket || socket.destroyed) { + return Promise.reject(new Error('Session agent is disconnected')); + } return new Promise((resolve, reject) => { const id = ++this.counter; const timer = setTimeout(() => { this.pending.delete(id); - reject(new Error(`Session request '${method}' timed out; submitted code may still be running`)); + reject( + new Error( + `Session request '${method}' timed out; submitted code may still be running`, + ), + ); }, timeout); - this.pending.set(id, { resolve: value => resolve(value as T), reject, timer }); - socket.write(JSON.stringify({ id, method, params: { generation: this.manifest.generation, ...params } }) + '\n'); + this.pending.set(id, { resolve: (value) => resolve(value as T), reject, timer }); + socket.write( + JSON.stringify({ + id, + method, + params: { generation: this.manifest.generation, ...params }, + }) + '\n', + ); }); } close(): void { this.closing = true; this.replaying = undefined; - this.ready = false; this.control = false; + this.ready = false; + this.control = false; clearInterval(this.heartbeat); - for (const pending of this.pending.values()) { clearTimeout(pending.timer); pending.reject(new Error('Session agent disconnected')); } + for (const pending of this.pending.values()) { + clearTimeout(pending.timer); + pending.reject(new Error('Session agent disconnected')); + } this.pending.clear(); this.socket?.destroy(); } } /** Read live status without claiming control or subscribing to output. */ -export async function probeSession(manifest: SessionManifest, timeout = 1000): Promise { +export async function probeSession( + manifest: SessionManifest, + timeout = 1000, +): Promise { const client = new AgentClient(manifest); try { await client.connect({ claim: false, timeout }); return client.manifest; - } catch { return undefined; } - finally { client.close(); } + } catch { + return undefined; + } finally { + client.close(); + } } diff --git a/src/interactive/executable.ts b/src/interactive/executable.ts index 7b91d15a7..0da7eac63 100644 --- a/src/interactive/executable.ts +++ b/src/interactive/executable.ts @@ -4,25 +4,43 @@ import * as path from 'path'; import { findExecutableOnPath } from '../rPathResolver'; /** Resolve an executable without a shell, using the prospective process's cwd. */ -export function resolveExecutable(command: string, directory: string, pathValue = process.env.PATH): string | undefined { +export function resolveExecutable( + command: string, + directory: string, + pathValue = process.env.PATH, +): string | undefined { command = command.trim(); - if (!command) { return; } - if ((command.startsWith('"') && command.endsWith('"')) || (command.startsWith('\'') && command.endsWith('\''))) { + if (!command) { + return; + } + if ( + (command.startsWith('"') && command.endsWith('"')) || + (command.startsWith("'") && command.endsWith("'")) + ) { command = command.slice(1, -1); } - if (command.startsWith('~/')) { command = path.join(os.homedir(), command.slice(2)); } + if (command.startsWith('~/')) { + command = path.join(os.homedir(), command.slice(2)); + } const executable = (file: string): boolean => { try { - if (!fs.statSync(file).isFile()) { return false; } + if (!fs.statSync(file).isFile()) { + return false; + } fs.accessSync(file, fs.constants.X_OK); return true; - } catch { return false; } + } catch { + return false; + } }; if (command.includes('/') || command.includes('\\')) { const file = path.resolve(directory, command); return executable(file) ? file : undefined; } // Resolve relative PATH entries against the session's cwd, as spawn does. - const searchPath = pathValue?.split(path.delimiter).map(entry => path.resolve(directory, entry)).join(path.delimiter); + const searchPath = pathValue + ?.split(path.delimiter) + .map((entry) => path.resolve(directory, entry)) + .join(path.delimiter); return findExecutableOnPath(command, process.platform, searchPath, executable); } diff --git a/src/interactive/executionTarget.ts b/src/interactive/executionTarget.ts index 2629771a3..4b73c78e9 100644 --- a/src/interactive/executionTarget.ts +++ b/src/interactive/executionTarget.ts @@ -2,10 +2,24 @@ import type { Uri } from 'vscode'; import type { SourceLocation } from './protocol'; export type ExecutionResult = 'executed' | 'cancelled' | 'createTerminal' | false; -let executor: ((code: string, resource?: Uri, source?: SourceLocation, offerTarget?: boolean) => Promise) | undefined; +let executor: + | (( + code: string, + resource?: Uri, + source?: SourceLocation, + offerTarget?: boolean, + ) => Promise) + | undefined; -export function setInteractiveExecutor(value: typeof executor): void { executor = value; } +export function setInteractiveExecutor(value: typeof executor): void { + executor = value; +} -export async function tryInteractiveExecution(code: string, resource?: Uri, source?: SourceLocation, offerTarget = false): Promise { +export async function tryInteractiveExecution( + code: string, + resource?: Uri, + source?: SourceLocation, + offerTarget = false, +): Promise { return executor ? executor(code, resource, source, offerTarget) : false; } diff --git a/src/interactive/framing.ts b/src/interactive/framing.ts index d2a495396..1c6fc4f6d 100644 --- a/src/interactive/framing.ts +++ b/src/interactive/framing.ts @@ -6,8 +6,10 @@ export class JsonLines { private decoder = new StringDecoder('utf8'); private buffer = ''; - constructor(private readonly receive: (message: Record) => void, - private readonly limit = MAX_MESSAGE_BYTES) {} + constructor( + private readonly receive: (message: Record) => void, + private readonly limit = MAX_MESSAGE_BYTES, + ) {} push(chunk: Buffer): void { this.buffer += this.decoder.write(chunk); diff --git a/src/interactive/history.ts b/src/interactive/history.ts index 4c9eea4ef..b82105775 100644 --- a/src/interactive/history.ts +++ b/src/interactive/history.ts @@ -1,18 +1,32 @@ import { ExecutionRecord } from './protocol'; -export interface HistoryPage { executions: ExecutionRecord[]; more: boolean } +export interface HistoryPage { + executions: ExecutionRecord[]; + more: boolean; +} /** Search admitted code without loading output journals or assets. Bound every response. */ -export function searchHistory(records: Iterable, query = '', before = Number.MAX_SAFE_INTEGER, limit = 100): HistoryPage { +export function searchHistory( + records: Iterable, + query = '', + before = Number.MAX_SAFE_INTEGER, + limit = 100, +): HistoryPage { const needle = query.toLocaleLowerCase(); - const matches = [...records].filter(record => record.order < before && record.code.toLocaleLowerCase().includes(needle)) + const matches = [...records] + .filter( + (record) => record.order < before && record.code.toLocaleLowerCase().includes(needle), + ) .sort((a, b) => b.order - a.order); const executions: ExecutionRecord[] = []; let bytes = 0; for (const record of matches) { const size = Buffer.byteLength(JSON.stringify(record)); - if (executions.length >= limit || bytes + size > 2 * 1024 * 1024) { break; } - executions.push(record); bytes += size; + if (executions.length >= limit || bytes + size > 2 * 1024 * 1024) { + break; + } + executions.push(record); + bytes += size; } return { executions, more: matches.length > executions.length }; } diff --git a/src/interactive/inlineTable.ts b/src/interactive/inlineTable.ts index 79ee01298..af5b83522 100644 --- a/src/interactive/inlineTable.ts +++ b/src/interactive/inlineTable.ts @@ -1,12 +1,25 @@ /// import { TABLE_PAGE_SIZE, tablePage } from './tablePaging'; import { tableColumnAlignment, tableDisplayValue, tableSnapshotSummary } from './tableFormatting'; -import { columnOperators, filterNeedsValue, filterOperators, TableColumn, TableFilters, tableSchema, TableSort } from './tableQuery'; +import { + columnOperators, + filterNeedsValue, + filterOperators, + TableColumn, + TableFilters, + tableSchema, + TableSort, +} from './tableQuery'; import { toolbarButton as button } from './rendererToolbar'; export interface InlineTableState { - data: Record; start: number; size: number; live: boolean; - sort: TableSort[]; filters: TableFilters; order: string[]; + data: Record; + start: number; + size: number; + live: boolean; + sort: TableSort[]; + filters: TableFilters; + order: string[]; filterOpen?: boolean; } export interface InlineTable { @@ -16,221 +29,521 @@ export interface InlineTable { } /** A single requested page, never an accumulating copy of the R dataset. */ -export function createInlineTable(parent: HTMLElement, toolbar: HTMLElement, status: HTMLElement, - original: Record, cached: InlineTableState | undefined, - send: (query: Record) => number, remember: (state: InlineTableState) => void, - connectionHint: string): InlineTable { - const initial = (): InlineTableState => ({ data: original, start: 0, size: TABLE_PAGE_SIZE, live: false, sort: [], filters: {}, order: [] }); +export function createInlineTable( + parent: HTMLElement, + toolbar: HTMLElement, + status: HTMLElement, + original: Record, + cached: InlineTableState | undefined, + send: (query: Record) => number, + remember: (state: InlineTableState) => void, + connectionHint: string, +): InlineTable { + const initial = (): InlineTableState => ({ + data: original, + start: 0, + size: TABLE_PAGE_SIZE, + live: false, + sort: [], + filters: {}, + order: [], + }); let state = cached ?? initial(); - let pending: { id: number; start: number; size: number; live: boolean; sort: TableSort[]; filters: TableFilters } | undefined; + let pending: + | { + id: number; + start: number; + size: number; + live: boolean; + sort: TableSort[]; + filters: TableFilters; + } + | undefined; let active = true; let textMode = false; let dragged: string | undefined; let error = ''; - const available = original.connected !== false && original.archived !== true && original.running !== false; + const available = + original.connected !== false && original.archived !== true && original.running !== false; const hasFull = !!original.fullViewId; const columns = (): TableColumn[] => (state.data.columns ?? []) as TableColumn[]; - const count = (): number => !state.live && hasFull && !state.sort.length && !Object.keys(state.filters).length - ? Number(original.sourceRows ?? original.totalRows) : Number(state.data.totalRows ?? 0); + const count = (): number => + !state.live && hasFull && !state.sort.length && !Object.keys(state.filters).length + ? Number(original.sourceRows ?? original.totalRows) + : Number(state.data.totalRows ?? 0); const ordered = (): TableColumn[] => { const all = columns(); - return [...all.filter(column => column.field === '0'), - ...state.order.map(field => all.find(column => column.field === field && field !== '0')).filter((column): column is TableColumn => !!column), - ...all.filter(column => column.field !== '0' && !state.order.includes(column.field))]; + return [ + ...all.filter((column) => column.field === '0'), + ...state.order + .map((field) => all.find((column) => column.field === field && field !== '0')) + .filter((column): column is TableColumn => !!column), + ...all.filter((column) => column.field !== '0' && !state.order.includes(column.field)), + ]; }; - const tableHost = document.createElement('div'); tableHost.dataset.table = ''; parent.append(tableHost); - const tools = document.createElement('div'); tools.className = 'r-inline-tools'; parent.append(tools); - const filtersPanel = document.createElement('form'); filtersPanel.className = 'r-inline-panel'; filtersPanel.setAttribute('aria-label', 'Table filters'); + const tableHost = document.createElement('div'); + tableHost.dataset.table = ''; + parent.append(tableHost); + const tools = document.createElement('div'); + tools.className = 'r-inline-tools'; + parent.append(tools); + const filtersPanel = document.createElement('form'); + filtersPanel.className = 'r-inline-panel'; + filtersPanel.setAttribute('aria-label', 'Table filters'); tools.append(filtersPanel); - const pager = document.createElement('div'); pager.className = 'r-inline-pager'; - const number = document.createElement('input'); number.type = 'number'; number.min = '1'; number.step = '1'; number.setAttribute('aria-label', 'Page number'); + const pager = document.createElement('div'); + pager.className = 'r-inline-pager'; + const number = document.createElement('input'); + number.type = 'number'; + number.min = '1'; + number.step = '1'; + number.setAttribute('aria-label', 'Page number'); const pageCount = document.createElement('span'); - const size = document.createElement('select'); size.setAttribute('aria-label', 'Rows per page'); - for (const value of [20, 50, 100]) { size.add(new Option(`${value} rows`, String(value))); } + const size = document.createElement('select'); + size.setAttribute('aria-label', 'Rows per page'); + for (const value of [20, 50, 100]) { + size.add(new Option(`${value} rows`, String(value))); + } - const request = (start: number, sort = state.sort, filters = state.filters, pageSize = state.size): void => { - if (!active || !available || pending) { return; } + const request = ( + start: number, + sort = state.sort, + filters = state.filters, + pageSize = state.size, + ): void => { + if (!active || !available || pending) { + return; + } const changedQuery = sort !== state.sort || filters !== state.filters; - const live = hasFull && (state.live || start + pageSize > Number(original.totalRows) || sort.length > 0 || Object.keys(filters).length > 0); + const live = + hasFull && + (state.live || + start + pageSize > Number(original.totalRows) || + sort.length > 0 || + Object.keys(filters).length > 0); const offset = changedQuery ? 0 : tablePage(count(), start, pageSize).start; error = ''; - const id = send({ start: offset, size: pageSize, live, refresh: live && (!state.live || changedQuery), + const id = send({ + start: offset, + size: pageSize, + live, + refresh: live && (!state.live || changedQuery), schema: tableSchema(columns()), - columns: columns(), sortModel: sort, filterModel: filters }); + columns: columns(), + sortModel: sort, + filterModel: filters, + }); pending = { id, start: offset, size: pageSize, live, sort, filters }; update(); }; const first = button('First page', 'first', () => request(0), ''); - const previous = button('Previous page', 'previous', () => request(state.start - state.size), ''); + const previous = button( + 'Previous page', + 'previous', + () => request(state.start - state.size), + '', + ); const next = button('Next page', 'next', () => request(state.start + state.size), ''); const last = button('Last page', 'last', () => request(Math.max(0, count() - 1)), ''); const jump = (): void => { const page = Number(number.value); - if (!Number.isSafeInteger(page) || page < 1 || page > Math.max(1, Math.ceil(count() / state.size))) { - error = `Enter a page from 1 to ${Math.max(1, Math.ceil(count() / state.size)).toLocaleString('en-US')}.`; update(); return; + if ( + !Number.isSafeInteger(page) || + page < 1 || + page > Math.max(1, Math.ceil(count() / state.size)) + ) { + error = `Enter a page from 1 to ${Math.max(1, Math.ceil(count() / state.size)).toLocaleString('en-US')}.`; + update(); + return; } request((page - 1) * state.size); }; - number.onkeydown = event => { if (event.key === 'Enter') { event.preventDefault(); jump(); } }; - const go = document.createElement('button'); go.type = 'button'; go.className = 'r-interactive-button'; - go.textContent = 'Go'; go.setAttribute('aria-label', 'Go to page'); go.title = 'Go to page'; go.onclick = jump; + number.onkeydown = (event) => { + if (event.key === 'Enter') { + event.preventDefault(); + jump(); + } + }; + const go = document.createElement('button'); + go.type = 'button'; + go.className = 'r-interactive-button'; + go.textContent = 'Go'; + go.setAttribute('aria-label', 'Go to page'); + go.title = 'Go to page'; + go.onclick = jump; size.onchange = () => request(state.start, state.sort, state.filters, Number(size.value)); - pager.append(first, previous, document.createTextNode('Page '), number, pageCount, go, next, last, size); - const filtersButton = button('Filter columns', 'filter', () => { state.filterOpen = !state.filterOpen; remember(state); update(); }, 'Filters'); - const reset = button('Reset table view', 'reset', () => { - if (!active) { return; } - // Restoring saved output is local, and invalidates even an in-flight reply. - pending = undefined; state = initial(); error = ''; remember(state); - draw(); buildFilters(true); update(); tableHost.scrollLeft = 0; tableHost.scrollTop = 0; - }, 'Reset'); - reset.title = 'Restore the saved preview, first page, original column order, and clear sorting and filters'; + pager.append( + first, + previous, + document.createTextNode('Page '), + number, + pageCount, + go, + next, + last, + size, + ); + const filtersButton = button( + 'Filter columns', + 'filter', + () => { + state.filterOpen = !state.filterOpen; + remember(state); + update(); + }, + 'Filters', + ); + const reset = button( + 'Reset table view', + 'reset', + () => { + if (!active) { + return; + } + // Restoring saved output is local, and invalidates even an in-flight reply. + pending = undefined; + state = initial(); + error = ''; + remember(state); + draw(); + buildFilters(true); + update(); + tableHost.scrollLeft = 0; + tableHost.scrollTop = 0; + }, + 'Reset', + ); + reset.title = + 'Restore the saved preview, first page, original column order, and clear sorting and filters'; toolbar.append(filtersButton, reset, pager); const move = (field: string, target: string): void => { - if (!active || field === '0' || target === '0' || field === target) { return; } - const fields = ordered().filter(column => column.field !== '0').map(column => column.field); - const from = fields.indexOf(field); const to = fields.indexOf(target); - if (from < 0 || to < 0) { return; } - fields.splice(from, 1); fields.splice(to, 0, field); state.order = fields; - remember(state); draw(); + if (!active || field === '0' || target === '0' || field === target) { + return; + } + const fields = ordered() + .filter((column) => column.field !== '0') + .map((column) => column.field); + const from = fields.indexOf(field); + const to = fields.indexOf(target); + if (from < 0 || to < 0) { + return; + } + fields.splice(from, 1); + fields.splice(to, 0, field); + state.order = fields; + remember(state); + draw(); }; function draw(): void { const scroll = tableHost.scrollLeft; - const focused = tableHost.contains(document.activeElement) ? document.activeElement?.getAttribute('aria-label') : undefined; - tableHost.replaceChildren(); tableHost.style.cssText = 'overflow:auto;max-height:460px'; - const table = document.createElement('table'); table.className = 'r-inline-table'; + const focused = tableHost.contains(document.activeElement) + ? document.activeElement?.getAttribute('aria-label') + : undefined; + tableHost.replaceChildren(); + tableHost.style.cssText = 'overflow:auto;max-height:460px'; + const table = document.createElement('table'); + table.className = 'r-inline-table'; const header = document.createElement('tr'); const visible = ordered(); for (const column of visible) { const th = document.createElement('th'); th.style.textAlign = tableColumnAlignment(state.data, column); - if (column.headerTooltip) { th.title = column.headerTooltip; } - const sort = state.sort.find(item => item.colId === column.field)?.sort; - th.setAttribute('aria-sort', sort === 'asc' ? 'ascending' : sort === 'desc' ? 'descending' : 'none'); + if (column.headerTooltip) { + th.title = column.headerTooltip; + } + const sort = state.sort.find((item) => item.colId === column.field)?.sort; + th.setAttribute( + 'aria-sort', + sort === 'asc' ? 'ascending' : sort === 'desc' ? 'descending' : 'none', + ); if (column.field !== '0' && column.sortable !== false) { - const heading = document.createElement('button'); heading.type = 'button'; heading.className = 'r-inline-heading'; + const heading = document.createElement('button'); + heading.type = 'button'; + heading.className = 'r-inline-heading'; heading.textContent = `${column.headerName}${sort === 'asc' ? ' ↑' : sort === 'desc' ? ' ↓' : ''}`; heading.setAttribute('aria-label', `Sort by ${column.headerName}`); heading.title = `Sort ${column.headerName}: ${sort === 'asc' ? 'descending' : sort === 'desc' ? 'original order' : 'ascending'}`; - heading.dataset.sort = ''; heading.disabled = !available || !!pending; - heading.onclick = () => request(0, sort === 'desc' ? [] : [{ colId: column.field, sort: sort === 'asc' ? 'desc' : 'asc' }]); + heading.dataset.sort = ''; + heading.disabled = !available || !!pending; + heading.onclick = () => + request( + 0, + sort === 'desc' + ? [] + : [{ colId: column.field, sort: sort === 'asc' ? 'desc' : 'asc' }], + ); th.append(heading); - } else { th.textContent = column.headerName; } + } else { + th.textContent = column.headerName; + } th.draggable = column.field !== '0'; - th.ondragstart = event => { dragged = column.field; event.dataTransfer?.setData('text/plain', column.field); }; - th.ondragover = event => { if (dragged && column.field !== '0') { event.preventDefault(); } }; - th.ondrop = event => { event.preventDefault(); if (dragged) { move(dragged, column.field); } dragged = undefined; }; - th.ondragend = () => { dragged = undefined; }; + th.ondragstart = (event) => { + dragged = column.field; + event.dataTransfer?.setData('text/plain', column.field); + }; + th.ondragover = (event) => { + if (dragged && column.field !== '0') { + event.preventDefault(); + } + }; + th.ondrop = (event) => { + event.preventDefault(); + if (dragged) { + move(dragged, column.field); + } + dragged = undefined; + }; + th.ondragend = () => { + dragged = undefined; + }; header.append(th); } table.append(header); - for (const [rowIndex, row] of ((state.data.rows ?? []) as Record[]).entries()) { + for (const [rowIndex, row] of ( + (state.data.rows ?? []) as Record[] + ).entries()) { const tr = document.createElement('tr'); for (const column of visible) { - const td = document.createElement('td'); const value = row[column.field]; + const td = document.createElement('td'); + const value = row[column.field]; td.textContent = tableDisplayValue(state.data, row, column.field, rowIndex); td.style.textAlign = tableColumnAlignment(state.data, column); - if (typeof value === 'number' && td.textContent !== String(value)) { td.title = String(value); } + if (typeof value === 'number' && td.textContent !== String(value)) { + td.title = String(value); + } if (value === null && ['NA', 'NaN'].includes(td.textContent)) { - td.style.fontStyle = 'italic'; td.style.color = 'var(--vscode-descriptionForeground)'; - td.title = td.textContent === 'NaN' ? 'Not a number (NaN)' : 'Missing value (NA)'; td.setAttribute('aria-label', td.title); + td.style.fontStyle = 'italic'; + td.style.color = 'var(--vscode-descriptionForeground)'; + td.title = + td.textContent === 'NaN' ? 'Not a number (NaN)' : 'Missing value (NA)'; + td.setAttribute('aria-label', td.title); } tr.append(td); } table.append(tr); } - tableHost.append(table); tableHost.scrollLeft = scroll; - if (focused) { Array.from(tableHost.querySelectorAll('[data-sort]')).find(control => control.getAttribute('aria-label') === focused)?.focus({ preventScroll: true }); } + tableHost.append(table); + tableHost.scrollLeft = scroll; + if (focused) { + Array.from(tableHost.querySelectorAll('[data-sort]')) + .find((control) => control.getAttribute('aria-label') === focused) + ?.focus({ preventScroll: true }); + } } - const filterColumn = document.createElement('select'); filterColumn.setAttribute('aria-label', 'Filter column'); - const operator = document.createElement('select'); operator.setAttribute('aria-label', 'Filter operator'); - const value = document.createElement('input'); value.type = 'text'; value.maxLength = 1000; value.setAttribute('aria-label', 'Filter value'); - const apply = document.createElement('button'); apply.type = 'submit'; apply.textContent = 'Apply'; apply.className = 'r-interactive-button'; - const chips = document.createElement('div'); chips.className = 'r-inline-chips'; - const hint = document.createElement('span'); hint.className = 'r-inline-hint'; - hint.textContent = hasFull ? 'Applies to the full data. Large sorts and filters may take time.' : 'Filters are combined with AND.'; + const filterColumn = document.createElement('select'); + filterColumn.setAttribute('aria-label', 'Filter column'); + const operator = document.createElement('select'); + operator.setAttribute('aria-label', 'Filter operator'); + const value = document.createElement('input'); + value.type = 'text'; + value.maxLength = 1000; + value.setAttribute('aria-label', 'Filter value'); + const apply = document.createElement('button'); + apply.type = 'submit'; + apply.textContent = 'Apply'; + apply.className = 'r-interactive-button'; + const chips = document.createElement('div'); + chips.className = 'r-inline-chips'; + const hint = document.createElement('span'); + hint.className = 'r-inline-hint'; + hint.textContent = hasFull + ? 'Applies to the full data. Large sorts and filters may take time.' + : 'Filters are combined with AND.'; filtersPanel.append(filterColumn, operator, value, apply, chips, hint); - const updateValue = (): void => { value.disabled = !available || !!pending || !filterNeedsValue(operator.value); }; + const updateValue = (): void => { + value.disabled = !available || !!pending || !filterNeedsValue(operator.value); + }; const updateOperators = (preserveDraft = false): void => { const draft = { operator: operator.value, value: value.value }; - const column = columns().find(column => column.field === filterColumn.value); + const column = columns().find((column) => column.field === filterColumn.value); operator.replaceChildren(); if (column) { - for (const op of columnOperators(column)) { operator.add(new Option(filterOperators[op as keyof typeof filterOperators], op)); } + for (const op of columnOperators(column)) { + operator.add(new Option(filterOperators[op as keyof typeof filterOperators], op)); + } } const filter = state.filters[filterColumn.value]; value.value = ''; - if (filter) { operator.value = filter.type; value.value = filter.filter ?? ''; } - if (preserveDraft && Array.from(operator.options).some(option => option.value === draft.operator)) { - operator.value = draft.operator; value.value = draft.value; + if (filter) { + operator.value = filter.type; + value.value = filter.filter ?? ''; } - value.placeholder = column?.type === 'dateColumn' ? 'YYYY-MM-DD' : column?.type === 'datetimeColumn' ? 'YYYY-MM-DD HH:MM:SS' : 'Value'; + if ( + preserveDraft && + Array.from(operator.options).some((option) => option.value === draft.operator) + ) { + operator.value = draft.operator; + value.value = draft.value; + } + value.placeholder = + column?.type === 'dateColumn' + ? 'YYYY-MM-DD' + : column?.type === 'datetimeColumn' + ? 'YYYY-MM-DD HH:MM:SS' + : 'Value'; updateValue(); }; - filterColumn.onchange = () => updateOperators(); operator.onchange = updateValue; - filtersPanel.onsubmit = event => { + filterColumn.onchange = () => updateOperators(); + operator.onchange = updateValue; + filtersPanel.onsubmit = (event) => { event.preventDefault(); - if (!filterColumn.value) { return; } - const column = columns().find(column => column.field === filterColumn.value); - if (filterNeedsValue(operator.value) && (!value.value.trim() || (column?.type === 'numericColumn' && !Number.isFinite(Number(value.value))) || - (column?.type === 'bigintColumn' && !/^[+-]?\d+$/.test(value.value)))) { - error = 'Enter a valid filter value.'; update(); return; + if (!filterColumn.value) { + return; + } + const column = columns().find((column) => column.field === filterColumn.value); + if ( + filterNeedsValue(operator.value) && + (!value.value.trim() || + (column?.type === 'numericColumn' && !Number.isFinite(Number(value.value))) || + (column?.type === 'bigintColumn' && !/^[+-]?\d+$/.test(value.value))) + ) { + error = 'Enter a valid filter value.'; + update(); + return; } - request(0, state.sort, { ...state.filters, [filterColumn.value]: { type: operator.value, ...(filterNeedsValue(operator.value) ? { filter: value.value } : {}) } }); + request(0, state.sort, { + ...state.filters, + [filterColumn.value]: { + type: operator.value, + ...(filterNeedsValue(operator.value) ? { filter: value.value } : {}), + }, + }); }; function buildFilters(resetDraft = false): void { - const selected = filterColumn.value; filterColumn.replaceChildren(); - for (const column of columns().filter(column => column.field !== '0' && column.filter !== false)) { filterColumn.add(new Option(column.headerName, column.field)); } - if (!resetDraft && Array.from(filterColumn.options).some(option => option.value === selected)) { filterColumn.value = selected; } - updateOperators(!resetDraft && filterColumn.value === selected); chips.replaceChildren(); + const selected = filterColumn.value; + filterColumn.replaceChildren(); + for (const column of columns().filter( + (column) => column.field !== '0' && column.filter !== false, + )) { + filterColumn.add(new Option(column.headerName, column.field)); + } + if ( + !resetDraft && + Array.from(filterColumn.options).some((option) => option.value === selected) + ) { + filterColumn.value = selected; + } + updateOperators(!resetDraft && filterColumn.value === selected); + chips.replaceChildren(); for (const [field, filter] of Object.entries(state.filters)) { - const name = columns().find(column => column.field === field)?.headerName ?? field; - const remove = document.createElement('button'); remove.type = 'button'; remove.className = 'r-interactive-button'; + const name = columns().find((column) => column.field === field)?.headerName ?? field; + const remove = document.createElement('button'); + remove.type = 'button'; + remove.className = 'r-interactive-button'; remove.textContent = `${name} ${filterOperators[filter.type as keyof typeof filterOperators]} ${filter.filter ?? ''} ×`; remove.setAttribute('aria-label', `Remove filter for ${name}`); - remove.onclick = () => { const filters = { ...state.filters }; delete filters[field]; request(0, state.sort, filters); }; + remove.onclick = () => { + const filters = { ...state.filters }; + delete filters[field]; + request(0, state.sort, filters); + }; chips.append(remove); } } function update(): void { - tableHost.hidden = textMode; tools.hidden = textMode; pager.hidden = textMode; + tableHost.hidden = textMode; + tools.hidden = textMode; + pager.hidden = textMode; filtersButton.hidden = reset.hidden = textMode; filtersButton.setAttribute('aria-expanded', String(!!state.filterOpen)); filtersPanel.hidden = !state.filterOpen; const disabled = !available || !!pending; first.disabled = previous.disabled = disabled || state.start === 0; - last.disabled = next.disabled = disabled || state.start + state.size >= count() || count() === 0; + last.disabled = next.disabled = + disabled || state.start + state.size >= count() || count() === 0; go.disabled = number.disabled = size.disabled = disabled || count() === 0; - number.value = String(Math.floor(state.start / state.size) + 1); number.max = String(Math.max(1, Math.ceil(count() / state.size))); - pageCount.textContent = ` of ${Number(number.max).toLocaleString('en-US')}`; size.value = String(state.size); - for (const control of Array.from(filtersPanel.querySelectorAll('input,select,button'))) { control.disabled = disabled; } - updateValue(); apply.disabled ||= !filterColumn.options.length; - for (const control of Array.from(tableHost.querySelectorAll('[data-sort]'))) { control.disabled = disabled; } + number.value = String(Math.floor(state.start / state.size) + 1); + number.max = String(Math.max(1, Math.ceil(count() / state.size))); + pageCount.textContent = ` of ${Number(number.max).toLocaleString('en-US')}`; + size.value = String(state.size); + for (const control of Array.from( + filtersPanel.querySelectorAll( + 'input,select,button', + ), + )) { + control.disabled = disabled; + } + updateValue(); + apply.disabled ||= !filterColumn.options.length; + for (const control of Array.from( + tableHost.querySelectorAll('[data-sort]'), + )) { + control.disabled = disabled; + } const shown = Array.isArray(state.data.rows) ? state.data.rows.length : 0; - let label = textMode ? 'Printed R output' : pending ? 'Loading rows…' : shown - ? `Rows ${state.start + 1}–${state.start + shown} of ${count().toLocaleString('en-US')}` : 'No matching rows'; - if (textMode) { label = [label, tableSnapshotSummary(original)].filter(Boolean).join(' · '); } - else { label = [label, state.live ? 'Live data' : hasFull ? 'Saved preview' : '', Object.keys(state.filters).length ? 'Filtered' : ''].filter(Boolean).join(' · '); } + let label = textMode + ? 'Printed R output' + : pending + ? 'Loading rows…' + : shown + ? `Rows ${state.start + 1}–${state.start + shown} of ${count().toLocaleString('en-US')}` + : 'No matching rows'; + if (textMode) { + label = [label, tableSnapshotSummary(original)].filter(Boolean).join(' · '); + } else { + label = [ + label, + state.live ? 'Live data' : hasFull ? 'Saved preview' : '', + Object.keys(state.filters).length ? 'Filtered' : '', + ] + .filter(Boolean) + .join(' · '); + } status.textContent = [error || label, connectionHint].filter(Boolean).join(' · '); - status.title = state.live && !textMode ? 'Rows are fetched on demand from the original object. Reference edits may appear. Reset restores the saved preview.' - : hasFull ? `${tableSnapshotSummary(original)}. Browse beyond these rows, sort or filter to retrieve live data.` : ''; + status.title = + state.live && !textMode + ? 'Rows are fetched on demand from the original object. Reference edits may appear. Reset restores the saved preview.' + : hasFull + ? `${tableSnapshotSummary(original)}. Browse beyond these rows, sort or filter to retrieve live data.` + : ''; } - draw(); buildFilters(); update(); + draw(); + buildFilters(); + update(); return { - setTextMode(text) { textMode = text; update(); }, + setTextMode(text) { + textMode = text; + update(); + }, reply(message) { - if (!active || !pending || message.requestId !== pending.id) { return; } - const query = pending; pending = undefined; - if (message.error) { error = `${typeof message.error === 'string' ? message.error : JSON.stringify(message.error)} Use Reset to restore the saved preview.`; update(); return; } + if (!active || !pending || message.requestId !== pending.id) { + return; + } + const query = pending; + pending = undefined; + if (message.error) { + error = `${typeof message.error === 'string' ? message.error : JSON.stringify(message.error)} Use Reset to restore the saved preview.`; + update(); + return; + } const result = message.result as Record; - state = { ...state, data: { ...state.data, formattedColumns: undefined, ...result }, start: Number(result.startRow ?? query.start), - size: query.size, live: result.live === true || query.live, - sort: result.queryReset ? [] : query.sort, filters: result.queryReset ? {} : query.filters }; - if (result.queryReset) { state.order = []; error = 'Columns changed; sorting and filters were cleared.'; } - remember(state); draw(); buildFilters(result.queryReset === true); update(); + state = { + ...state, + data: { ...state.data, formattedColumns: undefined, ...result }, + start: Number(result.startRow ?? query.start), + size: query.size, + live: result.live === true || query.live, + sort: result.queryReset ? [] : query.sort, + filters: result.queryReset ? {} : query.filters, + }; + if (result.queryReset) { + state.order = []; + error = 'Columns changed; sorting and filters were cleared.'; + } + remember(state); + draw(); + buildFilters(result.queryReset === true); + update(); + }, + dispose() { + active = false; + pending = undefined; + tableHost.replaceChildren(); + tools.replaceChildren(); + pager.replaceChildren(); }, - dispose() { active = false; pending = undefined; tableHost.replaceChildren(); tools.replaceChildren(); pager.replaceChildren(); }, }; } diff --git a/src/interactive/inputFeatures.ts b/src/interactive/inputFeatures.ts index 6d4883e72..e5bfbbd80 100644 --- a/src/interactive/inputFeatures.ts +++ b/src/interactive/inputFeatures.ts @@ -1,13 +1,17 @@ import * as vscode from 'vscode'; function isInteractiveInput(editor: vscode.TextEditor | undefined): editor is vscode.TextEditor { - return editor?.document.uri.scheme === 'vscode-interactive-input' && editor.document.languageId === 'r'; + return ( + editor?.document.uri.scheme === 'vscode-interactive-input' && + editor.document.languageId === 'r' + ); } function formattingOptions(editor: vscode.TextEditor): vscode.FormattingOptions { return { tabSize: typeof editor.options.tabSize === 'number' ? editor.options.tabSize : 4, - insertSpaces: typeof editor.options.insertSpaces === 'boolean' ? editor.options.insertSpaces : true, + insertSpaces: + typeof editor.options.insertSpaces === 'boolean' ? editor.options.insertSpaces : true, }; } @@ -23,71 +27,148 @@ export class InteractiveInputFeatures implements vscode.Disposable { // Forward every paste to the built-in command, including other editors. // Unlike text-change events, this also identifies single-character pastes. vscode.commands.registerCommand('paste', (args: unknown) => this.paste(args)), - vscode.workspace.onDidChangeTextDocument(event => { - void this.formatOnType(event).catch((error: unknown) => { console.warn('R Interactive on-type formatting failed', error); }); + vscode.workspace.onDidChangeTextDocument((event) => { + void this.formatOnType(event).catch((error: unknown) => { + console.warn('R Interactive on-type formatting failed', error); + }); }), ]; } private async formatOnType(event: vscode.TextDocumentChangeEvent): Promise { - const document = event.document, editor = vscode.window.activeTextEditor; - if (this.disposed || this.pasteDepth > 0 || !isInteractiveInput(editor) || editor.document !== document || event.reason !== undefined - || event.contentChanges.length !== 1 || editor.selections.length !== 1 - || !vscode.workspace.getConfiguration('editor', document).get('formatOnType')) { return; } + const document = event.document, + editor = vscode.window.activeTextEditor; + if ( + this.disposed || + this.pasteDepth > 0 || + !isInteractiveInput(editor) || + editor.document !== document || + event.reason !== undefined || + event.contentChanges.length !== 1 || + editor.selections.length !== 1 || + !vscode.workspace.getConfiguration('editor', document).get('formatOnType') + ) { + return; + } const change = event.contentChanges[0]; // Typing over an auto-inserted closing bracket replaces that character // with itself. VS Code still reports a one-character replacement. - if (change.rangeLength !== 0 && (change.rangeLength !== 1 || change.text.length !== 1)) { return; } + if (change.rangeLength !== 0 && (change.rangeLength !== 1 || change.text.length !== 1)) { + return; + } // Enter may also insert the indentation supplied by the language rules. const ch = /^\r?\n[\t ]*$/.test(change.text) ? '\n' : change.text; - if (ch.length !== 1) { return; } + if (ch.length !== 1) { + return; + } const context = this.contextForEditor(editor); const version = document.version; // VS Code checks the provider's trigger characters and minimizes its // edits, preserving the cursor even for whole-expression replacements. - const edits = await vscode.commands.executeCommand('vscode.executeFormatOnTypeProvider', document.uri, - document.positionAt(change.rangeOffset + change.text.length), ch, formattingOptions(editor)); - if (!edits?.length || this.disposed || document.isClosed || document.version !== version - || vscode.window.activeTextEditor !== editor || this.contextForEditor(editor) !== context - || !vscode.workspace.getConfiguration('editor', document).get('formatOnType')) { return; } - await editor.edit(builder => edits.forEach(edit => builder.replace(edit.range, edit.newText)), - { undoStopBefore: false, undoStopAfter: false }); + const edits = await vscode.commands.executeCommand( + 'vscode.executeFormatOnTypeProvider', + document.uri, + document.positionAt(change.rangeOffset + change.text.length), + ch, + formattingOptions(editor), + ); + if ( + !edits?.length || + this.disposed || + document.isClosed || + document.version !== version || + vscode.window.activeTextEditor !== editor || + this.contextForEditor(editor) !== context || + !vscode.workspace.getConfiguration('editor', document).get('formatOnType') + ) { + return; + } + await editor.edit( + (builder) => edits.forEach((edit) => builder.replace(edit.range, edit.newText)), + { undoStopBefore: false, undoStopAfter: false }, + ); } private async nativePaste(args: unknown): Promise { // Single-character pastes emit the same document changes as typing. // Suppress them even when format-on-paste is disabled. this.pasteDepth++; - try { await vscode.commands.executeCommand('default:paste', args); } - finally { this.pasteDepth--; } + try { + await vscode.commands.executeCommand('default:paste', args); + } finally { + this.pasteDepth--; + } } private async paste(args: unknown): Promise { const editor = vscode.window.activeTextEditor; - if (!isInteractiveInput(editor) || !vscode.workspace.getConfiguration('editor', editor.document).get('formatOnPaste')) { - await this.nativePaste(args); return; + if ( + !isInteractiveInput(editor) || + !vscode.workspace + .getConfiguration('editor', editor.document) + .get('formatOnPaste') + ) { + await this.nativePaste(args); + return; } - const context = this.contextForEditor(editor), document = editor.document; + const context = this.contextForEditor(editor), + document = editor.document; const changes: vscode.TextDocumentContentChangeEvent[] = []; - const listener = vscode.workspace.onDidChangeTextDocument(event => { - if (event.document === document && event.reason === undefined) { changes.push(...event.contentChanges); } + const listener = vscode.workspace.onDidChangeTextDocument((event) => { + if (event.document === document && event.reason === undefined) { + changes.push(...event.contentChanges); + } }); - try { await this.nativePaste(args); } - finally { listener.dispose(); } - if (this.disposed || document.isClosed || vscode.window.activeTextEditor !== editor || editor.selections.length !== 1 - || changes.length !== 1 || !changes[0].text || this.contextForEditor(editor) !== context) { return; } - const version = document.version, change = changes[0]; - const range = new vscode.Range(document.positionAt(change.rangeOffset), document.positionAt(change.rangeOffset + change.text.length)); - const edits = await vscode.commands.executeCommand('vscode.executeFormatRangeProvider', document.uri, range, formattingOptions(editor)); - if (!edits?.length || this.disposed || document.isClosed || document.version !== version || vscode.window.activeTextEditor !== editor - || this.contextForEditor(editor) !== context || !vscode.workspace.getConfiguration('editor', document).get('formatOnPaste')) { return; } + try { + await this.nativePaste(args); + } finally { + listener.dispose(); + } + if ( + this.disposed || + document.isClosed || + vscode.window.activeTextEditor !== editor || + editor.selections.length !== 1 || + changes.length !== 1 || + !changes[0].text || + this.contextForEditor(editor) !== context + ) { + return; + } + const version = document.version, + change = changes[0]; + const range = new vscode.Range( + document.positionAt(change.rangeOffset), + document.positionAt(change.rangeOffset + change.text.length), + ); + const edits = await vscode.commands.executeCommand( + 'vscode.executeFormatRangeProvider', + document.uri, + range, + formattingOptions(editor), + ); + if ( + !edits?.length || + this.disposed || + document.isClosed || + document.version !== version || + vscode.window.activeTextEditor !== editor || + this.contextForEditor(editor) !== context || + !vscode.workspace.getConfiguration('editor', document).get('formatOnPaste') + ) { + return; + } // Native format-on-paste is a separate undo step: first undo the // formatting, then undo the paste. Keep the same behavior here. - await editor.edit(builder => { edits.forEach(edit => builder.replace(edit.range, edit.newText)); }); + await editor.edit((builder) => { + edits.forEach((edit) => builder.replace(edit.range, edit.newText)); + }); } dispose(): void { this.disposed = true; - this.listeners.forEach(listener => { listener.dispose(); }); + this.listeners.forEach((listener) => { + listener.dispose(); + }); } } diff --git a/src/interactive/journal.ts b/src/interactive/journal.ts index b3d6d2160..3424a24b5 100644 --- a/src/interactive/journal.ts +++ b/src/interactive/journal.ts @@ -4,42 +4,80 @@ import { createHash } from 'crypto'; import { AgentSnapshot, ExecutionRecord, SessionEvent, Submission, identifier } from './protocol'; /** Read a completed process without opening a writer or repairing its journal. */ -export function readPreviousJournal(storage: string, generation: string, limit: number): Pick { +export function readPreviousJournal( + storage: string, + generation: string, + limit: number, +): Pick { const directory = path.join(storage, identifier(generation)); const executions: ExecutionRecord[] = []; let bytes = 0; for (const record of readExecutionRecords(directory).slice(-Math.max(1, limit)).reverse()) { bytes += Buffer.byteLength(JSON.stringify(record)); - if (bytes > 1400 * 1024) { break; } + if (bytes > 1400 * 1024) { + break; + } executions.unshift(record); } - const ids = new Set(executions.map(record => record.id)); + const ids = new Set(executions.map((record) => record.id)); const events: SessionEvent[] = []; let seq = 0; // Bound restoration as with a live snapshot; full output stays on disk. - for (const name of fs.readdirSync(directory).filter(name => /^events-\d{6}\.jsonl$/.test(name)).sort().reverse()) { + for (const name of fs + .readdirSync(directory) + .filter((name) => /^events-\d{6}\.jsonl$/.test(name)) + .sort() + .reverse()) { const text = fs.readFileSync(path.join(directory, name), 'utf8'); - for (const line of text.slice(0, text.lastIndexOf('\n') + 1).split('\n').reverse()) { - if (!line) { continue; } + for (const line of text + .slice(0, text.lastIndexOf('\n') + 1) + .split('\n') + .reverse()) { + if (!line) { + continue; + } const event = JSON.parse(line) as SessionEvent; - if (event.generation !== generation) { throw new Error('Journal generation mismatch'); } + if (event.generation !== generation) { + throw new Error('Journal generation mismatch'); + } seq = Math.max(seq, event.seq); - if (!event.executionId || !ids.has(event.executionId)) { continue; } + if (!event.executionId || !ids.has(event.executionId)) { + continue; + } bytes += Buffer.byteLength(line); - if (bytes <= 3 * 1024 * 1024) { events.push(event); } + if (bytes <= 3 * 1024 * 1024) { + events.push(event); + } } } events.reverse(); - return { executions, events, seq, - truncated: executions.filter(record => !events.some(event => event.type === 'accepted' && event.executionId === record.id)).map(record => record.id) }; + return { + executions, + events, + seq, + truncated: executions + .filter( + (record) => + !events.some( + (event) => event.type === 'accepted' && event.executionId === record.id, + ), + ) + .map((record) => record.id), + }; } export function readExecutionRecords(directory: string): ExecutionRecord[] { - return fs.readdirSync(path.join(directory, 'executions')).filter(name => name.endsWith('.json')).map(name => { - const record = JSON.parse(fs.readFileSync(path.join(directory, 'executions', name), 'utf8')) as ExecutionRecord; - identifier(record.id); - return record; - }).sort((a, b) => a.order - b.order); + return fs + .readdirSync(path.join(directory, 'executions')) + .filter((name) => name.endsWith('.json')) + .map((name) => { + const record = JSON.parse( + fs.readFileSync(path.join(directory, 'executions', name), 'utf8'), + ) as ExecutionRecord; + identifier(record.id); + return record; + }) + .sort((a, b) => a.order - b.order); } export function atomicJson(file: string, value: unknown): void { @@ -58,13 +96,20 @@ export function atomicJson(file: string, value: unknown): void { export function retainedAssetIds(storage: string): Set { const retained = new Set(); for (const generation of fs.readdirSync(storage, { withFileTypes: true })) { - if (!generation.isDirectory() || generation.name === 'assets') { continue; } + if (!generation.isDirectory() || generation.name === 'assets') { + continue; + } const directory = path.join(storage, generation.name); const displays = new Map>(); - for (const name of fs.readdirSync(directory).filter(name => /^events-\d{6}\.jsonl$/.test(name)).sort()) { + for (const name of fs + .readdirSync(directory) + .filter((name) => /^events-\d{6}\.jsonl$/.test(name)) + .sort()) { const text = fs.readFileSync(path.join(directory, name), 'utf8'); for (const line of text.slice(0, text.lastIndexOf('\n') + 1).split('\n')) { - if (!line) { continue; } + if (!line) { + continue; + } const event = JSON.parse(line) as SessionEvent; if (event.type === 'display' && typeof event.data.displayId === 'string') { displays.set(`${event.executionId ?? ''}:${event.data.displayId}`, event.data); @@ -72,7 +117,11 @@ export function retainedAssetIds(storage: string): Set { } } for (const display of displays.values()) { - for (const key of ['svg', 'asset']) { if (typeof display[key] === 'string') { retained.add(display[key]); } } + for (const key of ['svg', 'asset']) { + if (typeof display[key] === 'string') { + retained.add(display[key]); + } + } } } return retained; @@ -89,13 +138,20 @@ export class SessionJournal { seq = 0; earliestSeq = 1; - constructor(readonly directory: string, private readonly generation: string, - private readonly maxBytes = 128 * 1024 * 1024) { + constructor( + readonly directory: string, + private readonly generation: string, + private readonly maxBytes = 128 * 1024 * 1024, + ) { fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); fs.mkdirSync(path.join(directory, 'executions'), { recursive: true, mode: 0o700 }); for (const name of fs.readdirSync(path.join(directory, 'executions'))) { - if (!name.endsWith('.json')) { continue; } - const entry = JSON.parse(fs.readFileSync(path.join(directory, 'executions', name), 'utf8')) as ExecutionRecord; + if (!name.endsWith('.json')) { + continue; + } + const entry = JSON.parse( + fs.readFileSync(path.join(directory, 'executions', name), 'utf8'), + ) as ExecutionRecord; identifier(entry.id); this.executions.set(entry.id, entry); this.nextOrder = Math.max(this.nextOrder, entry.order + 1); @@ -110,9 +166,13 @@ export class SessionJournal { fs.truncateSync(file, Buffer.byteLength(data.slice(0, boundary))); } for (const line of data.slice(0, boundary).split('\n')) { - if (!line) { continue; } + if (!line) { + continue; + } const event = JSON.parse(line) as SessionEvent; - if (event.generation !== generation) { throw new Error('Journal generation mismatch'); } + if (event.generation !== generation) { + throw new Error('Journal generation mismatch'); + } this.seq = Math.max(this.seq, event.seq); this.events.push(event); } @@ -131,57 +191,98 @@ export class SessionJournal { const hash = createHash('sha256').update(request.code).digest('hex'); const previous = this.executions.get(request.id); if (previous) { - if (previous.hash !== hash) { throw new Error('Execution ID already used for different code'); } + if (previous.hash !== hash) { + throw new Error('Execution ID already used for different code'); + } return { record: previous, duplicate: true }; } - const record: ExecutionRecord = { ...request, hash, state: 'queued', - order: this.nextOrder++, accepted: Date.now() }; + const record: ExecutionRecord = { + ...request, + hash, + state: 'queued', + order: this.nextOrder++, + accepted: Date.now(), + }; this.update(record); return { record, duplicate: false }; } update(record: ExecutionRecord): void { - atomicJson(path.join(this.directory, 'executions', `${identifier(record.id)}.json`), record); + atomicJson( + path.join(this.directory, 'executions', `${identifier(record.id)}.json`), + record, + ); this.executions.set(record.id, record); } - append(type: string, data: Record, executionId?: string, durable = false): SessionEvent { - const event: SessionEvent = { seq: this.seq + 1, generation: this.generation, - type, data, executionId, time: Date.now() }; + append( + type: string, + data: Record, + executionId?: string, + durable = false, + ): SessionEvent { + const event: SessionEvent = { + seq: this.seq + 1, + generation: this.generation, + type, + data, + executionId, + time: Date.now(), + }; const line = `${JSON.stringify(event)}\n`; fs.writeSync(this.fd, line); - if (durable) { fs.fsyncSync(this.fd); } + if (durable) { + fs.fsyncSync(this.fd); + } this.seq = event.seq; this.size += Buffer.byteLength(line); this.events.push(event); - if (this.size >= 4 * 1024 * 1024) { this.rotate(); } + if (this.size >= 4 * 1024 * 1024) { + this.rotate(); + } this.trimMemory(); return event; } replay(after: number, limit = 1000): { events: SessionEvent[]; reset: boolean; seq: number } { - if (after < this.earliestSeq - 1) { return { events: [], reset: true, seq: this.seq }; } + if (after < this.earliestSeq - 1) { + return { events: [], reset: true, seq: this.seq }; + } const events: SessionEvent[] = []; if (after >= (this.events[0]?.seq ?? this.seq + 1) - 1) { let bytes = 0; for (const event of this.events) { - if (event.seq <= after) { continue; } + if (event.seq <= after) { + continue; + } const size = Buffer.byteLength(JSON.stringify(event)); - if (events.length && (bytes + size > 3 * 1024 * 1024 || events.length >= limit)) { break; } - events.push(event); bytes += size; + if (events.length && (bytes + size > 3 * 1024 * 1024 || events.length >= limit)) { + break; + } + events.push(event); + bytes += size; } } else { let bytes = 0; for (const name of this.segments()) { - for (const line of fs.readFileSync(path.join(this.directory, name), 'utf8').split('\n')) { - if (!line) { continue; } + for (const line of fs + .readFileSync(path.join(this.directory, name), 'utf8') + .split('\n')) { + if (!line) { + continue; + } const event = JSON.parse(line) as SessionEvent; if (event.seq > after) { const size = Buffer.byteLength(JSON.stringify(event)); - if (events.length && bytes + size > 3 * 1024 * 1024) { return { events, reset: false, seq: this.seq }; } - events.push(event); bytes += size; + if (events.length && bytes + size > 3 * 1024 * 1024) { + return { events, reset: false, seq: this.seq }; + } + events.push(event); + bytes += size; + } + if (events.length >= limit) { + return { events, reset: false, seq: this.seq }; } - if (events.length >= limit) { return { events, reset: false, seq: this.seq }; } } } } @@ -189,14 +290,22 @@ export class SessionJournal { } recent(limit: number): { executions: ExecutionRecord[]; events: SessionEvent[] } { - const executions = [...this.executions.values()].sort((a, b) => a.order - b.order).slice(-limit); - const ids = new Set(executions.map(record => record.id)); + const executions = [...this.executions.values()] + .sort((a, b) => a.order - b.order) + .slice(-limit); + const ids = new Set(executions.map((record) => record.id)); const events: SessionEvent[] = []; for (const name of this.segments()) { - for (const line of fs.readFileSync(path.join(this.directory, name), 'utf8').split('\n')) { - if (!line) { continue; } + for (const line of fs + .readFileSync(path.join(this.directory, name), 'utf8') + .split('\n')) { + if (!line) { + continue; + } const event = JSON.parse(line) as SessionEvent; - if (event.executionId && ids.has(event.executionId)) { events.push(event); } + if (event.executionId && ids.has(event.executionId)) { + events.push(event); + } } } return { executions, events }; @@ -208,7 +317,10 @@ export class SessionJournal { } private segments(): string[] { - return fs.readdirSync(this.directory).filter(name => /^events-\d{6}\.jsonl$/.test(name)).sort(); + return fs + .readdirSync(this.directory) + .filter((name) => /^events-\d{6}\.jsonl$/.test(name)) + .sort(); } private segmentFile(): string { @@ -216,7 +328,9 @@ export class SessionJournal { } private trimMemory(): void { - if (this.events.length > 2000) { this.events.splice(0, this.events.length - 2000); } + if (this.events.length > 2000) { + this.events.splice(0, this.events.length - 2000); + } } private rotate(): void { @@ -226,13 +340,18 @@ export class SessionJournal { this.fd = fs.openSync(this.segmentFile(), 'a', 0o600); this.size = 0; const segments = this.segments(); - let total = segments.reduce((sum, name) => sum + fs.statSync(path.join(this.directory, name)).size, 0); + let total = segments.reduce( + (sum, name) => sum + fs.statSync(path.join(this.directory, name)).size, + 0, + ); while (total > this.maxBytes && segments.length > 1) { const file = path.join(this.directory, segments.shift()!); total -= fs.statSync(file).size; fs.unlinkSync(file); } - const first = fs.readFileSync(path.join(this.directory, segments[0]), 'utf8').split('\n')[0]; + const first = fs + .readFileSync(path.join(this.directory, segments[0]), 'utf8') + .split('\n')[0]; this.earliestSeq = first ? (JSON.parse(first) as SessionEvent).seq : this.seq + 1; } } diff --git a/src/interactive/launcher.ts b/src/interactive/launcher.ts index 55fc0aa4f..d6f5832a7 100644 --- a/src/interactive/launcher.ts +++ b/src/interactive/launcher.ts @@ -14,51 +14,80 @@ export { prepareStorage } from './storage'; const run = promisify(execFile); -export function defaultStorage(platform = process.platform, home = os.homedir(), environment = process.env): string { +export function defaultStorage( + platform = process.platform, + home = os.homedir(), + environment = process.env, +): string { const xdg = environment.XDG_STATE_HOME; // XDG paths must be absolute; an empty value is equivalent to being unset. - if (xdg && path.isAbsolute(xdg)) { return path.join(xdg, 'vscode-r', 'interactive'); } + if (xdg && path.isAbsolute(xdg)) { + return path.join(xdg, 'vscode-r', 'interactive'); + } const legacy = path.join(home, '.local', 'state', 'vscode-r', 'interactive'); // Keep existing agents and their on-disk paths discoverable without moving live storage. - if (platform !== 'darwin' || fs.existsSync(legacy)) { return legacy; } + if (platform !== 'darwin' || fs.existsSync(legacy)) { + return legacy; + } return path.join(home, 'Library', 'Application Support', 'vscode-r', 'interactive'); } export function discoverSessions(root: string): SessionManifest[] { let names: string[]; - try { names = fs.readdirSync(root); } - catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { return []; } + try { + names = fs.readdirSync(root); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return []; + } throw storageError(root, error); } const sessions: SessionManifest[] = []; for (const name of names) { - if (name === 'runtimes') { continue; } + if (name === 'runtimes') { + continue; + } try { identifier(name); const file = path.join(root, name, 'manifest.json'); const stat = fs.lstatSync(file); - if (stat.isSymbolicLink() || (process.getuid && stat.uid !== process.getuid())) { continue; } + if (stat.isSymbolicLink() || (process.getuid && stat.uid !== process.getuid())) { + continue; + } const manifest = JSON.parse(fs.readFileSync(file, 'utf8')) as SessionManifest; - if (manifest.id !== name || manifest.host !== os.hostname()) { continue; } + if (manifest.id !== name || manifest.host !== os.hostname()) { + continue; + } sessions.push(manifest); - } catch { /* Ignore partial, foreign, and unrecognized registry entries. */ } + } catch { + /* Ignore partial, foreign, and unrecognized registry entries. */ + } } return sessions.sort((a, b) => b.created - a.created); } /** Cheap rejection of stale registry entries; a socket still needs an authenticated probe. */ export function hasSessionEndpoint(manifest: SessionManifest): boolean { - if (!Number.isSafeInteger(manifest.agentPid) || manifest.agentPid <= 0 || - typeof manifest.endpoint !== 'string' || !path.isAbsolute(manifest.endpoint)) { return false; } + if ( + !Number.isSafeInteger(manifest.agentPid) || + manifest.agentPid <= 0 || + typeof manifest.endpoint !== 'string' || + !path.isAbsolute(manifest.endpoint) + ) { + return false; + } try { process.kill(manifest.agentPid, 0); return fs.statSync(manifest.endpoint).isSocket(); - } catch { return false; } + } catch { + return false; + } } /** Shell quoting is used only for tmux/system service launch commands, never for R code. */ -export function shellQuote(value: string): string { return `'${value.replace(/'/g, `'"'"'`)}'`; } +export function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'"'"'`)}'`; +} /** Debugger auto-attach must not make the persistent agent part of the editor's debug session. */ export function agentEnvironment(environment = process.env): NodeJS.ProcessEnv { @@ -72,9 +101,14 @@ export function agentEnvironment(environment = process.env): NodeJS.ProcessEnv { } /** Only the Electron launcher and agent need this flag, not their R children. */ -export function nodeEnvironment(runtime: NodeRuntime, environment = process.env): NodeJS.ProcessEnv { +export function nodeEnvironment( + runtime: NodeRuntime, + environment = process.env, +): NodeJS.ProcessEnv { const result = agentEnvironment(environment); - if (runtime.electron) { result.ELECTRON_RUN_AS_NODE = '1'; } + if (runtime.electron) { + result.ELECTRON_RUN_AS_NODE = '1'; + } return result; } @@ -86,36 +120,62 @@ export function installAgentBundle(extensionPath: string, root: string): string const agent = path.join(root, 'runtimes', `agent-${hash}.cjs`); if (!fs.existsSync(agent)) { const temporary = `${agent}.${randomUUID()}.tmp`; - try { fs.writeFileSync(temporary, bytes, { mode: 0o600 }); fs.renameSync(temporary, agent); } - finally { fs.rmSync(temporary, { force: true }); } + try { + fs.writeFileSync(temporary, bytes, { mode: 0o600 }); + fs.renameSync(temporary, agent); + } finally { + fs.rmSync(temporary, { force: true }); + } } return agent; } /** Convenience for existing callers preparing the shipped sess backend. */ -export async function installRuntime(extensionPath: string, root: string, rPath: string, - log: (text: string) => void): Promise<{ library: string; resources: string; agent: string }> { +export async function installRuntime( + extensionPath: string, + root: string, + rPath: string, + log: (text: string) => void, +): Promise<{ library: string; resources: string; agent: string }> { const runtime = await installSessRuntime(extensionPath, root, rPath, log); return { ...runtime, agent: installAgentBundle(extensionPath, root) }; } /** Validate before building a runtime or stopping a session for restart. */ -export async function prepareNodeRuntime(directory: string, runtime = hostNodeRuntime()): Promise { +export async function prepareNodeRuntime( + directory: string, + runtime = hostNodeRuntime(), +): Promise { const node = runtime.executable; - const help = 'Set r.interactive.nodePath to a Node.js 18+ executable on the R host, or clear it to use VS Code\'s runtime. Reload VS Code to refresh the automatic runtime; repair or update VS Code (VS Code Server on a remote host) if needed.'; + const help = + "Set r.interactive.nodePath to a Node.js 18+ executable on the R host, or clear it to use VS Code's runtime. Reload VS Code to refresh the automatic runtime; repair or update VS Code (VS Code Server on a remote host) if needed."; let version: string; try { - version = (await run(node, ['-p', 'process.versions.node'], { env: nodeEnvironment(runtime), cwd: directory, timeout: 5000 })).stdout.trim(); - } catch (error) { throw new Error(`Cannot run the Node.js runtime “${node}”. ${help}`, { cause: error }); } + version = ( + await run(node, ['-p', 'process.versions.node'], { + env: nodeEnvironment(runtime), + cwd: directory, + timeout: 5000, + }) + ).stdout.trim(); + } catch (error) { + throw new Error(`Cannot run the Node.js runtime “${node}”. ${help}`, { cause: error }); + } const major = /^(\d+)\.\d+\.\d+(?:[-+].*)?$/.exec(version)?.[1]; if (!major || Number(major) < 18) { - throw new Error(`The session agent requires Node.js 18 or newer; “${node}” reported “${version}”. ${help}`); + throw new Error( + `The session agent requires Node.js 18 or newer; “${node}” reported “${version}”. ${help}`, + ); } return runtime; } -export async function launchAgent(config: AgentConfig, agent: string, runtime = hostNodeRuntime(), - log: (text: string) => void = () => undefined): Promise { +export async function launchAgent( + config: AgentConfig, + agent: string, + runtime = hostNodeRuntime(), + log: (text: string) => void = () => undefined, +): Promise { const supervisor = prepareSupervisor(config.supervision, config.directory); runtime = await prepareNodeRuntime(config.directory, runtime); const node = runtime.executable; @@ -125,22 +185,43 @@ export async function launchAgent(config: AgentConfig, agent: string, runtime = config.supervision = supervisor.kind; atomicJson(file, config); if (supervisor.kind !== 'detached') { - const args = supervisor.kind === 'tmux' - ? ['new-session', '-d', '-s', `vscode-r-${config.id.slice(0, 8)}-${config.generation.slice(0, 8)}`, - `${runtime.electron ? 'ELECTRON_RUN_AS_NODE=1 ' : ''}exec ${[node, agent, file].map(shellQuote).join(' ')} >>${shellQuote(path.join(config.storage, 'agent.log'))} 2>&1`] - : ['--user', '--collect', '--unit', `vscode-r-${config.id}-${config.generation}`, - ...(runtime.electron ? ['--setenv=ELECTRON_RUN_AS_NODE=1'] : []), '--', node, agent, file]; - try { await run(supervisor.executable, args, { env, cwd: config.directory, timeout: 10000 }); } - catch (error) { + const args = + supervisor.kind === 'tmux' + ? [ + 'new-session', + '-d', + '-s', + `vscode-r-${config.id.slice(0, 8)}-${config.generation.slice(0, 8)}`, + `${runtime.electron ? 'ELECTRON_RUN_AS_NODE=1 ' : ''}exec ${[node, agent, file].map(shellQuote).join(' ')} >>${shellQuote(path.join(config.storage, 'agent.log'))} 2>&1`, + ] + : [ + '--user', + '--collect', + '--unit', + `vscode-r-${config.id}-${config.generation}`, + ...(runtime.electron ? ['--setenv=ELECTRON_RUN_AS_NODE=1'] : []), + '--', + node, + agent, + file, + ]; + try { + await run(supervisor.executable, args, { env, cwd: config.directory, timeout: 10000 }); + } catch (error) { // A failed command may already have started an agent. Falling back now // could create two R processes using the same session registry. - throw new Error(`Could not start the ${supervisor.kind} session supervisor. Check ${supervisor.kind === 'systemd' ? 'the systemd user service' : 'tmux'} on the R host, ` + - `or set r.interactive.supervision to "detached". ${String(error)}`, { cause: error }); + throw new Error( + `Could not start the ${supervisor.kind} session supervisor. Check ${supervisor.kind === 'systemd' ? 'the systemd user service' : 'tmux'} on the R host, ` + + `or set r.interactive.supervision to "detached". ${String(error)}`, + { cause: error }, + ); } } else { if (supervisor.notice) { log(supervisor.notice); - fs.appendFileSync(path.join(config.storage, 'agent.log'), `${supervisor.notice}\n`, { mode: 0o600 }); + fs.appendFileSync(path.join(config.storage, 'agent.log'), `${supervisor.notice}\n`, { + mode: 0o600, + }); } // setsid/unref alone leaves the agent in the extension host's process tree. // Wait for a short-lived launcher to exit so tree-based editor/debugger cleanup @@ -159,12 +240,22 @@ export async function launchAgent(config: AgentConfig, agent: string, runtime = const deadline = Date.now() + 30000; while (Date.now() < deadline) { try { - const manifest = JSON.parse(fs.readFileSync(path.join(config.storage, 'manifest.json'), 'utf8')) as SessionManifest; - if (manifest.generation === config.generation) { return manifest; } - } catch { /* Atomic manifest is published after the endpoints are ready. */ } - await new Promise(resolve => setTimeout(resolve, 100)); + const manifest = JSON.parse( + fs.readFileSync(path.join(config.storage, 'manifest.json'), 'utf8'), + ) as SessionManifest; + if (manifest.generation === config.generation) { + return manifest; + } + } catch { + /* Atomic manifest is published after the endpoints are ready. */ + } + await new Promise((resolve) => setTimeout(resolve, 100)); } - throw new Error(`Session agent did not become ready. See ${path.join(config.storage, 'agent.log')}`); + throw new Error( + `Session agent did not become ready. See ${path.join(config.storage, 'agent.log')}`, + ); } -export function newIdentity(): { id: string; generation: string } { return { id: randomUUID(), generation: randomUUID() }; } +export function newIdentity(): { id: string; generation: string } { + return { id: randomUUID(), generation: randomUUID() }; +} diff --git a/src/interactive/manager.ts b/src/interactive/manager.ts index 499c820d9..7902c5075 100644 --- a/src/interactive/manager.ts +++ b/src/interactive/manager.ts @@ -3,16 +3,39 @@ import * as fs from 'fs'; import * as path from 'path'; import { randomUUID } from 'crypto'; import { AgentClient } from './client'; -import { AgentConfig, AgentSnapshot, DEFAULT_MAX_ASSET_BYTES, ExecutionRecord, SessionEvent, SessionManifest, SourceLocation, object, sessionLabel } from './protocol'; +import { + AgentConfig, + AgentSnapshot, + DEFAULT_MAX_ASSET_BYTES, + ExecutionRecord, + SessionEvent, + SessionManifest, + SourceLocation, + object, + sessionLabel, +} from './protocol'; import { AssetStore, AssetStorageStats, exportedAssetName, readAsset } from './assets'; -import { defaultStorage, discoverSessions, hasSessionEndpoint, launchAgent, newIdentity, prepareNodeRuntime } from './launcher'; +import { + defaultStorage, + discoverSessions, + hasSessionEndpoint, + launchAgent, + newIdentity, + prepareNodeRuntime, +} from './launcher'; import { NodeRuntime, resolveNodeRuntime } from './nodeExecutable'; import { backendDescriptor, prepareBackendRuntime } from './backendRegistry'; import { discoverArf, probeArfSession, ArfSession } from './arf'; import { resolveArfExecutable } from './arfExecutable'; import { prepareSupervisor } from './supervisor'; import { Transcript, TranscriptCell } from './transcript'; -import { DISPLAY_MIME, InteractiveSerializer, persistentCellMetadata, persistentNotebookMetadata, restoredCellMetadata } from './notebook'; +import { + DISPLAY_MIME, + InteractiveSerializer, + persistentCellMetadata, + persistentNotebookMetadata, + restoredCellMetadata, +} from './notebook'; import { setInteractiveExecutor } from './executionTarget'; import * as session from '../session'; import * as util from '../util'; @@ -48,10 +71,20 @@ interface InteractiveView { restarting?: boolean; } -interface SessionChoice extends vscode.QuickPickItem { manifest?: SessionManifest; arf?: ArfSession; create?: boolean; terminal?: boolean } -interface SavedConnection { id: string; notebookUri?: string } +interface SessionChoice extends vscode.QuickPickItem { + manifest?: SessionManifest; + arf?: ArfSession; + create?: boolean; + terminal?: boolean; +} +interface SavedConnection { + id: string; + notebookUri?: string; +} -export class InteractiveManager implements vscode.Disposable, vscode.TreeDataProvider { +export class InteractiveManager + implements vscode.Disposable, vscode.TreeDataProvider +{ private views = new Map(); private opening = new Map>(); private presentationRefresh = new Set(); @@ -69,24 +102,43 @@ export class InteractiveManager implements vscode.Disposable, vscode.TreeDataPro private closed = false; private clientId: string; private savedConnections: Map; - private outputViews: Map; + private outputViews: Map< + string, + { tableView?: string; listView?: string; selectedPlot?: string } + >; private status = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 20); constructor(private context: vscode.ExtensionContext) { // A restart replaces the transcript, invalidating pending formatting edits. - this.disposables.push(new InteractiveInputFeatures(editor => [...this.views.values()].find(view => - !view.disposed && view.inputUri?.toString() === editor.document.uri.toString())?.model)); + this.disposables.push( + new InteractiveInputFeatures( + (editor) => + [...this.views.values()].find( + (view) => + !view.disposed && + view.inputUri?.toString() === editor.document.uri.toString(), + )?.model, + ), + ); this.root = util.config().get('interactive.storagePath') || defaultStorage(); this.clientId = randomUUID(); // Native Interactive tabs restore their URI, but discard notebook metadata. // Keep that association outside the notebook, including while R is unreachable. - const saved = context.workspaceState.get('r.interactive.connections') ?? - context.workspaceState.get('r.interactive.sessions', []).map(id => ({ id })); - this.savedConnections = new Map(saved.map(connection => [connection.id, connection])); + const saved = + context.workspaceState.get('r.interactive.connections') ?? + context.workspaceState + .get('r.interactive.sessions', []) + .map((id) => ({ id })); + this.savedConnections = new Map(saved.map((connection) => [connection.id, connection])); this.outputViews = new Map(context.workspaceState.get('r.interactive.outputViews', [])); const command = (name: string, handler: (...args: unknown[]) => unknown): void => { - this.disposables.push(vscode.commands.registerCommand(name, (...args: unknown[]) => - Promise.resolve().then(() => handler(...args)).catch((error: unknown) => this.report(error)))); + this.disposables.push( + vscode.commands.registerCommand(name, (...args: unknown[]) => + Promise.resolve() + .then(() => handler(...args)) + .catch((error: unknown) => this.report(error)), + ), + ); }; command('r.interactive.new', async () => { const view = await this.create(); @@ -94,129 +146,268 @@ export class InteractiveManager implements vscode.Disposable, vscode.TreeDataPro if (view.inputUri) { // Reuse the native editor created beside the previous input. // showNotebookDocument would open a second tab in the old group. - await vscode.commands.executeCommand('interactive.open', { preserveFocus: false }, view.notebook.uri); + await vscode.commands.executeCommand( + 'interactive.open', + { preserveFocus: false }, + view.notebook.uri, + ); await vscode.commands.executeCommand('interactive.input.focus'); } else { - const editor = vscode.window.visibleNotebookEditors.find(item => item.notebook === view.notebook); - await vscode.window.showNotebookDocument(view.notebook, { preserveFocus: false, viewColumn: editor?.viewColumn }); + const editor = vscode.window.visibleNotebookEditors.find( + (item) => item.notebook === view.notebook, + ); + await vscode.window.showNotebookDocument(view.notebook, { + preserveFocus: false, + viewColumn: editor?.viewColumn, + }); } } }); command('r.interactive.connect', () => this.pick()); - command('r.interactive.open', value => value ? this.open(value as SessionManifest) : this.pick()); + command('r.interactive.open', (value) => + value ? this.open(value as SessionManifest) : this.pick(), + ); command('r.interactive.refresh', () => this.refresh()); - command('r.interactive.interrupt', value => this.forView(value, view => view.client.request('interrupt'))); - command('r.interactive.input', value => this.forView(value, view => this.resumeInput(view))); - command('r.interactive.detach', value => this.forView(value, view => this.detach(view))); - command('r.interactive.stop', value => this.forView(value, view => this.stop(view))); - command('r.interactive.stopSelected', (value, selection) => this.stopMultiple(false, value, selection)); + command('r.interactive.interrupt', (value) => + this.forView(value, (view) => view.client.request('interrupt')), + ); + command('r.interactive.input', (value) => + this.forView(value, (view) => this.resumeInput(view)), + ); + command('r.interactive.detach', (value) => + this.forView(value, (view) => this.detach(view)), + ); + command('r.interactive.stop', (value) => this.forView(value, (view) => this.stop(view))); + command('r.interactive.stopSelected', (value, selection) => + this.stopMultiple(false, value, selection), + ); command('r.interactive.stopAll', () => this.stopMultiple(true)); - command('r.interactive.restart', value => this.forView(value, view => this.restart(view))); - command('r.interactive.sessionActions', value => this.forView(value, view => this.sessionActions(view))); - command('r.interactive.takeControl', value => this.forView(value, async view => { - view.client.control = await view.client.request('claim', { force: true }); this.updateStatus(); this.changes.fire(undefined); - })); - command('r.interactive.rename', (value, label) => this.forView(value, view => this.rename(view, typeof label === 'string' ? label : undefined))); - command('r.interactive.history', value => this.forView(value, view => this.history(view))); - command('r.interactive.plots', value => this.forView(value, view => this.plots(view))); - command('r.interactive.cancelQueued', value => this.forView(value, view => this.cancelQueued(view))); - command('r.interactive.reuseCell', value => this.forView(value, async view => { - const cell = value as vscode.NotebookCell; - // Native Interactive toolbars do not expose notebookCellType, so - // their actions also appear on lifecycle notices. - if (cell.kind !== vscode.NotebookCellKind.Code) { - void vscode.window.showInformationMessage('Select an R code cell to insert into the Interactive input.'); - return; - } - await this.insertCode(view, cell.document.getText()); - })); - command('r.interactive.copyCell', value => vscode.env.clipboard.writeText((value as vscode.NotebookCell).document.getText())); - command('r.interactive.source', value => { + command('r.interactive.restart', (value) => + this.forView(value, (view) => this.restart(view)), + ); + command('r.interactive.sessionActions', (value) => + this.forView(value, (view) => this.sessionActions(view)), + ); + command('r.interactive.takeControl', (value) => + this.forView(value, async (view) => { + view.client.control = await view.client.request('claim', { force: true }); + this.updateStatus(); + this.changes.fire(undefined); + }), + ); + command('r.interactive.rename', (value, label) => + this.forView(value, (view) => + this.rename(view, typeof label === 'string' ? label : undefined), + ), + ); + command('r.interactive.history', (value) => + this.forView(value, (view) => this.history(view)), + ); + command('r.interactive.plots', (value) => this.forView(value, (view) => this.plots(view))); + command('r.interactive.cancelQueued', (value) => + this.forView(value, (view) => this.cancelQueued(view)), + ); + command('r.interactive.reuseCell', (value) => + this.forView(value, async (view) => { + const cell = value as vscode.NotebookCell; + // Native Interactive toolbars do not expose notebookCellType, so + // their actions also appear on lifecycle notices. + if (cell.kind !== vscode.NotebookCellKind.Code) { + void vscode.window.showInformationMessage( + 'Select an R code cell to insert into the Interactive input.', + ); + return; + } + await this.insertCode(view, cell.document.getText()); + }), + ); + command('r.interactive.copyCell', (value) => + vscode.env.clipboard.writeText((value as vscode.NotebookCell).document.getText()), + ); + command('r.interactive.source', (value) => { const cell = value as vscode.NotebookCell; if (cell.kind !== vscode.NotebookCellKind.Code) { - void vscode.window.showInformationMessage('Session notices have no source location.'); + void vscode.window.showInformationMessage( + 'Session notices have no source location.', + ); return; } return this.source(cell.metadata.rSource as SourceLocation | undefined); }); - command('r.interactive.info', value => this.forView(value, view => { - this.output.appendLine(sessionPresentation(view.client.manifest, view.client.connected, view.client.control, - view.restarting, view.target.workingDir).tooltip); - this.output.show(true); - })); - command('r.interactive.useTerminal', () => { this.routing = false; }); + command('r.interactive.info', (value) => + this.forView(value, (view) => { + this.output.appendLine( + sessionPresentation( + view.client.manifest, + view.client.connected, + view.client.control, + view.restarting, + view.target.workingDir, + ).tooltip, + ); + this.output.show(true); + }), + ); + command('r.interactive.useTerminal', () => { + this.routing = false; + }); command('r.interactive.bindDocument', () => { const document = vscode.window.activeTextEditor?.document; - if (document && this.active) { session.bindSessionDocument(document.uri, this.active.target); } + if (document && this.active) { + session.bindSessionDocument(document.uri, this.active.target); + } }); - command('r.interactive.export', value => this.forView(value, view => this.exportHistory(view))); - command('r.interactive.clear', value => this.forView(value, view => this.clearCompleted(view))); - command('r.interactive.cleanAssets', value => this.forView(value, async view => { - if (!view.client.manifest.capabilities.assetStorage) { throw new Error('Asset cleanup requires a session started with the current extension build'); } - const stats = await view.client.request('assetStorage', { compact: true }); - void vscode.window.showInformationMessage(`R Interactive: reclaimed ${(stats.reclaimedBytes / 1024 / 1024).toFixed(1)} MiB. Retained assets use ${(stats.usedBytes / 1024 / 1024).toFixed(1)} MiB of ${(stats.limitBytes / 1024 / 1024 / 1024).toFixed(2)} GiB.`); - })); - this.status.name = 'R Interactive session'; this.status.command = 'r.interactive.connect'; + command('r.interactive.export', (value) => + this.forView(value, (view) => this.exportHistory(view)), + ); + command('r.interactive.clear', (value) => + this.forView(value, (view) => this.clearCompleted(view)), + ); + command('r.interactive.cleanAssets', (value) => + this.forView(value, async (view) => { + if (!view.client.manifest.capabilities.assetStorage) { + throw new Error( + 'Asset cleanup requires a session started with the current extension build', + ); + } + const stats = await view.client.request('assetStorage', { + compact: true, + }); + void vscode.window.showInformationMessage( + `R Interactive: reclaimed ${(stats.reclaimedBytes / 1024 / 1024).toFixed(1)} MiB. Retained assets use ${(stats.usedBytes / 1024 / 1024).toFixed(1)} MiB of ${(stats.limitBytes / 1024 / 1024 / 1024).toFixed(2)} GiB.`, + ); + }), + ); + this.status.name = 'R Interactive session'; + this.status.command = 'r.interactive.connect'; this.disposables.push(this.status); - const ageTimer = setInterval(() => { this.changes.fire(undefined); this.updateStatus(); }, 60000); + const ageTimer = setInterval(() => { + this.changes.fire(undefined); + this.updateStatus(); + }, 60000); ageTimer.unref(); this.disposables.push({ dispose: () => clearInterval(ageTimer) }); - this.disposables.push(vscode.window.createTreeView('rInteractiveSessions', { treeDataProvider: this, canSelectMany: true }), - vscode.workspace.registerNotebookSerializer('r-interactive', this.serializer, { transientOutputs: false }), - this.messages.onDidReceiveMessage(event => { void this.rendererMessage(event.editor, event.message as Record); }), - vscode.window.onDidChangeActiveNotebookEditor(editor => { - const view = [...this.views.values()].find(item => item.notebook === editor?.notebook); + this.disposables.push( + vscode.window.createTreeView('rInteractiveSessions', { + treeDataProvider: this, + canSelectMany: true, + }), + vscode.workspace.registerNotebookSerializer('r-interactive', this.serializer, { + transientOutputs: false, + }), + this.messages.onDidReceiveMessage((event) => { + void this.rendererMessage(event.editor, event.message as Record); + }), + vscode.window.onDidChangeActiveNotebookEditor((editor) => { + const view = [...this.views.values()].find( + (item) => item.notebook === editor?.notebook, + ); void vscode.commands.executeCommand('setContext', 'r.interactive.notebook', !!view); - if (view) { void this.activate(view); } + if (view) { + void this.activate(view); + } }), - vscode.window.onDidChangeActiveTextEditor(editor => { + vscode.window.onDidChangeActiveTextEditor((editor) => { const target = editor && session.boundSessionForDocument(editor.document.uri); - const view = target && [...this.views.values()].find(item => item.target === target); - if (view) { void this.activate(view); } - else { this.updateStatus(); } + const view = + target && [...this.views.values()].find((item) => item.target === target); + if (view) { + void this.activate(view); + } else { + this.updateStatus(); + } }), - vscode.workspace.onDidCloseNotebookDocument(document => { - const view = [...this.views.values()].find(item => item.notebook === document); - if (view) { this.disposeView(view); } + vscode.workspace.onDidCloseNotebookDocument((document) => { + const view = [...this.views.values()].find((item) => item.notebook === document); + if (view) { + this.disposeView(view); + } }), - vscode.workspace.onDidChangeConfiguration(event => { + vscode.workspace.onDidChangeConfiguration((event) => { if (event.affectsConfiguration('r.interactive.tableView')) { for (const view of this.views.values()) { - view.chain = view.chain.then(async () => { - for (const cell of this.transcriptCells(view)) { - if (cell.outputs.some(output => output.data.kind === 'table')) { await this.render(view, cell.record.id); } - } - }).catch(error => this.report(error)); + view.chain = view.chain + .then(async () => { + for (const cell of this.transcriptCells(view)) { + if ( + cell.outputs.some((output) => output.data.kind === 'table') + ) { + await this.render(view, cell.record.id); + } + } + }) + .catch((error) => this.report(error)); } } - if (!event.affectsConfiguration('r.interactive.maxAssetSizeMiB')) { return; } + if (!event.affectsConfiguration('r.interactive.maxAssetSizeMiB')) { + return; + } const limitBytes = this.retentionLimits().maxAssetBytes; for (const view of this.views.values()) { - if (view.client.connected && view.client.control && view.client.manifest.capabilities.assetStorage) { - void view.client.request('assetStorage', { limitBytes }).catch(error => this.report(error)); + if ( + view.client.connected && + view.client.control && + view.client.manifest.capabilities.assetStorage + ) { + void view.client + .request('assetStorage', { limitBytes }) + .catch((error) => this.report(error)); } } - })); + }), + ); setInteractiveExecutor(async (code, resource, source, offerTarget) => { const mode = util.config(resource).get('interactive.executionTarget', 'auto'); - if (mode === 'terminal' || (!this.routing && mode !== 'interactive' && !offerTarget)) { return false; } + if (mode === 'terminal' || (!this.routing && mode !== 'interactive' && !offerTarget)) { + return false; + } try { const bound = resource && session.boundSessionForDocument(resource); - let view = this.routing || mode === 'interactive' - ? bound ? [...this.views.values()].find(item => item.target === bound) : this.active : undefined; - if (view?.restarting) { throw new Error('R is restarting. Wait for the restart notice before running code.'); } - if (!view || view.disposed || !view.client.connected || ['exited', 'stopping'].includes(view.client.manifest.status)) { + let view = + this.routing || mode === 'interactive' + ? bound + ? [...this.views.values()].find((item) => item.target === bound) + : this.active + : undefined; + if (view?.restarting) { + throw new Error( + 'R is restarting. Wait for the restart notice before running code.', + ); + } + if ( + !view || + view.disposed || + !view.client.connected || + ['exited', 'stopping'].includes(view.client.manifest.status) + ) { // Share one chooser when several Run Selection commands arrive during startup. - const pending = this.targetSelection ??= this.chooseExecutionTarget(resource); + const pending = (this.targetSelection ??= this.chooseExecutionTarget(resource)); let chosen: Awaited; - try { chosen = await pending; } finally { if (this.targetSelection === pending) { this.targetSelection = undefined; } } - if (!chosen) { return 'cancelled'; } - if (chosen === 'createTerminal') { this.routing = false; return chosen; } + try { + chosen = await pending; + } finally { + if (this.targetSelection === pending) { + this.targetSelection = undefined; + } + } + if (!chosen) { + return 'cancelled'; + } + if (chosen === 'createTerminal') { + this.routing = false; + return chosen; + } view = chosen; - if (bound && resource) { session.bindSessionDocument(resource, view.target); } + if (bound && resource) { + session.bindSessionDocument(resource, view.target); + } } - await this.submit(view, code, source); return 'executed'; - } catch (error) { this.report(error); return 'cancelled'; } + await this.submit(view, code, source); + return 'executed'; + } catch (error) { + this.report(error); + return 'cancelled'; + } }); this.refresh(); if (util.config().get('interactive.restore', true)) { @@ -226,71 +417,129 @@ export class InteractiveManager implements vscode.Disposable, vscode.TreeDataPro private async restoreConnections(): Promise { for (const id of this.savedConnections.keys()) { - if (this.closed) { return; } - const manifest = this.manifests.find(item => item.id === id); + if (this.closed) { + return; + } + const manifest = this.manifests.find((item) => item.id === id); if (manifest) { - try { await this.open(manifest); } - catch (error) { if (!this.closed) { this.output.appendLine(`Restore ${manifest.label}: ${String(error)}`); } } + try { + await this.open(manifest); + } catch (error) { + if (!this.closed) { + this.output.appendLine(`Restore ${manifest.label}: ${String(error)}`); + } + } } } // Restoring background tabs must not leave their workspace selected over // the visible Interactive input or a source file with an explicit binding. const document = vscode.window.activeTextEditor?.document; const target = document && session.boundSessionForDocument(document.uri); - const focused = [...this.views.values()].find(view => target - ? view.target === target : view.notebook === vscode.window.activeNotebookEditor?.notebook); - if (focused) { await this.activate(focused); } + const focused = [...this.views.values()].find((view) => + target + ? view.target === target + : view.notebook === vscode.window.activeNotebookEditor?.notebook, + ); + if (focused) { + await this.activate(focused); + } } private saveConnections(): Thenable { - return this.context.workspaceState.update('r.interactive.connections', [...this.savedConnections.values()]); + return this.context.workspaceState.update('r.interactive.connections', [ + ...this.savedConnections.values(), + ]); } private report(error: unknown): void { const message = error instanceof Error ? error.message : String(error); - this.output.appendLine(message); void vscode.window.showErrorMessage(`R Interactive: ${message}`); + this.output.appendLine(message); + void vscode.window.showErrorMessage(`R Interactive: ${message}`); } - private async forView(value: unknown, action: (view: InteractiveView) => unknown): Promise { + private async forView( + value: unknown, + action: (view: InteractiveView) => unknown, + ): Promise { let view = this.active; if (value && typeof value === 'object') { const target = value as Partial & { - notebook?: vscode.NotebookDocument; notebookUri?: vscode.Uri; uri?: vscode.Uri; + notebook?: vscode.NotebookDocument; + notebookUri?: vscode.Uri; + uri?: vscode.Uri; notebookEditor?: { notebookUri?: vscode.Uri }; }; if (typeof target.id === 'string' && typeof target.generation === 'string') { const key = `${target.id}:${target.generation}`; - if (!this.views.has(key)) { await this.open(target as SessionManifest); } + if (!this.views.has(key)) { + await this.open(target as SessionManifest); + } view = this.views.get(key); } else { // Native notebook/Interactive toolbars serialize their editor under notebookEditor. - const uri = value instanceof vscode.Uri ? value : target.notebookEditor?.notebookUri ?? target.notebook?.uri ?? target.notebookUri ?? target.uri; - view = uri && [...this.views.values()].find(item => item.notebook.uri.toString() === uri.toString()); + const uri = + value instanceof vscode.Uri + ? value + : (target.notebookEditor?.notebookUri ?? + target.notebook?.uri ?? + target.notebookUri ?? + target.uri); + view = + uri && + [...this.views.values()].find( + (item) => item.notebook.uri.toString() === uri.toString(), + ); + } + if (!view) { + throw new Error('This notebook is not connected to an R Interactive session'); } - if (!view) { throw new Error('This notebook is not connected to an R Interactive session'); } } - if (!view || view.disposed) { return; } + if (!view || view.disposed) { + return; + } return action(view); } private updateStatus(): void { for (const view of this.views.values()) { - const unavailable = view.restarting ? 'R is restarting. The workspace will refresh when it is ready.' - : ['exited', 'stopping'].includes(view.client.manifest.status) ? 'R session stopped. Restart it to inspect a new workspace.' - : !view.client.connected ? 'R session disconnected. Reconnecting to its workspace…' - : view.client.manifest.status === 'starting' ? 'R is starting…' : undefined; - if (view.target.label !== view.client.manifest.label || view.target.workspaceUnavailable !== unavailable) { + const unavailable = view.restarting + ? 'R is restarting. The workspace will refresh when it is ready.' + : ['exited', 'stopping'].includes(view.client.manifest.status) + ? 'R session stopped. Restart it to inspect a new workspace.' + : !view.client.connected + ? 'R session disconnected. Reconnecting to its workspace…' + : view.client.manifest.status === 'starting' + ? 'R is starting…' + : undefined; + if ( + view.target.label !== view.client.manifest.label || + view.target.workspaceUnavailable !== unavailable + ) { view.target.label = view.client.manifest.label; view.target.workspaceUnavailable = unavailable; session.updateSessionWorkspace(view.target, view.target.workspaceData); } - const { label, description, detail } = sessionPresentation(view.client.manifest, view.client.connected, view.client.control, - view.restarting, view.target.workingDir); + const { label, description, detail } = sessionPresentation( + view.client.manifest, + view.client.connected, + view.client.control, + view.restarting, + view.target.workingDir, + ); let changed = false; for (const controller of view.controllers) { - if (controller.label !== label) { controller.label = label; changed = true; } - if (controller.description !== description) { controller.description = description; changed = true; } - if (controller.detail !== detail) { controller.detail = detail; changed = true; } + if (controller.label !== label) { + controller.label = label; + changed = true; + } + if (controller.description !== description) { + controller.description = description; + changed = true; + } + if (controller.detail !== detail) { + controller.detail = detail; + changed = true; + } } if (changed && !this.presentationRefresh.has(view)) { this.presentationRefresh.add(view); @@ -301,32 +550,83 @@ export class InteractiveManager implements vscode.Disposable, vscode.TreeDataPro setImmediate(() => { this.presentationRefresh.delete(view); if (!view.disposed && !view.notebook.isClosed) { - view.controller.updateNotebookAffinity(view.notebook, vscode.NotebookControllerAffinity.Preferred); + view.controller.updateNotebookAffinity( + view.notebook, + vscode.NotebookControllerAffinity.Preferred, + ); } }); } } const document = vscode.window.activeTextEditor?.document; const target = document && session.boundSessionForDocument(document.uri); - const view = (target && [...this.views.values()].find(item => item.target === target)) || this.active; - if (!view || view.disposed) { this.status.hide(); return; } + const view = + (target && [...this.views.values()].find((item) => item.target === target)) || + this.active; + if (!view || view.disposed) { + this.status.hide(); + return; + } const manifest = view.client.manifest; - const queued = [...view.model.cells.values()].filter(cell => cell.record.state === 'queued').length; - const state = view.restarting ? 'Restarting' : !view.client.connected ? 'Disconnected' : manifest.status === 'input' ? 'Waiting for input' : manifest.status; - const icon = !view.client.connected ? 'debug-disconnect' : manifest.status === 'busy' ? 'sync~spin' : manifest.status === 'input' ? 'question' : 'terminal'; + const queued = [...view.model.cells.values()].filter( + (cell) => cell.record.state === 'queued', + ).length; + const state = view.restarting + ? 'Restarting' + : !view.client.connected + ? 'Disconnected' + : manifest.status === 'input' + ? 'Waiting for input' + : manifest.status; + const icon = !view.client.connected + ? 'debug-disconnect' + : manifest.status === 'busy' + ? 'sync~spin' + : manifest.status === 'input' + ? 'question' + : 'terminal'; this.status.text = `$(${icon}) R: ${manifest.label} · ${state}${queued ? ` · ${queued} queued` : ''}${view.client.connected && !view.client.control ? ' · observing' : ''}`; - this.status.tooltip = `${sessionPresentation(manifest, view.client.connected, view.client.control, - view.restarting, view.target.workingDir).tooltip}\nSelect to ${manifest.status === 'input' ? 'reply to R input' : 'switch sessions'}`; - this.status.command = manifest.status === 'input' - ? { command: 'r.interactive.input', title: 'Reply to R input', arguments: [manifest] } : 'r.interactive.connect'; + this.status.tooltip = `${ + sessionPresentation( + manifest, + view.client.connected, + view.client.control, + view.restarting, + view.target.workingDir, + ).tooltip + }\nSelect to ${manifest.status === 'input' ? 'reply to R input' : 'switch sessions'}`; + this.status.command = + manifest.status === 'input' + ? { + command: 'r.interactive.input', + title: 'Reply to R input', + arguments: [manifest], + } + : 'r.interactive.connect'; this.status.show(); } private async rename(view: InteractiveView, supplied?: string): Promise { - if (!view.client.manifest.capabilities.rename) { throw new Error('Renaming requires a session started with the current extension build'); } - const label = supplied ?? await vscode.window.showInputBox({ title: 'Rename R session', value: view.client.manifest.label, - validateInput: value => { try { sessionLabel(value); return undefined; } catch (error) { return String(error); } } }); - if (label === undefined) { return; } + if (!view.client.manifest.capabilities.rename) { + throw new Error('Renaming requires a session started with the current extension build'); + } + const label = + supplied ?? + (await vscode.window.showInputBox({ + title: 'Rename R session', + value: view.client.manifest.label, + validateInput: (value) => { + try { + sessionLabel(value); + return undefined; + } catch (error) { + return String(error); + } + }, + })); + if (label === undefined) { + return; + } view.client.manifest.label = await view.client.request('rename', { label }); this.refresh(); } @@ -347,41 +647,68 @@ export class InteractiveManager implements vscode.Disposable, vscode.TreeDataPro { label: '$(export) Export History…', command: 'r.interactive.export' }, { label: '$(discard) Clean Up Assets', command: 'r.interactive.cleanAssets' }, { label: 'Lifecycle', kind: vscode.QuickPickItemKind.Separator }, - { label: '$(debug-disconnect) Disconnect', description: 'Keep R running', command: 'r.interactive.detach' }, - { label: '$(debug-stop) Stop Session', description: 'End R and discard its in-memory objects', command: 'r.interactive.stop' }, + { + label: '$(debug-disconnect) Disconnect', + description: 'Keep R running', + command: 'r.interactive.detach', + }, + { + label: '$(debug-stop) Stop Session', + description: 'End R and discard its in-memory objects', + command: 'r.interactive.stop', + }, ]; const selected = await vscode.window.showQuickPick(actions, { - title: `Session: ${view.client.manifest.label}`, placeHolder: 'Choose a session action', matchOnDescription: true, + title: `Session: ${view.client.manifest.label}`, + placeHolder: 'Choose a session action', + matchOnDescription: true, }); // Keep the original notebook as the target even if focus changes while the picker is open. - if (selected?.command && !view.disposed) { await vscode.commands.executeCommand(selected.command, view.notebook.uri); } + if (selected?.command && !view.disposed) { + await vscode.commands.executeCommand(selected.command, view.notebook.uri); + } } private async cancelQueued(view: InteractiveView): Promise { - if (view.client.manifest.capabilities.cancelQueued) { await view.client.request('cancelQueued'); } - else { + if (view.client.manifest.capabilities.cancelQueued) { + await view.client.request('cancelQueued'); + } else { for (const cell of view.model.cells.values()) { - if (cell.record.state === 'queued') { await view.client.request('cancel', { id: cell.record.id }); } + if (cell.record.state === 'queued') { + await view.client.request('cancel', { id: cell.record.id }); + } } } } private async clearCompleted(view: InteractiveView): Promise { view.chain = view.chain.then(async () => { - if (view.disposed) { return; } + if (view.disposed) { + return; + } const edits: vscode.NotebookEdit[] = []; for (const cell of view.notebook.getCells().slice().reverse()) { const id = cell.metadata.rExecutionId as string | undefined; - const record = id && this.transcriptCells(view).find(cell => cell.record.id === id)?.record; + const record = + id && this.transcriptCells(view).find((cell) => cell.record.id === id)?.record; if (record && !['queued', 'running', 'unknown'].includes(record.state)) { view.hidden.add(record.id); const adjacent = edits.at(-1); if (adjacent?.range.start === cell.index + 1) { - edits[edits.length - 1] = vscode.NotebookEdit.deleteCells(new vscode.NotebookRange(cell.index, adjacent.range.end)); - } else { edits.push(vscode.NotebookEdit.deleteCells(new vscode.NotebookRange(cell.index, cell.index + 1))); } + edits[edits.length - 1] = vscode.NotebookEdit.deleteCells( + new vscode.NotebookRange(cell.index, adjacent.range.end), + ); + } else { + edits.push( + vscode.NotebookEdit.deleteCells( + new vscode.NotebookRange(cell.index, cell.index + 1), + ), + ); + } } } - const edit = new vscode.WorkspaceEdit(); edit.set(view.notebook.uri, edits); + const edit = new vscode.WorkspaceEdit(); + edit.set(view.notebook.uri, edits); await vscode.workspace.applyEdit(edit); }); await view.chain; @@ -389,7 +716,11 @@ export class InteractiveManager implements vscode.Disposable, vscode.TreeDataPro private async insertCode(view: InteractiveView, code: string): Promise { if (view.inputUri) { - await vscode.commands.executeCommand('interactive.open', { preserveFocus: false }, view.notebook.uri); + await vscode.commands.executeCommand( + 'interactive.open', + { preserveFocus: false }, + view.notebook.uri, + ); } else { await vscode.window.showNotebookDocument(view.notebook, { preserveFocus: false }); } @@ -397,105 +728,242 @@ export class InteractiveManager implements vscode.Disposable, vscode.TreeDataPro if (view.inputUri) { const document = await vscode.workspace.openTextDocument(view.inputUri); const current = document.getText(); - edit.insert(document.uri, document.positionAt(current.length), `${current && !current.endsWith('\n') ? '\n' : ''}${code}`); + edit.insert( + document.uri, + document.positionAt(current.length), + `${current && !current.endsWith('\n') ? '\n' : ''}${code}`, + ); } else { - edit.set(view.notebook.uri, [vscode.NotebookEdit.insertCells(view.notebook.cellCount, - [new vscode.NotebookCellData(vscode.NotebookCellKind.Code, code, 'r')])]); + edit.set(view.notebook.uri, [ + vscode.NotebookEdit.insertCells(view.notebook.cellCount, [ + new vscode.NotebookCellData(vscode.NotebookCellKind.Code, code, 'r'), + ]), + ]); } await vscode.workspace.applyEdit(edit); - if (view.inputUri) { await vscode.commands.executeCommand('interactive.input.focus'); } + if (view.inputUri) { + await vscode.commands.executeCommand('interactive.input.focus'); + } } private async source(source?: SourceLocation): Promise { - if (!source) { void vscode.window.showInformationMessage('This code was entered directly in the Interactive window.'); return; } + if (!source) { + void vscode.window.showInformationMessage( + 'This code was entered directly in the Interactive window.', + ); + return; + } const document = await vscode.workspace.openTextDocument(vscode.Uri.parse(source.uri)); const line = Math.min(source.line, document.lineCount - 1); - await vscode.window.showTextDocument(document, { selection: new vscode.Range(line, 0, line, 0) }); + await vscode.window.showTextDocument(document, { + selection: new vscode.Range(line, 0, line, 0), + }); } private history(view: InteractiveView): void { type Item = vscode.QuickPickItem & { record: ExecutionRecord }; const picker = vscode.window.createQuickPick(); - const older = { iconPath: new vscode.ThemeIcon('history'), tooltip: 'Load older matching commands' }; + const older = { + iconPath: new vscode.ThemeIcon('history'), + tooltip: 'Load older matching commands', + }; const copy = { iconPath: new vscode.ThemeIcon('copy'), tooltip: 'Copy code' }; - const run = { iconPath: new vscode.ThemeIcon('play'), tooltip: 'Run again in this session' }; + const run = { + iconPath: new vscode.ThemeIcon('play'), + tooltip: 'Run again in this session', + }; const source = { iconPath: new vscode.ThemeIcon('go-to-file'), tooltip: 'Go to source' }; picker.title = `R history: ${view.client.manifest.label}`; picker.placeholder = 'Search code; Enter inserts it into the input for editing'; - picker.matchOnDescription = true; picker.matchOnDetail = true; - let version = 0, closed = false, timer: NodeJS.Timeout | undefined; + picker.matchOnDescription = true; + picker.matchOnDetail = true; + let version = 0, + closed = false, + timer: NodeJS.Timeout | undefined; const load = async (more = false, request = ++version): Promise => { picker.busy = true; try { const before = more ? picker.items.at(-1)?.record.order : undefined; // Orders restart at one for each R process. Use a transcript-wide order for // history pagination after restart, including admission records outside replay. - const records = (): ExecutionRecord[] => view.history.length - ? [...view.history, view.model].flatMap(model => readExecutionRecords(path.join(this.root, view.client.manifest.id, model.generation))) - .map((record, index) => ({ ...record, order: index + 1 })) - : this.transcriptCells(view).map(cell => cell.record); - const page = !view.history.length && view.client.connected && view.client.manifest.capabilities.history - ? await view.client.request('history', { query: picker.value, before }) - : searchHistory(records(), picker.value, before); - if (closed || request !== version) { return; } - const items = page.executions.map(record => ({ record, alwaysShow: true, + const records = (): ExecutionRecord[] => + view.history.length + ? [...view.history, view.model] + .flatMap((model) => + readExecutionRecords( + path.join( + this.root, + view.client.manifest.id, + model.generation, + ), + ), + ) + .map((record, index) => ({ ...record, order: index + 1 })) + : this.transcriptCells(view).map((cell) => cell.record); + const page = + !view.history.length && + view.client.connected && + view.client.manifest.capabilities.history + ? await view.client.request('history', { + query: picker.value, + before, + }) + : searchHistory(records(), picker.value, before); + if (closed || request !== version) { + return; + } + const items = page.executions.map((record) => ({ + record, + alwaysShow: true, label: `#${record.order} ${record.code.split('\n')[0].slice(0, 100)}`, description: `${record.state} · ${new Date(record.accepted).toLocaleString()}`, - detail: record.code.slice(0, 600), buttons: record.source ? [copy, run, source] : [copy, run] })); + detail: record.code.slice(0, 600), + buttons: record.source ? [copy, run, source] : [copy, run], + })); picker.items = more ? [...picker.items, ...items] : items; picker.buttons = page.more ? [older] : []; } catch (error) { - if (!closed && request === version) { picker.placeholder = String(error); } - } finally { if (!closed && request === version) { picker.busy = false; } } + if (!closed && request === version) { + picker.placeholder = String(error); + } + } finally { + if (!closed && request === version) { + picker.busy = false; + } + } }; const listeners: vscode.Disposable[] = [ - picker.onDidChangeValue(() => { clearTimeout(timer); const request = ++version; timer = setTimeout(() => { void load(false, request); }, 150); }), - picker.onDidTriggerButton(() => { void load(true); }), - picker.onDidAccept(() => { const item = picker.selectedItems[0]; if (item) { picker.hide(); void this.insertCode(view, item.record.code).catch(error => this.report(error)); } }), - picker.onDidTriggerItemButton(event => { + picker.onDidChangeValue(() => { + clearTimeout(timer); + const request = ++version; + timer = setTimeout(() => { + void load(false, request); + }, 150); + }), + picker.onDidTriggerButton(() => { + void load(true); + }), + picker.onDidAccept(() => { + const item = picker.selectedItems[0]; + if (item) { + picker.hide(); + void this.insertCode(view, item.record.code).catch((error) => + this.report(error), + ); + } + }), + picker.onDidTriggerItemButton((event) => { const record = event.item.record; - if (event.button === copy) { void vscode.env.clipboard.writeText(record.code); } - else { picker.hide(); void (event.button === run ? this.submit(view, record.code, record.source) : this.source(record.source)).catch(error => this.report(error)); } + if (event.button === copy) { + void vscode.env.clipboard.writeText(record.code); + } else { + picker.hide(); + void ( + event.button === run + ? this.submit(view, record.code, record.source) + : this.source(record.source) + ).catch((error) => this.report(error)); + } + }), + picker.onDidHide(() => { + closed = true; + clearTimeout(timer); + listeners.forEach((listener) => { + listener.dispose(); + }); + picker.dispose(); }), - picker.onDidHide(() => { closed = true; clearTimeout(timer); listeners.forEach(listener => { listener.dispose(); }); picker.dispose(); }), ]; - picker.show(); void load(); + picker.show(); + void load(); } private async plots(view: InteractiveView): Promise { - const items = this.transcriptCells(view).reverse().flatMap(cell => cell.outputs.filter(output => - output.type === 'display' && ['plot', 'image'].includes(String(output.data.kind))).map((output, index) => ({ - label: `#${cell.record.order} · Plot ${index + 1}`, description: cell.record.code.split('\n')[0].slice(0, 120), - detail: new Date(cell.record.accepted).toLocaleString(), data: output.data, - }))); - if (!items.length) { void vscode.window.showInformationMessage('This session has no plots in its restored history.'); return; } - const selected = await vscode.window.showQuickPick(items, { title: `Plots: ${view.client.manifest.label}`, matchOnDescription: true }); - if (selected) { await this.openOutput(view, selected.data); } + const items = this.transcriptCells(view) + .reverse() + .flatMap((cell) => + cell.outputs + .filter( + (output) => + output.type === 'display' && + ['plot', 'image'].includes(String(output.data.kind)), + ) + .map((output, index) => ({ + label: `#${cell.record.order} · Plot ${index + 1}`, + description: cell.record.code.split('\n')[0].slice(0, 120), + detail: new Date(cell.record.accepted).toLocaleString(), + data: output.data, + })), + ); + if (!items.length) { + void vscode.window.showInformationMessage( + 'This session has no plots in its restored history.', + ); + return; + } + const selected = await vscode.window.showQuickPick(items, { + title: `Plots: ${view.client.manifest.label}`, + matchOnDescription: true, + }); + if (selected) { + await this.openOutput(view, selected.data); + } } private async openOutput(view: InteractiveView, data: Record): Promise { - let url = data.svg || data.asset ? view.base + String(data.svg ?? data.asset).split('/').map(encodeURIComponent).join('/') : (typeof data.url === 'string' ? data.url : ''); - if (/^https?:\/\/(localhost|127\.0\.0\.1)(:|\/)/.test(url)) { url = (await vscode.env.asExternalUri(vscode.Uri.parse(url))).toString(true); } - if (data.kind === 'image' && !url) { url = `data:${String(data.mime)};base64,${String(data.data)}`; } + let url = + data.svg || data.asset + ? view.base + + String(data.svg ?? data.asset) + .split('/') + .map(encodeURIComponent) + .join('/') + : typeof data.url === 'string' + ? data.url + : ''; + if (/^https?:\/\/(localhost|127\.0\.0\.1)(:|\/)/.test(url)) { + url = (await vscode.env.asExternalUri(vscode.Uri.parse(url))).toString(true); + } + if (data.kind === 'image' && !url) { + url = `data:${String(data.mime)};base64,${String(data.data)}`; + } const html = data.kind === 'mime' && data.mime === 'text/html'; - if (!url && !html) { return; } + if (!url && !html) { + return; + } const kind = ['plot', 'image'].includes(String(data.kind)) ? 'Plot' : 'Viewer'; - const panel = vscode.window.createWebviewPanel('rInteractiveOutput', `${kind}: ${view.client.manifest.label}`, vscode.ViewColumn.Beside, { enableScripts: true }); - panel.webview.html = `${['plot', 'image'].includes(String(data.kind)) - ? `R plot` - : ``}`; + const panel = vscode.window.createWebviewPanel( + 'rInteractiveOutput', + `${kind}: ${view.client.manifest.label}`, + vscode.ViewColumn.Beside, + { enableScripts: true }, + ); + panel.webview.html = `${ + ['plot', 'image'].includes(String(data.kind)) + ? `R plot` + : `` + }`; } private defaultExportUri(view: InteractiveView, suffix: string): vscode.Uri { - // eslint-disable-next-line no-control-regex -- export names must exclude control characters - const name = view.client.manifest.label.replace(/[<>:"/\\|?*\x00-\x1f]/g, '-').replace(/[. ]+$/, '') || 'R'; + const name = + view.client.manifest.label + // eslint-disable-next-line no-control-regex -- export names must exclude control characters + .replace(/[<>:"/\\|?*\x00-\x1f]/g, '-') + .replace(/[. ]+$/, '') || 'R'; return vscode.Uri.file(path.join(view.target.workingDir, `${name}-${suffix}`)); } refresh(): void { - try { this.manifests = discoverSessions(this.root).filter(hasSessionEndpoint); } - catch (error) { this.manifests = []; this.report(error); } - const manifests = new Map(this.manifests.map(manifest => [`${manifest.id}:${manifest.generation}`, manifest])); + try { + this.manifests = discoverSessions(this.root).filter(hasSessionEndpoint); + } catch (error) { + this.manifests = []; + this.report(error); + } + const manifests = new Map( + this.manifests.map((manifest) => [`${manifest.id}:${manifest.generation}`, manifest]), + ); for (const view of this.views.values()) { const manifest = view.client.manifest; manifests.set(`${manifest.id}:${manifest.generation}`, manifest); @@ -504,132 +972,297 @@ export class InteractiveManager implements vscode.Disposable, vscode.TreeDataPro this.changes.fire(undefined); this.updateStatus(); } - getChildren(): SessionManifest[] { return this.manifests; } + getChildren(): SessionManifest[] { + return this.manifests; + } getTreeItem(manifest: SessionManifest): vscode.TreeItem { const view = this.views.get(`${manifest.id}:${manifest.generation}`); manifest = view?.client.manifest ?? manifest; - const presentation = sessionPresentation(manifest, view?.client.connected, view?.client.control ?? false, - view?.restarting, view?.target.workingDir); + const presentation = sessionPresentation( + manifest, + view?.client.connected, + view?.client.control ?? false, + view?.restarting, + view?.target.workingDir, + ); const item = new vscode.TreeItem(manifest.label); - item.id = `${manifest.id}:${manifest.generation}`; item.description = `${presentation.state} · ${manifest.provider}`; + item.id = `${manifest.id}:${manifest.generation}`; + item.description = `${presentation.state} · ${manifest.provider}`; const age = sessionAge(manifest); - if (age) { item.description += ` · ${age}`; } + if (age) { + item.description += ` · ${age}`; + } item.contextValue = 'rInteractiveSession'; - if (view?.client.connected && !view.client.control) { item.description += ' · observing'; } + if (view?.client.connected && !view.client.control) { + item.description += ' · observing'; + } item.tooltip = presentation.tooltip; - item.iconPath = new vscode.ThemeIcon(manifest.status === 'busy' ? 'sync~spin' : manifest.status === 'exited' ? 'circle-outline' : 'terminal'); - item.command = { command: 'r.interactive.open', title: 'Open R Interactive', arguments: [manifest] }; + item.iconPath = new vscode.ThemeIcon( + manifest.status === 'busy' + ? 'sync~spin' + : manifest.status === 'exited' + ? 'circle-outline' + : 'terminal', + ); + item.command = { + command: 'r.interactive.open', + title: 'Open R Interactive', + arguments: [manifest], + }; return item; } - private async create(adopt?: ArfSession, resource = vscode.window.activeTextEditor?.document.uri): Promise { - if (!vscode.workspace.isTrusted) { throw new Error('Trust this workspace before starting or controlling R'); } - const directory = adopt?.cwd ?? vscode.workspace.getWorkspaceFolder(resource ?? vscode.Uri.file(this.root))?.uri.fsPath ?? vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? process.env.HOME ?? process.cwd(); + private async create( + adopt?: ArfSession, + resource = vscode.window.activeTextEditor?.document.uri, + ): Promise { + if (!vscode.workspace.isTrusted) { + throw new Error('Trust this workspace before starting or controlling R'); + } + const directory = + adopt?.cwd ?? + vscode.workspace.getWorkspaceFolder(resource ?? vscode.Uri.file(this.root))?.uri + .fsPath ?? + vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? + process.env.HOME ?? + process.cwd(); const rPath = await util.getRpath(false, resource); - if (!rPath) { throw new Error('Configure an R executable before starting Interactive'); } + if (!rPath) { + throw new Error('Configure an R executable before starting Interactive'); + } const arfCommand = this.arfCommand(resource); let arfPath = adopt ? undefined : resolveArfExecutable(arfCommand, directory); - const provider = adopt ? 'arf-existing' : await vscode.window.showQuickPick( - arfPath ? [{ label: 'arf', detail: arfPath, value: 'arf' as const }] - : [{ label: '$(gear) Configure arf…', description: 'Required for Interactive · executable unavailable', - detail: `Install arf on this host and set its path. Ordinary R terminals remain available. Checked: ${arfCommand}`, value: 'configure' as const }], - { title: 'R Interactive session provider' }); - if (!provider) { return; } + const provider = adopt + ? 'arf-existing' + : await vscode.window.showQuickPick( + arfPath + ? [{ label: 'arf', detail: arfPath, value: 'arf' as const }] + : [ + { + label: '$(gear) Configure arf…', + description: 'Required for Interactive · executable unavailable', + detail: `Install arf on this host and set its path. Ordinary R terminals remain available. Checked: ${arfCommand}`, + value: 'configure' as const, + }, + ], + { title: 'R Interactive session provider' }, + ); + if (!provider) { + return; + } const kind = typeof provider === 'string' ? provider : provider.value; - if (kind === 'configure') { await this.configureArf(); return; } - const label = await vscode.window.showInputBox({ title: 'Session name', value: adopt ? `arf ${adopt.pid}` : path.basename(directory) }); - if (!label) { return; } - if (kind === 'arf' && !(arfPath = this.checkArfExecutable(arfPath ?? arfCommand, directory))) { return; } + if (kind === 'configure') { + await this.configureArf(); + return; + } + const label = await vscode.window.showInputBox({ + title: 'Session name', + value: adopt ? `arf ${adopt.pid}` : path.basename(directory), + }); + if (!label) { + return; + } + if ( + kind === 'arf' && + !(arfPath = this.checkArfExecutable(arfPath ?? arfCommand, directory)) + ) { + return; + } const supervision = util.config(resource).get('interactive.supervision', 'auto'); prepareSupervisor(supervision, directory); const node = await this.nodeRuntime(directory, resource); - return vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: 'Starting persistent R Interactive' }, async progress => { - progress.report({ message: 'Preparing the private R runtime' }); - if (kind === 'arf' && !(arfPath = this.checkArfExecutable(arfPath ?? arfCommand, directory))) { return; } - const identity = newIdentity(); - let config: AgentConfig = { ...identity, label, directory, storage: path.join(this.root, identity.id), - rPath, provider: kind, - arfPath, arfEndpoint: adopt?.socket_path, - supervision, - plotBackend: util.config().get('interactive.plotBackend', 'auto'), - historyLimit: util.config().get('interactive.historyLimit', 100), - ...this.retentionLimits() }; - const runtime = await prepareBackendRuntime(config, { extensionPath: this.context.extensionPath, root: this.root, log: text => this.output.append(text) }); - config = runtime.config; - progress.report({ message: 'Launching the independent session agent' }); - const manifest = await launchAgent(config, runtime.agent, node, text => this.output.appendLine(text)); - this.refresh(); await this.open(manifest); - return this.views.get(`${manifest.id}:${manifest.generation}`); - }); + return vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: 'Starting persistent R Interactive', + }, + async (progress) => { + progress.report({ message: 'Preparing the private R runtime' }); + if ( + kind === 'arf' && + !(arfPath = this.checkArfExecutable(arfPath ?? arfCommand, directory)) + ) { + return; + } + const identity = newIdentity(); + let config: AgentConfig = { + ...identity, + label, + directory, + storage: path.join(this.root, identity.id), + rPath, + provider: kind, + arfPath, + arfEndpoint: adopt?.socket_path, + supervision, + plotBackend: util + .config() + .get('interactive.plotBackend', 'auto'), + historyLimit: util.config().get('interactive.historyLimit', 100), + ...this.retentionLimits(), + }; + const runtime = await prepareBackendRuntime(config, { + extensionPath: this.context.extensionPath, + root: this.root, + log: (text) => this.output.append(text), + }); + config = runtime.config; + progress.report({ message: 'Launching the independent session agent' }); + const manifest = await launchAgent(config, runtime.agent, node, (text) => + this.output.appendLine(text), + ); + this.refresh(); + await this.open(manifest); + return this.views.get(`${manifest.id}:${manifest.generation}`); + }, + ); } - private retentionLimits(): Required> { + private retentionLimits(): Required< + Pick + > { const settings = util.config(); const mib = 1024 * 1024; // Settings use MiB; the agent and its saved configuration use whole bytes. return { - maxOutputBytes: Math.floor(settings.get('interactive.maxOutputSizeMiB', 4) * mib), - maxAssetBytes: Math.floor(settings.get('interactive.maxAssetSizeMiB', DEFAULT_MAX_ASSET_BYTES / mib) * mib), - maxJournalBytes: Math.floor(settings.get('interactive.maxJournalSizeMiB', 128) * mib), + maxOutputBytes: Math.floor( + settings.get('interactive.maxOutputSizeMiB', 4) * mib, + ), + maxAssetBytes: Math.floor( + settings.get('interactive.maxAssetSizeMiB', DEFAULT_MAX_ASSET_BYTES / mib) * + mib, + ), + maxJournalBytes: Math.floor( + settings.get('interactive.maxJournalSizeMiB', 128) * mib, + ), }; } private arfCommand(resource?: vscode.Uri): string { - return util.substituteVariables(util.config(resource).get('interactive.arfPath', 'arf'), resource).trim() || 'arf'; + return ( + util + .substituteVariables( + util.config(resource).get('interactive.arfPath', 'arf'), + resource, + ) + .trim() || 'arf' + ); } - private nodeRuntime(directory: string, resource = vscode.Uri.file(directory)): Promise { - const command = util.substituteVariables(util.config(resource).get('interactive.nodePath', ''), resource); + private nodeRuntime( + directory: string, + resource = vscode.Uri.file(directory), + ): Promise { + const command = util.substituteVariables( + util.config(resource).get('interactive.nodePath', ''), + resource, + ); return prepareNodeRuntime(directory, resolveNodeRuntime(command, directory)); } private async configureArf(): Promise { - await vscode.commands.executeCommand('workbench.action.openSettings', 'r.interactive.arfPath'); + await vscode.commands.executeCommand( + 'workbench.action.openSettings', + 'r.interactive.arfPath', + ); } - private checkArfExecutable(command: string, directory: string, restarting = false): string | undefined { + private checkArfExecutable( + command: string, + directory: string, + restarting = false, + ): string | undefined { const executable = resolveArfExecutable(command, directory); - if (executable) { return executable; } + if (executable) { + return executable; + } // A nonmodal setup notification must not keep the view in Restarting or // hold the source execution target chooser until the user dismisses it. - void Promise.resolve(vscode.window.showWarningMessage( - `Cannot ${restarting ? 'restart' : 'start'} Headless arf: “${command}” was not found or is not executable on this host. ` + - 'Install arf or set r.interactive.arfPath to its executable. Plain R sessions do not require arf.' + - (restarting ? ' The current R session has not been stopped.' : ''), 'Configure arf')).then(async action => { - if (action === 'Configure arf') { await this.configureArf(); } - }).catch(error => this.report(error)); + void Promise.resolve( + vscode.window.showWarningMessage( + `Cannot ${restarting ? 'restart' : 'start'} Headless arf: “${command}” was not found or is not executable on this host. ` + + 'Install arf or set r.interactive.arfPath to its executable. Plain R sessions do not require arf.' + + (restarting ? ' The current R session has not been stopped.' : ''), + 'Configure arf', + ), + ) + .then(async (action) => { + if (action === 'Configure arf') { + await this.configureArf(); + } + }) + .catch((error) => this.report(error)); } private async sessionChoices(): Promise { this.refresh(); const live = await runningSessions(this.manifests.filter(hasSessionEndpoint)); const terminals: ArfSession[] = []; - const arfCandidates = discoverArf().filter(arf => fs.existsSync(arf.socket_path) && !live.some(item => item.rPid === arf.pid)); + const arfCandidates = discoverArf().filter( + (arf) => fs.existsSync(arf.socket_path) && !live.some((item) => item.rPid === arf.pid), + ); for (let i = 0; i < arfCandidates.length; i += 8) { - const batch = await Promise.all(arfCandidates.slice(i, i + 8).map(arf => probeArfSession(arf))); - for (const arf of batch) { if (arf) { terminals.push(arf); } } + const batch = await Promise.all( + arfCandidates.slice(i, i + 8).map((arf) => probeArfSession(arf)), + ); + for (const arf of batch) { + if (arf) { + terminals.push(arf); + } + } } return [ - ...live.map(manifest => ({ label: manifest.label, + ...live.map((manifest) => ({ + label: manifest.label, description: this.sessionChoiceDescription(manifest), - detail: `${manifest.directory} · ${manifest.id.slice(0, 8)}`, manifest })), - ...terminals.map(arf => - ({ label: `arf ${arf.pid}`, description: `${arf.r_version ?? ''} · ${arf.cwd ?? ''}`, arf })), + detail: `${manifest.directory} · ${manifest.id.slice(0, 8)}`, + manifest, + })), + ...terminals.map((arf) => ({ + label: `arf ${arf.pid}`, + description: `${arf.r_version ?? ''} · ${arf.cwd ?? ''}`, + arf, + })), { label: '$(add) New persistent R session', create: true }, ]; } - private async chooseExecutionTarget(resource?: vscode.Uri): Promise { - const choices = this.sessionChoices().then(items => [ - { label: '$(add) New R Interactive window', description: 'Start a persistent R session', create: true }, - ...(util.config(resource).get('interactive.executionTarget') === 'interactive' ? [] - : [{ label: '$(terminal) Create R terminal', description: 'Run R in the Terminal pane', terminal: true }]), - ...items.filter(item => !item.create), - ] as SessionChoice[]); - const selected = await vscode.window.showQuickPick(choices, { title: 'Run R code', - placeHolder: 'Choose where to run the selected code', matchOnDescription: true, matchOnDetail: true }); - if (selected?.terminal) { return 'createTerminal'; } - if (selected?.create || selected?.arf) { return this.create(selected.arf, resource); } + private async chooseExecutionTarget( + resource?: vscode.Uri, + ): Promise { + const choices = this.sessionChoices().then( + (items) => + [ + { + label: '$(add) New R Interactive window', + description: 'Start a persistent R session', + create: true, + }, + ...(util.config(resource).get('interactive.executionTarget') === + 'interactive' + ? [] + : [ + { + label: '$(terminal) Create R terminal', + description: 'Run R in the Terminal pane', + terminal: true, + }, + ]), + ...items.filter((item) => !item.create), + ] as SessionChoice[], + ); + const selected = await vscode.window.showQuickPick(choices, { + title: 'Run R code', + placeHolder: 'Choose where to run the selected code', + matchOnDescription: true, + matchOnDetail: true, + }); + if (selected?.terminal) { + return 'createTerminal'; + } + if (selected?.create || selected?.arf) { + return this.create(selected.arf, resource); + } if (selected?.manifest) { await this.open(selected.manifest); return this.views.get(`${selected.manifest.id}:${selected.manifest.generation}`); @@ -643,59 +1276,109 @@ export class InteractiveManager implements vscode.Disposable, vscode.TreeDataPro private async pick(): Promise { // Passing a promise keeps the picker responsive while local agents are checked. - const selected = await vscode.window.showQuickPick(this.sessionChoices(), { title: 'Connect to a persistent R session', - placeHolder: 'Select a running session or create a new one', matchOnDescription: true, matchOnDetail: true }); - if (selected?.manifest) { await this.open(selected.manifest); } - else if (selected?.arf) { await this.create(selected.arf); } - else if (selected?.create) { await this.create(); } + const selected = await vscode.window.showQuickPick(this.sessionChoices(), { + title: 'Connect to a persistent R session', + placeHolder: 'Select a running session or create a new one', + matchOnDescription: true, + matchOnDetail: true, + }); + if (selected?.manifest) { + await this.open(selected.manifest); + } else if (selected?.arf) { + await this.create(selected.arf); + } else if (selected?.create) { + await this.create(); + } } async open(manifest: SessionManifest): Promise { const key = `${manifest.id}:${manifest.generation}`; const pending = this.opening.get(key); - if (pending) { return pending; } + if (pending) { + return pending; + } const opening = this.openView(manifest); this.opening.set(key, opening); - try { await opening; } finally { this.opening.delete(key); } + try { + await opening; + } finally { + this.opening.delete(key); + } } private async openView(manifest: SessionManifest): Promise { - if (!vscode.workspace.isTrusted) { throw new Error('Trust this workspace before connecting to R'); } + if (!vscode.workspace.isTrusted) { + throw new Error('Trust this workspace before connecting to R'); + } const key = `${manifest.id}:${manifest.generation}`; const previous = this.views.get(key); - if (previous && !previous.notebook.isClosed && (previous.client.connected || previous.restarting)) { + if ( + previous && + !previous.notebook.isClosed && + (previous.client.connected || previous.restarting) + ) { if (previous.notebook.notebookType === 'interactive') { - await vscode.commands.executeCommand('interactive.open', { preserveFocus: true }, previous.notebook.uri); + await vscode.commands.executeCommand( + 'interactive.open', + { preserveFocus: true }, + previous.notebook.uri, + ); } else { - const editor = vscode.window.visibleNotebookEditors.find(item => item.notebook === previous.notebook); - await vscode.window.showNotebookDocument(previous.notebook, { preserveFocus: true, viewColumn: editor?.viewColumn }); + const editor = vscode.window.visibleNotebookEditors.find( + (item) => item.notebook === previous.notebook, + ); + await vscode.window.showNotebookDocument(previous.notebook, { + preserveFocus: true, + viewColumn: editor?.viewColumn, + }); } - await this.activate(previous); return; + await this.activate(previous); + return; + } + if (previous) { + this.disposeView(previous, false); } - if (previous) { this.disposeView(previous, false); } const client = new AgentClient(manifest, this.clientId); - const created: vscode.Disposable[] = [ { dispose: () => client.close() } ]; + const created: vscode.Disposable[] = [{ dispose: () => client.close() }]; try { - try { await client.connect(); } - catch (error) { - if (['ENOENT', 'ECONNREFUSED'].includes((error as NodeJS.ErrnoException).code ?? '')) { + try { + await client.connect(); + } catch (error) { + if ( + ['ENOENT', 'ECONNREFUSED'].includes((error as NodeJS.ErrnoException).code ?? '') + ) { this.refresh(); - throw new Error(`Session “${manifest.label}” is no longer available. Refresh the session list or start a new session. Its saved history has not been removed.`, { cause: error }); + throw new Error( + `Session “${manifest.label}” is no longer available. Refresh the session list or start a new session. Its saved history has not been removed.`, + { cause: error }, + ); } throw error; } - if (this.closed) { throw new Error('Interactive manager was closed during connection'); } + if (this.closed) { + throw new Error('Interactive manager was closed during connection'); + } const makeController = (type: string): vscode.NotebookController => { - const controller = vscode.notebooks.createNotebookController(`r-${manifest.id}-${manifest.generation}-${type}`, type, `R: ${manifest.label}`); - controller.supportedLanguages = ['r']; controller.supportsExecutionOrder = true; - controller.executeHandler = cells => { - if (cells.some(cell => cell.notebook !== view.notebook)) { - this.report(new Error('This kernel belongs to another Interactive window. Connect this notebook to its original session before running it.')); + const controller = vscode.notebooks.createNotebookController( + `r-${manifest.id}-${manifest.generation}-${type}`, + type, + `R: ${manifest.label}`, + ); + controller.supportedLanguages = ['r']; + controller.supportsExecutionOrder = true; + controller.executeHandler = (cells) => { + if (cells.some((cell) => cell.notebook !== view.notebook)) { + this.report( + new Error( + 'This kernel belongs to another Interactive window. Connect this notebook to its original session before running it.', + ), + ); return; } - return this.executeCells(view, cells).catch(error => this.report(error)); + return this.executeCells(view, cells).catch((error) => this.report(error)); }; - controller.interruptHandler = () => view.client.request('interrupt').catch(error => this.report(error)); + controller.interruptHandler = () => + view.client.request('interrupt').catch((error) => this.report(error)); created.push(controller); return controller; }; @@ -708,89 +1391,184 @@ export class InteractiveManager implements vscode.Disposable, vscode.TreeDataPro const rememberedUri = this.savedConnections.get(manifest.id)?.notebookUri; // Native Interactive reuses numbered URIs after tabs close. A saved URI // can consequently belong to a different, newer session. - const occupied = (uri: string): boolean => [...this.views.values()].some(view => - !view.disposed && view.client.manifest.id !== manifest.id && view.notebook.uri.toString() === uri) || - vscode.workspace.notebookDocuments.some(document => document.uri.toString() === uri && - document.metadata.rSessionId && document.metadata.rSessionId !== manifest.id); + const occupied = (uri: string): boolean => + [...this.views.values()].some( + (view) => + !view.disposed && + view.client.manifest.id !== manifest.id && + view.notebook.uri.toString() === uri, + ) || + vscode.workspace.notebookDocuments.some( + (document) => + document.uri.toString() === uri && + document.metadata.rSessionId && + document.metadata.rSessionId !== manifest.id, + ); const savedUri = rememberedUri && !occupied(rememberedUri) ? rememberedUri : undefined; - const existing = vscode.workspace.notebookDocuments.find(document => - document.metadata.rSessionId === manifest.id || - (!document.metadata.rSessionId && document.uri.toString() === savedUri)); + const existing = vscode.workspace.notebookDocuments.find( + (document) => + document.metadata.rSessionId === manifest.id || + (!document.metadata.rSessionId && document.uri.toString() === savedUri), + ); try { - if (existing?.notebookType === 'r-interactive') { throw new Error('Restoring an R Interactive notebook'); } - const result = await vscode.commands.executeCommand<{ notebookUri: vscode.Uri; inputUri: vscode.Uri; notebookEditor?: vscode.NotebookEditor }>( - 'interactive.open', { viewColumn: vscode.ViewColumn.Beside, preserveFocus: true }, + if (existing?.notebookType === 'r-interactive') { + throw new Error('Restoring an R Interactive notebook'); + } + const result = await vscode.commands.executeCommand<{ + notebookUri: vscode.Uri; + inputUri: vscode.Uri; + notebookEditor?: vscode.NotebookEditor; + }>( + 'interactive.open', + { viewColumn: vscode.ViewColumn.Beside, preserveFocus: true }, existing?.uri ?? (savedUri ? vscode.Uri.parse(savedUri) : undefined), - `${this.context.extension.id}/${native.id}`, `R: ${manifest.label}`); - if (!result?.notebookUri || !result.inputUri) { throw new Error('Interactive Window returned no document'); } - notebook = await vscode.workspace.openNotebookDocument(result.notebookUri); inputUri = result.inputUri; + `${this.context.extension.id}/${native.id}`, + `R: ${manifest.label}`, + ); + if (!result?.notebookUri || !result.inputUri) { + throw new Error('Interactive Window returned no document'); + } + notebook = await vscode.workspace.openNotebookDocument(result.notebookUri); + inputUri = result.inputUri; notebookEditor = result.notebookEditor; } catch (error) { this.output.appendLine(`Using notebook fallback: ${String(error)}`); controller = fallback; - notebook = existing?.notebookType === 'r-interactive' ? existing : await vscode.workspace.openNotebookDocument('r-interactive', new vscode.NotebookData([])); - fallback.updateNotebookAffinity(notebook, vscode.NotebookControllerAffinity.Preferred); - notebookEditor = await vscode.window.showNotebookDocument(notebook, { preserveFocus: true }); + notebook = + existing?.notebookType === 'r-interactive' + ? existing + : await vscode.workspace.openNotebookDocument( + 'r-interactive', + new vscode.NotebookData([]), + ); + fallback.updateNotebookAffinity( + notebook, + vscode.NotebookControllerAffinity.Preferred, + ); + notebookEditor = await vscode.window.showNotebookDocument(notebook, { + preserveFocus: true, + }); + } + if (this.closed) { + throw new Error('Interactive manager was closed during connection'); } - if (this.closed) { throw new Error('Interactive manager was closed during connection'); } const target = this.registerTarget(client); - session.bindSessionDocument(vscode.Uri.from({ scheme: 'vscode-notebook-cell', path: notebook.uri.path }), target); - const view: InteractiveView = { client, target, model: new Transcript(manifest.generation), history: this.readHistory(manifest), notebook, controller, - controllers: [native, fallback], inputUri, executions: new Map(), chain: Promise.resolve(), - pending: new Set(), disposed: false, prompts: new Set(), base: '', hidden: new Set() }; + session.bindSessionDocument( + vscode.Uri.from({ scheme: 'vscode-notebook-cell', path: notebook.uri.path }), + target, + ); + const view: InteractiveView = { + client, + target, + model: new Transcript(manifest.generation), + history: this.readHistory(manifest), + notebook, + controller, + controllers: [native, fallback], + inputUri, + executions: new Map(), + chain: Promise.resolve(), + pending: new Set(), + disposed: false, + prompts: new Set(), + base: '', + hidden: new Set(), + }; this.views.set(key, view); - controller.updateNotebookAffinity(notebook, vscode.NotebookControllerAffinity.Preferred); + controller.updateNotebookAffinity( + notebook, + vscode.NotebookControllerAffinity.Preferred, + ); // A restored native tab may still have the previous generation's kernel selected. if (notebookEditor) { await vscode.commands.executeCommand('notebook.selectKernel', { - id: controller.id, extension: this.context.extension.id, notebookEditor, + id: controller.id, + extension: this.context.extension.id, + notebookEditor, }); } if (inputUri) { session.bindSessionDocument(inputUri, target); - await vscode.languages.setTextDocumentLanguage(await vscode.workspace.openTextDocument(inputUri), 'r'); + await vscode.languages.setTextDocumentLanguage( + await vscode.workspace.openTextDocument(inputUri), + 'r', + ); } - const restoring = notebook === existing || !!savedUri && notebook.uri.toString() === savedUri; + const restoring = + notebook === existing || (!!savedUri && notebook.uri.toString() === savedUri); if (!restoring && inputUri) { // A recycled native input model may still contain the previous tab's draft. const input = await vscode.workspace.openTextDocument(inputUri); if (input.getText()) { const edit = new vscode.WorkspaceEdit(); - edit.replace(inputUri, new vscode.Range(input.positionAt(0), input.positionAt(input.getText().length)), ''); + edit.replace( + inputUri, + new vscode.Range( + input.positionAt(0), + input.positionAt(input.getText().length), + ), + '', + ); await vscode.workspace.applyEdit(edit); } } await this.restore(view, await client.snapshot(), restoring); - if (this.closed || view.disposed) { throw new Error('Interactive manager was closed during connection'); } + if (this.closed || view.disposed) { + throw new Error('Interactive manager was closed during connection'); + } this.listen(view, client); - if (!await client.subscribe(view.model.seq)) { await this.restore(view, await client.snapshot()); await client.subscribe(view.model.seq); } + if (!(await client.subscribe(view.model.seq))) { + await this.restore(view, await client.snapshot()); + await client.subscribe(view.model.seq); + } await this.activate(view); for (const [id, connection] of this.savedConnections) { if (id !== manifest.id && connection.notebookUri === notebook.uri.toString()) { this.savedConnections.set(id, { id }); } } - this.savedConnections.set(manifest.id, { id: manifest.id, notebookUri: notebook.uri.toString() }); + this.savedConnections.set(manifest.id, { + id: manifest.id, + notebookUri: notebook.uri.toString(), + }); await this.saveConnections(); } catch (error) { const view = this.views.get(key); - if (view) { this.disposeView(view, false); } - else { created.forEach(item => { item.dispose(); }); } + if (view) { + this.disposeView(view, false); + } else { + created.forEach((item) => { + item.dispose(); + }); + } throw error; } } private registerTarget(client: AgentClient): session.Session { const manifest = client.manifest; - const target = session.registerSessionTransport(`${manifest.id}:${manifest.generation}`, manifest.host, manifest.directory, data => - client.request('inspect', { method: data.method, params: data.params ?? {} }, data.method === 'hover' || data.method === 'completion' ? 250 : 6000)); - target.pid = String(manifest.rPid ?? ''); target.rVer = manifest.rVersion ?? ''; + const target = session.registerSessionTransport( + `${manifest.id}:${manifest.generation}`, + manifest.host, + manifest.directory, + (data) => + client.request( + 'inspect', + { method: data.method, params: data.params ?? {} }, + data.method === 'hover' || data.method === 'completion' ? 250 : 6000, + ), + ); + target.pid = String(manifest.rPid ?? ''); + target.rVer = manifest.rVersion ?? ''; target.processExited = manifest.status === 'exited'; - target.rPath = manifest.rPath; target.libraryPaths = manifest.libraryPaths; + target.rPath = manifest.rPath; + target.libraryPaths = manifest.libraryPaths; target.label = manifest.label; - target.execute = async code => { - const view = [...this.views.values()].find(item => item.target === target); - if (!view || view.disposed) { throw new Error('This Interactive session is no longer attached.'); } + target.execute = async (code) => { + const view = [...this.views.values()].find((item) => item.target === target); + if (!view || view.disposed) { + throw new Error('This Interactive session is no longer attached.'); + } await this.submit(view, code); }; return target; @@ -798,47 +1576,92 @@ export class InteractiveManager implements vscode.Disposable, vscode.TreeDataPro private listen(view: InteractiveView, client: AgentClient): void { client.on('event', (event: SessionEvent) => { - if (view.client !== client) { return; } - view.chain = view.chain.then(() => this.event(view, event)).catch(error => this.report(error)); + if (view.client !== client) { + return; + } + view.chain = view.chain + .then(() => this.event(view, event)) + .catch((error) => this.report(error)); + }); + client.on('activity', () => { + if (view.client === client) { + this.updateStatus(); + this.changes.fire(undefined); + } }); - client.on('activity', () => { if (view.client === client) { this.updateStatus(); this.changes.fire(undefined); } }); client.on('disconnect', () => { - if (view.client !== client || view.restarting) { return; } - this.updateStatus(); this.changes.fire(undefined); this.reconnect(view, 1000); + if (view.client !== client || view.restarting) { + return; + } + this.updateStatus(); + this.changes.fire(undefined); + this.reconnect(view, 1000); }); } private readHistory(manifest: SessionManifest): Transcript[] { const storage = path.join(this.root, manifest.id); const file = path.join(storage, 'config.json'); - if (!fs.existsSync(file)) { return []; } + if (!fs.existsSync(file)) { + return []; + } const config = JSON.parse(fs.readFileSync(file, 'utf8')) as AgentConfig; - return (config.previousGenerations ?? []).filter(generation => generation !== manifest.generation).map(generation => { - const model = new Transcript(generation); - model.restore({ manifest: { ...manifest, generation }, ...readPreviousJournal(storage, generation, config.historyLimit) }); - return model; - }); + return (config.previousGenerations ?? []) + .filter((generation) => generation !== manifest.generation) + .map((generation) => { + const model = new Transcript(generation); + model.restore({ + manifest: { ...manifest, generation }, + ...readPreviousJournal(storage, generation, config.historyLimit), + }); + return model; + }); } private transcriptCells(view: InteractiveView): TranscriptCell[] { - return [...view.history, view.model].flatMap(model => [...model.cells.values()]); + return [...view.history, view.model].flatMap((model) => [...model.cells.values()]); } - private async restore(view: InteractiveView, snapshot: AgentSnapshot, preserveDrafts = true): Promise { + private async restore( + view: InteractiveView, + snapshot: AgentSnapshot, + preserveDrafts = true, + ): Promise { const previousCells = preserveDrafts ? view.notebook.getCells() : []; - for (const { task } of view.executions.values()) { task.end(undefined); } + for (const { task } of view.executions.values()) { + task.end(undefined); + } view.executions.clear(); - for (const cell of view.notebook.getCells()) { session.unbindSessionDocument(cell.document.uri); } + for (const cell of view.notebook.getCells()) { + session.unbindSessionDocument(cell.document.uri); + } view.client.manifest = snapshot.manifest; - view.target.pid = String(snapshot.manifest.rPid ?? ''); view.target.rVer = snapshot.manifest.rVersion ?? ''; + view.target.pid = String(snapshot.manifest.rPid ?? ''); + view.target.rVer = snapshot.manifest.rVersion ?? ''; view.target.processExited = snapshot.manifest.status === 'exited'; - view.target.rPath = snapshot.manifest.rPath; view.target.libraryPaths = snapshot.manifest.libraryPaths; - view.base = snapshot.manifest.assetBase ? (await vscode.env.asExternalUri(vscode.Uri.parse(snapshot.manifest.assetBase))).toString(true) : ''; + view.target.rPath = snapshot.manifest.rPath; + view.target.libraryPaths = snapshot.manifest.libraryPaths; + view.base = snapshot.manifest.assetBase + ? ( + await vscode.env.asExternalUri(vscode.Uri.parse(snapshot.manifest.assetBase)) + ).toString(true) + : ''; view.model.restore(snapshot); - const metadata = new Map(previousCells.map(cell => [cell, restoredCellMetadata(cell.metadata)])); - const edited = new Map(previousCells.map(cell => [metadata.get(cell)!.rExecutionId as string, cell.document.getText()])); - const previousNotice = previousCells.find(cell => metadata.get(cell)!.rSessionNotice === snapshot.manifest.generation); - if (snapshot.workspace) { view.target.workspaceData = snapshot.workspace as unknown as session.WorkspaceData; } + const metadata = new Map( + previousCells.map((cell) => [cell, restoredCellMetadata(cell.metadata)]), + ); + const edited = new Map( + previousCells.map((cell) => [ + metadata.get(cell)!.rExecutionId as string, + cell.document.getText(), + ]), + ); + const previousNotice = previousCells.find( + (cell) => metadata.get(cell)!.rSessionNotice === snapshot.manifest.generation, + ); + if (snapshot.workspace) { + view.target.workspaceData = snapshot.workspace as unknown as session.WorkspaceData; + } const cells: vscode.NotebookCellData[] = []; for (const model of [...view.history, view.model]) { for (const cell of model.cells.values()) { @@ -848,46 +1671,86 @@ export class InteractiveManager implements vscode.Disposable, vscode.TreeDataPro cells.push(data); } } - if (model !== view.model) { cells.push(this.noticeData(view, 'restarted', model.generation)); } + if (model !== view.model) { + cells.push(this.noticeData(view, 'restarted', model.generation)); + } } - if (snapshot.manifest.status === 'exited') { cells.push(this.noticeData(view, 'stopped')); } - else if (previousNotice) { - const notice = new vscode.NotebookCellData(vscode.NotebookCellKind.Markup, previousNotice.document.getText(), 'markdown'); - notice.metadata = persistentCellMetadata(metadata.get(previousNotice)!, 'markdown'); cells.push(notice); + if (snapshot.manifest.status === 'exited') { + cells.push(this.noticeData(view, 'stopped')); + } else if (previousNotice) { + const notice = new vscode.NotebookCellData( + vscode.NotebookCellKind.Markup, + previousNotice.document.getText(), + 'markdown', + ); + notice.metadata = persistentCellMetadata(metadata.get(previousNotice)!, 'markdown'); + cells.push(notice); } // Group drafts by their next retained execution or notice, then merge // once. Identity lookups avoid source matching and repeated array edits. - const executions = new Map(cells.map(cell => [cell.metadata?.rExecutionId, cell])); - const notices = new Map(cells.map(cell => [cell.metadata?.rSessionNotice, cell])); + const executions = new Map(cells.map((cell) => [cell.metadata?.rExecutionId, cell])); + const notices = new Map(cells.map((cell) => [cell.metadata?.rSessionNotice, cell])); const draftsBefore = new Map(); let pendingDrafts: vscode.NotebookCellData[] = []; let draftCount = 0; for (const cell of previousCells) { const data = metadata.get(cell)!; if (cell.kind === vscode.NotebookCellKind.Code && !data.rExecutionId) { - pendingDrafts.push(new vscode.NotebookCellData(cell.kind, cell.document.getText(), cell.document.languageId)); + pendingDrafts.push( + new vscode.NotebookCellData( + cell.kind, + cell.document.getText(), + cell.document.languageId, + ), + ); draftCount++; } else { - const anchor = data.rExecutionId ? executions.get(data.rExecutionId) - : data.rSessionNotice ? notices.get(data.rSessionNotice) : undefined; + const anchor = data.rExecutionId + ? executions.get(data.rExecutionId) + : data.rSessionNotice + ? notices.get(data.rSessionNotice) + : undefined; if (anchor && pendingDrafts.length) { - draftsBefore.set(anchor, pendingDrafts); pendingDrafts = []; + draftsBefore.set(anchor, pendingDrafts); + pendingDrafts = []; } } } - const restoredCells = cells.flatMap(cell => [...draftsBefore.get(cell) ?? [], cell]).concat(pendingDrafts); - if (view.notebook.notebookType === 'r-interactive' && !draftCount) { restoredCells.push(new vscode.NotebookCellData(vscode.NotebookCellKind.Code, '', 'r')); } + const restoredCells = cells + .flatMap((cell) => [...(draftsBefore.get(cell) ?? []), cell]) + .concat(pendingDrafts); + if (view.notebook.notebookType === 'r-interactive' && !draftCount) { + restoredCells.push(new vscode.NotebookCellData(vscode.NotebookCellKind.Code, '', 'r')); + } const edit = new vscode.WorkspaceEdit(); - edit.set(view.notebook.uri, [vscode.NotebookEdit.replaceCells(new vscode.NotebookRange(0, view.notebook.cellCount), restoredCells), - vscode.NotebookEdit.updateNotebookMetadata(persistentNotebookMetadata(view.notebook.metadata, snapshot.manifest.id, snapshot.manifest.generation))]); + edit.set(view.notebook.uri, [ + vscode.NotebookEdit.replaceCells( + new vscode.NotebookRange(0, view.notebook.cellCount), + restoredCells, + ), + vscode.NotebookEdit.updateNotebookMetadata( + persistentNotebookMetadata( + view.notebook.metadata, + snapshot.manifest.id, + snapshot.manifest.generation, + ), + ), + ]); await vscode.workspace.applyEdit(edit); - for (const cell of view.notebook.getCells()) { session.bindSessionDocument(cell.document.uri, view.target); } - if (snapshot.input && view.client.control) { void this.prompt(view, snapshot.input); } + for (const cell of view.notebook.getCells()) { + session.bindSessionDocument(cell.document.uri, view.target); + } + if (snapshot.input && view.client.control) { + void this.prompt(view, snapshot.input); + } } private async activate(view: InteractiveView): Promise { - if (view.disposed) { return; } - this.active = view; this.routing = true; + if (view.disposed) { + return; + } + this.active = view; + this.routing = true; ensureWorkspaceViewer(); this.updateStatus(); // Select synchronously before any command round trips; the latest focus wins. @@ -895,151 +1758,279 @@ export class InteractiveManager implements vscode.Disposable, vscode.TreeDataPro await vscode.commands.executeCommand('setContext', 'r.WorkspaceViewer:show', true); await activated; await vscode.commands.executeCommand('setContext', 'r.interactive.active', true); - await vscode.commands.executeCommand('setContext', 'r.interactive.notebook', vscode.window.activeNotebookEditor?.notebook === view.notebook); + await vscode.commands.executeCommand( + 'setContext', + 'r.interactive.notebook', + vscode.window.activeNotebookEditor?.notebook === view.notebook, + ); this.updateStatus(); } private cell(view: InteractiveView, id: string): vscode.NotebookCell | undefined { - return view.notebook.getCells().find(cell => cell.metadata.rExecutionId === id); + return view.notebook.getCells().find((cell) => cell.metadata.rExecutionId === id); } - private async submit(view: InteractiveView, code: string, source?: SourceLocation): Promise { - if (!code.trim()) { return; } - if (view.restarting) { throw new Error('R is restarting. Wait for the restart notice before running code.'); } - if (!view.client.control) { throw new Error('This window is observing R. Run R: Take Control of Interactive Session first.'); } + private async submit( + view: InteractiveView, + code: string, + source?: SourceLocation, + ): Promise { + if (!code.trim()) { + return; + } + if (view.restarting) { + throw new Error('R is restarting. Wait for the restart notice before running code.'); + } + if (!view.client.control) { + throw new Error( + 'This window is observing R. Run R: Take Control of Interactive Session first.', + ); + } const data = new vscode.NotebookCellData(vscode.NotebookCellKind.Code, code, 'r'); - const id = randomUUID(); data.metadata = persistentCellMetadata({ rExecutionId: id, rSource: source }, 'r'); + const id = randomUUID(); + data.metadata = persistentCellMetadata({ rExecutionId: id, rSource: source }, 'r'); const edit = new vscode.WorkspaceEdit(); - edit.set(view.notebook.uri, [vscode.NotebookEdit.insertCells(view.notebook.cellCount, [data])]); + edit.set(view.notebook.uri, [ + vscode.NotebookEdit.insertCells(view.notebook.cellCount, [data]), + ]); await vscode.workspace.applyEdit(edit); const cell = this.cell(view, id); - if (cell) { await this.executeCells(view, [cell]); } + if (cell) { + await this.executeCells(view, [cell]); + } } - private async executeCells(view: InteractiveView, cells: readonly vscode.NotebookCell[]): Promise { - if (!view || view.disposed) { return; } - if (view.restarting) { throw new Error('R is restarting. Wait for the restart notice before running code.'); } - if (['exited', 'stopping'].includes(view.client.manifest.status)) { throw new Error('This R session has stopped. Restart it or choose another session to run code.'); } - if (!view.client.control) { throw new Error('This window is observing R. Run R: Take Control of Interactive Session first.'); } + private async executeCells( + view: InteractiveView, + cells: readonly vscode.NotebookCell[], + ): Promise { + if (!view || view.disposed) { + return; + } + if (view.restarting) { + throw new Error('R is restarting. Wait for the restart notice before running code.'); + } + if (['exited', 'stopping'].includes(view.client.manifest.status)) { + throw new Error( + 'This R session has stopped. Restart it or choose another session to run code.', + ); + } + if (!view.client.control) { + throw new Error( + 'This window is observing R. Run R: Take Control of Interactive Session first.', + ); + } for (const cell of cells) { - const code = cell.document.getText(); if (!code.trim()) { continue; } + const code = cell.document.getText(); + if (!code.trim()) { + continue; + } // Re-running creates a new execution identity, even if the same cell is selected. const previousId = cell.metadata.rExecutionId as string | undefined; - if (previousId && this.transcriptCells(view).some(cell => cell.record.id === previousId)) { + if ( + previousId && + this.transcriptCells(view).some((cell) => cell.record.id === previousId) + ) { await this.submit(view, code, cell.metadata.rSource as SourceLocation | undefined); continue; } const id = previousId && !view.model.cells.has(previousId) ? previousId : randomUUID(); const source = cell.metadata.rSource as SourceLocation | undefined; const edit = new vscode.WorkspaceEdit(); - edit.set(view.notebook.uri, [vscode.NotebookEdit.updateCellMetadata(cell.index, - persistentCellMetadata({ ...cell.metadata, rExecutionId: id }, 'r'))]); + edit.set(view.notebook.uri, [ + vscode.NotebookEdit.updateCellMetadata( + cell.index, + persistentCellMetadata({ ...cell.metadata, rExecutionId: id }, 'r'), + ), + ]); await vscode.workspace.applyEdit(edit); session.bindSessionDocument(cell.document.uri, view.target); const task = view.controller.createNotebookCellExecution(cell); view.executions.set(id, { task, started: false }); - try { await view.client.request('submit', { submission: { id, code, source } }); } - catch (error) { + try { + await view.client.request('submit', { submission: { id, code, source } }); + } catch (error) { // Query by the same ID; never resubmit code after an ambiguous transport failure. this.output.appendLine(`Submission ${id}: ${String(error)}`); - if (view.executions.get(id)?.task === task) { task.end(undefined); view.executions.delete(id); } + if (view.executions.get(id)?.task === task) { + task.end(undefined); + view.executions.delete(id); + } throw error; } } } private async event(view: InteractiveView, event: SessionEvent): Promise { - if (view.disposed || event.generation !== view.model.generation || event.seq <= view.model.seq) { return; } + if ( + view.disposed || + event.generation !== view.model.generation || + event.seq <= view.model.seq + ) { + return; + } const changed = view.model.apply(event); this.updateStatus(); if (event.type === 'state') { view.client.manifest.status = event.data.status as SessionManifest['status']; view.target.processExited = event.data.status === 'exited'; - view.client.manifest.rPid = typeof event.data.rPid === 'number' ? event.data.rPid : undefined; - view.client.manifest.rVersion = typeof event.data.rVersion === 'string' ? event.data.rVersion : undefined; - view.client.manifest.ended = typeof event.data.ended === 'number' ? event.data.ended : undefined; - if (event.data.capabilities) { view.client.manifest.capabilities = object(event.data.capabilities) as Record; } - if (typeof event.data.rPath === 'string') { view.client.manifest.rPath = event.data.rPath; view.target.rPath = event.data.rPath; } - if (Array.isArray(event.data.libraryPaths)) { view.client.manifest.libraryPaths = event.data.libraryPaths.map(String); view.target.libraryPaths = view.client.manifest.libraryPaths; } - if (typeof event.data.runtimeSessionId === 'string') { view.client.manifest.runtimeSessionId = event.data.runtimeSessionId; } - view.target.pid = typeof event.data.rPid === 'number' ? String(event.data.rPid) : ''; view.target.rVer = typeof event.data.rVersion === 'string' ? event.data.rVersion : ''; + view.client.manifest.rPid = + typeof event.data.rPid === 'number' ? event.data.rPid : undefined; + view.client.manifest.rVersion = + typeof event.data.rVersion === 'string' ? event.data.rVersion : undefined; + view.client.manifest.ended = + typeof event.data.ended === 'number' ? event.data.ended : undefined; + if (event.data.capabilities) { + view.client.manifest.capabilities = object(event.data.capabilities) as Record< + string, + boolean + >; + } + if (typeof event.data.rPath === 'string') { + view.client.manifest.rPath = event.data.rPath; + view.target.rPath = event.data.rPath; + } + if (Array.isArray(event.data.libraryPaths)) { + view.client.manifest.libraryPaths = event.data.libraryPaths.map(String); + view.target.libraryPaths = view.client.manifest.libraryPaths; + } + if (typeof event.data.runtimeSessionId === 'string') { + view.client.manifest.runtimeSessionId = event.data.runtimeSessionId; + } + view.target.pid = typeof event.data.rPid === 'number' ? String(event.data.rPid) : ''; + view.target.rVer = typeof event.data.rVersion === 'string' ? event.data.rVersion : ''; this.refresh(); if (event.data.status === 'exited' && !view.restarting) { await this.sessionNotice(view, 'stopped'); for (const cell of this.transcriptCells(view)) { - if (cell.outputs.some(output => output.type === 'display')) { await this.render(view, cell.record.id); } + if (cell.outputs.some((output) => output.type === 'display')) { + await this.render(view, cell.record.id); + } } } } else if (event.type === 'session' && typeof event.data.label === 'string') { view.client.manifest.label = event.data.label; this.refresh(); } else if (event.type === 'workspace') { - session.updateSessionWorkspace(view.target, event.data as unknown as session.WorkspaceData); + session.updateSessionWorkspace( + view.target, + event.data as unknown as session.WorkspaceData, + ); } else if (event.type === 'input' && view.client.control) { void this.prompt(view, event.data); } else if (event.type === 'clientRequest' && view.client.control) { void this.clientRequest(view, event.data); } else if (event.type === 'viewer' && event.data.method === 'dataview') { const params = object(event.data.params ?? {}); - const title = typeof params.title === 'string' ? params.title - : Array.isArray(params.title) && params.title.every((line: unknown) => typeof line === 'string') - ? params.title.join(',') : undefined; - const viewer = util.config().get>('session.viewers.viewColumn')?.view ?? 'Two'; - if (viewer !== 'Disable' - && typeof params.source === 'string' && params.source - && typeof params.type === 'string' && params.type - && title - && (params.file === undefined || params.file === null || typeof params.file === 'string') - && (params.view_id === undefined || params.view_id === null || typeof params.view_id === 'string')) { + const title = + typeof params.title === 'string' + ? params.title + : Array.isArray(params.title) && + params.title.every((line: unknown) => typeof line === 'string') + ? params.title.join(',') + : undefined; + const viewer = + util.config().get>('session.viewers.viewColumn')?.view ?? + 'Two'; + if ( + viewer !== 'Disable' && + typeof params.source === 'string' && + params.source && + typeof params.type === 'string' && + params.type && + title && + (params.file === undefined || + params.file === null || + typeof params.file === 'string') && + (params.view_id === undefined || + params.view_id === null || + typeof params.view_id === 'string') + ) { await session.showDataView( - params.source, params.type, title, params.file ?? '', viewer, + params.source, + params.type, + title, + params.file ?? '', + viewer, params.view_id || undefined, params.navigation as ListViewNavigation | undefined, view.target.sessionId, - typeof params.state_generation === 'number' ? params.state_generation : undefined, + typeof params.state_generation === 'number' + ? params.state_generation + : undefined, ); } } else if (event.type === 'notification') { const params = object(event.data.params ?? {}); - if (event.data.method === 'help') { await session.showHelpNotification(params); } + if (event.data.method === 'help') { + await session.showHelpNotification(params); + } if (event.data.method === 'rstudioapi/send_to_console') { - if (params.execute) { await this.submit(view, String(params.code)); } - else if (view.inputUri) { + if (params.execute) { + await this.submit(view, String(params.code)); + } else if (view.inputUri) { const document = await vscode.workspace.openTextDocument(view.inputUri); - const edit = new vscode.WorkspaceEdit(); edit.insert(document.uri, document.positionAt(document.getText().length), String(params.code)); + const edit = new vscode.WorkspaceEdit(); + edit.insert( + document.uri, + document.positionAt(document.getText().length), + String(params.code), + ); await vscode.workspace.applyEdit(edit); } } } else if (event.type === 'agentError' || event.type === 'agentWarning') { this.output.appendLine(String(event.data.message)); } - if (!changed || view.hidden.has(changed)) { return; } + if (!changed || view.hidden.has(changed)) { + return; + } if (!this.cell(view, changed)) { const data = await this.cellData(view, view.model.cells.get(changed)!); - const edit = new vscode.WorkspaceEdit(); edit.set(view.notebook.uri, [vscode.NotebookEdit.insertCells(view.notebook.cellCount, [data])]); + const edit = new vscode.WorkspaceEdit(); + edit.set(view.notebook.uri, [ + vscode.NotebookEdit.insertCells(view.notebook.cellCount, [data]), + ]); await vscode.workspace.applyEdit(edit); } if (event.type === 'started') { let execution = view.executions.get(changed); if (!execution) { try { - execution = { task: view.controller.createNotebookCellExecution(this.cell(view, changed)!), started: false }; + execution = { + task: view.controller.createNotebookCellExecution( + this.cell(view, changed)!, + ), + started: false, + }; view.executions.set(changed, execution); + } catch { + /* Restored/observed execution can still display its retained state. */ } - catch { /* Restored/observed execution can still display its retained state. */ } } if (execution) { execution.task.executionOrder = Number(event.data.order); - if (!execution.started) { execution.task.start(event.time); execution.started = true; } + if (!execution.started) { + execution.task.start(event.time); + execution.started = true; + } } } if (event.type === 'finished' || event.type === 'uncertain') { view.pending.delete(changed); - try { await this.render(view, changed); } - finally { + try { + await this.render(view, changed); + } finally { const execution = view.executions.get(changed); if (execution) { - execution.task.end(event.data.state === 'success' ? true : event.data.state === 'error' ? false : undefined, event.time); + execution.task.end( + event.data.state === 'success' + ? true + : event.data.state === 'error' + ? false + : undefined, + event.time, + ); view.executions.delete(changed); } } @@ -1048,32 +2039,73 @@ export class InteractiveManager implements vscode.Disposable, vscode.TreeDataPro if (!view.timer) { view.timer = setTimeout(() => { view.timer = undefined; - const ids = [...view.pending]; view.pending.clear(); - view.chain = view.chain.then(async () => { for (const id of ids) { await this.render(view, id); } }).catch(error => this.report(error)); + const ids = [...view.pending]; + view.pending.clear(); + view.chain = view.chain + .then(async () => { + for (const id of ids) { + await this.render(view, id); + } + }) + .catch((error) => this.report(error)); }, 40); } } } - private async cellData(view: InteractiveView, cell: TranscriptCell, portable = false): Promise { - const data = new vscode.NotebookCellData(vscode.NotebookCellKind.Code, cell.record.code, 'r'); - data.metadata = persistentCellMetadata({ rExecutionId: cell.record.id, rGeneration: cell.generation, - rSource: cell.record.source, rState: cell.record.state, execution_count: cell.record.order }, 'r'); - data.executionSummary = { executionOrder: cell.record.order, - success: cell.record.state === 'success' ? true : cell.record.state === 'error' ? false : undefined, - timing: cell.record.started && cell.record.ended ? { startTime: cell.record.started, endTime: cell.record.ended } : undefined }; + private async cellData( + view: InteractiveView, + cell: TranscriptCell, + portable = false, + ): Promise { + const data = new vscode.NotebookCellData( + vscode.NotebookCellKind.Code, + cell.record.code, + 'r', + ); + data.metadata = persistentCellMetadata( + { + rExecutionId: cell.record.id, + rGeneration: cell.generation, + rSource: cell.record.source, + rState: cell.record.state, + execution_count: cell.record.order, + }, + 'r', + ); + data.executionSummary = { + executionOrder: cell.record.order, + success: + cell.record.state === 'success' + ? true + : cell.record.state === 'error' + ? false + : undefined, + timing: + cell.record.started && cell.record.ended + ? { startTime: cell.record.started, endTime: cell.record.ended } + : undefined, + }; data.outputs = await this.outputs(view, cell, portable); return data; } - private async outputs(view: InteractiveView, cell: TranscriptCell, portable = false): Promise { + private async outputs( + view: InteractiveView, + cell: TranscriptCell, + portable = false, + ): Promise { const outputs: vscode.NotebookCellOutput[] = []; const plots: { output: vscode.NotebookCellOutput; data: Record }[] = []; for (const output of cell.outputs) { const data = output.data; let items: vscode.NotebookCellOutputItem[]; if (output.type === 'stream') { - items = [data.channel === 'stderr' ? vscode.NotebookCellOutputItem.stderr(String(data.text)) : vscode.NotebookCellOutputItem.stdout(String(data.text))]; + items = [ + data.channel === 'stderr' + ? vscode.NotebookCellOutputItem.stderr(String(data.text)) + : vscode.NotebookCellOutputItem.stdout(String(data.text)), + ]; } else if (output.type === 'condition') { if (data.kind === 'error') { const message = String(data.message); @@ -1081,52 +2113,113 @@ export class InteractiveManager implements vscode.Disposable, vscode.TreeDataPro // Include the heading so nested calls cannot hide the cause. const trace = Array.isArray(data.trace) ? data.trace.join('\n') : ''; const stack = `R error: ${message}${trace ? `\n${trace}` : ''}`; - items = [vscode.NotebookCellOutputItem.error({ name: 'R error', message, stack })]; + items = [ + vscode.NotebookCellOutputItem.error({ name: 'R error', message, stack }), + ]; } else { - items = [vscode.NotebookCellOutputItem.stderr(`${String(data.kind)}: ${String(data.message)}\n`)]; + items = [ + vscode.NotebookCellOutputItem.stderr( + `${String(data.kind)}: ${String(data.message)}\n`, + ), + ]; } - } else if (output.type === 'truncated') { items = [vscode.NotebookCellOutputItem.text(String(data.message))]; } - else if (data.kind === 'mime' && data.mime !== 'text/html') { + } else if (output.type === 'truncated') { + items = [vscode.NotebookCellOutputItem.text(String(data.message))]; + } else if (data.kind === 'mime' && data.mime !== 'text/html') { items = [vscode.NotebookCellOutputItem.text(String(data.text), String(data.mime))]; } else { - const display: Record = { ...data, generation: cell.generation, - archived: cell.generation !== view.model.generation, connected: view.client.connected, - running: !['exited', 'stopping'].includes(view.client.manifest.status) }; - const preference = this.outputViews.get(`${view.client.manifest.id}:${cell.generation}:${String(data.displayId)}`); - if (data.kind === 'list') { display.listView = preference?.listView ?? 'list'; } - if (data.kind === 'table') { display.tableView = preference?.tableView ?? util.config().get('interactive.tableView', 'table'); } - if (data.svg || data.asset) { display.url = view.base + String(data.svg ?? data.asset).split('/').map(encodeURIComponent).join('/'); } - else if (typeof data.url === 'string' && /^https?:\/\/(localhost|127\.0\.0\.1)(:|\/)/.test(data.url)) { - display.url = (await vscode.env.asExternalUri(vscode.Uri.parse(data.url))).toString(true); + const display: Record = { + ...data, + generation: cell.generation, + archived: cell.generation !== view.model.generation, + connected: view.client.connected, + running: !['exited', 'stopping'].includes(view.client.manifest.status), + }; + const preference = this.outputViews.get( + `${view.client.manifest.id}:${cell.generation}:${String(data.displayId)}`, + ); + if (data.kind === 'list') { + display.listView = preference?.listView ?? 'list'; + } + if (data.kind === 'table') { + display.tableView = + preference?.tableView ?? + util.config().get('interactive.tableView', 'table'); } - if (data.kind === 'mime' && data.mime === 'text/html') { display.kind = 'htmlText'; } - items = [vscode.NotebookCellOutputItem.json(display, DISPLAY_MIME), - vscode.NotebookCellOutputItem.text(typeof data.printedText === 'string' ? data.printedText : data.kind === 'table' ? `${String(data.totalRows)} rows\n${JSON.stringify(data.rows, null, 2)}` : data.kind === 'list' ? JSON.stringify(data.children, null, 2) : `R ${String(data.kind)} output`)]; + if (data.svg || data.asset) { + display.url = + view.base + + String(data.svg ?? data.asset) + .split('/') + .map(encodeURIComponent) + .join('/'); + } else if ( + typeof data.url === 'string' && + /^https?:\/\/(localhost|127\.0\.0\.1)(:|\/)/.test(data.url) + ) { + display.url = ( + await vscode.env.asExternalUri(vscode.Uri.parse(data.url)) + ).toString(true); + } + if (data.kind === 'mime' && data.mime === 'text/html') { + display.kind = 'htmlText'; + } + items = [ + vscode.NotebookCellOutputItem.json(display, DISPLAY_MIME), + vscode.NotebookCellOutputItem.text( + typeof data.printedText === 'string' + ? data.printedText + : data.kind === 'table' + ? `${String(data.totalRows)} rows\n${JSON.stringify(data.rows, null, 2)}` + : data.kind === 'list' + ? JSON.stringify(data.children, null, 2) + : `R ${String(data.kind)} output`, + ), + ]; if (typeof data.svg === 'string') { try { - const svg = portable ? readAsset(path.join(this.root, view.client.manifest.id, 'assets'), data.svg).toString('base64') + const svg = portable + ? readAsset( + path.join(this.root, view.client.manifest.id, 'assets'), + data.svg, + ).toString('base64') : await view.client.request('asset', { id: data.svg }); display.svgData = svg; items[0] = vscode.NotebookCellOutputItem.json(display, DISPLAY_MIME); - items.push(new vscode.NotebookCellOutputItem(Buffer.from(svg, 'base64'), 'image/svg+xml')); - } - catch (error) { - if (portable) { throw error; } + items.push( + new vscode.NotebookCellOutputItem( + Buffer.from(svg, 'base64'), + 'image/svg+xml', + ), + ); + } catch (error) { + if (portable) { + throw error; + } // The live renderer can load a larger SVG directly from its asset URL. } } if (data.kind === 'image') { - const bytes = typeof data.asset === 'string' - ? readAsset(path.join(this.root, view.client.manifest.id, 'assets'), data.asset) - : Buffer.from(String(data.data), 'base64'); - display.kind = 'plot'; display.imageData = bytes.toString('base64'); + const bytes = + typeof data.asset === 'string' + ? readAsset( + path.join(this.root, view.client.manifest.id, 'assets'), + data.asset, + ) + : Buffer.from(String(data.data), 'base64'); + display.kind = 'plot'; + display.imageData = bytes.toString('base64'); display.resizable = false; items[0] = vscode.NotebookCellOutputItem.json(display, DISPLAY_MIME); items.push(new vscode.NotebookCellOutputItem(bytes, String(data.mime))); } if (data.kind === 'plot' || data.kind === 'image') { - const plotOutput = new vscode.NotebookCellOutput(items, { rDisplayId: data.displayId }); - plots.push({ output: plotOutput, data: display }); outputs.push(plotOutput); continue; + const plotOutput = new vscode.NotebookCellOutput(items, { + rDisplayId: data.displayId, + }); + plots.push({ output: plotOutput, data: display }); + outputs.push(plotOutput); + continue; } } outputs.push(new vscode.NotebookCellOutput(items, { rDisplayId: data.displayId })); @@ -1134,261 +2227,609 @@ export class InteractiveManager implements vscode.Disposable, vscode.TreeDataPro if (plots.length > 1) { const first = plots[0]; const key = `${view.client.manifest.id}:${cell.generation}:${String(first.data.displayId)}`; - const gallery = { kind: 'plot', displayId: first.data.displayId, generation: cell.generation, - connected: view.client.connected, archived: cell.generation !== view.model.generation, + const gallery = { + kind: 'plot', + displayId: first.data.displayId, + generation: cell.generation, + connected: view.client.connected, + archived: cell.generation !== view.model.generation, running: !['exited', 'stopping'].includes(view.client.manifest.status), - pages: plots.map(plot => plot.data), selectedPlot: this.outputViews.get(key)?.selectedPlot }; + pages: plots.map((plot) => plot.data), + selectedPlot: this.outputViews.get(key)?.selectedPlot, + }; // Keep the gallery at the first plot's position. Other output retains // its order, and the journal keeps each page as an independent asset. - const grouped = new vscode.NotebookCellOutput([vscode.NotebookCellOutputItem.json(gallery, DISPLAY_MIME), - vscode.NotebookCellOutputItem.text(`${plots.length} R plot pages`), - ...first.output.items.filter(item => item.mime.startsWith('image/'))], first.output.metadata); - const plotOutputs = new Set(plots.map(plot => plot.output)); - return outputs.flatMap(output => output === first.output ? [grouped] : plotOutputs.has(output) ? [] : [output]); + const grouped = new vscode.NotebookCellOutput( + [ + vscode.NotebookCellOutputItem.json(gallery, DISPLAY_MIME), + vscode.NotebookCellOutputItem.text(`${plots.length} R plot pages`), + ...first.output.items.filter((item) => item.mime.startsWith('image/')), + ], + first.output.metadata, + ); + const plotOutputs = new Set(plots.map((plot) => plot.output)); + return outputs.flatMap((output) => + output === first.output ? [grouped] : plotOutputs.has(output) ? [] : [output], + ); } return outputs; } private async render(view: InteractiveView, id: string): Promise { - if (view.disposed || view.notebook.isClosed) { return; } - const data = this.transcriptCells(view).find(cell => cell.record.id === id); - if (!data || !this.cell(view, id)) { return; } + if (view.disposed || view.notebook.isClosed) { + return; + } + const data = this.transcriptCells(view).find((cell) => cell.record.id === id); + if (!data || !this.cell(view, id)) { + return; + } // Admission can precede R's start event by an arbitrary queue/startup delay. // Keep VS Code's execution pending; output mutation requires start(). - if (data.record.state === 'queued' || (view.executions.get(id)?.started === false && !data.outputs.length)) { return; } + if ( + data.record.state === 'queued' || + (view.executions.get(id)?.started === false && !data.outputs.length) + ) { + return; + } const outputs = await this.outputs(view, data); // Asset reads can outlive detach or reconnection. Re-read the live handle. - if (view.disposed || view.notebook.isClosed) { return; } + if (view.disposed || view.notebook.isClosed) { + return; + } const cell = this.cell(view, id); - if (!cell) { return; } + if (!cell) { + return; + } const execution = view.executions.get(id); if (execution) { // A dispatch failure may have diagnostics but no R start event. - if (!execution.started) { execution.task.start(data.record.started); execution.started = true; } + if (!execution.started) { + execution.task.start(data.record.started); + execution.started = true; + } await execution.task.replaceOutput(outputs); - } - else { + } else { // Updating a late plot must preserve the cell URI, edited code, selection, and diagnostics. const summary = cell.executionSummary; const update = view.controller.createNotebookCellExecution(cell); update.executionOrder = summary?.executionOrder; update.start(summary?.timing?.startTime); - try { await update.replaceOutput(outputs); } - finally { update.end(summary?.success, summary?.timing?.endTime); } + try { + await update.replaceOutput(outputs); + } finally { + update.end(summary?.success, summary?.timing?.endTime); + } } } private async prompt(view: InteractiveView, input: Record): Promise { const client = view.client; const id = Number(input.inputId); - if (view.prompts.has(id) || view.disposed) { return; } + if (view.prompts.has(id) || view.disposed) { + return; + } view.prompts.add(id); - const value = await vscode.window.showInputBox({ title: `${view.client.manifest.label}: R input`, prompt: typeof input.prompt === 'string' ? input.prompt : 'Input', ignoreFocusOut: true }); - if (view.client !== client) { return; } + const value = await vscode.window.showInputBox({ + title: `${view.client.manifest.label}: R input`, + prompt: typeof input.prompt === 'string' ? input.prompt : 'Input', + ignoreFocusOut: true, + }); + if (view.client !== client) { + return; + } view.prompts.delete(id); if (value !== undefined && view.client === client && !view.restarting) { - try { await view.client.request('input', { id, executionId: input.executionId, value }); } - catch (error) { this.report(error); } + try { + await view.client.request('input', { id, executionId: input.executionId, value }); + } catch (error) { + this.report(error); + } } } private async resumeInput(view: InteractiveView): Promise { - const snapshot = await view.client.snapshot(1); if (snapshot.input) { await this.prompt(view, snapshot.input); } + const snapshot = await view.client.snapshot(1); + if (snapshot.input) { + await this.prompt(view, snapshot.input); + } } - private async clientRequest(view: InteractiveView, data: Record): Promise { + private async clientRequest( + view: InteractiveView, + data: Record, + ): Promise { const client = view.client; const params = object(data.params ?? {}); try { const result = await session.handleEditorRequest(String(data.method), params); await client.request('clientReply', { id: data.id, result }); - } catch (error) { await client.request('clientReply', { id: data.id, error: String(error) }).catch(() => undefined); } + } catch (error) { + await client + .request('clientReply', { id: data.id, error: String(error) }) + .catch(() => undefined); + } } - private async rendererMessage(editor: vscode.NotebookEditor, message: Record): Promise { - const view = [...this.views.values()].find(item => item.notebook === editor.notebook); - if (!view) { return; } - const cells = this.transcriptCells(view).filter(cell => message.generation ? cell.generation === message.generation : cell.generation === view.model.generation); - const owner = cells.find(cell => cell.outputs.some(item => item.type === 'display' && item.data.displayId === message.displayId)); - const output = owner?.outputs.find(item => item.type === 'display' && item.data.displayId === message.displayId); - if (!owner || !output) { return; } + private async rendererMessage( + editor: vscode.NotebookEditor, + message: Record, + ): Promise { + const view = [...this.views.values()].find((item) => item.notebook === editor.notebook); + if (!view) { + return; + } + const cells = this.transcriptCells(view).filter((cell) => + message.generation + ? cell.generation === message.generation + : cell.generation === view.model.generation, + ); + const owner = cells.find((cell) => + cell.outputs.some( + (item) => item.type === 'display' && item.data.displayId === message.displayId, + ), + ); + const output = owner?.outputs.find( + (item) => item.type === 'display' && item.data.displayId === message.displayId, + ); + if (!owner || !output) { + return; + } const data = output.data; try { - if ((message.action === 'tableView' && data.kind === 'table' && ['table', 'text'].includes(String(message.mode))) || - (message.action === 'listView' && data.kind === 'list' && ['list', 'text'].includes(String(message.mode))) || - (message.action === 'plotPage' && ['plot', 'image'].includes(String(data.kind)) && owner?.outputs.some(item => item.type === 'display' && - ['plot', 'image'].includes(String(item.data.kind)) && item.data.displayId === message.selectedPlot))) { + if ( + (message.action === 'tableView' && + data.kind === 'table' && + ['table', 'text'].includes(String(message.mode))) || + (message.action === 'listView' && + data.kind === 'list' && + ['list', 'text'].includes(String(message.mode))) || + (message.action === 'plotPage' && + ['plot', 'image'].includes(String(data.kind)) && + owner?.outputs.some( + (item) => + item.type === 'display' && + ['plot', 'image'].includes(String(item.data.kind)) && + item.data.displayId === message.selectedPlot, + )) + ) { const key = `${view.client.manifest.id}:${owner.generation}:${String(data.displayId)}`; - const preference = message.action === 'tableView' ? { tableView: String(message.mode) } : message.action === 'listView' ? { listView: String(message.mode) } : { selectedPlot: String(message.selectedPlot) }; - this.outputViews.delete(key); this.outputViews.set(key, preference); - while (this.outputViews.size > 1000) { this.outputViews.delete(this.outputViews.keys().next().value as string); } - await this.context.workspaceState.update('r.interactive.outputViews', [...this.outputViews]); + const preference = + message.action === 'tableView' + ? { tableView: String(message.mode) } + : message.action === 'listView' + ? { listView: String(message.mode) } + : { selectedPlot: String(message.selectedPlot) }; + this.outputViews.delete(key); + this.outputViews.set(key, preference); + while (this.outputViews.size > 1000) { + this.outputViews.delete(this.outputViews.keys().next().value as string); + } + await this.context.workspaceState.update('r.interactive.outputViews', [ + ...this.outputViews, + ]); return; } - if (['table', 'page', 'list', 'listPage', 'listNavigate', 'listItem', 'resize'].includes(String(message.action)) && ['exited', 'stopping'].includes(view.client.manifest.status)) { - throw new Error('R has stopped. Start a new session and run the code again to use live controls.'); + if ( + [ + 'table', + 'page', + 'list', + 'listPage', + 'listNavigate', + 'listItem', + 'resize', + ].includes(String(message.action)) && + ['exited', 'stopping'].includes(view.client.manifest.status) + ) { + throw new Error( + 'R has stopped. Start a new session and run the code again to use live controls.', + ); } - if (['table', 'page', 'list', 'listPage', 'listNavigate', 'listItem', 'resize'].includes(String(message.action)) && (view.restarting || owner?.generation !== view.model.generation)) { - throw new Error('This output belongs to a previous R process. Run its code again to use live controls.'); + if ( + [ + 'table', + 'page', + 'list', + 'listPage', + 'listNavigate', + 'listItem', + 'resize', + ].includes(String(message.action)) && + (view.restarting || owner?.generation !== view.model.generation) + ) { + throw new Error( + 'This output belongs to a previous R process. Run its code again to use live controls.', + ); } let result: unknown; switch (message.action) { case 'table': - if (data.kind !== 'table') { return; } - await session.showDataView('table', 'json', `${view.client.manifest.label}: ${data.fullViewId ? 'full table' : 'table'}`, '', 'Beside', String(data.fullViewId ?? data.viewId), undefined, view.target?.sessionId ?? null); break; + if (data.kind !== 'table') { + return; + } + await session.showDataView( + 'table', + 'json', + `${view.client.manifest.label}: ${data.fullViewId ? 'full table' : 'table'}`, + '', + 'Beside', + String(data.fullViewId ?? data.viewId), + undefined, + view.target?.sessionId ?? null, + ); + break; case 'page': { - if (data.kind !== 'table') { return; } - result = await queryTablePage(data, message, request => view.client.request('inspect', request)); break; + if (data.kind !== 'table') { + return; + } + result = await queryTablePage(data, message, (request) => + view.client.request('inspect', request), + ); + break; } - case 'list': case 'listPage': case 'listNavigate': case 'listItem': { - if (data.kind !== 'list') { return; } + case 'list': + case 'listPage': + case 'listNavigate': + case 'listItem': { + if (data.kind !== 'list') { + return; + } const listPath = message.path ?? []; - if (!Array.isArray(listPath) || !listPath.every(index => Number.isSafeInteger(index) && index > 0)) { throw new Error('Invalid list path'); } + if ( + !Array.isArray(listPath) || + !listPath.every((index) => Number.isSafeInteger(index) && index > 0) + ) { + throw new Error('Invalid list path'); + } const params = { view_id: data.viewId, path: listPath }; if (message.action === 'listPage') { - if (!Number.isSafeInteger(message.start) || Number(message.start) < 1) { throw new Error('Invalid list page'); } - result = await view.client.request('inspect', { method: 'workspace_children', params: { ...params, start: message.start } }); - } else { - if (message.action === 'listItem' && (!Number.isSafeInteger(message.index) || Number(message.index) < 1)) { throw new Error('Invalid list index'); } - const navigation = await view.client.request('inspect', { - method: message.action === 'listItem' ? 'listview_view' : 'listview_navigate', params: { ...params, index: message.index }, + if (!Number.isSafeInteger(message.start) || Number(message.start) < 1) { + throw new Error('Invalid list page'); + } + result = await view.client.request('inspect', { + method: 'workspace_children', + params: { ...params, start: message.start }, }); - if (!navigation) { throw new Error('Unable to open this item. Check the R session and try again.'); } - if (typeof navigation === 'object' && Array.isArray(navigation.breadcrumbs)) { + } else { + if ( + message.action === 'listItem' && + (!Number.isSafeInteger(message.index) || Number(message.index) < 1) + ) { + throw new Error('Invalid list index'); + } + const navigation = await view.client.request( + 'inspect', + { + method: + message.action === 'listItem' + ? 'listview_view' + : 'listview_navigate', + params: { ...params, index: message.index }, + }, + ); + if (!navigation) { + throw new Error( + 'Unable to open this item. Check the R session and try again.', + ); + } + if ( + typeof navigation === 'object' && + Array.isArray(navigation.breadcrumbs) + ) { if (message.action === 'list') { - await session.showDataView('list', 'json', `${view.client.manifest.label}: ${navigation.title}`, '', 'Beside', String(data.viewId), navigation, view.target.sessionId); - } else { result = { navigation }; } + await session.showDataView( + 'list', + 'json', + `${view.client.manifest.label}: ${navigation.title}`, + '', + 'Beside', + String(data.viewId), + navigation, + view.target.sessionId, + ); + } else { + result = { navigation }; + } } } break; } case 'resize': - if (data.kind !== 'plot') { return; } - await view.client.request('resize', { device: data.device, plot: data.plot, width: message.width, height: message.height }); break; + if (data.kind !== 'plot') { + return; + } + await view.client.request('resize', { + device: data.device, + plot: data.plot, + width: message.width, + height: message.height, + }); + break; case 'saveAs': { - if (!['plot', 'image'].includes(String(data.kind))) { return; } + if (!['plot', 'image'].includes(String(data.kind))) { + return; + } const formats = [ - ...(data.kind === 'plot' || data.mime === 'image/svg+xml' ? [{ label: 'SVG (.svg)', description: 'Scalable vector image', format: 'svg' }] : []), - ...(message.pngReady === true ? [{ label: 'PNG (.png)', description: 'Raster image', format: 'png' }] : []), + ...(data.kind === 'plot' || data.mime === 'image/svg+xml' + ? [ + { + label: 'SVG (.svg)', + description: 'Scalable vector image', + format: 'svg', + }, + ] + : []), + ...(message.pngReady === true + ? [{ label: 'PNG (.png)', description: 'Raster image', format: 'png' }] + : []), ]; - result = (await vscode.window.showQuickPick(formats, { title: 'Save R plot as', - placeHolder: 'Choose an image format' }))?.format; break; + result = ( + await vscode.window.showQuickPick(formats, { + title: 'Save R plot as', + placeHolder: 'Choose an image format', + }) + )?.format; + break; } - case 'save': case 'savePng': { - if (!['plot', 'image'].includes(String(data.kind))) { return; } + case 'save': + case 'savePng': { + if (!['plot', 'image'].includes(String(data.kind))) { + return; + } const png = message.action === 'savePng'; - if (!png && data.kind === 'image' && data.mime !== 'image/svg+xml') { return; } - const pages = owner?.outputs.filter(item => item.type === 'display' && ['plot', 'image'].includes(String(item.data.kind))) ?? []; + if (!png && data.kind === 'image' && data.mime !== 'image/svg+xml') { + return; + } + const pages = + owner?.outputs.filter( + (item) => + item.type === 'display' && + ['plot', 'image'].includes(String(item.data.kind)), + ) ?? []; const pageSuffix = pages.length > 1 ? `-${pages.indexOf(output) + 1}` : ''; const file = await vscode.window.showSaveDialog({ - defaultUri: this.defaultExportUri(view, `plot-${owner?.record.order ?? 1}${pageSuffix}.${png ? 'png' : 'svg'}`), + defaultUri: this.defaultExportUri( + view, + `plot-${owner?.record.order ?? 1}${pageSuffix}.${png ? 'png' : 'svg'}`, + ), filters: png ? { PNG: ['png'] } : { SVG: ['svg'] }, }); - if (!file) { return; } + if (!file) { + return; + } let bytes: Buffer; if (png) { - if (typeof message.image !== 'string' || !/^data:image\/png;base64,[A-Za-z0-9+/=]+$/.test(message.image) || message.image.length > 32 * 1024 * 1024) { throw new Error('Invalid PNG export'); } - bytes = Buffer.from(message.image.slice('data:image/png;base64,'.length), 'base64'); - } else if (data.svg || data.asset) { bytes = readAsset(path.join(this.root, view.client.manifest.id, 'assets'), String(data.svg ?? data.asset)); } - else { bytes = Buffer.from(String(data.data), 'base64'); } - await vscode.workspace.fs.writeFile(file, bytes); break; + if ( + typeof message.image !== 'string' || + !/^data:image\/png;base64,[A-Za-z0-9+/=]+$/.test(message.image) || + message.image.length > 32 * 1024 * 1024 + ) { + throw new Error('Invalid PNG export'); + } + bytes = Buffer.from( + message.image.slice('data:image/png;base64,'.length), + 'base64', + ); + } else if (data.svg || data.asset) { + bytes = readAsset( + path.join(this.root, view.client.manifest.id, 'assets'), + String(data.svg ?? data.asset), + ); + } else { + bytes = Buffer.from(String(data.data), 'base64'); + } + await vscode.workspace.fs.writeFile(file, bytes); + break; } - case 'plot': case 'open': { - await this.openOutput(view, data); break; + case 'plot': + case 'open': { + await this.openOutput(view, data); + break; } } - await this.messages.postMessage({ outputId: message.outputId, action: message.action, requestId: message.requestId, result }, editor); - } catch (error) { await this.messages.postMessage({ outputId: message.outputId, action: message.action, requestId: message.requestId, error: String(error) }, editor); } + await this.messages.postMessage( + { + outputId: message.outputId, + action: message.action, + requestId: message.requestId, + result, + }, + editor, + ); + } catch (error) { + await this.messages.postMessage( + { + outputId: message.outputId, + action: message.action, + requestId: message.requestId, + error: String(error), + }, + editor, + ); + } } private reconnect(view: InteractiveView, delay: number): void { - if (view.disposed || this.closed || view.restarting) { return; } + if (view.disposed || this.closed || view.restarting) { + return; + } clearTimeout(view.reconnectTimer); view.reconnectTimer = setTimeout(() => { view.chain = view.chain.then(async () => { - if (view.disposed || view.restarting) { return; } + if (view.disposed || view.restarting) { + return; + } try { - const latest = discoverSessions(this.root).find(item => item.id === view.client.manifest.id); + const latest = discoverSessions(this.root).find( + (item) => item.id === view.client.manifest.id, + ); if (latest && latest.generation !== view.model.generation) { - this.output.appendLine(`${latest.label}: a new R generation is available. Reconnect from Interactive Sessions.`); + this.output.appendLine( + `${latest.label}: a new R generation is available. Reconnect from Interactive Sessions.`, + ); return; } - if (latest) { view.client.manifest = latest; } + if (latest) { + view.client.manifest = latest; + } await view.client.connect(); if (view.client.manifest.assetBase) { - view.base = (await vscode.env.asExternalUri(vscode.Uri.parse(view.client.manifest.assetBase))).toString(true); + view.base = ( + await vscode.env.asExternalUri( + vscode.Uri.parse(view.client.manifest.assetBase), + ) + ).toString(true); + } + if (!(await view.client.subscribe(view.model.seq))) { + await this.restore(view, await view.client.snapshot()); + await view.client.subscribe(view.model.seq); } - if (!await view.client.subscribe(view.model.seq)) { - await this.restore(view, await view.client.snapshot()); await view.client.subscribe(view.model.seq); + if (this.active === view) { + await this.activate(view); } - if (this.active === view) { await this.activate(view); } - } catch { this.reconnect(view, Math.min(delay * 2, 30000)); } + } catch { + this.reconnect(view, Math.min(delay * 2, 30000)); + } }); }, delay); } private async detach(view: InteractiveView): Promise { - try { if (view.client.connected) { await view.client.request('detach'); } } - finally { this.disposeView(view); } + try { + if (view.client.connected) { + await view.client.request('detach'); + } + } finally { + this.disposeView(view); + } } private async stop(view: InteractiveView): Promise { - const answer = await vscode.window.showWarningMessage(`Stop R session “${view.client.manifest.label}”? Its in-memory objects will be lost.`, { modal: true }, 'Stop Session'); - if (answer === 'Stop Session') { await view.client.request('stop'); } + const answer = await vscode.window.showWarningMessage( + `Stop R session “${view.client.manifest.label}”? Its in-memory objects will be lost.`, + { modal: true }, + 'Stop Session', + ); + if (answer === 'Stop Session') { + await view.client.request('stop'); + } } private async stopMultiple(all: boolean, value?: unknown, selection?: unknown): Promise { - if (!vscode.workspace.isTrusted) { throw new Error('Trust this workspace before stopping R sessions'); } - const isManifest = (item: unknown): item is SessionManifest => !!item && typeof item === 'object' - && typeof (item as SessionManifest).id === 'string' && typeof (item as SessionManifest).generation === 'string'; + if (!vscode.workspace.isTrusted) { + throw new Error('Trust this workspace before stopping R sessions'); + } + const isManifest = (item: unknown): item is SessionManifest => + !!item && + typeof item === 'object' && + typeof (item as SessionManifest).id === 'string' && + typeof (item as SessionManifest).generation === 'string'; // Tree actions can supply both the focused item and its selection; palette commands supply neither. - const selected = !all && isManifest(value) ? Array.isArray(selection) && selection.length - ? selection.filter(isManifest) : [value] : undefined; + const selected = + !all && isManifest(value) + ? Array.isArray(selection) && selection.length + ? selection.filter(isManifest) + : [value] + : undefined; this.refresh(); - let targets = selected ?? await runningSessions(this.manifests); + let targets = selected ?? (await runningSessions(this.manifests)); if (!all && !selected && targets.length) { - const choices = targets.map(manifest => ({ label: manifest.label, + const choices = targets.map((manifest) => ({ + label: manifest.label, description: this.sessionChoiceDescription(manifest), - detail: `${manifest.directory} · ${manifest.host} · ${manifest.id.slice(0, 8)}`, manifest })); - const picked = await vscode.window.showQuickPick(choices, { canPickMany: true, matchOnDescription: true, matchOnDetail: true, - title: 'Stop Selected Interactive Sessions', placeHolder: 'Select the R sessions to stop' }); - if (!picked?.length) { return; } - targets = picked.map(item => item.manifest); + detail: `${manifest.directory} · ${manifest.host} · ${manifest.id.slice(0, 8)}`, + manifest, + })); + const picked = await vscode.window.showQuickPick(choices, { + canPickMany: true, + matchOnDescription: true, + matchOnDetail: true, + title: 'Stop Selected Interactive Sessions', + placeHolder: 'Select the R sessions to stop', + }); + if (!picked?.length) { + return; + } + targets = picked.map((item) => item.manifest); } // Freeze identities before confirmation: a restart or a newly created session must never become a target. - targets = [...new Map(targets.filter(manifest => !['exited', 'stopping'].includes(manifest.status)) - .map(manifest => [`${manifest.id}:${manifest.generation}`, { ...manifest }])).values()]; - if (!targets.length) { void vscode.window.showInformationMessage('No running R Interactive sessions to stop.'); return; } + targets = [ + ...new Map( + targets + .filter((manifest) => !['exited', 'stopping'].includes(manifest.status)) + .map((manifest) => [`${manifest.id}:${manifest.generation}`, { ...manifest }]), + ).values(), + ]; + if (!targets.length) { + void vscode.window.showInformationMessage('No running R Interactive sessions to stop.'); + return; + } const label = targets.length === 1 ? 'Stop Session' : `Stop ${targets.length} Sessions`; const detail = [ 'Their in-memory objects will be lost. Transcripts and saved output will be retained.', - targets.map(manifest => `${manifest.label} — PID ${manifest.rPid ?? 'starting'} · ${manifest.id.slice(0, 8)}\n${manifest.directory}`).join('\n\n'), - ...(targets.some(manifest => manifest.ownership === 'adopted' || !manifest.ownership && manifest.provider === 'arf-existing') ? ['Stopping an attached arf session also ends its R process.'] : []), + targets + .map( + (manifest) => + `${manifest.label} — PID ${manifest.rPid ?? 'starting'} · ${manifest.id.slice(0, 8)}\n${manifest.directory}`, + ) + .join('\n\n'), + ...(targets.some( + (manifest) => + manifest.ownership === 'adopted' || + (!manifest.ownership && manifest.provider === 'arf-existing'), + ) + ? ['Stopping an attached arf session also ends its R process.'] + : []), 'Sessions controlled by another VS Code window will be skipped. Take Control of those sessions before retrying.', ].join('\n\n'); - const confirmed = await vscode.window.showWarningMessage(`Stop ${targets.length} R Interactive session${targets.length === 1 ? '' : 's'}?`, - { modal: true, detail }, label); - if (confirmed !== label || this.closed) { return; } + const confirmed = await vscode.window.showWarningMessage( + `Stop ${targets.length} R Interactive session${targets.length === 1 ? '' : 's'}?`, + { modal: true, detail }, + label, + ); + if (confirmed !== label || this.closed) { + return; + } const stopped = new Set(); const failures: string[] = []; - await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: 'Stopping R Interactive sessions' }, async progress => { - for (let i = 0; i < targets.length; i += 4) { - const results = await Promise.allSettled(targets.slice(i, i + 4).map(async manifest => { - if (this.views.get(`${manifest.id}:${manifest.generation}`)?.restarting) { throw new Error('Session is restarting; retry when it is ready.'); } - await stopSession(manifest, this.clientId); - })); - results.forEach((result, index) => { - const manifest = targets[i + index]; - if (result.status === 'fulfilled') { stopped.add(`${manifest.id}:${manifest.generation}`); } - else { failures.push(`${manifest.label} (PID ${manifest.rPid ?? 'starting'}): ${String(result.reason)}`); } - }); - progress.report({ increment: results.length / targets.length * 100, message: `${i + results.length} of ${targets.length} checked` }); - } - }); + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: 'Stopping R Interactive sessions', + }, + async (progress) => { + for (let i = 0; i < targets.length; i += 4) { + const results = await Promise.allSettled( + targets.slice(i, i + 4).map(async (manifest) => { + if ( + this.views.get(`${manifest.id}:${manifest.generation}`)?.restarting + ) { + throw new Error('Session is restarting; retry when it is ready.'); + } + await stopSession(manifest, this.clientId); + }), + ); + results.forEach((result, index) => { + const manifest = targets[i + index]; + if (result.status === 'fulfilled') { + stopped.add(`${manifest.id}:${manifest.generation}`); + } else { + failures.push( + `${manifest.label} (PID ${manifest.rPid ?? 'starting'}): ${String(result.reason)}`, + ); + } + }); + progress.report({ + increment: (results.length / targets.length) * 100, + message: `${i + results.length} of ${targets.length} checked`, + }); + } + }, + ); this.refresh(); // An unopened session's agent shuts down when the temporary client closes. // Its socket can still exist during refresh; do not leave an unusable tree link. - this.manifests = this.manifests.filter(manifest => { + this.manifests = this.manifests.filter((manifest) => { const key = `${manifest.id}:${manifest.generation}`; return !stopped.has(key) || this.views.has(key); }); @@ -1397,78 +2838,182 @@ export class InteractiveManager implements vscode.Disposable, vscode.TreeDataPro if (failures.length) { this.output.appendLine(`${summary}\n${failures.join('\n')}`); const details = 'Show Details'; - if (await vscode.window.showWarningMessage(`${summary} ${failures.length} could not be stopped.`, details) === details) { this.output.show(true); } - } else { void vscode.window.showInformationMessage(summary); } + if ( + (await vscode.window.showWarningMessage( + `${summary} ${failures.length} could not be stopped.`, + details, + )) === details + ) { + this.output.show(true); + } + } else { + void vscode.window.showInformationMessage(summary); + } } - private noticeData(view: InteractiveView, state: 'stopped' | 'restarting' | 'restarted' | 'restartFailed', generation = view.model.generation): vscode.NotebookCellData { + private noticeData( + view: InteractiveView, + state: 'stopped' | 'restarting' | 'restarted' | 'restartFailed', + generation = view.model.generation, + ): vscode.NotebookCellData { const messages = { - stopped: ['R session stopped', 'This R process has ended. The transcript is retained. Restart the session or choose another execution target to continue.'], - restarting: ['Restarting R session…', 'The R process is being replaced. Your transcript and input stay in this window.'], - restarted: ['R session restarted', 'Continue here in a fresh R process. Previous code and output are retained above; objects from the previous process are no longer available.'], - restartFailed: ['R session restart did not complete', 'The previous transcript is retained. Check the R Interactive output channel and session list before trying again.'], + stopped: [ + 'R session stopped', + 'This R process has ended. The transcript is retained. Restart the session or choose another execution target to continue.', + ], + restarting: [ + 'Restarting R session…', + 'The R process is being replaced. Your transcript and input stay in this window.', + ], + restarted: [ + 'R session restarted', + 'Continue here in a fresh R process. Previous code and output are retained above; objects from the previous process are no longer available.', + ], + restartFailed: [ + 'R session restart did not complete', + 'The previous transcript is retained. Check the R Interactive output channel and session list before trying again.', + ], }; const [title, body] = messages[state]; // appendText escapes spaces as NBSP; use it only for the untrusted session name. - const markdown = new vscode.MarkdownString().appendMarkdown(`**${title}**\n\n`) - .appendText(view.client.manifest.label).appendMarkdown(` — ${body}`); - const data = new vscode.NotebookCellData(vscode.NotebookCellKind.Markup, markdown.value, 'markdown'); - data.metadata = persistentCellMetadata({ rSessionNotice: generation, rNoticeKind: state }, 'markdown'); + const markdown = new vscode.MarkdownString() + .appendMarkdown(`**${title}**\n\n`) + .appendText(view.client.manifest.label) + .appendMarkdown(` — ${body}`); + const data = new vscode.NotebookCellData( + vscode.NotebookCellKind.Markup, + markdown.value, + 'markdown', + ); + data.metadata = persistentCellMetadata( + { rSessionNotice: generation, rNoticeKind: state }, + 'markdown', + ); return data; } - private async sessionNotice(view: InteractiveView, state: Parameters[1], generation = view.model.generation): Promise { - if (view.notebook.isClosed) { return; } - const previous = view.notebook.getCells().find(cell => cell.metadata.rSessionNotice === generation); + private async sessionNotice( + view: InteractiveView, + state: Parameters[1], + generation = view.model.generation, + ): Promise { + if (view.notebook.isClosed) { + return; + } + const previous = view.notebook + .getCells() + .find((cell) => cell.metadata.rSessionNotice === generation); const data = this.noticeData(view, state, generation); const edit = new vscode.WorkspaceEdit(); - edit.set(view.notebook.uri, [previous - ? vscode.NotebookEdit.replaceCells(new vscode.NotebookRange(previous.index, previous.index + 1), [data]) - : vscode.NotebookEdit.insertCells(view.notebook.cellCount, [data])]); + edit.set(view.notebook.uri, [ + previous + ? vscode.NotebookEdit.replaceCells( + new vscode.NotebookRange(previous.index, previous.index + 1), + [data], + ) + : vscode.NotebookEdit.insertCells(view.notebook.cellCount, [data]), + ]); await vscode.workspace.applyEdit(edit); } - private queueNotice(view: InteractiveView, state: Parameters[1], generation = view.model.generation): Promise { + private queueNotice( + view: InteractiveView, + state: Parameters[1], + generation = view.model.generation, + ): Promise { view.chain = view.chain.then(() => this.sessionNotice(view, state, generation)); return view.chain; } private async restart(view: InteractiveView): Promise { - if (view.restarting) { return; } - if (!(view.client.manifest.capabilities.restart ?? view.client.manifest.provider !== 'arf-existing')) { throw new Error('Restart adopted arf from its original terminal or session manager, then reconnect to the new process'); } + if (view.restarting) { + return; + } + if ( + !( + view.client.manifest.capabilities.restart ?? + view.client.manifest.provider !== 'arf-existing' + ) + ) { + throw new Error( + 'Restart adopted arf from its original terminal or session manager, then reconnect to the new process', + ); + } view.restarting = true; this.updateStatus(); const generation = view.model.generation; let announced = false; try { - const answer = await vscode.window.showWarningMessage(`Restart R session “${view.client.manifest.label}”? Its in-memory objects will be lost. Code, output, and input will stay in this Interactive window.`, { modal: true }, 'Restart Session'); - if (answer !== 'Restart Session' || view.disposed) { return; } + const answer = await vscode.window.showWarningMessage( + `Restart R session “${view.client.manifest.label}”? Its in-memory objects will be lost. Code, output, and input will stay in this Interactive window.`, + { modal: true }, + 'Restart Session', + ); + if (answer !== 'Restart Session' || view.disposed) { + return; + } const storage = path.join(this.root, view.client.manifest.id); - let config = JSON.parse(fs.readFileSync(path.join(storage, 'config.json'), 'utf8')) as AgentConfig; + let config = JSON.parse( + fs.readFileSync(path.join(storage, 'config.json'), 'utf8'), + ) as AgentConfig; // Re-evaluate auto and honor repaired settings; the saved config records // the actual supervisor of the old process, not the current preference. - config.supervision = util.config(vscode.Uri.file(config.directory)).get('interactive.supervision', 'auto'); + config.supervision = util + .config(vscode.Uri.file(config.directory)) + .get('interactive.supervision', 'auto'); prepareSupervisor(config.supervision, config.directory); const node = await this.nodeRuntime(config.directory); const backend = backendDescriptor(config); - if (backend.kind === 'sess' && backend.options.frontend === 'arf' && backend.options.ownership === 'managed') { + if ( + backend.kind === 'sess' && + backend.options.frontend === 'arf' && + backend.options.ownership === 'managed' + ) { // Preserve the original binary across PATH changes after a reload, but // allow a repaired setting to replace a removed/moved executable. - const command = resolveArfExecutable(typeof backend.options.arfPath === 'string' ? backend.options.arfPath : 'arf', config.directory) - ?? this.arfCommand(vscode.Uri.file(config.directory)); + const command = + resolveArfExecutable( + typeof backend.options.arfPath === 'string' + ? backend.options.arfPath + : 'arf', + config.directory, + ) ?? this.arfCommand(vscode.Uri.file(config.directory)); const arfPath = this.checkArfExecutable(command, config.directory, true); - if (!arfPath) { return; } + if (!arfPath) { + return; + } backend.options = { ...backend.options, arfPath }; } config.backend = backend; - const runtime = await prepareBackendRuntime(config, { extensionPath: this.context.extensionPath, root: this.root, log: text => this.output.append(text) }); + const runtime = await prepareBackendRuntime(config, { + extensionPath: this.context.extensionPath, + root: this.root, + log: (text) => this.output.append(text), + }); config = runtime.config; Object.assign(config, this.retentionLimits()); - config.previousGenerations = [...view.history.map(model => model.generation), generation]; + config.previousGenerations = [ + ...view.history.map((model) => model.generation), + generation, + ]; config.generation = randomUUID(); - if (view.disposed) { return; } - if (backend.kind === 'sess' && backend.options.frontend === 'arf' && backend.options.ownership === 'managed' && - !this.checkArfExecutable(typeof runtime.config.backend?.options.arfPath === 'string' ? runtime.config.backend.options.arfPath : 'arf', config.directory, true)) { return; } + if (view.disposed) { + return; + } + if ( + backend.kind === 'sess' && + backend.options.frontend === 'arf' && + backend.options.ownership === 'managed' && + !this.checkArfExecutable( + typeof runtime.config.backend?.options.arfPath === 'string' + ? runtime.config.backend.options.arfPath + : 'arf', + config.directory, + true, + ) + ) { + return; + } prepareSupervisor(config.supervision, config.directory); clearTimeout(view.reconnectTimer); announced = true; @@ -1478,149 +3023,306 @@ export class InteractiveManager implements vscode.Disposable, vscode.TreeDataPro // Wait for the old runtime to publish its final state before its successor. for (let i = 0; i < 100; i++) { const state = await view.client.request<{ status: string }>('heartbeat'); - if (state.status === 'exited') { break; } - if (i === 99) { throw new Error('The previous R process has not exited'); } - await new Promise(resolve => setTimeout(resolve, 100)); + if (state.status === 'exited') { + break; + } + if (i === 99) { + throw new Error('The previous R process has not exited'); + } + await new Promise((resolve) => setTimeout(resolve, 100)); } await view.client.request('shutdown'); } else if (view.client.manifest.status !== 'exited') { throw new Error('Reconnect to the R session before restarting it.'); } await view.chain; - clearTimeout(view.timer); view.timer = undefined; view.pending.clear(); - for (const { task } of view.executions.values()) { task.end(undefined); } + clearTimeout(view.timer); + view.timer = undefined; + view.pending.clear(); + for (const { task } of view.executions.values()) { + task.end(undefined); + } view.executions.clear(); view.client.close(); - const manifest = await launchAgent(config, runtime.agent, node, text => this.output.appendLine(text)); - if (view.disposed) { return; } + const manifest = await launchAgent(config, runtime.agent, node, (text) => + this.output.appendLine(text), + ); + if (view.disposed) { + return; + } this.views.delete(`${manifest.id}:${generation}`); view.history.push(view.model); view.model = new Transcript(manifest.generation); view.client = new AgentClient(manifest, this.clientId); this.views.set(`${manifest.id}:${manifest.generation}`, view); const target = this.registerTarget(view.client); - session.replaceSessionTransport(view.target, target); view.target = target; + session.replaceSessionTransport(view.target, target); + view.target = target; this.listen(view, view.client); await view.client.connect(); const deadline = Date.now() + 30000; - while ((await view.client.request<{ status: string }>('heartbeat')).status === 'starting') { - if (Date.now() >= deadline) { throw new Error('The replacement R process has not become ready'); } - await new Promise(resolve => setTimeout(resolve, 100)); + while ( + (await view.client.request<{ status: string }>('heartbeat')).status === 'starting' + ) { + if (Date.now() >= deadline) { + throw new Error('The replacement R process has not become ready'); + } + await new Promise((resolve) => setTimeout(resolve, 100)); } const snapshot = await view.client.snapshot(); view.client.manifest = snapshot.manifest; - target.pid = String(snapshot.manifest.rPid ?? ''); target.rVer = snapshot.manifest.rVersion ?? ''; + target.pid = String(snapshot.manifest.rPid ?? ''); + target.rVer = snapshot.manifest.rVersion ?? ''; target.processExited = snapshot.manifest.status === 'exited'; - target.rPath = snapshot.manifest.rPath; target.libraryPaths = snapshot.manifest.libraryPaths; + target.rPath = snapshot.manifest.rPath; + target.libraryPaths = snapshot.manifest.libraryPaths; view.prompts.clear(); - if (snapshot.workspace) { target.workspaceData = snapshot.workspace as unknown as session.WorkspaceData; } - view.base = snapshot.manifest.assetBase ? (await vscode.env.asExternalUri(vscode.Uri.parse(snapshot.manifest.assetBase))).toString(true) : ''; + if (snapshot.workspace) { + target.workspaceData = snapshot.workspace as unknown as session.WorkspaceData; + } + view.base = snapshot.manifest.assetBase + ? ( + await vscode.env.asExternalUri(vscode.Uri.parse(snapshot.manifest.assetBase)) + ).toString(true) + : ''; view.model.restore(snapshot); const edit = new vscode.WorkspaceEdit(); - edit.set(view.notebook.uri, [vscode.NotebookEdit.updateNotebookMetadata(persistentNotebookMetadata(view.notebook.metadata, manifest.id, manifest.generation))]); + edit.set(view.notebook.uri, [ + vscode.NotebookEdit.updateNotebookMetadata( + persistentNotebookMetadata( + view.notebook.metadata, + manifest.id, + manifest.generation, + ), + ), + ]); await vscode.workspace.applyEdit(edit); await this.queueNotice(view, 'restarted', generation); if (view.model.cells.size) { - const cells = await Promise.all([...view.model.cells.values()].map(cell => this.cellData(view, cell))); + const cells = await Promise.all( + [...view.model.cells.values()].map((cell) => this.cellData(view, cell)), + ); const insert = new vscode.WorkspaceEdit(); - insert.set(view.notebook.uri, [vscode.NotebookEdit.insertCells(view.notebook.cellCount, cells)]); + insert.set(view.notebook.uri, [ + vscode.NotebookEdit.insertCells(view.notebook.cellCount, cells), + ]); await vscode.workspace.applyEdit(insert); - for (const cell of view.notebook.getCells()) { session.bindSessionDocument(cell.document.uri, target); } + for (const cell of view.notebook.getCells()) { + session.bindSessionDocument(cell.document.uri, target); + } } // Refresh asset URLs and disable process-specific controls without replacing old cells. for (const cell of this.transcriptCells(view)) { - if (cell.outputs.some(output => output.type === 'display')) { await this.render(view, cell.record.id); } + if (cell.outputs.some((output) => output.type === 'display')) { + await this.render(view, cell.record.id); + } + } + if (snapshot.manifest.status === 'exited') { + throw new Error('The replacement R process exited during startup'); + } + if (!(await view.client.subscribe(view.model.seq))) { + await this.restore(view, await view.client.snapshot()); + await view.client.subscribe(view.model.seq); + } + if (this.active === view) { + await this.activate(view); } - if (snapshot.manifest.status === 'exited') { throw new Error('The replacement R process exited during startup'); } - if (!await view.client.subscribe(view.model.seq)) { await this.restore(view, await view.client.snapshot()); await view.client.subscribe(view.model.seq); } - if (this.active === view) { await this.activate(view); } } catch (error) { - if (announced) { await this.queueNotice(view, 'restartFailed', generation); } + if (announced) { + await this.queueNotice(view, 'restartFailed', generation); + } throw error; } finally { - view.restarting = false; this.refresh(); - if (!view.client.connected && view.client.manifest.status !== 'exited') { this.reconnect(view, 1000); } + view.restarting = false; + this.refresh(); + if (!view.client.connected && view.client.manifest.status !== 'exited') { + this.reconnect(view, 1000); + } } } private async exportHistory(view: InteractiveView): Promise { // macOS uses only the first save-dialog filter and otherwise appends .rnb // even when the user types another supported suffix. Choose the format first. - const selected = await vscode.window.showQuickPick([ - { label: 'R Interactive notebook (.rnb)', description: 'Reopen cells and retained output in VS Code', format: 'rnb' }, - { label: 'Jupyter notebook (.ipynb)', description: 'Portable notebook with standard output formats', format: 'ipynb' }, - { label: 'R script (.R)', description: 'Code and session boundaries', format: 'R' }, - { label: 'HTML report (.html)', description: 'Share output and interactive widgets in a browser', format: 'html' }, - ], { title: `Export history: ${view.client.manifest.label}`, placeHolder: 'Choose an export format' }); - if (!selected) { return; } - const file = await vscode.window.showSaveDialog({ defaultUri: this.defaultExportUri(view, `history.${selected.format}`), - filters: { [selected.label]: [selected.format] } }); - if (!file) { return; } - const entries = [...view.history, view.model].flatMap(model => - [...model.cells.values(), ...(model === view.model ? [] : [model.generation])]); + const selected = await vscode.window.showQuickPick( + [ + { + label: 'R Interactive notebook (.rnb)', + description: 'Reopen cells and retained output in VS Code', + format: 'rnb', + }, + { + label: 'Jupyter notebook (.ipynb)', + description: 'Portable notebook with standard output formats', + format: 'ipynb', + }, + { label: 'R script (.R)', description: 'Code and session boundaries', format: 'R' }, + { + label: 'HTML report (.html)', + description: 'Share output and interactive widgets in a browser', + format: 'html', + }, + ], + { + title: `Export history: ${view.client.manifest.label}`, + placeHolder: 'Choose an export format', + }, + ); + if (!selected) { + return; + } + const file = await vscode.window.showSaveDialog({ + defaultUri: this.defaultExportUri(view, `history.${selected.format}`), + filters: { [selected.label]: [selected.format] }, + }); + if (!file) { + return; + } + const entries = [...view.history, view.model].flatMap((model) => [ + ...model.cells.values(), + ...(model === view.model ? [] : [model.generation]), + ]); const cells = entries.filter((entry): entry is TranscriptCell => typeof entry !== 'string'); if (selected.format === 'R') { - await vscode.workspace.fs.writeFile(file, Buffer.from(entries.map(entry => typeof entry === 'string' - ? '# R session restarted — fresh R process; previous objects are unavailable.' - : `# %% ${entry.record.order}: ${entry.record.state}\n${entry.record.code}`).join('\n\n'))); return; + await vscode.workspace.fs.writeFile( + file, + Buffer.from( + entries + .map((entry) => + typeof entry === 'string' + ? '# R session restarted — fresh R process; previous objects are unavailable.' + : `# %% ${entry.record.order}: ${entry.record.state}\n${entry.record.code}`, + ) + .join('\n\n'), + ), + ); + return; } if (selected.format === 'html') { const sections: string[] = []; const assetFolder = `${file.fsPath}.assets`; const assets = new AssetStore(path.join(this.root, view.client.manifest.id, 'assets')); - assets.exportTo(assetFolder, cells.flatMap(cell => cell.outputs.flatMap(output => - [output.data.svg, output.data.asset].filter((id): id is string => typeof id === 'string')))); + assets.exportTo( + assetFolder, + cells.flatMap((cell) => + cell.outputs.flatMap((output) => + [output.data.svg, output.data.asset].filter( + (id): id is string => typeof id === 'string', + ), + ), + ), + ); for (const cell of entries) { if (typeof cell === 'string') { - sections.push('

R session restarted — fresh R process; previous objects are unavailable.

'); continue; + sections.push( + '

R session restarted — fresh R process; previous objects are unavailable.

', + ); + continue; } sections.push(`
${escapeXml(cell.record.code)}
`); for (const output of cell.outputs) { const data = output.data; if (data.svg || data.asset) { const url = `${encodeURIComponent(path.basename(assetFolder))}/${exportedAssetName(String(data.svg ?? data.asset))}`; - sections.push(data.svg || data.kind === 'image' ? `` : ``); + sections.push( + data.svg || data.kind === 'image' + ? `` + : ``, + ); } else if (data.kind === 'table') { - const preference = this.outputViews.get(`${view.client.manifest.id}:${cell.generation}:${String(data.displayId)}`); - if ((preference?.tableView ?? util.config().get('interactive.tableView', 'table')) === 'text' && typeof data.printedText === 'string') { - sections.push(`
${escapeXml(data.printedText)}
`); continue; + const preference = this.outputViews.get( + `${view.client.manifest.id}:${cell.generation}:${String(data.displayId)}`, + ); + if ( + (preference?.tableView ?? + util.config().get('interactive.tableView', 'table')) === 'text' && + typeof data.printedText === 'string' + ) { + sections.push(`
${escapeXml(data.printedText)}
`); + continue; } - const columns = data.columns as { field: string; headerName: string; type?: unknown }[]; + const columns = data.columns as { + field: string; + headerName: string; + type?: unknown; + }[]; const rows = data.rows as Record[]; - sections.push(`${columns.map(column => ``).join('')}${rows.map((row, rowIndex) => `${columns.map(column => ``).join('')}`).join('')}
${escapeXml(column.headerName)}
${escapeXml(tableDisplayValue(data, row, column.field, rowIndex))}

${tableSnapshotSummary(data) || `${String(data.totalRows)} rows (preview)`}

`); + sections.push( + `${columns.map((column) => ``).join('')}${rows.map((row, rowIndex) => `${columns.map((column) => ``).join('')}`).join('')}
${escapeXml(column.headerName)}
${escapeXml(tableDisplayValue(data, row, column.field, rowIndex))}

${tableSnapshotSummary(data) || `${String(data.totalRows)} rows (preview)`}

`, + ); } else if (data.kind === 'list') { - sections.push(`
${escapeXml(data.printedText ?? JSON.stringify(data.children, null, 2))}
`); + sections.push( + `
${escapeXml(data.printedText ?? JSON.stringify(data.children, null, 2))}
`, + ); } else if (data.kind === 'mime' && data.mime === 'text/html') { - sections.push(``); - } else { sections.push(`
${escapeXml(data.text ?? data.message ?? JSON.stringify(data, null, 2))}
`); } + sections.push( + ``, + ); + } else { + sections.push( + `
${escapeXml(data.text ?? data.message ?? JSON.stringify(data, null, 2))}
`, + ); + } } sections.push('
'); } - await vscode.workspace.fs.writeFile(file, Buffer.from(`R Interactive${sections.join('\n')}`)); return; + await vscode.workspace.fs.writeFile( + file, + Buffer.from( + `R Interactive${sections.join('\n')}`, + ), + ); + return; } const exported: vscode.NotebookCellData[] = []; for (const model of [...view.history, view.model]) { - for (const cell of model.cells.values()) { exported.push(await this.cellData(view, cell, true)); } - if (model !== view.model) { exported.push(this.noticeData(view, 'restarted', model.generation)); } + for (const cell of model.cells.values()) { + exported.push(await this.cellData(view, cell, true)); + } + if (model !== view.model) { + exported.push(this.noticeData(view, 'restarted', model.generation)); + } } const notebook = new vscode.NotebookData(exported); - notebook.metadata = { rSessionId: view.client.manifest.id, rGeneration: view.client.manifest.generation }; - await vscode.workspace.fs.writeFile(file, selected.format === 'ipynb' ? this.serializer.exportIpynb(notebook) : this.serializer.serializeNotebook(notebook)); + notebook.metadata = { + rSessionId: view.client.manifest.id, + rGeneration: view.client.manifest.generation, + }; + await vscode.workspace.fs.writeFile( + file, + selected.format === 'ipynb' + ? this.serializer.exportIpynb(notebook) + : this.serializer.serializeNotebook(notebook), + ); } private disposeView(view: InteractiveView, forget = true): void { - if (view.disposed) { return; } - view.disposed = true; clearTimeout(view.timer); clearTimeout(view.reconnectTimer); - view.client.close(); for (const { task } of view.executions.values()) { task.end(undefined); } + if (view.disposed) { + return; + } + view.disposed = true; + clearTimeout(view.timer); + clearTimeout(view.reconnectTimer); + view.client.close(); + for (const { task } of view.executions.values()) { + task.end(undefined); + } view.executions.clear(); - for (const controller of view.controllers) { controller.dispose(); } + for (const controller of view.controllers) { + controller.dispose(); + } session.unregisterSessionTransport(view.target); this.views.delete(`${view.client.manifest.id}:${view.client.manifest.generation}`); if (this.active === view) { - this.active = undefined; this.routing = false; + this.active = undefined; + this.routing = false; this.status.hide(); void vscode.commands.executeCommand('setContext', 'r.interactive.active', false); - void vscode.commands.executeCommand('setContext', 'r.WorkspaceViewer:show', util.config().get('sessionWatcher', false)); + void vscode.commands.executeCommand( + 'setContext', + 'r.WorkspaceViewer:show', + util.config().get('sessionWatcher', false), + ); } if (!this.closed && forget) { this.savedConnections.delete(view.client.manifest.id); @@ -1629,9 +3331,15 @@ export class InteractiveManager implements vscode.Disposable, vscode.TreeDataPro } dispose(): void { - this.closed = true; setInteractiveExecutor(undefined); - for (const view of this.views.values()) { this.disposeView(view); } - for (const disposable of this.disposables) { disposable.dispose(); } - this.changes.dispose(); this.output.dispose(); + this.closed = true; + setInteractiveExecutor(undefined); + for (const view of this.views.values()) { + this.disposeView(view); + } + for (const disposable of this.disposables) { + disposable.dispose(); + } + this.changes.dispose(); + this.output.dispose(); } } diff --git a/src/interactive/nodeExecutable.ts b/src/interactive/nodeExecutable.ts index 69d317a38..fa77b67cd 100644 --- a/src/interactive/nodeExecutable.ts +++ b/src/interactive/nodeExecutable.ts @@ -12,10 +12,14 @@ export function hostNodeRuntime(): NodeRuntime { /** Empty uses the extension host; explicit overrides must resolve on the R host. */ export function resolveNodeRuntime(command: string, directory: string): NodeRuntime { - if (!command.trim()) { return hostNodeRuntime(); } + if (!command.trim()) { + return hostNodeRuntime(); + } const executable = resolveExecutable(command, directory); if (!executable) { - throw new Error(`Cannot find an executable Node.js runtime “${command}”. Set r.interactive.nodePath to a Node.js 18+ executable on the R host (the remote server when using Remote SSH), or clear it to use VS Code's runtime.`); + throw new Error( + `Cannot find an executable Node.js runtime “${command}”. Set r.interactive.nodePath to a Node.js 18+ executable on the R host (the remote server when using Remote SSH), or clear it to use VS Code's runtime.`, + ); } return { executable, electron: false }; } diff --git a/src/interactive/notebook.ts b/src/interactive/notebook.ts index 72b3a2067..dec3bfe06 100644 --- a/src/interactive/notebook.ts +++ b/src/interactive/notebook.ts @@ -12,96 +12,222 @@ interface SavedCell { export const DISPLAY_MIME = 'application/vnd.vscode-r.display+json'; /** Native Interactive uses the IPython serializer, which retains only metadata.metadata. */ -export function persistentCellMetadata(metadata: Record, languageId: string): Record { +export function persistentCellMetadata( + metadata: Record, + languageId: string, +): Record { const saved = metadata.metadata as Record | undefined; - return { ...metadata, metadata: { ...saved, - ...Object.fromEntries(Object.entries(metadata).filter(([key]) => key.startsWith('r'))), - vscode: { ...saved?.vscode as Record | undefined, languageId } } }; + return { + ...metadata, + metadata: { + ...saved, + ...Object.fromEntries(Object.entries(metadata).filter(([key]) => key.startsWith('r'))), + vscode: { ...(saved?.vscode as Record | undefined), languageId }, + }, + }; } export function restoredCellMetadata(metadata: Record): Record { - return { ...metadata.metadata as Record | undefined, ...metadata }; + return { ...(metadata.metadata as Record | undefined), ...metadata }; } -export function persistentNotebookMetadata(metadata: Record, sessionId: string, generation: string): Record { +export function persistentNotebookMetadata( + metadata: Record, + sessionId: string, + generation: string, +): Record { const saved = metadata.metadata as Record | undefined; - return { ...metadata, rSessionId: sessionId, rGeneration: generation, - metadata: { ...saved, language_info: { ...saved?.language_info as Record | undefined, name: 'r' } } }; + return { + ...metadata, + rSessionId: sessionId, + rGeneration: generation, + metadata: { + ...saved, + language_info: { + ...(saved?.language_info as Record | undefined), + name: 'r', + }, + }, + }; } export class InteractiveSerializer implements vscode.NotebookSerializer { exportIpynb(data: vscode.NotebookData): Uint8Array { - const cells = data.cells.map(cell => ({ + const cells = data.cells.map((cell) => ({ cell_type: cell.kind === vscode.NotebookCellKind.Markup ? 'markdown' : 'code', - source: cell.value, metadata: {}, - ...(cell.kind === vscode.NotebookCellKind.Code ? { - execution_count: cell.executionSummary?.executionOrder ?? null, - outputs: (cell.outputs ?? []).flatMap((output): Record[] => { - const stream = output.items.find(item => /vnd\.code\.notebook\.(stdout|stderr)$/.test(item.mime)); - if (stream) { return [{ output_type: 'stream', name: stream.mime.endsWith('stderr') ? 'stderr' : 'stdout', text: Buffer.from(stream.data).toString() }]; } - const error = output.items.find(item => item.mime === 'application/vnd.code.notebook.error'); - if (error) { - const value = JSON.parse(Buffer.from(error.data).toString()) as { name: string; message: string; stack?: string }; - return [{ output_type: 'error', ename: value.name, evalue: value.message, traceback: (value.stack ?? '').split('\n') }]; - } - const custom = output.items.find(item => item.mime === DISPLAY_MIME); - const display = custom && JSON.parse(Buffer.from(custom.data).toString()) as Record | undefined; - if (display?.kind === 'plot' && Array.isArray(display.pages)) { - return (display.pages as Record[]).map((page, index) => { - const mime: Record = { 'text/plain': `R plot ${index + 1} of ${(display.pages as unknown[]).length}` }; - if (typeof page.svgData === 'string') { mime['image/svg+xml'] = Buffer.from(page.svgData, 'base64').toString('utf8'); } - else if (typeof page.imageData === 'string' && typeof page.mime === 'string') { - mime[page.mime] = page.mime === 'image/svg+xml' ? Buffer.from(page.imageData, 'base64').toString('utf8') : page.imageData; - } - return { output_type: 'display_data', data: mime, metadata: {} }; - }); - } - const mime: Record = {}; - for (const item of output.items) { - if (item.mime === DISPLAY_MIME) { continue; } - mime[item.mime] = Buffer.from(item.data).toString(/^image\/(png|jpeg|gif)$/.test(item.mime) ? 'base64' : 'utf8'); - } - return [{ output_type: 'display_data', data: mime, metadata: {} }]; - }), - } : {}), + source: cell.value, + metadata: {}, + ...(cell.kind === vscode.NotebookCellKind.Code + ? { + execution_count: cell.executionSummary?.executionOrder ?? null, + outputs: (cell.outputs ?? []).flatMap((output): Record[] => { + const stream = output.items.find((item) => + /vnd\.code\.notebook\.(stdout|stderr)$/.test(item.mime), + ); + if (stream) { + return [ + { + output_type: 'stream', + name: stream.mime.endsWith('stderr') ? 'stderr' : 'stdout', + text: Buffer.from(stream.data).toString(), + }, + ]; + } + const error = output.items.find( + (item) => item.mime === 'application/vnd.code.notebook.error', + ); + if (error) { + const value = JSON.parse(Buffer.from(error.data).toString()) as { + name: string; + message: string; + stack?: string; + }; + return [ + { + output_type: 'error', + ename: value.name, + evalue: value.message, + traceback: (value.stack ?? '').split('\n'), + }, + ]; + } + const custom = output.items.find((item) => item.mime === DISPLAY_MIME); + const display = + custom && + (JSON.parse(Buffer.from(custom.data).toString()) as + | Record + | undefined); + if (display?.kind === 'plot' && Array.isArray(display.pages)) { + return (display.pages as Record[]).map( + (page, index) => { + const mime: Record = { + 'text/plain': `R plot ${index + 1} of ${(display.pages as unknown[]).length}`, + }; + if (typeof page.svgData === 'string') { + mime['image/svg+xml'] = Buffer.from( + page.svgData, + 'base64', + ).toString('utf8'); + } else if ( + typeof page.imageData === 'string' && + typeof page.mime === 'string' + ) { + mime[page.mime] = + page.mime === 'image/svg+xml' + ? Buffer.from(page.imageData, 'base64').toString( + 'utf8', + ) + : page.imageData; + } + return { + output_type: 'display_data', + data: mime, + metadata: {}, + }; + }, + ); + } + const mime: Record = {}; + for (const item of output.items) { + if (item.mime === DISPLAY_MIME) { + continue; + } + mime[item.mime] = Buffer.from(item.data).toString( + /^image\/(png|jpeg|gif)$/.test(item.mime) ? 'base64' : 'utf8', + ); + } + return [{ output_type: 'display_data', data: mime, metadata: {} }]; + }), + } + : {}), })); - return Buffer.from(JSON.stringify({ nbformat: 4, nbformat_minor: 4, - metadata: { kernelspec: { name: 'ir', display_name: 'R', language: 'R' }, language_info: { name: 'R' } }, cells }, null, 2)); + return Buffer.from( + JSON.stringify( + { + nbformat: 4, + nbformat_minor: 4, + metadata: { + kernelspec: { name: 'ir', display_name: 'R', language: 'R' }, + language_info: { name: 'R' }, + }, + cells, + }, + null, + 2, + ), + ); } deserializeNotebook(content: Uint8Array): vscode.NotebookData { - if (!content.length) { return new vscode.NotebookData([]); } - const saved = JSON.parse(Buffer.from(content).toString('utf8')) as { version: number; metadata: Record; cells: SavedCell[] }; - if (saved.version !== 1 || !Array.isArray(saved.cells)) { throw new Error('Unsupported R Interactive notebook'); } - const cells = saved.cells.map(cell => { + if (!content.length) { + return new vscode.NotebookData([]); + } + const saved = JSON.parse(Buffer.from(content).toString('utf8')) as { + version: number; + metadata: Record; + cells: SavedCell[]; + }; + if (saved.version !== 1 || !Array.isArray(saved.cells)) { + throw new Error('Unsupported R Interactive notebook'); + } + const cells = saved.cells.map((cell) => { const data = new vscode.NotebookCellData(cell.kind, cell.value, cell.language); - data.metadata = cell.metadata; data.executionSummary = cell.summary; - data.outputs = cell.outputs.map(output => new vscode.NotebookCellOutput(output.items.map(item => - new vscode.NotebookCellOutputItem(Buffer.from(item.data, 'base64'), item.mime)), output.metadata)); + data.metadata = cell.metadata; + data.executionSummary = cell.summary; + data.outputs = cell.outputs.map( + (output) => + new vscode.NotebookCellOutput( + output.items.map( + (item) => + new vscode.NotebookCellOutputItem( + Buffer.from(item.data, 'base64'), + item.mime, + ), + ), + output.metadata, + ), + ); return data; }); - const notebook = new vscode.NotebookData(cells); notebook.metadata = saved.metadata; + const notebook = new vscode.NotebookData(cells); + notebook.metadata = saved.metadata; return notebook; } serializeNotebook(data: vscode.NotebookData): Uint8Array { - const cells: SavedCell[] = data.cells.map(cell => ({ - kind: cell.kind, value: cell.value, language: cell.languageId, metadata: cell.metadata ?? {}, + const cells: SavedCell[] = data.cells.map((cell) => ({ + kind: cell.kind, + value: cell.value, + language: cell.languageId, + metadata: cell.metadata ?? {}, summary: cell.executionSummary, - outputs: (cell.outputs ?? []).map(output => ({ metadata: output.metadata, - items: output.items.map(item => { + outputs: (cell.outputs ?? []).map((output) => ({ + metadata: output.metadata, + items: output.items.map((item) => { let bytes = item.data; if (item.mime === DISPLAY_MIME) { - const display = JSON.parse(Buffer.from(bytes).toString('utf8')) as Record; + const display = JSON.parse(Buffer.from(bytes).toString('utf8')) as Record< + string, + unknown + >; // Forwarding URLs contain transient credentials. Retain asset identities only. - for (const page of [display, ...(Array.isArray(display.pages) ? display.pages as Record[] : [])]) { - delete page.url; page.connected = false; + for (const page of [ + display, + ...(Array.isArray(display.pages) + ? (display.pages as Record[]) + : []), + ]) { + delete page.url; + page.connected = false; } bytes = Buffer.from(JSON.stringify(display)); } return { mime: item.mime, data: Buffer.from(bytes).toString('base64') }; - }) })), + }), + })), })); - return Buffer.from(JSON.stringify({ version: 1, metadata: data.metadata ?? {}, cells }, null, 2)); + return Buffer.from( + JSON.stringify({ version: 1, metadata: data.metadata ?? {}, cells }, null, 2), + ); } } diff --git a/src/interactive/plotSvg.ts b/src/interactive/plotSvg.ts index 96e61e1d8..8c4d09299 100644 --- a/src/interactive/plotSvg.ts +++ b/src/interactive/plotSvg.ts @@ -1,115 +1,203 @@ import type { PlotFrame } from '../plotViewer/jgdPlotHistory'; interface GraphicsContext { - col?: string | null; fill?: string | null; lwd?: number; lty?: number[]; - lend?: string; ljoin?: string; + col?: string | null; + fill?: string | null; + lwd?: number; + lty?: number[]; + lend?: string; + ljoin?: string; font?: { family?: string; size?: number; face?: number }; } interface Operation { - op: string; gc?: GraphicsContext; - x: number & number[]; y: number & number[]; - x0: number; y0: number; x1: number; y1: number; x2: number; y2: number; - r: number; str: string; rot: number; hadj: number; - w: number; h: number; data: string; interpolate?: boolean; winding: string; subpaths: number[][][]; + op: string; + gc?: GraphicsContext; + x: number & number[]; + y: number & number[]; + x0: number; + y0: number; + x1: number; + y1: number; + x2: number; + y2: number; + r: number; + str: string; + rot: number; + hadj: number; + w: number; + h: number; + data: string; + interpolate?: boolean; + winding: string; + subpaths: number[][][]; ext?: { opacity?: number; blendMode?: string; filter?: string }; } export function escapeXml(value: unknown): string { - const text = value === null || value === undefined ? '' - : typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' - || typeof value === 'bigint' || typeof value === 'symbol' ? String(value) : JSON.stringify(value); - return text.replace(/[&<>"']/g, character => ({ - '&': '&', '<': '<', '>': '>', '"': '"', '\'': ''', - }[character]!)); + const text = + value === null || value === undefined + ? '' + : typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' || + typeof value === 'bigint' || + typeof value === 'symbol' + ? String(value) + : JSON.stringify(value); + return text.replace( + /[&<>"']/g, + (character) => + ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + })[character]!, + ); } /** JGD's portable retained representation, also used when no browser is attached. */ export function plotToSvg(plot: PlotFrame): string { - const n = (value: unknown): number => typeof value === 'number' && Number.isFinite(value) ? value : 0; + const n = (value: unknown): number => + typeof value === 'number' && Number.isFinite(value) ? value : 0; // JGD coordinates are device pixels, but font sizes are R points (1/72 in). const fontScale = (n(plot.device.dpi) > 0 ? n(plot.device.dpi) : 96) / 72; - const color = (value: unknown): string => escapeXml(typeof value === 'string' && - !/[<>]|url\s*\(/i.test(value) ? value : 'none'); - const parts = [``]; + const color = (value: unknown): string => + escapeXml(typeof value === 'string' && !/[<>]|url\s*\(/i.test(value) ? value : 'none'); + const parts = [ + ``, + ]; parts.push(``); let clip = 0; let currentClip: number | undefined; let clipOpen = false; const groups: (number | undefined)[] = []; const closeClip = (): void => { - if (clipOpen) { parts.push(''); clipOpen = false; } + if (clipOpen) { + parts.push(''); + clipOpen = false; + } }; const openClip = (): void => { - if (currentClip !== undefined) { parts.push(``); clipOpen = true; } + if (currentClip !== undefined) { + parts.push(``); + clipOpen = true; + } }; for (const op of plot.ops as Operation[]) { const gc = op.gc ?? {}; - const stroke = ` stroke="${color(gc.col)}" stroke-width="${n(gc.lwd ?? 1)}" stroke-linecap="${escapeXml(gc.lend ?? 'round')}" stroke-linejoin="${escapeXml(gc.ljoin ?? 'round')}"` + + const stroke = + ` stroke="${color(gc.col)}" stroke-width="${n(gc.lwd ?? 1)}" stroke-linecap="${escapeXml(gc.lend ?? 'round')}" stroke-linejoin="${escapeXml(gc.ljoin ?? 'round')}"` + (Array.isArray(gc.lty) ? ` stroke-dasharray="${gc.lty.map(n).join(',')}"` : ''); const paint = ` fill="${color(gc.fill)}"${stroke}`; switch (op.op) { case 'clip': - closeClip(); currentClip = ++clip; - parts.push(``); - openClip(); break; + closeClip(); + currentClip = ++clip; + parts.push( + ``, + ); + openClip(); + break; case 'line': - parts.push(``); break; + parts.push( + ``, + ); + break; case 'rect': // SVG omits zero-area rectangles entirely; R still strokes // their border (e.g. empty bins in a marginal histogram). if (n(op.x0) === n(op.x1) || n(op.y0) === n(op.y1)) { - parts.push(``); + parts.push( + ``, + ); } else { - parts.push(``); + parts.push( + ``, + ); } break; case 'circle': - parts.push(``); break; - case 'polyline': case 'polygon': { - const points = Array.isArray(op.x) && Array.isArray(op.y) - ? op.x.map((x, index) => `${n(x)},${n(op.y[index])}`).join(' ') : ''; - parts.push(`<${op.op} points="${points}"${op.op === 'polyline' ? ` fill="none"${stroke}` : paint}/>`); + parts.push(``); + break; + case 'polyline': + case 'polygon': { + const points = + Array.isArray(op.x) && Array.isArray(op.y) + ? op.x.map((x, index) => `${n(x)},${n(op.y[index])}`).join(' ') + : ''; + parts.push( + `<${op.op} points="${points}"${op.op === 'polyline' ? ` fill="none"${stroke}` : paint}/>`, + ); break; } case 'path': { - const d = (op.subpaths ?? []).map(points => points.map((point, index) => - `${index ? 'L' : 'M'}${n(point[0])} ${n(point[1])}`).join('') + 'Z').join(''); - parts.push(``); + const d = (op.subpaths ?? []) + .map( + (points) => + points + .map( + (point, index) => + `${index ? 'L' : 'M'}${n(point[0])} ${n(point[1])}`, + ) + .join('') + 'Z', + ) + .join(''); + parts.push( + ``, + ); break; } case 'text': { const font = gc.font ?? {}; const face = font.face ?? 1; const anchor = op.hadj === 0.5 ? 'middle' : op.hadj === 1 ? 'end' : 'start'; - parts.push(`${escapeXml(op.str)}`); + parts.push( + `${escapeXml(op.str)}`, + ); break; } case 'raster': { - if (!/^data:image\/(png|jpeg);base64,[a-zA-Z0-9+/=\s]+$/.test(op.data)) { break; } - const w = Math.abs(n(op.w)), h = Math.abs(n(op.h)); + if (!/^data:image\/(png|jpeg);base64,[a-zA-Z0-9+/=\s]+$/.test(op.data)) { + break; + } + const w = Math.abs(n(op.w)), + h = Math.abs(n(op.h)); // R anchors rasters at (xleft, ybottom) and rotates about that // point. JGD's downward device Y axis gives ordinary rasters // a negative height; reversed extents mirror the pixels. const transform = `translate(${n(op.x)} ${n(op.y)}) rotate(${-n(op.rot)}) scale(${n(op.w) < 0 ? -1 : 1} ${n(op.h) > 0 ? -1 : 1})`; - parts.push(``); + parts.push( + ``, + ); break; } case 'beginGroup': // A clip is replaceable drawing state, not a permanent mask on // the group. Move it inside so later clips can expand beyond it // (e.g. from a base-graphics panel to its axes and title). - closeClip(); groups.push(currentClip); + closeClip(); + groups.push(currentClip); parts.push(``); - openClip(); break; + openClip(); + break; case 'endGroup': - if (!groups.length) { break; } - closeClip(); parts.push(''); - currentClip = groups.pop(); openClip(); + if (!groups.length) { + break; + } + closeClip(); + parts.push(''); + currentClip = groups.pop(); + openClip(); break; } } closeClip(); - while (groups.length) { groups.pop(); parts.push(''); } + while (groups.length) { + groups.pop(); + parts.push(''); + } parts.push(''); return parts.join(''); } diff --git a/src/interactive/protocol.ts b/src/interactive/protocol.ts index 06a3b4199..37d9a998e 100644 --- a/src/interactive/protocol.ts +++ b/src/interactive/protocol.ts @@ -5,7 +5,14 @@ export const MAX_CODE_BYTES = 1024 * 1024; export const DEFAULT_MAX_ASSET_BYTES = 1024 * 1024 * 1024; export type ProviderKind = 'r' | 'arf' | 'arf-existing'; -export type ExecutionState = 'queued' | 'running' | 'success' | 'error' | 'interrupted' | 'cancelled' | 'unknown'; +export type ExecutionState = + | 'queued' + | 'running' + | 'success' + | 'error' + | 'interrupted' + | 'cancelled' + | 'unknown'; export interface SessionIdentity { id: string; @@ -91,7 +98,10 @@ export interface AgentSettings extends SessionIdentity { } /** Persisted backend descriptor. Its definition validates and owns its options. */ -export interface BackendDescriptor { kind: string; options: Record } +export interface BackendDescriptor { + kind: string; + options: Record; +} export interface AgentConfig extends AgentSettings { backend?: BackendDescriptor; @@ -112,7 +122,11 @@ export interface Request { export function sessionLabel(value: unknown): string { const label = typeof value === 'string' ? value.trim() : ''; - if (!label || label.length > 80 || label.split('').some(char => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127)) { + if ( + !label || + label.length > 80 || + label.split('').some((char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127) + ) { throw new Error('Use a session name of 1–80 characters without control characters'); } return label; @@ -135,17 +149,28 @@ export function identifier(value: unknown): string { export function submission(value: unknown): Submission { const data = object(value); const id = identifier(data.id); - if (typeof data.code !== 'string' || !data.code.trim() || Buffer.byteLength(data.code) > MAX_CODE_BYTES) { + if ( + typeof data.code !== 'string' || + !data.code.trim() || + Buffer.byteLength(data.code) > MAX_CODE_BYTES + ) { throw new Error('Code must be nonempty and no larger than 1 MiB'); } let source: SourceLocation | undefined; if (data.source) { const location = object(data.source); - if (typeof location.uri !== 'string' || typeof location.line !== 'number' || location.line < 0) { + if ( + typeof location.uri !== 'string' || + typeof location.line !== 'number' || + location.line < 0 + ) { throw new Error('Invalid source location'); } - source = { uri: location.uri, line: location.line, - version: typeof location.version === 'number' ? location.version : undefined }; + source = { + uri: location.uri, + line: location.line, + version: typeof location.version === 'number' ? location.version : undefined, + }; } return { id, code: data.code, source }; } diff --git a/src/interactive/renderer.ts b/src/interactive/renderer.ts index 3ef312bc3..07ab973dc 100644 --- a/src/interactive/renderer.ts +++ b/src/interactive/renderer.ts @@ -1,72 +1,136 @@ /// import { createInlineTable, InlineTable, InlineTableState, inlineTableStyle } from './inlineTable'; -import { createListViewer, ListViewer, ListViewNavigation, ListViewPage, ListViewReply, listViewerStyle } from '../listViewer'; +import { + createListViewer, + ListViewer, + ListViewNavigation, + ListViewPage, + ListViewReply, + listViewerStyle, +} from '../listViewer'; import { toolbarButton as button, toolbarStyle } from './rendererToolbar'; -interface OutputItem { id: string; json(): Record } +interface OutputItem { + id: string; + json(): Record; +} interface RendererContext { postMessage(message: unknown): void; onDidReceiveMessage(listener: (message: Record) => void): { dispose(): void }; } -export function activate(context: RendererContext): { renderOutputItem(item: OutputItem, element: HTMLElement): void; disposeOutputItem(id?: string): void } { +export function activate(context: RendererContext): { + renderOutputItem(item: OutputItem, element: HTMLElement): void; + disposeOutputItem(id?: string): void; +} { interface OutputState { - element: HTMLElement; data: Record; - table?: InlineTable; list?: ListViewer; - saveRequest?: number; saveButton?: HTMLButtonElement; savePlot?(format: string): void; updatePlotPaging?(): void; + element: HTMLElement; + data: Record; + table?: InlineTable; + list?: ListViewer; + saveRequest?: number; + saveButton?: HTMLButtonElement; + savePlot?(format: string): void; + updatePlotPaging?(): void; dispose?(): void; } const outputs = new Map(); const tables = new Map(); // Output IDs change on replaceOutput. Keep local choices until the host has // persisted them, and while browsing a saved notebook without a live host. - const choices = new Map(); - const remember = (key: string, value: { tableView?: string; listView?: string; selectedPlot?: string }): void => { - choices.delete(key); choices.set(key, value); - while (choices.size > 1000) { choices.delete(choices.keys().next().value as string); } + const choices = new Map< + string, + { tableView?: string; listView?: string; selectedPlot?: string } + >(); + const remember = ( + key: string, + value: { tableView?: string; listView?: string; selectedPlot?: string }, + ): void => { + choices.delete(key); + choices.set(key, value); + while (choices.size > 1000) { + choices.delete(choices.keys().next().value as string); + } }; let requestId = 0; - const send = (item: OutputItem, data: Record, action: string, extra: Record = {}): void => { - context.postMessage({ outputId: item.id, displayId: data.displayId, generation: data.generation, action, ...extra }); + const send = ( + item: OutputItem, + data: Record, + action: string, + extra: Record = {}, + ): void => { + context.postMessage({ + outputId: item.id, + displayId: data.displayId, + generation: data.generation, + action, + ...extra, + }); }; - const connectionHint = (data: Record): string => data.connected === false ? 'Reconnect to use live controls' - : data.running === false ? 'R has stopped · saved output is still available' - : data.archived === true ? 'Previous R process · run the code again for live controls' : ''; - context.onDidReceiveMessage(message => { + const connectionHint = (data: Record): string => + data.connected === false + ? 'Reconnect to use live controls' + : data.running === false + ? 'R has stopped · saved output is still available' + : data.archived === true + ? 'Previous R process · run the code again for live controls' + : ''; + context.onDidReceiveMessage((message) => { const output = outputs.get(String(message.outputId)); - if (!output) { return; } + if (!output) { + return; + } const status = output.element.querySelector('[data-status]'); if (message.action === 'page') { output.table?.reply(message); } else if (['listPage', 'listNavigate', 'listItem'].includes(String(message.action))) { - output.list?.reply({ ...message.result as ListViewReply, requestId: Number(message.requestId), error: typeof message.error === 'string' ? message.error : undefined }); + output.list?.reply({ + ...(message.result as ListViewReply), + requestId: Number(message.requestId), + error: typeof message.error === 'string' ? message.error : undefined, + }); } else if (message.action === 'saveAs') { - if (message.requestId !== output.saveRequest || output.saveRequest === undefined) { return; } + if (message.requestId !== output.saveRequest || output.saveRequest === undefined) { + return; + } output.saveRequest = undefined; if (output.saveButton) { output.saveButton.disabled = output.data.connected === false; output.saveButton.focus({ preventScroll: true }); } output.updatePlotPaging?.(); - if (message.error && status) { status.textContent = typeof message.error === 'string' ? message.error : JSON.stringify(message.error); } - else if (typeof message.result === 'string') { output.savePlot?.(message.result); } + if (message.error && status) { + status.textContent = + typeof message.error === 'string' + ? message.error + : JSON.stringify(message.error); + } else if (typeof message.result === 'string') { + output.savePlot?.(message.result); + } } else if (message.error && status) { - status.textContent = typeof message.error === 'string' ? message.error : JSON.stringify(message.error); + status.textContent = + typeof message.error === 'string' ? message.error : JSON.stringify(message.error); } }); return { renderOutputItem(item, element) { outputs.get(item.id)?.dispose?.(); - const data = item.json(); element.replaceChildren(); + const data = item.json(); + element.replaceChildren(); const choiceKey = `${typeof data.generation === 'string' ? data.generation : ''}:${typeof data.displayId === 'string' ? data.displayId : item.id}`; const choice = choices.get(choiceKey); const connected = data.connected !== false; const live = connected && data.archived !== true && data.running !== false; element.classList.add('r-interactive-output'); - element.style.cssText = 'color:var(--vscode-editor-foreground);font-family:var(--vscode-font-family)'; + element.style.cssText = + 'color:var(--vscode-editor-foreground);font-family:var(--vscode-font-family)'; const style = document.createElement('style'); - style.textContent = toolbarStyle + inlineTableStyle + listViewerStyle + ` + style.textContent = + toolbarStyle + + inlineTableStyle + + listViewerStyle + + ` .r-interactive-output .r-list-viewer .arrow{display:inline-block;width:8px;height:8px;margin:4px} .r-interactive-output .r-list-viewer .codicon-chevron-right{display:inline-block;width:6px;height:6px;border-top:1px solid currentColor;border-right:1px solid currentColor;transform:rotate(45deg);margin:4px} .r-interactive-output .r-list-viewer details[open] > summary > .arrow{transform:rotate(135deg)} @@ -75,120 +139,286 @@ export function activate(context: RendererContext): { renderOutputItem(item: Out .r-interactive-output .r-list-viewer .label{min-width:64px} `; element.append(style); - const state: OutputState = { element, data: { ...data } }; outputs.set(item.id, state); - const toolbar = document.createElement('div'); toolbar.className = 'r-interactive-toolbar'; - toolbar.setAttribute('role', 'group'); toolbar.setAttribute('aria-label', 'Output actions'); - const status = document.createElement('span'); status.dataset.status = ''; - status.setAttribute('role', 'status'); status.setAttribute('aria-live', 'polite'); + const state: OutputState = { element, data: { ...data } }; + outputs.set(item.id, state); + const toolbar = document.createElement('div'); + toolbar.className = 'r-interactive-toolbar'; + toolbar.setAttribute('role', 'group'); + toolbar.setAttribute('aria-label', 'Output actions'); + const status = document.createElement('span'); + status.dataset.status = ''; + status.setAttribute('role', 'status'); + status.setAttribute('aria-live', 'polite'); if (data.kind === 'table') { - state.table = createInlineTable(element, toolbar, status, data, data.displayId ? tables.get(choiceKey) : undefined, - query => { const id = ++requestId; send(item, data, 'page', { ...query, requestId: id }); return id; }, - table => { - if (!data.displayId) { return; } - tables.delete(choiceKey); tables.set(choiceKey, table); - while (tables.size > 32) { tables.delete(tables.keys().next().value as string); } - }, connectionHint(data)); - const printed = document.createElement('pre'); printed.dataset.printed = ''; - printed.style.cssText = 'white-space:pre;overflow:auto;max-height:460px;font-family:var(--vscode-editor-font-family,monospace);font-size:var(--vscode-editor-font-size,12px)'; + state.table = createInlineTable( + element, + toolbar, + status, + data, + data.displayId ? tables.get(choiceKey) : undefined, + (query) => { + const id = ++requestId; + send(item, data, 'page', { ...query, requestId: id }); + return id; + }, + (table) => { + if (!data.displayId) { + return; + } + tables.delete(choiceKey); + tables.set(choiceKey, table); + while (tables.size > 32) { + tables.delete(tables.keys().next().value as string); + } + }, + connectionHint(data), + ); + const printed = document.createElement('pre'); + printed.dataset.printed = ''; + printed.style.cssText = + 'white-space:pre;overflow:auto;max-height:460px;font-family:var(--vscode-editor-font-family,monospace);font-size:var(--vscode-editor-font-size,12px)'; printed.textContent = typeof data.printedText === 'string' ? data.printedText : ''; element.append(printed); - const open = button('Open data viewer', 'table', () => send(item, data, 'table'), 'Data viewer'); + const open = button( + 'Open data viewer', + 'table', + () => send(item, data, 'table'), + 'Data viewer', + ); if (data.fullViewId) { - open.title = 'Open the full table without copying it. Reference edits may appear here; reopen to refresh. Sorting and filtering scan the full data.'; + open.title = + 'Open the full table without copying it. Reference edits may appear here; reopen to refresh. Sorting and filtering scan the full data.'; } open.disabled = !live; const hasText = typeof data.printedText === 'string'; - state.data.tableView = hasText ? choice?.tableView ?? data.tableView ?? 'table' : 'table'; + state.data.tableView = hasText + ? (choice?.tableView ?? data.tableView ?? 'table') + : 'table'; const showView = (): void => { const text = state.data.tableView === 'text'; - printed.hidden = !text; state.table?.setTextMode(text); - toggle.replaceChildren(...Array.from(button('', text ? 'table' : 'text', () => undefined, text ? 'Table' : 'Text').childNodes)); + printed.hidden = !text; + state.table?.setTextMode(text); + toggle.replaceChildren( + ...Array.from( + button( + '', + text ? 'table' : 'text', + () => undefined, + text ? 'Table' : 'Text', + ).childNodes, + ), + ); toggle.title = text ? 'Show table preview' : 'Show R printed output'; - toggle.setAttribute('aria-label', toggle.title); toggle.disabled = !hasText; - if (!hasText) { toggle.title = data.printError ? `R printout unavailable: ${typeof data.printError === 'string' ? data.printError : JSON.stringify(data.printError)}` : 'Printed output unavailable. Run the code in a session started with the current extension.'; } + toggle.setAttribute('aria-label', toggle.title); + toggle.disabled = !hasText; + if (!hasText) { + toggle.title = data.printError + ? `R printout unavailable: ${typeof data.printError === 'string' ? data.printError : JSON.stringify(data.printError)}` + : 'Printed output unavailable. Run the code in a session started with the current extension.'; + } + }; + const toggle = button( + 'Show R printed output', + 'text', + () => { + state.data.tableView = state.data.tableView === 'text' ? 'table' : 'text'; + remember(choiceKey, { tableView: String(state.data.tableView) }); + send(item, data, 'tableView', { mode: state.data.tableView }); + showView(); + }, + 'Text', + ); + toolbar.prepend(open, toggle); + showView(); + state.dispose = () => { + state.table?.dispose(); + open.onclick = null; + toggle.onclick = null; }; - const toggle = button('Show R printed output', 'text', () => { - state.data.tableView = state.data.tableView === 'text' ? 'table' : 'text'; - remember(choiceKey, { tableView: String(state.data.tableView) }); - send(item, data, 'tableView', { mode: state.data.tableView }); showView(); - }, 'Text'); - toolbar.prepend(open, toggle); showView(); - state.dispose = () => { state.table?.dispose(); open.onclick = null; toggle.onclick = null; }; } else if (data.kind === 'list') { - const tree = document.createElement('div'); tree.className = 'r-list-viewer'; tree.dataset.list = ''; - tree.style.cssText = 'display:flex;flex-direction:column;max-height:460px;overflow:hidden'; - const navigation = document.createElement('div'); navigation.className = 'navigation'; - const back = button('Back', 'previous', () => undefined); back.classList.add('back'); - const breadcrumbs = document.createElement('nav'); breadcrumbs.className = 'breadcrumbs'; - breadcrumbs.setAttribute('aria-label', 'Object path'); navigation.append(back, breadcrumbs); - const navigationStatus = document.createElement('div'); navigationStatus.className = 'navigation-status'; + const tree = document.createElement('div'); + tree.className = 'r-list-viewer'; + tree.dataset.list = ''; + tree.style.cssText = + 'display:flex;flex-direction:column;max-height:460px;overflow:hidden'; + const navigation = document.createElement('div'); + navigation.className = 'navigation'; + const back = button('Back', 'previous', () => undefined); + back.classList.add('back'); + const breadcrumbs = document.createElement('nav'); + breadcrumbs.className = 'breadcrumbs'; + breadcrumbs.setAttribute('aria-label', 'Object path'); + navigation.append(back, breadcrumbs); + const navigationStatus = document.createElement('div'); + navigationStatus.className = 'navigation-status'; navigationStatus.setAttribute('role', 'status'); - const list = document.createElement('div'); list.className = 'list'; + const list = document.createElement('div'); + list.className = 'list'; const reset = button('Reset list view', 'reset', () => undefined, 'Reset'); - reset.title = 'Return to the initial list, collapse expanded rows, and clear navigation history'; - tree.append(navigation, navigationStatus, list); element.append(tree); - const initial = data.navigation as ListViewNavigation ?? { title: 'List', path: [], breadcrumbs: [{ label: 'List', path: [] }] }; - state.list = createListViewer({ root: tree, list, back, reset, breadcrumbs, navigationStatus }, initial, message => { - const id = ++requestId; - const action = message.message === 'listview/page' ? 'listPage' : message.message === 'listview/navigate' ? 'listNavigate' : 'listItem'; - send(item, data, action, { path: message.path, index: message.index, start: message.start, requestId: id }); return id; - }, Array.isArray(data.children) ? data as unknown as ListViewPage : undefined, live); - const printed = document.createElement('pre'); printed.dataset.printed = ''; - printed.style.cssText = 'white-space:pre;overflow:auto;max-height:460px;font-family:var(--vscode-editor-font-family,monospace);font-size:var(--vscode-editor-font-size,12px)'; - printed.textContent = typeof data.printedText === 'string' ? data.printedText : ''; element.append(printed); - const open = button('Open list viewer', 'list', () => send(item, data, 'list', { path: state.list?.navigation().path }), 'List viewer'); + reset.title = + 'Return to the initial list, collapse expanded rows, and clear navigation history'; + tree.append(navigation, navigationStatus, list); + element.append(tree); + const initial = (data.navigation as ListViewNavigation) ?? { + title: 'List', + path: [], + breadcrumbs: [{ label: 'List', path: [] }], + }; + state.list = createListViewer( + { root: tree, list, back, reset, breadcrumbs, navigationStatus }, + initial, + (message) => { + const id = ++requestId; + const action = + message.message === 'listview/page' + ? 'listPage' + : message.message === 'listview/navigate' + ? 'listNavigate' + : 'listItem'; + send(item, data, action, { + path: message.path, + index: message.index, + start: message.start, + requestId: id, + }); + return id; + }, + Array.isArray(data.children) ? (data as unknown as ListViewPage) : undefined, + live, + ); + const printed = document.createElement('pre'); + printed.dataset.printed = ''; + printed.style.cssText = + 'white-space:pre;overflow:auto;max-height:460px;font-family:var(--vscode-editor-font-family,monospace);font-size:var(--vscode-editor-font-size,12px)'; + printed.textContent = typeof data.printedText === 'string' ? data.printedText : ''; + element.append(printed); + const open = button( + 'Open list viewer', + 'list', + () => send(item, data, 'list', { path: state.list?.navigation().path }), + 'List viewer', + ); open.disabled = !live; const hasText = typeof data.printedText === 'string'; let text = hasText && (choice?.listView ?? data.listView) === 'text'; const showView = (): void => { - tree.hidden = text; printed.hidden = !text; - toggle.replaceChildren(...Array.from(button('', text ? 'list' : 'text', () => undefined, text ? 'List' : 'Text').childNodes)); - toggle.title = text ? 'Show list preview' : 'Show R printed output'; toggle.setAttribute('aria-label', toggle.title); + tree.hidden = text; + printed.hidden = !text; + toggle.replaceChildren( + ...Array.from( + button( + '', + text ? 'list' : 'text', + () => undefined, + text ? 'List' : 'Text', + ).childNodes, + ), + ); + toggle.title = text ? 'Show list preview' : 'Show R printed output'; + toggle.setAttribute('aria-label', toggle.title); toggle.disabled = !hasText; - if (!hasText) { toggle.title = data.printError ? `R printout unavailable: ${typeof data.printError === 'string' ? data.printError : JSON.stringify(data.printError)}` : 'Printed output unavailable'; } + if (!hasText) { + toggle.title = data.printError + ? `R printout unavailable: ${typeof data.printError === 'string' ? data.printError : JSON.stringify(data.printError)}` + : 'Printed output unavailable'; + } + }; + const toggle = button( + 'Show R printed output', + 'text', + () => { + text = !text; + remember(choiceKey, { listView: text ? 'text' : 'list' }); + send(item, data, 'listView', { mode: text ? 'text' : 'list' }); + showView(); + }, + 'Text', + ); + toolbar.append(open, toggle, reset); + status.textContent = connectionHint(data); + showView(); + state.dispose = () => { + state.list?.dispose(); + open.onclick = null; + toggle.onclick = null; }; - const toggle = button('Show R printed output', 'text', () => { - text = !text; remember(choiceKey, { listView: text ? 'text' : 'list' }); - send(item, data, 'listView', { mode: text ? 'text' : 'list' }); showView(); - }, 'Text'); - toolbar.append(open, toggle, reset); status.textContent = connectionHint(data); showView(); - state.dispose = () => { state.list?.dispose(); open.onclick = null; toggle.onclick = null; }; } else if (data.kind === 'plot') { - const pages = (Array.isArray(data.pages) && data.pages.length ? data.pages : [data]) as Record[]; + const pages = ( + Array.isArray(data.pages) && data.pages.length ? data.pages : [data] + ) as Record[]; const selected = choice?.selectedPlot ?? data.selectedPlot; - let index = pages.findIndex(page => page.displayId === selected); - if (index < 0) { index = pages.length - 1; } - const current = (): Record => ({ ...pages[index], generation: data.generation }); - const image = document.createElement('img'); image.crossOrigin = 'anonymous'; - image.style.cssText = 'display:block;max-width:100%;height:auto'; element.append(image); + let index = pages.findIndex((page) => page.displayId === selected); + if (index < 0) { + index = pages.length - 1; + } + const current = (): Record => ({ + ...pages[index], + generation: data.generation, + }); + const image = document.createElement('img'); + image.crossOrigin = 'anonymous'; + image.style.cssText = 'display:block;max-width:100%;height:auto'; + element.append(image); const pngReady = (): boolean => image.complete && image.naturalWidth > 0; const savePng = (): void => { - const canvas = document.createElement('canvas'); canvas.width = image.naturalWidth; canvas.height = image.naturalHeight; + const canvas = document.createElement('canvas'); + canvas.width = image.naturalWidth; + canvas.height = image.naturalHeight; const ctx = canvas.getContext('2d'); try { - if (!ctx) { throw new Error('Canvas unavailable'); } - ctx.drawImage(image, 0, 0); send(item, current(), 'savePng', { image: canvas.toDataURL('image/png') }); - } catch { status.textContent = 'Open the plot to export this image.'; } + if (!ctx) { + throw new Error('Canvas unavailable'); + } + ctx.drawImage(image, 0, 0); + send(item, current(), 'savePng', { image: canvas.toDataURL('image/png') }); + } catch { + status.textContent = 'Open the plot to export this image.'; + } }; // The host's native picker does not resize or scroll the notebook output. - const save = button('Save plot as', 'save', () => { - state.saveRequest = ++requestId; save.disabled = true; - state.updatePlotPaging?.(); - send(item, current(), 'saveAs', { requestId: state.saveRequest, pngReady: pngReady() }); - }, 'Save…'); + const save = button( + 'Save plot as', + 'save', + () => { + state.saveRequest = ++requestId; + save.disabled = true; + state.updatePlotPaging?.(); + send(item, current(), 'saveAs', { + requestId: state.saveRequest, + pngReady: pngReady(), + }); + }, + 'Save…', + ); save.disabled = !connected; - save.setAttribute('aria-haspopup', 'dialog'); state.saveButton = save; - state.savePlot = format => { - if (!connected) { return; } - if (format === 'svg') { send(item, current(), 'save'); } - else if (format === 'png' && pngReady()) { savePng(); } + save.setAttribute('aria-haspopup', 'dialog'); + state.saveButton = save; + state.savePlot = (format) => { + if (!connected) { + return; + } + if (format === 'svg') { + send(item, current(), 'save'); + } else if (format === 'png' && pngReady()) { + savePng(); + } }; const open = button('Open plot', 'open', () => send(item, current(), 'plot')); open.disabled = !connected; - const fit = button('Fit R device to cell width', 'fit', () => send(item, current(), 'resize', { width: Math.round(element.clientWidth), height: 600 }), 'Fit R device'); + const fit = button( + 'Fit R device to cell width', + 'fit', + () => + send(item, current(), 'resize', { + width: Math.round(element.clientWidth), + height: 600, + }), + 'Fit R device', + ); const previous = button('Previous plot', 'previous', () => select(index - 1), ''); const next = button('Next plot', 'next', () => select(index + 1), ''); - const counter = document.createElement('span'); counter.dataset.plotPage = ''; + const counter = document.createElement('span'); + counter.dataset.plotPage = ''; counter.setAttribute('aria-live', 'polite'); state.updatePlotPaging = () => { previous.disabled = state.saveRequest !== undefined || index === 0; @@ -202,56 +432,100 @@ export function activate(context: RendererContext): { renderOutputItem(item: Out // Reserve the previous page's dimensions until the new image // loads, so equal-sized pages do not collapse between clicks. if (Number(page.width) > 0 && Number(page.height) > 0) { - image.width = Number(page.width); image.height = Number(page.height); + image.width = Number(page.width); + image.height = Number(page.height); } - image.alt = pages.length > 1 ? `R plot ${index + 1} of ${pages.length}` : 'R plot'; - image.src = typeof page.url === 'string' && page.url ? page.url : typeof page.imageData === 'string' && page.imageData - ? `data:${String(page.mime)};base64,${page.imageData}` : `data:image/svg+xml;base64,${typeof page.svgData === 'string' ? page.svgData : ''}`; + image.alt = + pages.length > 1 ? `R plot ${index + 1} of ${pages.length}` : 'R plot'; + image.src = + typeof page.url === 'string' && page.url + ? page.url + : typeof page.imageData === 'string' && page.imageData + ? `data:${String(page.mime)};base64,${page.imageData}` + : `data:image/svg+xml;base64,${typeof page.svgData === 'string' ? page.svgData : ''}`; state.updatePlotPaging?.(); }; const select = (page: number): void => { - if (state.saveRequest !== undefined || page < 0 || page >= pages.length) { return; } + if (state.saveRequest !== undefined || page < 0 || page >= pages.length) { + return; + } index = page; remember(choiceKey, { selectedPlot: String(pages[index].displayId) }); send(item, data, 'plotPage', { selectedPlot: pages[index].displayId }); - status.textContent = connectionHint(data); showPlot(); + status.textContent = connectionHint(data); + showPlot(); + }; + image.onload = () => { + image.width = image.naturalWidth; + image.height = image.naturalHeight; + }; + image.onerror = () => { + status.textContent = + 'Could not load this plot. Reconnect to the session and try again.'; }; - image.onload = () => { image.width = image.naturalWidth; image.height = image.naturalHeight; }; - image.onerror = () => { status.textContent = 'Could not load this plot. Reconnect to the session and try again.'; }; state.dispose = () => { - for (const control of [save, open, fit, previous, next]) { control.onclick = null; } - image.onload = null; image.onerror = null; + for (const control of [save, open, fit, previous, next]) { + control.onclick = null; + } + image.onload = null; + image.onerror = null; }; - if (pages.length > 1) { toolbar.append(previous, counter, next); } - toolbar.append(open, save, fit); showPlot(); + if (pages.length > 1) { + toolbar.append(previous, counter, next); + } + toolbar.append(open, save, fit); + showPlot(); } else if (data.kind === 'html' || data.kind === 'htmlText') { const iframe = document.createElement('iframe'); iframe.setAttribute('sandbox', 'allow-scripts allow-forms allow-downloads'); iframe.style.cssText = 'width:100%;height:500px;border:0;background:white'; if (data.kind === 'html') { - if (typeof data.url === 'string' && data.url) { iframe.src = data.url; } - else { iframe.srcdoc = '

Reconnect to view this widget, or use the portable HTML export.

'; } + if (typeof data.url === 'string' && data.url) { + iframe.src = data.url; + } else { + iframe.srcdoc = + '

Reconnect to view this widget, or use the portable HTML export.

'; + } + } else { + iframe.srcdoc = String(data.text); } - else { iframe.srcdoc = String(data.text); } element.append(iframe); toolbar.append(button('Open viewer', 'open', () => send(item, data, 'open'))); } else if (data.kind === 'url') { - const label = document.createElement('span'); label.textContent = typeof data.url === 'string' ? data.url : ''; element.append(label); + const label = document.createElement('span'); + label.textContent = typeof data.url === 'string' ? data.url : ''; + element.append(label); toolbar.append(button('Open application', 'open', () => send(item, data, 'open'))); } else { - const pre = document.createElement('pre'); pre.textContent = JSON.stringify(data, null, 2); element.append(pre); + const pre = document.createElement('pre'); + pre.textContent = JSON.stringify(data, null, 2); + element.append(pre); } toolbar.append(status); - if (!toolbar.parentElement) { element.append(toolbar); } + if (!toolbar.parentElement) { + element.append(toolbar); + } if (!['table', 'list'].includes(String(data.kind))) { - status.textContent = [status.textContent, connectionHint(data)].filter(Boolean).join(' · '); - if (!connected && data.kind !== 'plot') { toolbar.querySelectorAll('button').forEach(control => { control.disabled = true; }); } + status.textContent = [status.textContent, connectionHint(data)] + .filter(Boolean) + .join(' · '); + if (!connected && data.kind !== 'plot') { + toolbar.querySelectorAll('button').forEach((control) => { + control.disabled = true; + }); + } } }, disposeOutputItem(id) { - if (id) { outputs.get(id)?.dispose?.(); outputs.delete(id); } - else { outputs.forEach(state => state.dispose?.()); outputs.clear(); tables.clear(); } + if (id) { + outputs.get(id)?.dispose?.(); + outputs.delete(id); + } else { + outputs.forEach((state) => state.dispose?.()); + outputs.clear(); + tables.clear(); + } }, }; } diff --git a/src/interactive/rendererToolbar.ts b/src/interactive/rendererToolbar.ts index 4ecf32d76..8ccbe1bbb 100644 --- a/src/interactive/rendererToolbar.ts +++ b/src/interactive/rendererToolbar.ts @@ -18,20 +18,41 @@ type Icon = keyof typeof paths; function icon(name: Icon): SVGSVGElement { const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - svg.setAttribute('viewBox', '0 0 16 16'); svg.setAttribute('width', '16'); svg.setAttribute('height', '16'); - svg.setAttribute('aria-hidden', 'true'); svg.setAttribute('focusable', 'false'); + svg.setAttribute('viewBox', '0 0 16 16'); + svg.setAttribute('width', '16'); + svg.setAttribute('height', '16'); + svg.setAttribute('aria-hidden', 'true'); + svg.setAttribute('focusable', 'false'); const path = document.createElementNS(svg.namespaceURI, 'path'); - path.setAttribute('d', paths[name]); path.setAttribute('fill', 'none'); path.setAttribute('stroke', 'currentColor'); - path.setAttribute('stroke-width', '1.3'); path.setAttribute('stroke-linecap', 'round'); path.setAttribute('stroke-linejoin', 'round'); - svg.append(path); return svg; + path.setAttribute('d', paths[name]); + path.setAttribute('fill', 'none'); + path.setAttribute('stroke', 'currentColor'); + path.setAttribute('stroke-width', '1.3'); + path.setAttribute('stroke-linecap', 'round'); + path.setAttribute('stroke-linejoin', 'round'); + svg.append(path); + return svg; } -export function toolbarButton(label: string, glyph: Icon, action: () => void, text = label): HTMLButtonElement { - const button = document.createElement('button'); button.type = 'button'; - button.className = 'r-interactive-button'; button.title = label; button.setAttribute('aria-label', label); +export function toolbarButton( + label: string, + glyph: Icon, + action: () => void, + text = label, +): HTMLButtonElement { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'r-interactive-button'; + button.title = label; + button.setAttribute('aria-label', label); button.append(icon(glyph)); - if (text) { const caption = document.createElement('span'); caption.textContent = text; button.append(caption); } - button.onclick = action; return button; + if (text) { + const caption = document.createElement('span'); + caption.textContent = text; + button.append(caption); + } + button.onclick = action; + return button; } export const toolbarStyle = ` diff --git a/src/interactive/sessionLifecycle.ts b/src/interactive/sessionLifecycle.ts index 1ebe9131b..22e4709c4 100644 --- a/src/interactive/sessionLifecycle.ts +++ b/src/interactive/sessionLifecycle.ts @@ -4,11 +4,19 @@ import { SessionManifest } from './protocol'; /** Probe only registered Interactive agents, without taking their control leases. */ export async function runningSessions(candidates: SessionManifest[]): Promise { const live: SessionManifest[] = []; - const unique = [...new Map(candidates.map(manifest => [`${manifest.id}:${manifest.generation}`, manifest])).values()]; + const unique = [ + ...new Map( + candidates.map((manifest) => [`${manifest.id}:${manifest.generation}`, manifest]), + ).values(), + ]; for (let i = 0; i < unique.length; i += 8) { - const batch = await Promise.all(unique.slice(i, i + 8).map(manifest => probeSession(manifest))); + const batch = await Promise.all( + unique.slice(i, i + 8).map((manifest) => probeSession(manifest)), + ); for (const manifest of batch) { - if (manifest && !['exited', 'stopping'].includes(manifest.status)) { live.push(manifest); } + if (manifest && !['exited', 'stopping'].includes(manifest.status)) { + live.push(manifest); + } } } return live; @@ -19,19 +27,31 @@ export async function stopSession(manifest: SessionManifest, clientId: string): const client = new AgentClient({ ...manifest }, clientId); try { await client.connect({ claim: false, timeout: 1500 }); - if (client.manifest.status === 'exited') { return; } - if (!await client.request('claim', {}, 1500)) { - throw new Error('Controlled by another window. Use Take Control of Interactive Session before retrying.'); + if (client.manifest.status === 'exited') { + return; + } + if (!(await client.request('claim', {}, 1500))) { + throw new Error( + 'Controlled by another window. Use Take Control of Interactive Session before retrying.', + ); } await client.request('stop', {}, 5000); // A stop reply acknowledges the request; wait for process exit before reporting success. const deadline = Date.now() + 8000; do { - await new Promise(resolve => setTimeout(resolve, 100)); - const heartbeat = await client.request<{ status: SessionManifest['status'] }>('heartbeat', {}, 1500); - if (heartbeat.status === 'exited') { return; } + await new Promise((resolve) => setTimeout(resolve, 100)); + const heartbeat = await client.request<{ status: SessionManifest['status'] }>( + 'heartbeat', + {}, + 1500, + ); + if (heartbeat.status === 'exited') { + return; + } } while (Date.now() < deadline); - throw new Error('Stop was requested, but R has not exited yet. Refresh the session list to check its state.'); + throw new Error( + 'Stop was requested, but R has not exited yet. Refresh the session list to check its state.', + ); } finally { // Do not detach: an open view in this window shares the same client ID. client.close(); diff --git a/src/interactive/sessionPresentation.ts b/src/interactive/sessionPresentation.ts index b48e60d2d..4315dd364 100644 --- a/src/interactive/sessionPresentation.ts +++ b/src/interactive/sessionPresentation.ts @@ -3,19 +3,45 @@ import { SessionManifest } from './protocol'; /** Time in the current process generation; never keep aging a stopped session. */ export function sessionAge(manifest: SessionManifest, now = Date.now()): string | undefined { const end = manifest.status === 'exited' ? manifest.ended : now; - if (!Number.isFinite(manifest.created) || end === undefined || !Number.isFinite(end)) { return undefined; } + if (!Number.isFinite(manifest.created) || end === undefined || !Number.isFinite(end)) { + return undefined; + } const minutes = Math.floor(Math.max(0, end - manifest.created) / 60000); - return minutes < 1 ? '<1m' : minutes < 60 ? `${minutes}m` : minutes < 1440 ? `${Math.floor(minutes / 60)}h` : `${Math.floor(minutes / 1440)}d`; + return minutes < 1 + ? '<1m' + : minutes < 60 + ? `${minutes}m` + : minutes < 1440 + ? `${Math.floor(minutes / 60)}h` + : `${Math.floor(minutes / 1440)}d`; } /** Public session details; never include connection credentials. */ -export function sessionPresentation(manifest: SessionManifest, connected: boolean | undefined, control: boolean, restarting = false, - directory = manifest.directory): { state: string; label: string; description: string; detail: string; tooltip: string } { - const state = restarting ? 'restarting' : manifest.status === 'exited' ? 'stopped' : connected === false ? 'disconnected' - : manifest.status === 'input' ? 'waiting for input' : manifest.status; - const provider = manifest.provider === 'r' ? 'plain' : manifest.provider === 'arf-existing' ? 'arf (attached)' : 'arf'; +export function sessionPresentation( + manifest: SessionManifest, + connected: boolean | undefined, + control: boolean, + restarting = false, + directory = manifest.directory, +): { state: string; label: string; description: string; detail: string; tooltip: string } { + const state = restarting + ? 'restarting' + : manifest.status === 'exited' + ? 'stopped' + : connected === false + ? 'disconnected' + : manifest.status === 'input' + ? 'waiting for input' + : manifest.status; + const provider = + manifest.provider === 'r' + ? 'plain' + : manifest.provider === 'arf-existing' + ? 'arf (attached)' + : 'arf'; const version = manifest.rVersion?.match(/^R version (\S+)/)?.[1]; - const supervision = manifest.supervision === 'detached' ? 'Independent process' : manifest.supervision; + const supervision = + manifest.supervision === 'detached' ? 'Independent process' : manifest.supervision; const age = sessionAge(manifest); return { state, @@ -28,13 +54,22 @@ export function sessionPresentation(manifest: SessionManifest, connected: boolea tooltip: [ manifest.label, `State: ${state}`, - `Connection: ${connected === undefined ? 'Not open in this VS Code window' : connected - ? `Connected · ${control ? 'controlling' : 'observing'}` : 'Disconnected'}`, + `Connection: ${ + connected === undefined + ? 'Not open in this VS Code window' + : connected + ? `Connected · ${control ? 'controlling' : 'observing'}` + : 'Disconnected' + }`, `Provider: ${manifest.provider === 'r' ? 'Plain R' : manifest.provider === 'arf-existing' ? 'Attached arf' : 'Headless arf'}`, manifest.rVersion ?? 'R version: pending', `PID: ${manifest.rPid ?? 'pending'}`, - ...(age ? [`${manifest.status === 'exited' ? 'Lifetime' : 'Session age'}: ${age}`] : []), - ...(Number.isFinite(manifest.created) ? [`Started: ${new Date(manifest.created).toLocaleString()}`] : []), + ...(age + ? [`${manifest.status === 'exited' ? 'Lifetime' : 'Session age'}: ${age}`] + : []), + ...(Number.isFinite(manifest.created) + ? [`Started: ${new Date(manifest.created).toLocaleString()}`] + : []), `Working directory: ${directory}`, `Host: ${manifest.host}`, `Process supervision: ${supervision}${manifest.supervision === 'detached' ? ' (survives VS Code reload/exit)' : ''}`, diff --git a/src/interactive/storage.ts b/src/interactive/storage.ts index 0ca2e538b..1ee3ef623 100644 --- a/src/interactive/storage.ts +++ b/src/interactive/storage.ts @@ -2,15 +2,26 @@ import * as fs from 'fs'; import * as path from 'path'; export function storageError(root: string, error: unknown): unknown { - if (!['EACCES', 'EPERM', 'EROFS', 'ENOTDIR', 'EEXIST'].includes((error as NodeJS.ErrnoException).code ?? '')) { return error; } - return new Error(`Cannot access persistent session storage "${root}". Set r.interactive.storagePath to an absolute, writable directory on the R host and reload VS Code. ${String(error)}`, { cause: error }); + if ( + !['EACCES', 'EPERM', 'EROFS', 'ENOTDIR', 'EEXIST'].includes( + (error as NodeJS.ErrnoException).code ?? '', + ) + ) { + return error; + } + return new Error( + `Cannot access persistent session storage "${root}". Set r.interactive.storagePath to an absolute, writable directory on the R host and reload VS Code. ${String(error)}`, + { cause: error }, + ); } export function ensureStorageDirectory(directory: string, root: string): void { try { fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); fs.accessSync(directory, fs.constants.R_OK | fs.constants.W_OK | fs.constants.X_OK); - } catch (error) { throw storageError(root, error); } + } catch (error) { + throw storageError(root, error); + } } /** Check storage before probing R or building the private runtime. Never redirect an existing registry. */ diff --git a/src/interactive/supervisor.ts b/src/interactive/supervisor.ts index 9cb3545e4..040a2846d 100644 --- a/src/interactive/supervisor.ts +++ b/src/interactive/supervisor.ts @@ -1,24 +1,43 @@ import { resolveExecutable } from './executable'; -export type SessionSupervisor = { kind: 'detached'; notice?: string } +export type SessionSupervisor = + | { kind: 'detached'; notice?: string } | { kind: 'tmux' | 'systemd'; executable: string }; /** Resolve before installing a runtime or stopping R for restart. Never start a supervisor here. */ -export function prepareSupervisor(requested: string, directory: string, - platform = process.platform, pathValue = process.env.PATH): SessionSupervisor { - if (platform === 'win32') { throw new Error('Persistent Interactive sessions currently require Linux or macOS'); } - if (requested === 'detached' || (requested === 'auto' && platform !== 'linux')) { return { kind: 'detached' }; } +export function prepareSupervisor( + requested: string, + directory: string, + platform = process.platform, + pathValue = process.env.PATH, +): SessionSupervisor { + if (platform === 'win32') { + throw new Error('Persistent Interactive sessions currently require Linux or macOS'); + } + if (requested === 'detached' || (requested === 'auto' && platform !== 'linux')) { + return { kind: 'detached' }; + } if (!['auto', 'tmux', 'systemd'].includes(requested)) { - throw new Error(`Unknown session supervisor “${requested}”. Set r.interactive.supervision to auto, tmux, systemd, or detached.`); + throw new Error( + `Unknown session supervisor “${requested}”. Set r.interactive.supervision to auto, tmux, systemd, or detached.`, + ); } const kind = requested === 'systemd' ? 'systemd' : 'tmux'; const command = kind === 'systemd' ? 'systemd-run' : 'tmux'; const executable = resolveExecutable(command, directory, pathValue); - if (executable) { return { kind, executable }; } + if (executable) { + return { kind, executable }; + } if (requested === 'auto') { - return { kind: 'detached', notice: 'tmux is unavailable on this host; using an independent process. ' + - 'The session survives VS Code reload/exit. Host logout policies may still stop it.' }; + return { + kind: 'detached', + notice: + 'tmux is unavailable on this host; using an independent process. ' + + 'The session survives VS Code reload/exit. Host logout policies may still stop it.', + }; } - throw new Error(`Cannot find an executable ${command} on this host. Install ${command} on the R host ` + - '(the remote server when using Remote SSH), or set r.interactive.supervision to "auto" or "detached".'); + throw new Error( + `Cannot find an executable ${command} on this host. Install ${command} on the R host ` + + '(the remote server when using Remote SSH), or set r.interactive.supervision to "auto" or "detached".', + ); } diff --git a/src/interactive/tableFormatting.ts b/src/interactive/tableFormatting.ts index bac3c544d..3b147b51f 100644 --- a/src/interactive/tableFormatting.ts +++ b/src/interactive/tableFormatting.ts @@ -3,31 +3,59 @@ export function tableSnapshotSummary(data: Record): string { const rows = Number(data.totalRows); const source = Number(data.sourceRows); return Number.isSafeInteger(rows) && Number.isSafeInteger(source) && source > rows - ? `Snapshot: first ${rows.toLocaleString('en-US')} of ${source.toLocaleString('en-US')} rows` : ''; + ? `Snapshot: first ${rows.toLocaleString('en-US')} of ${source.toLocaleString('en-US')} rows` + : ''; } -export function tableColumnAlignment(data: Record, column: { field: string; type?: unknown }): 'left' | 'right' { +export function tableColumnAlignment( + data: Record, + column: { field: string; type?: unknown }, +): 'left' | 'right' { const types = Array.isArray(column.type) ? column.type : [column.type]; - if (types.some(type => type === 'numericColumn' || type === 'bigintColumn')) { return 'right'; } - if (column.type !== undefined) { return 'left'; } - const values = ((data.rows ?? []) as Record[]).map(row => row[column.field]).filter(value => value !== null && value !== undefined); - return values.length && values.every(value => typeof value === 'number') ? 'right' : 'left'; + if (types.some((type) => type === 'numericColumn' || type === 'bigintColumn')) { + return 'right'; + } + if (column.type !== undefined) { + return 'left'; + } + const values = ((data.rows ?? []) as Record[]) + .map((row) => row[column.field]) + .filter((value) => value !== null && value !== undefined); + return values.length && values.every((value) => typeof value === 'number') ? 'right' : 'left'; } /** Display labels are separate from the numeric values used by the data viewer. */ -export function tableDisplayValue(data: Record, row: Record, field: string, rowIndex: number): string { +export function tableDisplayValue( + data: Record, + row: Record, + field: string, + rowIndex: number, +): string { const columns = data.formattedColumns as Record | undefined; const labels = columns?.[field]; - if (Array.isArray(labels) && typeof labels[rowIndex] === 'string') { return labels[rowIndex]; } + if (Array.isArray(labels) && typeof labels[rowIndex] === 'string') { + return labels[rowIndex]; + } const value = row[field]; // Older persistent agents and saved outputs have no R-formatted labels. // Approximate R's default digits = 7 without rounding integer IDs or parsing strings. if (typeof value === 'number' && Number.isFinite(value) && !Number.isInteger(value)) { return String(Number(value.toPrecision(7))); } - if (value === null) { return 'NA'; } - if (value === undefined) { return ''; } - if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' - || typeof value === 'bigint' || typeof value === 'symbol') { return String(value); } + if (value === null) { + return 'NA'; + } + if (value === undefined) { + return ''; + } + if ( + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' || + typeof value === 'bigint' || + typeof value === 'symbol' + ) { + return String(value); + } return JSON.stringify(value); } diff --git a/src/interactive/tablePaging.ts b/src/interactive/tablePaging.ts index cf9d27d7d..b0c2837d7 100644 --- a/src/interactive/tablePaging.ts +++ b/src/interactive/tablePaging.ts @@ -1,7 +1,11 @@ // Match the R-side inline preview so advancing a page never skips unseen rows. export const TABLE_PAGE_SIZE = 20; -export function tablePage(total: number, start: number, size = TABLE_PAGE_SIZE): { start: number; end: number } { +export function tablePage( + total: number, + start: number, + size = TABLE_PAGE_SIZE, +): { start: number; end: number } { size = [20, 50, 100].includes(size) ? size : TABLE_PAGE_SIZE; const count = Number.isSafeInteger(total) && total > 0 ? total : 0; const last = Math.max(0, Math.ceil(count / size) - 1) * size; diff --git a/src/interactive/tableQuery.ts b/src/interactive/tableQuery.ts index 3a463cbe5..7366da120 100644 --- a/src/interactive/tableQuery.ts +++ b/src/interactive/tableQuery.ts @@ -1,39 +1,110 @@ import { TABLE_PAGE_SIZE, tablePage } from './tablePaging'; -export interface TableColumn { field: string; headerName: string; headerTooltip?: string; type?: unknown; sortable?: boolean; filter?: unknown } -export interface TableSort { colId: string; sort: 'asc' | 'desc' } -export interface TableFilter { type: string; filter?: string } +export interface TableColumn { + field: string; + headerName: string; + headerTooltip?: string; + type?: unknown; + sortable?: boolean; + filter?: unknown; +} +export interface TableSort { + colId: string; + sort: 'asc' | 'desc'; +} +export interface TableFilter { + type: string; + filter?: string; +} export type TableFilters = Record; export const filterOperators = { - equals: '=', notEqual: '≠', lessThan: '<', lessThanOrEqual: '≤', greaterThan: '>', greaterThanOrEqual: '≥', - contains: 'contains', notContains: 'does not contain', startsWith: 'starts with', endsWith: 'ends with', - blank: 'is blank', notBlank: 'is not blank', true: 'is true', false: 'is false', + equals: '=', + notEqual: '≠', + lessThan: '<', + lessThanOrEqual: '≤', + greaterThan: '>', + greaterThanOrEqual: '≥', + contains: 'contains', + notContains: 'does not contain', + startsWith: 'starts with', + endsWith: 'ends with', + blank: 'is blank', + notBlank: 'is not blank', + true: 'is true', + false: 'is false', }; export function columnOperators(column: TableColumn): string[] { - if (column.type === 'booleanColumn') { return ['true', 'false', 'blank', 'notBlank']; } - return ['numericColumn', 'bigintColumn', 'dateColumn', 'datetimeColumn'].includes(String(column.type)) - ? ['equals', 'notEqual', 'lessThan', 'lessThanOrEqual', 'greaterThan', 'greaterThanOrEqual', 'blank', 'notBlank'] - : ['contains', 'notContains', 'equals', 'notEqual', 'startsWith', 'endsWith', 'blank', 'notBlank']; + if (column.type === 'booleanColumn') { + return ['true', 'false', 'blank', 'notBlank']; + } + return ['numericColumn', 'bigintColumn', 'dateColumn', 'datetimeColumn'].includes( + String(column.type), + ) + ? [ + 'equals', + 'notEqual', + 'lessThan', + 'lessThanOrEqual', + 'greaterThan', + 'greaterThanOrEqual', + 'blank', + 'notBlank', + ] + : [ + 'contains', + 'notContains', + 'equals', + 'notEqual', + 'startsWith', + 'endsWith', + 'blank', + 'notBlank', + ]; } -export const filterNeedsValue = (type: string): boolean => !['blank', 'notBlank', 'true', 'false'].includes(type); +export const filterNeedsValue = (type: string): boolean => + !['blank', 'notBlank', 'true', 'false'].includes(type); // Slicing can turn automatic row numbers into explicit character row names. // The synthetic index is never sorted/filtered and is not part of the data schema. -export const tableSchema = (columns: TableColumn[]): unknown[][] => columns.filter(column => column.field !== '0') - .map(column => [column.field, column.headerName, column.type]); +export const tableSchema = (columns: TableColumn[]): unknown[][] => + columns + .filter((column) => column.field !== '0') + .map((column) => [column.field, column.headerName, column.type]); /** Validate renderer queries and choose only handles owned by this output. */ -export async function queryTablePage(data: Record, message: Record, - inspect: (request: { method: string; params: Record }) => Promise>): Promise> { +export async function queryTablePage( + data: Record, + message: Record, + inspect: (request: { + method: string; + params: Record; + }) => Promise>, +): Promise> { const size = message.size === undefined ? TABLE_PAGE_SIZE : Number(message.size); - if (![20, 50, 100].includes(size)) { throw new Error('Choose 20, 50 or 100 rows per page.'); } + if (![20, 50, 100].includes(size)) { + throw new Error('Choose 20, 50 or 100 rows per page.'); + } const offset = Number(message.start ?? 0); - if (!Number.isSafeInteger(offset) || offset < 0 || offset > 2147483646) { throw new Error('Invalid table page.'); } + if (!Number.isSafeInteger(offset) || offset < 0 || offset > 2147483646) { + throw new Error('Invalid table page.'); + } let sort = (message.sortModel ?? []) as TableSort[]; let filters = (message.filterModel ?? {}) as TableFilters; - if (!Array.isArray(sort) || sort.length > 1 || !filters || typeof filters !== 'object' || Array.isArray(filters) || Object.keys(filters).length > 100) { + if ( + !Array.isArray(sort) || + sort.length > 1 || + !filters || + typeof filters !== 'object' || + Array.isArray(filters) || + Object.keys(filters).length > 100 + ) { throw new Error('Invalid table query.'); } - const full = !!data.fullViewId && (message.live === true || offset + size > Number(data.totalRows) || sort.length > 0 || Object.keys(filters).length > 0); + const full = + !!data.fullViewId && + (message.live === true || + offset + size > Number(data.totalRows) || + sort.length > 0 || + Object.keys(filters).length > 0); const viewId = full ? data.fullViewId : data.viewId; let columns = data.columns as TableColumn[]; let queryReset = false; @@ -43,35 +114,69 @@ export async function queryTablePage(data: Record, message: Rec // A by-reference rename/reorder must never apply a filter to the wrong column. if (JSON.stringify(tableSchema(columns)) !== JSON.stringify(message.schema)) { queryReset = sort.length > 0 || Object.keys(filters).length > 0; - sort = []; filters = {}; + sort = []; + filters = {}; } } else if (full && Array.isArray(message.columns)) { // The live schema may differ from the historical cell after a refresh. // Use it only for validating field IDs; R still owns data and schema checks. columns = message.columns as TableColumn[]; } - if (!Array.isArray(columns) || columns.length > 100000) { throw new Error('Invalid table columns.'); } + if (!Array.isArray(columns) || columns.length > 100000) { + throw new Error('Invalid table columns.'); + } for (const item of sort) { - if (!item || !['asc', 'desc'].includes(item.sort) || !columns.some(column => column.field === item.colId && column.field !== '0' && column.sortable !== false)) { + if ( + !item || + !['asc', 'desc'].includes(item.sort) || + !columns.some( + (column) => + column.field === item.colId && + column.field !== '0' && + column.sortable !== false, + ) + ) { throw new Error('This column cannot be sorted.'); } } for (const [field, filter] of Object.entries(filters)) { - const column = columns.find(column => column.field === field && column.field !== '0' && column.filter !== false); - if (!column || !filter || !columnOperators(column).includes(filter.type) || (filterNeedsValue(filter.type) && (typeof filter.filter !== 'string' || filter.filter.length > 1000))) { + const column = columns.find( + (column) => column.field === field && column.field !== '0' && column.filter !== false, + ); + if ( + !column || + !filter || + !columnOperators(column).includes(filter.type) || + (filterNeedsValue(filter.type) && + (typeof filter.filter !== 'string' || filter.filter.length > 1000)) + ) { throw new Error('Invalid column filter.'); } } // Do not clamp to the snapshot's count, or to a stale count after live edits. let start = queryReset ? 0 : Math.floor(offset / size) * size; - const fetch = (): Promise> => inspect({ method: 'dataview_page', params: { - view_id: viewId, startRow: start, endRow: Math.min(2147483647, start + size), - sortModel: sort, filterModel: filters, formatNumbers: true, - } }); + const fetch = (): Promise> => + inspect({ + method: 'dataview_page', + params: { + view_id: viewId, + startRow: start, + endRow: Math.min(2147483647, start + size), + sortModel: sort, + filterModel: filters, + formatNumbers: true, + }, + }); let result = await fetch(); if (Number(result.totalRows) > 0 && start >= Number(result.totalRows)) { start = tablePage(Number(result.totalRows), start, size).start; result = await fetch(); } - return { ...result, startRow: Number(result.totalRows) === 0 ? 0 : start, columns, live: full, queryReset }; + return { + ...result, + startRow: Number(result.totalRows) === 0 ? 0 : start, + columns, + live: full, + queryReset, + }; } diff --git a/src/interactive/transcript.ts b/src/interactive/transcript.ts index 156442f96..cc2dff5f1 100644 --- a/src/interactive/transcript.ts +++ b/src/interactive/transcript.ts @@ -1,7 +1,14 @@ import { AgentSnapshot, ExecutionRecord, SessionEvent } from './protocol'; -export interface TranscriptOutput { type: 'stream' | 'condition' | 'display' | 'truncated'; data: Record } -export interface TranscriptCell { generation: string; record: ExecutionRecord; outputs: TranscriptOutput[] } +export interface TranscriptOutput { + type: 'stream' | 'condition' | 'display' | 'truncated'; + data: Record; +} +export interface TranscriptCell { + generation: string; + record: ExecutionRecord; + outputs: TranscriptOutput[]; +} /** Pure replay model: notebook indices and socket incarnations are never execution identity. */ export class Transcript { @@ -12,24 +19,43 @@ export class Transcript { restore(snapshot: AgentSnapshot): void { this.cells.clear(); - for (const record of snapshot.executions) { this.cells.set(record.id, { generation: this.generation, record, outputs: [] }); } - for (const event of snapshot.events) { this.apply(event, true); } + for (const record of snapshot.executions) { + this.cells.set(record.id, { generation: this.generation, record, outputs: [] }); + } + for (const event of snapshot.events) { + this.apply(event, true); + } for (const id of snapshot.truncated ?? []) { - this.cells.get(id)?.outputs.unshift({ type: 'truncated', data: { message: 'Earlier output is outside the retained history or reconnect window.' } }); + this.cells.get(id)?.outputs.unshift({ + type: 'truncated', + data: { + message: 'Earlier output is outside the retained history or reconnect window.', + }, + }); } this.seq = snapshot.seq; } apply(event: SessionEvent, replay = false): string | undefined { - if (event.generation !== this.generation || (!replay && event.seq <= this.seq)) { return; } + if (event.generation !== this.generation || (!replay && event.seq <= this.seq)) { + return; + } this.seq = Math.max(this.seq, event.seq); const id = event.executionId; - if (!id) { return; } + if (!id) { + return; + } if (event.type === 'accepted' && !this.cells.has(id)) { - this.cells.set(id, { generation: this.generation, record: event.data.record as ExecutionRecord, outputs: [] }); + this.cells.set(id, { + generation: this.generation, + record: event.data.record as ExecutionRecord, + outputs: [], + }); } const cell = this.cells.get(id); - if (!cell) { return; } + if (!cell) { + return; + } if (event.type === 'started' && !replay) { cell.record = { ...cell.record, state: 'running', started: event.time }; } else if (event.type === 'finished' || event.type === 'uncertain') { @@ -37,13 +63,22 @@ export class Transcript { } else if (event.type === 'stream') { const previous = cell.outputs.at(-1); if (previous?.type === 'stream' && previous.data.channel === event.data.channel) { - previous.data.text = (typeof previous.data.text === 'string' ? previous.data.text : '') - + (typeof event.data.text === 'string' ? event.data.text : ''); - } else { cell.outputs.push({ type: 'stream', data: { ...event.data } }); } + previous.data.text = + (typeof previous.data.text === 'string' ? previous.data.text : '') + + (typeof event.data.text === 'string' ? event.data.text : ''); + } else { + cell.outputs.push({ type: 'stream', data: { ...event.data } }); + } } else if (event.type === 'display') { - const previous = cell.outputs.find(output => output.type === 'display' && output.data.displayId === event.data.displayId); - if (previous) { previous.data = event.data; } - else { cell.outputs.push({ type: 'display', data: event.data }); } + const previous = cell.outputs.find( + (output) => + output.type === 'display' && output.data.displayId === event.data.displayId, + ); + if (previous) { + previous.data = event.data; + } else { + cell.outputs.push({ type: 'display', data: event.data }); + } } else if (event.type === 'condition' || event.type === 'truncated') { cell.outputs.push({ type: event.type, data: event.data }); } diff --git a/src/languageService.ts b/src/languageService.ts index 581b01405..5b047841a 100644 --- a/src/languageService.ts +++ b/src/languageService.ts @@ -5,9 +5,34 @@ import { basename, dirname } from 'path'; import * as net from 'net'; import { URL } from 'url'; import * as fs from 'fs'; -import { LanguageClient, LanguageClientOptions, StreamInfo, DocumentFilter, ErrorAction, CloseAction, RevealOutputChannelOn } from 'vscode-languageclient/node'; -import { Disposable, workspace, Uri, TextDocument, WorkspaceConfiguration, LogOutputChannel, window, WorkspaceFolder } from 'vscode'; -import { config, DisposableProcess, getRLibPaths, getRpath, promptToInstallRPackage, spawn, substituteVariables } from './util'; +import { + LanguageClient, + LanguageClientOptions, + StreamInfo, + DocumentFilter, + ErrorAction, + CloseAction, + RevealOutputChannelOn, +} from 'vscode-languageclient/node'; +import { + Disposable, + workspace, + Uri, + TextDocument, + WorkspaceConfiguration, + LogOutputChannel, + window, + WorkspaceFolder, +} from 'vscode'; +import { + config, + DisposableProcess, + getRLibPaths, + getRpath, + promptToInstallRPackage, + spawn, + substituteVariables, +} from './util'; import { extensionContext } from './extension'; import { CommonOptions } from 'child_process'; import { boundSessionForDocument, onDidBindSessionDocument, Session } from './session'; @@ -32,11 +57,18 @@ export class LanguageService implements Disposable { dispose(): Thenable { this.disposed = true; - this.listeners.forEach(listener => { listener.dispose(); }); + this.listeners.forEach((listener) => { + listener.dispose(); + }); return this.stopLanguageService(); } - private spawnServer(client: LanguageClient, rPath: string, args: readonly string[], options: CommonOptions & { cwd: string }): DisposableProcess { + private spawnServer( + client: LanguageClient, + rPath: string, + args: readonly string[], + options: CommonOptions & { cwd: string }, + ): DisposableProcess { const childProcess = spawn(rPath, args, options); const pid = childProcess.pid || -1; client.outputChannel.appendLine(`R Language Server (${pid}) started`); @@ -44,15 +76,19 @@ export class LanguageService implements Disposable { client.outputChannel.appendLine(chunk.toString()); }); childProcess.on('exit', (code, signal) => { - client.outputChannel.appendLine(`R Language Server (${pid}) exited ` + - (signal ? `from signal ${signal}` : `with exit code ${code || 'null'}`)); + client.outputChannel.appendLine( + `R Language Server (${pid}) exited ` + + (signal ? `from signal ${signal}` : `with exit code ${code || 'null'}`), + ); if (code !== 0) { if (code === 10) { // languageserver is not installed. void promptToInstallRPackage( - 'languageserver', 'lsp.promptToInstall', options.cwd, + 'languageserver', + 'lsp.promptToInstall', + options.cwd, 'R package {languageserver} is required to enable R language service features such as code completion, function signature, find references, etc. Do you want to install it?', - 'You may need to reopen an R file to start the language service after the package is installed.' + 'You may need to reopen an R file to start the language service after the package is installed.', ); } else { client.outputChannel.show(); @@ -65,13 +101,21 @@ export class LanguageService implements Disposable { return childProcess; } - private async createClient(selector: DocumentFilter[], - cwd: string, workspaceFolder: WorkspaceFolder | undefined, outputChannel: LogOutputChannel, - resource?: Uri, target?: Session): Promise { - + private async createClient( + selector: DocumentFilter[], + cwd: string, + workspaceFolder: WorkspaceFolder | undefined, + outputChannel: LogOutputChannel, + resource?: Uri, + target?: Session, + ): Promise { let client: LanguageClient; - const virtualOnly = selector.every(filter => 'scheme' in filter - && (filter.scheme === 'vscode-notebook-cell' || filter.scheme === 'vscode-interactive-input')); + const virtualOnly = selector.every( + (filter) => + 'scheme' in filter && + (filter.scheme === 'vscode-notebook-cell' || + filter.scheme === 'vscode-interactive-input'), + ); // An unrooted server resolves opaque document paths against cwd, which // makes languageserver discard cells when the session lives under /tmp. // Give virtual documents their actual working directory as the root. @@ -79,13 +123,18 @@ export class LanguageService implements Disposable { if (syntheticWorkspace) { workspaceFolder = { uri: Uri.file(cwd), name: basename(cwd), index: 0 }; } - const pathlessNotebook = selector.some(filter => 'scheme' in filter && filter.scheme === 'vscode-notebook-cell' - && typeof filter.pattern === 'string' && !fs.existsSync(filter.pattern)); + const pathlessNotebook = selector.some( + (filter) => + 'scheme' in filter && + filter.scheme === 'vscode-notebook-cell' && + typeof filter.pattern === 'string' && + !fs.existsSync(filter.pattern), + ); const resourceConfig = config(resource); const debug = this.config.get('lsp.debug'); const useRenvLibPath = this.config.get('useRenvLibPath') ?? false; - const rPath = target?.rPath || await getRpath(false, resource) || ''; // TODO: Abort gracefully + const rPath = target?.rPath || (await getRpath(false, resource)) || ''; // TODO: Abort gracefully if (debug) { console.log(`R path: ${rPath}`); } @@ -110,7 +159,11 @@ export class LanguageService implements Disposable { const rScriptPath = extensionContext.asAbsolutePath('R/languageServer.R'); const options = { cwd: cwd, env: env }; - const args = (resourceConfig.get('lsp.args')?.map(value => substituteVariables(value, resource)) ?? []).concat( + const args = ( + resourceConfig + .get('lsp.args') + ?.map((value) => substituteVariables(value, resource)) ?? [] + ).concat( '--silent', '--no-echo', '--no-save', @@ -118,31 +171,32 @@ export class LanguageService implements Disposable { '-e', 'base::source(base::commandArgs(TRUE))', '--args', - rScriptPath + rScriptPath, ); - const tcpServerOptions = () => new Promise((resolve, reject) => { - // Use a TCP socket because of problems with blocking STDIO - const server = net.createServer(socket => { - // 'connection' listener - console.log('R process connected'); - socket.on('end', () => { - console.log('R process disconnected'); + const tcpServerOptions = () => + new Promise((resolve, reject) => { + // Use a TCP socket because of problems with blocking STDIO + const server = net.createServer((socket) => { + // 'connection' listener + console.log('R process connected'); + socket.on('end', () => { + console.log('R process disconnected'); + }); + socket.on('error', (e: Error) => { + console.log(`R process error: ${e.message}`); + reject(e); + }); + server.close(); + resolve({ reader: socket, writer: socket }); }); - socket.on('error', (e: Error) => { - console.log(`R process error: ${e.message}`); - reject(e); + // Listen on random port + server.listen(0, '127.0.0.1', () => { + const port = (server.address() as net.AddressInfo).port; + env.VSCR_LSP_PORT = String(port); + return this.spawnServer(client, rPath, args, options); }); - server.close(); - resolve({ reader: socket, writer: socket }); }); - // Listen on random port - server.listen(0, '127.0.0.1', () => { - const port = (server.address() as net.AddressInfo).port; - env.VSCR_LSP_PORT = String(port); - return this.spawnServer(client, rPath, args, options); - }); - }); // Options to control the language client const clientOptions: LanguageClientOptions = { @@ -156,12 +210,19 @@ export class LanguageService implements Disposable { // call normalizePath even with lint_cache disabled. Use an opaque // scheme so the server lints the text like an unsaved input document. // Preserve the full path/fragment and translate every result back. - code2Protocol: uri => new URL((uri.scheme === 'vscode-notebook-cell' && pathlessNotebook - ? uri.with({ scheme: 'vscode-r-cell' }) : uri).toString(true)).toString(), - protocol2Code: str => { + code2Protocol: (uri) => + new URL( + (uri.scheme === 'vscode-notebook-cell' && pathlessNotebook + ? uri.with({ scheme: 'vscode-r-cell' }) + : uri + ).toString(true), + ).toString(), + protocol2Code: (str) => { const uri = Uri.parse(str); - return uri.scheme === 'vscode-r-cell' ? uri.with({ scheme: 'vscode-notebook-cell' }) : uri; - } + return uri.scheme === 'vscode-r-cell' + ? uri.with({ scheme: 'vscode-notebook-cell' }) + : uri; + }, }, workspaceFolder: workspaceFolder, outputChannel: outputChannel, @@ -176,46 +237,61 @@ export class LanguageService implements Disposable { const result = await next(document, position, context, token); // An empty LSP result still suppresses other VS Code providers. // Preserve source/package signatures, then consult this session. - return result?.signatures.length ? result : this.sessionSignatures.provideSignatureHelp(document, position, token); + return result?.signatures.length + ? result + : this.sessionSignatures.provideSignatureHelp(document, position, token); }, didChange: (event, next) => { - if (event.document.uri.scheme === 'vscode-interactive-input' && !event.document.getText().trim()) { + if ( + event.document.uri.scheme === 'vscode-interactive-input' && + !event.document.getText().trim() + ) { client.diagnostics?.delete(event.document.uri); } return next(event); }, handleDiagnostics: (uri, diagnostics, next) => { - const supportedSchemes = ['file', 'untitled', 'vscode-notebook-cell', 'vscode-interactive-input']; - + const supportedSchemes = [ + 'file', + 'untitled', + 'vscode-notebook-cell', + 'vscode-interactive-input', + ]; + // Drop diagnostics for unsupported schemes (like git://) if (!supportedSchemes.includes(uri.scheme)) { - return next(uri, []); + return next(uri, []); } - + // Drop diagnostics for files that no longer exist on disk if (uri.scheme === 'file' && !fs.existsSync(uri.fsPath)) { return next(uri, []); } // An empty prompt is ready for the next command, not an R source file // needing whitespace repairs. Also reject late replies after it clears. - if (uri.scheme === 'vscode-interactive-input' && !workspace.textDocuments.find(document => - document.uri.toString() === uri.toString())?.getText().trim()) { + if ( + uri.scheme === 'vscode-interactive-input' && + !workspace.textDocuments + .find((document) => document.uri.toString() === uri.toString()) + ?.getText() + .trim() + ) { return next(uri, []); } - + return next(uri, diagnostics); - } + }, }, revealOutputChannelOn: RevealOutputChannelOn.Never, errorHandler: { - error: () => { + error: () => { return { - action: ErrorAction.Continue + action: ErrorAction.Continue, }; }, closed: () => { return { - action: CloseAction.DoNotRestart + action: CloseAction.DoNotRestart, }; }, }, @@ -223,7 +299,12 @@ export class LanguageService implements Disposable { // Create the language client and start the client. if (use_stdio && process.platform !== 'win32') { - client = new LanguageClient('r', 'R Language Server', { command: rPath, args: args, options: options }, clientOptions); + client = new LanguageClient( + 'r', + 'R Language Server', + { command: rPath, args: args, options: options }, + clientOptions, + ); } else { client = new LanguageClient('r', 'R Language Server', tcpServerOptions, clientOptions); } @@ -233,7 +314,6 @@ export class LanguageService implements Disposable { return client; } - private checkClient(name: string): boolean { if (this.initSet.has(name)) { return true; @@ -260,8 +340,17 @@ export class LanguageService implements Disposable { private startMultiLanguageService(virtualOnly = false): void { const clientTargets = new Map(); const didOpenTextDocument = async (document: TextDocument) => { - if (virtualOnly && !['vscode-notebook-cell', 'vscode-interactive-input'].includes(document.uri.scheme)) { return; } - if (!['file', 'untitled', 'vscode-notebook-cell', 'vscode-interactive-input'].includes(document.uri.scheme)) { + if ( + virtualOnly && + !['vscode-notebook-cell', 'vscode-interactive-input'].includes(document.uri.scheme) + ) { + return; + } + if ( + !['file', 'untitled', 'vscode-notebook-cell', 'vscode-interactive-input'].includes( + document.uri.scheme, + ) + ) { return; } @@ -270,14 +359,30 @@ export class LanguageService implements Disposable { } const target = boundSessionForDocument(document.uri); - const folder = workspace.getWorkspaceFolder(target ? Uri.file(target.workingDir) : document.uri); + const folder = workspace.getWorkspaceFolder( + target ? Uri.file(target.workingDir) : document.uri, + ); if (document.uri.scheme === 'vscode-interactive-input') { const key = document.uri.toString(); if (!this.checkClient(key)) { - const selector = [{ scheme: 'vscode-interactive-input', language: 'r', pattern: document.uri.fsPath }]; - const client = await this.createClient(selector, target?.workingDir ?? folder?.uri.fsPath ?? os.homedir(), folder, this.outputChannel, folder?.uri, target); - this.clients.set(key, client); this.initSet.delete(key); + const selector = [ + { + scheme: 'vscode-interactive-input', + language: 'r', + pattern: document.uri.fsPath, + }, + ]; + const client = await this.createClient( + selector, + target?.workingDir ?? folder?.uri.fsPath ?? os.homedir(), + folder, + this.outputChannel, + folder?.uri, + target, + ); + this.clients.set(key, client); + this.initSet.delete(key); } return; } @@ -288,10 +393,20 @@ export class LanguageService implements Disposable { if (!this.checkClient(key)) { console.log(`Start language server for ${document.uri.toString(true)}`); const documentSelector: DocumentFilter[] = [ - { scheme: 'vscode-notebook-cell', language: 'r', pattern: `${document.uri.fsPath}` }, + { + scheme: 'vscode-notebook-cell', + language: 'r', + pattern: `${document.uri.fsPath}`, + }, ]; - const client = await this.createClient(documentSelector, - target?.workingDir ?? folder?.uri.fsPath ?? dirname(document.uri.fsPath), folder, this.outputChannel, folder?.uri ?? document.uri, target); + const client = await this.createClient( + documentSelector, + target?.workingDir ?? folder?.uri.fsPath ?? dirname(document.uri.fsPath), + folder, + this.outputChannel, + folder?.uri ?? document.uri, + target, + ); this.clients.set(key, client); this.initSet.delete(key); } @@ -299,7 +414,6 @@ export class LanguageService implements Disposable { } if (folder) { - // Each workspace uses a server started from the workspace folder const key = this.getKey(folder.uri); if (!this.checkClient(key)) { @@ -309,13 +423,17 @@ export class LanguageService implements Disposable { { scheme: 'file', language: 'r', pattern: pattern }, { scheme: 'file', language: 'rmd', pattern: pattern }, ]; - const client = await this.createClient(documentSelector, folder.uri.fsPath, folder, this.outputChannel, folder.uri); + const client = await this.createClient( + documentSelector, + folder.uri.fsPath, + folder, + this.outputChannel, + folder.uri, + ); this.clients.set(key, client); this.initSet.delete(key); } - } else { - // All untitled documents share a server started from home folder if (document.uri.scheme === 'untitled') { const key = this.getKey(document.uri); @@ -325,7 +443,13 @@ export class LanguageService implements Disposable { { scheme: 'untitled', language: 'r' }, { scheme: 'untitled', language: 'rmd' }, ]; - const client = await this.createClient(documentSelector, os.homedir(), undefined, this.outputChannel, document.uri); + const client = await this.createClient( + documentSelector, + os.homedir(), + undefined, + this.outputChannel, + document.uri, + ); this.clients.set(key, client); this.initSet.delete(key); } @@ -340,8 +464,13 @@ export class LanguageService implements Disposable { const documentSelector: DocumentFilter[] = [ { scheme: 'file', pattern: document.uri.fsPath }, ]; - const client = await this.createClient(documentSelector, - dirname(document.uri.fsPath), undefined, this.outputChannel, document.uri); + const client = await this.createClient( + documentSelector, + dirname(document.uri.fsPath), + undefined, + this.outputChannel, + document.uri, + ); this.clients.set(key, client); this.initSet.delete(key); } @@ -360,8 +489,11 @@ export class LanguageService implements Disposable { } if (document.uri.scheme === 'vscode-notebook-cell') { - const result = workspace.textDocuments.find((doc) => - doc.uri.scheme === document.uri.scheme && doc.uri.fsPath === document.uri.fsPath); + const result = workspace.textDocuments.find( + (doc) => + doc.uri.scheme === document.uri.scheme && + doc.uri.fsPath === document.uri.fsPath, + ); if (result) { // Stop the language server when all cell documents are closed (notebook closed). return; @@ -384,46 +516,69 @@ export class LanguageService implements Disposable { // A native input may already be R when interactive.open returns, // before its owner is bound. Serialize startup and rebinding so its // server uses the owner's R, library paths, and working directory. - const update = (this.clientUpdates.get(key) ?? Promise.resolve()).then(async () => { - if (this.disposed || document.isClosed) { return; } - const target = boundSessionForDocument(document.uri); - // Rebinding retained cells can report the same notebook owner - // repeatedly. Restart its shared server only once per owner. - if (restart && clientTargets.get(key) !== target) { - const client = this.clients.get(key); - this.clients.delete(key); this.initSet.delete(key); - await client?.stop(); - } - await didOpenTextDocument(document); - if (this.clients.has(key)) { clientTargets.set(key, target); } - }).catch((error: unknown) => { - this.initSet.delete(key); - this.outputChannel.appendLine(`Failed to start language server: ${String(error)}`); - }); + const update = (this.clientUpdates.get(key) ?? Promise.resolve()) + .then(async () => { + if (this.disposed || document.isClosed) { + return; + } + const target = boundSessionForDocument(document.uri); + // Rebinding retained cells can report the same notebook owner + // repeatedly. Restart its shared server only once per owner. + if (restart && clientTargets.get(key) !== target) { + const client = this.clients.get(key); + this.clients.delete(key); + this.initSet.delete(key); + await client?.stop(); + } + await didOpenTextDocument(document); + if (this.clients.has(key)) { + clientTargets.set(key, target); + } + }) + .catch((error: unknown) => { + this.initSet.delete(key); + this.outputChannel.appendLine( + `Failed to start language server: ${String(error)}`, + ); + }); this.clientUpdates.set(key, update); void update.then(() => { - if (this.clientUpdates.get(key) === update) { this.clientUpdates.delete(key); } + if (this.clientUpdates.get(key) === update) { + this.clientUpdates.delete(key); + } }); }; - this.listeners.push(onDidBindSessionDocument(uri => { - if (!['vscode-notebook-cell', 'vscode-interactive-input'].includes(uri.scheme)) { return; } - const document = workspace.textDocuments.find(doc => this.getKey(doc.uri) === this.getKey(uri)); - if (document) { updateDocument(document, true); } - })); - this.listeners.push(workspace.onDidOpenTextDocument(document => updateDocument(document))); + this.listeners.push( + onDidBindSessionDocument((uri) => { + if (!['vscode-notebook-cell', 'vscode-interactive-input'].includes(uri.scheme)) { + return; + } + const document = workspace.textDocuments.find( + (doc) => this.getKey(doc.uri) === this.getKey(uri), + ); + if (document) { + updateDocument(document, true); + } + }), + ); + this.listeners.push( + workspace.onDidOpenTextDocument((document) => updateDocument(document)), + ); this.listeners.push(workspace.onDidCloseTextDocument(didCloseTextDocument)); - workspace.textDocuments.forEach(document => updateDocument(document)); - this.listeners.push(workspace.onDidChangeWorkspaceFolders((event) => { - for (const folder of event.removed) { - const key = this.getKey(folder.uri); - const client = this.clients.get(key); - if (client) { - this.clients.delete(key); - this.initSet.delete(key); - void client.stop(); + workspace.textDocuments.forEach((document) => updateDocument(document)); + this.listeners.push( + workspace.onDidChangeWorkspaceFolders((event) => { + for (const folder of event.removed) { + const key = this.getKey(folder.uri); + const client = this.clients.get(key); + if (client) { + this.clients.delete(key); + this.initSet.delete(key); + void client.stop(); + } } - } - })); + }), + ); } private async startLanguageService(): Promise { @@ -446,7 +601,13 @@ export class LanguageService implements Disposable { const workspaceFolder = workspace.workspaceFolders?.[0]; const cwd = workspaceFolder ? workspaceFolder.uri.fsPath : os.homedir(); - const client = await this.createClient(documentSelector, cwd, undefined, this.outputChannel, workspaceFolder?.uri); + const client = await this.createClient( + documentSelector, + cwd, + undefined, + this.outputChannel, + workspaceFolder?.uri, + ); this.clients.set('global', client); this.startMultiLanguageService(true); } diff --git a/src/lineCache.ts b/src/lineCache.ts index 82d83811a..343559243 100644 --- a/src/lineCache.ts +++ b/src/lineCache.ts @@ -39,7 +39,7 @@ export class LineCache { } function isQuote(c: string) { - return c === '"' || c === '\'' || c === '`'; + return c === '"' || c === "'" || c === '`'; } function isComment(c: string) { @@ -53,7 +53,9 @@ export function cleanLine(text: string): string { const c = text[i]; if (withinQuotes && c === '\\') { cleaned += c; - if (i + 1 < text.length) { cleaned += text[++i]; } + if (i + 1 < text.length) { + cleaned += text[++i]; + } continue; } if (isQuote(c)) { @@ -69,12 +71,14 @@ export function cleanLine(text: string): string { cleaned += c; } - return (cleaned.trimEnd()); + return cleaned.trimEnd(); } function doesLineEndInOperator(text: string) { - const endingOperatorIndex = text.search(/(\(|,|\+|!|\$|\^|&|\*|-|=|:|~|\||\/|\?|<|>|%.*%)(\s*|\s*#.*)$/); + const endingOperatorIndex = text.search( + /(\(|,|\+|!|\$|\^|&|\*|-|=|:|~|\||\/|\?|<|>|%.*%)(\s*|\s*#.*)$/, + ); const spacesOnlyIndex = text.search(/^\s*$/); - return ((endingOperatorIndex >= 0) || (spacesOnlyIndex >= 0)); + return endingOperatorIndex >= 0 || spacesOnlyIndex >= 0; } diff --git a/src/lintrConfig.ts b/src/lintrConfig.ts index 676c032a5..3373bb333 100644 --- a/src/lintrConfig.ts +++ b/src/lintrConfig.ts @@ -15,7 +15,8 @@ export async function createLintrConfig(): Promise { if (existsSync(lintrFilePath)) { const overwrite = await window.showWarningMessage( '".lintr" file already exists. Do you want to overwrite?', - 'Yes', 'No' + 'Yes', + 'No', ); if (overwrite !== 'Yes') { return; @@ -31,9 +32,17 @@ export async function createLintrConfig(): Promise { backupDirectory = mkdtempSync(join(currentWorkspaceFolder, '.vscode-r-lintr-')); renameSync(lintrFilePath, join(backupDirectory, '.lintr')); } - const output = await executeRCommand('lintr::use_lintr()', currentWorkspaceFolder, (error: Error) => { throw error; }); + const output = await executeRCommand( + 'lintr::use_lintr()', + currentWorkspaceFolder, + (error: Error) => { + throw error; + }, + ); if (output !== undefined) { - if (!existsSync(lintrFilePath)) { throw new Error('R did not create a .lintr configuration.'); } + if (!existsSync(lintrFilePath)) { + throw new Error('R did not create a .lintr configuration.'); + } generated = true; } return output; @@ -43,7 +52,9 @@ export async function createLintrConfig(): Promise { } finally { if (backupDirectory) { const backupPath = join(backupDirectory, '.lintr'); - if (!generated && existsSync(backupPath)) { renameSync(backupPath, lintrFilePath); } + if (!generated && existsSync(backupPath)) { + renameSync(backupPath, lintrFilePath); + } rmSync(backupDirectory, { recursive: true, force: true }); } } diff --git a/src/listViewer.ts b/src/listViewer.ts index 491171fc3..f0e2088e1 100644 --- a/src/listViewer.ts +++ b/src/listViewer.ts @@ -11,7 +11,13 @@ export interface ListViewNavigation { } export interface ListViewPage { - children: { label: string; str: string; index: number; viewable?: boolean; has_children?: boolean }[]; + children: { + label: string; + str: string; + index: number; + viewable?: boolean; + has_children?: boolean; + }[]; next_start?: number | null; } export interface ListViewReply extends Partial { @@ -27,17 +33,36 @@ export interface ListViewer { /** Shared tree, paging and navigation for the panel and Interactive cells. */ export function createListViewer( - { root, list, back, reset, breadcrumbs, navigationStatus }: { - root: HTMLElement; list: HTMLElement; back: HTMLButtonElement; reset: HTMLButtonElement; - breadcrumbs: HTMLElement; navigationStatus: HTMLElement; + { + root, + list, + back, + reset, + breadcrumbs, + navigationStatus, + }: { + root: HTMLElement; + list: HTMLElement; + back: HTMLButtonElement; + reset: HTMLButtonElement; + breadcrumbs: HTMLElement; + navigationStatus: HTMLElement; }, initial: ListViewNavigation, - request: (message: { message: string; path: number[]; index?: number; start?: number }) => number, + request: (message: { + message: string; + path: number[]; + index?: number; + start?: number; + }) => number, initialPage?: ListViewPage, live = true, ): ListViewer { const pending = new Map void>(); - const pages = new Map(); + const pages = new Map< + string, + { element: HTMLElement; scrollTop: number; loader: { loadOnce(): void } } + >(); const history: ListViewNavigation[] = []; let current: ListViewNavigation | undefined; let navigating = false; @@ -46,15 +71,27 @@ export function createListViewer( const key = JSON.stringify(navigation.path); if (current) { pages.get(JSON.stringify(current.path))!.scrollTop = list.scrollTop; - if (goingBack) { history.pop(); } - else if (JSON.stringify(current.path) !== key) { history.push(current); } + if (goingBack) { + history.pop(); + } else if (JSON.stringify(current.path) !== key) { + history.push(current); + } } current = navigation; root.classList.toggle('vector', !!navigation.vector); let page = pages.get(key); if (!page) { const element = document.createElement('div'); - page = { element, scrollTop: 0, loader: createPage(element, navigation.path, navigation.vector, pages.size === 0 ? initialPage : undefined) }; + page = { + element, + scrollTop: 0, + loader: createPage( + element, + navigation.path, + navigation.vector, + pages.size === 0 ? initialPage : undefined, + ), + }; pages.set(key, page); } list.replaceChildren(page.element); @@ -74,18 +111,28 @@ export function createListViewer( item.className = 'breadcrumb'; item.textContent = crumb.label; item.title = crumb.label; - if (isCurrent) { item.setAttribute('aria-current', 'page'); } - else { + if (isCurrent) { + item.setAttribute('aria-current', 'page'); + } else { (item as HTMLButtonElement).disabled = !live; - item.addEventListener('click', () => navigate('listview/navigate', { path: crumb.path })); + item.addEventListener('click', () => + navigate('listview/navigate', { path: crumb.path }), + ); } breadcrumbs.appendChild(item); }); page.loader.loadOnce(); } - function navigate(message: string, params: { path: number[]; index?: number }, goingBack = false, resetting = false): void { - if (disposed || !live || navigating) { return; } + function navigate( + message: string, + params: { path: number[]; index?: number }, + goingBack = false, + resetting = false, + ): void { + if (disposed || !live || navigating) { + return; + } navigating = true; navigationStatus.textContent = ''; const requestId = request({ message, ...params }); @@ -95,7 +142,10 @@ export function createListViewer( navigationStatus.textContent = response.error; } else if (response.navigation) { if (resetting) { - pending.clear(); pages.clear(); history.length = 0; current = undefined; + pending.clear(); + pages.clear(); + history.length = 0; + current = undefined; } showNavigation(response.navigation, goingBack); } @@ -103,11 +153,20 @@ export function createListViewer( } back.addEventListener('click', () => { - if (history.length) { navigate('listview/navigate', { path: history[history.length - 1].path }, true); } + if (history.length) { + navigate('listview/navigate', { path: history[history.length - 1].path }, true); + } }); - reset.addEventListener('click', () => navigate('listview/navigate', { path: initial.path }, false, true)); + reset.addEventListener('click', () => + navigate('listview/navigate', { path: initial.path }, false, true), + ); - function createPage(container: HTMLElement, path: number[], vector = false, savedPage?: ListViewPage): { loadOnce(): void } { + function createPage( + container: HTMLElement, + path: number[], + vector = false, + savedPage?: ListViewPage, + ): { loadOnce(): void } { const rows = document.createElement('div'); const more = document.createElement('button'); more.className = 'load-more'; @@ -122,7 +181,9 @@ export function createListViewer( let loaded = false; function loadPage(): void { - if (disposed || (!live && !savedPage) || loading || nextStart === null) { return; } + if (disposed || (!live && !savedPage) || loading || nextStart === null) { + return; + } loading = true; more.disabled = true; more.textContent = 'Loading…'; @@ -143,7 +204,9 @@ export function createListViewer( row.className = 'item'; if (!vector) { const arrow = document.createElement('span'); - arrow.className = expandable ? 'arrow codicon codicon-chevron-right' : 'arrow'; + arrow.className = expandable + ? 'arrow codicon codicon-chevron-right' + : 'arrow'; arrow.setAttribute('aria-hidden', 'true'); row.appendChild(arrow); } @@ -179,7 +242,10 @@ export function createListViewer( entry.appendChild(children); let page: { loadOnce(): void } | undefined; entry.addEventListener('toggle', () => { - if ((entry as HTMLDetailsElement).open && !live && !page) { (entry as HTMLDetailsElement).open = false; return; } + if ((entry as HTMLDetailsElement).open && !live && !page) { + (entry as HTMLDetailsElement).open = false; + return; + } if ((entry as HTMLDetailsElement).open) { page ??= createPage(children, [...path, item.index]); page.loadOnce(); @@ -194,13 +260,21 @@ export function createListViewer( status.textContent = rows.childElementCount ? '' : 'No items'; }; if (savedPage) { - const page = savedPage; savedPage = undefined; receive({ ...page, requestId: 0 }); + const page = savedPage; + savedPage = undefined; + receive({ ...page, requestId: 0 }); } else { pending.set(request({ message: 'listview/page', path, start: nextStart }), receive); } } more.addEventListener('click', loadPage); - return { loadOnce: () => { if (!loaded) { loadPage(); } } }; + return { + loadOnce: () => { + if (!loaded) { + loadPage(); + } + }, + }; } showNavigation(initial); @@ -208,16 +282,27 @@ export function createListViewer( navigation: () => current!, reply(message) { const receive = pending.get(message.requestId); - if (receive) { pending.delete(message.requestId); receive(message); } + if (receive) { + pending.delete(message.requestId); + receive(message); + } + }, + dispose() { + disposed = true; + pending.clear(); }, - dispose() { disposed = true; pending.clear(); }, }; } /** Panel wrapper and its interaction tests. Keep panel focus/blur hooks here. */ -export function getListViewerScript(documentGeneration: number, initial: ListViewNavigation = { - title: '', path: [], breadcrumbs: [{ label: '', path: [] }], -}): string { +export function getListViewerScript( + documentGeneration: number, + initial: ListViewNavigation = { + title: '', + path: [], + breadcrumbs: [{ label: '', path: [] }], + }, +): string { return ` const vscode = acquireVsCodeApi(); const documentGeneration = ${documentGeneration}; diff --git a/src/nativeTerminalPidWait.ts b/src/nativeTerminalPidWait.ts index e545377e0..42ac9c137 100644 --- a/src/nativeTerminalPidWait.ts +++ b/src/nativeTerminalPidWait.ts @@ -21,13 +21,17 @@ export class NativeTerminalPidWaitRegistry { public invalidate(terminal: T): void { this.terminalRevisions.set(terminal, this.revisionOf(terminal) + 1); for (const waiter of this.waiters) { - if (waiter.terminal === terminal) { waiter.invalidate('terminal'); } + if (waiter.terminal === terminal) { + waiter.invalidate('terminal'); + } } } public invalidateAll(): void { this.lifecycleRevision++; - for (const waiter of this.waiters) { waiter.invalidate('lifecycle'); } + for (const waiter of this.waiters) { + waiter.invalidate('lifecycle'); + } } public waitFor( @@ -40,29 +44,38 @@ export class NativeTerminalPidWaitRegistry { return new Promise((resolve, reject) => { let settled = false; - const isCurrent = (): boolean => !options.signal?.aborted - && lifecycleRevision === this.lifecycleRevision - && terminalRevision === this.revisionOf(terminal) - && (options.isEligible?.() ?? true); + const isCurrent = (): boolean => + !options.signal?.aborted && + lifecycleRevision === this.lifecycleRevision && + terminalRevision === this.revisionOf(terminal) && + (options.isEligible?.() ?? true); const checkCurrent = (): boolean => { - try { return isCurrent(); } - catch (error) { finishError(error); return false; } + try { + return isCurrent(); + } catch (error) { + finishError(error); + return false; + } }; const finish = (result: NativeTerminalPidWaitResult): void => { - if (settled) { return; } + if (settled) { + return; + } settled = true; cleanup(); resolve(result); }; const finishError = (error: unknown): void => { - if (settled) { return; } + if (settled) { + return; + } settled = true; cleanup(); reject(error); }; const waiter: NativeTerminalPidWaiter = { terminal, - invalidate: reason => finish({ kind: 'invalidated', reason }), + invalidate: (reason) => finish({ kind: 'invalidated', reason }), }; const onAbort = () => finish({ kind: 'invalidated', reason: 'operation' }); const cleanup = (): void => { @@ -83,41 +96,68 @@ export class NativeTerminalPidWaitRegistry { return; } let eligible: boolean; - try { eligible = options.isEligible?.() ?? true; } - catch (error) { finishError(error); return; } + try { + eligible = options.isEligible?.() ?? true; + } catch (error) { + finishError(error); + return; + } if (terminalRevision !== this.revisionOf(terminal) || !eligible) { finish({ kind: 'invalidated', reason: 'terminal' }); return; } let pending: Promise; - try { pending = Promise.resolve(read()); } - catch (error) { finishError(error); return; } + try { + pending = Promise.resolve(read()); + } catch (error) { + finishError(error); + return; + } // Keep a rejection observer after invalidation so the underlying VS Code // promise cannot become an unhandled rejection if it settles late. - void pending.then(value => { - if (settled) { return; } - if (!checkCurrent()) { - if (settled) { return; } - const reason = options.signal?.aborted ? 'operation' - : lifecycleRevision !== this.lifecycleRevision ? 'lifecycle' : 'terminal'; - finish({ kind: 'invalidated', reason }); - return; - } - finish({ kind: 'resolved', value, isCurrent }); - }, error => { - if (settled) { return; } - if (!checkCurrent()) { - if (settled) { return; } - const reason = options.signal?.aborted ? 'operation' - : lifecycleRevision !== this.lifecycleRevision ? 'lifecycle' : 'terminal'; - finish({ kind: 'invalidated', reason }); - return; - } - finishError(error); - }); + void pending.then( + (value) => { + if (settled) { + return; + } + if (!checkCurrent()) { + if (settled) { + return; + } + const reason = options.signal?.aborted + ? 'operation' + : lifecycleRevision !== this.lifecycleRevision + ? 'lifecycle' + : 'terminal'; + finish({ kind: 'invalidated', reason }); + return; + } + finish({ kind: 'resolved', value, isCurrent }); + }, + (error) => { + if (settled) { + return; + } + if (!checkCurrent()) { + if (settled) { + return; + } + const reason = options.signal?.aborted + ? 'operation' + : lifecycleRevision !== this.lifecycleRevision + ? 'lifecycle' + : 'terminal'; + finish({ kind: 'invalidated', reason }); + return; + } + finishError(error); + }, + ); }); } - private revisionOf(terminal: T): number { return this.terminalRevisions.get(terminal) ?? 0; } + private revisionOf(terminal: T): number { + return this.terminalRevisions.get(terminal) ?? 0; + } } diff --git a/src/plotViewer/httpgdClient.ts b/src/plotViewer/httpgdClient.ts index eb266ce3c..f6c88e6b8 100644 --- a/src/plotViewer/httpgdClient.ts +++ b/src/plotViewer/httpgdClient.ts @@ -2,10 +2,24 @@ import { getHttpResponse } from '../http'; export type HttpgdPlotId = string; export type HttpgdRendererId = string; -export interface HttpgdIdResponse { id: HttpgdPlotId } -export interface HttpgdState { upid: number; hsize: number; active: boolean } -export interface HttpgdPlotsResponse { state: HttpgdState; plots: HttpgdIdResponse[] } -export interface HttpgdRendererResponse { id: string; name: string; ext: string; descr: string } +export interface HttpgdIdResponse { + id: HttpgdPlotId; +} +export interface HttpgdState { + upid: number; + hsize: number; + active: boolean; +} +export interface HttpgdPlotsResponse { + state: HttpgdState; + plots: HttpgdIdResponse[]; +} +export interface HttpgdRendererResponse { + id: string; + name: string; + ext: string; + descr: string; +} export interface HttpgdPlotRequest { id?: string; renderer?: string; @@ -25,8 +39,14 @@ function record(value: unknown): value is Record { } function state(value: unknown): HttpgdState { - if (!record(value) || typeof value.upid !== 'number' || !Number.isFinite(value.upid) - || typeof value.hsize !== 'number' || !Number.isFinite(value.hsize) || typeof value.active !== 'boolean') { + if ( + !record(value) || + typeof value.upid !== 'number' || + !Number.isFinite(value.upid) || + typeof value.hsize !== 'number' || + !Number.isFinite(value.hsize) || + typeof value.active !== 'boolean' + ) { throw new Error('Invalid httpgd state'); } return { upid: value.upid, hsize: value.hsize, active: value.active }; @@ -61,7 +81,9 @@ export class HttpgdClient { } public async connect(): Promise { - if (this.controller) { return; } + if (this.controller) { + return; + } this.controller = new AbortController(); try { await this.loadRenderers(); @@ -85,8 +107,12 @@ export class HttpgdClient { this.listeners.add(listener); } - public getPlots(): HttpgdIdResponse[] { return this.snapshot?.plots ?? []; } - public getRenderers(): HttpgdRendererResponse[] { return this.renderers; } + public getPlots(): HttpgdIdResponse[] { + return this.snapshot?.plots ?? []; + } + public getRenderers(): HttpgdRendererResponse[] { + return this.renderers; + } public async getPlotText(request: HttpgdPlotRequest): Promise { return (await this.getPlotBytes(request)).toString('utf8'); @@ -94,9 +120,13 @@ export class HttpgdClient { public async getPlotBytes(request: HttpgdPlotRequest): Promise { const url = this.url('/plot'); - for (const [name, value] of Object.entries(request) as [string, string | number | undefined][]) { + for (const [name, value] of Object.entries(request) as [ + string, + string | number | undefined, + ][]) { if (value !== undefined) { - const parameter = name === 'width' || name === 'height' ? Math.round(Number(value)) : value; + const parameter = + name === 'width' || name === 'height' ? Math.round(Number(value)) : value; url.searchParams.set(name, String(parameter)); } } @@ -110,15 +140,21 @@ export class HttpgdClient { await this.refreshPlots(); } - private url(path: string): URL { return new URL(path, this.base); } + private url(path: string): URL { + return new URL(path, this.base); + } private async request(url: URL): Promise { const signal = this.controller?.signal; - if (!signal || signal.aborted) { throw new Error('httpgd client is disconnected'); } + if (!signal || signal.aborted) { + throw new Error('httpgd client is disconnected'); + } // Rendering and exports may be slow; preserve httpgd-js's lack of a // request timeout while still cancelling requests on disconnect. const response = await getHttpResponse(url, { headers: this.headers, signal }); - if (response.status !== 200) { throw new Error(`httpgd request failed (${response.status})`); } + if (response.status !== 200) { + throw new Error(`httpgd request failed (${response.status})`); + } return response.body; } @@ -128,13 +164,25 @@ export class HttpgdClient { private async loadRenderers(): Promise { const value = await this.json(this.url('/renderers')); - if (!record(value) || !Array.isArray(value.renderers)) { throw new Error('Invalid httpgd renderers'); } + if (!record(value) || !Array.isArray(value.renderers)) { + throw new Error('Invalid httpgd renderers'); + } this.renderers = value.renderers.map((renderer: unknown) => { - if (!record(renderer) || typeof renderer.id !== 'string' || typeof renderer.name !== 'string' - || typeof renderer.ext !== 'string' || typeof renderer.descr !== 'string') { + if ( + !record(renderer) || + typeof renderer.id !== 'string' || + typeof renderer.name !== 'string' || + typeof renderer.ext !== 'string' || + typeof renderer.descr !== 'string' + ) { throw new Error('Invalid httpgd renderer'); } - return { id: renderer.id, name: renderer.name, ext: renderer.ext, descr: renderer.descr }; + return { + id: renderer.id, + name: renderer.name, + ext: renderer.ext, + descr: renderer.descr, + }; }); this.renderersLoaded = true; } @@ -142,7 +190,9 @@ export class HttpgdClient { private refreshPlots(): Promise { this.refreshPending = true; if (!this.refreshTask) { - this.refreshTask = this.drainPlots().finally(() => { this.refreshTask = undefined; }); + this.refreshTask = this.drainPlots().finally(() => { + this.refreshTask = undefined; + }); } return this.refreshTask; } @@ -151,22 +201,33 @@ export class HttpgdClient { while (this.refreshPending && this.controller) { this.refreshPending = false; const value = await this.json(this.url('/plots')); - if (!record(value) || !Array.isArray(value.plots)) { throw new Error('Invalid httpgd plots'); } + if (!record(value) || !Array.isArray(value.plots)) { + throw new Error('Invalid httpgd plots'); + } const plots = value.plots.map((plot: unknown) => { - if (!record(plot) || typeof plot.id !== 'string') { throw new Error('Invalid httpgd plot ID'); } + if (!record(plot) || typeof plot.id !== 'string') { + throw new Error('Invalid httpgd plot ID'); + } return { id: plot.id }; }); this.snapshot = { state: state(value.state), plots }; // TODO: Revisit this notification boundary if httpgd and JGD viewer // updates are unified. Their transports remain separate for now. - for (const listener of this.listeners) { listener(this.snapshot); } + for (const listener of this.listeners) { + listener(this.snapshot); + } } } private async updateState(value: unknown): Promise { const next = state(value); const previous = this.snapshot?.state; - if (!previous || previous.upid !== next.upid || previous.hsize !== next.hsize || previous.active !== next.active) { + if ( + !previous || + previous.upid !== next.upid || + previous.hsize !== next.hsize || + previous.active !== next.active + ) { await this.refreshPlots(); } } @@ -174,18 +235,26 @@ export class HttpgdClient { private schedulePoll(delay = this.pollInterval): void { clearTimeout(this.pollTimer); if (this.controller && this.socket?.readyState !== 1) { - this.pollTimer = setTimeout(() => { void this.poll(); }, delay); + this.pollTimer = setTimeout(() => { + void this.poll(); + }, delay); } } private async poll(): Promise { - if (!this.controller || this.polling) { return; } + if (!this.controller || this.polling) { + return; + } this.polling = true; let delay = this.pollInterval; try { - if (!this.renderersLoaded) { await this.loadRenderers(); } + if (!this.renderersLoaded) { + await this.loadRenderers(); + } await this.updateState(await this.json(this.url('/state'))); - if (!this.socket && Date.now() >= this.retryAt) { this.openWebSocket(); } + if (!this.socket && Date.now() >= this.retryAt) { + this.openWebSocket(); + } } catch { delay = this.retryInterval; } finally { @@ -197,17 +266,23 @@ export class HttpgdClient { private openWebSocket(): void { // TODO: Remove this missing-WebSocket compatibility path when the minimum // extension host provides Node 22. Keep polling for connection failures. - if (typeof globalThis.WebSocket !== 'function' || !this.controller || this.socket) { return; } + if (typeof globalThis.WebSocket !== 'function' || !this.controller || this.socket) { + return; + } this.retryAt = Date.now() + this.retryInterval; const url = this.url('/'); url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; const token = this.headers['X-HTTPGD-TOKEN']; - if (token) { url.searchParams.set('token', token); } + if (token) { + url.searchParams.set('token', token); + } try { const socket = new WebSocket(url); this.socket = socket; const fallback = () => { - if (this.socket !== socket) { return; } + if (this.socket !== socket) { + return; + } this.closeWebSocket(); this.schedulePoll(); }; @@ -218,11 +293,15 @@ export class HttpgdClient { // Resync after every connection: changes during reconnect may have been missed. void this.refreshPlots().catch(fallback); }; - socket.onmessage = event => { - if (typeof event.data !== 'string') { return; } + socket.onmessage = (event) => { + if (typeof event.data !== 'string') { + return; + } try { void this.updateState(JSON.parse(event.data) as unknown).catch(fallback); - } catch { fallback(); } + } catch { + fallback(); + } }; socket.onclose = fallback; socket.onerror = fallback; diff --git a/src/plotViewer/httpgdTypes.d.ts b/src/plotViewer/httpgdTypes.d.ts index 3753b1ba3..db601ed0e 100644 --- a/src/plotViewer/httpgdTypes.d.ts +++ b/src/plotViewer/httpgdTypes.d.ts @@ -1,5 +1,3 @@ - - import { HttpgdClient, HttpgdPlotId } from './httpgdClient'; import * as vscode from 'vscode'; import { HttpgdManager } from '.'; @@ -8,7 +6,6 @@ import { PreviewPlotLayout } from './webviewMessages'; export type MaybePromise = T | Promise; export interface HttpgdPlot { - // unique ID for this plot (w.r.t. this connection/device) id: HttpgdPlotId; @@ -30,7 +27,7 @@ export interface HttpgdViewerOptions { htmlRoot: string; stripStyles?: boolean; fullWindow?: boolean; - previewPlotLayout?: PreviewPlotLayout, + previewPlotLayout?: PreviewPlotLayout; resizeTimeoutLength?: number; refreshTimeoutLength?: number; } @@ -58,7 +55,6 @@ export class IHttpgdViewer { // creates a new api instance itself constructor(options: HttpgdViewerOptions); - // Methods to interact with the webview // Can e.g. be called by vscode commands + menu items: diff --git a/src/plotViewer/httpgdViewer.ts b/src/plotViewer/httpgdViewer.ts index ff071d86e..2e28d4f32 100644 --- a/src/plotViewer/httpgdViewer.ts +++ b/src/plotViewer/httpgdViewer.ts @@ -1,4 +1,3 @@ - import * as vscode from 'vscode'; import { HttpgdClient, HttpgdIdResponse, HttpgdPlotId, HttpgdRendererId } from './httpgdClient'; import { HttpgdPlot, IHttpgdViewer, HttpgdViewerOptions } from './httpgdTypes'; @@ -8,7 +7,18 @@ import * as ejs from 'ejs'; import { asViewColumn, config, setContext, UriIcon, makeWebviewCommandUriString } from '../util'; import { extensionContext } from '../extension'; -import { FocusPlotMessage, InMessage, OutMessage, ToggleStyleMessage, UpdatePlotMessage, HidePlotMessage, AddPlotMessage, PreviewPlotLayout, PreviewPlotLayoutMessage, ToggleFullWindowMessage } from './webviewMessages'; +import { + FocusPlotMessage, + InMessage, + OutMessage, + ToggleStyleMessage, + UpdatePlotMessage, + HidePlotMessage, + AddPlotMessage, + PreviewPlotLayout, + PreviewPlotLayoutMessage, + ToggleFullWindowMessage, +} from './webviewMessages'; import { PlotViewer } from './types'; export class HttpgdManager { @@ -21,7 +31,7 @@ export class HttpgdManager { this.viewerOptions = { parent: this, htmlRoot: htmlRoot, - preserveFocus: true + preserveFocus: true, }; } @@ -30,9 +40,7 @@ export class HttpgdManager { const url = new URL(urlString); const host = url.host; const token = url.searchParams.get('token') || undefined; - const ind = this.viewers.findIndex( - (viewer) => viewer.host === host - ); + const ind = this.viewers.findIndex((viewer) => viewer.host === host); if (ind >= 0) { const viewer = this.viewers.splice(ind, 1)[0]; this.viewers.unshift(viewer); @@ -40,8 +48,11 @@ export class HttpgdManager { } else { const conf = config(); const colorTheme = conf.get('plot.defaults.colorTheme', 'vscode'); - this.viewerOptions.stripStyles = (colorTheme === 'vscode'); - this.viewerOptions.previewPlotLayout = conf.get('plot.defaults.plotPreviewLayout', 'multirow'); + this.viewerOptions.stripStyles = colorTheme === 'vscode'; + this.viewerOptions.previewPlotLayout = conf.get( + 'plot.defaults.plotPreviewLayout', + 'multirow', + ); this.viewerOptions.refreshTimeoutLength = conf.get('plot.timing.refreshInterval', 10); this.viewerOptions.resizeTimeoutLength = conf.get('plot.timing.resizeInterval', 100); this.viewerOptions.fullWindow = conf.get('plot.defaults.fullWindowMode', false); @@ -72,7 +83,7 @@ export class HttpgdManager { const val0 = clipText.trim().split(/[\n ]/)[0]; const options: vscode.InputBoxOptions = { value: val0, - prompt: 'Please enter the httpgd url' + prompt: 'Please enter the httpgd url', }; const urlString = await vscode.window.showInputBox(options); if (urlString) { @@ -96,8 +107,8 @@ interface EjsData { } interface ShowOptions { - viewColumn: vscode.ViewColumn, - preserveFocus?: boolean + viewColumn: vscode.ViewColumn; + preserveFocus?: boolean; } export class HttpgdViewer implements IHttpgdViewer, PlotViewer { @@ -136,7 +147,7 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { readonly webviewOptions: vscode.WebviewPanelOptions & vscode.WebviewOptions; protected get activeIndex(): number { - if(!this.activePlot){ + if (!this.activePlot) { return -1; } return this.getIndex(this.activePlot); @@ -159,37 +170,48 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { this.api = new HttpgdClient(this.host, this.token); this.api.onPlotsChanged((newState) => { - void this.refreshPlotsDelayed(newState.plots).catch(err => { + void this.refreshPlotsDelayed(newState.plots).catch((err) => { this.reportRefreshError(err); }); }); const conf = config(); this.customOverwriteCssPath = conf.get('plot.customStyleOverwrites', ''); - const localResourceRoots = ( - this.customOverwriteCssPath ? - [extensionContext.extensionUri, vscode.Uri.file(path.dirname(this.customOverwriteCssPath))] : - undefined - ); + const localResourceRoots = this.customOverwriteCssPath + ? [ + extensionContext.extensionUri, + vscode.Uri.file(path.dirname(this.customOverwriteCssPath)), + ] + : undefined; this.htmlRoot = options.htmlRoot; this.htmlTemplate = fs.readFileSync(path.join(this.htmlRoot, 'index.ejs'), 'utf-8'); - this.smallPlotTemplate = fs.readFileSync(path.join(this.htmlRoot, 'smallPlot.ejs'), 'utf-8'); + this.smallPlotTemplate = fs.readFileSync( + path.join(this.htmlRoot, 'smallPlot.ejs'), + 'utf-8', + ); this.showOptions = { - viewColumn: options.viewColumn ?? asViewColumn(conf.get('session.viewers.viewColumn.plot'), vscode.ViewColumn.Two), - preserveFocus: !!options.preserveFocus + viewColumn: + options.viewColumn ?? + asViewColumn( + conf.get('session.viewers.viewColumn.plot'), + vscode.ViewColumn.Two, + ), + preserveFocus: !!options.preserveFocus, }; this.webviewOptions = { enableCommandUris: true, enableScripts: true, retainContextWhenHidden: true, - localResourceRoots: localResourceRoots + localResourceRoots: localResourceRoots, }; this.stripStyles = options.stripStyles ?? this.defaultStripStyles; this.previewPlotLayout = options.previewPlotLayout ?? this.defaultPreviewPlotLayout; this.fullWindow = options.fullWindow ?? this.defaultFullWindow; this.resizeTimeoutLength = options.refreshTimeoutLength ?? this.resizeTimeoutLength; this.refreshTimeoutLength = options.refreshTimeoutLength ?? this.refreshTimeoutLength; - void this.api.connect().catch(err => { - if (!this.disposed) { void vscode.window.showErrorMessage(`httpgd connection failed: ${String(err)}`); } + void this.api.connect().catch((err) => { + if (!this.disposed) { + void vscode.window.showErrorMessage(`httpgd connection failed: ${String(err)}`); + } }); } @@ -198,21 +220,36 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { const boolArg = findItemOfType(args, 'boolean'); switch (command) { - case 'showIndex': return this.focusPlot(stringArg); - case 'nextPlot': return this.nextPlot(boolArg); - case 'prevPlot': return this.prevPlot(boolArg); - case 'lastPlot': return this.nextPlot(true); - case 'firstPlot': return this.prevPlot(true); - case 'resetPlots': return this.resetPlots(); - case 'toggleStyle': return this.toggleStyle(boolArg); - case 'togglePreviewPlots': return this.togglePreviewPlots(stringArg as PreviewPlotLayout); - case 'closePlot': return this.closePlot(stringArg); - case 'hidePlot': return this.hidePlot(stringArg); - case 'exportPlot': return this.exportPlot(stringArg); - case 'zoomIn': return this.zoomIn(); - case 'zoomOut': return this.zoomOut(); - case 'openExternal': return this.openExternal(); - case 'toggleFullWindow': return this.toggleFullWindow(); + case 'showIndex': + return this.focusPlot(stringArg); + case 'nextPlot': + return this.nextPlot(boolArg); + case 'prevPlot': + return this.prevPlot(boolArg); + case 'lastPlot': + return this.nextPlot(true); + case 'firstPlot': + return this.prevPlot(true); + case 'resetPlots': + return this.resetPlots(); + case 'toggleStyle': + return this.toggleStyle(boolArg); + case 'togglePreviewPlots': + return this.togglePreviewPlots(stringArg as PreviewPlotLayout); + case 'closePlot': + return this.closePlot(stringArg); + case 'hidePlot': + return this.hidePlot(stringArg); + case 'exportPlot': + return this.exportPlot(stringArg); + case 'zoomIn': + return this.zoomIn(); + case 'zoomOut': + return this.zoomOut(); + case 'openExternal': + return this.openExternal(); + case 'toggleFullWindow': + return this.toggleFullWindow(); } } @@ -221,7 +258,7 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { if (!this.webviewPanel) { const showOptions = { ...this.showOptions, - preserveFocus: preserveFocus + preserveFocus: preserveFocus, }; this.webviewPanel = this.makeNewWebview(showOptions); this.refreshHtml(); @@ -243,7 +280,12 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { public async focusPlot(id?: HttpgdPlotId): Promise { this.activePlot = id || this.activePlot; const plt = this.plots[this.activeIndex]; - if (plt && (plt.height !== this.viewHeight || plt.width !== this.viewHeight || plt.zoom !== this.zoom)) { + if ( + plt && + (plt.height !== this.viewHeight || + plt.width !== this.viewHeight || + plt.zoom !== this.zoom) + ) { await this.refreshPlots(this.api.getPlots()); } else { this._focusPlot(); @@ -251,12 +293,12 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { } protected _focusPlot(plotId?: HttpgdPlotId): void { plotId ??= this.activePlot; - if(!plotId){ + if (!plotId) { return; } const msg: FocusPlotMessage = { message: 'focusPlot', - plotId: plotId + plotId: plotId, }; this.postWebviewMessage(msg); void this.setContextValues(); @@ -274,12 +316,16 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { public resetPlots(): void { this.hiddenPlots = []; this.zoom = this.zoom0; - void this.refreshPlots(this.api.getPlots(), true, true).catch(err => this.reportRefreshError(err)); + void this.refreshPlots(this.api.getPlots(), true, true).catch((err) => + this.reportRefreshError(err), + ); } public hidePlot(id?: HttpgdPlotId): void { id ??= this.activePlot; - if (!id) { return; } + if (!id) { + return; + } const tmpIndex = this.activeIndex; this.hiddenPlots.push(id); this.plots = this.plots.filter((plt) => !this.hiddenPlots.includes(plt.id)); @@ -292,7 +338,7 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { protected _hidePlot(id: HttpgdPlotId): void { const msg: HidePlotMessage = { message: 'hidePlot', - plotId: id + plotId: id, }; this.postWebviewMessage(msg); } @@ -309,7 +355,7 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { this.stripStyles = force ?? !this.stripStyles; const msg: ToggleStyleMessage = { message: 'toggleStyle', - useOverwrites: this.stripStyles + useOverwrites: this.stripStyles, }; this.postWebviewMessage(msg); } @@ -318,7 +364,7 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { this.fullWindow = force ?? !this.fullWindow; const msg: ToggleFullWindowMessage = { message: 'toggleFullWindow', - useFullWindow: this.fullWindow + useFullWindow: this.fullWindow, }; this.postWebviewMessage(msg); } @@ -335,7 +381,7 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { } const msg: PreviewPlotLayoutMessage = { message: 'togglePreviewPlotLayout', - style: this.previewPlotLayout + style: this.previewPlotLayout, }; this.postWebviewMessage(msg); } @@ -343,13 +389,13 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { public zoomOut(): void { if (this.zoom > 0.1) { this.zoom -= 0.1; - void this.resizePlot().catch(err => this.reportRefreshError(err)); + void this.resizePlot().catch((err) => this.reportRefreshError(err)); } } public zoomIn(): void { this.zoom += 0.1; - void this.resizePlot().catch(err => this.reportRefreshError(err)); + void this.resizePlot().catch((err) => this.reportRefreshError(err)); } public async setContextValues(mightBeInBackground: boolean = false): Promise { @@ -381,51 +427,67 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { this.viewHeight = height; this.viewWidth = width; if (userTriggered || this.resizeTimeoutLength === 0) { - if(this.resizeTimeout){ + if (this.resizeTimeout) { clearTimeout(this.resizeTimeout); } this.resizeTimeout = undefined; - void this.resizePlot().catch(err => this.reportRefreshError(err)); + void this.resizePlot().catch((err) => this.reportRefreshError(err)); } else if (!this.resizeTimeout) { this.resizeTimeout = setTimeout(() => { - void this.resizePlot().catch(err => this.reportRefreshError(err)).then(() => - this.resizeTimeout = undefined - ); + void this.resizePlot() + .catch((err) => this.reportRefreshError(err)) + .then(() => (this.resizeTimeout = undefined)); }, this.resizeTimeoutLength); } } private reportRefreshError(err: unknown): void { - if (!this.disposed) { void vscode.window.showErrorMessage(`Plot refresh failed: ${String(err)}`); } + if (!this.disposed) { + void vscode.window.showErrorMessage(`Plot refresh failed: ${String(err)}`); + } } protected async resizePlot(id?: HttpgdPlotId): Promise { id ??= this.activePlot; - if (!id) { return; } + if (!id) { + return; + } const plt = await this.getPlotContent(id, this.viewWidth, this.viewHeight, this.zoom); - if (this.disposed) { return; } + if (this.disposed) { + return; + } this.plotWidth = plt.width; this.plotHeight = plt.height; this.updatePlot(plt); } - protected async refreshPlotsDelayed(plotsIdResponse: HttpgdIdResponse[], redraw: boolean = false, force: boolean = false): Promise { - if(this.refreshTimeoutLength === 0){ + protected async refreshPlotsDelayed( + plotsIdResponse: HttpgdIdResponse[], + redraw: boolean = false, + force: boolean = false, + ): Promise { + if (this.refreshTimeoutLength === 0) { await this.refreshPlots(plotsIdResponse, redraw, force); - } else{ + } else { clearTimeout(this.refreshTimeout); this.refreshTimeout = setTimeout(() => { - void this.refreshPlots(plotsIdResponse, redraw, force).catch(err => { - this.reportRefreshError(err); - }).then(() => - this.refreshTimeout = undefined - ); + void this.refreshPlots(plotsIdResponse, redraw, force) + .catch((err) => { + this.reportRefreshError(err); + }) + .then(() => (this.refreshTimeout = undefined)); }, this.refreshTimeoutLength); } } - protected async refreshPlots(plotsIdResponse: HttpgdIdResponse[], redraw: boolean = false, force: boolean = false): Promise { - if (this.disposed) { return; } + protected async refreshPlots( + plotsIdResponse: HttpgdIdResponse[], + redraw: boolean = false, + force: boolean = false, + ): Promise { + if (this.disposed) { + return; + } const nPlots = this.plots.length; let plotIds = plotsIdResponse.map((x) => x.id); plotIds = plotIds.filter((id) => !this.hiddenPlots.includes(id)); @@ -438,8 +500,10 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { } }); const newPlots = await Promise.all(newPlotPromises); - if (this.disposed) { return; } - const oldPlotIds = this.plots.map(plt => plt.id); + if (this.disposed) { + return; + } + const oldPlotIds = this.plots.map((plt) => plt.id); this.plots = newPlots; if (this.plots.length !== nPlots) { this.activePlot = this.plots[this.plots.length - 1]?.id; @@ -462,7 +526,7 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { const msg: UpdatePlotMessage = { message: 'updatePlot', plotId: plt.id, - svg: plt.data + svg: plt.data, }; this.postWebviewMessage(msg); } @@ -473,20 +537,25 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { const html = ejs.render(this.smallPlotTemplate, ejsData); const msg: AddPlotMessage = { message: 'addPlot', - html: html + html: html, }; this.postWebviewMessage(msg); void this.focusPlot(plt.id); void this.setContextValues(); } - protected async getPlotContent(id: HttpgdPlotId, width: number, height: number, zoom: number): Promise> { + protected async getPlotContent( + id: HttpgdPlotId, + width: number, + height: number, + zoom: number, + ): Promise> { const args = { id: id, height: height, width: width, zoom: zoom, - renderer: 'svgp' + renderer: 'svgp', }; const svg = await this.api.getPlotText(args); const plt: HttpgdPlot = { @@ -547,7 +616,7 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { asLocalPath: asLocalPath, asWebViewPath: asWebViewPath, makeCommandUri: makeWebviewCommandUriString, - overwriteCssPath: overwriteCssPath + overwriteCssPath: overwriteCssPath, }; } @@ -556,10 +625,10 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { 'RPlot', 'R Plot', showOptions || this.showOptions, - this.webviewOptions + this.webviewOptions, ); webviewPanel.iconPath = new UriIcon('graph'); - webviewPanel.onDidDispose(() => this.webviewPanel = undefined); + webviewPanel.onDidDispose(() => (this.webviewPanel = undefined)); webviewPanel.onDidChangeViewState(() => { void this.setContextValues(); }); @@ -581,7 +650,11 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { void this.webviewPanel?.webview.postMessage(msg); } - public async exportPlot(id?: HttpgdPlotId, rendererId?: HttpgdRendererId, outFile?: string): Promise { + public async exportPlot( + id?: HttpgdPlotId, + rendererId?: HttpgdRendererId, + outFile?: string, + ): Promise { id ||= this.activePlot || this.plots[this.plots.length - 1]?.id; const plot = this.plots.find((plt) => plt.id === id); if (!plot) { @@ -590,41 +663,53 @@ export class HttpgdViewer implements IHttpgdViewer, PlotViewer { } if (!rendererId) { const renderers = this.api.getRenderers(); - const qpItems = renderers.map(renderer => ({ + const qpItems = renderers.map((renderer) => ({ label: renderer.name, detail: renderer.descr, - id: renderer.id + id: renderer.id, })); - const qpPick = await vscode.window.showQuickPick(qpItems, { placeHolder: 'Please choose a file format' }); + const qpPick = await vscode.window.showQuickPick(qpItems, { + placeHolder: 'Please choose a file format', + }); rendererId = qpPick?.id; - if(!rendererId){ + if (!rendererId) { return; } } if (!outFile) { const options: vscode.SaveDialogOptions = {}; - const renderer = this.api.getRenderers().find(r => r.id === rendererId); + const renderer = this.api.getRenderers().find((r) => r.id === rendererId); const ext = renderer?.ext.replace(/^\./, ''); - if(this.lastExportUri){ + if (this.lastExportUri) { const noExtPath = this.lastExportUri.fsPath.replace(/\.[^.]*$/, ''); options.defaultUri = vscode.Uri.file(noExtPath + (ext ? `.${ext}` : '')); } else { const defaultFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - if(defaultFolder) {options.defaultUri = vscode.Uri.file(path.join(defaultFolder, 'plot' + (ext ? `.${ext}` : '')));} + if (defaultFolder) { + options.defaultUri = vscode.Uri.file( + path.join(defaultFolder, 'plot' + (ext ? `.${ext}` : '')), + ); + } + } + if (ext && renderer?.name) { + options.filters = { [renderer.name]: [ext], ['All']: ['*'] }; } - if(ext && renderer?.name) {options.filters = { [renderer.name]: [ext], ['All']: ['*'] };} const outUri = await vscode.window.showSaveDialog(options); - if(outUri){ + if (outUri) { this.lastExportUri = outUri; outFile = outUri.fsPath; - } else {return;} + } else { + return; + } } try { const bytes = await this.api.getPlotBytes({ id, renderer: rendererId }); await fs.promises.writeFile(outFile, bytes); void vscode.window.showInformationMessage(`Export done: ${outFile}`); } catch (err) { - if (!this.disposed) { void vscode.window.showErrorMessage(`Export failed: ${String(err)}`); } + if (!this.disposed) { + void vscode.window.showErrorMessage(`Export failed: ${String(err)}`); + } } } diff --git a/src/plotViewer/index.ts b/src/plotViewer/index.ts index 6988427af..89ccc4f87 100644 --- a/src/plotViewer/index.ts +++ b/src/plotViewer/index.ts @@ -1,4 +1,3 @@ - import * as vscode from 'vscode'; import { PlotViewer, PlotManager } from './types'; import { HttpgdManager, HttpgdViewer } from './httpgdViewer'; @@ -16,12 +15,12 @@ export function resolveBackend(): PlotBackend { config(), 'plot.backend', 'plot.useHttpgd', - value => value !== 'auto' && value !== false + (value) => value !== 'auto' && value !== false, )?.value; if (selected === true) { return 'httpgd'; } - return typeof selected === 'string' ? selected as PlotBackend : 'auto'; + return typeof selected === 'string' ? (selected as PlotBackend) : 'auto'; } export function jgdEnabled(backend = resolveBackend()): boolean { @@ -45,7 +44,7 @@ const commands = [ 'closePlot', 'resetPlots', 'zoomIn', - 'zoomOut' + 'zoomOut', ] as const; export class CommonPlotManager implements PlotManager { @@ -71,7 +70,11 @@ export class CommonPlotManager implements PlotManager { get activeViewer(): PlotViewer | undefined { if (jgdEnabled()) { - return this.jgdManager.getViewer() || this.httpgdManager.getRecentViewer() || this.standardPlotViewer; + return ( + this.jgdManager.getViewer() || + this.httpgdManager.getRecentViewer() || + this.standardPlotViewer + ); } return this.httpgdManager.getRecentViewer() || this.standardPlotViewer; } @@ -82,19 +85,25 @@ export class CommonPlotManager implements PlotManager { for (const cmd of commands) { const fullCommand = `r.plot.${cmd}`; extensionContext.subscriptions.push( - vscode.commands.registerCommand(fullCommand, (hostOrWebviewUri?: string | vscode.Uri, ...args: unknown[]) => { - void this.handleCommand(cmd, hostOrWebviewUri, ...args); - }) + vscode.commands.registerCommand( + fullCommand, + (hostOrWebviewUri?: string | vscode.Uri, ...args: unknown[]) => { + void this.handleCommand(cmd, hostOrWebviewUri, ...args); + }, + ), ); } this.applyBackend(); extensionContext.subscriptions.push( - vscode.workspace.onDidChangeConfiguration(e => { - if (e.affectsConfiguration('r.plot.backend') || e.affectsConfiguration('r.plot.useHttpgd')) { + vscode.workspace.onDidChangeConfiguration((e) => { + if ( + e.affectsConfiguration('r.plot.backend') || + e.affectsConfiguration('r.plot.useHttpgd') + ) { this.applyBackend(); } - }) + }), ); } @@ -132,7 +141,11 @@ export class CommonPlotManager implements PlotManager { this.jgdManager.stop(); } - private async handleCommand(command: string, hostOrWebviewUri?: string | vscode.Uri, ...args: unknown[]): Promise { + private async handleCommand( + command: string, + hostOrWebviewUri?: string | vscode.Uri, + ...args: unknown[] + ): Promise { if (command === 'showViewers') { for (const viewer of this.viewers) { viewer.show(true); @@ -148,9 +161,13 @@ export class CommonPlotManager implements PlotManager { // Identify the correct viewer let viewer: PlotViewer | undefined; if (typeof hostOrWebviewUri === 'string') { - viewer = this.httpgdManager.viewers.find((v: HttpgdViewer) => v.host === hostOrWebviewUri); + viewer = this.httpgdManager.viewers.find( + (v: HttpgdViewer) => v.host === hostOrWebviewUri, + ); } else if (hostOrWebviewUri instanceof vscode.Uri) { - viewer = this.httpgdManager.viewers.find((v: HttpgdViewer) => v.getPanelPath() === hostOrWebviewUri.path); + viewer = this.httpgdManager.viewers.find( + (v: HttpgdViewer) => v.getPanelPath() === hostOrWebviewUri.path, + ); } // Fallback to active viewer diff --git a/src/plotViewer/jgdPlotHistory.ts b/src/plotViewer/jgdPlotHistory.ts index c1fb694ad..a7679d470 100644 --- a/src/plotViewer/jgdPlotHistory.ts +++ b/src/plotViewer/jgdPlotHistory.ts @@ -85,7 +85,9 @@ export class PlotHistory { latest.ops.push(op); } latest.device = plot.device; - if (plot.frameExt) { latest.frameExt = plot.frameExt; } + if (plot.frameExt) { + latest.frameExt = plot.frameExt; + } this.activeSessionId = sessionId; this.emitter.emit('change'); return true; @@ -96,7 +98,7 @@ export class PlotHistory { if (!session) { return false; } - const idx = session.plots.findIndex(p => p.rIndex === rIndex); + const idx = session.plots.findIndex((p) => p.rIndex === rIndex); if (idx < 0) { return false; } @@ -117,7 +119,11 @@ export class PlotHistory { return true; } const old = session.plots[session.plots.length - 1]; - if (expectedRIndex !== undefined && old?.rIndex !== undefined && old.rIndex !== expectedRIndex) { + if ( + expectedRIndex !== undefined && + old?.rIndex !== undefined && + old.rIndex !== expectedRIndex + ) { return false; } if (old?.rIndex !== undefined) { @@ -202,7 +208,7 @@ export class PlotHistory { if (!session || session.plots.length === 0) { return null; } - const wasLatest = (session.currentIndex === session.plots.length - 1); + const wasLatest = session.currentIndex === session.plots.length - 1; session.plots.splice(session.currentIndex, 1); if (wasLatest) { session.latestDeleted = true; diff --git a/src/plotViewer/jgdSocketServer.ts b/src/plotViewer/jgdSocketServer.ts index 9b623b447..29e7f100b 100644 --- a/src/plotViewer/jgdSocketServer.ts +++ b/src/plotViewer/jgdSocketServer.ts @@ -138,9 +138,17 @@ export class JgdSocketServer { // Place the socket in a private 0o700 directory so other local // users cannot connect to it (mirrors the IPC pipe handling in #1705). this.socketDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jgd-')); - try { fs.chmodSync(this.socketDir, 0o700); } catch { /* ignore */ } + try { + fs.chmodSync(this.socketDir, 0o700); + } catch { + /* ignore */ + } this.socketPath = path.join(this.socketDir, `${token}.sock`); - try { fs.unlinkSync(this.socketPath); } catch { /* ignore */ } + try { + fs.unlinkSync(this.socketPath); + } catch { + /* ignore */ + } this.server.listen(this.socketPath, () => { console.log('jgd: socket server listening at', this.socketPath); @@ -166,9 +174,17 @@ export class JgdSocketServer { this.sessions.clear(); this.server?.close(); if (!isWindows) { - try { fs.unlinkSync(this.socketPath); } catch { /* ignore */ } + try { + fs.unlinkSync(this.socketPath); + } catch { + /* ignore */ + } if (this.socketDir) { - try { fs.rmSync(this.socketDir, { recursive: true, force: true }); } catch { /* ignore */ } + try { + fs.rmSync(this.socketDir, { recursive: true, force: true }); + } catch { + /* ignore */ + } this.socketDir = ''; } } @@ -179,8 +195,14 @@ export class JgdSocketServer { // Metrics requests omit DPI. Use JGD's default until a frame announces // the device's resolution, then keep it specific to this connection. const session: RSession = { - id: sessionId, socket, buffer: '', welcomeSent: false, - lastResizeW: 0, lastResizeH: 0, lastResizeHadPlotIndex: false, dpi: 96 + id: sessionId, + socket, + buffer: '', + welcomeSent: false, + lastResizeW: 0, + lastResizeH: 0, + lastResizeHadPlotIndex: false, + dpi: 96, }; this.sessions.set(sessionId, session); this.notifyConnectionChange(); @@ -209,7 +231,13 @@ export class JgdSocketServer { if (dims) { session.lastResizeW = dims.width; session.lastResizeH = dims.height; - socket.write(JSON.stringify({ type: 'resize', width: dims.width, height: dims.height }) + '\n'); + socket.write( + JSON.stringify({ + type: 'resize', + width: dims.width, + height: dims.height, + }) + '\n', + ); } } @@ -236,23 +264,43 @@ export class JgdSocketServer { case 'frame': { const plot = msg.plot; if (plot) { - if (Number.isFinite(plot.device?.dpi) && plot.device.dpi > 0) { session.dpi = plot.device.dpi; } + if (Number.isFinite(plot.device?.dpi) && plot.device.dpi > 0) { + session.dpi = plot.device.dpi; + } plot.sessionId = session.id; plot.frameExt = msg.ext ?? null; const isResizeReplay = !!msg.resizeReplay; - const plotIndex = (typeof msg.plotIndex === 'number' && Number.isFinite(msg.plotIndex)) ? msg.plotIndex : undefined; + const plotIndex = + typeof msg.plotIndex === 'number' && Number.isFinite(msg.plotIndex) + ? msg.plotIndex + : undefined; let accepted = true; if (isResizeReplay && plotIndex !== undefined) { - accepted = this.history.replaceAtIndex(session.id, plotIndex, plot as PlotFrame); + accepted = this.history.replaceAtIndex( + session.id, + plotIndex, + plot as PlotFrame, + ); } else if (isResizeReplay) { - const plotNumber = (typeof msg.plotNumber === 'number' && Number.isFinite(msg.plotNumber)) ? msg.plotNumber : undefined; - accepted = this.history.replaceLatest(session.id, plot as PlotFrame, plotNumber); + const plotNumber = + typeof msg.plotNumber === 'number' && + Number.isFinite(msg.plotNumber) + ? msg.plotNumber + : undefined; + accepted = this.history.replaceLatest( + session.id, + plot as PlotFrame, + plotNumber, + ); } else if (msg.incremental) { accepted = this.history.appendOps(session.id, plot as PlotFrame); } else if (msg.newPage) { - if (typeof msg.plotNumber === 'number' && Number.isFinite(msg.plotNumber)) { + if ( + typeof msg.plotNumber === 'number' && + Number.isFinite(msg.plotNumber) + ) { plot.rIndex = msg.plotNumber; } this.history.addPlot(session.id, plot as PlotFrame); @@ -268,12 +316,14 @@ export class JgdSocketServer { case 'metrics_request': if (this.measureTextFn) { - void this.measureTextFn(msg, session.dpi).then((response: unknown) => { - const resp = JSON.stringify(response) + '\n'; - session.socket.write(resp); - }).catch((error: unknown) => { - console.warn('jgd font measurement failed:', error); - }); + void this.measureTextFn(msg, session.dpi) + .then((response: unknown) => { + const resp = JSON.stringify(response) + '\n'; + session.socket.write(resp); + }) + .catch((error: unknown) => { + console.warn('jgd font measurement failed:', error); + }); } break; diff --git a/src/plotViewer/jgdViewer.ts b/src/plotViewer/jgdViewer.ts index 1c377c8ce..5714c835c 100644 --- a/src/plotViewer/jgdViewer.ts +++ b/src/plotViewer/jgdViewer.ts @@ -135,10 +135,16 @@ export class JgdManager { this.server.start(); this.historyChangeDisposable = this.history.onDidChange(() => { - void vscode.commands.executeCommand('setContext', 'r.plot.canGoBack', - this.history.currentIndex() > 1); - void vscode.commands.executeCommand('setContext', 'r.plot.canGoForward', - this.history.currentIndex() < this.history.count()); + void vscode.commands.executeCommand( + 'setContext', + 'r.plot.canGoBack', + this.history.currentIndex() > 1, + ); + void vscode.commands.executeCommand( + 'setContext', + 'r.plot.canGoForward', + this.history.currentIndex() < this.history.count(), + ); }); } @@ -205,7 +211,10 @@ export class JgdViewer implements PlotViewer { if (plot) { this.sendPlotToWebview(plot); this.updateToolbar(); - if (plot.device.width !== this.panelWidth || plot.device.height !== this.panelHeight) { + if ( + plot.device.width !== this.panelWidth || + plot.device.height !== this.panelHeight + ) { this.server.handleResize(this.panelWidth, this.panelHeight); } } @@ -216,7 +225,10 @@ export class JgdViewer implements PlotViewer { if (plot) { this.sendPlotToWebview(plot); this.updateToolbar(); - if (plot.device.width !== this.panelWidth || plot.device.height !== this.panelHeight) { + if ( + plot.device.width !== this.panelWidth || + plot.device.height !== this.panelHeight + ) { this.server.handleResize(this.panelWidth, this.panelHeight); } } @@ -303,7 +315,7 @@ export class JgdViewer implements PlotViewer { void this.panel?.webview.postMessage({ type: 'toolbar', current: this.history.currentIndex(), - total: this.history.count() + total: this.history.count(), }); } @@ -379,7 +391,7 @@ export class JgdViewer implements PlotViewer { const result: MetricsCacheEntry = { width: baseCached.width * scale, ascent: baseCached.ascent * scale, - descent: baseCached.descent * scale + descent: baseCached.descent * scale, }; this.metricsCache.set(cacheKey, result); return { type: 'metrics_response', id: request.id, ...result }; @@ -393,7 +405,11 @@ export class JgdViewer implements PlotViewer { return new Promise((resolve) => { const id = ++this.metricsIdCounter; this.pendingMetrics.set(id, (response: MetricsResponse) => { - this.metricsCache.set(cacheKey, { width: response.width, ascent: response.ascent, descent: response.descent }); + this.metricsCache.set(cacheKey, { + width: response.width, + ascent: response.ascent, + descent: response.descent, + }); resolve(response); }); void this.panel!.webview.postMessage({ @@ -403,20 +419,27 @@ export class JgdViewer implements PlotViewer { kind: request.kind, str: request.str, c: request.c, - gc: request.gc + gc: request.gc, }); setTimeout(() => { if (this.pendingMetrics.has(id)) { this.pendingMetrics.delete(id); - resolve({ type: 'metrics_response', id: request.id, width: 0, ascent: 0, descent: 0 }); + resolve({ + type: 'metrics_response', + id: request.id, + width: 0, + ascent: 0, + descent: 0, + }); } }, 500); }); } private createPanel(preserveFocus = false) { - const viewColumnConfig = config().get>('session.viewers.viewColumn') ?? {}; + const viewColumnConfig = + config().get>('session.viewers.viewColumn') ?? {}; const plotColumn = viewColumnConfig['plot'] ?? 'Two'; let viewColumn = vscode.ViewColumn.Two; if (plotColumn === 'Active') { @@ -432,7 +455,7 @@ export class JgdViewer implements PlotViewer { { enableScripts: true, retainContextWhenHidden: true, - } + }, ); this.panel.iconPath = new UriIcon('graph'); @@ -449,7 +472,7 @@ export class JgdViewer implements PlotViewer { id: raw.originalId, width: raw.width, ascent: raw.ascent, - descent: raw.descent + descent: raw.descent, }); } break; @@ -457,7 +480,11 @@ export class JgdViewer implements PlotViewer { case 'metrics_warmup': { if (raw.entries && Array.isArray(raw.entries)) { for (const e of raw.entries) { - this.metricsCache.set(e.key, { width: e.width, ascent: e.ascent, descent: e.descent }); + this.metricsCache.set(e.key, { + width: e.width, + ascent: e.ascent, + descent: e.descent, + }); } } break; @@ -510,7 +537,9 @@ export class JgdViewer implements PlotViewer { if (!m) { return 'Enter as "7 x 5 @ 150" (inches @ DPI)'; } - const w = parseFloat(m[1]), h = parseFloat(m[2]), dpi = parseInt(m[3] || '150'); + const w = parseFloat(m[1]), + h = parseFloat(m[2]), + dpi = parseInt(m[3] || '150'); if (w < 0.5 || h < 0.5 || w > 50 || h > 50) { return 'Dimensions must be 0.5–50 inches'; } @@ -518,7 +547,7 @@ export class JgdViewer implements PlotViewer { return 'DPI must be 36–600'; } return null; - } + }, }); if (!input) { return; @@ -542,7 +571,7 @@ export class JgdViewer implements PlotViewer { const ext = msg.format; const uri = await vscode.window.showSaveDialog({ filters: { [filters[ext]?.[0] ?? ext]: [ext] }, - defaultUri: vscode.Uri.file(`plot.${ext}`) + defaultUri: vscode.Uri.file(`plot.${ext}`), }); if (!uri) { return; diff --git a/src/plotViewer/standardViewer.ts b/src/plotViewer/standardViewer.ts index e88015138..16633c16e 100644 --- a/src/plotViewer/standardViewer.ts +++ b/src/plotViewer/standardViewer.ts @@ -1,4 +1,3 @@ - import * as vscode from 'vscode'; import { asViewColumn, config, UriIcon } from '../util'; import * as session from '../session'; @@ -31,7 +30,10 @@ export class StandardPlotViewer implements PlotViewer { private plotRequest: Promise | undefined; public async update(): Promise { - const viewColumn = asViewColumn(config().get('session.viewers.viewColumn.plot'), vscode.ViewColumn.Two); + const viewColumn = asViewColumn( + config().get('session.viewers.viewColumn.plot'), + vscode.ViewColumn.Two, + ); if (!this.panel) { this.createPanel(viewColumn); } else { @@ -65,12 +67,12 @@ export class StandardPlotViewer implements PlotViewer { 'R Plot', { viewColumn, - preserveFocus: true + preserveFocus: true, }, { enableScripts: true, - retainContextWhenHidden: true - } + retainContextWhenHidden: true, + }, ); this.panel = panel; const generation = ++this.panelGeneration; @@ -78,13 +80,19 @@ export class StandardPlotViewer implements PlotViewer { panel.iconPath = new UriIcon('graph'); panel.webview.html = this.getHtml(); - panel.webview.onDidReceiveMessage(async (msg: { type: string, width?: number, height?: number }) => { - if (this.panel === panel && this.panelGeneration === generation && msg.type === 'resize') { - this.viewWidth = msg.width || this.viewWidth; - this.viewHeight = msg.height || this.viewHeight; - await this.requestPlot(); - } - }); + panel.webview.onDidReceiveMessage( + async (msg: { type: string; width?: number; height?: number }) => { + if ( + this.panel === panel && + this.panelGeneration === generation && + msg.type === 'resize' + ) { + this.viewWidth = msg.width || this.viewWidth; + this.viewHeight = msg.height || this.viewHeight; + await this.requestPlot(); + } + }, + ); panel.onDidDispose(() => { if (this.panel === panel && this.panelGeneration === generation) { @@ -107,7 +115,7 @@ export class StandardPlotViewer implements PlotViewer { width: this.viewWidth, height: this.viewHeight, format: config().get('plot.format', 'svglite'), - devArgs: config().get>('plot.devArgs') + devArgs: config().get>('plot.devArgs'), }; return this.startPlotDrain(); } @@ -120,30 +128,35 @@ export class StandardPlotViewer implements PlotViewer { // Publish the flight before starting the drain because a requester can // synchronously trigger another viewer event while starting its RPC. let resolveFlight!: () => void; - const flight = new Promise(resolve => { resolveFlight = resolve; }); + const flight = new Promise((resolve) => { + resolveFlight = resolve; + }); this.plotRequest = flight; const drain = () => { - void this.drainPlotRequests().then(() => { - if (this.plotRequest !== flight) { - resolveFlight(); - return; - } - if (this.pendingPlotRequest) { - // Keep the queued descriptor as captured; never recapture - // the active session while handing off the same flight. - drain(); - return; - } - this.plotRequest = undefined; - resolveFlight(); - }, error => { - console.error('Failed to update the standard plot viewer:', error); - if (this.plotRequest === flight) { + void this.drainPlotRequests().then( + () => { + if (this.plotRequest !== flight) { + resolveFlight(); + return; + } + if (this.pendingPlotRequest) { + // Keep the queued descriptor as captured; never recapture + // the active session while handing off the same flight. + drain(); + return; + } this.plotRequest = undefined; - } - resolveFlight(); - }); + resolveFlight(); + }, + (error) => { + console.error('Failed to update the standard plot viewer:', error); + if (this.plotRequest === flight) { + this.plotRequest = undefined; + } + resolveFlight(); + }, + ); }; drain(); @@ -160,15 +173,18 @@ export class StandardPlotViewer implements PlotViewer { let response: PlotResponse | undefined; try { - response = await session.sessionRequest({ - method: 'plot_latest', - params: { - width: request.width, - height: request.height, - format: request.format, - devArgs: request.devArgs - } - }, request.targetSession) as PlotResponse | undefined; + response = (await session.sessionRequest( + { + method: 'plot_latest', + params: { + width: request.width, + height: request.height, + format: request.format, + devArgs: request.devArgs, + }, + }, + request.targetSession, + )) as PlotResponse | undefined; } catch { // sessionRequest normally converts transport failures to undefined; // keep the viewer resilient if a requester rejects directly. @@ -187,17 +203,19 @@ export class StandardPlotViewer implements PlotViewer { void request.panel.webview.postMessage({ type: 'update', data: this.plotData, - format: this.plotFormat + format: this.plotFormat, }); } } } private isCurrentRequest(request: PendingPlotRequest): boolean { - return Boolean(session.globalPipePath) - && this.panel === request.panel - && this.panelGeneration === request.panelGeneration - && session.activeSession === request.targetSession; + return ( + Boolean(session.globalPipePath) && + this.panel === request.panel && + this.panelGeneration === request.panelGeneration && + session.activeSession === request.targetSession + ); } private discardPendingForSession(targetSession: session.Session | undefined): void { diff --git a/src/plotViewer/types.ts b/src/plotViewer/types.ts index 478c2d291..7bb352250 100644 --- a/src/plotViewer/types.ts +++ b/src/plotViewer/types.ts @@ -1,4 +1,3 @@ - export interface PlotViewer { readonly id: string; show(preserveFocus?: boolean): void; diff --git a/src/plotViewer/webview/index.ts b/src/plotViewer/webview/index.ts index 3f569a8de..b7eb66f54 100644 --- a/src/plotViewer/webview/index.ts +++ b/src/plotViewer/webview/index.ts @@ -1,5 +1,3 @@ - - interface Plot { // unique ID for this plot (w.r.t. this connection/device) id: string; @@ -18,17 +16,15 @@ const vscode = acquireVsCodeApi() as { postMessage: (msg: any) => void }; let oldHeight = -1; let oldWidth = -1; - const handler = document.querySelector('#handler') as HTMLDivElement; const largePlotDiv = document.querySelector('#largePlot') as HTMLDivElement; const largeSvg = largePlotDiv.querySelector('svg') as SVGElement; const cssLink = document.querySelector('link.overwrites') as HTMLLinkElement; const smallPlotDiv = document.querySelector('#smallPlots') as HTMLDivElement; - function getSmallPlots(): HTMLAnchorElement[] { const smallPlots: HTMLAnchorElement[] = []; - document.querySelectorAll('a.focusPlot').forEach(elm => { + document.querySelectorAll('a.focusPlot').forEach((elm) => { smallPlots.push(elm as HTMLAnchorElement); }); return smallPlots; @@ -36,22 +32,21 @@ function getSmallPlots(): HTMLAnchorElement[] { let isHandlerDragging = false; - let isFullWindow = false; -function postResizeMessage(userTriggered: boolean = false){ +function postResizeMessage(userTriggered: boolean = false) { let newHeight = largePlotDiv.clientHeight; let newWidth = largePlotDiv.clientWidth; - if(isFullWindow){ + if (isFullWindow) { newHeight = window.innerHeight; newWidth = window.innerWidth; } - if(userTriggered || newHeight !== oldHeight || newWidth !== oldWidth){ + if (userTriggered || newHeight !== oldHeight || newWidth !== oldWidth) { const msg: ResizeMessage = { message: 'resize', height: newHeight, width: newWidth, - userTriggered: userTriggered + userTriggered: userTriggered, }; vscode.postMessage(msg); oldHeight = newHeight; @@ -61,27 +56,27 @@ function postResizeMessage(userTriggered: boolean = false){ window.addEventListener('message', (ev: MessageEvent) => { const msg = ev.data; - if(msg.message === 'updatePlot'){ + if (msg.message === 'updatePlot') { updatePlot({ id: String(msg.plotId), - svg: msg.svg + svg: msg.svg, }); - } else if(msg.message === 'focusPlot'){ + } else if (msg.message === 'focusPlot') { focusPlot(String(msg.plotId)); - } else if(msg.message === 'toggleStyle'){ + } else if (msg.message === 'toggleStyle') { toggleStyle(msg.useOverwrites); - } else if(msg.message === 'hidePlot'){ + } else if (msg.message === 'hidePlot') { hidePlot(msg.plotId); - } else if(msg.message === 'addPlot'){ + } else if (msg.message === 'addPlot') { addPlot(msg.html); - } else if(msg.message === 'togglePreviewPlotLayout'){ + } else if (msg.message === 'togglePreviewPlotLayout') { togglePreviewPlotLayout(msg.style); - } else if(msg.message === 'toggleFullWindow'){ + } else if (msg.message === 'toggleFullWindow') { toggleFullWindowMode(msg.useFullWindow); } }); -function addPlot(html: string){ +function addPlot(html: string) { const wrapper = document.createElement('div'); wrapper.classList.add('wrapper'); wrapper.innerHTML = html; @@ -89,15 +84,14 @@ function addPlot(html: string){ } function focusPlot(plotId: string): void { - const smallPlots = getSmallPlots(); const ind = findIndex(plotId, smallPlots); - if(ind < 0){ + if (ind < 0) { return; } - for(const elm of smallPlots){ + for (const elm of smallPlots) { elm.classList.remove('active'); } @@ -109,17 +103,16 @@ function focusPlot(plotId: string): void { } function updatePlot(plt: Plot): void { - const smallPlots = getSmallPlots(); const ind = findIndex(plt.id, smallPlots); - if(ind<0){ + if (ind < 0) { return; } smallPlots[ind].innerHTML = plt.svg; - if(smallPlots[ind].classList.contains('active')){ + if (smallPlots[ind].classList.contains('active')) { largePlotDiv.innerHTML = plt.svg; } } @@ -128,11 +121,11 @@ function hidePlot(plotId: string): void { const smallPlots = getSmallPlots(); const ind = findIndex(plotId, smallPlots); - if(ind<0){ + if (ind < 0) { return; } - if(smallPlots[ind].classList.contains('active')){ + if (smallPlots[ind].classList.contains('active')) { largePlotDiv.innerHTML = ''; } @@ -141,8 +134,8 @@ function hidePlot(plotId: string): void { function findIndex(plotId: string, smallPlots?: Element[]): number { smallPlots ||= getSmallPlots(); - const ind = smallPlots.findIndex(elm => elm.getAttribute('plotId') === String(plotId)); - if(ind<0){ + const ind = smallPlots.findIndex((elm) => elm.getAttribute('plotId') === String(plotId)); + if (ind < 0) { console.warn(`plotId not found: ${plotId}`); } return ind; @@ -159,7 +152,7 @@ function togglePreviewPlotLayout(newStyle: PreviewPlotLayout): void { function toggleFullWindowMode(useFullWindow: boolean): void { isFullWindow = useFullWindow; - if(useFullWindow){ + if (useFullWindow) { document.body.classList.add('fullWindow'); window.scrollTo(0, 0); } else { @@ -177,12 +170,10 @@ window.onload = () => { postResizeMessage(true); }; - //// // Resize bar //// - document.addEventListener('mousedown', (e) => { // If mousedown event is fired from .handler, toggle flag to true if (!isFullWindow && e.target === handler) { @@ -213,7 +204,7 @@ document.addEventListener('mousemove', (e) => { const newHeight = Math.max(largePlotMinHeight, pointerRelativeYpos - 5); // <- why 5? const newHeightString = `${newHeight}px`; - if(largePlotDiv.style.height !== newHeightString){ + if (largePlotDiv.style.height !== newHeightString) { largePlotDiv.style.height = newHeightString; postResizeMessage(); } @@ -223,11 +214,10 @@ window.onresize = () => postResizeMessage(); document.addEventListener('mouseup', () => { // Turn off dragging flag when user mouse is up - if(isHandlerDragging){ + if (isHandlerDragging) { postResizeMessage(true); document.body.style.cursor = ''; } handler.classList.remove('dragging'); isHandlerDragging = false; }); - diff --git a/src/plotViewer/webviewMessages.d.ts b/src/plotViewer/webviewMessages.d.ts index 47b9a62b3..206d81541 100644 --- a/src/plotViewer/webviewMessages.d.ts +++ b/src/plotViewer/webviewMessages.d.ts @@ -1,70 +1,70 @@ - // Make sure these files contain the same interfaces as ./html/XXX/webviewMessages.d.ts! - interface VsCode { postMessage: (msg: OutMessage) => void; setState: (state: string) => void; } declare function acquireVsCodeApi(): VsCode; - - export interface IMessage { message: string; } export interface ResizeMessage extends IMessage { - message: 'resize', - height: number, - width: number, - userTriggered: boolean + message: 'resize'; + height: number; + width: number; + userTriggered: boolean; } export interface LogMessage extends IMessage { - message: 'log', - body: any + message: 'log'; + body: any; } export type OutMessage = ResizeMessage | LogMessage; - - export interface UpdatePlotMessage extends IMessage { - message: 'updatePlot', - svg: string, - plotId: string + message: 'updatePlot'; + svg: string; + plotId: string; } export interface FocusPlotMessage extends IMessage { - message: 'focusPlot', - plotId: string + message: 'focusPlot'; + plotId: string; } export interface ToggleStyleMessage extends IMessage { - message: 'toggleStyle', - useOverwrites: boolean + message: 'toggleStyle'; + useOverwrites: boolean; } export interface ToggleFullWindowMessage extends IMessage { - message: 'toggleFullWindow', - useFullWindow: boolean + message: 'toggleFullWindow'; + useFullWindow: boolean; } - export type PreviewPlotLayout = 'multirow' | 'scroll' | 'hidden'; export interface PreviewPlotLayoutMessage extends IMessage { - message: 'togglePreviewPlotLayout', - style: PreviewPlotLayout + message: 'togglePreviewPlotLayout'; + style: PreviewPlotLayout; } export interface HidePlotMessage extends IMessage { - message: 'hidePlot', - plotId: string + message: 'hidePlot'; + plotId: string; } export interface AddPlotMessage extends IMessage { - message: 'addPlot', - html: string + message: 'addPlot'; + html: string; } -export type InMessage = UpdatePlotMessage | FocusPlotMessage | ToggleStyleMessage | HidePlotMessage | AddPlotMessage | PreviewPlotLayoutMessage | ToggleFullWindowMessage; \ No newline at end of file +export type InMessage = + | UpdatePlotMessage + | FocusPlotMessage + | ToggleStyleMessage + | HidePlotMessage + | AddPlotMessage + | PreviewPlotLayoutMessage + | ToggleFullWindowMessage; diff --git a/src/plotViewer/webviewMessages.ts b/src/plotViewer/webviewMessages.ts index 6c737a02f..c60d8512a 100644 --- a/src/plotViewer/webviewMessages.ts +++ b/src/plotViewer/webviewMessages.ts @@ -1,4 +1,3 @@ - export interface VsCode { postMessage: (msg: OutMessage) => void; setState: (state: string) => void; @@ -6,60 +5,68 @@ export interface VsCode { /** * Function declared by VS Code in Webview */ -export const acquireVsCodeApi: () => VsCode = (globalThis as { acquireVsCodeApi?: () => VsCode }).acquireVsCodeApi || (() => ({} as VsCode)); +export const acquireVsCodeApi: () => VsCode = + (globalThis as { acquireVsCodeApi?: () => VsCode }).acquireVsCodeApi || (() => ({}) as VsCode); export interface IMessage { message: string; } export interface ResizeMessage extends IMessage { - message: 'resize', - height: number, - width: number, - userTriggered: boolean + message: 'resize'; + height: number; + width: number; + userTriggered: boolean; } export interface LogMessage extends IMessage { - message: 'log', - body: any + message: 'log'; + body: any; } export type OutMessage = ResizeMessage | LogMessage; export interface UpdatePlotMessage extends IMessage { - message: 'updatePlot', - svg: string, - plotId: string + message: 'updatePlot'; + svg: string; + plotId: string; } export interface FocusPlotMessage extends IMessage { - message: 'focusPlot', - plotId: string + message: 'focusPlot'; + plotId: string; } export interface ToggleStyleMessage extends IMessage { - message: 'toggleStyle', - useOverwrites: boolean + message: 'toggleStyle'; + useOverwrites: boolean; } export interface ToggleFullWindowMessage extends IMessage { - message: 'toggleFullWindow', - useFullWindow: boolean + message: 'toggleFullWindow'; + useFullWindow: boolean; } export type PreviewPlotLayout = 'multirow' | 'scroll' | 'hidden'; export interface PreviewPlotLayoutMessage extends IMessage { - message: 'togglePreviewPlotLayout', - style: PreviewPlotLayout + message: 'togglePreviewPlotLayout'; + style: PreviewPlotLayout; } export interface HidePlotMessage extends IMessage { - message: 'hidePlot', - plotId: string + message: 'hidePlot'; + plotId: string; } export interface AddPlotMessage extends IMessage { - message: 'addPlot', - html: string + message: 'addPlot'; + html: string; } -export type InMessage = UpdatePlotMessage | FocusPlotMessage | ToggleStyleMessage | HidePlotMessage | AddPlotMessage | PreviewPlotLayoutMessage | ToggleFullWindowMessage; +export type InMessage = + | UpdatePlotMessage + | FocusPlotMessage + | ToggleStyleMessage + | HidePlotMessage + | AddPlotMessage + | PreviewPlotLayoutMessage + | ToggleFullWindowMessage; diff --git a/src/preview.ts b/src/preview.ts index 4480ebe4f..259432764 100644 --- a/src/preview.ts +++ b/src/preview.ts @@ -5,7 +5,14 @@ import { commands, extensions, window, workspace } from 'vscode'; import { runTextInTerm } from './rTerminal'; import { getWordOrSelection } from './selection'; -import { config, checkForSpecialCharacters, checkIfFileExists, delay, createTempDir, getCurrentWorkspaceFolder } from './util'; +import { + config, + checkForSpecialCharacters, + checkIfFileExists, + delay, + createTempDir, + getCurrentWorkspaceFolder, +} from './util'; export async function previewEnvironment(): Promise { if (config().get('sessionWatcher')) { @@ -21,13 +28,16 @@ export async function previewEnvironment(): Promise { const tmpDir = createTempDir(currentWorkspaceFolder, true); const pathToTmpCsv = `${tmpDir}/environment.csv`; const envName = 'name=ls()'; - const envClass = 'class=sapply(ls(), function(x) {class(get(x, envir = parent.env(environment())))[1]})'; - const envOut = 'out=sapply(ls(), function(x) {capture.output(str(get(x, envir = parent.env(environment()))), silent = T)[1]})'; - const rWriteCsvCommand = 'write.csv(data.frame(' - + `${envName},` - + `${envClass},` - + `${envOut}), '` - + `${pathToTmpCsv}', row.names=FALSE, quote = TRUE)`; + const envClass = + 'class=sapply(ls(), function(x) {class(get(x, envir = parent.env(environment())))[1]})'; + const envOut = + 'out=sapply(ls(), function(x) {capture.output(str(get(x, envir = parent.env(environment()))), silent = T)[1]})'; + const rWriteCsvCommand = + 'write.csv(data.frame(' + + `${envName},` + + `${envClass},` + + `${envOut}), '` + + `${pathToTmpCsv}', row.names=FALSE, quote = TRUE)`; await runTextInTerm(rWriteCsvCommand); await openTmpCSV(pathToTmpCsv, tmpDir); } @@ -61,8 +71,8 @@ export async function previewDataframe(): Promise { // Create R write CSV command. Turn off row names and quotes, they mess with Excel Viewer. const pathToTmpCsv = `${tmpDir}/${dataframeName}.csv`; - const rWriteCsvCommand = `write.csv(${dataframeName}, ` - + `'${pathToTmpCsv}', row.names = FALSE, quote = FALSE)`; + const rWriteCsvCommand = + `write.csv(${dataframeName}, ` + `'${pathToTmpCsv}', row.names = FALSE, quote = FALSE)`; await runTextInTerm(rWriteCsvCommand); await openTmpCSV(pathToTmpCsv, tmpDir); } @@ -81,19 +91,19 @@ async function openTmpCSV(pathToTmpCsv: string, tmpDir: string): Promise { - await commands.executeCommand('csv.preview', file.uri); - rmSync(tmpDir, { recursive: true, force: true }); - } - ); + void workspace.openTextDocument(pathToTmpCsv).then(async (file) => { + await commands.executeCommand('csv.preview', file.uri); + rmSync(tmpDir, { recursive: true, force: true }); + }); } async function waitForFileToFinish(filePath: string): Promise { @@ -107,7 +117,8 @@ async function waitForFileToFinish(filePath: string): Promise 2 * 10000000) { // 20 MB + if (currentSize > 2 * 10000000) { + // 20 MB return false; } @@ -124,15 +135,20 @@ function checkcsv(): boolean { if (iscsv !== undefined && iscsv.isActive) { return true; } - void window.showInformationMessage( - 'This function need to install `GrapeCity.gc-excelviewer`, will you install?', - 'Yes', - 'No' - ).then((select) => { - if (select === 'Yes') { - void commands.executeCommand('workbench.extensions.installExtension', 'GrapeCity.gc-excelviewer'); - } - }); + void window + .showInformationMessage( + 'This function need to install `GrapeCity.gc-excelviewer`, will you install?', + 'Yes', + 'No', + ) + .then((select) => { + if (select === 'Yes') { + void commands.executeCommand( + 'workbench.extensions.installExtension', + 'GrapeCity.gc-excelviewer', + ); + } + }); return false; } diff --git a/src/processTree.ts b/src/processTree.ts index ebe1554ea..3b3cbded4 100644 --- a/src/processTree.ts +++ b/src/processTree.ts @@ -7,30 +7,46 @@ export function getProcessQuerySpec(platform: NodeJS.Platform = process.platform } { return platform === 'win32' ? { - executable: 'powershell.exe', - args: ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', - 'Get-CimInstance Win32_Process -Property ProcessId,ParentProcessId | ForEach-Object { "{0} {1}" -f $_.ProcessId, $_.ParentProcessId }'], - timeout: 15000, - } + executable: 'powershell.exe', + args: [ + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-Command', + 'Get-CimInstance Win32_Process -Property ProcessId,ParentProcessId | ForEach-Object { "{0} {1}" -f $_.ProcessId, $_.ParentProcessId }', + ], + timeout: 15000, + } : { executable: 'ps', args: ['-A', '-o', 'pid=', '-o', 'ppid='], timeout: 5000 }; } /** Return nearest ancestors first, using one bounded process-table snapshot. */ export async function getProcessAncestors(pid: number): Promise { - if (!Number.isSafeInteger(pid) || pid <= 0) { return []; } + if (!Number.isSafeInteger(pid) || pid <= 0) { + return []; + } try { const { executable, args, timeout } = getProcessQuerySpec(); const stdout = await new Promise((resolve, reject) => { - childProcess.execFile(executable, args, { - // Cold PowerShell/CIM startup on Windows can exceed five seconds. - encoding: 'utf8', timeout, - maxBuffer: 4 * 1024 * 1024, windowsHide: true, - }, (error, output) => error ? reject(error) : resolve(output)); + childProcess.execFile( + executable, + args, + { + // Cold PowerShell/CIM startup on Windows can exceed five seconds. + encoding: 'utf8', + timeout, + maxBuffer: 4 * 1024 * 1024, + windowsHide: true, + }, + (error, output) => (error ? reject(error) : resolve(output)), + ); }); const parents = new Map(); for (const line of stdout.split(/\r?\n/)) { const match = /^\s*(\d+)\s+(\d+)\s*$/.exec(line); - if (match) { parents.set(Number(match[1]), Number(match[2])); } + if (match) { + parents.set(Number(match[1]), Number(match[2])); + } } const ancestors: number[] = []; const seen = new Set([pid]); diff --git a/src/rDebuggerCompatibility.ts b/src/rDebuggerCompatibility.ts index 00e3cc689..d387c31a4 100644 --- a/src/rDebuggerCompatibility.ts +++ b/src/rDebuggerCompatibility.ts @@ -2,13 +2,10 @@ import * as vscode from 'vscode'; export const rDebuggerCompatibilityWarningKey = 'rDebuggerLegacyRPathCompatibilityWarning.v1'; -const legacyRPathSettings = [ - 'r.rpath.windows', - 'r.rpath.mac', - 'r.rpath.linux', -]; +const legacyRPathSettings = ['r.rpath.windows', 'r.rpath.mac', 'r.rpath.linux']; -const warningMessage = 'R Debugger still registers the legacy `r.rpath.` setting. Until it is updated, keep that setting and keep it in sync with `r.executablePath` so both extensions use the same R installation.'; +const warningMessage = + 'R Debugger still registers the legacy `r.rpath.` setting. Until it is updated, keep that setting and keep it in sync with `r.executablePath` so both extensions use the same R installation.'; function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); @@ -21,10 +18,13 @@ export function registersLegacyRPathSetting(packageJSON: unknown): boolean { const configuration = packageJSON.contributes.configuration; const entries = Array.isArray(configuration) ? configuration : [configuration]; - return entries.some(entry => { + return entries.some((entry) => { const properties = isRecord(entry) ? entry.properties : undefined; - return isRecord(properties) && legacyRPathSettings.some(setting => - Object.prototype.hasOwnProperty.call(properties, setting) + return ( + isRecord(properties) && + legacyRPathSettings.some((setting) => + Object.prototype.hasOwnProperty.call(properties, setting), + ) ); }); } @@ -32,9 +32,12 @@ export function registersLegacyRPathSetting(packageJSON: unknown): boolean { export async function showRDebuggerCompatibilityWarningOnce( globalState: vscode.Memento, packageJSON: unknown, - showWarning: (message: string) => unknown + showWarning: (message: string) => unknown, ): Promise { - if (!registersLegacyRPathSetting(packageJSON) || globalState.get(rDebuggerCompatibilityWarningKey)) { + if ( + !registersLegacyRPathSetting(packageJSON) || + globalState.get(rDebuggerCompatibilityWarningKey) + ) { return false; } diff --git a/src/rGitignore.ts b/src/rGitignore.ts index 62fe79065..a919b9928 100644 --- a/src/rGitignore.ts +++ b/src/rGitignore.ts @@ -21,7 +21,8 @@ export async function createGitignore(): Promise { if (existsSync(ignorePath)) { const overwrite = await window.showWarningMessage( '".gitignore" file already exists. Do you want to overwrite?', - 'Yes', 'No' + 'Yes', + 'No', ); if (overwrite !== 'Yes') { return; diff --git a/src/rPathResolver.ts b/src/rPathResolver.ts index 69d155627..7606f30f1 100644 --- a/src/rPathResolver.ts +++ b/src/rPathResolver.ts @@ -14,7 +14,7 @@ export interface PathVariables { export interface ExecutableResolution { path?: string; setting?: string; - quote?: '"' | '\''; + quote?: '"' | "'"; } export interface RPathResolverDependencies { @@ -42,13 +42,13 @@ export interface SystemRResolverDependencies { } export function createRPathResolverDependencies( - options: RPathResolverAdapterOptions + options: RPathResolverAdapterOptions, ): RPathResolverDependencies { const currentConfig = options.getConfiguration(options.resource); return { - getSetting: setting => currentConfig.get(setting), + getSetting: (setting) => currentConfig.get(setting), inspectSetting: currentConfig.inspect?.bind(currentConfig), - substituteVariables: value => options.substituteVariables(value, options.resource), + substituteVariables: (value) => options.substituteVariables(value, options.resource), findExecutable: options.findExecutable, pathExists: options.pathExists, getSystemR: options.getSystemR, @@ -79,7 +79,7 @@ export function substitutePathVariables(value: string, variables: PathVariables) export function selectWorkspaceFolder( workspaceFolders: readonly T[] | undefined, activeFileWorkspaceFolder: T | undefined, - resourceWorkspaceFolder?: T + resourceWorkspaceFolder?: T, ): T | undefined { if (resourceWorkspaceFolder) { return resourceWorkspaceFolder; @@ -97,7 +97,7 @@ export function findExecutableOnPath( executableName: string, platform: NodeJS.Platform, pathValue: string | undefined, - exists: (value: string) => boolean + exists: (value: string) => boolean, ): string | undefined { const pathImplementation = platform === 'win32' ? path.win32 : path.posix; const delimiter = platform === 'win32' ? ';' : ':'; @@ -112,7 +112,9 @@ export function findExecutableOnPath( return undefined; } -export async function resolveSystemR(dependencies: SystemRResolverDependencies): Promise { +export async function resolveSystemR( + dependencies: SystemRResolverDependencies, +): Promise { const fromPath = dependencies.findExecutable('R'); if (fromPath) { return fromPath; @@ -129,24 +131,29 @@ export async function resolveSystemR(dependencies: SystemRResolverDependencies): export async function resolveBackgroundR( dependencies: RPathResolverDependencies, - legacySetting: string + legacySetting: string, ): Promise { - return resolveMigratedExecutable(dependencies, rExecutablePathSetting, legacySetting) - ?? { path: await dependencies.getSystemR() }; + return ( + resolveMigratedExecutable(dependencies, rExecutablePathSetting, legacySetting) ?? { + path: await dependencies.getSystemR(), + } + ); } export async function resolveConsoleR( dependencies: RPathResolverDependencies, - legacySetting: string + legacySetting: string, ): Promise { - return resolveMigratedExecutable(dependencies, rConsolePathSetting, legacySetting) - ?? resolveWithFallback([rExecutablePathSetting], dependencies); + return ( + resolveMigratedExecutable(dependencies, rConsolePathSetting, legacySetting) ?? + resolveWithFallback([rExecutablePathSetting], dependencies) + ); } function resolveMigratedExecutable( dependencies: RPathResolverDependencies, canonicalKey: string, - legacyKey: string + legacyKey: string, ): ConfiguredExecutableResolution | undefined { const configured = getMigratedSetting( { @@ -155,22 +162,24 @@ function resolveMigratedExecutable( }, canonicalKey, legacyKey, - value => Boolean(removeWrappingQuotes(value.trim()).value) + (value) => Boolean(removeWrappingQuotes(value.trim()).value), + ); + return ( + configured && resolveConfiguredExecutable(configured.key, dependencies, configured.value) ); - return configured && resolveConfiguredExecutable(configured.key, dependencies, configured.value); } export function formatRPath( resolution: ExecutableResolution, quote: boolean, - platform: NodeJS.Platform + platform: NodeJS.Platform, ): string | undefined { if (!resolution.path || !quote) { return resolution.path; } if (resolution.quote) { - if (platform === 'win32' && resolution.quote === '\'' && resolution.path.includes(' ')) { + if (platform === 'win32' && resolution.quote === "'" && resolution.path.includes(' ')) { return `"${resolution.path}"`; } return `${resolution.quote}${resolution.path}${resolution.quote}`; @@ -181,7 +190,7 @@ export function formatRPath( async function resolveWithFallback( settings: readonly string[], - dependencies: RPathResolverDependencies + dependencies: RPathResolverDependencies, ): Promise { for (const setting of settings) { const configured = resolveConfiguredExecutable(setting, dependencies); @@ -196,7 +205,7 @@ async function resolveWithFallback( function resolveConfiguredExecutable( setting: string, dependencies: RPathResolverDependencies, - rawValue = dependencies.getSetting(setting) + rawValue = dependencies.getSetting(setting), ): ConfiguredExecutableResolution | undefined { if (!rawValue?.trim()) { return undefined; @@ -210,15 +219,17 @@ function resolveConfiguredExecutable( const resolvedPath = isExecutableName(value) ? dependencies.findExecutable(value) - : dependencies.pathExists(value) ? value : undefined; + : dependencies.pathExists(value) + ? value + : undefined; return { path: resolvedPath, setting, quote }; } -function removeWrappingQuotes(value: string): { value: string; quote?: '"' | '\'' } { +function removeWrappingQuotes(value: string): { value: string; quote?: '"' | "'" } { if (value.length >= 2) { const first = value[0]; - if ((first === '"' || first === '\'') && value.at(-1) === first) { + if ((first === '"' || first === "'") && value.at(-1) === first) { return { value: value.slice(1, -1), quote: first }; } } diff --git a/src/rTerminal.ts b/src/rTerminal.ts index 1c6e3228c..3d4cd02e7 100644 --- a/src/rTerminal.ts +++ b/src/rTerminal.ts @@ -30,13 +30,16 @@ import * as yaml from 'js-yaml'; export let rTerm: vscode.Terminal | undefined = undefined; let rTermResource: vscode.Uri | undefined; -const terminalStartup = new WeakMap; -}>(); +const terminalStartup = new WeakMap< + vscode.Terminal, + { + integrated: boolean; + gateComplete: boolean; + startupFilePath?: string; + startupToken?: string; + pending?: Promise; + } +>(); async function prepareTerminalForInput(terminal: vscode.Terminal): Promise { let startup = terminalStartup.get(terminal); @@ -58,11 +61,18 @@ async function prepareTerminalForInput(terminal: vscode.Terminal): Promise { const ready = startup.integrated - ? await waitForTerminalReady(terminal, 30000, startup.startupFilePath, startup.startupToken) + ? await waitForTerminalReady( + terminal, + 30000, + startup.startupFilePath, + startup.startupToken, + ) : await delay(200).then(() => !isTerminalClosed(terminal) && !terminal.exitStatus); startup.gateComplete = ready; if (!ready && !isTerminalClosed(terminal) && !terminal.exitStatus) { - void vscode.window.showWarningMessage('R session did not attach, so code was not sent. Finish sess setup in the R terminal and retry, or disable r.sessionWatcher and reload VS Code before retrying.'); + void vscode.window.showWarningMessage( + 'R session did not attach, so code was not sent. Finish sess setup in the R terminal and retry, or disable r.sessionWatcher and reload VS Code before retrying.', + ); } return ready; })(); @@ -131,7 +141,10 @@ export function getRmdParamsCommand(document: vscode.TextDocument): string | und lastParamsRmdPath = filePath; lastParamsRmdVersion = document.version; try { - const frontmatter = yaml.load(match[1], { schema: RMARKDOWN_SCHEMA }) as Record; + const frontmatter = yaml.load(match[1], { schema: RMARKDOWN_SCHEMA }) as Record< + string, + unknown + >; const params = frontmatter?.['params'] as Record | undefined; if (!params || typeof params !== 'object') { return undefined; @@ -143,14 +156,23 @@ export function getRmdParamsCommand(document: vscode.TextDocument): string | und } } -export async function runSource(echo: boolean): Promise { +export async function runSource(echo: boolean): Promise { const wad = vscode.window.activeTextEditor?.document; if (!wad) { return; } - const target = await tryInteractiveExecution(wad.getText(), wad.uri, { uri: wad.uri.toString(), line: 0, version: wad.version }, !findTerminal()); - if (target === 'executed' || target === 'cancelled') { return; } - if (target === 'createTerminal' && !await createExecutionTerminal(wad.uri)) { return; } + const target = await tryInteractiveExecution( + wad.getText(), + wad.uri, + { uri: wad.uri.toString(), line: 0, version: wad.version }, + !findTerminal(), + ); + if (target === 'executed' || target === 'cancelled') { + return; + } + if (target === 'createTerminal' && !(await createExecutionTerminal(wad.uri))) { + return; + } const isSaved = await util.saveDocument(wad); if (!isSaved) { return; @@ -189,7 +211,7 @@ export async function runSelectionOrWord(rFunctionName: string[]): Promise await runTextInTerm(wrappedText); } -export async function runCommandWithSelectionOrWord(rCommand: string): Promise { +export async function runCommandWithSelectionOrWord(rCommand: string): Promise { const text = selection.getWordOrSelection(); if (!text) { return; @@ -198,7 +220,7 @@ export async function runCommandWithSelectionOrWord(rCommand: string): Promise { +export async function runCommandWithEditorPath(rCommand: string): Promise { const textEditor = vscode.window.activeTextEditor; if (!textEditor) { return; @@ -212,11 +234,11 @@ export async function runCommandWithEditorPath(rCommand: string): Promise } } -export async function runCommand(rCommand: string): Promise { +export async function runCommand(rCommand: string): Promise { await runTextInTerm(rCommand); } -export async function runFromBeginningToLine(): Promise { +export async function runFromBeginningToLine(): Promise { const textEditor = vscode.window.activeTextEditor; if (!textEditor) { return; @@ -232,7 +254,7 @@ export async function runFromBeginningToLine(): Promise { await runTextInTerm(text); } -export async function runFromLineToEnd(): Promise { +export async function runFromLineToEnd(): Promise { const textEditor = vscode.window.activeTextEditor; if (!textEditor) { return; @@ -246,38 +268,47 @@ export async function runFromLineToEnd(): Promise { } function getConsoleArgs(configuration: vscode.WorkspaceConfiguration): string[] { - return getMigratedSetting(configuration, 'consoleArgs', 'rterm.option')?.value - ?? ['--no-save', '--no-restore']; + return ( + getMigratedSetting(configuration, 'consoleArgs', 'rterm.option')?.value ?? [ + '--no-save', + '--no-restore', + ] + ); } function getConsoleSendDelay(resource?: vscode.Uri): number { - return getMigratedSetting(config(resource), 'consoleSendDelay', 'rtermSendDelay')?.value ?? 8; + return ( + getMigratedSetting(config(resource), 'consoleSendDelay', 'rtermSendDelay')?.value ?? + 8 + ); } export async function makeTerminalOptions(resource?: vscode.Uri): Promise { - const workspaceFolder = resource ? getCurrentWorkspaceFolder(resource) : getCurrentWorkspaceFolder(); + const workspaceFolder = resource + ? getCurrentWorkspaceFolder(resource) + : getCurrentWorkspaceFolder(); const configResource = resource ?? workspaceFolder?.uri; const workspaceFolderPath = workspaceFolder?.uri.fsPath; const currentConfig = config(configResource); const termPath = await getRterm(configResource); - const shellArgs = getConsoleArgs(currentConfig) - .map(value => util.substituteVariables(value, configResource)); + const shellArgs = getConsoleArgs(currentConfig).map((value) => + util.substituteVariables(value, configResource), + ); const termOptions: vscode.TerminalOptions = { name: 'R Interactive', shellPath: termPath, shellArgs: shellArgs, cwd: workspaceFolderPath, }; - const skipsProfile = shellArgs.some(arg => arg === '--vanilla' || arg === '--no-init-file'); + const skipsProfile = shellArgs.some((arg) => arg === '--vanilla' || arg === '--no-init-file'); const executableAvailable = Boolean(termPath && fs.existsSync(termPath)); - if (currentConfig.get('sessionWatcher') - && !skipsProfile && executableAvailable) { + if (currentConfig.get('sessionWatcher') && !skipsProfile && executableAvailable) { let discoveryFile: string | undefined; try { // Resolve all values that can fail before creating terminal-local files. const backend = resolveBackend(); const jgdVars = jgdEnabled(backend) - ? (globalPlotManager as CommonPlotManager)?.getJgdEnvVars() ?? {} + ? ((globalPlotManager as CommonPlotManager)?.getJgdEnvVars() ?? {}) : {}; const pipePath = await getGlobalPipePath(); const consentDirectory = await getSessConsentDirectory(); @@ -288,21 +319,33 @@ export async function makeTerminalOptions(resource?: vscode.Uri): Promise = { R_PROFILE_USER_OLD: process.env.R_PROFILE_USER, R_PROFILE_USER: newRprofile, - VSCODE_R_SESS_PKG_PATH: extensionContext.asAbsolutePath(path.join('dist', 'resources', 'sess')), - VSCODE_R_SESS_SOURCE_HELPER: extensionContext.asAbsolutePath(path.join('R', 'sess_source.R')), + VSCODE_R_SESS_PKG_PATH: extensionContext.asAbsolutePath( + path.join('dist', 'resources', 'sess'), + ), + VSCODE_R_SESS_SOURCE_HELPER: extensionContext.asAbsolutePath( + path.join('R', 'sess_source.R'), + ), VSCODE_R_SESS_ROOT: path.join(extensionContext.globalStorageUri.fsPath, 'sess'), VSCODE_R_SESS_CONSENT_DIRECTORY: consentDirectory, - VSCODE_R_SESS_INSTALLER_HELPER: extensionContext.asAbsolutePath(path.join('R', 'sess-package-install.R')), - VSCODE_R_SESS_ATTACH_HELPER: extensionContext.asAbsolutePath(path.join('R', 'attach_sess.R')), + VSCODE_R_SESS_INSTALLER_HELPER: extensionContext.asAbsolutePath( + path.join('R', 'sess-package-install.R'), + ), + VSCODE_R_SESS_ATTACH_HELPER: extensionContext.asAbsolutePath( + path.join('R', 'attach_sess.R'), + ), VSCODE_R_SESS_STARTUP_FILE: startupFile, VSCODE_R_SESS_STARTUP_TOKEN: startupToken, VSCODE_R_SESS_STARTUP_ENDPOINT: pipePath, - VSCODE_R_SESS_STARTUP_HELPER: extensionContext.asAbsolutePath(path.join('R', 'terminal-startup.R')), + VSCODE_R_SESS_STARTUP_HELPER: extensionContext.asAbsolutePath( + path.join('R', 'terminal-startup.R'), + ), // Remove inherited endpoint overrides so the per-terminal discovery file // remains authoritative, including after a VS Code window reload. SESS_ENDPOINT: null, SESS_DISCOVERY_FILE: discoveryFile, - SESS_RSTUDIOAPI: config(configResource).get('session.emulateRStudioAPI') ? 'TRUE' : 'FALSE', + SESS_RSTUDIOAPI: config(configResource).get('session.emulateRStudioAPI') + ? 'TRUE' + : 'FALSE', SESS_PLOT_BACKEND: backend, }; Object.assign(env, jgdVars); @@ -310,13 +353,19 @@ export async function makeTerminalOptions(resource?: vscode.Uri): Promise { - console.error('Failed to clean incomplete session watcher files', cleanupError); - }); + await fs.promises + .rm(filePath, { recursive: true, force: true }) + .catch((cleanupError) => { + console.error( + 'Failed to clean incomplete session watcher files', + cleanupError, + ); + }); } } void vscode.window.showWarningMessage( - `The R session watcher could not be prepared. R will start without session integration. ${String(error)}`); + `The R session watcher could not be prepared. R will start without session integration. ${String(error)}`, + ); } } return termOptions; @@ -330,7 +379,9 @@ export async function createRTerm(preserveshow?: boolean, resource?: vscode.Uri) const startupFile = termOptions.env?.['VSCODE_R_SESS_STARTUP_FILE']; const discardDiscoveryFile = async () => { for (const filePath of [discoveryFile, startupFile]) { - if (typeof filePath !== 'string') { continue; } + if (typeof filePath !== 'string') { + continue; + } try { await fs.promises.unlink(filePath); } catch (error) { @@ -341,11 +392,13 @@ export async function createRTerm(preserveshow?: boolean, resource?: vscode.Uri) } } }; - if(!termPath){ + if (!termPath) { await discardDiscoveryFile(); return false; - } else if(!fs.existsSync(termPath)){ - void vscode.window.showErrorMessage(`Cannot find R console executable at ${termPath}. Please check r.consolePath, r.${getRPathConfigEntry(true)}, or r.executablePath.`); + } else if (!fs.existsSync(termPath)) { + void vscode.window.showErrorMessage( + `Cannot find R console executable at ${termPath}. Please check r.consolePath, r.${getRPathConfigEntry(true)}, or r.executablePath.`, + ); await discardDiscoveryFile(); return false; } @@ -359,30 +412,40 @@ export async function createRTerm(preserveshow?: boolean, resource?: vscode.Uri) rTerm = createdTerminal; rTermResource = resource; const args = termOptions.shellArgs; - const skipsProfile = Array.isArray(args) && args.some(arg => arg === '--vanilla' || arg === '--no-init-file'); + const skipsProfile = + Array.isArray(args) && args.some((arg) => arg === '--vanilla' || arg === '--no-init-file'); terminalStartup.set(createdTerminal, { integrated: typeof discoveryFile === 'string' && !skipsProfile, gateComplete: false, startupFilePath: typeof startupFile === 'string' ? startupFile : undefined, - startupToken: typeof termOptions.env?.['VSCODE_R_SESS_STARTUP_TOKEN'] === 'string' - ? termOptions.env['VSCODE_R_SESS_STARTUP_TOKEN'] : undefined, + startupToken: + typeof termOptions.env?.['VSCODE_R_SESS_STARTUP_TOKEN'] === 'string' + ? termOptions.env['VSCODE_R_SESS_STARTUP_TOKEN'] + : undefined, }); createdTerminal.show(preserveshow); - void Promise.resolve(createdTerminal.processId).then(async (pid: number | undefined) => { - if (pid && typeof discoveryFile === 'string' && !isTerminalClosed(createdTerminal)) { - const pipePath = await getGlobalPipePath(); - if (!isTerminalClosed(createdTerminal)) { - await updateTerminalSessionDiscoveryFile(createdTerminal, discoveryFile, pipePath, pid); + void Promise.resolve(createdTerminal.processId) + .then(async (pid: number | undefined) => { + if (pid && typeof discoveryFile === 'string' && !isTerminalClosed(createdTerminal)) { + const pipePath = await getGlobalPipePath(); + if (!isTerminalClosed(createdTerminal)) { + await updateTerminalSessionDiscoveryFile( + createdTerminal, + discoveryFile, + pipePath, + pid, + ); + } } - } - }).catch(error => console.error('Failed to update terminal session discovery file', error)); + }) + .catch((error) => console.error('Failed to update terminal session discovery file', error)); return true; } -export async function restartRTerminal(): Promise{ - if (typeof rTerm !== 'undefined'){ +export async function restartRTerminal(): Promise { + if (typeof rTerm !== 'undefined') { const resource = rTermResource; rTerm.dispose(); deleteTerminal(rTerm); @@ -393,10 +456,12 @@ export async function restartRTerminal(): Promise{ export function deleteTerminal(term: vscode.Terminal): void { cleanupTerminalBinding(term); const exitReason = term.exitStatus?.reason; - if (exitReason === vscode.TerminalExitReason.User - || exitReason === vscode.TerminalExitReason.Process - || exitReason === vscode.TerminalExitReason.Extension) { - void removeTerminalDiscoveryFile(term).catch(error => { + if ( + exitReason === vscode.TerminalExitReason.User || + exitReason === vscode.TerminalExitReason.Process || + exitReason === vscode.TerminalExitReason.Extension + ) { + void removeTerminalDiscoveryFile(term).catch((error) => { console.error('Failed to remove terminal session discovery file', error); }); } @@ -416,11 +481,13 @@ function getTerminalResource(term: vscode.Terminal): vscode.Uri | undefined { const creationOptions = term.creationOptions; const cwd = creationOptions && 'cwd' in creationOptions ? creationOptions.cwd : undefined; // Profile terminals store cwd as a path; retain the matching remote URI. - const cwdResource = typeof cwd === 'string' - ? vscode.workspace.workspaceFolders?.find(folder => folder.uri.fsPath === cwd)?.uri ?? vscode.Uri.file(cwd) - : cwd; + const cwdResource = + typeof cwd === 'string' + ? (vscode.workspace.workspaceFolders?.find((folder) => folder.uri.fsPath === cwd) + ?.uri ?? vscode.Uri.file(cwd)) + : cwd; return cwdResource - ? getCurrentWorkspaceFolder(cwdResource)?.uri ?? cwdResource + ? (getCurrentWorkspaceFolder(cwdResource)?.uri ?? cwdResource) : getCurrentWorkspaceFolder()?.uri; } @@ -430,23 +497,29 @@ export function findTerminal(): vscode.Terminal | undefined { const ignoreTermIdentifier = 'deactivate'; // Filter out terminals to be ignored - const visibleTerminals = vscode.window.terminals.filter(terminal => { - return terminal.exitStatus === undefined && !terminal.name.toLowerCase().includes(ignoreTermIdentifier); + const visibleTerminals = vscode.window.terminals.filter((terminal) => { + return ( + terminal.exitStatus === undefined && + !terminal.name.toLowerCase().includes(ignoreTermIdentifier) + ); }); if (config().get('alwaysUseActiveTerminal')) { - return visibleTerminals.find(terminal => terminal === vscode.window.activeTerminal); + return visibleTerminals.find((terminal) => terminal === vscode.window.activeTerminal); } const rTermNameOptions = ['R', 'R Interactive']; - const validRTerminals = visibleTerminals.filter(terminal => { + const validRTerminals = visibleTerminals.filter((terminal) => { return rTermNameOptions.includes(terminal.name); }); if (validRTerminals.length > 0) { // If there is an active terminal that is an R terminal, use it - if (vscode.window.activeTerminal && validRTerminals.includes(vscode.window.activeTerminal)) { + if ( + vscode.window.activeTerminal && + validRTerminals.includes(vscode.window.activeTerminal) + ) { return vscode.window.activeTerminal; } // Otherwise, use last valid R terminal @@ -456,20 +529,33 @@ export function findTerminal(): vscode.Terminal | undefined { export async function chooseTerminal(): Promise { const terminal = findTerminal(); - if (terminal) { terminal.show(true); return terminal; } + if (terminal) { + terminal.show(true); + return terminal; + } if (config().get('alwaysUseActiveTerminal')) { - void vscode.window.showInformationMessage('There are no open terminals.'); return; + void vscode.window.showInformationMessage('There are no open terminals.'); + return; } return await createExecutionTerminal(); } -async function createExecutionTerminal(resource?: vscode.Uri): Promise { - const pending = startingExecutionTerminal ??= (async () => { - if (!await createRTerm(true, resource)) { return; } +async function createExecutionTerminal( + resource?: vscode.Uri, +): Promise { + const pending = (startingExecutionTerminal ??= (async () => { + if (!(await createRTerm(true, resource))) { + return; + } return rTerm; - })(); - try { return await pending; } - finally { if (startingExecutionTerminal === pending) { startingExecutionTerminal = undefined; } } + })()); + try { + return await pending; + } finally { + if (startingExecutionTerminal === pending) { + startingExecutionTerminal = undefined; + } + } } export async function runSelectionInTerm(moveCursor: boolean, useRepl = true): Promise { @@ -478,25 +564,41 @@ export async function runSelectionInTerm(moveCursor: boolean, useRepl = true): P return; } const textEditor = vscode.window.activeTextEditor; - if(useRepl && vscode.debug.activeDebugSession?.type === 'R-Debugger'){ + if (useRepl && vscode.debug.activeDebugSession?.type === 'R-Debugger') { await sendRangeToRepl(selection.range); - } else{ + } else { const paramsCmd = textEditor ? getRmdParamsCommand(textEditor.document) : undefined; - if (!await runTextInTerm(paramsCmd ? `${paramsCmd}\n${selection.selectedText}` : selection.selectedText)) { return; } + if ( + !(await runTextInTerm( + paramsCmd ? `${paramsCmd}\n${selection.selectedText}` : selection.selectedText, + )) + ) { + return; + } } // Cancellation must preserve both the selected code and its cursor position. - if (vscode.window.activeTextEditor !== textEditor) { return; } + if (vscode.window.activeTextEditor !== textEditor) { + return; + } if (moveCursor && selection.linesDownToMoveCursor > 0) { if (!textEditor) { return; } const lineCount = textEditor.document.lineCount; if (selection.linesDownToMoveCursor + textEditor.selection.end.line === lineCount) { - const endPos = new vscode.Position(lineCount, textEditor.document.lineAt(lineCount - 1).text.length); - await textEditor.edit(e => e.insert(endPos, '\n')); + const endPos = new vscode.Position( + lineCount, + textEditor.document.lineAt(lineCount - 1).text.length, + ); + await textEditor.edit((e) => e.insert(endPos, '\n')); } - await vscode.commands.executeCommand('cursorMove', { to: 'down', value: selection.linesDownToMoveCursor }); - await vscode.commands.executeCommand('cursorMove', { to: 'wrappedLineFirstNonWhitespaceCharacter' }); + await vscode.commands.executeCommand('cursorMove', { + to: 'down', + value: selection.linesDownToMoveCursor, + }); + await vscode.commands.executeCommand('cursorMove', { + to: 'wrappedLineFirstNonWhitespaceCharacter', + }); } } @@ -517,15 +619,30 @@ export async function runChunksInTerm(chunks: vscode.Range[]): Promise { export async function runTextInTerm(text: string, execute: boolean = true): Promise { const document = vscode.window.activeTextEditor?.document; - const target = execute && await tryInteractiveExecution(text, document?.uri, document ? { - uri: document.uri.toString(), line: vscode.window.activeTextEditor?.selection?.start.line ?? 0, version: document.version, - } : undefined, !findTerminal()); - if (target === 'executed' || target === 'cancelled') { return target === 'executed'; } + const target = + execute && + (await tryInteractiveExecution( + text, + document?.uri, + document + ? { + uri: document.uri.toString(), + line: vscode.window.activeTextEditor?.selection?.start.line ?? 0, + version: document.version, + } + : undefined, + !findTerminal(), + )); + if (target === 'executed' || target === 'cancelled') { + return target === 'executed'; + } let term: vscode.Terminal | undefined; if (target === 'createTerminal') { term = await createExecutionTerminal(document?.uri); - } else { term = await chooseTerminal(); } - if (term === undefined || !await prepareTerminalForInput(term)) { + } else { + term = await chooseTerminal(); + } + if (term === undefined || !(await prepareTerminalForInput(term))) { return false; } await runTextInTerminal(term, text, execute); @@ -534,16 +651,30 @@ export async function runTextInTerm(text: string, execute: boolean = true): Prom /** The optional guard rechecks session ownership after queue and per-line waits. */ export async function runTextInTerminal( - terminal: vscode.Terminal, text: string, execute = true, validate?: () => void, + terminal: vscode.Terminal, + text: string, + execute = true, + validate?: () => void, ): Promise { - const pending = (terminalSends.get(terminal) ?? Promise.resolve()).catch(() => undefined) + const pending = (terminalSends.get(terminal) ?? Promise.resolve()) + .catch(() => undefined) .then(() => sendToTerminal(terminal, text, execute, validate)); terminalSends.set(terminal, pending); - try { await pending; } - finally { if (terminalSends.get(terminal) === pending) { terminalSends.delete(terminal); } } + try { + await pending; + } finally { + if (terminalSends.get(terminal) === pending) { + terminalSends.delete(terminal); + } + } } -async function sendToTerminal(term: vscode.Terminal, text: string, execute: boolean, validate?: () => void): Promise { +async function sendToTerminal( + term: vscode.Terminal, + text: string, + execute: boolean, + validate?: () => void, +): Promise { validate?.(); deferWorkspaceRefresh(); if (config().get('bracketedPaste')) { @@ -589,10 +720,10 @@ export async function sendRangeToRepl(rng: vscode.Range): Promise { } const sel0 = editor.selections; let sel1 = new vscode.Selection(rng.start, rng.end); - while(/^[\r\n]/.exec(editor.document.getText(sel1))){ + while (/^[\r\n]/.exec(editor.document.getText(sel1))) { sel1 = new vscode.Selection(sel1.start.translate(1), sel1.end); } - while(/\r?\n\r?\n$/.exec(editor.document.getText(sel1))){ + while (/\r?\n\r?\n$/.exec(editor.document.getText(sel1))) { sel1 = new vscode.Selection(sel1.start, sel1.end.translate(-1)); } editor.selections = [sel1]; diff --git a/src/rmarkdown/chunks.ts b/src/rmarkdown/chunks.ts index c802c23f5..a70583298 100644 --- a/src/rmarkdown/chunks.ts +++ b/src/rmarkdown/chunks.ts @@ -3,34 +3,34 @@ import { config } from '../util'; import { runChunksInTerm } from '../rTerminal'; export function isRDocument(document: vscode.TextDocument) { - return (document.languageId === 'r'); + return document.languageId === 'r'; } function isRChunkLine(text: string) { - return (!!text.match(/^#+\s*%%/g)); + return !!text.match(/^#+\s*%%/g); } function isChunkStartLine(text: string, isRDoc: boolean) { if (isRDoc) { - return (isRChunkLine(text)); + return isRChunkLine(text); } else { - return (!!text.match(/^\s*```+\s*\{\w+\s*.*$/g)); + return !!text.match(/^\s*```+\s*\{\w+\s*.*$/g); } } function isChunkEndLine(text: string, isRDoc: boolean) { if (isRDoc) { const isSectionHeader = text.match(/^#+\s*.*[-#+=*]{4,}/g); - return (isRChunkLine(text) || isSectionHeader); + return isRChunkLine(text) || isSectionHeader; } else { - return (!!text.match(/^\s*```+\s*$/g)); + return !!text.match(/^\s*```+\s*$/g); } } function getChunkLanguage(text: string, isRDoc: boolean = false) { if (isRDoc) { return 'r'; - } + } return text.replace(/^\s*```+\s*\{(\w+)\s*.*\}\s*$/g, '$1').toLowerCase(); } @@ -43,14 +43,17 @@ function getChunkOptions(text: string, isRDoc: boolean = false) { } function getChunkEval(chunkOptions: string) { - return (!chunkOptions.match(/eval\s*=\s*(F|FALSE)/g)); + return !chunkOptions.match(/eval\s*=\s*(F|FALSE)/g); } // This is for #| style chunk options function isOptionComment(text: string) { - return (!!text.match(/^#+\|/g)); + return !!text.match(/^#+\|/g); } -export function shouldDisplayChunkOptions(document: vscode.TextDocument, position: vscode.Position) { +export function shouldDisplayChunkOptions( + document: vscode.TextDocument, + position: vscode.Position, +) { const line = document.lineAt(position).text; const isRDoc = isRDocument(document); const currentChunk = getCurrentChunk(getChunks(document), position.line); @@ -59,10 +62,10 @@ export function shouldDisplayChunkOptions(document: vscode.TextDocument, positio return false; } - const isRChunk = isRDoc ? - true : - getChunkLanguage(document.lineAt(currentChunk?.startLine).text, isRDoc) === 'r'; - + const isRChunk = isRDoc + ? true + : getChunkLanguage(document.lineAt(currentChunk?.startLine).text, isRDoc) === 'r'; + return isRChunk && (isChunkStartLine(line, isRDoc) || isOptionComment(line)); } @@ -83,7 +86,7 @@ export function getChunks(document: vscode.TextDocument): RMarkdownChunk[] { const chunks: RMarkdownChunk[] = []; let line = 0; - let chunkId = 0; // One-based index + let chunkId = 0; // One-based index let chunkStartLine: number | undefined = undefined; let chunkLanguage: string | undefined = undefined; let chunkOptions: string | undefined = undefined; @@ -92,16 +95,23 @@ export function getChunks(document: vscode.TextDocument): RMarkdownChunk[] { const finishChunk = (endLine: number, codeEndLine: number) => { const startLine = chunkStartLine!; - const codeStart = codeEndLine > startLine - ? new vscode.Position(startLine + 1, 0) - : new vscode.Position(startLine, lines[startLine].length); - const codeEnd = codeEndLine > startLine - ? new vscode.Position(codeEndLine, lines[codeEndLine].length) : codeStart; + const codeStart = + codeEndLine > startLine + ? new vscode.Position(startLine + 1, 0) + : new vscode.Position(startLine, lines[startLine].length); + const codeEnd = + codeEndLine > startLine + ? new vscode.Position(codeEndLine, lines[codeEndLine].length) + : codeStart; chunks.push({ - id: chunkId, startLine, endLine, language: chunkLanguage, - options: chunkOptions, eval: chunkEval, + id: chunkId, + startLine, + endLine, + language: chunkLanguage, + options: chunkOptions, + eval: chunkEval, chunkRange: new vscode.Range(startLine, 0, endLine, lines[endLine].length), - codeRange: new vscode.Range(codeStart, codeEnd) + codeRange: new vscode.Range(codeStart, codeEnd), }); chunkStartLine = undefined; }; @@ -120,7 +130,9 @@ export function getChunks(document: vscode.TextDocument): RMarkdownChunk[] { finishChunk(isRDoc ? line - 1 : line, line - 1); // An R marker ends the previous chunk and starts the next one, // including when the marker is the document's final line. - if (isRDoc) { line--; } + if (isRDoc) { + line--; + } } } line++; @@ -131,7 +143,10 @@ export function getChunks(document: vscode.TextDocument): RMarkdownChunk[] { return chunks; } -export function getCurrentChunk(chunks: RMarkdownChunk[], line: number): RMarkdownChunk | undefined { +export function getCurrentChunk( + chunks: RMarkdownChunk[], + line: number, +): RMarkdownChunk | undefined { const textEditor = vscode.window.activeTextEditor; if (!textEditor) { return; @@ -141,7 +156,7 @@ export function getCurrentChunk(chunks: RMarkdownChunk[], line: number): RMarkdo if (chunks.length === 0) { return undefined; } - + // Case: Cursor is above first chunk, use first chunk if (line < chunks[0].startLine) { return chunks[0]; @@ -168,10 +183,10 @@ function getPreviousChunk(chunks: RMarkdownChunk[], line: number): RMarkdownChun if (currentChunk.id !== 1) { // When cursor is below the last 'chunk end line', the definition of the previous chunk is the last chunk const previousChunkId = currentChunk.endLine < line ? currentChunk.id : currentChunk.id - 1; - const previousChunk = chunks.find(i => i.id === previousChunkId); + const previousChunk = chunks.find((i) => i.id === previousChunkId); return previousChunk; } else { - return (currentChunk); + return currentChunk; } } @@ -183,12 +198,11 @@ function getNextChunk(chunks: RMarkdownChunk[], line: number): RMarkdownChunk | if (currentChunk.id !== chunks.length) { // When cursor is above the first 'chunk start line', the definition of the next chunk is the first chunk const nextChunkId = line < currentChunk.startLine ? currentChunk.id : currentChunk.id + 1; - const nextChunk = chunks.find(i => i.id === nextChunkId); + const nextChunk = chunks.find((i) => i.id === nextChunkId); return nextChunk; } else { return currentChunk; } - } // Helpers @@ -207,19 +221,23 @@ function _getStartLine(): number { return textEditor.selection.start.line; } export function isWithinChunk(chunk: RMarkdownChunk, line: number = _getStartLine()): boolean { - return (line >= chunk.startLine && line <= chunk.endLine); + return line >= chunk.startLine && line <= chunk.endLine; } -export async function runCurrentChunk(chunks: RMarkdownChunk[] = _getChunks(), - line: number = _getStartLine()): Promise { +export async function runCurrentChunk( + chunks: RMarkdownChunk[] = _getChunks(), + line: number = _getStartLine(), +): Promise { const currentChunk = getCurrentChunk(chunks, line); if (currentChunk) { await runChunksInTerm([currentChunk.codeRange]); } } -export async function runCurrentChunkAndMove(chunks: RMarkdownChunk[] = _getChunks(), - line: number = _getStartLine()): Promise { +export async function runCurrentChunkAndMove( + chunks: RMarkdownChunk[] = _getChunks(), + line: number = _getStartLine(), +): Promise { const currentChunk = getCurrentChunk(chunks, line); if (currentChunk) { await runChunksInTerm([currentChunk.codeRange]); @@ -230,23 +248,26 @@ export async function runCurrentChunkAndMove(chunks: RMarkdownChunk[] = _getChun } } -export async function runPreviousChunk(chunks: RMarkdownChunk[] = _getChunks(), - line: number = _getStartLine()): Promise { +export async function runPreviousChunk( + chunks: RMarkdownChunk[] = _getChunks(), + line: number = _getStartLine(), +): Promise { const currentChunk = getCurrentChunk(chunks, line); const previousChunk = getPreviousChunk(chunks, line); // Case: cursor is below the last chunk, run last chunk if (currentChunk && line > currentChunk.endLine) { - await(runChunksInTerm([currentChunk.codeRange])); - // Case: currentChunk is not the first chunk, so run previousChunk + await runChunksInTerm([currentChunk.codeRange]); + // Case: currentChunk is not the first chunk, so run previousChunk } else if (previousChunk && previousChunk !== currentChunk) { await runChunksInTerm([previousChunk.codeRange]); } - } -export async function runNextChunk(chunks: RMarkdownChunk[] = _getChunks(), - line: number = _getStartLine()): Promise { +export async function runNextChunk( + chunks: RMarkdownChunk[] = _getChunks(), + line: number = _getStartLine(), +): Promise { const currentChunk = getCurrentChunk(chunks, line); const nextChunk = getNextChunk(chunks, line); @@ -256,8 +277,10 @@ export async function runNextChunk(chunks: RMarkdownChunk[] = _getChunks(), } } -export async function runAboveChunks(chunks: RMarkdownChunk[] = _getChunks(), - line: number = _getStartLine()): Promise { +export async function runAboveChunks( + chunks: RMarkdownChunk[] = _getChunks(), + line: number = _getStartLine(), +): Promise { const currentChunk = getCurrentChunk(chunks, line); const previousChunk = getPreviousChunk(chunks, line); if (!currentChunk || !previousChunk) { @@ -271,7 +294,7 @@ export async function runAboveChunks(chunks: RMarkdownChunk[] = _getChunks(), // Only do something if current chunk is not the first chunk if (currentChunk.id > 1) { for (let i = firstChunkId; i <= previousChunkId; i++) { - const chunk = chunks.find(e => e.id === i); + const chunk = chunks.find((e) => e.id === i); if (chunk?.eval) { codeRanges.push(chunk.codeRange); } @@ -280,9 +303,10 @@ export async function runAboveChunks(chunks: RMarkdownChunk[] = _getChunks(), } } -export async function runBelowChunks(chunks: RMarkdownChunk[] = _getChunks(), - line: number = _getStartLine()): Promise { - +export async function runBelowChunks( + chunks: RMarkdownChunk[] = _getChunks(), + line: number = _getStartLine(), +): Promise { const currentChunk = getCurrentChunk(chunks, line); const nextChunk = getNextChunk(chunks, line); if (!currentChunk || !nextChunk) { @@ -296,7 +320,7 @@ export async function runBelowChunks(chunks: RMarkdownChunk[] = _getChunks(), // Only do something if current chunk is not the last chunk if (currentChunk.id < lastChunkId) { for (let i = nextChunkId; i <= lastChunkId; i++) { - const chunk = chunks.find(e => e.id === i); + const chunk = chunks.find((e) => e.id === i); if (chunk?.eval) { codeRanges.push(chunk.codeRange); } @@ -305,8 +329,10 @@ export async function runBelowChunks(chunks: RMarkdownChunk[] = _getChunks(), } } -export async function runCurrentAndBelowChunks(chunks: RMarkdownChunk[] = _getChunks(), - line: number = _getStartLine()): Promise { +export async function runCurrentAndBelowChunks( + chunks: RMarkdownChunk[] = _getChunks(), + line: number = _getStartLine(), +): Promise { const currentChunk = getCurrentChunk(chunks, line); if (!currentChunk) { return; @@ -317,7 +343,7 @@ export async function runCurrentAndBelowChunks(chunks: RMarkdownChunk[] = _getCh const codeRanges: vscode.Range[] = []; for (let i = currentChunkId; i <= lastChunkId; i++) { - const chunk = chunks.find(e => e.id === i); + const chunk = chunks.find((e) => e.id === i); if (chunk) { codeRanges.push(chunk.codeRange); } @@ -326,14 +352,13 @@ export async function runCurrentAndBelowChunks(chunks: RMarkdownChunk[] = _getCh } export async function runAllChunks(chunks: RMarkdownChunk[] = _getChunks()): Promise { - const firstChunkId = 1; const lastChunkId = chunks.length; const codeRanges: vscode.Range[] = []; for (let i = firstChunkId; i <= lastChunkId; i++) { - const chunk = chunks.find(e => e.id === i); + const chunk = chunks.find((e) => e.id === i); if (chunk?.eval) { codeRanges.push(chunk.codeRange); } @@ -352,24 +377,30 @@ async function goToChunk(chunk: RMarkdownChunk) { await vscode.commands.executeCommand('revealLine', { lineNumber: line, at: 'center' }); } -export function goToPreviousChunk(chunks: RMarkdownChunk[] = _getChunks(), - line: number = _getStartLine()): void { +export function goToPreviousChunk( + chunks: RMarkdownChunk[] = _getChunks(), + line: number = _getStartLine(), +): void { const previousChunk = getPreviousChunk(chunks, line); if (previousChunk) { void goToChunk(previousChunk); } } -export function goToNextChunk(chunks: RMarkdownChunk[] = _getChunks(), - line: number = _getStartLine()): void { +export function goToNextChunk( + chunks: RMarkdownChunk[] = _getChunks(), + line: number = _getStartLine(), +): void { const nextChunk = getNextChunk(chunks, line); if (nextChunk) { void goToChunk(nextChunk); } } -export function selectCurrentChunk(chunks: RMarkdownChunk[] = _getChunks(), - line: number = _getStartLine()): void { +export function selectCurrentChunk( + chunks: RMarkdownChunk[] = _getChunks(), + line: number = _getStartLine(), +): void { const editor = vscode.window.activeTextEditor; const currentChunk = getCurrentChunk(chunks, line); if (!editor || !currentChunk || !isWithinChunk(currentChunk, line)) { @@ -378,22 +409,26 @@ export function selectCurrentChunk(chunks: RMarkdownChunk[] = _getChunks(), const lines = editor.document.getText().split(/\r?\n/); editor.selection = new vscode.Selection( - currentChunk.startLine, 0, - currentChunk.endLine, lines[currentChunk.endLine].length + currentChunk.startLine, + 0, + currentChunk.endLine, + lines[currentChunk.endLine].length, ); } -export function getCodeLenses(chunks: RMarkdownChunk[], token: vscode.CancellationToken): vscode.CodeLens[] { - +export function getCodeLenses( + chunks: RMarkdownChunk[], + token: vscode.CancellationToken, +): vscode.CodeLens[] { const enabledCodeLens = config().get('rmarkdown.enableCodeLens'); if (enabledCodeLens === false) { return []; } - + // Iterate through all code chunks for getting chunk information for both CodeLens and chunk background color (set by `editor.setDecorations`) let codeLenses: vscode.CodeLens[] = []; for (let i = 1; i <= chunks.length; i++) { - const chunk = chunks.find(e => e.id === i); + const chunk = chunks.find((e) => e.id === i); if (!chunk) { continue; } @@ -410,61 +445,61 @@ export function getCodeLenses(chunks: RMarkdownChunk[], token: vscode.Cancellati title: 'Run Chunk', tooltip: 'Run current chunk', command: 'r.runCurrentChunk', - arguments: [chunks, line] + arguments: [chunks, line], }), new vscode.CodeLens(chunkRange, { title: 'Run Above', tooltip: 'Run all chunks above', command: 'r.runAboveChunks', - arguments: [chunks, line] + arguments: [chunks, line], }), new vscode.CodeLens(chunkRange, { title: 'Run Current & Below', tooltip: 'Run current and all chunks below', command: 'r.runCurrentAndBelowChunks', - arguments: [chunks, line] + arguments: [chunks, line], }), new vscode.CodeLens(chunkRange, { title: 'Run Below', tooltip: 'Run all chunks below', command: 'r.runBelowChunks', - arguments: [chunks, line] + arguments: [chunks, line], }), new vscode.CodeLens(chunkRange, { title: 'Run Previous', tooltip: 'Run previous chunk', command: 'r.runPreviousChunk', - arguments: [chunks, line] + arguments: [chunks, line], }), new vscode.CodeLens(chunkRange, { title: 'Run Next', tooltip: 'Run next chunk', command: 'r.runNextChunk', - arguments: [chunks, line] + arguments: [chunks, line], }), new vscode.CodeLens(chunkRange, { title: 'Run All', tooltip: 'Run all chunks', command: 'r.runAllChunks', - arguments: [chunks] + arguments: [chunks], }), new vscode.CodeLens(chunkRange, { title: 'Go Previous', tooltip: 'Go to previous chunk', command: 'r.goToPreviousChunk', - arguments: [chunks, line] + arguments: [chunks, line], }), new vscode.CodeLens(chunkRange, { title: 'Go Next', tooltip: 'Go to next chunk', command: 'r.goToNextChunk', - arguments: [chunks, line] + arguments: [chunks, line], }), new vscode.CodeLens(chunkRange, { title: 'Select Chunk', tooltip: 'Select current chunk', command: 'r.selectCurrentChunk', - arguments: [chunks, line] + arguments: [chunks, line], }), ); } @@ -473,11 +508,14 @@ export function getCodeLenses(chunks: RMarkdownChunk[], token: vscode.Cancellati // For default options, both options and sort order are based on options specified in package.json. // For user-specified options, both options and sort order are based on options specified in settings UI or settings.json. const rmdCodeLensCommands: string[] = config().get('rmarkdown.codeLensCommands', []); - codeLenses = codeLenses. - filter(e => e.command && rmdCodeLensCommands.includes(e.command.command)). - sort(function (a, b) { - if (!a.command || !b.command) { return 0; } - const sorted = rmdCodeLensCommands.indexOf(a.command.command) - + codeLenses = codeLenses + .filter((e) => e.command && rmdCodeLensCommands.includes(e.command.command)) + .sort(function (a, b) { + if (!a.command || !b.command) { + return 0; + } + const sorted = + rmdCodeLensCommands.indexOf(a.command.command) - rmdCodeLensCommands.indexOf(b.command.command); return sorted; }); diff --git a/src/rmarkdown/draft.ts b/src/rmarkdown/draft.ts index 66ed93acb..c9ea38144 100644 --- a/src/rmarkdown/draft.ts +++ b/src/rmarkdown/draft.ts @@ -1,6 +1,14 @@ import { QuickPickItem, QuickPickOptions, Uri, window, workspace, env } from 'vscode'; import { extensionContext } from '../extension'; -import { executeRCommand, getCurrentWorkspaceFolder, getRpath, ToRStringLiteral, spawnAsync, getConfirmation, catchAsError } from '../util'; +import { + executeRCommand, + getCurrentWorkspaceFolder, + getRpath, + ToRStringLiteral, + spawnAsync, + getConfirmation, + catchAsError, +} from '../util'; import * as cp from 'child_process'; import * as path from 'path'; import * as fs from 'fs'; @@ -28,19 +36,12 @@ async function getTemplateItems(cwd: string): Promise(items, options); return selection; } else { - void window.showInformationMessage('No templates found. Would you like to browse the wiki page for R packages that provide R Markdown templates?', 'Yes', 'No') + void window + .showInformationMessage( + 'No templates found. Would you like to browse the wiki page for R packages that provide R Markdown templates?', + 'Yes', + 'No', + ) .then((select: string | undefined) => { if (select === 'Yes') { - void env.openExternal(Uri.parse('https://github.com/REditorSupport/vscode-R/wiki/R-Markdown#templates')); + void env.openExternal( + Uri.parse( + 'https://github.com/REditorSupport/vscode-R/wiki/R-Markdown#templates', + ), + ); } }); } @@ -100,7 +110,11 @@ async function launchTemplatePicker(cwd: string): Promise { +async function makeDraft( + file: string, + template: TemplateItem, + cwd: string, +): Promise { const cmd = `cat(normalizePath(rmarkdown::draft(file=${ToRStringLiteral(file, '"')}, template=${ToRStringLiteral(template.info.id, '"')}, package=${ToRStringLiteral(template.info.package, '"')}, edit=FALSE)))`; return await executeRCommand(cmd, cwd, (e: Error) => { void window.showErrorMessage(e.message); @@ -124,17 +138,21 @@ export async function newDraft(): Promise { const uri = await window.showSaveDialog({ defaultUri: Uri.file(defaultPath), filters: { - 'Folder': [''] + Folder: [''], }, saveLabel: 'Create Folder', - title: 'R Markdown: New Draft' + title: 'R Markdown: New Draft', }); if (uri) { const parsedPath = path.parse(uri.fsPath); 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?`)) { + if ( + await getConfirmation( + `Folder already exists. Are you sure you want to replace the folder?`, + ) + ) { fs.rmSync(dir, { recursive: true, force: true }); } else { return; @@ -143,8 +161,9 @@ export async function newDraft(): Promise { const draftPath = await makeDraft(uri.fsPath, template, cwd); if (draftPath) { - await workspace.openTextDocument(draftPath) - .then(document => window.showTextDocument(document)); + await workspace + .openTextDocument(draftPath) + .then((document) => window.showTextDocument(document)); } } } else { @@ -154,8 +173,9 @@ export async function newDraft(): Promise { const draftPath = await makeDraft(tempFile, template, cwd); if (draftPath) { const text = fs.readFileSync(draftPath, 'utf8'); - await workspace.openTextDocument({ language: 'rmd', content: text }) - .then(document => window.showTextDocument(document)); + await workspace + .openTextDocument({ language: 'rmd', content: text }) + .then((document) => window.showTextDocument(document)); } } finally { fs.rmSync(tempDir, { recursive: true, force: true }); diff --git a/src/rmarkdown/index.ts b/src/rmarkdown/index.ts index 5661dc634..061226696 100644 --- a/src/rmarkdown/index.ts +++ b/src/rmarkdown/index.ts @@ -1,16 +1,32 @@ import * as vscode from 'vscode'; import { config } from '../util'; -import { - shouldDisplayChunkOptions, getChunks, getCurrentChunk, - getCodeLenses, isRDocument, - type RMarkdownChunk +import { + shouldDisplayChunkOptions, + getChunks, + getCurrentChunk, + getCodeLenses, + isRDocument, + type RMarkdownChunk, } from './chunks'; // reexports export { knitDir, RMarkdownKnitManager } from './knit'; export { RMarkdownPreviewManager } from './preview'; export { newDraft } from './draft'; -export { getChunks, runCurrentChunk, runCurrentChunkAndMove, runPreviousChunk, runNextChunk, runAboveChunks, runBelowChunks, runCurrentAndBelowChunks, runAllChunks, goToPreviousChunk, goToNextChunk, selectCurrentChunk } from './chunks'; +export { + getChunks, + runCurrentChunk, + runCurrentChunkAndMove, + runPreviousChunk, + runNextChunk, + runAboveChunks, + runBelowChunks, + runCurrentAndBelowChunks, + runAllChunks, + goToPreviousChunk, + goToNextChunk, + selectCurrentChunk, +} from './chunks'; export class RMarkdownCodeLensProvider implements vscode.CodeLensProvider { private _onDidChangeCodeLenses: vscode.EventEmitter = new vscode.EventEmitter(); @@ -30,18 +46,18 @@ export class RMarkdownCodeLensProvider implements vscode.CodeLensProvider { borderColor: new vscode.ThemeColor('interactive.activeCodeBorder'), borderWidth: '2px 0px 0px 0px', borderStyle: 'solid', - isWholeLine: true + isWholeLine: true, }); this.currentCellBottom = vscode.window.createTextEditorDecorationType({ borderColor: new vscode.ThemeColor('interactive.activeCodeBorder'), borderWidth: '0px 0px 1px 0px', borderStyle: 'solid', - isWholeLine: true + isWholeLine: true, }); // Register the event listener and store the disposable this.onDidChangeTextEditorSelectionHandler = vscode.window.onDidChangeTextEditorSelection( - () => this.onDidChangeTextEditorSelection() + () => this.onDidChangeTextEditorSelection(), ); } @@ -49,7 +65,7 @@ export class RMarkdownCodeLensProvider implements vscode.CodeLensProvider { private onDidChangeTextEditorSelection() { // Get the active editor const editor = vscode.window.activeTextEditor; - + if (editor) { const document = editor.document; const chunks = getChunks(document); @@ -60,24 +76,30 @@ export class RMarkdownCodeLensProvider implements vscode.CodeLensProvider { } private highlightCurrentChunk(chunks: RMarkdownChunk[], document: vscode.TextDocument) { - for (const editor of vscode.window.visibleTextEditors) { + for (const editor of vscode.window.visibleTextEditors) { if (editor.document.uri.toString() === document.uri.toString()) { const lines = document.getText().split(/\r?\n/); const currentLine = editor.selection.active.line; - const currentChunk = getCurrentChunk(chunks, currentLine); - + const currentChunk = getCurrentChunk(chunks, currentLine); + if (currentChunk) { // set top border const currentChunkStart = new vscode.Range( new vscode.Position(currentChunk.startLine, 0), - new vscode.Position(currentChunk.startLine, lines[currentChunk.startLine].length) + new vscode.Position( + currentChunk.startLine, + lines[currentChunk.startLine].length, + ), ); editor.setDecorations(this.currentCellTop, [currentChunkStart]); // set bottom border const currentChunkEnd = new vscode.Range( new vscode.Position(currentChunk.endLine, 0), - new vscode.Position(currentChunk.endLine, lines[currentChunk.endLine].length) + new vscode.Position( + currentChunk.endLine, + lines[currentChunk.endLine].length, + ), ); editor.setDecorations(this.currentCellBottom, [currentChunkEnd]); } @@ -98,7 +120,7 @@ export class RMarkdownCodeLensProvider implements vscode.CodeLensProvider { if (!chunks) { return; } - + // Highlight differently for `.R` and `.Rmd` files const isRDoc = isRDocument(document); if (isRDoc) { @@ -108,24 +130,24 @@ export class RMarkdownCodeLensProvider implements vscode.CodeLensProvider { } } - public provideCodeLenses(document: vscode.TextDocument, token: vscode.CancellationToken): vscode.CodeLens[] | Thenable { - + public provideCodeLenses( + document: vscode.TextDocument, + token: vscode.CancellationToken, + ): vscode.CodeLens[] | Thenable { const chunks = getChunks(document); // Highlight chunks - this.highlight(chunks, document); + this.highlight(chunks, document); // Loop through chunks and setup - const codeLenses = getCodeLenses( - chunks, token - ); + const codeLenses = getCodeLenses(chunks, token); return codeLenses; } public resolveCodeLens(codeLens: vscode.CodeLens): vscode.CodeLens { return codeLens; } - + // Clean-up dispose() { // Unregister the event listener when the provider is disposed @@ -136,19 +158,63 @@ export class RMarkdownCodeLensProvider implements vscode.CodeLensProvider { } export class RMarkdownCompletionItemProvider implements vscode.CompletionItemProvider { - // obtained from R code // paste0("[", paste0(paste0("'", names(knitr:: opts_chunk$merge(NULL)), "'"), collapse = ", "), "]") - public readonly chunkOptions = ['eval', 'echo', 'results', 'tidy', 'tidy.opts', 'collapse', - 'prompt', 'comment', 'highlight', 'strip.white', 'size', 'background', - 'cache', 'cache.path', 'cache.vars', 'cache.lazy', 'dependson', - 'autodep', 'cache.rebuild', 'fig.keep', 'fig.show', 'fig.align', - 'fig.path', 'dev', 'dev.args', 'dpi', 'fig.ext', 'fig.width', - 'fig.height', 'fig.env', 'fig.cap', 'fig.scap', 'fig.lp', 'fig.subcap', - 'fig.pos', 'out.width', 'out.height', 'out.extra', 'fig.retina', - 'external', 'sanitize', 'interval', 'aniopts', 'warning', 'error', - 'message', 'render', 'ref.label', 'child', 'engine', 'split', - 'include', 'purl']; + public readonly chunkOptions = [ + 'eval', + 'echo', + 'results', + 'tidy', + 'tidy.opts', + 'collapse', + 'prompt', + 'comment', + 'highlight', + 'strip.white', + 'size', + 'background', + 'cache', + 'cache.path', + 'cache.vars', + 'cache.lazy', + 'dependson', + 'autodep', + 'cache.rebuild', + 'fig.keep', + 'fig.show', + 'fig.align', + 'fig.path', + 'dev', + 'dev.args', + 'dpi', + 'fig.ext', + 'fig.width', + 'fig.height', + 'fig.env', + 'fig.cap', + 'fig.scap', + 'fig.lp', + 'fig.subcap', + 'fig.pos', + 'out.width', + 'out.height', + 'out.extra', + 'fig.retina', + 'external', + 'sanitize', + 'interval', + 'aniopts', + 'warning', + 'error', + 'message', + 'render', + 'ref.label', + 'child', + 'engine', + 'split', + 'include', + 'purl', + ]; public readonly chunkOptionCompletionItems: vscode.CompletionItem[]; constructor() { @@ -159,7 +225,10 @@ export class RMarkdownCompletionItemProvider implements vscode.CompletionItemPro }); } - public provideCompletionItems(document: vscode.TextDocument, position: vscode.Position): vscode.CompletionItem[] | undefined { + public provideCompletionItems( + document: vscode.TextDocument, + position: vscode.Position, + ): vscode.CompletionItem[] | undefined { if (shouldDisplayChunkOptions(document, position)) { return this.chunkOptionCompletionItems; } @@ -170,11 +239,17 @@ export class RMarkdownCompletionItemProvider implements vscode.CompletionItemPro // Fold code chunks export class RChunkFoldingProvider implements vscode.FoldingRangeProvider { - provideFoldingRanges(document: vscode.TextDocument): vscode.ProviderResult { + provideFoldingRanges( + document: vscode.TextDocument, + ): vscode.ProviderResult { const chunks = getChunks(document); if (chunks) { return chunks.map((chunk) => { - return new vscode.FoldingRange(chunk.startLine, chunk.endLine, vscode.FoldingRangeKind.Region); + return new vscode.FoldingRange( + chunk.startLine, + chunk.endLine, + vscode.FoldingRangeKind.Region, + ); }); } return undefined; diff --git a/src/rmarkdown/knit.ts b/src/rmarkdown/knit.ts index 2cc487458..97d3caf53 100644 --- a/src/rmarkdown/knit.ts +++ b/src/rmarkdown/knit.ts @@ -12,29 +12,39 @@ import { DisposableProcess } from '../util'; export let knitDir: KnitWorkingDirectory | undefined; interface IKnitQuickPickItem { - label: string, - description: string, - detail: string, - value: KnitWorkingDirectory + label: string; + description: string; + detail: string; + value: KnitWorkingDirectory; } interface IYamlFrontmatter { - title?: string, - author?: string, - knit?: string, - site?: string, - [key: string]: unknown + title?: string; + author?: string; + knit?: string; + site?: string; + [key: string]: unknown; } export class RMarkdownKnitManager extends RMarkdownManager { constructor() { super(); - knitDir = util.config().get('rmarkdown.knit.defaults.knitWorkingDirectory') ?? undefined; + knitDir = + util + .config() + .get('rmarkdown.knit.defaults.knitWorkingDirectory') ?? + undefined; } - - private async renderDocument(rDocumentPath: string, docPath: string, docName: string, yamlParams: IYamlFrontmatter, outputFormat?: string): Promise { - const openOutfile: boolean = util.config().get('rmarkdown.knit.openOutputFile') ?? false; + private async renderDocument( + rDocumentPath: string, + docPath: string, + docName: string, + yamlParams: IYamlFrontmatter, + outputFormat?: string, + ): Promise { + const openOutfile: boolean = + util.config().get('rmarkdown.knit.openOutputFile') ?? false; const knitWorkingDir = this.getKnitDir(knitDir, docPath); const knitWorkingDirText = knitWorkingDir ? `${knitWorkingDir}` : ''; const knitCommand = await this.getKnitCommand(yamlParams, rDocumentPath, outputFormat); @@ -46,9 +56,9 @@ export class RMarkdownKnitManager extends RMarkdownManager { const lim = '<<>>'; const re = new RegExp(`.*${lim}(.*)${lim}.*`, 'gms'); const scriptValues = { - 'VSCR_KNIT_DIR': knitWorkingDirText, - 'VSCR_LIM': lim, - 'VSCR_KNIT_COMMAND': knitCommand + VSCR_KNIT_DIR: knitWorkingDirText, + VSCR_LIM: lim, + VSCR_KNIT_COMMAND: knitCommand, }; const callback = (dat: string) => { @@ -61,7 +71,9 @@ export class RMarkdownKnitManager extends RMarkdownManager { if (fs.existsSync(outFile.fsPath)) { void vscode.commands.executeCommand('vscode.open', outFile); } else { - void vscode.window.showWarningMessage(`Could not find the output file at path: "${outFile.fsPath}"`); + void vscode.window.showWarningMessage( + `Could not find the output file at path: "${outFile.fsPath}"`, + ); } } return true; @@ -71,19 +83,16 @@ export class RMarkdownKnitManager extends RMarkdownManager { this.rMarkdownOutput.show(true); } - return await this.knitWithProgress( - { - workingDirectory: knitWorkingDirText, - fileName: docName, - filePath: rDocumentPath, - scriptArgs: scriptValues, - scriptPath: extensionContext.asAbsolutePath('R/rmarkdown/knit.R'), - rCmd: knitCommand, - rOutputFormat: outputFormat, - callback: callback - } - ); - + return await this.knitWithProgress({ + workingDirectory: knitWorkingDirText, + fileName: docName, + filePath: rDocumentPath, + scriptArgs: scriptValues, + scriptPath: extensionContext.asAbsolutePath('R/rmarkdown/knit.R'), + rCmd: knitCommand, + rOutputFormat: outputFormat, + callback: callback, + }); } private getYamlFrontmatter(docPath: string): IYamlFrontmatter { @@ -121,14 +130,20 @@ export class RMarkdownKnitManager extends RMarkdownManager { paramObj = parsed as IYamlFrontmatter; } } catch (e) { - console.error(`Could not parse YAML frontmatter for "${docPath}". Error: ${String(e)}`); + console.error( + `Could not parse YAML frontmatter for "${docPath}". Error: ${String(e)}`, + ); } } return paramObj; } - private async getKnitCommand(yamlParams: IYamlFrontmatter, docPath: string, outputFormat?: string): Promise { + private async getKnitCommand( + yamlParams: IYamlFrontmatter, + docPath: string, + outputFormat?: string, + ): Promise { let knitCommand: string; if (!yamlParams?.['site']) { @@ -137,7 +152,9 @@ export class RMarkdownKnitManager extends RMarkdownManager { // precedence: // knit > site > configuration - const formatArg = outputFormat ? `, output_format = ${util.ToRStringLiteral(outputFormat, '"')}` : ''; + const formatArg = outputFormat + ? `, output_format = ${util.ToRStringLiteral(outputFormat, '"')}` + : ''; if (typeof yamlParams['knit'] === 'string' && yamlParams['knit']) { const knitParam = yamlParams['knit'].trim(); knitCommand = `(${knitParam})(${docPath}${formatArg})`; @@ -162,8 +179,16 @@ export class RMarkdownKnitManager extends RMarkdownManager { if (!wad) { return; } - const rootFolder = vscode.workspace.getWorkspaceFolder(vscode.Uri.file(wad))?.uri.fsPath ?? path.dirname(wad); - const indexFile = (await vscode.workspace.findFiles(new vscode.RelativePattern(rootFolder, 'index.{Rmd,rmd,md}'), null, 1))?.[0]; + const rootFolder = + vscode.workspace.getWorkspaceFolder(vscode.Uri.file(wad))?.uri.fsPath ?? + path.dirname(wad); + const indexFile = ( + await vscode.workspace.findFiles( + new vscode.RelativePattern(rootFolder, 'index.{Rmd,rmd,md}'), + null, + 1, + ) + )?.[0]; const siteRoot = path.join(path.dirname(wad), '_site.yml'); // 'Simple' R Markdown websites require all docs to be in the root folder @@ -188,50 +213,56 @@ export class RMarkdownKnitManager extends RMarkdownManager { // alters the working directory for evaluating chunks public setKnitDir(): void { const textEditor = vscode.window.activeTextEditor; - const currentDocumentWorkspacePath: string | undefined = textEditor ? vscode.workspace.getWorkspaceFolder(textEditor.document.uri)?.uri.fsPath : undefined; - const currentDocumentFolderPath: string | undefined = textEditor ? path.dirname(textEditor.document.uri.fsPath) : undefined; + const currentDocumentWorkspacePath: string | undefined = textEditor + ? vscode.workspace.getWorkspaceFolder(textEditor.document.uri)?.uri.fsPath + : undefined; + const currentDocumentFolderPath: string | undefined = textEditor + ? path.dirname(textEditor.document.uri.fsPath) + : undefined; const items: IKnitQuickPickItem[] = []; if (currentDocumentWorkspacePath) { - items.push( - { - label: (knitDir === KnitWorkingDirectory.workspaceRoot ? '$(check)' : '') + KnitWorkingDirectory.workspaceRoot, - value: KnitWorkingDirectory.workspaceRoot, - detail: 'Use the workspace root as the knit working directory', - description: currentDocumentWorkspacePath ?? currentDocumentFolderPath ?? 'No available workspace' - } - ); + items.push({ + label: + (knitDir === KnitWorkingDirectory.workspaceRoot ? '$(check)' : '') + + KnitWorkingDirectory.workspaceRoot, + value: KnitWorkingDirectory.workspaceRoot, + detail: 'Use the workspace root as the knit working directory', + description: + currentDocumentWorkspacePath ?? + currentDocumentFolderPath ?? + 'No available workspace', + }); } if (currentDocumentFolderPath && currentDocumentFolderPath !== '.') { - items.push( - { - label: (knitDir === KnitWorkingDirectory.documentDirectory ? '$(check)' : '') + KnitWorkingDirectory.documentDirectory, - value: KnitWorkingDirectory.documentDirectory, - detail: 'Use the document\'s directory as the knit working directory', - description: currentDocumentFolderPath ?? 'No folder available' - - } - ); + items.push({ + label: + (knitDir === KnitWorkingDirectory.documentDirectory ? '$(check)' : '') + + KnitWorkingDirectory.documentDirectory, + value: KnitWorkingDirectory.documentDirectory, + detail: "Use the document's directory as the knit working directory", + description: currentDocumentFolderPath ?? 'No folder available', + }); } if (items.length > 0) { - void vscode.window.showQuickPick( - items, - { + void vscode.window + .showQuickPick(items, { title: 'Set knit working directory', - canPickMany: false - } - ).then(async choice => { - if (choice?.value && knitDir !== choice.value) { - knitDir = choice.value; - await rmdPreviewManager?.updatePreview(); - } - }); + canPickMany: false, + }) + .then(async (choice) => { + if (choice?.value && knitDir !== choice.value) { + knitDir = choice.value; + await rmdPreviewManager?.updatePreview(); + } + }); } else { - void vscode.window.showInformationMessage('Cannot set knit directory for untitled documents.'); + void vscode.window.showInformationMessage( + 'Cannot set knit directory for untitled documents.', + ); } - } public async knitRmd(echo: boolean, outputFormat?: string): Promise { @@ -245,7 +276,9 @@ export class RMarkdownKnitManager extends RMarkdownManager { // handle untitled rmd if (textEditor.document.isUntitled) { - void vscode.window.showWarningMessage('Cannot knit an untitled file. Please save the document.'); + void vscode.window.showWarningMessage( + 'Cannot knit an untitled file. Please save the document.', + ); await vscode.commands.executeCommand('workbench.action.files.save').then(() => { if (!textEditor.document.isUntitled) { void this.knitRmd(echo, outputFormat); @@ -277,7 +310,7 @@ export class RMarkdownKnitManager extends RMarkdownManager { wad.uri.fsPath, path.basename(wad.uri.fsPath), this.getYamlFrontmatter(wad.uri.fsPath), - outputFormat + outputFormat, ); } finally { this.busyUriStore.delete(busyPath); @@ -286,7 +319,9 @@ export class RMarkdownKnitManager extends RMarkdownManager { if (outputFormat === undefined) { void runTextInTerm(`rmarkdown::render(${rDocumentPath})`); } else { - void runTextInTerm(`rmarkdown::render(${rDocumentPath}, ${util.ToRStringLiteral(outputFormat, '"')})`); + void runTextInTerm( + `rmarkdown::render(${rDocumentPath}, ${util.ToRStringLiteral(outputFormat, '"')})`, + ); } } } diff --git a/src/rmarkdown/manager.ts b/src/rmarkdown/manager.ts index 8bb1fa5bf..ee06b62c3 100644 --- a/src/rmarkdown/manager.ts +++ b/src/rmarkdown/manager.ts @@ -50,165 +50,187 @@ export abstract class RMarkdownManager { // case 'current directory': { // return NULL // } - default: return undefined; + default: + return undefined; } } - protected async knitDocument(args: IKnitArgs, token?: vscode.CancellationToken, progress?: vscode.Progress): Promise { + protected async knitDocument( + args: IKnitArgs, + token?: vscode.CancellationToken, + progress?: vscode.Progress, + ): Promise { // vscode.Progress auto-increments progress, so we use this // variable to set progress to a specific number let currentProgress = 0; let printOutput = true; let cancellation: vscode.Disposable | undefined; - return await new Promise( - (resolve, reject) => { - const scriptArgs = args.scriptArgs; - const scriptPath = args.scriptPath; - const fileName = args.fileName; - // const cmd = `${this.rPath} --silent --no-echo --no-save --no-restore -f "${scriptPath}"`; - const cpArgs = [ - '--silent', - '--no-echo', - '--no-save', - '--no-restore', - '-f', - scriptPath - ]; - // When there's no LANG variable, we should try to set to a UTF-8 compatible one, as R relies - // on locale setting (based on LANG) to render certain characters. - // See https://github.com/REditorSupport/vscode-R/issues/933 - const env = { ...process.env }; - if (env.LANG === undefined) { - env.LANG = 'en_US.UTF-8'; + return await new Promise((resolve, reject) => { + const scriptArgs = args.scriptArgs; + const scriptPath = args.scriptPath; + const fileName = args.fileName; + // const cmd = `${this.rPath} --silent --no-echo --no-save --no-restore -f "${scriptPath}"`; + const cpArgs = ['--silent', '--no-echo', '--no-save', '--no-restore', '-f', scriptPath]; + // When there's no LANG variable, we should try to set to a UTF-8 compatible one, as R relies + // on locale setting (based on LANG) to render certain characters. + // See https://github.com/REditorSupport/vscode-R/issues/933 + const env = { ...process.env }; + if (env.LANG === undefined) { + env.LANG = 'en_US.UTF-8'; + } + const processOptions: cp.SpawnOptions = { + env: { + ...env, + ...scriptArgs, + }, + cwd: args.workingDirectory, + }; + + let childProcess: DisposableProcess | undefined = undefined; + try { + if (!this.rPath) { + throw new Error('R path not defined'); } - const processOptions: cp.SpawnOptions = { - env: { - ...env, - ...scriptArgs - }, - cwd: args.workingDirectory, - }; - - let childProcess: DisposableProcess | undefined = undefined; - try { - if (!this.rPath) { - throw new Error('R path not defined'); - } - childProcess = spawn(this.rPath, cpArgs, processOptions, () => { - rMarkdownOutput.appendLine('[VSC-R] terminating R process'); - printOutput = false; - }); - progress?.report({ - increment: 0, - message: '0%' - }); - } catch (e: unknown) { - console.warn(`[VSC-R] error: ${e as string}`); - reject({ cp: childProcess, wasCancelled: false }); - return; - } + childProcess = spawn(this.rPath, cpArgs, processOptions, () => { + rMarkdownOutput.appendLine('[VSC-R] terminating R process'); + printOutput = false; + }); + progress?.report({ + increment: 0, + message: '0%', + }); + } catch (e: unknown) { + console.warn(`[VSC-R] error: ${e as string}`); + reject({ cp: childProcess, wasCancelled: false }); + return; + } - this.rMarkdownOutput.appendLine(`[VSC-R] ${fileName} process started`); + this.rMarkdownOutput.appendLine(`[VSC-R] ${fileName} process started`); - if (args.rCmd) { - this.rMarkdownOutput.appendLine(`==> ${args.rCmd}`); - } - - const stdoutDecoder = new StringDecoder('utf8'); - const stderrDecoder = new StringDecoder('utf8'); - let outputBuffer = ''; - let outputComplete = false; - const receive = (dat: string) => { - // R's output-path marker can span several pipe writes. - // Retain a bounded suffix while streaming ordinary output. - outputBuffer = (outputBuffer + dat).slice(-65536); - if (printOutput) { - this.rMarkdownOutput.appendLine(dat); - } - const percentRegex = /[0-9]+(?=%)/g; - const divisionRegex = /([0-9]+)\/([0-9]+)/g; - const percentRegOutput = dat.match(percentRegex); - if (percentRegOutput) { - for (const item of percentRegOutput) { - const perc = Number(item); - progress?.report({ increment: perc - currentProgress, message: `${perc}%` }); - currentProgress = perc; - } - } else { - const divisionRegOutput = divisionRegex.exec(dat); - if (divisionRegOutput) { - const perc = Math.ceil(parseInt(divisionRegOutput[1]) / parseInt(divisionRegOutput[2]) * 100); - progress?.report({ increment: perc - currentProgress, message: `${perc}%` }); - currentProgress = perc; - } - } + if (args.rCmd) { + this.rMarkdownOutput.appendLine(`==> ${args.rCmd}`); + } - if (token?.isCancellationRequested) { - reject({ cp: childProcess, wasCancelled: true }); - } else if (!outputComplete && args.callback(outputBuffer, childProcess)) { - outputComplete = true; - if (childProcess) { resolve(childProcess); } + const stdoutDecoder = new StringDecoder('utf8'); + const stderrDecoder = new StringDecoder('utf8'); + let outputBuffer = ''; + let outputComplete = false; + const receive = (dat: string) => { + // R's output-path marker can span several pipe writes. + // Retain a bounded suffix while streaming ordinary output. + outputBuffer = (outputBuffer + dat).slice(-65536); + if (printOutput) { + this.rMarkdownOutput.appendLine(dat); + } + const percentRegex = /[0-9]+(?=%)/g; + const divisionRegex = /([0-9]+)\/([0-9]+)/g; + const percentRegOutput = dat.match(percentRegex); + if (percentRegOutput) { + for (const item of percentRegOutput) { + const perc = Number(item); + progress?.report({ + increment: perc - currentProgress, + message: `${perc}%`, + }); + currentProgress = perc; } - }; - childProcess.stdout.on('data', (data: Buffer) => { - try { receive(stdoutDecoder.write(data)); } - catch (error) { - this.rMarkdownOutput.appendLine(String(error)); - reject({ cp: childProcess, wasCancelled: false }); + } else { + const divisionRegOutput = divisionRegex.exec(dat); + if (divisionRegOutput) { + const perc = Math.ceil( + (parseInt(divisionRegOutput[1]) / parseInt(divisionRegOutput[2])) * 100, + ); + progress?.report({ + increment: perc - currentProgress, + message: `${perc}%`, + }); + currentProgress = perc; } - }); + } - childProcess.stderr.on('data', (data: Buffer) => { - const dat = stderrDecoder.write(data); - if (printOutput) { - this.rMarkdownOutput.appendLine(dat); + if (token?.isCancellationRequested) { + reject({ cp: childProcess, wasCancelled: true }); + } else if (!outputComplete && args.callback(outputBuffer, childProcess)) { + outputComplete = true; + if (childProcess) { + resolve(childProcess); } - }); - - childProcess.on('error', error => { - this.rMarkdownOutput.appendLine(error.message); - reject({ cp: childProcess, wasCancelled: false }); - }); - childProcess.on('close', (code, signal) => { - this.rMarkdownOutput.appendLine(`[VSC-R] ${fileName} process exited ` + - (signal ? `from signal '${signal}'` : `with exit code ${code ?? 'null'}`)); - // If no callback recognized the output, even a zero exit code - // cannot produce a preview. Always settle the pending progress. + } + }; + childProcess.stdout.on('data', (data: Buffer) => { + try { + receive(stdoutDecoder.write(data)); + } catch (error) { + this.rMarkdownOutput.appendLine(String(error)); reject({ cp: childProcess, wasCancelled: false }); - }); + } + }); - cancellation = token?.onCancellationRequested(() => { - reject({ cp: childProcess, wasCancelled: true }); - }); - } - ).finally(() => { cancellation?.dispose(); }); + childProcess.stderr.on('data', (data: Buffer) => { + const dat = stderrDecoder.write(data); + if (printOutput) { + this.rMarkdownOutput.appendLine(dat); + } + }); + + childProcess.on('error', (error) => { + this.rMarkdownOutput.appendLine(error.message); + reject({ cp: childProcess, wasCancelled: false }); + }); + childProcess.on('close', (code, signal) => { + this.rMarkdownOutput.appendLine( + `[VSC-R] ${fileName} process exited ` + + (signal ? `from signal '${signal}'` : `with exit code ${code ?? 'null'}`), + ); + // If no callback recognized the output, even a zero exit code + // cannot produce a preview. Always settle the pending progress. + reject({ cp: childProcess, wasCancelled: false }); + }); + + cancellation = token?.onCancellationRequested(() => { + reject({ cp: childProcess, wasCancelled: true }); + }); + }).finally(() => { + cancellation?.dispose(); + }); } protected async knitWithProgress(args: IKnitArgs): Promise { let childProcess: DisposableProcess | undefined = undefined; - await util.doWithProgress( - (async ( - token: vscode.CancellationToken | undefined, - progress: vscode.Progress<{ - message?: string | undefined; - increment?: number | undefined; - }> | undefined) => { - childProcess = await this.knitDocument(args, token, progress) as DisposableProcess; - }), - vscode.ProgressLocation.Notification, - `Knitting ${args.fileName} ${args.rOutputFormat ? 'to ' + args.rOutputFormat : ''} `, - true - ).catch((rejection: IKnitRejection) => { - if (!rejection.wasCancelled) { - void vscode.window.showErrorMessage('There was an error in knitting the document. Please check the R Markdown output stream.'); - this.rMarkdownOutput.show(true); - } - // this can occur when a successfuly knitted document is later altered (while still being previewed) and subsequently fails to knit - args?.onRejection?.(args.filePath, rejection); - rejection.cp?.dispose(); - }); + await util + .doWithProgress( + async ( + token: vscode.CancellationToken | undefined, + progress: + | vscode.Progress<{ + message?: string | undefined; + increment?: number | undefined; + }> + | undefined, + ) => { + childProcess = (await this.knitDocument( + args, + token, + progress, + )) as DisposableProcess; + }, + vscode.ProgressLocation.Notification, + `Knitting ${args.fileName} ${args.rOutputFormat ? 'to ' + args.rOutputFormat : ''} `, + true, + ) + .catch((rejection: IKnitRejection) => { + if (!rejection.wasCancelled) { + void vscode.window.showErrorMessage( + 'There was an error in knitting the document. Please check the R Markdown output stream.', + ); + this.rMarkdownOutput.show(true); + } + // this can occur when a successfuly knitted document is later altered (while still being previewed) and subsequently fails to knit + args?.onRejection?.(args.filePath, rejection); + rejection.cp?.dispose(); + }); return childProcess; } } diff --git a/src/rmarkdown/preview.ts b/src/rmarkdown/preview.ts index 40e77655f..7840766e5 100644 --- a/src/rmarkdown/preview.ts +++ b/src/rmarkdown/preview.ts @@ -5,8 +5,16 @@ import * as cheerio from 'cheerio'; import path = require('path'); import crypto = require('crypto'); - -import { config, readContent, setContext, escapeHtml, UriIcon, saveDocument, getRpath, DisposableProcess } from '../util'; +import { + config, + readContent, + setContext, + escapeHtml, + UriIcon, + saveDocument, + getRpath, + DisposableProcess, +} from '../util'; import { extensionContext, tmpDir } from '../extension'; import { knitDir } from './knit'; import { RMarkdownManager } from './manager'; @@ -24,9 +32,17 @@ class RMarkdownPreview extends vscode.Disposable { mtime: number; isRendering: boolean; - constructor(title: string, cp: DisposableProcess | undefined, panel: vscode.WebviewPanel, - resourceViewColumn: vscode.ViewColumn, outputUri: vscode.Uri, filePath: string, - RMarkdownPreviewManager: RMarkdownPreviewManager, useCodeTheme: boolean, autoRefresh: boolean) { + constructor( + title: string, + cp: DisposableProcess | undefined, + panel: vscode.WebviewPanel, + resourceViewColumn: vscode.ViewColumn, + outputUri: vscode.Uri, + filePath: string, + RMarkdownPreviewManager: RMarkdownPreviewManager, + useCodeTheme: boolean, + autoRefresh: boolean, + ) { super(() => { this.cp?.dispose(); this.panel?.dispose(); @@ -55,7 +71,7 @@ class RMarkdownPreview extends vscode.Disposable { } public async refreshContent(useCodeTheme: boolean) { - this.getHtmlContent(await readContent(this.outputUri.fsPath, 'utf8') ?? ''); + this.getHtmlContent((await readContent(this.outputUri.fsPath, 'utf8')) ?? ''); this.styleHtml(useCodeTheme); } @@ -64,7 +80,9 @@ class RMarkdownPreview extends vscode.Disposable { const fileWatcher = fs.watch(filePath, {}, () => { const mtime = fs.statSync(filePath).mtime.getTime(); if (this.autoRefresh && !this.isRendering && !fsTimeout && mtime !== this.mtime) { - fsTimeout = setTimeout(() => { fsTimeout = null; }, 1000); + fsTimeout = setTimeout(() => { + fsTimeout = null; + }, 1000); this.mtime = mtime; void RMarkdownPreviewManager.updatePreview(this); } @@ -73,8 +91,10 @@ class RMarkdownPreview extends vscode.Disposable { } private getHtmlContent(htmlContent: string): void { - let content = htmlContent.replace(/<(\w+)\s+(href|src)="(?!(\w+:)|#)/g, - `<$1 $2="${String(this.panel.webview.asWebviewUri(vscode.Uri.file(tmpDir())))}/`); + let content = htmlContent.replace( + /<(\w+)\s+(href|src)="(?!(\w+:)|#)/g, + `<$1 $2="${String(this.panel.webview.asWebviewUri(vscode.Uri.file(tmpDir())))}/`, + ); const re = new RegExp('.*', 'ms'); const isHtml = !!re.exec(content); @@ -106,8 +126,7 @@ class RMarkdownPreview extends vscode.Disposable { outCol = 'rgba(128, 128, 128, 0.05)'; } - const style = - ` Section @@ -433,17 +629,41 @@ suite('HTML widget browser rendering', () => { const report = document.createElement('a'); report.href = 'https://widget-test.invalid/fragments?' + results; document.body.append(report); report.click(); }, 750); - `); - await showWebView(file, 'Report fragments', 'Two', session.getViewerSessionContext(source.sessionId)); + `, + ); + 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`); + 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(); }); + panels.forEach((panel) => { + panel.dispose(); + }); await shutdownHtmlWidgetViewers(); session.unregisterSessionTransport(source); sandbox.restore(); @@ -453,25 +673,38 @@ 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() }); + 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'; + 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)!, + 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') + sandbox + .stub(vscode.commands, 'executeCommand') + .callThrough() + .withArgs('setContext') .callsFake((_command: string, key: string, value: unknown) => { - contexts.set(key, value); return executeCommand('setContext', key, value); + contexts.set(key, value); + return executeCommand('setContext', key, value); }); const createPanel = vscode.window.createWebviewPanel.bind(vscode.window); sandbox.stub(vscode.window, 'createWebviewPanel').callsFake((...args) => { @@ -480,22 +713,45 @@ suite('HTML widget browser rendering', () => { // 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))); - }); + 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); }); + 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; }'); + 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, ` + fs.writeFileSync( + file, + ` ${name === 'table' ? '
Centered flextable output
' : '

HTML file output

'} `); + }, 50));`, + ); 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); + 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)); + 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 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()); + 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.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); + 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'); + 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.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); + 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'); + 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(); }); + panels.forEach((panel) => { + panel.dispose(); + }); await shutdownHtmlWidgetViewers(); session.unregisterSessionTransport(source); sandbox.restore(); @@ -557,31 +860,55 @@ suite('HTML widget browser rendering', () => { 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() }); + sandbox + .stub(vscode.window, 'registerWebviewPanelSerializer') + .returns({ dispose: sandbox.stub() }); const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'vscode-r-widget-browser-')); const panels: vscode.WebviewPanel[] = []; const urls: vscode.Uri[] = []; - const source = session.registerSessionTransport('html-browser-rendering', 'widget-test-host', directory, () => Promise.resolve({})); - source.pid = '12101'; source.rVer = '4.6.1'; + const source = session.registerSessionTransport( + 'html-browser-rendering', + 'widget-test-host', + directory, + () => Promise.resolve({}), + ); + source.pid = '12101'; + source.rVer = '4.6.1'; try { mockExtensionContext(path.resolve(__dirname, '../../..'), sandbox); const viewerManager = initializeHtmlWidgetViewers(extensionContext, { - resolveSession: source => session.getViewerSessionContext(source.sessionId, source)!, + resolveSession: (source) => + session.getViewerSessionContext(source.sessionId, source)!, getActiveSessionId: () => session.activeSession?.sessionId, }); const createPanel = vscode.window.createWebviewPanel.bind(vscode.window); sandbox.stub(vscode.window, 'createWebviewPanel').callsFake((...args) => { - const panel = createPanel(...args); panels.push(panel); return panel; + const panel = createPanel(...args); + panels.push(panel); + return panel; + }); + sandbox.stub(vscode.env, 'openExternal').callsFake((uri) => { + urls.push(uri); + return Promise.resolve(true); }); - sandbox.stub(vscode.env, 'openExternal').callsFake(uri => { urls.push(uri); return Promise.resolve(true); }); const fixture = (name: string) => { const root = path.join(directory, name); fs.mkdirSync(path.join(root, 'lib'), { recursive: true }); - fs.writeFileSync(path.join(root, 'lib/widget.css'), '#widget { min-width:137px; }' + - (name === 'first' ? 'body { background:white; }' : 'body { background:#123; color:#eee; } code { color:#abc; background:#234; }')); - fs.writeFileSync(path.join(root, 'lib/pixel.svg'), ''); + fs.writeFileSync( + path.join(root, 'lib/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; + 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(() => { @@ -593,31 +920,60 @@ suite('HTML widget browser rendering', () => { codeColor: getComputedStyle(document.getElementById('markdown-code')).color }); report.href = 'https://widget-test.invalid/${name}?' + params; report.click(); if (++reports === 30) clearInterval(timer); - }, 100);`); + }, 100);`, + ); const file = path.join(root, 'index.html'); - fs.writeFileSync(file, `
${name}

Rendered Markdown inline code

`); + 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 count = (name: string) => urls.filter((uri) => uri.path === `/${name}`).length; const loaded = async (name: string, previous: number) => { const uri = await waitForValue(() => { - const reports = urls.filter(uri => uri.path === `/${name}`); + const reports = urls.filter((uri) => uri.path === `/${name}`); const latest = reports.at(-1); const params = new URLSearchParams(latest?.query); - return reports.length > previous && ['css', 'image', 'fetch', 'direct'].every(key => params.get(key) === 'true') && - [...viewerManager.viewers.values()].some(entry => entry.panel === panels.at(-1) && !entry.loading) ? latest : undefined; + return reports.length > previous && + ['css', 'image', 'fetch', 'direct'].every( + (key) => params.get(key) === 'true', + ) && + [...viewerManager.viewers.values()].some( + (entry) => entry.panel === panels.at(-1) && !entry.loading, + ) + ? latest + : undefined; }); const dimensions = new URLSearchParams(uri.query); - assert.ok(Number(dimensions.get('width')) > 0 && Number(dimensions.get('height')) > 0); - assert.strictEqual(dimensions.get('textColor'), name === 'first' ? 'rgb(0, 0, 0)' : 'rgb(238, 238, 238)'); - assert.strictEqual(dimensions.get('codeColor'), name === 'first' ? 'rgb(0, 0, 0)' : 'rgb(170, 187, 204)'); + assert.ok( + 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 showWebView( + first, + 'First widget', + 'Two', + session.getViewerSessionContext(source.sessionId), + ); await loaded('first', 0); - await showWebView(second, 'Second widget', 'Two', session.getViewerSessionContext(source.sessionId)); + await showWebView( + second, + 'Second widget', + 'Two', + session.getViewerSessionContext(source.sessionId), + ); await loaded('second', 0); assert.strictEqual(panels.length, 1); const panel = panels[0]; @@ -632,7 +988,10 @@ suite('HTML widget browser rendering', () => { const removeCount = count('first'); await runHtmlViewerCommand('remove'); await loaded('first', removeCount); - assert.ok(fs.existsSync(second), 'Removing history must preserve the original HTML file'); + 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); @@ -650,7 +1009,9 @@ suite('HTML widget browser rendering', () => { assert.strictEqual(panels.length, 2); assert.strictEqual(panels[1].title, 'HTML Viewer'); } finally { - panels.forEach(panel => { panel.dispose(); }); + panels.forEach((panel) => { + panel.dispose(); + }); await shutdownHtmlWidgetViewers(); session.unregisterSessionTransport(source); sandbox.restore(); @@ -659,7 +1020,9 @@ suite('HTML widget browser rendering', () => { }); 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() }); + 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(); @@ -672,19 +1035,30 @@ suite('HTML widget browser rendering', () => { 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') + sandbox + .stub(vscode.commands, 'executeCommand') + .callThrough() + .withArgs('setContext') .callsFake((_command: string, key: string, value: unknown) => - executeCommand('setContext', key, value).then(() => { contexts.set(key, value); })); + executeCommand('setContext', key, value).then(() => { + contexts.set(key, value); + }), + ); const createPanel = vscode.window.createWebviewPanel.bind(vscode.window); sandbox.stub(vscode.window, 'createWebviewPanel').callsFake((...args) => { const panel = createPanel(...args); if (panel.viewType === 'r.htmlViewer') { panels.push(panel); - panel.webview.onDidReceiveMessage((message: { message?: string; generation?: number }) => { - if (message.message === 'widget/loaded' && typeof message.generation === 'number') { - loaded.set(panel, message.generation); - } - }); + panel.webview.onDidReceiveMessage( + (message: { message?: string; generation?: number }) => { + if ( + message.message === 'widget/loaded' && + typeof message.generation === 'number' + ) { + loaded.set(panel, message.generation); + } + }, + ); } return panel; }); @@ -694,53 +1068,110 @@ suite('HTML widget browser rendering', () => { let attached = false; let buffer = ''; const socket = client; - socket.on('data', data => { + 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 }; + 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'); + 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); }); + 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); + 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'); + fs.writeFileSync( + first, + 'Native first', + ); + fs.writeFileSync( + second, + 'Native second', + ); notify('webview', { url: first, title: 'First' }); - const panel = await waitForValue(() => panels[0]?.webview.html.includes('Native first') ? panels[0] : undefined); - const run = async (viewer: vscode.WebviewPanel, action: 'back' | 'forward' | 'remove' | 'info') => { - const generation = () => Number(/data-generation="(\d+)"/.exec(viewer.webview.html)?.[1]); + const panel = await waitForValue(() => + panels[0]?.webview.html.includes('Native first') ? panels[0] : undefined, + ); + const run = async ( + viewer: vscode.WebviewPanel, + action: 'back' | 'forward' | 'remove' | 'info', + ) => { + const generation = () => + Number(/data-generation="(\d+)"/.exec(viewer.webview.html)?.[1]); const ready = () => loaded.get(viewer) === generation(); - await waitForValue(() => ready() ? true : undefined); + await waitForValue(() => (ready() ? true : undefined)); await focusHtmlViewer(viewer); - const key = { back: 'canGoBack', forward: 'canGoForward', remove: 'canRemove', info: 'canShowInfo' }[action]; + 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); + await waitForValue(() => + ready() && viewer.active && contexts.get(`r.htmlViewer.${key}`) === true + ? true + : undefined, + ); const previousGeneration = generation(); await vscode.commands.executeCommand(`r.htmlViewer.${action}`); // Navigation must load a new document, rather than accepting the // previous page's acknowledgement if the command was ignored. - await waitForValue(() => ready() && (action === 'info' || generation() > previousGeneration) ? true : undefined); + await waitForValue(() => + ready() && (action === 'info' || generation() > previousGeneration) + ? true + : undefined, + ); // Title changes reach the workbench asynchronously. Navigation // preserves focus, so check the viewer's group even if another // group becomes active before the next action refocuses it. - await waitForValue(() => vscode.window.tabGroups.all.find(group => - group.viewColumn === viewer.viewColumn)?.activeTab?.label === viewer.title ? true : undefined); + await waitForValue(() => + vscode.window.tabGroups.all.find( + (group) => group.viewColumn === viewer.viewColumn, + )?.activeTab?.label === viewer.title + ? true + : undefined, + ); }; notify('webview', { url: second, title: 'Second' }); - await waitForValue(() => panel.webview.html.includes('Native second') ? true : undefined); + await waitForValue(() => + panel.webview.html.includes('Native second') ? true : undefined, + ); await run(panel, 'back'); assert.ok(panel.webview.html.includes('Native first')); await run(panel, 'forward'); @@ -762,11 +1193,12 @@ suite('HTML widget browser rendering', () => { await run(panels[1], 'remove'); assert.ok(panels[1].webview.html.includes('No HTML outputs in this session')); } finally { - panels.forEach(panel => { panel.dispose(); }); + 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 index 4ce62e1f3..7420241de 100644 --- a/src/test/suite/htmlWidgetContent.test.ts +++ b/src/test/suite/htmlWidgetContent.test.ts @@ -1,15 +1,25 @@ import * as assert from 'assert'; import * as vm from 'vm'; -import { initializeWidgetContent, initializeWidgetLoad, initializeWidgetState } from '../../webViewer/webview/widget'; +import { + initializeWidgetContent, + initializeWidgetLoad, + initializeWidgetState, +} from '../../webViewer/webview/widget'; suite('HTML output content controls', () => { function content(sessionOwned = true) { const posted: unknown[] = []; const events = new Map void>(); vm.runInNewContext(`(${initializeWidgetContent.toString()})(vscode, 7, sessionOwned)`, { - document: { addEventListener: (name: string, listener: (event: unknown) => void) => events.set(name, listener) }, - window: {}, sessionOwned, - vscode: { postMessage: (message: unknown) => posted.push(JSON.parse(JSON.stringify(message))) }, + 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 }; } @@ -17,8 +27,19 @@ suite('HTML output content controls', () => { 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++; } }); + 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 }, @@ -29,7 +50,11 @@ suite('HTML output content controls', () => { 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' }); + 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 }); @@ -41,14 +66,28 @@ suite('HTML output content controls', () => { 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; } }); + 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' }); + initializeWidgetState( + { + setState: (state) => { + saved = state; + }, + postMessage: () => {}, + }, + { id: 'original-panel' }, + ); assert.deepStrictEqual(saved, { id: 'original-panel' }); }); @@ -59,11 +98,22 @@ suite('HTML output content controls', () => { 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; - } }, + 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))) }, + 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'); @@ -71,7 +121,11 @@ suite('HTML output content controls', () => { } assert.deepStrictEqual(posted, []); frames.shift()!(); - assert.deepStrictEqual(posted, [], 'Wait until the browser has had a paint opportunity'); + 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 9ce3e55ca..3587e842c 100644 --- a/src/test/suite/htmlWidgetViewer.test.ts +++ b/src/test/suite/htmlWidgetViewer.test.ts @@ -5,7 +5,14 @@ import * as path from 'path'; import * as sinon from 'sinon'; import * as vscode from 'vscode'; import * as session from '../../session'; -import { HtmlViewerSessionAccess, initializeHtmlWidgetViewers, restoreHtmlViewer, runHtmlViewerCommand, showWebView, shutdownHtmlWidgetViewers } from '../../webViewer'; +import { + HtmlViewerSessionAccess, + initializeHtmlWidgetViewers, + restoreHtmlViewer, + runHtmlViewerCommand, + showWebView, + shutdownHtmlWidgetViewers, +} from '../../webViewer'; import { extensionContext } from '../../extension'; import { widgetHistoryKey, WidgetHistory } from '../../webViewer/history'; import type { HtmlViewerPanelState } from '../../webViewer/webviewMessages'; @@ -23,7 +30,10 @@ suite('Session-aware HTML widget Viewer', () => { let execute: sinon.SinonStub; let information: sinon.SinonStub; const contexts = new Map(); - const viewEvents = new Map>(); + const viewEvents = new Map< + vscode.WebviewPanel, + vscode.EventEmitter + >(); const savedState = new Map(); let trustedWorkspace: boolean; let workspaceRoots: string[]; @@ -32,7 +42,7 @@ suite('Session-aware HTML widget Viewer', () => { let disposeLoadingStatus: sinon.SinonStub; let statusVisible: boolean; const viewerSessions: HtmlViewerSessionAccess = { - resolveSession: source => session.getViewerSessionContext(source.sessionId, source)!, + resolveSession: (source) => session.getViewerSessionContext(source.sessionId, source)!, getActiveSessionId: () => session.activeSession?.sessionId, }; @@ -42,103 +52,175 @@ suite('Session-aware HTML widget Viewer', () => { workspaceRoots = []; autoLoad = true; statusVisible = false; - sandbox.stub(vscode.window, 'createStatusBarItem').callThrough().withArgs(sinon.match.same('r.htmlViewer.loading')).callsFake((id, alignment) => { - assert.strictEqual(id, 'r.htmlViewer.loading'); - assert.strictEqual(alignment, vscode.StatusBarAlignment.Right); - disposeLoadingStatus = sandbox.stub().callsFake(() => { statusVisible = false; }); - loadingStatus = { - text: '', tooltip: undefined, command: undefined, - show: sandbox.stub().callsFake(() => { statusVisible = true; }), - hide: sandbox.stub().callsFake(() => { statusVisible = false; }), - dispose: disposeLoadingStatus, - } as unknown as vscode.StatusBarItem; - return loadingStatus; - }); + sandbox + .stub(vscode.window, 'createStatusBarItem') + .callThrough() + .withArgs(sinon.match.same('r.htmlViewer.loading')) + .callsFake((id, alignment) => { + assert.strictEqual(id, 'r.htmlViewer.loading'); + assert.strictEqual(alignment, vscode.StatusBarAlignment.Right); + disposeLoadingStatus = sandbox.stub().callsFake(() => { + statusVisible = false; + }); + loadingStatus = { + text: '', + tooltip: undefined, + command: undefined, + show: sandbox.stub().callsFake(() => { + statusVisible = true; + }), + hide: sandbox.stub().callsFake(() => { + statusVisible = false; + }), + dispose: disposeLoadingStatus, + } as unknown as vscode.StatusBarItem; + return loadingStatus; + }); sandbox.stub(vscode.workspace, 'isTrusted').get(() => trustedWorkspace); - sandbox.stub(vscode.workspace, 'workspaceFolders').get(() => workspaceRoots.map((root, index) => ({ - uri: vscode.Uri.file(path.resolve(root)), name: 'HTML viewer test workspace', index, - }))); + sandbox.stub(vscode.workspace, 'workspaceFolders').get(() => + workspaceRoots.map((root, index) => ({ + uri: vscode.Uri.file(path.resolve(root)), + name: 'HTML viewer test workspace', + index, + })), + ); activePanel = undefined; contexts.clear(); execute = sandbox.stub(vscode.commands, 'executeCommand').callThrough(); - execute.withArgs('setContext').callsFake((_command: string, key: string, value: unknown) => { contexts.set(key, value); return Promise.resolve(); }); + 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() }); + sandbox + .stub(vscode.window, 'registerWebviewPanelSerializer') + .returns({ dispose: sandbox.stub() }); mockExtensionContext(path.resolve(__dirname, '../../..'), sandbox); savedState.clear(); - (extensionContext.workspaceState.get as sinon.SinonStub).callsFake((key: string, fallback: unknown) => savedState.get(key) ?? fallback); - (extensionContext.workspaceState.update as sinon.SinonStub).callsFake((key: string, value: unknown) => { - savedState.set(key, JSON.parse(JSON.stringify(value))); - return Promise.resolve(); - }); + (extensionContext.workspaceState.get as sinon.SinonStub).callsFake( + (key: string, fallback: unknown) => savedState.get(key) ?? fallback, + ); + (extensionContext.workspaceState.update as sinon.SinonStub).callsFake( + (key: string, value: unknown) => { + savedState.set(key, JSON.parse(JSON.stringify(value))); + return Promise.resolve(); + }, + ); viewerManager = initializeHtmlWidgetViewers(extensionContext, viewerSessions); read = sandbox.stub(fs, 'readFile'); - read.callsFake(file => Promise.resolve( - `
${String(file)}
`)); - sandbox.stub(vscode.window, 'createWebviewPanel').callsFake((_type, title, _column, options) => { - const disposed = new vscode.EventEmitter(); - const viewState = new vscode.EventEmitter(); - const listeners: Array<(message: unknown) => unknown> = []; - let closed = false; - let html = ''; - const webview = { - get html() { return html; }, - set html(value: string) { - html = value; - const generation = Number(/data-generation="(\d+)"/.exec(value)?.[1]); - if (autoLoad && generation) { - queueMicrotask(() => { void receivers.get(panel)?.({ message: 'widget/loaded', generation }); }); - } - }, - options, cspSource: 'webview-test:', - asWebviewUri: (uri: vscode.Uri) => uri, - onDidReceiveMessage: (listener: (message: unknown) => unknown) => { - listeners.push(listener); - return { dispose: () => { listeners.splice(listeners.indexOf(listener), 1); } }; - }, - }; - const panel = { - title, viewType: 'r.htmlViewer', viewColumn: vscode.ViewColumn.Two, - get active() { return activePanel === panel; }, - reveal: sandbox.stub().callsFake((_column: vscode.ViewColumn, preserveFocus: boolean) => { - if (!preserveFocus) { activate(panel); } - }), - get webview() { - assert.strictEqual(closed, false, 'Disposed panels must not be accessed'); - return webview; - }, - onDidDispose: disposed.event, - onDidChangeViewState: viewState.event, - dispose: () => { - if (closed) { return; } - closed = true; disposed.fire(); disposed.dispose(); viewState.dispose(); listeners.length = 0; - }, - } as unknown as vscode.WebviewPanel; - panels.push(panel); - activePanel = panel; - viewEvents.set(panel, viewState); - receivers.set(panel, async message => { await Promise.all(listeners.map(listener => listener(message))); }); - return panel; - }); + read.callsFake((file) => + Promise.resolve(`
${String(file)}
`), + ); + sandbox + .stub(vscode.window, 'createWebviewPanel') + .callsFake((_type, title, _column, options) => { + const disposed = new vscode.EventEmitter(); + const viewState = + new vscode.EventEmitter(); + const listeners: Array<(message: unknown) => unknown> = []; + let closed = false; + let html = ''; + const webview = { + get html() { + return html; + }, + set html(value: string) { + html = value; + const generation = Number(/data-generation="(\d+)"/.exec(value)?.[1]); + if (autoLoad && generation) { + queueMicrotask(() => { + void receivers.get(panel)?.({ + message: 'widget/loaded', + generation, + }); + }); + } + }, + options, + cspSource: 'webview-test:', + asWebviewUri: (uri: vscode.Uri) => uri, + onDidReceiveMessage: (listener: (message: unknown) => unknown) => { + listeners.push(listener); + return { + dispose: () => { + listeners.splice(listeners.indexOf(listener), 1); + }, + }; + }, + }; + const panel = { + title, + viewType: 'r.htmlViewer', + viewColumn: vscode.ViewColumn.Two, + get active() { + return activePanel === panel; + }, + reveal: sandbox + .stub() + .callsFake((_column: vscode.ViewColumn, preserveFocus: boolean) => { + if (!preserveFocus) { + activate(panel); + } + }), + get webview() { + assert.strictEqual(closed, false, 'Disposed panels must not be accessed'); + return webview; + }, + onDidDispose: disposed.event, + onDidChangeViewState: viewState.event, + dispose: () => { + if (closed) { + return; + } + closed = true; + disposed.fire(); + disposed.dispose(); + viewState.dispose(); + listeners.length = 0; + }, + } as unknown as vscode.WebviewPanel; + panels.push(panel); + activePanel = panel; + viewEvents.set(panel, viewState); + receivers.set(panel, async (message) => { + await Promise.all(listeners.map((listener) => listener(message))); + }); + return panel; + }); }); teardown(async () => { - panels.splice(0).forEach(panel => { panel.dispose(); }); + panels.splice(0).forEach((panel) => { + panel.dispose(); + }); await shutdownHtmlWidgetViewers(); receivers.clear(); viewEvents.clear(); - owners.splice(0).forEach(owner => session.unregisterSessionTransport(owner)); + 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'; + 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') { + function show( + file: string, + source: session.Session, + title = 'Viewer', + viewer: string | boolean = 'Two', + ) { return showWebView(file, title, viewer, session.getViewerSessionContext(source.sessionId)); } @@ -146,50 +228,74 @@ suite('Session-aware HTML widget Viewer', () => { assert.strictEqual(panel.title, 'HTML Viewer'); const reference = panelReference(panel); const states = savedState.get('r.htmlViewer.panels') as HtmlViewerPanelState[]; - const state = states.find(state => state.id === reference.id)!; + const state = states.find((state) => state.id === reference.id)!; const item = state.history[state.index]; - assert.ok(panel.webview.html.includes(`
${item.file}
`), 'The retained title must belong to the rendered output'); + assert.ok( + panel.webview.html.includes(`
${item.file}
`), + 'The retained title must belong to the rendered output', + ); return item.title; } function widgetDocument(panel: vscode.WebviewPanel): string { - assert.ok(!panel.webview.html.includes('widget-frame'), 'Outputs must render directly without the private iframe'); + assert.ok( + !panel.webview.html.includes('widget-frame'), + 'Outputs must render directly without the private iframe', + ); return panel.webview.html; } function activate(panel?: vscode.WebviewPanel): void { activePanel = panel; - for (const [viewer, emitter] of viewEvents) { emitter.fire({ webviewPanel: viewer }); } + for (const [viewer, emitter] of viewEvents) { + emitter.fire({ webviewPanel: viewer }); + } } function position(panel: vscode.WebviewPanel): string { - const entry = [...viewerManager.viewers.values()].find(entry => entry.panel === panel)!; + 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, '"'); + 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 { + 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; + 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) : ''; + 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 { + 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 }); + await receivers.get(panel)!({ + message: 'widget/navigate', + direction, + generation: oldGeneration, + }); } else { activate(panel); await runHtmlViewerCommand(direction); @@ -229,12 +335,17 @@ suite('Session-aware HTML widget Viewer', () => { 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, + get: (key: string) => + key === 'session.viewers.viewColumn' && viewer ? { viewer } : undefined, } as vscode.WorkspaceConfiguration); viewerManager = initializeHtmlWidgetViewers(extensionContext, viewerSessions); await restoreHtmlViewer(source.sessionId); - const options = (vscode.window.createWebviewPanel as sinon.SinonStub).lastCall.args[2] as { viewColumn: vscode.ViewColumn }; - assert.strictEqual(options.viewColumn, viewer ? vscode.ViewColumn.Three : vscode.ViewColumn.Two); + 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, + ); } }); @@ -250,8 +361,15 @@ 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.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( + panels[0].webview.options.localResourceRoots?.some( + (root) => root.fsPath === vscode.Uri.file('/tmp/widget-c').fsPath, + ), + ); + assert.deepStrictEqual((panels[0].reveal as sinon.SinonStub).lastCall.args, [ + vscode.ViewColumn.Two, + true, + ]); assert.ok((await info(panels[0])).includes(`R 4.6.1: ${first.pid}`)); assert.ok((await info(panels[1])).includes(`R 4.6.1: ${second.pid}`)); assert.ok(!widgetDocument(panels[0]).includes('widget-toolbar')); @@ -270,7 +388,11 @@ suite('Session-aware HTML widget Viewer', () => { await pending; assert.strictEqual(panels[0].webview.html, latest); assert.strictEqual(outputTitle(panels[0]), 'Latest'); - assert.ok(panels[0].webview.options.localResourceRoots?.some(root => root.fsPath === vscode.Uri.file('/tmp/latest').fsPath)); + 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 () => { @@ -319,7 +441,10 @@ suite('Session-aware HTML widget Viewer', () => { 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']); + 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. @@ -375,7 +500,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')); + assert.ok(panels.every((panel) => panel.title === 'HTML Viewer')); }); test('Back and Forward browse only the owning session with correct boundary states', async () => { @@ -390,18 +515,30 @@ suite('Session-aware HTML widget Viewer', () => { 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)); + 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( + panel.webview.options.localResourceRoots, + roots, + 'Back must not change permissions or reload the previous document', + ); assert.strictEqual(outputTitle(panel), 'A'); assert.ok(widgetDocument(panel).includes('/tmp/a/index.html')); - assert.ok(panel.webview.options.localResourceRoots?.some(root => root.fsPath === vscode.Uri.file('/tmp/a').fsPath)); + assert.ok( + 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( + 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); @@ -429,11 +566,15 @@ suite('Session-aware HTML widget Viewer', () => { 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.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)); + 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 () => { @@ -454,7 +595,12 @@ suite('Session-aware HTML widget Viewer', () => { 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']); + 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 () => { @@ -481,7 +627,9 @@ suite('Session-aware HTML widget Viewer', () => { assert.strictEqual(panels[0].title, 'HTML Viewer'); assert.ok(widgetDocument(panels[0]).includes('No HTML outputs in this session')); assert.strictEqual(position(panels[0]), '0 / 0'); - assert.ok((['back', 'forward', 'remove'] as const).every(button => disabled(panels[0], button))); + assert.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]); @@ -666,7 +814,8 @@ suite('Session-aware HTML widget Viewer', () => { await show('/tmp/a.html', source, 'A'); await runHtmlViewerCommand('info'); source.processExited = true; - source.pid = ''; source.rVer = ''; + source.pid = ''; + source.rVer = ''; await reloadPanel(panels[0]); assert.strictEqual(panels[0].title, 'HTML Viewer · R: (not attached)'); assert.ok(panels[0].webview.html.includes('/tmp/a.html')); @@ -689,15 +838,25 @@ suite('Session-aware HTML widget Viewer', () => { activate(panels[0]); assert.strictEqual(panels[0].title, `HTML Viewer · R 4.6.1: ${original.pid}`); assert.ok(panels[0].webview.html.includes('/tmp/old.html')); - assert.strictEqual(viewerManager.histories.entries.get(replacement.sessionId)?.source.pid, replacement.pid); + assert.strictEqual( + viewerManager.histories.entries.get(replacement.sessionId)?.source.pid, + replacement.pid, + ); original.processExited = true; assert.strictEqual(panels[0].title, 'HTML Viewer · R: (not attached)'); - assert.strictEqual(viewerManager.histories.entries.get(replacement.sessionId)?.source.pid, replacement.pid); + assert.strictEqual( + viewerManager.histories.entries.get(replacement.sessionId)?.source.pid, + replacement.pid, + ); }); test('reload ignores output-supplied paths and restores standalone HTML into its original panel', async () => { await showWebView('/tmp/standalone.html', 'Standalone', 'Two'); - const reference = { ...panelReference(panels[0]), history: [{ file: '/tmp/forged.html', title: 'Forged' }], index: 0 }; + const reference = { + ...panelReference(panels[0]), + history: [{ file: '/tmp/forged.html', title: 'Forged' }], + index: 0, + }; await reloadPanel(panels[0], reference); assert.strictEqual(panels.length, 1); assert.ok(panels[0].webview.html.includes('/tmp/standalone.html')); @@ -728,7 +887,8 @@ suite('Session-aware HTML widget Viewer', () => { await shutdownHtmlWidgetViewers(true); viewerManager = initializeHtmlWidgetViewers(extensionContext, viewerSessions); await show('/tmp/b.html', source, 'B'); - const serializer = (vscode.window.registerWebviewPanelSerializer as sinon.SinonStub).lastCall.args[1] as vscode.WebviewPanelSerializer; + const serializer = (vscode.window.registerWebviewPanelSerializer as sinon.SinonStub) + .lastCall.args[1] as vscode.WebviewPanelSerializer; await serializer.deserializeWebviewPanel(original, reference); activate(original); assert.strictEqual(viewerManager.viewers.size, 1); @@ -757,7 +917,9 @@ suite('Session-aware HTML widget Viewer', () => { 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(); }); + 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'); @@ -781,11 +943,16 @@ suite('Session-aware HTML widget Viewer', () => { 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(); }); + 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])); + const pick = sandbox + .stub(vscode.window, 'showQuickPick') + .callsFake((items: unknown) => + Promise.resolve((items as Array)[1]), + ); await restoreHtmlViewer(); sinon.assert.calledOnce(pick); assert.strictEqual(outputTitle(panels[2]), 'Second'); @@ -855,7 +1022,9 @@ suite('Session-aware HTML widget Viewer', () => { test('full widget documents retain scripts, styles, and authored titles without an iframe', async () => { const source = owner('html-history-document'); - read.resolves('Authored & original
Widget
'); + read.resolves( + 'Authored & original
Widget
', + ); await show('/tmp/widget/index.html', source, ''); const document = widgetDocument(panels[0]); assert.ok(document.startsWith(' { read.resolves(original); await showWebView('/tmp/document.html', 'History title', 'Two'); const rendered = panels[0].webview.html; - const headStart = original.indexOf('') + ''.length; + 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(' { @@ -897,33 +1078,67 @@ suite('Session-aware HTML widget Viewer', () => { for (const fixture of [ { href: 'assets/', target: '/tmp/output/assets/', root: '/tmp/output/assets' }, { href: '', target: '/tmp/output/index.html', root: '/tmp/output' }, - { href: vscode.Uri.file(path.resolve('/tmp/output/assets.html')).toString(), target: '/tmp/output/assets.html', root: '/tmp/output' }, - { href: 'data:text/plain,ignored', target: '/tmp/output/index.html', root: '/tmp/output' }, + { + href: vscode.Uri.file(path.resolve('/tmp/output/assets.html')).toString(), + target: '/tmp/output/assets.html', + root: '/tmp/output', + }, + { + href: 'data:text/plain,ignored', + target: '/tmp/output/index.html', + root: '/tmp/output', + }, { href: 'https://[', target: '/tmp/output/index.html', root: '/tmp/output' }, ]) { - read.resolves(`Output`); + read.resolves( + `Output`, + ); await showWebView(file, 'Authored base', 'Two'); const panel = panels[panels.length - 1]; let expected = vscode.Uri.file(path.resolve(fixture.target)).toString(); - if (fixture.target.endsWith('/') && !expected.endsWith('/')) { expected += '/'; } - assert.ok(panel.webview.html.includes(``), fixture.href); + 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('')); assert.strictEqual((panel.webview.html.match(/ uri.fsPath === vscode.Uri.file(path.resolve(fixture.root)).fsPath), fixture.href); + assert.ok( + panel.webview.options.localResourceRoots?.some( + (uri) => uri.fsPath === vscode.Uri.file(path.resolve(fixture.root)).fsPath, + ), + fixture.href, + ); } }); test('authored bases cannot grant access to the filesystem root or unrelated directories', async () => { const file = path.resolve('/tmp/output/index.html'); - for (const href of ['/', '../', '../../', '../output-other/', '../shared%20assets/', - vscode.Uri.file(path.resolve('/tmp/external/')).toString() + '/']) { + 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); + 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, + ); } }); @@ -931,14 +1146,26 @@ suite('Session-aware HTML widget Viewer', () => { workspaceRoots = ['/tmp']; for (const trusted of [false, true]) { trustedWorkspace = trusted; - read.resolves('Output'); + 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); + 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'); + await showWebView( + path.resolve('/tmp/output/index.html'), + 'Outside trusted workspace', + 'Two', + ); assert.strictEqual(panels[panels.length - 1].webview.options.localResourceRoots?.length, 2); }); @@ -948,32 +1175,54 @@ suite('Session-aware HTML widget Viewer', () => { 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('Output'); + sandbox + .stub(panels[0].webview, 'asWebviewUri') + .callsFake((uri) => uri.with({ scheme: 'https', authority: 'widget-local.test' })); + read.resolves( + 'Output', + ); await show(file, source); - const expected = vscode.Uri.file(path.resolve('/tmp/shared assets')).with({ scheme: 'https', authority: 'widget-local.test' }).toString() + '/'; + 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(``)); - assert.ok(panels[0].webview.options.localResourceRoots?.some(uri => uri.scheme === 'file' && uri.fsPath === vscode.Uri.file(path.resolve('/tmp/shared assets')).fsPath)); + 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'); + 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)); + 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)); + 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)); + 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 = ""; + const original = + ""; read.resolves(`${original}Output`); await showWebView('/tmp/output/index.html', 'Remote base', 'Two'); assert.ok(panels[0].webview.html.includes(original)); @@ -1008,7 +1257,7 @@ suite('Session-aware HTML widget Viewer', () => { assert.strictEqual(contexts.get('r.htmlViewer.canGoBack'), false); assert.strictEqual(contexts.get('r.htmlViewer.canGoForward'), true); activate(); - assert.ok([...contexts.values()].every(value => value === false)); + assert.ok([...contexts.values()].every((value) => value === false)); await runHtmlViewerCommand('remove'); assert.strictEqual(position(panels[0]), '1 / 2'); }); @@ -1019,11 +1268,15 @@ suite('Session-aware HTML widget Viewer', () => { await show('/tmp/b.html', source, 'B'); const slow = deferred(); autoLoad = false; - const previousGeneration = Number(/data-generation="(\d+)"/.exec(panels[0].webview.html)?.[1]); + const previousGeneration = Number( + /data-generation="(\d+)"/.exec(panels[0].webview.html)?.[1], + ); read.onCall(2).returns(slow.promise); activate(panels[0]); const pending = runHtmlViewerCommand('back'); - assert.ok((['back', 'forward', 'remove'] as const).every(button => disabled(panels[0], button))); + assert.ok( + (['back', 'forward', 'remove'] as const).every((button) => disabled(panels[0], button)), + ); assert.strictEqual(contexts.get('r.htmlViewer.canShowInfo'), true); await runHtmlViewerCommand('remove'); await runHtmlViewerCommand('forward'); @@ -1031,12 +1284,25 @@ 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'); + 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'); + 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); @@ -1047,22 +1313,37 @@ suite('Session-aware HTML widget Viewer', () => { const source = owner('html-load-timeout'); await show('/tmp/a.html', source, 'A'); autoLoad = false; - read.onCall(1).resolves('Blocked acknowledgement'); + read.onCall(1).resolves( + 'Blocked acknowledgement', + ); await show('/tmp/b.html', source, 'B'); const panel = panels[0]; const html = panel.webview.html; assert.strictEqual(loadingStatus.text, '$(loading~spin) HTML Viewer: Loading HTML output…'); clock.tick(4999); - assert.ok((['back', 'forward', 'remove'] as const).every(button => disabled(panel, button))); + assert.ok( + (['back', 'forward', 'remove'] as const).every((button) => disabled(panel, button)), + ); clock.tick(1); assert.strictEqual(disabled(panel, 'back'), false); assert.strictEqual(disabled(panel, 'remove'), false); assert.strictEqual(panel.webview.html, html, 'Timeout must preserve the rendered content'); - assert.strictEqual(loadingStatus.text, '$(loading~spin) HTML Viewer: Still loading. Please wait.'); - assert.strictEqual(statusVisible, true, 'Timeout releases navigation without claiming loading completed'); + assert.strictEqual( + loadingStatus.text, + '$(loading~spin) HTML Viewer: Still loading. Please wait.', + ); + assert.strictEqual( + statusVisible, + true, + 'Timeout releases navigation without claiming loading completed', + ); const generation = Number(/data-generation="(\d+)"/.exec(html)?.[1]); await receivers.get(panel)!({ message: 'widget/loaded', generation }); - assert.strictEqual(statusVisible, false, 'Readiness after the timeout must still dismiss progress'); + assert.strictEqual( + statusVisible, + false, + 'Readiness after the timeout must still dismiss progress', + ); assert.strictEqual(clock.countTimers(), 0); await navigate(panel, 'back'); clock.tick(5000); @@ -1081,16 +1362,25 @@ suite('Session-aware HTML widget Viewer', () => { const panel = panels[0]; const html = panel.webview.html; clock.tick(9999); - assert.strictEqual(loadingStatus.text, '$(loading~spin) HTML Viewer: Still loading. Please wait.'); + 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.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.'); + 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); @@ -1136,9 +1426,20 @@ suite('Session-aware HTML widget Viewer', () => { const pending = show('/tmp/b.html', source, 'B'); clock.tick(preparationTime); assert.strictEqual(panels[0].webview.html, previous); - assert.ok((['back', 'forward', 'remove'] as const).every(button => disabled(panels[0], button))); - assert.strictEqual(loadingStatus.text, '$(loading~spin) HTML Viewer: Loading HTML output…'); - assert.strictEqual(clock.countTimers(), 0, 'Preparation must not start the browser timeout'); + assert.ok( + (['back', 'forward', 'remove'] as const).every((button) => + disabled(panels[0], button), + ), + ); + assert.strictEqual( + loadingStatus.text, + '$(loading~spin) HTML Viewer: Loading HTML output…', + ); + assert.strictEqual( + clock.countTimers(), + 0, + 'Preparation must not start the browser timeout', + ); await navigate(panels[0], 'back'); await remove(panels[0]); assert.strictEqual(position(panels[0]), '2 / 2'); @@ -1146,10 +1447,17 @@ suite('Session-aware HTML widget Viewer', () => { await pending; assert.strictEqual(outputTitle(panels[0]), 'B'); clock.tick(4999); - assert.ok((['back', 'forward', 'remove'] as const).every(button => disabled(panels[0], button))); + assert.ok( + (['back', 'forward', 'remove'] as const).every((button) => + disabled(panels[0], button), + ), + ); clock.tick(1); assert.strictEqual(disabled(panels[0], 'back'), false); - assert.strictEqual(loadingStatus.text, '$(loading~spin) HTML Viewer: Still loading. Please wait.'); + 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'); @@ -1190,7 +1498,10 @@ suite('Session-aware HTML widget Viewer', () => { 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/); + await assert.rejects( + showWebView('/tmp/unowned.html', 'Unowned', 'Two'), + /unowned file failure/, + ); assert.strictEqual(statusVisible, false); assert.strictEqual(clock.countTimers(), 0); }); @@ -1215,10 +1526,17 @@ suite('Session-aware HTML widget Viewer', () => { 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]); + 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 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]); @@ -1270,7 +1588,7 @@ suite('Session-aware HTML widget Viewer', () => { test('unowned output disables history and Info actions without changing its title', async () => { await showWebView('/tmp/unowned.html', 'Unowned', 'Two'); activate(panels[0]); - assert.ok([...contexts.values()].every(value => value === false)); + assert.ok([...contexts.values()].every((value) => value === false)); assert.strictEqual(await info(panels[0]), ''); sinon.assert.notCalled(information); const html = panels[0].webview.html; @@ -1279,32 +1597,70 @@ suite('Session-aware HTML widget Viewer', () => { }); test('native toolbar contributes four HTML-specific commands with distinct icons and ordering', () => { - const manifest = JSON.parse(readFileSync(path.resolve(__dirname, '../../../package.json'), 'utf8')) as { + const manifest = JSON.parse( + readFileSync(path.resolve(__dirname, '../../../package.json'), 'utf8'), + ) as { contributes: { menus: { 'editor/title': Array<{ command?: string; when: string; group: string }> }; commands: Array<{ command: string; title: string; icon: string }>; }; }; - const entries = manifest.contributes.menus['editor/title'].filter(item => item.command?.startsWith('r.htmlViewer.')); - assert.deepStrictEqual(entries.map(item => item.command), - ['r.htmlViewer.back', 'r.htmlViewer.forward', 'r.htmlViewer.remove', 'r.htmlViewer.info']); - assert.strictEqual(manifest.contributes.commands.find(command => command.command === 'r.htmlViewer.info')?.title, 'Toggle Session Information in Tab Title'); + const entries = manifest.contributes.menus['editor/title'].filter((item) => + item.command?.startsWith('r.htmlViewer.'), + ); + assert.deepStrictEqual( + entries.map((item) => item.command), + [ + 'r.htmlViewer.back', + 'r.htmlViewer.forward', + 'r.htmlViewer.remove', + 'r.htmlViewer.info', + ], + ); + assert.strictEqual( + manifest.contributes.commands.find((command) => command.command === 'r.htmlViewer.info') + ?.title, + 'Toggle Session Information in Tab Title', + ); for (const [index, item] of entries.entries()) { assert.strictEqual(item.when, "activeWebviewPanelId == 'r.htmlViewer'"); - assert.strictEqual(item.group, index < 2 ? `navigation@${index + 1}` : `htmlViewer@${index - 1}`); - const command = manifest.contributes.commands.find(command => command.command === item.command)!; - if (item.command !== 'r.htmlViewer.info') { assert.ok(command.title.includes('HTML')); } - assert.ok(['$(arrow-circle-left)', '$(arrow-circle-right)', '$(trash)', '$(info)'].includes(command.icon)); + assert.strictEqual( + item.group, + index < 2 ? `navigation@${index + 1}` : `htmlViewer@${index - 1}`, + ); + const command = manifest.contributes.commands.find( + (command) => command.command === item.command, + )!; + if (item.command !== 'r.htmlViewer.info') { + assert.ok(command.title.includes('HTML')); + } + assert.ok( + ['$(arrow-circle-left)', '$(arrow-circle-right)', '$(trash)', '$(info)'].includes( + command.icon, + ), + ); } }); test('history is bounded to the latest 50 outputs', async () => { const source = owner('html-history-limit'); - for (let i = 0; i < 51; i++) { await show(`/tmp/widget-${i}/index.html`, source, `Widget ${i}`); } + 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.ok( + !panels[0].webview.options.localResourceRoots?.some( + (root) => root.fsPath === vscode.Uri.file('/tmp/widget-0').fsPath, + ), + ); + assert.strictEqual( + panels[0].webview.options.localResourceRoots?.length, + 51, + 'Only 50 output directories and viewer assets remain', + ); + for (let i = 0; i < 49; i++) { + await navigate(panels[0], 'back'); + } assert.strictEqual(outputTitle(panels[0]), 'Widget 1'); assert.ok(disabled(panels[0], 'back')); }); diff --git a/src/test/suite/index.ts b/src/test/suite/index.ts index 3e757d705..96a823d25 100644 --- a/src/test/suite/index.ts +++ b/src/test/suite/index.ts @@ -1,4 +1,3 @@ - import * as path from 'path'; import Mocha from 'mocha'; import { glob } from 'glob'; @@ -7,16 +6,16 @@ export function run(): Promise { // Create the mocha test const mocha = new Mocha({ ui: 'tdd', - color: true + color: true, }); const testsRoot = __dirname; return new Promise((c, e) => { glob('**/**.test.js', { cwd: testsRoot }) - .then(files => { + .then((files) => { // Add files to the test suite - files.forEach(f => mocha.addFile(path.resolve(testsRoot, f))); + files.forEach((f) => mocha.addFile(path.resolve(testsRoot, f))); try { // Run the mocha test @@ -31,7 +30,7 @@ export function run(): Promise { e(err); } }) - .catch(err => { + .catch((err) => { return e(err); }); }); diff --git a/src/test/suite/inlineTable.test.ts b/src/test/suite/inlineTable.test.ts index e68bd1b66..fe9bec7ca 100644 --- a/src/test/suite/inlineTable.test.ts +++ b/src/test/suite/inlineTable.test.ts @@ -8,16 +8,30 @@ suite('Inline table queries', () => { { field: '1', headerName: 'id', type: 'bigintColumn' }, { field: '2', headerName: 'name', type: 'textColumn' }, ]; - const data = { viewId: 'snapshot', fullViewId: 'full', totalRows: 1000, sourceRows: 832976871, columns }; + const data = { + viewId: 'snapshot', + fullViewId: 'full', + totalRows: 1000, + sourceRows: 832976871, + columns, + }; const schema = tableSchema(columns); type Request = { method: string; params: Record }; - function backend(total = data.sourceRows, currentColumns = columns): { calls: Request[]; inspect: (request: Request) => Promise> } { + function backend( + total = data.sourceRows, + currentColumns = columns, + ): { calls: Request[]; inspect: (request: Request) => Promise> } { const calls: Request[] = []; - return { calls, inspect: request => { - calls.push(request); - if (request.method === 'dataview_init') { return Promise.resolve({ columns: currentColumns, totalRows: total }); } - return Promise.resolve({ rows: [], totalRows: total }); - } }; + return { + calls, + inspect: (request) => { + calls.push(request); + if (request.method === 'dataview_init') { + return Promise.resolve({ columns: currentColumns, totalRows: total }); + } + return Promise.resolve({ rows: [], totalRows: total }); + }, + }; } test('retains snapshot pages until a request crosses its boundary', async () => { const rpc = backend(); @@ -31,23 +45,42 @@ suite('Inline table queries', () => { }); test('last-page and wider requests remain bounded without the old snapshot clamp', async () => { const rpc = backend(); - const result = await queryTablePage(data, { start: 832976860, size: 100, live: true }, rpc.inspect); + const result = await queryTablePage( + data, + { start: 832976860, size: 100, live: true }, + rpc.inspect, + ); assert.strictEqual(result.startRow, 832976800); assert.strictEqual(rpc.calls[0].params.endRow, 832976900); assert.deepStrictEqual(tablePage(53, 52, 50), { start: 50, end: 53 }); }); test('sorting and filtering a large result uses the full handle and exact integer64 text', async () => { const rpc = backend(); - const result = await queryTablePage(data, { start: 0, refresh: true, schema, - sortModel: [{ colId: '1', sort: 'desc' }], filterModel: { '1': { type: 'equals', filter: '9007199254740993' } } }, rpc.inspect); + const result = await queryTablePage( + data, + { + start: 0, + refresh: true, + schema, + sortModel: [{ colId: '1', sort: 'desc' }], + filterModel: { '1': { type: 'equals', filter: '9007199254740993' } }, + }, + rpc.inspect, + ); assert.strictEqual(result.live, true); assert.strictEqual(rpc.calls[1].params.view_id, 'full'); - assert.deepStrictEqual(rpc.calls[1].params.filterModel, { '1': { type: 'equals', filter: '9007199254740993' } }); + assert.deepStrictEqual(rpc.calls[1].params.filterModel, { + '1': { type: 'equals', filter: '9007199254740993' }, + }); assert.strictEqual(result.queryReset, false); }); test('reuses R query caches on navigation instead of reinitializing each page', async () => { const rpc = backend(); - await queryTablePage(data, { start: 60, live: true, sortModel: [{ colId: '1', sort: 'asc' }] }, rpc.inspect); + await queryTablePage( + data, + { start: 60, live: true, sortModel: [{ colId: '1', sort: 'asc' }] }, + rpc.inspect, + ); assert.strictEqual(rpc.calls.length, 1); assert.strictEqual(rpc.calls[0].method, 'dataview_page'); }); @@ -61,28 +94,43 @@ suite('Inline table queries', () => { }); test('clears positional queries when a refreshed schema has changed', async () => { const rpc = backend(2000, [columns[0], columns[2], columns[1]]); - const result = await queryTablePage(data, { start: 0, refresh: true, schema, sortModel: [{ colId: '1', sort: 'asc' }] }, rpc.inspect); + const result = await queryTablePage( + data, + { start: 0, refresh: true, schema, sortModel: [{ colId: '1', sort: 'asc' }] }, + rpc.inspect, + ); assert.strictEqual(result.queryReset, true); assert.deepStrictEqual(rpc.calls[1].params.sortModel, []); assert.deepStrictEqual(result.columns, [columns[0], columns[2], columns[1]]); }); test('row-name representation changes do not clear a valid full-data query', async () => { const rpc = backend(2000, [{ ...columns[0], type: 'numericColumn' }, ...columns.slice(1)]); - const result = await queryTablePage(data, { start: 0, refresh: true, schema, sortModel: [{ colId: '1', sort: 'asc' }] }, rpc.inspect); + const result = await queryTablePage( + data, + { start: 0, refresh: true, schema, sortModel: [{ colId: '1', sort: 'asc' }] }, + rpc.inspect, + ); assert.strictEqual(result.queryReset, false); assert.deepStrictEqual(rpc.calls[1].params.sortModel, [{ colId: '1', sort: 'asc' }]); }); test('legacy and small outputs use their owned snapshot handle', async () => { const rpc = backend(50); - const result = await queryTablePage({ ...data, fullViewId: undefined }, { start: 20, live: true, viewId: 'foreign', - sortModel: [{ colId: '2', sort: 'asc' }] }, rpc.inspect); + const result = await queryTablePage( + { ...data, fullViewId: undefined }, + { start: 20, live: true, viewId: 'foreign', sortModel: [{ colId: '2', sort: 'asc' }] }, + rpc.inspect, + ); assert.strictEqual(result.live, false); assert.strictEqual(rpc.calls[0].params.view_id, 'snapshot'); }); test('rejects oversized, invalid and unsupported renderer queries before R work', async () => { for (const query of [ - { size: 10000000 }, { start: -1 }, { start: Infinity }, { start: 0.5 }, - { sortModel: [{ colId: '0', sort: 'asc' }] }, { sortModel: [{ colId: '1', sort: 'code()' }] }, + { size: 10000000 }, + { start: -1 }, + { start: Infinity }, + { start: 0.5 }, + { sortModel: [{ colId: '0', sort: 'asc' }] }, + { sortModel: [{ colId: '1', sort: 'code()' }] }, { filterModel: { '1': { type: 'contains', filter: '1' } } }, { filterModel: { '2': { type: 'contains', filter: 'x'.repeat(1001) } } }, ]) { diff --git a/src/test/suite/interactiveEditor.test.ts b/src/test/suite/interactiveEditor.test.ts index 7c95ba973..915d8e96d 100644 --- a/src/test/suite/interactiveEditor.test.ts +++ b/src/test/suite/interactiveEditor.test.ts @@ -19,10 +19,16 @@ import { shellQuote } from '../../interactive/launcher'; import type { InteractiveManager } from '../../interactive/manager'; import type { GlobalEnvItem, WorkspaceDataProvider } from '../../workspaceViewer'; import type { WorkspaceData } from '../../session'; -import { LanguageClient, State, StreamMessageReader, StreamMessageWriter, createMessageConnection } 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 ?? ''; + return typeof item.tooltip === 'string' ? item.tooltip : (item.tooltip?.value ?? ''); } (process.platform === 'win32' ? suite.skip : suite)('Interactive VS Code integration', function () { @@ -39,24 +45,39 @@ function treeTooltip(item: vscode.TreeItem): string { let originalTabs: Set; let originalLanguageClients: Set; suiteSetup(async () => { - originalTabs = new Set(vscode.window.tabGroups.all.flatMap(group => group.tabs)); + 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; process.env.TMPDIR = fs.realpathSync(os.tmpdir()); root = fs.mkdtempSync(path.join(os.tmpdir(), 'r-interactive-editor-')); - previousStorage = vscode.workspace.getConfiguration('r').inspect('interactive.storagePath')?.globalValue; - await vscode.workspace.getConfiguration('r').update('interactive.storagePath', root, vscode.ConfigurationTarget.Global); + previousStorage = vscode.workspace + .getConfiguration('r') + .inspect('interactive.storagePath')?.globalValue; + await vscode.workspace + .getConfiguration('r') + .update('interactive.storagePath', root, vscode.ConfigurationTarget.Global); // Reproduce the user profile setting that makes lintr read virtual cell paths. - fs.writeFileSync(path.join(root, '.Rprofile'), 'options(languageserver.lint_cache = TRUE)\n'); + fs.writeFileSync( + path.join(root, '.Rprofile'), + 'options(languageserver.lint_cache = TRUE)\n', + ); previousRProfile = process.env.R_PROFILE_USER; process.env.R_PROFILE_USER = path.join(root, '.Rprofile'); fs.mkdirSync(path.join(root, 'library')); 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 promisify(execFile)('R', [ + 'CMD', + 'INSTALL', + '--clean', + `--library=${path.join(root, 'library')}`, + path.join(root, 'sess'), + ]); await vscode.extensions.getExtension('REditorSupport.r')?.activate(); - await until(() => [...languageService().clients.values()].some(client => client.isRunning()), - 'Timed out starting the R language server for Interactive language-service integration tests'); + await until( + () => [...languageService().clients.values()].some((client) => client.isRunning()), + 'Timed out starting the R language server for Interactive language-service integration tests', + ); 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. @@ -64,36 +85,68 @@ function treeTooltip(item: vscode.TreeItem): string { previousConnections = context.workspaceState.get('r.interactive.connections'); await context.workspaceState.update('r.interactive.connections', []); recreateManager(context); - agents = []; manifests = []; + agents = []; + manifests = []; for (let i = 0; i < 2; i++) { const id = randomUUID(); - const config: AgentConfig = { id, generation: randomUUID(), label: `Editor test ${i}`, - directory: root, storage: path.join(root, id), library: path.join(root, 'library'), - rPath: 'R', resources: path.join(process.cwd(), 'R'), provider: 'arf', arfPath: process.env.ARF_PATH ?? 'arf', supervision: 'detached', - plotBackend: 'auto', historyLimit: 50, maxOutputBytes: 1048576, maxJournalBytes: 16777216 }; + const config: AgentConfig = { + id, + generation: randomUUID(), + label: `Editor test ${i}`, + directory: root, + storage: path.join(root, id), + library: path.join(root, 'library'), + rPath: 'R', + resources: path.join(process.cwd(), 'R'), + provider: 'arf', + arfPath: process.env.ARF_PATH ?? 'arf', + supervision: 'detached', + plotBackend: 'auto', + historyLimit: 50, + maxOutputBytes: 1048576, + maxJournalBytes: 16777216, + }; const agent = new SessionAgent(config); - agents.push(agent); manifests.push(await agent.start()); + agents.push(agent); + manifests.push(await agent.start()); fs.writeFileSync(path.join(config.storage, 'config.json'), JSON.stringify(config)); } }); suiteTeardown(async () => { - if (previousTmpdir === undefined) { delete process.env.TMPDIR; } - else { process.env.TMPDIR = previousTmpdir; } - if (previousRProfile === undefined) { delete process.env.R_PROFILE_USER; } - else { process.env.R_PROFILE_USER = previousRProfile; } + if (previousTmpdir === undefined) { + delete process.env.TMPDIR; + } else { + process.env.TMPDIR = previousTmpdir; + } + if (previousRProfile === undefined) { + delete process.env.R_PROFILE_USER; + } else { + process.env.R_PROFILE_USER = previousRProfile; + } await vscode.commands.executeCommand('r.interactive.detach'); - 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()) ?? []); + 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))); + const clients = new Set( + [...service.clients.values()].filter((client) => !originalLanguageClients.has(client)), + ); // Await shutdown before tab-close handlers can start an unobserved stop. await Promise.all([...clients].map(stopLanguageClient)); // Detach retains editors. Close this suite's tabs and await its language // servers before deleting directories that those R processes use as cwd. - await vscode.window.tabGroups.close(vscode.window.tabGroups.all.flatMap(group => group.tabs) - .filter(tab => !originalTabs.has(tab)), true); + await 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)) { @@ -102,9 +155,11 @@ function treeTooltip(item: vscode.TreeItem): string { } } await Promise.all([...clients].map(stopLanguageClient)); - await new Promise(resolve => setTimeout(resolve, 200)); + await new Promise((resolve) => setTimeout(resolve, 200)); fs.rmSync(root, { recursive: true, force: true }); - sourceDirectories.forEach(directory => fs.rmSync(directory, { recursive: true, force: true })); + sourceDirectories.forEach((directory) => + fs.rmSync(directory, { recursive: true, force: true }), + ); }); const until = async ( predicate: () => boolean, @@ -113,51 +168,80 @@ function treeTooltip(item: vscode.TreeItem): string { const deadline = Date.now() + 20000; while (!predicate()) { if (Date.now() > deadline) { - throw new Error(typeof timeoutMessage === 'function' ? timeoutMessage() : timeoutMessage); + throw new Error( + typeof timeoutMessage === 'function' ? timeoutMessage() : timeoutMessage, + ); } - await new Promise(resolve => setTimeout(resolve, 50)); + await new Promise((resolve) => setTimeout(resolve, 50)); } }; function bundleContext(): vscode.ExtensionContext { - return (createRequire(__filename)(path.join(process.cwd(), 'dist/extension')) as { extensionContext: vscode.ExtensionContext }).extensionContext; + return ( + createRequire(__filename)(path.join(process.cwd(), 'dist/extension')) as { + extensionContext: vscode.ExtensionContext; + } + ).extensionContext; } function workspaceProvider(): WorkspaceDataProvider { - return (createRequire(__filename)(path.join(process.cwd(), 'dist/extension')) as { rWorkspace: WorkspaceDataProvider }).rWorkspace; + return ( + createRequire(__filename)(path.join(process.cwd(), 'dist/extension')) as { + rWorkspace: WorkspaceDataProvider; + } + ).rWorkspace; } - function languageService(): { clients: Map; clientUpdates: Map> } { - const service = bundleContext().subscriptions.find(item => - (item as { clients?: unknown }).clients instanceof Map) as ReturnType | undefined; + 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. - const manager = context.subscriptions.find(item => - typeof (item as InteractiveManager).open === 'function' - && typeof (item as InteractiveManager).getChildren === 'function') as InteractiveManager | undefined; + 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 = 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); + const Manager = ( + Object.getPrototypeOf(manager) as { constructor: typeof InteractiveManager } + ).constructor; + manager.dispose(); + context.subscriptions.splice(context.subscriptions.indexOf(manager), 1); context.subscriptions.push(new Manager(context)); } function nodeOverride(name: string): { executable: string; log: string } { - const node = resolveExecutable('node', root); assert.ok(node); - const directory = path.join(root, name); fs.mkdirSync(directory); - const executable = path.join(directory, 'node'), log = path.join(directory, 'launches'); - fs.writeFileSync(executable, `#!/bin/sh\n[ -z "$ELECTRON_RUN_AS_NODE" ] || exit 1\nprintf '%s\\n' "$1" >>${shellQuote(log)}\nexec ${shellQuote(node)} "$@"\n`, { mode: 0o700 }); + const node = resolveExecutable('node', root); + assert.ok(node); + const directory = path.join(root, name); + fs.mkdirSync(directory); + const executable = path.join(directory, 'node'), + log = path.join(directory, 'launches'); + fs.writeFileSync( + executable, + `#!/bin/sh\n[ -z "$ELECTRON_RUN_AS_NODE" ] || exit 1\nprintf '%s\\n' "$1" >>${shellQuote(log)}\nexec ${shellQuote(node)} "$@"\n`, + { mode: 0o700 }, + ); return { executable, log }; } function assertNodeLaunched(log: string): void { const invocations = fs.readFileSync(log, 'utf8').split('\n').filter(Boolean); // Version probes use -p. Detached launches use -e; tmux/systemd launch // the agent file directly. Both must invoke the configured executable. - assert.ok(invocations.some(argument => argument !== '-p'), 'The configured Node must launch the session'); + assert.ok( + invocations.some((argument) => argument !== '-p'), + 'The configured Node must launch the session', + ); } test('missing arf offers setup, with no failed session or runtime installation', async () => { const config = vscode.workspace.getConfiguration('r'); @@ -172,16 +256,27 @@ function treeTooltip(item: vscode.TreeItem): string { commands.withArgs('workbench.action.openSettings', 'r.interactive.arfPath').resolves(); try { await vscode.commands.executeCommand('r.interactive.new'); - const choices = picker.firstCall.args[0] as (vscode.QuickPickItem & { value: string })[]; - assert.deepStrictEqual(choices.map(item => item.value), ['configure']); + const choices = picker.firstCall.args[0] as (vscode.QuickPickItem & { + value: string; + })[]; + assert.deepStrictEqual( + choices.map((item) => item.value), + ['configure'], + ); assert.ok(choices[0].detail?.includes(missing)); picker.resolves(choices[0]); await vscode.commands.executeCommand('r.interactive.new'); - sinon.assert.calledOnce(commands.withArgs('workbench.action.openSettings', 'r.interactive.arfPath')); - sinon.assert.notCalled(input); sinon.assert.notCalled(errors); + sinon.assert.calledOnce( + commands.withArgs('workbench.action.openSettings', 'r.interactive.arfPath'), + ); + sinon.assert.notCalled(input); + sinon.assert.notCalled(errors); assert.deepStrictEqual(fs.readdirSync(root), entries); } finally { - commands.restore(); picker.restore(); input.restore(); errors.restore(); + commands.restore(); + picker.restore(); + input.restore(); + errors.restore(); await config.update('interactive.arfPath', previous, vscode.ConfigurationTarget.Global); } }); @@ -190,35 +285,59 @@ function treeTooltip(item: vscode.TreeItem): string { const previous = config.inspect('interactive.arfPath')?.globalValue; const executable = path.join(root, 'custom arf'); fs.writeFileSync(executable, '#!/bin/sh\nexit 1\n', { mode: 0o700 }); - await config.update('interactive.arfPath', `"${executable}"`, vscode.ConfigurationTarget.Global); + await config.update( + 'interactive.arfPath', + `"${executable}"`, + vscode.ConfigurationTarget.Global, + ); const picker = sinon.stub(vscode.window, 'showQuickPick').resolves(undefined); const input = sinon.stub(vscode.window, 'showInputBox').callsFake(() => { - fs.unlinkSync(executable); return Promise.resolve('Removed arf'); + fs.unlinkSync(executable); + return Promise.resolve('Removed arf'); }); const warning = sinon.stub(vscode.window, 'showWarningMessage').resolves(undefined); const errors = sinon.stub(vscode.window, 'showErrorMessage').resolves(undefined); try { await vscode.commands.executeCommand('r.interactive.new'); - const choices = picker.firstCall.args[0] as (vscode.QuickPickItem & { value: string })[]; - assert.deepStrictEqual(choices.map(item => item.value), ['arf']); - assert.deepStrictEqual(choices.map(item => item.label), ['arf']); - assert.ok(choices.every(item => item.description === undefined)); + const choices = picker.firstCall.args[0] as (vscode.QuickPickItem & { + value: string; + })[]; + assert.deepStrictEqual( + choices.map((item) => item.value), + ['arf'], + ); + assert.deepStrictEqual( + choices.map((item) => item.label), + ['arf'], + ); + assert.ok(choices.every((item) => item.description === undefined)); assert.strictEqual(choices[0].detail, executable); picker.resolves(choices[0]); await vscode.commands.executeCommand('r.interactive.new'); - sinon.assert.calledOnce(warning); sinon.assert.notCalled(errors); + sinon.assert.calledOnce(warning); + sinon.assert.notCalled(errors); assert.match(warning.firstCall.args[0], /Cannot start Headless arf/); - assert.ok(!fs.existsSync(path.join(root, 'runtimes')), 'No runtime or agent should be created'); + assert.ok( + !fs.existsSync(path.join(root, 'runtimes')), + 'No runtime or agent should be created', + ); } finally { - picker.restore(); input.restore(); warning.restore(); errors.restore(); + picker.restore(); + input.restore(); + warning.restore(); + errors.restore(); fs.rmSync(executable, { force: true }); await config.update('interactive.arfPath', previous, vscode.ConfigurationTarget.Global); } }); test('a removed VS Code runtime is reported before installing a runtime or creating a session', async () => { - const executable = sinon.stub(process, 'execPath').value(path.join(root, 'removed-host-runtime')); + const executable = sinon + .stub(process, 'execPath') + .value(path.join(root, 'removed-host-runtime')); const entries = fs.readdirSync(root); - const picker = sinon.stub(vscode.window, 'showQuickPick').resolves({ label: 'arf', value: 'arf' } as vscode.QuickPickItem); + const picker = sinon + .stub(vscode.window, 'showQuickPick') + .resolves({ label: 'arf', value: 'arf' } as vscode.QuickPickItem); const input = sinon.stub(vscode.window, 'showInputBox').resolves('Missing Node'); const errors = sinon.stub(vscode.window, 'showErrorMessage').resolves(undefined); try { @@ -227,7 +346,9 @@ function treeTooltip(item: vscode.TreeItem): string { assert.match(errors.firstCall.args[0], /Reload VS Code.*VS Code Server/); assert.deepStrictEqual(fs.readdirSync(root), entries); } finally { - picker.restore(); input.restore(); errors.restore(); + picker.restore(); + input.restore(); + errors.restore(); executable.restore(); } }); @@ -235,41 +356,69 @@ function treeTooltip(item: vscode.TreeItem): string { const settings = vscode.workspace.getConfiguration('r'); const previous = settings.inspect('interactive.nodePath')?.globalValue; const entries = fs.readdirSync(root); - const picker = sinon.stub(vscode.window, 'showQuickPick').resolves({ label: 'arf', value: 'arf' } as vscode.QuickPickItem); + const picker = sinon + .stub(vscode.window, 'showQuickPick') + .resolves({ label: 'arf', value: 'arf' } as vscode.QuickPickItem); const input = sinon.stub(vscode.window, 'showInputBox').resolves('Missing Node override'); const errors = sinon.stub(vscode.window, 'showErrorMessage').resolves(undefined); try { - await settings.update('interactive.nodePath', path.join(root, 'missing-node'), vscode.ConfigurationTarget.Global); + await settings.update( + 'interactive.nodePath', + path.join(root, 'missing-node'), + vscode.ConfigurationTarget.Global, + ); await vscode.commands.executeCommand('r.interactive.new'); sinon.assert.calledOnce(errors); assert.match(errors.firstCall.args[0], /r\.interactive\.nodePath.*remote server/); assert.deepStrictEqual(fs.readdirSync(root), entries); } finally { - picker.restore(); input.restore(); errors.restore(); - await settings.update('interactive.nodePath', previous, vscode.ConfigurationTarget.Global); + picker.restore(); + input.restore(); + errors.restore(); + await settings.update( + 'interactive.nodePath', + previous, + vscode.ConfigurationTarget.Global, + ); } }); test('missing explicit tmux reports a recovery setting before installing or creating a session', async () => { const config = vscode.workspace.getConfiguration('r'); const previous = config.inspect('interactive.supervision')?.globalValue; const entries = fs.readdirSync(root); - const picker = sinon.stub(vscode.window, 'showQuickPick').resolves({ label: 'arf', value: 'arf' } as vscode.QuickPickItem); + const picker = sinon + .stub(vscode.window, 'showQuickPick') + .resolves({ label: 'arf', value: 'arf' } as vscode.QuickPickItem); const previousPath = process.env.PATH; // Resolve R normally, then simulate tmux disappearing before launch. const input = sinon.stub(vscode.window, 'showInputBox').callsFake(() => { - process.env.PATH = root; return Promise.resolve('Missing tmux'); + process.env.PATH = root; + return Promise.resolve('Missing tmux'); }); const errors = sinon.stub(vscode.window, 'showErrorMessage').resolves(undefined); try { - await config.update('interactive.supervision', 'tmux', vscode.ConfigurationTarget.Global); + await config.update( + 'interactive.supervision', + 'tmux', + vscode.ConfigurationTarget.Global, + ); await vscode.commands.executeCommand('r.interactive.new'); sinon.assert.calledOnce(errors); - assert.match(errors.firstCall.args[0], /tmux.*remote server.*r\.interactive\.supervision/); + assert.match( + errors.firstCall.args[0], + /tmux.*remote server.*r\.interactive\.supervision/, + ); assert.deepStrictEqual(fs.readdirSync(root), entries); } finally { process.env.PATH = previousPath; - picker.restore(); input.restore(); errors.restore(); - await config.update('interactive.supervision', previous, vscode.ConfigurationTarget.Global); + picker.restore(); + input.restore(); + errors.restore(); + await config.update( + 'interactive.supervision', + previous, + vscode.ConfigurationTarget.Global, + ); } }); test('cancels Open Interactive Session from the command palette without opening a notebook or reporting an error', async () => { @@ -281,11 +430,21 @@ function treeTooltip(item: vscode.TreeItem): string { sinon.assert.notCalled(errors); sinon.assert.calledOnce(picker); sinon.assert.notCalled(created); - } finally { picker.restore(); errors.restore(); created.restore(); } + } finally { + picker.restore(); + errors.restore(); + created.restore(); + } }); test('the status-bar picker excludes dead agents without deleting their retained history', async () => { - const stale = { ...manifests[0], id: randomUUID(), label: 'Stale session', endpoint: path.join(root, 'missing', 'control.sock') }; - const storage = path.join(root, stale.id); fs.mkdirSync(storage); + const stale = { + ...manifests[0], + id: randomUUID(), + label: 'Stale session', + endpoint: path.join(root, 'missing', 'control.sock'), + }; + const storage = path.join(root, stale.id); + fs.mkdirSync(storage); const manifest = path.join(storage, 'manifest.json'); fs.writeFileSync(manifest, JSON.stringify(stale)); fs.writeFileSync(path.join(storage, 'retained-history'), 'keep this history'); @@ -294,49 +453,103 @@ function treeTooltip(item: vscode.TreeItem): string { try { await vscode.commands.executeCommand('r.interactive.connect'); sinon.assert.calledOnce(picker); - const choices = await picker.firstCall.args[0] as (vscode.QuickPickItem & { manifest?: SessionManifest })[]; - assert.ok(!choices.some(choice => choice.manifest?.id === stale.id), 'A saved idle status does not make a dead agent connectable'); + const choices = (await picker.firstCall.args[0]) as (vscode.QuickPickItem & { + manifest?: SessionManifest; + })[]; + assert.ok( + !choices.some((choice) => choice.manifest?.id === stale.id), + 'A saved idle status does not make a dead agent connectable', + ); for (const live of manifests) { - const choice = choices.find(choice => choice.manifest?.id === live.id); assert.ok(choice); - assert.ok(choice.description?.includes(`PID ${choice.manifest?.rPid ?? 'starting'}`), 'Identical session names must be distinguishable by PID'); + const choice = choices.find((choice) => choice.manifest?.id === live.id); + assert.ok(choice); + assert.ok( + choice.description?.includes(`PID ${choice.manifest?.rPid ?? 'starting'}`), + 'Identical session names must be distinguishable by PID', + ); } - assert.ok(choices.some(choice => choice.label.includes('New persistent R session'))); - assert.strictEqual(fs.readFileSync(path.join(storage, 'retained-history'), 'utf8'), 'keep this history'); + assert.ok(choices.some((choice) => choice.label.includes('New persistent R session'))); + assert.strictEqual( + fs.readFileSync(path.join(storage, 'retained-history'), 'utf8'), + 'keep this history', + ); assert.ok(fs.existsSync(manifest)); sinon.assert.notCalled(errors); - } finally { picker.restore(); errors.restore(); fs.rmSync(storage, { recursive: true, force: true }); } + } finally { + picker.restore(); + errors.restore(); + fs.rmSync(storage, { recursive: true, force: true }); + } }); test('opens native Interactive without Jupyter and executes through its R kernel', async () => { const created = sinon.spy(vscode.notebooks, 'createNotebookController'); try { await vscode.commands.executeCommand('r.interactive.open', manifests[0]); - const native = created.returnValues.find(value => value.notebookType === 'interactive'); - assert.ok(native); controller = native; - } finally { created.restore(); } - const notebook = vscode.workspace.notebookDocuments.find(doc => doc.metadata.rSessionId === manifests[0].id); + const native = created.returnValues.find( + (value) => value.notebookType === 'interactive', + ); + assert.ok(native); + controller = native; + } finally { + created.restore(); + } + const notebook = vscode.workspace.notebookDocuments.find( + (doc) => doc.metadata.rSessionId === manifests[0].id, + ); assert.ok(notebook, 'Session notebook was opened'); - assert.strictEqual(notebook.notebookType, 'interactive', 'Native Interactive API is available'); + assert.strictEqual( + notebook.notebookType, + 'interactive', + 'Native Interactive API is available', + ); const edit = new vscode.WorkspaceEdit(); - edit.set(notebook.uri, [vscode.NotebookEdit.insertCells(0, [new vscode.NotebookCellData(vscode.NotebookCellKind.Code, - 'editor_value <- 42; cat("editor-stream"); data.frame(x=1:3); plot(1:3)', 'r')])]); + edit.set(notebook.uri, [ + vscode.NotebookEdit.insertCells(0, [ + new vscode.NotebookCellData( + vscode.NotebookCellKind.Code, + 'editor_value <- 42; cat("editor-stream"); data.frame(x=1:3); plot(1:3)', + 'r', + ), + ]), + ]); await vscode.workspace.applyEdit(edit); - await vscode.commands.executeCommand('notebook.cell.execute', { ranges: [{ start: 0, end: 1 }], document: notebook.uri }); - await until(() => notebook.getCells().some(cell => cell.executionSummary?.success === true)); - const items = notebook.cellAt(0).outputs.flatMap(output => output.items); - assert.ok(items.some(item => Buffer.from(item.data).toString().includes('editor-stream'))); - assert.ok(items.some(item => item.mime === 'application/vnd.vscode-r.display+json')); + await vscode.commands.executeCommand('notebook.cell.execute', { + ranges: [{ start: 0, end: 1 }], + document: notebook.uri, + }); + await until(() => + notebook.getCells().some((cell) => cell.executionSummary?.success === true), + ); + const items = notebook.cellAt(0).outputs.flatMap((output) => output.items); + assert.ok( + items.some((item) => Buffer.from(item.data).toString().includes('editor-stream')), + ); + assert.ok(items.some((item) => item.mime === 'application/vnd.vscode-r.display+json')); }); test('shows the error message alongside user calls in native cells and portable exports', async () => { - const notebook = vscode.workspace.notebookDocuments.find(doc => doc.metadata.rSessionId === manifests[0].id); + const notebook = vscode.workspace.notebookDocuments.find( + (doc) => doc.metadata.rSessionId === manifests[0].id, + ); assert.ok(notebook); const index = notebook.cellCount; - const code = 'error_inner <- function(x) sqrt(x); error_wrapper <- function(x) error_inner(x); error_wrapper("bad")'; + const code = + 'error_inner <- function(x) sqrt(x); error_wrapper <- function(x) error_inner(x); error_wrapper("bad")'; await vscode.commands.executeCommand('r.runSelection', code); - await until(() => notebook.cellCount > index && notebook.cellAt(index).executionSummary?.success === false); + await until( + () => + notebook.cellCount > index && + notebook.cellAt(index).executionSummary?.success === false, + ); const cell = notebook.cellAt(index); - const item = cell.outputs.flatMap(output => output.items).find(output => output.mime === 'application/vnd.code.notebook.error'); + const item = cell.outputs + .flatMap((output) => output.items) + .find((output) => output.mime === 'application/vnd.code.notebook.error'); assert.ok(item); - const error = JSON.parse(Buffer.from(item.data).toString()) as { name: string; message: string; stack: string }; + const error = JSON.parse(Buffer.from(item.data).toString()) as { + name: string; + message: string; + stack: string; + }; assert.match(error.message, /non-numeric argument/); assert.ok(!code.includes(error.message), 'The call text alone cannot explain this failure'); assert.ok(error.stack.startsWith(`R error: ${error.message}\n`)); @@ -355,40 +568,81 @@ function treeTooltip(item: vscode.TreeItem): string { await vscode.commands.executeCommand('r.interactive.export', notebook.uri); sinon.assert.notCalled(errors); if (extension === 'rnb') { - const restored = new InteractiveSerializer().deserializeNotebook(fs.readFileSync(file)); - const restoredError: vscode.NotebookCellOutputItem | undefined = restored.cells[index].outputs?.flatMap(output => output.items) - .find(output => output.mime === item.mime); + const restored = new InteractiveSerializer().deserializeNotebook( + fs.readFileSync(file), + ); + const restoredError: vscode.NotebookCellOutputItem | undefined = restored.cells[ + index + ].outputs + ?.flatMap((output) => output.items) + .find((output) => output.mime === item.mime); assert.ok(restoredError); - assert.deepStrictEqual(JSON.parse(Buffer.from(restoredError.data).toString()), error); + assert.deepStrictEqual( + JSON.parse(Buffer.from(restoredError.data).toString()), + error, + ); } else { const exported = JSON.parse(fs.readFileSync(file, 'utf8')) as { - cells: { outputs: { output_type: string; evalue: string; traceback: string[] }[] }[]; + cells: { + outputs: { output_type: string; evalue: string; traceback: string[] }[]; + }[]; }; - const exportedError = exported.cells[index].outputs.find(output => output.output_type === 'error'); + const exportedError = exported.cells[index].outputs.find( + (output) => output.output_type === 'error', + ); assert.ok(exportedError); assert.strictEqual(exportedError.evalue, error.message); assert.strictEqual(exportedError.traceback.join('\n'), error.stack); } } - } finally { format.restore(); save.restore(); errors.restore(); } + } finally { + format.restore(); + save.restore(); + errors.restore(); + } }); test('Open Interactive Session without arguments opens the chosen session even when another is active', async () => { - const picker = sinon.stub(vscode.window, 'showQuickPick').resolves({ label: manifests[1].label, manifest: manifests[1] } as vscode.QuickPickItem); + const picker = sinon.stub(vscode.window, 'showQuickPick').resolves({ + label: manifests[1].label, + manifest: manifests[1], + } as vscode.QuickPickItem); const errors = sinon.stub(vscode.window, 'showErrorMessage').resolves(undefined); try { await vscode.commands.executeCommand('r.interactive.open'); sinon.assert.notCalled(errors); sinon.assert.calledOnce(picker); - const choices = await picker.firstCall.args[0] as (vscode.QuickPickItem & { manifest?: SessionManifest })[]; - for (const manifest of manifests) { assert.ok(choices.some(choice => choice.manifest?.id === manifest.id)); } - assert.ok(vscode.workspace.notebookDocuments.some(document => document.metadata.rSessionId === manifests[1].id)); - await vscode.commands.executeCommand('r.runSelection', 'cat("selected through Open Interactive Session")'); - const second = vscode.workspace.notebookDocuments.find(document => document.metadata.rSessionId === manifests[1].id); + const choices = (await picker.firstCall.args[0]) as (vscode.QuickPickItem & { + manifest?: SessionManifest; + })[]; + for (const manifest of manifests) { + assert.ok(choices.some((choice) => choice.manifest?.id === manifest.id)); + } + assert.ok( + vscode.workspace.notebookDocuments.some( + (document) => document.metadata.rSessionId === manifests[1].id, + ), + ); + await vscode.commands.executeCommand( + 'r.runSelection', + 'cat("selected through Open Interactive Session")', + ); + const second = vscode.workspace.notebookDocuments.find( + (document) => document.metadata.rSessionId === manifests[1].id, + ); assert.ok(second); - await until(() => second.getCells().some(cell => cell.executionSummary?.success === true && - cell.document.getText() === 'cat("selected through Open Interactive Session")')); + await until(() => + second + .getCells() + .some( + (cell) => + cell.executionSummary?.success === true && + cell.document.getText() === + 'cat("selected through Open Interactive Session")', + ), + ); } finally { - picker.restore(); errors.restore(); + picker.restore(); + errors.restore(); await vscode.commands.executeCommand('r.interactive.open', manifests[0]); } }); @@ -400,40 +654,66 @@ function treeTooltip(item: vscode.TreeItem): string { assert.ok(controller.description?.includes(`PID ${manifest.rPid ?? ''}`)); assert.ok(controller.detail?.includes(manifest.directory)); assert.match(controller.description ?? '', /^R \d/); - await new Promise(resolve => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); const refreshed: string[] = []; const update = controller.updateNotebookAffinity.bind(controller); - const affinity = sinon.stub(controller, 'updateNotebookAffinity').callsFake((notebook, value) => { - assert.strictEqual(notebook.metadata.rSessionId, manifest.id); - assert.strictEqual(value, vscode.NotebookControllerAffinity.Preferred); - refreshed.push(controller.label); - update(notebook, value); - }); + const affinity = sinon + .stub(controller, 'updateNotebookAffinity') + .callsFake((notebook, value) => { + assert.strictEqual(notebook.metadata.rSessionId, manifest.id); + assert.strictEqual(value, vscode.NotebookControllerAffinity.Preferred); + refreshed.push(controller.label); + update(notebook, value); + }); const selectionEvents: boolean[] = []; - const listener = controller.onDidChangeSelectedNotebooks(event => selectionEvents.push(event.selected)); + const listener = controller.onDidChangeSelectedNotebooks((event) => + selectionEvents.push(event.selected), + ); try { await vscode.commands.executeCommand('r.runSelection', 'Sys.sleep(0.4)'); - await until(() => refreshed.some(label => label.endsWith(' · busy'))); + await until(() => refreshed.some((label) => label.endsWith(' · busy'))); await until(() => refreshed.at(-1)?.endsWith(' · idle') === true); - assert.deepStrictEqual(selectionEvents, [], 'Presentation refresh must not deselect the execution kernel'); + 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 = interactiveManager(); const status = manager as unknown as { updateStatus(): void }; - status.updateStatus(); status.updateStatus(); - await new Promise(resolve => setImmediate(resolve)); + status.updateStatus(); + status.updateStatus(); + await new Promise((resolve) => setImmediate(resolve)); sinon.assert.notCalled(affinity); - } finally { listener.dispose(); affinity.restore(); } + } finally { + listener.dispose(); + affinity.restore(); + } }); test('refreshes a controller opened during startup when R becomes ready', async () => { const id = randomUUID(); const ready = path.join(root, 'release-startup'); - const arf = resolveExecutable(process.env.ARF_PATH ?? 'arf', root); assert.ok(arf); + const arf = resolveExecutable(process.env.ARF_PATH ?? 'arf', root); + assert.ok(arf); const delayedArf = path.join(root, 'delayed-arf'); - fs.writeFileSync(delayedArf, `#!/bin/sh\nwhile [ ! -f ${shellQuote(ready)} ]; do sleep 0.02; done\nexec ${shellQuote(arf)} "$@"\n`, { mode: 0o700 }); - const config = JSON.parse(fs.readFileSync(path.join(root, manifests[0].id, 'config.json'), 'utf8')) as AgentConfig; - const agent = new SessionAgent({ ...config, id, generation: randomUUID(), label: 'Delayed startup', storage: path.join(root, id), arfPath: delayedArf }); + fs.writeFileSync( + delayedArf, + `#!/bin/sh\nwhile [ ! -f ${shellQuote(ready)} ]; do sleep 0.02; done\nexec ${shellQuote(arf)} "$@"\n`, + { mode: 0o700 }, + ); + const config = JSON.parse( + fs.readFileSync(path.join(root, manifests[0].id, 'config.json'), 'utf8'), + ) as AgentConfig; + const agent = new SessionAgent({ + ...config, + id, + generation: randomUUID(), + label: 'Delayed startup', + storage: path.join(root, id), + arfPath: delayedArf, + }); const created = sinon.spy(vscode.notebooks, 'createNotebookController'); const starting = agent.start(); let affinity: sinon.SinonSpy | undefined; @@ -442,10 +722,12 @@ function treeTooltip(item: vscode.TreeItem): string { await until(() => fs.existsSync(manifestFile)); const manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf8')) as SessionManifest; await vscode.commands.executeCommand('r.interactive.open', manifest); - const native = created.returnValues.find(value => value.notebookType === 'interactive'); + const native = created.returnValues.find( + (value) => value.notebookType === 'interactive', + ); assert.ok(native); assert.match(native.label, / · starting$/); - await new Promise(resolve => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); affinity = sinon.spy(native, 'updateNotebookAffinity'); fs.writeFileSync(ready, 'ready'); await starting; @@ -453,21 +735,33 @@ function treeTooltip(item: vscode.TreeItem): string { assert.match(native.description ?? '', /^R \d.*PID \d/); // Selection must still execute in this same session after the refresh. await vscode.commands.executeCommand('r.runSelection', 'cat("startup refreshed")'); - const notebook = vscode.workspace.notebookDocuments.find(doc => doc.metadata.rSessionId === id); + const notebook = vscode.workspace.notebookDocuments.find( + (doc) => doc.metadata.rSessionId === id, + ); assert.ok(notebook); - await until(() => notebook.getCells().some(cell => cell.executionSummary?.success === true)); + await until(() => + notebook.getCells().some((cell) => cell.executionSummary?.success === true), + ); await vscode.commands.executeCommand('r.interactive.detach', notebook.uri); } finally { fs.writeFileSync(ready, 'ready'); await starting.catch(() => undefined); - affinity?.restore(); created.restore(); - const notebook = vscode.workspace.notebookDocuments.find(doc => doc.metadata.rSessionId === id); + affinity?.restore(); + created.restore(); + const notebook = vscode.workspace.notebookDocuments.find( + (doc) => doc.metadata.rSessionId === id, + ); if (notebook) { await vscode.commands.executeCommand('r.interactive.detach', notebook.uri); // Detached native tabs remain open. Remove this fixture's association // so later tests cannot mistake it for a newly created live session. const edit = new vscode.WorkspaceEdit(); - edit.set(notebook.uri, [vscode.NotebookEdit.updateNotebookMetadata({ rSessionId: null, rGeneration: null })]); + edit.set(notebook.uri, [ + vscode.NotebookEdit.updateNotebookMetadata({ + rSessionId: null, + rGeneration: null, + }), + ]); await vscode.workspace.applyEdit(edit); await until(() => !notebook.metadata.rSessionId); } @@ -481,114 +775,261 @@ function treeTooltip(item: vscode.TreeItem): string { const context = bundleContext(); 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); } - const notebooks = manifests.map(manifest => { - const notebook = vscode.workspace.notebookDocuments.find(doc => doc.metadata.rSessionId === manifest.id); - assert.ok(notebook); return notebook; + const Manager = ( + Object.getPrototypeOf(manager) as { constructor: typeof InteractiveManager } + ).constructor; + for (const manifest of manifests) { + await vscode.commands.executeCommand('r.interactive.open', manifest); + } + const notebooks = manifests.map((manifest) => { + const notebook = vscode.workspace.notebookDocuments.find( + (doc) => doc.metadata.rSessionId === manifest.id, + ); + assert.ok(notebook); + return notebook; }); - const originalUris = notebooks.map(notebook => notebook.uri.toString()); + const originalUris = notebooks.map((notebook) => notebook.uri.toString()); const groupCount = vscode.window.tabGroups.all.length; - const saved = context.workspaceState.get<{ id: string; notebookUri: string }[]>('r.interactive.connections', []); + const saved = context.workspaceState.get<{ id: string; notebookUri: string }[]>( + 'r.interactive.connections', + [], + ); for (let i = 0; i < manifests.length; i++) { - assert.strictEqual(saved.find(item => item.id === manifests[i].id)?.notebookUri, originalUris[i]); + assert.strictEqual( + saved.find((item) => item.id === manifests[i].id)?.notebookUri, + originalUris[i], + ); } await vscode.commands.executeCommand('r.runSelection', 'survives_editor_reload <- 73'); - await until(() => notebooks[1].getCells().some(cell => cell.document.getText() === 'survives_editor_reload <- 73' && cell.executionSummary?.success)); - const inputResult = await vscode.commands.executeCommand<{ inputUri: vscode.Uri }>('interactive.open', { preserveFocus: true }, notebooks[0].uri); + await until(() => + notebooks[1] + .getCells() + .some( + (cell) => + cell.document.getText() === 'survives_editor_reload <- 73' && + cell.executionSummary?.success, + ), + ); + const inputResult = await vscode.commands.executeCommand<{ inputUri: vscode.Uri }>( + 'interactive.open', + { preserveFocus: true }, + notebooks[0].uri, + ); const originalInput = await vscode.workspace.openTextDocument(inputResult.inputUri); // Include invisible assignments and repeated commands: neither output presence // nor a set of source strings is enough to distinguish executions from drafts. - for (const code of ['1:10', 'mtcars', 'reload_list <- list(a = 1, b = rnorm(10))', 'reload_list', - 'reload_model <- lm(mpg ~ wt + hp, data = mtcars)', 'summary(reload_model)', '1:10', 'plot(reload_model)']) { + for (const code of [ + '1:10', + 'mtcars', + 'reload_list <- list(a = 1, b = rnorm(10))', + 'reload_list', + 'reload_model <- lm(mpg ~ wt + hp, data = mtcars)', + 'summary(reload_model)', + '1:10', + 'plot(reload_model)', + ]) { const index = notebooks[0].cellCount; const edit = new vscode.WorkspaceEdit(); - edit.replace(originalInput.uri, new vscode.Range(0, 0, originalInput.lineCount, 0), code); + edit.replace( + originalInput.uri, + new vscode.Range(0, 0, originalInput.lineCount, 0), + code, + ); await vscode.workspace.applyEdit(edit); await vscode.commands.executeCommand('interactive.execute', notebooks[0].uri); - await until(() => notebooks[0].cellCount > index && notebooks[0].cellAt(index).executionSummary?.success === true); + await until( + () => + notebooks[0].cellCount > index && + notebooks[0].cellAt(index).executionSummary?.success === true, + ); } - const editedCell = notebooks[0].getCells().find(cell => cell.document.getText() === 'mtcars'); assert.ok(editedCell); + const editedCell = notebooks[0] + .getCells() + .find((cell) => cell.document.getText() === 'mtcars'); + assert.ok(editedCell); const draft = new vscode.WorkspaceEdit(); - draft.replace(editedCell.document.uri, new vscode.Range(0, 0, editedCell.document.lineCount, 0), 'mtcars # edited after execution'); + draft.replace( + editedCell.document.uri, + new vscode.Range(0, 0, editedCell.document.lineCount, 0), + 'mtcars # edited after execution', + ); const middleIndex = editedCell.index + 1; draft.set(notebooks[0].uri, [ vscode.NotebookEdit.insertCells(middleIndex, [ new vscode.NotebookCellData(vscode.NotebookCellKind.Code, '1:10', 'r'), - new vscode.NotebookCellData(vscode.NotebookCellKind.Code, '# unfinished middle draft', 'r'), + new vscode.NotebookCellData( + vscode.NotebookCellKind.Code, + '# unfinished middle draft', + 'r', + ), ]), vscode.NotebookEdit.insertCells(notebooks[0].cellCount, [ - new vscode.NotebookCellData(vscode.NotebookCellKind.Code, '# unfinished notebook draft', 'r'), + new vscode.NotebookCellData( + vscode.NotebookCellKind.Code, + '# unfinished notebook draft', + 'r', + ), ]), ]); - draft.replace(originalInput.uri, new vscode.Range(0, 0, originalInput.lineCount, 0), '# unfinished input'); + draft.replace( + originalInput.uri, + new vscode.Range(0, 0, originalInput.lineCount, 0), + '# unfinished input', + ); await vscode.workspace.applyEdit(draft); - const contents = (notebook: vscode.NotebookDocument): unknown => notebook.getCells().map(cell => ({ - kind: cell.kind, value: cell.document.getText(), language: cell.document.languageId, - executionId: cell.metadata.rExecutionId as string | undefined, - summary: { executionOrder: cell.executionSummary?.executionOrder, success: cell.executionSummary?.success }, - outputs: cell.outputs.map(output => output.items.map(item => ({ mime: item.mime, data: Buffer.from(item.data).toString('base64') }))), - })); + const contents = (notebook: vscode.NotebookDocument): unknown => + notebook.getCells().map((cell) => ({ + kind: cell.kind, + value: cell.document.getText(), + language: cell.document.languageId, + executionId: cell.metadata.rExecutionId as string | undefined, + summary: { + executionOrder: cell.executionSummary?.executionOrder, + success: cell.executionSummary?.success, + }, + outputs: cell.outputs.map((output) => + output.items.map((item) => ({ + mime: item.mime, + data: Buffer.from(item.data).toString('base64'), + })), + ), + })); const before = notebooks.map(contents); manager.dispose(); context.subscriptions.splice(context.subscriptions.indexOf(manager), 1); - assert.deepStrictEqual(context.workspaceState.get('r.interactive.connections'), saved, 'Shutdown must retain saved connections'); + assert.deepStrictEqual( + context.workspaceState.get('r.interactive.connections'), + saved, + 'Shutdown must retain saved connections', + ); // Round-trip through VS Code's actual native serializer, which drops // top-level custom metadata (and preserves only IPython metadata). const reset = new vscode.WorkspaceEdit(); for (const notebook of notebooks) { - const data = new vscode.NotebookData(notebook.getCells().map(cell => { - const data = new vscode.NotebookCellData(cell.kind, cell.document.getText(), cell.document.languageId); - data.metadata = cell.metadata; - data.executionSummary = cell.executionSummary; data.outputs = [...cell.outputs]; - return data; - })); + const data = new vscode.NotebookData( + notebook.getCells().map((cell) => { + const data = new vscode.NotebookCellData( + cell.kind, + cell.document.getText(), + cell.document.languageId, + ); + data.metadata = cell.metadata; + data.executionSummary = cell.executionSummary; + data.outputs = [...cell.outputs]; + return data; + }), + ); data.metadata = notebook.metadata; - const ipynb = await vscode.extensions.getExtension<{ exportNotebook(data: vscode.NotebookData): Promise }>('vscode.ipynb')?.activate(); + const ipynb = await vscode.extensions + .getExtension<{ exportNotebook(data: vscode.NotebookData): Promise }>( + 'vscode.ipynb', + ) + ?.activate(); assert.ok(ipynb); const bytes = Buffer.from(await ipynb.exportNotebook(data)); - const restored = await vscode.commands.executeCommand('vscode.executeDataToNotebook', 'interactive', bytes); - assert.ok(restored.cells.every(cell => !cell.metadata?.rExecutionId)); - assert.deepStrictEqual(restored.cells.map(cell => (cell.metadata?.metadata as Record | undefined)?.rExecutionId), - notebook.getCells().map(cell => cell.metadata.rExecutionId as string | undefined), 'Execution IDs must survive the native serializer'); - assert.ok(restored.cells.every(cell => cell.languageId === 'r')); - reset.set(notebook.uri, [vscode.NotebookEdit.replaceCells(new vscode.NotebookRange(0, notebook.cellCount), restored.cells), - vscode.NotebookEdit.updateNotebookMetadata({ ...restored.metadata, rSessionId: null, rGeneration: null })]); + const restored = await vscode.commands.executeCommand( + 'vscode.executeDataToNotebook', + 'interactive', + bytes, + ); + assert.ok(restored.cells.every((cell) => !cell.metadata?.rExecutionId)); + assert.deepStrictEqual( + restored.cells.map( + (cell) => + (cell.metadata?.metadata as Record | undefined) + ?.rExecutionId, + ), + notebook.getCells().map((cell) => cell.metadata.rExecutionId as string | undefined), + 'Execution IDs must survive the native serializer', + ); + assert.ok(restored.cells.every((cell) => cell.languageId === 'r')); + reset.set(notebook.uri, [ + vscode.NotebookEdit.replaceCells( + new vscode.NotebookRange(0, notebook.cellCount), + restored.cells, + ), + vscode.NotebookEdit.updateNotebookMetadata({ + ...restored.metadata, + rSessionId: null, + rGeneration: null, + }), + ]); } await vscode.workspace.applyEdit(reset); - await until(() => notebooks.every(notebook => !notebook.metadata.rSessionId)); + await until(() => notebooks.every((notebook) => !notebook.metadata.rSessionId)); const created = sinon.spy(vscode.notebooks, 'createNotebookController'); const errors = sinon.stub(vscode.window, 'showErrorMessage').resolves(undefined); try { const restoredManager = new Manager(context); context.subscriptions.push(restoredManager); - await until(() => notebooks.every((notebook, i) => notebook.metadata.rSessionId === manifests[i].id)); - await Promise.all(manifests.map(manifest => restoredManager.open(manifest))); - assert.deepStrictEqual(notebooks.map(contents), before, 'Reload must preserve cell order, code, language, execution summaries and outputs'); + await until(() => + notebooks.every((notebook, i) => notebook.metadata.rSessionId === manifests[i].id), + ); + await Promise.all(manifests.map((manifest) => restoredManager.open(manifest))); + assert.deepStrictEqual( + notebooks.map(contents), + before, + 'Reload must preserve cell order, code, language, execution summaries and outputs', + ); for (const index of [middleIndex, middleIndex + 1]) { const cell = notebooks[0].cellAt(index); - assert.strictEqual(cell.metadata.rExecutionId, undefined, 'A middle draft must remain unexecuted'); + assert.strictEqual( + cell.metadata.rExecutionId, + undefined, + 'A middle draft must remain unexecuted', + ); assert.deepStrictEqual(cell.outputs, []); } assert.strictEqual(notebooks[0].cellAt(middleIndex).document.getText(), '1:10'); - assert.strictEqual(notebooks[0].cellAt(middleIndex + 1).document.getText(), '# unfinished middle draft'); + assert.strictEqual( + notebooks[0].cellAt(middleIndex + 1).document.getText(), + '# unfinished middle draft', + ); assert.strictEqual(originalInput.getText(), '# unfinished input'); - const native = created.returnValues.find(item => item.notebookType === 'interactive' && item.id.includes(manifests[0].id)); - assert.ok(native); controller = native; + const native = created.returnValues.find( + (item) => item.notebookType === 'interactive' && item.id.includes(manifests[0].id), + ); + assert.ok(native); + controller = native; await vscode.commands.executeCommand('r.interactive.open', manifests[1]); // Execute in that native input explicitly: preserved source/input focus // can legitimately remain bound to another session after a background open. - const result = await vscode.commands.executeCommand<{ inputUri: vscode.Uri }>('interactive.open', { preserveFocus: true }, notebooks[1].uri); + const result = await vscode.commands.executeCommand<{ inputUri: vscode.Uri }>( + 'interactive.open', + { preserveFocus: true }, + notebooks[1].uri, + ); const input = await vscode.workspace.openTextDocument(result.inputUri); const code = new vscode.WorkspaceEdit(); - code.replace(input.uri, new vscode.Range(0, 0, input.lineCount, 0), 'stopifnot(survives_editor_reload == 73)'); + code.replace( + input.uri, + new vscode.Range(0, 0, input.lineCount, 0), + 'stopifnot(survives_editor_reload == 73)', + ); await vscode.workspace.applyEdit(code); await vscode.commands.executeCommand('interactive.execute', notebooks[1].uri); - await until(() => notebooks[1].getCells().some(cell => cell.document.getText() === 'stopifnot(survives_editor_reload == 73)' && cell.executionSummary?.success)); - assert.strictEqual(vscode.window.tabGroups.all.length, groupCount, 'Restoration must reuse existing editor groups'); + await until(() => + notebooks[1] + .getCells() + .some( + (cell) => + cell.document.getText() === 'stopifnot(survives_editor_reload == 73)' && + cell.executionSummary?.success, + ), + ); + assert.strictEqual( + vscode.window.tabGroups.all.length, + groupCount, + 'Restoration must reuse existing editor groups', + ); for (let i = 0; i < manifests.length; i++) { - const matching = vscode.workspace.notebookDocuments.filter(doc => doc.metadata.rSessionId === manifests[i].id); - assert.deepStrictEqual(matching.map(doc => doc.uri.toString()), [originalUris[i]]); + const matching = vscode.workspace.notebookDocuments.filter( + (doc) => doc.metadata.rSessionId === manifests[i].id, + ); + assert.deepStrictEqual( + matching.map((doc) => doc.uri.toString()), + [originalUris[i]], + ); const item = restoredManager.getTreeItem(manifests[i]); assert.match(treeTooltip(item), /Connection: Connected · controlling/); assert.match(treeTooltip(item), /Process supervision: Independent process/); @@ -598,59 +1039,111 @@ function treeTooltip(item: vscode.TreeItem): string { } sinon.assert.notCalled(errors); } finally { - created.restore(); errors.restore(); + created.restore(); + errors.restore(); await vscode.commands.executeCommand('r.interactive.open', manifests[0]); } }); test('refreshes session tree connection and control details after reconnect and Take Control', async () => { const manager = interactiveManager() as InteractiveManager; - const view = (manager as unknown as { views: Map }).views.get(`${manifests[0].id}:${manifests[0].generation}`); + const view = ( + manager as unknown as { views: Map } + ).views.get(`${manifests[0].id}:${manifests[0].generation}`); assert.ok(view); let refreshes = 0; const listener = manager.onDidChangeTreeData(() => refreshes++); const other = new AgentClient(manifests[0]); try { - await other.connect(); await other.request('claim', { force: true }); + await other.connect(); + await other.request('claim', { force: true }); await view.client.connect(); - assert.ok(refreshes > 0, 'Connecting updates the session tree even without a state event'); - assert.match(treeTooltip(manager.getTreeItem(manifests[0])), /Connection: Connected · observing/); + assert.ok( + refreshes > 0, + 'Connecting updates the session tree even without a state event', + ); + assert.match( + treeTooltip(manager.getTreeItem(manifests[0])), + /Connection: Connected · observing/, + ); refreshes = 0; await vscode.commands.executeCommand('r.interactive.takeControl', manifests[0]); assert.ok(refreshes > 0, 'Taking control updates the tree immediately'); - assert.match(treeTooltip(manager.getTreeItem(manifests[0])), /Connection: Connected · controlling/); + assert.match( + treeTooltip(manager.getTreeItem(manifests[0])), + /Connection: Connected · controlling/, + ); view.client.close(); - assert.match(treeTooltip(manager.getTreeItem(manifests[0])), /Connection: Disconnected/); + assert.match( + treeTooltip(manager.getTreeItem(manifests[0])), + /Connection: Disconnected/, + ); } finally { - other.close(); await view.client.connect(); await view.client.subscribe(0); listener.dispose(); + other.close(); + await view.client.connect(); + await view.client.subscribe(0); + listener.dispose(); } }); 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 = interactiveManager() as InteractiveManager; - const view = (manager as unknown as { views: Map }).views.get(`${manifests[0].id}:${manifests[0].generation}`); + 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]); const errors = sinon.stub(vscode.window, 'showErrorMessage').resolves(undefined); try { - await other.connect(); await other.request('claim', { force: true }); + await other.connect(); + await other.request('claim', { force: true }); const before = (await other.snapshot()).executions.length; // Test both a stale local lease and an already-known observer. for (const stale of [true, false]) { view.client.control = stale; const edit = new vscode.WorkspaceEdit(); - edit.set(view.notebook.uri, [vscode.NotebookEdit.insertCells(view.notebook.cellCount, [ - new vscode.NotebookCellData(vscode.NotebookCellKind.Code, 'stop("observer must not execute")', 'r'), - ])]); + edit.set(view.notebook.uri, [ + vscode.NotebookEdit.insertCells(view.notebook.cellCount, [ + new vscode.NotebookCellData( + vscode.NotebookCellKind.Code, + 'stop("observer must not execute")', + 'r', + ), + ]), + ]); await vscode.workspace.applyEdit(edit); const count = errors.callCount; - await view.controller.executeHandler([view.notebook.cellAt(view.notebook.cellCount - 1)], view.notebook, view.controller); + await view.controller.executeHandler( + [view.notebook.cellAt(view.notebook.cellCount - 1)], + view.notebook, + view.controller, + ); assert.strictEqual(errors.callCount, count + 1); assert.match(errors.lastCall.args[0], /observing.*[Tt]ake [Cc]ontrol/); } - const input = await vscode.commands.executeCommand<{ inputUri: vscode.Uri }>('interactive.open', { preserveFocus: false }, view.notebook.uri); + const input = await vscode.commands.executeCommand<{ inputUri: vscode.Uri }>( + 'interactive.open', + { preserveFocus: false }, + view.notebook.uri, + ); const document = await vscode.workspace.openTextDocument(input.inputUri); const edit = new vscode.WorkspaceEdit(); - edit.replace(document.uri, new vscode.Range(document.positionAt(0), document.positionAt(document.getText().length)), 'stop("native observer")'); + edit.replace( + document.uri, + new vscode.Range( + document.positionAt(0), + document.positionAt(document.getText().length), + ), + 'stop("native observer")', + ); await vscode.workspace.applyEdit(edit); const count = errors.callCount; await vscode.commands.executeCommand('interactive.execute', view.notebook.uri); @@ -659,13 +1152,21 @@ function treeTooltip(item: vscode.TreeItem): string { assert.strictEqual(errors.callCount, count + 2); assert.strictEqual((await other.snapshot()).executions.length, before); } finally { - errors.restore(); other.close(); + errors.restore(); + other.close(); await vscode.commands.executeCommand('r.interactive.takeControl', manifests[0]); } }); test('a session that disappears after selection reports a useful recovery message', async () => { - const stale = { ...manifests[0], id: randomUUID(), label: 'Closed session', endpoint: path.join(root, 'missing.sock') }; - const picker = sinon.stub(vscode.window, 'showQuickPick').resolves({ label: stale.label, manifest: stale } as vscode.QuickPickItem); + const stale = { + ...manifests[0], + id: randomUUID(), + label: 'Closed session', + endpoint: path.join(root, 'missing.sock'), + }; + const picker = sinon + .stub(vscode.window, 'showQuickPick') + .resolves({ label: stale.label, manifest: stale } as vscode.QuickPickItem); const errors = sinon.stub(vscode.window, 'showErrorMessage').resolves(undefined); const count = vscode.workspace.notebookDocuments.length; try { @@ -675,61 +1176,119 @@ function treeTooltip(item: vscode.TreeItem): string { assert.match(errors.firstCall.args[0], /Refresh the session list/); assert.ok(!errors.firstCall.args[0].includes('ENOENT')); assert.strictEqual(vscode.workspace.notebookDocuments.length, count); - } finally { picker.restore(); errors.restore(); } + } finally { + picker.restore(); + errors.restore(); + } }); test('lints Interactive cells and clears diagnostics after an edit', async () => { - const notebook = vscode.workspace.notebookDocuments.find(doc => doc.metadata.rSessionId === manifests[0].id); + const notebook = vscode.workspace.notebookDocuments.find( + (doc) => doc.metadata.rSessionId === manifests[0].id, + ); assert.ok(notebook); const edit = new vscode.WorkspaceEdit(); - edit.set(notebook.uri, [vscode.NotebookEdit.insertCells(notebook.cellCount, [ - new vscode.NotebookCellData(vscode.NotebookCellKind.Code, 'diagnostic_value<-1\n', 'r'), - ])]); + edit.set(notebook.uri, [ + vscode.NotebookEdit.insertCells(notebook.cellCount, [ + new vscode.NotebookCellData( + vscode.NotebookCellKind.Code, + 'diagnostic_value<-1\n', + 'r', + ), + ]), + ]); await vscode.workspace.applyEdit(edit); const document = notebook.cellAt(notebook.cellCount - 1).document; await until(() => vscode.languages.getDiagnostics(document.uri).length > 0); const diagnostics = vscode.languages.getDiagnostics(document.uri); - assert.ok(!diagnostics.some(item => item.message.includes('Failed to run diagnostics')), JSON.stringify(diagnostics)); - assert.ok(diagnostics.some(item => item.message.includes('spaces around'))); + assert.ok( + !diagnostics.some((item) => item.message.includes('Failed to run diagnostics')), + JSON.stringify(diagnostics), + ); + assert.ok(diagnostics.some((item) => item.message.includes('spaces around'))); const fix = new vscode.WorkspaceEdit(); - fix.replace(document.uri, new vscode.Range(0, 0, document.lineCount, 0), 'diagnostic_value <- 1\n'); + fix.replace( + document.uri, + new vscode.Range(0, 0, document.lineCount, 0), + 'diagnostic_value <- 1\n', + ); await vscode.workspace.applyEdit(fix); await until(() => vscode.languages.getDiagnostics(document.uri).length === 0); }); test('namespace linters work on virtual cells and diagnostics keep their cell identity', async () => { - const notebook = vscode.workspace.notebookDocuments.find(doc => doc.metadata.rSessionId === manifests[0].id); + const notebook = vscode.workspace.notebookDocuments.find( + (doc) => doc.metadata.rSessionId === manifests[0].id, + ); assert.ok(notebook); const index = notebook.cellCount; const edit = new vscode.WorkspaceEdit(); - edit.set(notebook.uri, [vscode.NotebookEdit.insertCells(index, [ - new vscode.NotebookCellData(vscode.NotebookCellKind.Code, 'base::sum(1:3)\nnamespace_value<-1\n', 'r'), - new vscode.NotebookCellData(vscode.NotebookCellKind.Code, 'base::sum(1:3)\n', 'r'), - ])]); + edit.set(notebook.uri, [ + vscode.NotebookEdit.insertCells(index, [ + new vscode.NotebookCellData( + vscode.NotebookCellKind.Code, + 'base::sum(1:3)\nnamespace_value<-1\n', + 'r', + ), + new vscode.NotebookCellData(vscode.NotebookCellKind.Code, 'base::sum(1:3)\n', 'r'), + ]), + ]); await vscode.workspace.applyEdit(edit); - const bad = notebook.cellAt(index).document, good = notebook.cellAt(index + 1).document; + const bad = notebook.cellAt(index).document, + good = notebook.cellAt(index + 1).document; await until(() => vscode.languages.getDiagnostics(bad.uri).length > 0); const diagnostics = vscode.languages.getDiagnostics(bad.uri); - assert.ok(!diagnostics.some(item => item.message.includes('Failed to run diagnostics')), JSON.stringify(diagnostics)); - assert.ok(diagnostics.some(item => item.message.includes('spaces around') && item.range.start.line === 1)); + assert.ok( + !diagnostics.some((item) => item.message.includes('Failed to run diagnostics')), + JSON.stringify(diagnostics), + ); + assert.ok( + diagnostics.some( + (item) => item.message.includes('spaces around') && item.range.start.line === 1, + ), + ); assert.deepStrictEqual(vscode.languages.getDiagnostics(good.uri), []); const fix = new vscode.WorkspaceEdit(); - fix.replace(bad.uri, new vscode.Range(0, 0, bad.lineCount, 0), 'base::sum(1:3)\nnamespace_value <- 1\n'); + fix.replace( + bad.uri, + new vscode.Range(0, 0, bad.lineCount, 0), + 'base::sum(1:3)\nnamespace_value <- 1\n', + ); await vscode.workspace.applyEdit(fix); await until(() => vscode.languages.getDiagnostics(bad.uri).length === 0); }); test('lints the Interactive input before execution', async () => { - const document = vscode.workspace.textDocuments.find(doc => doc.uri.scheme === 'vscode-interactive-input' && doc.languageId === 'r'); + const document = vscode.workspace.textDocuments.find( + (doc) => doc.uri.scheme === 'vscode-interactive-input' && doc.languageId === 'r', + ); assert.ok(document); const edit = new vscode.WorkspaceEdit(); - edit.replace(document.uri, new vscode.Range(0, 0, document.lineCount, 0), 'input_value<-1\n'); + edit.replace( + document.uri, + new vscode.Range(0, 0, document.lineCount, 0), + 'input_value<-1\n', + ); await vscode.workspace.applyEdit(edit); - await until(() => vscode.languages.getDiagnostics(document.uri).some(item => - item.message.includes('spaces around') || item.message.includes('Failed to run diagnostics'))); + await until(() => + vscode.languages + .getDiagnostics(document.uri) + .some( + (item) => + item.message.includes('spaces around') || + item.message.includes('Failed to run diagnostics'), + ), + ); const diagnostics = vscode.languages.getDiagnostics(document.uri); - assert.ok(!diagnostics.some(item => item.message.includes('Failed to run diagnostics')), JSON.stringify(diagnostics)); - assert.ok(diagnostics.some(item => item.message.includes('spaces around'))); + assert.ok( + !diagnostics.some((item) => item.message.includes('Failed to run diagnostics')), + JSON.stringify(diagnostics), + ); + assert.ok(diagnostics.some((item) => item.message.includes('spaces around'))); const clear = new vscode.WorkspaceEdit(); - clear.replace(document.uri, new vscode.Range(0, 0, document.lineCount, 0), 'input_value <- 1\n'); + clear.replace( + document.uri, + new vscode.Range(0, 0, document.lineCount, 0), + 'input_value <- 1\n', + ); await vscode.workspace.applyEdit(clear); await until(() => vscode.languages.getDiagnostics(document.uri).length === 0); const reset = new vscode.WorkspaceEdit(); @@ -743,19 +1302,37 @@ function treeTooltip(item: vscode.TreeItem): string { try { const manifest = manifests[0]; await vscode.commands.executeCommand('r.interactive.open', manifest); - const notebook = vscode.workspace.notebookDocuments.find(doc => doc.metadata.rSessionId === manifest.id); + const notebook = vscode.workspace.notebookDocuments.find( + (doc) => doc.metadata.rSessionId === manifest.id, + ); assert.ok(notebook); - const opened = await vscode.commands.executeCommand<{ inputUri: vscode.Uri }>('interactive.open', { preserveFocus: false }, notebook.uri); + const opened = await vscode.commands.executeCommand<{ inputUri: vscode.Uri }>( + 'interactive.open', + { preserveFocus: false }, + notebook.uri, + ); await vscode.commands.executeCommand('interactive.input.focus'); - await until(() => vscode.window.activeTextEditor?.document.uri.toString() === opened.inputUri.toString()); + await until( + () => + vscode.window.activeTextEditor?.document.uri.toString() === + opened.inputUri.toString(), + ); const editor = vscode.window.activeTextEditor!; - const document = editor.document, original = document.getText(), originalOptions = editor.options; + const document = editor.document, + original = document.getText(), + originalOptions = editor.options; editor.options = { tabSize: 2, insertSpaces: true }; // This integration case intentionally verifies languageserver; // independent-provider behavior is tested with the built-in LSP off. - await until(() => languageService().clients.get(opened.inputUri.toString())?.isRunning() === true); - assert.ok(languageService().clients.get(opened.inputUri.toString())?.initializeResult?.capabilities.documentOnTypeFormattingProvider, - 'languageserver must advertise on-type formatting'); + await until( + () => + languageService().clients.get(opened.inputUri.toString())?.isRunning() === true, + ); + assert.ok( + languageService().clients.get(opened.inputUri.toString())?.initializeResult + ?.capabilities.documentOnTypeFormattingProvider, + 'languageserver must advertise on-type formatting', + ); try { for (const [text, ch, expected] of [ ['typed_value<-sum(1,2', ')', 'typed_value <- sum(1, 2)'], @@ -764,15 +1341,25 @@ function treeTooltip(item: vscode.TreeItem): string { ['typed_value<-1', '\n', 'typed_value <- 1\n'], ['if(TRUE){', '\n', 'if (TRUE) {\n '], ]) { - await editor.edit(builder => builder.replace(new vscode.Range(0, 0, document.lineCount, 0), text)); + await editor.edit((builder) => + builder.replace(new vscode.Range(0, 0, document.lineCount, 0), text), + ); const end = document.positionAt(document.getText().length); editor.selection = new vscode.Selection(end, end); await vscode.commands.executeCommand('type', { text: ch }); - await until(() => document.getText() === expected, - () => `Expected ${JSON.stringify(expected)}, got ${JSON.stringify(document.getText())}`); + await until( + () => document.getText() === expected, + () => + `Expected ${JSON.stringify(expected)}, got ${JSON.stringify(document.getText())}`, + ); // VS Code sends selection updates after the document change. - await until(() => editor.selection.isEmpty && document.offsetAt(editor.selection.active) === expected.length, - () => `Expected cursor at ${expected.length} after formatting, got ${document.offsetAt(editor.selection.active)}`); + await until( + () => + editor.selection.isEmpty && + document.offsetAt(editor.selection.active) === expected.length, + () => + `Expected cursor at ${expected.length} after formatting, got ${document.offsetAt(editor.selection.active)}`, + ); if (ch === ')') { await vscode.commands.executeCommand('undo'); await until(() => document.getText() === text); @@ -782,126 +1369,250 @@ function treeTooltip(item: vscode.TreeItem): string { ['(1+1)', '(1 + 1)'], ['typed_value<-sum(1,2)', 'typed_value <- sum(1, 2)'], ]) { - await editor.edit(builder => builder.replace(new vscode.Range(0, 0, document.lineCount, 0), '')); + await editor.edit((builder) => + builder.replace(new vscode.Range(0, 0, document.lineCount, 0), ''), + ); editor.selection = new vscode.Selection(0, 0, 0, 0); - for (const ch of text) { await vscode.commands.executeCommand('type', { text: ch }); } - await until(() => document.getText() === expected, - () => `Typing with auto-closing brackets produced ${JSON.stringify(document.getText())}`); - await until(() => editor.selection.isEmpty && document.offsetAt(editor.selection.active) === expected.length, - () => `Expected cursor at ${expected.length} after formatting, got ${document.offsetAt(editor.selection.active)}`); + for (const ch of text) { + await vscode.commands.executeCommand('type', { text: ch }); + } + await until( + () => document.getText() === expected, + () => + `Typing with auto-closing brackets produced ${JSON.stringify(document.getText())}`, + ); + await until( + () => + editor.selection.isEmpty && + document.offsetAt(editor.selection.active) === expected.length, + () => + `Expected cursor at ${expected.length} after formatting, got ${document.offsetAt(editor.selection.active)}`, + ); } } finally { - await editor.edit(builder => builder.replace(new vscode.Range(0, 0, document.lineCount, 0), original)); + await editor.edit((builder) => + builder.replace(new vscode.Range(0, 0, document.lineCount, 0), original), + ); editor.options = originalOptions; } } finally { - await settings.update('formatOnType', previous, vscode.ConfigurationTarget.Global, true); + await settings.update( + 'formatOnType', + previous, + vscode.ConfigurationTarget.Global, + true, + ); await vscode.commands.executeCommand('r.interactive.open', manifests[0]); } }); test('provides live function hover in Interactive inputs as well as source documents', async () => { await vscode.commands.executeCommand('r.interactive.open', manifests[0]); - const notebook = vscode.workspace.notebookDocuments.find(doc => doc.metadata.rSessionId === manifests[0].id); + const notebook = vscode.workspace.notebookDocuments.find( + (doc) => doc.metadata.rSessionId === manifests[0].id, + ); assert.ok(notebook); const index = notebook.cellCount; const edit = new vscode.WorkspaceEdit(); - edit.set(notebook.uri, [vscode.NotebookEdit.insertCells(index, [new vscode.NotebookCellData(vscode.NotebookCellKind.Code, - 'interactive_signature <- function(x, y) x + y', 'r')])]); + edit.set(notebook.uri, [ + vscode.NotebookEdit.insertCells(index, [ + new vscode.NotebookCellData( + vscode.NotebookCellKind.Code, + 'interactive_signature <- function(x, y) x + y', + 'r', + ), + ]), + ]); await vscode.workspace.applyEdit(edit); - await vscode.commands.executeCommand('notebook.cell.execute', { ranges: [{ start: index, end: index + 1 }], document: notebook.uri }); + await vscode.commands.executeCommand('notebook.cell.execute', { + ranges: [{ start: index, end: index + 1 }], + document: notebook.uri, + }); await until(() => notebook.cellAt(index).executionSummary?.success === true); const workspace = workspaceProvider(); const expectedOwner = `${manifests[0].id}:${manifests[0].generation}`; - const state = (): string => JSON.stringify({ - expectedOwner, - actualOwner: workspace.owner?.sessionId, - function: workspace.data?.globalenv.interactive_signature, - }); - await until(() => workspace.owner?.sessionId === expectedOwner, () => `Timed out waiting for Interactive owner: ${state()}`); - await until(() => workspace.owner?.sessionId === expectedOwner - && workspace.data?.globalenv.interactive_signature?.type === 'closure', - () => `Timed out waiting for Interactive workspace snapshot: ${state()}`); - const result = await vscode.commands.executeCommand<{ inputUri: vscode.Uri }>('interactive.open', { preserveFocus: true }, notebook.uri); + const state = (): string => + JSON.stringify({ + expectedOwner, + actualOwner: workspace.owner?.sessionId, + function: workspace.data?.globalenv.interactive_signature, + }); + await until( + () => workspace.owner?.sessionId === expectedOwner, + () => `Timed out waiting for Interactive owner: ${state()}`, + ); + await until( + () => + workspace.owner?.sessionId === expectedOwner && + workspace.data?.globalenv.interactive_signature?.type === 'closure', + () => `Timed out waiting for Interactive workspace snapshot: ${state()}`, + ); + const result = await vscode.commands.executeCommand<{ inputUri: vscode.Uri }>( + 'interactive.open', + { preserveFocus: true }, + notebook.uri, + ); const input = await vscode.workspace.openTextDocument(result.inputUri); const previous = input.getText(); const text = new vscode.WorkspaceEdit(); - text.replace(input.uri, new vscode.Range(0, 0, input.lineCount, 0), 'interactive_signature'); + text.replace( + input.uri, + new vscode.Range(0, 0, input.lineCount, 0), + 'interactive_signature', + ); await vscode.workspace.applyEdit(text); try { - const source = await vscode.workspace.openTextDocument({ language: 'r', content: 'interactive_signature' }); + const source = await vscode.workspace.openTextDocument({ + language: 'r', + content: 'interactive_signature', + }); for (const document of [source, input]) { - const hovers = await vscode.commands.executeCommand('vscode.executeHoverProvider', document.uri, new vscode.Position(0, 3)); - const text = hovers.flatMap(hover => hover.contents.map(content => typeof content === 'string' ? content : content.value)).join('\n'); - assert.ok(text.includes('function (x, y)'), `Missing live function hover in ${document.uri.scheme}: ${text}`); + const hovers = await vscode.commands.executeCommand( + 'vscode.executeHoverProvider', + document.uri, + new vscode.Position(0, 3), + ); + const text = hovers + .flatMap((hover) => + hover.contents.map((content) => + typeof content === 'string' ? content : content.value, + ), + ) + .join('\n'); + assert.ok( + text.includes('function (x, y)'), + `Missing live function hover in ${document.uri.scheme}: ${text}`, + ); } } finally { - const reset = new vscode.WorkspaceEdit(); reset.replace(input.uri, new vscode.Range(0, 0, input.lineCount, 0), previous); + const reset = new vscode.WorkspaceEdit(); + reset.replace(input.uri, new vscode.Range(0, 0, input.lineCount, 0), previous); await vscode.workspace.applyEdit(reset); } }); test('keeps blank Interactive prompts free of diagnostics while still linting nonempty input', async () => { await vscode.commands.executeCommand('r.interactive.open', manifests[0]); - const notebook = vscode.workspace.notebookDocuments.find(doc => doc.metadata.rSessionId === manifests[0].id); + const notebook = vscode.workspace.notebookDocuments.find( + (doc) => doc.metadata.rSessionId === manifests[0].id, + ); assert.ok(notebook); - const result = await vscode.commands.executeCommand<{ inputUri: vscode.Uri }>('interactive.open', { preserveFocus: true }, notebook.uri); + const result = await vscode.commands.executeCommand<{ inputUri: vscode.Uri }>( + 'interactive.open', + { preserveFocus: true }, + notebook.uri, + ); const document = await vscode.workspace.openTextDocument(result.inputUri); const previous = document.getText(); const replace = async (text: string): Promise => { - const edit = new vscode.WorkspaceEdit(); edit.replace(document.uri, new vscode.Range(0, 0, document.lineCount, 0), text); + const edit = new vscode.WorkspaceEdit(); + edit.replace(document.uri, new vscode.Range(0, 0, document.lineCount, 0), text); await vscode.workspace.applyEdit(edit); }; try { // Expression diagnostics also work with lintr versions that filter // file-level whitespace lints for synthetic document paths. await replace('input_value<-2\n'); - await until(() => vscode.languages.getDiagnostics(document.uri).some(item => item.message.includes('spaces around'))); + await until(() => + vscode.languages + .getDiagnostics(document.uri) + .some((item) => item.message.includes('spaces around')), + ); await replace(' \n\t\n'); await until(() => vscode.languages.getDiagnostics(document.uri).length === 0); // Let the server publish its whitespace diagnostics after the immediate clear. - await new Promise(resolve => setTimeout(resolve, 1500)); + await new Promise((resolve) => setTimeout(resolve, 1500)); assert.deepStrictEqual(vscode.languages.getDiagnostics(document.uri), []); await replace('input_value<-1\n'); - await until(() => vscode.languages.getDiagnostics(document.uri).some(item => item.message.includes('spaces around'))); + await until(() => + vscode.languages + .getDiagnostics(document.uri) + .some((item) => item.message.includes('spaces around')), + ); await replace(''); await until(() => vscode.languages.getDiagnostics(document.uri).length === 0); - } finally { await replace(previous); } + } finally { + await replace(previous); + } }); test('uses each input owner for parameter hints and keeps live signatures available while R is busy', async () => { const inputs: vscode.TextDocument[] = []; const previous: string[] = []; const notebooks: vscode.NotebookDocument[] = []; const replace = async (document: vscode.TextDocument, code: string): Promise => { - const edit = new vscode.WorkspaceEdit(); edit.replace(document.uri, new vscode.Range(0, 0, document.lineCount, 0), code); + const edit = new vscode.WorkspaceEdit(); + edit.replace(document.uri, new vscode.Range(0, 0, document.lineCount, 0), code); await vscode.workspace.applyEdit(edit); }; - const execute = async (notebook: vscode.NotebookDocument, code: string, wait = true): Promise => { + const execute = async ( + notebook: vscode.NotebookDocument, + code: string, + wait = true, + ): Promise => { const index = notebook.cellCount; const edit = new vscode.WorkspaceEdit(); - edit.set(notebook.uri, [vscode.NotebookEdit.insertCells(index, [new vscode.NotebookCellData(vscode.NotebookCellKind.Code, code, 'r')])]); + edit.set(notebook.uri, [ + vscode.NotebookEdit.insertCells(index, [ + new vscode.NotebookCellData(vscode.NotebookCellKind.Code, code, 'r'), + ]), + ]); await vscode.workspace.applyEdit(edit); - await vscode.commands.executeCommand('notebook.cell.execute', { ranges: [{ start: index, end: index + 1 }], document: notebook.uri }); - if (wait) { await until(() => notebook.cellAt(index).executionSummary?.success === true); } + await vscode.commands.executeCommand('notebook.cell.execute', { + ranges: [{ start: index, end: index + 1 }], + document: notebook.uri, + }); + if (wait) { + await until(() => notebook.cellAt(index).executionSummary?.success === true); + } }; - const help = async (document: vscode.TextDocument, expected: string, activeParameter: number, endOffset = 0): Promise => { + const help = async ( + document: vscode.TextDocument, + expected: string, + activeParameter: number, + endOffset = 0, + ): Promise => { const deadline = Date.now() + 10000; for (;;) { - const result = await vscode.commands.executeCommand('vscode.executeSignatureHelpProvider', - document.uri, document.positionAt(document.getText().length + endOffset), ','); - if (result?.signatures[0]?.label === expected) { assert.strictEqual(result.activeParameter, activeParameter); return; } - if (Date.now() > deadline) { assert.fail(`Missing ${expected} in ${document.uri.scheme}: ${JSON.stringify(result)}`); } - await new Promise(resolve => setTimeout(resolve, 100)); + const result = await vscode.commands.executeCommand< + vscode.SignatureHelp | undefined + >( + 'vscode.executeSignatureHelpProvider', + document.uri, + document.positionAt(document.getText().length + endOffset), + ',', + ); + if (result?.signatures[0]?.label === expected) { + assert.strictEqual(result.activeParameter, activeParameter); + return; + } + if (Date.now() > deadline) { + assert.fail( + `Missing ${expected} in ${document.uri.scheme}: ${JSON.stringify(result)}`, + ); + } + await new Promise((resolve) => setTimeout(resolve, 100)); } }; try { for (let i = 0; i < 2; i++) { await vscode.commands.executeCommand('r.interactive.open', manifests[i]); - const notebook = vscode.workspace.notebookDocuments.find(doc => doc.metadata.rSessionId === manifests[i].id); - assert.ok(notebook); notebooks.push(notebook); - await execute(notebook, i === 0 ? 'interactive_signature <- function(x, y) x + y' - : 'interactive_signature <- function(alpha, beta, gamma = 3) alpha + beta + gamma'); - const opened = await vscode.commands.executeCommand<{ inputUri: vscode.Uri }>('interactive.open', { preserveFocus: true }, notebook.uri); + const notebook = vscode.workspace.notebookDocuments.find( + (doc) => doc.metadata.rSessionId === manifests[i].id, + ); + assert.ok(notebook); + notebooks.push(notebook); + await execute( + notebook, + i === 0 + ? 'interactive_signature <- function(x, y) x + y' + : 'interactive_signature <- function(alpha, beta, gamma = 3) alpha + beta + gamma', + ); + const opened = await vscode.commands.executeCommand<{ inputUri: vscode.Uri }>( + 'interactive.open', + { preserveFocus: true }, + notebook.uri, + ); const input = await vscode.workspace.openTextDocument(opened.inputUri); - inputs.push(input); previous.push(input.getText()); + inputs.push(input); + previous.push(input.getText()); await replace(input, 'interactive_signature(1, '); } await help(inputs[0], 'interactive_signature(x, y)', 1); @@ -911,13 +1622,31 @@ function treeTooltip(item: vscode.TreeItem): string { await execute(notebooks[0], 'Sys.sleep(1)', false); await until(() => manifests[0].status === 'busy'); await help(inputs[0], 'interactive_signature(x, y)', 1); - const hovers = await vscode.commands.executeCommand('vscode.executeHoverProvider', inputs[0].uri, new vscode.Position(0, 3)); - assert.ok(hovers.some(hover => hover.contents.some(content => typeof content !== 'string' && content.value.includes('function (x, y)')))); + const hovers = await vscode.commands.executeCommand( + 'vscode.executeHoverProvider', + inputs[0].uri, + new vscode.Position(0, 3), + ); + assert.ok( + hovers.some((hover) => + hover.contents.some( + (content) => + typeof content !== 'string' && + content.value.includes('function (x, y)'), + ), + ), + ); await until(() => manifests[0].status === 'idle'); - const source = await vscode.workspace.openTextDocument({ language: 'r', content: 'interactive_signature <- function(local_arg) local_arg\ninteractive_signature()' }); + const source = await vscode.workspace.openTextDocument({ + language: 'r', + content: + 'interactive_signature <- function(local_arg) local_arg\ninteractive_signature()', + }); await help(source, 'interactive_signature(local_arg)', 0, -1); } finally { - for (let i = 0; i < inputs.length; i++) { await replace(inputs[i], previous[i]); } + for (let i = 0; i < inputs.length; i++) { + await replace(inputs[i], previous[i]); + } await vscode.commands.executeCommand('r.interactive.open', manifests[0]); } }); @@ -927,76 +1656,130 @@ function treeTooltip(item: vscode.TreeItem): string { try { const file = path.join(directory, 'diagnostics.R'); fs.writeFileSync(file, 'file_value<-1\n'); - const document = await vscode.languages.setTextDocumentLanguage(await vscode.workspace.openTextDocument(file), 'r'); + const document = await vscode.languages.setTextDocumentLanguage( + await vscode.workspace.openTextDocument(file), + 'r', + ); await until(() => vscode.languages.getDiagnostics(document.uri).length > 0); const diagnostics = vscode.languages.getDiagnostics(document.uri); - assert.ok(!diagnostics.some(item => item.message.includes('Failed to run diagnostics')), JSON.stringify(diagnostics)); - assert.ok(diagnostics.some(item => item.message.includes('spaces around'))); + assert.ok( + !diagnostics.some((item) => item.message.includes('Failed to run diagnostics')), + JSON.stringify(diagnostics), + ); + assert.ok(diagnostics.some((item) => item.message.includes('spaces around'))); const fix = new vscode.WorkspaceEdit(); - fix.replace(document.uri, new vscode.Range(0, 0, document.lineCount, 0), 'file_value <- 1\n'); + fix.replace( + document.uri, + new vscode.Range(0, 0, document.lineCount, 0), + 'file_value <- 1\n', + ); await vscode.workspace.applyEdit(fix); await until(() => vscode.languages.getDiagnostics(document.uri).length === 0); await document.save(); - } finally { sourceDirectories.push(directory); } + } finally { + sourceDirectories.push(directory); + } }); test('starts each Interactive input language server in its owning session directory', async () => { const service = languageService(); try { for (const manifest of manifests) { await vscode.commands.executeCommand('r.interactive.open', manifest); - const notebook = vscode.workspace.notebookDocuments.find(doc => doc.metadata.rSessionId === manifest.id); + const notebook = vscode.workspace.notebookDocuments.find( + (doc) => doc.metadata.rSessionId === manifest.id, + ); assert.ok(notebook); - const result = await vscode.commands.executeCommand<{ inputUri: vscode.Uri }>('interactive.open', { preserveFocus: true }, notebook.uri); + const result = await vscode.commands.executeCommand<{ inputUri: vscode.Uri }>( + 'interactive.open', + { preserveFocus: true }, + notebook.uri, + ); await until(() => { const client = service.clients.get(result.inputUri.toString()); - return client?.isRunning() === true && client.clientOptions.workspaceFolder?.uri.fsPath === manifest.directory; + return ( + client?.isRunning() === true && + client.clientOptions.workspaceFolder?.uri.fsPath === manifest.directory + ); }); } - } finally { await vscode.commands.executeCommand('r.interactive.open', manifests[0]); } + } finally { + await vscode.commands.executeCommand('r.interactive.open', manifests[0]); + } }); test('reuses cell code in the input without executing it or replacing a draft', async () => { - const notebook = vscode.workspace.notebookDocuments.find(doc => doc.metadata.rSessionId === manifests[0].id); + const notebook = vscode.workspace.notebookDocuments.find( + (doc) => doc.metadata.rSessionId === manifests[0].id, + ); assert.ok(notebook); - const input = vscode.workspace.textDocuments.find(doc => doc.uri.scheme === 'vscode-interactive-input' && doc.languageId === 'r'); + const input = vscode.workspace.textDocuments.find( + (doc) => doc.uri.scheme === 'vscode-interactive-input' && doc.languageId === 'r', + ); assert.ok(input); - const draft = new vscode.WorkspaceEdit(); draft.insert(input.uri, new vscode.Position(0, 0), '# keep my draft'); + const draft = new vscode.WorkspaceEdit(); + draft.insert(input.uri, new vscode.Position(0, 0), '# keep my draft'); await vscode.workspace.applyEdit(draft); - const client = new AgentClient(manifests[0]); await client.connect(); + const client = new AgentClient(manifests[0]); + await client.connect(); try { const count = (await client.snapshot()).executions.length; - const tabs = () => vscode.window.tabGroups.all.flatMap(group => group.tabs.map(tab => `${group.viewColumn}:${tab.label}`)).sort(); + const tabs = () => + vscode.window.tabGroups.all + .flatMap((group) => group.tabs.map((tab) => `${group.viewColumn}:${tab.label}`)) + .sort(); const before = tabs(); await vscode.commands.executeCommand('r.interactive.reuseCell', notebook.cellAt(0)); - assert.deepStrictEqual(tabs(), before, 'Cell reuse must keep the existing native Interactive tab'); + assert.deepStrictEqual( + tabs(), + before, + 'Cell reuse must keep the existing native Interactive tab', + ); assert.ok(input.getText().startsWith('# keep my draft\n')); assert.ok(input.getText().includes('editor_value <- 42')); assert.strictEqual((await client.snapshot()).executions.length, count); } finally { client.close(); - const reset = new vscode.WorkspaceEdit(); reset.delete(input.uri, new vscode.Range(0, 0, input.lineCount, 0)); + const reset = new vscode.WorkspaceEdit(); + reset.delete(input.uri, new vscode.Range(0, 0, input.lineCount, 0)); await vscode.workspace.applyEdit(reset); } }); test('late plot updates preserve the cell document, edited code, and execution summary', async function () { - if (!manifests[0].capabilities.jgd) { this.skip(); } - const notebook = vscode.workspace.notebookDocuments.find(doc => doc.metadata.rSessionId === manifests[0].id); + if (!manifests[0].capabilities.jgd) { + this.skip(); + } + const notebook = vscode.workspace.notebookDocuments.find( + (doc) => doc.metadata.rSessionId === manifests[0].id, + ); assert.ok(notebook); const cell = notebook.cellAt(0); - const plot = (): Record | undefined => cell.outputs.flatMap(output => output.items) - .filter(item => item.mime === DISPLAY_MIME).map(item => JSON.parse(Buffer.from(item.data).toString()) as Record) - .find(data => data.kind === 'plot'); + const plot = (): Record | undefined => + cell.outputs + .flatMap((output) => output.items) + .filter((item) => item.mime === DISPLAY_MIME) + .map( + (item) => + JSON.parse(Buffer.from(item.data).toString()) as Record, + ) + .find((data) => data.kind === 'plot'); await until(() => !!plot()); const initial = plot(); assert.ok(initial); - const uri = cell.document.uri.toString(), summary = cell.executionSummary; + const uri = cell.document.uri.toString(), + summary = cell.executionSummary; const code = cell.document.getText() + '\n# unsubmitted edit'; const edit = new vscode.WorkspaceEdit(); edit.replace(cell.document.uri, new vscode.Range(0, 0, cell.document.lineCount, 0), code); await vscode.workspace.applyEdit(edit); - const client = new AgentClient(manifests[0]); await client.connect(); + const client = new AgentClient(manifests[0]); + await client.connect(); try { await client.request('claim', { force: true }); - await client.request('resize', { device: initial.device, plot: initial.plot, width: 500, height: 350 }); + await client.request('resize', { + device: initial.device, + plot: initial.plot, + width: 500, + height: 350, + }); await until(() => plot()?.svg !== initial.svg); assert.strictEqual(notebook.cellAt(0).document.uri.toString(), uri); assert.strictEqual(notebook.cellAt(0).document.getText(), code); @@ -1008,57 +1791,113 @@ function treeTooltip(item: vscode.TreeItem): string { } }); test('queued cells wait for R to start before refreshing their output', async () => { - const notebook = vscode.workspace.notebookDocuments.find(doc => doc.metadata.rSessionId === manifests[0].id); + const notebook = vscode.workspace.notebookDocuments.find( + (doc) => doc.metadata.rSessionId === manifests[0].id, + ); assert.ok(notebook); const errors = sinon.stub(vscode.window, 'showErrorMessage').resolves(undefined); try { const index = notebook.cellCount; const edit = new vscode.WorkspaceEdit(); - edit.set(notebook.uri, [vscode.NotebookEdit.insertCells(index, [ - new vscode.NotebookCellData(vscode.NotebookCellKind.Code, 'Sys.sleep(0.8); cat("first-complete")', 'r'), - new vscode.NotebookCellData(vscode.NotebookCellKind.Code, 'cat("queued-complete"); data.frame(x=1:3)', 'r'), - ])]); + edit.set(notebook.uri, [ + vscode.NotebookEdit.insertCells(index, [ + new vscode.NotebookCellData( + vscode.NotebookCellKind.Code, + 'Sys.sleep(0.8); cat("first-complete")', + 'r', + ), + new vscode.NotebookCellData( + vscode.NotebookCellKind.Code, + 'cat("queued-complete"); data.frame(x=1:3)', + 'r', + ), + ]), + ]); await vscode.workspace.applyEdit(edit); - await vscode.commands.executeCommand('notebook.cell.execute', { ranges: [{ start: index, end: index + 2 }], document: notebook.uri }); + await vscode.commands.executeCommand('notebook.cell.execute', { + ranges: [{ start: index, end: index + 2 }], + document: notebook.uri, + }); await until(() => notebook.cellAt(index + 1).executionSummary?.success === true); - assert.ok(notebook.cellAt(index + 1).outputs.some(output => output.items.some(item => - Buffer.from(item.data).toString().includes('queued-complete')))); + assert.ok( + notebook + .cellAt(index + 1) + .outputs.some((output) => + output.items.some((item) => + Buffer.from(item.data).toString().includes('queued-complete'), + ), + ), + ); sinon.assert.notCalled(errors); - } finally { errors.restore(); } + } finally { + errors.restore(); + } }); test('cancelling queued cells completes their notebook execution without a start event', async () => { - const notebook = vscode.workspace.notebookDocuments.find(doc => doc.metadata.rSessionId === manifests[0].id); + const notebook = vscode.workspace.notebookDocuments.find( + (doc) => doc.metadata.rSessionId === manifests[0].id, + ); assert.ok(notebook); const errors = sinon.stub(vscode.window, 'showErrorMessage').resolves(undefined); - const client = new AgentClient(manifests[0]); await client.connect(); + const client = new AgentClient(manifests[0]); + await client.connect(); try { const index = notebook.cellCount; const edit = new vscode.WorkspaceEdit(); - edit.set(notebook.uri, [vscode.NotebookEdit.insertCells(index, [ - new vscode.NotebookCellData(vscode.NotebookCellKind.Code, 'Sys.sleep(1); cat("running-complete")', 'r'), - new vscode.NotebookCellData(vscode.NotebookCellKind.Code, 'stop("cancelled code must not run")', 'r'), - ])]); + edit.set(notebook.uri, [ + vscode.NotebookEdit.insertCells(index, [ + new vscode.NotebookCellData( + vscode.NotebookCellKind.Code, + 'Sys.sleep(1); cat("running-complete")', + 'r', + ), + new vscode.NotebookCellData( + vscode.NotebookCellKind.Code, + 'stop("cancelled code must not run")', + 'r', + ), + ]), + ]); await vscode.workspace.applyEdit(edit); - await vscode.commands.executeCommand('notebook.cell.execute', { ranges: [{ start: index, end: index + 2 }], document: notebook.uri }); + await vscode.commands.executeCommand('notebook.cell.execute', { + ranges: [{ start: index, end: index + 2 }], + document: notebook.uri, + }); const cancelled = notebook.cellAt(index + 1); await until(() => !!cancelled.metadata.rExecutionId && manifests[0].status === 'busy'); // Let the normal output-refresh timer run while the second cell is queued. - await new Promise(resolve => setTimeout(resolve, 150)); + await new Promise((resolve) => setTimeout(resolve, 150)); await vscode.commands.executeCommand('r.interactive.cancelQueued', notebook.uri); await until(() => notebook.cellAt(index).executionSummary?.success === true); - assert.strictEqual((await client.request<{ state: string }>('execution', { id: cancelled.metadata.rExecutionId })).state, 'cancelled'); + assert.strictEqual( + ( + await client.request<{ state: string }>('execution', { + id: cancelled.metadata.rExecutionId, + }) + ).state, + 'cancelled', + ); assert.strictEqual(cancelled.executionSummary?.success, undefined); assert.strictEqual(cancelled.outputs.length, 0); // A fresh handle is only allowed once the pending execution has ended. controller.createNotebookCellExecution(cancelled).end(undefined); sinon.assert.notCalled(errors); - } finally { client.close(); errors.restore(); } + } finally { + client.close(); + errors.restore(); + } }); test('runs the diamonds data.table example through Send Selection without lifecycle errors', async function () { - const packages = await promisify(execFile)('Rscript', ['-e', - 'cat(all(vapply(c("data.table", "ggplot2", "dplyr"), requireNamespace, logical(1), quietly=TRUE)))']); - if (packages.stdout.trim() !== 'TRUE') { this.skip(); } - const notebook = vscode.workspace.notebookDocuments.find(doc => doc.metadata.rSessionId === manifests[0].id); + const packages = await promisify(execFile)('Rscript', [ + '-e', + 'cat(all(vapply(c("data.table", "ggplot2", "dplyr"), requireNamespace, logical(1), quietly=TRUE)))', + ]); + if (packages.stdout.trim() !== 'TRUE') { + this.skip(); + } + const notebook = vscode.workspace.notebookDocuments.find( + (doc) => doc.metadata.rSessionId === manifests[0].id, + ); assert.ok(notebook); const code = String.raw`# R Script for Testing data.table with ggplot2 Diamonds Data # ============================================================ @@ -1087,17 +1926,29 @@ cat("\n")`; await vscode.commands.executeCommand('r.runSelection', 'Sys.sleep(0.8)'); await until(() => notebook.cellCount > index && manifests[0].status === 'busy'); await vscode.commands.executeCommand('r.runSelection', code); - await until(() => notebook.cellCount > index + 1 && notebook.cellAt(index + 1).executionSummary?.success === true); - const output = notebook.cellAt(index + 1).outputs.flatMap(item => item.items) - .map(item => Buffer.from(item.data).toString()).join('\n'); + await until( + () => + notebook.cellCount > index + 1 && + notebook.cellAt(index + 1).executionSummary?.success === true, + ); + const output = notebook + .cellAt(index + 1) + .outputs.flatMap((item) => item.items) + .map((item) => Buffer.from(item.data).toString()) + .join('\n'); assert.match(output, /53940 rows x 10 columns/); assert.match(output, /First few rows/); assert.match(output, /0\.23/); sinon.assert.notCalled(errors); - } finally { errors.restore(); } + } finally { + errors.restore(); + } }); test('Run Selection with no target offers live sessions and cancellation preserves the document and cursor', async () => { - const document = await vscode.workspace.openTextDocument({ language: 'r', content: 'choice_cancelled <- 1\n2' }); + const document = await vscode.workspace.openTextDocument({ + language: 'r', + content: 'choice_cancelled <- 1\n2', + }); const editor = await vscode.window.showTextDocument(document); editor.selection = new vscode.Selection(0, 0, 0, 0); await vscode.commands.executeCommand('r.interactive.useTerminal'); @@ -1107,32 +1958,62 @@ cat("\n")`; const create = sinon.spy(vscode.window, 'createTerminal'); try { await vscode.commands.executeCommand('r.runSelection'); - sinon.assert.calledOnce(picker); sinon.assert.notCalled(create); + sinon.assert.calledOnce(picker); + sinon.assert.notCalled(create); assert.strictEqual(picker.firstCall.args[1]?.title, 'Run R code'); - const choices = await picker.firstCall.args[0] as (vscode.QuickPickItem & { manifest?: SessionManifest; create?: boolean; terminal?: boolean })[]; - assert.ok(choices.some(item => item.create)); assert.ok(choices.some(item => item.terminal)); - for (const manifest of manifests) { assert.ok(choices.some(item => item.manifest?.id === manifest.id)); } + const choices = (await picker.firstCall.args[0]) as (vscode.QuickPickItem & { + manifest?: SessionManifest; + create?: boolean; + terminal?: boolean; + })[]; + assert.ok(choices.some((item) => item.create)); + assert.ok(choices.some((item) => item.terminal)); + for (const manifest of manifests) { + assert.ok(choices.some((item) => item.manifest?.id === manifest.id)); + } assert.strictEqual(document.getText(), 'choice_cancelled <- 1\n2'); assert.strictEqual(editor.selection.active.line, 0); assert.strictEqual(editor.selection.active.character, 0); - } finally { terminals.restore(); active.restore(); picker.restore(); create.restore(); } + } finally { + terminals.restore(); + active.restore(); + picker.restore(); + create.restore(); + } }); test('Run Selection connects the chosen existing session and submits the captured code once', async () => { await vscode.commands.executeCommand('r.interactive.useTerminal'); const terminals = sinon.stub(vscode.window, 'terminals').value([]); - const picker = sinon.stub(vscode.window, 'showQuickPick').resolves({ label: manifests[1].label, manifest: manifests[1] } as vscode.QuickPickItem); + const picker = sinon.stub(vscode.window, 'showQuickPick').resolves({ + label: manifests[1].label, + manifest: manifests[1], + } as vscode.QuickPickItem); try { await vscode.commands.executeCommand('r.runSelection', 'chosen_target_value <- 42'); await vscode.commands.executeCommand('r.runSelection', 'chosen_target_value + 1'); sinon.assert.calledOnce(picker); - const client = new AgentClient(manifests[1]); await client.connect(); + const client = new AgentClient(manifests[1]); + await client.connect(); try { const snapshot = await client.snapshot(); - assert.strictEqual(snapshot.executions.filter(record => record.code === 'chosen_target_value <- 42').length, 1); - assert.ok(snapshot.executions.some(record => record.code === 'chosen_target_value + 1')); - } finally { client.close(); } - } finally { terminals.restore(); picker.restore(); await vscode.commands.executeCommand('r.interactive.open', manifests[0]); } + assert.strictEqual( + snapshot.executions.filter( + (record) => record.code === 'chosen_target_value <- 42', + ).length, + 1, + ); + assert.ok( + snapshot.executions.some((record) => record.code === 'chosen_target_value + 1'), + ); + } finally { + client.close(); + } + } finally { + terminals.restore(); + picker.restore(); + await vscode.commands.executeCommand('r.interactive.open', manifests[0]); + } }); test('concurrent submissions share the target chooser and create only one R terminal', async () => { @@ -1140,27 +2021,53 @@ cat("\n")`; const terminals = sinon.stub(vscode.window, 'terminals').value([]); const active = sinon.stub(vscode.window, 'activeTerminal').value(undefined); const picker = sinon.stub(vscode.window, 'showQuickPick').callsFake(async () => { - await new Promise(resolve => setTimeout(resolve, 20)); + await new Promise((resolve) => setTimeout(resolve, 20)); return { label: 'Create R terminal', terminal: true } as vscode.QuickPickItem; }); const sendText = sinon.spy(); - const terminal = { name: 'R Interactive', processId: Promise.resolve(undefined), show: () => undefined, sendText, dispose: () => undefined } as unknown as vscode.Terminal; + const terminal = { + name: 'R Interactive', + processId: Promise.resolve(undefined), + show: () => undefined, + sendText, + dispose: () => undefined, + } as unknown as vscode.Terminal; const create = sinon.stub(vscode.window, 'createTerminal').returns(terminal); const configuration = vscode.workspace.getConfiguration('r'); const watcher = configuration.inspect('sessionWatcher')?.globalValue; await configuration.update('sessionWatcher', false, vscode.ConfigurationTarget.Global); try { await Promise.all([ - vscode.commands.executeCommand('r.runSelection', 'terminal_choice_one <- 1\nterminal_choice_one + 1'), - vscode.commands.executeCommand('r.runSelection', 'terminal_choice_two <- 2\nterminal_choice_two + 1'), - ]); - sinon.assert.calledOnce(picker); sinon.assert.calledOnce(create); - assert.deepStrictEqual(sendText.args.map(args => args[0] as string), [ - 'terminal_choice_one <- 1', 'terminal_choice_one + 1', 'terminal_choice_two <- 2', 'terminal_choice_two + 1', + vscode.commands.executeCommand( + 'r.runSelection', + 'terminal_choice_one <- 1\nterminal_choice_one + 1', + ), + vscode.commands.executeCommand( + 'r.runSelection', + 'terminal_choice_two <- 2\nterminal_choice_two + 1', + ), ]); + sinon.assert.calledOnce(picker); + sinon.assert.calledOnce(create); + assert.deepStrictEqual( + sendText.args.map((args) => args[0] as string), + [ + 'terminal_choice_one <- 1', + 'terminal_choice_one + 1', + 'terminal_choice_two <- 2', + 'terminal_choice_two + 1', + ], + ); } finally { - terminals.restore(); active.restore(); picker.restore(); create.restore(); - await configuration.update('sessionWatcher', watcher, vscode.ConfigurationTarget.Global); + terminals.restore(); + active.restore(); + picker.restore(); + create.restore(); + await configuration.update( + 'sessionWatcher', + watcher, + vscode.ConfigurationTarget.Global, + ); await vscode.commands.executeCommand('r.interactive.open', manifests[0]); } }); @@ -1170,47 +2077,109 @@ cat("\n")`; const previousNode = settings.inspect('interactive.nodePath')?.globalValue; await vscode.commands.executeCommand('r.interactive.useTerminal'); const terminals = sinon.stub(vscode.window, 'terminals').value([]); - const picker = sinon.stub(vscode.window, 'showQuickPick').callsFake((_items, options) => Promise.resolve( - options?.title === 'Run R code' ? { label: 'New R Interactive window', create: true } : { label: 'arf', value: 'arf' } - ) as ReturnType); - const input = sinon.stub(vscode.window, 'showInputBox').resolves('Created by Run Selection'); + const picker = sinon + .stub(vscode.window, 'showQuickPick') + .callsFake( + (_items, options) => + Promise.resolve( + options?.title === 'Run R code' + ? { label: 'New R Interactive window', create: true } + : { label: 'arf', value: 'arf' }, + ) as ReturnType, + ); + const input = sinon + .stub(vscode.window, 'showInputBox') + .resolves('Created by Run Selection'); let client: AgentClient | undefined; let notebook: vscode.NotebookDocument | undefined; try { // Exercise successful agent creation with the default VS Code runtime. await settings.update('interactive.nodePath', '', vscode.ConfigurationTarget.Global); await vscode.commands.executeCommand('r.runSelection', 'created_target_value <- 123'); - notebook = vscode.workspace.notebookDocuments.find(doc => doc.metadata.rSessionId && !manifests.some(item => item.id === doc.metadata.rSessionId)); + notebook = vscode.workspace.notebookDocuments.find( + (doc) => + doc.metadata.rSessionId && + !manifests.some((item) => item.id === doc.metadata.rSessionId), + ); assert.ok(notebook); - const manifest = JSON.parse(fs.readFileSync(path.join(root, String(notebook.metadata.rSessionId), 'manifest.json'), 'utf8')) as SessionManifest; - const config = JSON.parse(fs.readFileSync(path.join(root, manifest.id, 'config.json'), 'utf8')) as AgentConfig; + const manifest = JSON.parse( + fs.readFileSync( + path.join(root, String(notebook.metadata.rSessionId), 'manifest.json'), + 'utf8', + ), + ) as SessionManifest; + const config = JSON.parse( + fs.readFileSync(path.join(root, manifest.id, 'config.json'), 'utf8'), + ) as AgentConfig; assert.strictEqual(config.maxOutputBytes, 4 * 1024 * 1024); assert.strictEqual(config.maxJournalBytes, 128 * 1024 * 1024); assert.strictEqual(config.maxAssetBytes, 1024 * 1024 * 1024); - client = new AgentClient(manifest); await client.connect(); + client = new AgentClient(manifest); + await client.connect(); const snapshot = await client.snapshot(); - assert.strictEqual(snapshot.executions.filter(record => record.code === 'created_target_value <- 123').length, 1); - await until(() => notebook?.getCells().some(cell => cell.executionSummary?.success === true) ?? false); + assert.strictEqual( + snapshot.executions.filter( + (record) => record.code === 'created_target_value <- 123', + ).length, + 1, + ); + await until( + () => + notebook?.getCells().some((cell) => cell.executionSummary?.success === true) ?? + false, + ); await vscode.commands.executeCommand('r.runSelection', 'q("no")'); - await until(() => notebook?.getCells().some(cell => cell.metadata.rNoticeKind === 'stopped') ?? false); + await until( + () => + notebook?.getCells().some((cell) => cell.metadata.rNoticeKind === 'stopped') ?? + false, + ); const uri = notebook.uri; await vscode.commands.executeCommand('r.interactive.detach', uri); await vscode.commands.executeCommand('r.interactive.open', manifest); - assert.strictEqual(notebook.getCells().filter(cell => cell.metadata.rNoticeKind === 'stopped').length, 1); - assert.ok(notebook.getCells().some(cell => cell.document.getText().includes('R session stopped'))); - assert.ok(notebook.getCells().some(cell => cell.document.getText().includes('The transcript is retained.')), - 'Notice body must keep breakable spaces so it wraps in narrow windows'); + assert.strictEqual( + notebook.getCells().filter((cell) => cell.metadata.rNoticeKind === 'stopped') + .length, + 1, + ); + assert.ok( + notebook + .getCells() + .some((cell) => cell.document.getText().includes('R session stopped')), + ); + assert.ok( + notebook + .getCells() + .some((cell) => + cell.document.getText().includes('The transcript is retained.'), + ), + 'Notice body must keep breakable spaces so it wraps in narrow windows', + ); } finally { - terminals.restore(); picker.restore(); input.restore(); - await settings.update('interactive.nodePath', previousNode, vscode.ConfigurationTarget.Global); - if (notebook) { await vscode.commands.executeCommand('r.interactive.detach', notebook.uri); } + terminals.restore(); + picker.restore(); + input.restore(); + await settings.update( + 'interactive.nodePath', + previousNode, + vscode.ConfigurationTarget.Global, + ); + if (notebook) { + await vscode.commands.executeCommand('r.interactive.detach', notebook.uri); + } if (client) { await client.request('claim', { force: true }); await client.request('stop'); - for (let i = 0; i < 100 && (await client.request<{ status: string }>('heartbeat')).status !== 'exited'; i++) { - await new Promise(resolve => setTimeout(resolve, 50)); + for ( + let i = 0; + i < 100 && + (await client.request<{ status: string }>('heartbeat')).status !== 'exited'; + i++ + ) { + await new Promise((resolve) => setTimeout(resolve, 50)); } - await client.request('shutdown'); client.close(); + await client.request('shutdown'); + client.close(); } await vscode.commands.executeCommand('r.interactive.open', manifests[0]); } @@ -1222,198 +2191,448 @@ cat("\n")`; const previousNode = settings.inspect('interactive.nodePath')?.globalValue; const override = nodeOverride('custom Node for creation'); await vscode.commands.executeCommand('r.interactive.open', manifests[0]); - const original = vscode.workspace.notebookDocuments.find(doc => doc.metadata.rSessionId === manifests[0].id); + const original = vscode.workspace.notebookDocuments.find( + (doc) => doc.metadata.rSessionId === manifests[0].id, + ); assert.ok(original); - const originalResult = await vscode.commands.executeCommand<{ inputUri: vscode.Uri }>('interactive.open', { preserveFocus: false }, original.uri); + const originalResult = await vscode.commands.executeCommand<{ inputUri: vscode.Uri }>( + 'interactive.open', + { preserveFocus: false }, + original.uri, + ); await vscode.commands.executeCommand('interactive.input.focus'); - await until(() => vscode.window.activeTextEditor?.document.uri.toString() === originalResult.inputUri.toString()); + await until( + () => + vscode.window.activeTextEditor?.document.uri.toString() === + originalResult.inputUri.toString(), + ); const originalInput = vscode.window.activeTextEditor?.document; assert.strictEqual(originalInput?.uri.scheme, 'vscode-interactive-input'); assert.ok(originalInput); const originalText = originalInput.getText(); const draft = new vscode.WorkspaceEdit(); - draft.replace(originalInput.uri, new vscode.Range(0, 0, originalInput.lineCount, 0), '# unfinished work in the previous session'); + draft.replace( + originalInput.uri, + new vscode.Range(0, 0, originalInput.lineCount, 0), + '# unfinished work in the previous session', + ); await vscode.workspace.applyEdit(draft); - const before = new Set(vscode.workspace.notebookDocuments.map(doc => doc.uri.toString())); - const inputsBefore = new Set(vscode.workspace.textDocuments.map(doc => doc.uri.toString())); - const picker = sinon.stub(vscode.window, 'showQuickPick').resolves({ label: 'arf', value: 'arf' } as vscode.QuickPickItem); + const before = new Set(vscode.workspace.notebookDocuments.map((doc) => doc.uri.toString())); + const inputsBefore = new Set( + vscode.workspace.textDocuments.map((doc) => doc.uri.toString()), + ); + const picker = sinon + .stub(vscode.window, 'showQuickPick') + .resolves({ label: 'arf', value: 'arf' } as vscode.QuickPickItem); const name = sinon.stub(vscode.window, 'showInputBox').resolves('New session focus'); const execute = vscode.commands.executeCommand.bind(vscode.commands); const commands = sinon.stub(vscode.commands, 'executeCommand').callThrough(); - commands.withArgs('interactive.open').callsFake(async (command: string, ...args: unknown[]) => { - const result = await execute<{ notebookUri: vscode.Uri; inputUri: vscode.Uri }>(command, ...args); - if (args[3] === 'R: New session focus') { - // Emulate native models retained under a recycled numbered URI. - const retained = new vscode.WorkspaceEdit(); - retained.set(result.notebookUri, [vscode.NotebookEdit.insertCells(0, [ - new vscode.NotebookCellData(vscode.NotebookCellKind.Code, '# previous window draft', 'r'), - ])]); - retained.insert(result.inputUri, new vscode.Position(0, 0), '# previous window input'); - await vscode.workspace.applyEdit(retained); - } - return result; - }); + commands + .withArgs('interactive.open') + .callsFake(async (command: string, ...args: unknown[]) => { + const result = await execute<{ notebookUri: vscode.Uri; inputUri: vscode.Uri }>( + command, + ...args, + ); + if (args[3] === 'R: New session focus') { + // Emulate native models retained under a recycled numbered URI. + const retained = new vscode.WorkspaceEdit(); + retained.set(result.notebookUri, [ + vscode.NotebookEdit.insertCells(0, [ + new vscode.NotebookCellData( + vscode.NotebookCellKind.Code, + '# previous window draft', + 'r', + ), + ]), + ]); + retained.insert( + result.inputUri, + new vscode.Position(0, 0), + '# previous window input', + ); + await vscode.workspace.applyEdit(retained); + } + return result; + }); let notebook: vscode.NotebookDocument | undefined; let client: AgentClient | undefined; try { - await settings.update('interactive.arfPath', process.env.ARF_PATH ?? 'arf', vscode.ConfigurationTarget.Global); - await settings.update('interactive.nodePath', `"\${userHome}/${path.relative(os.homedir(), override.executable)}"`, vscode.ConfigurationTarget.Global); + await settings.update( + 'interactive.arfPath', + process.env.ARF_PATH ?? 'arf', + vscode.ConfigurationTarget.Global, + ); + await settings.update( + 'interactive.nodePath', + `"\${userHome}/${path.relative(os.homedir(), override.executable)}"`, + vscode.ConfigurationTarget.Global, + ); await vscode.commands.executeCommand('r.interactive.new'); - notebook = vscode.workspace.notebookDocuments.find(doc => !before.has(doc.uri.toString()) && doc.metadata.rSessionId); + notebook = vscode.workspace.notebookDocuments.find( + (doc) => !before.has(doc.uri.toString()) && doc.metadata.rSessionId, + ); assert.ok(notebook); - const manifest = JSON.parse(fs.readFileSync(path.join(root, String(notebook.metadata.rSessionId), 'manifest.json'), 'utf8')) as SessionManifest; + const manifest = JSON.parse( + fs.readFileSync( + path.join(root, String(notebook.metadata.rSessionId), 'manifest.json'), + 'utf8', + ), + ) as SessionManifest; // Acquire cleanup handles before assertions so a failure cannot leave // an extra session or notebook to affect subsequent tests. - client = new AgentClient(manifest); await client.connect(); + client = new AgentClient(manifest); + await client.connect(); assertNodeLaunched(override.log); - assert.strictEqual(vscode.window.tabGroups.all.flatMap(group => group.tabs).filter(tab => tab.label === 'R: New session focus').length, 1, - 'Focusing the new native input must not open a duplicate tab in the previous editor group'); - const input = vscode.workspace.textDocuments.find(doc => doc.uri.scheme === 'vscode-interactive-input' && !inputsBefore.has(doc.uri.toString())); + assert.strictEqual( + vscode.window.tabGroups.all + .flatMap((group) => group.tabs) + .filter((tab) => tab.label === 'R: New session focus').length, + 1, + 'Focusing the new native input must not open a duplicate tab in the previous editor group', + ); + const input = vscode.workspace.textDocuments.find( + (doc) => + doc.uri.scheme === 'vscode-interactive-input' && + !inputsBefore.has(doc.uri.toString()), + ); assert.ok(input); - await until(() => vscode.window.activeTextEditor?.document.uri.toString() === input.uri.toString()); + await until( + () => + vscode.window.activeTextEditor?.document.uri.toString() === + input.uri.toString(), + ); assert.notStrictEqual(input.uri.toString(), originalInput.uri.toString()); - assert.strictEqual(originalInput.getText(), '# unfinished work in the previous session'); - assert.strictEqual(input.getText(), '', 'A new session must not inherit a recycled input model'); - assert.strictEqual(notebook.cellCount, 0, 'A new session must not inherit old notebook drafts'); + assert.strictEqual( + originalInput.getText(), + '# unfinished work in the previous session', + ); + assert.strictEqual( + input.getText(), + '', + 'A new session must not inherit a recycled input model', + ); + assert.strictEqual( + notebook.cellCount, + 0, + 'A new session must not inherit old notebook drafts', + ); const code = 'new_session_focus_value <- 123'; - const edit = new vscode.WorkspaceEdit(); edit.insert(input.uri, new vscode.Position(0, 0), code); + const edit = new vscode.WorkspaceEdit(); + edit.insert(input.uri, new vscode.Position(0, 0), code); await vscode.workspace.applyEdit(edit); await vscode.commands.executeCommand('interactive.execute', notebook.uri); - await until(() => notebook?.getCells().some(cell => cell.document.getText() === code && cell.executionSummary?.success === true) ?? false); - assert.ok(!original.getCells().some(cell => cell.document.getText() === code)); + await until( + () => + notebook + ?.getCells() + .some( + (cell) => + cell.document.getText() === code && + cell.executionSummary?.success === true, + ) ?? false, + ); + assert.ok(!original.getCells().some((cell) => cell.document.getText() === code)); } finally { - commands.restore(); picker.restore(); name.restore(); - await settings.update('interactive.arfPath', previousArf, vscode.ConfigurationTarget.Global); - await settings.update('interactive.nodePath', previousNode, vscode.ConfigurationTarget.Global); - if (notebook) { await vscode.commands.executeCommand('r.interactive.detach', notebook.uri); } + commands.restore(); + picker.restore(); + name.restore(); + await settings.update( + 'interactive.arfPath', + previousArf, + vscode.ConfigurationTarget.Global, + ); + await settings.update( + 'interactive.nodePath', + previousNode, + vscode.ConfigurationTarget.Global, + ); + if (notebook) { + await vscode.commands.executeCommand('r.interactive.detach', notebook.uri); + } if (client) { await client.request('claim', { force: true }); await client.request('stop'); - for (let i = 0; i < 100 && (await client.request<{ status: string }>('heartbeat')).status !== 'exited'; i++) { - await new Promise(resolve => setTimeout(resolve, 50)); + for ( + let i = 0; + i < 100 && + (await client.request<{ status: string }>('heartbeat')).status !== 'exited'; + i++ + ) { + await new Promise((resolve) => setTimeout(resolve, 50)); } - await client.request('shutdown'); client.close(); + await client.request('shutdown'); + client.close(); } const restore = new vscode.WorkspaceEdit(); - restore.replace(originalInput.uri, new vscode.Range(0, 0, originalInput.lineCount, 0), originalText); + restore.replace( + originalInput.uri, + new vscode.Range(0, 0, originalInput.lineCount, 0), + originalText, + ); await vscode.workspace.applyEdit(restore); await vscode.commands.executeCommand('r.interactive.open', manifests[0]); } }); test('history export chooses one format before saving and supports cancelling either step', async () => { - const notebook = vscode.workspace.notebookDocuments.find(doc => doc.metadata.rSessionId === manifests[0].id); + const notebook = vscode.workspace.notebookDocuments.find( + (doc) => doc.metadata.rSessionId === manifests[0].id, + ); assert.ok(notebook); const picker = sinon.stub(vscode.window, 'showQuickPick').resolves(undefined); const save = sinon.stub(vscode.window, 'showSaveDialog').resolves(undefined); try { await vscode.commands.executeCommand('r.interactive.export', notebook.uri); sinon.assert.notCalled(save); - const choices = picker.firstCall.args[0] as (vscode.QuickPickItem & { format: string })[]; - assert.deepStrictEqual(choices.map(choice => choice.format), ['rnb', 'ipynb', 'R', 'html']); + const choices = picker.firstCall.args[0] as (vscode.QuickPickItem & { + format: string; + })[]; + assert.deepStrictEqual( + choices.map((choice) => choice.format), + ['rnb', 'ipynb', 'R', 'html'], + ); for (const choice of choices) { picker.resolves(choice); await vscode.commands.executeCommand('r.interactive.export', notebook.uri); const options = save.lastCall.args[0]; assert.deepStrictEqual(options?.filters, { [choice.label]: [choice.format] }); - assert.strictEqual(options?.defaultUri?.fsPath, path.join(root, `${manifests[0].label}-history.${choice.format}`)); + assert.strictEqual( + options?.defaultUri?.fsPath, + path.join(root, `${manifests[0].label}-history.${choice.format}`), + ); } assert.strictEqual(save.callCount, 4); - } finally { picker.restore(); save.restore(); } + } finally { + picker.restore(); + save.restore(); + } }); test('exports R-formatted numeric labels to HTML and retains raw numbers in saved notebooks', async () => { - const notebook = vscode.workspace.notebookDocuments.find(doc => doc.metadata.rSessionId === manifests[0].id); + const notebook = vscode.workspace.notebookDocuments.find( + (doc) => doc.metadata.rSessionId === manifests[0].id, + ); assert.ok(notebook); const errors = sinon.stub(vscode.window, 'showErrorMessage').resolves(undefined); const saved = sinon.stub(vscode.window, 'showSaveDialog'); - const format = sinon.stub(vscode.window, 'showQuickPick').resolves({ label: 'HTML report', format: 'html' } as vscode.QuickPickItem); + const format = sinon + .stub(vscode.window, 'showQuickPick') + .resolves({ label: 'HTML report', format: 'html' } as vscode.QuickPickItem); try { await vscode.commands.executeCommand('r.interactive.open', manifests[0]); const index = notebook.cellCount; - await vscode.commands.executeCommand('r.runSelection', 'options(digits=7, scipen=0); data.frame(price_per_carat=c(326/0.23, 326/0.21, 1400))'); - await until(() => notebook.cellCount > index && notebook.cellAt(index).executionSummary?.success === true); - const htmlFile = path.join(root, 'numeric.html'); saved.resolves(vscode.Uri.file(htmlFile)); + await vscode.commands.executeCommand( + 'r.runSelection', + 'options(digits=7, scipen=0); data.frame(price_per_carat=c(326/0.23, 326/0.21, 1400))', + ); + await until( + () => + notebook.cellCount > index && + notebook.cellAt(index).executionSummary?.success === true, + ); + const htmlFile = path.join(root, 'numeric.html'); + saved.resolves(vscode.Uri.file(htmlFile)); await vscode.commands.executeCommand('r.interactive.export', notebook.uri); const html = fs.readFileSync(htmlFile, 'utf8'); - assert.ok(html.includes('1417.391') && html.includes('1400.000')); - const notebookFile = path.join(root, 'numeric.rnb'); saved.resolves(vscode.Uri.file(notebookFile)); - format.resolves({ label: 'R Interactive notebook', format: 'rnb' } as vscode.QuickPickItem); + assert.ok( + html.includes('1417.391') && + html.includes('1400.000'), + ); + const notebookFile = path.join(root, 'numeric.rnb'); + saved.resolves(vscode.Uri.file(notebookFile)); + format.resolves({ + label: 'R Interactive notebook', + format: 'rnb', + } as vscode.QuickPickItem); await vscode.commands.executeCommand('r.interactive.export', notebook.uri); - const report = new InteractiveSerializer().deserializeNotebook(fs.readFileSync(notebookFile)); - const table = report.cells.at(-1)?.outputs?.flatMap(output => output.items) - .filter(item => item.mime === DISPLAY_MIME).map(item => JSON.parse(Buffer.from(item.data).toString()) as Record) - .find(data => data.kind === 'table'); + const report = new InteractiveSerializer().deserializeNotebook( + fs.readFileSync(notebookFile), + ); + const table = report.cells + .at(-1) + ?.outputs?.flatMap((output) => output.items) + .filter((item) => item.mime === DISPLAY_MIME) + .map( + (item) => + JSON.parse(Buffer.from(item.data).toString()) as Record, + ) + .find((data) => data.kind === 'table'); assert.ok(table); - assert.deepStrictEqual(table.formattedColumns, { '1': ['1417.391', '1552.381', '1400.000'] }); - assert.ok(Math.abs((table.rows as Record[])[0]['1'] - 326 / 0.23) < 1e-10); + assert.deepStrictEqual(table.formattedColumns, { + '1': ['1417.391', '1552.381', '1400.000'], + }); + assert.ok( + Math.abs((table.rows as Record[])[0]['1'] - 326 / 0.23) < 1e-10, + ); assert.strictEqual(table.connected, false); assert.match(String(table.printedText), /price_per_carat/); - 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 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 = interactiveManager(context) as unknown as { - rendererMessage(editor: vscode.NotebookEditor, message: Record): Promise; + rendererMessage( + editor: vscode.NotebookEditor, + message: Record, + ): Promise; }; - const editor = vscode.window.visibleNotebookEditors.find(editor => editor.notebook === notebook); assert.ok(editor); + const editor = vscode.window.visibleNotebookEditors.find( + (editor) => editor.notebook === notebook, + ); + assert.ok(editor); const before = notebook.cellCount; - await manager.rendererMessage(editor, { displayId: table.displayId, generation: table.generation, action: 'tableView', mode: 'text' }); - assert.strictEqual(notebook.cellCount, before, 'Selecting a view must not execute another cell'); + await manager.rendererMessage(editor, { + displayId: table.displayId, + generation: table.generation, + action: 'tableView', + mode: 'text', + }); + assert.strictEqual( + notebook.cellCount, + before, + 'Selecting a view must not execute another cell', + ); saved.resolves(vscode.Uri.file(htmlFile)); format.resolves({ label: 'HTML report', format: 'html' } as vscode.QuickPickItem); await vscode.commands.executeCommand('r.interactive.export', notebook.uri); - assert.ok(fs.readFileSync(htmlFile, 'utf8').includes(`
${String(table.printedText)}
`)); + assert.ok( + fs + .readFileSync(htmlFile, 'utf8') + .includes(`
${String(table.printedText)}
`), + ); saved.resolves(vscode.Uri.file(notebookFile)); - format.resolves({ label: 'R Interactive notebook', format: 'rnb' } as vscode.QuickPickItem); + format.resolves({ + label: 'R Interactive notebook', + format: 'rnb', + } as vscode.QuickPickItem); await vscode.commands.executeCommand('r.interactive.export', notebook.uri); - const updated = new InteractiveSerializer().deserializeNotebook(fs.readFileSync(notebookFile)); - const custom = updated.cells.at(-1)?.outputs?.flatMap(output => output.items).find(item => item.mime === DISPLAY_MIME); assert.ok(custom); - assert.strictEqual((JSON.parse(Buffer.from(custom.data).toString()) as Record).tableView, 'text'); + const updated = new InteractiveSerializer().deserializeNotebook( + fs.readFileSync(notebookFile), + ); + const custom = updated.cells + .at(-1) + ?.outputs?.flatMap((output) => output.items) + .find((item) => item.mime === DISPLAY_MIME); + assert.ok(custom); + assert.strictEqual( + (JSON.parse(Buffer.from(custom.data).toString()) as Record) + .tableView, + 'text', + ); sinon.assert.notCalled(errors); - } finally { errors.restore(); saved.restore(); format.restore(); } + } finally { + errors.restore(); + saved.restore(); + format.restore(); + } }); test('unclassed list cells route browsing to their owner and save text preferences', async () => { await vscode.commands.executeCommand('r.interactive.open', manifests[0]); - const notebook = vscode.workspace.notebookDocuments.find(doc => doc.metadata.rSessionId === manifests[0].id); assert.ok(notebook); + const notebook = vscode.workspace.notebookDocuments.find( + (doc) => doc.metadata.rSessionId === manifests[0].id, + ); + assert.ok(notebook); const index = notebook.cellCount; - await vscode.commands.executeCommand('r.runSelection', 'list(nested=list(value=1:2), table=data.frame(value=1:2))'); - await until(() => notebook.cellCount > index && notebook.cellAt(index).executionSummary?.success === true); - const custom = notebook.cellAt(index).outputs.flatMap(output => output.items).find(item => item.mime === DISPLAY_MIME); assert.ok(custom); + await vscode.commands.executeCommand( + 'r.runSelection', + 'list(nested=list(value=1:2), table=data.frame(value=1:2))', + ); + await until( + () => + notebook.cellCount > index && + notebook.cellAt(index).executionSummary?.success === true, + ); + const custom = notebook + .cellAt(index) + .outputs.flatMap((output) => output.items) + .find((item) => item.mime === DISPLAY_MIME); + assert.ok(custom); const list = JSON.parse(Buffer.from(custom.data).toString()) as Record; - assert.strictEqual(list.kind, 'list'); assert.strictEqual((list.children as unknown[]).length, 2); - const plain = notebook.cellAt(index).outputs.flatMap(output => output.items).find(item => item.mime === 'text/plain'); assert.ok(plain); + assert.strictEqual(list.kind, 'list'); + assert.strictEqual((list.children as unknown[]).length, 2); + 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 = interactiveManager(context) as unknown as { messages: vscode.NotebookRendererMessaging; - rendererMessage(editor: vscode.NotebookEditor, message: Record): Promise; + rendererMessage( + editor: vscode.NotebookEditor, + message: Record, + ): Promise; }; - const editor = vscode.window.visibleNotebookEditors.find(editor => editor.notebook === notebook); assert.ok(editor); + const editor = vscode.window.visibleNotebookEditors.find( + (editor) => editor.notebook === notebook, + ); + assert.ok(editor); const replies = sinon.spy(manager.messages, 'postMessage'); const panels = sinon.spy(vscode.window, 'createWebviewPanel'); - const saved = sinon.stub(vscode.window, 'showSaveDialog').resolves(vscode.Uri.file(path.join(root, 'list.rnb'))); - const format = sinon.stub(vscode.window, 'showQuickPick').resolves({label:'Notebook',format:'rnb'} as vscode.QuickPickItem); - const message = {displayId:list.displayId,generation:list.generation,outputId:'list-test'}; + const saved = sinon + .stub(vscode.window, 'showSaveDialog') + .resolves(vscode.Uri.file(path.join(root, 'list.rnb'))); + const format = sinon + .stub(vscode.window, 'showQuickPick') + .resolves({ label: 'Notebook', format: 'rnb' } as vscode.QuickPickItem); + const message = { + displayId: list.displayId, + generation: list.generation, + outputId: 'list-test', + }; try { await vscode.commands.executeCommand('r.interactive.open', manifests[1]); - await manager.rendererMessage(editor, {...message,action:'listPage',path:[1],start:1,requestId:1}); - const page = replies.lastCall.args[0] as {result:{children:{label:string}[]}}; + await manager.rendererMessage(editor, { + ...message, + action: 'listPage', + path: [1], + start: 1, + requestId: 1, + }); + const page = replies.lastCall.args[0] as { result: { children: { label: string }[] } }; assert.strictEqual(page.result.children[0].label, '$ value'); - await manager.rendererMessage(editor, {...message,action:'listItem',path:[],index:2,requestId:2}); - await until(() => panels.calledOnce); assert.strictEqual(panels.firstCall.args[1], 'List$table'); - await manager.rendererMessage(editor, {...message,action:'list',path:[]}); + await manager.rendererMessage(editor, { + ...message, + action: 'listItem', + path: [], + index: 2, + requestId: 2, + }); + await until(() => panels.calledOnce); + assert.strictEqual(panels.firstCall.args[1], 'List$table'); + await manager.rendererMessage(editor, { ...message, action: 'list', path: [] }); assert.strictEqual(panels.callCount, 2); - assert.strictEqual(notebook.cellCount, index+1, 'Browsing must not run another cell'); - await manager.rendererMessage(editor, {...message,action:'listView',mode:'text'}); + assert.strictEqual(notebook.cellCount, index + 1, 'Browsing must not run another cell'); + await manager.rendererMessage(editor, { ...message, action: 'listView', mode: 'text' }); await vscode.commands.executeCommand('r.interactive.export', notebook.uri); - const report = new InteractiveSerializer().deserializeNotebook(fs.readFileSync(path.join(root,'list.rnb'))); - const item = report.cells.at(-1)?.outputs?.flatMap(output=>output.items).find(item=>item.mime===DISPLAY_MIME); assert.ok(item); - const exported = JSON.parse(Buffer.from(item.data).toString()) as Record; - assert.strictEqual(exported.listView,'text'); assert.strictEqual(exported.connected,false); - assert.strictEqual(exported.printedText,list.printedText); + const report = new InteractiveSerializer().deserializeNotebook( + fs.readFileSync(path.join(root, 'list.rnb')), + ); + const item = report.cells + .at(-1) + ?.outputs?.flatMap((output) => output.items) + .find((item) => item.mime === DISPLAY_MIME); + assert.ok(item); + const exported = JSON.parse(Buffer.from(item.data).toString()) as Record< + string, + unknown + >; + assert.strictEqual(exported.listView, 'text'); + assert.strictEqual(exported.connected, false); + assert.strictEqual(exported.printedText, list.printedText); } finally { replies.restore(); - panels.returnValues.forEach(panel => { panel.dispose(); }); panels.restore(); saved.restore(); format.restore(); + panels.returnValues.forEach((panel) => { + panel.dispose(); + }); + panels.restore(); + saved.restore(); + format.restore(); } }); @@ -1422,79 +2641,164 @@ cat("\n")`; const original = vscode.window.createWebviewPanel; const panels: vscode.WebviewPanel[] = []; const receivers: sinon.SinonSpy[] = []; - const create = sinon.stub(vscode.window, 'createWebviewPanel').callsFake((type, title, column, options) => { - const panel = original(type, title, column, options); - panels.push(panel); - receivers.push(sinon.spy(panel.webview, 'onDidReceiveMessage')); - return panel; - }); + const create = sinon + .stub(vscode.window, 'createWebviewPanel') + .callsFake((type, title, column, options) => { + const panel = original(type, title, column, options); + panels.push(panel); + receivers.push(sinon.spy(panel.webview, 'onDidReceiveMessage')); + return panel; + }); try { - await vscode.commands.executeCommand('r.runSelection', 'rebase_view <- structure(list(table=data.frame(value=1:2)), class="test_list"); View(rebase_view)'); - await until(() => panels.length === 1 && /const documentGeneration = \d+/.test(panels[0].webview.html)); + await vscode.commands.executeCommand( + 'r.runSelection', + 'rebase_view <- structure(list(table=data.frame(value=1:2)), class="test_list"); View(rebase_view)', + ); + await until( + () => + panels.length === 1 && + /const documentGeneration = \d+/.test(panels[0].webview.html), + ); assert.strictEqual(panels[0].title, 'rebase_view'); await vscode.commands.executeCommand('r.interactive.open', manifests[1]); - const documentGeneration = Number(/const documentGeneration = (\d+)/.exec(panels[0].webview.html)?.[1]); + const documentGeneration = Number( + /const documentGeneration = (\d+)/.exec(panels[0].webview.html)?.[1], + ); // VS Code delivers webview messages to every registered listener, // including the session tooltip and list navigation listeners. - await Promise.all(receivers[0].getCalls().map(call => { - const receive = call.args[0] as (message: unknown) => unknown; - return receive({ message: 'listview/view', documentGeneration, requestId: 1, path: [], index: 1 }); - })); + await Promise.all( + receivers[0].getCalls().map((call) => { + const receive = call.args[0] as (message: unknown) => unknown; + return receive({ + message: 'listview/view', + documentGeneration, + requestId: 1, + path: [], + index: 1, + }); + }), + ); await until(() => panels.length === 2); assert.strictEqual(panels[1].title, 'rebase_view$table'); } finally { - panels.forEach(panel => { panel.dispose(); }); - receivers.forEach(receiver => { receiver.restore(); }); + panels.forEach((panel) => { + panel.dispose(); + }); + receivers.forEach((receiver) => { + receiver.restore(); + }); create.restore(); } }); test('large native cells export their snapshot scope and open the full data viewer', async () => { await vscode.commands.executeCommand('r.interactive.open', manifests[0]); - const notebook = vscode.workspace.notebookDocuments.find(doc => doc.metadata.rSessionId === manifests[0].id); assert.ok(notebook); + const notebook = vscode.workspace.notebookDocuments.find( + (doc) => doc.metadata.rSessionId === manifests[0].id, + ); + assert.ok(notebook); const index = notebook.cellCount; - await vscode.commands.executeCommand('r.runSelection', 'data.frame(id=seq_len(832976871L))'); - await until(() => notebook.cellCount > index && notebook.cellAt(index).executionSummary?.success === true); - const custom = notebook.cellAt(index).outputs.flatMap(output => output.items).find(item => item.mime === DISPLAY_MIME); assert.ok(custom); + await vscode.commands.executeCommand( + 'r.runSelection', + 'data.frame(id=seq_len(832976871L))', + ); + await until( + () => + notebook.cellCount > index && + notebook.cellAt(index).executionSummary?.success === true, + ); + const custom = notebook + .cellAt(index) + .outputs.flatMap((output) => output.items) + .find((item) => item.mime === DISPLAY_MIME); + assert.ok(custom); const table = JSON.parse(Buffer.from(custom.data).toString()) as Record; assert.strictEqual(table.totalRows, 1000); assert.strictEqual(table.sourceRows, 832976871); assert.ok(table.fullViewId && table.fullViewId !== table.viewId); const file = path.join(root, 'large-preview.html'); const saved = sinon.stub(vscode.window, 'showSaveDialog').resolves(vscode.Uri.file(file)); - const format = sinon.stub(vscode.window, 'showQuickPick').resolves({ label: 'HTML report', format: 'html' } as vscode.QuickPickItem); + const format = sinon + .stub(vscode.window, 'showQuickPick') + .resolves({ label: 'HTML report', format: 'html' } as vscode.QuickPickItem); const panels = sinon.spy(vscode.window, 'createWebviewPanel'); 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')); + assert.ok( + fs.readFileSync(file, 'utf8').includes('Snapshot: first 1,000 of 832,976,871 rows'), + ); const manager = interactiveManager() as unknown as { messages: vscode.NotebookRendererMessaging; - rendererMessage(editor: vscode.NotebookEditor, message: Record): Promise; + rendererMessage( + editor: vscode.NotebookEditor, + message: Record, + ): Promise; }; - const editor = vscode.window.visibleNotebookEditors.find(editor => editor.notebook === notebook); assert.ok(editor); + const editor = vscode.window.visibleNotebookEditors.find( + (editor) => editor.notebook === notebook, + ); + assert.ok(editor); const replies = sinon.spy(manager.messages, 'postMessage'); try { - await manager.rendererMessage(editor, { displayId: table.displayId, generation: table.generation, action: 'page', start: 832976860, size: 20, live: true, refresh: true, requestId: 77 }); - const response = replies.lastCall.args[0] as { requestId: number; result: { startRow: number; live: boolean; rows: Record[] }; error?: string }; + await manager.rendererMessage(editor, { + displayId: table.displayId, + generation: table.generation, + action: 'page', + start: 832976860, + size: 20, + live: true, + refresh: true, + requestId: 77, + }); + const response = replies.lastCall.args[0] as { + requestId: number; + result: { startRow: number; live: boolean; rows: Record[] }; + error?: string; + }; assert.strictEqual(response.error, undefined); assert.strictEqual(response.requestId, 77); assert.strictEqual(response.result.live, true); assert.strictEqual(response.result.rows[10]['1'], 832976871); - assert.strictEqual(notebook.cellAt(index).outputs.flatMap(output => output.items).find(item => item.mime === DISPLAY_MIME), custom, 'Browsing must not replace the saved output'); - } finally { replies.restore(); } - await manager.rendererMessage(editor, { displayId: table.displayId, generation: table.generation, action: 'table' }); + assert.strictEqual( + notebook + .cellAt(index) + .outputs.flatMap((output) => output.items) + .find((item) => item.mime === DISPLAY_MIME), + custom, + 'Browsing must not replace the saved output', + ); + } finally { + replies.restore(); + } + await manager.rendererMessage(editor, { + displayId: table.displayId, + generation: table.generation, + action: 'table', + }); sinon.assert.calledOnce(panels); assert.match(panels.firstCall.args[1], /full table$/); - assert.strictEqual(notebook.cellCount, index + 1, 'Opening the full viewer must not execute another cell'); + assert.strictEqual( + notebook.cellCount, + index + 1, + 'Opening the full viewer must not execute another cell', + ); } finally { - for (const panel of panels.returnValues) { panel.dispose(); } - saved.restore(); format.restore(); panels.restore(); + for (const panel of panels.returnValues) { + panel.dispose(); + } + saved.restore(); + format.restore(); + panels.restore(); } }); test('groups two four-panel plot pages and exports every page without transient URLs', async function () { - if (!manifests[0].capabilities.jgd) { this.skip(); } - const notebook = vscode.workspace.notebookDocuments.find(doc => doc.metadata.rSessionId === manifests[0].id); + if (!manifests[0].capabilities.jgd) { + this.skip(); + } + const notebook = vscode.workspace.notebookDocuments.find( + (doc) => doc.metadata.rSessionId === manifests[0].id, + ); assert.ok(notebook); const errors = sinon.stub(vscode.window, 'showErrorMessage').resolves(undefined); const saved = sinon.stub(vscode.window, 'showSaveDialog'); @@ -1502,56 +2806,136 @@ cat("\n")`; try { await vscode.commands.executeCommand('r.interactive.open', manifests[0]); const index = notebook.cellCount; - await vscode.commands.executeCommand('r.runSelection', `par(mfrow=c(2,2), mar=c(3,3,2,2)) + await vscode.commands.executeCommand( + 'r.runSelection', + `par(mfrow=c(2,2), mar=c(3,3,2,2)) for (i in 1:8) { plot(rnorm(100)); title(main=paste("Plot", i)) } -par(mfrow=c(1,1))`); - const displays = (): Record[] => notebook.cellAt(index).outputs.flatMap(output => output.items) - .filter(item => item.mime === DISPLAY_MIME).map(item => JSON.parse(Buffer.from(item.data).toString()) as Record); - await until(() => notebook.cellCount > index && notebook.cellAt(index).executionSummary?.success === true && - displays().some(display => Array.isArray(display.pages) && display.pages.length === 2)); - assert.strictEqual(displays().filter(display => display.kind === 'plot').length, 1); - const gallery = displays().find(display => display.kind === 'plot'); assert.ok(gallery); +par(mfrow=c(1,1))`, + ); + const displays = (): Record[] => + notebook + .cellAt(index) + .outputs.flatMap((output) => output.items) + .filter((item) => item.mime === DISPLAY_MIME) + .map( + (item) => + JSON.parse(Buffer.from(item.data).toString()) as Record< + string, + unknown + >, + ); + await until( + () => + notebook.cellCount > index && + notebook.cellAt(index).executionSummary?.success === true && + displays().some( + (display) => Array.isArray(display.pages) && display.pages.length === 2, + ), + ); + assert.strictEqual(displays().filter((display) => display.kind === 'plot').length, 1); + const gallery = displays().find((display) => display.kind === 'plot'); + assert.ok(gallery); const pages = gallery.pages as Record[]; const context = bundleContext(); const manager = interactiveManager(context) as unknown as { - rendererMessage(editor: vscode.NotebookEditor, message: Record): Promise; + rendererMessage( + editor: vscode.NotebookEditor, + message: Record, + ): Promise; + }; + const editor = vscode.window.visibleNotebookEditors.find( + (editor) => editor.notebook === notebook, + ); + assert.ok(editor); + const message = { + displayId: gallery.displayId, + generation: gallery.generation, + action: 'plotPage', + selectedPlot: pages[0].displayId, }; - const editor = vscode.window.visibleNotebookEditors.find(editor => editor.notebook === notebook); assert.ok(editor); - const message = { displayId: gallery.displayId, generation: gallery.generation, action: 'plotPage', selectedPlot: pages[0].displayId }; await manager.rendererMessage(editor, message); const preferenceKey = `${manifests[0].id}:${String(gallery.generation)}:${String(gallery.displayId)}`; - const preferences = (): Map => new Map(context.workspaceState.get('r.interactive.outputViews', [])); + const preferences = (): Map => + new Map(context.workspaceState.get('r.interactive.outputViews', [])); assert.strictEqual(preferences().get(preferenceKey)?.selectedPlot, pages[0].displayId); - await manager.rendererMessage(editor, { ...message, selectedPlot: 'another-cells-plot' }); - assert.strictEqual(preferences().get(preferenceKey)?.selectedPlot, pages[0].displayId, 'A plot preference cannot target another cell'); + await manager.rendererMessage(editor, { + ...message, + selectedPlot: 'another-cells-plot', + }); + assert.strictEqual( + preferences().get(preferenceKey)?.selectedPlot, + pages[0].displayId, + 'A plot preference cannot target another cell', + ); const executionId: unknown = notebook.cellAt(index).metadata.rExecutionId; await vscode.commands.executeCommand('r.interactive.detach', notebook.uri); await vscode.commands.executeCommand('r.interactive.open', manifests[0]); - const restored = vscode.workspace.notebookDocuments.find(doc => doc.metadata.rSessionId === manifests[0].id); assert.ok(restored); - const restoredCell = (): vscode.NotebookCell | undefined => restored.getCells().find(cell => cell.metadata.rExecutionId === executionId); - await until(() => !!restoredCell()?.outputs.some(output => output.items.some(item => item.mime === DISPLAY_MIME))); - const restoredGallery = restoredCell()?.outputs.flatMap(output => output.items).find(item => item.mime === DISPLAY_MIME); assert.ok(restoredGallery); - assert.strictEqual((JSON.parse(Buffer.from(restoredGallery.data).toString()) as Record).selectedPlot, pages[0].displayId); + const restored = vscode.workspace.notebookDocuments.find( + (doc) => doc.metadata.rSessionId === manifests[0].id, + ); + assert.ok(restored); + const restoredCell = (): vscode.NotebookCell | undefined => + restored.getCells().find((cell) => cell.metadata.rExecutionId === executionId); + await until( + () => + !!restoredCell()?.outputs.some((output) => + output.items.some((item) => item.mime === DISPLAY_MIME), + ), + ); + const restoredGallery = restoredCell() + ?.outputs.flatMap((output) => output.items) + .find((item) => item.mime === DISPLAY_MIME); + assert.ok(restoredGallery); + assert.strictEqual( + ( + JSON.parse(Buffer.from(restoredGallery.data).toString()) as Record< + string, + unknown + > + ).selectedPlot, + pages[0].displayId, + ); for (const extension of ['rnb', 'ipynb', 'html']) { - const file = path.join(root, `gallery.${extension}`); saved.resolves(vscode.Uri.file(file)); + const file = path.join(root, `gallery.${extension}`); + saved.resolves(vscode.Uri.file(file)); format.resolves({ label: extension, format: extension } as vscode.QuickPickItem); await vscode.commands.executeCommand('r.interactive.export', restored.uri); if (extension === 'rnb') { - const report = new InteractiveSerializer().deserializeNotebook(fs.readFileSync(file)); - const gallery = report.cells.at(-1)?.outputs?.flatMap(output => output.items) - .filter(item => item.mime === DISPLAY_MIME).map(item => JSON.parse(Buffer.from(item.data).toString()) as Record).find(item => item.kind === 'plot'); - assert.ok(gallery); assert.strictEqual(gallery.connected, false); + const report = new InteractiveSerializer().deserializeNotebook( + fs.readFileSync(file), + ); + const gallery = report.cells + .at(-1) + ?.outputs?.flatMap((output) => output.items) + .filter((item) => item.mime === DISPLAY_MIME) + .map( + (item) => + JSON.parse(Buffer.from(item.data).toString()) as Record< + string, + unknown + >, + ) + .find((item) => item.kind === 'plot'); + assert.ok(gallery); + assert.strictEqual(gallery.connected, false); assert.strictEqual(gallery.selectedPlot, message.selectedPlot); - const pages = gallery.pages as Record[]; assert.strictEqual(pages.length, 2); + const pages = gallery.pages as Record[]; + assert.strictEqual(pages.length, 2); for (const [pageIndex, page] of pages.entries()) { - assert.strictEqual(page.url, undefined); assert.strictEqual(page.connected, false); + assert.strictEqual(page.url, undefined); + assert.strictEqual(page.connected, false); const svg = Buffer.from(String(page.svgData), 'base64').toString(); - for (let panel = 1; panel <= 4; panel++) { assert.ok(svg.includes(`Plot ${pageIndex * 4 + panel}`)); } + for (let panel = 1; panel <= 4; panel++) { + assert.ok(svg.includes(`Plot ${pageIndex * 4 + panel}`)); + } } } else if (extension === 'ipynb') { - const report = JSON.parse(fs.readFileSync(file, 'utf8')) as { cells: { outputs: { data?: Record }[] }[] }; - const last = report.cells.at(-1); assert.ok(last); - const plots = last.outputs.filter(output => output.data?.['image/svg+xml']); + const report = JSON.parse(fs.readFileSync(file, 'utf8')) as { + cells: { outputs: { data?: Record }[] }[]; + }; + const last = report.cells.at(-1); + assert.ok(last); + const plots = last.outputs.filter((output) => output.data?.['image/svg+xml']); assert.strictEqual(plots.length, 2); assert.match(String(plots[0].data?.['image/svg+xml']), /Plot 1/); assert.match(String(plots[1].data?.['image/svg+xml']), /Plot 8/); @@ -1561,12 +2945,20 @@ par(mfrow=c(1,1))`); } } sinon.assert.notCalled(errors); - } finally { errors.restore(); saved.restore(); format.restore(); } + } finally { + errors.restore(); + saved.restore(); + format.restore(); + } }); test('exports large compressed plots to portable notebooks and ordinary SVG report assets', async function () { - if (!manifests[0].capabilities.jgd) { this.skip(); } - const notebook = vscode.workspace.notebookDocuments.find(doc => doc.metadata.rSessionId === manifests[0].id); + if (!manifests[0].capabilities.jgd) { + this.skip(); + } + const notebook = vscode.workspace.notebookDocuments.find( + (doc) => doc.metadata.rSessionId === manifests[0].id, + ); assert.ok(notebook); const errors = sinon.stub(vscode.window, 'showErrorMessage').resolves(undefined); const saved = sinon.stub(vscode.window, 'showSaveDialog'); @@ -1574,16 +2966,38 @@ par(mfrow=c(1,1))`); const info = sinon.stub(vscode.window, 'showInformationMessage').resolves(undefined); try { const index = notebook.cellCount; - await vscode.commands.executeCommand('r.runSelection', 'plot(seq_len(22000), main="Large portable plot")'); - await until(() => notebook.cellCount > index && notebook.cellAt(index).executionSummary?.success === true); - const plot = (): Record | undefined => notebook.cellAt(index).outputs.flatMap(output => output.items) - .filter(item => item.mime === DISPLAY_MIME).map(item => JSON.parse(Buffer.from(item.data).toString()) as Record) - .find(data => data.kind === 'plot'); + await vscode.commands.executeCommand( + 'r.runSelection', + 'plot(seq_len(22000), main="Large portable plot")', + ); + await until( + () => + notebook.cellCount > index && + notebook.cellAt(index).executionSummary?.success === true, + ); + const plot = (): Record | undefined => + notebook + .cellAt(index) + .outputs.flatMap((output) => output.items) + .filter((item) => item.mime === DISPLAY_MIME) + .map( + (item) => + JSON.parse(Buffer.from(item.data).toString()) as Record< + string, + unknown + >, + ) + .find((data) => data.kind === 'plot'); await until(() => !!plot()); - const display = plot(); assert.ok(display); - const id = String(display.svg), assetRoot = path.join(root, manifests[0].id, 'assets'); + const display = plot(); + assert.ok(display); + const id = String(display.svg), + assetRoot = path.join(root, manifests[0].id, 'assets'); const svg = readAsset(assetRoot, id).toString(); - assert.ok(Buffer.byteLength(svg) > 2 * 1024 * 1024, 'Exercise plots too large for the asset RPC'); + assert.ok( + Buffer.byteLength(svg) > 2 * 1024 * 1024, + 'Exercise plots too large for the asset RPC', + ); assert.ok(id.endsWith('.svg.gz')); for (const extension of ['rnb', 'ipynb', 'html']) { const file = path.join(root, `report.${extension}`); @@ -1592,71 +3006,149 @@ par(mfrow=c(1,1))`); await vscode.commands.executeCommand('r.interactive.export', notebook.uri); sinon.assert.notCalled(errors); if (extension === 'html') { - assert.strictEqual(fs.readFileSync(path.join(file + '.assets', exportedAssetName(id)), 'utf8'), svg); + assert.strictEqual( + fs.readFileSync(path.join(file + '.assets', exportedAssetName(id)), 'utf8'), + svg, + ); assert.ok(fs.readFileSync(file, 'utf8').includes(exportedAssetName(id))); } else if (extension === 'rnb') { - const report = new InteractiveSerializer().deserializeNotebook(fs.readFileSync(file)); - assert.ok(report.cells.some(cell => cell.outputs?.some(output => output.items.some(item => - item.mime === 'image/svg+xml' && Buffer.from(item.data).toString() === svg)))); + const report = new InteractiveSerializer().deserializeNotebook( + fs.readFileSync(file), + ); + assert.ok( + report.cells.some((cell) => + cell.outputs?.some((output) => + output.items.some( + (item) => + item.mime === 'image/svg+xml' && + Buffer.from(item.data).toString() === svg, + ), + ), + ), + ); } else { - const report = JSON.parse(fs.readFileSync(file, 'utf8')) as { cells: { outputs: { data?: Record }[] }[] }; - assert.ok(report.cells.some(cell => cell.outputs.some(output => output.data?.['image/svg+xml'] === svg))); + const report = JSON.parse(fs.readFileSync(file, 'utf8')) as { + cells: { outputs: { data?: Record }[] }[]; + }; + assert.ok( + report.cells.some((cell) => + cell.outputs.some((output) => output.data?.['image/svg+xml'] === svg), + ), + ); } } await vscode.commands.executeCommand('r.interactive.cleanAssets', notebook.uri); assert.ok(info.calledOnce); assert.strictEqual(readAsset(assetRoot, id).toString(), svg); sinon.assert.notCalled(errors); - } finally { errors.restore(); saved.restore(); format.restore(); info.restore(); } + } finally { + errors.restore(); + saved.restore(); + format.restore(); + info.restore(); + } }); test('applies asset quota settings to connected sessions without restarting R', async () => { const configuration = vscode.workspace.getConfiguration('r'); const previous = configuration.inspect('interactive.maxAssetSizeMiB')?.globalValue; - const client = new AgentClient(manifests[0]); await client.connect(); + const client = new AgentClient(manifests[0]); + await client.connect(); const pid = manifests[0].rPid; try { - await configuration.update('interactive.maxAssetSizeMiB', 64.1, vscode.ConfigurationTarget.Global); + await configuration.update( + 'interactive.maxAssetSizeMiB', + 64.1, + vscode.ConfigurationTarget.Global, + ); const expectedBytes = Math.floor(64.1 * 1024 * 1024); let stats: AssetStorageStats | undefined; const deadline = Date.now() + 10000; do { stats = await client.request('assetStorage'); - if (stats.limitBytes === expectedBytes) { break; } - await new Promise(resolve => setTimeout(resolve, 50)); + if (stats.limitBytes === expectedBytes) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 50)); } while (Date.now() < deadline); assert.strictEqual(stats.limitBytes, expectedBytes); assert.strictEqual((await client.snapshot()).manifest.rPid, pid); - const config = JSON.parse(fs.readFileSync(path.join(root, manifests[0].id, 'config.json'), 'utf8')) as AgentConfig; + const config = JSON.parse( + fs.readFileSync(path.join(root, manifests[0].id, 'config.json'), 'utf8'), + ) as AgentConfig; assert.strictEqual(config.maxAssetBytes, expectedBytes); } finally { - await configuration.update('interactive.maxAssetSizeMiB', previous, vscode.ConfigurationTarget.Global); + await configuration.update( + 'interactive.maxAssetSizeMiB', + previous, + vscode.ConfigurationTarget.Global, + ); client.close(); } }); test('clearing completed cells preserves drafts, running output, and durable history', async () => { - const notebook = vscode.workspace.notebookDocuments.find(doc => doc.metadata.rSessionId === manifests[0].id); + const notebook = vscode.workspace.notebookDocuments.find( + (doc) => doc.metadata.rSessionId === manifests[0].id, + ); assert.ok(notebook); const index = notebook.cellCount; const edit = new vscode.WorkspaceEdit(); - edit.set(notebook.uri, [vscode.NotebookEdit.insertCells(index, [ - new vscode.NotebookCellData(vscode.NotebookCellKind.Code, 'Sys.sleep(2); cat("retained-after-clear")', 'r'), - new vscode.NotebookCellData(vscode.NotebookCellKind.Code, '# unfinished draft', 'r'), - ])]); + edit.set(notebook.uri, [ + vscode.NotebookEdit.insertCells(index, [ + new vscode.NotebookCellData( + vscode.NotebookCellKind.Code, + 'Sys.sleep(2); cat("retained-after-clear")', + 'r', + ), + new vscode.NotebookCellData( + vscode.NotebookCellKind.Code, + '# unfinished draft', + 'r', + ), + ]), + ]); await vscode.workspace.applyEdit(edit); - await vscode.commands.executeCommand('notebook.cell.execute', { ranges: [{ start: index, end: index + 1 }], document: notebook.uri }); + await vscode.commands.executeCommand('notebook.cell.execute', { + ranges: [{ start: index, end: index + 1 }], + document: notebook.uri, + }); await until(() => manifests[0].status === 'busy'); await vscode.commands.executeCommand('r.interactive.clear', notebook.uri); - assert.ok(notebook.getCells().some(cell => cell.document.getText() === '# unfinished draft')); - assert.ok(!notebook.getCells().some(cell => cell.document.getText().includes('editor_value <- 42'))); - await until(() => notebook.getCells().some(cell => cell.outputs.some(output => output.items.some(item => - Buffer.from(item.data).toString().includes('retained-after-clear'))))); - const client = new AgentClient(manifests[0]); await client.connect(); - try { assert.ok((await client.snapshot()).executions.some(record => record.code.includes('editor_value <- 42'))); } - finally { client.close(); } + assert.ok( + notebook.getCells().some((cell) => cell.document.getText() === '# unfinished draft'), + ); + assert.ok( + !notebook + .getCells() + .some((cell) => cell.document.getText().includes('editor_value <- 42')), + ); + await until(() => + notebook + .getCells() + .some((cell) => + cell.outputs.some((output) => + output.items.some((item) => + Buffer.from(item.data).toString().includes('retained-after-clear'), + ), + ), + ), + ); + const client = new AgentClient(manifests[0]); + await client.connect(); + try { + assert.ok( + (await client.snapshot()).executions.some((record) => + record.code.includes('editor_value <- 42'), + ), + ); + } finally { + client.close(); + } }); test('keeps independent notebooks and reconnects to the original R environment', async () => { await vscode.commands.executeCommand('r.interactive.open', manifests[1]); - const documents = vscode.workspace.notebookDocuments.filter(doc => manifests.some(m => m.id === doc.metadata.rSessionId)); + const documents = vscode.workspace.notebookDocuments.filter((doc) => + manifests.some((m) => m.id === doc.metadata.rSessionId), + ); assert.strictEqual(documents.length, 2); await vscode.commands.executeCommand('r.interactive.detach'); await vscode.commands.executeCommand('r.interactive.open', manifests[0]); @@ -1664,70 +3156,107 @@ par(mfrow=c(1,1))`); try { await client.connect(); const snapshot = await client.snapshot(); - assert.ok(snapshot.executions.some(record => record.code.includes('editor_value'))); + assert.ok(snapshot.executions.some((record) => record.code.includes('editor_value'))); assert.ok(snapshot.workspace?.globalenv); - } finally { client.close(); } + } finally { + client.close(); + } }); test('Workspace follows native focus and bound sources, and actions retain their displayed owner', async () => { const workspace = workspaceProvider; const nodes = async (): Promise => { const provider = workspace(); - const root = (await provider.getChildren()).find(item => item.id === 'globalenv'); - return await provider.getChildren(root) as GlobalEnvItem[]; + const root = (await provider.getChildren()).find((item) => item.id === 'globalenv'); + return (await provider.getChildren(root)) as GlobalEnvItem[]; }; const focus = async (index: number): Promise => { await vscode.commands.executeCommand('r.interactive.open', manifests[index]); - const notebook = vscode.workspace.notebookDocuments.find(doc => doc.metadata.rSessionId === manifests[index].id); + const notebook = vscode.workspace.notebookDocuments.find( + (doc) => doc.metadata.rSessionId === manifests[index].id, + ); assert.ok(notebook); - await vscode.commands.executeCommand('interactive.open', { preserveFocus: false }, notebook.uri); + await vscode.commands.executeCommand( + 'interactive.open', + { preserveFocus: false }, + notebook.uri, + ); await vscode.commands.executeCommand('interactive.input.focus'); - await until(() => workspace().owner?.sessionId === `${manifests[index].id}:${manifests[index].generation}`); + await until( + () => + workspace().owner?.sessionId === + `${manifests[index].id}:${manifests[index].generation}`, + ); }; for (const index of [0, 1]) { await focus(index); - await vscode.commands.executeCommand('r.runSelection', `workspace_marker <- rep(${index}, ${index + 1}); workspace_list <- list(owner = ${index})`); + await vscode.commands.executeCommand( + 'r.runSelection', + `workspace_marker <- rep(${index}, ${index + 1}); workspace_list <- list(owner = ${index})`, + ); await until(() => workspace().data?.globalenv.workspace_marker?.length === index + 1); } await focus(0); - const oldNode = (await nodes()).find(node => node.label === 'workspace_marker'); assert.ok(oldNode); - const source = await vscode.workspace.openTextDocument({ language: 'r', content: 'workspace_marker' }); + const oldNode = (await nodes()).find((node) => node.label === 'workspace_marker'); + assert.ok(oldNode); + const source = await vscode.workspace.openTextDocument({ + language: 'r', + content: 'workspace_marker', + }); await vscode.window.showTextDocument(source); await vscode.commands.executeCommand('r.interactive.bindDocument'); await focus(1); await vscode.window.showTextDocument(source); await until(() => workspace().owner === oldNode.owner); assert.strictEqual(workspace().data?.globalenv.workspace_marker.length, 1); - const list = (await nodes()).find(node => node.label === 'workspace_list'); assert.ok(list); + const list = (await nodes()).find((node) => node.label === 'workspace_list'); + assert.ok(list); const provider = workspace(); const ownerBefore = provider.owner?.sessionId; - const generationBefore = (provider as unknown as { childPageGeneration: number }).childPageGeneration; + const generationBefore = (provider as unknown as { childPageGeneration: number }) + .childPageGeneration; const children = await provider.getChildren(list); const ownerAfter = provider.owner?.sessionId; - const generationAfter = (provider as unknown as { childPageGeneration: number }).childPageGeneration; - assert.ok(children.some(child => String(child.description).includes('0')), JSON.stringify({ - expectedOwner: oldNode.owner?.sessionId, - itemOwner: list.owner?.sessionId, - ownerBefore, - ownerAfter, - generationBefore, - generationAfter, - displayedList: provider.data?.globalenv.workspace_list, - returnedChildren: children.map(child => ({ label: child.label, description: child.description })), - })); + const generationAfter = (provider as unknown as { childPageGeneration: number }) + .childPageGeneration; + assert.ok( + children.some((child) => String(child.description).includes('0')), + JSON.stringify({ + expectedOwner: oldNode.owner?.sessionId, + itemOwner: list.owner?.sessionId, + ownerBefore, + ownerAfter, + generationBefore, + generationAfter, + displayedList: provider.data?.globalenv.workspace_list, + returnedChildren: children.map((child) => ({ + label: child.label, + description: child.description, + })), + }), + ); await focus(1); await vscode.commands.executeCommand('r.workspaceViewer.remove', oldNode); - const firstClient = new AgentClient(manifests[0]); await firstClient.connect({ claim: false }); + const firstClient = new AgentClient(manifests[0]); + await firstClient.connect({ claim: false }); try { let removed = false; const deadline = Date.now() + 10000; while (!removed && Date.now() < deadline) { - removed = !((await firstClient.snapshot()).workspace as WorkspaceData | undefined)?.globalenv.workspace_marker; - if (!removed) { await new Promise(resolve => setTimeout(resolve, 50)); } + removed = !((await firstClient.snapshot()).workspace as WorkspaceData | undefined) + ?.globalenv.workspace_marker; + if (!removed) { + await new Promise((resolve) => setTimeout(resolve, 50)); + } } assert.ok(removed, 'Remove applies to the displayed node owner'); - assert.strictEqual(workspace().owner?.sessionId, `${manifests[1].id}:${manifests[1].generation}`); + assert.strictEqual( + workspace().owner?.sessionId, + `${manifests[1].id}:${manifests[1].generation}`, + ); assert.strictEqual(workspace().data?.globalenv.workspace_marker.length, 2); - } finally { firstClient.close(); } + } finally { + firstClient.close(); + } // Closing/detaching an active view must not leave live-looking objects behind. await vscode.commands.executeCommand('r.interactive.detach', manifests[1]); assert.strictEqual(workspace().owner, undefined); @@ -1736,16 +3265,25 @@ par(mfrow=c(1,1))`); }); test('native toolbar actions target their notebook while another session is active', async () => { await vscode.commands.executeCommand('r.interactive.open', manifests[0]); - const notebook = vscode.workspace.notebookDocuments.find(doc => doc.metadata.rSessionId === manifests[0].id); + const notebook = vscode.workspace.notebookDocuments.find( + (doc) => doc.metadata.rSessionId === manifests[0].id, + ); assert.ok(notebook); await vscode.commands.executeCommand('r.interactive.open', manifests[1]); // VS Code serializes the notebook toolbar's editor to { notebookUri }. - const target = { notebookEditor: { notebookUri: notebook.uri }, source: 'notebookToolbar', ui: true }; + const target = { + notebookEditor: { notebookUri: notebook.uri }, + source: 'notebookToolbar', + ui: true, + }; const originalName = manifests[0].label; const otherName = manifests[1].label; const errors = sinon.stub(vscode.window, 'showErrorMessage').resolves(undefined); const confirm = sinon.stub(vscode.window, 'showWarningMessage').resolves(undefined); - const picker = sinon.stub(vscode.window, 'showQuickPick').resolves({ label: 'Rename…', command: 'r.interactive.rename' } as vscode.QuickPickItem); + const picker = sinon.stub(vscode.window, 'showQuickPick').resolves({ + label: 'Rename…', + command: 'r.interactive.rename', + } as vscode.QuickPickItem); const input = sinon.stub(vscode.window, 'showInputBox').resolves('Toolbar target'); try { await vscode.commands.executeCommand('r.interactive.sessionActions', target); @@ -1758,74 +3296,162 @@ par(mfrow=c(1,1))`); assert.match(confirm.lastCall.args[0], /Toolbar target/); await vscode.commands.executeCommand('r.interactive.stop', target); assert.match(confirm.lastCall.args[0], /Toolbar target/); - assert.strictEqual(manifests[0].status, 'idle', 'Cancelling a confirmation must leave R alive'); + assert.strictEqual( + manifests[0].status, + 'idle', + 'Cancelling a confirmation must leave R alive', + ); assert.strictEqual(manifests[1].status, 'idle'); sinon.assert.notCalled(errors); await vscode.commands.executeCommand('r.interactive.rename', target, originalName); - } finally { errors.restore(); confirm.restore(); picker.restore(); input.restore(); } + } finally { + errors.restore(); + confirm.restore(); + picker.restore(); + input.restore(); + } }); test('toolbar actions never fall back to another session for an unrelated notebook', async () => { const errors = sinon.stub(vscode.window, 'showErrorMessage').resolves(undefined); const confirm = sinon.stub(vscode.window, 'showWarningMessage').resolves(undefined); try { await vscode.commands.executeCommand('r.interactive.stop', { - notebookEditor: { notebookUri: vscode.Uri.parse('untitled:unrelated.interactive') }, source: 'notebookToolbar', ui: true, + notebookEditor: { notebookUri: vscode.Uri.parse('untitled:unrelated.interactive') }, + source: 'notebookToolbar', + ui: true, }); sinon.assert.calledOnce(errors); assert.match(errors.firstCall.args[0], /not connected to an R Interactive session/); sinon.assert.notCalled(confirm); - } finally { errors.restore(); confirm.restore(); } + } finally { + errors.restore(); + confirm.restore(); + } }); test('session actions target their tree entry even when another session is active', async () => { await vscode.commands.executeCommand('r.interactive.open', manifests[1]); const secondName = manifests[1].label; - await vscode.commands.executeCommand('r.interactive.rename', manifests[0], 'Renamed first session'); + await vscode.commands.executeCommand( + 'r.interactive.rename', + manifests[0], + 'Renamed first session', + ); assert.strictEqual(manifests[0].label, 'Renamed first session'); assert.strictEqual(manifests[1].label, secondName); await vscode.commands.executeCommand('r.interactive.detach', manifests[0]); - const second = new AgentClient(manifests[1]); await second.connect(); - try { assert.strictEqual(second.control, false, 'The second session must remain controlled by its original window'); } - finally { second.close(); } + const second = new AgentClient(manifests[1]); + await second.connect(); + try { + assert.strictEqual( + second.control, + false, + 'The second session must remain controlled by its original window', + ); + } finally { + second.close(); + } }); test('Stop Session adds one notice to its own notebook and source execution can choose a live replacement', async () => { await vscode.commands.executeCommand('r.interactive.open', manifests[0]); - const source = await vscode.workspace.openTextDocument({ language: 'r', content: 'replacement_target_value <- 7' }); + const source = await vscode.workspace.openTextDocument({ + language: 'r', + content: 'replacement_target_value <- 7', + }); await vscode.window.showTextDocument(source); await vscode.commands.executeCommand('r.interactive.bindDocument'); - const notebook = vscode.workspace.notebookDocuments.find(doc => doc.metadata.rSessionId === manifests[0].id); + const notebook = vscode.workspace.notebookDocuments.find( + (doc) => doc.metadata.rSessionId === manifests[0].id, + ); assert.ok(notebook); - const confirmation = sinon.stub(vscode.window, 'showWarningMessage').resolves('Stop Session' as unknown as vscode.MessageItem); - const picker = sinon.stub(vscode.window, 'showQuickPick').resolves({ label: manifests[1].label, manifest: manifests[1] } as vscode.QuickPickItem); + const confirmation = sinon + .stub(vscode.window, 'showWarningMessage') + .resolves('Stop Session' as unknown as vscode.MessageItem); + const picker = sinon.stub(vscode.window, 'showQuickPick').resolves({ + label: manifests[1].label, + manifest: manifests[1], + } as vscode.QuickPickItem); const info = sinon.stub(vscode.window, 'showInformationMessage').resolves(undefined); try { await vscode.commands.executeCommand('r.interactive.stop', notebook.uri); - await until(() => notebook.getCells().some(cell => cell.metadata.rNoticeKind === 'stopped')); - const displays = (): Record[] => notebook.getCells().flatMap(cell => cell.outputs).flatMap(output => output.items) - .filter(item => item.mime === DISPLAY_MIME).map(item => JSON.parse(Buffer.from(item.data).toString()) as Record); - await until(() => displays().length > 0 && displays().every(data => data.running === false)); + await until(() => + notebook.getCells().some((cell) => cell.metadata.rNoticeKind === 'stopped'), + ); + const displays = (): Record[] => + notebook + .getCells() + .flatMap((cell) => cell.outputs) + .flatMap((output) => output.items) + .filter((item) => item.mime === DISPLAY_MIME) + .map( + (item) => + JSON.parse(Buffer.from(item.data).toString()) as Record< + string, + unknown + >, + ); + await until( + () => displays().length > 0 && displays().every((data) => data.running === false), + ); const ended = manifests[0].ended; assert.ok(typeof ended === 'number' && ended >= manifests[0].created); - assert.strictEqual((JSON.parse(fs.readFileSync(path.join(root, manifests[0].id, 'manifest.json'), 'utf8')) as SessionManifest).ended, ended); - const notice = notebook.getCells().find(cell => cell.metadata.rNoticeKind === 'stopped'); - const drafts = vscode.workspace.textDocuments.filter(doc => doc.uri.scheme === 'vscode-interactive-input'); - const contents = drafts.map(doc => doc.getText()); + assert.strictEqual( + ( + JSON.parse( + fs.readFileSync(path.join(root, manifests[0].id, 'manifest.json'), 'utf8'), + ) as SessionManifest + ).ended, + ended, + ); + const notice = notebook + .getCells() + .find((cell) => cell.metadata.rNoticeKind === 'stopped'); + const drafts = vscode.workspace.textDocuments.filter( + (doc) => doc.uri.scheme === 'vscode-interactive-input', + ); + const contents = drafts.map((doc) => doc.getText()); await vscode.commands.executeCommand('r.interactive.reuseCell', notice); - assert.deepStrictEqual(drafts.map(doc => doc.getText()), contents, 'A lifecycle notice must not become executable R code'); + assert.deepStrictEqual( + drafts.map((doc) => doc.getText()), + contents, + 'A lifecycle notice must not become executable R code', + ); assert.match(info.lastCall.args[0], /Select an R code cell/); await vscode.commands.executeCommand('r.interactive.source', notice); assert.match(info.lastCall.args[0], /no source location/); await vscode.commands.executeCommand('r.interactive.stop', notebook.uri); - assert.strictEqual(manifests[0].ended, ended, 'Repeated Stop must not extend the recorded lifetime'); - assert.strictEqual(notebook.getCells().filter(cell => cell.metadata.rNoticeKind === 'stopped').length, 1); + assert.strictEqual( + manifests[0].ended, + ended, + 'Repeated Stop must not extend the recorded lifetime', + ); + assert.strictEqual( + notebook.getCells().filter((cell) => cell.metadata.rNoticeKind === 'stopped') + .length, + 1, + ); assert.notStrictEqual(manifests[1].status, 'exited'); await vscode.commands.executeCommand('r.runSelection', 'replacement_target_value <- 7'); sinon.assert.calledOnce(picker); - const choices = await picker.firstCall.args[0] as (vscode.QuickPickItem & { manifest?: SessionManifest })[]; - assert.ok(!choices.some(item => item.manifest?.id === manifests[0].id)); - const client = new AgentClient(manifests[1]); await client.connect(); - try { assert.ok((await client.snapshot()).executions.some(record => record.code === 'replacement_target_value <- 7')); } - finally { client.close(); } - } finally { confirmation.restore(); picker.restore(); info.restore(); } + const choices = (await picker.firstCall.args[0]) as (vscode.QuickPickItem & { + manifest?: SessionManifest; + })[]; + assert.ok(!choices.some((item) => item.manifest?.id === manifests[0].id)); + const client = new AgentClient(manifests[1]); + await client.connect(); + try { + assert.ok( + (await client.snapshot()).executions.some( + (record) => record.code === 'replacement_target_value <- 7', + ), + ); + } finally { + client.close(); + } + } finally { + confirmation.restore(); + picker.restore(); + info.restore(); + } }); test('restart keeps the window, drafts, transcript, bindings and kernel through multiple R processes', async function () { @@ -1838,38 +3464,81 @@ par(mfrow=c(1,1))`); let restartController: vscode.NotebookController; try { await vscode.commands.executeCommand('r.interactive.open', original); - const native = created.returnValues.find(value => value.notebookType === 'interactive'); - assert.ok(native); restartController = native; - } finally { created.restore(); } - const notebook = vscode.workspace.notebookDocuments.find(doc => doc.metadata.rSessionId === original.id); + const native = created.returnValues.find( + (value) => value.notebookType === 'interactive', + ); + assert.ok(native); + restartController = native; + } finally { + created.restore(); + } + const notebook = vscode.workspace.notebookDocuments.find( + (doc) => doc.metadata.rSessionId === original.id, + ); assert.ok(notebook); - const beforeCode = 'before_restart <- 42; cat("before restart"); data.frame(x=1:50); plot(1:3)'; + const beforeCode = + 'before_restart <- 42; cat("before restart"); data.frame(x=1:50); plot(1:3)'; await vscode.commands.executeCommand('r.runSelection', beforeCode); - await until(() => notebook.getCells().some(cell => cell.document.getText() === beforeCode && cell.executionSummary?.success === true)); - const oldCell = notebook.getCells().find(cell => cell.document.getText() === beforeCode); + await until(() => + notebook + .getCells() + .some( + (cell) => + cell.document.getText() === beforeCode && + cell.executionSummary?.success === true, + ), + ); + const oldCell = notebook.getCells().find((cell) => cell.document.getText() === beforeCode); assert.ok(oldCell); - const displays = (cell: vscode.NotebookCell): Record[] => cell.outputs.flatMap(output => output.items) - .filter(item => item.mime === DISPLAY_MIME).map(item => JSON.parse(Buffer.from(item.data).toString()) as Record); - await until(() => displays(oldCell).some(data => data.kind === 'plot')); + const displays = (cell: vscode.NotebookCell): Record[] => + cell.outputs + .flatMap((output) => output.items) + .filter((item) => item.mime === DISPLAY_MIME) + .map( + (item) => + JSON.parse(Buffer.from(item.data).toString()) as Record, + ); + await until(() => displays(oldCell).some((data) => data.kind === 'plot')); const oldUri = oldCell.document.uri.toString(); const oldId = oldCell.metadata.rExecutionId as string; const oldCount = notebook.cellCount; const windowCount = vscode.workspace.notebookDocuments.length; - const input = await vscode.commands.executeCommand<{ inputUri: vscode.Uri }>('interactive.open', - { preserveFocus: true }, notebook.uri, `REditorSupport.r/r-${original.id}-${original.generation}-interactive`); + const input = await vscode.commands.executeCommand<{ inputUri: vscode.Uri }>( + 'interactive.open', + { preserveFocus: true }, + notebook.uri, + `REditorSupport.r/r-${original.id}-${original.generation}-interactive`, + ); const draft = await vscode.workspace.openTextDocument(input.inputUri); const edit = new vscode.WorkspaceEdit(); edit.insert(draft.uri, new vscode.Position(0, 0), 'unsent_draft <- 99'); - edit.insert(oldCell.document.uri, oldCell.document.positionAt(oldCell.document.getText().length), '\n# edited after execution'); + edit.insert( + oldCell.document.uri, + oldCell.document.positionAt(oldCell.document.getText().length), + '\n# edited after execution', + ); await vscode.workspace.applyEdit(edit); - const source = await vscode.workspace.openTextDocument({ language: 'r', content: 'cat("bound source")' }); - await vscode.window.showTextDocument(source, { viewColumn: vscode.ViewColumn.One, preserveFocus: false }); + const source = await vscode.workspace.openTextDocument({ + language: 'r', + content: 'cat("bound source")', + }); + await vscode.window.showTextDocument(source, { + viewColumn: vscode.ViewColumn.One, + preserveFocus: false, + }); await vscode.commands.executeCommand('r.interactive.bindDocument'); let sawRestarting = false; - const change = vscode.workspace.onDidChangeNotebookDocument(event => { - if (event.notebook === notebook && event.notebook.getCells().some(cell => cell.metadata.rNoticeKind === 'restarting')) { sawRestarting = true; } + const change = vscode.workspace.onDidChangeNotebookDocument((event) => { + if ( + event.notebook === notebook && + event.notebook.getCells().some((cell) => cell.metadata.rNoticeKind === 'restarting') + ) { + sawRestarting = true; + } }); - const confirmation = sinon.stub(vscode.window, 'showWarningMessage').resolves('Restart Session' as unknown as vscode.MessageItem); + const confirmation = sinon + .stub(vscode.window, 'showWarningMessage') + .resolves('Restart Session' as unknown as vscode.MessageItem); const errors = sinon.stub(vscode.window, 'showErrorMessage').resolves(undefined); // R shutdown can also emit Language Server notifications; assert only // the errors reported by the Interactive manager. @@ -1877,10 +3546,20 @@ par(mfrow=c(1,1))`); const kernels = sinon.spy(vscode.notebooks, 'createNotebookController'); let client: AgentClient | undefined; let executable: sinon.SinonStub | undefined; - const supervision = vscode.workspace.getConfiguration('r').inspect('interactive.supervision')?.globalValue; - const previousNode = vscode.workspace.getConfiguration('r').inspect('interactive.nodePath')?.globalValue; - const limitSettings = ['interactive.maxOutputSizeMiB', 'interactive.maxJournalSizeMiB', 'interactive.maxAssetSizeMiB']; - const previousLimits = limitSettings.map(key => vscode.workspace.getConfiguration('r').inspect(key)?.globalValue); + const supervision = vscode.workspace + .getConfiguration('r') + .inspect('interactive.supervision')?.globalValue; + const previousNode = vscode.workspace + .getConfiguration('r') + .inspect('interactive.nodePath')?.globalValue; + const limitSettings = [ + 'interactive.maxOutputSizeMiB', + 'interactive.maxJournalSizeMiB', + 'interactive.maxAssetSizeMiB', + ]; + const previousLimits = limitSettings.map( + (key) => vscode.workspace.getConfiguration('r').inspect(key)?.globalValue, + ); try { // A cancelled restart must leave the process and transcript alone. confirmation.resolves(undefined); @@ -1892,41 +3571,78 @@ par(mfrow=c(1,1))`); // process: failed preflight must never reach Stop or change its journal. const launchFile = path.join(root, original.id, 'config.json'); const savedLaunch = fs.readFileSync(launchFile, 'utf8'); - const arfSetting = vscode.workspace.getConfiguration('r').inspect('interactive.arfPath')?.globalValue; + const arfSetting = vscode.workspace + .getConfiguration('r') + .inspect('interactive.arfPath')?.globalValue; try { const missingArf = path.join(root, 'removed-arf'); - fs.writeFileSync(launchFile, JSON.stringify({ ...JSON.parse(savedLaunch) as AgentConfig, provider: 'arf', arfPath: missingArf })); - await vscode.workspace.getConfiguration('r').update('interactive.arfPath', missingArf, vscode.ConfigurationTarget.Global); - confirmation.callsFake(message => message.startsWith('Restart R session') - ? Promise.resolve('Restart Session' as unknown as vscode.MessageItem) : new Promise(() => { /* Leave the nonmodal warning open. */ })); + fs.writeFileSync( + launchFile, + JSON.stringify({ + ...(JSON.parse(savedLaunch) as AgentConfig), + provider: 'arf', + arfPath: missingArf, + }), + ); + await vscode.workspace + .getConfiguration('r') + .update('interactive.arfPath', missingArf, vscode.ConfigurationTarget.Global); + confirmation.callsFake((message) => + message.startsWith('Restart R session') + ? Promise.resolve('Restart Session' as unknown as vscode.MessageItem) + : new Promise(() => { + /* Leave the nonmodal warning open. */ + }), + ); await vscode.commands.executeCommand('r.interactive.restart', notebook.uri); - assert.ok(confirmation.args.some(args => args[0].includes('The current R session has not been stopped.'))); - const stillRunning = JSON.parse(fs.readFileSync(path.join(root, original.id, 'manifest.json'), 'utf8')) as SessionManifest; + assert.ok( + confirmation.args.some((args) => + args[0].includes('The current R session has not been stopped.'), + ), + ); + const stillRunning = JSON.parse( + fs.readFileSync(path.join(root, original.id, 'manifest.json'), 'utf8'), + ) as SessionManifest; assert.strictEqual(stillRunning.rPid, original.rPid); assert.strictEqual(stillRunning.status, 'idle'); assert.strictEqual(notebook.metadata.rGeneration, original.generation); assert.strictEqual(notebook.cellCount, oldCount); - assert.ok(restartController.label.endsWith(' · idle'), 'An undismissed setup warning must not keep R in Restarting'); - assert.ok(original.rPid); process.kill(original.rPid, 0); + assert.ok( + restartController.label.endsWith(' · idle'), + 'An undismissed setup warning must not keep R in Restarting', + ); + assert.ok(original.rPid); + process.kill(original.rPid, 0); sinon.assert.notCalled(interactiveErrors); } finally { fs.writeFileSync(launchFile, savedLaunch); - await vscode.workspace.getConfiguration('r').update('interactive.arfPath', arfSetting, vscode.ConfigurationTarget.Global); + await vscode.workspace + .getConfiguration('r') + .update('interactive.arfPath', arfSetting, vscode.ConfigurationTarget.Global); confirmation.resolves('Restart Session' as unknown as vscode.MessageItem); } // A missing Node runtime must be detected before stopping the current R. - executable = sinon.stub(process, 'execPath').value(path.join(root, 'removed-host-runtime')); + executable = sinon + .stub(process, 'execPath') + .value(path.join(root, 'removed-host-runtime')); await vscode.commands.executeCommand('r.interactive.restart', notebook.uri); sinon.assert.calledOnce(interactiveErrors); assert.match(interactiveErrors.firstCall.args[0], /Reload VS Code/); assert.strictEqual(notebook.cellCount, oldCount); assert.strictEqual(notebook.metadata.rGeneration, original.generation); assert.ok(restartController.label.endsWith(' · idle')); - assert.ok(original.rPid); process.kill(original.rPid, 0); + assert.ok(original.rPid); + process.kill(original.rPid, 0); executable?.restore(); errors.resetHistory(); // An explicit missing override must also leave the current R process alone. - await vscode.workspace.getConfiguration('r').update('interactive.nodePath', path.join(root, 'missing-restart-node'), vscode.ConfigurationTarget.Global); + await vscode.workspace + .getConfiguration('r') + .update( + 'interactive.nodePath', + path.join(root, 'missing-restart-node'), + vscode.ConfigurationTarget.Global, + ); await vscode.commands.executeCommand('r.interactive.restart', notebook.uri); sinon.assert.calledOnce(interactiveErrors); assert.match(interactiveErrors.firstCall.args[0], /r\.interactive\.nodePath/); @@ -1934,18 +3650,27 @@ par(mfrow=c(1,1))`); assert.strictEqual(notebook.metadata.rGeneration, original.generation); assert.ok(restartController.label.endsWith(' · idle')); process.kill(original.rPid, 0); - await vscode.workspace.getConfiguration('r').update('interactive.nodePath', '', vscode.ConfigurationTarget.Global); + await vscode.workspace + .getConfiguration('r') + .update('interactive.nodePath', '', vscode.ConfigurationTarget.Global); errors.resetHistory(); // Losing tmux must not stop the live R process. This preflight uses the // current setting even though the saved session used detached launch. - await vscode.workspace.getConfiguration('r').update('interactive.supervision', 'tmux', vscode.ConfigurationTarget.Global); + await vscode.workspace + .getConfiguration('r') + .update('interactive.supervision', 'tmux', vscode.ConfigurationTarget.Global); const previousPath = process.env.PATH; try { process.env.PATH = root; await vscode.commands.executeCommand('r.interactive.restart', notebook.uri); - } finally { process.env.PATH = previousPath; } + } finally { + process.env.PATH = previousPath; + } sinon.assert.calledOnce(interactiveErrors); - assert.match(interactiveErrors.firstCall.args[0], /Cannot find an executable tmux.*r\.interactive\.supervision/); + assert.match( + interactiveErrors.firstCall.args[0], + /Cannot find an executable tmux.*r\.interactive\.supervision/, + ); assert.strictEqual(notebook.cellCount, oldCount); assert.strictEqual(notebook.metadata.rGeneration, original.generation); assert.strictEqual(fs.readFileSync(launchFile, 'utf8'), savedLaunch); @@ -1953,43 +3678,79 @@ par(mfrow=c(1,1))`); process.kill(original.rPid, 0); errors.resetHistory(); // A later supervisor failure must still retain the window and permit retry. - const supervisorBin = path.join(root, 'failed-supervisor'); fs.mkdirSync(supervisorBin); - fs.writeFileSync(path.join(supervisorBin, 'tmux'), '#!/bin/sh\necho "test tmux launch failure" >&2\nexit 1\n', { mode: 0o700 }); + const supervisorBin = path.join(root, 'failed-supervisor'); + fs.mkdirSync(supervisorBin); + fs.writeFileSync( + path.join(supervisorBin, 'tmux'), + '#!/bin/sh\necho "test tmux launch failure" >&2\nexit 1\n', + { mode: 0o700 }, + ); try { process.env.PATH = `${supervisorBin}${path.delimiter}${previousPath ?? ''}`; await vscode.commands.executeCommand('r.interactive.restart', notebook.uri); - } finally { process.env.PATH = previousPath; } + } finally { + process.env.PATH = previousPath; + } sinon.assert.calledOnce(interactiveErrors); - assert.match(interactiveErrors.firstCall.args[0], /Could not start the tmux.*test tmux launch failure/s); - assert.ok(notebook.getCells().some(cell => cell.metadata.rNoticeKind === 'restartFailed')); + assert.match( + interactiveErrors.firstCall.args[0], + /Could not start the tmux.*test tmux launch failure/s, + ); + assert.ok( + notebook.getCells().some((cell) => cell.metadata.rNoticeKind === 'restartFailed'), + ); assert.strictEqual(oldCell.document.uri.toString(), oldUri); assert.strictEqual(draft.getText(), 'unsent_draft <- 99'); - await vscode.workspace.getConfiguration('r').update('interactive.supervision', 'detached', vscode.ConfigurationTarget.Global); + await vscode.workspace + .getConfiguration('r') + .update('interactive.supervision', 'detached', vscode.ConfigurationTarget.Global); const override = nodeOverride('custom Node for restart'); - await vscode.workspace.getConfiguration('r').update('interactive.nodePath', override.executable, vscode.ConfigurationTarget.Global); + await vscode.workspace + .getConfiguration('r') + .update( + 'interactive.nodePath', + override.executable, + vscode.ConfigurationTarget.Global, + ); for (const [index, value] of [1.5, 24, 96].entries()) { - await vscode.workspace.getConfiguration('r').update(limitSettings[index], value, vscode.ConfigurationTarget.Global); + await vscode.workspace + .getConfiguration('r') + .update(limitSettings[index], value, vscode.ConfigurationTarget.Global); } - confirmation.resetHistory(); errors.resetHistory(); + confirmation.resetHistory(); + errors.resetHistory(); await Promise.all([ vscode.commands.executeCommand('r.interactive.restart', notebook.uri), vscode.commands.executeCommand('r.interactive.restart', notebook.uri), ]); sinon.assert.calledOnce(confirmation); - sinon.assert.notCalled(interactiveErrors); sinon.assert.notCalled(kernels); + sinon.assert.notCalled(interactiveErrors); + sinon.assert.notCalled(kernels); assertNodeLaunched(override.log); assert.ok(sawRestarting); assert.strictEqual(vscode.workspace.notebookDocuments.length, windowCount); assert.strictEqual(notebook.cellCount, oldCount + 1); - assert.strictEqual(notebook.cellAt(oldCell.index), oldCell, 'Existing cells must keep their identity'); + assert.strictEqual( + notebook.cellAt(oldCell.index), + oldCell, + 'Existing cells must keep their identity', + ); assert.strictEqual(oldCell.document.uri.toString(), oldUri); assert.ok(oldCell.document.getText().endsWith('# edited after execution')); assert.strictEqual(draft.getText(), 'unsent_draft <- 99'); - assert.ok(displays(oldCell).every(data => data.archived === true && data.generation === original.generation)); - const boundary = notebook.getCells().find(cell => cell.metadata.rNoticeKind === 'restarted'); + assert.ok( + displays(oldCell).every( + (data) => data.archived === true && data.generation === original.generation, + ), + ); + const boundary = notebook + .getCells() + .find((cell) => cell.metadata.rNoticeKind === 'restarted'); assert.ok(boundary && boundary.index > oldCell.index); assert.match(boundary.document.getText(), /Continue here in a fresh R process/); - const config = JSON.parse(fs.readFileSync(path.join(root, original.id, 'config.json'), 'utf8')) as AgentConfig; + const config = JSON.parse( + fs.readFileSync(path.join(root, original.id, 'config.json'), 'utf8'), + ) as AgentConfig; assert.notStrictEqual(config.generation, original.generation); assert.strictEqual(notebook.metadata.rGeneration, config.generation); assert.deepStrictEqual(config.previousGenerations, [original.generation]); @@ -1998,80 +3759,176 @@ par(mfrow=c(1,1))`); assert.strictEqual(config.maxOutputBytes, 1.5 * 1024 * 1024); assert.strictEqual(config.maxJournalBytes, 24 * 1024 * 1024); assert.strictEqual(config.maxAssetBytes, 96 * 1024 * 1024); - assert.strictEqual(config.supervision, 'detached', 'Restart honors the repaired setting instead of the persisted tmux supervisor'); + assert.strictEqual( + config.supervision, + 'detached', + 'Restart honors the repaired setting instead of the persisted tmux supervisor', + ); assert.ok(fs.existsSync(path.join(config.library!, 'sess'))); - const current = (): SessionManifest => JSON.parse(fs.readFileSync(path.join(root, original.id, 'manifest.json'), 'utf8')) as SessionManifest; - client = new AgentClient(current()); await client.connect({ claim: false }); + const current = (): SessionManifest => + JSON.parse( + fs.readFileSync(path.join(root, original.id, 'manifest.json'), 'utf8'), + ) as SessionManifest; + client = new AgentClient(current()); + await client.connect({ claim: false }); assert.notStrictEqual(current().rPid, original.rPid); assert.ok(restartController.description?.includes(`PID ${current().rPid ?? ''}`)); assert.ok(restartController.label.endsWith(' · idle')); - const afterCode = 'stopifnot(!exists("before_restart")); after_restart <- 7; cat("fresh process")'; + const afterCode = + 'stopifnot(!exists("before_restart")); after_restart <- 7; cat("fresh process")'; await vscode.commands.executeCommand('r.runSelection', afterCode); - await until(() => notebook.getCells().some(cell => cell.document.getText() === afterCode && cell.executionSummary?.success === true)); + await until(() => + notebook + .getCells() + .some( + (cell) => + cell.document.getText() === afterCode && + cell.executionSummary?.success === true, + ), + ); const index = notebook.cellCount; - await vscode.commands.executeCommand('notebook.cell.execute', { ranges: [{ start: oldCell.index, end: oldCell.index + 1 }], document: notebook.uri }); - await until(() => notebook.cellCount > index && notebook.cellAt(index).executionSummary?.success === true); - assert.notStrictEqual(notebook.cellAt(index).metadata.rExecutionId, oldId, 'Reruns get fresh execution IDs'); + await vscode.commands.executeCommand('notebook.cell.execute', { + ranges: [{ start: oldCell.index, end: oldCell.index + 1 }], + document: notebook.uri, + }); + await until( + () => + notebook.cellCount > index && + notebook.cellAt(index).executionSummary?.success === true, + ); + assert.notStrictEqual( + notebook.cellAt(index).metadata.rExecutionId, + oldId, + 'Reruns get fresh execution IDs', + ); assert.strictEqual(oldCell.document.uri.toString(), oldUri); - assert.ok((await client.snapshot()).executions.some(record => record.code === afterCode)); + assert.ok( + (await client.snapshot()).executions.some((record) => record.code === afterCode), + ); // Source routing remains bound even when another window becomes active. await vscode.commands.executeCommand('r.interactive.open', manifests[0]); - await vscode.window.showTextDocument(source, { viewColumn: vscode.ViewColumn.One, preserveFocus: false }); + await vscode.window.showTextDocument(source, { + viewColumn: vscode.ViewColumn.One, + preserveFocus: false, + }); await until(() => vscode.window.activeTextEditor?.document === source); await vscode.commands.executeCommand('r.runSelection'); - await until(() => notebook.getCells().some(cell => cell.document.getText() === source.getText().trim() && cell.executionSummary?.success === true)); + await until(() => + notebook + .getCells() + .some( + (cell) => + cell.document.getText() === source.getText().trim() && + cell.executionSummary?.success === true, + ), + ); client.close(); const replacement = nodeOverride('replacement Node for restart'); - await vscode.workspace.getConfiguration('r').update('interactive.nodePath', replacement.executable, vscode.ConfigurationTarget.Global); + await vscode.workspace + .getConfiguration('r') + .update( + 'interactive.nodePath', + replacement.executable, + vscode.ConfigurationTarget.Global, + ); for (const [index, value] of [0.0625, 40, 96.5].entries()) { - await vscode.workspace.getConfiguration('r').update(limitSettings[index], value, vscode.ConfigurationTarget.Global); + await vscode.workspace + .getConfiguration('r') + .update(limitSettings[index], value, vscode.ConfigurationTarget.Global); } await vscode.commands.executeCommand('r.interactive.restart', notebook.uri); assertNodeLaunched(replacement.log); - const restartedConfig = JSON.parse(fs.readFileSync(path.join(root, original.id, 'config.json'), 'utf8')) as AgentConfig; + const restartedConfig = JSON.parse( + fs.readFileSync(path.join(root, original.id, 'config.json'), 'utf8'), + ) as AgentConfig; assert.strictEqual(restartedConfig.maxOutputBytes, 65536); assert.strictEqual(restartedConfig.maxJournalBytes, 40 * 1024 * 1024); assert.strictEqual(restartedConfig.maxAssetBytes, 96.5 * 1024 * 1024); assert.strictEqual(vscode.workspace.notebookDocuments.length, windowCount); - assert.strictEqual(notebook.getCells().filter(cell => cell.metadata.rNoticeKind === 'restarted').length, 2); + assert.strictEqual( + notebook.getCells().filter((cell) => cell.metadata.rNoticeKind === 'restarted') + .length, + 2, + ); assert.strictEqual(draft.getText(), 'unsent_draft <- 99'); - client = new AgentClient(current()); await client.connect({ claim: false }); + client = new AgentClient(current()); + await client.connect({ claim: false }); await vscode.commands.executeCommand('r.interactive.open', current()); const finalCode = 'stopifnot(!exists("after_restart")); cat("third process")'; await vscode.commands.executeCommand('r.runSelection', finalCode); - await until(() => notebook.getCells().some(cell => cell.document.getText() === finalCode && cell.executionSummary?.success === true)); + await until(() => + notebook + .getCells() + .some( + (cell) => + cell.document.getText() === finalCode && + cell.executionSummary?.success === true, + ), + ); for (const format of ['rnb', 'ipynb', 'R', 'html']) { const destination = vscode.Uri.file(path.join(root, `restarted.${format}`)); const save = sinon.stub(vscode.window, 'showSaveDialog').resolves(destination); - const picker = sinon.stub(vscode.window, 'showQuickPick').resolves({ label: format, format } as vscode.QuickPickItem); - try { await vscode.commands.executeCommand('r.interactive.export', notebook.uri); } - finally { save.restore(); picker.restore(); } + const picker = sinon + .stub(vscode.window, 'showQuickPick') + .resolves({ label: format, format } as vscode.QuickPickItem); + try { + await vscode.commands.executeCommand('r.interactive.export', notebook.uri); + } finally { + save.restore(); + picker.restore(); + } const content = fs.readFileSync(destination.fsPath, 'utf8'); - assert.ok(content.includes('before_restart') && content.includes('after_restart') && content.includes('third process')); + assert.ok( + content.includes('before_restart') && + content.includes('after_restart') && + content.includes('third process'), + ); assert.strictEqual((content.match(/R session restarted/g) ?? []).length, 2); } // Disconnect/reopen reconstructs all three generations from disk in this notebook. await vscode.commands.executeCommand('r.interactive.detach', notebook.uri); await vscode.commands.executeCommand('r.interactive.open', current()); assert.strictEqual(vscode.workspace.notebookDocuments.length, windowCount); - assert.strictEqual(notebook.getCells().filter(cell => cell.metadata.rNoticeKind === 'restarted').length, 2); - assert.ok(notebook.getCells().some(cell => cell.metadata.rExecutionId === oldId)); - assert.ok(notebook.getCells().some(cell => cell.document.getText() === finalCode)); + assert.strictEqual( + notebook.getCells().filter((cell) => cell.metadata.rNoticeKind === 'restarted') + .length, + 2, + ); + assert.ok(notebook.getCells().some((cell) => cell.metadata.rExecutionId === oldId)); + assert.ok(notebook.getCells().some((cell) => cell.document.getText() === finalCode)); assert.strictEqual(draft.getText(), 'unsent_draft <- 99'); await vscode.commands.executeCommand('r.interactive.clear', notebook.uri); - assert.ok(!notebook.getCells().some(cell => cell.metadata.rExecutionId === oldId)); + assert.ok(!notebook.getCells().some((cell) => cell.metadata.rExecutionId === oldId)); sinon.assert.notCalled(interactiveErrors); } finally { - confirmation.restore(); errors.restore(); kernels.restore(); change.dispose(); + confirmation.restore(); + errors.restore(); + kernels.restore(); + change.dispose(); executable?.restore(); - await vscode.workspace.getConfiguration('r').update('interactive.supervision', supervision, vscode.ConfigurationTarget.Global); - await vscode.workspace.getConfiguration('r').update('interactive.nodePath', previousNode, vscode.ConfigurationTarget.Global); + await vscode.workspace + .getConfiguration('r') + .update('interactive.supervision', supervision, vscode.ConfigurationTarget.Global); + await vscode.workspace + .getConfiguration('r') + .update('interactive.nodePath', previousNode, vscode.ConfigurationTarget.Global); for (const [index, key] of limitSettings.entries()) { - await vscode.workspace.getConfiguration('r').update(key, previousLimits[index], vscode.ConfigurationTarget.Global); + await vscode.workspace + .getConfiguration('r') + .update(key, previousLimits[index], vscode.ConfigurationTarget.Global); } if (!client?.connected) { - client = new AgentClient(JSON.parse(fs.readFileSync(path.join(root, original.id, 'manifest.json'), 'utf8')) as SessionManifest); - try { await client.connect(); } catch { client.close(); client = undefined; } + client = new AgentClient( + JSON.parse( + fs.readFileSync(path.join(root, original.id, 'manifest.json'), 'utf8'), + ) as SessionManifest, + ); + try { + await client.connect(); + } catch { + client.close(); + client = undefined; + } } if (client) { await client.request('claim', { force: true }); @@ -2079,10 +3936,15 @@ par(mfrow=c(1,1))`); let exited = false; const deadline = Date.now() + 10000; while (!exited && Date.now() < deadline) { - exited = (await client.request<{ status: string }>('heartbeat')).status === 'exited'; - if (!exited) { await new Promise(resolve => setTimeout(resolve, 50)); } + exited = + (await client.request<{ status: string }>('heartbeat')).status === 'exited'; + if (!exited) { + await new Promise((resolve) => setTimeout(resolve, 50)); + } + } + if (exited) { + await client.request('shutdown'); } - if (exited) { await client.request('shutdown'); } client.close(); } } @@ -2094,32 +3956,65 @@ par(mfrow=c(1,1))`); let windowCount: number; const start = async (): Promise => { const id = randomUUID(); - const config: AgentConfig = { id, generation: randomUUID(), label: `Bulk test ${bulk.length}`, - directory: root, storage: path.join(root, id), library: path.join(root, 'library'), - rPath: 'R', resources: path.join(process.cwd(), 'R'), provider: 'arf', arfPath: process.env.ARF_PATH ?? 'arf', supervision: 'detached', - plotBackend: 'auto', historyLimit: 50, maxOutputBytes: 1048576, maxJournalBytes: 16777216 }; - const agent = new SessionAgent(config); agents.push(agent); - const manifest = await agent.start(); bulk.push(manifest); + const config: AgentConfig = { + id, + generation: randomUUID(), + label: `Bulk test ${bulk.length}`, + directory: root, + storage: path.join(root, id), + library: path.join(root, 'library'), + rPath: 'R', + resources: path.join(process.cwd(), 'R'), + provider: 'arf', + arfPath: process.env.ARF_PATH ?? 'arf', + supervision: 'detached', + plotBackend: 'auto', + historyLimit: 50, + maxOutputBytes: 1048576, + maxJournalBytes: 16777216, + }; + const agent = new SessionAgent(config); + agents.push(agent); + const manifest = await agent.start(); + bulk.push(manifest); await until(() => manifest.status === 'idle'); return manifest; }; suiteSetup(async () => { - for (let i = 0; i < 4; i++) { await start(); } - other = new AgentClient(bulk[2]); await other.connect(); + for (let i = 0; i < 4; i++) { + await start(); + } + other = new AgentClient(bulk[2]); + await other.connect(); await vscode.commands.executeCommand('r.interactive.open', bulk[0]); - const opened = vscode.workspace.notebookDocuments.find(doc => doc.metadata.rSessionId === bulk[0].id); - assert.ok(opened); notebook = opened; + const opened = vscode.workspace.notebookDocuments.find( + (doc) => doc.metadata.rSessionId === bulk[0].id, + ); + assert.ok(opened); + notebook = opened; const edit = new vscode.WorkspaceEdit(); - edit.set(notebook.uri, [vscode.NotebookEdit.insertCells(0, [new vscode.NotebookCellData(vscode.NotebookCellKind.Code, - 'bulk_retained <- 42; print(bulk_retained)', 'r')])]); + edit.set(notebook.uri, [ + vscode.NotebookEdit.insertCells(0, [ + new vscode.NotebookCellData( + vscode.NotebookCellKind.Code, + 'bulk_retained <- 42; print(bulk_retained)', + 'r', + ), + ]), + ]); await vscode.workspace.applyEdit(edit); - await vscode.commands.executeCommand('notebook.cell.execute', { ranges: [{ start: 0, end: 1 }], document: notebook.uri }); + await vscode.commands.executeCommand('notebook.cell.execute', { + ranges: [{ start: 0, end: 1 }], + document: notebook.uri, + }); await until(() => notebook.cellAt(0).executionSummary?.success === true); windowCount = vscode.workspace.notebookDocuments.length; }); suiteTeardown(async () => { other?.close(); - if (notebook) { await vscode.commands.executeCommand('r.interactive.detach', notebook.uri); } + if (notebook) { + await vscode.commands.executeCommand('r.interactive.detach', notebook.uri); + } }); test('cancelling the multi-select picker or all-session confirmation leaves every session alive', async () => { const picker = sinon.stub(vscode.window, 'showQuickPick').resolves(undefined); @@ -2133,10 +4028,17 @@ par(mfrow=c(1,1))`); sinon.assert.calledOnce(confirm); assert.match(confirm.firstCall.args[0], /Stop 4 R Interactive sessions/); const detail = confirm.firstCall.args[1].detail ?? ''; - for (const manifest of bulk) { assert.ok(detail.includes(`${manifest.label} — PID ${manifest.rPid ?? 'starting'}`)); } - assert.ok(bulk.every(manifest => manifest.status === 'idle')); + for (const manifest of bulk) { + assert.ok( + detail.includes(`${manifest.label} — PID ${manifest.rPid ?? 'starting'}`), + ); + } + assert.ok(bulk.every((manifest) => manifest.status === 'idle')); assert.strictEqual(vscode.workspace.notebookDocuments.length, windowCount); - } finally { picker.restore(); confirm.restore(); } + } finally { + picker.restore(); + confirm.restore(); + } }); test('tree multi-selection deduplicates targets, retains output, and never stops a replacement generation', async () => { const stale = { ...bulk[1], generation: randomUUID() }; @@ -2144,35 +4046,75 @@ par(mfrow=c(1,1))`); confirm.onFirstCall().resolves('Stop 2 Sessions' as unknown as vscode.MessageItem); const picker = sinon.stub(vscode.window, 'showQuickPick').resolves(undefined); try { - await vscode.commands.executeCommand('r.interactive.stopSelected', bulk[0], [bulk[0], stale, bulk[0]]); + await vscode.commands.executeCommand('r.interactive.stopSelected', bulk[0], [ + bulk[0], + stale, + bulk[0], + ]); sinon.assert.notCalled(picker); assert.strictEqual(bulk[0].status, 'exited'); assert.strictEqual(bulk[1].status, 'idle'); assert.strictEqual(bulk[2].status, 'idle'); assert.match(confirm.lastCall.args[0], /Stopped 1 of 2.*1 could not be stopped/); - await until(() => notebook.getCells().some(cell => cell.metadata.rNoticeKind === 'stopped')); - assert.ok(notebook.cellAt(0).outputs.some(output => output.items.some(item => Buffer.from(item.data).toString().includes('42')))); + await until(() => + notebook.getCells().some((cell) => cell.metadata.rNoticeKind === 'stopped'), + ); + assert.ok( + notebook + .cellAt(0) + .outputs.some((output) => + output.items.some((item) => + Buffer.from(item.data).toString().includes('42'), + ), + ), + ); assert.strictEqual(vscode.workspace.notebookDocuments.length, windowCount); - } finally { confirm.restore(); picker.restore(); } + } finally { + confirm.restore(); + picker.restore(); + } }); test('picker selection stops unopened sessions and reports other-window control without affecting unselected sessions', async () => { - const picker = sinon.stub(vscode.window, 'showQuickPick').resolves(bulk.slice(1, 3).map(manifest => ({ label: manifest.label, manifest })) as never); + const picker = sinon + .stub(vscode.window, 'showQuickPick') + .resolves( + bulk + .slice(1, 3) + .map((manifest) => ({ label: manifest.label, manifest })) as never, + ); const confirm = sinon.stub(vscode.window, 'showWarningMessage').resolves(undefined); confirm.onFirstCall().resolves('Stop 2 Sessions' as unknown as vscode.MessageItem); try { await vscode.commands.executeCommand('r.interactive.stopSelected'); - const choices = await picker.firstCall.args[0] as (vscode.QuickPickItem & { manifest: SessionManifest })[]; - assert.deepStrictEqual(new Set(choices.map(choice => choice.manifest.id)), new Set(bulk.slice(1).map(manifest => manifest.id))); + const choices = (await picker.firstCall.args[0]) as (vscode.QuickPickItem & { + manifest: SessionManifest; + })[]; + assert.deepStrictEqual( + new Set(choices.map((choice) => choice.manifest.id)), + new Set(bulk.slice(1).map((manifest) => manifest.id)), + ); assert.strictEqual(bulk[1].status, 'exited'); assert.strictEqual(bulk[2].status, 'idle'); assert.strictEqual(bulk[3].status, 'idle'); - assert.strictEqual((await other.request<{ control: boolean }>('heartbeat')).control, true); + 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 = 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.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); - } finally { picker.restore(); confirm.restore(); } + } finally { + picker.restore(); + confirm.restore(); + } }); test('Stop All captures its targets before confirmation, skips other windows, and stops the remaining sessions after control is released', async () => { const confirm = sinon.stub(vscode.window, 'showWarningMessage').resolves(undefined); @@ -2187,70 +4129,129 @@ par(mfrow=c(1,1))`); assert.strictEqual(bulk[3].status, 'exited'); assert.strictEqual(bulk[4].status, 'idle'); assert.match(confirm.lastCall.args[0], /Stopped 1 of 2/); - await other.request('detach'); other.close(); - confirm.resetBehavior(); confirm.resolves('Stop 2 Sessions' as unknown as vscode.MessageItem); + await other.request('detach'); + other.close(); + confirm.resetBehavior(); + confirm.resolves('Stop 2 Sessions' as unknown as vscode.MessageItem); await vscode.commands.executeCommand('r.interactive.stopAll'); - assert.ok(bulk.every(manifest => manifest.status === 'exited')); + assert.ok(bulk.every((manifest) => manifest.status === 'exited')); assert.match(info.lastCall.args[0], /Stopped 2 of 2/); const count = confirm.callCount; await vscode.commands.executeCommand('r.interactive.stopAll'); assert.strictEqual(confirm.callCount, count); assert.match(info.lastCall.args[0], /No running R Interactive sessions/); assert.strictEqual(vscode.workspace.notebookDocuments.length, windowCount); - } finally { confirm.restore(); info.restore(); } + } finally { + confirm.restore(); + info.restore(); + } }); }); test('a stale saved URI cannot reconnect one session into another session window', async () => { const manager = interactiveManager() as InteractiveManager; - const saved = (manager as unknown as { savedConnections: Map }).savedConnections; + const saved = ( + manager as unknown as { + savedConnections: Map; + } + ).savedConnections; const temporary: { agent: SessionAgent; manifest: SessionManifest }[] = []; - const before = new Set(vscode.window.tabGroups.all.flatMap(group => group.tabs)); + const before = new Set(vscode.window.tabGroups.all.flatMap((group) => group.tabs)); try { for (let i = 0; i < 2; i++) { const id = randomUUID(); - const agent = new SessionAgent({ id, generation: randomUUID(), label: `URI reuse ${i}`, directory: root, storage: path.join(root, id), - rPath: 'R', library: path.join(root, 'library'), resources: path.join(process.cwd(), 'R'), provider: 'arf', arfPath: process.env.ARF_PATH ?? 'arf', supervision: 'test', - plotBackend: 'standard', historyLimit: 50, maxOutputBytes: 1048576, maxJournalBytes: 16777216 }); - const manifest = await agent.start(); temporary.push({ agent, manifest }); + const agent = new SessionAgent({ + id, + generation: randomUUID(), + label: `URI reuse ${i}`, + directory: root, + storage: path.join(root, id), + rPath: 'R', + library: path.join(root, 'library'), + resources: path.join(process.cwd(), 'R'), + provider: 'arf', + arfPath: process.env.ARF_PATH ?? 'arf', + supervision: 'test', + plotBackend: 'standard', + historyLimit: 50, + maxOutputBytes: 1048576, + maxJournalBytes: 16777216, + }); + const manifest = await agent.start(); + temporary.push({ agent, manifest }); await manager.open(manifest); } - const [first, second] = temporary.map(({ manifest }) => vscode.workspace.notebookDocuments.find(doc => doc.metadata.rSessionId === manifest.id)); + const [first, second] = temporary.map(({ manifest }) => + vscode.workspace.notebookDocuments.find( + (doc) => doc.metadata.rSessionId === manifest.id, + ), + ); assert.ok(first && second); await vscode.commands.executeCommand('r.interactive.detach', first.uri); const reset = new vscode.WorkspaceEdit(); reset.set(first.uri, [vscode.NotebookEdit.updateNotebookMetadata({})]); await vscode.workspace.applyEdit(reset); - saved.set(temporary[0].manifest.id, { id: temporary[0].manifest.id, notebookUri: second.uri.toString() }); + saved.set(temporary[0].manifest.id, { + id: temporary[0].manifest.id, + notebookUri: second.uri.toString(), + }); await manager.open(temporary[0].manifest); assert.strictEqual(second.metadata.rSessionId, temporary[1].manifest.id); - const reopened = vscode.workspace.notebookDocuments.find(doc => doc.metadata.rSessionId === temporary[0].manifest.id); + const reopened = vscode.workspace.notebookDocuments.find( + (doc) => doc.metadata.rSessionId === temporary[0].manifest.id, + ); assert.ok(reopened && reopened !== second); - assert.notStrictEqual(saved.get(temporary[0].manifest.id)?.notebookUri, saved.get(temporary[1].manifest.id)?.notebookUri); + assert.notStrictEqual( + saved.get(temporary[0].manifest.id)?.notebookUri, + saved.get(temporary[1].manifest.id)?.notebookUri, + ); } finally { for (const { agent, manifest } of temporary) { - await vscode.commands.executeCommand('r.interactive.detach', manifest); await agent.close(); + await vscode.commands.executeCommand('r.interactive.detach', manifest); + await agent.close(); } - await vscode.window.tabGroups.close(vscode.window.tabGroups.all.flatMap(group => group.tabs).filter(tab => !before.has(tab)), true); + await vscode.window.tabGroups.close( + vscode.window.tabGroups.all + .flatMap((group) => group.tabs) + .filter((tab) => !before.has(tab)), + true, + ); } }); test('exports portable notebook output without transient connection URLs', () => { const cell = new vscode.NotebookCellData(vscode.NotebookCellKind.Code, 'plot(1:3)', 'r'); cell.executionSummary = { executionOrder: 1, success: true }; - cell.outputs = [new vscode.NotebookCellOutput([ - vscode.NotebookCellOutputItem.json({ kind: 'plot', svg: 'plot.svg', svgData: 'c3Zn', url: 'http://localhost:1234/private-token/plot.svg' }, DISPLAY_MIME), - vscode.NotebookCellOutputItem.text('', 'image/svg+xml'), - ]), new vscode.NotebookCellOutput([vscode.NotebookCellOutputItem.stdout('hello')])]; + cell.outputs = [ + new vscode.NotebookCellOutput([ + vscode.NotebookCellOutputItem.json( + { + kind: 'plot', + svg: 'plot.svg', + svgData: 'c3Zn', + url: 'http://localhost:1234/private-token/plot.svg', + }, + DISPLAY_MIME, + ), + vscode.NotebookCellOutputItem.text('', 'image/svg+xml'), + ]), + new vscode.NotebookCellOutput([vscode.NotebookCellOutputItem.stdout('hello')]), + ]; const data = new vscode.NotebookData([cell]); const serializer = new InteractiveSerializer(); const saved = serializer.serializeNotebook(data); const restored = serializer.deserializeNotebook(saved); - const custom = restored.cells[0].outputs?.[0].items.find(item => item.mime === DISPLAY_MIME); + const custom = restored.cells[0].outputs?.[0].items.find( + (item) => item.mime === DISPLAY_MIME, + ); assert.ok(custom); const output = JSON.parse(Buffer.from(custom.data).toString()) as Record; - assert.strictEqual(output.url, undefined); assert.strictEqual(output.svgData, 'c3Zn'); + assert.strictEqual(output.url, undefined); + assert.strictEqual(output.svgData, 'c3Zn'); assert.strictEqual(output.connected, false); const ipynb = JSON.parse(Buffer.from(serializer.exportIpynb(data)).toString()) as { - nbformat: number; cells: { outputs: { output_type: string; data?: Record; text?: string }[] }[]; + nbformat: number; + cells: { + outputs: { output_type: string; data?: Record; text?: string }[]; + }[]; }; assert.strictEqual(ipynb.nbformat, 4); assert.strictEqual(ipynb.cells[0].outputs[0].data?.['image/svg+xml'], ''); @@ -2258,7 +4259,6 @@ par(mfrow=c(1,1))`); assert.strictEqual(ipynb.cells[0].outputs[1].text, 'hello'); assert.ok(!JSON.stringify(ipynb).includes('private-token')); }); - }); async function stopLanguageClient(client: LanguageClient): Promise { @@ -2269,7 +4269,14 @@ async function stopLanguageClient(client: LanguageClient): Promise { } 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; } + if ( + client.state === State.Stopped && + error instanceof Error && + 'code' in error && + error.code === 'EPIPE' + ) { + return; + } throw error; } } @@ -2278,25 +4285,28 @@ suite('Interactive language client cleanup', () => { test('waits for a slow shutdown and accepts the server closing before the exit notification', async () => { const connections: Array<{ dispose(): void }> = []; let serverSocket: net.Socket | undefined; - const server = net.createServer(socket => { + const server = net.createServer((socket) => { serverSocket = socket; - const connection = createMessageConnection(new StreamMessageReader(socket), new StreamMessageWriter(socket)); + const connection = createMessageConnection( + new StreamMessageReader(socket), + new StreamMessageWriter(socket), + ); connections.push(connection); connection.onRequest('initialize', () => ({ capabilities: {} })); connection.onRequest('shutdown', async () => { - await new Promise(resolve => setTimeout(resolve, 2500)); + await new Promise((resolve) => setTimeout(resolve, 2500)); return null; }); connection.listen(); }); - await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + 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 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 => { + writer.write = async (message) => { const isExit = 'method' in message && message.method === 'exit'; if (isExit) { serverSocket!.end(); @@ -2305,33 +4315,51 @@ suite('Interactive language client cleanup', () => { try { return await write(message); } catch (error) { - if (isExit) { exitErrorCode = (error as NodeJS.ErrnoException).code; } + 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 }); + 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())); + 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')], - [State.Stopped, new Error('Stopping the server timed out')]] as const) { - const client = { state, stop: () => Promise.reject(failure) } as unknown as LanguageClient; - await assert.rejects(stopLanguageClient(client), thrown => thrown === failure); + for (const [state, failure] of [ + [State.Running, error], + [State.Stopped, new Error('Unexpected shutdown failure')], + [State.Stopped, new Error('Stopping the server timed out')], + ] as const) { + const client = { + state, + stop: () => Promise.reject(failure), + } as unknown as LanguageClient; + await assert.rejects(stopLanguageClient(client), (thrown) => thrown === failure); } }); }); diff --git a/src/test/suite/interactiveInputFeatures.test.ts b/src/test/suite/interactiveInputFeatures.test.ts index 098c59c16..4c4d8fb0e 100644 --- a/src/test/suite/interactiveInputFeatures.test.ts +++ b/src/test/suite/interactiveInputFeatures.test.ts @@ -13,41 +13,73 @@ suite('Interactive input formatting with independent providers', () => { let previousOnType: boolean | undefined; let previousOnPaste: boolean | undefined; const selector = { scheme: 'vscode-interactive-input', language: 'r' }; - const settings = (): vscode.WorkspaceConfiguration => vscode.workspace.getConfiguration('editor', { languageId: 'r' }); + const settings = (): vscode.WorkspaceConfiguration => + vscode.workspace.getConfiguration('editor', { languageId: 'r' }); const until = async (predicate: () => boolean): Promise => { const deadline = Date.now() + 5000; while (!predicate()) { assert.ok(Date.now() < deadline, 'Timed out waiting for Interactive formatting'); - await new Promise(resolve => setTimeout(resolve, 20)); + await new Promise((resolve) => setTimeout(resolve, 20)); } }; const replaceInput = async (text: string): Promise => { - await editor.edit(builder => builder.replace(new vscode.Range(0, 0, editor.document.lineCount, 0), text)); + await editor.edit((builder) => + builder.replace(new vscode.Range(0, 0, editor.document.lineCount, 0), text), + ); const end = editor.document.positionAt(text.length); editor.selection = new vscode.Selection(end, end); }; const focusInput = async (): Promise => { - await vscode.commands.executeCommand('interactive.open', { preserveFocus: false }, notebook.uri); + await vscode.commands.executeCommand( + 'interactive.open', + { preserveFocus: false }, + notebook.uri, + ); await vscode.commands.executeCommand('interactive.input.focus'); await until(() => vscode.window.activeTextEditor?.document.uri.scheme === selector.scheme); editor = vscode.window.activeTextEditor!; }; const formatExpression = (document: vscode.TextDocument): vscode.TextEdit[] => [ - vscode.TextEdit.replace(new vscode.Range(0, 0, document.lineCount, 0), document.getText().replace('1+1', '1 + 1')), + vscode.TextEdit.replace( + new vscode.Range(0, 0, document.lineCount, 0), + document.getText().replace('1+1', '1 + 1'), + ), ]; suiteSetup(async () => { - assert.strictEqual(vscode.workspace.getConfiguration('r').get('lsp.enabled'), false, - 'Provider tests must run with the built-in language service disabled'); - originalTabs = new Set(vscode.window.tabGroups.all.flatMap(group => group.tabs)); - controller = vscode.notebooks.createNotebookController('independent-formatting-test', 'interactive', 'Independent formatter test'); + assert.strictEqual( + vscode.workspace.getConfiguration('r').get('lsp.enabled'), + false, + 'Provider tests must run with the built-in language service disabled', + ); + originalTabs = new Set(vscode.window.tabGroups.all.flatMap((group) => group.tabs)); + controller = vscode.notebooks.createNotebookController( + 'independent-formatting-test', + 'interactive', + 'Independent formatter test', + ); controller.supportedLanguages = ['r']; - const opened = await vscode.commands.executeCommand<{ notebookUri: vscode.Uri; inputUri: vscode.Uri }>( - 'interactive.open', { preserveFocus: false }, undefined, `REditorSupport.r/${controller.id}`, 'Independent formatting'); + const opened = await vscode.commands.executeCommand<{ + notebookUri: vscode.Uri; + inputUri: vscode.Uri; + }>( + 'interactive.open', + { preserveFocus: false }, + undefined, + `REditorSupport.r/${controller.id}`, + 'Independent formatting', + ); notebook = await vscode.workspace.openNotebookDocument(opened.notebookUri); - await vscode.languages.setTextDocumentLanguage(await vscode.workspace.openTextDocument(opened.inputUri), 'r'); + await vscode.languages.setTextDocumentLanguage( + await vscode.workspace.openTextDocument(opened.inputUri), + 'r', + ); await vscode.commands.executeCommand('interactive.input.focus'); - await until(() => vscode.window.activeTextEditor?.document.uri.toString() === opened.inputUri.toString()); + await until( + () => + vscode.window.activeTextEditor?.document.uri.toString() === + opened.inputUri.toString(), + ); editor = vscode.window.activeTextEditor!; previousOnType = settings().inspect('formatOnType')?.globalLanguageValue; previousOnPaste = settings().inspect('formatOnPaste')?.globalLanguageValue; @@ -60,39 +92,71 @@ suite('Interactive input formatting with independent providers', () => { await replaceInput(''); }); teardown(async () => { - providers.forEach(provider => { provider.dispose(); }); + providers.forEach((provider) => { + provider.dispose(); + }); await focusInput(); await settings().update('formatOnType', false, vscode.ConfigurationTarget.Global, true); await replaceInput(''); }); suiteTeardown(async () => { - await settings().update('formatOnType', previousOnType, vscode.ConfigurationTarget.Global, true); - await settings().update('formatOnPaste', previousOnPaste, vscode.ConfigurationTarget.Global, true); + await settings().update( + 'formatOnType', + previousOnType, + vscode.ConfigurationTarget.Global, + true, + ); + await settings().update( + 'formatOnPaste', + previousOnPaste, + vscode.ConfigurationTarget.Global, + true, + ); controller.dispose(); - await vscode.window.tabGroups.close(vscode.window.tabGroups.all.flatMap(group => group.tabs) - .filter(tab => !originalTabs.has(tab)), true); + await vscode.window.tabGroups.close( + vscode.window.tabGroups.all + .flatMap((group) => group.tabs) + .filter((tab) => !originalTabs.has(tab)), + true, + ); }); test('formats typing with a registered provider, including its own trigger characters', async () => { const calls: Array<{ ch: string; options: vscode.FormattingOptions }> = []; - providers.push(vscode.languages.registerOnTypeFormattingEditProvider(selector, { - provideOnTypeFormattingEdits(document, _position, ch, options) { - calls.push({ ch, options }); - return formatExpression(document); - }, - }, ')', '!')); + providers.push( + vscode.languages.registerOnTypeFormattingEditProvider( + selector, + { + provideOnTypeFormattingEdits(document, _position, ch, options) { + calls.push({ ch, options }); + return formatExpression(document); + }, + }, + ')', + '!', + ), + ); editor.options = { tabSize: 2, insertSpaces: true }; for (const text of ['(1+1)', '(1+1)!']) { await replaceInput(''); const before = calls.length; - for (const ch of text) { await vscode.commands.executeCommand('type', { text: ch }); } + for (const ch of text) { + await vscode.commands.executeCommand('type', { text: ch }); + } await until(() => editor.document.getText() === text.replace('1+1', '1 + 1')); assert.ok(calls.length > before); // Formatting's document change can arrive before its selection update. - await until(() => editor.selection.isEmpty - && editor.document.offsetAt(editor.selection.active) === editor.document.getText().length); + await until( + () => + editor.selection.isEmpty && + editor.document.offsetAt(editor.selection.active) === + editor.document.getText().length, + ); } - assert.ok(calls.some(call => call.ch === '!'), 'VS Code uses the independent provider’s triggers'); + assert.ok( + calls.some((call) => call.ch === '!'), + 'VS Code uses the independent provider’s triggers', + ); assert.deepStrictEqual(calls[0].options, { tabSize: 2, insertSpaces: true }); await replaceInput('(1+1'); await vscode.commands.executeCommand('type', { text: ')' }); @@ -103,37 +167,69 @@ suite('Interactive input formatting with independent providers', () => { test('honors format-on-type settings and ignores unregistered triggers', async () => { let calls = 0; - providers.push(vscode.languages.registerOnTypeFormattingEditProvider(selector, { - provideOnTypeFormattingEdits(document) { calls++; return formatExpression(document); }, - }, ')')); + providers.push( + vscode.languages.registerOnTypeFormattingEditProvider( + selector, + { + provideOnTypeFormattingEdits(document) { + calls++; + return formatExpression(document); + }, + }, + ')', + ), + ); await settings().update('formatOnType', false, vscode.ConfigurationTarget.Global, true); await replaceInput('(1+1'); await vscode.commands.executeCommand('type', { text: ')' }); - await new Promise(resolve => setTimeout(resolve, 100)); + await new Promise((resolve) => setTimeout(resolve, 100)); assert.strictEqual(editor.document.getText(), '(1+1)'); assert.strictEqual(calls, 0); await settings().update('formatOnType', true, vscode.ConfigurationTarget.Global, true); await vscode.commands.executeCommand('type', { text: '!' }); - await new Promise(resolve => setTimeout(resolve, 100)); + await new Promise((resolve) => setTimeout(resolve, 100)); assert.strictEqual(calls, 0); }); test('single-character paste only uses format-on-paste even when format-on-type is enabled', async () => { let typeCalls = 0; const ranges: vscode.Range[] = []; - providers.push(vscode.languages.registerOnTypeFormattingEditProvider(selector, { - provideOnTypeFormattingEdits(document) { typeCalls++; return formatExpression(document); }, - }, ')')); - providers.push(vscode.languages.registerDocumentRangeFormattingEditProvider(selector, { - provideDocumentRangeFormattingEdits(document, range) { ranges.push(range); return formatExpression(document); }, - })); + providers.push( + vscode.languages.registerOnTypeFormattingEditProvider( + selector, + { + provideOnTypeFormattingEdits(document) { + typeCalls++; + return formatExpression(document); + }, + }, + ')', + ), + ); + providers.push( + vscode.languages.registerDocumentRangeFormattingEditProvider(selector, { + provideDocumentRangeFormattingEdits(document, range) { + ranges.push(range); + return formatExpression(document); + }, + }), + ); for (const enabled of [false, true]) { - await settings().update('formatOnPaste', enabled, vscode.ConfigurationTarget.Global, true); + await settings().update( + 'formatOnPaste', + enabled, + vscode.ConfigurationTarget.Global, + true, + ); await replaceInput('(1+1'); await vscode.commands.executeCommand('paste', { text: ')' }); // On-type formatting runs asynchronously from document-change events. - await new Promise(resolve => setTimeout(resolve, 100)); - assert.strictEqual(typeCalls, 0, 'Pasting a trigger character must not invoke the on-type provider'); + await new Promise((resolve) => setTimeout(resolve, 100)); + assert.strictEqual( + typeCalls, + 0, + 'Pasting a trigger character must not invoke the on-type provider', + ); assert.strictEqual(editor.document.getText(), enabled ? '(1 + 1)' : '(1+1)'); assert.strictEqual(ranges.length, enabled ? 1 : 0); } @@ -146,9 +242,14 @@ suite('Interactive input formatting with independent providers', () => { test('formats pasted ranges and keeps separate undo steps for formatting and paste', async () => { const ranges: vscode.Range[] = []; - providers.push(vscode.languages.registerDocumentRangeFormattingEditProvider(selector, { - provideDocumentRangeFormattingEdits(document, range) { ranges.push(range); return formatExpression(document); }, - })); + providers.push( + vscode.languages.registerDocumentRangeFormattingEditProvider(selector, { + provideDocumentRangeFormattingEdits(document, range) { + ranges.push(range); + return formatExpression(document); + }, + }), + ); await vscode.commands.executeCommand('paste', { text: '(1+1)' }); assert.strictEqual(editor.document.getText(), '(1 + 1)'); assert.deepStrictEqual(ranges[0], new vscode.Range(0, 0, 0, 5)); @@ -161,7 +262,10 @@ suite('Interactive input formatting with independent providers', () => { await vscode.commands.executeCommand('paste', { text: '(1+1)' }); assert.strictEqual(editor.document.getText(), '(1+1)'); assert.strictEqual(ranges.length, 1); - const source = await vscode.workspace.openTextDocument({ language: 'plaintext', content: '' }); + const source = await vscode.workspace.openTextDocument({ + language: 'plaintext', + content: '', + }); await vscode.window.showTextDocument(source); await until(() => vscode.window.activeTextEditor?.document === source); await vscode.commands.executeCommand('paste', { text: '(1+1)' }); @@ -172,48 +276,96 @@ suite('Interactive input formatting with independent providers', () => { for (const operation of ['type', 'paste'] as const) { test(`discards delayed ${operation} formatting after the input clears`, async () => { let reply: ((edits: vscode.TextEdit[]) => void) | undefined; - const provide = (): Promise => new Promise(resolve => { reply = resolve; }); - providers.push(operation === 'type' - ? vscode.languages.registerOnTypeFormattingEditProvider(selector, { provideOnTypeFormattingEdits: provide }, ')') - : vscode.languages.registerDocumentRangeFormattingEditProvider(selector, { provideDocumentRangeFormattingEdits: provide })); + const provide = (): Promise => + new Promise((resolve) => { + reply = resolve; + }); + providers.push( + operation === 'type' + ? vscode.languages.registerOnTypeFormattingEditProvider( + selector, + { provideOnTypeFormattingEdits: provide }, + ')', + ) + : vscode.languages.registerDocumentRangeFormattingEditProvider(selector, { + provideDocumentRangeFormattingEdits: provide, + }), + ); await replaceInput(operation === 'type' ? '(1+1' : ''); - const request = vscode.commands.executeCommand(operation, { text: operation === 'type' ? ')' : '(1+1)' }); + const request = vscode.commands.executeCommand(operation, { + text: operation === 'type' ? ')' : '(1+1)', + }); try { await until(() => reply !== undefined); const edits = formatExpression(editor.document); await replaceInput(''); reply!(edits); await request; - await new Promise(resolve => setTimeout(resolve, 100)); - assert.strictEqual(editor.document.getText(), '', 'A delayed provider must not restore cleared input'); - } finally { reply?.([]); await request; } + await new Promise((resolve) => setTimeout(resolve, 100)); + assert.strictEqual( + editor.document.getText(), + '', + 'A delayed provider must not restore cleared input', + ); + } finally { + reply?.([]); + await request; + } }); } test('discards pending formatting when the Interactive manager is recreated', async () => { let reply: ((edits: vscode.TextEdit[]) => void) | undefined; - providers.push(vscode.languages.registerDocumentRangeFormattingEditProvider(selector, { - provideDocumentRangeFormattingEdits: () => new Promise(resolve => { reply = resolve; }), - })); + providers.push( + vscode.languages.registerDocumentRangeFormattingEditProvider(selector, { + provideDocumentRangeFormattingEdits: () => + new Promise((resolve) => { + reply = resolve; + }), + }), + ); const extension = vscode.extensions.getExtension('REditorSupport.r')!; const bundleRequire = createRequire(__filename); - const bundlePath = bundleRequire.resolve(path.join(extension.extensionPath, 'dist/extension')); - const key = Object.keys(bundleRequire.cache).find(filename => process.platform === 'win32' - ? filename.toLowerCase() === bundlePath.toLowerCase() : filename === bundlePath); + const bundlePath = bundleRequire.resolve( + path.join(extension.extensionPath, 'dist/extension'), + ); + const key = Object.keys(bundleRequire.cache).find((filename) => + process.platform === 'win32' + ? filename.toLowerCase() === bundlePath.toLowerCase() + : filename === bundlePath, + ); assert.ok(key); - const context = (bundleRequire.cache[key]!.exports as { extensionContext: vscode.ExtensionContext }).extensionContext; - const manager = context.subscriptions.find(item => typeof (item as InteractiveManager).open === 'function' - && typeof (item as InteractiveManager).getChildren === 'function') as InteractiveManager; + const context = ( + bundleRequire.cache[key]!.exports as { extensionContext: vscode.ExtensionContext } + ).extensionContext; + const manager = context.subscriptions.find( + (item) => + typeof (item as InteractiveManager).open === 'function' && + typeof (item as InteractiveManager).getChildren === 'function', + ) as InteractiveManager; assert.ok(manager); - const Manager = (Object.getPrototypeOf(manager) as { constructor: typeof InteractiveManager }).constructor; + const Manager = ( + Object.getPrototypeOf(manager) as { constructor: typeof InteractiveManager } + ).constructor; const request = vscode.commands.executeCommand('paste', { text: '(1+1)' }); try { await until(() => reply !== undefined); manager.dispose(); - context.subscriptions.splice(context.subscriptions.indexOf(manager), 1, new Manager(context)); + context.subscriptions.splice( + context.subscriptions.indexOf(manager), + 1, + new Manager(context), + ); reply!(formatExpression(editor.document)); await request; - assert.strictEqual(editor.document.getText(), '(1+1)', 'Disposed manager must not apply pending formatting'); - } finally { reply?.([]); await request; } + assert.strictEqual( + editor.document.getText(), + '(1+1)', + 'Disposed manager must not apply pending formatting', + ); + } finally { + reply?.([]); + await request; + } }); }); diff --git a/src/test/suite/jgdPlotHistory.test.ts b/src/test/suite/jgdPlotHistory.test.ts index 84cd46754..e528e8466 100644 --- a/src/test/suite/jgdPlotHistory.test.ts +++ b/src/test/suite/jgdPlotHistory.test.ts @@ -244,7 +244,9 @@ suite('JGD PlotHistory', () => { test('appends ops to the latest plot', () => { history.addPlot('s1', makePlot('A')); const extra: PlotFrame = { - version: 1, sessionId: '', ops: [{ op: 'line', label: 'extra' }], + version: 1, + sessionId: '', + ops: [{ op: 'line', label: 'extra' }], device: { width: 400, height: 300, dpi: 96, bg: 'A' }, }; history.appendOps('s1', extra); @@ -260,7 +262,9 @@ suite('JGD PlotHistory', () => { history.addPlot('s1', makePlot('B')); history.navigatePrevious(); const extra: PlotFrame = { - version: 1, sessionId: '', ops: [{ op: 'line' }], + version: 1, + sessionId: '', + ops: [{ op: 'line' }], device: { width: 400, height: 300, dpi: 96, bg: 'B' }, }; history.appendOps('s1', extra); @@ -274,7 +278,9 @@ suite('JGD PlotHistory', () => { history.addPlot('s1', makePlot('B')); history.removeCurrent(); const extra: PlotFrame = { - version: 1, sessionId: '', ops: [{ op: 'line' }], + version: 1, + sessionId: '', + ops: [{ op: 'line' }], device: { width: 400, height: 300, dpi: 96, bg: 'A' }, }; history.appendOps('s1', extra); diff --git a/src/test/suite/jgdSocketServer.test.ts b/src/test/suite/jgdSocketServer.test.ts index cca486e6b..10de74cc2 100644 --- a/src/test/suite/jgdSocketServer.test.ts +++ b/src/test/suite/jgdSocketServer.test.ts @@ -4,7 +4,12 @@ import { PlotHistory, PlotFrame } from '../../plotViewer/jgdPlotHistory'; import { JgdSocketServer, JgdMessage } from '../../plotViewer/jgdSocketServer'; let plotCounter = 0; -function makePlotMsg(label: string, width = 400, height = 300, extra: Record = {}): Record { +function makePlotMsg( + label: string, + width = 400, + height = 300, + extra: Record = {}, +): Record { const msg: Record = { type: 'frame', plot: { @@ -79,7 +84,9 @@ function connectClient(socketUri: string): Promise { if (lineQueue.length > 0) { return Promise.resolve(lineQueue.shift()!); } - return new Promise((res) => { lineResolve = res; }); + return new Promise((res) => { + lineResolve = res; + }); }, close: () => socket.destroy(), }); @@ -221,11 +228,16 @@ suite('JGD SocketServer', () => { assert.strictEqual(history.currentPlot()?.device.bg, 'RED'); server.handleResize(1000, 700); - const resizeMsg = JSON.parse(await client.readLine()) as { type: string; plotIndex: number }; + const resizeMsg = JSON.parse(await client.readLine()) as { + type: string; + plotIndex: number; + }; assert.strictEqual(resizeMsg.type, 'resize'); assert.strictEqual(resizeMsg.plotIndex, 0); - client.send(makePlotMsg('RED-resized', 1000, 700, { resizeReplay: true, plotIndex: 0 })); + client.send( + makePlotMsg('RED-resized', 1000, 700, { resizeReplay: true, plotIndex: 0 }), + ); await waitMs(50); assert.strictEqual(history.count(), 1); @@ -241,7 +253,11 @@ suite('JGD SocketServer', () => { client.send({ type: 'hello' }); const info = JSON.parse(await client.readLine()) as { type: string }; assert.strictEqual(info.type, 'server_info'); - const msg = JSON.parse(await client.readLine()) as { type: string; width: number; height: number }; + const msg = JSON.parse(await client.readLine()) as { + type: string; + width: number; + height: number; + }; assert.strictEqual(msg.type, 'resize'); assert.strictEqual(msg.width, 500); assert.strictEqual(msg.height, 400); @@ -272,7 +288,11 @@ suite('JGD SocketServer', () => { gc: { font: { size: 12, family: 'sans' } }, }); - const resp = JSON.parse(await client.readLine()) as { type: string; id: number; width: number }; + const resp = JSON.parse(await client.readLine()) as { + type: string; + id: number; + width: number; + }; assert.strictEqual(resp.type, 'metrics_response'); assert.strictEqual(resp.id, 7); assert.strictEqual(resp.width, 42); @@ -282,9 +302,18 @@ suite('JGD SocketServer', () => { test('font measurement uses each device DPI without leaking across connections', async () => { const first = await connect(); const second = await connect(); - server.setMeasureText((request, dpi) => Promise.resolve({ type: 'metrics_response', id: request.id, dpi })); - first.send({ type: 'frame', newPage: true, plot: { version: 1, - device: { width: 800, height: 600, dpi: 144, bg: 'white' }, ops: [] } }); + server.setMeasureText((request, dpi) => + Promise.resolve({ type: 'metrics_response', id: request.id, dpi }), + ); + first.send({ + type: 'frame', + newPage: true, + plot: { + version: 1, + device: { width: 800, height: 600, dpi: 144, bg: 'white' }, + ops: [], + }, + }); first.send({ type: 'metrics_request', id: 1, kind: 'strWidth', str: 'hello' }); second.send({ type: 'metrics_request', id: 2, kind: 'strWidth', str: 'hello' }); assert.strictEqual((JSON.parse(await first.readLine()) as { dpi: number }).dpi, 144); diff --git a/src/test/suite/lineCache.test.ts b/src/test/suite/lineCache.test.ts index 7cc0da9a5..63d030996 100644 --- a/src/test/suite/lineCache.test.ts +++ b/src/test/suite/lineCache.test.ts @@ -1,11 +1,9 @@ import * as assert from 'assert'; - import { cleanLine } from '../../lineCache'; // Defines a Mocha test suite to group tests of similar kind together suite('lineCache Tests', () => { - test('cleanLine', () => { assert.strictEqual(cleanLine('abcde '), 'abcde'); assert.strictEqual(cleanLine('abcde "abc" '), 'abcde "abc"'); diff --git a/src/test/suite/listViewer.test.ts b/src/test/suite/listViewer.test.ts index 53be431dd..47f45cb5c 100644 --- a/src/test/suite/listViewer.test.ts +++ b/src/test/suite/listViewer.test.ts @@ -13,15 +13,25 @@ class Element { disabled = false; open = false; scrollTop = 0; - constructor(readonly tag: string) { } - get childElementCount(): number { return this.children.length; } - appendChild(child: Element): void { this.children.push(child); } - replaceChildren(...children: Element[]): void { this.children = children; } - setAttribute(): void { /* Attributes do not affect these interaction tests. */ } + constructor(readonly tag: string) {} + get childElementCount(): number { + return this.children.length; + } + appendChild(child: Element): void { + this.children.push(child); + } + replaceChildren(...children: Element[]): void { + this.children = children; + } + setAttribute(): void { + /* Attributes do not affect these interaction tests. */ + } addEventListener(type: string, listener: (event: unknown) => void): void { this.listeners.set(type, listener); } - fire(type: string, event: unknown = {}): void { this.listeners.get(type)?.(event); } + fire(type: string, event: unknown = {}): void { + this.listeners.get(type)?.(event); + } } interface Request { @@ -33,41 +43,71 @@ interface Request { start?: number; } -function createViewer(initial: ListViewNavigation = { - title: 'x', path: [], breadcrumbs: [{ label: 'x', path: [] }], -}) { +function createViewer( + initial: ListViewNavigation = { + title: 'x', + path: [], + breadcrumbs: [{ label: 'x', path: [] }], + }, +) { const root = new Element('div'); const back = new Element('button'); const reset = new Element('button'); const breadcrumbs = new Element('nav'); const status = new Element('div'); const bodyClasses = new Set(); - const elements: Record = { list: root, back, reset, breadcrumbs, 'navigation-status': status }; + const elements: Record = { + list: root, + back, + reset, + breadcrumbs, + 'navigation-status': status, + }; const messages: Request[] = []; let receive: (event: unknown) => void = () => undefined; vm.runInNewContext(getListViewerScript(7, initial), { - acquireVsCodeApi: () => ({ postMessage: (message: Request) => { - messages.push(JSON.parse(JSON.stringify(message)) as Request); - } }), + acquireVsCodeApi: () => ({ + postMessage: (message: Request) => { + messages.push(JSON.parse(JSON.stringify(message)) as Request); + }, + }), document: { - body: { classList: { toggle: (name: string, enabled: boolean) => { - if (enabled) { - bodyClasses.add(name); - } else { - bodyClasses.delete(name); - } - } } }, + body: { + classList: { + toggle: (name: string, enabled: boolean) => { + if (enabled) { + bodyClasses.add(name); + } else { + bodyClasses.delete(name); + } + }, + }, + }, createElement: (tag: string) => new Element(tag), getElementById: (id: string) => elements[id] ?? new Element('template'), }, - window: { addEventListener: (_type: string, listener: typeof receive) => { receive = listener; } }, - }); - const reply = (request: Request, result: Record) => receive({ - data: { ...request, children: [], next_start: null, ...result }, + window: { + addEventListener: (_type: string, listener: typeof receive) => { + receive = listener; + }, + }, }); + const reply = (request: Request, result: Record) => + receive({ + data: { ...request, children: [], next_start: null, ...result }, + }); return { - get root() { return root.children[0]; }, - viewport: root, back, reset, breadcrumbs, status, messages, reply, bodyClasses, + get root() { + return root.children[0]; + }, + viewport: root, + back, + reset, + breadcrumbs, + status, + messages, + reply, + bodyClasses, }; } @@ -75,17 +115,29 @@ suite('List viewer', () => { test('Reset restores the initial page, collapsed rows and scroll, and discards navigation and pending pages', () => { const viewer = createViewer(); const initial = { title: 'x', path: [], breadcrumbs: [{ label: 'x', path: [] }] }; - viewer.reply(viewer.messages[0], { children: [{ label: '$ a', index: 1, viewable: true, has_children: true }] }); + viewer.reply(viewer.messages[0], { + children: [{ label: '$ a', index: 1, viewable: true, has_children: true }], + }); const original = viewer.root; const entry = original.children[0].children[0]; - entry.open = true; entry.fire('toggle'); + entry.open = true; + entry.fire('toggle'); const expansion = viewer.messages[1]; viewer.viewport.scrollTop = 120; - const button = entry.children[0].children.at(-1); assert.ok(button); + const button = entry.children[0].children.at(-1); + assert.ok(button); button.fire('click', { preventDefault: () => undefined, stopPropagation: () => undefined }); - viewer.reply(viewer.messages[2], { message: 'listview/navigation', navigation: { - title: 'x$a', path: [1], breadcrumbs: [{ label: 'x', path: [] }, { label: 'a', path: [1] }], - } }); + viewer.reply(viewer.messages[2], { + message: 'listview/navigation', + navigation: { + title: 'x$a', + path: [1], + breadcrumbs: [ + { label: 'x', path: [] }, + { label: 'a', path: [1] }, + ], + }, + }); const nestedPage = viewer.messages[3]; viewer.reset.fire('click'); assert.strictEqual(viewer.messages[4].message, 'listview/navigate'); @@ -96,7 +148,9 @@ suite('List viewer', () => { assert.strictEqual(viewer.back.disabled, true); viewer.reply(expansion, { children: [{ label: 'stale' }] }); viewer.reply(nestedPage, { children: [{ label: 'stale' }] }); - viewer.reply(viewer.messages[5], { children: [{ label: '$ a', index: 1, has_children: true }] }); + viewer.reply(viewer.messages[5], { + children: [{ label: '$ a', index: 1, has_children: true }], + }); assert.strictEqual(viewer.root.children[0].children[0].open, false); assert.strictEqual(viewer.root.children[0].children[0].children[1].childElementCount, 0); viewer.back.fire('click'); @@ -104,18 +158,33 @@ suite('List viewer', () => { }); test('Reset returns to a deep initial path and a failed reset preserves the current page', () => { - const initial = { title: 'x$a', path: [1], breadcrumbs: [{ label: 'x', path: [] }, { label: 'a', path: [1] }] }; + const initial = { + title: 'x$a', + path: [1], + breadcrumbs: [ + { label: 'x', path: [] }, + { label: 'a', path: [1] }, + ], + }; const viewer = createViewer(initial); viewer.reply(viewer.messages[0], { children: [] }); viewer.breadcrumbs.children[0].fire('click'); - viewer.reply(viewer.messages[1], { message: 'listview/navigation', navigation: { - title: 'x', path: [], breadcrumbs: [{ label: 'x', path: [] }], - } }); + viewer.reply(viewer.messages[1], { + message: 'listview/navigation', + navigation: { + title: 'x', + path: [], + breadcrumbs: [{ label: 'x', path: [] }], + }, + }); viewer.reply(viewer.messages[2], { children: [] }); const current = viewer.root; viewer.reset.fire('click'); assert.deepStrictEqual(viewer.messages[3].path, [1]); - viewer.reply(viewer.messages[3], { message: 'listview/navigation', error: 'Unable to reset' }); + viewer.reply(viewer.messages[3], { + message: 'listview/navigation', + error: 'Unable to reset', + }); assert.strictEqual(viewer.root, current); assert.strictEqual(viewer.back.disabled, false); assert.strictEqual(viewer.status.textContent, 'Unable to reset'); @@ -130,19 +199,32 @@ suite('List viewer', () => { const viewer = createViewer(); const { messages, reply, back, viewport } = viewer; assert.strictEqual(back.disabled, true); - reply(messages[0], { children: [{ label: '$ a', index: 1, viewable: true, has_children: true }] }); + reply(messages[0], { + children: [{ label: '$ a', index: 1, viewable: true, has_children: true }], + }); const original = viewer.root; const entry = original.children[0].children[0]; entry.open = true; entry.fire('toggle'); - reply(messages[1], { children: [{ label: '$ b', index: 1, viewable: true, has_children: true }] }); + reply(messages[1], { + children: [{ label: '$ b', index: 1, viewable: true, has_children: true }], + }); viewport.scrollTop = 120; const openButton = entry.children[0].children.at(-1); assert.ok(openButton); - openButton.fire('click', { preventDefault: () => undefined, stopPropagation: () => undefined }); + openButton.fire('click', { + preventDefault: () => undefined, + stopPropagation: () => undefined, + }); reply(messages[2], { - message: 'listview/navigation', navigation: { - title: 'x$a', path: [1], breadcrumbs: [{ label: 'x', path: [] }, { label: 'a', path: [1] }], + message: 'listview/navigation', + navigation: { + title: 'x$a', + path: [1], + breadcrumbs: [ + { label: 'x', path: [] }, + { label: 'a', path: [1] }, + ], }, }); assert.strictEqual(back.disabled, false); @@ -152,7 +234,8 @@ suite('List viewer', () => { assert.strictEqual(messages[4].message, 'listview/navigate'); assert.deepStrictEqual(messages[4].path, []); reply(messages[4], { - message: 'listview/navigation', navigation: { title: 'x', path: [], breadcrumbs: [{ label: 'x', path: [] }] }, + message: 'listview/navigation', + navigation: { title: 'x', path: [], breadcrumbs: [{ label: 'x', path: [] }] }, }); assert.strictEqual(viewer.root, original); assert.strictEqual(entry.open, true); @@ -163,20 +246,33 @@ suite('List viewer', () => { test('breadcrumbs work when opened directly at a deep item and Back follows page history', () => { const initial = { - title: 'x$a$b', path: [1, 2], breadcrumbs: [ - { label: 'x', path: [] }, { label: 'a', path: [1] }, { label: 'b', path: [1, 2] }, + title: 'x$a$b', + path: [1, 2], + breadcrumbs: [ + { label: 'x', path: [] }, + { label: 'a', path: [1] }, + { label: 'b', path: [1, 2] }, ], }; const viewer = createViewer(initial); assert.deepStrictEqual(viewer.messages[0].path, [1, 2]); viewer.reply(viewer.messages[0], { children: [] }); const original = viewer.root; - assert.deepStrictEqual(viewer.breadcrumbs.children.map(item => item.className), - ['breadcrumb', 'codicon codicon-chevron-right', 'breadcrumb', 'codicon codicon-chevron-right', 'breadcrumb']); + assert.deepStrictEqual( + viewer.breadcrumbs.children.map((item) => item.className), + [ + 'breadcrumb', + 'codicon codicon-chevron-right', + 'breadcrumb', + 'codicon codicon-chevron-right', + 'breadcrumb', + ], + ); viewer.breadcrumbs.children[0].fire('click'); assert.deepStrictEqual(viewer.messages[1].path, []); viewer.reply(viewer.messages[1], { - message: 'listview/navigation', navigation: { title: 'x', path: [], breadcrumbs: [{ label: 'x', path: [] }] }, + message: 'listview/navigation', + navigation: { title: 'x', path: [], breadcrumbs: [{ label: 'x', path: [] }] }, }); viewer.reply(viewer.messages[2], { children: [] }); viewer.back.fire('click'); @@ -187,7 +283,9 @@ suite('List viewer', () => { test('opening a table and failed navigation leave the list and its history intact', () => { const viewer = createViewer(); - viewer.reply(viewer.messages[0], { children: [{ label: '$ df', index: 1, viewable: true }] }); + viewer.reply(viewer.messages[0], { + children: [{ label: '$ df', index: 1, viewable: true }], + }); const original = viewer.root; const button = original.children[0].children[0].children[0].children.at(-1); assert.ok(button); @@ -206,7 +304,11 @@ suite('List viewer', () => { test('expands lazily, caches collapsed children, and opens descendants separately', () => { const { root, messages, reply } = createViewer(); assert.deepStrictEqual(messages[0].path, []); - reply(messages[0], { children: [{ label: '$ a', str: 'List of 1', index: 1, viewable: true, has_children: true }] }); + reply(messages[0], { + children: [ + { label: '$ a', str: 'List of 1', index: 1, viewable: true, has_children: true }, + ], + }); const entry = root.children[0].children[0]; assert.strictEqual(entry.tag, 'details'); assert.strictEqual(entry.children[1].childElementCount, 0); @@ -215,7 +317,11 @@ suite('List viewer', () => { entry.fire('toggle'); assert.strictEqual(entry.children[1].childElementCount, 3); assert.deepStrictEqual(messages[1].path, [1]); - reply(messages[1], { children: [{ label: '$ b', str: 'List of 1', index: 2, viewable: true, has_children: true }] }); + reply(messages[1], { + children: [ + { label: '$ b', str: 'List of 1', index: 2, viewable: true, has_children: true }, + ], + }); entry.open = false; entry.fire('toggle'); entry.open = true; @@ -231,8 +337,12 @@ suite('List viewer', () => { const openButton = nested.children[0].children.at(-1); assert.ok(openButton); openButton.fire('click', { - preventDefault: () => { prevented = true; }, - stopPropagation: () => { stopped = true; }, + preventDefault: () => { + prevented = true; + }, + stopPropagation: () => { + stopped = true; + }, }); assert.ok(prevented && stopped); assert.strictEqual(messages[3].message, 'listview/view'); @@ -242,7 +352,9 @@ suite('List viewer', () => { test('routes concurrent expansion pages, retries failures, and paginates each branch', () => { const { root, messages, reply } = createViewer(); - reply(messages[0], { children: [1, 2].map(index => ({ index, label: String(index), has_children: true })) }); + reply(messages[0], { + children: [1, 2].map((index) => ({ index, label: String(index), has_children: true })), + }); const [first, second] = root.children[0].children; first.open = second.open = true; first.fire('toggle'); @@ -267,30 +379,44 @@ suite('List viewer', () => { test('renders named and unnamed vector rows as plain text', () => { const viewer = createViewer({ - title: 'x', path: [], breadcrumbs: [{ label: 'x', path: [] }], vector: true, + title: 'x', + path: [], + breadcrumbs: [{ label: 'x', path: [] }], + vector: true, }); const labels = ['first', '[2]', 'first', '', 'a b']; viewer.reply(viewer.messages[0], { children: labels.map((label, index) => ({ - label, str: '12.4', index: index + 1, - viewable: false, has_children: false, + label, + str: '12.4', + index: index + 1, + viewable: false, + has_children: false, })), }); labels.forEach((label, index) => { const row = viewer.root.children[0].children[index].children[0]; - assert.deepStrictEqual(row.children.map(child => child.className), ['label', 'str']); + assert.deepStrictEqual( + row.children.map((child) => child.className), + ['label', 'str'], + ); assert.strictEqual(row.children[0].textContent, label); assert.strictEqual(row.children[0].innerHTML, ''); assert.strictEqual(row.children[1].textContent, '12.4'); - assert.ok(!row.children.some(child => child.tag === 'button')); + assert.ok(!row.children.some((child) => child.tag === 'button')); }); }); test('vectors share list navigation, Back and cached pages, including late page responses', () => { const rootNavigation = { title: 'x', path: [], breadcrumbs: [{ label: 'x', path: [] }] }; const vectorNavigation = { - title: 'x$v', path: [1], vector: true, - breadcrumbs: [{ label: 'x', path: [] }, { label: 'v', path: [1] }], + title: 'x$v', + path: [1], + vector: true, + breadcrumbs: [ + { label: 'x', path: [] }, + { label: 'v', path: [1] }, + ], }; const viewer = createViewer(rootNavigation); const { messages, reply, back, bodyClasses } = viewer; @@ -321,7 +447,10 @@ suite('List viewer', () => { assert.strictEqual(viewer.root, vectorPage); assert.strictEqual(messages.length, 5); const row = vectorPage.children[0].children[0].children[0]; - assert.deepStrictEqual(row.children.map(child => child.className), ['label', 'str']); + assert.deepStrictEqual( + row.children.map((child) => child.className), + ['label', 'str'], + ); assert.ok(bodyClasses.has('vector')); viewer.breadcrumbs.children[0].fire('click'); reply(messages[5], { message: 'listview/navigation', navigation: rootNavigation }); @@ -337,13 +466,15 @@ suite('List viewer', () => { const { root, messages, reply } = createViewer(); reply(messages[0], { documentGeneration: 6, children: [{ label: 'stale' }] }); assert.strictEqual(root.children[0].childElementCount, 0); - reply(messages[0], { children: [ - { label: 'removed', viewable: false }, - { label: 'scalar', index: 2, viewable: true, has_children: false }, - ] }); + reply(messages[0], { + children: [ + { label: 'removed', viewable: false }, + { label: 'scalar', index: 2, viewable: true, has_children: false }, + ], + }); const [removed, scalar] = root.children[0].children; - assert.ok(!removed.children[0].children.some(child => child.tag === 'button')); - assert.ok(scalar.children[0].children.some(child => child.tag === 'button')); + assert.ok(!removed.children[0].children.some((child) => child.tag === 'button')); + assert.ok(scalar.children[0].children.some((child) => child.tag === 'button')); assert.strictEqual(scalar.tag, 'div'); }); }); diff --git a/src/test/suite/listViewerPanels.test.ts b/src/test/suite/listViewerPanels.test.ts index 23f04f745..e2a1ac5f8 100644 --- a/src/test/suite/listViewerPanels.test.ts +++ b/src/test/suite/listViewerPanels.test.ts @@ -18,7 +18,9 @@ suite('List viewer panels', () => { }); teardown(() => { - panels.splice(0).forEach(panel => { panel.dispose(); }); + panels.splice(0).forEach((panel) => { + panel.dispose(); + }); sandbox.restore(); }); @@ -51,21 +53,40 @@ suite('List viewer panels', () => { }); test(`handles pending ${source} requests when a real VS Code panel closes`, async () => { - const panel = vscode.window.createWebviewPanel('dataview', 'x', vscode.ViewColumn.Two, {}); + const panel = vscode.window.createWebviewPanel( + 'dataview', + 'x', + vscode.ViewColumn.Two, + {}, + ); panels.push(panel); const receive = sandbox.spy(panel.webview, 'onDidReceiveMessage'); sandbox.stub(vscode.window, 'createWebviewPanel').returns(panel); await session.showDataView(source, 'json', 'x', '', 'Two', `test-pending-${source}`); - const documentGeneration = Number(/const documentGeneration = (\d+)/.exec(panel.webview.html)?.[1]); + const documentGeneration = Number( + /const documentGeneration = (\d+)/.exec(panel.webview.html)?.[1], + ); const listener = receive.firstCall.args[0] as (message: unknown) => Promise; // With no R session, requests settle asynchronously with an unavailable response. - const pending = source === 'table' - ? [listener({ - message: 'dataview/request', action: 'page', documentGeneration, requestId: 1, - })] - : ['listview/page', 'listview/navigate'].map(message => listener({ - message, documentGeneration, requestId: 1, path: [], start: 1, - })); + const pending = + source === 'table' + ? [ + listener({ + message: 'dataview/request', + action: 'page', + documentGeneration, + requestId: 1, + }), + ] + : ['listview/page', 'listview/navigate'].map((message) => + listener({ + message, + documentGeneration, + requestId: 1, + path: [], + start: 1, + }), + ); panel.dispose(); await Promise.all(pending); }); @@ -73,23 +94,30 @@ suite('List viewer panels', () => { test('reuses separate list and table panels, updates titles, and reopens closed viewers', async () => { const reveals: sinon.SinonStub[] = []; - const create = sandbox.stub(vscode.window, 'createWebviewPanel').callsFake((_type, title) => { - const disposed = new vscode.EventEmitter(); - const reveal = sandbox.stub(); - reveals.push(reveal); - const panel = { - title, viewColumn: vscode.ViewColumn.Three, - reveal, - webview: { - html: '', asWebviewUri: (uri: vscode.Uri) => uri, - onDidReceiveMessage: sandbox.stub(), - }, - onDidDispose: disposed.event, - dispose: () => { disposed.fire(); disposed.dispose(); }, - } as unknown as vscode.WebviewPanel; - panels.push(panel); - return panel; - }); + const create = sandbox + .stub(vscode.window, 'createWebviewPanel') + .callsFake((_type, title) => { + const disposed = new vscode.EventEmitter(); + const reveal = sandbox.stub(); + reveals.push(reveal); + const panel = { + title, + viewColumn: vscode.ViewColumn.Three, + reveal, + webview: { + html: '', + asWebviewUri: (uri: vscode.Uri) => uri, + onDidReceiveMessage: sandbox.stub(), + }, + onDidDispose: disposed.event, + dispose: () => { + disposed.fire(); + disposed.dispose(); + }, + } as unknown as vscode.WebviewPanel; + panels.push(panel); + return panel; + }); await session.showDataView('list', 'json', 'x', '', 'Two', 'test-list-x'); await session.showDataView('list', 'json', 'x$a', '', 'Two', 'test-list-x'); @@ -112,8 +140,13 @@ suite('List viewer panels', () => { await session.showDataView('list', 'json', 'y', '', 'Two', 'test-list-y'); assert.strictEqual(create.callCount, 3); await session.showDataView('list', 'json', 'x$id', '', 'Two', 'test-list-x', { - title: 'x$id', path: [1], vector: true, - breadcrumbs: [{ label: 'x', path: [] }, { label: 'id', path: [1] }], + title: 'x$id', + path: [1], + vector: true, + breadcrumbs: [ + { label: 'x', path: [] }, + { label: 'id', path: [1] }, + ], }); assert.strictEqual(create.callCount, 3); assert.strictEqual(panels[0].title, 'x$id'); @@ -132,31 +165,60 @@ suite('List viewer panels', () => { const postMessage = sandbox.stub().resolves(true); const disposed = new vscode.EventEmitter(); const panel = { - title: '', viewColumn: vscode.ViewColumn.Two, reveal: sandbox.stub(), + title: '', + viewColumn: vscode.ViewColumn.Two, + reveal: sandbox.stub(), webview: { - html: '', asWebviewUri: (uri: vscode.Uri) => uri, postMessage, - onDidReceiveMessage: (listener: typeof receive) => { receive = listener; }, + html: '', + asWebviewUri: (uri: vscode.Uri) => uri, + postMessage, + onDidReceiveMessage: (listener: typeof receive) => { + receive = listener; + }, }, onDidDispose: disposed.event, - dispose: () => { disposed.fire(); disposed.dispose(); }, + dispose: () => { + disposed.fire(); + disposed.dispose(); + }, } as unknown as vscode.WebviewPanel; panels.push(panel); sandbox.stub(vscode.window, 'createWebviewPanel').returns(panel); - const documentGeneration = () => Number(/const documentGeneration = (\d+)/.exec(panel.webview.html)?.[1]); + const documentGeneration = () => + Number(/const documentGeneration = (\d+)/.exec(panel.webview.html)?.[1]); await session.showDataView('list', 'json', 'x', '', 'Two', 'test-list-document-generation'); for (const message of ['listview/navigate', 'listview/page']) { - const request = { message, documentGeneration: documentGeneration(), requestId: 1, path: [], start: 1 }; + const request = { + message, + documentGeneration: documentGeneration(), + requestId: 1, + path: [], + start: 1, + }; // sessionRequest settles asynchronously even without an attached R session. const pending = receive(request); - await session.showDataView('list', 'json', 'x$updated', '', 'Two', 'test-list-document-generation'); + await session.showDataView( + 'list', + 'json', + 'x$updated', + '', + 'Two', + 'test-list-document-generation', + ); await pending; sinon.assert.notCalled(postMessage); await receive(request); sinon.assert.notCalled(postMessage); assert.strictEqual(panel.title, 'x$updated'); } - await receive({ message: 'listview/page', documentGeneration: documentGeneration(), requestId: 2, path: [], start: 1 }); + await receive({ + message: 'listview/page', + documentGeneration: documentGeneration(), + requestId: 2, + path: [], + start: 1, + }); sinon.assert.calledOnce(postMessage); const response = postMessage.firstCall.args[0] as { documentGeneration: number }; assert.strictEqual(response.documentGeneration, documentGeneration()); @@ -167,27 +229,50 @@ suite('List viewer panels', () => { const postMessage = sandbox.stub().resolves(true); const disposed = new vscode.EventEmitter(); const panel = { - title: '', viewColumn: vscode.ViewColumn.Two, reveal: sandbox.stub(), + title: '', + viewColumn: vscode.ViewColumn.Two, + reveal: sandbox.stub(), webview: { - html: '', asWebviewUri: (uri: vscode.Uri) => uri, postMessage, - onDidReceiveMessage: (listener: typeof receive) => { receive = listener; }, + html: '', + asWebviewUri: (uri: vscode.Uri) => uri, + postMessage, + onDidReceiveMessage: (listener: typeof receive) => { + receive = listener; + }, }, onDidDispose: disposed.event, - dispose: () => { disposed.fire(); disposed.dispose(); }, + dispose: () => { + disposed.fire(); + disposed.dispose(); + }, } as unknown as vscode.WebviewPanel; panels.push(panel); sandbox.stub(vscode.window, 'createWebviewPanel').returns(panel); const documentGeneration = () => Number(/const documentGeneration = (\d+)/.exec(panel.webview.html)?.[1]); - await session.showDataView('table', 'json', 'x', '', 'Two', 'test-dataview-document-generation'); + await session.showDataView( + 'table', + 'json', + 'x', + '', + 'Two', + 'test-dataview-document-generation', + ); const request = { - message: 'dataview/request', action: 'page', - documentGeneration: documentGeneration(), requestId: 1, + message: 'dataview/request', + action: 'page', + documentGeneration: documentGeneration(), + requestId: 1, }; const pending = receive(request); await session.showDataView( - 'table', 'json', 'x$updated', '', 'Two', 'test-dataview-document-generation' + 'table', + 'json', + 'x$updated', + '', + 'Two', + 'test-dataview-document-generation', ); await pending; sinon.assert.notCalled(postMessage); @@ -195,7 +280,9 @@ suite('List viewer panels', () => { sinon.assert.notCalled(postMessage); await receive({ - ...request, documentGeneration: documentGeneration(), requestId: 2, + ...request, + documentGeneration: documentGeneration(), + requestId: 2, }); sinon.assert.calledOnce(postMessage); const response = postMessage.firstCall.args[0] as { documentGeneration: number }; @@ -205,14 +292,40 @@ suite('List viewer panels', () => { test('supported workspace children retain open actions alongside expansion', () => { for (const type of ['list', 'environment', 'pairlist', 'S4', 'double', 'closure']) { const structured = ['list', 'environment', 'pairlist', 'S4'].includes(type); - const node = new GlobalEnvItem('', type, '$ item', type, 1, undefined, structured, - 'x', [{ kind: 'index', value: 1 }], undefined, true); + const node = new GlobalEnvItem( + '', + type, + '$ item', + type, + 1, + undefined, + structured, + 'x', + [{ kind: 'index', value: 1 }], + undefined, + true, + ); assert.strictEqual(node.contextValue, 'viewableNode'); - assert.strictEqual(node.collapsibleState, structured - ? vscode.TreeItemCollapsibleState.Collapsed : vscode.TreeItemCollapsibleState.None); + assert.strictEqual( + node.collapsibleState, + structured + ? vscode.TreeItemCollapsibleState.Collapsed + : vscode.TreeItemCollapsibleState.None, + ); } - const unavailable = new GlobalEnvItem('', 'active_binding', '', 'active_binding', 1, - undefined, false, 'x', [], undefined, false); + const unavailable = new GlobalEnvItem( + '', + 'active_binding', + '', + 'active_binding', + 1, + undefined, + false, + 'x', + [], + undefined, + false, + ); assert.notStrictEqual(unavailable.contextValue, 'viewableNode'); }); }); diff --git a/src/test/suite/plotBackend.test.ts b/src/test/suite/plotBackend.test.ts index fdb44c082..717423068 100644 --- a/src/test/suite/plotBackend.test.ts +++ b/src/test/suite/plotBackend.test.ts @@ -11,16 +11,20 @@ import { mockExtensionContext } from '../common/mockvscode'; suite('Plot backend setting migration', () => { let sandbox: sinon.SinonSandbox; - setup(() => { sandbox = sinon.createSandbox(); }); - teardown(() => { sandbox.restore(); }); + setup(() => { + sandbox = sinon.createSandbox(); + }); + teardown(() => { + sandbox.restore(); + }); function settings( canonical: Record = {}, - legacy: Record = {} + legacy: Record = {}, ): void { sandbox.stub(util, 'config').returns({ - get: (key: string) => key === 'plot.backend' ? 'auto' : false, - inspect: (key: string) => key === 'plot.backend' ? canonical : legacy + get: (key: string) => (key === 'plot.backend' ? 'auto' : false), + inspect: (key: string) => (key === 'plot.backend' ? canonical : legacy), } as unknown as vscode.WorkspaceConfiguration); } @@ -29,8 +33,12 @@ suite('Plot backend setting migration', () => { for (const legacyScope of scopes) { test(`canonical ${canonicalScope} versus legacy ${legacyScope}`, () => { settings({ [canonicalScope]: 'standard' }, { [legacyScope]: true }); - assert.strictEqual(resolveBackend(), - scopes.indexOf(canonicalScope) >= scopes.indexOf(legacyScope) ? 'standard' : 'httpgd'); + assert.strictEqual( + resolveBackend(), + scopes.indexOf(canonicalScope) >= scopes.indexOf(legacyScope) + ? 'standard' + : 'httpgd', + ); }); } } @@ -90,13 +98,17 @@ suite('Plot backend setting migration', () => { sinon.assert.notCalled(remove); sinon.assert.calledOnce(start); - const listener = onChange.firstCall.args[0] as (event: vscode.ConfigurationChangeEvent) => void; + const listener = onChange.firstCall.args[0] as ( + event: vscode.ConfigurationChangeEvent, + ) => void; for (const backend of ['auto', 'native', 'standard', 'httpgd'] as const) { canonical.workspaceValue = backend; replace.resetHistory(); remove.resetHistory(); start.resetHistory(); - listener({ affectsConfiguration: key => key === 'r.plot.backend' } as vscode.ConfigurationChangeEvent); + listener({ + affectsConfiguration: (key) => key === 'r.plot.backend', + } as vscode.ConfigurationChangeEvent); if (backend === 'auto') { sinon.assert.calledOnceWithExactly(replace, 'JGD_SOCKET', 'test-jgd-socket'); sinon.assert.notCalled(remove); diff --git a/src/test/suite/plotCommands.test.ts b/src/test/suite/plotCommands.test.ts index 3f27be8ee..c0438a9a6 100644 --- a/src/test/suite/plotCommands.test.ts +++ b/src/test/suite/plotCommands.test.ts @@ -21,10 +21,14 @@ const manifest = JSON.parse(fs.readFileSync(path.join(extensionRoot, 'package.js menus: Record; }; }; -const plotCommands = manifest.contributes.commands.filter(item => item.command.startsWith('r.plot.')); -const toolbarCommands = new Set(manifest.contributes.menus['editor/title'] - .flatMap(item => [item.command, item.alt]) - .filter((command): command is string => !!command?.startsWith('r.plot.'))); +const plotCommands = manifest.contributes.commands.filter((item) => + item.command.startsWith('r.plot.'), +); +const toolbarCommands = new Set( + manifest.contributes.menus['editor/title'] + .flatMap((item) => [item.command, item.alt]) + .filter((command): command is string => !!command?.startsWith('r.plot.')), +); suite('Contextual plot commands', () => { test('contextual plot commands are hidden while Show Viewers remains discoverable', () => { @@ -33,21 +37,33 @@ suite('Contextual plot commands', () => { if (command === 'r.plot.showViewers') { continue; } - const entries = manifest.contributes.menus.commandPalette.filter(item => item.command === command); + const entries = manifest.contributes.menus.commandPalette.filter( + (item) => item.command === command, + ); assert.ok(entries.length > 0, `${command} needs a Command Palette rule`); - assert.ok(entries.every(item => item.when === 'false'), `${command} must remain contextual`); + assert.ok( + entries.every((item) => item.when === 'false'), + `${command} must remain contextual`, + ); } for (const command of toolbarCommands) { - assert.ok(plotCommands.some(item => item.command === command), `${command} needs a contribution`); + assert.ok( + plotCommands.some((item) => item.command === command), + `${command} needs a contribution`, + ); } - const showViewers = plotCommands.find(item => item.command === 'r.plot.showViewers'); + const showViewers = plotCommands.find((item) => item.command === 'r.plot.showViewers'); assert.deepStrictEqual(showViewers, { title: 'Show Viewers', category: 'R Plot', command: 'r.plot.showViewers', - icon: '$(versions)' + icon: '$(versions)', }); - assert.ok(!manifest.contributes.menus.commandPalette.some(item => item.command === 'r.plot.showViewers')); + assert.ok( + !manifest.contributes.menus.commandPalette.some( + (item) => item.command === 'r.plot.showViewers', + ), + ); }); test('plot commands remain registered and route to the appropriate viewers', () => { @@ -62,7 +78,10 @@ suite('Contextual plot commands', () => { sandbox.stub(manager.jgdManager, 'start'); sandbox.stub(manager.jgdManager, 'getEnvVars').returns({}); const showJgdViewer = sandbox.stub(); - const jgdViewer = { show: showJgdViewer, handleCommand: sandbox.stub() } as unknown as JgdViewer; + const jgdViewer = { + show: showJgdViewer, + handleCommand: sandbox.stub(), + } as unknown as JgdViewer; sandbox.stub(manager.jgdManager, 'getViewer').returns(jgdViewer); const viewer = sandbox.createStubInstance(HttpgdViewer); Object.defineProperty(viewer, 'host', { value: 'localhost:1234' }); @@ -75,7 +94,7 @@ suite('Contextual plot commands', () => { manager.initialize(); const invoke = (command: string, ...args: unknown[]) => { - const registration = register.getCalls().find(call => call.args[0] === command); + const registration = register.getCalls().find((call) => call.args[0] === command); assert.ok(registration, `${command} must remain registered`); (registration.args[1] as (...args: unknown[]) => void)(...args); }; @@ -86,7 +105,9 @@ suite('Contextual plot commands', () => { sinon.assert.calledOnce(openUrl); } else { assert.strictEqual(viewer.handleCommand.callCount, 1); - assert.deepStrictEqual(viewer.handleCommand.firstCall.args, [command.slice('r.plot.'.length)]); + assert.deepStrictEqual(viewer.handleCommand.firstCall.args, [ + command.slice('r.plot.'.length), + ]); } } for (const command of ['showIndex', 'hidePlot']) { diff --git a/src/test/suite/processTree.test.ts b/src/test/suite/processTree.test.ts index 061edf44c..e7a97bc73 100644 --- a/src/test/suite/processTree.test.ts +++ b/src/test/suite/processTree.test.ts @@ -8,34 +8,45 @@ suite('Process ancestry', () => { teardown(() => sinon.restore()); test('follows parents in order without including siblings or looping', async () => { - const run = sinon.stub(childProcess, 'execFile').callsArgWith(3, null, - ' 30 20\r\n 20 10\r\n 40 20\r\n 10 20\r\n', ''); + const run = sinon + .stub(childProcess, 'execFile') + .callsArgWith(3, null, ' 30 20\r\n 20 10\r\n 40 20\r\n 10 20\r\n', ''); assert.deepStrictEqual(await getProcessAncestors(30), [20, 10]); sinon.assert.calledOnce(run); assert.strictEqual(run.firstCall.args[0], getProcessQuerySpec().executable); assert.deepStrictEqual(run.firstCall.args[1], getProcessQuerySpec().args); assert.deepStrictEqual(run.firstCall.args[2], { - encoding: 'utf8', timeout: getProcessQuerySpec().timeout, - maxBuffer: 4 * 1024 * 1024, windowsHide: true, + encoding: 'utf8', + timeout: getProcessQuerySpec().timeout, + maxBuffer: 4 * 1024 * 1024, + windowsHide: true, }); }); test('builds bounded process query commands for Windows and POSIX', () => { assert.deepStrictEqual(getProcessQuerySpec('win32'), { executable: 'powershell.exe', - args: ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', - 'Get-CimInstance Win32_Process -Property ProcessId,ParentProcessId | ForEach-Object { "{0} {1}" -f $_.ProcessId, $_.ParentProcessId }'], + args: [ + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-Command', + 'Get-CimInstance Win32_Process -Property ProcessId,ParentProcessId | ForEach-Object { "{0} {1}" -f $_.ProcessId, $_.ParentProcessId }', + ], timeout: 15000, }); assert.deepStrictEqual(getProcessQuerySpec('linux'), { - executable: 'ps', args: ['-A', '-o', 'pid=', '-o', 'ppid='], timeout: 5000, + executable: 'ps', + args: ['-A', '-o', 'pid=', '-o', 'ppid='], + timeout: 5000, }); assert.deepStrictEqual(getProcessQuerySpec('darwin'), getProcessQuerySpec('linux')); }); test('ignores malformed rows and stops at missing or zero parents', async () => { - sinon.stub(childProcess, 'execFile').callsArgWith(3, null, - '10 0\n20 10\n30 nope\n40 20 extra\n50 999\n', ''); + sinon + .stub(childProcess, 'execFile') + .callsArgWith(3, null, '10 0\n20 10\n30 nope\n40 20 extra\n50 999\n', ''); assert.deepStrictEqual(await getProcessAncestors(20), [10]); assert.deepStrictEqual(await getProcessAncestors(30), []); assert.deepStrictEqual(await getProcessAncestors(40), []); @@ -58,20 +69,21 @@ suite('Process ancestry', () => { sinon.assert.notCalled(run); }); - (process.platform !== 'win32' || process.env.VSCODE_R_TEST_WINDOWS_PROCESS_ANCESTRY === '1' ? test : test.skip)( - 'finds the parent of a real child process on this OS', async () => { - const child = childProcess.spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { - env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, stdio: 'ignore', - }); - try { - await once(child, 'spawn'); - assert.ok(child.pid); - assert.strictEqual((await getProcessAncestors(child.pid))[0], process.pid); - } finally { - const exited = once(child, 'exit'); - child.kill(); - await exited; - } + (process.platform !== 'win32' || process.env.VSCODE_R_TEST_WINDOWS_PROCESS_ANCESTRY === '1' + ? test + : test.skip)('finds the parent of a real child process on this OS', async () => { + const child = childProcess.spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { + env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, + stdio: 'ignore', + }); + try { + await once(child, 'spawn'); + assert.ok(child.pid); + assert.strictEqual((await getProcessAncestors(child.pid))[0], process.pid); + } finally { + const exited = once(child, 'exit'); + child.kill(); + await exited; } - ); + }); }); diff --git a/src/test/suite/rDebuggerCompatibility.test.ts b/src/test/suite/rDebuggerCompatibility.test.ts index a99630ba4..f4731ec8e 100644 --- a/src/test/suite/rDebuggerCompatibility.test.ts +++ b/src/test/suite/rDebuggerCompatibility.test.ts @@ -9,54 +9,85 @@ import { suite('R Debugger compatibility warning', () => { test('detects legacy settings in an object configuration', () => { - assert.strictEqual(registersLegacyRPathSetting({ - contributes: { - configuration: { - properties: { 'r.rpath.windows': { type: 'string' } }, + assert.strictEqual( + registersLegacyRPathSetting({ + contributes: { + configuration: { + properties: { 'r.rpath.windows': { type: 'string' } }, + }, }, - }, - }), true); + }), + true, + ); }); test('detects legacy settings in an array configuration', () => { - assert.strictEqual(registersLegacyRPathSetting({ - contributes: { - configuration: [ - { properties: { unrelated: { type: 'string' } } }, - { properties: { 'r.rpath.mac': { type: 'string' } } }, - ], - }, - }), true); + assert.strictEqual( + registersLegacyRPathSetting({ + contributes: { + configuration: [ + { properties: { unrelated: { type: 'string' } } }, + { properties: { 'r.rpath.mac': { type: 'string' } } }, + ], + }, + }), + true, + ); }); test('ignores malformed configuration and unrelated or inherited properties', () => { - const inheritedProperties = Object.create({ 'r.rpath.linux': { type: 'string' } }) as Record; + const inheritedProperties = Object.create({ + 'r.rpath.linux': { type: 'string' }, + }) as Record; assert.strictEqual(registersLegacyRPathSetting(undefined), false); - assert.strictEqual(registersLegacyRPathSetting({ contributes: { configuration: null } }), false); - assert.strictEqual(registersLegacyRPathSetting({ - contributes: { configuration: { properties: { 'r.rpath': { type: 'string' } } } }, - }), false); - assert.strictEqual(registersLegacyRPathSetting({ - contributes: { configuration: { properties: inheritedProperties } }, - }), false); + assert.strictEqual( + registersLegacyRPathSetting({ contributes: { configuration: null } }), + false, + ); + assert.strictEqual( + registersLegacyRPathSetting({ + contributes: { configuration: { properties: { 'r.rpath': { type: 'string' } } } }, + }), + false, + ); + assert.strictEqual( + registersLegacyRPathSetting({ + contributes: { configuration: { properties: inheritedProperties } }, + }), + false, + ); }); test('shows the warning once and stores its versioned global state key', async () => { let warningWasShown: boolean | undefined; const globalState = { - get: (key: string) => key === rDebuggerCompatibilityWarningKey ? warningWasShown as T | undefined : undefined, - update: (key: string, value: unknown) => Promise.resolve().then(() => { - assert.strictEqual(key, rDebuggerCompatibilityWarningKey); - warningWasShown = value as boolean; - }), + get: (key: string) => + key === rDebuggerCompatibilityWarningKey + ? (warningWasShown as T | undefined) + : undefined, + update: (key: string, value: unknown) => + Promise.resolve().then(() => { + assert.strictEqual(key, rDebuggerCompatibilityWarningKey); + warningWasShown = value as boolean; + }), } as unknown as vscode.Memento; const messages: string[] = []; const packageJSON = { contributes: { configuration: { properties: { 'r.rpath.linux': { type: 'string' } } } }, }; - assert.strictEqual(await showRDebuggerCompatibilityWarningOnce(globalState, packageJSON, message => messages.push(message)), true); - assert.strictEqual(await showRDebuggerCompatibilityWarningOnce(globalState, packageJSON, message => messages.push(message)), false); + assert.strictEqual( + await showRDebuggerCompatibilityWarningOnce(globalState, packageJSON, (message) => + messages.push(message), + ), + true, + ); + assert.strictEqual( + await showRDebuggerCompatibilityWarningOnce(globalState, packageJSON, (message) => + messages.push(message), + ), + false, + ); assert.strictEqual(warningWasShown, true); assert.strictEqual(messages.length, 1); assert.match(messages[0], /keep.*in sync with `r\.executablePath`/); @@ -66,15 +97,27 @@ suite('R Debugger compatibility warning', () => { let updateCalled = false; const globalState = { get: () => undefined, - update: () => Promise.resolve().then(() => { updateCalled = true; }), + update: () => + Promise.resolve().then(() => { + updateCalled = true; + }), } as unknown as vscode.Memento; let warningCalled = false; - assert.strictEqual(await showRDebuggerCompatibilityWarningOnce( - globalState, - { contributes: { configuration: { properties: { 'r.executablePath': { type: 'string' } } } } }, - () => { warningCalled = true; } - ), false); + assert.strictEqual( + await showRDebuggerCompatibilityWarningOnce( + globalState, + { + contributes: { + configuration: { properties: { 'r.executablePath': { type: 'string' } } }, + }, + }, + () => { + warningCalled = true; + }, + ), + false, + ); assert.strictEqual(updateCalled, false); assert.strictEqual(warningCalled, false); }); diff --git a/src/test/suite/rPathResolver.test.ts b/src/test/suite/rPathResolver.test.ts index 73568d9e3..77c86fa6e 100644 --- a/src/test/suite/rPathResolver.test.ts +++ b/src/test/suite/rPathResolver.test.ts @@ -25,20 +25,20 @@ function createDependencies( executables: Record = {}, existingPaths: readonly string[] = [], systemR = '/system/R', - substituteVariables: (value: string) => string = value => value + substituteVariables: (value: string) => string = (value) => value, ): DependencyHarness { const executableLookups: string[] = []; const pathChecks: string[] = []; let systemRCalls = 0; return { dependencies: { - getSetting: setting => settings[setting], + getSetting: (setting) => settings[setting], substituteVariables, - findExecutable: name => { + findExecutable: (name) => { executableLookups.push(name); return executables[name]; }, - pathExists: value => { + pathExists: (value) => { pathChecks.push(value); return existingPaths.includes(value); }, @@ -58,29 +58,30 @@ suite('R executable resolver', () => { const canonical = createDependencies( { executablePath: '/canonical/R', 'rpath.linux': '/legacy/R' }, {}, - ['/canonical/R', '/legacy/R'] - ); - assert.deepStrictEqual( - await resolveBackgroundR(canonical.dependencies, 'rpath.linux'), - { path: '/canonical/R', setting: 'executablePath', quote: undefined } + ['/canonical/R', '/legacy/R'], ); + assert.deepStrictEqual(await resolveBackgroundR(canonical.dependencies, 'rpath.linux'), { + path: '/canonical/R', + setting: 'executablePath', + quote: undefined, + }); assert.strictEqual(canonical.getSystemRCalls(), 0); const legacy = createDependencies( { executablePath: '', 'rpath.linux': 'legacy-R' }, - { 'legacy-R': '/legacy/R' } - ); - assert.deepStrictEqual( - await resolveBackgroundR(legacy.dependencies, 'rpath.linux'), - { path: '/legacy/R', setting: 'rpath.linux', quote: undefined } + { 'legacy-R': '/legacy/R' }, ); + assert.deepStrictEqual(await resolveBackgroundR(legacy.dependencies, 'rpath.linux'), { + path: '/legacy/R', + setting: 'rpath.linux', + quote: undefined, + }); assert.strictEqual(legacy.getSystemRCalls(), 0); const system = createDependencies({ executablePath: ' ', 'rpath.linux': '' }); - assert.deepStrictEqual( - await resolveBackgroundR(system.dependencies, 'rpath.linux'), - { path: '/system/R' } - ); + assert.deepStrictEqual(await resolveBackgroundR(system.dependencies, 'rpath.linux'), { + path: '/system/R', + }); assert.strictEqual(system.getSystemRCalls(), 1); }); @@ -88,22 +89,24 @@ suite('R executable resolver', () => { const harness = createDependencies( { executablePath: 'missing-R', 'rpath.linux': '/legacy/R' }, {}, - ['/legacy/R'] - ); - assert.deepStrictEqual( - await resolveBackgroundR(harness.dependencies, 'rpath.linux'), - { path: undefined, setting: 'executablePath', quote: undefined } + ['/legacy/R'], ); + assert.deepStrictEqual(await resolveBackgroundR(harness.dependencies, 'rpath.linux'), { + path: undefined, + setting: 'executablePath', + quote: undefined, + }); assert.strictEqual(harness.getSystemRCalls(), 0); assert.deepStrictEqual(harness.executableLookups, ['missing-R']); }); test('an invalid explicit legacy background setting does not fall through', async () => { const harness = createDependencies({ executablePath: '', 'rpath.linux': '/missing/R' }); - assert.deepStrictEqual( - await resolveBackgroundR(harness.dependencies, 'rpath.linux'), - { path: undefined, setting: 'rpath.linux', quote: undefined } - ); + assert.deepStrictEqual(await resolveBackgroundR(harness.dependencies, 'rpath.linux'), { + path: undefined, + setting: 'rpath.linux', + quote: undefined, + }); assert.strictEqual(harness.getSystemRCalls(), 0); }); @@ -115,38 +118,44 @@ suite('R executable resolver', () => { executablePath: '/background/R', }, {}, - ['/canonical/radian', '/legacy/radian', '/background/R'] - ); - assert.deepStrictEqual( - await resolveConsoleR(canonical.dependencies, 'rterm.linux'), - { path: '/canonical/radian', setting: 'consolePath', quote: undefined } + ['/canonical/radian', '/legacy/radian', '/background/R'], ); + assert.deepStrictEqual(await resolveConsoleR(canonical.dependencies, 'rterm.linux'), { + path: '/canonical/radian', + setting: 'consolePath', + quote: undefined, + }); const legacy = createDependencies( { consolePath: '', 'rterm.linux': 'legacy-radian', executablePath: '/background/R' }, { 'legacy-radian': '/legacy/radian' }, - ['/background/R'] - ); - assert.deepStrictEqual( - await resolveConsoleR(legacy.dependencies, 'rterm.linux'), - { path: '/legacy/radian', setting: 'rterm.linux', quote: undefined } + ['/background/R'], ); + assert.deepStrictEqual(await resolveConsoleR(legacy.dependencies, 'rterm.linux'), { + path: '/legacy/radian', + setting: 'rterm.linux', + quote: undefined, + }); const background = createDependencies( { consolePath: '', 'rterm.linux': '', executablePath: '/background/R' }, {}, - ['/background/R'] - ); - assert.deepStrictEqual( - await resolveConsoleR(background.dependencies, 'rterm.linux'), - { path: '/background/R', setting: 'executablePath', quote: undefined } + ['/background/R'], ); + assert.deepStrictEqual(await resolveConsoleR(background.dependencies, 'rterm.linux'), { + path: '/background/R', + setting: 'executablePath', + quote: undefined, + }); - const system = createDependencies({ consolePath: '', 'rterm.linux': '', executablePath: '' }); - assert.deepStrictEqual( - await resolveConsoleR(system.dependencies, 'rterm.linux'), - { path: '/system/R' } - ); + const system = createDependencies({ + consolePath: '', + 'rterm.linux': '', + executablePath: '', + }); + assert.deepStrictEqual(await resolveConsoleR(system.dependencies, 'rterm.linux'), { + path: '/system/R', + }); }); const scopes = ['globalValue', 'workspaceValue', 'workspaceFolderValue'] as const; @@ -159,23 +168,27 @@ suite('R executable resolver', () => { const canonicalKey = kind === 'console' ? 'consolePath' : 'executablePath'; const inspected = { [canonicalKey]: { [canonicalScope]: '/canonical/console' }, - [legacyKey]: { [legacyScope]: '/legacy/console' } + [legacyKey]: { [legacyScope]: '/legacy/console' }, }; const dependencies = createRPathResolverDependencies({ resource: 'project/script.R', - getConfiguration: resource => { + getConfiguration: (resource) => { assert.strictEqual(resource, 'project/script.R'); return { - get: key => key === canonicalKey ? '/canonical/console' : '/legacy/console', - inspect: key => inspected[key], + get: (key) => + key === canonicalKey + ? '/canonical/console' + : '/legacy/console', + inspect: (key) => inspected[key], }; }, - substituteVariables: value => value, + substituteVariables: (value) => value, findExecutable: () => undefined, pathExists: () => true, getSystemR: () => assert.fail('Configured console should be used'), }); - const canonicalWins = scopes.indexOf(canonicalScope) >= scopes.indexOf(legacyScope); + const canonicalWins = + scopes.indexOf(canonicalScope) >= scopes.indexOf(legacyScope); const resolve = kind === 'console' ? resolveConsoleR : resolveBackgroundR; assert.deepStrictEqual(await resolve(dependencies, legacyKey), { path: canonicalWins ? '/canonical/console' : '/legacy/console', @@ -186,37 +199,62 @@ suite('R executable resolver', () => { } } } - } test('an empty workspace path shadows its user value and allows the legacy setting', async () => { - const harness = createDependencies({ - consolePath: '', 'rterm.linux': '/legacy/console' - }, {}, ['/user/console', '/legacy/console']); - harness.dependencies.inspectSetting = key => key === 'consolePath' - ? { workspaceValue: '', globalValue: '/user/console' } - : { globalValue: '/legacy/console' }; - assert.strictEqual((await resolveConsoleR(harness.dependencies, 'rterm.linux')).path, '/legacy/console'); + const harness = createDependencies( + { + consolePath: '', + 'rterm.linux': '/legacy/console', + }, + {}, + ['/user/console', '/legacy/console'], + ); + harness.dependencies.inspectSetting = (key) => + key === 'consolePath' + ? { workspaceValue: '', globalValue: '/user/console' } + : { globalValue: '/legacy/console' }; + assert.strictEqual( + (await resolveConsoleR(harness.dependencies, 'rterm.linux')).path, + '/legacy/console', + ); }); test('invalid workspace legacy console path keeps its setting name and does not fall back', async () => { - const harness = createDependencies({ - consolePath: '/user/console', 'rterm.linux': '/missing/console', executablePath: '/background/R' - }, {}, ['/user/console', '/background/R']); - harness.dependencies.inspectSetting = key => key === 'consolePath' - ? { globalValue: '/user/console' } : { workspaceValue: '/missing/console' }; + const harness = createDependencies( + { + consolePath: '/user/console', + 'rterm.linux': '/missing/console', + executablePath: '/background/R', + }, + {}, + ['/user/console', '/background/R'], + ); + harness.dependencies.inspectSetting = (key) => + key === 'consolePath' + ? { globalValue: '/user/console' } + : { workspaceValue: '/missing/console' }; assert.deepStrictEqual(await resolveConsoleR(harness.dependencies, 'rterm.linux'), { - path: undefined, setting: 'rterm.linux', quote: undefined + path: undefined, + setting: 'rterm.linux', + quote: undefined, }); assert.strictEqual(harness.getSystemRCalls(), 0); }); test('blank console path allows a legacy value at the same scope', async () => { - const harness = createDependencies({ consolePath: ' ', 'rterm.linux': '/legacy/console' }, {}, ['/legacy/console']); - harness.dependencies.inspectSetting = key => ({ - workspaceFolderValue: key === 'consolePath' ? ' ' : '/legacy/console' + const harness = createDependencies( + { consolePath: ' ', 'rterm.linux': '/legacy/console' }, + {}, + ['/legacy/console'], + ); + harness.dependencies.inspectSetting = (key) => ({ + workspaceFolderValue: key === 'consolePath' ? ' ' : '/legacy/console', }); - assert.strictEqual((await resolveConsoleR(harness.dependencies, 'rterm.linux')).path, '/legacy/console'); + assert.strictEqual( + (await resolveConsoleR(harness.dependencies, 'rterm.linux')).path, + '/legacy/console', + ); }); test('legacy background setting never feeds console fallback', async () => { @@ -226,34 +264,43 @@ suite('R executable resolver', () => { executablePath: '', 'rpath.linux': '/legacy/background/R', }); - assert.deepStrictEqual( - await resolveConsoleR(harness.dependencies, 'rterm.linux'), - { path: '/system/R' } - ); + assert.deepStrictEqual(await resolveConsoleR(harness.dependencies, 'rterm.linux'), { + path: '/system/R', + }); assert.strictEqual(harness.getSystemRCalls(), 1); }); test('invalid explicit console choices do not silently fall through', async () => { - const consoleHarness = createDependencies({ - consolePath: 'missing-console', - 'rterm.linux': '/legacy/radian', - executablePath: '/background/R', - }, {}, ['/legacy/radian', '/background/R']); - assert.deepStrictEqual( - await resolveConsoleR(consoleHarness.dependencies, 'rterm.linux'), - { path: undefined, setting: 'consolePath', quote: undefined } + const consoleHarness = createDependencies( + { + consolePath: 'missing-console', + 'rterm.linux': '/legacy/radian', + executablePath: '/background/R', + }, + {}, + ['/legacy/radian', '/background/R'], ); + assert.deepStrictEqual(await resolveConsoleR(consoleHarness.dependencies, 'rterm.linux'), { + path: undefined, + setting: 'consolePath', + quote: undefined, + }); assert.strictEqual(consoleHarness.getSystemRCalls(), 0); - const legacyHarness = createDependencies({ - consolePath: '', - 'rterm.linux': '/missing/console', - executablePath: '/background/R', - }, {}, ['/background/R']); - assert.deepStrictEqual( - await resolveConsoleR(legacyHarness.dependencies, 'rterm.linux'), - { path: undefined, setting: 'rterm.linux', quote: undefined } + const legacyHarness = createDependencies( + { + consolePath: '', + 'rterm.linux': '/missing/console', + executablePath: '/background/R', + }, + {}, + ['/background/R'], ); + assert.deepStrictEqual(await resolveConsoleR(legacyHarness.dependencies, 'rterm.linux'), { + path: undefined, + setting: 'rterm.linux', + quote: undefined, + }); assert.strictEqual(legacyHarness.getSystemRCalls(), 0); const backgroundHarness = createDependencies({ @@ -263,7 +310,7 @@ suite('R executable resolver', () => { }); assert.deepStrictEqual( await resolveConsoleR(backgroundHarness.dependencies, 'rterm.linux'), - { path: undefined, setting: 'executablePath', quote: undefined } + { path: undefined, setting: 'executablePath', quote: undefined }, ); assert.strictEqual(backgroundHarness.getSystemRCalls(), 0); }); @@ -274,10 +321,9 @@ suite('R executable resolver', () => { 'rterm.linux': '\u0027\u0027', executablePath: '', }); - assert.deepStrictEqual( - await resolveConsoleR(harness.dependencies, 'rterm.linux'), - { path: '/system/R' } - ); + assert.deepStrictEqual(await resolveConsoleR(harness.dependencies, 'rterm.linux'), { + path: '/system/R', + }); assert.strictEqual(harness.getSystemRCalls(), 1); }); @@ -285,11 +331,11 @@ suite('R executable resolver', () => { const harness = createDependencies( { executablePath: 'custom-R', 'rpath.linux': '' }, { 'custom-R': '/tools/custom-R' }, - ['/workspace/custom-R'] + ['/workspace/custom-R'], ); assert.strictEqual( (await resolveBackgroundR(harness.dependencies, 'rpath.linux')).path, - '/tools/custom-R' + '/tools/custom-R', ); assert.deepStrictEqual(harness.executableLookups, ['custom-R']); assert.deepStrictEqual(harness.pathChecks, []); @@ -298,12 +344,13 @@ suite('R executable resolver', () => { test('r.consolePath bare arf resolves to its absolute path on PATH', async () => { const harness = createDependencies( { consolePath: 'arf', 'rterm.linux': '', executablePath: '' }, - { arf: '/opt/arf/bin/arf' } - ); - assert.deepStrictEqual( - await resolveConsoleR(harness.dependencies, 'rterm.linux'), - { path: '/opt/arf/bin/arf', setting: 'consolePath', quote: undefined } + { arf: '/opt/arf/bin/arf' }, ); + assert.deepStrictEqual(await resolveConsoleR(harness.dependencies, 'rterm.linux'), { + path: '/opt/arf/bin/arf', + setting: 'consolePath', + quote: undefined, + }); assert.deepStrictEqual(harness.executableLookups, ['arf']); assert.strictEqual(harness.getSystemRCalls(), 0); }); @@ -314,11 +361,11 @@ suite('R executable resolver', () => { {}, ['/workspace/tools/R'], '/system/R', - value => substitutePathVariables(value, { workspaceFolder: '/workspace' }) + (value) => substitutePathVariables(value, { workspaceFolder: '/workspace' }), ); assert.strictEqual( (await resolveBackgroundR(harness.dependencies, 'rpath.linux')).path, - '/workspace/tools/R' + '/workspace/tools/R', ); assert.deepStrictEqual(harness.pathChecks, ['/workspace/tools/R']); assert.deepStrictEqual(harness.executableLookups, []); @@ -330,12 +377,13 @@ suite('R executable resolver', () => { {}, ['/legacy/R'], '/system/R', - value => substitutePathVariables(value, {}) - ); - assert.deepStrictEqual( - await resolveBackgroundR(harness.dependencies, 'rpath.linux'), - { path: undefined, setting: 'executablePath', quote: undefined } + (value) => substitutePathVariables(value, {}), ); + assert.deepStrictEqual(await resolveBackgroundR(harness.dependencies, 'rpath.linux'), { + path: undefined, + setting: 'executablePath', + quote: undefined, + }); assert.strictEqual(harness.getSystemRCalls(), 0); }); @@ -348,11 +396,14 @@ suite('R executable resolver', () => { workspaceFolder: '/workspace/first', fileWorkspaceFolder: '/workspace/second', fileDirname: '/workspace/second/src', - } + }, ), - '/home/user|/workspace/first|/workspace/second|/workspace/second/src' + '/home/user|/workspace/first|/workspace/second|/workspace/second/src', + ); + assert.strictEqual( + substitutePathVariables('${workspaceFolder}/R', {}), + '${workspaceFolder}/R', ); - assert.strictEqual(substitutePathVariables('${workspaceFolder}/R', {}), '${workspaceFolder}/R'); }); test('workspace selection supports single-root, multi-root, no file, and no workspace', () => { @@ -374,20 +425,30 @@ suite('R executable resolver', () => { default: { executablePath: '/default/R' }, }; const existingPaths = new Set(['/workspace-a/R', '/workspace-b/R', '/default/R']); - const dependencies = (resource?: Resource) => createRPathResolverDependencies({ - resource, - getConfiguration: requestedResource => ({ - get: (setting: string) => settings[requestedResource ?? 'default'][setting], - }), - substituteVariables: value => value, - findExecutable: () => undefined, - pathExists: value => existingPaths.has(value), - getSystemR: () => Promise.resolve(undefined), - }); + const dependencies = (resource?: Resource) => + createRPathResolverDependencies({ + resource, + getConfiguration: (requestedResource) => ({ + get: (setting: string) => settings[requestedResource ?? 'default'][setting], + }), + substituteVariables: (value) => value, + findExecutable: () => undefined, + pathExists: (value) => existingPaths.has(value), + getSystemR: () => Promise.resolve(undefined), + }); - assert.strictEqual((await resolveBackgroundR(dependencies('workspace-a'), 'rpath.linux')).path, '/workspace-a/R'); - assert.strictEqual((await resolveBackgroundR(dependencies('workspace-b'), 'rpath.linux')).path, '/workspace-b/R'); - assert.strictEqual((await resolveBackgroundR(dependencies(), 'rpath.linux')).path, '/default/R'); + assert.strictEqual( + (await resolveBackgroundR(dependencies('workspace-a'), 'rpath.linux')).path, + '/workspace-a/R', + ); + assert.strictEqual( + (await resolveBackgroundR(dependencies('workspace-b'), 'rpath.linux')).path, + '/workspace-b/R', + ); + assert.strictEqual( + (await resolveBackgroundR(dependencies(), 'rpath.linux')).path, + '/default/R', + ); }); test('dependency adapter substitutes workspaceFolder for the requested resource', async () => { @@ -398,29 +459,41 @@ suite('R executable resolver', () => { default: '/active-workspace', }; const existingPaths = new Set(['/workspace-a/R', '/workspace-b/R', '/active-workspace/R']); - const dependencies = (resource?: Resource) => createRPathResolverDependencies({ - resource, - getConfiguration: () => ({ - get: (setting: string) => setting === 'executablePath' ? '${workspaceFolder}/R' : undefined, - }), - substituteVariables: (value, requestedResource) => substitutePathVariables(value, { - workspaceFolder: workspaceFolders[requestedResource ?? 'default'], - }), - findExecutable: () => undefined, - pathExists: value => existingPaths.has(value), - getSystemR: () => Promise.resolve(undefined), - }); + const dependencies = (resource?: Resource) => + createRPathResolverDependencies({ + resource, + getConfiguration: () => ({ + get: (setting: string) => + setting === 'executablePath' ? '${workspaceFolder}/R' : undefined, + }), + substituteVariables: (value, requestedResource) => + substitutePathVariables(value, { + workspaceFolder: workspaceFolders[requestedResource ?? 'default'], + }), + findExecutable: () => undefined, + pathExists: (value) => existingPaths.has(value), + getSystemR: () => Promise.resolve(undefined), + }); - assert.strictEqual((await resolveBackgroundR(dependencies('workspace-a'), 'rpath.linux')).path, '/workspace-a/R'); - assert.strictEqual((await resolveBackgroundR(dependencies('workspace-b'), 'rpath.linux')).path, '/workspace-b/R'); - assert.strictEqual((await resolveBackgroundR(dependencies(), 'rpath.linux')).path, '/active-workspace/R'); + assert.strictEqual( + (await resolveBackgroundR(dependencies('workspace-a'), 'rpath.linux')).path, + '/workspace-a/R', + ); + assert.strictEqual( + (await resolveBackgroundR(dependencies('workspace-b'), 'rpath.linux')).path, + '/workspace-b/R', + ); + assert.strictEqual( + (await resolveBackgroundR(dependencies(), 'rpath.linux')).path, + '/active-workspace/R', + ); }); test('quoted configured values are resolved and formatting preserves getRpath quote behavior', async () => { const doubleQuoted = createDependencies( { executablePath: '"/R path/bin/R"', 'rpath.linux': '' }, {}, - ['/R path/bin/R'] + ['/R path/bin/R'], ); const doubleResolution = await resolveBackgroundR(doubleQuoted.dependencies, 'rpath.linux'); assert.strictEqual(doubleResolution.path, '/R path/bin/R'); @@ -430,18 +503,21 @@ suite('R executable resolver', () => { const singleResolution: ExecutableResolution = { path: '/R path/bin/R', quote: `'` }; assert.strictEqual(formatRPath(singleResolution, true, 'linux'), `'/R path/bin/R'`); assert.strictEqual(formatRPath(singleResolution, true, 'win32'), '"/R path/bin/R"'); - assert.strictEqual(formatRPath({ path: '/R path/bin/R' }, true, 'linux'), '"/R path/bin/R"'); + assert.strictEqual( + formatRPath({ path: '/R path/bin/R' }, true, 'linux'), + '"/R path/bin/R"', + ); }); test('console resolution returns an unquoted executable path', async () => { const harness = createDependencies( { consolePath: `'/R console/radian'`, 'rterm.linux': '', executablePath: '' }, {}, - ['/R console/radian'] + ['/R console/radian'], ); assert.strictEqual( (await resolveConsoleR(harness.dependencies, 'rterm.linux')).path, - '/R console/radian' + '/R console/radian', ); }); }); @@ -454,51 +530,69 @@ suite('system R resolver', () => { const windowsRWithExtension = String.raw`C:\R\R.exe`; assert.strictEqual( - findExecutableOnPath('R', 'linux', '/first:/second', value => value === '/second/R'), - '/second/R' + findExecutableOnPath('R', 'linux', '/first:/second', (value) => value === '/second/R'), + '/second/R', ); assert.strictEqual( - findExecutableOnPath('R', 'win32', windowsPath, value => value === windowsR), - windowsR + findExecutableOnPath('R', 'win32', windowsPath, (value) => value === windowsR), + windowsR, ); assert.strictEqual( - findExecutableOnPath('R.exe', 'win32', windowsRDirectory, value => value === windowsRWithExtension), - windowsRWithExtension + findExecutableOnPath( + 'R.exe', + 'win32', + windowsRDirectory, + (value) => value === windowsRWithExtension, + ), + windowsRWithExtension, ); }); test('PATH wins over the Windows registry', async () => { const pathR = String.raw`C:\PATH\R.exe`; let registryCalls = 0; - assert.strictEqual(await resolveSystemR({ - platform: 'win32', - findExecutable: () => pathR, - getWindowsInstallPath: () => { - registryCalls += 1; - return Promise.resolve(String.raw`C:\Registry\R`); - }, - }), pathR); + assert.strictEqual( + await resolveSystemR({ + platform: 'win32', + findExecutable: () => pathR, + getWindowsInstallPath: () => { + registryCalls += 1; + return Promise.resolve(String.raw`C:\Registry\R`); + }, + }), + pathR, + ); assert.strictEqual(registryCalls, 0); }); - test(String.raw`Windows registry InstallPath supplies bin\R.exe after PATH misses`, async () => { - assert.strictEqual(await resolveSystemR({ - platform: 'win32', - findExecutable: () => undefined, - getWindowsInstallPath: () => Promise.resolve(String.raw`C:\Program Files\R\R-4.5.0`), - }), String.raw`C:\Program Files\R\R-4.5.0\bin\R.exe`); - }); + test( + String.raw`Windows registry InstallPath supplies bin\R.exe after PATH misses`, + async () => { + assert.strictEqual( + await resolveSystemR({ + platform: 'win32', + findExecutable: () => undefined, + getWindowsInstallPath: () => + Promise.resolve(String.raw`C:\Program Files\R\R-4.5.0`), + }), + String.raw`C:\Program Files\R\R-4.5.0\bin\R.exe`, + ); + }, + ); test('non-Windows systems do not query the registry', async () => { let registryCalls = 0; - assert.strictEqual(await resolveSystemR({ - platform: 'linux', - findExecutable: () => undefined, - getWindowsInstallPath: () => { - registryCalls += 1; - return Promise.resolve('/registry/R'); - }, - }), undefined); + assert.strictEqual( + await resolveSystemR({ + platform: 'linux', + findExecutable: () => undefined, + getWindowsInstallPath: () => { + registryCalls += 1; + return Promise.resolve('/registry/R'); + }, + }), + undefined, + ); assert.strictEqual(registryCalls, 0); }); }); diff --git a/src/test/suite/removeLeadingComments.test.ts b/src/test/suite/removeLeadingComments.test.ts index dea933ed7..5099c6277 100644 --- a/src/test/suite/removeLeadingComments.test.ts +++ b/src/test/suite/removeLeadingComments.test.ts @@ -1,12 +1,8 @@ - - import * as assert from 'assert'; import { removeLeadingComments } from '../../selection'; // Defines a Mocha test suite to group tests of similar kind together suite('removeLeadingComments Tests', () => { - - test('Check that nothing changes if no comments', () => { const input = `\ function (x) { @@ -23,7 +19,7 @@ suite('removeLeadingComments Tests', () => { const result = removeLeadingComments(input); assert.strictEqual(result, expectedOutput); }); - + test('Check that basic comments are removed', () => { const input = `\ # a leading comment @@ -41,7 +37,7 @@ suite('removeLeadingComments Tests', () => { const result = removeLeadingComments(input); assert.strictEqual(result, expectedOutput); }); - + test('Check that inner comments are not removed', () => { const input = `\ diff --git a/src/test/suite/rmarkdown.test.ts b/src/test/suite/rmarkdown.test.ts index c34b3fdbf..526df3f6c 100644 --- a/src/test/suite/rmarkdown.test.ts +++ b/src/test/suite/rmarkdown.test.ts @@ -11,11 +11,19 @@ import { RMarkdownKnitManager } from '../../rmarkdown/knit'; import { mockExtensionContext } from '../common/mockvscode'; class KnitHarness extends RMarkdownManager { - directory(kind: KnitWorkingDirectory, file: string) { return this.getKnitDir(kind, file); } + directory(kind: KnitWorkingDirectory, file: string) { + return this.getKnitDir(kind, file); + } knit(callback: (text: string) => boolean) { this.rPath = 'R'; - return this.knitDocument({ workingDirectory: os.tmpdir(), filePath: '/test.Rmd', - fileName: 'test.Rmd', scriptArgs: {}, scriptPath: '/knit.R', callback }); + return this.knitDocument({ + workingDirectory: os.tmpdir(), + filePath: '/test.Rmd', + fileName: 'test.Rmd', + scriptArgs: {}, + scriptPath: '/knit.R', + callback, + }); } } @@ -30,16 +38,29 @@ suite('R Markdown execution', () => { test('working directories preserve literal quotes for spawn and environment values', () => { const directory = path.join(os.tmpdir(), `project's "report"`); const manager = new KnitHarness(); - assert.strictEqual(manager.directory(KnitWorkingDirectory.documentDirectory, path.join(directory, 'test.Rmd')), directory); + assert.strictEqual( + manager.directory( + KnitWorkingDirectory.documentDirectory, + path.join(directory, 'test.Rmd'), + ), + directory, + ); const workspaceUri = vscode.Uri.file(directory); - sandbox.stub(vscode.workspace, 'getWorkspaceFolder').returns({ uri: workspaceUri } as vscode.WorkspaceFolder); + sandbox + .stub(vscode.workspace, 'getWorkspaceFolder') + .returns({ uri: workspaceUri } as vscode.WorkspaceFolder); // VS Code normalizes Windows drive-letter casing in URI filesystem paths. - assert.strictEqual(manager.directory(KnitWorkingDirectory.workspaceRoot, '/test.Rmd'), workspaceUri.fsPath); + assert.strictEqual( + manager.directory(KnitWorkingDirectory.workspaceRoot, '/test.Rmd'), + workspaceUri.fsPath, + ); }); function mockChild() { const child = Object.assign(new EventEmitter(), { - stdout: new EventEmitter(), stderr: new EventEmitter(), dispose: sandbox.stub() + stdout: new EventEmitter(), + stderr: new EventEmitter(), + dispose: sandbox.stub(), }) as unknown as util.DisposableProcess; sandbox.stub(util, 'spawn').returns(child); return child; @@ -51,8 +72,11 @@ suite('R Markdown execution', () => { child.emit('error', new Error('ENOENT')); child.emit('close', -2, null); const settled = await Promise.race([ - result.then(() => 'resolved', () => 'rejected'), - new Promise(resolve => setTimeout(resolve, 100)) + result.then( + () => 'resolved', + () => 'rejected', + ), + new Promise((resolve) => setTimeout(resolve, 100)), ]); assert.strictEqual(settled, 'rejected'); }); @@ -60,9 +84,11 @@ suite('R Markdown execution', () => { test('knitting recognizes a completion marker fragmented across UTF-8 chunks', async () => { const child = mockChild(); let output = ''; - const result = new KnitHarness().knit(text => { + const result = new KnitHarness().knit((text) => { const match = /<<>>(.*?)<<>>/s.exec(text); - if (!match) { return false; } + if (!match) { + return false; + } output = match[1]; return true; }); @@ -72,8 +98,11 @@ suite('R Markdown execution', () => { child.stdout.emit('data', bytes.subarray(22)); child.emit('close', 0, null); const settled = await Promise.race([ - result.then(() => 'resolved', () => 'rejected'), - new Promise(resolve => setTimeout(resolve, 100)) + result.then( + () => 'resolved', + () => 'rejected', + ), + new Promise((resolve) => setTimeout(resolve, 100)), ]); assert.strictEqual(settled, 'resolved'); assert.strictEqual(output, '/tmp/你好.html'); @@ -85,22 +114,37 @@ suite('R Markdown execution', () => { child.emit('exit', 0, null); child.emit('close', 0, null); const settled = await Promise.race([ - result.then(() => 'resolved', () => 'rejected'), - new Promise(resolve => setTimeout(resolve, 100)) + result.then( + () => 'resolved', + () => 'rejected', + ), + new Promise((resolve) => setTimeout(resolve, 100)), ]); assert.strictEqual(settled, 'rejected'); }); test('knit commands preserve R literals in filenames and custom functions', async () => { - sandbox.stub(util, 'config').returns({ get: () => 'rmarkdown::render' } as unknown as vscode.WorkspaceConfiguration); + sandbox.stub(util, 'config').returns({ + get: () => 'rmarkdown::render', + } as unknown as vscode.WorkspaceConfiguration); const manager = new RMarkdownKnitManager() as unknown as { - getKnitCommand(params: { knit?: string; site: string }, file: string, format?: string): Promise + getKnitCommand( + params: { knit?: string; site: string }, + file: string, + format?: string, + ): Promise; }; const file = util.ToRStringLiteral(`/tmp/it's "report".Rmd`, '"'); - assert.strictEqual(await manager.getKnitCommand({ site: '' }, file), `rmarkdown::render(${file})`); - const knit = 'function(input, ...) rmarkdown::render(input, params = list(label = "a\'b"), ...)'; - assert.strictEqual(await manager.getKnitCommand({ site: '', knit }, file, 'html_document'), - `(${knit})(${file}, output_format = "html_document")`); + assert.strictEqual( + await manager.getKnitCommand({ site: '' }, file), + `rmarkdown::render(${file})`, + ); + const knit = + 'function(input, ...) rmarkdown::render(input, params = list(label = "a\'b"), ...)'; + assert.strictEqual( + await manager.getKnitCommand({ site: '', knit }, file, 'html_document'), + `(${knit})(${file}, output_format = "html_document")`, + ); }); test('non-object YAML frontmatter cannot leave knitting permanently busy', async () => { @@ -109,22 +153,37 @@ suite('R Markdown execution', () => { const file = path.join(directory, 'test.Rmd'); fs.writeFileSync(file, '---\n# comments only\n---\n'); sandbox.stub(util, 'config').returns({ - get: (key: string) => key === 'rmarkdown.knit.useBackgroundProcess' ? true : undefined + get: (key: string) => + key === 'rmarkdown.knit.useBackgroundProcess' ? true : undefined, } as unknown as vscode.WorkspaceConfiguration); sandbox.stub(vscode.window, 'activeTextEditor').value({ - document: { uri: vscode.Uri.file(file), fileName: file, isUntitled: false, isDirty: false } + document: { + uri: vscode.Uri.file(file), + fileName: file, + isUntitled: false, + isDirty: false, + }, }); const manager = new RMarkdownKnitManager() as unknown as { knitRmd(echo: boolean): Promise; - renderDocument: (literal: string, file: string, name: string, params: unknown) => Promise; + renderDocument: ( + literal: string, + file: string, + name: string, + params: unknown, + ) => Promise; getYamlFrontmatter(file: string): unknown; }; assert.deepStrictEqual(manager.getYamlFrontmatter(file), {}); - const render = sandbox.stub(manager, 'renderDocument').rejects(new Error('transient failure')); + const render = sandbox + .stub(manager, 'renderDocument') + .rejects(new Error('transient failure')); await assert.rejects(manager.knitRmd(false), /transient failure/); render.resolves(); await manager.knitRmd(false); sinon.assert.calledTwice(render); - } finally { fs.rmSync(directory, { recursive: true, force: true }); } + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } }); }); diff --git a/src/test/suite/rmdParams.test.ts b/src/test/suite/rmdParams.test.ts index 5a3792a14..7dfe703a4 100644 --- a/src/test/suite/rmdParams.test.ts +++ b/src/test/suite/rmdParams.test.ts @@ -8,9 +8,9 @@ suite('Rmd Params Test Suite', () => { languageId: 'r', getText: () => '---\nparams:\n a: 1\n---', uri: vscode.Uri.file('/test_non_rmd.R'), - version: 1 + version: 1, } as vscode.TextDocument; - + const cmd = rTerminal.getRmdParamsCommand(mockDoc); assert.strictEqual(cmd, undefined); }); @@ -20,9 +20,9 @@ suite('Rmd Params Test Suite', () => { languageId: 'rmd', getText: () => 'title: Test\nparams:\n a: 1', uri: vscode.Uri.file('/test_no_header.Rmd'), - version: 1 + version: 1, } as vscode.TextDocument; - + const cmd = rTerminal.getRmdParamsCommand(mockDoc); assert.strictEqual(cmd, undefined); }); @@ -32,9 +32,9 @@ suite('Rmd Params Test Suite', () => { languageId: 'rmd', getText: () => '---\ntitle: Test\n---', uri: vscode.Uri.file('/test_no_params.Rmd'), - version: 1 + version: 1, } as vscode.TextDocument; - + const cmd = rTerminal.getRmdParamsCommand(mockDoc); assert.strictEqual(cmd, undefined); }); @@ -44,9 +44,9 @@ suite('Rmd Params Test Suite', () => { languageId: 'rmd', getText: () => '---\nparams:\n a: 1\n b: "test"\n---', uri: vscode.Uri.file('/test_valid_params.Rmd'), - version: 1 + version: 1, } as vscode.TextDocument; - + const cmd = rTerminal.getRmdParamsCommand(mockDoc); assert.strictEqual(cmd, 'params <- list(a = 1, b = "test")'); }); @@ -56,9 +56,9 @@ suite('Rmd Params Test Suite', () => { languageId: 'rmd', getText: () => '---\nparams:\n a: !r 1+1\n---', uri: vscode.Uri.file('/test_custom_type.Rmd'), - version: 1 + version: 1, } as vscode.TextDocument; - + const cmd = rTerminal.getRmdParamsCommand(mockDoc); assert.strictEqual(cmd, 'params <- list(a = 1+1)'); }); @@ -68,23 +68,23 @@ suite('Rmd Params Test Suite', () => { languageId: 'rmd', getText: () => '---\nparams:\n a: 1\n---', uri: vscode.Uri.file('/test_cache_invalidation.Rmd'), - version: 1 + version: 1, } as vscode.TextDocument; - + let cmd = rTerminal.getRmdParamsCommand(mockDoc); assert.strictEqual(cmd, 'params <- list(a = 1)'); - + // Same file, same version -> returns undefined (cached) cmd = rTerminal.getRmdParamsCommand(mockDoc); assert.strictEqual(cmd, undefined); - + // Same file, new version -> parses again const updatedDoc = { ...mockDoc, getText: () => '---\nparams:\n a: 2\n---', - version: 2 + version: 2, } as vscode.TextDocument; - + cmd = rTerminal.getRmdParamsCommand(updatedDoc); assert.strictEqual(cmd, 'params <- list(a = 2)'); }); diff --git a/src/test/suite/rstudioapi.test.ts b/src/test/suite/rstudioapi.test.ts index 46fb2b482..734c5f2fe 100644 --- a/src/test/suite/rstudioapi.test.ts +++ b/src/test/suite/rstudioapi.test.ts @@ -6,24 +6,39 @@ import { handleEditorRequest } from '../../session'; suite('RStudio editor request completion', () => { let sandbox: sinon.SinonSandbox; - setup(() => { sandbox = sinon.createSandbox(); }); + setup(() => { + sandbox = sinon.createSandbox(); + }); teardown(() => sandbox.restore()); function document() { const uri = vscode.Uri.file('/project/test.R'); - return { uri, lineCount: 1, validatePosition: (position: vscode.Position) => position, - validateRange: (range: vscode.Range) => range } as vscode.TextDocument; + return { + uri, + lineCount: 1, + validatePosition: (position: vscode.Position) => position, + validateRange: (range: vscode.Range) => range, + } as vscode.TextDocument; } test('insert requests wait for the workspace edit before acknowledging completion', async () => { const doc = document(); sandbox.stub(vscode.workspace, 'openTextDocument').resolves(doc); let finish!: (value: boolean) => void; - sandbox.stub(vscode.workspace, 'applyEdit').returns(new Promise(resolve => { finish = resolve; })); + sandbox.stub(vscode.workspace, 'applyEdit').returns( + new Promise((resolve) => { + finish = resolve; + }), + ); let completed = false; - const request = insertOrModifyText([{ operation: 'insertText', text: 'x <- 1', location: [1, 1] }], doc.uri.toString()); - void request.then(() => { completed = true; }); - await new Promise(resolve => setImmediate(resolve)); + const request = insertOrModifyText( + [{ operation: 'insertText', text: 'x <- 1', location: [1, 1] }], + doc.uri.toString(), + ); + void request.then(() => { + completed = true; + }); + await new Promise((resolve) => setImmediate(resolve)); assert.strictEqual(completed, false); finish(true); await request; @@ -32,16 +47,24 @@ suite('RStudio editor request completion', () => { test('new document requests wait for editing and showing the editor', async () => { const doc = document(); - sandbox.stub(vscode.workspace, 'workspaceFolders').value([{ uri: vscode.Uri.file('/project') }]); + sandbox + .stub(vscode.workspace, 'workspaceFolders') + .value([{ uri: vscode.Uri.file('/project') }]); sandbox.stub(vscode.workspace, 'openTextDocument').resolves(doc); let finish!: (value: boolean) => void; - sandbox.stub(vscode.workspace, 'applyEdit').returns(new Promise(resolve => { finish = resolve; })); + sandbox.stub(vscode.workspace, 'applyEdit').returns( + new Promise((resolve) => { + finish = resolve; + }), + ); const editor = { document: doc, selections: [] } as unknown as vscode.TextEditor; const show = sandbox.stub(vscode.window, 'showTextDocument').resolves(editor); let completed = false; const request = documentNew('x <- 1', 'R', [1, 1]); - void request.then(() => { completed = true; }); - await new Promise(resolve => setImmediate(resolve)); + void request.then(() => { + completed = true; + }); + await new Promise((resolve) => setImmediate(resolve)); assert.strictEqual(completed, false); sinon.assert.notCalled(show); finish(true); @@ -54,9 +77,19 @@ suite('RStudio editor request completion', () => { const doc = document(); sandbox.stub(vscode.workspace, 'openTextDocument').resolves(doc); const apply = sandbox.stub(vscode.workspace, 'applyEdit').resolves(true); - for (const [line, expected] of [[1, 'x'], [2, 'x\n'], ['Inf', 'x\n']] as const) { - await insertOrModifyText([{ operation: 'insertText', text: 'x', location: [line, 1] }], doc.uri.toString()); - assert.strictEqual((apply.lastCall.args[0] as vscode.WorkspaceEdit).get(doc.uri)[0].newText, expected); + for (const [line, expected] of [ + [1, 'x'], + [2, 'x\n'], + ['Inf', 'x\n'], + ] as const) { + await insertOrModifyText( + [{ operation: 'insertText', text: 'x', location: [line, 1] }], + doc.uri.toString(), + ); + assert.strictEqual( + (apply.lastCall.args[0] as vscode.WorkspaceEdit).get(doc.uri)[0].newText, + expected, + ); } }); @@ -66,32 +99,49 @@ suite('RStudio editor request completion', () => { sandbox.stub(vscode.workspace, 'applyEdit').resolves(false); for (const query of [ [{ operation: 'insertText', text: 'x', location: [1, 1] }], - [{ operation: 'modifyRange', text: 'x', location: { start: [1, 1], end: [1, 2] } }] + [{ operation: 'modifyRange', text: 'x', location: { start: [1, 1], end: [1, 2] } }], ]) { - await assert.rejects(handleEditorRequest('rstudioapi/insert_or_modify_text', { - id: doc.uri.toString(), query - }), /Could not apply editor changes/); + await assert.rejects( + handleEditorRequest('rstudioapi/insert_or_modify_text', { + id: doc.uri.toString(), + query, + }), + /Could not apply editor changes/, + ); } }); test('a failed new document edit reports an error without showing the editor', async () => { const doc = document(); - sandbox.stub(vscode.workspace, 'workspaceFolders').value([{ uri: vscode.Uri.file('/project') }]); + sandbox + .stub(vscode.workspace, 'workspaceFolders') + .value([{ uri: vscode.Uri.file('/project') }]); sandbox.stub(vscode.workspace, 'openTextDocument').resolves(doc); sandbox.stub(vscode.workspace, 'applyEdit').resolves(false); - const show = sandbox.stub(vscode.window, 'showTextDocument').resolves({ selections: [] } as unknown as vscode.TextEditor); - await assert.rejects(handleEditorRequest('rstudioapi/document_new', { - text: 'x <- 1', type: 'R', position: [1, 1] - }), /Could not apply editor changes/); + const show = sandbox + .stub(vscode.window, 'showTextDocument') + .resolves({ selections: [] } as unknown as vscode.TextEditor); + await assert.rejects( + handleEditorRequest('rstudioapi/document_new', { + text: 'x <- 1', + type: 'R', + position: [1, 1], + }), + /Could not apply editor changes/, + ); sinon.assert.notCalled(show); }); test('selection replacement rejects an edit that VS Code cannot apply', async () => { const doc = document(); sandbox.stub(vscode.window, 'activeTextEditor').value({ - document: doc, selection: new vscode.Selection(0, 0, 0, 1) + document: doc, + selection: new vscode.Selection(0, 0, 0, 1), }); sandbox.stub(vscode.workspace, 'applyEdit').resolves(false); - await assert.rejects(replaceTextInCurrentSelection('x', doc.uri.toString()), /Could not apply editor changes/); + await assert.rejects( + replaceTextInCurrentSelection('x', doc.uri.toString()), + /Could not apply editor changes/, + ); }); }); diff --git a/src/test/suite/selectionWord.test.ts b/src/test/suite/selectionWord.test.ts index c67e96322..56113f3a7 100644 --- a/src/test/suite/selectionWord.test.ts +++ b/src/test/suite/selectionWord.test.ts @@ -9,11 +9,13 @@ suite('Word execution selection', () => { const getText = sandbox.stub().returns('dangerous_full_document()'); sandbox.stub(vscode.window, 'activeTextEditor').value({ selection: new vscode.Selection(0, 0, 0, 0), - document: { getWordRangeAtPosition: () => undefined, getText } + document: { getWordRangeAtPosition: () => undefined, getText }, }); try { assert.strictEqual(getWordOrSelection(), undefined); sinon.assert.notCalled(getText); - } finally { sandbox.restore(); } + } finally { + sandbox.restore(); + } }); }); diff --git a/src/test/suite/sessInstaller.test.ts b/src/test/suite/sessInstaller.test.ts index a3a3da48a..eb2f36d58 100644 --- a/src/test/suite/sessInstaller.test.ts +++ b/src/test/suite/sessInstaller.test.ts @@ -7,13 +7,31 @@ suite('Bundled sess installation', () => { test('installs the stamped pure R bundle with dependencies available and verifies its identity', async function () { this.timeout(120000); const root = path.resolve(__dirname, '../../..'); - const result = await promisify(execFile)('Rscript', [path.join(root, 'src/test/examples/sess-installer.R'), root], - { timeout: 110000, maxBuffer: 4 * 1024 * 1024 }); - assert.match(result.stdout, /Bundled pure R source installation and Interactive API verification passed/); - assert.match(result.stdout, /Bundled pure R source installation for an ordinary R terminal passed/); - assert.match(result.stdout, /Ordinary and Interactive verification works with only base default packages/); - assert.match(result.stdout, /Exact source-revision mismatch rejected for ordinary and private verification/); - assert.match(result.stdout, /Missing Imports use the configured repository and propagate installation errors/); + const result = await promisify(execFile)( + 'Rscript', + [path.join(root, 'src/test/examples/sess-installer.R'), root], + { timeout: 110000, maxBuffer: 4 * 1024 * 1024 }, + ); + assert.match( + result.stdout, + /Bundled pure R source installation and Interactive API verification passed/, + ); + assert.match( + result.stdout, + /Bundled pure R source installation for an ordinary R terminal passed/, + ); + assert.match( + result.stdout, + /Ordinary and Interactive verification works with only base default packages/, + ); + assert.match( + result.stdout, + /Exact source-revision mismatch rejected for ordinary and private verification/, + ); + assert.match( + result.stdout, + /Missing Imports use the configured repository and propagate installation errors/, + ); assert.match(result.stdout, /Bundled sess installation failure is reported directly/); }); }); diff --git a/src/test/suite/session.test.ts b/src/test/suite/session.test.ts index 916f83a0e..4b582ebd5 100644 --- a/src/test/suite/session.test.ts +++ b/src/test/suite/session.test.ts @@ -29,14 +29,18 @@ function localSessEndpoint(name: string): string { : path.join(os.tmpdir(), `vscode-r-${name}.sock`); } -async function waitFor(condition: () => T | Promise, timeout = 10000, interval = 100): Promise { +async function waitFor( + condition: () => T | Promise, + timeout = 10000, + interval = 100, +): Promise { const start = Date.now(); while (Date.now() - start < timeout) { const result = await condition(); if (result) { return result; } - await new Promise(resolve => setTimeout(resolve, interval)); + await new Promise((resolve) => setTimeout(resolve, interval)); } throw new Error(`Timeout after ${timeout}ms waiting for condition`); } @@ -51,9 +55,11 @@ 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() }); + sandbox + .stub(vscode.window, 'registerWebviewPanelSerializer') + .returns({ dispose: sandbox.stub() }); initializeHtmlWidgetViewers(extension.extensionContext, { - resolveSession: source => session.getViewerSessionContext(source.sessionId, source)!, + resolveSession: (source) => session.getViewerSessionContext(source.sessionId, source)!, getActiveSessionId: () => session.activeSession?.sessionId, }); session.deploySessionWatcher(extension_root); @@ -69,7 +75,7 @@ suite('Session Communication', () => { const attachedSessionId = session.activeSession?.sessionId; if (rTerminal.rTerm) { rTerminal.rTerm.dispose(); - + // Explicitly invoke the extension's terminal cleanup logic // since the mocked VS Code environment won't fire onDidCloseTerminal rTerminal.deleteTerminal(rTerminal.rTerm); @@ -85,11 +91,16 @@ suite('Session Communication', () => { sandbox.restore(); }); - async function showHelpWith(viewColumn: Record | undefined, params: Record = { requestPath: 'base/html/mean.html' }) { + async function showHelpWith( + viewColumn: Record | undefined, + params: Record = { requestPath: 'base/html/mean.html' }, + ) { const showHelpForPath = sandbox.stub().resolves(); - sandbox.stub(extension, 'globalRHelp').value({ showHelpForPath } as unknown as NonNullable); + sandbox + .stub(extension, 'globalRHelp') + .value({ showHelpForPath } as unknown as NonNullable); sandbox.stub(util, 'config').returns({ - get: (key: string) => key === 'session.viewers.viewColumn' ? viewColumn : undefined + get: (key: string) => (key === 'session.viewers.viewColumn' ? viewColumn : undefined), } as unknown as vscode.WorkspaceConfiguration); await session.showHelpNotification(params); @@ -103,12 +114,17 @@ suite('Session Communication', () => { }); test('replacing an Interactive process moves document bindings while old viewers keep the old requester', async () => { - const previous = session.registerSessionTransport('restart-old', 'host', '/project', () => Promise.resolve('old')); - const next = session.registerSessionTransport('restart-new', 'host', '/project', () => Promise.resolve('new')); + const previous = session.registerSessionTransport('restart-old', 'host', '/project', () => + Promise.resolve('old'), + ); + const next = session.registerSessionTransport('restart-new', 'host', '/project', () => + Promise.resolve('new'), + ); const source = vscode.Uri.file('/project/restart.R'); const input = vscode.Uri.from({ scheme: 'vscode-interactive-input', path: '/restart' }); try { - session.bindSessionDocument(source, previous); session.bindSessionDocument(input, previous); + session.bindSessionDocument(source, previous); + session.bindSessionDocument(input, previous); session.replaceSessionTransport(previous, next); assert.strictEqual(session.boundSessionForDocument(source), next); assert.strictEqual(session.boundSessionForDocument(input), next); @@ -116,7 +132,10 @@ suite('Session Communication', () => { assert.strictEqual(await next.requester?.({}), 'new'); session.unregisterSessionTransport(previous); assert.strictEqual(session.boundSessionForDocument(source), next); - } finally { session.unregisterSessionTransport(previous); session.unregisterSessionTransport(next); } + } finally { + session.unregisterSessionTransport(previous); + session.unregisterSessionTransport(next); + } }); test('help notification does not open when help panel is disabled', async () => { @@ -132,7 +151,9 @@ suite('Session Communication', () => { }); test('help notification ignores malformed request paths', async () => { - const showHelpForPath = await showHelpWith(undefined, { requestPath: { path: 'base/html/mean.html' } }); + const showHelpForPath = await showHelpWith(undefined, { + requestPath: { path: 'base/html/mean.html' }, + }); await session.showHelpNotification({ requestPath: ['base/html/mean.html'] }); await session.showHelpNotification({ requestPath: 42 }); await session.showHelpNotification({ requestPath: '' }); @@ -141,10 +162,13 @@ suite('Session Communication', () => { }); test('help notification ignores stale viewer parameter from sess', async () => { - const showHelpForPath = await showHelpWith({ helpPanel: 'Active' }, { - requestPath: 'base/html/mean.html', - viewer: 'Two' - }); + const showHelpForPath = await showHelpWith( + { helpPanel: 'Active' }, + { + requestPath: 'base/html/mean.html', + viewer: 'Two', + }, + ); sinon.assert.calledOnceWithExactly(showHelpForPath, 'base/html/mean.html', 'Active'); }); @@ -152,7 +176,12 @@ suite('Session Communication', () => { test('attached session status uses one R prefix for short and full version strings', async () => { const status = vscode.window.createStatusBarItem(); sandbox.stub(extension, 'sessionStatusBarItem').value(status); - const target = session.registerSessionTransport('status-version-test', os.hostname(), process.cwd(), () => Promise.resolve({})); + const target = session.registerSessionTransport( + 'status-version-test', + os.hostname(), + process.cwd(), + () => Promise.resolve({}), + ); target.pid = '92026'; target.info.version = 'R version 4.6.1 (2026-06-24)'; try { @@ -163,7 +192,10 @@ suite('Session Communication', () => { assert.ok(typeof status.tooltip === 'string'); assert.ok(status.tooltip.includes(target.info.version)); } - } finally { session.unregisterSessionTransport(target); status.dispose(); } + } finally { + session.unregisterSessionTransport(target); + status.dispose(); + } }); test('concurrent server initialization and public API calls share one endpoint', async () => { @@ -214,8 +246,8 @@ suite('Session Communication', () => { const first = session.getGlobalPipePath(); const second = session.getGlobalPipePath(); await Promise.all([ - assert.rejects(first, error => error === failure), - assert.rejects(second, error => error === failure), + assert.rejects(first, (error) => error === failure), + assert.rejects(second, (error) => error === failure), ]); assert.strictEqual(listen.callCount, 1); assert.strictEqual(session.globalPipePath, undefined); @@ -235,17 +267,22 @@ suite('Session Communication', () => { const close = sandbox.spy(net.Server.prototype, 'close'); // Unix fails at chmod; Windows has no permission setup, so inject an // 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[]) { - const result = EventEmitter.prototype.emit.call(this, event, ...args); - if (event === 'listening') { - EventEmitter.prototype.emit.call(this, 'error', failure); - } - return result; - }); + 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[] + ) { + const result = EventEmitter.prototype.emit.call(this, event, ...args); + if (event === 'listening') { + EventEmitter.prototype.emit.call(this, 'error', failure); + } + return result; + }); session.deploySessionWatcher(extension_root); - await assert.rejects(session.getGlobalPipePath(), error => error === failure); + await assert.rejects(session.getGlobalPipePath(), (error) => error === failure); const endpoint = listen.firstCall.args[0] as unknown as string; const server = listen.firstCall.thisValue as net.Server; assert.strictEqual(server.listening, false); @@ -255,10 +292,12 @@ suite('Session Communication', () => { } const client = net.createConnection(endpoint); try { - await assert.rejects(new Promise((resolve, reject) => { - client.once('connect', resolve); - client.once('error', reject); - })); + await assert.rejects( + new Promise((resolve, reject) => { + client.once('connect', resolve); + client.once('error', reject); + }), + ); } finally { client.destroy(); } @@ -279,7 +318,10 @@ suite('Session Communication', () => { const listen = sandbox.spy(net.Server.prototype, 'listen'); session.deploySessionWatcher(extension_root); const startup = session.getGlobalPipePath(); - const startupRejected = assert.rejects(startup, /session watcher shut down while starting its IPC server/); + const startupRejected = assert.rejects( + startup, + /session watcher shut down while starting its IPC server/, + ); const shutdown = session.shutdownSessionWatcher(); const endpoint = listen.firstCall.args[0] as unknown as string; await Promise.all([startupRejected, shutdown]); @@ -289,10 +331,12 @@ suite('Session Communication', () => { } const client = net.createConnection(endpoint); try { - await assert.rejects(new Promise((resolve, reject) => { - client.once('connect', resolve); - client.once('error', reject); - })); + await assert.rejects( + new Promise((resolve, reject) => { + client.once('connect', resolve); + client.once('error', reject); + }), + ); } finally { client.destroy(); } @@ -320,9 +364,11 @@ suite('Session Communication', () => { watcher.value(true); let backend = 'standard'; const configStub = { - get: (key: string) => key === 'plot.backend' ? backend : undefined, + get: (key: string) => (key === 'plot.backend' ? backend : undefined), }; - sandbox.stub(util, 'config').returns(configStub as unknown as vscode.WorkspaceConfiguration); + sandbox + .stub(util, 'config') + .returns(configStub as unknown as vscode.WorkspaceConfiguration); await waitFor(() => session.globalPipePath); const connection = await api.getConnectionInfo(); @@ -330,7 +376,10 @@ suite('Session Communication', () => { assert.strictEqual(connection.protocolVersion, 2); assert.strictEqual(connection.endpoint, session.globalPipePath); assert.strictEqual(connection.plotBackend, 'standard'); - assert.ok(!('socket' in connection), 'connection info should contain plain contract data only'); + assert.ok( + !('socket' in connection), + 'connection info should contain plain contract data only', + ); backend = 'native'; const nativeConnection = await api.getConnectionInfo(); @@ -356,12 +405,18 @@ suite('Session Communication', () => { const attached: Array<{ id: string; client: net.Socket; server: net.Socket }> = []; const sendText = sandbox.stub(); const otherSendText = sandbox.stub(); - const terminal = { processId: Promise.resolve(undefined), sendText } as unknown as vscode.Terminal; - const otherTerminal = { processId: Promise.resolve(99999), sendText: otherSendText } as unknown as vscode.Terminal; + const terminal = { + processId: Promise.resolve(undefined), + sendText, + } as unknown as vscode.Terminal; + const otherTerminal = { + processId: Promise.resolve(99999), + sendText: otherSendText, + } as unknown as vscode.Terminal; sandbox.stub(vscode.window, 'terminals').value([terminal, otherTerminal]); sandbox.stub(vscode.window, 'activeTerminal').value(otherTerminal); sandbox.stub(util, 'config').returns({ - get: (key: string) => key === 'source.focus' ? 'none' : undefined, + get: (key: string) => (key === 'source.focus' ? 'none' : undefined), } as unknown as vscode.WorkspaceConfiguration); const attach = async (id: string): Promise => { const existingSockets = new Set(session.activeConnections); @@ -371,24 +426,26 @@ suite('Session Communication', () => { client.once('error', reject); }); const server = await waitFor(() => - [...session.activeConnections].find(socket => !existingSockets.has(socket)) + [...session.activeConnections].find((socket) => !existingSockets.has(socket)), ); assert.ok(server); attached.push({ id, client, server }); - client.write(`${JSON.stringify({ - jsonrpc: '2.0', - method: 'attach', - params: { - protocol_version: 2, - session_id: id, - host: 'test-remote-host', - version: '4.4.0', - pid: id, - tempdir: '/tmp', - wd: '/tmp', - info: { version: 'R version 4.4.0', command: 'R', start_time: '' }, - }, - })}\n`); + client.write( + `${JSON.stringify({ + jsonrpc: '2.0', + method: 'attach', + params: { + protocol_version: 2, + session_id: id, + host: 'test-remote-host', + version: '4.4.0', + pid: id, + tempdir: '/tmp', + wd: '/tmp', + info: { version: 'R version 4.4.0', command: 'R', start_time: '' }, + }, + })}\n`, + ); await waitFor(() => session.activeSession?.sessionId === id); }; @@ -402,7 +459,10 @@ suite('Session Communication', () => { assert.strictEqual(session.activeSession?.sessionId, 'session-api-first'); const owner = session.activeSession; assert.ok(owner); - await assert.rejects(session.executeSessionCode(owner, 'View(iris)'), /no attached terminal/); + await assert.rejects( + session.executeSessionCode(owner, 'View(iris)'), + /no attached terminal/, + ); assert.strictEqual(await api.activate(owner.sessionId, { terminal }), true); assert.strictEqual(await api.activate('session-api-second'), true); await session.executeSessionCode(owner, 'View(iris)'); @@ -414,7 +474,7 @@ suite('Session Communication', () => { await session.executeSessionCode(owner, 'rm(iris)'); sinon.assert.calledWithExactly(sendText, 'rm(iris)'); - const disconnected = attached.find(item => item.id === 'session-api-second'); + const disconnected = attached.find((item) => item.id === 'session-api-second'); assert.ok(disconnected); disconnected.server.destroy(); assert.strictEqual(await api.activate('session-api-second'), false); @@ -437,34 +497,36 @@ suite('Session Communication', () => { return ['--no-save']; } return undefined; - } + }, }; - sandbox.stub(util, 'config').returns(configStub as unknown as vscode.WorkspaceConfiguration); - + sandbox + .stub(util, 'config') + .returns(configStub as unknown as vscode.WorkspaceConfiguration); + const rPath = await util.getRterm(); assert.ok(rPath, 'R path should be found'); sandbox.stub(util, 'getRterm').resolves(rPath); - + const result = await rTerminal.createRTerm(true); assert.ok(result, 'createRTerm should return true'); assert.ok(rTerminal.rTerm, 'rTerminal.rTerm should be defined'); - + await waitFor(() => session.activeSession, 15000, 200); assert.ok(session.activeSession, 'activeSession should be established'); - + const term = rTerminal.rTerm; - - await new Promise(resolve => setTimeout(resolve, 2000)); + + await new Promise((resolve) => setTimeout(resolve, 2000)); const markerPath = path.join( os.tmpdir(), - `vscode-r-command-marker-${process.pid}-${Date.now()}` + `vscode-r-command-marker-${process.pid}-${Date.now()}`, ); commandMarkerPath = markerPath; await fsp.rm(markerPath, { recursive: true, force: true }); term.sendText( `my_list <- list(hello_vscode = 12345); ` + - `writeLines("evaluated", ${JSON.stringify(markerPath)})\n` + `writeLines("evaluated", ${JSON.stringify(markerPath)})\n`, ); // This filesystem marker is independent of the IPC path and confirms @@ -474,44 +536,56 @@ suite('Session Communication', () => { // Verify the workspace request path after command execution, independently // from the pushed workspace refresh notification. let rpcWorkspace: { globalenv?: Record } | undefined; - await waitFor(async () => { - rpcWorkspace = await session.sessionRequest({ - method: 'workspace', - params: {} - }) as { globalenv?: Record } | undefined; - return rpcWorkspace?.globalenv?.['my_list']; - }, 10000, 200); + await waitFor( + async () => { + rpcWorkspace = (await session.sessionRequest({ + method: 'workspace', + params: {}, + })) as { globalenv?: Record } | undefined; + return rpcWorkspace?.globalenv?.['my_list']; + }, + 10000, + 200, + ); assert.ok(rpcWorkspace?.globalenv?.['my_list'], 'workspace RPC should include my_list'); - - await waitFor(() => { - const ge = session.workspaceData?.globalenv; - return ge && ge['my_list']; - }, 15000, 200); - + + await waitFor( + () => { + const ge = session.workspaceData?.globalenv; + return ge && ge['my_list']; + }, + 15000, + 200, + ); + const listData = session.workspaceData.globalenv['my_list']; assert.ok(listData, 'my_list should be in workspaceData.globalenv'); const className = Array.isArray(listData.class) ? listData.class[0] : listData.class; assert.strictEqual(className, 'list', 'my_list should be a list'); assert.strictEqual(listData.has_children, true, 'my_list should be expandable'); - const childrenResult = await session.sessionRequest({ + const childrenResult = (await session.sessionRequest({ method: 'workspace_children', - params: { name: 'my_list', path: [], start: 1 } - }) as { children: Record[], next_start?: number }; + params: { name: 'my_list', path: [], start: 1 }, + })) as { children: Record[]; next_start?: number }; assert.ok(Array.isArray(childrenResult.children), 'workspace children should be an array'); - assert.strictEqual(childrenResult.children.length, 1, 'my_list should have one workspace child'); + assert.strictEqual( + childrenResult.children.length, + 1, + 'my_list should have one workspace child', + ); assert.match(String(childrenResult.children[0].str), /hello_vscode/); - + const completionRequestParams = { expr: 'my_list', - trigger: '$' + trigger: '$', }; - const completionResult = await session.sessionRequest({ + const completionResult = (await session.sessionRequest({ method: 'completion', - params: completionRequestParams - }) as Record[]; - + params: completionRequestParams, + })) as Record[]; + assert.ok(Array.isArray(completionResult), 'completion result should be an array'); const hasHello = completionResult.some((item) => item.name === 'hello_vscode'); assert.ok(hasHello, 'completion result should contain hello_vscode'); @@ -520,29 +594,45 @@ suite('Session Communication', () => { test('communication: plot() updates the standard viewer and View() events', async () => { const configStub = { get: (key: string, defaultValue?: unknown) => { - if (key === 'sessionWatcher') { return true; } - if (key === 'rterm.option') { return ['--no-save']; } - if (key === 'plot.useHttpgd') { return false; } + if (key === 'sessionWatcher') { + return true; + } + if (key === 'rterm.option') { + return ['--no-save']; + } + if (key === 'plot.useHttpgd') { + return false; + } // Pin the standard plot backend so this test is deterministic. // Otherwise it resolves to 'auto', which prefers jgd when jgd is // installed (now the case in CI since the build installs Suggests), // and the r.standardPlot webview is never created. - if (key === 'plot.backend') { return 'standard'; } - if (key === 'session.data.pageSize') { return 500; } - if (key === 'session.viewers.viewColumn') { return { - plot: 'Two', - browser: 'Active', - viewer: 'Two', - pageViewer: 'Active', - view: 'Two', - helpPanel: 'Two' - }; } - if (key === 'session.viewers.viewColumn.plot') { return 'Two'; } + if (key === 'plot.backend') { + return 'standard'; + } + if (key === 'session.data.pageSize') { + return 500; + } + if (key === 'session.viewers.viewColumn') { + return { + plot: 'Two', + browser: 'Active', + viewer: 'Two', + pageViewer: 'Active', + view: 'Two', + helpPanel: 'Two', + }; + } + if (key === 'session.viewers.viewColumn.plot') { + return 'Two'; + } return defaultValue; - } + }, }; - sandbox.stub(util, 'config').returns(configStub as unknown as vscode.WorkspaceConfiguration); - + sandbox + .stub(util, 'config') + .returns(configStub as unknown as vscode.WorkspaceConfiguration); + const rPath = await util.getRterm(); assert.ok(rPath, 'R path should be found'); sandbox.stub(util, 'getRterm').resolves(rPath); @@ -553,7 +643,7 @@ suite('Session Communication', () => { if (!activeSession) { throw new Error('activeSession is undefined'); } - + const term = rTerminal.rTerm; assert.ok(term, 'rTerminal.rTerm should be defined'); @@ -562,109 +652,180 @@ suite('Session Communication', () => { // cannot satisfy this phase. const plotManager = extension.globalPlotManager as plotViewer.CommonPlotManager; plotManager.standardPlotViewer.dispose(); - let resolvePlotUpdate!: (message: { type: string, data?: string, format?: string }, delivered: boolean) => void; + let resolvePlotUpdate!: ( + message: { type: string; data?: string; format?: string }, + delivered: boolean, + ) => void; let rejectPlotUpdate!: (error: unknown) => void; - const plotUpdateMessage = new Promise<{ message: { type: string, data?: string, format?: string }, delivered: boolean }>((resolve, reject) => { + const plotUpdateMessage = new Promise<{ + message: { type: string; data?: string; format?: string }; + delivered: boolean; + }>((resolve, reject) => { resolvePlotUpdate = (message, delivered) => resolve({ message, delivered }); rejectPlotUpdate = reject; }); const originalCreateWebviewPanel = vscode.window.createWebviewPanel.bind(vscode.window); - const createWebviewPanelSpy = sandbox.stub(vscode.window, 'createWebviewPanel').callsFake((...args) => { - const panel = originalCreateWebviewPanel(...args); - if (args[0] === 'r.standardPlot') { - const webview = panel.webview; - const originalPostMessage = webview.postMessage.bind(webview); - const postMessageStub = sandbox.stub(webview, 'postMessage').callsFake(async message => { - const update = message as { type?: unknown, data?: unknown, format?: unknown }; - try { - const delivered = await originalPostMessage(message); - if (update.type === 'update' && typeof update.data === 'string' && update.data.length > 0) { - resolvePlotUpdate({ - type: 'update', - data: update.data, - format: typeof update.format === 'string' ? update.format : undefined - }, delivered); - } - return delivered; - } catch (error) { - if (update.type === 'update') { rejectPlotUpdate(error); } - return false; - } - }); - plotPanelCleanup = () => { - postMessageStub.restore(); - panel.dispose(); - }; - } - return panel; - }); + const createWebviewPanelSpy = sandbox + .stub(vscode.window, 'createWebviewPanel') + .callsFake((...args) => { + const panel = originalCreateWebviewPanel(...args); + if (args[0] === 'r.standardPlot') { + const webview = panel.webview; + const originalPostMessage = webview.postMessage.bind(webview); + const postMessageStub = sandbox + .stub(webview, 'postMessage') + .callsFake(async (message) => { + const update = message as { + type?: unknown; + data?: unknown; + format?: unknown; + }; + try { + const delivered = await originalPostMessage(message); + if ( + update.type === 'update' && + typeof update.data === 'string' && + update.data.length > 0 + ) { + resolvePlotUpdate( + { + type: 'update', + data: update.data, + format: + typeof update.format === 'string' + ? update.format + : undefined, + }, + delivered, + ); + } + return delivered; + } catch (error) { + if (update.type === 'update') { + rejectPlotUpdate(error); + } + return false; + } + }); + plotPanelCleanup = () => { + postMessageStub.restore(); + panel.dispose(); + }; + } + return panel; + }); const standardPlotUpdateSpy = sandbox.spy(plotManager, 'showStandardPlot'); // 1. Test plot command -> plot_updated notification -> real viewer update. const plotMarkerPath = path.join( os.tmpdir(), - `vscode-r-plot-marker-${process.pid}-${Date.now()}` + `vscode-r-plot-marker-${process.pid}-${Date.now()}`, ); commandMarkerPath = plotMarkerPath; await fsp.rm(plotMarkerPath, { recursive: true, force: true }); let plotUpdateTimer: NodeJS.Timeout | undefined; const plotUpdateTimeout = new Promise((_resolve, reject) => { - plotUpdateTimer = setTimeout(() => reject(new Error('Timed out waiting for the standard plot viewer update')), 15000); + plotUpdateTimer = setTimeout( + () => reject(new Error('Timed out waiting for the standard plot viewer update')), + 15000, + ); }); const plotUpdateWait = Promise.race([plotUpdateMessage, plotUpdateTimeout]); term.sendText( `plot(0, main="standard viewer integration"); ` + - `writeLines("evaluated", ${JSON.stringify(plotMarkerPath)})\n` + `writeLines("evaluated", ${JSON.stringify(plotMarkerPath)})\n`, ); let plotUpdate: Awaited; try { const [, update] = await Promise.all([ waitFor(() => pathExists(plotMarkerPath), 10000, 200), - plotUpdateWait + plotUpdateWait, ]); plotUpdate = update; } finally { - if (plotUpdateTimer) { clearTimeout(plotUpdateTimer); } + if (plotUpdateTimer) { + clearTimeout(plotUpdateTimer); + } } - assert.ok(plotUpdate.message.data, 'the standard plot viewer should receive rendered plot data'); - assert.strictEqual(plotUpdate.delivered, true, 'VS Code should accept the standard plot viewer update'); - assert.ok(createWebviewPanelSpy.calledWith('r.standardPlot'), 'r.standardPlot should be created'); - assert.ok(standardPlotUpdateSpy.called, 'plot_updated should update the standard plot viewer'); + assert.ok( + plotUpdate.message.data, + 'the standard plot viewer should receive rendered plot data', + ); + assert.strictEqual( + plotUpdate.delivered, + true, + 'VS Code should accept the standard plot viewer update', + ); + assert.ok( + createWebviewPanelSpy.calledWith('r.standardPlot'), + 'r.standardPlot should be created', + ); + assert.ok( + standardPlotUpdateSpy.called, + 'plot_updated should update the standard plot viewer', + ); // 3. Test View() -> dataview term.sendText('View(mtcars)\n'); await waitFor(() => createWebviewPanelSpy.calledWith('dataview'), 10000, 200); - + assert.ok(createWebviewPanelSpy.calledWith('dataview'), 'dataview should be triggered'); // Preserve the title produced by String(array) for a multi-line deparse(). - const multilineTitle = 'subset(mtcars, mpg > 15 & cyl == 6 & disp < 300 & hp < 150 & ,' - + ' drat > 3 & wt < 4 & qsec < 20)'; - term.sendText('View(subset(mtcars, mpg > 15 & cyl == 6 & disp < 300 & hp < 150 & drat > 3 & wt < 4 & qsec < 20))\n'); - await waitFor(() => createWebviewPanelSpy.calledWith('dataview', multilineTitle), 10000, 200); + const multilineTitle = + 'subset(mtcars, mpg > 15 & cyl == 6 & disp < 300 & hp < 150 & ,' + + ' drat > 3 & wt < 4 & qsec < 20)'; + term.sendText( + 'View(subset(mtcars, mpg > 15 & cyl == 6 & disp < 300 & hp < 150 & drat > 3 & wt < 4 & qsec < 20))\n', + ); + await waitFor( + () => createWebviewPanelSpy.calledWith('dataview', multilineTitle), + 10000, + 200, + ); term.sendText('View(list(value = 1L), title = c("List title", "second line"))\n'); - await waitFor(() => createWebviewPanelSpy.calledWith('dataview', 'List title,second line'), 10000, 200); + await waitFor( + () => createWebviewPanelSpy.calledWith('dataview', 'List title,second line'), + 10000, + 200, + ); term.sendText('View(1:3, title = c("Object title", "second line"))\n'); - await waitFor(() => createWebviewPanelSpy.calledWith('dataview', 'Object title,second line'), 10000, 200); + await waitFor( + () => createWebviewPanelSpy.calledWith('dataview', 'Object title,second line'), + 10000, + 200, + ); // Objects and arrays containing non-string values must still be rejected. const dataViewCount = createWebviewPanelSpy.withArgs('dataview').callCount; term.sendText( 'for (title in list(list(invalid = TRUE), list("valid", 42L), 42L)) ' + - 'sess::notify_client("dataview", list(source = "table", type = "json", title = title)); ' + - 'View(mtcars, title = "Valid title after malformed notifications")\n' + 'sess::notify_client("dataview", list(source = "table", type = "json", title = title)); ' + + 'View(mtcars, title = "Valid title after malformed notifications")\n', + ); + await waitFor( + () => + createWebviewPanelSpy.calledWith( + 'dataview', + 'Valid title after malformed notifications', + ), + 10000, + 200, ); - await waitFor(() => createWebviewPanelSpy.calledWith('dataview', 'Valid title after malformed notifications'), 10000, 200); assert.strictEqual(createWebviewPanelSpy.withArgs('dataview').callCount, dataViewCount + 1); // 4. Test webview - term.sendText('tf <- tempfile(fileext=".html"); writeLines("test", tf); getOption("viewer")(tf)\n'); + term.sendText( + 'tf <- tempfile(fileext=".html"); writeLines("test", tf); getOption("viewer")(tf)\n', + ); await waitFor(() => createWebviewPanelSpy.calledWith('r.htmlViewer'), 10000, 200); - assert.ok(createWebviewPanelSpy.calledWith('r.htmlViewer'), 'webview should be triggered for html file'); - + assert.ok( + createWebviewPanelSpy.calledWith('r.htmlViewer'), + 'webview should be triggered for html file', + ); }).timeout(85000); test('attach session artifacts are owner-only', async () => { @@ -678,19 +839,33 @@ suite('Session Communication', () => { const scriptContent = await fsp.readFile(scriptPath, 'utf8'); const startupSource = scriptContent.indexOf('source(startup_helper, local = TRUE)'); const attachSource = scriptContent.indexOf('source(attach_helper, local = TRUE)'); - assert.ok(startupSource >= 0 && attachSource > startupSource, - 'the startup helper must be loaded before the generated attach helper'); + assert.ok( + startupSource >= 0 && attachSource > startupSource, + 'the startup helper must be loaded before the generated attach helper', + ); assert.match(scriptContent, /vscode_r_attach_sess\(endpoint, sess_src/); assert.match(scriptContent, /attach_sess\.R/); assert.match(scriptContent, /sess-package-install\.R/); assert.doesNotMatch(scriptContent, /install_sess\.R|sess::connect|sess_install_required/); - assert.ok(scriptContent.includes(extension.extensionContext.asAbsolutePath( - path.join('dist', 'resources', 'sess')).replace(/\\/g, '/'))); + assert.ok( + scriptContent.includes( + extension.extensionContext + .asAbsolutePath(path.join('dist', 'resources', 'sess')) + .replace(/\\/g, '/'), + ), + ); assert.doesNotMatch(scriptContent, /packageVersion|compareVersion/); - assert.strictEqual(path.dirname(scriptPath), path.join(extension.extensionContext.globalStorageUri.fsPath, 'tmp', 'attach')); + assert.strictEqual( + path.dirname(scriptPath), + path.join(extension.extensionContext.globalStorageUri.fsPath, 'tmp', 'attach'), + ); const scriptStat = await fsp.stat(scriptPath); if (process.platform !== 'win32') { - assert.strictEqual(scriptStat.mode & 0o777, 0o600, 'attach script should be owner-only'); + assert.strictEqual( + scriptStat.mode & 0o777, + 0o600, + 'attach script should be owner-only', + ); } const pipePath = session.globalPipePath; @@ -702,23 +877,34 @@ suite('Session Communication', () => { } const sessionFilePath = await session.createSessionDiscoveryFile(pipePath ?? ''); - assert.strictEqual(path.dirname(sessionFilePath), path.join(extension.extensionContext.globalStorageUri.fsPath, 'sessions')); + assert.strictEqual( + path.dirname(sessionFilePath), + path.join(extension.extensionContext.globalStorageUri.fsPath, 'sessions'), + ); assert.match(path.basename(sessionFilePath), /^[a-f0-9]{32}\.json$/); assert.deepStrictEqual(await readJson(sessionFilePath), { - version: 1, endpoint: pipePath ?? '', + version: 1, + endpoint: pipePath ?? '', jgdSocket: plotViewer.jgdEnabled() - ? (extension.globalPlotManager as plotViewer.CommonPlotManager).getJgdEnvVars()['JGD_SOCKET'] : '', + ? (extension.globalPlotManager as plotViewer.CommonPlotManager).getJgdEnvVars()[ + 'JGD_SOCKET' + ] + : '', }); const sessionFileStat = await fsp.stat(sessionFilePath); if (process.platform !== 'win32') { - assert.strictEqual(sessionFileStat.mode & 0o777, 0o600, 'session handoff file should be owner-only'); + assert.strictEqual( + sessionFileStat.mode & 0o777, + 0o600, + 'session handoff file should be owner-only', + ); } await fsp.rm(sessionFilePath, { recursive: true, force: true }); await session.shutdownSessionWatcher(); }).timeout(15000); - test('prepareAttachment returns an opaque consumer script with independent disposable lifetime', async function(this: Mocha.Context) { + test('prepareAttachment returns an opaque consumer script with independent disposable lifetime', async function (this: Mocha.Context) { sandbox.stub(extension, 'enableSessionWatcher').value(true); const api: RSessionApi = { getConnectionInfo: session.getConnectionInfo, @@ -728,21 +914,32 @@ suite('Session Communication', () => { registerTerminalIntegration: session.registerTerminalIntegration, activate: session.activateSessionById, }; - const endpoint = process.platform === 'win32' - ? localSessEndpoint('proxy') - : path.join(os.tmpdir(), 'proxy "quoted" \\ endpoint.sock'); + const endpoint = + process.platform === 'win32' + ? localSessEndpoint('proxy') + : path.join(os.tmpdir(), 'proxy "quoted" \\ endpoint.sock'); await assert.rejects( - api.prepareAttachment({ connection: { transport: 'sess', endpoint: 'tcp://remote/endpoint' } }), + api.prepareAttachment({ + connection: { transport: 'sess', endpoint: 'tcp://remote/endpoint' }, + }), /local absolute path or Windows named pipe/, ); - for (const invalidEndpoint of [String.raw`\\remote\pipe\x`, String.raw`\\server\share\x`, String.raw`C:\socket`]) { + for (const invalidEndpoint of [ + String.raw`\\remote\pipe\x`, + String.raw`\\server\share\x`, + String.raw`C:\socket`, + ]) { await assert.rejects( - api.prepareAttachment({ connection: { transport: 'sess', endpoint: invalidEndpoint } }), + api.prepareAttachment({ + connection: { transport: 'sess', endpoint: invalidEndpoint }, + }), /local absolute path or Windows named pipe/, ); } await assert.rejects( - session.prepareAttachment({ connection: { transport: 'sess', endpoint: undefined as unknown as string } }), + session.prepareAttachment({ + connection: { transport: 'sess', endpoint: undefined as unknown as string }, + }), /non-empty local sess IPC endpoint/, ); await assert.rejects( @@ -751,17 +948,26 @@ suite('Session Communication', () => { ); const [preparation, parallelPreparation, backgroundPreparation] = await Promise.all([ api.prepareAttachment({ connection: { transport: 'sess', endpoint } }), - api.prepareAttachment({ connection: { transport: 'sess', endpoint: localSessEndpoint('parallel') } }), - api.prepareAttachment({ connection: { transport: 'sess', endpoint: localSessEndpoint('background') }, autoActivate: false }), + api.prepareAttachment({ + connection: { transport: 'sess', endpoint: localSessEndpoint('parallel') }, + }), + api.prepareAttachment({ + connection: { transport: 'sess', endpoint: localSessEndpoint('background') }, + autoActivate: false, + }), ]); assert.ok(preparation); assert.ok(parallelPreparation); assert.ok(backgroundPreparation); - if (!preparation || !parallelPreparation || !backgroundPreparation) { return; } + if (!preparation || !parallelPreparation || !backgroundPreparation) { + return; + } try { const rscript = process.platform === 'win32' ? 'Rscript.exe' : 'Rscript'; const rAvailable = spawnSync(rscript, ['--version'], { encoding: 'utf8' }); - if (rAvailable.error) { this.skip(); } + if (rAvailable.error) { + this.skip(); + } assert.ok(Object.isFrozen(preparation), 'preparation metadata should be immutable'); assert.notStrictEqual(preparation.scriptPath, parallelPreparation.scriptPath); assert.strictEqual(typeof preparation.attachmentId, 'string'); @@ -772,7 +978,10 @@ suite('Session Communication', () => { assert.ok(await pathExists(preparation.scriptPath)); assert.ok(await pathExists(parallelPreparation.scriptPath)); assert.ok(await pathExists(backgroundPreparation.scriptPath)); - assert.strictEqual(preparation.command, `source(${JSON.stringify(preparation.scriptPath)})`); + assert.strictEqual( + preparation.command, + `source(${JSON.stringify(preparation.scriptPath)})`, + ); const source = await fsp.readFile(preparation.scriptPath, 'utf8'); assert.ok(source.includes(JSON.stringify(endpoint).slice(1, -1))); assert.ok(source.includes(`attachment_id = "${preparation.attachmentId}"`)); @@ -780,32 +989,73 @@ suite('Session Communication', () => { assert.match(source, /tryCatch\(/); assert.match(source, /use_rstudioapi = (TRUE|FALSE)/); const backgroundSource = await fsp.readFile(backgroundPreparation.scriptPath, 'utf8'); - assert.ok(backgroundSource.includes(`attachment_id = "${backgroundPreparation.attachmentId}"`)); + assert.ok( + backgroundSource.includes( + `attachment_id = "${backgroundPreparation.attachmentId}"`, + ), + ); assert.match(backgroundSource, /auto_activate = FALSE/); - const executionDirectory = await fsp.mkdtemp(path.join(os.tmpdir(), 'vscode-r-prepare-test-')); + const executionDirectory = await fsp.mkdtemp( + path.join(os.tmpdir(), 'vscode-r-prepare-test-'), + ); try { const fixtures = [ - { name: 'error', replacement: 'stop("generated fixture failure")', expected: 'FALSE', message: 'generated fixture failure' }, - { name: 'interrupt', replacement: 'stop(structure(list(message="generated fixture interrupt", call=NULL), class=c("interrupt", "condition")))', expected: 'FALSE', message: 'was interrupted' }, - { name: 'success', replacement: 'vscode_r_attach_sess <- function(...) TRUE', expected: 'TRUE' }, + { + name: 'error', + replacement: 'stop("generated fixture failure")', + expected: 'FALSE', + message: 'generated fixture failure', + }, + { + name: 'interrupt', + replacement: + 'stop(structure(list(message="generated fixture interrupt", call=NULL), class=c("interrupt", "condition")))', + expected: 'FALSE', + message: 'was interrupted', + }, + { + name: 'success', + replacement: 'vscode_r_attach_sess <- function(...) TRUE', + expected: 'TRUE', + }, ]; for (const fixture of fixtures) { const executionPath = path.join(executionDirectory, `${fixture.name}.R`); - const executionScript = source.replace('source(attach_helper, local = TRUE)', fixture.replacement); + const executionScript = source.replace( + 'source(attach_helper, local = TRUE)', + fixture.replacement, + ); await fsp.writeFile(executionPath, executionScript); - const execution = spawnSync(rscript, [ - '--vanilla', '-e', - `result <- source(commandArgs(TRUE)[[1L]]); cat(identical(result$value, ${fixture.expected}) && identical(result$visible, FALSE) && !exists("endpoint", envir=.GlobalEnv) && !exists("sess_src", envir=.GlobalEnv) && !exists("attach_helper", envir=.GlobalEnv))`, - executionPath, - ], { encoding: 'utf8', timeout: 10000 }); + const execution = spawnSync( + rscript, + [ + '--vanilla', + '-e', + `result <- source(commandArgs(TRUE)[[1L]]); cat(identical(result$value, ${fixture.expected}) && identical(result$visible, FALSE) && !exists("endpoint", envir=.GlobalEnv) && !exists("sess_src", envir=.GlobalEnv) && !exists("attach_helper", envir=.GlobalEnv))`, + executionPath, + ], + { encoding: 'utf8', timeout: 10000 }, + ); assert.strictEqual(execution.status, 0, `${fixture.name}: ${execution.stderr}`); - assert.ok(execution.stdout.includes('TRUE'), `${fixture.name} should return an invisible result without leaking locals`); + assert.ok( + execution.stdout.includes('TRUE'), + `${fixture.name} should return an invisible result without leaking locals`, + ); if (fixture.message) { - assert.ok(execution.stderr.includes(fixture.message) || execution.stdout.includes(fixture.message)); + assert.ok( + execution.stderr.includes(fixture.message) || + execution.stdout.includes(fixture.message), + ); } if (fixture.name === 'error') { - assert.ok(!execution.stderr.includes('close this R session and start a new one') - && !execution.stdout.includes('close this R session and start a new one')); + assert.ok( + !execution.stderr.includes( + 'close this R session and start a new one', + ) && + !execution.stdout.includes( + 'close this R session and start a new one', + ), + ); } } } finally { @@ -815,10 +1065,16 @@ suite('Session Communication', () => { preparation.dispose(); preparation.dispose(); await waitFor(async () => !(await pathExists(preparation.scriptPath))); - assert.ok(await pathExists(parallelPreparation.scriptPath), 'disposing one resource must not remove a parallel preparation'); + assert.ok( + await pathExists(parallelPreparation.scriptPath), + 'disposing one resource must not remove a parallel preparation', + ); parallelPreparation.dispose(); await waitFor(async () => !(await pathExists(parallelPreparation.scriptPath))); - assert.ok(await pathExists(backgroundPreparation.scriptPath), 'disposing one resource must not remove another preparation'); + assert.ok( + await pathExists(backgroundPreparation.scriptPath), + 'disposing one resource must not remove another preparation', + ); backgroundPreparation.dispose(); await waitFor(async () => !(await pathExists(backgroundPreparation.scriptPath))); } @@ -829,27 +1085,36 @@ suite('Session Communication', () => { sandbox.stub(extension, 'enableSessionWatcher').value(true); session.deploySessionWatcher(extension_root); const endpoint = await session.getGlobalPipePath(); - const scriptBase = path.basename(endpoint).replace(/[^a-zA-Z0-9_.-]/g, '_') || 'attach_session'; + const scriptBase = + path.basename(endpoint).replace(/[^a-zA-Z0-9_.-]/g, '_') || 'attach_session'; const scriptPath = path.join( - extension.extensionContext.globalStorageUri.fsPath, 'tmp', 'attach', `${scriptBase}.R`, + extension.extensionContext.globalStorageUri.fsPath, + 'tmp', + 'attach', + `${scriptBase}.R`, ); const originalRename = Object.getOwnPropertyDescriptor(fsp, 'rename')?.value as - ((oldPath: PathLike, newPath: PathLike) => Promise) | undefined; + | ((oldPath: PathLike, newPath: PathLike) => Promise) + | undefined; assert.ok(originalRename); let startShutdown!: () => void; let shutdown: Promise | undefined; let shutdownScheduled = false; - const shutdownStarted = new Promise(resolve => { startShutdown = resolve; }); + const shutdownStarted = new Promise((resolve) => { + startShutdown = resolve; + }); sandbox.stub(fsp, 'rename').callsFake(async (oldPath, newPath) => { const result = await originalRename!.call(fsp, oldPath, newPath); if (String(newPath) === scriptPath) { shutdownScheduled = true; // Queue shutdown after the publication callback's final generation // check but before the caller resumes from `await publish`. - queueMicrotask(() => queueMicrotask(() => { - shutdown = session.shutdownSessionWatcher(); - startShutdown(); - })); + queueMicrotask(() => + queueMicrotask(() => { + shutdown = session.shutdownSessionWatcher(); + startShutdown(); + }), + ); } return result; }); @@ -857,13 +1122,22 @@ suite('Session Communication', () => { const command = session.getAttachSessionCommand(); try { await Promise.all([ - assert.rejects(command, error => error instanceof Error - && error.message === 'The session watcher shut down before returning the attach command.'), + assert.rejects( + command, + (error) => + error instanceof Error && + error.message === + 'The session watcher shut down before returning the attach command.', + ), shutdownStarted, ]); assert.ok(shutdown, 'the publication-boundary callback should start shutdown'); await shutdown; - assert.strictEqual(await pathExists(scriptPath), false, 'shutdown must remove the just-published manual script'); + assert.strictEqual( + await pathExists(scriptPath), + false, + 'shutdown must remove the just-published manual script', + ); } finally { if (shutdownScheduled) { await shutdownStarted; @@ -879,10 +1153,15 @@ suite('Session Communication', () => { sandbox.stub(extension, 'enableSessionWatcher').value(true); let enterRename!: () => void; let releaseRename!: () => void; - const renameEntered = new Promise(resolve => { enterRename = resolve; }); - const renameGate = new Promise(resolve => { releaseRename = resolve; }); + const renameEntered = new Promise((resolve) => { + enterRename = resolve; + }); + const renameGate = new Promise((resolve) => { + releaseRename = resolve; + }); const originalRename = Object.getOwnPropertyDescriptor(fsp, 'rename')?.value as - ((oldPath: PathLike, newPath: PathLike) => Promise) | undefined; + | ((oldPath: PathLike, newPath: PathLike) => Promise) + | undefined; assert.ok(originalRename); sandbox.stub(fsp, 'rename').callsFake(async (oldPath, newPath) => { if (String(newPath).includes('consumer-')) { @@ -899,9 +1178,13 @@ suite('Session Communication', () => { releaseRename(); await assert.rejects(preparation, /shut down while publishing sess attachment/); await shutdown; - const attachDirectory = path.join(extension.extensionContext.globalStorageUri.fsPath, 'tmp', 'attach'); + const attachDirectory = path.join( + extension.extensionContext.globalStorageUri.fsPath, + 'tmp', + 'attach', + ); const files = await fsp.readdir(attachDirectory).catch(() => []); - assert.ok(!files.some(file => file.startsWith('consumer-'))); + assert.ok(!files.some((file) => file.startsWith('consumer-'))); }).timeout(20000); test('shutdown waits for a preparation dispose cleanup already in progress', async () => { @@ -910,12 +1193,18 @@ suite('Session Communication', () => { connection: { transport: 'sess', endpoint: localSessEndpoint('dispose-race') }, }); assert.ok(preparation); - if (!preparation) { return; } + if (!preparation) { + return; + } let enterRemove!: () => void; let releaseRemove!: () => void; - const removeEntered = new Promise(resolve => { enterRemove = resolve; }); - const removeGate = new Promise(resolve => { releaseRemove = resolve; }); + const removeEntered = new Promise((resolve) => { + enterRemove = resolve; + }); + const removeGate = new Promise((resolve) => { + releaseRemove = resolve; + }); const originalRemove = fsp.rm.bind(fsp); sandbox.stub(fsp, 'rm').callsFake(async (target, options) => { if (String(target) === preparation.scriptPath) { @@ -928,9 +1217,15 @@ suite('Session Communication', () => { preparation.dispose(); await removeEntered; let shutdownFinished = false; - const shutdown = session.shutdownSessionWatcher().then(() => { shutdownFinished = true; }); - await new Promise(resolve => setImmediate(resolve)); - assert.strictEqual(shutdownFinished, false, 'shutdown must await the resource cleanup started by dispose'); + const shutdown = session.shutdownSessionWatcher().then(() => { + shutdownFinished = true; + }); + await new Promise((resolve) => setImmediate(resolve)); + assert.strictEqual( + shutdownFinished, + false, + 'shutdown must await the resource cleanup started by dispose', + ); releaseRemove(); await shutdown; assert.strictEqual(shutdownFinished, true); @@ -945,17 +1240,25 @@ suite('Session Communication', () => { connection: { transport: 'sess', endpoint: localSessEndpoint('old-lifecycle') }, }); assert.ok(oldPreparation); - if (!oldPreparation) { return; } + if (!oldPreparation) { + return; + } let enterStop!: () => void; let releaseStop!: () => void; - const stopEntered = new Promise(resolve => { enterStop = resolve; }); - const stopGate = new Promise(resolve => { releaseStop = resolve; }); - const originalStop = Object.getOwnPropertyDescriptor(SessConsentService.prototype, 'stop')?.value as - ((this: SessConsentService) => Promise) | undefined; + const stopEntered = new Promise((resolve) => { + enterStop = resolve; + }); + const stopGate = new Promise((resolve) => { + releaseStop = resolve; + }); + const originalStop = Object.getOwnPropertyDescriptor(SessConsentService.prototype, 'stop') + ?.value as ((this: SessConsentService) => Promise) | undefined; assert.ok(originalStop); let firstStop = true; - sandbox.stub(SessConsentService.prototype, 'stop').callsFake(async function(this: SessConsentService) { + sandbox.stub(SessConsentService.prototype, 'stop').callsFake(async function ( + this: SessConsentService, + ) { if (firstStop) { firstStop = false; enterStop(); @@ -971,11 +1274,21 @@ suite('Session Communication', () => { connection: { transport: 'sess', endpoint: localSessEndpoint('new-lifecycle') }, }); assert.ok(newPreparation); - if (!newPreparation) { releaseStop(); await oldShutdown; return; } + if (!newPreparation) { + releaseStop(); + await oldShutdown; + return; + } releaseStop(); await oldShutdown; - assert.ok(await pathExists(newPreparation.scriptPath), 'old shutdown must not remove a later preparation'); - assert.ok(!(await pathExists(oldPreparation.scriptPath)), 'old shutdown must reclaim its preparation'); + assert.ok( + await pathExists(newPreparation.scriptPath), + 'old shutdown must not remove a later preparation', + ); + assert.ok( + !(await pathExists(oldPreparation.scriptPath)), + 'old shutdown must reclaim its preparation', + ); newPreparation.dispose(); await session.shutdownSessionWatcher(); }).timeout(30000); @@ -985,11 +1298,15 @@ suite('Session Communication', () => { session.deploySessionWatcher(extension_root); sandbox.stub(extension, 'enableSessionWatcher').value(true); let releaseStart!: () => void; - const startGate = new Promise(resolve => { releaseStart = resolve; }); - const originalStart = Object.getOwnPropertyDescriptor(SessConsentService.prototype, 'start')?.value as - ((this: SessConsentService) => Promise) | undefined; + const startGate = new Promise((resolve) => { + releaseStart = resolve; + }); + const originalStart = Object.getOwnPropertyDescriptor(SessConsentService.prototype, 'start') + ?.value as ((this: SessConsentService) => Promise) | undefined; assert.ok(originalStart); - const start = sandbox.stub(SessConsentService.prototype, 'start').callsFake(async function(this: SessConsentService) { + const start = sandbox.stub(SessConsentService.prototype, 'start').callsFake(async function ( + this: SessConsentService, + ) { await startGate; await originalStart!.call(this); }); @@ -1001,24 +1318,37 @@ suite('Session Communication', () => { releaseStart(); await assert.rejects(preparation, /shut down while preparing sess consent/); await shutdown; - const attachDirectory = path.join(extension.extensionContext.globalStorageUri.fsPath, 'tmp', 'attach'); + const attachDirectory = path.join( + extension.extensionContext.globalStorageUri.fsPath, + 'tmp', + 'attach', + ); const files = await fsp.readdir(attachDirectory).catch(() => []); - assert.ok(!files.some(file => file.startsWith('consumer-'))); + assert.ok(!files.some((file) => file.startsWith('consumer-'))); }).timeout(20000); test('connectToSession does not create attach artifacts when the watcher is disabled', async () => { await session.shutdownSessionWatcher(); sandbox.stub(extension, 'enableSessionWatcher').value(false); sandbox.stub(util, 'config').returns({ - get: (key: string) => key === 'sessionWatcher' ? false : undefined, + get: (key: string) => (key === 'sessionWatcher' ? false : undefined), } as unknown as vscode.WorkspaceConfiguration); - assert.strictEqual(await session.prepareAttachment({ connection: { transport: 'sess', endpoint: 'invalid' } }), undefined); - const information = sandbox.stub(vscode.window, 'showInformationMessage').resolves(undefined); + assert.strictEqual( + await session.prepareAttachment({ + connection: { transport: 'sess', endpoint: 'invalid' }, + }), + undefined, + ); + const information = sandbox + .stub(vscode.window, 'showInformationMessage') + .resolves(undefined); const before = session.globalPipePath; await session.connectToSession(); assert.strictEqual(session.globalPipePath, before); assert.strictEqual(information.callCount, 1); - assert.deepStrictEqual(information.firstCall.args, ['This command requires that r.sessionWatcher be enabled.']); + assert.deepStrictEqual(information.firstCall.args, [ + 'This command requires that r.sessionWatcher be enabled.', + ]); }); test('reconnecting terminals preserve the selected terminal in either attach order', async () => { @@ -1028,7 +1358,10 @@ suite('Session Communication', () => { sandbox.stub(vscode.window, 'terminals').value([selected, background]); sandbox.stub(vscode.window, 'activeTerminal').value(selected); - for (const order of [[46240, 46241], [46241, 46240]]) { + for (const order of [ + [46240, 46241], + [46241, 46240], + ]) { const clients: net.Socket[] = []; const sockets: net.Socket[] = []; try { @@ -1040,16 +1373,27 @@ suite('Session Communication', () => { client.once('connect', resolve); client.once('error', reject); }); - const socket = await waitFor(() => [...session.activeConnections].find(s => !existing.has(s))); + const socket = await waitFor(() => + [...session.activeConnections].find((s) => !existing.has(s)), + ); assert.ok(socket); sockets.push(socket); const id = `reconnected-${terminalPid}`; - client.write(`${JSON.stringify({ - jsonrpc: '2.0', method: 'attach', params: { - protocol_version: 2, session_id: id, host: os.hostname(), - pid: terminalPid, version: '4.4.0', tempdir: '/tmp', wd: '/tmp', - }, - })}\n`); + client.write( + `${JSON.stringify({ + jsonrpc: '2.0', + method: 'attach', + params: { + protocol_version: 2, + session_id: id, + host: os.hostname(), + pid: terminalPid, + version: '4.4.0', + tempdir: '/tmp', + wd: '/tmp', + }, + })}\n`, + ); await waitFor(() => socket._sessionId === id); if (terminalPid === 46241 && order[0] === 46240) { assert.strictEqual(session.activeSession?.sessionId, 'reconnected-46240'); @@ -1057,8 +1401,8 @@ suite('Session Communication', () => { } assert.strictEqual(session.activeSession?.sessionId, 'reconnected-46240'); } finally { - clients.forEach(client => client.destroy()); - sockets.forEach(socket => socket.destroy()); + clients.forEach((client) => client.destroy()); + sockets.forEach((socket) => socket.destroy()); for (const terminalPid of order) { await session.cleanupSession(`reconnected-${terminalPid}`); } @@ -1068,31 +1412,40 @@ suite('Session Communication', () => { test('attach script passes the native backend to sess', async () => { sandbox.stub(util, 'config').returns({ - get: (key: string) => key === 'plot.backend' ? 'native' : undefined, + get: (key: string) => (key === 'plot.backend' ? 'native' : undefined), } as unknown as vscode.WorkspaceConfiguration); const command = await session.getAttachSessionCommand(); const commandMatch = command.match(/^source\((.*)\)$/); assert.ok(commandMatch); const scriptPath = JSON.parse(commandMatch[1]) as string; const scriptContent = await fsp.readFile(scriptPath, 'utf8'); - assert.match(scriptContent, /vscode_r_attach_sess\(endpoint, sess_src, managed_root, consent_dir, source_helper, installer_helper, "native", use_rstudioapi = (?:TRUE|FALSE)\)/); + assert.match( + scriptContent, + /vscode_r_attach_sess\(endpoint, sess_src, managed_root, consent_dir, source_helper, installer_helper, "native", use_rstudioapi = (?:TRUE|FALSE)\)/, + ); assert.doesNotMatch(scriptContent, /Sys\.(?:setenv|unsetenv)\(JGD_SOCKET/); }); test('manual recovery targets the selected managed terminal while another session is active', async () => { const endpoint = await session.getGlobalPipePath(); const first = { - name: 'R Interactive', processId: Promise.resolve(45240), - creationOptions: { name: 'R Interactive' }, show: sandbox.spy(), sendText: sandbox.spy(), + name: 'R Interactive', + processId: Promise.resolve(45240), + creationOptions: { name: 'R Interactive' }, + show: sandbox.spy(), + sendText: sandbox.spy(), }; const second = { - name: 'Renamed R', processId: Promise.resolve(45241), - creationOptions: { name: 'R Interactive' }, show: sandbox.spy(), sendText: sandbox.spy(), + name: 'Renamed R', + processId: Promise.resolve(45241), + creationOptions: { name: 'R Interactive' }, + show: sandbox.spy(), + sendText: sandbox.spy(), }; const liveTerminals = sandbox.stub(vscode.window, 'terminals').value([first, second]); const activeTerminal = sandbox.stub(vscode.window, 'activeTerminal').value(second); sandbox.stub(util, 'config').returns({ - get: (key: string) => key === 'sessionWatcher' ? true : undefined, + get: (key: string) => (key === 'sessionWatcher' ? true : undefined), } as unknown as vscode.WorkspaceConfiguration); const discoveryFile = await session.createSessionDiscoveryFile(endpoint); await session.updateSessionDiscoveryFile(discoveryFile, endpoint, 45241); @@ -1102,14 +1455,22 @@ suite('Session Communication', () => { client.once('connect', resolve); client.once('error', reject); }); - client.write(`${JSON.stringify({ - jsonrpc: '2.0', method: 'attach', params: { - protocol_version: 2, session_id: 'manual-recovery-first', - host: os.hostname(), pid: 45240, version: '4.5.0', - tempdir: os.tmpdir(), wd: os.tmpdir(), - info: { version: 'R 4.5.0', command: 'R', start_time: '' }, - }, - })}\n`); + client.write( + `${JSON.stringify({ + jsonrpc: '2.0', + method: 'attach', + params: { + protocol_version: 2, + session_id: 'manual-recovery-first', + host: os.hostname(), + pid: 45240, + version: '4.5.0', + tempdir: os.tmpdir(), + wd: os.tmpdir(), + info: { version: 'R 4.5.0', command: 'R', start_time: '' }, + }, + })}\n`, + ); await waitFor(() => session.activeSession?.sessionId === 'manual-recovery-first'); await session.activateRSession(); sinon.assert.calledOnce(second.sendText); @@ -1125,7 +1486,11 @@ suite('Session Communication', () => { // The R Interactive name alone must not grant ownership of another terminal. first.show.resetHistory(); - const sameLabelTerminal = { ...second, name: 'R Interactive', processId: Promise.resolve(45242) }; + const sameLabelTerminal = { + ...second, + name: 'R Interactive', + processId: Promise.resolve(45242), + }; liveTerminals.value([first, second, sameLabelTerminal]); activeTerminal.value(sameLabelTerminal); await session.activateRSession(); @@ -1144,7 +1509,7 @@ suite('Session Communication', () => { const terminalPid = 45231; // Deliberately distinct from any R process PID. await session.updateSessionDiscoveryFile(discoveryFile, oldEndpoint, terminalPid); const newEndpoint = `${oldEndpoint}.reload`; - const { jgdSocket } = await readJson(discoveryFile) as { jgdSocket: string }; + const { jgdSocket } = (await readJson(discoveryFile)) as { jgdSocket: string }; const terminal = { name: 'R Interactive', processId: Promise.resolve(terminalPid), @@ -1166,7 +1531,11 @@ suite('Session Communication', () => { // extension-owned terminal PID metadata must still locate the same file. wrapperDiscoveryFile = await session.createSessionDiscoveryFile(oldEndpoint); const wrapperTerminalPid = 45233; - await session.updateSessionDiscoveryFile(wrapperDiscoveryFile, oldEndpoint, wrapperTerminalPid); + await session.updateSessionDiscoveryFile( + wrapperDiscoveryFile, + oldEndpoint, + wrapperTerminalPid, + ); const wrapperTerminal = { name: 'R Interactive', processId: Promise.resolve(wrapperTerminalPid), @@ -1192,13 +1561,15 @@ suite('Session Communication', () => { let backend = 'jgd'; let jgdSocket = 'old-jgd-socket'; sandbox.stub(util, 'config').returns({ - get: (key: string) => key === 'plot.backend' ? backend : undefined, + get: (key: string) => (key === 'plot.backend' ? backend : undefined), } as unknown as vscode.WorkspaceConfiguration); - sandbox.stub(extension.globalPlotManager as plotViewer.CommonPlotManager, 'getJgdEnvVars') + sandbox + .stub(extension.globalPlotManager as plotViewer.CommonPlotManager, 'getJgdEnvVars') .callsFake(() => ({ JGD_SOCKET: jgdSocket })); const file = await session.createSessionDiscoveryFile('old-sess'); const terminal = { - name: 'Renamed R', processId: Promise.resolve(45244), + name: 'Renamed R', + processId: Promise.resolve(45244), creationOptions: { name: 'R Interactive' }, } as unknown as vscode.Terminal; sandbox.stub(vscode.window, 'terminals').value([terminal]); @@ -1207,12 +1578,18 @@ suite('Session Communication', () => { jgdSocket = 'new-jgd-socket'; await session.refreshTerminalDiscoveryFiles('new-sess', [terminal]); assert.deepStrictEqual(await readJson(file), { - version: 1, endpoint: 'new-sess', terminalPid: 45244, jgdSocket, + version: 1, + endpoint: 'new-sess', + terminalPid: 45244, + jgdSocket, }); backend = 'standard'; await session.refreshTerminalDiscoveryFiles('next-sess', [terminal]); assert.deepStrictEqual(await readJson(file), { - version: 1, endpoint: 'next-sess', terminalPid: 45244, jgdSocket: '', + version: 1, + endpoint: 'next-sess', + terminalPid: 45244, + jgdSocket: '', }); } finally { await fsp.rm(file, { recursive: true, force: true }); @@ -1243,8 +1620,16 @@ suite('Session Communication', () => { const olderDiscovery: unknown = await readJson(olderFile); const newerDiscovery: unknown = await readJson(newerFile); - assert.ok(typeof olderDiscovery === 'object' && olderDiscovery !== null && 'endpoint' in olderDiscovery); - assert.ok(typeof newerDiscovery === 'object' && newerDiscovery !== null && 'endpoint' in newerDiscovery); + assert.ok( + typeof olderDiscovery === 'object' && + olderDiscovery !== null && + 'endpoint' in olderDiscovery, + ); + assert.ok( + typeof newerDiscovery === 'object' && + newerDiscovery !== null && + 'endpoint' in newerDiscovery, + ); assert.strictEqual(olderDiscovery.endpoint, oldEndpoint); assert.strictEqual(newerDiscovery.endpoint, newEndpoint); } finally { @@ -1256,10 +1641,13 @@ suite('Session Communication', () => { test('IPC protocol keys sessions by session_id and ignores close from a replaced socket', async () => { sandbox.stub(extension, 'enableSessionWatcher').value(true); const sendText = sandbox.stub(); - const terminal = { processId: Promise.resolve(undefined), sendText } as unknown as vscode.Terminal; + const terminal = { + processId: Promise.resolve(undefined), + sendText, + } as unknown as vscode.Terminal; sandbox.stub(vscode.window, 'terminals').value([terminal]); sandbox.stub(util, 'config').returns({ - get: (key: string) => key === 'source.focus' ? 'none' : undefined, + get: (key: string) => (key === 'source.focus' ? 'none' : undefined), } as unknown as vscode.WorkspaceConfiguration); const endpoint = await session.getGlobalPipePath(); const first = net.createConnection(endpoint); @@ -1270,21 +1658,23 @@ suite('Session Communication', () => { socket.once('connect', resolve); socket.once('error', reject); }); - socket.write(`${JSON.stringify({ - jsonrpc: '2.0', - method: 'attach', - params: { - protocol_version: 2, - session_id: 'stable-session-id', - host: 'remote-compute-node', - sess_version: '3.0.0', - pid, - version: 'R 4.4.0', - info: { version: '4.4.0', command: 'R', start_time: 'now' }, - tempdir: '/tmp/session', - wd: '/workspace' - } - })}\n`); + socket.write( + `${JSON.stringify({ + jsonrpc: '2.0', + method: 'attach', + params: { + protocol_version: 2, + session_id: 'stable-session-id', + host: 'remote-compute-node', + sess_version: '3.0.0', + pid, + version: 'R 4.4.0', + info: { version: '4.4.0', command: 'R', start_time: 'now' }, + tempdir: '/tmp/session', + wd: '/workspace', + }, + })}\n`, + ); }; try { @@ -1296,7 +1686,10 @@ suite('Session Communication', () => { if (!originalSession) { throw new Error('original session should have attached'); } - assert.strictEqual(await session.activateSessionById(originalSession.sessionId, { terminal }), true); + assert.strictEqual( + await session.activateSessionById(originalSession.sessionId, { terminal }), + true, + ); const reconnect = net.createConnection(endpoint); second = reconnect; @@ -1306,15 +1699,29 @@ suite('Session Communication', () => { assert.strictEqual(session.activeSession?.pid, '9876'); const replacementSession = session.activeSession; assert.ok(replacementSession); - await assert.rejects(session.executeSessionCode(replacementSession, 'View(iris)'), /no attached terminal/); - assert.strictEqual(await session.activateSessionById(replacementSession.sessionId, { terminal }), true); + await assert.rejects( + session.executeSessionCode(replacementSession, 'View(iris)'), + /no attached terminal/, + ); + assert.strictEqual( + await session.activateSessionById(replacementSession.sessionId, { terminal }), + true, + ); await session.cleanupSession('stable-session-id', originalSession.socket); - assert.strictEqual(session.activeSession, replacementSession, 'cleanup from the old socket must not remove the replacement session'); + assert.strictEqual( + session.activeSession, + replacementSession, + 'cleanup from the old socket must not remove the replacement session', + ); first.destroy(); - await new Promise(resolve => setTimeout(resolve, 100)); - assert.strictEqual(session.activeSession, replacementSession, 'old socket close must not clear the replacement session'); + await new Promise((resolve) => setTimeout(resolve, 100)); + assert.strictEqual( + session.activeSession, + replacementSession, + 'old socket close must not clear the replacement session', + ); await session.executeSessionCode(replacementSession, 'View(iris)'); sinon.assert.calledOnce(sendText); @@ -1324,27 +1731,37 @@ suite('Session Communication', () => { otherSession.once('connect', resolve); otherSession.once('error', reject); }); - otherSession.write(`${JSON.stringify({ - jsonrpc: '2.0', - method: 'attach', - params: { - protocol_version: 2, - session_id: 'other-session-id', - host: 'other-compute-node', - sess_version: '3.0.0', - pid: 9876, - version: 'R 4.4.0', - info: { version: '4.4.0', command: 'R', start_time: 'now' }, - tempdir: '/tmp/other-session', - wd: '/workspace' - } - })}\n`); + otherSession.write( + `${JSON.stringify({ + jsonrpc: '2.0', + method: 'attach', + params: { + protocol_version: 2, + session_id: 'other-session-id', + host: 'other-compute-node', + sess_version: '3.0.0', + pid: 9876, + version: 'R 4.4.0', + info: { version: '4.4.0', command: 'R', start_time: 'now' }, + tempdir: '/tmp/other-session', + wd: '/workspace', + }, + })}\n`, + ); await waitFor(() => session.activeSession?.sessionId === 'other-session-id'); - assert.strictEqual(session.activeSession?.pid, '9876', 'distinct session ids can share an R PID'); + assert.strictEqual( + session.activeSession?.pid, + '9876', + 'distinct session ids can share an R PID', + ); second.destroy(); - await new Promise(resolve => setTimeout(resolve, 100)); - assert.strictEqual(session.activeSession?.sessionId, 'other-session-id', 'closing a different session must not affect the active session'); + await new Promise((resolve) => setTimeout(resolve, 100)); + assert.strictEqual( + session.activeSession?.sessionId, + 'other-session-id', + 'closing a different session must not affect the active session', + ); third.destroy(); await waitFor(() => !session.activeSession); @@ -1359,21 +1776,24 @@ suite('Session Communication', () => { const showError = sandbox.stub(vscode.window, 'showErrorMessage'); const endpoint = await session.getGlobalPipePath(); const client = net.createConnection(endpoint); - const sendAttach = (sessionId: string) => client.write(`${JSON.stringify({ - jsonrpc: '2.0', - method: 'attach', - params: { - protocol_version: 2, - session_id: sessionId, - host: 'remote-compute-node', - sess_version: '3.0.0', - pid: 1234, - version: 'R 4.4.0', - info: { version: '4.4.0', command: 'R', start_time: 'now' }, - tempdir: '/tmp/session', - wd: '/workspace' - } - })}\n`); + const sendAttach = (sessionId: string) => + client.write( + `${JSON.stringify({ + jsonrpc: '2.0', + method: 'attach', + params: { + protocol_version: 2, + session_id: sessionId, + host: 'remote-compute-node', + sess_version: '3.0.0', + pid: 1234, + version: 'R 4.4.0', + info: { version: '4.4.0', command: 'R', start_time: 'now' }, + tempdir: '/tmp/session', + wd: '/workspace', + }, + })}\n`, + ); try { await new Promise((resolve, reject) => { @@ -1408,13 +1828,18 @@ suite('Session Communication', () => { client.once('connect', resolve); client.once('error', reject); }); - client.write(`${JSON.stringify({ - jsonrpc: '2.0', - method: 'attach', - params: { protocol_version: 1, session_id: 'legacy-session' } - })}\n`); + client.write( + `${JSON.stringify({ + jsonrpc: '2.0', + method: 'attach', + params: { protocol_version: 1, session_id: 'legacy-session' }, + })}\n`, + ); await waitFor(() => showError.called); - assert.match(String(showError.firstCall.args[0]), /unsupported sess protocol version 1; this extension requires protocol version 2/); + assert.match( + String(showError.firstCall.args[0]), + /unsupported sess protocol version 1; this extension requires protocol version 2/, + ); assert.notStrictEqual(session.activeSession?.sessionId, 'legacy-session'); } finally { client.destroy(); @@ -1430,16 +1855,18 @@ suite('Session Communication', () => { client.once('connect', resolve); client.once('error', reject); }); - client.write(`${JSON.stringify({ - jsonrpc: '2.0', - method: 'attach', - params: { - protocol_version: 2, - session_id: 'malformed-session', - tempdir: { path: os.tmpdir() }, - wd: os.tmpdir() - } - })}\n`); + client.write( + `${JSON.stringify({ + jsonrpc: '2.0', + method: 'attach', + params: { + protocol_version: 2, + session_id: 'malformed-session', + tempdir: { path: os.tmpdir() }, + wd: os.tmpdir(), + }, + })}\n`, + ); await waitFor(() => showError.called); assert.match(String(showError.firstCall.args[0]), /missing or invalid session paths/); assert.notStrictEqual(session.activeSession?.sessionId, 'malformed-session'); diff --git a/src/test/suite/sessionConnectionLifecycle.test.ts b/src/test/suite/sessionConnectionLifecycle.test.ts index 8d9447060..c87bb1b49 100644 --- a/src/test/suite/sessionConnectionLifecycle.test.ts +++ b/src/test/suite/sessionConnectionLifecycle.test.ts @@ -15,8 +15,10 @@ import { deferred, SessionConnections, waitForValue } from '../common/sessionCon async function waitForFileRemoval(filePath: string): Promise { for (let attempt = 0; attempt < 100; attempt++) { - if (!await pathExists(filePath)) { return; } - await new Promise(resolve => setTimeout(resolve, 10)); + if (!(await pathExists(filePath))) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 10)); } assert.fail(`Timed out waiting for ${filePath} to be removed`); } @@ -57,23 +59,30 @@ suite('Session Connection Lifecycle API', () => { test('background external attach remains unselected and exposes its current reference', async () => { const events: RSessionConnectionChange[] = []; - const subscription = session.onDidChangeSessionConnection(event => events.push(event)); + const subscription = session.onDidChangeSessionConnection((event) => events.push(event)); let socket: session.Session['socket'] | undefined; try { socket = await connections.attach('external-background', 55001, 'external-host', { - attachmentId: 'attachment-01', autoActivate: false, + attachmentId: 'attachment-01', + autoActivate: false, }); assert.ok(socket); - const reference = await waitForValue(() => session.getSessionReference('external-background')); + const reference = await waitForValue(() => + session.getSessionReference('external-background'), + ); assert.strictEqual(session.activeSession, undefined); assert.strictEqual(reference.sessionId, 'external-background'); assert.strictEqual(reference.attachmentId, 'attachment-01'); assert.strictEqual(events.length, 1); assert.strictEqual(events[0].kind, 'connected'); assert.deepStrictEqual(events[0].session, reference); - assert.strictEqual(await session.activateSessionById('external-background', { - connectionId: reference.connectionId, select: false, - }), true); + assert.strictEqual( + await session.activateSessionById('external-background', { + connectionId: reference.connectionId, + select: false, + }), + true, + ); assert.strictEqual(session.activeSession, undefined); } finally { subscription.dispose(); @@ -84,44 +93,88 @@ suite('Session Connection Lifecycle API', () => { test('autoActivate false skips pending PID discovery and select:false binds executable ownership only', async () => { const sendText = sandbox.stub(); const pendingTerminal = { - processId: new Promise(() => undefined), sendText, show: sandbox.stub(), + processId: new Promise(() => undefined), + sendText, + show: sandbox.stub(), } as unknown as vscode.Terminal; terminals.value([pendingTerminal]); const socket = await connections.attach('external-pending-pid', 55009, os.hostname(), { - attachmentId: 'pending-pid', autoActivate: false, + attachmentId: 'pending-pid', + autoActivate: false, }); - const reference = await waitForValue(() => session.getSessionReference('external-pending-pid')); + const reference = await waitForValue(() => + session.getSessionReference('external-pending-pid'), + ); assert.strictEqual(session.activeSession, undefined); assert.strictEqual(await session.activateSessionById('external-pending-pid'), true); const attachedOwner = session.activeSession; assert.ok(attachedOwner); - const otherOwner = session.registerSessionTransport('temporary-selected-owner', 'host', '/tmp', () => Promise.resolve(undefined)); + const otherOwner = session.registerSessionTransport( + 'temporary-selected-owner', + 'host', + '/tmp', + () => Promise.resolve(undefined), + ); assert.strictEqual(await session.activateSessionById(otherOwner.sessionId), true); session.unregisterSessionTransport(otherOwner); assert.strictEqual(session.activeSession, undefined); - assert.strictEqual(await session.activateSessionById('external-pending-pid', { - connectionId: reference.connectionId, terminal: pendingTerminal, select: false, - }), true); - assert.strictEqual(session.activeSession, undefined, 'select:false must not change the logical selection'); + assert.strictEqual( + await session.activateSessionById('external-pending-pid', { + connectionId: reference.connectionId, + terminal: pendingTerminal, + select: false, + }), + true, + ); + assert.strictEqual( + session.activeSession, + undefined, + 'select:false must not change the logical selection', + ); await session.executeSessionCode(attachedOwner, 'View(iris)'); assert.strictEqual(sendText.calledOnce, true); assert.ok(String(sendText.firstCall.args[0]).includes('View(iris)')); - const nativeTerminal = { processId: Promise.resolve(55010), sendText: sandbox.stub(), show: sandbox.stub() } as unknown as vscode.Terminal; + const nativeTerminal = { + processId: Promise.resolve(55010), + sendText: sandbox.stub(), + show: sandbox.stub(), + } as unknown as vscode.Terminal; terminals.value([nativeTerminal]); const nativeSocket = await connections.attach('native-selected', 55010, os.hostname()); - const nativeOwner = await waitForValue(() => session.activeSession?.sessionId === 'native-selected' ? session.activeSession : undefined); + const nativeOwner = await waitForValue(() => + session.activeSession?.sessionId === 'native-selected' + ? session.activeSession + : undefined, + ); assert.strictEqual(session.activeSession, nativeOwner); - await connections.attach('background-over-native', 55011, os.hostname(), { autoActivate: false, attachmentId: 'bg-native' }); + await connections.attach('background-over-native', 55011, os.hostname(), { + autoActivate: false, + attachmentId: 'bg-native', + }); assert.strictEqual(session.activeSession, nativeOwner); - const explicitTerminal = { processId: Promise.resolve(undefined), sendText: sandbox.stub(), show: sandbox.stub() } as unknown as vscode.Terminal; + const explicitTerminal = { + processId: Promise.resolve(undefined), + sendText: sandbox.stub(), + show: sandbox.stub(), + } as unknown as vscode.Terminal; terminals.value([explicitTerminal]); - const explicitOwnerSocket = await connections.attach('explicit-selected', 55012, 'external-host'); + const explicitOwnerSocket = await connections.attach( + 'explicit-selected', + 55012, + 'external-host', + ); const explicitOwner = session.activeSession; assert.ok(explicitOwner); - assert.strictEqual(await session.activateSessionById('explicit-selected', { terminal: explicitTerminal }), true); - await connections.attach('background-over-explicit', 55013, os.hostname(), { autoActivate: false, attachmentId: 'bg-explicit' }); + assert.strictEqual( + await session.activateSessionById('explicit-selected', { terminal: explicitTerminal }), + true, + ); + await connections.attach('background-over-explicit', 55013, os.hostname(), { + autoActivate: false, + attachmentId: 'bg-explicit', + }); assert.strictEqual(session.activeSession, explicitOwner); assert.notStrictEqual(socket, nativeSocket); assert.ok(explicitOwnerSocket.destroyed === false); @@ -130,14 +183,23 @@ suite('Session Connection Lifecycle API', () => { test('foreground external attach selects itself and selected logical replacement follows reconnect', async () => { const first = await connections.attach('external-foreground', 55005, 'external-host', { - attachmentId: 'foreground-1', autoActivate: true, + attachmentId: 'foreground-1', + autoActivate: true, }); - const oldReference = await waitForValue(() => session.getSessionReference('external-foreground')); + const oldReference = await waitForValue(() => + session.getSessionReference('external-foreground'), + ); assert.strictEqual(session.activeSession?.socket, first); - const replacement = await connections.attach('external-foreground', 55006, 'external-host', { - attachmentId: 'foreground-2', autoActivate: false, - }); + const replacement = await connections.attach( + 'external-foreground', + 55006, + 'external-host', + { + attachmentId: 'foreground-2', + autoActivate: false, + }, + ); const newReference = await waitForValue(() => { const reference = session.getSessionReference('external-foreground'); return reference?.connectionId !== oldReference.connectionId ? reference : undefined; @@ -148,40 +210,81 @@ suite('Session Connection Lifecycle API', () => { test('attach UI effects preserve native behavior but ignore background and superseded choices', async () => { const showHttpgdPlot = sandbox.stub().resolves(); - sandbox.stub(extension, 'globalPlotManager').value({ showHttpgdPlot } as unknown as typeof extension.globalPlotManager); - const explicitTerminal = { processId: Promise.resolve(undefined), sendText: sandbox.stub(), show: sandbox.stub() } as unknown as vscode.Terminal; - const nativeTerminal = { processId: Promise.resolve(55031), sendText: sandbox.stub(), show: sandbox.stub() } as unknown as vscode.Terminal; + sandbox + .stub(extension, 'globalPlotManager') + .value({ showHttpgdPlot } as unknown as typeof extension.globalPlotManager); + const explicitTerminal = { + processId: Promise.resolve(undefined), + sendText: sandbox.stub(), + show: sandbox.stub(), + } as unknown as vscode.Terminal; + const nativeTerminal = { + processId: Promise.resolve(55031), + sendText: sandbox.stub(), + show: sandbox.stub(), + } as unknown as vscode.Terminal; terminals.value([explicitTerminal, nativeTerminal]); const first = await connections.attach('plot-selected-explicit', 55030, 'external-host'); const selected = session.activeSession; assert.ok(selected); - assert.strictEqual(await session.activateSessionById('plot-selected-explicit', { terminal: explicitTerminal }), true); + assert.strictEqual( + await session.activateSessionById('plot-selected-explicit', { + terminal: explicitTerminal, + }), + true, + ); activeTerminal.value(explicitTerminal); - await connections.attach('plot-native-background', 55031, os.hostname(), { plotUrl: 'http://127.0.0.1:55031' }); - await waitForValue(() => showHttpgdPlot.calledOnce ? true : undefined); + await connections.attach('plot-native-background', 55031, os.hostname(), { + plotUrl: 'http://127.0.0.1:55031', + }); + await waitForValue(() => (showHttpgdPlot.calledOnce ? true : undefined)); assert.deepStrictEqual(showHttpgdPlot.firstCall.args, ['http://127.0.0.1:55031']); - assert.strictEqual(session.activeSession, selected, 'native attach preserves the explicit terminal selection'); + assert.strictEqual( + session.activeSession, + selected, + 'native attach preserves the explicit terminal selection', + ); await connections.attach('plot-external-background', 55032, 'external-host', { - attachmentId: 'plot-background', autoActivate: false, plotUrl: 'http://127.0.0.1:55032', + attachmentId: 'plot-background', + autoActivate: false, + plotUrl: 'http://127.0.0.1:55032', }); - await new Promise(resolve => setImmediate(resolve)); - assert.strictEqual(showHttpgdPlot.calledOnce, true, 'autoActivate:false background attach must not open its plot'); + await new Promise((resolve) => setImmediate(resolve)); + assert.strictEqual( + showHttpgdPlot.calledOnce, + true, + 'autoActivate:false background attach must not open its plot', + ); - const listenerTarget = session.registerSessionTransport('plot-listener-target', 'listener', '/tmp', () => Promise.resolve(undefined)); - const subscription = session.onDidChangeSessionConnection(change => { - if (change.kind === 'connected' && change.session.sessionId === 'plot-listener-background') { + const listenerTarget = session.registerSessionTransport( + 'plot-listener-target', + 'listener', + '/tmp', + () => Promise.resolve(undefined), + ); + const subscription = session.onDidChangeSessionConnection((change) => { + if ( + change.kind === 'connected' && + change.session.sessionId === 'plot-listener-background' + ) { void session.activateSessionById(listenerTarget.sessionId); } }); try { await connections.attach('plot-listener-background', 55033, 'external-host', { - attachmentId: 'plot-listener', autoActivate: true, plotUrl: 'http://127.0.0.1:55033', + attachmentId: 'plot-listener', + autoActivate: true, + plotUrl: 'http://127.0.0.1:55033', }); - await waitForValue(() => session.activeSession === listenerTarget ? true : undefined); - await new Promise(resolve => setImmediate(resolve)); - assert.strictEqual(showHttpgdPlot.calledOnce, true, 'a listener selection must cancel stale attach plot effects'); + await waitForValue(() => (session.activeSession === listenerTarget ? true : undefined)); + await new Promise((resolve) => setImmediate(resolve)); + assert.strictEqual( + showHttpgdPlot.calledOnce, + true, + 'a listener selection must cancel stale attach plot effects', + ); } finally { subscription.dispose(); session.unregisterSessionTransport(listenerTarget); @@ -191,7 +294,9 @@ suite('Session Connection Lifecycle API', () => { test('a replaced socket cannot publish its delayed attach plot', async () => { const showHttpgdPlot = sandbox.stub().resolves(); - sandbox.stub(extension, 'globalPlotManager').value({ showHttpgdPlot } as unknown as typeof extension.globalPlotManager); + sandbox + .stub(extension, 'globalPlotManager') + .value({ showHttpgdPlot } as unknown as typeof extension.globalPlotManager); const activationGate = deferred(); let delayedFirstContext = true; executeCommand.callsFake((command: string, ..._args: unknown[]) => { @@ -204,46 +309,75 @@ suite('Session Connection Lifecycle API', () => { let oldSocket: session.Session['socket'] | undefined; try { oldSocket = await connections.startAttach('plot-replaced', 55034, 'external-host', { - attachmentId: 'plot-old', autoActivate: true, plotUrl: 'http://127.0.0.1:55034', + attachmentId: 'plot-old', + autoActivate: true, + plotUrl: 'http://127.0.0.1:55034', }); - const oldReference = await waitForValue(() => session.getSessionReference('plot-replaced')); + const oldReference = await waitForValue(() => + session.getSessionReference('plot-replaced'), + ); const replacement = await connections.attach('plot-replaced', 55035, 'external-host', { - attachmentId: 'plot-new', autoActivate: false, + attachmentId: 'plot-new', + autoActivate: false, }); - assert.notStrictEqual(session.getSessionReference('plot-replaced')?.connectionId, oldReference.connectionId); + assert.notStrictEqual( + session.getSessionReference('plot-replaced')?.connectionId, + oldReference.connectionId, + ); activationGate.resolve(undefined); - await waitForValue(() => !session.activeConnections.has(oldSocket!) ? true : undefined); - await new Promise(resolve => setImmediate(resolve)); - assert.strictEqual(showHttpgdPlot.called, false, 'the stale socket must not open its plot after replacement'); - assert.strictEqual(session.getSessionReference('plot-replaced')?.attachmentId, 'plot-new'); + await waitForValue(() => + !session.activeConnections.has(oldSocket!) ? true : undefined, + ); + await new Promise((resolve) => setImmediate(resolve)); + assert.strictEqual( + showHttpgdPlot.called, + false, + 'the stale socket must not open its plot after replacement', + ); + assert.strictEqual( + session.getSessionReference('plot-replaced')?.attachmentId, + 'plot-new', + ); await session.cleanupSession('plot-replaced', replacement); } finally { activationGate.resolve(undefined); - if (oldSocket) { await session.cleanupSession('plot-replaced', oldSocket); } + if (oldSocket) { + await session.cleanupSession('plot-replaced', oldSocket); + } } }); test('only the replacement reference can bind its terminal and old sockets cannot remove it', async () => { const events: RSessionConnectionChange[] = []; - const subscription = session.onDidChangeSessionConnection(event => events.push(event)); + const subscription = session.onDidChangeSessionConnection((event) => events.push(event)); const sendText = sandbox.stub(); - const terminal = { processId: Promise.resolve(undefined), sendText, show: sandbox.stub() } as unknown as vscode.Terminal; + const terminal = { + processId: Promise.resolve(undefined), + sendText, + show: sandbox.stub(), + } as unknown as vscode.Terminal; terminals.value([terminal]); let oldSocket: session.Session['socket'] | undefined; let newSocket: session.Session['socket'] | undefined; try { oldSocket = await connections.attach('external-rebind', 55014, 'external-host', { - attachmentId: 'rebind-old', autoActivate: false, + attachmentId: 'rebind-old', + autoActivate: false, }); - const oldReference = await waitForValue(() => session.getSessionReference('external-rebind')); + const oldReference = await waitForValue(() => + session.getSessionReference('external-rebind'), + ); assert.strictEqual(await session.activateSessionById('external-rebind'), true); const oldOwner = session.activeSession; assert.ok(oldOwner); newSocket = await connections.startAttach('external-rebind', 55015, 'external-host', { - attachmentId: 'rebind-new', autoActivate: false, + attachmentId: 'rebind-new', + autoActivate: false, }); - await waitForValue(() => newSocket?._sessionId === 'external-rebind' ? true : undefined); + await waitForValue(() => + newSocket?._sessionId === 'external-rebind' ? true : undefined, + ); const newReference = await waitForValue(() => { const value = session.getSessionReference('external-rebind'); return value?.connectionId !== oldReference.connectionId ? value : undefined; @@ -251,106 +385,194 @@ suite('Session Connection Lifecycle API', () => { const newOwner = session.activeSession; assert.ok(newOwner); assert.strictEqual(newOwner.socket, newSocket); - assert.strictEqual(await session.activateSessionById('external-rebind', { - connectionId: oldReference.connectionId, terminal, - }), false, 'a stale reference must not bind a terminal to its replacement'); - await assert.rejects(session.executeSessionCode(oldOwner, 'View(old)'), /no longer attached/); - await assert.rejects(session.executeSessionCode(newOwner, 'View(new)'), /no attached terminal/); - assert.strictEqual(await session.activateSessionById('external-rebind', { - connectionId: newReference.connectionId, terminal, select: false, - }), true); - assert.strictEqual(session.activeSession, newOwner, 'select:false must preserve the current logical selection'); + assert.strictEqual( + await session.activateSessionById('external-rebind', { + connectionId: oldReference.connectionId, + terminal, + }), + false, + 'a stale reference must not bind a terminal to its replacement', + ); + await assert.rejects( + session.executeSessionCode(oldOwner, 'View(old)'), + /no longer attached/, + ); + await assert.rejects( + session.executeSessionCode(newOwner, 'View(new)'), + /no attached terminal/, + ); + assert.strictEqual( + await session.activateSessionById('external-rebind', { + connectionId: newReference.connectionId, + terminal, + select: false, + }), + true, + ); + assert.strictEqual( + session.activeSession, + newOwner, + 'select:false must preserve the current logical selection', + ); await session.executeSessionCode(newOwner, 'View(new)'); assert.strictEqual(sendText.calledOnce, true); assert.ok(String(sendText.firstCall.args[0]).includes('View(new)')); - const connectedCount = events.filter(event => event.kind === 'connected').length; + const connectedCount = events.filter((event) => event.kind === 'connected').length; connections.repeatAttach('external-rebind'); - const response = await connections.request('external-rebind', 'rstudioapi/get_project_path'); - assert.ok('result' in response || 'error' in response, 'the repeated-handshake client remains connected'); - assert.strictEqual(events.filter(event => event.kind === 'connected').length, connectedCount, - 'a repeated handshake on the same socket is idempotent'); + const response = await connections.request( + 'external-rebind', + 'rstudioapi/get_project_path', + ); + assert.ok( + 'result' in response || 'error' in response, + 'the repeated-handshake client remains connected', + ); + assert.strictEqual( + events.filter((event) => event.kind === 'connected').length, + connectedCount, + 'a repeated handshake on the same socket is idempotent', + ); const replacedSocket = oldSocket; assert.ok(replacedSocket); replacedSocket.destroy(); - await waitForValue(() => !session.activeConnections.has(replacedSocket) ? true : undefined); - assert.strictEqual(session.getSessionReference('external-rebind')?.connectionId, newReference.connectionId, - 'closing the old socket must not remove the replacement'); + await waitForValue(() => + !session.activeConnections.has(replacedSocket) ? true : undefined, + ); + assert.strictEqual( + session.getSessionReference('external-rebind')?.connectionId, + newReference.connectionId, + 'closing the old socket must not remove the replacement', + ); } finally { subscription.dispose(); - if (newSocket) { await session.cleanupSession('external-rebind', newSocket); } - if (oldSocket) { await session.cleanupSession('external-rebind', oldSocket); } + if (newSocket) { + await session.cleanupSession('external-rebind', newSocket); + } + if (oldSocket) { + await session.cleanupSession('external-rebind', oldSocket); + } } }); test('activation returns false when its awaited terminal or connection becomes stale', async () => { - const terminal = { processId: Promise.resolve(undefined), sendText: sandbox.stub(), show: sandbox.stub() } as unknown as vscode.Terminal; + const terminal = { + processId: Promise.resolve(undefined), + sendText: sandbox.stub(), + show: sandbox.stub(), + } as unknown as vscode.Terminal; terminals.value([terminal]); await connections.attach('activation-terminal-close', 55016, 'external-host', { - attachmentId: 'activate-close', autoActivate: false, + attachmentId: 'activate-close', + autoActivate: false, }); - const terminalReference = await waitForValue(() => session.getSessionReference('activation-terminal-close')); + const terminalReference = await waitForValue(() => + session.getSessionReference('activation-terminal-close'), + ); let releaseTerminalContext!: () => void; const terminalContext = deferred(); releaseTerminalContext = () => terminalContext.resolve(undefined); let blockNext = true; executeCommand.callsFake((async (command: unknown, key: unknown, value: unknown) => { - if (blockNext && command === 'setContext' && key === 'rSessionActive' && value === true) { + if ( + blockNext && + command === 'setContext' && + key === 'rSessionActive' && + value === true + ) { blockNext = false; return terminalContext.promise; } return undefined; }) as typeof vscode.commands.executeCommand); const closingActivation = session.activateSessionById('activation-terminal-close', { - connectionId: terminalReference.connectionId, terminal, + connectionId: terminalReference.connectionId, + terminal, }); - await waitForValue(() => !blockNext ? true : undefined); + await waitForValue(() => (!blockNext ? true : undefined)); session.cleanupTerminalBinding(terminal); terminals.value([]); releaseTerminalContext(); - assert.strictEqual(await closingActivation, false, 'a terminal closed during UI work invalidates activation'); + assert.strictEqual( + await closingActivation, + false, + 'a terminal closed during UI work invalidates activation', + ); - const rebindTerminal = { processId: Promise.resolve(undefined), sendText: sandbox.stub(), show: sandbox.stub() } as unknown as vscode.Terminal; + const rebindTerminal = { + processId: Promise.resolve(undefined), + sendText: sandbox.stub(), + show: sandbox.stub(), + } as unknown as vscode.Terminal; terminals.value([rebindTerminal]); await connections.attach('activation-terminal-old-owner', 55028, 'external-host', { - attachmentId: 'terminal-owner-old', autoActivate: false, + attachmentId: 'terminal-owner-old', + autoActivate: false, }); await connections.attach('activation-terminal-new-owner', 55029, 'external-host', { - attachmentId: 'terminal-owner-new', autoActivate: false, + attachmentId: 'terminal-owner-new', + autoActivate: false, }); - const oldOwnerReference = await waitForValue(() => session.getSessionReference('activation-terminal-old-owner')); - const newOwnerReference = await waitForValue(() => session.getSessionReference('activation-terminal-new-owner')); + const oldOwnerReference = await waitForValue(() => + session.getSessionReference('activation-terminal-old-owner'), + ); + const newOwnerReference = await waitForValue(() => + session.getSessionReference('activation-terminal-new-owner'), + ); const rebindContext = deferred(); let releaseRebindContext!: () => void; releaseRebindContext = () => rebindContext.resolve(undefined); blockNext = true; executeCommand.callsFake((async (command: unknown, key: unknown, value: unknown) => { - if (blockNext && command === 'setContext' && key === 'rSessionActive' && value === true) { + if ( + blockNext && + command === 'setContext' && + key === 'rSessionActive' && + value === true + ) { blockNext = false; return rebindContext.promise; } return undefined; }) as typeof vscode.commands.executeCommand); const rebindActivation = session.activateSessionById('activation-terminal-old-owner', { - connectionId: oldOwnerReference.connectionId, terminal: rebindTerminal, + connectionId: oldOwnerReference.connectionId, + terminal: rebindTerminal, }); - await waitForValue(() => !blockNext ? true : undefined); - assert.strictEqual(await session.activateSessionById('activation-terminal-new-owner', { - connectionId: newOwnerReference.connectionId, terminal: rebindTerminal, select: false, - }), true); + await waitForValue(() => (!blockNext ? true : undefined)); + assert.strictEqual( + await session.activateSessionById('activation-terminal-new-owner', { + connectionId: newOwnerReference.connectionId, + terminal: rebindTerminal, + select: false, + }), + true, + ); releaseRebindContext(); - assert.strictEqual(await rebindActivation, false, 'a terminal rebound during UI work invalidates activation'); + assert.strictEqual( + await rebindActivation, + false, + 'a terminal rebound during UI work invalidates activation', + ); await connections.attach('activation-connection-replace', 55017, 'external-host', { - attachmentId: 'activate-replace-old', autoActivate: false, + attachmentId: 'activate-replace-old', + autoActivate: false, }); - const oldReference = await waitForValue(() => session.getSessionReference('activation-connection-replace')); + const oldReference = await waitForValue(() => + session.getSessionReference('activation-connection-replace'), + ); const connectionContext = deferred(); let releaseConnectionContext!: () => void; releaseConnectionContext = () => connectionContext.resolve(undefined); blockNext = true; executeCommand.callsFake((async (command: unknown, key: unknown, value: unknown) => { - if (blockNext && command === 'setContext' && key === 'rSessionActive' && value === true) { + if ( + blockNext && + command === 'setContext' && + key === 'rSessionActive' && + value === true + ) { blockNext = false; return connectionContext.promise; } @@ -359,42 +581,61 @@ suite('Session Connection Lifecycle API', () => { const replacedActivation = session.activateSessionById('activation-connection-replace', { connectionId: oldReference.connectionId, }); - await waitForValue(() => !blockNext ? true : undefined); + await waitForValue(() => (!blockNext ? true : undefined)); await connections.attach('activation-connection-replace', 55018, 'external-host', { - attachmentId: 'activate-replace-new', autoActivate: false, + attachmentId: 'activate-replace-new', + autoActivate: false, }); releaseConnectionContext(); - assert.strictEqual(await replacedActivation, false, 'a replaced connection must not report successful activation'); - assert.notStrictEqual(session.getSessionReference('activation-connection-replace')?.connectionId, oldReference.connectionId); + assert.strictEqual( + await replacedActivation, + false, + 'a replaced connection must not report successful activation', + ); + assert.notStrictEqual( + session.getSessionReference('activation-connection-replace')?.connectionId, + oldReference.connectionId, + ); }); test('a connection listener can activate another session without later attach work overriding it', async () => { await connections.attach('listener-selected-session', 55019, 'external-host', { - attachmentId: 'listener-owner', autoActivate: false, + attachmentId: 'listener-owner', + autoActivate: false, }); assert.strictEqual(await session.activateSessionById('listener-selected-session'), true); const selected = session.activeSession; assert.ok(selected); - const subscription = session.onDidChangeSessionConnection(event => { - if (event.kind === 'connected' && event.session.sessionId === 'listener-background-attach') { + const subscription = session.onDidChangeSessionConnection((event) => { + if ( + event.kind === 'connected' && + event.session.sessionId === 'listener-background-attach' + ) { void session.activateSessionById('listener-selected-session'); } }); try { await connections.attach('listener-background-attach', 55020, 'external-host', { - attachmentId: 'listener-background', autoActivate: true, + attachmentId: 'listener-background', + autoActivate: true, }); - assert.strictEqual(session.activeSession, selected, - 'the attach handler must not auto-select again after delivering connection events'); - } finally { subscription.dispose(); } + assert.strictEqual( + session.activeSession, + selected, + 'the attach handler must not auto-select again after delivering connection events', + ); + } finally { + subscription.dispose(); + } }); test('replacing sess with a Requester revokes the old ref once and moves document routing', async () => { const id = 'sess-to-requester'; const events: RSessionConnectionChange[] = []; - const subscription = session.onDidChangeSessionConnection(event => events.push(event)); + const subscription = session.onDidChangeSessionConnection((event) => events.push(event)); const socket = await connections.attach(id, 55021, 'external-host', { - attachmentId: 'sess-before-requester', autoActivate: false, + attachmentId: 'sess-before-requester', + autoActivate: false, }); const sessReference = await waitForValue(() => session.getSessionReference(id)); const document = vscode.Uri.file('/tmp/sess-to-requester.R'); @@ -403,21 +644,40 @@ suite('Session Connection Lifecycle API', () => { assert.ok(oldOwner); session.bindSessionDocument(document, oldOwner); let requests = 0; - const requester = session.registerSessionTransport(id, 'interactive-host', '/interactive', () => { - requests++; - return Promise.resolve('requester response'); - }); + const requester = session.registerSessionTransport( + id, + 'interactive-host', + '/interactive', + () => { + requests++; + return Promise.resolve('requester response'); + }, + ); try { assert.notStrictEqual(requester, oldOwner); assert.strictEqual(session.getSessionReference(id), undefined); assert.strictEqual(session.sessionForDocument(document), requester); - assert.deepStrictEqual(events.map(event => [event.kind, event.session.connectionId]), [ - ['connected', sessReference.connectionId], ['disconnected', sessReference.connectionId], - ]); - assert.strictEqual(await session.sessionRequest({ method: 'test' }, session.sessionForDocument(document)), 'requester response'); + assert.deepStrictEqual( + events.map((event) => [event.kind, event.session.connectionId]), + [ + ['connected', sessReference.connectionId], + ['disconnected', sessReference.connectionId], + ], + ); + assert.strictEqual( + await session.sessionRequest( + { method: 'test' }, + session.sessionForDocument(document), + ), + 'requester response', + ); socket.destroy(); - await waitForValue(() => !session.activeConnections.has(socket) ? true : undefined); - assert.strictEqual(await session.activateSessionById(id), true, 'late sess close must not unregister the requester'); + await waitForValue(() => (!session.activeConnections.has(socket) ? true : undefined)); + assert.strictEqual( + await session.activateSessionById(id), + true, + 'late sess close must not unregister the requester', + ); assert.strictEqual(session.getSessionReference(id), undefined); assert.strictEqual(events.length, 2, 'the sess connection is invalidated exactly once'); assert.strictEqual(requests, 1); @@ -432,9 +692,13 @@ suite('Session Connection Lifecycle API', () => { const events: RSessionConnectionChange[] = []; let replacement: session.Session['socket'] | undefined; let revokedReplacement = false; - const subscription = session.onDidChangeSessionConnection(event => { + const subscription = session.onDidChangeSessionConnection((event) => { events.push(event); - if (event.kind === 'disconnected' && event.session.sessionId === 'external-replace' && !revokedReplacement) { + if ( + event.kind === 'disconnected' && + event.session.sessionId === 'external-replace' && + !revokedReplacement + ) { revokedReplacement = true; void session.cleanupSession('external-replace'); } @@ -442,45 +706,85 @@ suite('Session Connection Lifecycle API', () => { let first: session.Session['socket'] | undefined; try { first = await connections.attach('external-replace', 55002, 'external-host', { - attachmentId: 'first', autoActivate: false, + attachmentId: 'first', + autoActivate: false, }); assert.ok(first); - const oldReference = await waitForValue(() => session.getSessionReference('external-replace')); - replacement = await connections.startAttach('external-replace', 55003, 'external-host', { - attachmentId: 'second', autoActivate: false, - }); - await waitForValue(() => replacement?._sessionId === 'external-replace' ? true : undefined); - await waitForValue(() => events.length >= 4 ? true : undefined); - assert.deepStrictEqual(events.map(event => `${event.kind}:${event.session.attachmentId}`), [ - 'connected:first', 'disconnected:first', 'connected:second', 'disconnected:second', - ]); - assert.strictEqual(await session.activateSessionById('external-replace', { - connectionId: oldReference.connectionId, - }), false); + const oldReference = await waitForValue(() => + session.getSessionReference('external-replace'), + ); + replacement = await connections.startAttach( + 'external-replace', + 55003, + 'external-host', + { + attachmentId: 'second', + autoActivate: false, + }, + ); + await waitForValue(() => + replacement?._sessionId === 'external-replace' ? true : undefined, + ); + await waitForValue(() => (events.length >= 4 ? true : undefined)); + assert.deepStrictEqual( + events.map((event) => `${event.kind}:${event.session.attachmentId}`), + [ + 'connected:first', + 'disconnected:first', + 'connected:second', + 'disconnected:second', + ], + ); + assert.strictEqual( + await session.activateSessionById('external-replace', { + connectionId: oldReference.connectionId, + }), + false, + ); assert.strictEqual(session.getSessionReference('external-replace'), undefined); await session.cleanupSession('external-replace', first); assert.strictEqual(session.getSessionReference('external-replace'), undefined); } finally { subscription.dispose(); - if (replacement) { await session.cleanupSession('external-replace', replacement); } - if (first) { await session.cleanupSession('external-replace', first); } + if (replacement) { + await session.cleanupSession('external-replace', replacement); + } + if (first) { + await session.cleanupSession('external-replace', first); + } } }); test('malformed attach metadata is rejected before a session reference is published', async () => { - const showErrorMessage = sandbox.stub(vscode.window, 'showErrorMessage').resolves(undefined); - const invalidId = await connections.startAttach('external-malformed', 55004, 'external-host', { - attachmentId: 'invalid space', autoActivate: false, - }); - await waitForValue(() => invalidId.destroyed ? true : undefined); - await waitForValue(() => showErrorMessage.calledOnce ? true : undefined); + const showErrorMessage = sandbox + .stub(vscode.window, 'showErrorMessage') + .resolves(undefined); + const invalidId = await connections.startAttach( + 'external-malformed', + 55004, + 'external-host', + { + attachmentId: 'invalid space', + autoActivate: false, + }, + ); + await waitForValue(() => (invalidId.destroyed ? true : undefined)); + await waitForValue(() => (showErrorMessage.calledOnce ? true : undefined)); assert.match(String(showErrorMessage.firstCall.args[0]), /invalid attachment_id metadata/); - const missingId = await connections.startAttach('external-missing-id', 55008, 'external-host', { - autoActivate: false, - }); - await waitForValue(() => missingId.destroyed ? true : undefined); - await waitForValue(() => showErrorMessage.calledTwice ? true : undefined); - assert.match(String(showErrorMessage.secondCall.args[0]), /auto_activate=false requires a valid attachment_id/); + const missingId = await connections.startAttach( + 'external-missing-id', + 55008, + 'external-host', + { + autoActivate: false, + }, + ); + await waitForValue(() => (missingId.destroyed ? true : undefined)); + await waitForValue(() => (showErrorMessage.calledTwice ? true : undefined)); + assert.match( + String(showErrorMessage.secondCall.args[0]), + /auto_activate=false requires a valid attachment_id/, + ); assert.strictEqual(session.getSessionReference('external-malformed'), undefined); assert.strictEqual(session.getSessionReference('external-missing-id'), undefined); }); @@ -499,13 +803,19 @@ suite('Session Connection Lifecycle API', () => { assert.match(foregroundScript, /auto_activate = TRUE/); await connections.attach('prepared-dispose-live', 55022, 'external-host', { - attachmentId: background.attachmentId, autoActivate: false, + attachmentId: background.attachmentId, + autoActivate: false, }); - const reference = await waitForValue(() => session.getSessionReference('prepared-dispose-live')); + const reference = await waitForValue(() => + session.getSessionReference('prepared-dispose-live'), + ); background.dispose(); await waitForFileRemoval(background.scriptPath); - assert.strictEqual(session.getSessionReference('prepared-dispose-live')?.connectionId, reference.connectionId, - 'disposing a preparation only removes its script'); + assert.strictEqual( + session.getSessionReference('prepared-dispose-live')?.connectionId, + reference.connectionId, + 'disposing a preparation only removes its script', + ); } finally { background.dispose(); foreground.dispose(); @@ -514,16 +824,20 @@ suite('Session Connection Lifecycle API', () => { test('shutdown revokes live references and emits disconnect before awaiting teardown', async () => { const events: RSessionConnectionChange[] = []; - const subscription = session.onDidChangeSessionConnection(event => events.push(event)); + const subscription = session.onDidChangeSessionConnection((event) => events.push(event)); let shutdown: Promise | undefined; try { await connections.attach('external-shutdown', 55007, 'external-host', { - attachmentId: 'shutdown', autoActivate: false, + attachmentId: 'shutdown', + autoActivate: false, }); assert.ok(session.getSessionReference('external-shutdown')); shutdown = session.shutdownSessionWatcher(); assert.strictEqual(session.getSessionReference('external-shutdown'), undefined); - assert.deepStrictEqual(events.map(event => event.kind), ['connected', 'disconnected']); + assert.deepStrictEqual( + events.map((event) => event.kind), + ['connected', 'disconnected'], + ); assert.strictEqual(events[1].session.attachmentId, 'shutdown'); } finally { await shutdown; @@ -534,8 +848,12 @@ suite('Session Connection Lifecycle API', () => { test('shutdown listener can redeploy resources and accept a new attachment without old cleanup reclaiming them', async () => { let preparationPromise: Promise | undefined; let redeployed = false; - const subscription = session.onDidChangeSessionConnection(event => { - if (event.kind === 'disconnected' && event.session.sessionId === 'shutdown-redeploy-old' && !redeployed) { + const subscription = session.onDidChangeSessionConnection((event) => { + if ( + event.kind === 'disconnected' && + event.session.sessionId === 'shutdown-redeploy-old' && + !redeployed + ) { redeployed = true; session.deploySessionWatcher(extensionRoot); preparationPromise = session.prepareAttachment({ autoActivate: false }); @@ -545,19 +863,32 @@ suite('Session Connection Lifecycle API', () => { let preparation: RSessionPreparation | undefined; try { await connections.attach('shutdown-redeploy-old', 55023, 'external-host', { - attachmentId: 'old-lifecycle', autoActivate: false, + attachmentId: 'old-lifecycle', + autoActivate: false, }); oldShutdown = session.shutdownSessionWatcher(); await oldShutdown; assert.strictEqual(redeployed, true); preparation = await preparationPromise; assert.ok(preparation); - assert.ok(await pathExists(preparation.scriptPath), 'old shutdown must not remove a new preparation'); - const socket = await connections.attach('shutdown-redeploy-new', 55024, 'external-host', { - attachmentId: preparation.attachmentId, autoActivate: false, - }); + assert.ok( + await pathExists(preparation.scriptPath), + 'old shutdown must not remove a new preparation', + ); + const socket = await connections.attach( + 'shutdown-redeploy-new', + 55024, + 'external-host', + { + attachmentId: preparation.attachmentId, + autoActivate: false, + }, + ); assert.strictEqual(socket.destroyed, false); - assert.strictEqual(session.getSessionReference('shutdown-redeploy-new')?.attachmentId, preparation.attachmentId); + assert.strictEqual( + session.getSessionReference('shutdown-redeploy-new')?.attachmentId, + preparation.attachmentId, + ); } finally { subscription.dispose(); preparation?.dispose(); @@ -570,28 +901,45 @@ suite('Session Connection Lifecycle API', () => { const delayedPid = deferred(); let discoveryStarted = false; const terminal = { - get processId() { discoveryStarted = true; return delayedPid.promise; }, - sendText: sandbox.stub(), show: sandbox.stub(), + get processId() { + discoveryStarted = true; + return delayedPid.promise; + }, + sendText: sandbox.stub(), + show: sandbox.stub(), } as unknown as vscode.Terminal; terminals.value([terminal]); const events: RSessionConnectionChange[] = []; - const subscription = session.onDidChangeSessionConnection(event => events.push(event)); + const subscription = session.onDidChangeSessionConnection((event) => events.push(event)); let oldShutdown: Promise | undefined; try { await connections.startAttach('late-discovery-old', 55025, os.hostname()); - await waitForValue(() => discoveryStarted ? true : undefined); + await waitForValue(() => (discoveryStarted ? true : undefined)); oldShutdown = session.shutdownSessionWatcher(); session.deploySessionWatcher(extensionRoot); const newEndpoint = await session.getGlobalPipePath(); delayedPid.resolve(55025); await connections.attach('late-discovery-new', 55026, 'external-host', { - attachmentId: 'new-server', autoActivate: false, + attachmentId: 'new-server', + autoActivate: false, }); await oldShutdown; assert.ok(newEndpoint); - assert.ok(events.some(event => event.kind === 'connected' && event.session.sessionId === 'late-discovery-new')); - assert.ok(!events.some(event => event.kind === 'connected' && event.session.sessionId === 'late-discovery-old'), - 'the old server must reject a discovery result that completed after redeploy'); + assert.ok( + events.some( + (event) => + event.kind === 'connected' && + event.session.sessionId === 'late-discovery-new', + ), + ); + assert.ok( + !events.some( + (event) => + event.kind === 'connected' && + event.session.sessionId === 'late-discovery-old', + ), + 'the old server must reject a discovery result that completed after redeploy', + ); assert.ok(session.getSessionReference('late-discovery-new')); } finally { delayedPid.resolve(55025); @@ -606,8 +954,12 @@ suite('Session Connection Lifecycle API', () => { session.deploySessionWatcher(extensionRoot); assert.strictEqual(await session.getGlobalPipePath(), originalEndpoint); await connections.attach('same-server-redeploy', 55027, 'external-host', { - attachmentId: 'same-server', autoActivate: false, + attachmentId: 'same-server', + autoActivate: false, }); - assert.strictEqual(session.getSessionReference('same-server-redeploy')?.attachmentId, 'same-server'); + assert.strictEqual( + session.getSessionReference('same-server-redeploy')?.attachmentId, + 'same-server', + ); }); }); diff --git a/src/test/suite/sessionTerminalBinding.test.ts b/src/test/suite/sessionTerminalBinding.test.ts index 57c0472be..ba5399f9f 100644 --- a/src/test/suite/sessionTerminalBinding.test.ts +++ b/src/test/suite/sessionTerminalBinding.test.ts @@ -25,10 +25,14 @@ suite('Session Terminal Binding', () => { let otherSendText: sinon.SinonStub; let show: sinon.SinonStub; const extensionRoot = path.join(__dirname, '..', '..', '..'); - const api: RSessionApi = { getConnectionInfo: session.getConnectionInfo, prepareAttachment: session.prepareAttachment, - onDidChangeSessionConnection: session.onDidChangeSessionConnection, getSessionReference: session.getSessionReference, + const api: RSessionApi = { + getConnectionInfo: session.getConnectionInfo, + prepareAttachment: session.prepareAttachment, + onDidChangeSessionConnection: session.onDidChangeSessionConnection, + getSessionReference: session.getSessionReference, registerTerminalIntegration: session.registerTerminalIntegration, - activate: session.activateSessionById }; + activate: session.activateSessionById, + }; setup(async () => { await session.shutdownSessionWatcher(); @@ -38,14 +42,29 @@ suite('Session Terminal Binding', () => { sandbox.stub(extension, 'enableSessionWatcher').value(true); sandbox.stub(extension, 'rWorkspace').value(undefined); configuration = sandbox.stub(util, 'config').returns({ - get: (key: string) => key === 'source.focus' ? 'none' : undefined, + get: (key: string) => (key === 'source.focus' ? 'none' : undefined), } as unknown as vscode.WorkspaceConfiguration); - first = session.registerSessionTransport('binding-first', 'host', '/first', () => Promise.resolve(first.workspaceData)); - second = session.registerSessionTransport('binding-second', 'host', '/second', () => Promise.resolve(second.workspaceData)); - first.pid = '101'; second.pid = '202'; - sendText = sandbox.stub(); otherSendText = sandbox.stub(); show = sandbox.stub(); - terminal = { processId: Promise.resolve(undefined), sendText, show } as unknown as vscode.Terminal; - other = { processId: Promise.resolve(99999), sendText: otherSendText, show: sandbox.stub() } as unknown as vscode.Terminal; + first = session.registerSessionTransport('binding-first', 'host', '/first', () => + Promise.resolve(first.workspaceData), + ); + second = session.registerSessionTransport('binding-second', 'host', '/second', () => + Promise.resolve(second.workspaceData), + ); + first.pid = '101'; + second.pid = '202'; + sendText = sandbox.stub(); + otherSendText = sandbox.stub(); + show = sandbox.stub(); + terminal = { + processId: Promise.resolve(undefined), + sendText, + show, + } as unknown as vscode.Terminal; + other = { + processId: Promise.resolve(99999), + sendText: otherSendText, + show: sandbox.stub(), + } as unknown as vscode.Terminal; terminals = sandbox.stub(vscode.window, 'terminals').value([terminal, other]); activeTerminal = sandbox.stub(vscode.window, 'activeTerminal').value(other); session.deploySessionWatcher(extensionRoot); @@ -86,17 +105,28 @@ suite('Session Terminal Binding', () => { test(`readiness recognizes an explicit binding (PID: ${pid}, already bound: ${String(bindBeforeWaiting)})`, async () => { terminal = { ...terminal, - processId: pid === 'pending' ? new Promise(() => undefined) - : Promise.resolve(pid === 'native' ? 12345 : undefined), + processId: + pid === 'pending' + ? new Promise(() => undefined) + : Promise.resolve(pid === 'native' ? 12345 : undefined), } as vscode.Terminal; terminals.value([terminal, other]); - if (bindBeforeWaiting) { await api.activate(first.sessionId, { terminal }); } + if (bindBeforeWaiting) { + await api.activate(first.sessionId, { terminal }); + } let ready = false; - const waiting = session.waitForTerminalReady(terminal, 100).then(result => { ready = result; return result; }); + const waiting = session.waitForTerminalReady(terminal, 100).then((result) => { + ready = result; + return result; + }); if (!bindBeforeWaiting) { await Promise.resolve(); await api.activate(second.sessionId, { terminal: other }); - assert.strictEqual(ready, false, 'another terminal binding must not release the wait'); + assert.strictEqual( + ready, + false, + 'another terminal binding must not release the wait', + ); await api.activate(first.sessionId, { terminal }); } assert.strictEqual(await waiting, true); @@ -106,7 +136,10 @@ suite('Session Terminal Binding', () => { } test('terminal selection and manual activation recognize a binding without awaiting a PID', async () => { - terminal = { ...terminal, processId: new Promise(() => undefined) } as vscode.Terminal; + terminal = { + ...terminal, + processId: new Promise(() => undefined), + } as vscode.Terminal; terminals.value([terminal, other]); assert.strictEqual(await api.activate(first.sessionId, { terminal }), true); await api.activate(second.sessionId); @@ -114,7 +147,9 @@ suite('Session Terminal Binding', () => { assert.strictEqual(session.activeSession, first); await api.activate(second.sessionId); activeTerminal.value(terminal); - configuration.returns({ get: (key: string) => key === 'sessionWatcher' } as unknown as vscode.WorkspaceConfiguration); + configuration.returns({ + get: (key: string) => key === 'sessionWatcher', + } as unknown as vscode.WorkspaceConfiguration); await session.activateRSession(); assert.strictEqual(session.activeSession, first); sinon.assert.calledOnce(show); @@ -125,17 +160,27 @@ suite('Session Terminal Binding', () => { test(`pending ${selection} cannot overwrite a newer public activation (rebind: ${String(rebind)})`, async () => { await api.activate(first.sessionId, { terminal }); activeTerminal.value(terminal); - configuration.returns({ get: (key: string) => key === 'sessionWatcher' } as unknown as vscode.WorkspaceConfiguration); - const pending = selection === 'terminal selection' - ? session.switchSessionByTerminal(terminal) : session.activateRSession(); - assert.strictEqual(await api.activate(second.sessionId, rebind ? { terminal } : undefined), true); + configuration.returns({ + get: (key: string) => key === 'sessionWatcher', + } as unknown as vscode.WorkspaceConfiguration); + const pending = + selection === 'terminal selection' + ? session.switchSessionByTerminal(terminal) + : session.activateRSession(); + assert.strictEqual( + await api.activate(second.sessionId, rebind ? { terminal } : undefined), + true, + ); await pending; assert.strictEqual(session.activeSession, second); sinon.assert.notCalled(show); if (rebind) { await session.executeSessionCode(second, 'View(iris)'); sinon.assert.calledOnceWithExactly(sendText, 'View(iris)'); - await assert.rejects(session.executeSessionCode(first, 'rm(iris)'), /no attached terminal/); + await assert.rejects( + session.executeSessionCode(first, 'rm(iris)'), + /no attached terminal/, + ); } }); } @@ -155,14 +200,21 @@ suite('Session Terminal Binding', () => { test(`pending manual activation does not focus a terminal after ${change}`, async () => { await api.activate(first.sessionId, { terminal }); activeTerminal.value(terminal); - configuration.returns({ get: (key: string) => key === 'sessionWatcher' } as unknown as vscode.WorkspaceConfiguration); + configuration.returns({ + get: (key: string) => key === 'sessionWatcher', + } as unknown as vscode.WorkspaceConfiguration); const pending = session.activateRSession(); - if (change === 'close') { rTerminal.deleteTerminal(terminal); } - else { session.unregisterSessionTransport(first); } + if (change === 'close') { + rTerminal.deleteTerminal(terminal); + } else { + session.unregisterSessionTransport(first); + } await pending; sinon.assert.notCalled(show); sinon.assert.notCalled(sendText); - if (change === 'disconnect') { assert.strictEqual(session.activeSession, undefined); } + if (change === 'disconnect') { + assert.strictEqual(session.activeSession, undefined); + } }); } @@ -171,7 +223,11 @@ suite('Session Terminal Binding', () => { await api.activate(first.sessionId, { terminal: other }); await api.activate(second.sessionId); await session.switchSessionByTerminal(terminal); - assert.strictEqual(session.activeSession, second, 'old terminal must not select its former owner'); + assert.strictEqual( + session.activeSession, + second, + 'old terminal must not select its former owner', + ); await session.executeSessionCode(first, 'View(iris)'); sinon.assert.calledOnce(otherSendText); sinon.assert.notCalled(sendText); @@ -195,22 +251,33 @@ suite('Session Terminal Binding', () => { for (const transition of ['close', 'disconnect', 'cleanup', 'replace', 'move'] as const) { test(`${transition} invalidates execution, readiness and terminal selection together`, async () => { await api.activate(first.sessionId, { terminal }); - if (transition === 'close') { rTerminal.deleteTerminal(terminal); } - else if (transition === 'disconnect') { session.unregisterSessionTransport(first); } - else if (transition === 'cleanup') { await session.cleanupSession(first.sessionId); } - else if (transition === 'replace') { session.replaceSessionTransport(first, second); } - else { await api.activate(first.sessionId, { terminal: other }); } + if (transition === 'close') { + rTerminal.deleteTerminal(terminal); + } else if (transition === 'disconnect') { + session.unregisterSessionTransport(first); + } else if (transition === 'cleanup') { + await session.cleanupSession(first.sessionId); + } else if (transition === 'replace') { + session.replaceSessionTransport(first, second); + } else { + await api.activate(first.sessionId, { terminal: other }); + } await api.activate(second.sessionId); await session.switchSessionByTerminal(terminal); assert.strictEqual(session.activeSession, second); assert.strictEqual(await session.waitForTerminalReady(terminal, 1), false); if (transition !== 'move') { - await assert.rejects(session.executeSessionCode(first, 'rm(iris)'), /no (attached terminal|longer attached)/); + await assert.rejects( + session.executeSessionCode(first, 'rm(iris)'), + /no (attached terminal|longer attached)/, + ); } else { await session.executeSessionCode(first, 'View(iris)'); sinon.assert.calledOnce(otherSendText); } - if (transition === 'close') { assert.strictEqual(await api.activate(first.sessionId, { terminal }), false); } + if (transition === 'close') { + assert.strictEqual(await api.activate(first.sessionId, { terminal }), false); + } sinon.assert.notCalled(sendText); }); } @@ -219,9 +286,13 @@ suite('Session Terminal Binding', () => { test(`queued input rejects a ${change} before sending to the terminal`, async () => { await api.activate(first.sessionId, { terminal }); const sending = session.executeSessionCode(first, 'rm(iris)'); - if (change === 'close') { rTerminal.deleteTerminal(terminal); } - else if (change === 'rebind') { await api.activate(second.sessionId, { terminal }); } - else { session.unregisterSessionTransport(first); } + if (change === 'close') { + rTerminal.deleteTerminal(terminal); + } else if (change === 'rebind') { + await api.activate(second.sessionId, { terminal }); + } else { + session.unregisterSessionTransport(first); + } await assert.rejects(sending, /no longer (owns|attached)/); sinon.assert.notCalled(sendText); }); @@ -230,7 +301,10 @@ suite('Session Terminal Binding', () => { test('a terminal closed between lines receives no remaining input', async () => { await api.activate(first.sessionId, { terminal }); sendText.callsFake(() => rTerminal.deleteTerminal(terminal)); - await assert.rejects(session.executeSessionCode(first, 'View(iris)\nrm(iris)'), /no longer owns/); + await assert.rejects( + session.executeSessionCode(first, 'View(iris)\nrm(iris)'), + /no longer owns/, + ); sinon.assert.calledOnceWithExactly(sendText, 'View(iris)'); }); test('queued input cannot reuse ownership after rebinding away and back', async () => { @@ -252,7 +326,9 @@ suite('Session Terminal Binding', () => { other = { ...other, show: otherShow } as vscode.Terminal; terminals.value([terminal, other]); activeTerminal.value(terminal); - configuration.returns({ get: (key: string) => key === 'sessionWatcher' } as unknown as vscode.WorkspaceConfiguration); + configuration.returns({ + get: (key: string) => key === 'sessionWatcher', + } as unknown as vscode.WorkspaceConfiguration); sandbox.stub(fileSystem, 'pathExists').resolves(false); const create = sandbox.stub(rTerminal, 'createRTerm').resolves(false); await api.activate(first.sessionId, { terminal: other }); @@ -265,18 +341,28 @@ suite('Session Terminal Binding', () => { for (const change of ['activation', 'binding', 'close'] as const) { test(`manual attach is cancelled by ${change} while creating its command`, async () => { await session.getGlobalPipePath(); - const discovery = path.join(extension.extensionContext.globalStorageUri.fsPath, 'sessions', `${'a'.repeat(32)}.json`); + const discovery = path.join( + extension.extensionContext.globalStorageUri.fsPath, + 'sessions', + `${'a'.repeat(32)}.json`, + ); terminal = { - ...terminal, processId: Promise.resolve(12345), + ...terminal, + processId: Promise.resolve(12345), creationOptions: { env: { SESS_DISCOVERY_FILE: discovery } }, } as vscode.Terminal; terminals.value([terminal, other]); activeTerminal.value(terminal); - configuration.returns({ get: (key: string) => key === 'sessionWatcher' } as unknown as vscode.WorkspaceConfiguration); + configuration.returns({ + get: (key: string) => key === 'sessionWatcher', + } as unknown as vscode.WorkspaceConfiguration); const started = deferred(); const proceed = deferred(); const mkdir = fsp.mkdir; - const mkdirStub = sandbox.stub(fsp, 'mkdir') as unknown as sinon.SinonStub<[string, { recursive: true }], Promise>; + const mkdirStub = sandbox.stub(fsp, 'mkdir') as unknown as sinon.SinonStub< + [string, { recursive: true }], + Promise + >; mkdirStub.callsFake(async (directory, options) => { started.resolve(); await proceed.promise; @@ -284,13 +370,21 @@ suite('Session Terminal Binding', () => { }); const pending = session.activateRSession(); await started.promise; - if (change === 'close') { rTerminal.deleteTerminal(terminal); } - else { await api.activate(second.sessionId, change === 'binding' ? { terminal } : undefined); } + if (change === 'close') { + rTerminal.deleteTerminal(terminal); + } else { + await api.activate( + second.sessionId, + change === 'binding' ? { terminal } : undefined, + ); + } proceed.resolve(); await pending; sinon.assert.notCalled(sendText); sinon.assert.notCalled(show); - if (change !== 'close') { assert.strictEqual(session.activeSession, second); } + if (change !== 'close') { + assert.strictEqual(session.activeSession, second); + } }); } @@ -307,7 +401,8 @@ suite('Session Terminal Binding', () => { assert.strictEqual(await session.waitForTerminalReady(terminal, 100), true); await session.executeSessionCode(first, 'View(iris)'); sinon.assert.calledOnceWithExactly(sendText, 'View(iris)'); - } finally { await connections.dispose(); } + } finally { + await connections.dispose(); + } }); - }); diff --git a/src/test/suite/sessionTerminalIntegration.test.ts b/src/test/suite/sessionTerminalIntegration.test.ts index 20df29bb7..38e2b5f51 100644 --- a/src/test/suite/sessionTerminalIntegration.test.ts +++ b/src/test/suite/sessionTerminalIntegration.test.ts @@ -22,15 +22,21 @@ suite('External Terminal Integration', () => { let closeEvents: vscode.EventEmitter; let activeEvents: vscode.EventEmitter; let activeSubscriptionDisposals: sinon.SinonSpy[]; - const terminalSpies = new WeakMap(); + const terminalSpies = new WeakMap< + vscode.Terminal, + { sendText: sinon.SinonStub; show: sinon.SinonStub } + >(); const extensionRoot = path.join(__dirname, '..', '..', '..'); function makeTerminal(processId: Promise = Promise.resolve(undefined)) { const sendText = sandbox.stub(); const show = sandbox.stub(); const terminal = { - name: 'External R Console', processId, - sendText, show, dispose: sandbox.stub(), + name: 'External R Console', + processId, + sendText, + show, + dispose: sandbox.stub(), } as unknown as vscode.Terminal; terminalSpies.set(terminal, { sendText, show }); return terminal; @@ -55,18 +61,27 @@ suite('External Terminal Integration', () => { activeEvents = new vscode.EventEmitter(); activeSubscriptionDisposals = []; sandbox.stub(vscode.window, 'onDidCloseTerminal').callsFake(closeEvents.event); - sandbox.stub(vscode.window, 'onDidChangeActiveTerminal').callsFake(listener => { + sandbox.stub(vscode.window, 'onDidChangeActiveTerminal').callsFake((listener) => { const subscription = activeEvents.event(listener); const disposed = sandbox.spy(); activeSubscriptionDisposals.push(disposed); - return { dispose: () => { disposed(); subscription.dispose(); } }; + return { + dispose: () => { + disposed(); + subscription.dispose(); + }, + }; }); showError = sandbox.stub(vscode.window, 'showErrorMessage').resolves(undefined); showInformation = sandbox.stub(vscode.window, 'showInformationMessage').resolves(undefined); sandbox.stub(util, 'config').returns({ - get: (key: string) => ({ - sessionWatcher: true, alwaysUseActiveTerminal: false, bracketedPaste: false, 'source.focus': 'none', - })[key], + get: (key: string) => + ({ + sessionWatcher: true, + alwaysUseActiveTerminal: false, + bracketedPaste: false, + 'source.focus': 'none', + })[key], } as unknown as vscode.WorkspaceConfiguration); session.deploySessionWatcher(extensionRoot); await session.getGlobalPipePath(); @@ -85,23 +100,39 @@ suite('External Terminal Integration', () => { setTerminals([terminal]); let hookCalls = 0; const registration = session.registerTerminalIntegration(terminal, { - attach: async token => { + attach: async (token) => { hookCalls++; assert.strictEqual(token.isCancellationRequested, false); - const socket = await connections.attach('integration-real-connection', 55101, 'external-host', { - attachmentId: 'integration-test', autoActivate: false, - }); - const reference = await waitForValue(() => session.getSessionReference('integration-real-connection')); - return !socket.destroyed && await session.activateSessionById(reference.sessionId, { - connectionId: reference.connectionId, terminal, select: false, - }); + const socket = await connections.attach( + 'integration-real-connection', + 55101, + 'external-host', + { + attachmentId: 'integration-test', + autoActivate: false, + }, + ); + const reference = await waitForValue(() => + session.getSessionReference('integration-real-connection'), + ); + return ( + !socket.destroyed && + (await session.activateSessionById(reference.sessionId, { + connectionId: reference.connectionId, + terminal, + select: false, + })) + ); }, }); try { await session.activateRSession(); assert.strictEqual(hookCalls, 1); assert.strictEqual(session.activeSession?.sessionId, 'integration-real-connection'); - assert.strictEqual(session.getSessionReference('integration-real-connection')?.attachmentId, 'integration-test'); + assert.strictEqual( + session.getSessionReference('integration-real-connection')?.attachmentId, + 'integration-test', + ); await session.executeSessionCode(session.activeSession!, '1 + 1'); sinon.assert.calledOnceWithExactly(terminalSpies.get(terminal)!.sendText, '1 + 1'); sinon.assert.calledOnce(terminalSpies.get(terminal)!.show); @@ -114,7 +145,12 @@ suite('External Terminal Integration', () => { test('live owner is focused before the integration hook', async () => { const terminal = makeTerminal(); setTerminals([terminal]); - const owner = session.registerSessionTransport('integration-existing-owner', 'host', '/tmp', () => Promise.resolve({})); + const owner = session.registerSessionTransport( + 'integration-existing-owner', + 'host', + '/tmp', + () => Promise.resolve({}), + ); await session.activateSessionById(owner.sessionId, { terminal, select: false }); const attach = sandbox.stub().resolves(false); const registration = session.registerTerminalIntegration(terminal, { attach }); @@ -133,9 +169,15 @@ suite('External Terminal Integration', () => { const external = makeTerminal(new Promise(() => undefined)); const native = makeTerminal(Promise.resolve(55108)); setTerminals([external, native], native); - const registration = session.registerTerminalIntegration(external, { attach: () => Promise.resolve(false) }); + const registration = session.registerTerminalIntegration(external, { + attach: () => Promise.resolve(false), + }); try { - const socket = await connections.attach('native-scan-with-integration', 55108, os.hostname()); + const socket = await connections.attach( + 'native-scan-with-integration', + 55108, + os.hostname(), + ); assert.strictEqual(socket._terminalPid, 55108); assert.strictEqual(session.activeSession?.sessionId, 'native-scan-with-integration'); } finally { @@ -146,20 +188,29 @@ suite('External Terminal Integration', () => { test('registering a scanned terminal releases discovery and lets the scan continue', async () => { let pidRead!: () => void; - const readStarted = new Promise(resolve => { pidRead = resolve; }); + const readStarted = new Promise((resolve) => { + pidRead = resolve; + }); const externalSendText = sandbox.stub(); const externalShow = sandbox.stub(); const external = { name: 'External R Console', - get processId() { pidRead(); return new Promise(() => undefined); }, - sendText: externalSendText, show: externalShow, dispose: sandbox.stub(), + get processId() { + pidRead(); + return new Promise(() => undefined); + }, + sendText: externalSendText, + show: externalShow, + dispose: sandbox.stub(), } as unknown as vscode.Terminal; terminalSpies.set(external, { sendText: externalSendText, show: externalShow }); const native = makeTerminal(Promise.resolve(55109)); setTerminals([external, native], native); const socketPromise = connections.attach('native-scan-register-race', 55109, os.hostname()); await readStarted; - const registration = session.registerTerminalIntegration(external, { attach: () => Promise.resolve(false) }); + const registration = session.registerTerminalIntegration(external, { + attach: () => Promise.resolve(false), + }); try { const socket = await socketPromise; assert.strictEqual(socket._terminalPid, 55109); @@ -172,26 +223,40 @@ suite('External Terminal Integration', () => { test('discovery refresh skips a newly registered terminal and continues with live native terminals', async () => { let pidRead!: () => void; - const readStarted = new Promise(resolve => { pidRead = resolve; }); + const readStarted = new Promise((resolve) => { + pidRead = resolve; + }); const external = { name: 'External R Console', - get processId() { pidRead(); return new Promise(() => undefined); }, - sendText: sandbox.stub(), show: sandbox.stub(), dispose: sandbox.stub(), + get processId() { + pidRead(); + return new Promise(() => undefined); + }, + sendText: sandbox.stub(), + show: sandbox.stub(), + dispose: sandbox.stub(), } as unknown as vscode.Terminal; const discoveryFile = await session.createSessionDiscoveryFile('old-sess'); await session.updateSessionDiscoveryFile(discoveryFile, 'old-sess', 55112); const native = { - name: 'Native R', processId: Promise.resolve(55112), + name: 'Native R', + processId: Promise.resolve(55112), creationOptions: { name: 'R Interactive', env: { SESS_DISCOVERY_FILE: discoveryFile } }, - sendText: sandbox.stub(), show: sandbox.stub(), + sendText: sandbox.stub(), + show: sandbox.stub(), } as unknown as vscode.Terminal; setTerminals([external, native], native); const refresh = session.refreshTerminalDiscoveryFiles('new-sess', [external, native]); await readStarted; - const registration = session.registerTerminalIntegration(external, { attach: () => Promise.resolve(false) }); + const registration = session.registerTerminalIntegration(external, { + attach: () => Promise.resolve(false), + }); try { await refresh; - assert.strictEqual((await readJson(discoveryFile) as { endpoint?: string }).endpoint, 'new-sess'); + assert.strictEqual( + ((await readJson(discoveryFile)) as { endpoint?: string }).endpoint, + 'new-sess', + ); } finally { registration.dispose(); await fsp.rm(discoveryFile, { force: true }); @@ -200,22 +265,37 @@ suite('External Terminal Integration', () => { test('terminal discovery update cannot publish after its PID lookup is invalidated', async () => { let pidRead!: () => void; - const readStarted = new Promise(resolve => { pidRead = resolve; }); + const readStarted = new Promise((resolve) => { + pidRead = resolve; + }); const discoveryFile = await session.createSessionDiscoveryFile('old-sess'); await session.updateSessionDiscoveryFile(discoveryFile, 'old-sess', 55113); const external = { name: 'External R Console', - get processId() { pidRead(); return new Promise(() => undefined); }, - creationOptions: { name: 'External R Console', env: { SESS_DISCOVERY_FILE: discoveryFile } }, - sendText: sandbox.stub(), show: sandbox.stub(), dispose: sandbox.stub(), + get processId() { + pidRead(); + return new Promise(() => undefined); + }, + creationOptions: { + name: 'External R Console', + env: { SESS_DISCOVERY_FILE: discoveryFile }, + }, + sendText: sandbox.stub(), + show: sandbox.stub(), + dispose: sandbox.stub(), } as unknown as vscode.Terminal; setTerminals([external]); const update = session.updateTerminalDiscovery(external); await readStarted; - const registration = session.registerTerminalIntegration(external, { attach: () => Promise.resolve(false) }); + const registration = session.registerTerminalIntegration(external, { + attach: () => Promise.resolve(false), + }); try { await update; - assert.strictEqual((await readJson(discoveryFile) as { endpoint?: string }).endpoint, 'old-sess'); + assert.strictEqual( + ((await readJson(discoveryFile)) as { endpoint?: string }).endpoint, + 'old-sess', + ); } finally { registration.dispose(); await fsp.rm(discoveryFile, { force: true }); @@ -225,32 +305,60 @@ suite('External Terminal Integration', () => { test('a superseded socket is closed after discovery while the new same-ID connection commits', async () => { let resolveFirstPid!: (pid: number | undefined) => void; let pidRead!: () => void; - const firstRead = new Promise(resolve => { pidRead = resolve; }); + const firstRead = new Promise((resolve) => { + pidRead = resolve; + }); let reads = 0; const sendText = sandbox.stub(); const show = sandbox.stub(); const terminal = { name: 'Native R', get processId() { - if (reads++ === 0) { pidRead(); return new Promise(resolve => { resolveFirstPid = resolve; }); } + if (reads++ === 0) { + pidRead(); + return new Promise((resolve) => { + resolveFirstPid = resolve; + }); + } return Promise.resolve(55111); }, - sendText, show, + sendText, + show, } as unknown as vscode.Terminal; terminalSpies.set(terminal, { sendText, show }); setTerminals([terminal]); - const oldSocket = await connections.startAttach('same-id-discovery-replacement', 55111, os.hostname()); + const oldSocket = await connections.startAttach( + 'same-id-discovery-replacement', + 55111, + os.hostname(), + ); await firstRead; - const currentSocket = await connections.startAttach('same-id-discovery-replacement', 55111, os.hostname()); + const currentSocket = await connections.startAttach( + 'same-id-discovery-replacement', + 55111, + os.hostname(), + ); try { - const reference = await waitForValue(() => session.getSessionReference('same-id-discovery-replacement')); - await waitForValue(() => oldSocket.destroyed ? true : undefined); + const reference = await waitForValue(() => + session.getSessionReference('same-id-discovery-replacement'), + ); + await waitForValue(() => (oldSocket.destroyed ? true : undefined)); assert.strictEqual(currentSocket.destroyed, false); - assert.strictEqual(session.getSessionReference('same-id-discovery-replacement')?.connectionId, reference.connectionId); + assert.strictEqual( + session.getSessionReference('same-id-discovery-replacement')?.connectionId, + reference.connectionId, + ); resolveFirstPid(55111); - await new Promise(resolve => setImmediate(resolve)); - assert.strictEqual(currentSocket.destroyed, false, 'late old discovery must not close the committed socket'); - assert.strictEqual(session.getSessionReference('same-id-discovery-replacement')?.connectionId, reference.connectionId); + await new Promise((resolve) => setImmediate(resolve)); + assert.strictEqual( + currentSocket.destroyed, + false, + 'late old discovery must not close the committed socket', + ); + assert.strictEqual( + session.getSessionReference('same-id-discovery-replacement')?.connectionId, + reference.connectionId, + ); } finally { resolveFirstPid(55111); await session.cleanupSession('same-id-discovery-replacement'); @@ -260,27 +368,37 @@ suite('External Terminal Integration', () => { test('registering the active terminal during native PID discovery cancels the native path', async () => { let resolvePid!: (pid: number | undefined) => void; let pidRead!: () => void; - const readStarted = new Promise(resolve => { pidRead = resolve; }); + const readStarted = new Promise((resolve) => { + pidRead = resolve; + }); const sendText = sandbox.stub(); const show = sandbox.stub(); const terminal = { name: 'Native R', - get processId() { pidRead(); return new Promise(resolve => { resolvePid = resolve; }); }, - sendText, show, + get processId() { + pidRead(); + return new Promise((resolve) => { + resolvePid = resolve; + }); + }, + sendText, + show, } as unknown as vscode.Terminal; terminalSpies.set(terminal, { sendText, show }); setTerminals([terminal]); const createRTerm = sandbox.stub(rTerminal, 'createRTerm').resolves(true); const nativeOperation = session.activateRSession(); await readStarted; - const registration = session.registerTerminalIntegration(terminal, { attach: () => Promise.resolve(false) }); + const registration = session.registerTerminalIntegration(terminal, { + attach: () => Promise.resolve(false), + }); try { // Registration invalidates the pending PID lookup synchronously; the // command must finish even though VS Code never resolves processId. await nativeOperation; await session.activateRSession(); resolvePid(undefined); - await new Promise(resolve => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); sinon.assert.notCalled(terminalSpies.get(terminal)!.sendText); sinon.assert.notCalled(createRTerm); } finally { @@ -292,25 +410,35 @@ suite('External Terminal Integration', () => { test('disposing an integration cannot revive an older native PID wait', async () => { let resolvePid!: (pid: number | undefined) => void; let pidRead!: () => void; - const readStarted = new Promise(resolve => { pidRead = resolve; }); + const readStarted = new Promise((resolve) => { + pidRead = resolve; + }); const sendText = sandbox.stub(); const show = sandbox.stub(); const terminal = { name: 'Native R', - get processId() { pidRead(); return new Promise(resolve => { resolvePid = resolve; }); }, - sendText, show, + get processId() { + pidRead(); + return new Promise((resolve) => { + resolvePid = resolve; + }); + }, + sendText, + show, } as unknown as vscode.Terminal; terminalSpies.set(terminal, { sendText, show }); setTerminals([terminal]); const createRTerm = sandbox.stub(rTerminal, 'createRTerm').resolves(true); const operation = session.activateRSession(); await readStarted; - const registration = session.registerTerminalIntegration(terminal, { attach: () => Promise.resolve(false) }); + const registration = session.registerTerminalIntegration(terminal, { + attach: () => Promise.resolve(false), + }); registration.dispose(); try { await operation; resolvePid(55110); - await new Promise(resolve => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); sinon.assert.notCalled(sendText); sinon.assert.notCalled(createRTerm); } finally { @@ -322,13 +450,21 @@ suite('External Terminal Integration', () => { for (const invalidation of ['terminal', 'lifecycle'] as const) { let rejectPid!: (error: Error) => void; let pidRead!: () => void; - const readStarted = new Promise(resolve => { pidRead = resolve; }); + const readStarted = new Promise((resolve) => { + pidRead = resolve; + }); const sendText = sandbox.stub(); const show = sandbox.stub(); const terminal = { name: 'Native R', - get processId() { pidRead(); return new Promise((_resolve, reject) => { rejectPid = reject; }); }, - sendText, show, + get processId() { + pidRead(); + return new Promise((_resolve, reject) => { + rejectPid = reject; + }); + }, + sendText, + show, } as unknown as vscode.Terminal; terminalSpies.set(terminal, { sendText, show }); setTerminals([terminal]); @@ -341,22 +477,32 @@ suite('External Terminal Integration', () => { } await operation; rejectPid(new Error('late processId rejection')); - await new Promise(resolve => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); sinon.assert.notCalled(sendText); - if (invalidation === 'lifecycle') { break; } + if (invalidation === 'lifecycle') { + break; + } } }); test('registering a background terminal does not invalidate an unrelated native operation', async () => { let resolvePid!: (pid: number | undefined) => void; let pidRead!: () => void; - const readStarted = new Promise(resolve => { pidRead = resolve; }); + const readStarted = new Promise((resolve) => { + pidRead = resolve; + }); const sendText = sandbox.stub(); const show = sandbox.stub(); const active = { name: 'Native R', - get processId() { pidRead(); return new Promise(resolve => { resolvePid = resolve; }); }, - sendText, show, + get processId() { + pidRead(); + return new Promise((resolve) => { + resolvePid = resolve; + }); + }, + sendText, + show, } as unknown as vscode.Terminal; terminalSpies.set(active, { sendText, show }); const background = makeTerminal(); @@ -364,7 +510,9 @@ suite('External Terminal Integration', () => { const createRTerm = sandbox.stub(rTerminal, 'createRTerm').resolves(true); const nativeOperation = session.activateRSession(); await readStarted; - const registration = session.registerTerminalIntegration(background, { attach: () => Promise.resolve(false) }); + const registration = session.registerTerminalIntegration(background, { + attach: () => Promise.resolve(false), + }); try { resolvePid(undefined); await nativeOperation; @@ -379,61 +527,99 @@ suite('External Terminal Integration', () => { const terminal = makeTerminal(); setTerminals([terminal]); const createRTerm = sandbox.stub(rTerminal, 'createRTerm').resolves(true); - const decline = session.registerTerminalIntegration(terminal, { attach: () => Promise.resolve(false) }); + const decline = session.registerTerminalIntegration(terminal, { + attach: () => Promise.resolve(false), + }); try { await session.activateRSession(); sinon.assert.notCalled(terminalSpies.get(terminal)!.sendText); sinon.assert.notCalled(createRTerm); - } finally { decline.dispose(); } + } finally { + decline.dispose(); + } const failure = new Error('proxy setup failed'); - const rejecting = session.registerTerminalIntegration(terminal, { attach: () => Promise.reject(failure) }); + const rejecting = session.registerTerminalIntegration(terminal, { + attach: () => Promise.reject(failure), + }); try { await session.activateRSession(); - sinon.assert.calledOnceWithExactly(showError, - 'Failed to attach the R session in this external terminal: proxy setup failed'); + sinon.assert.calledOnceWithExactly( + showError, + 'Failed to attach the R session in this external terminal: proxy setup failed', + ); sinon.assert.notCalled(terminalSpies.get(terminal)!.sendText); sinon.assert.notCalled(createRTerm); - } finally { rejecting.dispose(); } + } finally { + rejecting.dispose(); + } }); test('true without a current terminal binding is diagnosed and does not fall back', async () => { const terminal = makeTerminal(); setTerminals([terminal]); const createRTerm = sandbox.stub(rTerminal, 'createRTerm').resolves(true); - const registration = session.registerTerminalIntegration(terminal, { attach: () => Promise.resolve(true) }); + const registration = session.registerTerminalIntegration(terminal, { + attach: () => Promise.resolve(true), + }); try { await session.activateRSession(); - sinon.assert.calledOnceWithExactly(showError, - 'The external R terminal reported a successful attach but has no current session binding.'); + sinon.assert.calledOnceWithExactly( + showError, + 'The external R terminal reported a successful attach but has no current session binding.', + ); sinon.assert.notCalled(terminalSpies.get(terminal)!.sendText); sinon.assert.notCalled(createRTerm); - } finally { registration.dispose(); } + } finally { + registration.dispose(); + } }); test('busy clicks do not duplicate or invalidate the in-flight hook', async () => { const terminal = makeTerminal(); setTerminals([terminal]); - const owner = session.registerSessionTransport('integration-busy-owner', 'host', '/tmp', () => Promise.resolve({})); + const owner = session.registerSessionTransport( + 'integration-busy-owner', + 'host', + '/tmp', + () => Promise.resolve({}), + ); const createRTerm = sandbox.stub(rTerminal, 'createRTerm').resolves(true); const gate = deferred(); const started = deferred(); - const attach = sandbox.stub().callsFake(() => { started.resolve(); return gate.promise; }); + const attach = sandbox.stub().callsFake(() => { + started.resolve(); + return gate.promise; + }); const registration = session.registerTerminalIntegration(terminal, { attach }); try { const first = session.activateRSession(); await started.promise; - assert.strictEqual(await session.activateSessionById(owner.sessionId, { terminal, select: false }), true); + assert.strictEqual( + await session.activateSessionById(owner.sessionId, { terminal, select: false }), + true, + ); await session.activateRSession(); sinon.assert.calledOnce(attach); - sinon.assert.calledOnceWithExactly(showInformation, 'An R session attach is already in progress for this terminal.'); - assert.strictEqual(session.activeSession, undefined, 'busy click must not focus a bound owner'); + sinon.assert.calledOnceWithExactly( + showInformation, + 'An R session attach is already in progress for this terminal.', + ); + assert.strictEqual( + session.activeSession, + undefined, + 'busy click must not focus a bound owner', + ); sinon.assert.notCalled(terminalSpies.get(terminal)!.show); sinon.assert.notCalled(terminalSpies.get(terminal)!.sendText); sinon.assert.notCalled(createRTerm); gate.resolve(true); await first; - assert.strictEqual(session.activeSession, owner, 'the successful hook should still select its live bound owner'); + assert.strictEqual( + session.activeSession, + owner, + 'the successful hook should still select its live bound owner', + ); sinon.assert.calledOnce(terminalSpies.get(terminal)!.show); } finally { gate.resolve(true); @@ -448,7 +634,12 @@ suite('External Terminal Integration', () => { const terminal = makeTerminal(); const other = makeTerminal(); setTerminals(outcome === 'cancelled' ? [terminal, other] : [terminal]); - const owner = session.registerSessionTransport(`integration-busy-${outcome}`, 'host', '/tmp', () => Promise.resolve({})); + const owner = session.registerSessionTransport( + `integration-busy-${outcome}`, + 'host', + '/tmp', + () => Promise.resolve({}), + ); const gate = deferred(); const started = deferred(); const attach = sandbox.stub().callsFake((token: vscode.CancellationToken) => { @@ -459,7 +650,10 @@ suite('External Terminal Integration', () => { try { const first = session.activateRSession(); const token = await started.promise; - assert.strictEqual(await session.activateSessionById(owner.sessionId, { terminal, select: false }), true); + assert.strictEqual( + await session.activateSessionById(owner.sessionId, { terminal, select: false }), + true, + ); if (outcome === 'cancelled') { activeTerminal.value(other); activeEvents.fire(other); @@ -470,16 +664,26 @@ suite('External Terminal Integration', () => { await session.activateRSession(); sinon.assert.calledOnce(attach); - sinon.assert.calledOnceWithExactly(showInformation, - 'An R session attach is already in progress for this terminal.'); - assert.strictEqual(session.activeSession, undefined, `${outcome} busy click must not focus the bound owner`); + sinon.assert.calledOnceWithExactly( + showInformation, + 'An R session attach is already in progress for this terminal.', + ); + assert.strictEqual( + session.activeSession, + undefined, + `${outcome} busy click must not focus the bound owner`, + ); sinon.assert.notCalled(terminalSpies.get(terminal)!.show); sinon.assert.notCalled(terminalSpies.get(terminal)!.sendText); sinon.assert.notCalled(createRTerm); gate.resolve(outcome === 'cancelled'); await first; - assert.strictEqual(session.activeSession, undefined, `${outcome} hook must not activate its owner`); + assert.strictEqual( + session.activeSession, + undefined, + `${outcome} hook must not activate its owner`, + ); sinon.assert.notCalled(terminalSpies.get(terminal)!.show); } finally { gate.resolve(false); @@ -498,7 +702,10 @@ suite('External Terminal Integration', () => { const gate = deferred(); const started = deferred(); const registration = session.registerTerminalIntegration(terminal, { - attach: token => { started.resolve(token); return gate.promise; }, + attach: (token) => { + started.resolve(token); + return gate.promise; + }, }); try { const operation = session.activateRSession(); @@ -525,34 +732,57 @@ suite('External Terminal Integration', () => { const gate = deferred(); const started = deferred(); const registration = session.registerTerminalIntegration(terminal, { - attach: token => { started.resolve(token); return gate.promise; }, + attach: (token) => { + started.resolve(token); + return gate.promise; + }, }); const operation = session.activateRSession(); const token = await started.promise; const focusDispose = activeSubscriptionDisposals.at(-1)!; - if (end === 'dispose') { registration.dispose(); } - else if (end === 'close') { closeEvents.fire(terminal); } - else { await session.shutdownSessionWatcher(); } - assert.strictEqual(token.isCancellationRequested, true, `${end} should cancel the hook`); + if (end === 'dispose') { + registration.dispose(); + } else if (end === 'close') { + closeEvents.fire(terminal); + } else { + await session.shutdownSessionWatcher(); + } + assert.strictEqual( + token.isCancellationRequested, + true, + `${end} should cancel the hook`, + ); sinon.assert.calledOnce(focusDispose); gate.resolve(true); await operation; sinon.assert.notCalled(terminalSpies.get(terminal)!.sendText); if (end === 'dispose') { - const replacement = session.registerTerminalIntegration(terminal, { attach: () => Promise.resolve(false) }); + const replacement = session.registerTerminalIntegration(terminal, { + attach: () => Promise.resolve(false), + }); replacement.dispose(); } else { - assert.throws(() => session.registerTerminalIntegration(terminal, { attach: () => Promise.resolve(true) }), /closed|shutting down/); + assert.throws( + () => + session.registerTerminalIntegration(terminal, { + attach: () => Promise.resolve(true), + }), + /closed|shutting down/, + ); } if (end === 'close') { session.cleanupTerminalBinding(terminal); // A new lifecycle can register only after a fresh terminal object appears. const freshTerminal = makeTerminal(); setTerminals([freshTerminal]); - const fresh = session.registerTerminalIntegration(freshTerminal, { attach: () => Promise.resolve(false) }); + const fresh = session.registerTerminalIntegration(freshTerminal, { + attach: () => Promise.resolve(false), + }); fresh.dispose(); } - if (end === 'shutdown') { break; } + if (end === 'shutdown') { + break; + } } }); @@ -565,10 +795,12 @@ suite('External Terminal Integration', () => { let replacementRegistration: vscode.Disposable | undefined; const replacementAttach = sandbox.stub().resolves(false); const oldRegistration = session.registerTerminalIntegration(terminal, { - attach: token => { + attach: (token) => { token.onCancellationRequested(() => { session.deploySessionWatcher(extensionRoot); - replacementRegistration = session.registerTerminalIntegration(freshTerminal, { attach: replacementAttach }); + replacementRegistration = session.registerTerminalIntegration(freshTerminal, { + attach: replacementAttach, + }); }); started.resolve(); return gate.promise; @@ -578,7 +810,10 @@ suite('External Terminal Integration', () => { await started.promise; try { await session.shutdownSessionWatcher(); - assert.ok(replacementRegistration, 'cancellation reentrancy should register against the redeployed lifecycle'); + assert.ok( + replacementRegistration, + 'cancellation reentrancy should register against the redeployed lifecycle', + ); assert.strictEqual(session.isTerminalClosed(freshTerminal), false); gate.resolve(false); await operation; @@ -586,7 +821,11 @@ suite('External Terminal Integration', () => { activeEvents.fire(freshTerminal); await session.activateRSession(); sinon.assert.calledOnce(replacementAttach); - assert.strictEqual(session.activeSession, undefined, 'the new registration should remain live and may decline attach'); + assert.strictEqual( + session.activeSession, + undefined, + 'the new registration should remain live and may decline attach', + ); } finally { gate.resolve(false); oldRegistration.dispose(); @@ -598,8 +837,16 @@ suite('External Terminal Integration', () => { test('duplicate registrations reject and unregistered terminals retain the native fallback path', async () => { const terminal = makeTerminal(); setTerminals([terminal]); - const registration = session.registerTerminalIntegration(terminal, { attach: () => Promise.resolve(false) }); - assert.throws(() => session.registerTerminalIntegration(terminal, { attach: () => Promise.resolve(false) }), /already registered/); + const registration = session.registerTerminalIntegration(terminal, { + attach: () => Promise.resolve(false), + }); + assert.throws( + () => + session.registerTerminalIntegration(terminal, { + attach: () => Promise.resolve(false), + }), + /already registered/, + ); registration.dispose(); const createRTerm = sandbox.stub(rTerminal, 'createRTerm').resolves(true); await session.activateRSession(); @@ -610,16 +857,22 @@ suite('External Terminal Integration', () => { const terminal = makeTerminal(); setTerminals([terminal]); const oldAttach = sandbox.stub().resolves(false); - const oldRegistration = session.registerTerminalIntegration(terminal, { attach: oldAttach }); + const oldRegistration = session.registerTerminalIntegration(terminal, { + attach: oldAttach, + }); oldRegistration.dispose(); const newAttach = sandbox.stub().resolves(false); - const newRegistration = session.registerTerminalIntegration(terminal, { attach: newAttach }); + const newRegistration = session.registerTerminalIntegration(terminal, { + attach: newAttach, + }); try { oldRegistration.dispose(); await session.activateRSession(); sinon.assert.notCalled(oldAttach); sinon.assert.calledOnce(newAttach); - } finally { newRegistration.dispose(); } + } finally { + newRegistration.dispose(); + } }); test('an old pending handler completion cannot clear a replacement handler run', async () => { @@ -628,7 +881,10 @@ suite('External Terminal Integration', () => { const oldGate = deferred(); const oldStarted = deferred(); const oldRegistration = session.registerTerminalIntegration(terminal, { - attach: () => { oldStarted.resolve(); return oldGate.promise; }, + attach: () => { + oldStarted.resolve(); + return oldGate.promise; + }, }); const oldOperation = session.activateRSession(); await oldStarted.promise; @@ -636,8 +892,13 @@ suite('External Terminal Integration', () => { const newGate = deferred(); const newStarted = deferred(); - const newAttach = sandbox.stub().callsFake(() => { newStarted.resolve(); return newGate.promise; }); - const newRegistration = session.registerTerminalIntegration(terminal, { attach: newAttach }); + const newAttach = sandbox.stub().callsFake(() => { + newStarted.resolve(); + return newGate.promise; + }); + const newRegistration = session.registerTerminalIntegration(terminal, { + attach: newAttach, + }); try { const newOperation = session.activateRSession(); await newStarted.promise; @@ -657,17 +918,32 @@ suite('External Terminal Integration', () => { test('another session selection during the hook is preserved while terminal binding remains usable', async () => { const terminal = makeTerminal(); setTerminals([terminal]); - const other = session.registerSessionTransport('integration-other-selection', 'host', '/tmp', () => Promise.resolve({})); + const other = session.registerSessionTransport( + 'integration-other-selection', + 'host', + '/tmp', + () => Promise.resolve({}), + ); const gate = deferred(); const started = deferred(); const registration = session.registerTerminalIntegration(terminal, { attach: async () => { - const socket = await connections.attach('integration-select-false', 55102, 'external-host', { - attachmentId: 'select-false-binding', autoActivate: false, - }); - const reference = await waitForValue(() => session.getSessionReference('integration-select-false')); + const socket = await connections.attach( + 'integration-select-false', + 55102, + 'external-host', + { + attachmentId: 'select-false-binding', + autoActivate: false, + }, + ); + const reference = await waitForValue(() => + session.getSessionReference('integration-select-false'), + ); const success = await session.activateSessionById(reference.sessionId, { - connectionId: reference.connectionId, terminal, select: false, + connectionId: reference.connectionId, + terminal, + select: false, }); started.resolve(); await gate.promise; @@ -680,15 +956,30 @@ suite('External Terminal Integration', () => { await session.activateSessionById(other.sessionId); gate.resolve(true); await operation; - assert.strictEqual(session.activeSession, other, 'hook completion must not take back a newer session selection'); + assert.strictEqual( + session.activeSession, + other, + 'hook completion must not take back a newer session selection', + ); const reference = session.getSessionReference('integration-select-false'); assert.ok(reference); assert.strictEqual(reference.attachmentId, 'select-false-binding'); - assert.strictEqual(await session.activateSessionById(reference.sessionId, { - connectionId: reference.connectionId, terminal, select: false, - }), true, 'the binding must remain available after a newer selection'); + assert.strictEqual( + await session.activateSessionById(reference.sessionId, { + connectionId: reference.connectionId, + terminal, + select: false, + }), + true, + 'the binding must remain available after a newer selection', + ); assert.strictEqual(session.activeSession, other); - assert.strictEqual(await session.activateSessionById(reference.sessionId, { connectionId: reference.connectionId }), true); + assert.strictEqual( + await session.activateSessionById(reference.sessionId, { + connectionId: reference.connectionId, + }), + true, + ); await session.executeSessionCode(session.activeSession!, 'View(iris)'); sinon.assert.calledOnceWithExactly(terminalSpies.get(terminal)!.sendText, 'View(iris)'); } finally { @@ -704,17 +995,28 @@ suite('External Terminal Integration', () => { setTerminals([terminal]); let connectionsMade = 0; const registration = session.registerTerminalIntegration(terminal, { - attach: async token => { + attach: async (token) => { assert.strictEqual(token.isCancellationRequested, false); connectionsMade++; const id = 'integration-reconnect'; - const socket = await connections.attach(id, 55103 + connectionsMade, 'external-host', { - attachmentId: `reconnect-${connectionsMade}`, autoActivate: false, - }); + const socket = await connections.attach( + id, + 55103 + connectionsMade, + 'external-host', + { + attachmentId: `reconnect-${connectionsMade}`, + autoActivate: false, + }, + ); const reference = await waitForValue(() => session.getSessionReference(id)); - return !socket.destroyed && await session.activateSessionById(id, { - connectionId: reference.connectionId, terminal, select: false, - }); + return ( + !socket.destroyed && + (await session.activateSessionById(id, { + connectionId: reference.connectionId, + terminal, + select: false, + })) + ); }, }); try { @@ -722,7 +1024,10 @@ suite('External Terminal Integration', () => { await session.cleanupSession('integration-reconnect'); await session.activateRSession(); assert.strictEqual(connectionsMade, 2); - assert.strictEqual(session.getSessionReference('integration-reconnect')?.attachmentId, 'reconnect-2'); + assert.strictEqual( + session.getSessionReference('integration-reconnect')?.attachmentId, + 'reconnect-2', + ); } finally { registration.dispose(); await session.cleanupSession('integration-reconnect'); diff --git a/src/test/suite/sessionTerminalLifecycle.test.ts b/src/test/suite/sessionTerminalLifecycle.test.ts index bec23f2f2..557beb314 100644 --- a/src/test/suite/sessionTerminalLifecycle.test.ts +++ b/src/test/suite/sessionTerminalLifecycle.test.ts @@ -12,15 +12,33 @@ import * as rTerminal from '../../rTerminal'; import * as session from '../../session'; import * as util from '../../util'; import { mockExtensionContext } from '../common/mockvscode'; -import { deferred, SessionConnections, waitForValue as waitFor } from '../common/sessionConnections'; +import { + deferred, + SessionConnections, + waitForValue as waitFor, +} from '../common/sessionConnections'; -async function settleWithFakeTime(promise: Promise, clock: sinon.SinonFakeTimers): Promise { +async function settleWithFakeTime( + promise: Promise, + clock: sinon.SinonFakeTimers, +): Promise { let settled = false; - void promise.then(() => { settled = true; }, () => { settled = true; }); + void promise.then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); for (let attempt = 0; attempt < 100 && !settled; attempt++) { await clock.tickAsync(10); } - assert.strictEqual(settled, true, 'async session handshake should settle while fake timers advance'); + assert.strictEqual( + settled, + true, + 'async session handshake should settle while fake timers advance', + ); return await promise; } @@ -51,19 +69,41 @@ suite('Session Terminal Lifecycle', () => { return token; } - async function makeStartupFile(state: 'pending' | 'ready' | 'failed', pid = 46250): - Promise<{ directory: string; file: string; discoveryFile: string; token: string; attempt: string; pid: number; endpoint: string }> { + async function makeStartupFile( + state: 'pending' | 'ready' | 'failed', + pid = 46250, + ): Promise<{ + directory: string; + file: string; + discoveryFile: string; + token: string; + attempt: string; + pid: number; + endpoint: string; + }> { const directory = await fsp.mkdtemp(path.join(os.tmpdir(), 'vscode-r-terminal-startup-')); const discoveryFile = path.join(directory, 'session.json'); const file = `${discoveryFile}.startup`; const endpoint = await session.getGlobalPipePath(); await fsp.writeFile(file, startupRecord(state, pid, endpoint)); - return { directory, file, discoveryFile, token: testStartupToken, attempt: testStartupAttempt, pid, endpoint }; + return { + directory, + file, + discoveryFile, + token: testStartupToken, + attempt: testStartupAttempt, + pid, + endpoint, + }; } - async function waitForTimerDelay(timerSpy: sinon.SinonSpy, delay: number, startupFile: string): Promise<() => void> { + async function waitForTimerDelay( + timerSpy: sinon.SinonSpy, + delay: number, + startupFile: string, + ): Promise<() => void> { for (let attempt = 0; attempt < 100; attempt++) { - const call = timerSpy.getCalls().find(candidate => candidate.args[1] === delay); + const call = timerSpy.getCalls().find((candidate) => candidate.args[1] === delay); if (call && typeof call.args[0] === 'function') { return call.args[0] as () => void; } @@ -77,12 +117,18 @@ suite('Session Terminal Lifecycle', () => { function makeManagedTerminal(rPid: number, discoveryFile: string, bracketedPaste = true) { const sendText = sandbox.stub(); const terminal = { - name: 'R Interactive', processId: Promise.resolve(rPid), - show: sandbox.stub(), dispose: sandbox.stub(), sendText, + name: 'R Interactive', + processId: Promise.resolve(rPid), + show: sandbox.stub(), + dispose: sandbox.stub(), + sendText, } as unknown as vscode.Terminal; sandbox.stub(executionTarget, 'tryInteractiveExecution').resolves(false); sandbox.stub(util, 'config').returns({ - get: (key: string) => ({ sessionWatcher: true, consoleArgs: [], bracketedPaste, 'source.focus': 'none' })[key], + get: (key: string) => + ({ sessionWatcher: true, consoleArgs: [], bracketedPaste, 'source.focus': 'none' })[ + key + ], } as unknown as vscode.WorkspaceConfiguration); sandbox.stub(util, 'getRterm').resolves(process.execPath); sandbox.stub(session, 'getSessConsentDirectory').resolves('/unused-test-consent'); @@ -95,7 +141,11 @@ suite('Session Terminal Lifecycle', () => { const close = new vscode.EventEmitter(); sandbox.stub(vscode.window, 'onDidCloseTerminal').callsFake(close.event); return { - terminal, sendText, warning, close, create, + terminal, + sendText, + warning, + close, + create, cleanup() { close.fire(terminal); rTerminal.deleteTerminal(terminal); @@ -140,8 +190,13 @@ suite('Session Terminal Lifecycle', () => { const sendText = sandbox.stub(); let resolvePid!: (pid: number) => void; const terminal = { - processId: delayedPid ? new Promise(resolve => { resolvePid = resolve; }) : Promise.resolve(46250), - sendText, show: () => undefined, + processId: delayedPid + ? new Promise((resolve) => { + resolvePid = resolve; + }) + : Promise.resolve(46250), + sendText, + show: () => undefined, } as unknown as vscode.Terminal; const other = { processId: Promise.resolve(46251) } as unknown as vscode.Terminal; // Keep the unresolved PID out of terminal enumeration until attach @@ -149,7 +204,10 @@ suite('Session Terminal Lifecycle', () => { const terminals = sandbox.stub(vscode.window, 'terminals').value([other]); sandbox.stub(vscode.window, 'activeTerminal').value(other); let ready = false; - const waiting = session.waitForTerminalReady(terminal).then(value => { ready = value; return value; }); + const waiting = session.waitForTerminalReady(terminal).then((value) => { + ready = value; + return value; + }); const sockets: session.Session['socket'][] = []; const attach = async (rPid: number) => { const socket = await connections.attach(`readiness-${rPid}`, rPid); @@ -159,52 +217,95 @@ suite('Session Terminal Lifecycle', () => { await attach(otherRPid); assert.strictEqual(ready, false, 'another terminal must not release the wait'); terminals.value([terminal, other]); - if (delayedPid) { resolvePid(46250); } + if (delayedPid) { + resolvePid(46250); + } await attach(rPid); assert.strictEqual(await waiting, true); assert.strictEqual(sockets[1]._terminalPid, 46250); - if (!parents.length) { sinon.assert.notCalled(ancestors); } - assert.strictEqual(await session.waitForTerminalReady(terminal), true, 'already attached terminals resolve immediately'); + if (!parents.length) { + sinon.assert.notCalled(ancestors); + } + assert.strictEqual( + await session.waitForTerminalReady(terminal), + true, + 'already attached terminals resolve immediately', + ); await session.switchSessionByTerminal(terminal); const foreground = session.activeSession; assert.strictEqual(foreground?.pid, String(rPid)); assert.ok(foreground); await attach(backgroundPid); - const background = await waitFor(() => session.activeSession?.pid === String(backgroundPid) - ? session.activeSession : undefined); + const background = await waitFor(() => + session.activeSession?.pid === String(backgroundPid) + ? session.activeSession + : undefined, + ); assert.ok(background); - assert.strictEqual(sockets[2]._terminalPid, undefined, 'background R must remain unassociated'); - await assert.rejects(session.executeSessionCode(background, 'Sys.getpid()'), /no attached terminal/); + assert.strictEqual( + sockets[2]._terminalPid, + undefined, + 'background R must remain unassociated', + ); + await assert.rejects( + session.executeSessionCode(background, 'Sys.getpid()'), + /no attached terminal/, + ); sinon.assert.notCalled(sendText); await session.switchSessionByTerminal(terminal); - assert.strictEqual(session.activeSession, foreground, 'terminal selection must retain the foreground workspace'); + assert.strictEqual( + session.activeSession, + foreground, + 'terminal selection must retain the foreground workspace', + ); await session.executeSessionCode(foreground, 'Sys.getpid()'); sinon.assert.calledOnce(sendText); // An explicit owner takes precedence even over a matching native PID. sendText.resetHistory(); - assert.strictEqual(await session.activateSessionById(background.sessionId, { terminal }), true); - await assert.rejects(session.executeSessionCode(foreground, 'rm(iris)'), /no attached terminal/); + assert.strictEqual( + await session.activateSessionById(background.sessionId, { terminal }), + true, + ); + await assert.rejects( + session.executeSessionCode(foreground, 'rm(iris)'), + /no attached terminal/, + ); sinon.assert.notCalled(sendText); await session.executeSessionCode(background, 'View(iris)'); sinon.assert.calledOnce(sendText); // Moving the explicit owner away must not revive old native ownership. sendText.resetHistory(); - assert.strictEqual(await session.activateSessionById(background.sessionId, { terminal: other }), true); - await assert.rejects(session.executeSessionCode(foreground, 'rm(iris)'), /no attached terminal/); + assert.strictEqual( + await session.activateSessionById(background.sessionId, { terminal: other }), + true, + ); + await assert.rejects( + session.executeSessionCode(foreground, 'rm(iris)'), + /no attached terminal/, + ); await session.switchSessionByTerminal(terminal); assert.strictEqual(session.activeSession, background); assert.strictEqual(await session.waitForTerminalReady(terminal, 1), false); sinon.assert.notCalled(sendText); // IPC disconnect also leaves the superseded PID association retired. - assert.strictEqual(await session.activateSessionById(background.sessionId, { terminal }), true); + assert.strictEqual( + await session.activateSessionById(background.sessionId, { terminal }), + true, + ); await session.cleanupSession(background.sessionId); - assert.strictEqual(await session.activateSessionById(`readiness-${otherRPid}`), true); + assert.strictEqual( + await session.activateSessionById(`readiness-${otherRPid}`), + true, + ); const selected = session.activeSession; - await assert.rejects(session.executeSessionCode(foreground, 'rm(iris)'), /no attached terminal/); + await assert.rejects( + session.executeSessionCode(foreground, 'rm(iris)'), + /no attached terminal/, + ); await session.switchSessionByTerminal(terminal); assert.strictEqual(session.activeSession, selected); sinon.assert.notCalled(sendText); @@ -224,11 +325,16 @@ suite('Session Terminal Lifecycle', () => { let resolveSelectionPid!: (pid: number) => void; const slowTerminal = { ...terminal, - processId: new Promise(resolve => { resolveSelectionPid = resolve; }), + processId: new Promise((resolve) => { + resolveSelectionPid = resolve; + }), } as vscode.Terminal; terminals.value([slowTerminal, other]); const pendingSelection = session.switchSessionByTerminal(slowTerminal); - assert.strictEqual(await session.activateSessionById(`readiness-${otherRPid}`), true); + assert.strictEqual( + await session.activateSessionById(`readiness-${otherRPid}`), + true, + ); resolveSelectionPid(46250); await pendingSelection; assert.strictEqual(session.activeSession, selected); @@ -240,28 +346,46 @@ suite('Session Terminal Lifecycle', () => { for (const explicitBinding of [false, true]) { test(`managed terminal accepts its first source command after native attach (explicit binding: ${String(explicitBinding)})`, async () => { - const { directory, discoveryFile, file: startupPath, pid, endpoint } = await makeStartupFile('ready', 46260); + const { + directory, + discoveryFile, + file: startupPath, + pid, + endpoint, + } = await makeStartupFile('ready', 46260); const fixture = makeManagedTerminal(46260, discoveryFile, false); const waitUntilReady = session.waitForTerminalReady; // Exercise the real readiness logic, with a bounded failure timeout. - const readiness = sandbox.stub(session, 'waitForTerminalReady') + const readiness = sandbox + .stub(session, 'waitForTerminalReady') .callsFake((terminal, _timeout, startupPath, startupToken) => - waitUntilReady(terminal, 1000, startupPath, startupToken)); + waitUntilReady(terminal, 1000, startupPath, startupToken), + ); const { terminal, sendText, warning } = fixture; const sessionId = `source-readiness-${String(explicitBinding)}`; try { assert.strictEqual(await rTerminal.createRTerm(), true); - const token = terminalStartupToken(fixture.create.firstCall.args[0] as vscode.TerminalOptions); + const token = terminalStartupToken( + fixture.create.firstCall.args[0] as vscode.TerminalOptions, + ); await fsp.writeFile(startupPath, startupRecord('ready', pid, endpoint, token)); await connections.attach(sessionId, 46260); - const owner = await waitFor(() => session.activeSession?.sessionId === sessionId ? session.activeSession : undefined); + const owner = await waitFor(() => + session.activeSession?.sessionId === sessionId + ? session.activeSession + : undefined, + ); assert.ok(owner); assert.strictEqual(owner.socket._terminalPid, 46260); if (explicitBinding) { - const api: RSessionApi = { getConnectionInfo: session.getConnectionInfo, prepareAttachment: session.prepareAttachment, - onDidChangeSessionConnection: session.onDidChangeSessionConnection, getSessionReference: session.getSessionReference, + const api: RSessionApi = { + getConnectionInfo: session.getConnectionInfo, + prepareAttachment: session.prepareAttachment, + onDidChangeSessionConnection: session.onDidChangeSessionConnection, + getSessionReference: session.getSessionReference, registerTerminalIntegration: session.registerTerminalIntegration, - activate: session.activateSessionById }; + activate: session.activateSessionById, + }; assert.strictEqual(await api.activate(owner.sessionId, { terminal }), true); } assert.strictEqual(await rTerminal.runTextInTerm('source("example.R")'), true); @@ -282,17 +406,29 @@ suite('Session Terminal Lifecycle', () => { test('moving a native session to an explicit terminal requires a fresh attach after close', async () => { const sendText = sandbox.stub(); const explicitSend = sandbox.stub(); - const terminal = { processId: Promise.resolve(46280), sendText, show: sandbox.stub() } as unknown as vscode.Terminal; - const other = { processId: Promise.resolve(undefined), sendText: explicitSend, show: sandbox.stub() } as unknown as vscode.Terminal; + const terminal = { + processId: Promise.resolve(46280), + sendText, + show: sandbox.stub(), + } as unknown as vscode.Terminal; + const other = { + processId: Promise.resolve(undefined), + sendText: explicitSend, + show: sandbox.stub(), + } as unknown as vscode.Terminal; sandbox.stub(vscode.window, 'terminals').value([terminal, other]); sandbox.stub(vscode.window, 'activeTerminal').value(terminal); sandbox.stub(util, 'config').returns({ - get: (key: string) => key === 'source.focus' ? 'none' : undefined, + get: (key: string) => (key === 'source.focus' ? 'none' : undefined), } as unknown as vscode.WorkspaceConfiguration); - const api: RSessionApi = { getConnectionInfo: session.getConnectionInfo, prepareAttachment: session.prepareAttachment, - onDidChangeSessionConnection: session.onDidChangeSessionConnection, getSessionReference: session.getSessionReference, + const api: RSessionApi = { + getConnectionInfo: session.getConnectionInfo, + prepareAttachment: session.prepareAttachment, + onDidChangeSessionConnection: session.onDidChangeSessionConnection, + getSessionReference: session.getSessionReference, registerTerminalIntegration: session.registerTerminalIntegration, - activate: session.activateSessionById }; + activate: session.activateSessionById, + }; await connections.attach('native-moved-to-explicit', 46280); const owner = session.activeSession; assert.ok(owner); @@ -334,7 +470,10 @@ suite('Session Terminal Lifecycle', () => { test('an older pending handshake cannot replace a newer connection with the same session ID', async () => { const entered = deferred(); const ancestorsResolved = deferred(); - ancestors.withArgs(46270).callsFake(() => { entered.resolve(); return ancestorsResolved.promise; }); + ancestors.withArgs(46270).callsFake(() => { + entered.resolve(); + return ancestorsResolved.promise; + }); const terminal = { processId: Promise.resolve(46271) } as unknown as vscode.Terminal; sandbox.stub(vscode.window, 'terminals').value([terminal]); sandbox.stub(vscode.window, 'activeTerminal').value(terminal); @@ -344,7 +483,7 @@ suite('Session Terminal Lifecycle', () => { const owner = session.activeSession; assert.strictEqual(owner?.socket, newer); ancestorsResolved.resolve([46271]); - await waitFor(() => old.destroyed || old._sessionId ? true : undefined); + await waitFor(() => (old.destroyed || old._sessionId ? true : undefined)); assert.strictEqual(session.activeSession, owner); assert.strictEqual(newer.destroyed, false); await session.cleanupSession('overlapping-reconnect', old); @@ -352,26 +491,52 @@ suite('Session Terminal Lifecycle', () => { }); // Selection belongs to a logical session; execution still belongs to a connection. - for (const selection of ['unchanged', 'same terminal', 'same public activation', 'same manual activation', - 'away and back', 'different terminal', 'different public activation'] as const) { + for (const selection of [ + 'unchanged', + 'same terminal', + 'same public activation', + 'same manual activation', + 'away and back', + 'different terminal', + 'different public activation', + ] as const) { test(`reconnect preserves logical selection during discovery (${selection})`, async () => { const entered = deferred(); const proceed = deferred(); ancestors.withArgs(46301).onFirstCall().resolves([46300]); - ancestors.withArgs(46301).onSecondCall().callsFake(() => { entered.resolve(); return proceed.promise; }); + ancestors + .withArgs(46301) + .onSecondCall() + .callsFake(() => { + entered.resolve(); + return proceed.promise; + }); const sendText = sandbox.stub(); const otherSendText = sandbox.stub(); - const terminal = { processId: Promise.resolve(46300), sendText, show: sandbox.stub() } as unknown as vscode.Terminal; - const other = { processId: Promise.resolve(46310), sendText: otherSendText, show: sandbox.stub() } as unknown as vscode.Terminal; + const terminal = { + processId: Promise.resolve(46300), + sendText, + show: sandbox.stub(), + } as unknown as vscode.Terminal; + const other = { + processId: Promise.resolve(46310), + sendText: otherSendText, + show: sandbox.stub(), + } as unknown as vscode.Terminal; sandbox.stub(vscode.window, 'terminals').value([terminal, other]); const activeTerminal = sandbox.stub(vscode.window, 'activeTerminal').value(terminal); sandbox.stub(util, 'config').returns({ - get: (key: string) => key === 'sessionWatcher' ? true : key === 'source.focus' ? 'none' : undefined, + get: (key: string) => + key === 'sessionWatcher' ? true : key === 'source.focus' ? 'none' : undefined, } as unknown as vscode.WorkspaceConfiguration); - const api: RSessionApi = { getConnectionInfo: session.getConnectionInfo, prepareAttachment: session.prepareAttachment, - onDidChangeSessionConnection: session.onDidChangeSessionConnection, getSessionReference: session.getSessionReference, + const api: RSessionApi = { + getConnectionInfo: session.getConnectionInfo, + prepareAttachment: session.prepareAttachment, + onDidChangeSessionConnection: session.onDidChangeSessionConnection, + getSessionReference: session.getSessionReference, registerTerminalIntegration: session.registerTerminalIntegration, - activate: session.activateSessionById }; + activate: session.activateSessionById, + }; const old = await connections.attach('selected-reconnect', 46301); const previous = session.activeSession; assert.ok(previous); @@ -380,22 +545,31 @@ suite('Session Terminal Lifecycle', () => { const replacement = await connections.startAttach(previous.sessionId, 46301); try { await entered.promise; - if (selection === 'same terminal') { await session.switchSessionByTerminal(terminal); } - else if (selection === 'same public activation') { assert.strictEqual(await api.activate(previous.sessionId), true); } - else if (selection === 'same manual activation') { await session.activateRSession(); } - else if (selection === 'away and back') { + if (selection === 'same terminal') { + await session.switchSessionByTerminal(terminal); + } else if (selection === 'same public activation') { + assert.strictEqual(await api.activate(previous.sessionId), true); + } else if (selection === 'same manual activation') { + await session.activateRSession(); + } else if (selection === 'away and back') { await api.activate('other-selected-reconnect'); await session.switchSessionByTerminal(terminal); } else if (selection === 'different terminal') { activeTerminal.value(other); await session.switchSessionByTerminal(other); - } else if (selection === 'different public activation') { await api.activate('other-selected-reconnect'); } - const preserveOther = selection === 'different terminal' || selection === 'different public activation'; + } else if (selection === 'different public activation') { + await api.activate('other-selected-reconnect'); + } + const preserveOther = + selection === 'different terminal' || + selection === 'different public activation'; const selected = session.activeSession; assert.ok(selected); assert.strictEqual(selected.socket, preserveOther ? otherSocket : old); proceed.resolve([46300]); - await waitFor(() => replacement._sessionId === previous.sessionId ? true : undefined); + await waitFor(() => + replacement._sessionId === previous.sessionId ? true : undefined, + ); assert.strictEqual(old.destroyed, true); assert.strictEqual(replacement.destroyed, false); @@ -403,45 +577,80 @@ suite('Session Terminal Lifecycle', () => { const current = session.activeSession; assert.ok(current); assert.strictEqual(current.socket, preserveOther ? otherSocket : replacement); - if (preserveOther) { assert.strictEqual(current, selected); } - else { assert.notStrictEqual(current, previous); } - assert.strictEqual(session.workspaceData, current.workspaceData, 'Workspace state must follow the selected connection'); + if (preserveOther) { + assert.strictEqual(current, selected); + } else { + assert.notStrictEqual(current, previous); + } + assert.strictEqual( + session.workspaceData, + current.workspaceData, + 'Workspace state must follow the selected connection', + ); await session.cleanupSession(previous.sessionId, old); - assert.strictEqual(session.activeSession, current, 'late old-socket cleanup must not clear the selection'); + assert.strictEqual( + session.activeSession, + current, + 'late old-socket cleanup must not clear the selection', + ); // Refresh selection, but never redirect a node owned by the old connection. - await assert.rejects(session.executeSessionCode(previous, 'rm(iris)'), /no longer attached/); + await assert.rejects( + session.executeSessionCode(previous, 'rm(iris)'), + /no longer attached/, + ); await session.executeSessionCode(current, 'View(iris)'); - sinon.assert.calledOnceWithExactly(preserveOther ? otherSendText : sendText, 'View(iris)'); + sinon.assert.calledOnceWithExactly( + preserveOther ? otherSendText : sendText, + 'View(iris)', + ); sinon.assert.notCalled(preserveOther ? sendText : otherSendText); - } finally { proceed.resolve([46300]); } + } finally { + proceed.resolve([46300]); + } }); } test('native discovery cannot revive ownership changed while its PID was pending', async () => { const entered = deferred(); const pid = deferred(); - const terminal = { get processId() { entered.resolve(); return pid.promise; } } as unknown as vscode.Terminal; + const terminal = { + get processId() { + entered.resolve(); + return pid.promise; + }, + } as unknown as vscode.Terminal; const other = { processId: Promise.resolve(46273) } as unknown as vscode.Terminal; sandbox.stub(vscode.window, 'terminals').value([terminal, other]); sandbox.stub(vscode.window, 'activeTerminal').value(terminal); - const owner = session.registerSessionTransport('binding-during-discovery', 'host', '/project', () => Promise.resolve({})); + const owner = session.registerSessionTransport( + 'binding-during-discovery', + 'host', + '/project', + () => Promise.resolve({}), + ); try { const socket = await connections.startAttach('pending-native-discovery', 46272); await entered.promise; await session.activateSessionById(owner.sessionId, { terminal }); await session.activateSessionById(owner.sessionId, { terminal: other }); pid.resolve(46272); - await waitFor(() => socket._sessionId === 'pending-native-discovery' ? true : undefined); + await waitFor(() => + socket._sessionId === 'pending-native-discovery' ? true : undefined, + ); assert.strictEqual(socket._terminalPid, undefined); assert.strictEqual(session.activeSession, owner); assert.strictEqual(await session.waitForTerminalReady(terminal, 1), false); - } finally { session.unregisterSessionTransport(owner); } + } finally { + session.unregisterSessionTransport(owner); + } }); test('terminal readiness aborts on close even before processId resolves', async () => { const { directory, file, token } = await makeStartupFile('pending'); - const terminal = { processId: new Promise(() => undefined) } as unknown as vscode.Terminal; + const terminal = { + processId: new Promise(() => undefined), + } as unknown as vscode.Terminal; sandbox.stub(vscode.window, 'terminals').value([terminal]); const close = new vscode.EventEmitter(); sandbox.stub(vscode.window, 'onDidCloseTerminal').callsFake(close.event); @@ -453,7 +662,11 @@ suite('Session Terminal Lifecycle', () => { await waitForTimerDelay(timerSpy, 600000, file); close.fire(terminal); assert.strictEqual(await waiting, false); - assert.strictEqual(clock.countTimers(), 0, 'closing the terminal removes the pending startup deadline and poll'); + assert.strictEqual( + clock.countTimers(), + 0, + 'closing the terminal removes the pending startup deadline and poll', + ); } finally { close.fire(terminal); timerSandbox.restore(); @@ -466,16 +679,25 @@ suite('Session Terminal Lifecycle', () => { test('pending startup holds queued terminal input beyond 30 seconds until ready and this terminal attaches', async () => { const rPid = 46291; const otherPid = 46292; - const { directory, file: startupFile, discoveryFile, endpoint } = await makeStartupFile('pending', rPid); + const { + directory, + file: startupFile, + discoveryFile, + endpoint, + } = await makeStartupFile('pending', rPid); const fixture = makeManagedTerminal(rPid, discoveryFile); const { sendText } = fixture; const waitForReady = session.waitForTerminalReady; let readinessStarted!: () => void; - const started = new Promise(resolve => { readinessStarted = resolve; }); - const readyStub = sandbox.stub(session, 'waitForTerminalReady').callsFake((current, timeout, startupPath, startupToken) => { - readinessStarted(); - return waitForReady(current, timeout, startupPath, startupToken); + const started = new Promise((resolve) => { + readinessStarted = resolve; }); + const readyStub = sandbox + .stub(session, 'waitForTerminalReady') + .callsFake((current, timeout, startupPath, startupToken) => { + readinessStarted(); + return waitForReady(current, timeout, startupPath, startupToken); + }); const timerSandbox = sinon.createSandbox(); const clock = timerSandbox.useFakeTimers(); const timerSpy = timerSandbox.spy(globalThis, 'setTimeout'); @@ -483,29 +705,46 @@ suite('Session Terminal Lifecycle', () => { try { assert.strictEqual(await rTerminal.createRTerm(), true); - const token = terminalStartupToken(fixture.create.firstCall.args[0] as vscode.TerminalOptions); + const token = terminalStartupToken( + fixture.create.firstCall.args[0] as vscode.TerminalOptions, + ); await fsp.writeFile(startupFile, startupRecord('pending', rPid, endpoint, token)); const first = rTerminal.runTextInTerm('first'); const second = rTerminal.runTextInTerm('second'); const commands = [first, second]; commandsSettled = Promise.allSettled(commands); let firstSettled = false; - const firstResult = first.then(value => { firstSettled = true; return value; }); + const firstResult = first.then((value) => { + firstSettled = true; + return value; + }); await started; assert.strictEqual(readyStub.firstCall.args[2], startupFile); assert.strictEqual(readyStub.firstCall.args[3], token); await waitForTimerDelay(timerSpy, 600000, startupFile); await clock.tickAsync(35000); - assert.strictEqual(firstSettled, false, 'pending setup suppresses the ordinary 30 second timeout'); + assert.strictEqual( + firstSettled, + false, + 'pending setup suppresses the ordinary 30 second timeout', + ); sinon.assert.notCalled(sendText); await fsp.writeFile(startupFile, startupRecord('ready', rPid, endpoint, token)); await clock.tickAsync(500); - assert.strictEqual(sendText.called, false, 'ready state alone cannot release terminal input'); + assert.strictEqual( + sendText.called, + false, + 'ready state alone cannot release terminal input', + ); const unrelated = connections.attach('startup-unrelated', otherPid); await settleWithFakeTime(unrelated, clock); - assert.strictEqual(sendText.called, false, 'an unrelated R attach cannot release this terminal'); + assert.strictEqual( + sendText.called, + false, + 'an unrelated R attach cannot release this terminal', + ); const target = connections.attach('startup-target', rPid); await settleWithFakeTime(target, clock); @@ -519,7 +758,9 @@ suite('Session Terminal Lifecycle', () => { } finally { fixture.cleanup(); timerSandbox.restore(); - if (commandsSettled) { await commandsSettled; } + if (commandsSettled) { + await commandsSettled; + } await connections.dispose(); await fsp.rm(directory, { recursive: true, force: true }); } @@ -530,18 +771,24 @@ suite('Session Terminal Lifecycle', () => { const { directory, file, token, endpoint } = await makeStartupFile('pending', rPid); const sendText = sandbox.stub(); const terminal = { - name: 'R Interactive', processId: Promise.resolve(rPid), sendText, show: sandbox.stub(), - creationOptions: { env: { - VSCODE_R_SESS_STARTUP_FILE: file, - VSCODE_R_SESS_STARTUP_TOKEN: token, - } }, + name: 'R Interactive', + processId: Promise.resolve(rPid), + sendText, + show: sandbox.stub(), + creationOptions: { + env: { + VSCODE_R_SESS_STARTUP_FILE: file, + VSCODE_R_SESS_STARTUP_TOKEN: token, + }, + }, } as unknown as vscode.Terminal; sandbox.stub(vscode.window, 'terminals').value([terminal]); sandbox.stub(vscode.window, 'activeTerminal').value(terminal); const close = new vscode.EventEmitter(); sandbox.stub(vscode.window, 'onDidCloseTerminal').callsFake(close.event); sandbox.stub(util, 'config').returns({ - get: (key: string) => ({ sessionWatcher: true, bracketedPaste: true, 'source.focus': 'none' })[key], + get: (key: string) => + ({ sessionWatcher: true, bracketedPaste: true, 'source.focus': 'none' })[key], } as unknown as vscode.WorkspaceConfiguration); sandbox.stub(executionTarget, 'tryInteractiveExecution').resolves(false); const timerSandbox = sinon.createSandbox(); @@ -549,7 +796,7 @@ suite('Session Terminal Lifecycle', () => { try { await connections.attach('restored-profile-terminal', rPid); - await waitFor(() => session.activeSession?.pipePath === endpoint ? true : undefined); + await waitFor(() => (session.activeSession?.pipePath === endpoint ? true : undefined)); const clock = timerSandbox.useFakeTimers(); const timerSpy = timerSandbox.spy(globalThis, 'setTimeout'); commandResult = rTerminal.runTextInTerm('restored input'); @@ -557,7 +804,9 @@ suite('Session Terminal Lifecycle', () => { sinon.assert.notCalled(sendText); await fsp.writeFile(file, startupRecord('ready', rPid, endpoint, token)); let settled = false; - void commandResult.then(() => { settled = true; }); + void commandResult.then(() => { + settled = true; + }); for (let attempt = 0; attempt < 100 && !settled; attempt++) { await clock.tickAsync(100); await fsp.readFile(file); @@ -567,7 +816,9 @@ suite('Session Terminal Lifecycle', () => { } finally { close.fire(terminal); timerSandbox.restore(); - if (commandResult) { await Promise.allSettled([commandResult]); } + if (commandResult) { + await Promise.allSettled([commandResult]); + } await connections.dispose(); close.dispose(); await fsp.rm(directory, { recursive: true, force: true }); @@ -577,7 +828,12 @@ suite('Session Terminal Lifecycle', () => { for (const state of ['ready', 'failed'] as const) { test(`an already attached managed terminal waits for pending startup to become ${state}`, async () => { const rPid = state === 'ready' ? 46294 : 46295; - const { directory, file: startupFile, discoveryFile, endpoint } = await makeStartupFile('pending', rPid); + const { + directory, + file: startupFile, + discoveryFile, + endpoint, + } = await makeStartupFile('pending', rPid); const fixture = makeManagedTerminal(rPid, discoveryFile); const { sendText, warning } = fixture; const timerSandbox = sinon.createSandbox(); @@ -585,29 +841,43 @@ suite('Session Terminal Lifecycle', () => { try { assert.strictEqual(await rTerminal.createRTerm(), true); - const token = terminalStartupToken(fixture.create.firstCall.args[0] as vscode.TerminalOptions); + const token = terminalStartupToken( + fixture.create.firstCall.args[0] as vscode.TerminalOptions, + ); await fsp.writeFile(startupFile, startupRecord('pending', rPid, endpoint, token)); await connections.attach(`startup-attached-before-wait-${state}`, rPid); - await waitFor(() => session.activeSession?.pid === String(rPid) ? session.activeSession : undefined); + await waitFor(() => + session.activeSession?.pid === String(rPid) ? session.activeSession : undefined, + ); const clock = timerSandbox.useFakeTimers(); const timerSpy = timerSandbox.spy(globalThis, 'setTimeout'); const submitted = rTerminal.runTextInTerm(`run only after ${state}`); - commandSettled = Promise.allSettled([submitted]).then(results => results[0]); + commandSettled = Promise.allSettled([submitted]).then((results) => results[0]); await waitForTimerDelay(timerSpy, 600000, startupFile); sinon.assert.notCalled(sendText); await fsp.writeFile(startupFile, startupRecord(state, rPid, endpoint, token)); let settled = false; - void submitted.then(() => { settled = true; }); + void submitted.then(() => { + settled = true; + }); for (let attempt = 0; attempt < 100 && !settled; attempt++) { await clock.tickAsync(100); await fsp.readFile(startupFile); } - assert.strictEqual(settled, true, `${state} startup state should settle the command`); + assert.strictEqual( + settled, + true, + `${state} startup state should settle the command`, + ); assert.strictEqual(await submitted, state === 'ready'); if (state === 'ready') { - sinon.assert.calledOnceWithExactly(sendText, '\x1b[200~run only after ready\x1b[201~', true); + sinon.assert.calledOnceWithExactly( + sendText, + '\x1b[200~run only after ready\x1b[201~', + true, + ); sinon.assert.notCalled(warning); } else { sinon.assert.notCalled(sendText); @@ -616,7 +886,9 @@ suite('Session Terminal Lifecycle', () => { } finally { fixture.cleanup(); timerSandbox.restore(); - if (commandSettled) { await commandSettled; } + if (commandSettled) { + await commandSettled; + } await connections.dispose(); await fsp.rm(directory, { recursive: true, force: true }); } @@ -628,35 +900,65 @@ suite('Session Terminal Lifecycle', () => { const { directory, file, token, endpoint } = await makeStartupFile('failed', rPid); const sendText = sandbox.stub(); const terminal = { - name: 'R Interactive', processId: Promise.resolve(rPid), sendText, show: sandbox.stub(), - creationOptions: { env: { - VSCODE_R_SESS_STARTUP_FILE: file, - VSCODE_R_SESS_STARTUP_TOKEN: token, - } }, + name: 'R Interactive', + processId: Promise.resolve(rPid), + sendText, + show: sandbox.stub(), + creationOptions: { + env: { + VSCODE_R_SESS_STARTUP_FILE: file, + VSCODE_R_SESS_STARTUP_TOKEN: token, + }, + }, } as unknown as vscode.Terminal; sandbox.stub(vscode.window, 'terminals').value([terminal]); sandbox.stub(vscode.window, 'activeTerminal').value(terminal); sandbox.stub(util, 'config').returns({ - get: (key: string) => ({ sessionWatcher: true, bracketedPaste: true, 'source.focus': 'none' })[key], + get: (key: string) => + ({ sessionWatcher: true, bracketedPaste: true, 'source.focus': 'none' })[key], } as unknown as vscode.WorkspaceConfiguration); sandbox.stub(executionTarget, 'tryInteractiveExecution').resolves(false); try { await connections.attach('startup-recovery-pending', rPid); - await waitFor(() => session.activeSession?.pid === String(rPid) ? session.activeSession : undefined); + await waitFor(() => + session.activeSession?.pid === String(rPid) ? session.activeSession : undefined, + ); - assert.strictEqual(await session.waitForTerminalReady(terminal, 1000, file, token), false); - assert.strictEqual(await session.waitForTerminalReady(terminal, 1000, file, token), false, - 'retrying the same failed attempt cannot turn its result into ready'); + assert.strictEqual( + await session.waitForTerminalReady(terminal, 1000, file, token), + false, + ); + assert.strictEqual( + await session.waitForTerminalReady(terminal, 1000, file, token), + false, + 'retrying the same failed attempt cannot turn its result into ready', + ); await session.shutdownSessionWatcher(); session.deploySessionWatcher(extensionRoot); const reloadedEndpoint = await session.getGlobalPipePath(); - assert.notStrictEqual(reloadedEndpoint, endpoint, 'a window reload may create a new IPC endpoint'); + assert.notStrictEqual( + reloadedEndpoint, + endpoint, + 'a window reload may create a new IPC endpoint', + ); await connections.attach('startup-recovery-after-reload', rPid); - await waitFor(() => session.activeSession?.pipePath === reloadedEndpoint ? true : undefined); - await fsp.writeFile(file, startupRecord('ready', rPid, reloadedEndpoint, token, 'attempt_111111111111')); - assert.strictEqual(await rTerminal.runTextInTerm('first input after manual recovery'), true, - 'the first input accepts a completed new manual-attach attempt'); - sinon.assert.calledOnceWithExactly(sendText, '\x1b[200~first input after manual recovery\x1b[201~', true); + await waitFor(() => + session.activeSession?.pipePath === reloadedEndpoint ? true : undefined, + ); + await fsp.writeFile( + file, + startupRecord('ready', rPid, reloadedEndpoint, token, 'attempt_111111111111'), + ); + assert.strictEqual( + await rTerminal.runTextInTerm('first input after manual recovery'), + true, + 'the first input accepts a completed new manual-attach attempt', + ); + sinon.assert.calledOnceWithExactly( + sendText, + '\x1b[200~first input after manual recovery\x1b[201~', + true, + ); } finally { await connections.dispose(); await fsp.rm(directory, { recursive: true, force: true }); @@ -665,16 +967,25 @@ suite('Session Terminal Lifecycle', () => { test('failed startup declines input before late attach, while a fresh attached-terminal retry can proceed', async () => { const rPid = 46293; - const { directory, file: startupFile, discoveryFile, endpoint } = await makeStartupFile('pending', rPid); + const { + directory, + file: startupFile, + discoveryFile, + endpoint, + } = await makeStartupFile('pending', rPid); const fixture = makeManagedTerminal(rPid, discoveryFile); const { sendText, warning } = fixture; const waitForReady = session.waitForTerminalReady; let readinessStarted!: () => void; - const started = new Promise(resolve => { readinessStarted = resolve; }); - const readyStub = sandbox.stub(session, 'waitForTerminalReady').callsFake((current, timeout, startupPath, startupToken) => { - readinessStarted(); - return waitForReady(current, timeout, startupPath, startupToken); + const started = new Promise((resolve) => { + readinessStarted = resolve; }); + const readyStub = sandbox + .stub(session, 'waitForTerminalReady') + .callsFake((current, timeout, startupPath, startupToken) => { + readinessStarted(); + return waitForReady(current, timeout, startupPath, startupToken); + }); const timerSandbox = sinon.createSandbox(); const clock = timerSandbox.useFakeTimers(); const timerSpy = timerSandbox.spy(globalThis, 'setTimeout'); @@ -683,14 +994,18 @@ suite('Session Terminal Lifecycle', () => { let pendingCommandSettled: Promise> | undefined; try { assert.strictEqual(await rTerminal.createRTerm(), true); - const token = terminalStartupToken(fixture.create.firstCall.args[0] as vscode.TerminalOptions); + const token = terminalStartupToken( + fixture.create.firstCall.args[0] as vscode.TerminalOptions, + ); await fsp.writeFile(startupFile, startupRecord('pending', rPid, endpoint, token)); let rejectionSettled = false; - pendingCommand = rTerminal.runTextInTerm('must not send').then(result => { + pendingCommand = rTerminal.runTextInTerm('must not send').then((result) => { rejectionSettled = true; return result; }); - pendingCommandSettled = Promise.allSettled([pendingCommand]).then(results => results[0]); + pendingCommandSettled = Promise.allSettled([pendingCommand]).then( + (results) => results[0], + ); await started; assert.strictEqual(readyStub.firstCall.args[2], startupFile); assert.strictEqual(readyStub.firstCall.args[3], token); @@ -701,37 +1016,67 @@ suite('Session Terminal Lifecycle', () => { await fsp.readFile(startupFile); // Await the command state through a short fake-time turn after each poll. } - assert.strictEqual(rejectionSettled, true, 'failed startup status should settle the pending input promptly'); + assert.strictEqual( + rejectionSettled, + true, + 'failed startup status should settle the pending input promptly', + ); assert.strictEqual(await pendingCommand, false); sinon.assert.notCalled(sendText); sinon.assert.calledOnce(warning); - assert.strictEqual(clock.countTimers(), 0, 'failed startup clears its bounded wait and poll'); + assert.strictEqual( + clock.countTimers(), + 0, + 'failed startup clears its bounded wait and poll', + ); timerSandbox.restore(); await connections.attach('startup-late-attach', rPid); - assert.strictEqual(sendText.called, false, 'late attach cannot release the already failed input'); - await fsp.writeFile(startupFile, startupRecord('ready', rPid, endpoint, token, 'attempt_111111111111')); + assert.strictEqual( + sendText.called, + false, + 'late attach cannot release the already failed input', + ); + await fsp.writeFile( + startupFile, + startupRecord('ready', rPid, endpoint, token, 'attempt_111111111111'), + ); assert.strictEqual(await rTerminal.runTextInTerm('retry after attach'), true); assert.strictEqual(readyStub.callCount, 2); - sinon.assert.calledOnceWithExactly(sendText, '\x1b[200~retry after attach\x1b[201~', true); + sinon.assert.calledOnceWithExactly( + sendText, + '\x1b[200~retry after attach\x1b[201~', + true, + ); } finally { fixture.cleanup(); timerSandbox.restore(); - if (pendingCommandSettled) { await pendingCommandSettled; } + if (pendingCommandSettled) { + await pendingCommandSettled; + } await connections.dispose(); await fsp.rm(directory, { recursive: true, force: true }); } }); test('terminal readiness timeout does not declare the terminal ready', async () => { - const terminal = { processId: new Promise(() => undefined) } as unknown as vscode.Terminal; + const terminal = { + processId: new Promise(() => undefined), + } as unknown as vscode.Terminal; const timerSandbox = sinon.createSandbox(); const clock = timerSandbox.useFakeTimers(); let settled = false; - const waiting = session.waitForTerminalReady(terminal).then(result => { settled = true; return result; }); + const waiting = session.waitForTerminalReady(terminal).then((result) => { + settled = true; + return result; + }); try { await clock.tickAsync(29999); - assert.strictEqual(settled, false, 'absence of startup state keeps the default 30 second timeout'); + assert.strictEqual( + settled, + false, + 'absence of startup state keeps the default 30 second timeout', + ); await clock.tickAsync(1); assert.strictEqual(await waiting, false); assert.strictEqual(clock.countTimers(), 0); @@ -747,7 +1092,9 @@ suite('Session Terminal Lifecycle', () => { test('pending startup has a finite 10 minute deadline', async () => { const { directory, file, token } = await makeStartupFile('pending'); - const terminal = { processId: new Promise(() => undefined) } as unknown as vscode.Terminal; + const terminal = { + processId: new Promise(() => undefined), + } as unknown as vscode.Terminal; sandbox.stub(vscode.window, 'terminals').value([terminal]); const timerSandbox = sinon.createSandbox(); const clock = timerSandbox.useFakeTimers(); @@ -768,5 +1115,4 @@ suite('Session Terminal Lifecycle', () => { await fsp.rm(directory, { recursive: true, force: true }); } }); - }); diff --git a/src/test/suite/signatureHelp.test.ts b/src/test/suite/signatureHelp.test.ts index f495fa0c8..617c3961e 100644 --- a/src/test/suite/signatureHelp.test.ts +++ b/src/test/suite/signatureHelp.test.ts @@ -2,39 +2,80 @@ import * as assert from 'assert'; import { liveSignature } from '../../signatureHelp'; suite('Live R function signatures', () => { - const summaries = { fun1: { type: 'closure', str: 'function (x, y = c(1, 2), ..., exact = "a,b")' } }; + const summaries = { + fun1: { type: 'closure', str: 'function (x, y = c(1, 2), ..., exact = "a,b")' }, + }; test('retains default expressions and labels the active positional argument', () => { const help = liveSignature('fun1(1, ', summaries); assert.ok(help); assert.strictEqual(help.label, 'fun1(x, y = c(1, 2), ..., exact = "a,b")'); assert.strictEqual(help.activeParameter, 1); - assert.deepStrictEqual(help.parameters.map(range => help.label.slice(...range)), ['x', 'y = c(1, 2)', '...', 'exact = "a,b"']); + assert.deepStrictEqual( + help.parameters.map((range) => help.label.slice(...range)), + ['x', 'y = c(1, 2)', '...', 'exact = "a,b"'], + ); }); test('ignores nested calls, indexing, strings, raw strings, and comments when counting arguments', () => { - for (const code of ['fun1(list(a = 1, b = 2), ', 'fun1(x[1, 2], ', String.raw`fun1("a,\"(", `, - 'fun1(r"-(a,"b,(,)-", ', 'fun1(1, # a misleading fn(,\n']) { + for (const code of [ + 'fun1(list(a = 1, b = 2), ', + 'fun1(x[1, 2], ', + String.raw`fun1("a,\"(", `, + 'fun1(r"-(a,"b,(,)-", ', + 'fun1(1, # a misleading fn(,\n', + ]) { assert.strictEqual(liveSignature(code, summaries)?.activeParameter, 1, code); } - assert.strictEqual(liveSignature('fun1(1); # fun1(,\nfun1(', summaries)?.activeParameter, 0); + assert.strictEqual( + liveSignature('fun1(1); # fun1(,\nfun1(', summaries)?.activeParameter, + 0, + ); }); test('matches named arguments before assigning positional arguments and handles dots', () => { assert.strictEqual(liveSignature('fun1(y = 2, ', summaries)?.activeParameter, 0); assert.strictEqual(liveSignature('fun1(1, 2, extra = ', summaries)?.activeParameter, 2); assert.strictEqual(liveSignature('fun1(1, exact = ', summaries)?.activeParameter, 3); - assert.strictEqual(liveSignature('fun1(1, ex = ', summaries)?.activeParameter, 2, 'R does not partially match names after ...'); - assert.strictEqual(liveSignature('fn(be = ', { fn: { type: 'closure', str: 'function (alpha, beta)' } })?.activeParameter, 1); + assert.strictEqual( + liveSignature('fun1(1, ex = ', summaries)?.activeParameter, + 2, + 'R does not partially match names after ...', + ); + assert.strictEqual( + liveSignature('fn(be = ', { fn: { type: 'closure', str: 'function (alpha, beta)' } }) + ?.activeParameter, + 1, + ); }); test('supports multiline calls and quoted or Unicode function names', () => { - const help = liveSignature('`my function`(\n1, ', { 'my function': { type: 'closure', str: 'function (x, y)' } }); + const help = liveSignature('`my function`(\n1, ', { + 'my function': { type: 'closure', str: 'function (x, y)' }, + }); assert.strictEqual(help?.label, '`my function`(x, y)'); assert.strictEqual(help?.activeParameter, 1); - assert.strictEqual(liveSignature('函数(', { 函数: { type: 'closure', str: 'function (x)' } })?.label, '函数(x)'); + assert.strictEqual( + liveSignature('函数(', { 函数: { type: 'closure', str: 'function (x)' } })?.label, + '函数(x)', + ); }); test('defers unknown, qualified, nested, closed, invalid, and truncated calls', () => { - for (const code of ['unknown(', 'pkg::fun1(', 'pkg:::fun1(', 'obj$fun1(', 'obj@fun1(', 'fun1(list(', 'fun1()', 'fun1(]']) { + for (const code of [ + 'unknown(', + 'pkg::fun1(', + 'pkg:::fun1(', + 'obj$fun1(', + 'obj@fun1(', + 'fun1(list(', + 'fun1()', + 'fun1(]', + ]) { assert.strictEqual(liveSignature(code, summaries), undefined, code); } - assert.strictEqual(liveSignature('fun1(', { fun1: { type: 'closure', str: 'function (x, ...' } }), undefined); - assert.strictEqual(liveSignature('fun1(', { fun1: { type: 'double', str: 'num 1' } }), undefined); + assert.strictEqual( + liveSignature('fun1(', { fun1: { type: 'closure', str: 'function (x, ...' } }), + undefined, + ); + assert.strictEqual( + liveSignature('fun1(', { fun1: { type: 'double', str: 'num 1' } }), + undefined, + ); }); }); diff --git a/src/test/suite/standardPlotViewer.test.ts b/src/test/suite/standardPlotViewer.test.ts index 29b42de64..06ad2b150 100644 --- a/src/test/suite/standardPlotViewer.test.ts +++ b/src/test/suite/standardPlotViewer.test.ts @@ -16,7 +16,10 @@ interface Deferred { function deferred(): Deferred { let resolve!: (value: T) => void; let reject!: (error: Error) => void; - const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); return { promise, resolve, reject }; } @@ -42,11 +45,14 @@ suite('Standard plot viewer request coordination', () => { sandbox.stub(session, 'globalPipePath').value('test-pipe'); activeSession = sandbox.stub(session, 'activeSession').value(undefined); sandbox.stub(util, 'config').returns({ - get: (key: string, defaultValue?: unknown) => key === 'plot.format' ? 'svglite' : defaultValue + get: (key: string, defaultValue?: unknown) => + key === 'plot.format' ? 'svglite' : defaultValue, } as unknown as vscode.WorkspaceConfiguration); sandbox.stub(vscode.window, 'createWebviewPanel').callsFake(() => { const disposed = new vscode.EventEmitter(); - let receiveMessage: ((message: { type: string; width?: number; height?: number }) => Promise) | undefined; + let receiveMessage: + | ((message: { type: string; width?: number; height?: number }) => Promise) + | undefined; const postMessage = sandbox.stub().resolves(true); const panel = { title: 'R Plot', @@ -55,15 +61,21 @@ suite('Standard plot viewer request coordination', () => { webview: { html: '', postMessage, - onDidReceiveMessage: (listener: typeof receiveMessage) => { receiveMessage = listener; }, + onDidReceiveMessage: (listener: typeof receiveMessage) => { + receiveMessage = listener; + }, }, onDidDispose: disposed.event, - dispose: () => { disposed.fire(); disposed.dispose(); }, + dispose: () => { + disposed.fire(); + disposed.dispose(); + }, } as unknown as vscode.WebviewPanel; panels.push({ panel, postMessage, - resize: (width, height) => receiveMessage?.({ type: 'resize', width, height }) ?? Promise.resolve(), + resize: (width, height) => + receiveMessage?.({ type: 'resize', width, height }) ?? Promise.resolve(), }); return panel; }); @@ -82,8 +94,13 @@ suite('Standard plot viewer request coordination', () => { const secondStarted = deferred(); const thirdStarted = deferred(); const request = sandbox.stub(session, 'sessionRequest').callsFake(() => { - if (request.callCount === 1) { return firstRequest.promise; } - if (request.callCount === 2) { secondStarted.resolve(); return secondRequest.promise; } + if (request.callCount === 1) { + return firstRequest.promise; + } + if (request.callCount === 2) { + secondStarted.resolve(); + return secondRequest.promise; + } thirdStarted.resolve(); return thirdRequest.promise; }); @@ -101,7 +118,7 @@ suite('Standard plot viewer request coordination', () => { sinon.assert.calledTwice(request); assert.deepStrictEqual(request.secondCall.args[0], { method: 'plot_latest', - params: { width: 300, height: 500, format: 'svglite', devArgs: undefined } + params: { width: 300, height: 500, format: 'svglite', devArgs: undefined }, }); const pendingResize = firstPanel.resize(700, 800); @@ -112,11 +129,20 @@ suite('Standard plot viewer request coordination', () => { sinon.assert.calledThrice(request); assert.deepStrictEqual(request.thirdCall.args[0], { method: 'plot_latest', - params: { width: 700, height: 800, format: 'svglite', devArgs: undefined } + params: { width: 700, height: 800, format: 'svglite', devArgs: undefined }, }); thirdRequest.resolve({ data: 'third', format: 'svg' }); - await Promise.all([firstResize, update, latestResize, anotherResize, pendingResize, pendingUpdate]); - sinon.assert.calledOnce(firstPanel.postMessage.withArgs({ type: 'update', data: 'third', format: 'svg' })); + await Promise.all([ + firstResize, + update, + latestResize, + anotherResize, + pendingResize, + pendingUpdate, + ]); + sinon.assert.calledOnce( + firstPanel.postMessage.withArgs({ type: 'update', data: 'third', format: 'svg' }), + ); }); test('does not retry a rejected same-session request queued during the failure', async () => { @@ -141,7 +167,9 @@ suite('Standard plot viewer request coordination', () => { const secondRequest = deferred(); const secondStarted = deferred(); const request = sandbox.stub(session, 'sessionRequest').callsFake(() => { - if (request.callCount === 1) { return firstRequest.promise; } + if (request.callCount === 1) { + return firstRequest.promise; + } secondStarted.resolve(); return secondRequest.promise; }); @@ -168,7 +196,9 @@ suite('Standard plot viewer request coordination', () => { secondRequest.resolve({ data: 'current', format: 'svg' }); await Promise.all([oldResize, newResize]); sinon.assert.calledOnceWithExactly(newPanel.postMessage, { - type: 'update', data: 'current', format: 'svg' + type: 'update', + data: 'current', + format: 'svg', }); }); @@ -195,7 +225,9 @@ suite('Standard plot viewer request coordination', () => { const secondRequest = deferred(); const secondStarted = deferred(); const request = sandbox.stub(session, 'sessionRequest').callsFake(() => { - if (request.callCount === 1) { return firstRequest.promise; } + if (request.callCount === 1) { + return firstRequest.promise; + } secondStarted.resolve(); return secondRequest.promise; }); @@ -213,7 +245,9 @@ suite('Standard plot viewer request coordination', () => { secondRequest.resolve({ data: 'B plot', format: 'svg' }); await Promise.all([firstResize, sessionBUpdate]); sinon.assert.calledOnceWithExactly(panels[0].postMessage, { - type: 'update', data: 'B plot', format: 'svg' + type: 'update', + data: 'B plot', + format: 'svg', }); }); @@ -225,7 +259,9 @@ suite('Standard plot viewer request coordination', () => { const secondRequest = deferred(); const secondStarted = deferred(); const request = sandbox.stub(session, 'sessionRequest').callsFake(() => { - if (request.callCount === 1) { return firstRequest.promise; } + if (request.callCount === 1) { + return firstRequest.promise; + } secondStarted.resolve(); return secondRequest.promise; }); @@ -242,7 +278,9 @@ suite('Standard plot viewer request coordination', () => { secondRequest.resolve({ data: 'B plot', format: 'svg' }); await Promise.all([firstResize, sessionBUpdate]); sinon.assert.calledOnceWithExactly(panels[0].postMessage, { - type: 'update', data: 'B plot', format: 'svg' + type: 'update', + data: 'B plot', + format: 'svg', }); }); @@ -255,7 +293,9 @@ suite('Standard plot viewer request coordination', () => { const secondRequest = deferred(); const secondStarted = deferred(); const request = sandbox.stub(session, 'sessionRequest').callsFake(() => { - if (request.callCount === 1) { return firstRequest.promise; } + if (request.callCount === 1) { + return firstRequest.promise; + } secondStarted.resolve(); return secondRequest.promise; }); @@ -275,7 +315,9 @@ suite('Standard plot viewer request coordination', () => { secondRequest.resolve({ data: 'current C', format: 'svg' }); await Promise.all([firstResize, updateB, updateC]); sinon.assert.calledOnceWithExactly(panels[0].postMessage, { - type: 'update', data: 'current C', format: 'svg' + type: 'update', + data: 'current C', + format: 'svg', }); }); @@ -319,7 +361,9 @@ suite('Standard plot viewer request coordination', () => { }); test('waits for the initial webview resize before requesting the first plot', async () => { - const request = sandbox.stub(session, 'sessionRequest').resolves({ data: 'plot', format: 'svg' }); + const request = sandbox + .stub(session, 'sessionRequest') + .resolves({ data: 'plot', format: 'svg' }); await viewer.update(); sinon.assert.notCalled(request); await panels[0].resize(640, 480); diff --git a/src/test/suite/terminal.test.ts b/src/test/suite/terminal.test.ts index 78d75bd3e..975c7e9bc 100644 --- a/src/test/suite/terminal.test.ts +++ b/src/test/suite/terminal.test.ts @@ -17,10 +17,10 @@ const extension_root: string = path.join(__dirname, '..', '..', '..'); async function waitForDiscoveryRemoval(filePath: string): Promise { const deadline = Date.now() + 1000; while (Date.now() < deadline) { - if (!await pathExists(filePath)) { + if (!(await pathExists(filePath))) { return; } - await new Promise(resolve => setTimeout(resolve, 10)); + await new Promise((resolve) => setTimeout(resolve, 10)); } assert.fail(`Timed out waiting for discovery file removal: ${filePath}`); } @@ -29,27 +29,37 @@ suite('R Terminal', () => { let sandbox: sinon.SinonSandbox; let consentDirectoryStub: sinon.SinonStub; - function configuration(values: Record = {}, defaults: Record = {}, workspaceFolderScope = false): vscode.WorkspaceConfiguration { + function configuration( + values: Record = {}, + defaults: Record = {}, + workspaceFolderScope = false, + ): vscode.WorkspaceConfiguration { return { - get: (key: string, defaultValue?: unknown) => values[key] ?? defaults[key] ?? defaultValue, - inspect: (key: string) => values[key] === undefined - ? undefined - : workspaceFolderScope ? { workspaceFolderValue: values[key] } : { globalValue: values[key] } + get: (key: string, defaultValue?: unknown) => + values[key] ?? defaults[key] ?? defaultValue, + inspect: (key: string) => + values[key] === undefined + ? undefined + : workspaceFolderScope + ? { workspaceFolderValue: values[key] } + : { globalValue: values[key] }, } as unknown as vscode.WorkspaceConfiguration; } async function sendDelayFor( values: Record, defaults: Record = {}, - settings = configuration(values, defaults) + settings = configuration(values, defaults), ): Promise { const resource = vscode.Uri.file(path.join(path.sep, 'workspace', 'project')); - sandbox.stub(util, 'getCurrentWorkspaceFolder').returns({ uri: resource } as vscode.WorkspaceFolder); + sandbox + .stub(util, 'getCurrentWorkspaceFolder') + .returns({ uri: resource } as vscode.WorkspaceFolder); sandbox.stub(util, 'config').returns(settings); const terminal = { name: 'R Interactive', show: () => undefined, - sendText: () => undefined + sendText: () => undefined, }; sandbox.stub(vscode.window, 'terminals').value([terminal as unknown as vscode.Terminal]); sandbox.stub(vscode.window, 'activeTerminal').value(terminal as unknown as vscode.Terminal); @@ -62,7 +72,9 @@ suite('R Terminal', () => { await session.shutdownSessionWatcher(); sandbox = sinon.createSandbox(); mockExtensionContext(extension_root, sandbox); - consentDirectoryStub = sandbox.stub(session, 'getSessConsentDirectory').resolves('/unused-test-consent'); + consentDirectoryStub = sandbox + .stub(session, 'getSessConsentDirectory') + .resolves('/unused-test-consent'); session.deploySessionWatcher(extension_root); await session.getGlobalPipePath(); }); @@ -84,12 +96,19 @@ suite('R Terminal', () => { test(`initial sends share terminal creation and readiness (watcher: ${String(watcher)}, args: ${args.join(' ')}, target picker: ${String(createTarget)})`, async () => { // Offer creation only while no terminal exists, as the default // execution target picker does after choosing a terminal. - const target = sandbox.stub(executionTarget, 'tryInteractiveExecution') - .callsFake((_code, _resource, _source, offerTarget) => Promise.resolve(createTarget && offerTarget ? 'createTerminal' : false)); + const target = sandbox + .stub(executionTarget, 'tryInteractiveExecution') + .callsFake((_code, _resource, _source, offerTarget) => + Promise.resolve(createTarget && offerTarget ? 'createTerminal' : false), + ); let resolveReady!: (ready: boolean) => void; - const readiness = new Promise(resolve => { resolveReady = resolve; }); + const readiness = new Promise((resolve) => { + resolveReady = resolve; + }); let startedWaiting!: () => void; - const waiting = new Promise(resolve => { startedWaiting = resolve; }); + const waiting = new Promise((resolve) => { + startedWaiting = resolve; + }); const readyStub = sandbox.stub(session, 'waitForTerminalReady').callsFake(() => { startedWaiting(); return readiness; @@ -98,16 +117,25 @@ suite('R Terminal', () => { startedWaiting(); await readiness; }); - sandbox.stub(util, 'config').returns(configuration({ - sessionWatcher: watcher, consoleArgs: args, bracketedPaste: true, 'source.focus': 'none' - })); + sandbox.stub(util, 'config').returns( + configuration({ + sessionWatcher: watcher, + consoleArgs: args, + bracketedPaste: true, + 'source.focus': 'none', + }), + ); sandbox.stub(util, 'getRterm').resolves(process.execPath); - const pipe = sandbox.stub(session, 'getGlobalPipePath').resolves('unused-test-endpoint'); + const pipe = sandbox + .stub(session, 'getGlobalPipePath') + .resolves('unused-test-endpoint'); sandbox.stub(session, 'createSessionDiscoveryFile').resolves('/unused-test-discovery'); const sent: string[] = []; const terminal = { - name: 'R Interactive', processId: Promise.resolve(undefined), - show: () => undefined, dispose: () => undefined, + name: 'R Interactive', + processId: Promise.resolve(undefined), + show: () => undefined, + dispose: () => undefined, sendText: (text: string) => sent.push(text), } as unknown as vscode.Terminal; const create = sandbox.stub(vscode.window, 'createTerminal').returns(terminal); @@ -123,13 +151,25 @@ suite('R Terminal', () => { terminals.value([terminal]); const third = rTerminal.runTextInTerm('third'); commands.push(third); - for (let i = 0; i < 20; i++) { await Promise.resolve(); } + for (let i = 0; i < 20; i++) { + await Promise.resolve(); + } assert.deepStrictEqual(sent, []); sinon.assert.calledOnce(create); if (integrated) { - const startupToken = (create.firstCall.args[0] as vscode.TerminalOptions).env?.['VSCODE_R_SESS_STARTUP_TOKEN']; - if (typeof startupToken !== 'string') { throw new Error('managed terminal should receive a startup token'); } - sinon.assert.calledOnceWithExactly(readyStub, terminal, 30000, '/unused-test-discovery.startup', startupToken); + const startupToken = (create.firstCall.args[0] as vscode.TerminalOptions).env?.[ + 'VSCODE_R_SESS_STARTUP_TOKEN' + ]; + if (typeof startupToken !== 'string') { + throw new Error('managed terminal should receive a startup token'); + } + sinon.assert.calledOnceWithExactly( + readyStub, + terminal, + 30000, + '/unused-test-discovery.startup', + startupToken, + ); sinon.assert.notCalled(delayStub); sinon.assert.calledOnce(pipe); } else { @@ -139,7 +179,11 @@ suite('R Terminal', () => { } resolveReady(true); await Promise.all(commands); - assert.deepStrictEqual(sent, ['\x1b[200~first\x1b[201~', '\x1b[200~second\x1b[201~', '\x1b[200~third\x1b[201~']); + assert.deepStrictEqual(sent, [ + '\x1b[200~first\x1b[201~', + '\x1b[200~second\x1b[201~', + '\x1b[200~third\x1b[201~', + ]); assert.strictEqual(consentDirectoryStub.called, integrated); await rTerminal.runTextInTerm('fourth'); assert.strictEqual(sent.length, 4); @@ -155,7 +199,13 @@ suite('R Terminal', () => { } test('failed readiness does not send code and allows retry', async () => { - sandbox.stub(util, 'config').returns(configuration({ sessionWatcher: true, bracketedPaste: true, 'source.focus': 'none' })); + sandbox.stub(util, 'config').returns( + configuration({ + sessionWatcher: true, + bracketedPaste: true, + 'source.focus': 'none', + }), + ); sandbox.stub(util, 'getRterm').resolves(process.execPath); sandbox.stub(session, 'getGlobalPipePath').resolves('unused-test-endpoint'); sandbox.stub(session, 'createSessionDiscoveryFile').resolves('/unused-test-discovery'); @@ -164,27 +214,47 @@ suite('R Terminal', () => { ready.onSecondCall().resolves(true); const sendText = sandbox.stub(); const terminal = { - name: 'R Interactive', processId: Promise.resolve(undefined), - show: () => undefined, sendText, + name: 'R Interactive', + processId: Promise.resolve(undefined), + show: () => undefined, + sendText, } as unknown as vscode.Terminal; const createTerminal = sandbox.stub(vscode.window, 'createTerminal').returns(terminal); sandbox.stub(vscode.window, 'terminals').value([terminal]); sandbox.stub(vscode.window, 'activeTerminal').value(terminal); const warning = sandbox.stub(vscode.window, 'showWarningMessage').resolves(undefined); await rTerminal.createRTerm(); - const startupToken = (createTerminal.firstCall.args[0] as vscode.TerminalOptions).env?.['VSCODE_R_SESS_STARTUP_TOKEN']; - if (typeof startupToken !== 'string') { throw new Error('managed terminal should receive a startup token'); } + const startupToken = (createTerminal.firstCall.args[0] as vscode.TerminalOptions).env?.[ + 'VSCODE_R_SESS_STARTUP_TOKEN' + ]; + if (typeof startupToken !== 'string') { + throw new Error('managed terminal should receive a startup token'); + } await rTerminal.runTextInTerm('first'); sinon.assert.notCalled(sendText); sinon.assert.calledOnce(warning); - assert.ok(String(warning.firstCall.args[0]).includes('r.sessionWatcher'), - 'the warning should explain how to opt out of session integration'); - assert.ok(String(warning.firstCall.args[0]).includes('reload VS Code'), - 'the warning should explain how to apply the setting to an existing terminal'); + assert.ok( + String(warning.firstCall.args[0]).includes('r.sessionWatcher'), + 'the warning should explain how to opt out of session integration', + ); + assert.ok( + String(warning.firstCall.args[0]).includes('reload VS Code'), + 'the warning should explain how to apply the setting to an existing terminal', + ); await rTerminal.runTextInTerm('retry'); sinon.assert.calledOnceWithExactly(sendText, '\x1b[200~retry\x1b[201~', true); - assert.deepStrictEqual(ready.firstCall.args, [terminal, 30000, '/unused-test-discovery.startup', startupToken]); - assert.deepStrictEqual(ready.secondCall.args, [terminal, 30000, '/unused-test-discovery.startup', startupToken]); + assert.deepStrictEqual(ready.firstCall.args, [ + terminal, + 30000, + '/unused-test-discovery.startup', + startupToken, + ]); + assert.deepStrictEqual(ready.secondCall.args, [ + terminal, + 30000, + '/unused-test-discovery.startup', + startupToken, + ]); rTerminal.deleteTerminal(terminal); }); @@ -200,7 +270,6 @@ suite('R Terminal', () => { terminals.value([stopped, hidden]); assert.strictEqual(rTerminal.findTerminal(), undefined); sinon.assert.notCalled(create); - }); test('makeTerminalOptions respects legacy plot.useHttpgd configurations', async () => { @@ -220,9 +289,11 @@ suite('R Terminal', () => { return ['--no-save']; } return defaultValue; - } + }, }; - sandbox.stub(util, 'config').returns(configStub as unknown as vscode.WorkspaceConfiguration); + sandbox + .stub(util, 'config') + .returns(configStub as unknown as vscode.WorkspaceConfiguration); sandbox.stub(util, 'getRterm').resolves(process.execPath); const options = await rTerminal.makeTerminalOptions(); @@ -240,7 +311,11 @@ suite('R Terminal', () => { throw new Error('SESS_DISCOVERY_FILE should be a string path'); } const discovery: unknown = await readJson(discoveryFile); - assert.deepStrictEqual(discovery, { version: 1, endpoint: session.globalPipePath, jgdSocket: '' }); + assert.deepStrictEqual(discovery, { + version: 1, + endpoint: session.globalPipePath, + jgdSocket: '', + }); } finally { if (typeof discoveryFile === 'string') { await fsp.rm(discoveryFile, { recursive: true, force: true }); @@ -259,7 +334,10 @@ suite('R Terminal', () => { processId: Promise.resolve(45239), creationOptions: { name: 'R Interactive', - env: { SESS_DISCOVERY_FILE: discoveryFile, VSCODE_R_SESS_STARTUP_FILE: startupFile }, + env: { + SESS_DISCOVERY_FILE: discoveryFile, + VSCODE_R_SESS_STARTUP_FILE: startupFile, + }, }, exitStatus: { code: undefined, reason: vscode.TerminalExitReason.User }, } as unknown as vscode.Terminal; @@ -287,20 +365,32 @@ suite('R Terminal', () => { const shutdownTerminal = { name: 'R Interactive', processId: Promise.resolve(45241), - creationOptions: { name: 'R Interactive', env: { SESS_DISCOVERY_FILE: shutdownFile, VSCODE_R_SESS_STARTUP_FILE: shutdownStartupFile } }, + creationOptions: { + name: 'R Interactive', + env: { + SESS_DISCOVERY_FILE: shutdownFile, + VSCODE_R_SESS_STARTUP_FILE: shutdownStartupFile, + }, + }, exitStatus: { code: undefined, reason: vscode.TerminalExitReason.Shutdown }, } as unknown as vscode.Terminal; const unknownTerminal = { name: 'R Interactive', processId: Promise.resolve(45243), - creationOptions: { name: 'R Interactive', env: { SESS_DISCOVERY_FILE: unknownFile, VSCODE_R_SESS_STARTUP_FILE: unknownStartupFile } }, + creationOptions: { + name: 'R Interactive', + env: { + SESS_DISCOVERY_FILE: unknownFile, + VSCODE_R_SESS_STARTUP_FILE: unknownStartupFile, + }, + }, exitStatus: { code: undefined, reason: vscode.TerminalExitReason.Unknown }, } as unknown as vscode.Terminal; try { rTerminal.deleteTerminal(shutdownTerminal); rTerminal.deleteTerminal(unknownTerminal); - await new Promise(resolve => setTimeout(resolve, 10)); + await new Promise((resolve) => setTimeout(resolve, 10)); assert.strictEqual(await pathExists(shutdownFile), true); assert.strictEqual(await pathExists(unknownFile), true); assert.strictEqual(await pathExists(shutdownStartupFile), true); @@ -346,9 +436,11 @@ suite('R Terminal', () => { return true; } return defaultValue; - } + }, }; - sandbox.stub(util, 'config').returns(configStub as unknown as vscode.WorkspaceConfiguration); + sandbox + .stub(util, 'config') + .returns(configStub as unknown as vscode.WorkspaceConfiguration); sandbox.stub(util, 'getRterm').resolves(process.execPath); const options = await rTerminal.makeTerminalOptions(); @@ -358,11 +450,13 @@ suite('R Terminal', () => { }); test('makeTerminalOptions passes native to sess without a JGD socket', async () => { - sandbox.stub(util, 'config').returns(configuration({ - sessionWatcher: true, - 'plot.backend': 'native', - 'plot.useHttpgd': true, - })); + sandbox.stub(util, 'config').returns( + configuration({ + sessionWatcher: true, + 'plot.backend': 'native', + 'plot.useHttpgd': true, + }), + ); sandbox.stub(util, 'getRterm').resolves(process.execPath); const options = await rTerminal.makeTerminalOptions(); const discoveryFile = options.env?.['SESS_DISCOVERY_FILE']; @@ -383,9 +477,11 @@ suite('R Terminal', () => { return false; } return undefined; - } + }, }; - sandbox.stub(util, 'config').returns(configStub as unknown as vscode.WorkspaceConfiguration); + sandbox + .stub(util, 'config') + .returns(configStub as unknown as vscode.WorkspaceConfiguration); sandbox.stub(util, 'getRterm').resolves(process.execPath); const options = await rTerminal.makeTerminalOptions(); @@ -394,10 +490,12 @@ suite('R Terminal', () => { }); test('makeTerminalOptions prefers canonical consoleArgs to legacy rterm.option', async () => { - sandbox.stub(util, 'config').returns(configuration({ - consoleArgs: ['--vanilla'], - 'rterm.option': ['--no-save'] - })); + sandbox.stub(util, 'config').returns( + configuration({ + consoleArgs: ['--vanilla'], + 'rterm.option': ['--no-save'], + }), + ); sandbox.stub(util, 'getRterm').resolves(process.execPath); const options = await rTerminal.makeTerminalOptions(); @@ -406,9 +504,14 @@ suite('R Terminal', () => { }); test('makeTerminalOptions uses customized legacy console args when canonical setting is unset', async () => { - sandbox.stub(util, 'config').returns(configuration({ 'rterm.option': ['--no-save', '--quiet'] }, { - consoleArgs: ['--no-save', '--no-restore'] - })); + sandbox.stub(util, 'config').returns( + configuration( + { 'rterm.option': ['--no-save', '--quiet'] }, + { + consoleArgs: ['--no-save', '--no-restore'], + }, + ), + ); sandbox.stub(util, 'getRterm').resolves(process.execPath); const options = await rTerminal.makeTerminalOptions(); @@ -429,7 +532,9 @@ suite('R Terminal', () => { }); test('makeTerminalOptions keeps existing console args defaults when neither setting is explicit', async () => { - sandbox.stub(util, 'config').returns(configuration({}, { consoleArgs: ['--no-save', '--no-restore'] })); + sandbox + .stub(util, 'config') + .returns(configuration({}, { consoleArgs: ['--no-save', '--no-restore'] })); sandbox.stub(util, 'getRterm').resolves(process.execPath); const options = await rTerminal.makeTerminalOptions(); @@ -450,15 +555,23 @@ suite('R Terminal', () => { const settings = configuration(); sandbox.stub(settings, 'inspect').callsFake((key: string) => ({ key, - ...(key === canonicalKey ? { [canonicalScope]: canonicalValue } - : key === legacyKey ? { [legacyScope]: legacyValue } : {}) + ...(key === canonicalKey + ? { [canonicalScope]: canonicalValue } + : key === legacyKey + ? { [legacyScope]: legacyValue } + : {}), })); - const expected = scopes.indexOf(canonicalScope) >= scopes.indexOf(legacyScope) - ? canonicalValue : legacyValue; + const expected = + scopes.indexOf(canonicalScope) >= scopes.indexOf(legacyScope) + ? canonicalValue + : legacyValue; if (setting === 'args') { sandbox.stub(util, 'config').returns(settings); sandbox.stub(util, 'getRterm').resolves(process.execPath); - assert.deepStrictEqual((await rTerminal.makeTerminalOptions()).shellArgs, expected); + assert.deepStrictEqual( + (await rTerminal.makeTerminalOptions()).shellArgs, + expected, + ); } else { assert.strictEqual(await sendDelayFor({}, {}, settings), expected); } @@ -474,22 +587,27 @@ suite('R Terminal', () => { // Variable substitution also inspects the active editor. Keep this // explicit-resource test independent of preceding editor suites. sandbox.stub(vscode.window, 'activeTextEditor').value(undefined); - sandbox.stub(vscode.workspace, 'workspaceFolders').value( - hasWorkspace ? [{ uri: folder } as vscode.WorkspaceFolder] : undefined - ); - sandbox.stub(vscode.workspace, 'getWorkspaceFolder').callsFake(resource => { + sandbox + .stub(vscode.workspace, 'workspaceFolders') + .value(hasWorkspace ? [{ uri: folder } as vscode.WorkspaceFolder] : undefined); + sandbox.stub(vscode.workspace, 'getWorkspaceFolder').callsFake((resource) => { assert.strictEqual(resource, file); - return hasWorkspace ? { uri: folder } as vscode.WorkspaceFolder : undefined; + return hasWorkspace ? ({ uri: folder } as vscode.WorkspaceFolder) : undefined; }); - const configStub = sandbox.stub(util, 'config').returns(configuration({ - consoleArgs: hasWorkspace ? ['--project=${workspaceFolder}'] : ['--quiet'] - })); + const configStub = sandbox.stub(util, 'config').returns( + configuration({ + consoleArgs: hasWorkspace ? ['--project=${workspaceFolder}'] : ['--quiet'], + }), + ); const pathStub = sandbox.stub(util, 'getRterm').resolves(process.execPath); const options = await rTerminal.makeTerminalOptions(file); assert.strictEqual(options.cwd, hasWorkspace ? folder.fsPath : undefined); - assert.deepStrictEqual(options.shellArgs, hasWorkspace ? [`--project=${folder.fsPath}`] : ['--quiet']); + assert.deepStrictEqual( + options.shellArgs, + hasWorkspace ? [`--project=${folder.fsPath}`] : ['--quiet'], + ); assert.ok(configStub.calledWithExactly(file)); assert.ok(pathStub.calledWithExactly(file)); }); @@ -497,11 +615,17 @@ suite('R Terminal', () => { test('createRTerm reports an invalid legacy console path setting only once', async () => { const legacySetting = util.getRPathConfigEntry(true); - const settings: Record = { consolePath: '', [legacySetting]: '/missing/r-console', executablePath: '' }; + const settings: Record = { + consolePath: '', + [legacySetting]: '/missing/r-console', + executablePath: '', + }; const configStub = { - get: (key: string) => key === 'sessionWatcher' ? false : settings[key] + get: (key: string) => (key === 'sessionWatcher' ? false : settings[key]), }; - sandbox.stub(vscode.workspace, 'getConfiguration').returns(configStub as unknown as vscode.WorkspaceConfiguration); + sandbox + .stub(vscode.workspace, 'getConfiguration') + .returns(configStub as unknown as vscode.WorkspaceConfiguration); const errorStub = sandbox.stub(vscode.window, 'showErrorMessage').resolves(undefined); assert.strictEqual(await rTerminal.createRTerm(), false); @@ -512,11 +636,16 @@ suite('R Terminal', () => { test('getRterm reports an invalid canonical consolePath setting clearly', async () => { const legacySetting = util.getRPathConfigEntry(true); - const settings: Record = { consolePath: '/missing/canonical-console', [legacySetting]: '/legacy/r-console' }; + const settings: Record = { + consolePath: '/missing/canonical-console', + [legacySetting]: '/legacy/r-console', + }; const configStub = { - get: (key: string) => settings[key] + get: (key: string) => settings[key], }; - sandbox.stub(vscode.workspace, 'getConfiguration').returns(configStub as unknown as vscode.WorkspaceConfiguration); + sandbox + .stub(vscode.workspace, 'getConfiguration') + .returns(configStub as unknown as vscode.WorkspaceConfiguration); const errorStub = sandbox.stub(vscode.window, 'showErrorMessage').resolves(undefined); assert.strictEqual(await util.getRterm(), undefined); @@ -528,19 +657,25 @@ suite('R Terminal', () => { test('console arguments, substitution, and send delay use the R terminal workspace resource', async () => { const consoleSendDelay = 17; const resource = vscode.Uri.file(path.join(path.sep, 'workspace', 'project')); - sandbox.stub(util, 'getCurrentWorkspaceFolder').returns({ uri: resource } as vscode.WorkspaceFolder); + sandbox + .stub(util, 'getCurrentWorkspaceFolder') + .returns({ uri: resource } as vscode.WorkspaceFolder); const requestedResources: Array = []; - const configStub = configuration({ - consoleArgs: ['--project=${workspaceFolder}'], - 'rterm.option': ['--legacy'], - consoleSendDelay, - rtermSendDelay: 4 - }, {}, true); + const configStub = configuration( + { + consoleArgs: ['--project=${workspaceFolder}'], + 'rterm.option': ['--legacy'], + consoleSendDelay, + rtermSendDelay: 4, + }, + {}, + true, + ); sandbox.stub(util, 'config').callsFake((requestedResource?: vscode.Uri) => { requestedResources.push(requestedResource); return configStub; }); - sandbox.stub(util, 'getRterm').callsFake(requestedResource => { + sandbox.stub(util, 'getRterm').callsFake((requestedResource) => { assert.strictEqual(requestedResource, resource); return Promise.resolve(process.execPath); }); @@ -554,11 +689,17 @@ suite('R Terminal', () => { processId: Promise.resolve(undefined), show: () => undefined, dispose: () => undefined, - sendText: (text: string) => sent.push(text) + sendText: (text: string) => sent.push(text), }; - sandbox.stub(vscode.window, 'createTerminal').returns(fakeTerminal as unknown as vscode.Terminal); - sandbox.stub(vscode.window, 'terminals').value([fakeTerminal as unknown as vscode.Terminal]); - sandbox.stub(vscode.window, 'activeTerminal').value(fakeTerminal as unknown as vscode.Terminal); + sandbox + .stub(vscode.window, 'createTerminal') + .returns(fakeTerminal as unknown as vscode.Terminal); + sandbox + .stub(vscode.window, 'terminals') + .value([fakeTerminal as unknown as vscode.Terminal]); + sandbox + .stub(vscode.window, 'activeTerminal') + .value(fakeTerminal as unknown as vscode.Terminal); const delayStub = sandbox.stub(util, 'delay').resolves(); assert.strictEqual(await rTerminal.createRTerm(), true); @@ -566,9 +707,15 @@ suite('R Terminal', () => { await rTerminal.runTextInTerm('first\nsecond'); assert.deepStrictEqual(options.shellArgs, [`--project=${resource.fsPath}`]); - assert.ok(requestedResources.includes(resource), 'configuration should be requested for the R terminal resource'); - assert.deepStrictEqual(delayStub.args, [[200], [consoleSendDelay]], - 'startup fallback should be followed by the explicit canonical per-line send delay'); + assert.ok( + requestedResources.includes(resource), + 'configuration should be requested for the R terminal resource', + ); + assert.deepStrictEqual( + delayStub.args, + [[200], [consoleSendDelay]], + 'startup fallback should be followed by the explicit canonical per-line send delay', + ); assert.deepStrictEqual(sent, ['first', 'second']); rTerminal.deleteTerminal(fakeTerminal as unknown as vscode.Terminal); }); @@ -576,12 +723,29 @@ suite('R Terminal', () => { for (const scheme of ['file', 'vscode-remote']) { const authority = scheme === 'vscode-remote' ? 'ssh-remote+test-host' : ''; test(`profile R terminal send delay preserves its ${scheme} workspace URI`, async () => { - const folderA = { uri: vscode.Uri.file(path.join(path.sep, 'workspace', 'a')).with({ scheme, authority }) } as vscode.WorkspaceFolder; - const folderB = { uri: vscode.Uri.file(path.join(path.sep, 'workspace', 'b')).with({ scheme, authority }) } as vscode.WorkspaceFolder; - sandbox.stub(vscode.window, 'activeTextEditor').value({ document: { uri: folderB.uri } } as vscode.TextEditor); + const folderA = { + uri: vscode.Uri.file(path.join(path.sep, 'workspace', 'a')).with({ + scheme, + authority, + }), + } as vscode.WorkspaceFolder; + const folderB = { + uri: vscode.Uri.file(path.join(path.sep, 'workspace', 'b')).with({ + scheme, + authority, + }), + } as vscode.WorkspaceFolder; + sandbox + .stub(vscode.window, 'activeTextEditor') + .value({ document: { uri: folderB.uri } } as vscode.TextEditor); sandbox.stub(vscode.workspace, 'workspaceFolders').value([folderA, folderB]); - sandbox.stub(vscode.workspace, 'getWorkspaceFolder').callsFake(resource => - [folderA, folderB].find(folder => folder.uri.toString() === resource.toString())); + sandbox + .stub(vscode.workspace, 'getWorkspaceFolder') + .callsFake((resource) => + [folderA, folderB].find( + (folder) => folder.uri.toString() === resource.toString(), + ), + ); const requestedResources: Array = []; sandbox.stub(util, 'config').callsFake((resource?: vscode.Uri) => { requestedResources.push(resource); @@ -597,15 +761,22 @@ suite('R Terminal', () => { name: 'R Interactive', creationOptions: { name: 'R Interactive', cwd: folderA.uri.fsPath }, show: () => undefined, - sendText: () => undefined + sendText: () => undefined, }; - sandbox.stub(vscode.window, 'terminals').value([terminal as unknown as vscode.Terminal]); - sandbox.stub(vscode.window, 'activeTerminal').value(terminal as unknown as vscode.Terminal); + sandbox + .stub(vscode.window, 'terminals') + .value([terminal as unknown as vscode.Terminal]); + sandbox + .stub(vscode.window, 'activeTerminal') + .value(terminal as unknown as vscode.Terminal); const delayStub = sandbox.stub(util, 'delay').resolves(); await rTerminal.runTextInTerm('first\nsecond'); - assert.ok(requestedResources.includes(folderA.uri), 'configuration should use the workspace containing the profile terminal cwd'); + assert.ok( + requestedResources.includes(folderA.uri), + 'configuration should use the workspace containing the profile terminal cwd', + ); assert.strictEqual(delayStub.firstCall.args[0], 31); }); } @@ -614,14 +785,17 @@ suite('R Terminal', () => { const backgroundRPath = '/background/R'; const consoleRPath = '/target/radian'; const configStub = { - get: (key: string) => ({ - sessionWatcher: true, - executablePath: backgroundRPath, - consolePath: consoleRPath, - 'source.focus': 'none', - })[key], + get: (key: string) => + ({ + sessionWatcher: true, + executablePath: backgroundRPath, + consolePath: consoleRPath, + 'source.focus': 'none', + })[key], }; - sandbox.stub(util, 'config').returns(configStub as unknown as vscode.WorkspaceConfiguration); + sandbox + .stub(util, 'config') + .returns(configStub as unknown as vscode.WorkspaceConfiguration); const getConsoleR = sandbox.stub(util, 'getRterm').callsFake(() => { assert.strictEqual(configStub.get('consolePath'), consoleRPath); assert.notStrictEqual(configStub.get('consolePath'), configStub.get('executablePath')); @@ -631,9 +805,13 @@ suite('R Terminal', () => { const queryR = sandbox.spy(util, 'executeRCommand'); const installTask = sandbox.spy(util, 'executeAsTask'); let finishSetup!: (result: string) => void; - const setupFinished = new Promise(resolve => { finishSetup = resolve; }); + const setupFinished = new Promise((resolve) => { + finishSetup = resolve; + }); let setupStarted!: () => void; - const started = new Promise(resolve => { setupStarted = resolve; }); + const started = new Promise((resolve) => { + setupStarted = resolve; + }); consentDirectoryStub.callsFake(() => { setupStarted(); return setupFinished; @@ -641,8 +819,10 @@ suite('R Terminal', () => { sandbox.stub(session, 'getGlobalPipePath').resolves('unused-test-endpoint'); sandbox.stub(session, 'createSessionDiscoveryFile').resolves('/unused-test-discovery'); const terminal = { - name: 'R Interactive', processId: Promise.resolve(undefined), - show: sandbox.stub(), dispose: sandbox.stub(), + name: 'R Interactive', + processId: Promise.resolve(undefined), + show: sandbox.stub(), + dispose: sandbox.stub(), } as unknown as vscode.Terminal; const createTerminal = sandbox.stub(vscode.window, 'createTerminal').returns(terminal); @@ -686,24 +866,30 @@ suite('R Terminal', () => { if (failedResource === 'discovery') { createDiscovery.rejects(failure); } else if (failedResource === 'helper-path') { - createDiscovery.callsFake(async endpoint => { + createDiscovery.callsFake(async (endpoint) => { const file = await createFile(endpoint); createdFiles.push(file); return file; }); - const asAbsolutePath = extension.extensionContext.asAbsolutePath.bind(extension.extensionContext); - sandbox.stub(extension.extensionContext, 'asAbsolutePath').callsFake(relativePath => { - if (relativePath.startsWith(path.join('dist', 'resources', 'sess'))) { - throw failure; - } - return asAbsolutePath(relativePath); - }); + const asAbsolutePath = extension.extensionContext.asAbsolutePath.bind( + extension.extensionContext, + ); + sandbox + .stub(extension.extensionContext, 'asAbsolutePath') + .callsFake((relativePath) => { + if (relativePath.startsWith(path.join('dist', 'resources', 'sess'))) { + throw failure; + } + return asAbsolutePath(relativePath); + }); } else { createDiscovery.resolves('/unused-test-discovery'); } const terminal = { - name: 'R Interactive', processId: Promise.resolve(undefined), - show: sandbox.stub(), dispose: sandbox.stub(), + name: 'R Interactive', + processId: Promise.resolve(undefined), + show: sandbox.stub(), + dispose: sandbox.stub(), } as unknown as vscode.Terminal; const createTerminal = sandbox.stub(vscode.window, 'createTerminal').returns(terminal); const warning = sandbox.stub(vscode.window, 'showWarningMessage').resolves(undefined); @@ -724,8 +910,14 @@ suite('R Terminal', () => { } } finally { rTerminal.deleteTerminal(terminal); - await Promise.all(createdFiles.map(file => fsp.rm(file, { recursive: true, force: true }))); - await Promise.all(createdFiles.map(file => fsp.rm(`${file}.startup`, { recursive: true, force: true }))); + await Promise.all( + createdFiles.map((file) => fsp.rm(file, { recursive: true, force: true })), + ); + await Promise.all( + createdFiles.map((file) => + fsp.rm(`${file}.startup`, { recursive: true, force: true }), + ), + ); } }); } @@ -733,9 +925,11 @@ suite('R Terminal', () => { test('createRTerm skips watcher setup when the configured executable is invalid', async () => { const createDiscoveryFile = sandbox.stub(session, 'createSessionDiscoveryFile'); const configStub = { - get: (key: string) => key === 'sessionWatcher' ? true : undefined, + get: (key: string) => (key === 'sessionWatcher' ? true : undefined), }; - sandbox.stub(util, 'config').returns(configStub as unknown as vscode.WorkspaceConfiguration); + sandbox + .stub(util, 'config') + .returns(configStub as unknown as vscode.WorkspaceConfiguration); sandbox.stub(util, 'getRterm').resolves(`${process.execPath}.does-not-exist`); assert.strictEqual(await rTerminal.createRTerm(), false); @@ -746,7 +940,7 @@ suite('R Terminal', () => { const createdDiscoveryFiles: string[] = []; const createdStartupFiles: string[] = []; const createDiscoveryFile = session.createSessionDiscoveryFile; - sandbox.stub(session, 'createSessionDiscoveryFile').callsFake(async endpoint => { + sandbox.stub(session, 'createSessionDiscoveryFile').callsFake(async (endpoint) => { const filePath = await createDiscoveryFile(endpoint); createdDiscoveryFiles.push(filePath); const startupPath = `${filePath}.startup`; @@ -755,9 +949,11 @@ suite('R Terminal', () => { return filePath; }); const configStub = { - get: (key: string) => key === 'sessionWatcher' ? true : undefined, + get: (key: string) => (key === 'sessionWatcher' ? true : undefined), }; - sandbox.stub(util, 'config').returns(configStub as unknown as vscode.WorkspaceConfiguration); + sandbox + .stub(util, 'config') + .returns(configStub as unknown as vscode.WorkspaceConfiguration); sandbox.stub(util, 'getRterm').resolves(process.execPath); sandbox.stub(vscode.window, 'createTerminal').throws(new Error('terminal creation failed')); @@ -767,8 +963,16 @@ suite('R Terminal', () => { assert.strictEqual(await pathExists(createdDiscoveryFiles[0]), false); assert.strictEqual(await pathExists(createdStartupFiles[0]), false); } finally { - await Promise.all(createdDiscoveryFiles.map(filePath => fsp.rm(filePath, { recursive: true, force: true }))); - await Promise.all(createdStartupFiles.map(filePath => fsp.rm(filePath, { recursive: true, force: true }))); + await Promise.all( + createdDiscoveryFiles.map((filePath) => + fsp.rm(filePath, { recursive: true, force: true }), + ), + ); + await Promise.all( + createdStartupFiles.map((filePath) => + fsp.rm(filePath, { recursive: true, force: true }), + ), + ); } }); @@ -776,43 +980,77 @@ suite('R Terminal', () => { function deferred() { let resolve!: (value: T) => void; let reject!: (error: unknown) => void; - const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); return { promise, resolve, reject }; } let resolveFirst!: (pid: number | undefined) => void; let resolveSecond!: (pid: number | undefined) => void; - const firstProcessId = new Promise(resolve => { resolveFirst = resolve; }); - const secondProcessId = new Promise(resolve => { resolveSecond = resolve; }); + const firstProcessId = new Promise((resolve) => { + resolveFirst = resolve; + }); + const secondProcessId = new Promise((resolve) => { + resolveSecond = resolve; + }); const firstTerminal = { - name: 'R Interactive', processId: firstProcessId, - show: () => undefined, dispose: () => undefined, + name: 'R Interactive', + processId: firstProcessId, + show: () => undefined, + dispose: () => undefined, } as unknown as vscode.Terminal; const secondTerminal = { - name: 'R Interactive', processId: secondProcessId, - show: () => undefined, dispose: () => undefined, + name: 'R Interactive', + processId: secondProcessId, + show: () => undefined, + dispose: () => undefined, } as unknown as vscode.Terminal; - const firstWrite = deferred<{ terminal: vscode.Terminal; filePath: string; pid: number | undefined }>(); - const secondWrite = deferred<{ terminal: vscode.Terminal; filePath: string; pid: number | undefined }>(); + const firstWrite = deferred<{ + terminal: vscode.Terminal; + filePath: string; + pid: number | undefined; + }>(); + const secondWrite = deferred<{ + terminal: vscode.Terminal; + filePath: string; + pid: number | undefined; + }>(); const updateDiscoveryFile = session.updateTerminalSessionDiscoveryFile; const pendingWrites: Promise[] = []; - sandbox.stub(session, 'updateTerminalSessionDiscoveryFile').callsFake((terminal, filePath, endpoint, pid) => { - const writing = updateDiscoveryFile(terminal, filePath, endpoint, pid).then(() => { - const write = { terminal, filePath, pid }; - if (terminal === firstTerminal) { firstWrite.resolve(write); } - if (terminal === secondTerminal) { secondWrite.resolve(write); } - }, error => { - if (terminal === firstTerminal) { firstWrite.reject(error); } - if (terminal === secondTerminal) { secondWrite.reject(error); } - throw error; + sandbox + .stub(session, 'updateTerminalSessionDiscoveryFile') + .callsFake((terminal, filePath, endpoint, pid) => { + const writing = updateDiscoveryFile(terminal, filePath, endpoint, pid).then( + () => { + const write = { terminal, filePath, pid }; + if (terminal === firstTerminal) { + firstWrite.resolve(write); + } + if (terminal === secondTerminal) { + secondWrite.resolve(write); + } + }, + (error) => { + if (terminal === firstTerminal) { + firstWrite.reject(error); + } + if (terminal === secondTerminal) { + secondWrite.reject(error); + } + throw error; + }, + ); + pendingWrites.push(writing); + return writing; }); - pendingWrites.push(writing); - return writing; - }); const configStub = { - get: (key: string) => key === 'sessionWatcher' ? true : undefined, + get: (key: string) => (key === 'sessionWatcher' ? true : undefined), }; - sandbox.stub(util, 'config').returns(configStub as unknown as vscode.WorkspaceConfiguration); + sandbox + .stub(util, 'config') + .returns(configStub as unknown as vscode.WorkspaceConfiguration); sandbox.stub(util, 'getRterm').resolves(process.execPath); const createTerminalStub = sandbox.stub(vscode.window, 'createTerminal'); createTerminalStub.onFirstCall().returns(firstTerminal); @@ -869,20 +1107,30 @@ suite('R Terminal', () => { return true; } return undefined; - } + }, }; - sandbox.stub(util, 'config').returns(configStub as unknown as vscode.WorkspaceConfiguration); + sandbox + .stub(util, 'config') + .returns(configStub as unknown as vscode.WorkspaceConfiguration); sandbox.stub(util, 'getRterm').resolves(process.execPath); // We need to mock the terminal and its processId const fakeTerminal = { name: 'R Interactive', processId: Promise.resolve(1234), - show: () => { /* empty */ }, - dispose: () => { /* empty */ }, - sendText: () => { /* empty */ } + show: () => { + /* empty */ + }, + dispose: () => { + /* empty */ + }, + sendText: () => { + /* empty */ + }, }; - const createTerminalStub = sandbox.stub(vscode.window, 'createTerminal').returns(fakeTerminal as unknown as vscode.Terminal); + const createTerminalStub = sandbox + .stub(vscode.window, 'createTerminal') + .returns(fakeTerminal as unknown as vscode.Terminal); const result = await rTerminal.createRTerm(true); assert.ok(result); @@ -896,7 +1144,10 @@ suite('R Terminal', () => { workingDir: '', workspaceData: { search: [], loaded_namespaces: [], globalenv: {} }, pipePath: '', - socket: { destroyed: true, destroy: () => undefined } as unknown as session.Session['socket'] + socket: { + destroyed: true, + destroy: () => undefined, + } as unknown as session.Session['socket'], }; await session.activateSession(fakeSession as unknown as session.Session); @@ -904,7 +1155,11 @@ suite('R Terminal', () => { assert.ok(session.activeSession, 'Active session should be defined'); assert.ok(rTerminal.rTerm, 'rTerminal.rTerm should be defined'); const terminalPid = await rTerminal.rTerm.processId; - assert.strictEqual(session.activeSession.pid, String(terminalPid), 'Session PID should match terminal PID'); + assert.strictEqual( + session.activeSession.pid, + String(terminalPid), + 'Session PID should match terminal PID', + ); // Clean up rTerminal.rTerm?.dispose(); diff --git a/src/test/suite/util.test.ts b/src/test/suite/util.test.ts index 008d10fd7..c9c51f735 100644 --- a/src/test/suite/util.test.ts +++ b/src/test/suite/util.test.ts @@ -17,8 +17,10 @@ suite('R command utilities', () => { teardown(() => sandbox.restore()); test('R literals escape quotes, backslashes and control characters', () => { - assert.strictEqual(util.ToRStringLiteral('a"b\'c\\d\n\r\t\b\f\v', '"'), - String.raw`"a\"b'c\\d\n\r\t\b\f\v"`); + assert.strictEqual( + util.ToRStringLiteral('a"b\'c\\d\n\r\t\b\f\v', '"'), + String.raw`"a\"b'c\\d\n\r\t\b\f\v"`, + ); assert.strictEqual(util.ToRStringLiteral('a"b\'c', "'"), String.raw`'a"b\'c'`); // Editor-path templates can supply either kind of surrounding quote. assert.strictEqual(util.ToRStringLiteral('a"b\'c', ''), String.raw`a\"b\'c`); @@ -27,7 +29,7 @@ suite('R command utilities', () => { test('a failed executable settles with its spawn error', async () => { const result = await Promise.race([ util.spawnAsync(path.join(os.tmpdir(), 'vscode-r-nonexistent-executable')), - new Promise(resolve => setTimeout(resolve, 500)) + new Promise((resolve) => setTimeout(resolve, 500)), ]); assert.ok(result, 'spawnAsync must finish when spawning fails'); assert.strictEqual((result.error as NodeJS.ErrnoException).code, 'ENOENT'); @@ -37,7 +39,9 @@ suite('R command utilities', () => { test('collects all output through close and preserves fragmented UTF-8', async () => { const child = Object.assign(new EventEmitter(), { pid: 123, - stdout: new EventEmitter(), stderr: new EventEmitter(), kill: sandbox.stub() + stdout: new EventEmitter(), + stderr: new EventEmitter(), + kill: sandbox.stub(), }); sandbox.stub(cp, 'spawn').returns(child as unknown as cp.ChildProcessWithoutNullStreams); const result = util.spawnAsync('R'); @@ -52,12 +56,17 @@ suite('R command utilities', () => { }); test('progress propagates synchronous callback exceptions', async () => { - sandbox.stub(vscode.window, 'withProgress').callsFake((_options, task) => - task({ report: () => undefined }, new vscode.CancellationTokenSource().token)); + sandbox + .stub(vscode.window, 'withProgress') + .callsFake((_options, task) => + task({ report: () => undefined }, new vscode.CancellationTokenSource().token), + ); const clock = sandbox.useFakeTimers(); const error = new Error('refresh failed'); - const result = util.doWithProgress(() => { throw error; }); - const rejected = assert.rejects(result, candidate => candidate === error); + const result = util.doWithProgress(() => { + throw error; + }); + const rejected = assert.rejects(result, (candidate) => candidate === error); assert.doesNotThrow(() => clock.tick(1)); await rejected; }); diff --git a/src/test/suite/viewerSession.test.ts b/src/test/suite/viewerSession.test.ts index f6df9e57b..4ce76c8b0 100644 --- a/src/test/suite/viewerSession.test.ts +++ b/src/test/suite/viewerSession.test.ts @@ -10,22 +10,34 @@ suite('Viewer session tooltip updates', () => { const tooltip = { textContent: label }; const posted: unknown[] = []; vm.runInNewContext(getViewerSessionScript(), { - window: { addEventListener: (_type: string, listener: typeof receive) => { receive = listener; } }, + window: { + addEventListener: (_type: string, listener: typeof receive) => { + receive = listener; + }, + }, document: { readyState: 'loading', addEventListener: (type: string, listener: typeof ready) => { - if (type === 'DOMContentLoaded') { ready = listener; } + if (type === 'DOMContentLoaded') { + ready = listener; + } }, - querySelector: () => ({ addEventListener: () => undefined, setAttribute: (name: string, text: string) => { - assert.strictEqual(name, 'aria-label'); label = text; - } }), + querySelector: () => ({ + addEventListener: () => undefined, + setAttribute: (name: string, text: string) => { + assert.strictEqual(name, 'aria-label'); + label = text; + }, + }), getElementById: () => tooltip, }, vscode: { postMessage: (message: unknown) => posted.push(message) }, }); assert.strictEqual(posted.length, 0); ready(); - assert.deepStrictEqual(JSON.parse(JSON.stringify(posted)), [{ message: 'viewer-session/ready' }]); + assert.deepStrictEqual(JSON.parse(JSON.stringify(posted)), [ + { message: 'viewer-session/ready' }, + ]); receive({ data: null }); receive({ data: { message: 'dataview/response', text: 'unrelated' } }); assert.strictEqual(tooltip.textContent, 'R 4.6.1: 12101'); @@ -42,9 +54,14 @@ suite('Viewer session tooltip updates', () => { let focused = true; let hovered = false; let consumed = 0; - let keydown: (event: { key: string; preventDefault(): void; stopPropagation(): void }) => void = () => undefined; + let keydown: (event: { + key: string; + preventDefault(): void; + stopPropagation(): void; + }) => void = () => undefined; const icon = { - addEventListener: (type: string, listener: () => void) => iconEvents.set(type, listener), + addEventListener: (type: string, listener: () => void) => + iconEvents.set(type, listener), setAttribute: (name: string, value: string) => attributes.set(name, value), removeAttribute: (name: string) => attributes.delete(name), hasAttribute: (name: string) => attributes.has(name), @@ -54,14 +71,22 @@ suite('Viewer session tooltip updates', () => { vm.runInNewContext(getViewerSessionScript(), { window: { addEventListener: () => undefined }, document: { - readyState: 'complete', querySelector: () => icon, - addEventListener: (_type: string, listener: typeof keydown) => { keydown = listener; }, + readyState: 'complete', + querySelector: () => icon, + addEventListener: (_type: string, listener: typeof keydown) => { + keydown = listener; + }, }, vscode: { postMessage: () => undefined }, }); - const key = (value: string) => keydown({ - key: value, preventDefault: () => { consumed++; }, stopPropagation: () => undefined, - }); + const key = (value: string) => + keydown({ + key: value, + preventDefault: () => { + consumed++; + }, + stopPropagation: () => undefined, + }); key('Enter'); assert.ok(!attributes.has('data-tooltip-dismissed')); key('Escape'); @@ -69,7 +94,11 @@ suite('Viewer session tooltip updates', () => { assert.strictEqual(focused, true); assert.strictEqual(consumed, 1); key('Escape'); - assert.strictEqual(consumed, 1, 'Dismissed tooltips should not consume further Escape presses'); + assert.strictEqual( + consumed, + 1, + 'Dismissed tooltips should not consume further Escape presses', + ); iconEvents.get('focus')!(); assert.ok(!attributes.has('data-tooltip-dismissed')); key('Escape'); diff --git a/src/test/suite/workspaceViewer.test.ts b/src/test/suite/workspaceViewer.test.ts index d9937509b..39f9d941a 100644 --- a/src/test/suite/workspaceViewer.test.ts +++ b/src/test/suite/workspaceViewer.test.ts @@ -13,7 +13,14 @@ import * as util from '../../util'; import type { RSessionApi } from '../../api'; const extension_root: string = path.join(__dirname, '..', '..', '..'); -const workspaceFile = path.join(extension_root, 'src', 'test', 'testdata', 'session', 'workspace.json'); +const workspaceFile = path.join( + extension_root, + 'src', + 'test', + 'testdata', + 'session', + 'workspace.json', +); suite('Workspace Viewer', () => { let sandbox: sinon.SinonSandbox; @@ -21,32 +28,52 @@ suite('Workspace Viewer', () => { let tree: vscode.TreeView; let first: session.Session; let second: session.Session; - const data = (name: string): session.WorkspaceData => ({ search: [], loaded_namespaces: [], globalenv: { - [name]: { class: 'list', type: 'list', length: 1, str: name, has_children: true } - } }); + const data = (name: string): session.WorkspaceData => ({ + search: [], + loaded_namespaces: [], + globalenv: { + [name]: { class: 'list', type: 'list', length: 1, str: name, has_children: true }, + }, + }); const envNodes = async (): Promise => - await provider.getChildren((await provider.getChildren()).find(item => item.id === 'globalenv')) as workspace.GlobalEnvItem[]; + (await provider.getChildren( + (await provider.getChildren()).find((item) => item.id === 'globalenv'), + )) as workspace.GlobalEnvItem[]; setup(async () => { sandbox = sinon.createSandbox(); mockExtensionContext(extension_root, sandbox); - sandbox.stub(vscode.commands, 'registerCommand').returns(new vscode.Disposable(() => undefined)); + sandbox + .stub(vscode.commands, 'registerCommand') + .returns(new vscode.Disposable(() => undefined)); sandbox.stub(vscode.commands, 'executeCommand').resolves(); tree = { dispose: () => undefined } as vscode.TreeView; sandbox.stub(vscode.window, 'createTreeView').returns(tree); provider = new workspace.WorkspaceDataProvider(); sandbox.stub(extension, 'rWorkspace').value(provider); - first = session.registerSessionTransport('workspace-first', 'host', '/first', () => Promise.resolve(first.workspaceData)); - second = session.registerSessionTransport('workspace-second', 'host', '/second', () => Promise.resolve(second.workspaceData)); - first.label = 'First'; first.pid = '101'; second.label = 'Second'; second.pid = '202'; - first.workspaceData = JSON.parse(fs.readFileSync(workspaceFile, 'utf8')) as session.WorkspaceData; + first = session.registerSessionTransport('workspace-first', 'host', '/first', () => + Promise.resolve(first.workspaceData), + ); + second = session.registerSessionTransport('workspace-second', 'host', '/second', () => + Promise.resolve(second.workspaceData), + ); + first.label = 'First'; + first.pid = '101'; + second.label = 'Second'; + second.pid = '202'; + first.workspaceData = JSON.parse( + fs.readFileSync(workspaceFile, 'utf8'), + ) as session.WorkspaceData; second.workspaceData = data('second_only'); - first.execute = sandbox.stub().resolves(); second.execute = sandbox.stub().resolves(); + first.execute = sandbox.stub().resolves(); + second.execute = sandbox.stub().resolves(); await session.activateSession(first); }); teardown(() => { - session.unregisterSessionTransport(first); session.unregisterSessionTransport(second); - session.deferWorkspaceRefresh(); sandbox.restore(); + session.unregisterSessionTransport(first); + session.unregisterSessionTransport(second); + session.deferWorkspaceRefresh(); + sandbox.restore(); }); test('shows namespace roots and environment with the selected session name and PID', async () => { @@ -56,63 +83,112 @@ suite('Workspace Viewer', () => { assert.strictEqual((await provider.getChildren(nodes[1])).length, 14); assert.strictEqual((await envNodes()).length, 9); assert.strictEqual(tree.description, 'First · PID 101'); - assert.ok((await envNodes()).every(node => node.owner === first)); + assert.ok((await envNodes()).every((node) => node.owner === first)); await session.activateSession(second); assert.strictEqual(tree.description, 'Second · PID 202'); - assert.deepStrictEqual((await envNodes()).map(node => node.label), ['second_only']); + assert.deepStrictEqual( + (await envNodes()).map((node) => node.label), + ['second_only'], + ); }); test('late child replies and old nodes cannot mix two session trees', async () => { - second.workspaceData = data('same_name'); session.updateSessionWorkspace(first, data('same_name')); + second.workspaceData = data('same_name'); + session.updateSessionWorkspace(first, data('same_name')); let reply!: (value: unknown) => void; - const request = sandbox.stub().callsFake(() => new Promise(resolve => { reply = resolve; })); + const request = sandbox.stub().callsFake( + () => + new Promise((resolve) => { + reply = resolve; + }), + ); first.requester = request; const oldNode = (await envNodes())[0]; const pending = provider.getChildren(oldNode); await session.activateSession(second); - const secondRequest = sandbox.stub().resolves({ children: [{ str: 'second child', class: 'numeric', type: 'double', has_children: false }] }); + const secondRequest = sandbox.stub().resolves({ + children: [ + { str: 'second child', class: 'numeric', type: 'double', has_children: false }, + ], + }); second.requester = secondRequest; - reply({ children: [{ str: 'first child', class: 'numeric', type: 'double', has_children: false }], next_start: 501 }); + reply({ + children: [ + { str: 'first child', class: 'numeric', type: 'double', has_children: false }, + ], + next_start: 501, + }); assert.deepStrictEqual(await pending, []); assert.deepStrictEqual(await provider.getChildren(oldNode), []); sinon.assert.notCalled(secondRequest); const children = await provider.getChildren((await envNodes())[0]); assert.strictEqual(children[0].description, 'second child'); assert.strictEqual((children[0] as workspace.GlobalEnvItem).owner, second); - sinon.assert.calledOnce(request); sinon.assert.calledOnce(secondRequest); + sinon.assert.calledOnce(request); + sinon.assert.calledOnce(secondRequest); }); test('background workspace replies update their owner without selecting it', async () => { let reply!: (value: unknown) => void; - first.requester = () => new Promise(resolve => { reply = resolve; }); + first.requester = () => + new Promise((resolve) => { + reply = resolve; + }); const pending = session.updateWorkspace(); await session.activateSession(second); - reply(data('first_late')); await pending; + reply(data('first_late')); + await pending; assert.strictEqual(session.activeSession, second); - assert.deepStrictEqual((await envNodes()).map(node => node.label), ['second_only']); + assert.deepStrictEqual( + (await envNodes()).map((node) => node.label), + ['second_only'], + ); await session.activateSession(first); - assert.deepStrictEqual((await envNodes()).map(node => node.label), ['first_late']); + assert.deepStrictEqual( + (await envNodes()).map((node) => node.label), + ['first_late'], + ); }); test('readiness updates reenable workspace actions without another focus event', async () => { first.workspaceUnavailable = 'R is starting…'; session.updateSessionWorkspace(first, first.workspaceData); assert.deepStrictEqual(await envNodes(), []); - sinon.assert.calledWith(vscode.commands.executeCommand as sinon.SinonStub, 'setContext', 'rSessionActive', false); + sinon.assert.calledWith( + vscode.commands.executeCommand as sinon.SinonStub, + 'setContext', + 'rSessionActive', + false, + ); first.workspaceUnavailable = undefined; session.updateSessionWorkspace(first, data('ready')); assert.strictEqual(tree.message, undefined); - assert.deepStrictEqual((await envNodes()).map(node => node.label), ['ready']); - sinon.assert.calledWith(vscode.commands.executeCommand as sinon.SinonStub, 'setContext', 'rSessionActive', true); + assert.deepStrictEqual( + (await envNodes()).map((node) => node.label), + ['ready'], + ); + sinon.assert.calledWith( + vscode.commands.executeCommand as sinon.SinonStub, + 'setContext', + 'rSessionActive', + true, + ); }); test('View and Remove use the node owner and quote non-syntactic names', async () => { session.updateSessionWorkspace(first, data('odd " name')); const node = (await envNodes())[0]; await session.activateSession(second); - await workspace.viewItem(node); await workspace.removeItem(node); - sinon.assert.calledWithExactly(first.execute as sinon.SinonStub, String.raw`View(get("odd \" name", envir = .GlobalEnv, inherits = FALSE), title = "odd \" name")`); - sinon.assert.calledWithExactly(first.execute as sinon.SinonStub, 'rm(list = "odd \\" name", envir = .GlobalEnv)'); + await workspace.viewItem(node); + await workspace.removeItem(node); + sinon.assert.calledWithExactly( + first.execute as sinon.SinonStub, + String.raw`View(get("odd \" name", envir = .GlobalEnv, inherits = FALSE), title = "odd \" name")`, + ); + sinon.assert.calledWithExactly( + first.execute as sinon.SinonStub, + 'rm(list = "odd \\" name", envir = .GlobalEnv)', + ); sinon.assert.notCalled(second.execute as sinon.SinonStub); }); @@ -121,18 +197,32 @@ suite('Workspace Viewer', () => { second.execute = undefined; const sendText = sandbox.stub(); const otherSendText = sandbox.stub(); - const terminal = { processId: Promise.resolve(undefined), sendText } as unknown as vscode.Terminal; - const otherTerminal = { processId: Promise.resolve(99999), sendText: otherSendText } as unknown as vscode.Terminal; + const terminal = { + processId: Promise.resolve(undefined), + sendText, + } as unknown as vscode.Terminal; + const otherTerminal = { + processId: Promise.resolve(99999), + sendText: otherSendText, + } as unknown as vscode.Terminal; sandbox.stub(vscode.window, 'terminals').value([terminal, otherTerminal]); const activeTerminal = sandbox.stub(vscode.window, 'activeTerminal').value(terminal); sandbox.stub(util, 'config').returns({ - get: (key: string) => key === 'source.focus' ? 'none' - : key === 'session.watchGlobalEnvironment' ? true : undefined, + get: (key: string) => + key === 'source.focus' + ? 'none' + : key === 'session.watchGlobalEnvironment' + ? true + : undefined, } as unknown as vscode.WorkspaceConfiguration); - const api: RSessionApi = { getConnectionInfo: session.getConnectionInfo, prepareAttachment: session.prepareAttachment, - onDidChangeSessionConnection: session.onDidChangeSessionConnection, getSessionReference: session.getSessionReference, + const api: RSessionApi = { + getConnectionInfo: session.getConnectionInfo, + prepareAttachment: session.prepareAttachment, + onDidChangeSessionConnection: session.onDidChangeSessionConnection, + getSessionReference: session.getSessionReference, registerTerminalIntegration: session.registerTerminalIntegration, - activate: session.activateSessionById }; + activate: session.activateSessionById, + }; assert.strictEqual(await api.activate(first.sessionId, { terminal }), true); session.updateSessionWorkspace(first, data('table')); const node = (await envNodes())[0]; @@ -144,7 +234,10 @@ suite('Workspace Viewer', () => { await workspace.viewItem(node); await workspace.removeItem(node); - sinon.assert.calledWithExactly(sendText, 'View(get("table", envir = .GlobalEnv, inherits = FALSE), title = "table")'); + sinon.assert.calledWithExactly( + sendText, + 'View(get("table", envir = .GlobalEnv, inherits = FALSE), title = "table")', + ); sinon.assert.calledWithExactly(sendText, 'rm(list = "table", envir = .GlobalEnv)'); sinon.assert.notCalled(otherSendText); @@ -158,10 +251,18 @@ suite('Workspace Viewer', () => { test('nested View keeps the originating session after focus changes', async () => { session.updateSessionWorkspace(first, data('nested')); - const request = sandbox.stub().resolves({ children: [{ - str: '$ table', class: 'data.frame', type: 'list', has_children: true, - viewable: true, selector: { kind: 'index', value: 1 }, - }] }); + const request = sandbox.stub().resolves({ + children: [ + { + str: '$ table', + class: 'data.frame', + type: 'list', + has_children: true, + viewable: true, + selector: { kind: 'index', value: 1 }, + }, + ], + }); first.requester = request; const children = await provider.getChildren((await envNodes())[0]); const child = children[0] as workspace.GlobalEnvItem; @@ -170,7 +271,8 @@ suite('Workspace Viewer', () => { request.resetHistory(); await workspace.viewItem(child); sinon.assert.calledOnceWithExactly(request, { - method: 'workspace_view', params: { name: 'nested', path: [{ kind: 'index', value: 1 }] }, + method: 'workspace_view', + params: { name: 'nested', path: [{ kind: 'index', value: 1 }] }, }); sinon.assert.notCalled(second.execute as sinon.SinonStub); }); @@ -189,22 +291,34 @@ suite('Workspace Viewer', () => { test('save and load dialogs keep the original owner and working directory', async () => { const saved = vscode.Uri.file(path.join(first.workingDir, 'saved.RData')); - const save = sandbox.stub(vscode.window, 'showSaveDialog').callsFake(async options => { - assert.strictEqual(options?.defaultUri?.toString(), vscode.Uri.file(path.join(first.workingDir, 'workspace.RData')).toString()); + const save = sandbox.stub(vscode.window, 'showSaveDialog').callsFake(async (options) => { + assert.strictEqual( + options?.defaultUri?.toString(), + vscode.Uri.file(path.join(first.workingDir, 'workspace.RData')).toString(), + ); await session.activateSession(second); return saved; }); await workspace.saveWorkspace(); sinon.assert.calledOnce(save); - sinon.assert.calledWithExactly(first.execute as sinon.SinonStub, `save.image(${JSON.stringify(saved.fsPath)})`); - const open = sandbox.stub(vscode.window, 'showOpenDialog').callsFake(async options => { - assert.strictEqual(options?.defaultUri?.toString(), vscode.Uri.file(second.workingDir).toString()); + sinon.assert.calledWithExactly( + first.execute as sinon.SinonStub, + `save.image(${JSON.stringify(saved.fsPath)})`, + ); + const open = sandbox.stub(vscode.window, 'showOpenDialog').callsFake(async (options) => { + assert.strictEqual( + options?.defaultUri?.toString(), + vscode.Uri.file(second.workingDir).toString(), + ); await session.activateSession(first); return [saved]; }); await workspace.loadWorkspace(); sinon.assert.calledOnce(open); - sinon.assert.calledWithExactly(second.execute as sinon.SinonStub, `load(${JSON.stringify(saved.fsPath)})`); + sinon.assert.calledWithExactly( + second.execute as sinon.SinonStub, + `load(${JSON.stringify(saved.fsPath)})`, + ); }); test('unavailable and detached sessions clear the tree and reject stale actions', async () => { @@ -215,7 +329,8 @@ suite('Workspace Viewer', () => { assert.strictEqual(tree.message, 'R session stopped.'); const warning = sandbox.stub(vscode.window, 'showWarningMessage').resolves(undefined); await workspace.removeItem(node); - sinon.assert.calledOnce(warning); sinon.assert.notCalled(first.execute as sinon.SinonStub); + sinon.assert.calledOnce(warning); + sinon.assert.notCalled(first.execute as sinon.SinonStub); session.unregisterSessionTransport(first); assert.strictEqual(session.activeSession, undefined); assert.strictEqual(provider.owner, undefined); diff --git a/src/test/svgAssertions.ts b/src/test/svgAssertions.ts index 4e2b731dd..f3118a0ef 100644 --- a/src/test/svgAssertions.ts +++ b/src/test/svgAssertions.ts @@ -7,19 +7,27 @@ export function assertSvgTextVisible(svg: string, label: string, count = 1): voi const matches = $('text').filter((_, element) => $(element).text() === label); assert.strictEqual(matches.length, count, `Expected ${count} occurrences of ${label}`); matches.each((_, element) => { - const position = /translate\(\s*([-\d.]+)[,\s]+([-\d.]+)\s*\)/.exec($(element).attr('transform') ?? ''); + const position = /translate\(\s*([-\d.]+)[,\s]+([-\d.]+)\s*\)/.exec( + $(element).attr('transform') ?? '', + ); const x = Number($(element).attr('x') ?? 0) + Number(position?.[1] ?? 0); const y = Number($(element).attr('y') ?? 0) + Number(position?.[2] ?? 0); assert.ok(Number.isFinite(x) && Number.isFinite(y), `Invalid text position for ${label}`); - $(element).parents('[clip-path]').each((_, ancestor) => { - const id = /^url\(#(.+)\)$/.exec($(ancestor).attr('clip-path') ?? '')?.[1]; - assert.ok(id, `Missing clip reference for ${label}`); - const rect = $(`clipPath[id="${id}"] > rect`); - assert.strictEqual(rect.length, 1); - const left = Number(rect.attr('x')), top = Number(rect.attr('y')); - const right = left + Number(rect.attr('width')), bottom = top + Number(rect.attr('height')); - assert.ok(x >= left && x <= right && y >= top && y <= bottom, - `${label} at (${x}, ${y}) is hidden by clip ${id}: (${left}, ${top})–(${right}, ${bottom})`); - }); + $(element) + .parents('[clip-path]') + .each((_, ancestor) => { + const id = /^url\(#(.+)\)$/.exec($(ancestor).attr('clip-path') ?? '')?.[1]; + assert.ok(id, `Missing clip reference for ${label}`); + const rect = $(`clipPath[id="${id}"] > rect`); + assert.strictEqual(rect.length, 1); + const left = Number(rect.attr('x')), + top = Number(rect.attr('y')); + const right = left + Number(rect.attr('width')), + bottom = top + Number(rect.attr('height')); + assert.ok( + x >= left && x <= right && y >= top && y <= bottom, + `${label} at (${x}, ${y}) is hidden by clip ${id}: (${left}, ${top})–(${right}, ${bottom})`, + ); + }); }); } diff --git a/src/util.ts b/src/util.ts index 2299f6b00..e67a25a63 100644 --- a/src/util.ts +++ b/src/util.ts @@ -43,13 +43,20 @@ export function substituteVariables(str: string, resource?: vscode.Uri): string } function getRfromEnvPath(platform: string, executableName: string = 'R') { - return findExecutableOnPath(executableName, platform as NodeJS.Platform, process.env.PATH, fs.existsSync) ?? ''; + return ( + findExecutableOnPath( + executableName, + platform as NodeJS.Platform, + process.env.PATH, + fs.existsSync, + ) ?? '' + ); } export async function getRpathFromSystem(): Promise { const rpath = await resolveSystemR({ platform: process.platform, - findExecutable: name => getRfromEnvPath(process.platform, name) || undefined, + findExecutable: (name) => getRfromEnvPath(process.platform, name) || undefined, getWindowsInstallPath: async () => { try { const key = new winreg({ @@ -57,7 +64,8 @@ export async function getRpathFromSystem(): Promise { key: '\\Software\\R-Core\\R', }); const item: winreg.RegistryItem = await new Promise((c, e) => - key.get('InstallPath', (err, result) => err === null ? c(result) : e(err))); + key.get('InstallPath', (err, result) => (err === null ? c(result) : e(err))), + ); return item.value; } catch { return undefined; @@ -68,29 +76,25 @@ export async function getRpathFromSystem(): Promise { } export function getRPathConfigEntry(term: boolean = false): string { - const trunc = (term ? 'rterm' : 'rpath'); - const platform = ( - process.platform === 'win32' ? 'windows' : - process.platform === 'darwin' ? 'mac' : - 'linux' - ); + const trunc = term ? 'rterm' : 'rpath'; + const platform = + process.platform === 'win32' ? 'windows' : process.platform === 'darwin' ? 'mac' : 'linux'; return `${trunc}.${platform}`; } -export async function getRpath( - quote = false, - resource?: vscode.Uri -): Promise { +export async function getRpath(quote = false, resource?: vscode.Uri): Promise { const configEntry = getRPathConfigEntry(); const resolution = await resolveBackgroundR( getRPathResolverDependencies(resource), - configEntry + configEntry, ); const rpath = formatRPath(resolution, quote, process.platform); if (!rpath) { // inform user about missing R path: - void vscode.window.showErrorMessage(`Cannot find R to use for help, package installation etc. Change setting r.${rExecutablePathSetting} to an R executable.`); + void vscode.window.showErrorMessage( + `Cannot find R to use for help, package installation etc. Change setting r.${rExecutablePathSetting} to an R executable.`, + ); } return rpath; @@ -106,9 +110,13 @@ export async function getRterm(resource?: vscode.Uri): Promise getRfromEnvPath(process.platform, name) || undefined, + findExecutable: (name) => getRfromEnvPath(process.platform, name) || undefined, pathExists: fs.existsSync, getSystemR: getRpathFromSystem, }); @@ -129,9 +137,17 @@ export function ToRStringLiteral(s: string, quote: string): string { return 'NULL'; } - const escapes: Record = { '\\': '\\\\', '\n': '\\n', '\r': '\\r', '\t': '\\t', - '\b': '\\b', '\x07': '\\a', '\f': '\\f', '\v': '\\v' }; - const escaped = Array.from(s, character => { + const escapes: Record = { + '\\': '\\\\', + '\n': '\\n', + '\r': '\\r', + '\t': '\\t', + '\b': '\\b', + '\x07': '\\a', + '\f': '\\f', + '\v': '\\v', + }; + const escaped = Array.from(s, (character) => { if (character === '"' || character === "'") { return !quote || character === quote ? `\\${character}` : character; } @@ -159,32 +175,48 @@ function getActiveFileWorkspaceFolder(): vscode.WorkspaceFolder | undefined { } } -export function getCurrentWorkspaceFolder(resource?: vscode.Uri): vscode.WorkspaceFolder | undefined { +export function getCurrentWorkspaceFolder( + resource?: vscode.Uri, +): vscode.WorkspaceFolder | undefined { const workspaceFolders = vscode.workspace.workspaceFolders; - const resourceWorkspaceFolder = resource ? vscode.workspace.getWorkspaceFolder(resource) : undefined; - const activeFileWorkspaceFolder = !resourceWorkspaceFolder && workspaceFolders && workspaceFolders.length > 1 - ? getActiveFileWorkspaceFolder() + const resourceWorkspaceFolder = resource + ? vscode.workspace.getWorkspaceFolder(resource) : undefined; - return selectWorkspaceFolder(workspaceFolders, activeFileWorkspaceFolder, resourceWorkspaceFolder); + const activeFileWorkspaceFolder = + !resourceWorkspaceFolder && workspaceFolders && workspaceFolders.length > 1 + ? getActiveFileWorkspaceFolder() + : undefined; + return selectWorkspaceFolder( + workspaceFolders, + activeFileWorkspaceFolder, + resourceWorkspaceFolder, + ); } const readFile = promisify(readFileCallback); export function readContent(file: PathLike | number): Promise | undefined; -export function readContent(file: PathLike | number, encoding: BufferEncoding): Promise | undefined; -export function readContent(file: PathLike | number, encoding?: BufferEncoding): Promise | undefined { +export function readContent( + file: PathLike | number, + encoding: BufferEncoding, +): Promise | undefined; +export function readContent( + file: PathLike | number, + encoding?: BufferEncoding, +): Promise | undefined { return encoding === undefined ? readFile(file) : readFile(file, encoding); } - export async function saveDocument(document: vscode.TextDocument): Promise { if (document.isUntitled) { - void vscode.window.showErrorMessage('Document is unsaved. Please save and retry running R command.'); + void vscode.window.showErrorMessage( + 'Document is unsaved. Please save and retry running R command.', + ); return false; } - const isSaved: boolean = document.isDirty ? (await document.save()) : true; + const isSaved: boolean = document.isDirty ? await document.save() : true; if (!isSaved) { void vscode.window.showErrorMessage('Cannot run R command: document could not be saved.'); } @@ -194,19 +226,23 @@ export async function saveDocument(document: vscode.TextDocument): Promise { +export async function getConfirmation( + prompt: string, + confirmation?: string, + detail?: string, +): Promise { confirmation ||= 'Yes'; const items: vscode.QuickPickItem[] = [ { label: confirmation, - detail: detail + detail: detail, }, { - label: 'Cancel' - } + label: 'Cancel', + }, ]; const answer = await vscode.window.showQuickPick(items, { - placeHolder: prompt + placeHolder: prompt, }); return answer === items[0]; } @@ -214,29 +250,42 @@ export async function getConfirmation(prompt: string, confirmation?: string, det // executes a given command as shell task // is more transparent than background processes without littering the integrated terminals // is not intended for actual user interaction -export async function executeAsTask(name: string, process: string, args?: string[], asProcess?: true, cwd?: string): Promise; -export async function executeAsTask(name: string, command: string, args?: string[], asProcess?: false, cwd?: string): Promise; -export async function executeAsTask(name: string, cmdOrProcess: string, args?: string[], asProcess: boolean = false, cwd?: string): Promise { +export async function executeAsTask( + name: string, + process: string, + args?: string[], + asProcess?: true, + cwd?: string, +): Promise; +export async function executeAsTask( + name: string, + command: string, + args?: string[], + asProcess?: false, + cwd?: string, +): Promise; +export async function executeAsTask( + name: string, + cmdOrProcess: string, + args?: string[], + asProcess: boolean = false, + cwd?: string, +): Promise { let taskDefinition: vscode.TaskDefinition; let taskExecution: vscode.ShellExecution | vscode.ProcessExecution; - if(asProcess){ - taskDefinition = { type: 'process'}; - taskExecution = new vscode.ProcessExecution( - cmdOrProcess, - args ?? [], - { cwd } - ); + if (asProcess) { + taskDefinition = { type: 'process' }; + taskExecution = new vscode.ProcessExecution(cmdOrProcess, args ?? [], { cwd }); } else { taskDefinition = { type: 'shell' }; - const quotedArgs = args && args.map(arg => { return { value: arg, quoting: vscode.ShellQuoting.Weak }; }); - taskExecution = quotedArgs ? new vscode.ShellExecution( - cmdOrProcess, - quotedArgs, - { cwd } - ) : new vscode.ShellExecution( - cmdOrProcess, - { cwd } - ); + const quotedArgs = + args && + args.map((arg) => { + return { value: arg, quoting: vscode.ShellQuoting.Weak }; + }); + taskExecution = quotedArgs + ? new vscode.ShellExecution(cmdOrProcess, quotedArgs, { cwd }) + : new vscode.ShellExecution(cmdOrProcess, { cwd }); } const task = new vscode.Task( taskDefinition, @@ -244,12 +293,12 @@ export async function executeAsTask(name: string, cmdOrProcess: string, args?: s name, 'R', taskExecution, - [] + [], ); const taskExecutionRunning = await vscode.tasks.executeTask(task); const taskDonePromise = new Promise((resolve) => { - const subscription = vscode.tasks.onDidEndTask(e => { + const subscription = vscode.tasks.onDidEndTask((e) => { if (e.execution === taskExecutionRunning) { subscription.dispose(); resolve(); @@ -263,18 +312,31 @@ export async function executeAsTask(name: string, cmdOrProcess: string, args?: s // executes a callback and shows a 'busy' progress bar during the execution // synchronous callbacks are converted to async to properly render the progress bar // default location is in the help pages tree view -export async function doWithProgress(cb: (token?: vscode.CancellationToken, progress?: vscode.Progress<{ message?: string; increment?: number }>) => T | Promise, location: (string | vscode.ProgressLocation) = vscode.ProgressLocation.Window, title?: string, cancellable?: boolean): Promise { - const location2 = (typeof location === 'string' ? { viewId: location } : location); +export async function doWithProgress( + cb: ( + token?: vscode.CancellationToken, + progress?: vscode.Progress<{ message?: string; increment?: number }>, + ) => T | Promise, + location: string | vscode.ProgressLocation = vscode.ProgressLocation.Window, + title?: string, + cancellable?: boolean, +): Promise { + const location2 = typeof location === 'string' ? { viewId: location } : location; const options: vscode.ProgressOptions = { location: location2, cancellable: cancellable ?? false, - title: title + title: title, }; return await vscode.window.withProgress(options, async (progress, token) => { - return await new Promise((resolve, reject) => setTimeout(() => { - try { resolve(cb(token, progress)); } - catch (error) { reject(error); } - })); + return await new Promise((resolve, reject) => + setTimeout(() => { + try { + resolve(cb(token, progress)); + } catch (error) { + reject(error); + } + }), + ); }); } @@ -285,7 +347,7 @@ export async function doWithProgress(cb: (token?: vscode.CancellationToken, p export async function getCranUrl(path: string = '', cwd?: string | URL): Promise { const defaultCranUrl = 'https://cran.r-project.org/'; // get cran URL from R. Returns empty string if option is not set. - const baseUrl = await executeRCommand('cat(getOption(\'repos\')[\'CRAN\'])', cwd); + const baseUrl = await executeRCommand("cat(getOption('repos')['CRAN'])", cwd); let url: string; try { url = new URL(path, baseUrl).toString(); @@ -299,19 +361,30 @@ export async function getRVersion(cwd?: string | URL): Promise { - const result = await executeRCommand(`cat(if (requireNamespace('${name}', quietly = TRUE)) as.character(utils::packageVersion('${name}')) else '')`, cwd); +export async function getRPackageVersion( + name: string, + cwd?: string | URL, +): Promise { + const result = await executeRCommand( + `cat(if (requireNamespace('${name}', quietly = TRUE)) as.character(utils::packageVersion('${name}')) else '')`, + cwd, + ); return result || undefined; } export function readSessSourceRevision(description: string | undefined): string | undefined { - const revision = description?.match(/^Config\/vscode-R\/source-revision:[ \t]*(\S+)[ \t]*\r?$/m)?.[1]; - return revision && /^git-tree:(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(revision) ? revision : undefined; + const revision = description?.match( + /^Config\/vscode-R\/source-revision:[ \t]*(\S+)[ \t]*\r?$/m, + )?.[1]; + return revision && /^git-tree:(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(revision) + ? revision + : undefined; } export function getRLibPaths(): string | undefined { - return config().get('libPaths') - ?.map(value => substituteVariables(value)) + return config() + .get('libPaths') + ?.map((value) => substituteVariables(value)) .join('\n'); } @@ -322,7 +395,11 @@ export function getRLibPaths(): string | undefined { // WARNING: Cannot handle double quotes in the R command! (e.g. `print("hello world")`) // Single quotes are ok. // -export async function executeRCommand(rCommand: string, cwd?: string | URL, fallback?: string | ((e: Error) => string)): Promise { +export async function executeRCommand( + rCommand: string, + cwd?: string | URL, + fallback?: string | ((e: Error) => string), +): Promise { const resource = resourceFromCwd(cwd); const rPath = await getRpath(false, resource); if (!rPath) { @@ -339,9 +416,12 @@ export async function executeRCommand(rCommand: string, cwd?: string | URL, fall '--no-echo', '--no-save', '--no-restore', - '-e', `cat('${lim}')`, - '-e', rCommand, - '-e', `cat('${lim}')` + '-e', + `cat('${lim}')`, + '-e', + rCommand, + '-e', + `cat('${lim}')`, ]; let ret: string | undefined = undefined; @@ -359,7 +439,7 @@ export async function executeRCommand(rCommand: string, cwd?: string | URL, fall ret = match[1]; } catch (e) { if (fallback) { - ret = (typeof fallback === 'function' ? fallback(catchAsError(e)) : fallback); + ret = typeof fallback === 'function' ? fallback(catchAsError(e)) : fallback; } else { console.warn(e); } @@ -378,11 +458,10 @@ function resourceFromCwd(cwd?: string | URL | vscode.Uri): vscode.Uri | undefine return typeof cwd === 'string' ? vscode.Uri.file(cwd) : vscode.Uri.parse(cwd.toString()); } - // This class is a wrapper around Map that implements vscode.Memento // Can be used in place of vscode.ExtensionContext.globalState or .workspaceState when no caching is desired export class DummyMemento implements vscode.Memento { - items = new Map() + items = new Map(); public get(key: string, defaultValue?: T): T | undefined { if (this.items.has(key)) { return this.items.get(key) || defaultValue; @@ -401,21 +480,21 @@ export class DummyMemento implements vscode.Memento { } export async function setContext(key: string, value: any): Promise { - await vscode.commands.executeCommand( - 'setContext', key, value - ); + await vscode.commands.executeCommand('setContext', key, value); } // Helper function used to convert raw text files to html export function escapeHtml(source: string): string { - const entityMap = new Map(Object.entries({ - '&': '&', - '<': '<', - '>': '>', - '"': '"', - '\'': ''', - '/': '/' - })); + const entityMap = new Map( + Object.entries({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + '/': '/', + }), + ); return String(source).replace(/[&<>"'/]/g, (s: string) => entityMap.get(s) || ''); } @@ -448,7 +527,10 @@ export class UriIcon { * @param {Function} disposeFunction - the method called when the object is disposed * @returns returned object is considered types T and vscode.Disposable */ -export function asDisposable(toDispose: T, disposeFunction: (...args: unknown[]) => unknown): T & vscode.Disposable { +export function asDisposable( + toDispose: T, + disposeFunction: (...args: unknown[]) => unknown, +): T & vscode.Disposable { type disposeType = T & vscode.Disposable; (toDispose as disposeType).dispose = () => disposeFunction(); extensionContext.subscriptions.push(toDispose as disposeType); @@ -456,7 +538,12 @@ export function asDisposable(toDispose: T, disposeFunction: (...args: unknown } export type DisposableProcess = cp.ChildProcessWithoutNullStreams & vscode.Disposable; -export function spawn(command: string, args?: ReadonlyArray, options?: cp.CommonOptions, onDisposed?: () => unknown): DisposableProcess { +export function spawn( + command: string, + args?: ReadonlyArray, + options?: cp.CommonOptions, + onDisposed?: () => unknown, +): DisposableProcess { const proc = cp.spawn(command, args, options); console.log(proc.pid ? `Process ${proc.pid} spawned` : 'Process failed to spawn'); let running = true; @@ -484,9 +571,13 @@ export function spawn(command: string, args?: ReadonlyArray, options?: c return disposable; } -export async function spawnAsync(command: string, args?: ReadonlyArray, options?: cp.CommonOptions, onDisposed?: () => unknown): Promise> { +export async function spawnAsync( + command: string, + args?: ReadonlyArray, + options?: cp.CommonOptions, + onDisposed?: () => unknown, +): Promise> { return new Promise((resolve) => { - const result: cp.SpawnSyncReturns = { error: undefined, pid: -1, @@ -494,7 +585,7 @@ export async function spawnAsync(command: string, args?: ReadonlyArray, stdout: '', stderr: '', status: null, - signal: null + signal: null, }; try { @@ -522,7 +613,7 @@ export async function spawnAsync(command: string, args?: ReadonlyArray, resolve(result); }); } catch (e) { - result.error = (e instanceof Error) ? e : undefined; + result.error = e instanceof Error ? e : undefined; resolve(result); } }); @@ -534,7 +625,13 @@ export async function spawnAsync(command: string, args?: ReadonlyArray, * @param name the R package name to ask user to install * @returns a boolean Promise */ -export async function promptToInstallRPackage(name: string, section: string, cwd: string | URL, installMsg?: string, postInstallMsg?: string): Promise { +export async function promptToInstallRPackage( + name: string, + section: string, + cwd: string | URL, + installMsg?: string, + postInstallMsg?: string, +): Promise { const resource = resourceFromCwd(cwd); const _config = config(); const prompt = _config.get(section); @@ -544,7 +641,8 @@ export async function promptToInstallRPackage(name: string, section: string, cwd if (installMsg === undefined) { installMsg = `R package {${name}} is not installed. Do you want to install it?`; } - await vscode.window.showErrorMessage(installMsg, 'Yes', 'No', 'Never ask again') + await vscode.window + .showErrorMessage(installMsg, 'Yes', 'No', 'Never ask again') .then(async function (select) { if (select === 'Yes') { const repo = await getCranUrl('', cwd); @@ -553,7 +651,14 @@ export async function promptToInstallRPackage(name: string, section: string, cwd void vscode.window.showErrorMessage('R path not set', 'OK'); return; } - const args = ['--silent', '--no-echo', '--no-save', '--no-restore', '-e', `install.packages('${name}', repos='${repo}')`]; + const args = [ + '--silent', + '--no-echo', + '--no-save', + '--no-restore', + '-e', + `install.packages('${name}', repos='${repo}')`, + ]; void executeAsTask('Install Package', rPath, args, true); if (postInstallMsg) { void vscode.window.showInformationMessage(postInstallMsg, 'OK'); @@ -572,9 +677,15 @@ export async function promptToInstallRPackage(name: string, section: string, cwd * @returns Path to the temporary directory. */ export function createTempDir(root: string, hidden?: boolean): string { - const hidePrefix = (!hidden || process.platform === 'win32') ? '' : '.'; + const hidePrefix = !hidden || process.platform === 'win32' ? '' : '.'; let tempDir: string; - while (fs.existsSync(tempDir = path.join(root, `${hidePrefix}___temp_${randomBytes(8).toString('hex')}`))) { /* Name clash */ } + while ( + fs.existsSync( + (tempDir = path.join(root, `${hidePrefix}___temp_${randomBytes(8).toString('hex')}`)), + ) + ) { + /* Name clash */ + } fs.mkdirSync(tempDir); return tempDir; } @@ -595,16 +706,22 @@ export function createTempDir(root: string, hidden?: boolean): string { * @returns */ export function catchAsError(err: unknown, fallbackMessage?: string): Error { - return (err instanceof Error) ? err : Error(fallbackMessage ?? 'Unknown error'); -} - - - -const VIEW_COLUMN_KEYS = Object.keys(vscode.ViewColumn).filter(x => isNaN(parseInt(x))); - -export function asViewColumn(s: string | undefined | vscode.ViewColumn): vscode.ViewColumn | undefined; -export function asViewColumn(s: string | undefined | vscode.ViewColumn, fallback: vscode.ViewColumn): vscode.ViewColumn; -export function asViewColumn(s: string | undefined | vscode.ViewColumn, fallback?: vscode.ViewColumn): vscode.ViewColumn | undefined { + return err instanceof Error ? err : Error(fallbackMessage ?? 'Unknown error'); +} + +const VIEW_COLUMN_KEYS = Object.keys(vscode.ViewColumn).filter((x) => isNaN(parseInt(x))); + +export function asViewColumn( + s: string | undefined | vscode.ViewColumn, +): vscode.ViewColumn | undefined; +export function asViewColumn( + s: string | undefined | vscode.ViewColumn, + fallback: vscode.ViewColumn, +): vscode.ViewColumn; +export function asViewColumn( + s: string | undefined | vscode.ViewColumn, + fallback?: vscode.ViewColumn, +): vscode.ViewColumn | undefined { if (!s) { return fallback; } @@ -618,7 +735,6 @@ export function asViewColumn(s: string | undefined | vscode.ViewColumn, fallback return fallback; } - export function makeWebviewCommandUriString(command: string, ...args: any[]): string { const argString = encodeURIComponent(JSON.stringify(args)); return `command:${command}?${argString}`; @@ -627,22 +743,19 @@ export function makeWebviewCommandUriString(command: string, ...args: any[]): st // Tries to read a file, returns undefined if an error occurs (e.g. the file does not exist) export function readFileSyncSafe( path: fs.PathOrFileDescriptor, - encoding: BufferEncoding = 'utf-8' + encoding: BufferEncoding = 'utf-8', ): string | undefined { try { - return fs.readFileSync(path, {encoding:encoding}); + return fs.readFileSync(path, { encoding: encoding }); } catch { return undefined; } } // Tries to read a dir, returns undefined if an error occurs (e.g. the dir does not exist) -export function readdirSyncSafe( - path: fs.PathLike, - encoding: BufferEncoding = 'utf-8' -){ +export function readdirSyncSafe(path: fs.PathLike, encoding: BufferEncoding = 'utf-8') { try { - return fs.readdirSync(path, {encoding: encoding}); + return fs.readdirSync(path, { encoding: encoding }); } catch { return undefined; } @@ -665,9 +778,12 @@ export function isFileSafe(path: fs.PathLike): boolean { } // Keeps only the unique entries in an array, optionally with a custom comparison function -export function uniqueEntries(array: T[], isIdentical: (x: T, y: T) => boolean = (x, y) => (x === y)){ +export function uniqueEntries( + array: T[], + isIdentical: (x: T, y: T) => boolean = (x, y) => x === y, +) { function uniqueFunction(v: T, index: number, array: T[]): boolean { - return array.findIndex(v2 => isIdentical(v2, v)) === index; + return array.findIndex((v2) => isIdentical(v2, v)) === index; } return array.filter(uniqueFunction); } diff --git a/src/viewerSession.ts b/src/viewerSession.ts index c34f88b7f..2b1a1e6e8 100644 --- a/src/viewerSession.ts +++ b/src/viewerSession.ts @@ -20,24 +20,45 @@ export interface ViewerSessionContext { // Shared by the standalone HTML, list and data viewers. export function createViewerSessionContext( - owner: Session, monitor: SessionProcessMonitor, + owner: Session, + monitor: SessionProcessMonitor, ): 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), + 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 ''; } + if (!exited && (!owner.pid || !owner.rVer)) { + return ''; + } const text = exited ? 'R: (not attached)' : formatSessionLabel(owner.rVer, owner.pid); - const info = text.replace(/[&<>"']/g, character => ({ - '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', - })[character]!); + const info = text.replace( + /[&<>"']/g, + (character) => + ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + })[character]!, + ); return `${info}`; }, - attach: panel => { - if (!monitor.hasExited(owner) && (!owner.pid || !owner.rVer)) { return; } + attach: (panel) => { + if (!monitor.hasExited(owner) && (!owner.pid || !owner.rVer)) { + return; + } const attachedLabel = formatSessionLabel(owner.rVer, owner.pid); const refresh = (force = false) => { const text = source.exited ? 'R: (not attached)' : attachedLabel; @@ -50,9 +71,14 @@ export function createViewerSessionContext( const source = monitor.observe(owner, refresh); let lastLabel = source.exited ? 'R: (not attached)' : attachedLabel; const received = panel.webview.onDidReceiveMessage((message: { message?: string }) => { - if (message?.message === 'viewer-session/ready') { refresh(true); } + if (message?.message === 'viewer-session/ready') { + refresh(true); + } + }); + panel.onDidDispose(() => { + source.dispose(); + received?.dispose(); }); - panel.onDidDispose(() => { source.dispose(); received?.dispose(); }); }, }; } @@ -66,11 +92,21 @@ export function getViewerSessionScript(): string { return `(${initializeViewerSession.toString()})(vscode);`; } -export function initializeViewerSession(vscode: { postMessage?(message: { message: 'viewer-session/ready' }): unknown }): void { - window.addEventListener('message', event => { +export function initializeViewerSession(vscode: { + postMessage?(message: { message: 'viewer-session/ready' }): unknown; +}): void { + window.addEventListener('message', (event) => { const message: unknown = event.data; - if (!message || typeof message !== 'object' || !('message' in message) || - message.message !== 'viewer-session/update' || !('text' in message) || typeof message.text !== 'string') { return; } + if ( + !message || + typeof message !== 'object' || + !('message' in message) || + message.message !== 'viewer-session/update' || + !('text' in message) || + typeof message.text !== 'string' + ) { + return; + } const icon = document.querySelector('.viewer-session'); const tooltip = document.getElementById('viewer-session-tooltip'); if (icon && tooltip) { @@ -84,9 +120,12 @@ export function initializeViewerSession(vscode: { postMessage?(message: { messag const showTooltip = () => icon.removeAttribute('data-tooltip-dismissed'); icon.addEventListener('focus', showTooltip); icon.addEventListener('pointerenter', showTooltip); - document.addEventListener('keydown', event => { - if (event.key === 'Escape' && !icon.hasAttribute('data-tooltip-dismissed') && - icon.matches(':hover, :focus-visible')) { + document.addEventListener('keydown', (event) => { + if ( + event.key === 'Escape' && + !icon.hasAttribute('data-tooltip-dismissed') && + icon.matches(':hover, :focus-visible') + ) { icon.setAttribute('data-tooltip-dismissed', ''); event.preventDefault(); event.stopPropagation(); diff --git a/src/webViewer/history.ts b/src/webViewer/history.ts index fb1fb3425..107188c6e 100644 --- a/src/webViewer/history.ts +++ b/src/webViewer/history.ts @@ -28,9 +28,15 @@ export class WidgetHistoryStore { const saved = state.get(widgetHistoryKey); if (Array.isArray(saved)) { for (const value of saved) { - if (!isHistory(value)) { continue; } + 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 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); @@ -44,7 +50,10 @@ export class WidgetHistoryStore { remember(session: ViewerSessionContext): WidgetHistory { let record = this.entries.get(session.sessionId); - if (record && widgetSessionIdentity(record.source) !== widgetSessionIdentity(session.source)) { + if ( + record && + widgetSessionIdentity(record.source) !== widgetSessionIdentity(session.source) + ) { this.forget(session.sessionId); record = undefined; } @@ -57,50 +66,84 @@ export class WidgetHistoryStore { } save(record: WidgetHistory): Promise { - if (this.entries.get(record.source.sessionId) !== record) { return this.writes; } + 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(); } + if (this.entries.delete(sessionId)) { + void this.persist(); + } } private watch(session: ViewerSessionContext): void { this.subscriptions.get(session.sessionId)?.dispose(); - this.subscriptions.set(session.sessionId, session.observeExit(() => this.forget(session.sessionId))); + this.subscriptions.set( + session.sessionId, + session.observeExit(() => this.forget(session.sessionId)), + ); } private persist(): Promise { - if (this.disposed) { return this.writes; } + 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); - }); + 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; } + flush(): Promise { + return this.writes; + } dispose(): void { this.disposed = true; - this.subscriptions.forEach(subscription => subscription.dispose()); + this.subscriptions.forEach((subscription) => subscription.dispose()); this.subscriptions.clear(); } } function isHistory(value: unknown): value is WidgetHistory { - if (!value || typeof value !== 'object') { return false; } + 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)); + 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 1b13a6edd..37a66febf 100644 --- a/src/webViewer/index.ts +++ b/src/webViewer/index.ts @@ -5,9 +5,27 @@ import { randomUUID } from 'crypto'; import { pathToFileURL } from 'url'; import { readFile } from 'fs/promises'; import { load } from 'cheerio'; -import { Disposable, ExtensionContext, StatusBarAlignment, StatusBarItem, Uri, ViewColumn, Webview, WebviewPanel, window, env, commands, workspace } from 'vscode'; +import { + 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 { + WidgetHistory, + WidgetHistoryStore, + widgetHistoryLimit, + widgetSessionIdentity, +} from './history'; import type { HtmlViewerPanelReference, HtmlViewerPanelState } from './webviewMessages'; export interface HtmlViewerSessionAccess { @@ -36,14 +54,23 @@ const htmlViewerTitle = 'HTML Viewer'; const panelStateKey = 'r.htmlViewer.panels'; function escapeHtml(text: string): string { - return text.replace(/[&<>"']/g, character => ({ - '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', - })[character]!); + return text.replace( + /[&<>"']/g, + (character) => + ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + })[character]!, + ); } /** Initialize retained history once per extension host, including closed Viewer tabs. */ export function initializeHtmlWidgetViewers( - context: ExtensionContext, sessions: HtmlViewerSessionAccess, + context: ExtensionContext, + sessions: HtmlViewerSessionAccess, ): HtmlWidgetViewerManager { manager?.dispose(); manager = new HtmlWidgetViewerManager(context, sessions); @@ -59,7 +86,10 @@ function getManager(): HtmlWidgetViewerManager { } export async function showWebView( - file: string, title: string, viewer: string | boolean, session?: ViewerSessionContext, + file: string, + title: string, + viewer: string | boolean, + session?: ViewerSessionContext, ): Promise { if (viewer === false) { void env.openExternal(Uri.file(file)); @@ -93,45 +123,82 @@ class HtmlWidgetViewerManager { private readonly panelStates = new Map(); private panelWrites: Promise = Promise.resolve(); - constructor(readonly context: ExtensionContext, private readonly sessions: HtmlViewerSessionAccess) { - this.histories = new WidgetHistoryStore(context.workspaceState, source => sessions.resolveSession(source)); + constructor( + readonly context: ExtensionContext, + private readonly sessions: HtmlViewerSessionAccess, + ) { + this.histories = new WidgetHistoryStore(context.workspaceState, (source) => + sessions.resolveSession(source), + ); const savedPanels = context.workspaceState.get(panelStateKey); if (Array.isArray(savedPanels)) { for (const value of savedPanels) { const state = parsePanelState(value); - if (state) { this.panelStates.set(state.id, state); } + if (state) { + this.panelStates.set(state.id, state); + } } } this.serializer = window.registerWebviewPanelSerializer('r.htmlViewer', this); - this.loadingStatus = window.createStatusBarItem('r.htmlViewer.loading', StatusBarAlignment.Right, 10); + this.loadingStatus = window.createStatusBarItem( + 'r.htmlViewer.loading', + StatusBarAlignment.Right, + 10, + ); this.loadingStatus.name = 'R HTML Viewer Loading'; } async deserializeWebviewPanel(panel: WebviewPanel, state: unknown): Promise { - if (this.disposed) { panel.dispose(); return; } + if (this.disposed) { + panel.dispose(); + return; + } // Webview state contains only a reference. Paths and ownership come from // extension-owned storage, so output scripts cannot supply arbitrary files. - const id = state && typeof state === 'object' && 'id' in state && typeof state.id === 'string' ? state.id : undefined; + const id = + state && typeof state === 'object' && 'id' in state && typeof state.id === 'string' + ? state.id + : undefined; const restored = id ? this.panelStates.get(id) : undefined; - const resolved = restored?.source ? this.sessions.resolveSession(restored.source) : undefined; - const session = resolved && restored?.source && - widgetSessionIdentity(resolved.source) === widgetSessionIdentity(restored.source) ? resolved : undefined; + const resolved = restored?.source + ? this.sessions.resolveSession(restored.source) + : undefined; + const session = + resolved && + restored?.source && + widgetSessionIdentity(resolved.source) === widgetSessionIdentity(restored.source) + ? resolved + : undefined; const identity = session ? widgetSessionIdentity(session.source) : undefined; const retained = session ? this.histories.entries.get(session.sessionId) : undefined; // The restored tab must belong to the same process as its retained history. - const saved = retained && widgetSessionIdentity(retained.source) === identity ? retained : - session && !retained && !session.hasExited ? this.histories.remember(session) : undefined; + const saved = + retained && widgetSessionIdentity(retained.source) === identity + ? retained + : session && !retained && !session.hasExited + ? this.histories.remember(session) + : undefined; if (saved && !retained && restored) { - saved.history = restored.history.map(item => ({ ...item })); + saved.history = restored.history.map((item) => ({ ...item })); saved.index = restored.index; } const existing = identity ? this.viewers.get(identity) : undefined; // A notification may open a new panel before VS Code restores the original tab. // Keep the restored tab and the latest retained history as the canonical viewer. existing?.panel.dispose(); - const entry = this.attachPanel(panel, session, saved, restored?.showSessionInfo, restored?.id); + const entry = this.attachPanel( + panel, + session, + saved, + restored?.showSessionInfo, + restored?.id, + ); if (!saved && restored) { - entry.state = { history: restored.history.map(item => ({ ...item })), index: restored.index, viewColumn: panel.viewColumn }; + entry.state = { + history: restored.history.map((item) => ({ ...item })), + index: restored.index, + viewColumn: panel.viewColumn, + }; } panel.webview.options = { ...panel.webview.options, enableScripts: true }; const pending = this.save(entry); @@ -139,11 +206,18 @@ class HtmlWidgetViewerManager { await pending; } - async show(file: string, title: string, viewer: string | boolean, session?: ViewerSessionContext): Promise { + async show( + file: string, + title: string, + viewer: string | boolean, + session?: ViewerSessionContext, + ): Promise { const saved = session ? this.histories.remember(session) : undefined; const entry = this.open(viewer, session, saved); entry.state.history.push({ file, title }); - if (entry.state.history.length > widgetHistoryLimit) { entry.state.history.shift(); } + if (entry.state.history.length > widgetHistoryLimit) { + entry.state.history.shift(); + } entry.state.index = entry.state.history.length - 1; const pending = this.save(entry); await this.render(entry); @@ -152,7 +226,7 @@ class HtmlWidgetViewerManager { async restore(sessionId?: string): Promise { // Polling can discover an exit while the session picker is open. - const available = [...this.histories.entries.values()].filter(record => { + const available = [...this.histories.entries.values()].filter((record) => { if (this.sessions.resolveSession(record.source).hasExited) { this.histories.forget(record.source.sessionId); return false; @@ -160,33 +234,47 @@ class HtmlWidgetViewerManager { return record.history.length > 0; }); const activeSessionId = sessionId ? undefined : this.sessions.getActiveSessionId(); - let record = sessionId ? this.histories.entries.get(sessionId) : - available.find(item => item.source.sessionId === activeSessionId); + let record = sessionId + ? this.histories.entries.get(sessionId) + : available.find((item) => item.source.sessionId === activeSessionId); if (!record && !sessionId) { - if (available.length === 1) { record = available[0]; } - else if (available.length > 1) { - const picked = await window.showQuickPick(available.map(item => ({ - label: item.history[item.index].title, - description: `R ${item.source.rVer}: ${item.source.pid} · ${item.source.host}`, - detail: `${item.history.length} HTML outputs · ${item.source.sessionId}`, - record: item, - })), { title: 'Restore HTML Viewer', matchOnDescription: true, matchOnDetail: true }); - if (!picked) { return; } + 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.'); + void window.showInformationMessage( + 'No retained HTML Viewer history is available for this session.', + ); return; } const session = this.sessions.resolveSession(record.source); if (session.hasExited) { this.histories.forget(session.sessionId); - void window.showInformationMessage('This R session has exited; its HTML Viewer history is no longer available.'); + void window.showInformationMessage( + 'This R session has exited; its HTML Viewer history is no longer available.', + ); return; } - const viewer = record.viewColumn ? ViewColumn[record.viewColumn] : - workspace.getConfiguration('r').get>('session.viewers.viewColumn')?.viewer ?? 'Two'; + const viewer = record.viewColumn + ? ViewColumn[record.viewColumn] + : (workspace + .getConfiguration('r') + .get>('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); @@ -194,97 +282,160 @@ class HtmlWidgetViewerManager { await pending; } - private open(viewer: string | boolean, session?: ViewerSessionContext, saved?: WidgetHistory): WidgetViewer { + private open( + viewer: string | boolean, + session?: ViewerSessionContext, + saved?: WidgetHistory, + ): WidgetViewer { const identity = session ? widgetSessionIdentity(session.source) : undefined; const existing = identity ? this.viewers.get(identity) : undefined; - if (existing) { return existing; } - const panel = window.createWebviewPanel('r.htmlViewer', htmlViewerTitle, - { preserveFocus: true, viewColumn: ViewColumn[String(viewer) as keyof typeof ViewColumn] ?? ViewColumn.Two }, - { enableScripts: true, enableFindWidget: true, retainContextWhenHidden: true }); + if (existing) { + return existing; + } + const panel = window.createWebviewPanel( + 'r.htmlViewer', + htmlViewerTitle, + { + preserveFocus: true, + viewColumn: ViewColumn[String(viewer) as keyof typeof ViewColumn] ?? ViewColumn.Two, + }, + { enableScripts: true, enableFindWidget: true, retainContextWhenHidden: true }, + ); return this.attachPanel(panel, session, saved); } - private attachPanel(panel: WebviewPanel, session?: ViewerSessionContext, saved?: WidgetHistory, showSessionInfo = false, id: string = randomUUID()): WidgetViewer { + private attachPanel( + panel: WebviewPanel, + session?: ViewerSessionContext, + saved?: WidgetHistory, + showSessionInfo = false, + id: string = randomUUID(), + ): WidgetViewer { const identity = session ? widgetSessionIdentity(session.source) : undefined; const entry: WidgetViewer = { - id, panel, revision: 0, disposed: false, loading: false, showSessionInfo, session, saved, subscriptions: [], resourceRoots: new Map(), + id, + panel, + revision: 0, + disposed: false, + loading: false, + showSessionInfo, + session, + saved, + subscriptions: [], + resourceRoots: new Map(), state: saved ?? { history: [], index: -1 }, }; this.panels.add(entry); // Register before loading HTML so concurrent requests reuse the panel. - if (identity) { this.viewers.set(identity, entry); } + if (identity) { + this.viewers.set(identity, entry); + } const exitObservation = session?.observeExit(() => { this.updateTitle(entry); this.updateToolbar(); void this.updatePanelState(entry); }); - if (exitObservation) { entry.subscriptions.push(exitObservation); } - entry.subscriptions.push(panel.onDidDispose(() => { - entry.disposed = true; - this.releasePanel(entry); - this.panelStates.delete(entry.id); - this.panels.delete(entry); - this.updateToolbar(); - if (!this.disposed) { - if (entry.saved) { void this.histories.save(entry.saved); } - void this.persistPanelStates(); - } - if (identity && this.viewers.get(identity) === entry) { this.viewers.delete(identity); } - })); - entry.subscriptions.push(panel.onDidChangeViewState(() => { - if (!entry.disposed) { void this.save(entry); } - this.updateToolbar(); - })); + if (exitObservation) { + entry.subscriptions.push(exitObservation); + } + entry.subscriptions.push( + panel.onDidDispose(() => { + entry.disposed = true; + this.releasePanel(entry); + this.panelStates.delete(entry.id); + this.panels.delete(entry); + this.updateToolbar(); + if (!this.disposed) { + if (entry.saved) { + void this.histories.save(entry.saved); + } + void this.persistPanelStates(); + } + if (identity && this.viewers.get(identity) === entry) { + this.viewers.delete(identity); + } + }), + ); + entry.subscriptions.push( + panel.onDidChangeViewState(() => { + if (!entry.disposed) { + void this.save(entry); + } + this.updateToolbar(); + }), + ); const iconPath = this.context.asAbsolutePath('images/icons'); panel.iconPath = { dark: Uri.file(path.join(iconPath, 'dark', 'globe.svg')), light: Uri.file(path.join(iconPath, 'light', 'globe.svg')), }; - entry.subscriptions.push(panel.webview.onDidReceiveMessage(async (msg: { - message: string; href?: string; direction?: string; generation?: number; - }) => { - if (entry.disposed) { return; } - if (msg.message === 'linkClicked' && msg.href) { - void env.openExternal(Uri.parse(msg.href)); - } else if (msg.message === 'widget/loaded' && msg.generation === entry.revision) { - entry.finishLoading?.(); - this.updateToolbar(); - } else if (msg.message === 'widget/find' && msg.generation === entry.revision) { - panel.reveal(panel.viewColumn, false); - await commands.executeCommand('editor.action.webvieweditor.showFind'); - } else if (msg.message === 'widget/navigate' && msg.generation === entry.revision && - (msg.direction === 'back' || msg.direction === 'forward')) { - await this.changeHistory(entry, msg.direction); - } - })); + entry.subscriptions.push( + panel.webview.onDidReceiveMessage( + async (msg: { + message: string; + href?: string; + direction?: string; + generation?: number; + }) => { + if (entry.disposed) { + return; + } + if (msg.message === 'linkClicked' && msg.href) { + void env.openExternal(Uri.parse(msg.href)); + } else if ( + msg.message === 'widget/loaded' && + msg.generation === entry.revision + ) { + entry.finishLoading?.(); + this.updateToolbar(); + } else if (msg.message === 'widget/find' && msg.generation === entry.revision) { + panel.reveal(panel.viewColumn, false); + await commands.executeCommand('editor.action.webvieweditor.showFind'); + } else if ( + msg.message === 'widget/navigate' && + msg.generation === entry.revision && + (msg.direction === 'back' || msg.direction === 'forward') + ) { + await this.changeHistory(entry, msg.direction); + } + }, + ), + ); this.updateTitle(entry); return entry; } private releasePanel(entry: WidgetViewer): void { entry.finishLoading?.(); - entry.subscriptions.splice(0).forEach(subscription => subscription?.dispose()); + entry.subscriptions.splice(0).forEach((subscription) => subscription?.dispose()); } private panelState(entry: WidgetViewer): HtmlViewerPanelState { return { - id: entry.id, version: 1, - source: entry.session ? { ...entry.session.source, processExited: entry.session.hasExited } : undefined, - history: entry.state.history.map(item => ({ ...item })), index: entry.state.index, + id: entry.id, + version: 1, + source: entry.session + ? { ...entry.session.source, processExited: entry.session.hasExited } + : undefined, + history: entry.state.history.map((item) => ({ ...item })), + index: entry.state.index, showSessionInfo: entry.showSessionInfo, }; } private updatePanelState(entry: WidgetViewer): Promise { - if (entry.disposed || this.disposed) { return this.panelWrites; } + if (entry.disposed || this.disposed) { + return this.panelWrites; + } this.panelStates.set(entry.id, this.panelState(entry)); return this.persistPanelStates(); } private persistPanelStates(): Promise { const states = [...this.panelStates.values()]; - this.panelWrites = this.panelWrites.then(() => this.context.workspaceState.update(panelStateKey, states)) - .catch(error => console.warn('[HTML Viewer] Could not save panel state', error)); + this.panelWrites = this.panelWrites + .then(() => this.context.workspaceState.update(panelStateKey, states)) + .catch((error) => console.warn('[HTML Viewer] Could not save panel state', error)); return this.panelWrites; } @@ -293,7 +444,7 @@ class HtmlWidgetViewerManager { } private activeViewer(): WidgetViewer | undefined { - return [...this.panels].find(entry => !entry.disposed && entry.panel.active); + return [...this.panels].find((entry) => !entry.disposed && entry.panel.active); } private updateToolbar(): void { @@ -302,16 +453,35 @@ class HtmlWidgetViewerManager { const session = entry?.session; const ready = Boolean(entry?.session && !entry.loading); void Promise.all([ - commands.executeCommand('setContext', 'r.htmlViewer.canGoBack', ready && entry!.state.index > 0), - commands.executeCommand('setContext', 'r.htmlViewer.canGoForward', ready && entry!.state.index < entry!.state.history.length - 1), - commands.executeCommand('setContext', 'r.htmlViewer.canRemove', ready && entry!.state.index >= 0), - commands.executeCommand('setContext', 'r.htmlViewer.canShowInfo', Boolean(session && - (session.hasExited || (session.source.rVer && session.source.pid)))), - ]).catch(error => console.warn('[HTML Viewer] Could not update toolbar', error)); + commands.executeCommand( + 'setContext', + 'r.htmlViewer.canGoBack', + ready && entry!.state.index > 0, + ), + commands.executeCommand( + 'setContext', + 'r.htmlViewer.canGoForward', + ready && entry!.state.index < entry!.state.history.length - 1, + ), + commands.executeCommand( + 'setContext', + 'r.htmlViewer.canRemove', + ready && entry!.state.index >= 0, + ), + commands.executeCommand( + 'setContext', + 'r.htmlViewer.canShowInfo', + Boolean( + session && (session.hasExited || (session.source.rVer && session.source.pid)), + ), + ), + ]).catch((error) => console.warn('[HTML Viewer] Could not update toolbar', error)); } private updateLoadingStatus(entry?: WidgetViewer): void { - if (this.disposed) { return; } + if (this.disposed) { + return; + } const item = entry?.state.history[entry.state.index]; if (!entry?.loadStatus || !item) { this.loadingStatus.hide(); @@ -323,26 +493,42 @@ class HtmlWidgetViewerManager { 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.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; } + if (entry.disposed) { + return; + } const session = entry.session; - const label = session?.hasExited ? 'R: (not attached)' : - session ? formatSessionLabel(session.source.rVer, session.source.pid) : ''; - entry.panel.title = entry.showSessionInfo && label ? `${htmlViewerTitle} · ${label}` : htmlViewerTitle; + const label = session?.hasExited + ? 'R: (not attached)' + : session + ? formatSessionLabel(session.source.rVer, session.source.pid) + : ''; + entry.panel.title = + entry.showSessionInfo && label ? `${htmlViewerTitle} · ${label}` : htmlViewerTitle; } async runCommand(action: HtmlViewerAction): Promise { const entry = this.activeViewer(); - if (!entry) { return; } + if (!entry) { + return; + } if (action === 'info') { const session = entry.session; - if (!session || (!session.hasExited && (!session.source.rVer || !session.source.pid))) { return; } + if (!session || (!session.hasExited && (!session.source.rVer || !session.source.pid))) { + return; + } entry.showSessionInfo = !entry.showSessionInfo; this.updateTitle(entry); await this.updatePanelState(entry); @@ -351,16 +537,27 @@ class HtmlWidgetViewerManager { } } - private async changeHistory(entry: WidgetViewer, action: 'back' | 'forward' | 'remove'): Promise { - if (entry.disposed || entry.loading || !entry.session) { return; } + private async changeHistory( + entry: WidgetViewer, + action: 'back' | 'forward' | 'remove', + ): Promise { + if (entry.disposed || entry.loading || !entry.session) { + return; + } if (action === 'remove') { - if (entry.state.index < 0 || entry.state.index >= entry.state.history.length) { return; } + if (entry.state.index < 0 || entry.state.index >= entry.state.history.length) { + return; + } entry.state.history.splice(entry.state.index, 1); // Prefer the previous output; deleting the first selects the next. - entry.state.index = entry.state.history.length ? Math.max(0, entry.state.index - 1) : -1; + entry.state.index = entry.state.history.length + ? Math.max(0, entry.state.index - 1) + : -1; } else { const index = entry.state.index + (action === 'back' ? -1 : 1); - if (index < 0 || index >= entry.state.history.length) { return; } + if (index < 0 || index >= entry.state.history.length) { + return; + } entry.state.index = index; } const pending = this.save(entry); @@ -386,13 +583,17 @@ class HtmlWidgetViewerManager { // blocked scripts cannot leave navigation or the spinner stuck forever. return () => { navigationTimeout = setTimeout(() => { - if (entry.disposed || entry.revision !== generation) { return; } + 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; } + 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 @@ -409,30 +610,65 @@ class HtmlWidgetViewerManager { const generation = ++entry.revision; const startBrowserTimeout = this.startLoading(entry, generation); const item = entry.state.history[entry.state.index]; - const dir = item ? path.dirname(item.file) : path.join(extensionPath, 'dist/webviews/webview'); + const dir = item + ? path.dirname(item.file) + : path.join(extensionPath, 'dist/webviews/webview'); const { panel } = entry; try { - const { html, localResourceRoots, failed } = await getWebviewHtml(panel.webview, item?.file, dir, Boolean(entry.session), generation, { id: entry.id }); + 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)); + 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); } + 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 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())) { + 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.options = { + ...panel.webview.options, + localResourceRoots: resourceRoots, + }; } panel.webview.html = html; if (failed) { @@ -441,7 +677,9 @@ class HtmlWidgetViewerManager { } else { startBrowserTimeout(); } - if (reveal) { panel.reveal(panel.viewColumn, true); } + if (reveal) { + panel.reveal(panel.viewColumn, true); + } } } catch (error) { if (!entry.disposed && entry.revision === generation) { @@ -453,7 +691,9 @@ class HtmlWidgetViewerManager { } private save(entry: WidgetViewer): Promise { - if (!entry.disposed) { entry.state.viewColumn = entry.panel.viewColumn; } + if (!entry.disposed) { + entry.state.viewColumn = entry.panel.viewColumn; + } return Promise.all([ entry.saved ? this.histories.save(entry.saved) : Promise.resolve(), this.updatePanelState(entry), @@ -461,16 +701,26 @@ class HtmlWidgetViewerManager { } dispose(preservePanels = false): void { - if (this.disposed) { return; } - this.panels.forEach(entry => { void this.save(entry); }); + if (this.disposed) { + return; + } + this.panels.forEach((entry) => { + void this.save(entry); + }); this.disposed = true; this.serializer.dispose(); this.loadingStatus.dispose(); - this.panels.forEach(entry => { - if (preservePanels) { entry.disposed = true; this.releasePanel(entry); } - else { entry.panel.dispose(); } + this.panels.forEach((entry) => { + if (preservePanels) { + entry.disposed = true; + this.releasePanel(entry); + } else { + entry.panel.dispose(); + } }); - if (!preservePanels) { void this.persistPanelStates(); } + if (!preservePanels) { + void this.persistPanelStates(); + } this.panels.clear(); this.viewers.clear(); this.histories.dispose(); @@ -479,21 +729,35 @@ class HtmlWidgetViewerManager { } async function getWebviewHtml( - webview: Webview, file: string | undefined, dir: string, sessionOwned = false, generation = 0, state?: HtmlViewerPanelReference, + webview: Webview, + file: string | undefined, + dir: string, + sessionOwned = false, + generation = 0, + state?: HtmlViewerPanelReference, ): Promise<{ html: string; localResourceRoots: Uri[]; failed: boolean }> { const { extensionPath } = getManager().context; // Resolve webview URIs before awaiting I/O; the panel may close while loading. const baseUri = String(webview.asWebviewUri(Uri.file(dir))); - const scriptUri = webview.asWebviewUri(Uri.file(path.join(extensionPath, 'dist/webviews/webview/index.js'))); - const styleUri = webview.asWebviewUri(Uri.file(path.join(extensionPath, 'dist/webviews/webview/style.css'))); - const localResourceRoots = [Uri.file(dir), Uri.file(path.join(extensionPath, 'dist/webviews/webview'))]; + 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 = '

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; } + if (!sessionOwned) { + throw error; + } failed = true; source = `

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

${escapeHtml(file)}
`; } @@ -516,18 +780,29 @@ async function getWebviewHtml( 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 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')); @@ -538,20 +813,32 @@ async function getWebviewHtml( 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 === '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); - const resourceRoot = 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. if (isAllowedResourceRoot(resourceRoot, dir)) { localResourceRoots.push(resourceRoot); } - edits.push({ start: hrefLocation.startOffset, end: hrefLocation.endOffset, - text: `href="${escapeHtml(String(webview.asWebviewUri(localBase)))}"` }); + edits.push({ + start: hrefLocation.startOffset, + end: hrefLocation.endOffset, + text: `href="${escapeHtml(String(webview.asWebviewUri(localBase)))}"`, + }); } } const baseTag = base ? '' : ``; @@ -566,24 +853,55 @@ async function getWebviewHtml( } 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 trustedRoots = [ + Uri.file(dir), + ...(workspace.isTrusted + ? (workspace.workspaceFolders?.map((folder) => folder.uri) ?? []) + : []), + ]; + return trustedRoots.some((root) => { + if (root.scheme !== 'file' || root.authority !== resourceRoot.authority) { + return false; + } const relative = path.relative(root.fsPath, resourceRoot.fsPath); - return relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`)); + return ( + relative === '' || + (!path.isAbsolute(relative) && + relative !== '..' && + !relative.startsWith(`..${path.sep}`)) + ); }); } function parsePanelState(state: unknown): HtmlViewerPanelState | undefined { - if (!state || typeof state !== 'object') { return; } + 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; } + 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 13dd9cf01..585b7ea2b 100644 --- a/src/webViewer/webview/index.ts +++ b/src/webViewer/webview/index.ts @@ -8,4 +8,6 @@ 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); } +if (state) { + initializeWidgetState(vscode, state); +} diff --git a/src/webViewer/webview/widget.ts b/src/webViewer/webview/widget.ts index 2b8a08e69..353e7a3b9 100644 --- a/src/webViewer/webview/widget.ts +++ b/src/webViewer/webview/widget.ts @@ -7,46 +7,89 @@ export function initializeWidgetState(vscode: VsCode, state: HtmlViewerPanelRefe 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 }); } + 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; } +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; } + 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); } + try { + fragment = decodeURIComponent(fragment); + } catch { + /* Keep malformed fragments literal. */ + } + const target = + document.getElementById(fragment) ?? + Array.from(document.getElementsByName(fragment)).find( + (element) => element instanceof HTMLAnchorElement, + ); + if (target) { + target.scrollIntoView(); + } else if (!fragment || fragment.toLowerCase() === 'top') { + window.scrollTo(0, 0); + } } else if (/^(https?:|mailto:)/i.test(href)) { event.preventDefault(); vscode.postMessage({ message: 'linkClicked', href: anchor.href }); } }); - document.addEventListener('mousedown', event => { + 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 }); + 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')) { + 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') { + 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 4cd3e804b..80e77015e 100644 --- a/src/webViewer/webviewMessages.ts +++ b/src/webViewer/webviewMessages.ts @@ -1,4 +1,3 @@ - import type { ViewerSessionSource } from '../viewerSession'; export interface HtmlViewerPanelState { @@ -19,14 +18,16 @@ export interface VsCode { /** * Function declared by VS Code in Webview */ -export const acquireVsCodeApi: () => VsCode = (globalThis as { acquireVsCodeApi?: () => VsCode }).acquireVsCodeApi || (() => ({} as VsCode)); +export const acquireVsCodeApi: () => VsCode = + (globalThis as { acquireVsCodeApi?: () => VsCode }).acquireVsCodeApi || (() => ({}) as VsCode); export interface LinkClickedMessage { - message: 'linkClicked', - href: string + message: 'linkClicked'; + href: string; } -export type OutMessage = LinkClickedMessage +export type OutMessage = + | LinkClickedMessage | { message: 'widget/loaded'; generation: number } | { message: 'widget/navigate'; direction: 'back' | 'forward'; generation: number } | { message: 'widget/find'; generation: number }; diff --git a/src/workspaceViewer.ts b/src/workspaceViewer.ts index 78cae3a71..dc5122555 100644 --- a/src/workspaceViewer.ts +++ b/src/workspaceViewer.ts @@ -2,19 +2,31 @@ import * as vscode from 'vscode'; import * as path from 'path'; -import { TreeDataProvider, EventEmitter, TreeItemCollapsibleState, TreeItem, Event, Uri, window, ThemeIcon } from 'vscode'; +import { + TreeDataProvider, + EventEmitter, + TreeItemCollapsibleState, + TreeItem, + Event, + Uri, + window, + ThemeIcon, +} from 'vscode'; import { runTextInTerm } from './rTerminal'; -import { workspaceData, WorkspaceData, GlobalEnv, sessionRequest, activeSession, Session, executeSessionCode } from './session'; +import { + workspaceData, + WorkspaceData, + GlobalEnv, + sessionRequest, + activeSession, + Session, + executeSessionCode, +} from './session'; import { config } from './util'; import { extensionContext, globalRHelp, rWorkspace } from './extension'; import { PackageNode } from './helpViewer/treeView'; -const collapsibleTypes: string[] = [ - 'list', - 'environment', - 'pairlist', - 'S4' -]; +const collapsibleTypes: string[] = ['list', 'environment', 'pairlist', 'S4']; interface WorkspaceChild { str: string; @@ -36,7 +48,7 @@ interface WorkspaceChildPage { } function getFirstClass(rClass: string[] | string | undefined): string { - return Array.isArray(rClass) ? rClass[0] : rClass ?? ''; + return Array.isArray(rClass) ? rClass[0] : (rClass ?? ''); } async function populatePackageNodes(): Promise { @@ -51,7 +63,7 @@ async function populatePackageNodes(): Promise { function getPackageNode(name: string): PackageNode | undefined { const rootNode = globalRHelp?.treeViewWrapper.helpViewProvider.rootItem; if (rootNode) { - return rootNode?.pkgRootNode?.children?.find(node => node.label === name); + return rootNode?.pkgRootNode?.children?.find((node) => node.label === name); } } @@ -66,14 +78,21 @@ export class WorkspaceDataProvider implements TreeDataProvider { private childPageGeneration = 0; private _onDidChangeTreeData: EventEmitter = new EventEmitter(); - public readonly onDidChangeTreeData: Event = this._onDidChangeTreeData.event; + public readonly onDidChangeTreeData: Event = + this._onDidChangeTreeData.event; public data: WorkspaceData | undefined; public refresh(): void { this.owner = activeSession; this.data = this.owner?.workspaceUnavailable ? undefined : workspaceData; - this.tree.description = this.owner ? `${this.owner.label ?? 'R'}${this.owner.pid ? ` · PID ${this.owner.pid}` : ''}` : undefined; - this.tree.message = this.owner?.workspaceUnavailable ?? (!this.owner ? 'Select an R Interactive window or attach an R terminal to inspect its workspace.' : undefined); + this.tree.description = this.owner + ? `${this.owner.label ?? 'R'}${this.owner.pid ? ` · PID ${this.owner.pid}` : ''}` + : undefined; + this.tree.message = + this.owner?.workspaceUnavailable ?? + (!this.owner + ? 'Select an R Interactive window or attach an R terminal to inspect its workspace.' + : undefined); this.childPageGeneration++; this.childPages.clear(); this.childPageLoads.clear(); @@ -81,15 +100,24 @@ export class WorkspaceDataProvider implements TreeDataProvider { } public constructor() { - this.attachedNamespacesRootItem = new TreeItem('Attached Namespaces', TreeItemCollapsibleState.Collapsed); + this.attachedNamespacesRootItem = new TreeItem( + 'Attached Namespaces', + TreeItemCollapsibleState.Collapsed, + ); this.attachedNamespacesRootItem.id = 'attached-namespaces'; this.attachedNamespacesRootItem.iconPath = new ThemeIcon('library'); - this.loadedNamespacesRootItem = new TreeItem('Loaded Namespaces', TreeItemCollapsibleState.Collapsed); + this.loadedNamespacesRootItem = new TreeItem( + 'Loaded Namespaces', + TreeItemCollapsibleState.Collapsed, + ); this.loadedNamespacesRootItem.id = 'loaded-namespaces'; this.loadedNamespacesRootItem.iconPath = new ThemeIcon('package'); - this.globalEnvRootItem = new TreeItem('Global Environment', TreeItemCollapsibleState.Expanded); + this.globalEnvRootItem = new TreeItem( + 'Global Environment', + TreeItemCollapsibleState.Expanded, + ); this.globalEnvRootItem.id = 'globalenv'; this.globalEnvRootItem.iconPath = new ThemeIcon('menu'); @@ -99,7 +127,7 @@ export class WorkspaceDataProvider implements TreeDataProvider { }), vscode.commands.registerCommand(LoadMoreItem.command, async (node: LoadMoreItem) => { await this.loadMore(node.parent, node.start); - }) + }), ); this.tree = vscode.window.createTreeView('workspaceViewer', { treeDataProvider: this }); @@ -121,8 +149,10 @@ export class WorkspaceDataProvider implements TreeDataProvider { const generation = this.childPageGeneration; if (element.id === 'attached-namespaces') { await populatePackageNodes(); - if (generation !== this.childPageGeneration) { return []; } - return data.search.map(name => { + if (generation !== this.childPageGeneration) { + return []; + } + return data.search.map((name) => { if (name.startsWith(pkgPrefix)) { const pkgName = name.substring(pkgPrefix.length); const pkgNode = getPackageNode(pkgName); @@ -135,11 +165,13 @@ export class WorkspaceDataProvider implements TreeDataProvider { }); } else if (element.id === 'loaded-namespaces') { await populatePackageNodes(); - if (generation !== this.childPageGeneration) { return []; } + if (generation !== this.childPageGeneration) { + return []; + } const attached_packages = data.search - .filter(name => name.startsWith(pkgPrefix)) - .map(name => name.substring(pkgPrefix.length)); - return data.loaded_namespaces.map(name => { + .filter((name) => name.startsWith(pkgPrefix)) + .map((name) => name.substring(pkgPrefix.length)); + return data.loaded_namespaces.map((name) => { const pkgNode = getPackageNode(name); const item = new PackageItem(name, name, pkgNode); if (attached_packages.includes(name)) { @@ -151,20 +183,23 @@ export class WorkspaceDataProvider implements TreeDataProvider { return this.getGlobalEnvItems(this.data.globalenv); } else if (element instanceof GlobalEnvItem) { const page = await this.getGlobalEnvChildren(element); - const items: TreeItem[] = page.children.map(child => - new GlobalEnvItem( - '', - child.class, - child.str.replace(/\s+/g, ' ').trim(), - child.type, - element.treeLevel + 1, - undefined, - child.has_children, - element.rootName, - child.selector ? [...element.objectPath, child.selector] : element.objectPath, - element.owner, - child.viewable - ) + const items: TreeItem[] = page.children.map( + (child) => + new GlobalEnvItem( + '', + child.class, + child.str.replace(/\s+/g, ' ').trim(), + child.type, + element.treeLevel + 1, + undefined, + child.has_children, + element.rootName, + child.selector + ? [...element.objectPath, child.selector] + : element.objectPath, + element.owner, + child.viewable, + ), ); if (page.nextStart !== undefined) { items.push(new LoadMoreItem(element, page.nextStart)); @@ -189,7 +224,7 @@ export class WorkspaceDataProvider implements TreeDataProvider { str: string, type: string, dim?: number[], - hasChildren?: boolean + hasChildren?: boolean, ): GlobalEnvItem => { return new GlobalEnvItem( key, @@ -205,15 +240,18 @@ export class WorkspaceDataProvider implements TreeDataProvider { ); }; - const items = globalenv ? Object.keys(globalenv).map((key) => - toItem( - key, - getFirstClass(globalenv[key].class), - globalenv[key].str, - globalenv[key].type, - globalenv[key].dim, - globalenv[key].has_children, - )) : []; + const items = globalenv + ? Object.keys(globalenv).map((key) => + toItem( + key, + getFirstClass(globalenv[key].class), + globalenv[key].str, + globalenv[key].type, + globalenv[key].dim, + globalenv[key].has_children, + ), + ) + : []; function sortItems(a: GlobalEnvItem, b: GlobalEnvItem) { if (a.priority > b.priority) { @@ -221,7 +259,7 @@ export class WorkspaceDataProvider implements TreeDataProvider { } else if (a.priority < b.priority) { return 1; } else { - return (a.label && b.label) ? a.label.localeCompare(b.label) : 0; + return a.label && b.label ? a.label.localeCompare(b.label) : 0; } } @@ -233,7 +271,9 @@ export class WorkspaceDataProvider implements TreeDataProvider { } private async getGlobalEnvChildren(element: GlobalEnvItem): Promise { - if (element.owner !== this.owner) { return { children: [] }; } + if (element.owner !== this.owner) { + return { children: [] }; + } const key = this.getChildPageKey(element); const cached = this.childPages.get(key); if (cached) { @@ -249,28 +289,39 @@ export class WorkspaceDataProvider implements TreeDataProvider { return { children: [] }; } - private async requestGlobalEnvChildren(element: GlobalEnvItem, start: number): Promise { - if (element.owner && element.owner === this.owner && !element.owner.workspaceUnavailable && element.rootName) { + private async requestGlobalEnvChildren( + element: GlobalEnvItem, + start: number, + ): Promise { + if ( + element.owner && + element.owner === this.owner && + !element.owner.workspaceUnavailable && + element.rootName + ) { try { - const response = await sessionRequest({ - method: 'workspace_children', - params: { - name: element.rootName, - path: element.objectPath, - start, + const response = (await sessionRequest( + { + method: 'workspace_children', + params: { + name: element.rootName, + path: element.objectPath, + start, + }, }, - }, element.owner) as { children?: unknown, next_start?: unknown } | undefined; + element.owner, + )) as { children?: unknown; next_start?: unknown } | undefined; if (response && Array.isArray(response.children)) { - const children = response.children.filter((child): child is WorkspaceChild => - typeof child === 'object' && - child !== null && - 'str' in child && - 'type' in child && - 'has_children' in child + const children = response.children.filter( + (child): child is WorkspaceChild => + typeof child === 'object' && + child !== null && + 'str' in child && + 'type' in child && + 'has_children' in child, ); - const nextStart = typeof response.next_start === 'number' ? - response.next_start : - undefined; + const nextStart = + typeof response.next_start === 'number' ? response.next_start : undefined; return { children, nextStart }; } } catch { @@ -282,7 +333,9 @@ export class WorkspaceDataProvider implements TreeDataProvider { } private async loadMore(parent: GlobalEnvItem, start: number): Promise { - if (parent.owner !== this.owner || !this.data) { return; } + if (parent.owner !== this.owner || !this.data) { + return; + } const key = this.getChildPageKey(parent); const loadKey = `${key}:${start}`; if (this.childPageLoads.has(loadKey)) { @@ -299,11 +352,13 @@ export class WorkspaceDataProvider implements TreeDataProvider { } this.childPages.set(key, { children: [...current.children, ...next.children], - nextStart: next.nextStart + nextStart: next.nextStart, }); this._onDidChangeTreeData.fire(parent); } finally { - if (generation === this.childPageGeneration) { this.childPageLoads.delete(loadKey); } + if (generation === this.childPageGeneration) { + this.childPageLoads.delete(loadKey); + } } } } @@ -323,7 +378,7 @@ class PackageItem extends TreeItem { this.command = { command: PackageItem.command, title: 'Show Quick Pick', - arguments: [pkgNode] + arguments: [pkgNode], }; } } @@ -334,7 +389,7 @@ class LoadMoreItem extends TreeItem { public constructor( public readonly parent: GlobalEnvItem, - public readonly start: number + public readonly start: number, ) { super('...', TreeItemCollapsibleState.None); this.tooltip = 'Load next 500 items'; @@ -342,14 +397,14 @@ class LoadMoreItem extends TreeItem { this.command = { command: LoadMoreItem.command, title: 'Load next 500 items', - arguments: [this] + arguments: [this], }; } } enum TreeLevel { Parent = 0, - Scalar = 1 + Scalar = 1, } export class GlobalEnvItem extends TreeItem { @@ -373,27 +428,25 @@ export class GlobalEnvItem extends TreeItem { public readonly owner?: Session, viewable?: boolean, ) { - super( - label, - GlobalEnvItem.setCollapsibleState(type, hasChildren) - ); + super(label, GlobalEnvItem.setCollapsibleState(type, hasChildren)); this.treeLevel = treeLevel ?? TreeLevel.Scalar; this.priority = dim ? 1 : 0; this.rootName = rootName ?? label; this.objectPath = objectPath ?? []; - this.description = this.getDescription( - dim, - str, - rClass, - type - ); + this.description = this.getDescription(dim, str, rClass, type); this.tooltip = this.getTooltip(label, rClass, treeLevel); this.iconPath = this.getIcon(type, dim); - this.contextValue = treeLevel === 0 ? 'rootNode' : viewable ? 'viewableNode' : `childNode${this.treeLevel}`; + this.contextValue = + treeLevel === 0 ? 'rootNode' : viewable ? 'viewableNode' : `childNode${this.treeLevel}`; } - private getDescription(dim: number[] | undefined, str: string, rClass: string, type: string): string { + private getDescription( + dim: number[] | undefined, + str: string, + rClass: string, + type: string, + ): string { if (dim && type === 'list') { if (dim[1] === 1) { return `${rClass}: ${dim[0]} obs. of ${dim[1]} variable`; @@ -405,11 +458,7 @@ export class GlobalEnvItem extends TreeItem { } } - private getTooltip( - label: string, - rClass: string, - treeLevel?: number - ): string { + private getTooltip(label: string, rClass: string, treeLevel?: number): string { if (treeLevel === TreeLevel.Scalar) { return ''; } else { @@ -437,7 +486,7 @@ export class GlobalEnvItem extends TreeItem { in the futere for more tree levels.*/ private static setCollapsibleState( type: string, - hasChildren?: boolean + hasChildren?: boolean, ): vscode.TreeItemCollapsibleState { if (collapsibleTypes.includes(type) && hasChildren) { return TreeItemCollapsibleState.Collapsed; @@ -453,62 +502,93 @@ function workspaceOwner(): Session | undefined { async function runWorkspaceCode(code: string, owner: Session | undefined): Promise { try { - if (owner) { await executeSessionCode(owner, code); } - else { await runTextInTerm(code); } + if (owner) { + await executeSessionCode(owner, code); + } else { + await runTextInTerm(code); + } } catch (error) { - void window.showWarningMessage(`R Workspace: ${error instanceof Error ? error.message : String(error)}`); + void window.showWarningMessage( + `R Workspace: ${error instanceof Error ? error.message : String(error)}`, + ); } } export async function clearWorkspace(): Promise { const owner = workspaceOwner(); - if (!owner || owner.workspaceUnavailable) { return; } + if (!owner || owner.workspaceUnavailable) { + return; + } const removeHiddenItems = config().get('workspaceViewer.removeHiddenItems'); if (config().get('workspaceViewer.clearPrompt')) { const selection = await window.showInformationMessage( `Clear the workspace of “${owner.label ?? 'R'}” (PID ${owner.pid})? This cannot be reversed.`, - 'Confirm', 'Cancel'); - if (selection !== 'Confirm') { return; } + 'Confirm', + 'Cancel', + ); + if (selection !== 'Confirm') { + return; + } } - await runWorkspaceCode(removeHiddenItems ? 'rm(list = ls(all.names = TRUE))' : 'rm(list = ls())', owner); + await runWorkspaceCode( + removeHiddenItems ? 'rm(list = ls(all.names = TRUE))' : 'rm(list = ls())', + owner, + ); } export async function saveWorkspace(): Promise { const owner = workspaceOwner(); - if (!owner || owner.workspaceUnavailable) { return; } + if (!owner || owner.workspaceUnavailable) { + return; + } const uri = await window.showSaveDialog({ defaultUri: Uri.file(path.join(owner.workingDir, 'workspace.RData')), - filters: { 'RData': ['RData'] }, - title: `Save Workspace — ${owner.label ?? 'R'}` + filters: { RData: ['RData'] }, + title: `Save Workspace — ${owner.label ?? 'R'}`, }); - if (uri) { await runWorkspaceCode(`save.image(${JSON.stringify(uri.fsPath)})`, owner); } + if (uri) { + await runWorkspaceCode(`save.image(${JSON.stringify(uri.fsPath)})`, owner); + } } export async function loadWorkspace(): Promise { const owner = workspaceOwner(); const uri = await window.showOpenDialog({ - defaultUri: owner?.workingDir ? Uri.file(owner.workingDir) : vscode.window.activeTextEditor?.document.uri, - filters: { 'Data': ['RData'] }, - title: `Load Workspace${owner ? ` — ${owner.label ?? 'R'}` : ''}` + defaultUri: owner?.workingDir + ? Uri.file(owner.workingDir) + : vscode.window.activeTextEditor?.document.uri, + filters: { Data: ['RData'] }, + title: `Load Workspace${owner ? ` — ${owner.label ?? 'R'}` : ''}`, }); - if (uri?.length) { await runWorkspaceCode(`load(${JSON.stringify(uri[0].fsPath)})`, owner); } + if (uri?.length) { + await runWorkspaceCode(`load(${JSON.stringify(uri[0].fsPath)})`, owner); + } } export async function viewItem(node: GlobalEnvItem): Promise { if (node.owner && !node.owner.workspaceUnavailable && node.rootName) { if (!node.objectPath.length) { - await runWorkspaceCode(`View(get(${JSON.stringify(node.rootName)}, envir = .GlobalEnv, inherits = FALSE), title = ${JSON.stringify(node.rootName)})`, node.owner); + await runWorkspaceCode( + `View(get(${JSON.stringify(node.rootName)}, envir = .GlobalEnv, inherits = FALSE), title = ${JSON.stringify(node.rootName)})`, + node.owner, + ); return; } - await sessionRequest({ - method: 'workspace_view', - params: { name: node.rootName, path: node.objectPath }, - }, node.owner); + await sessionRequest( + { + method: 'workspace_view', + params: { name: node.rootName, path: node.objectPath }, + }, + node.owner, + ); } } export async function removeItem(node: GlobalEnvItem): Promise { if (node.owner && node.label) { - await runWorkspaceCode(`rm(list = ${JSON.stringify(node.label)}, envir = .GlobalEnv)`, node.owner); + await runWorkspaceCode( + `rm(list = ${JSON.stringify(node.label)}, envir = .GlobalEnv)`, + node.owner, + ); } }