Set settings directly from the release notes (#204832)

* Set settings directly from the release notes
Fixes #204338

* Fix build
pull/205024/head
Alex Ross 2024-02-12 19:46:00 +01:00 committed by GitHub
parent 7ff2572a3e
commit 69fd227084
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 399 additions and 16 deletions

View File

@ -62,6 +62,10 @@
"name": "vs/workbench/contrib/mappedEdits",
"project": "vscode-workbench"
},
{
"name": "vs/workbench/contrib/markdown",
"project": "vscode-workbench"
},
{
"name": "vs/workbench/contrib/comments",
"project": "vscode-workbench"

View File

@ -111,6 +111,11 @@ export namespace Schemas {
* Scheme used for the Source Control commit input's text document
*/
export const vscodeSourceControl = 'vscode-scm';
/**
* Scheme used for special rendering of settings in the release notes
*/
export const codeSetting = 'code-setting';
}
export function matchesScheme(target: URI | string, scheme: string): boolean {

View File

@ -13,6 +13,7 @@ import { ILanguageService } from 'vs/editor/common/languages/language';
import { tokenizeToString } from 'vs/editor/common/languages/textToHtmlTokenizer';
import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions';
import { escape } from 'vs/base/common/strings';
import { SimpleSettingRenderer } from 'vs/workbench/contrib/markdown/browser/markdownSettingRenderer';
export const DEFAULT_MARKDOWN_STYLES = `
body {
@ -195,6 +196,7 @@ export async function renderMarkdownDocument(
shouldSanitize: boolean = true,
allowUnknownProtocols: boolean = false,
token?: CancellationToken,
settingRenderer?: SimpleSettingRenderer
): Promise<string> {
const highlight = (code: string, lang: string | undefined, callback: ((error: any, code: string) => void) | undefined): any => {
@ -220,8 +222,13 @@ export async function renderMarkdownDocument(
return '';
};
const renderer = new marked.Renderer();
if (settingRenderer) {
renderer.html = settingRenderer.getHtmlRenderer();
}
return new Promise<string>((resolve, reject) => {
marked(text, { highlight }, (err, value) => err ? reject(err) : resolve(value));
marked(text, { highlight, renderer }, (err, value) => err ? reject(err) : resolve(value));
}).then(raw => {
if (shouldSanitize) {
return sanitize(raw, allowUnknownProtocols);

View File

@ -0,0 +1,187 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as nls from 'vs/nls';
import { ISetting, ISettingsGroup } from 'vs/workbench/services/preferences/common/preferences';
import { settingKeyToDisplayFormat } from 'vs/workbench/contrib/preferences/browser/settingsTreeModels';
import { URI } from 'vs/base/common/uri';
import { Schemas } from 'vs/base/common/network';
import { ConfigurationTarget, IConfigurationService } from 'vs/platform/configuration/common/configuration';
import { DefaultSettings } from 'vs/workbench/services/preferences/common/preferencesModels';
const codeSettingRegex = /^<span codesetting="([^\s"\:]+)(?::([^\s"]+))?">/;
export class SimpleSettingRenderer {
private defaultSettings: DefaultSettings;
private updatedSettings = new Map<string, any>(); // setting ID to user's original setting value
private encounteredSettings = new Map<string, ISetting>(); // setting ID to setting
constructor(
@IConfigurationService private readonly configurationService: IConfigurationService
) {
this.defaultSettings = new DefaultSettings([], ConfigurationTarget.USER);
}
getHtmlRenderer(): (html: string) => string {
return (html): string => {
const match = codeSettingRegex.exec(html);
if (match && match.length === 3) {
const settingId = match[1];
const rendered = this.render(settingId, match[2]);
if (rendered) {
html = html.replace(codeSettingRegex, rendered);
}
}
return html;
};
}
private settingsGroups: ISettingsGroup[] | undefined = undefined;
private getSetting(settingId: string): ISetting | undefined {
if (!this.settingsGroups) {
this.settingsGroups = this.defaultSettings.getSettingsGroups();
}
if (this.encounteredSettings.has(settingId)) {
return this.encounteredSettings.get(settingId);
}
for (const group of this.settingsGroups) {
for (const section of group.sections) {
for (const setting of section.settings) {
if (setting.key === settingId) {
this.encounteredSettings.set(settingId, setting);
return setting;
}
}
}
}
return undefined;
}
parseValue(settingId: string, value: string): any {
if (value === 'undefined') {
return undefined;
}
const setting = this.getSetting(settingId);
if (!setting) {
return value;
}
switch (setting.type) {
case 'boolean':
return value === 'true';
case 'number':
return parseInt(value, 10);
case 'string':
default:
return value;
}
}
private render(settingId: string, newValue: string): string | undefined {
const setting = this.getSetting(settingId);
if (!setting) {
return '';
}
return this.renderSetting(setting, newValue);
}
private viewInSettings(settingId: string, alreadySet: boolean): string {
let message: string;
if (alreadySet) {
const displayName = settingKeyToDisplayFormat(settingId);
message = nls.localize('viewInSettingsDetailed', "View \"{0}: {1}\" in Settings", displayName.category, displayName.label);
} else {
message = nls.localize('viewInSettings', "View in Settings");
}
return `<a href="${URI.parse(`command:workbench.action.openSettings?${encodeURIComponent(JSON.stringify([`@id:${settingId}`]))}`)}">${message}</a>`;
}
private renderRestorePreviousSetting(settingId: string): string {
const displayName = settingKeyToDisplayFormat(settingId);
const value = this.updatedSettings.get(settingId);
const message = nls.localize('restorePreviousValue', "Restore value of \"{0}: {1}\"", displayName.category, displayName.label);
return `<a href="${Schemas.codeSetting}://${settingId}/${value}">${message}</a>`;
}
private renderBooleanSetting(setting: ISetting, value: string): string | undefined {
const booleanValue: boolean = value === 'true' ? true : false;
const currentValue = this.configurationService.getValue<boolean>(setting.key);
if (currentValue === booleanValue || (currentValue === undefined && setting.value === booleanValue)) {
return undefined;
}
const displayName = settingKeyToDisplayFormat(setting.key);
let message: string;
if (booleanValue) {
message = nls.localize('trueMessage', "Enable \"{0}: {1}\" now", displayName.category, displayName.label);
} else {
message = nls.localize('falseMessage', "Disable \"{0}: {1}\" now", displayName.category, displayName.label);
}
return `<a href="${Schemas.codeSetting}://${setting.key}/${value}">${message}</a>`;
}
private renderStringSetting(setting: ISetting, value: string): string | undefined {
const currentValue = this.configurationService.getValue<string>(setting.key);
if (currentValue === value || (currentValue === undefined && setting.value === value)) {
return undefined;
}
const displayName = settingKeyToDisplayFormat(setting.key);
const message = nls.localize('stringValue', "Set \"{0}: {1}\" to \"{2}\" now", displayName.category, displayName.label, value);
return `<a href="${Schemas.codeSetting}://${setting.key}/${value}">${message}</a>`;
}
private renderNumberSetting(setting: ISetting, value: string): string | undefined {
const numberValue: number = parseInt(value, 10);
const currentValue = this.configurationService.getValue<number>(setting.key);
if (currentValue === numberValue || (currentValue === undefined && setting.value === numberValue)) {
return undefined;
}
const displayName = settingKeyToDisplayFormat(setting.key);
const message = nls.localize('numberValue', "Set \"{0}: {1}\" to {2} now", displayName.category, displayName.label, numberValue);
return `<a href="${Schemas.codeSetting}://${setting.key}/${value}">${message}</a>`;
}
private renderSetting(setting: ISetting, newValue: string | undefined): string | undefined {
let renderedSetting: string | undefined;
if (newValue !== undefined) {
if (this.updatedSettings.has(setting.key)) {
renderedSetting = this.renderRestorePreviousSetting(setting.key);
} else if (setting.type === 'boolean') {
renderedSetting = this.renderBooleanSetting(setting, newValue);
} else if (setting.type === 'string') {
renderedSetting = this.renderStringSetting(setting, newValue);
} else if (setting.type === 'number') {
renderedSetting = this.renderNumberSetting(setting, newValue);
}
}
if (!renderedSetting) {
return `(${this.viewInSettings(setting.key, true)})`;
}
return nls.localize({ key: 'fullRenderedSetting', comment: ['A pair of already localized links. The first argument is a link to change a setting, the second is a link to view the setting.'] },
"({0} | {1})", renderedSetting, this.viewInSettings(setting.key, false),);
}
async updateSettingValue(uri: URI) {
if (uri.scheme !== Schemas.codeSetting) {
return;
}
const settingId = uri.authority;
const newSettingValue = this.parseValue(uri.authority, uri.path.substring(1));
const oldSettingValue = this.configurationService.inspect(settingId).userValue;
if (newSettingValue === this.updatedSettings.get(settingId)) {
this.updatedSettings.delete(settingId);
} else {
this.updatedSettings.set(settingId, oldSettingValue);
}
await this.configurationService.updateValue(settingId, newSettingValue, ConfigurationTarget.USER);
}
}

View File

@ -0,0 +1,147 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as assert from 'assert';
import { Schemas } from 'vs/base/common/network';
import { URI } from 'vs/base/common/uri';
import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils';
import { ConfigurationScope, Extensions, IConfigurationNode, IConfigurationRegistry } from 'vs/platform/configuration/common/configurationRegistry';
import { TestConfigurationService } from 'vs/platform/configuration/test/common/testConfigurationService';
import { Registry } from 'vs/platform/registry/common/platform';
import { SimpleSettingRenderer } from 'vs/workbench/contrib/markdown/browser/markdownSettingRenderer';
const configuration: IConfigurationNode = {
'id': 'examples',
'title': 'Examples',
'type': 'object',
'properties': {
'example.booleanSetting': {
'type': 'boolean',
'default': false,
'scope': ConfigurationScope.APPLICATION
},
'example.booleanSetting2': {
'type': 'boolean',
'default': true,
'scope': ConfigurationScope.APPLICATION
},
'example.stringSetting': {
'type': 'string',
'default': 'one',
'scope': ConfigurationScope.APPLICATION
},
'example.numberSetting': {
'type': 'number',
'default': 3,
'scope': ConfigurationScope.APPLICATION
}
}
};
class MarkdownConfigurationService extends TestConfigurationService {
override async updateValue(key: string, value: any): Promise<void> {
const [section, setting] = key.split('.');
return this.setUserConfiguration(section, { [setting]: value });
}
}
suite('Markdown Setting Renderer Test', () => {
ensureNoDisposablesAreLeakedInTestSuite();
let configurationService: TestConfigurationService;
let settingRenderer: SimpleSettingRenderer;
suiteSetup(() => {
configurationService = new MarkdownConfigurationService();
Registry.as<IConfigurationRegistry>(Extensions.Configuration).registerConfiguration(configuration);
settingRenderer = new SimpleSettingRenderer(configurationService);
});
suiteTeardown(() => {
Registry.as<IConfigurationRegistry>(Extensions.Configuration).deregisterConfigurations([configuration]);
});
test('render boolean setting', () => {
const htmlRenderer = settingRenderer.getHtmlRenderer();
const htmlNoValue = '<span codesetting="example.booleanSetting">';
const renderedHtmlNoValue = htmlRenderer(htmlNoValue);
assert.equal(renderedHtmlNoValue,
`(<a href="command:workbench.action.openSettings?%5B%22%40id%3Aexample.booleanSetting%22%5D">View "Example: Boolean Setting" in Settings</a>)`);
const htmlWithValue = '<span codesetting="example.booleanSetting:true">';
const renderedHtmlWithValue = htmlRenderer(htmlWithValue);
assert.equal(renderedHtmlWithValue,
`(<a href="code-setting://example.booleanSetting/true">Enable "Example: Boolean Setting" now</a> | <a href="command:workbench.action.openSettings?%5B%22%40id%3Aexample.booleanSetting%22%5D">View in Settings</a>)`);
const htmlWithValueSetToFalse = '<span codesetting="example.booleanSetting2:false">';
const renderedHtmlWithValueSetToFalse = htmlRenderer(htmlWithValueSetToFalse);
assert.equal(renderedHtmlWithValueSetToFalse,
`(<a href="code-setting://example.booleanSetting2/false">Disable "Example: Boolean Setting2" now</a> | <a href="command:workbench.action.openSettings?%5B%22%40id%3Aexample.booleanSetting2%22%5D">View in Settings</a>)`);
const htmlSameValue = '<span codesetting="example.booleanSetting:false">';
const renderedHtmlSameValue = htmlRenderer(htmlSameValue);
assert.equal(renderedHtmlSameValue,
`(<a href="command:workbench.action.openSettings?%5B%22%40id%3Aexample.booleanSetting%22%5D">View "Example: Boolean Setting" in Settings</a>)`);
});
test('render string setting', () => {
const htmlRenderer = settingRenderer.getHtmlRenderer();
const htmlNoValue = '<span codesetting="example.stringSetting">';
const renderedHtmlNoValue = htmlRenderer(htmlNoValue);
assert.equal(renderedHtmlNoValue,
`(<a href="command:workbench.action.openSettings?%5B%22%40id%3Aexample.stringSetting%22%5D">View "Example: String Setting" in Settings</a>)`);
const htmlWithValue = '<span codesetting="example.stringSetting:two">';
const renderedHtmlWithValue = htmlRenderer(htmlWithValue);
assert.equal(renderedHtmlWithValue,
`(<a href="code-setting://example.stringSetting/two">Set "Example: String Setting" to "two" now</a> | <a href="command:workbench.action.openSettings?%5B%22%40id%3Aexample.stringSetting%22%5D">View in Settings</a>)`);
const htmlSameValue = '<span codesetting="example.stringSetting:one">';
const renderedHtmlSameValue = htmlRenderer(htmlSameValue);
assert.equal(renderedHtmlSameValue,
`(<a href="command:workbench.action.openSettings?%5B%22%40id%3Aexample.stringSetting%22%5D">View "Example: String Setting" in Settings</a>)`);
});
test('render number setting', () => {
const htmlRenderer = settingRenderer.getHtmlRenderer();
const htmlNoValue = '<span codesetting="example.numberSetting">';
const renderedHtmlNoValue = htmlRenderer(htmlNoValue);
assert.equal(renderedHtmlNoValue,
`(<a href="command:workbench.action.openSettings?%5B%22%40id%3Aexample.numberSetting%22%5D">View "Example: Number Setting" in Settings</a>)`);
const htmlWithValue = '<span codesetting="example.numberSetting:2">';
const renderedHtmlWithValue = htmlRenderer(htmlWithValue);
assert.equal(renderedHtmlWithValue,
`(<a href="code-setting://example.numberSetting/2">Set "Example: Number Setting" to 2 now</a> | <a href="command:workbench.action.openSettings?%5B%22%40id%3Aexample.numberSetting%22%5D">View in Settings</a>)`);
const htmlSameValue = '<span codesetting="example.numberSetting:3">';
const renderedHtmlSameValue = htmlRenderer(htmlSameValue);
assert.equal(renderedHtmlSameValue,
`(<a href="command:workbench.action.openSettings?%5B%22%40id%3Aexample.numberSetting%22%5D">View "Example: Number Setting" in Settings</a>)`);
});
test('updating and restoring the setting through the renderer changes what is rendered', async () => {
await configurationService.setUserConfiguration('example', { stringSetting: 'two' });
const htmlRenderer = settingRenderer.getHtmlRenderer();
const htmlWithValue = '<span codesetting="example.stringSetting:three">';
const renderedHtmlWithValue = htmlRenderer(htmlWithValue);
assert.equal(renderedHtmlWithValue,
`(<a href="code-setting://example.stringSetting/three">Set "Example: String Setting" to "three" now</a> | <a href="command:workbench.action.openSettings?%5B%22%40id%3Aexample.stringSetting%22%5D">View in Settings</a>)`);
assert.equal(configurationService.getValue('example.stringSetting'), 'two');
// Update the value
await settingRenderer.updateSettingValue(URI.parse(`${Schemas.codeSetting}://example.stringSetting/three`));
assert.equal(configurationService.getValue('example.stringSetting'), 'three');
const renderedHtmlWithValueAfterUpdate = htmlRenderer(htmlWithValue);
assert.equal(renderedHtmlWithValueAfterUpdate,
`(<a href="code-setting://example.stringSetting/two">Restore value of "Example: String Setting"</a> | <a href="command:workbench.action.openSettings?%5B%22%40id%3Aexample.stringSetting%22%5D">View in Settings</a>)`);
// Restore the value
await settingRenderer.updateSettingValue(URI.parse(`${Schemas.codeSetting}://example.stringSetting/two`));
assert.equal(configurationService.getValue('example.stringSetting'), 'two');
const renderedHtmlWithValueAfterRestore = htmlRenderer(htmlWithValue);
assert.equal(renderedHtmlWithValueAfterRestore,
`(<a href="code-setting://example.stringSetting/three">Set "Example: String Setting" to "three" now</a> | <a href="command:workbench.action.openSettings?%5B%22%40id%3Aexample.stringSetting%22%5D">View in Settings</a>)`);
});
});

View File

@ -30,10 +30,14 @@ import { getTelemetryLevel, supportsTelemetry } from 'vs/platform/telemetry/comm
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';
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;
@ -50,20 +54,28 @@ export class ReleaseNotesManager {
@IEditorGroupsService private readonly _editorGroupService: IEditorGroupsService,
@IWebviewWorkbenchService private readonly _webviewWorkbenchService: IWebviewWorkbenchService,
@IExtensionService private readonly _extensionService: IExtensionService,
@IProductService private readonly _productService: IProductService
@IProductService private readonly _productService: IProductService,
@IInstantiationService private readonly _instantiationService: IInstantiationService,
) {
TokenizationRegistry.onDidChange(async () => {
if (!this._currentReleaseNotes || !this._lastText) {
return;
}
const html = await this.renderBody(this._lastText);
if (this._currentReleaseNotes) {
this._currentReleaseNotes.webview.setHtml(html);
}
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): Promise<boolean> {
@ -102,6 +114,8 @@ export class ReleaseNotesManager {
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;
}
}));
@ -204,10 +218,15 @@ export class ReleaseNotesManager {
return this._releaseNotesCache.get(version)!;
}
private onDidClickLink(uri: URI) {
this.addGAParameters(uri, 'ReleaseNotes')
.then(updated => this._openerService.open(updated))
.then(undefined, onUnexpectedError);
private async onDidClickLink(uri: URI) {
if (uri.scheme === Schemas.codeSetting) {
await this._simpleSettingRenderer.updateSettingValue(uri);
this.updateHtml();
} 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> {
@ -221,7 +240,7 @@ export class ReleaseNotesManager {
private async renderBody(text: string) {
const nonce = generateUuid();
const content = await renderMarkdownDocument(text, this._extensionService, this._languageService, false);
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'));
@ -267,9 +286,23 @@ export class ReleaseNotesManager {
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);
}
});
window.onscroll = () => {
vscode.postMessage({
type: 'scroll',
value: {
scrollPosition: {
x: window.scrollX,
y: window.scrollY
}
}
});
};
input.addEventListener('change', event => {
vscode.postMessage({ type: 'showReleaseNotes', value: input.checked }, '*');
});