Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Validate Preferences Used in Editor #10607

Merged
merged 8 commits into from
Feb 23, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions packages/core/src/browser/frontend-application-bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,17 @@
********************************************************************************/

import { interfaces } from 'inversify';
import { bindContributionProvider, DefaultResourceProvider, MessageClient, MessageService, ResourceProvider, ResourceResolver } from '../common';
import {
bindContributionProvider, DefaultResourceProvider, MaybePromise, MessageClient,
MessageService, ResourceProvider, ResourceResolver
} from '../common';
import {
bindPreferenceSchemaProvider, PreferenceProvider,
PreferenceProviderProvider, PreferenceSchemaProvider, PreferenceScope,
PreferenceService, PreferenceServiceImpl
PreferenceProviderProvider, PreferenceProxyOptions, PreferenceSchema, PreferenceSchemaProvider, PreferenceScope,
PreferenceService, PreferenceServiceImpl, PreferenceValidationService
} from './preferences';
import { InjectablePreferenceProxy, PreferenceProxyFactory, PreferenceProxySchema } from './preferences/injectable-preference-proxy';
import { ValidatedPreferenceProxy } from './preferences/validated-preference-proxy';

export function bindMessageService(bind: interfaces.Bind): interfaces.BindingWhenOnSyntax<MessageService> {
bind(MessageClient).toSelf().inSingletonScope();
Expand All @@ -40,6 +45,16 @@ export function bindPreferenceService(bind: interfaces.Bind): void {
bind(PreferenceServiceImpl).toSelf().inSingletonScope();
bind(PreferenceService).toService(PreferenceServiceImpl);
bindPreferenceSchemaProvider(bind);
bind(PreferenceValidationService).toSelf().inSingletonScope();
bind(InjectablePreferenceProxy).toSelf();
bind(ValidatedPreferenceProxy).toSelf();
bind(PreferenceProxyFactory).toFactory(({ container }) => (schema: MaybePromise<PreferenceSchema>, options: PreferenceProxyOptions = {}) => {
const child = container.createChild();
child.bind(PreferenceProxyOptions).toConstantValue(options ?? {});
child.bind(PreferenceProxySchema).toConstantValue(schema);
const handler = options.validated ? child.get(ValidatedPreferenceProxy) : child.get(InjectablePreferenceProxy);
return new Proxy(Object.create(null), handler); // eslint-disable-line no-null/no-null
});
}

export function bindResourceProvider(bind: interfaces.Bind): void {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/browser/preferences/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ export * from './preference-contribution';
export * from './preference-provider';
export * from './preference-scope';
export * from './preference-language-override-service';
export * from './preference-validation-service';
279 changes: 279 additions & 0 deletions packages/core/src/browser/preferences/injectable-preference-proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
/********************************************************************************
* Copyright (C) 2022 Ericsson and others.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v. 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0.
*
* This Source Code may also be made available under the following Secondary
* Licenses when the conditions for such availability set forth in the Eclipse
* Public License v. 2.0 are satisfied: GNU General Public License, version 2
* with the GNU Classpath Exception which is available at
* https://www.gnu.org/software/classpath/license.html.
*
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
********************************************************************************/

import { inject, injectable, postConstruct } from 'inversify';
import { PreferenceSchema } from '../../common/preferences/preference-schema';
import { Disposable, DisposableCollection, Emitter, Event, MaybePromise } from '../../common';
import { PreferenceChangeEvent, PreferenceEventEmitter, PreferenceProxyOptions, PreferenceRetrieval } from './preference-proxy';
import { PreferenceChange, PreferenceScope, PreferenceService } from './preference-service';
import { OverridePreferenceName, PreferenceChangeImpl, PreferenceChanges, PreferenceProviderDataChange, PreferenceProxy } from '.';
import { JSONValue } from '@phosphor/coreutils';

export const PreferenceProxySchema = Symbol('PreferenceProxySchema');
export interface PreferenceProxyFactory {
<T>(schema: MaybePromise<PreferenceSchema>, options?: PreferenceProxyOptions): PreferenceProxy<T>;
}
export const PreferenceProxyFactory = Symbol('PreferenceProxyFactory');

export class PreferenceProxyChange extends PreferenceChangeImpl {
constructor(change: PreferenceProviderDataChange, protected readonly overrideIdentifier?: string) {
super(change);
}

override affects(resourceUri?: string, overrideIdentifier?: string): boolean {
if (overrideIdentifier !== this.overrideIdentifier) {
return false;
}
return super.affects(resourceUri);
}
}

@injectable()
export class InjectablePreferenceProxy<T extends Record<string, JSONValue>> implements
ProxyHandler<T>, ProxyHandler<Disposable>, ProxyHandler<PreferenceEventEmitter<T>>, ProxyHandler<PreferenceRetrieval<T>> {

@inject(PreferenceProxyOptions) protected readonly options: PreferenceProxyOptions;
@inject(PreferenceService) protected readonly preferences: PreferenceService;
@inject(PreferenceProxySchema) protected readonly promisedSchema: PreferenceSchema | Promise<PreferenceSchema>;
@inject(PreferenceProxyFactory) protected readonly factory: PreferenceProxyFactory;
protected toDispose = new DisposableCollection();
protected _onPreferenceChangedEmitter: Emitter<PreferenceChangeEvent<T>> | undefined;
protected schema: PreferenceSchema | undefined;

protected get prefix(): string {
return this.options.prefix ?? '';
}

protected get style(): Required<PreferenceProxyOptions>['style'] {
return this.options.style ?? 'flat';
}

protected get resourceUri(): PreferenceProxyOptions['resourceUri'] {
return this.options.resourceUri;
}

protected get overrideIdentifier(): PreferenceProxyOptions['overrideIdentifier'] {
return this.options.overrideIdentifier;
}

protected get isDeep(): boolean {
const { style } = this;
return style === 'deep' || style === 'both';
}

protected get isFlat(): boolean {
const { style } = this;
return style === 'flat' || style === 'both';
}

protected get onPreferenceChangedEmitter(): Emitter<PreferenceChangeEvent<T>> {
if (!this._onPreferenceChangedEmitter) {
this._onPreferenceChangedEmitter = new Emitter();
this.subscribeToChangeEvents();
this.toDispose.push(this._onPreferenceChangedEmitter);
}
return this._onPreferenceChangedEmitter;
}

get onPreferenceChanged(): Event<PreferenceChangeEvent<T>> {
return this.onPreferenceChangedEmitter.event;
}

@postConstruct()
protected init(): void {
if (this.promisedSchema instanceof Promise) {
this.promisedSchema.then(schema => this.schema = schema);
} else {
this.schema = this.promisedSchema;
}
}

get(target: unknown, property: string, receiver: unknown): unknown {
if (typeof property !== 'string') { throw new Error(`Unexpected property: ${String(property)}`); }
const preferenceName = this.prefix + property;
if (this.schema && (this.isFlat || !property.includes('.')) && this.schema.properties[preferenceName]) {
const { overrideIdentifier } = this;
const toGet = overrideIdentifier ? this.preferences.overridePreferenceName({ overrideIdentifier, preferenceName }) : preferenceName;
return this.getValue(toGet as keyof T & string, undefined as any); // eslint-disable-line @typescript-eslint/no-explicit-any
}
switch (property) {
case 'onPreferenceChanged':
return this.onPreferenceChanged;
case 'dispose':
return this.dispose.bind(this);
case 'ready':
return Promise.all([this.preferences.ready, this.promisedSchema]).then(() => undefined);
case 'get':
return this.getValue.bind(this);
case 'toJSON':
return this.toJSON.bind(this);
case 'ownKeys':
return this.ownKeys.bind(this);
}
if (this.schema && this.isDeep) {
const prefix = `${preferenceName}.`;
if (Object.keys(this.schema.properties).some(key => key.startsWith(prefix))) {
const { style, resourceUri, overrideIdentifier } = this;
return this.factory(this.schema, { prefix, resourceUri, style, overrideIdentifier });
}
let value: any; // eslint-disable-line @typescript-eslint/no-explicit-any
let parentSegment = preferenceName;
const segments = [];
do {
const index = parentSegment.lastIndexOf('.');
segments.push(parentSegment.substring(index + 1));
parentSegment = parentSegment.substring(0, index);
if (parentSegment in this.schema.properties) {
value = this.get(target, parentSegment, receiver);
}
} while (parentSegment && value === undefined);

let segment;
while (typeof value === 'object' && (segment = segments.pop())) {
value = value[segment];
}
return segments.length ? undefined : value;
}
}

set(target: unknown, property: string, value: unknown, receiver: unknown): boolean {
if (typeof property !== 'string') {
throw new Error(`Unexpected property: ${String(property)}`);
}
const { style, schema, prefix, resourceUri, overrideIdentifier } = this;
if (style === 'deep' && property.indexOf('.') !== -1) {
return false;
}
if (schema) {
const fullProperty = prefix ? prefix + property : property;
if (schema.properties[fullProperty]) {
this.preferences.set(fullProperty, value, PreferenceScope.Default);
return true;
}
const newPrefix = fullProperty + '.';
for (const p of Object.keys(schema.properties)) {
if (p.startsWith(newPrefix)) {
const subProxy = this.factory<T>(schema, {
prefix: newPrefix,
resourceUri,
overrideIdentifier,
style
}) as any; // eslint-disable-line @typescript-eslint/no-explicit-any
const valueAsContainer = value as T;
for (const k of Object.keys(valueAsContainer)) {
subProxy[k as keyof T] = valueAsContainer[k as keyof T];
}
}
}
}
return false;
}

ownKeys(): string[] {
const properties = [];
if (this.schema) {
const { isDeep, isFlat, prefix } = this;
for (const property of Object.keys(this.schema.properties)) {
if (property.startsWith(prefix)) {
const idx = property.indexOf('.', prefix.length);
if (idx !== -1 && isDeep) {
const pre = property.substring(prefix.length, idx);
if (properties.indexOf(pre) === -1) {
properties.push(pre);
}
}
const prop = property.substring(prefix.length);
if (isFlat || prop.indexOf('.') === -1) {
properties.push(prop);
}
}
}
}
return properties;
}

getOwnPropertyDescriptor(target: unknown, property: string): PropertyDescriptor {
if (this.ownKeys().includes(property)) {
return {
enumerable: true,
configurable: true
};
}
return {};
}

deleteProperty(): never {
throw new Error('Unsupported operation');
}

defineProperty(): never {
throw new Error('Unsupported operation');
}

toJSON(): JSONValue {
const result: JSONValue = {};
for (const key of this.ownKeys()) {
result[key] = this.get(undefined, key, undefined) as JSONValue;
}
return result;
};

protected subscribeToChangeEvents(): void {
this.toDispose.push(this.preferences.onPreferencesChanged(changes => this.handlePreferenceChanges(changes)));
}

protected handlePreferenceChanges(changes: PreferenceChanges): void {
if (this.schema) {
for (const change of Object.values(changes)) {
const overrideInfo = this.preferences.overriddenPreferenceName(change.preferenceName);
if (this.isRelevantChange(change, overrideInfo)) {
this.fireChangeEvent(this.buildNewChangeEvent(change, overrideInfo));
}
}
}
}

protected isRelevantChange(change: PreferenceChange, overrideInfo?: OverridePreferenceName): boolean {
const preferenceName = overrideInfo?.preferenceName ?? change.preferenceName;
return preferenceName.startsWith(this.prefix)
&& (!this.overrideIdentifier || overrideInfo?.overrideIdentifier === this.overrideIdentifier)
&& Boolean(this.schema?.properties[preferenceName]);
}

protected fireChangeEvent(change: PreferenceChangeEvent<T>): void {
this.onPreferenceChangedEmitter.fire(change);
}

protected buildNewChangeEvent(change: PreferenceProviderDataChange, overrideInfo?: OverridePreferenceName): PreferenceChangeEvent<T> {
const preferenceName = (overrideInfo?.preferenceName ?? change.preferenceName) as keyof T & string;
const { newValue, oldValue, scope, domain } = change;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return new PreferenceProxyChange({ newValue, oldValue, preferenceName, scope, domain }, overrideInfo?.overrideIdentifier) as any;
}

protected getValue<K extends keyof T & string>(
preferenceIdentifier: K | OverridePreferenceName & { preferenceName: K }, defaultValue: T[K], resourceUri = this.resourceUri
): T[K] {
const preferenceName = OverridePreferenceName.is(preferenceIdentifier) ? this.preferences.overridePreferenceName(preferenceIdentifier) : preferenceIdentifier as string;
return this.preferences.get(preferenceName, defaultValue, resourceUri);
}

dispose(): void {
if (this.options.isDisposable) {
this.toDispose.dispose();
}
}
}
13 changes: 8 additions & 5 deletions packages/core/src/browser/preferences/preference-contribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { bindPreferenceConfigurations, PreferenceConfigurations } from './prefer
export { PreferenceSchema, PreferenceSchemaProperties, PreferenceDataSchema, PreferenceItem, PreferenceSchemaProperty, PreferenceDataProperty, JsonType };
import { Mutable } from '../../common/types';
import { OverridePreferenceName, PreferenceLanguageOverrideService } from './preference-language-override-service';
import { JSONValue } from '@phosphor/coreutils';

/**
* @deprecated since 1.13.0 import from @theia/core/lib/browser/preferences/preference-language-override-service.
Expand Down Expand Up @@ -218,10 +219,12 @@ export class PreferenceSchemaProvider extends PreferenceProvider {
schemaProps.defaultValue = PreferenceSchemaProperties.is(configuredDefault)
? PreferenceProvider.merge(schemaDefault, configuredDefault)
: schemaDefault;
for (const overriddenPreferenceName in schemaProps.defaultValue) {
const overrideValue = schemaDefault[overriddenPreferenceName];
const overridePreferenceName = `${preferenceName}.${overriddenPreferenceName}`;
changes.push(this.doSetPreferenceValue(overridePreferenceName, overrideValue, { scope, domain }));
if (schemaProps.defaultValue && PreferenceSchemaProperties.is(schemaProps.defaultValue)) {
for (const overriddenPreferenceName in schemaProps.defaultValue) {
const overrideValue = schemaDefault[overriddenPreferenceName];
const overridePreferenceName = `${preferenceName}.${overriddenPreferenceName}`;
changes.push(this.doSetPreferenceValue(overridePreferenceName, overrideValue, { scope, domain }));
}
}
} else {
schemaProps.defaultValue = configuredDefault === undefined ? schemaDefault : configuredDefault;
Expand All @@ -241,7 +244,7 @@ export class PreferenceSchemaProvider extends PreferenceProvider {
return { preferenceName, oldValue, newValue, scope, domain };
}

protected getDefaultValue(property: PreferenceItem): any {
getDefaultValue(property: PreferenceItem): JSONValue {
if (property.defaultValue !== undefined) {
return property.defaultValue;
}
Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/browser/preferences/preference-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,25 @@ import { PreferenceScope } from './preference-scope';
import { PreferenceLanguageOverrideService } from './preference-language-override-service';

export interface PreferenceProviderDataChange {
/**
* The name of the changed preference.
*/
readonly preferenceName: string;
/**
* The new value of the changed preference.
*/
readonly newValue?: any;
/**
* The old value of the changed preference.
*/
readonly oldValue?: any;
/**
* The {@link PreferenceScope} of the changed preference.
*/
readonly scope: PreferenceScope;
/**
* URIs of the scopes in which this change applies.
*/
readonly domain?: string[];
}

Expand Down
Loading