diff --git a/package.json b/package.json index e9a339a9d..8aad6fdd0 100644 --- a/package.json +++ b/package.json @@ -526,6 +526,54 @@ "category": "R", "command": "r.goToNextChunk" }, + { + "command": "r.markdown.showPreviewToSide", + "title": "Open Preview to the Side", + "icon": "$(open-preview)", + "category": "R" + }, + { + "command": "r.markdown.showPreview", + "title": "Open Preview", + "icon": "$(open-preview)", + "category": "R" + }, + { + "command": "r.markdown.preview.refresh", + "title": "Refresh Preview", + "icon": "$(refresh)", + "category": "R" + }, + { + "command": "r.markdown.preview.openExternal", + "title": "Open in External Browser", + "icon": "$(link-external)", + "category": "R" + }, + { + "command": "r.markdown.preview.showSource", + "title": "Show Source", + "icon": "$(go-to-file)", + "category": "R" + }, + { + "title": "Toggle Style", + "category": "R", + "command": "r.markdown.preview.toggleStyle", + "icon": "$(symbol-color)" + }, + { + "title": "Enable Auto Refresh", + "category": "R", + "command": "r.markdown.preview.enableAutoRefresh", + "icon": "$(sync)" + }, + { + "title": "Disable Auto Refresh", + "category": "R", + "command": "r.markdown.preview.disableAutoRefresh", + "icon": "$(sync-ignored)" + }, { "title": "Launch RStudio Addin", "category": "R", @@ -797,6 +845,18 @@ "key": "Ctrl+alt+e", "mac": "cmd+alt+e", "when": "editorTextFocus && editorLangId == 'r'" + }, + { + "command": "r.markdown.showPreviewToSide", + "key": "Ctrl+k v", + "mac": "cmd+k v", + "when": "editorTextFocus && editorLangId == 'rmd'" + }, + { + "command": "r.markdown.showPreview", + "key": "Ctrl+shift+v", + "mac": "cmd+shift+v", + "when": "editorTextFocus && editorLangId == 'rmd'" } ], "menus": { @@ -812,6 +872,36 @@ } ], "editor/title": [ + { + "command": "r.markdown.preview.openExternal", + "when": "resourceScheme =~ /webview/ && r.preview.active", + "group": "navigation@1" + }, + { + "command": "r.markdown.preview.showSource", + "when": "resourceScheme =~ /webview/ && r.preview.active", + "group": "navigation@2" + }, + { + "command": "r.markdown.preview.toggleStyle", + "when": "resourceScheme =~ /webview/ && r.preview.active", + "group": "navigation@3" + }, + { + "command": "r.markdown.preview.refresh", + "when": "resourceScheme =~ /webview/ && r.preview.active", + "group": "navigation@4" + }, + { + "command": "r.markdown.preview.enableAutoRefresh", + "when": "resourceScheme =~ /webview/ && r.preview.active && !r.preview.autoRefresh", + "group": "navigation@5" + }, + { + "command": "r.markdown.preview.disableAutoRefresh", + "when": "resourceScheme =~ /webview/ && r.preview.active && r.preview.autoRefresh", + "group": "navigation@5" + }, { "command": "r.browser.refresh", "when": "resourceScheme =~ /webview/ && r.browser.active", @@ -906,6 +996,12 @@ "group": "httpgd", "when": "resourceScheme =~ /webview/ && r.plot.active", "command": "r.plot.openUrl" + }, + { + "command": "r.markdown.showPreviewToSide", + "alt": "r.markdown.showPreview", + "when": "editorLangId == rmd", + "group": "navigation" } ], "editor/context": [ @@ -1040,6 +1136,13 @@ "group": "navigation@3", "when": "view == rLiveShare && r.liveShare:aborted" } + ], + "explorer/context": [ + { + "command": "r.markdown.showPreview", + "when": "resourceLangId == rmd", + "group": "navigation" + } ] }, "configuration": { @@ -1108,6 +1211,11 @@ "default": "rgba(128, 128, 128, 0.1)", "description": "RMarkdown chunk background color in RGBA or RGB value. Defaults to rgba(128, 128, 128, 0.1). Leave it empty to disable it (use default editor background color). Reload VS Code after changing settings.\n\nLearn how to set colors: https://www.w3schools.com/css/css_colors_rgb.asp.\n\nExamples for syntax rgba(, , , ):\nrgba(128, 128, 128, 0.1)\nrgba(128, 128, 128, 0.3)\nrgba(255, 165, 0, 0.1)\n\n" }, + "r.rmarkdown.preview.autoRefresh": { + "type": "boolean", + "default": true, + "description": "Enable automatic refresh of R Markdown preview on file update." + }, "r.helpPanel.enableSyntaxHighlighting": { "type": "boolean", "default": true, @@ -1305,6 +1413,7 @@ "dependencies": { "bootstrap": "^5.0.1", "cheerio": "1.0.0-rc.10", + "crypto": "^1.0.1", "datatables.net": "^1.10.25", "datatables.net-bs4": "^1.10.25", "datatables.net-fixedheader-jqui": "^3.1.9", diff --git a/src/extension.ts b/src/extension.ts index eb373503b..c287d76d2 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -3,6 +3,9 @@ // interfaces, functions, etc. provided by vscode import * as vscode from 'vscode'; +import * as os from 'os'; +import path = require('path'); +import fs = require('fs'); // functions etc. implemented in this extension import * as preview from './preview'; @@ -19,17 +22,28 @@ import * as completions from './completions'; import * as rShare from './liveshare'; import * as httpgdViewer from './plotViewer'; +import { RMarkdownPreviewManager } from './rmarkdown/preview'; // global objects used in other files +export const extDir: string = path.join(os.homedir(), '.vscode-R'); +export const tmpDir: string = path.join(extDir, 'tmp'); export let rWorkspace: workspaceViewer.WorkspaceDataProvider | undefined = undefined; export let globalRHelp: rHelp.RHelp | undefined = undefined; export let extensionContext: vscode.ExtensionContext; export let enableSessionWatcher: boolean = undefined; export let globalHttpgdManager: httpgdViewer.HttpgdManager | undefined = undefined; - +export let rMarkdownPreview: RMarkdownPreviewManager | undefined = undefined; // Called (once) when the extension is activated export async function activate(context: vscode.ExtensionContext): Promise { + if (!fs.existsSync(extDir)) { + fs.mkdirSync(extDir); + } + + if (!fs.existsSync(tmpDir)) { + fs.mkdirSync(tmpDir); + } + // create a new instance of RExtensionImplementation // is used to export an interface to the help panel // this export is used e.g. by vscode-r-debugger to show the help panel from within debug sessions @@ -79,6 +93,15 @@ export async function activate(context: vscode.ExtensionContext): Promise rMarkdownPreview.previewRmd(vscode.ViewColumn.Beside), + 'r.markdown.showPreview': (uri: vscode.Uri) => rMarkdownPreview.previewRmd(vscode.ViewColumn.Active, uri), + 'r.markdown.preview.refresh': () => rMarkdownPreview.refreshPanel(), + 'r.markdown.preview.openExternal': () => void rMarkdownPreview.openExternalBrowser(), + 'r.markdown.preview.showSource': () => rMarkdownPreview.showSource(), + 'r.markdown.preview.toggleStyle': () => rMarkdownPreview.toggleTheme(), + 'r.markdown.preview.enableAutoRefresh': () => rMarkdownPreview.enableAutoRefresh(), + 'r.markdown.preview.disableAutoRefresh': () => rMarkdownPreview.disableAutoRefresh(), + // editor independent commands 'r.createGitignore': rGitignore.createGitignore, 'r.loadAll': () => rTerminal.runTextInTerm('devtools::load_all()'), @@ -136,6 +159,9 @@ export async function activate(context: vscode.ExtensionContext): Promise(Object.entries({ - '&': '&', - '<': '<', - '>': '>', - '"': '"', - '\'': ''', - '/': '/' - })); - return String(source).replace(/[&<>"'/]/g, (s: string) => entityMap.get(s) || ''); -} diff --git a/src/rTerminal.ts b/src/rTerminal.ts index 8262f7872..9a864fd29 100644 --- a/src/rTerminal.ts +++ b/src/rTerminal.ts @@ -6,11 +6,11 @@ import { isDeepStrictEqual } from 'util'; import * as vscode from 'vscode'; -import { extensionContext } from './extension'; +import { extensionContext, extDir } from './extension'; import * as util from './util'; import * as selection from './selection'; import { getSelection } from './selection'; -import { removeSessionFiles, watcherDir } from './session'; +import { removeSessionFiles } from './session'; import { config, delay, getRterm } from './util'; import { rGuestService, isGuestSession } from './liveshare'; export let rTerm: vscode.Terminal; @@ -124,7 +124,7 @@ export async function createRTerm(preserveshow?: boolean): Promise { R_PROFILE_USER_OLD: process.env.R_PROFILE_USER, R_PROFILE_USER: newRprofile, VSCODE_INIT_R: initR, - VSCODE_WATCHER_DIR: watcherDir + VSCODE_WATCHER_DIR: extDir }; } rTerm = vscode.window.createTerminal(termOptions); diff --git a/src/rmarkdown.ts b/src/rmarkdown/index.ts similarity index 99% rename from src/rmarkdown.ts rename to src/rmarkdown/index.ts index 4f9a51b2b..2210e3a66 100644 --- a/src/rmarkdown.ts +++ b/src/rmarkdown/index.ts @@ -3,8 +3,8 @@ import { CompletionItem, CompletionItemProvider, Event, EventEmitter, Position, Range, TextDocument, TextEditorDecorationType, window, Selection, commands } from 'vscode'; -import { runChunksInTerm } from './rTerminal'; -import { config } from './util'; +import { runChunksInTerm } from '../rTerminal'; +import { config } from '../util'; function isRDocument(document: TextDocument) { return (document.languageId === 'r'); diff --git a/src/rmarkdown/preview.ts b/src/rmarkdown/preview.ts new file mode 100644 index 000000000..712c7eb5f --- /dev/null +++ b/src/rmarkdown/preview.ts @@ -0,0 +1,416 @@ + +import * as cp from 'child_process'; +import * as vscode from 'vscode'; +import * as fs from 'fs-extra'; +import * as cheerio from 'cheerio'; + +import path = require('path'); +import crypto = require('crypto'); + + +import { config, doWithProgress, getRpath, readContent, setContext, escapeHtml } from '../util'; +import { extensionContext, tmpDir } from '../extension'; + +class RMarkdownPreview extends vscode.Disposable { + title: string; + cp: cp.ChildProcessWithoutNullStreams; + panel: vscode.WebviewPanel; + resourceViewColumn: vscode.ViewColumn; + outputUri: vscode.Uri; + htmlDarkContent: string; + htmlLightContent: string; + fileWatcher: fs.FSWatcher; + autoRefresh: boolean; + + constructor(title: string, cp: cp.ChildProcessWithoutNullStreams, panel: vscode.WebviewPanel, + resourceViewColumn: vscode.ViewColumn, outputUri: vscode.Uri, uri: vscode.Uri, + RMarkdownPreviewManager: RMarkdownPreviewManager, themeBool: boolean, autoRefresh: boolean) { + super(() => { + this.cp?.kill('SIGKILL'); + this.panel?.dispose(); + this.fileWatcher?.close(); + fs.removeSync(this.outputUri.path); + }); + + this.title = title; + this.cp = cp; + this.panel = panel; + this.resourceViewColumn = resourceViewColumn; + this.outputUri = outputUri; + this.autoRefresh = autoRefresh; + void this.refreshContent(themeBool); + this.startFileWatcher(RMarkdownPreviewManager, uri); + } + + public styleHtml(themeBool: boolean) { + if (themeBool) { + this.panel.webview.html = this.htmlDarkContent; + } else { + this.panel.webview.html = this.htmlLightContent; + } + } + + public async refreshContent(themeBool: boolean) { + this.getHtmlContent(await readContent(this.outputUri.path, 'utf8')); + this.styleHtml(themeBool); + } + + private startFileWatcher(RMarkdownPreviewManager: RMarkdownPreviewManager, uri: vscode.Uri) { + let fsTimeout: NodeJS.Timeout; + const fileWatcher = fs.watch(uri.path, {}, () => { + if (this.autoRefresh && !fsTimeout) { + fsTimeout = setTimeout(() => { fsTimeout = null; }, 1000); + void RMarkdownPreviewManager.updatePreview(this); + } + }); + this.fileWatcher = fileWatcher; + } + + 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)))}/`); + this.htmlLightContent = content; + + const re = new RegExp('.*', 'ms'); + const isHtml = !!re.exec(content); + + if (!isHtml) { + const html = escapeHtml(content); + content = `
${html}
`; + } + + const $ = cheerio.load(this.htmlLightContent); + const chunkCol = String(config().get('rmarkdown.chunkBackgroundColor')); + + // make the output chunks a little lighter to stand out + const colReg = /[0-9.]+/g; + const regOut = chunkCol.match(colReg); + const outCol = `rgba(${regOut[0] ?? 100}, ${regOut[1] ?? 100}, ${regOut[2] ?? 100}, ${Number(regOut[3]) + 0.05 ?? .5})`; + + const style = + ` + `; + $('head').append(style); + this.htmlDarkContent = $.html(); + } +} + +class RMarkdownPreviewStore extends vscode.Disposable { + private store: Map = new Map(); + + constructor() { + super((): void => { + for (const preview of this.store) { + preview[1].dispose(); + } + this.store.clear(); + }); + } + + public add(uri: vscode.Uri, preview: RMarkdownPreview): Map { + return this.store.set(uri, preview); + } + + // dispose child and remove it from set + public delete(uri: vscode.Uri): boolean { + this.store.get(uri).dispose(); + return this.store.delete(uri); + } + + public get(uri: vscode.Uri): RMarkdownPreview { + return this.store.get(uri); + } + + public getUri(preview: RMarkdownPreview): vscode.Uri { + for (const _preview of this.store) { + if (_preview[1] === preview) { + return _preview[0]; + } + } + return undefined; + } + + public has(uri: vscode.Uri): boolean { + return this.store.has(uri); + } + + [Symbol.iterator]() { + return this.store[Symbol.iterator](); + } +} + +export class RMarkdownPreviewManager { + private rPath: string; + private rMarkdownOutput: vscode.OutputChannel = vscode.window.createOutputChannel('R Markdown'); + + // the currently selected RMarkdown preview + private activePreview: { uri: vscode.Uri, preview: RMarkdownPreview } = { uri: null, preview: null}; + // store of all open RMarkdown previews + private previewStore: RMarkdownPreviewStore = new RMarkdownPreviewStore; + // uri that are in the process of knitting + // so that we can't spam the preview button + private busyUriStore: Set = new Set(); + + // todo, better name? enum? + private vscodeTheme = true; + + public async init(): Promise { + this.rPath = await getRpath(false); + extensionContext.subscriptions.push(this.previewStore); + } + + public async previewRmd(viewer: vscode.ViewColumn, uri?: vscode.Uri): Promise { + const fileUri = uri ?? vscode.window.activeTextEditor.document.uri; + const fileName = fileUri.path.substring(fileUri.path.lastIndexOf('/') + 1); + const currentViewColumn: vscode.ViewColumn = vscode.window.activeTextEditor?.viewColumn ?? vscode.ViewColumn.Active ?? vscode.ViewColumn.One; + if (this.busyUriStore.has(fileUri)) { + return; + } else if (this.previewStore.has(fileUri)) { + this.previewStore.get(fileUri)?.panel.reveal(); + } else { + this.busyUriStore.add(fileUri); + await this.knitWithProgress(fileUri, fileName, viewer, currentViewColumn, uri); + this.busyUriStore.delete(fileUri); + } + } + + public refreshPanel(preview?: RMarkdownPreview): void { + if (preview) { + void preview.refreshContent(this.vscodeTheme); + } else if (this.activePreview) { + void this.activePreview?.preview?.refreshContent(this.vscodeTheme); + } + } + + public enableAutoRefresh(preview?: RMarkdownPreview): void { + if (preview) { + preview.autoRefresh = true; + } else if (this.activePreview?.preview) { + this.activePreview.preview.autoRefresh = true; + void setContext('r.preview.autoRefresh', true); + } + } + + public disableAutoRefresh(preview?: RMarkdownPreview): void { + if (preview) { + preview.autoRefresh = false; + } else if (this.activePreview?.preview) { + this.activePreview.preview.autoRefresh = false; + void setContext('r.preview.autoRefresh', false); + } + } + + public toggleTheme(): void { + this.vscodeTheme = !this.vscodeTheme; + for (const preview of this.previewStore) { + void preview[1].styleHtml(this.vscodeTheme); + } + } + + // show the source uri for the current preview. + // has a few idiosyncracies with view columns due to some limitations with + // vscode api. the view column will be set in order of priority: + // 1. the original document's view column when the preview button was pressed + // 2. the current webview's view column + // 3. the current active editor + // this is because we cannot tell the view column of a file if it is not visible + // (e.g., is an unopened tab) + public async showSource(): Promise { + if (this.activePreview) { + await vscode.commands.executeCommand('vscode.open', this.activePreview?.uri, { + preserveFocus: false, + preview: false, + viewColumn: this.activePreview?.preview?.resourceViewColumn ?? this.activePreview?.preview?.panel.viewColumn ?? vscode.ViewColumn.Active + }); + } + } + + public async openExternalBrowser(): Promise { + if (this.activePreview) { + await vscode.env.openExternal(this.activePreview?.preview?.outputUri); + } + } + + public async updatePreview(preview: RMarkdownPreview): Promise { + const previewUri = this.previewStore?.getUri(preview); + preview.cp?.kill('SIGKILL'); + + const childProcess: cp.ChildProcessWithoutNullStreams | void = await this.knitDocument(previewUri, preview.title).catch(() => { + void vscode.window.showErrorMessage('There was an error in knitting the document. Please check the R Markdown output stream.'); + this.rMarkdownOutput.show(true); + this.previewStore.delete(previewUri); + }); + + if (childProcess) { + preview.cp = childProcess; + } + + this.refreshPanel(preview); + } + + private async knitWithProgress(fileUri: vscode.Uri, fileName: string, viewer: vscode.ViewColumn, currentViewColumn: vscode.ViewColumn, uri?: vscode.Uri) { + await doWithProgress(async (token: vscode.CancellationToken) => { + await this.knitDocument(fileUri, fileName, token, viewer, currentViewColumn); + }, + vscode.ProgressLocation.Notification, + `Knitting ${fileName}...`, + true + ).catch((rejection: { + cp: cp.ChildProcessWithoutNullStreams, + wasCancelled?: boolean + }) => { + // todo, this section may need to be cleaned up a bit, + // move the non-rejection catch to the await knitDocument? + 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 + if (this.previewStore.has(uri)) { + this.previewStore.delete(uri); + } else { + rejection.cp.kill('SIGKILL'); + } + }); + } + + private async knitDocument(fileUri: vscode.Uri, fileName: string, token?: vscode.CancellationToken, viewer?: vscode.ViewColumn, currentViewColumn?: vscode.ViewColumn) { + return await new Promise((resolve, reject) => { + const lim = '---vsc---'; + const re = new RegExp(`.*${lim}(.*)${lim}.*`, 'ms'); + const outputFile = path.join(tmpDir, crypto.createHash('sha256').update(fileUri.fsPath).digest('hex') + '.html'); + const cmd = ( + `${this.rPath} --silent --slave --no-save --no-restore -e ` + + `"cat('${lim}', + rmarkdown::render('${String(fileUri.path)}', output_format = rmarkdown::html_document(), output_file = '${outputFile}', intermediates_dir = '${tmpDir}'), + '${lim}', sep='')"` + ); + + let childProcess: cp.ChildProcessWithoutNullStreams; + try { + childProcess = cp.exec(cmd); + } catch (e: unknown) { + console.warn(`[VSC-R] error: ${e as string}`); + reject({ cp: childProcess, wasCancelled: false }); + } + + this.rMarkdownOutput.appendLine(`[VSC-R] ${fileName} process started`); + + childProcess.stdout.on('data', + (data: Buffer) => { + const dat = data.toString('utf8'); + this.rMarkdownOutput.appendLine(dat); + if (token?.isCancellationRequested) { + resolve(childProcess); + } else { + const outputUrl = re.exec(dat)?.[0]?.replace(re, '$1'); + if (outputUrl) { + if (viewer !== undefined) { + const autoRefresh = config().get('rmarkdown.preview.autoRefresh'); + void this.openPreview( + vscode.Uri.parse(outputUrl), + fileUri, + fileName, + childProcess, + viewer, + currentViewColumn, + autoRefresh + ); + } + resolve(childProcess); + } + } + } + ); + + childProcess.stderr.on('data', (data: Buffer) => { + const dat = data.toString('utf8'); + this.rMarkdownOutput.appendLine(dat); + if (dat.includes('Execution halted')) { + reject({ cp: childProcess, wasCancelled: false }); + } + }); + + childProcess.on('exit', (code, signal) => { + this.rMarkdownOutput.appendLine(`[VSC-R] ${fileName} process exited ` + + (signal ? `from signal '${signal}'` : `with exit code ${code}`)); + }); + + token?.onCancellationRequested(() => { + reject({ cp: childProcess, wasCancelled: true }); + }); + }); + } + + private openPreview(outputUri: vscode.Uri, fileUri: vscode.Uri, title: string, cp: cp.ChildProcessWithoutNullStreams, viewer: vscode.ViewColumn, resourceViewColumn: vscode.ViewColumn, autoRefresh:boolean): void { + const panel = vscode.window.createWebviewPanel( + 'previewRmd', + `Preview ${title}`, + { + preserveFocus: true, + viewColumn: viewer + }, + { + enableFindWidget: true, + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [vscode.Uri.file(tmpDir)], + }); + + // Push the new rmd webview to the open proccesses array, + // to keep track of running child processes + // (primarily used in killing the child process, but also + // general state tracking) + const preview = new RMarkdownPreview( + title, + cp, + panel, + resourceViewColumn, + outputUri, + fileUri, + this, + this.vscodeTheme, + autoRefresh + ); + this.previewStore.add(fileUri, preview); + + // state change + panel.onDidDispose(() => { + // clear values + this.activePreview = this.activePreview?.preview === preview ? { uri: null, preview: null} : this.activePreview; + void setContext('r.preview.active', false); + this.previewStore.delete(fileUri); + }); + + panel.onDidChangeViewState(({ webviewPanel }) => { + void setContext('r.preview.active', webviewPanel.active); + if (webviewPanel.active) { + this.activePreview.preview = preview; + this.activePreview.uri = fileUri; + void setContext('r.preview.autoRefresh', preview.autoRefresh); + } + }); + } +} + diff --git a/src/session.ts b/src/session.ts index 6095b1e72..e6d3a5e07 100644 --- a/src/session.ts +++ b/src/session.ts @@ -14,12 +14,11 @@ import { FSWatcher } from 'fs-extra'; import { config, readContent } from './util'; import { purgeAddinPickerItems, dispatchRStudioAPICall } from './rstudioapi'; -import { rWorkspace, globalRHelp, globalHttpgdManager } from './extension'; +import { extDir, rWorkspace, globalRHelp, globalHttpgdManager } from './extension'; import { UUID, rHostService, rGuestService, isLiveShare, isHost, isGuestSession, closeBrowser, guestResDir, shareBrowser, openVirtualDoc, shareWorkspace } from './liveshare'; export let globalenv: any; let resDir: string; -export let watcherDir: string; export let requestFile: string; export let requestLockFile: string; let requestTimeStamp: number; @@ -44,22 +43,16 @@ let activeBrowserExternalUri: Uri; export function deploySessionWatcher(extensionPath: string): void { console.info(`[deploySessionWatcher] extensionPath: ${extensionPath}`); resDir = path.join(extensionPath, 'dist', 'resources'); - watcherDir = path.join(os.homedir(), '.vscode-R'); - console.info(`[deploySessionWatcher] watcherDir: ${watcherDir}`); - if (!fs.existsSync(watcherDir)) { - console.info('[deploySessionWatcher] watcherDir not exists, create directory'); - fs.mkdirSync(watcherDir); - } const initPath = path.join(extensionPath, 'R', 'init.R'); - const linkPath = path.join(watcherDir, 'init.R'); + const linkPath = path.join(extDir, 'init.R'); fs.writeFileSync(linkPath, `local(source("${initPath.replace(/\\/g, '\\\\')}", chdir = TRUE, local = TRUE))\n`); } export function startRequestWatcher(sessionStatusBarItem: StatusBarItem): void { console.info('[startRequestWatcher] Starting'); - requestFile = path.join(watcherDir, 'request.log'); - requestLockFile = path.join(watcherDir, 'request.lock'); + requestFile = path.join(extDir, 'request.log'); + requestLockFile = path.join(extDir, 'request.lock'); requestTimeStamp = 0; responseTimeStamp = 0; if (!fs.existsSync(requestLockFile)) { @@ -238,7 +231,7 @@ export async function showBrowser(url: string, title: string, viewer: string | b console.info('[showBrowser] Done'); } -function getBrowserHtml(uri: Uri) { +function getBrowserHtml(uri: Uri): string { return ` diff --git a/src/util.ts b/src/util.ts index 502a47a5f..d649dd4e8 100644 --- a/src/util.ts +++ b/src/util.ts @@ -230,16 +230,17 @@ export async function executeAsTask(name: string, command: string, args?: string // 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: () => T | Promise, location: string | vscode.ProgressLocation = 'rHelpPages'): Promise { +export async function doWithProgress(cb: (token?: vscode.CancellationToken) => T | Promise, location: string | vscode.ProgressLocation = 'rHelpPages', title?: string, cancellable?: boolean): Promise { const location2 = (typeof location === 'string' ? { viewId: location } : location); const options: vscode.ProgressOptions = { location: location2, - cancellable: false + cancellable: cancellable ?? false, + title: title }; let ret: T; - await vscode.window.withProgress(options, async () => { + await vscode.window.withProgress(options, async (_progress, token) => { const retPromise = new Promise((resolve) => setTimeout(() => { - const ret = cb(); + const ret = cb(token); resolve(ret); })); ret = await retPromise; @@ -338,3 +339,16 @@ export async function setContext(key: string, value: any): Promise { '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({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + '\'': ''', + '/': '/' + })); + return String(source).replace(/[&<>"'/]/g, (s: string) => entityMap.get(s) || ''); +} diff --git a/yarn.lock b/yarn.lock index 68254321f..32330d6c0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -913,6 +913,11 @@ cross-spawn@^7.0.2, cross-spawn@^7.0.3: shebang-command "^2.0.0" which "^2.0.1" +crypto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/crypto/-/crypto-1.0.1.tgz#2af1b7cad8175d24c8a1b0778255794a21803037" + integrity sha512-VxBKmeNcqQdiUQUW2Tzq0t377b54N2bMtXO/qiLa+6eRRmmC4qT3D4OnTGoT/U6O9aklQ/jTwbOtRMTTY8G0Ig== + css-select@^4.1.3: version "4.1.3" resolved "https://registry.yarnpkg.com/css-select/-/css-select-4.1.3.tgz#a70440f70317f2669118ad74ff105e65849c7067"