diff --git a/.theia/settings.json b/.theia/settings.json index be725ff0a2b86..198dfbae0092e 100644 --- a/.theia/settings.json +++ b/.theia/settings.json @@ -1,3 +1,14 @@ { - "typescript.tsdk": "node_modules/typescript/lib" -} + "editor.formatOnSave": true, + "editor.insertSpaces": true, + "[typescript]": { + "editor.tabSize": 4 + }, + "[json]": { + "editor.tabSize": 2 + }, + "[jsonc]": { + "editor.tabSize": 2 + }, + "typescript.tsdk": "node_modules/typescript/lib" +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 3651a916538fc..1ab601bdfd054 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -44,6 +44,9 @@ "[json]": { "editor.tabSize": 2 }, + "[jsonc]": { + "editor.tabSize": 2 + }, "typescript.tsdk": "node_modules/typescript/lib", "files.insertFinalNewline": true } \ No newline at end of file diff --git a/packages/core/src/browser/preferences/preference-contribution.ts b/packages/core/src/browser/preferences/preference-contribution.ts index 093980bc38825..706ea87b2be2e 100644 --- a/packages/core/src/browser/preferences/preference-contribution.ts +++ b/packages/core/src/browser/preferences/preference-contribution.ts @@ -16,11 +16,12 @@ import * as Ajv from 'ajv'; import { inject, injectable, interfaces, named, postConstruct } from 'inversify'; -import { ContributionProvider, bindContributionProvider } from '../../common'; +import { ContributionProvider, bindContributionProvider, escapeRegExpCharacters, Emitter, Event } from '../../common'; import { PreferenceScope } from './preference-service'; import { PreferenceProvider, PreferenceProviderPriority, PreferenceProviderDataChange } from './preference-provider'; // tslint:disable:no-any +// tslint:disable:forin export const PreferenceContribution = Symbol('PreferenceContribution'); export interface PreferenceContribution { @@ -30,9 +31,8 @@ export interface PreferenceContribution { export interface PreferenceSchema { [name: string]: any, scope?: 'application' | 'window' | 'resource' | PreferenceScope, - properties: { - [name: string]: PreferenceSchemaProperty - } + overridable?: boolean; + properties: PreferenceSchemaProperties } export namespace PreferenceSchema { export function getDefaultScope(schema: PreferenceSchema): PreferenceScope { @@ -46,12 +46,19 @@ export namespace PreferenceSchema { } } +export interface PreferenceSchemaProperties { + [name: string]: PreferenceSchemaProperty +} + export interface PreferenceDataSchema { [name: string]: any, scope?: PreferenceScope, properties: { [name: string]: PreferenceDataProperty } + patternProperties: { + [name: string]: PreferenceDataProperty + }; } export interface PreferenceItem { @@ -63,6 +70,7 @@ export interface PreferenceItem { properties?: { [name: string]: PreferenceItem }; additionalProperties?: object; [name: string]: any; + overridable?: boolean; } export interface PreferenceSchemaProperty extends PreferenceItem { @@ -92,15 +100,26 @@ export function bindPreferenceSchemaProvider(bind: interfaces.Bind): void { bindContributionProvider(bind, PreferenceContribution); } +const OVERRIDE_PROPERTY = '\\[(.*)\\]$'; +export const OVERRIDE_PROPERTY_PATTERN = new RegExp(OVERRIDE_PROPERTY); + +const OVERRIDE_PATTERN_WITH_SUBSTITUTION = '\\[(${0})\\]$'; + @injectable() export class PreferenceSchemaProvider extends PreferenceProvider { protected readonly preferences: { [name: string]: any } = {}; - protected readonly combinedSchema: PreferenceDataSchema = { properties: {} }; - protected validateFunction: Ajv.ValidateFunction; + protected readonly combinedSchema: PreferenceDataSchema = { properties: {}, patternProperties: {} }; @inject(ContributionProvider) @named(PreferenceContribution) protected readonly preferenceContributions: ContributionProvider; + protected validateFunction: Ajv.ValidateFunction; + + protected readonly onDidPreferenceSchemaChangedEmitter = new Emitter(); + readonly onDidPreferenceSchemaChanged: Event = this.onDidPreferenceSchemaChangedEmitter.event; + protected fireDidPreferenceSchemaChanged(): void { + this.onDidPreferenceSchemaChangedEmitter.fire(undefined); + } @postConstruct() protected init(): void { @@ -109,27 +128,79 @@ export class PreferenceSchemaProvider extends PreferenceProvider { }); this.combinedSchema.additionalProperties = false; this.updateValidate(); + this.onDidPreferencesChanged(() => this.updateValidate()); this._ready.resolve(); } - protected doSetSchema(schema: PreferenceSchema): void { + protected readonly overrideIdentifiers = new Set(); + registerOverrideIdentifier(overrideIdentifier: string): void { + if (this.overrideIdentifiers.has(overrideIdentifier)) { + return; + } + this.overrideIdentifiers.add(overrideIdentifier); + this.updateOverridePatternPropertiesKey(); + } + + protected readonly overridePatternProperties: Required> & PreferenceDataProperty = { + type: 'object', + description: 'Configure editor settings to be overridden for a language.', + errorMessage: 'Unknown Identifier. Use language identifiers', + properties: {} + }; + protected overridePatternPropertiesKey: string | undefined; + protected updateOverridePatternPropertiesKey(): void { + const oldKey = this.overridePatternPropertiesKey; + const newKey = this.computeOverridePatternPropertiesKey(); + if (oldKey === newKey) { + return; + } + if (oldKey) { + delete this.combinedSchema.patternProperties[oldKey]; + } + this.overridePatternPropertiesKey = newKey; + if (newKey) { + this.combinedSchema.patternProperties[newKey] = this.overridePatternProperties; + } + this.fireDidPreferenceSchemaChanged(); + } + protected computeOverridePatternPropertiesKey(): string | undefined { + let param: string = ''; + for (const overrideIdentifier of this.overrideIdentifiers.keys()) { + if (param.length) { + param += '|'; + } + param += new RegExp(escapeRegExpCharacters(overrideIdentifier)).source; + } + return param.length ? OVERRIDE_PATTERN_WITH_SUBSTITUTION.replace('${0}', param) : undefined; + } + + protected doSetSchema(schema: PreferenceSchema): PreferenceProviderDataChange[] { + const scope = this.getScope(); + const domain = this.getDomain(); + const changes: PreferenceProviderDataChange[] = []; const defaultScope = PreferenceSchema.getDefaultScope(schema); - const props: string[] = []; - for (const property of Object.keys(schema.properties)) { - const schemaProps = schema.properties[property]; - if (this.combinedSchema.properties[property]) { - console.error('Preference name collision detected in the schema for property: ' + property); + const overridable = schema.overridable || false; + for (const preferenceName of Object.keys(schema.properties)) { + if (this.combinedSchema.properties[preferenceName]) { + console.error('Preference name collision detected in the schema for property: ' + preferenceName); } else { - this.combinedSchema.properties[property] = PreferenceDataProperty.fromPreferenceSchemaProperty(schemaProps, defaultScope); - props.push(property); + const schemaProps = PreferenceDataProperty.fromPreferenceSchemaProperty(schema.properties[preferenceName], defaultScope); + if (typeof schemaProps.overridable !== 'boolean' && overridable) { + schemaProps.overridable = true; + } + if (schemaProps.overridable) { + this.overridePatternProperties.properties[preferenceName] = schemaProps; + } + const newValue = schemaProps.default = this.getDefaultValue(schemaProps); + this.combinedSchema.properties[preferenceName] = schemaProps; + this.preferences[preferenceName] = newValue; + changes.push({ preferenceName, newValue, scope, domain }); } } - for (const property of props) { - this.preferences[property] = this.getDefaultValue(this.combinedSchema.properties[property]); - } + return changes; } - protected getDefaultValue(property: PreferenceDataProperty): any { + protected getDefaultValue(property: PreferenceItem): any { if (property.default) { return property.default; } @@ -164,15 +235,8 @@ export class PreferenceSchemaProvider extends PreferenceProvider { } setSchema(schema: PreferenceSchema): void { - this.doSetSchema(schema); - this.updateValidate(); - const changes: PreferenceProviderDataChange[] = []; - for (const property of Object.keys(schema.properties)) { - const schemaProps = schema.properties[property]; - changes.push({ - preferenceName: property, newValue: schemaProps.default, oldValue: undefined, scope: this.getScope(), domain: this.getDomain() - }); - } + const changes = this.doSetSchema(schema); + this.fireDidPreferenceSchemaChanged(); this.emitPreferencesChangedEvent(changes); } @@ -188,11 +252,44 @@ export class PreferenceSchemaProvider extends PreferenceProvider { return { priority: PreferenceProviderPriority.Default, provider: this }; } - isValidInScope(prefName: string, scope: PreferenceScope): boolean { - const schemaProps = this.combinedSchema.properties[prefName]; - if (schemaProps) { - return schemaProps.scope! >= scope; + isValidInScope(preferenceName: string, scope: PreferenceScope): boolean { + const preference = this.getPreferenceProperty(preferenceName); + if (preference) { + return preference.scope! >= scope; } return false; } + + *getPreferenceNames(): IterableIterator { + for (const preferenceName in this.combinedSchema.properties) { + yield preferenceName; + const preference = this.combinedSchema.properties[preferenceName]; + if (preference.overridable) { + for (const overrideIdentifier of this.overrideIdentifiers) { + yield `[${overrideIdentifier}].${preferenceName}`; + } + } + } + } + + getPreferenceProperty(preferenceName: string): PreferenceItem | undefined { + const overridenName = this.testOverridenPreferenceName(preferenceName); + return this.combinedSchema.properties[overridenName || preferenceName]; + } + + testOverridenPreferenceName(name: string): string | undefined { + const index = name.indexOf('.'); + if (index === -1) { + return undefined; + } + const matches = name.substr(0, index).match(OVERRIDE_PROPERTY_PATTERN); + if (!matches || !this.overrideIdentifiers.has(matches[1])) { + return undefined; + } + return name.substr(index + 1); + } + + testOverrideValue(name: string, value: any): boolean { + return typeof value === 'object' && OVERRIDE_PROPERTY_PATTERN.test(name); + } } diff --git a/packages/core/src/browser/preferences/preference-service.ts b/packages/core/src/browser/preferences/preference-service.ts index d34d3101ad1ba..15dce9128139e 100644 --- a/packages/core/src/browser/preferences/preference-service.ts +++ b/packages/core/src/browser/preferences/preference-service.ts @@ -227,7 +227,7 @@ export class PreferenceServiceImpl implements PreferenceService, FrontendApplica } for (const s of PreferenceScope.getReversedScopes()) { if (this.schema.isValidInScope(prefName, s)) { - const p = this.providersMap.get(s); + const p = this.getProvider(s); if (p) { const value = p.get(prefName); if (s > change.scope && value !== undefined && value !== null) { @@ -282,36 +282,54 @@ export class PreferenceServiceImpl implements PreferenceService, FrontendApplica this.toDispose.push(provider); } + protected getProvider(scope: PreferenceScope, preferenceName?: string, resourceUri?: string): PreferenceProvider | undefined { + const provider = this.providersMap.get(scope); + if (provider && (!preferenceName || provider.canProvide(preferenceName, resourceUri).priority >= 0)) { + return provider; + } + return undefined; + } + getPreferences(resourceUri?: string): { [key: string]: any } { - const prefs: { [key: string]: any } = {}; - Object.keys(this.schema.getCombinedSchema().properties).forEach(p => { - prefs[p] = resourceUri ? this.get(p, undefined, resourceUri) : this.get(p, undefined); - }); - return prefs; + const preferences: { [key: string]: any } = {}; + for (const preferenceName of this.schema.getPreferenceNames()) { + preferences[preferenceName] = this.get(preferenceName, undefined, resourceUri); + } + return preferences; } has(preferenceName: string, resourceUri?: string): boolean { - return resourceUri ? this.get(preferenceName, undefined, resourceUri) !== undefined : this.get(preferenceName, undefined) !== undefined; + return this.get(preferenceName, undefined, resourceUri) !== undefined; } get(preferenceName: string): T | undefined; get(preferenceName: string, defaultValue: T): T; get(preferenceName: string, defaultValue: T, resourceUri: string): T; + get(preferenceName: string, defaultValue?: T, resourceUri?: string): T | undefined; get(preferenceName: string, defaultValue?: T, resourceUri?: string): T | undefined { - for (const s of PreferenceScope.getReversedScopes()) { - if (this.schema.isValidInScope(preferenceName, s)) { - const p = this.providersMap.get(s); - if (p && p.canProvide(preferenceName, resourceUri).priority >= 0) { - const value = p.get(preferenceName, resourceUri); - const ret = value !== null && value !== undefined ? value : defaultValue; - return deepFreeze(ret); + for (const scope of PreferenceScope.getReversedScopes()) { + if (this.schema.isValidInScope(preferenceName, scope)) { + const provider = this.getProvider(scope, preferenceName, resourceUri); + if (provider) { + const value = provider.get(preferenceName, resourceUri); + const result = value !== null && value !== undefined ? value : defaultValue; + if (result === null || value === undefined) { + const overriddenPreferenceName = this.schema.testOverridenPreferenceName(preferenceName); + if (overriddenPreferenceName) { + return this.get(overriddenPreferenceName, defaultValue, resourceUri); + } + } + return deepFreeze(result); } } } } - set(preferenceName: string, value: any, scope: PreferenceScope = PreferenceScope.User, resourceUri?: string): Promise { - return this.providerProvider(scope).setPreference(preferenceName, value, resourceUri); + async set(preferenceName: string, value: any, scope: PreferenceScope = PreferenceScope.User, resourceUri?: string): Promise { + const provider = this.getProvider(scope); + if (provider) { + await provider.setPreference(preferenceName, value, resourceUri); + } } getBoolean(preferenceName: string): boolean | undefined; @@ -347,22 +365,6 @@ export class PreferenceServiceImpl implements PreferenceService, FrontendApplica return Number(value); } - protected inpsectInScope(preferenceName: string, scope: PreferenceScope, resourceUri?: string): T | undefined { - const val = this.inspect(preferenceName, resourceUri); - if (val) { - switch (scope) { - case PreferenceScope.Default: - return val.defaultValue; - case PreferenceScope.User: - return val.globalValue; - case PreferenceScope.Workspace: - return val.workspaceValue; - case PreferenceScope.Folder: - return val.workspaceFolderValue; - } - } - } - inspect(preferenceName: string, resourceUri?: string): { preferenceName: string, defaultValue: T | undefined, @@ -370,22 +372,27 @@ export class PreferenceServiceImpl implements PreferenceService, FrontendApplica workspaceValue: T | undefined, // Workspace Preference workspaceFolderValue: T | undefined // Folder Preference } | undefined { - const schemaProps = this.schema.getCombinedSchema().properties[preferenceName]; + const schemaProps = this.schema.getPreferenceProperty(preferenceName); if (schemaProps) { const defaultValue = schemaProps.default; - const userProvider = this.providersMap.get(PreferenceScope.User); - const globalValue = userProvider && userProvider.canProvide(preferenceName, resourceUri).priority >= 0 - ? userProvider.get(preferenceName, resourceUri) : undefined; - - const workspaceProvider = this.providersMap.get(PreferenceScope.Workspace); - const workspaceValue = workspaceProvider && workspaceProvider.canProvide(preferenceName, resourceUri).priority >= 0 - ? workspaceProvider.get(preferenceName, resourceUri) : undefined; - - const folderProvider = this.providersMap.get(PreferenceScope.Folder); - const workspaceFolderValue = folderProvider && folderProvider.canProvide(preferenceName, resourceUri).priority >= 0 - ? folderProvider.get(preferenceName, resourceUri) : undefined; + const globalValue = this.inspectInScope(preferenceName, PreferenceScope.User, resourceUri); + const workspaceValue = this.inspectInScope(preferenceName, PreferenceScope.Workspace, resourceUri); + const workspaceFolderValue = this.inspectInScope(preferenceName, PreferenceScope.Folder, resourceUri); return { preferenceName, defaultValue, globalValue, workspaceValue, workspaceFolderValue }; } } + + protected inspectInScope(preferenceName: string, scope: PreferenceScope, resourceUri?: string): T | undefined { + const provider = this.getProvider(scope, preferenceName, resourceUri); + const value = provider && provider.get(preferenceName, resourceUri); + if (value === undefined) { + const overriddenPreferenceName = this.schema.testOverridenPreferenceName(preferenceName); + if (overriddenPreferenceName) { + return this.inspectInScope(overriddenPreferenceName, scope, resourceUri); + } + } + return value; + } + } diff --git a/packages/core/src/browser/preferences/test/mock-preference-provider.ts b/packages/core/src/browser/preferences/test/mock-preference-provider.ts index 0913c3518c61f..f74821bb2d22c 100644 --- a/packages/core/src/browser/preferences/test/mock-preference-provider.ts +++ b/packages/core/src/browser/preferences/test/mock-preference-provider.ts @@ -27,6 +27,7 @@ export class MockPreferenceProvider extends PreferenceProvider { } // tslint:disable-next-line:no-any setPreference(key: string, value: any, resourceUri?: string): Promise { + this.prefs[key] = value; return Promise.resolve(); } canProvide(preferenceName: string, resourceUri?: string): { priority: number, provider: PreferenceProvider } { diff --git a/packages/core/src/common/strings.ts b/packages/core/src/common/strings.ts index 7714171c88d24..5f3db25ea6275 100644 --- a/packages/core/src/common/strings.ts +++ b/packages/core/src/common/strings.ts @@ -27,10 +27,14 @@ export function* split(s: string, splitter: string): IterableIterator { } } -export function escapeInvisibleChars(value: string ): string { +export function escapeInvisibleChars(value: string): string { return value.replace(/\n/g, '\\n').replace(/\r/g, '\\r'); } export function unescapeInvisibleChars(value: string): string { return value.replace(/\\n/g, '\n').replace(/\\r/g, '\r'); } + +export function escapeRegExpCharacters(value: string): string { + return value.replace(/[\-\\\{\}\*\+\?\|\^\$\.\[\]\(\)\#]/g, '\\$&'); +} diff --git a/packages/editor/src/browser/editor-preferences.ts b/packages/editor/src/browser/editor-preferences.ts index c81035f138f0e..efb1fab549720 100644 --- a/packages/editor/src/browser/editor-preferences.ts +++ b/packages/editor/src/browser/editor-preferences.ts @@ -28,6 +28,7 @@ import { isOSX } from '@theia/core/lib/common/os'; export const editorPreferenceSchema: PreferenceSchema = { 'type': 'object', 'scope': 'resource', + 'overridable': true, 'properties': { 'editor.tabSize': { 'type': 'number', @@ -65,12 +66,14 @@ export const editorPreferenceSchema: PreferenceSchema = { 'off' ], 'default': 'on', - 'description': 'Configure whether the editor should be auto saved.' + 'description': 'Configure whether the editor should be auto saved.', + overridable: false }, 'editor.autoSaveDelay': { 'type': 'number', 'default': 500, - 'description': 'Configure the auto save delay in milliseconds.' + 'description': 'Configure the auto save delay in milliseconds.', + overridable: false }, 'editor.rulers': { 'type': 'array', diff --git a/packages/monaco/src/browser/monaco-frontend-application-contribution.ts b/packages/monaco/src/browser/monaco-frontend-application-contribution.ts index 419294f9d5fb6..2e36d16d03f86 100644 --- a/packages/monaco/src/browser/monaco-frontend-application-contribution.ts +++ b/packages/monaco/src/browser/monaco-frontend-application-contribution.ts @@ -15,7 +15,7 @@ ********************************************************************************/ import { injectable, inject } from 'inversify'; -import { FrontendApplicationContribution } from '@theia/core/lib/browser'; +import { FrontendApplicationContribution, PreferenceSchemaProvider } from '@theia/core/lib/browser'; import { ThemeService } from '@theia/core/lib/browser/theming'; import { MonacoSnippetSuggestProvider } from './monaco-snippet-suggest-provider'; @@ -28,12 +28,24 @@ export class MonacoFrontendApplicationContribution implements FrontendApplicatio @inject(MonacoSnippetSuggestProvider) protected readonly snippetSuggestProvider: MonacoSnippetSuggestProvider; + @inject(PreferenceSchemaProvider) + protected readonly preferenceSchema: PreferenceSchemaProvider; + async initialize() { const currentTheme = this.themeService.getCurrentTheme(); this.changeTheme(currentTheme.editorTheme); this.themeService.onThemeChange(event => this.changeTheme(event.newTheme.editorTheme)); monaco.suggest.setSnippetSuggestSupport(this.snippetSuggestProvider); + + for (const language of monaco.languages.getLanguages()) { + this.preferenceSchema.registerOverrideIdentifier(language.id); + } + const registerLanguage = monaco.languages.register.bind(monaco.languages); + monaco.languages.register = language => { + registerLanguage(language); + this.preferenceSchema.registerOverrideIdentifier(language.id); + }; } protected changeTheme(editorTheme: string | undefined) { diff --git a/packages/monaco/src/browser/monaco-text-model-service.ts b/packages/monaco/src/browser/monaco-text-model-service.ts index c69e06036c006..6d87f9727a85d 100644 --- a/packages/monaco/src/browser/monaco-text-model-service.ts +++ b/packages/monaco/src/browser/monaco-text-model-service.ts @@ -18,6 +18,7 @@ import { inject, injectable } from 'inversify'; import { MonacoToProtocolConverter, ProtocolToMonacoConverter } from 'monaco-languageclient'; import URI from '@theia/core/lib/common/uri'; import { ResourceProvider, ReferenceCollection, Event } from '@theia/core'; +import { PreferenceService } from '@theia/core/lib/browser'; import { EditorPreferences, EditorPreferenceChange } from '@theia/editor/lib/browser'; import { MonacoEditorModel } from './monaco-editor-model'; @@ -34,6 +35,9 @@ export class MonacoTextModelService implements monaco.editor.ITextModelService { @inject(EditorPreferences) protected readonly editorPreferences: EditorPreferences; + @inject(PreferenceService) + protected readonly preferences: PreferenceService; + @inject(MonacoToProtocolConverter) protected readonly m2p: MonacoToProtocolConverter; @@ -57,14 +61,11 @@ export class MonacoTextModelService implements monaco.editor.ITextModelService { } protected async loadModel(uri: URI): Promise { - const uriStr = uri.toString(); await this.editorPreferences.ready; const resource = await this.resourceProvider(uri); const model = await (new MonacoEditorModel(resource, this.m2p, this.p2m).load()); - model.autoSave = this.editorPreferences.get('editor.autoSave', undefined, uriStr); - model.autoSaveDelay = this.editorPreferences.get('editor.autoSaveDelay', undefined, uriStr); - model.textEditorModel.updateOptions(this.getModelOptions(uriStr)); - const disposable = this.editorPreferences.onPreferenceChanged(change => this.updateModel(model, change)); + this.updateModel(model); + const disposable = this.preferences.onPreferenceChanged(() => this.updateModel(model)); model.onDispose(() => disposable.dispose()); return model; } @@ -76,26 +77,28 @@ export class MonacoTextModelService implements monaco.editor.ITextModelService { 'editor.insertSpaces': 'insertSpaces' }; - protected updateModel(model: MonacoEditorModel, change: EditorPreferenceChange): void { - if (change.preferenceName === 'editor.autoSave') { - model.autoSave = this.editorPreferences.get('editor.autoSave', undefined, model.uri); - } - if (change.preferenceName === 'editor.autoSaveDelay') { - model.autoSaveDelay = this.editorPreferences.get('editor.autoSaveDelay', undefined, model.uri); - } - const modelOption = this.modelOptions[change.preferenceName]; - if (modelOption) { - const options: monaco.editor.ITextModelUpdateOptions = {}; - // tslint:disable-next-line:no-any - options[modelOption] = change.newValue as any; - model.textEditorModel.updateOptions(options); - } + protected updateModel(model: MonacoEditorModel): void; + /** @deprecated pass only MonacoEditorModel */ + protected updateModel(model: MonacoEditorModel, change: EditorPreferenceChange): void; + protected updateModel(model: MonacoEditorModel, change?: EditorPreferenceChange): void { + model.autoSave = this.editorPreferences.get('editor.autoSave', undefined, model.uri); + model.autoSaveDelay = this.editorPreferences.get('editor.autoSaveDelay', undefined, model.uri); + model.textEditorModel.updateOptions(this.getModelOptions(model)); } - protected getModelOptions(uri: string): monaco.editor.ITextModelUpdateOptions { + /** @deprecated pass MonacoEditorModel instead */ + protected getModelOptions(uri: string): monaco.editor.ITextModelUpdateOptions; + protected getModelOptions(model: MonacoEditorModel): monaco.editor.ITextModelUpdateOptions; + protected getModelOptions(arg: string | MonacoEditorModel): monaco.editor.ITextModelUpdateOptions { + if (typeof arg === 'string') { + return { + tabSize: this.editorPreferences.get('editor.tabSize', undefined, arg), + insertSpaces: this.editorPreferences.get('editor.insertSpaces', undefined, arg) + }; + } return { - tabSize: this.editorPreferences.get('editor.tabSize', undefined, uri), - insertSpaces: this.editorPreferences.get('editor.insertSpaces', undefined, uri) + tabSize: this.preferences.get(`[${arg.languageId}].editor.tabSize`, undefined, arg.uri), + insertSpaces: this.preferences.get(`[${arg.languageId}].editor.insertSpaces`, undefined, arg.uri) }; } diff --git a/packages/plugin-ext/src/common/plugin-protocol.ts b/packages/plugin-ext/src/common/plugin-protocol.ts index a30e2902852ab..95a38c1f28954 100644 --- a/packages/plugin-ext/src/common/plugin-protocol.ts +++ b/packages/plugin-ext/src/common/plugin-protocol.ts @@ -18,7 +18,8 @@ import { RPCProtocol } from '../api/rpc-protocol'; import { Disposable } from '@theia/core/lib/common/disposable'; import { LogPart, KeysToAnyValues, KeysToKeysToAnyValue } from './types'; import { CharacterPair, CommentRule, PluginAPIFactory, Plugin } from '../api/plugin-api'; -import { PreferenceSchema } from '@theia/core/lib/browser/preferences'; +// FIXME get rid of browser code in backend +import { PreferenceSchema, PreferenceSchemaProperties } from '@theia/core/lib/browser/preferences'; import { ExtPluginApi } from './plugin-ext-api-contribution'; import { IJSONSchema, IJSONSchemaSnippet } from '@theia/core/lib/common/json-schema'; @@ -55,6 +56,7 @@ export interface PluginPackage { */ export interface PluginPackageContribution { configuration?: PreferenceSchema; + configurationDefaults?: PreferenceSchemaProperties; languages?: PluginPackageLanguageContribution[]; grammars?: PluginPackageGrammarsContribution[]; viewsContainers?: { [location: string]: PluginPackageViewContainer[] }; @@ -347,6 +349,7 @@ export interface PluginModel { */ export interface PluginContribution { configuration?: PreferenceSchema; + configurationDefaults?: PreferenceSchemaProperties; languages?: LanguageContribution[]; grammars?: GrammarsContribution[]; viewsContainers?: { [location: string]: ViewContainer[] }; diff --git a/packages/plugin-ext/src/hosted/node/scanners/scanner-theia.ts b/packages/plugin-ext/src/hosted/node/scanners/scanner-theia.ts index 9d0dc1b8831d7..6331b304a1030 100644 --- a/packages/plugin-ext/src/hosted/node/scanners/scanner-theia.ts +++ b/packages/plugin-ext/src/hosted/node/scanners/scanner-theia.ts @@ -119,6 +119,7 @@ export class TheiaPluginScanner implements PluginScanner { const config = this.readConfiguration(rawPlugin.contributes.configuration!, rawPlugin.packagePath); contributions.configuration = config; } + contributions.configurationDefaults = rawPlugin.contributes.configurationDefaults; if (rawPlugin.contributes!.languages) { const languages = this.readLanguages(rawPlugin.contributes.languages!, rawPlugin.packagePath); diff --git a/packages/plugin-ext/src/main/browser/plugin-contribution-handler.ts b/packages/plugin-ext/src/main/browser/plugin-contribution-handler.ts index 7cad8d6d98f8e..1639da7024871 100644 --- a/packages/plugin-ext/src/main/browser/plugin-contribution-handler.ts +++ b/packages/plugin-ext/src/main/browser/plugin-contribution-handler.ts @@ -21,7 +21,7 @@ import { MenusContributionPointHandler } from './menus/menus-contribution-handle import { ViewRegistry } from './view/view-registry'; import { PluginContribution, IndentationRules, FoldingRules, ScopeMap } from '../../common'; import { PreferenceSchemaProvider } from '@theia/core/lib/browser'; -import { PreferenceSchema } from '@theia/core/lib/browser/preferences'; +import { PreferenceSchema, PreferenceSchemaProperties } from '@theia/core/lib/browser/preferences'; import { KeybindingsContributionPointHandler } from './keybindings/keybindings-contribution-handler'; import { MonacoSnippetSuggestProvider } from '@theia/monaco/lib/browser/monaco-snippet-suggest-provider'; import { PluginSharedStyle } from './plugin-shared-style'; @@ -64,6 +64,9 @@ export class PluginContributionHandler { if (contributions.configuration) { this.updateConfigurationSchema(contributions.configuration); } + if (contributions.configurationDefaults) { + this.updateDefaultOverridesSchema(contributions.configurationDefaults); + } if (contributions.languages) { for (const lang of contributions.languages) { @@ -185,6 +188,28 @@ export class PluginContributionHandler { this.preferenceSchemaProvider.setSchema(schema); } + protected updateDefaultOverridesSchema(configurationDefaults: PreferenceSchemaProperties): void { + const defaultOverrides: PreferenceSchema = { + id: 'defaultOverrides', + title: 'Default Configuration Overrides', + properties: {} + }; + // tslint:disable-next-line:forin + for (const key in configurationDefaults) { + const defaultValue = configurationDefaults[key]; + if (this.preferenceSchemaProvider.testOverrideValue(key, defaultValue)) { + defaultOverrides.properties[key] = { + type: 'object', + default: defaultValue, + description: `Configure editor settings to be overridden for ${key} language.` + }; + } + } + if (Object.keys(defaultOverrides.properties).length) { + this.preferenceSchemaProvider.setSchema(defaultOverrides); + } + } + private createRegex(value: string | undefined): RegExp | undefined { if (typeof value === 'string') { return new RegExp(value, ''); diff --git a/packages/preferences/src/browser/abstract-resource-preference-provider.ts b/packages/preferences/src/browser/abstract-resource-preference-provider.ts index 543709cb080a8..05de310a9835b 100644 --- a/packages/preferences/src/browser/abstract-resource-preference-provider.ts +++ b/packages/preferences/src/browser/abstract-resource-preference-provider.ts @@ -119,8 +119,31 @@ export abstract class AbstractResourcePreferenceProvider extends PreferenceProvi // tslint:disable-next-line:no-any protected async getParsedContent(content: string): Promise<{ [key: string]: any }> { const strippedContent = jsoncparser.stripComments(content); - const newPrefs = jsoncparser.parse(strippedContent) || {}; - return newPrefs; + const jsonData = jsoncparser.parse(strippedContent); + // tslint:disable-next-line:no-any + const preferences: { [key: string]: any } = {}; + if (typeof jsonData !== 'object') { + return preferences; + } + const uri = (await this.resource).uri.toString(); + // tslint:disable-next-line:forin + for (const preferenceName in jsonData) { + const preferenceValue = jsonData[preferenceName]; + if (preferenceValue !== undefined && !this.schemaProvider.validate(preferenceName, preferenceValue)) { + console.warn(`Preference ${preferenceName} in ${uri} is invalid.`); + continue; + } + if (this.schemaProvider.testOverrideValue(preferenceName, preferenceValue)) { + // tslint:disable-next-line:forin + for (const overriddenPreferenceName in preferenceValue) { + const overriddeValue = preferenceValue[overriddenPreferenceName]; + preferences[`${preferenceName}.${overriddenPreferenceName}`] = overriddeValue; + } + } else { + preferences[preferenceName] = preferenceValue; + } + } + return preferences; } // tslint:disable-next-line:no-any @@ -129,24 +152,19 @@ export abstract class AbstractResourcePreferenceProvider extends PreferenceProvi this.preferences = newPrefs; const prefNames = new Set([...Object.keys(oldPrefs), ...Object.keys(newPrefs)]); const prefChanges: PreferenceProviderDataChange[] = []; + const uri = (await this.resource).uri.toString(); for (const prefName of prefNames.values()) { const oldValue = oldPrefs[prefName]; const newValue = newPrefs[prefName]; - const prefNameAndFile = `Preference ${prefName} in ${(await this.resource).uri.toString()}`; - if (!this.schemaProvider.validate(prefName, newValue) && newValue !== undefined) { // do not emit the change event if pref is not defined in schema - console.warn(`${prefNameAndFile} is invalid.`); - continue; - } const schemaProperties = this.schemaProvider.getCombinedSchema().properties[prefName]; if (schemaProperties) { const scope = schemaProperties.scope; // do not emit the change event if the change is made out of the defined preference scope if (!this.schemaProvider.isValidInScope(prefName, this.getScope())) { - console.warn(`${prefNameAndFile} can only be defined in scopes: ${PreferenceScope.getScopeNames(scope).join(', ')}.`); + console.warn(`Preference ${prefName} in ${uri} can only be defined in scopes: ${PreferenceScope.getScopeNames(scope).join(', ')}.`); continue; } } - if (newValue === undefined && oldValue !== newValue || oldValue === undefined && newValue !== oldValue // JSONExt.deepEqual() does not support handling `undefined` || !JSONExt.deepEqual(oldValue, newValue)) { diff --git a/packages/preferences/src/browser/preference-service.spec.ts b/packages/preferences/src/browser/preference-service.spec.ts index 9ed7308665d0f..720accc81b082 100644 --- a/packages/preferences/src/browser/preference-service.spec.ts +++ b/packages/preferences/src/browser/preference-service.spec.ts @@ -22,6 +22,7 @@ import { enableJSDOM } from '@theia/core/lib/browser/test/jsdom'; let disableJSDOM = enableJSDOM(); import { Container } from 'inversify'; +import * as assert from 'assert'; import * as chai from 'chai'; import * as fs from 'fs-extra'; import * as temp from 'temp'; @@ -418,4 +419,169 @@ describe('Preference Service', () => { stubs.push(sinon.stub(prefSchema, 'isValidInScope').returns(true)); expect(service.get('mypref')).to.equal(5); }); + + describe('overridden preferences', () => { + + it('getPreferences', () => { + const { preferences, schema } = prepareServices(); + preferences.set('[json].editor.tabSize', 2, PreferenceScope.User); + + assert.deepEqual({ + 'editor.tabSize': 4 + }, preferences.getPreferences()); + + schema.registerOverrideIdentifier('json'); + + assert.deepEqual({ + 'editor.tabSize': 4, + '[json].editor.tabSize': 2 + }, preferences.getPreferences()); + }); + + it('get #0', () => { + const { preferences, schema } = prepareServices(); + preferences.set('[json].editor.tabSize', 2, PreferenceScope.User); + + assert.equal(4, preferences.get('editor.tabSize')); + assert.equal(undefined, preferences.get('[json].editor.tabSize')); + + schema.registerOverrideIdentifier('json'); + + assert.equal(4, preferences.get('editor.tabSize')); + assert.equal(2, preferences.get('[json].editor.tabSize')); + }); + + it('get #1', () => { + const { preferences, schema } = prepareServices(); + schema.registerOverrideIdentifier('json'); + + assert.equal(4, preferences.get('editor.tabSize')); + assert.equal(4, preferences.get('[json].editor.tabSize')); + + preferences.set('[json].editor.tabSize', 2, PreferenceScope.User); + + assert.equal(4, preferences.get('editor.tabSize')); + assert.equal(2, preferences.get('[json].editor.tabSize')); + }); + + it('get #2', () => { + const { preferences, schema } = prepareServices(); + schema.registerOverrideIdentifier('json'); + + assert.equal(4, preferences.get('editor.tabSize')); + assert.equal(4, preferences.get('[json].editor.tabSize')); + + preferences.set('editor.tabSize', 2, PreferenceScope.User); + + assert.equal(2, preferences.get('editor.tabSize')); + assert.equal(2, preferences.get('[json].editor.tabSize')); + }); + + it('has', () => { + const { preferences, schema } = prepareServices(); + + assert.ok(preferences.has('editor.tabSize')); + assert.ok(!preferences.has('[json].editor.tabSize')); + + schema.registerOverrideIdentifier('json'); + + assert.ok(preferences.has('editor.tabSize')); + assert.ok(preferences.has('[json].editor.tabSize')); + }); + + it('inspect #0', () => { + const { preferences, schema } = prepareServices(); + + const expected = { + preferenceName: 'editor.tabSize', + defaultValue: 4, + globalValue: undefined, + workspaceValue: undefined, + workspaceFolderValue: undefined, + }; + assert.deepEqual(expected, preferences.inspect('editor.tabSize')); + assert.ok(!preferences.has('[json].editor.tabSize')); + + schema.registerOverrideIdentifier('json'); + + assert.deepEqual(expected, preferences.inspect('editor.tabSize')); + assert.deepEqual({ + ...expected, + preferenceName: '[json].editor.tabSize' + }, preferences.inspect('[json].editor.tabSize')); + }); + + it('inspect #1', () => { + const { preferences, schema } = prepareServices(); + + const expected = { + preferenceName: 'editor.tabSize', + defaultValue: 4, + globalValue: 2, + workspaceValue: undefined, + workspaceFolderValue: undefined, + }; + preferences.set('editor.tabSize', 2, PreferenceScope.User); + + assert.deepEqual(expected, preferences.inspect('editor.tabSize')); + assert.ok(!preferences.has('[json].editor.tabSize')); + + schema.registerOverrideIdentifier('json'); + + assert.deepEqual(expected, preferences.inspect('editor.tabSize')); + assert.deepEqual({ + ...expected, + preferenceName: '[json].editor.tabSize' + }, preferences.inspect('[json].editor.tabSize')); + }); + + it('inspect #2', () => { + const { preferences, schema } = prepareServices(); + + const expected = { + preferenceName: 'editor.tabSize', + defaultValue: 4, + globalValue: undefined, + workspaceValue: undefined, + workspaceFolderValue: undefined, + }; + assert.deepEqual(expected, preferences.inspect('editor.tabSize')); + assert.ok(!preferences.has('[json].editor.tabSize')); + + schema.registerOverrideIdentifier('json'); + preferences.set('[json].editor.tabSize', 2, PreferenceScope.User); + + assert.deepEqual(expected, preferences.inspect('editor.tabSize')); + assert.deepEqual({ + ...expected, + preferenceName: '[json].editor.tabSize', + globalValue: 2 + }, preferences.inspect('[json].editor.tabSize')); + }); + + function prepareServices() { + const container = new Container(); + bindPreferenceSchemaProvider(container.bind.bind(container)); + container.bind(PreferenceProviderProvider).toFactory(() => () => new MockPreferenceProvider()); + container.bind(PreferenceServiceImpl).toSelf().inSingletonScope(); + + const schema = container.get(PreferenceSchemaProvider); + schema.setSchema({ + properties: { + 'editor.tabSize': { + type: 'number', + description: '', + overridable: true, + default: 4 + } + } + }); + + const preferences = container.get(PreferenceServiceImpl); + preferences.initialize(); + return { preferences, schema }; + } + + }); + }); diff --git a/packages/preferences/src/browser/preferences-frontend-application-contribution.ts b/packages/preferences/src/browser/preferences-frontend-application-contribution.ts index 91da9b3e14835..e1bd32331200d 100644 --- a/packages/preferences/src/browser/preferences-frontend-application-contribution.ts +++ b/packages/preferences/src/browser/preferences-frontend-application-contribution.ts @@ -36,7 +36,7 @@ export class PreferencesFrontendApplicationContribution implements FrontendAppli fileMatch: ['.theia/settings.json', USER_PREFERENCE_URI.toString()], url: uri.toString() }); - this.schemaProvider.onDidPreferencesChanged(() => + this.schemaProvider.onDidPreferenceSchemaChanged(() => this.inmemoryResources.update(uri, serializeSchema()) ); }