Files
vscode/src/vs/workbench/contrib/update/browser/releaseNotesEditor.ts
T

531 lines
19 KiB
TypeScript

/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import 'vs/css!./media/releasenoteseditor';
import { CancellationToken } from 'vs/base/common/cancellation';
import { onUnexpectedError } from 'vs/base/common/errors';
import { escapeMarkdownSyntaxTokens } from 'vs/base/common/htmlContent';
import { KeybindingParser } from 'vs/base/common/keybindingParser';
import { escape } from 'vs/base/common/strings';
import { URI } from 'vs/base/common/uri';
import { generateUuid } from 'vs/base/common/uuid';
import { TokenizationRegistry } from 'vs/editor/common/languages';
import { generateTokensCSSForColorMap } from 'vs/editor/common/languages/supports/tokenization';
import { ILanguageService } from 'vs/editor/common/languages/language';
import * as nls from 'vs/nls';
import { IEnvironmentService } from 'vs/platform/environment/common/environment';
import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding';
import { IOpenerService } from 'vs/platform/opener/common/opener';
import { IProductService } from 'vs/platform/product/common/productService';
import { asTextOrError, IRequestService } from 'vs/platform/request/common/request';
import { DEFAULT_MARKDOWN_STYLES, renderMarkdownDocument } from 'vs/workbench/contrib/markdown/browser/markdownDocumentRenderer';
import { WebviewInput } from 'vs/workbench/contrib/webviewPanel/browser/webviewEditorInput';
import { IWebviewWorkbenchService } from 'vs/workbench/contrib/webviewPanel/browser/webviewWorkbenchService';
import { IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService';
import { ACTIVE_GROUP, IEditorService } from 'vs/workbench/services/editor/common/editorService';
import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions';
import { getTelemetryLevel, supportsTelemetry } from 'vs/platform/telemetry/common/telemetryUtils';
import { IConfigurationChangeEvent, IConfigurationService } from 'vs/platform/configuration/common/configuration';
import { TelemetryLevel } from 'vs/platform/telemetry/common/telemetry';
import { DisposableStore } from 'vs/base/common/lifecycle';
import { SimpleSettingRenderer } from 'vs/workbench/contrib/markdown/browser/markdownSettingRenderer';
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
import { Schemas } from 'vs/base/common/network';
import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService';
export class ReleaseNotesManager {
private readonly _simpleSettingRenderer: SimpleSettingRenderer;
private readonly _releaseNotesCache = new Map<string, Promise<string>>();
private scrollPosition: { x: number; y: number } | undefined;
private _currentReleaseNotes: WebviewInput | undefined = undefined;
private _lastText: string | undefined;
private readonly disposables = new DisposableStore();
public constructor(
@IEnvironmentService private readonly _environmentService: IEnvironmentService,
@IKeybindingService private readonly _keybindingService: IKeybindingService,
@ILanguageService private readonly _languageService: ILanguageService,
@IOpenerService private readonly _openerService: IOpenerService,
@IRequestService private readonly _requestService: IRequestService,
@IConfigurationService private readonly _configurationService: IConfigurationService,
@IEditorService private readonly _editorService: IEditorService,
@IEditorGroupsService private readonly _editorGroupService: IEditorGroupsService,
@ICodeEditorService private readonly _codeEditorService: ICodeEditorService,
@IWebviewWorkbenchService private readonly _webviewWorkbenchService: IWebviewWorkbenchService,
@IExtensionService private readonly _extensionService: IExtensionService,
@IProductService private readonly _productService: IProductService,
@IInstantiationService private readonly _instantiationService: IInstantiationService,
) {
TokenizationRegistry.onDidChange(() => {
return this.updateHtml();
});
_configurationService.onDidChangeConfiguration(this.onDidChangeConfiguration, this, this.disposables);
_webviewWorkbenchService.onDidChangeActiveWebviewEditor(this.onDidChangeActiveWebviewEditor, this, this.disposables);
this._simpleSettingRenderer = this._instantiationService.createInstance(SimpleSettingRenderer);
}
private async updateHtml() {
if (!this._currentReleaseNotes || !this._lastText) {
return;
}
const captureScroll = this.scrollPosition;
const html = await this.renderBody(this._lastText);
if (this._currentReleaseNotes) {
this._currentReleaseNotes.webview.setHtml(html);
this._currentReleaseNotes.webview.postMessage({ type: 'setScroll', value: { scrollPosition: captureScroll } });
}
}
public async show(version: string, useCurrentFile: boolean): Promise<boolean> {
const releaseNoteText = await this.loadReleaseNotes(version, useCurrentFile);
this._lastText = releaseNoteText;
const html = await this.renderBody(releaseNoteText);
const title = nls.localize('releaseNotesInputName', "Release Notes: {0}", version);
const activeEditorPane = this._editorService.activeEditorPane;
if (this._currentReleaseNotes) {
this._currentReleaseNotes.setName(title);
this._currentReleaseNotes.webview.setHtml(html);
this._webviewWorkbenchService.revealWebview(this._currentReleaseNotes, activeEditorPane ? activeEditorPane.group : this._editorGroupService.activeGroup, false);
} else {
this._currentReleaseNotes = this._webviewWorkbenchService.openWebview(
{
title,
options: {
tryRestoreScrollPosition: true,
enableFindWidget: true,
disableServiceWorker: true,
},
contentOptions: {
localResourceRoots: [],
allowScripts: true
},
extension: undefined
},
'releaseNotes',
title,
{ group: ACTIVE_GROUP, preserveFocus: false });
this._currentReleaseNotes.webview.onDidClickLink(uri => this.onDidClickLink(URI.parse(uri)));
const disposables = new DisposableStore();
disposables.add(this._currentReleaseNotes.webview.onMessage(e => {
if (e.message.type === 'showReleaseNotes') {
this._configurationService.updateValue('update.showReleaseNotes', e.message.value);
} else if (e.message.type === 'scroll') {
this.scrollPosition = e.message.value.scrollPosition;
} else if (e.message.type === 'clickSetting') {
const x = this._currentReleaseNotes?.webview.container.offsetLeft + e.message.value.x;
const y = this._currentReleaseNotes?.webview.container.offsetTop + e.message.value.y;
this._simpleSettingRenderer.updateSetting(URI.parse(e.message.value.uri), x, y);
}
}));
disposables.add(this._currentReleaseNotes.onWillDispose(() => {
disposables.dispose();
this._currentReleaseNotes = undefined;
}));
this._currentReleaseNotes.webview.setHtml(html);
}
return true;
}
private async loadReleaseNotes(version: string, useCurrentFile: boolean): Promise<string> {
const match = /^(\d+\.\d+)\./.exec(version);
if (!match) {
throw new Error('not found');
}
const versionLabel = match[1].replace(/\./g, '_');
const baseUrl = 'https://code.visualstudio.com/raw';
const url = `${baseUrl}/v${versionLabel}.md`;
const unassigned = nls.localize('unassigned', "unassigned");
const escapeMdHtml = (text: string): string => {
return escape(text).replace(/\\/g, '\\\\');
};
const patchKeybindings = (text: string): string => {
const kb = (match: string, kb: string) => {
const keybinding = this._keybindingService.lookupKeybinding(kb);
if (!keybinding) {
return unassigned;
}
return keybinding.getLabel() || unassigned;
};
const kbstyle = (match: string, kb: string) => {
const keybinding = KeybindingParser.parseKeybinding(kb);
if (!keybinding) {
return unassigned;
}
const resolvedKeybindings = this._keybindingService.resolveKeybinding(keybinding);
if (resolvedKeybindings.length === 0) {
return unassigned;
}
return resolvedKeybindings[0].getLabel() || unassigned;
};
const kbCode = (match: string, binding: string) => {
const resolved = kb(match, binding);
return resolved ? `<code title="${binding}">${escapeMdHtml(resolved)}</code>` : resolved;
};
const kbstyleCode = (match: string, binding: string) => {
const resolved = kbstyle(match, binding);
return resolved ? `<code title="${binding}">${escapeMdHtml(resolved)}</code>` : resolved;
};
return text
.replace(/`kb\(([a-z.\d\-]+)\)`/gi, kbCode)
.replace(/`kbstyle\(([^\)]+)\)`/gi, kbstyleCode)
.replace(/kb\(([a-z.\d\-]+)\)/gi, (match, binding) => escapeMarkdownSyntaxTokens(kb(match, binding)))
.replace(/kbstyle\(([^\)]+)\)/gi, (match, binding) => escapeMarkdownSyntaxTokens(kbstyle(match, binding)));
};
const fetchReleaseNotes = async () => {
let text;
try {
if (useCurrentFile) {
const file = this._codeEditorService.getActiveCodeEditor()?.getModel()?.getValue();
text = file ? file.substring(file.indexOf('#')) : undefined;
} else {
text = await asTextOrError(await this._requestService.request({ url }, CancellationToken.None));
}
} catch {
throw new Error('Failed to fetch release notes');
}
if (!text || !/^#\s/.test(text)) { // release notes always starts with `#` followed by whitespace
throw new Error('Invalid release notes');
}
return patchKeybindings(text);
};
// Don't cache the current file
if (useCurrentFile) {
return fetchReleaseNotes();
}
if (!this._releaseNotesCache.has(version)) {
this._releaseNotesCache.set(version, (async () => {
try {
return await fetchReleaseNotes();
} catch (err) {
this._releaseNotesCache.delete(version);
throw err;
}
})());
}
return this._releaseNotesCache.get(version)!;
}
private async onDidClickLink(uri: URI) {
if (uri.scheme === Schemas.codeSetting || uri.scheme === Schemas.codeFeature) {
// handled in receive message
} else {
this.addGAParameters(uri, 'ReleaseNotes')
.then(updated => this._openerService.open(updated, { allowCommands: ['workbench.action.openSettings'] }))
.then(undefined, onUnexpectedError);
}
}
private async addGAParameters(uri: URI, origin: string, experiment = '1'): Promise<URI> {
if (supportsTelemetry(this._productService, this._environmentService) && getTelemetryLevel(this._configurationService) === TelemetryLevel.USAGE) {
if (uri.scheme === 'https' && uri.authority === 'code.visualstudio.com') {
return uri.with({ query: `${uri.query ? uri.query + '&' : ''}utm_source=VsCode&utm_medium=${encodeURIComponent(origin)}&utm_content=${encodeURIComponent(experiment)}` });
}
}
return uri;
}
private async renderBody(text: string) {
const nonce = generateUuid();
const content = await renderMarkdownDocument(text, this._extensionService, this._languageService, false, undefined, undefined, this._simpleSettingRenderer);
const colorMap = TokenizationRegistry.getColorMap();
const css = colorMap ? generateTokensCSSForColorMap(colorMap) : '';
const showReleaseNotes = Boolean(this._configurationService.getValue<boolean>('update.showReleaseNotes'));
return `<!DOCTYPE html>
<html>
<head>
<base href="https://code.visualstudio.com/raw/">
<meta http-equiv="Content-type" content="text/html;charset=UTF-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src https: data:; media-src https:; style-src 'nonce-${nonce}' https://code.visualstudio.com; script-src 'nonce-${nonce}';">
<style nonce="${nonce}">
${DEFAULT_MARKDOWN_STYLES}
${css}
/* codesetting */
code:has(.codesetting)+code {
display: none;
}
code:has(.codesetting) {
background-color: var(--vscode-textPreformat-background);
color: var(--vscode-textPreformat-foreground);
padding-left: 1px;
margin-right: 3px;
padding-right: 0px;
}
code:has(.codesetting):focus {
border: 1px solid var(--vscode-button-border, transparent);
}
.codesetting {
color: var(--vscode-textPreformat-foreground);
padding: 0px 1px 1px 0px;
font-size: 0px;
overflow: hidden;
text-overflow: ellipsis;
outline-offset: 2px !important;
box-sizing: border-box;
text-align: center;
cursor: pointer;
display: inline;
margin-right: 3px;
}
.codesetting svg {
font-size: 12px;
text-align: center;
cursor: pointer;
border: 1px solid var(--vscode-button-secondaryBorder, transparent);
outline: 1px solid transparent;
line-height: 9px;
margin-bottom: -5px;
padding-left: 0px;
padding-top: 2px;
padding-bottom: 2px;
padding-right: 2px;
display: inline-block;
text-decoration: none;
text-rendering: auto;
text-transform: none;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
user-select: none;
-webkit-user-select: none;
}
.codesetting .setting-name {
font-size: 13px;
padding-left: 2px;
padding-right: 3px;
padding-top: 1px;
padding-bottom: 1px;
margin-left: -5px;
margin-top: -3px;
}
.codesetting:hover {
color: var(--vscode-textPreformat-foreground) !important;
text-decoration: none !important;
}
code:has(.codesetting):hover {
filter: brightness(140%);
text-decoration: none !important;
}
.codesetting:focus {
outline: 0 !important;
text-decoration: none !important;
color: var(--vscode-button-hoverForeground) !important;
}
.codesetting .separator {
width: 1px;
height: 14px;
margin-bottom: -3px;
display: inline-block;
background-color: var(--vscode-editor-background);
font-size: 12px;
margin-right: 8px;
}
/* codefeature */
.codefeature-container {
display: flex;
}
.codefeature {
position: relative;
display: inline-block;
width: 46px;
height: 24px;
}
.codefeature-container input {
display: none;
}
.toggle {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--vscode-button-background);
transition: .4s;
border-radius: 24px;
}
.toggle:before {
position: absolute;
content: "";
height: 16px;
width: 16px;
left: 4px;
bottom: 4px;
background-color: var(--vscode-editor-foreground);
transition: .4s;
border-radius: 50%;
}
input:checked+.codefeature > .toggle:before {
transform: translateX(22px);
}
.codefeature-container:has(input) .title {
line-height: 30px;
padding-left: 4px;
font-weight: bold;
}
.codefeature-container:has(input:checked) .title:after {
content: "${nls.localize('disableFeature', "Disable this feature")}";
}
.codefeature-container:has(input:not(:checked)) .title:after {
content: "${nls.localize('enableFeature', "Enable this feature")}";
}
header { display: flex; align-items: center; padding-top: 1em; }
</style>
</head>
<body>
${content}
<script nonce="${nonce}">
const vscode = acquireVsCodeApi();
const container = document.createElement('p');
container.style.display = 'flex';
container.style.alignItems = 'center';
const input = document.createElement('input');
input.type = 'checkbox';
input.id = 'showReleaseNotes';
input.checked = ${showReleaseNotes};
container.appendChild(input);
const label = document.createElement('label');
label.htmlFor = 'showReleaseNotes';
label.textContent = '${nls.localize('showOnUpdate', "Show release notes after an update")}';
container.appendChild(label);
const beforeElement = document.querySelector("body > h1")?.nextElementSibling;
if (beforeElement) {
document.body.insertBefore(container, beforeElement);
} else {
document.body.appendChild(container);
}
window.addEventListener('message', event => {
if (event.data.type === 'showReleaseNotes') {
input.checked = event.data.value;
} else if (event.data.type === 'setScroll') {
window.scrollTo(event.data.value.scrollPosition.x, event.data.value.scrollPosition.y);
} else if (event.data.type === 'setFeaturedSettings') {
for (const [settingId, value] of event.data.value) {
const setting = document.getElementById(settingId);
if (setting instanceof HTMLInputElement) {
setting.checked = value;
}
}
}
});
window.onscroll = () => {
vscode.postMessage({
type: 'scroll',
value: {
scrollPosition: {
x: window.scrollX,
y: window.scrollY
}
}
});
};
window.addEventListener('click', event => {
const href = event.target.href ?? event.target.parentElement.href ?? event.target.parentElement.parentElement?.href;
if (href && (href.startsWith('${Schemas.codeSetting}') || href.startsWith('${Schemas.codeFeature}'))) {
vscode.postMessage({ type: 'clickSetting', value: { uri: href, x: event.clientX, y: event.clientY }});
if (href.startsWith('${Schemas.codeFeature}')) {
const featureInput = event.target.parentElement.previousSibling;
if (featureInput instanceof HTMLInputElement) {
featureInput.checked = !featureInput.checked;
}
}
}
});
window.addEventListener('keypress', event => {
if (event.keyCode === 13) {
if (event.target.children.length > 0 && event.target.children[0].href) {
const clientRect = event.target.getBoundingClientRect();
vscode.postMessage({ type: 'clickSetting', value: { uri: event.target.children[0].href, x: clientRect.right , y: clientRect.bottom }});
}
}
});
input.addEventListener('change', event => {
vscode.postMessage({ type: 'showReleaseNotes', value: input.checked }, '*');
});
</script>
</body>
</html>`;
}
private onDidChangeConfiguration(e: IConfigurationChangeEvent): void {
if (e.affectsConfiguration('update.showReleaseNotes')) {
this.updateCheckboxWebview();
}
}
private onDidChangeActiveWebviewEditor(input: WebviewInput | undefined): void {
if (input && input === this._currentReleaseNotes) {
this.updateCheckboxWebview();
this.updateFeaturedSettingsWebview();
}
}
private updateCheckboxWebview() {
if (this._currentReleaseNotes) {
this._currentReleaseNotes.webview.postMessage({
type: 'showReleaseNotes',
value: this._configurationService.getValue<boolean>('update.showReleaseNotes')
});
}
}
private updateFeaturedSettingsWebview() {
if (this._currentReleaseNotes) {
this._currentReleaseNotes.webview.postMessage({
type: 'setFeaturedSettings',
value: this._simpleSettingRenderer.featuredSettingStates
});
}
}
}