diff --git a/extensions/markdown-language-features/src/commands/renderDocument.ts b/extensions/markdown-language-features/src/commands/renderDocument.ts index f9ec89fce3d..d46e10d52e9 100644 --- a/extensions/markdown-language-features/src/commands/renderDocument.ts +++ b/extensions/markdown-language-features/src/commands/renderDocument.ts @@ -15,6 +15,6 @@ export class RenderDocument implements Command { ) { } public async execute(document: SkinnyTextDocument | string): Promise { - return this.engine.render(document); + return (await (this.engine.render(document))).html; } } diff --git a/extensions/markdown-language-features/src/features/preview.ts b/extensions/markdown-language-features/src/features/preview.ts index ac7f76c9271..009f75ea840 100644 --- a/extensions/markdown-language-features/src/features/preview.ts +++ b/extensions/markdown-language-features/src/features/preview.ts @@ -14,8 +14,9 @@ import { isMarkdownFile } from '../util/file'; import { normalizeResource, WebviewResourceProvider } from '../util/resources'; import { getVisibleLine, TopmostLineMonitor } from '../util/topmostLineMonitor'; import { MarkdownPreviewConfigurationManager } from './previewConfig'; -import { MarkdownContentProvider } from './previewContentProvider'; +import { MarkdownContentProvider, MarkdownContentProviderOutput } from './previewContentProvider'; import { MarkdownEngine } from '../markdownEngine'; +import { urlToUri } from '../util/url'; const localize = nls.loadMessageBundle(); @@ -118,6 +119,8 @@ class MarkdownPreview extends Disposable implements WebviewResourceProvider { private _disposed: boolean = false; private imageInfo: { readonly id: string, readonly width: number, readonly height: number; }[] = []; + private readonly _fileWatchersBySrc = new Map(); + constructor( webview: vscode.WebviewPanel, resource: vscode.Uri, @@ -208,6 +211,9 @@ class MarkdownPreview extends Disposable implements WebviewResourceProvider { super.dispose(); this._disposed = true; clearTimeout(this.throttleTimer); + for (const entry of this._fileWatchersBySrc.values()) { + entry.dispose(); + } } public get resource(): vscode.Uri { @@ -224,6 +230,10 @@ class MarkdownPreview extends Disposable implements WebviewResourceProvider { }; } + /** + * The first call immediately refreshes the preview, + * calls happening shortly thereafter are debounced. + */ public refresh() { // Schedule update if none is pending if (!this.throttleTimer) { @@ -360,7 +370,7 @@ class MarkdownPreview extends Disposable implements WebviewResourceProvider { this._webviewPanel.webview.html = this._contentProvider.provideFileNotFoundContent(this._resource); } - private setContent(html: string): void { + private setContent(content: MarkdownContentProviderOutput): void { if (this._disposed) { return; } @@ -371,7 +381,30 @@ class MarkdownPreview extends Disposable implements WebviewResourceProvider { this._webviewPanel.iconPath = this.iconPath; this._webviewPanel.webview.options = this.getWebviewOptions(); - this._webviewPanel.webview.html = html; + this._webviewPanel.webview.html = content.html; + + const srcs = new Set(content.containingImages.map(img => img.src)); + + // Delete stale file watchers. + for (const [src, watcher] of [...this._fileWatchersBySrc]) { + if (!srcs.has(src)) { + watcher.dispose(); + this._fileWatchersBySrc.delete(src); + } + } + + // Create new file watchers. + const root = vscode.Uri.joinPath(this._resource, '../'); + for (const src of srcs) { + const uri = urlToUri(src, root); + if (uri && uri.scheme === 'file' && !this._fileWatchersBySrc.has(src)) { + const watcher = vscode.workspace.createFileSystemWatcher(uri.fsPath); + watcher.onDidChange(() => { + this.refresh(); + }); + this._fileWatchersBySrc.set(src, watcher); + } + } } private getWebviewOptions(): vscode.WebviewOptions { diff --git a/extensions/markdown-language-features/src/features/previewContentProvider.ts b/extensions/markdown-language-features/src/features/previewContentProvider.ts index a8a64e5270d..1db106403d3 100644 --- a/extensions/markdown-language-features/src/features/previewContentProvider.ts +++ b/extensions/markdown-language-features/src/features/previewContentProvider.ts @@ -39,6 +39,12 @@ function escapeAttribute(value: string | vscode.Uri): string { return value.toString().replace(/"/g, '"'); } +export interface MarkdownContentProviderOutput { + html: string; + containingImages: { src: string }[]; +} + + export class MarkdownContentProvider { constructor( private readonly engine: MarkdownEngine, @@ -54,7 +60,7 @@ export class MarkdownContentProvider { previewConfigurations: MarkdownPreviewConfigurationManager, initialLine: number | undefined = undefined, state?: any - ): Promise { + ): Promise { const sourceUri = markdownDocument.uri; const config = previewConfigurations.loadAndCacheConfiguration(sourceUri); const initialData = { @@ -75,7 +81,7 @@ export class MarkdownContentProvider { const csp = this.getCsp(resourceProvider, sourceUri, nonce); const body = await this.engine.render(markdownDocument); - return ` + const html = ` @@ -89,11 +95,15 @@ export class MarkdownContentProvider { - ${body} + ${body.html}
${this.getScripts(resourceProvider, nonce)} `; + return { + html, + containingImages: body.containingImages, + }; } public provideFileNotFoundContent( diff --git a/extensions/markdown-language-features/src/markdownEngine.ts b/extensions/markdown-language-features/src/markdownEngine.ts index d83c8f22a5f..80761e0ea3a 100644 --- a/extensions/markdown-language-features/src/markdownEngine.ts +++ b/extensions/markdown-language-features/src/markdownEngine.ts @@ -54,6 +54,15 @@ class TokenCache { } } +export interface RenderOutput { + html: string; + containingImages: { src: string }[]; +} + +interface RenderEnv { + containingImages: { src: string }[]; +} + export class MarkdownEngine { private md?: Promise; @@ -141,7 +150,7 @@ export class MarkdownEngine { return engine.parse(text.replace(UNICODE_NEWLINE_REGEX, ''), {}); } - public async render(input: SkinnyTextDocument | string): Promise { + public async render(input: SkinnyTextDocument | string): Promise { const config = this.getConfig(typeof input === 'string' ? undefined : input.uri); const engine = await this.getEngine(config); @@ -149,10 +158,19 @@ export class MarkdownEngine { ? this.tokenizeString(input, engine) : this.tokenizeDocument(input, config, engine); - return engine.renderer.render(tokens, { + const env: RenderEnv = { + containingImages: [] + }; + + const html = engine.renderer.render(tokens, { ...(engine as any).options, ...config - }, {}); + }, env); + + return { + html, + containingImages: env.containingImages + }; } public async parse(document: SkinnyTextDocument): Promise { @@ -192,12 +210,13 @@ export class MarkdownEngine { private addImageStabilizer(md: any): void { const original = md.renderer.rules.image; - md.renderer.rules.image = (tokens: any, idx: number, options: any, env: any, self: any) => { + md.renderer.rules.image = (tokens: any, idx: number, options: any, env: RenderEnv, self: any) => { const token = tokens[idx]; token.attrJoin('class', 'loading'); const src = token.attrGet('src'); if (src) { + env.containingImages.push({ src }); const imgHash = hash(src); token.attrSet('id', `image-hash-${imgHash}`); } diff --git a/extensions/markdown-language-features/src/test/engine.test.ts b/extensions/markdown-language-features/src/test/engine.test.ts index 3cd4f86693e..c3eb7c85056 100644 --- a/extensions/markdown-language-features/src/test/engine.test.ts +++ b/extensions/markdown-language-features/src/test/engine.test.ts @@ -21,12 +21,31 @@ suite('markdown.engine', () => { test('Renders a document', async () => { const doc = new InMemoryDocument(testFileName, input); const engine = createNewMarkdownEngine(); - assert.strictEqual(await engine.render(doc), output); + assert.strictEqual((await engine.render(doc)).html, output); }); test('Renders a string', async () => { const engine = createNewMarkdownEngine(); - assert.strictEqual(await engine.render(input), output); + assert.strictEqual((await engine.render(input)).html, output); + }); + }); + + suite('image-caching', () => { + const input = '![](img.png) [](no-img.png) ![](http://example.org/img.png) ![](img.png) ![](./img2.png)'; + + test('Extracts all images', async () => { + const engine = createNewMarkdownEngine(); + assert.deepStrictEqual((await engine.render(input)), { + html: '

' + + ' ' + + ' ' + + ' ' + + ' ' + + '' + + '

\n' + , + containingImages: [{ src: 'img.png' }, { src: 'http://example.org/img.png' }, { src: 'img.png' }, { src: './img2.png' }], + }); }); }); }); diff --git a/extensions/markdown-language-features/src/test/urlToUri.test.ts b/extensions/markdown-language-features/src/test/urlToUri.test.ts new file mode 100644 index 00000000000..6578d207e1a --- /dev/null +++ b/extensions/markdown-language-features/src/test/urlToUri.test.ts @@ -0,0 +1,39 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { deepStrictEqual } from 'assert'; +import 'mocha'; +import { Uri } from 'vscode'; +import { urlToUri } from '../util/url'; + +suite('urlToUri', () => { + test('Absolute File', () => { + deepStrictEqual( + urlToUri('file:///root/test.txt', Uri.parse('file:///usr/home/')), + Uri.parse('file:///root/test.txt') + ); + }); + + test('Relative File', () => { + deepStrictEqual( + urlToUri('./file.ext', Uri.parse('file:///usr/home/')), + Uri.parse('file:///usr/home/file.ext') + ); + }); + + test('Http Basic', () => { + deepStrictEqual( + urlToUri('http://example.org?q=10&f', Uri.parse('file:///usr/home/')), + Uri.parse('http://example.org?q=10&f') + ); + }); + + test('Http Encoded Chars', () => { + deepStrictEqual( + urlToUri('http://example.org/%C3%A4', Uri.parse('file:///usr/home/')), + Uri.parse('http://example.org/%C3%A4') + ); + }); +}); diff --git a/extensions/markdown-language-features/src/util/url.ts b/extensions/markdown-language-features/src/util/url.ts new file mode 100644 index 00000000000..d91cb4ab06d --- /dev/null +++ b/extensions/markdown-language-features/src/util/url.ts @@ -0,0 +1,25 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; + +declare const URL: typeof import('url').URL; + +/** + * Tries to convert an url into a vscode uri and returns undefined if this is not possible. + * `url` can be absolute or relative. +*/ +export function urlToUri(url: string, base: vscode.Uri): vscode.Uri | undefined { + try { + // `vscode.Uri.joinPath` cannot be used, since it understands + // `src` as path, not as relative url. This is problematic for query args. + const parsedUrl = new URL(url, base.toString()); + const uri = vscode.Uri.parse(parsedUrl.toString()); + return uri; + } catch (e) { + // Don't crash if `URL` cannot parse `src`. + return undefined; + } +} diff --git a/src/vs/platform/webview/common/resourceLoader.ts b/src/vs/platform/webview/common/resourceLoader.ts index ac2931d3fe8..46f0217fa20 100644 --- a/src/vs/platform/webview/common/resourceLoader.ts +++ b/src/vs/platform/webview/common/resourceLoader.ts @@ -25,7 +25,8 @@ export namespace WebviewResourceResponse { constructor( public readonly stream: VSBufferReadableStream, - public readonly mimeType: string + public readonly etag: string | undefined, + public readonly mimeType: string, ) { } } @@ -36,7 +37,7 @@ export namespace WebviewResourceResponse { } interface FileReader { - readFileStream(resource: URI): Promise; + readFileStream(resource: URI): Promise<{ stream: VSBufferReadableStream, etag?: string }>; } export async function loadLocalResource( @@ -73,7 +74,7 @@ export async function loadLocalResource( logService.debug(`loadLocalResource - Loaded over http(s). requestUri=${requestUri}, response=${response.res.statusCode}`); if (response.res.statusCode === 200) { - return new WebviewResourceResponse.StreamSuccess(response.stream, mime); + return new WebviewResourceResponse.StreamSuccess(response.stream, undefined, mime); } return WebviewResourceResponse.Failed; } @@ -82,7 +83,7 @@ export async function loadLocalResource( const contents = await fileReader.readFileStream(resourceToLoad); logService.debug(`loadLocalResource - Loaded using fileReader. requestUri=${requestUri}`); - return new WebviewResourceResponse.StreamSuccess(contents, mime); + return new WebviewResourceResponse.StreamSuccess(contents.stream, contents.etag, mime); } catch (err) { logService.debug(`loadLocalResource - Error using fileReader. requestUri=${requestUri}`); console.log(err); diff --git a/src/vs/platform/webview/electron-main/webviewProtocolProvider.ts b/src/vs/platform/webview/electron-main/webviewProtocolProvider.ts index ea3a6dee8a9..a8c2e0fefa3 100644 --- a/src/vs/platform/webview/electron-main/webviewProtocolProvider.ts +++ b/src/vs/platform/webview/electron-main/webviewProtocolProvider.ts @@ -175,10 +175,14 @@ export class WebviewProtocolProvider extends Disposable { }; } - const fileService = { - readFileStream: async (resource: URI): Promise => { + const fileReader = { + readFileStream: async (resource: URI): Promise<{ stream: VSBufferReadableStream, etag?: string }> => { if (resource.scheme === Schemas.file) { - return (await this.fileService.readFileStream(resource)).value; + const result = (await this.fileService.readFileStream(resource)); + return { + stream: result.value, + etag: result.etag + }; } // Unknown uri scheme. Try delegating the file read back to the renderer @@ -201,7 +205,7 @@ export class WebviewProtocolProvider extends Disposable { throw new FileOperationError('Could not read file', FileOperationResult.FILE_NOT_FOUND); } - return bufferToStream(result); + return { stream: bufferToStream(result), etag: undefined }; } }; @@ -210,15 +214,41 @@ export class WebviewProtocolProvider extends Disposable { roots: metadata.localResourceRoots, remoteConnectionData: metadata.remoteConnectionData, rewriteUri, - }, fileService, this.requestService, this.logService); + }, fileReader, this.requestService, this.logService); if (result.type === WebviewResourceResponse.Type.Success) { + const cacheHeaders: Record = result.etag ? { + 'ETag': result.etag, + 'Cache-Control': 'no-cache' + } : {}; + + const ifNoneMatch = request.headers['If-None-Match']; + if (ifNoneMatch && result.etag === ifNoneMatch) { + /* + * Note that the server generating a 304 response MUST + * generate any of the following header fields that would + * have been sent in a 200 (OK) response to the same request: + * Cache-Control, Content-Location, Date, ETag, Expires, and Vary. + * (https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match) + */ + return callback({ + statusCode: 304, // not modified + data: undefined, // The request fails if `data` is not set + headers: { + 'Content-Type': result.mimeType, + 'Access-Control-Allow-Origin': '*', + ...cacheHeaders + } + }); + } + return callback({ statusCode: 200, data: this.streamToNodeReadable(result.stream), headers: { 'Content-Type': result.mimeType, 'Access-Control-Allow-Origin': '*', + ...cacheHeaders } }); } diff --git a/src/vs/workbench/contrib/webview/browser/webviewElement.ts b/src/vs/workbench/contrib/webview/browser/webviewElement.ts index c922702eaf4..4403a2ac4b4 100644 --- a/src/vs/workbench/contrib/webview/browser/webviewElement.ts +++ b/src/vs/workbench/contrib/webview/browser/webviewElement.ts @@ -202,7 +202,7 @@ export class IFrameWebview extends BaseWebview implements Web remoteConnectionData, rewriteUri, }, { - readFileStream: (resource) => this.fileService.readFileStream(resource).then(x => x.value), + readFileStream: (resource) => this.fileService.readFileStream(resource).then(x => ({ stream: x.value, etag: x.etag })), }, this.requestService, this.logService); if (result.type === WebviewResourceResponse.Type.Success) { diff --git a/src/vs/workbench/contrib/webview/electron-sandbox/resourceLoading.ts b/src/vs/workbench/contrib/webview/electron-sandbox/resourceLoading.ts index 62e5d11d330..99820e69b3e 100644 --- a/src/vs/workbench/contrib/webview/electron-sandbox/resourceLoading.ts +++ b/src/vs/workbench/contrib/webview/electron-sandbox/resourceLoading.ts @@ -111,7 +111,7 @@ export class WebviewResourceRequestManager extends Disposable { roots: this._localResourceRoots, remoteConnectionData: remoteConnectionData, }, { - readFileStream: (resource) => fileService.readFileStream(resource).then(x => x.value), + readFileStream: (resource) => fileService.readFileStream(resource).then(x => ({ stream: x.value, etag: x.etag })), }, requestService, this._logService); this._logService.debug(`WebviewResourceRequestManager(${this.id}): finished resource load. uri: ${uri}, type=${response.type}`);