UNPKG

18.4 kBPlain TextView Raw
1// *****************************************************************************
2// Copyright (C) 2018 Ericsson and others.
3//
4// This program and the accompanying materials are made available under the
5// terms of the Eclipse Public License v. 2.0 which is available at
6// http://www.eclipse.org/legal/epl-2.0.
7//
8// This Source Code may also be made available under the following Secondary
9// Licenses when the conditions for such availability set forth in the Eclipse
10// Public License v. 2.0 are satisfied: GNU General Public License, version 2
11// with the GNU Classpath Exception which is available at
12// https://www.gnu.org/software/classpath/license.html.
13//
14// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
15// *****************************************************************************
16
17import * as Ajv from 'ajv';
18import { inject, injectable, interfaces, named, postConstruct } from 'inversify';
19import { ContributionProvider, bindContributionProvider, Emitter, Event, Disposable } from '../../common';
20import { PreferenceScope } from './preference-scope';
21import { PreferenceProvider, PreferenceProviderDataChange } from './preference-provider';
22import {
23 PreferenceSchema, PreferenceSchemaProperties, PreferenceDataSchema, PreferenceItem, PreferenceSchemaProperty, PreferenceDataProperty
24} from '../../common/preferences/preference-schema';
25import { FrontendApplicationConfigProvider } from '../frontend-application-config-provider';
26import { FrontendApplicationConfig } from '@theia/application-package/lib/application-props';
27import { bindPreferenceConfigurations, PreferenceConfigurations } from './preference-configurations';
28export { PreferenceSchema, PreferenceSchemaProperties, PreferenceDataSchema, PreferenceItem, PreferenceSchemaProperty, PreferenceDataProperty };
29import { isObject, Mutable } from '../../common/types';
30import { PreferenceLanguageOverrideService } from './preference-language-override-service';
31import { JSONValue } from '@phosphor/coreutils';
32
33/* eslint-disable guard-for-in, @typescript-eslint/no-explicit-any */
34
35export const PreferenceContribution = Symbol('PreferenceContribution');
36
37/**
38 * A {@link PreferenceContribution} allows adding additional custom preferences.
39 * For this, the {@link PreferenceContribution} has to provide a valid JSON Schema specifying which preferences
40 * are available including their types and description.
41 *
42 * ### Example usage
43 * ```typescript
44 * const MyPreferencesSchema: PreferenceSchema = {
45 * 'type': 'object',
46 * 'properties': {
47 * 'myext.decorations.enabled': {
48 * 'type': 'boolean',
49 * 'description': 'Show file status',
50 * 'default': true
51 * },
52 * // [...]
53 * }
54 * }
55 * @injectable()
56 * export class MyPreferenceContribution implements PreferenceContribution{
57 * schema= MyPreferencesSchema;
58 * }
59 * ```
60 */
61export interface PreferenceContribution {
62 readonly schema: PreferenceSchema;
63}
64
65export function bindPreferenceSchemaProvider(bind: interfaces.Bind): void {
66 bindPreferenceConfigurations(bind);
67 bind(PreferenceSchemaProvider).toSelf().inSingletonScope();
68 bind(PreferenceLanguageOverrideService).toSelf().inSingletonScope();
69 bindContributionProvider(bind, PreferenceContribution);
70}
71
72/**
73 * Specialized {@link FrontendApplicationConfig} to configure default
74 * preference values for the {@link PreferenceSchemaProvider}.
75 */
76export interface FrontendApplicationPreferenceConfig extends FrontendApplicationConfig {
77 preferences: {
78 [preferenceName: string]: any
79 }
80}
81export namespace FrontendApplicationPreferenceConfig {
82 export function is(config: FrontendApplicationConfig): config is FrontendApplicationPreferenceConfig {
83 return isObject(config.preferences);
84 }
85}
86
87/**
88 * The {@link PreferenceSchemaProvider} collects all {@link PreferenceContribution}s and combines
89 * the preference schema provided by these contributions into one collective schema. The preferences which
90 * are provided by this {@link PreferenceProvider} are derived from this combined schema.
91 */
92@injectable()
93export class PreferenceSchemaProvider extends PreferenceProvider {
94
95 protected readonly preferences: { [name: string]: any } = {};
96 protected readonly combinedSchema: PreferenceDataSchema = { properties: {}, patternProperties: {}, allowComments: true, allowTrailingCommas: true, };
97 protected readonly workspaceSchema: PreferenceDataSchema = { properties: {}, patternProperties: {}, allowComments: true, allowTrailingCommas: true, };
98 protected readonly folderSchema: PreferenceDataSchema = { properties: {}, patternProperties: {}, allowComments: true, allowTrailingCommas: true, };
99
100 @inject(ContributionProvider) @named(PreferenceContribution)
101 protected readonly preferenceContributions: ContributionProvider<PreferenceContribution>;
102
103 @inject(PreferenceConfigurations)
104 protected readonly configurations: PreferenceConfigurations;
105
106 protected readonly onDidPreferenceSchemaChangedEmitter = new Emitter<void>();
107 readonly onDidPreferenceSchemaChanged: Event<void> = this.onDidPreferenceSchemaChangedEmitter.event;
108 protected fireDidPreferenceSchemaChanged(): void {
109 this.onDidPreferenceSchemaChangedEmitter.fire(undefined);
110 }
111
112 @postConstruct()
113 protected init(): void {
114 this.readConfiguredPreferences();
115 this.preferenceContributions.getContributions().forEach(contrib => {
116 this.doSetSchema(contrib.schema);
117 });
118 this.combinedSchema.additionalProperties = false;
119 this._ready.resolve();
120 }
121
122 /**
123 * Register a new overrideIdentifier. Existing identifiers are not replaced.
124 *
125 * Allows overriding existing values while keeping both values in store.
126 * For example to store different editor settings, e.g. "[markdown].editor.autoIndent",
127 * "[json].editor.autoIndent" and "editor.autoIndent"
128 * @param overrideIdentifier the new overrideIdentifier
129 */
130 registerOverrideIdentifier(overrideIdentifier: string): void {
131 if (this.preferenceOverrideService.addOverrideIdentifier(overrideIdentifier)) {
132 this.updateOverridePatternPropertiesKey();
133 }
134 }
135
136 protected readonly overridePatternProperties: Required<Pick<PreferenceDataProperty, 'properties' | 'additionalProperties'>> & PreferenceDataProperty = {
137 type: 'object',
138 description: 'Configure editor settings to be overridden for a language.',
139 errorMessage: 'Unknown Identifier. Use language identifiers',
140 properties: {},
141 additionalProperties: false
142 };
143 protected overridePatternPropertiesKey: string | undefined;
144 protected updateOverridePatternPropertiesKey(): void {
145 const oldKey = this.overridePatternPropertiesKey;
146 const newKey = this.preferenceOverrideService.computeOverridePatternPropertiesKey();
147 if (oldKey === newKey) {
148 return;
149 }
150 if (oldKey) {
151 delete this.combinedSchema.patternProperties[oldKey];
152 }
153 this.overridePatternPropertiesKey = newKey;
154 if (newKey) {
155 this.combinedSchema.patternProperties[newKey] = this.overridePatternProperties;
156 }
157 this.fireDidPreferenceSchemaChanged();
158 }
159
160 protected doUnsetSchema(changes: PreferenceProviderDataChange[]): PreferenceProviderDataChange[] {
161 const inverseChanges: PreferenceProviderDataChange[] = [];
162 for (const change of changes) {
163 const preferenceName = change.preferenceName;
164 const overridden = this.preferenceOverrideService.overriddenPreferenceName(preferenceName);
165 if (overridden) {
166 delete this.overridePatternProperties.properties[`[${overridden.overrideIdentifier}]`];
167 this.removePropFromSchemas(`[${overridden.overrideIdentifier}]`);
168 } else {
169 this.removePropFromSchemas(preferenceName);
170 }
171 const newValue = change.oldValue;
172 const oldValue = change.newValue;
173 const { scope, domain } = change;
174 const inverseChange: Mutable<PreferenceProviderDataChange> = { preferenceName, oldValue, scope, domain };
175 if (typeof newValue === undefined) {
176 delete this.preferences[preferenceName];
177 } else {
178 inverseChange.newValue = newValue;
179 this.preferences[preferenceName] = newValue;
180 }
181 inverseChanges.push(inverseChange);
182 }
183 return inverseChanges;
184 }
185
186 protected validateSchema(schema: PreferenceSchema): void {
187 const ajv = new Ajv();
188 const valid = ajv.validateSchema(schema);
189 if (!valid) {
190 const errors = !!ajv.errors ? ajv.errorsText(ajv.errors) : 'unknown validation error';
191 console.warn('A contributed preference schema has validation issues : ' + errors);
192 }
193 }
194
195 protected doSetSchema(schema: PreferenceSchema): PreferenceProviderDataChange[] {
196 if (FrontendApplicationConfigProvider.get().validatePreferencesSchema) {
197 this.validateSchema(schema);
198 }
199 const scope = PreferenceScope.Default;
200 const domain = this.getDomain();
201 const changes: PreferenceProviderDataChange[] = [];
202 const defaultScope = PreferenceSchema.getDefaultScope(schema);
203 const overridable = schema.overridable || false;
204 for (const [preferenceName, rawSchemaProps] of Object.entries(schema.properties)) {
205 if (this.combinedSchema.properties[preferenceName]) {
206 console.error('Preference name collision detected in the schema for property: ' + preferenceName);
207 } else if (!rawSchemaProps.hasOwnProperty('included') || rawSchemaProps.included) {
208 const schemaProps = PreferenceDataProperty.fromPreferenceSchemaProperty(rawSchemaProps, defaultScope);
209 if (typeof schemaProps.overridable !== 'boolean' && overridable) {
210 schemaProps.overridable = true;
211 }
212 if (schemaProps.overridable) {
213 this.overridePatternProperties.properties[preferenceName] = schemaProps;
214 }
215 this.updateSchemaProps(preferenceName, schemaProps);
216
217 const schemaDefault = this.getDefaultValue(schemaProps);
218 const configuredDefault = this.getConfiguredDefault(preferenceName);
219 if (this.preferenceOverrideService.testOverrideValue(preferenceName, schemaDefault)) {
220 schemaProps.defaultValue = PreferenceSchemaProperties.is(configuredDefault)
221 ? PreferenceProvider.merge(schemaDefault, configuredDefault)
222 : schemaDefault;
223 if (schemaProps.defaultValue && PreferenceSchemaProperties.is(schemaProps.defaultValue)) {
224 for (const overriddenPreferenceName in schemaProps.defaultValue) {
225 const overrideValue = schemaDefault[overriddenPreferenceName];
226 const overridePreferenceName = `${preferenceName}.${overriddenPreferenceName}`;
227 changes.push(this.doSetPreferenceValue(overridePreferenceName, overrideValue, { scope, domain }));
228 }
229 }
230 } else {
231 schemaProps.defaultValue = configuredDefault === undefined ? schemaDefault : configuredDefault;
232 changes.push(this.doSetPreferenceValue(preferenceName, schemaProps.defaultValue, { scope, domain }));
233 }
234 }
235 }
236 return changes;
237 }
238
239 protected doSetPreferenceValue(preferenceName: string, newValue: any, { scope, domain }: {
240 scope: PreferenceScope,
241 domain?: string[]
242 }): PreferenceProviderDataChange {
243 const oldValue = this.preferences[preferenceName];
244 this.preferences[preferenceName] = newValue;
245 return { preferenceName, oldValue, newValue, scope, domain };
246 }
247
248 getDefaultValue(property: PreferenceItem): JSONValue {
249 if (property.defaultValue !== undefined) {
250 return property.defaultValue;
251 }
252 if (property.default !== undefined) {
253 return property.default;
254 }
255 const type = Array.isArray(property.type) ? property.type[0] : property.type;
256 switch (type) {
257 case 'boolean':
258 return false;
259 case 'integer':
260 case 'number':
261 return 0;
262 case 'string':
263 return '';
264 case 'array':
265 return [];
266 case 'object':
267 return {};
268 }
269 // eslint-disable-next-line no-null/no-null
270 return null;
271 }
272
273 protected getConfiguredDefault(preferenceName: string): any {
274 const config = FrontendApplicationConfigProvider.get();
275 if (preferenceName && FrontendApplicationPreferenceConfig.is(config) && preferenceName in config.preferences) {
276 return config.preferences[preferenceName];
277 }
278 }
279
280 getCombinedSchema(): PreferenceDataSchema {
281 return this.combinedSchema;
282 }
283
284 getSchema(scope: PreferenceScope): PreferenceDataSchema {
285 switch (scope) {
286 case PreferenceScope.Default:
287 case PreferenceScope.User:
288 return this.combinedSchema;
289 case PreferenceScope.Workspace:
290 return this.workspaceSchema;
291 case PreferenceScope.Folder:
292 return this.folderSchema;
293 }
294 }
295
296 setSchema(schema: PreferenceSchema): Disposable {
297 const changes = this.doSetSchema(schema);
298 if (!changes.length) {
299 return Disposable.NULL;
300 }
301 this.fireDidPreferenceSchemaChanged();
302 this.emitPreferencesChangedEvent(changes);
303 return Disposable.create(() => {
304 const inverseChanges = this.doUnsetSchema(changes);
305 if (!inverseChanges.length) {
306 return;
307 }
308 this.fireDidPreferenceSchemaChanged();
309 this.emitPreferencesChangedEvent(inverseChanges);
310 });
311 }
312
313 getPreferences(): { [name: string]: any } {
314 return this.preferences;
315 }
316
317 async setPreference(): Promise<boolean> {
318 return false;
319 }
320
321 isValidInScope(preferenceName: string, scope: PreferenceScope): boolean {
322 let property;
323 const overridden = this.preferenceOverrideService.overriddenPreferenceName(preferenceName);
324 if (overridden) {
325 // try from overridden schema
326 property = this.overridePatternProperties[`[${overridden.overrideIdentifier}]`];
327 property = property && property[overridden.preferenceName];
328 if (!property) {
329 // try from overridden identifier
330 property = this.overridePatternProperties[overridden.preferenceName];
331 }
332 if (!property) {
333 // try from overridden value
334 property = this.combinedSchema.properties[overridden.preferenceName];
335 }
336 } else {
337 property = this.combinedSchema.properties[preferenceName];
338 }
339 return property && property.scope! >= scope;
340 }
341
342 *getPreferenceNames(): IterableIterator<string> {
343 for (const preferenceName in this.combinedSchema.properties) {
344 yield preferenceName;
345 for (const overridePreferenceName of this.getOverridePreferenceNames(preferenceName)) {
346 yield overridePreferenceName;
347 }
348 }
349 }
350
351 getOverridePreferenceNames(preferenceName: string): IterableIterator<string> {
352 const preference = this.combinedSchema.properties[preferenceName];
353 if (preference && preference.overridable) {
354 return this.preferenceOverrideService.getOverridePreferenceNames(preferenceName);
355 }
356 return [][Symbol.iterator]();
357 }
358
359 getSchemaProperty(key: string): PreferenceDataProperty | undefined {
360 return this.combinedSchema.properties[key];
361 }
362
363 /**
364 * {@link property} will be assigned to field {@link key} in the schema.
365 * Pass a new object to invalidate old schema.
366 */
367 updateSchemaProperty(key: string, property: PreferenceDataProperty): void {
368 this.updateSchemaProps(key, property);
369 this.fireDidPreferenceSchemaChanged();
370 }
371
372 protected updateSchemaProps(key: string, property: PreferenceDataProperty): void {
373 this.combinedSchema.properties[key] = property;
374
375 switch (property.scope) {
376 case PreferenceScope.Folder:
377 this.folderSchema.properties[key] = property;
378 // Fall through. isValidInScope implies that User ⊃ Workspace ⊃ Folder,
379 // so anything we add to folder should be added to workspace, but not vice versa.
380 case PreferenceScope.Workspace:
381 this.workspaceSchema.properties[key] = property;
382 break;
383 }
384 }
385
386 protected removePropFromSchemas(key: string): void {
387 // If we remove a key from combined, it should also be removed from all narrower scopes.
388 delete this.combinedSchema.properties[key];
389 delete this.workspaceSchema.properties[key];
390 delete this.folderSchema.properties[key];
391 }
392
393 protected readConfiguredPreferences(): void {
394 const config = FrontendApplicationConfigProvider.get();
395 if (FrontendApplicationPreferenceConfig.is(config)) {
396 try {
397 const configuredDefaults = config.preferences;
398 const parsedDefaults = this.getParsedContent(configuredDefaults);
399 Object.assign(this.preferences, parsedDefaults);
400 const scope = PreferenceScope.Default;
401 const domain = this.getDomain();
402 const changes: PreferenceProviderDataChange[] = Object.keys(this.preferences)
403 .map((key): PreferenceProviderDataChange => ({ preferenceName: key, oldValue: undefined, newValue: this.preferences[key], scope, domain }));
404 this.emitPreferencesChangedEvent(changes);
405 } catch (e) {
406 console.error('Failed to load preferences from frontend configuration.', e);
407 }
408 }
409 }
410
411}