diff --git a/CHANGELOG.md b/CHANGELOG.md index e3217b3b1957a..ef04823b1922e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,19 @@ Breaking changes: - `debug.thread.next` renamed to `workbench.action.debug.stepOver` - `debug.stop` renamed to `workbench.action.debug.stop` - `debug.editor.showHover` renamed to `editor.debug.action.showDebugHover` +- multi-root workspace support for preferences [#3247](https://github.com/theia-ide/theia/pull/3247) + - `PreferenceProvider` + - is changed from a regular class to an abstract class. + - the `fireOnDidPreferencesChanged` function is deprecated. `emitPreferencesChangedEvent` function should be used instead. `fireOnDidPreferencesChanged` will be removed with the next major release. + - `PreferenceServiceImpl` + - `preferences` is deprecated. `getPreferences` function should be used instead. `preferences` will be removed with the next major release. + - having `properties` property defined in the `PreferenceSchema` object is now mandatory. + - `PreferenceProperty` is renamed to `PreferenceDataProperty`. + - `PreferenceSchemaProvider` + - the type of `combinedSchema` property is changed from `PreferenceSchema` to `PreferenceDataSchema`. + - the return type of `getCombinedSchema` function is changed from `PreferenceSchema` to `PreferenceDataSchema`. + - `affects` function is added to `PreferenceChangeEvent` and `PreferenceChange` interface. + ## v0.3.19 - [core] added `hostname` alias diff --git a/packages/core/src/browser/frontend-application-module.ts b/packages/core/src/browser/frontend-application-module.ts index 1d10b33f2f27e..4edb14bf7517f 100644 --- a/packages/core/src/browser/frontend-application-module.ts +++ b/packages/core/src/browser/frontend-application-module.ts @@ -193,6 +193,7 @@ export const frontendApplicationModule = new ContainerModule((bind, unbind, isBo bind(PreferenceProvider).toSelf().inSingletonScope().whenTargetNamed(PreferenceScope.User); bind(PreferenceProvider).toSelf().inSingletonScope().whenTargetNamed(PreferenceScope.Workspace); + bind(PreferenceProvider).toSelf().inSingletonScope().whenTargetNamed(PreferenceScope.Folder); bind(PreferenceProviderProvider).toFactory(ctx => (scope: PreferenceScope) => { if (scope === PreferenceScope.Default) { return ctx.container.get(PreferenceSchemaProvider); diff --git a/packages/core/src/browser/preferences/preference-contribution.ts b/packages/core/src/browser/preferences/preference-contribution.ts index a7713b9bc4864..d20bf3a64a814 100644 --- a/packages/core/src/browser/preferences/preference-contribution.ts +++ b/packages/core/src/browser/preferences/preference-contribution.ts @@ -15,9 +15,10 @@ ********************************************************************************/ import * as Ajv from 'ajv'; -import { inject, injectable, named, interfaces, postConstruct } from 'inversify'; +import { inject, injectable, interfaces, named, postConstruct } from 'inversify'; import { ContributionProvider, bindContributionProvider } from '../../common'; -import { PreferenceProvider } from './preference-provider'; +import { PreferenceScope } from './preference-service'; +import { PreferenceProvider, PreferenceProviderPriority, PreferenceProviderDataChange } from './preference-provider'; // tslint:disable:no-any @@ -26,30 +27,62 @@ export interface PreferenceContribution { readonly schema: PreferenceSchema; } -export const PreferenceSchema = Symbol('PreferenceSchema'); - export interface PreferenceSchema { - [name: string]: Object, + [name: string]: any, + scope?: 'application' | 'window' | 'resource' | PreferenceScope, + properties: { + [name: string]: PreferenceSchemaProperty + } +} +export namespace PreferenceSchema { + export function getDefaultScope(schema: PreferenceSchema): PreferenceScope { + let defaultScope: PreferenceScope = PreferenceScope.Workspace; + if (!PreferenceScope.is(schema.scope)) { + defaultScope = PreferenceScope.fromString(schema.scope) || PreferenceScope.Workspace; + } else { + defaultScope = schema.scope; + } + return defaultScope; + } +} + +export interface PreferenceDataSchema { + [name: string]: any, + scope?: PreferenceScope, properties: { - [name: string]: PreferenceProperty + [name: string]: PreferenceDataProperty } } export interface PreferenceItem { type?: JsonType | JsonType[]; minimum?: number; - // tslint:disable-next-line:no-any default?: any; enum?: string[]; items?: PreferenceItem; properties?: { [name: string]: PreferenceItem }; additionalProperties?: object; - // tslint:disable-next-line:no-any [name: string]: any; } -export interface PreferenceProperty extends PreferenceItem { +export interface PreferenceSchemaProperty extends PreferenceItem { + description: string; + scope?: 'application' | 'window' | 'resource' | PreferenceScope; +} + +export interface PreferenceDataProperty extends PreferenceItem { description: string; + scope?: PreferenceScope; +} +export namespace PreferenceDataProperty { + export function fromPreferenceSchemaProperty(schemaProps: PreferenceSchemaProperty, defaultScope: PreferenceScope = PreferenceScope.Workspace): PreferenceDataProperty { + if (!schemaProps.scope) { + schemaProps.scope = defaultScope; + } else if (typeof schemaProps.scope === 'string') { + return Object.assign(schemaProps, { scope: PreferenceScope.fromString(schemaProps.scope) || defaultScope }); + } + return schemaProps; + } } export type JsonType = 'string' | 'array' | 'number' | 'integer' | 'object' | 'boolean' | 'null'; @@ -62,8 +95,8 @@ export function bindPreferenceSchemaProvider(bind: interfaces.Bind): void { @injectable() export class PreferenceSchemaProvider extends PreferenceProvider { - protected readonly combinedSchema: PreferenceSchema = { properties: {} }; protected readonly preferences: { [name: string]: any } = {}; + protected readonly combinedSchema: PreferenceDataSchema = { properties: {} }; protected validateFunction: Ajv.ValidateFunction; @inject(ContributionProvider) @named(PreferenceContribution) @@ -74,21 +107,23 @@ export class PreferenceSchemaProvider extends PreferenceProvider { this.preferenceContributions.getContributions().forEach(contrib => { this.doSetSchema(contrib.schema); }); + this.combinedSchema.additionalProperties = false; this.updateValidate(); this._ready.resolve(); } protected doSetSchema(schema: PreferenceSchema): void { + const defaultScope = PreferenceSchema.getDefaultScope(schema); const props: string[] = []; - for (const property in schema.properties) { + 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); } else { - this.combinedSchema.properties[property] = schema.properties[property]; + this.combinedSchema.properties[property] = PreferenceDataProperty.fromPreferenceSchemaProperty(schemaProps, defaultScope); props.push(property); } } - // tslint:disable-next-line:forin for (const property of props) { this.preferences[property] = this.combinedSchema.properties[property].default; } @@ -102,22 +137,40 @@ export class PreferenceSchemaProvider extends PreferenceProvider { return this.validateFunction({ [name]: value }) as boolean; } - getCombinedSchema(): PreferenceSchema { + getCombinedSchema(): PreferenceDataSchema { return this.combinedSchema; } - getPreferences(): { [name: string]: any } { - return this.preferences; - } - setSchema(schema: PreferenceSchema): void { this.doSetSchema(schema); this.updateValidate(); - this.fireOnDidPreferencesChanged(); + 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() + }); + } + this.emitPreferencesChangedEvent(changes); + } + + getPreferences(): { [name: string]: any } { + return this.preferences; } async setPreference(): Promise { throw new Error('Unsupported'); } + canProvide(preferenceName: string, resourceUri?: string): { priority: number, provider: 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; + } + return false; + } } diff --git a/packages/core/src/browser/preferences/preference-provider.ts b/packages/core/src/browser/preferences/preference-provider.ts index d29d0e7473fbf..35c93fe7ba951 100644 --- a/packages/core/src/browser/preferences/preference-provider.ts +++ b/packages/core/src/browser/preferences/preference-provider.ts @@ -19,11 +19,33 @@ import { injectable } from 'inversify'; import { Disposable, DisposableCollection, Emitter, Event } from '../../common'; import { Deferred } from '../../common/promise-util'; +import { PreferenceScope } from './preference-service'; + +export namespace PreferenceProviderPriority { + export const NA = -1; + export const Default = 0; + export const User = 1; + export const Workspace = 2; + export const Folder = 3; +} + +export interface PreferenceProviderDataChange { + readonly preferenceName: string; + readonly newValue?: any; + readonly oldValue?: any; + readonly scope: PreferenceScope; + readonly domain: string[]; +} + +export interface PreferenceProviderDataChanges { + [preferenceName: string]: PreferenceProviderDataChange +} @injectable() -export class PreferenceProvider implements Disposable { - protected readonly onDidPreferencesChangedEmitter = new Emitter(); - readonly onDidPreferencesChanged: Event = this.onDidPreferencesChangedEmitter.event; +export abstract class PreferenceProvider implements Disposable { + + protected readonly onDidPreferencesChangedEmitter = new Emitter(); + readonly onDidPreferencesChanged: Event = this.onDidPreferencesChangedEmitter.event; protected readonly toDispose = new DisposableCollection(); @@ -41,20 +63,57 @@ export class PreferenceProvider implements Disposable { this.toDispose.dispose(); } + /** + * Informs the listeners that one or more preferences of this provider are changed. + * The listeners are able to find what was changed from the emitted event. + */ + protected emitPreferencesChangedEvent(changes: PreferenceProviderDataChanges | PreferenceProviderDataChange[]): void { + if (Array.isArray(changes)) { + const prefChanges: PreferenceProviderDataChanges = {}; + for (const change of changes) { + prefChanges[change.preferenceName] = change; + } + this.onDidPreferencesChangedEmitter.fire(prefChanges); + } else { + this.onDidPreferencesChangedEmitter.fire(changes); + } + } + + /** + * Informs the listeners that one or more preferences of this provider are changed. + * @deprecated Use emitPreferencesChangedEvent instead. + */ protected fireOnDidPreferencesChanged(): void { this.onDidPreferencesChangedEmitter.fire(undefined); } - getPreferences(): { [p: string]: any } { - return []; + get(preferenceName: string, resourceUri?: string): T | undefined { + const value = this.getPreferences(resourceUri)[preferenceName]; + if (value !== undefined && value !== null) { + return value; + } } - setPreference(key: string, value: any): Promise { - return Promise.resolve(); - } + // tslint:disable-next-line:no-any + abstract getPreferences(resourceUri?: string): { [p: string]: any }; + + // tslint:disable-next-line:no-any + abstract setPreference(key: string, value: any, resourceUri?: string): Promise; /** See `_ready`. */ get ready() { return this._ready.promise; } + + canProvide(preferenceName: string, resourceUri?: string): { priority: number, provider: PreferenceProvider } { + return { priority: PreferenceProviderPriority.NA, provider: this }; + } + + getDomain(): string[] { + return []; + } + + protected getScope() { + return PreferenceScope.Default; + } } diff --git a/packages/core/src/browser/preferences/preference-proxy.ts b/packages/core/src/browser/preferences/preference-proxy.ts index 6986bcae35a14..89ffd5d2d14fe 100644 --- a/packages/core/src/browser/preferences/preference-proxy.ts +++ b/packages/core/src/browser/preferences/preference-proxy.ts @@ -21,16 +21,23 @@ import { PreferenceService, PreferenceChange } from './preference-service'; import { PreferenceSchema } from './preference-contribution'; export interface PreferenceChangeEvent { - readonly preferenceName: keyof T - readonly newValue?: T[keyof T] - readonly oldValue?: T[keyof T] + readonly preferenceName: keyof T; + readonly newValue?: T[keyof T]; + readonly oldValue?: T[keyof T]; + affects(resourceUri?: string): boolean; } + export interface PreferenceEventEmitter { readonly onPreferenceChanged: Event>; readonly ready: Promise; } -export type PreferenceProxy = Readonly & Disposable & PreferenceEventEmitter; +export interface PreferenceRetrieval { + get(preferenceName: K, defaultValue?: T[K], resourceUri?: string): T[K]; +} + +export type PreferenceProxy = Readonly & Disposable & PreferenceEventEmitter & PreferenceRetrieval; + export function createPreferenceProxy(preferences: PreferenceService, schema: PreferenceSchema): PreferenceProxy { const toDispose = new DisposableCollection(); const onPreferenceChangedEmitter = new Emitter(); @@ -40,6 +47,7 @@ export function createPreferenceProxy(preferences: PreferenceService, schema: onPreferenceChangedEmitter.fire(e); } })); + const unsupportedOperation = (_: any, __: string) => { throw new Error('Unsupported operation'); }; @@ -57,7 +65,10 @@ export function createPreferenceProxy(preferences: PreferenceService, schema: if (property === 'ready') { return preferences.ready; } - throw new Error('unexpected property: ' + property); + if (property === 'get') { + return preferences.get.bind(preferences); + } + throw new Error(`unexpected property: ${property}`); }, ownKeys: () => Object.keys(schema.properties), getOwnPropertyDescriptor: (_, property: string) => { diff --git a/packages/core/src/browser/preferences/preference-service.ts b/packages/core/src/browser/preferences/preference-service.ts index 338f95f153843..d34d3101ad1ba 100644 --- a/packages/core/src/browser/preferences/preference-service.ts +++ b/packages/core/src/browser/preferences/preference-service.ts @@ -16,36 +16,109 @@ // tslint:disable:no-any -import { JSONExt } from '@phosphor/coreutils'; import { injectable, inject, postConstruct } from 'inversify'; +import { JSONExt } from '@phosphor/coreutils'; import { FrontendApplicationContribution } from '../../browser'; import { Event, Emitter, DisposableCollection, Disposable, deepFreeze } from '../../common'; import { Deferred } from '../../common/promise-util'; -import { PreferenceProvider } from './preference-provider'; +import { PreferenceProvider, PreferenceProviderDataChange, PreferenceProviderDataChanges } from './preference-provider'; import { PreferenceSchemaProvider } from './preference-contribution'; +import URI from '../../common/uri'; export enum PreferenceScope { Default, User, - Workspace + Workspace, + Folder } export namespace PreferenceScope { + export function is(scope: any): scope is PreferenceScope { + return typeof scope === 'number' && getScopes().findIndex(s => s === scope) >= 0; + } + export function getScopes(): PreferenceScope[] { return Object.keys(PreferenceScope) .filter(k => typeof PreferenceScope[k as any] === 'string') .map(v => Number(v)); } -} -export interface PreferenceChangedEvent { - changes: PreferenceChange[] + export function getReversedScopes(): PreferenceScope[] { + return getScopes().reverse(); + } + + export function getScopeNames(scope?: PreferenceScope): string[] { + const names: string[] = []; + const allNames = Object.keys(PreferenceScope) + .filter(k => typeof PreferenceScope[k as any] === 'number'); + if (scope) { + for (const name of allNames) { + if ((PreferenceScope)[name] <= scope) { + names.push(name); + } + } + } + return names; + } + + export function fromString(strScope: string): PreferenceScope | undefined { + switch (strScope) { + case 'application': + return PreferenceScope.User; + case 'window': + return PreferenceScope.Workspace; + case 'resource': + return PreferenceScope.Folder; + } + } } export interface PreferenceChange { readonly preferenceName: string; readonly newValue?: any; readonly oldValue?: any; + affects(resourceUri?: string): boolean; +} + +export class PreferenceChangeImpl implements PreferenceChange { + constructor( + private change: PreferenceProviderDataChange, + private providers: Map + ) { } + + get preferenceName() { + return this.change.preferenceName; + } + get newValue() { + return this.change.newValue; + } + get oldValue() { + return this.change.oldValue; + } + + affects(resourceUri?: string): boolean { + if (this.change.domain && resourceUri && + this.change.domain.length !== 0 && + this.change.domain.map(uriStr => new URI(uriStr)) + .every(folderUri => folderUri.path.relativity(new URI(resourceUri).path) < 0) + ) { + return false; + } + for (const [scope, provider] of this.providers.entries()) { + if (!resourceUri && scope === PreferenceScope.Folder) { + continue; + } + const providerInfo = provider.canProvide(this.preferenceName, resourceUri); + const priority = providerInfo.priority; + if (priority >= 0 && scope > this.change.scope) { + return false; + } + if (scope === this.change.scope && this.change.domain.some(d => providerInfo.provider.getDomain().findIndex(pd => pd === d) < 0)) { + return false; + } + } + return true; + } } export interface PreferenceChanges { @@ -57,8 +130,9 @@ export interface PreferenceService extends Disposable { readonly ready: Promise; get(preferenceName: string): T | undefined; get(preferenceName: string, defaultValue: T): T; - get(preferenceName: string, defaultValue?: T): T | undefined; - set(preferenceName: string, value: any, scope?: PreferenceScope): Promise; + get(preferenceName: string, defaultValue: T, resourceUri: string): T; + get(preferenceName: string, defaultValue?: T, resourceUri?: string): T | undefined; + set(preferenceName: string, value: any, scope?: PreferenceScope, resourceUri?: string): Promise; onPreferenceChanged: Event; } @@ -67,13 +141,11 @@ export interface PreferenceService extends Disposable { * It allows to load them lazilly after DI is configured. */ export const PreferenceProviderProvider = Symbol('PreferenceProviderProvider'); -export type PreferenceProviderProvider = (scope: PreferenceScope) => PreferenceProvider; +export type PreferenceProviderProvider = (scope: PreferenceScope, uri?: URI) => PreferenceProvider; @injectable() export class PreferenceServiceImpl implements PreferenceService, FrontendApplicationContribution { - protected preferences: { [key: string]: any } = {}; - protected readonly onPreferenceChangedEmitter = new Emitter(); readonly onPreferenceChanged = this.onPreferenceChangedEmitter.event; @@ -89,12 +161,17 @@ export class PreferenceServiceImpl implements PreferenceService, FrontendApplica protected readonly providerProvider: PreferenceProviderProvider; protected readonly providers: PreferenceProvider[] = []; + protected providersMap: Map = new Map(); + + /** + * @deprecated Use getPreferences() instead + */ + protected preferences: { [key: string]: any } = {}; @postConstruct() protected init(): void { this.toDispose.push(Disposable.create(() => this._ready.reject())); - this.providers.push(this.schema); - this.preferences = this.parsePreferences(); + this.doSetProvider(PreferenceScope.Default, this.schema); } dispose(): void { @@ -109,119 +186,158 @@ export class PreferenceServiceImpl implements PreferenceService, FrontendApplica initialize(): void { this.initializeProviders(); } - protected async initializeProviders(): Promise { + + protected initializeProviders(): void { try { - const providers = this.createProviders(); - this.toDispose.pushAll(providers); - await Promise.all(providers.map(p => p.ready)); + this.createProviders(); if (this.toDispose.disposed) { return; } - this.providers.push(...providers); - for (const provider of providers) { - provider.onDidPreferencesChanged(_ => this.reconcilePreferences()); + for (const provider of this.providersMap.values()) { + this.toDispose.push(provider.onDidPreferencesChanged(changes => + this.reconcilePreferences(changes) + )); } - this.reconcilePreferences(); - this._ready.resolve(); + Promise.all(this.providers.map(p => p.ready)).then(() => this._ready.resolve()); } catch (e) { this._ready.reject(e); } } + protected createProviders(): PreferenceProvider[] { - return [ - this.providerProvider(PreferenceScope.User), - this.providerProvider(PreferenceScope.Workspace) - ]; - } - - protected reconcilePreferences(): void { - const changes: PreferenceChanges = {}; - const deleted = new Set(Object.keys(this.preferences)); - const preferences = this.parsePreferences(); - // tslint:disable-next-line:forin - for (const preferenceName in preferences) { - deleted.delete(preferenceName); - const oldValue = this.preferences[preferenceName]; - const newValue = preferences[preferenceName]; - if (oldValue !== undefined) { - if (!JSONExt.deepEqual(oldValue, newValue)) { - changes[preferenceName] = { preferenceName, newValue, oldValue }; - this.preferences[preferenceName] = deepFreeze(newValue); + const providers: PreferenceProvider[] = []; + PreferenceScope.getScopes().forEach(scope => { + const p = this.doCreateProvider(scope); + if (p) { + providers.push(p); + } + }); + return providers; + } + + protected reconcilePreferences(changes?: PreferenceProviderDataChanges): void { + const changesToEmit: PreferenceChanges = {}; + if (changes) { + for (const prefName of Object.keys(changes)) { + const change = changes[prefName]; + if (this.schema.isValidInScope(prefName, PreferenceScope.Folder)) { + const toEmit = new PreferenceChangeImpl(change, this.providersMap); + changesToEmit[prefName] = toEmit; + continue; + } + for (const s of PreferenceScope.getReversedScopes()) { + if (this.schema.isValidInScope(prefName, s)) { + const p = this.providersMap.get(s); + if (p) { + const value = p.get(prefName); + if (s > change.scope && value !== undefined && value !== null) { + // preference defined in a more specific scope + break; + } else if (s === change.scope) { + const toEmit = new PreferenceChangeImpl(change, this.providersMap); + changesToEmit[prefName] = toEmit; + } + } + } } - } else { - changes[preferenceName] = { preferenceName, newValue }; - this.preferences[preferenceName] = deepFreeze(newValue); } + } else { // go through providers for the Default, User, and Workspace Scopes to find delta + const newPrefs = this.getPreferences(); + const oldPrefs = this.preferences; + for (const preferenceName of Object.keys(newPrefs)) { + const newValue = newPrefs[preferenceName]; + const oldValue = oldPrefs[preferenceName]; + if (newValue === undefined && oldValue !== newValue + || oldValue === undefined && newValue !== oldValue // JSONExt.deepEqual() does not support handling `undefined` + || !JSONExt.deepEqual(oldValue, newValue)) { + const toEmit = new PreferenceChangeImpl({ + newValue, oldValue, preferenceName, scope: PreferenceScope.Workspace, domain: [] + }, this.providersMap); + changesToEmit[preferenceName] = toEmit; + } + } + this.preferences = newPrefs; } - for (const preferenceName of deleted) { - const oldValue = this.preferences[preferenceName]; - changes[preferenceName] = { preferenceName, oldValue }; - this.preferences[preferenceName] = undefined; - } - this.onPreferencesChangedEmitter.fire(changes); - // tslint:disable-next-line:forin - for (const preferenceName in changes) { - this.onPreferenceChangedEmitter.fire(changes[preferenceName]); + + // emit the changes + const changedPreferenceNames = Object.keys(changesToEmit); + if (changedPreferenceNames.length > 0) { + this.onPreferencesChangedEmitter.fire(changesToEmit); } + changedPreferenceNames.forEach(preferenceName => this.onPreferenceChangedEmitter.fire(changesToEmit[preferenceName])); } - protected parsePreferences(): { [name: string]: any } { - const result: { [name: string]: any } = {}; - for (const provider of this.providers) { - const preferences = provider.getPreferences(); - // tslint:disable-next-line:forin - for (const preferenceName in preferences) { - if (this.schema.validate(preferenceName, preferences[preferenceName])) { - result[preferenceName] = preferences[preferenceName]; - } - } + + protected doCreateProvider(scope: PreferenceScope): PreferenceProvider | undefined { + if (!this.providersMap.has(scope)) { + const provider = this.providerProvider(scope); + this.doSetProvider(scope, provider); + return provider; } - return result; + return this.providersMap.get(scope); + } + + private doSetProvider(scope: PreferenceScope, provider: PreferenceProvider): void { + this.providersMap.set(scope, provider); + this.providers.push(provider); + this.toDispose.push(provider); } - getPreferences(): { [key: string]: Object | undefined } { - return this.preferences; + 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; } - has(preferenceName: string): boolean { - return this.preferences[preferenceName] !== undefined; + has(preferenceName: string, resourceUri?: string): boolean { + return resourceUri ? this.get(preferenceName, undefined, resourceUri) !== undefined : this.get(preferenceName, undefined) !== undefined; } get(preferenceName: string): T | undefined; get(preferenceName: string, defaultValue: T): T; - get(preferenceName: string, defaultValue?: T): T | undefined { - const value = this.preferences[preferenceName]; - return value !== null && value !== undefined ? value : defaultValue; + get(preferenceName: string, defaultValue: T, resourceUri: string): T; + 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); + } + } + } } - set(preferenceName: string, value: any, scope: PreferenceScope = PreferenceScope.User): Promise { - return this.providerProvider(scope).setPreference(preferenceName, value); + set(preferenceName: string, value: any, scope: PreferenceScope = PreferenceScope.User, resourceUri?: string): Promise { + return this.providerProvider(scope).setPreference(preferenceName, value, resourceUri); } getBoolean(preferenceName: string): boolean | undefined; getBoolean(preferenceName: string, defaultValue: boolean): boolean; - getBoolean(preferenceName: string, defaultValue?: boolean): boolean | undefined { - const value = this.preferences[preferenceName]; + getBoolean(preferenceName: string, defaultValue: boolean, resourceUri: string): boolean; + getBoolean(preferenceName: string, defaultValue?: boolean, resourceUri?: string): boolean | undefined { + const value = resourceUri ? this.get(preferenceName, defaultValue, resourceUri) : this.get(preferenceName, defaultValue); return value !== null && value !== undefined ? !!value : defaultValue; } getString(preferenceName: string): string | undefined; getString(preferenceName: string, defaultValue: string): string; - getString(preferenceName: string, defaultValue?: string): string | undefined { - const value = this.preferences[preferenceName]; + getString(preferenceName: string, defaultValue: string, resourceUri: string): string; + getString(preferenceName: string, defaultValue?: string, resourceUri?: string): string | undefined { + const value = resourceUri ? this.get(preferenceName, defaultValue, resourceUri) : this.get(preferenceName, defaultValue); if (value === null || value === undefined) { return defaultValue; } - if (typeof value === 'string') { - return value; - } return value.toString(); } getNumber(preferenceName: string): number | undefined; getNumber(preferenceName: string, defaultValue: number): number; - getNumber(preferenceName: string, defaultValue?: number): number | undefined { - const value = this.preferences[preferenceName]; - + getNumber(preferenceName: string, defaultValue: number, resourceUri: string): number; + getNumber(preferenceName: string, defaultValue?: number, resourceUri?: string): number | undefined { + const value = resourceUri ? this.get(preferenceName, defaultValue, resourceUri) : this.get(preferenceName, defaultValue); if (value === null || value === undefined) { return defaultValue; } @@ -231,4 +347,45 @@ 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, + globalValue: T | undefined, // User Preference + workspaceValue: T | undefined, // Workspace Preference + workspaceFolderValue: T | undefined // Folder Preference + } | undefined { + const schemaProps = this.schema.getCombinedSchema().properties[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; + + return { preferenceName, defaultValue, globalValue, workspaceValue, workspaceFolderValue }; + } + } } diff --git a/packages/core/src/browser/preferences/test/index.ts b/packages/core/src/browser/preferences/test/index.ts index d475c62da8335..eafad6ff2ee1b 100644 --- a/packages/core/src/browser/preferences/test/index.ts +++ b/packages/core/src/browser/preferences/test/index.ts @@ -16,3 +16,4 @@ export * from './mock-preference-service'; export * from './mock-preference-proxy'; +export * from './mock-preference-provider'; diff --git a/packages/core/src/browser/preferences/test/mock-preference-provider.ts b/packages/core/src/browser/preferences/test/mock-preference-provider.ts new file mode 100644 index 0000000000000..0913c3518c61f --- /dev/null +++ b/packages/core/src/browser/preferences/test/mock-preference-provider.ts @@ -0,0 +1,38 @@ +/******************************************************************************** + * Copyright (C) 2019 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 { injectable } from 'inversify'; +import { PreferenceProvider, PreferenceProviderPriority } from '../'; + +@injectable() +export class MockPreferenceProvider extends PreferenceProvider { + // tslint:disable-next-line:no-any + readonly prefs: { [p: string]: any } = {}; + + getPreferences() { + return this.prefs; + } + // tslint:disable-next-line:no-any + setPreference(key: string, value: any, resourceUri?: string): Promise { + return Promise.resolve(); + } + canProvide(preferenceName: string, resourceUri?: string): { priority: number, provider: PreferenceProvider } { + if (this.prefs[preferenceName] === undefined) { + return { priority: PreferenceProviderPriority.NA, provider: this }; + } + return { priority: PreferenceProviderPriority.User, provider: this }; + } +} diff --git a/packages/core/src/browser/preferences/test/mock-preference-service.ts b/packages/core/src/browser/preferences/test/mock-preference-service.ts index 98dbac441e2cc..4b3f46ac528c0 100644 --- a/packages/core/src/browser/preferences/test/mock-preference-service.ts +++ b/packages/core/src/browser/preferences/test/mock-preference-service.ts @@ -24,7 +24,8 @@ export class MockPreferenceService implements PreferenceService { dispose() { } get(preferenceName: string): T | undefined; get(preferenceName: string, defaultValue: T): T; - get(preferenceName: string, defaultValue?: T): T | undefined { + get(preferenceName: string, defaultValue: T, resourceUri: string): T; + get(preferenceName: string, defaultValue?: T, resourceUri?: string): T | undefined { return undefined; } // tslint:disable-next-line:no-any diff --git a/packages/core/src/browser/style/tabs.css b/packages/core/src/browser/style/tabs.css index ad71c82301e94..be8c3e880d525 100644 --- a/packages/core/src/browser/style/tabs.css +++ b/packages/core/src/browser/style/tabs.css @@ -73,6 +73,22 @@ display: inline-block; } +.p-TabBar-tab-secondary-label { + color: var(--theia-brand-color2); + cursor: pointer; + font-size: var(--theia-ui-font-size0); + margin-left: 5px; + text-decoration-line: underline; + + -webkit-appearance: none; + -moz-appearance: none; + background-image: linear-gradient(45deg, transparent 50%, var(--theia-ui-font-color1) 50%), linear-gradient(135deg, var(--theia-ui-font-color1) 50%, transparent 50%); + background-position: calc(100% - 6px) 8px, calc(100% - 2px) 8px, 100% 0; + background-size: 4px 5px; + background-repeat: no-repeat; + padding: 2px 14px 0 0; +} + .p-TabBar .p-TabBar-tabIcon { width: 15px; line-height: 1.7; diff --git a/packages/core/src/common/path.ts b/packages/core/src/common/path.ts index 7f104d708df00..6ead3e1f01884 100644 --- a/packages/core/src/common/path.ts +++ b/packages/core/src/common/path.ts @@ -166,4 +166,15 @@ export class Path { return !!this.relative(path); } + relativity(path: Path): number { + const relative = this.relative(path); + if (relative) { + const relativeStr = relative.toString(); + if (relativeStr === '') { + return 0; + } + return relativeStr.split(Path.separator).length; + } + return -1; + } } diff --git a/packages/editor/src/browser/editor-preferences.ts b/packages/editor/src/browser/editor-preferences.ts index 60abb4f8e76c4..c81035f138f0e 100644 --- a/packages/editor/src/browser/editor-preferences.ts +++ b/packages/editor/src/browser/editor-preferences.ts @@ -27,6 +27,7 @@ import { isOSX } from '@theia/core/lib/common/os'; export const editorPreferenceSchema: PreferenceSchema = { 'type': 'object', + 'scope': 'resource', 'properties': { 'editor.tabSize': { 'type': 'number', diff --git a/packages/filesystem/src/browser/filesystem-preferences.ts b/packages/filesystem/src/browser/filesystem-preferences.ts index 607c19ead1b16..9d18868760843 100644 --- a/packages/filesystem/src/browser/filesystem-preferences.ts +++ b/packages/filesystem/src/browser/filesystem-preferences.ts @@ -35,13 +35,14 @@ export const filesystemPreferenceSchema: PreferenceSchema = { '**/.git/objects/**': true, '**/.git/subtree-cache/**': true, '**/node_modules/**': true - } + }, + 'scope': 'resource' } } }; export interface FileSystemConfiguration { - 'files.watcherExclude': { [globPattern: string]: boolean } + 'files.watcherExclude': { [globPattern: string]: boolean }; } export const FileSystemPreferences = Symbol('FileSystemPreferences'); diff --git a/packages/filesystem/src/browser/filesystem-watcher.ts b/packages/filesystem/src/browser/filesystem-watcher.ts index 0c4d23ac50794..3e1881c2734ba 100644 --- a/packages/filesystem/src/browser/filesystem-watcher.ts +++ b/packages/filesystem/src/browser/filesystem-watcher.ts @@ -146,7 +146,7 @@ export class FileSystemWatcher implements Disposable { * Return a disposable to stop file watching under the given uri. */ watchFileChanges(uri: URI): Promise { - return this.createWatchOptions() + return this.createWatchOptions(uri.toString()) .then(options => this.server.watchFileChanges(uri.toString(), options) ) @@ -167,16 +167,15 @@ export class FileSystemWatcher implements Disposable { }); } - protected createWatchOptions(): Promise { - return this.getIgnored().then(ignored => ({ + protected createWatchOptions(uri: string): Promise { + return this.getIgnored(uri).then(ignored => ({ ignored })); } - protected getIgnored(): Promise { - const patterns = this.preferences['files.watcherExclude']; - - return Promise.resolve(Object.keys(patterns).filter(pattern => patterns[pattern])); + protected async getIgnored(uri: string): Promise { + const patterns = this.preferences.get('files.watcherExclude', undefined, uri); + return Object.keys(patterns).filter(pattern => patterns[pattern]); } protected fireDidMove(sourceUri: string, targetUri: string): void { diff --git a/packages/monaco/src/browser/monaco-editor-provider.ts b/packages/monaco/src/browser/monaco-editor-provider.ts index c1808de239b7d..3fe7c93e304d7 100644 --- a/packages/monaco/src/browser/monaco-editor-provider.ts +++ b/packages/monaco/src/browser/monaco-editor-provider.ts @@ -143,7 +143,11 @@ export class MonacoEditorProvider { const model = await this.getModel(uri, toDispose); const options = this.createMonacoEditorOptions(model); const editor = new MonacoEditor(uri, model, document.createElement('div'), this.m2p, this.p2m, options, override); - toDispose.push(this.editorPreferences.onPreferenceChanged(event => this.updateMonacoEditorOptions(editor, event))); + toDispose.push(this.editorPreferences.onPreferenceChanged(event => { + if (event.affects(uri.toString())) { + this.updateMonacoEditorOptions(editor, event); + } + })); editor.document.onWillSaveModel(event => { event.waitUntil(new Promise(async resolve => { if (event.reason === TextDocumentSaveReason.Manual && this.editorPreferences['editor.formatOnSave']) { @@ -152,17 +156,17 @@ export class MonacoEditorProvider { resolve([]); })); }); - return editor; } protected createMonacoEditorOptions(model: MonacoEditorModel): MonacoEditor.IOptions { - const options = this.createOptions(this.preferencePrefixes); + const options = this.createOptions(this.preferencePrefixes, model.uri); options.model = model.textEditorModel; options.readOnly = model.readOnly; return options; } protected updateMonacoEditorOptions(editor: MonacoEditor, event: EditorPreferenceChange): void { - const { preferenceName, newValue } = event; + const preferenceName = event.preferenceName; + const newValue = this.editorPreferences.get(preferenceName, undefined, editor.uri.toString()); editor.getControl().updateOptions(this.setOption(preferenceName, newValue, this.preferencePrefixes)); } @@ -183,23 +187,29 @@ export class MonacoEditorProvider { this.diffNavigatorFactory, options, override); - toDispose.push(this.editorPreferences.onPreferenceChanged(event => this.updateMonacoDiffEditorOptions(editor, event))); + toDispose.push(this.editorPreferences.onPreferenceChanged(event => { + const originalFileUri = original.withoutQuery().withScheme('file').toString(); + if (event.affects(originalFileUri)) { + this.updateMonacoDiffEditorOptions(editor, event, originalFileUri); + } + })); return editor; } protected createMonacoDiffEditorOptions(original: MonacoEditorModel, modified: MonacoEditorModel): MonacoDiffEditor.IOptions { - const options = this.createOptions(this.diffPreferencePrefixes); + const options = this.createOptions(this.diffPreferencePrefixes, modified.uri); options.originalEditable = !original.readOnly; options.readOnly = modified.readOnly; return options; } - protected updateMonacoDiffEditorOptions(editor: MonacoDiffEditor, event: EditorPreferenceChange): void { - const { preferenceName, newValue } = event; + protected updateMonacoDiffEditorOptions(editor: MonacoDiffEditor, event: EditorPreferenceChange, resourceUri?: string): void { + const preferenceName = event.preferenceName; + const newValue = this.editorPreferences.get(preferenceName, undefined, resourceUri); editor.diffEditor.updateOptions(this.setOption(preferenceName, newValue, this.diffPreferencePrefixes)); } - protected createOptions(prefixes: string[]): { [name: string]: any } { + protected createOptions(prefixes: string[], uri: string): { [name: string]: any } { return Object.keys(this.editorPreferences).reduce((options, preferenceName) => { - const value = (this.editorPreferences)[preferenceName]; + const value = (this.editorPreferences).get(preferenceName, undefined, uri); return this.setOption(preferenceName, value, prefixes, options); }, {}); } diff --git a/packages/monaco/src/browser/monaco-text-model-service.ts b/packages/monaco/src/browser/monaco-text-model-service.ts index 9830f7c238097..c69e06036c006 100644 --- a/packages/monaco/src/browser/monaco-text-model-service.ts +++ b/packages/monaco/src/browser/monaco-text-model-service.ts @@ -57,12 +57,13 @@ 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['editor.autoSave']; - model.autoSaveDelay = this.editorPreferences['editor.autoSaveDelay']; - model.textEditorModel.updateOptions(this.getModelOptions()); + 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)); model.onDispose(() => disposable.dispose()); return model; @@ -77,10 +78,10 @@ export class MonacoTextModelService implements monaco.editor.ITextModelService { protected updateModel(model: MonacoEditorModel, change: EditorPreferenceChange): void { if (change.preferenceName === 'editor.autoSave') { - model.autoSave = this.editorPreferences['editor.autoSave']; + model.autoSave = this.editorPreferences.get('editor.autoSave', undefined, model.uri); } if (change.preferenceName === 'editor.autoSaveDelay') { - model.autoSaveDelay = this.editorPreferences['editor.autoSaveDelay']; + model.autoSaveDelay = this.editorPreferences.get('editor.autoSaveDelay', undefined, model.uri); } const modelOption = this.modelOptions[change.preferenceName]; if (modelOption) { @@ -91,10 +92,10 @@ export class MonacoTextModelService implements monaco.editor.ITextModelService { } } - protected getModelOptions(): monaco.editor.ITextModelUpdateOptions { + protected getModelOptions(uri: string): monaco.editor.ITextModelUpdateOptions { return { - tabSize: this.editorPreferences['editor.tabSize'], - insertSpaces: this.editorPreferences['editor.insertSpaces'] + tabSize: this.editorPreferences.get('editor.tabSize', undefined, uri), + insertSpaces: this.editorPreferences.get('editor.insertSpaces', undefined, uri) }; } diff --git a/packages/navigator/src/browser/navigator-filter.ts b/packages/navigator/src/browser/navigator-filter.ts index 491509d9c4e34..45908658a0658 100644 --- a/packages/navigator/src/browser/navigator-filter.ts +++ b/packages/navigator/src/browser/navigator-filter.ts @@ -14,7 +14,7 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { inject, injectable } from 'inversify'; +import { inject, injectable, postConstruct } from 'inversify'; import { Minimatch } from 'minimatch'; import { MaybePromise } from '@theia/core/lib/common/types'; import { Event, Emitter } from '@theia/core/lib/common/event'; @@ -28,16 +28,20 @@ import { FileNavigatorPreferences, FileNavigatorConfiguration } from './navigato @injectable() export class FileNavigatorFilter { - protected readonly emitter: Emitter; + protected readonly emitter: Emitter = new Emitter(); protected filterPredicate: FileNavigatorFilter.Predicate; protected showHiddenFiles: boolean; - constructor(@inject(FileNavigatorPreferences) protected readonly preferences: FileNavigatorPreferences) { - this.emitter = new Emitter(); + constructor( + @inject(FileNavigatorPreferences) protected readonly preferences: FileNavigatorPreferences + ) { } + + @postConstruct() + protected async init(): Promise { this.filterPredicate = this.createFilterPredicate(this.preferences['navigator.exclude']); - preferences.onPreferenceChanged(this.onPreferenceChanged.bind(this)); + this.preferences.onPreferenceChanged(this.onPreferenceChanged.bind(this)); } async filter(items: MaybePromise): Promise { diff --git a/packages/plugin-ext/src/main/browser/preference-registry-main.ts b/packages/plugin-ext/src/main/browser/preference-registry-main.ts index 18cbc2ea0092e..3b9d93f9617fd 100644 --- a/packages/plugin-ext/src/main/browser/preference-registry-main.ts +++ b/packages/plugin-ext/src/main/browser/preference-registry-main.ts @@ -18,7 +18,6 @@ import { PreferenceService, PreferenceServiceImpl, PreferenceScope, - PreferenceChange, PreferenceProviderProvider } from '@theia/core/lib/browser/preferences'; import { interfaces } from 'inversify'; @@ -52,12 +51,7 @@ export class PreferenceRegistryMainImpl implements PreferenceRegistryMain { preferenceServiceImpl.onPreferenceChanged(e => { const data = getPreferences(this.preferenceProviderProvider); - const eventData: PreferenceChange = { - preferenceName: e.preferenceName, - newValue: e.newValue, - oldValue: e.oldValue - }; - this.proxy.$acceptConfigurationChanged(data, eventData); + this.proxy.$acceptConfigurationChanged(data, Object.assign({}, e)); }); } diff --git a/packages/preferences/README.md b/packages/preferences/README.md index 834942c0492bf..06fcf29890ee4 100644 --- a/packages/preferences/README.md +++ b/packages/preferences/README.md @@ -1,6 +1,15 @@ # Theia - Preferences Extension -This package includes preferences implementation for the preferences api defined in `@theia/core`. This provides two preference providers, one for the user home directory, and one for the workspace, which has precedence over the previous one. To set preferences, create or edit a `settings.json` under the `.theia` folder located either in the user home, or the root of the workspace. +This package includes preferences implementation for the preferences api defined in `@theia/core`, which provides four preference providers: +- Default Preference, which serves as default values of preferences, +- User Preference for the user home directory, which has precedence over the default values, +- Workspace Preference for the workspace, which has precedence over User Preference, and +- Folder Preference for the root folder, which has precedence over the Workspace Preference + +To set +- User Preferences: Create or edit a `settings.json` under the `.theia` folder located either in the user home. +- Workspace Preference: If one folder is opened as the workspace, create or edit a `settings.json` under the root of the workspace. If a multi-root workspace is opened, create or edit the "settings" property in the workspace file. +- Folder Preferences: Create or edit a `settings.json` under any of the root folders. Example of a `settings.json` below: @@ -14,6 +23,27 @@ Example of a `settings.json` below: } ``` +Example of a workspace file below: + +```typescript +{ + "folders": [ + { + "path": "file:///home/username/helloworld" + }, + { + "path": "file:///home/username/dev/byeworld" + } + ], + "settings": { + // Enable/Disable the line numbers in the monaco editor + "editor.lineNumbers": "off", + // Tab width in the editor + "editor.tabSize": 4, + } +} +``` + ## License - [Eclipse Public License 2.0](http://www.eclipse.org/legal/epl-2.0/) -- [一 (Secondary) GNU General Public License, version 2 with the GNU Classpath Exception](https://projects.eclipse.org/license/secondary-gpl-2.0-cp) \ No newline at end of file +- [一 (Secondary) GNU General Public License, version 2 with the GNU Classpath Exception](https://projects.eclipse.org/license/secondary-gpl-2.0-cp) diff --git a/packages/preferences/package.json b/packages/preferences/package.json index ef860de53f5e0..1db5fb26832c1 100644 --- a/packages/preferences/package.json +++ b/packages/preferences/package.json @@ -6,6 +6,7 @@ "@theia/core": "^0.3.19", "@theia/editor": "^0.3.19", "@theia/filesystem": "^0.3.19", + "@theia/json": "^0.3.19", "@theia/monaco": "^0.3.19", "@theia/userstorage": "^0.3.19", "@theia/workspace": "^0.3.19", diff --git a/packages/preferences/src/browser/abstract-resource-preference-provider.ts b/packages/preferences/src/browser/abstract-resource-preference-provider.ts index dcbcede23f766..543709cb080a8 100644 --- a/packages/preferences/src/browser/abstract-resource-preference-provider.ts +++ b/packages/preferences/src/browser/abstract-resource-preference-provider.ts @@ -15,22 +15,23 @@ ********************************************************************************/ import { inject, injectable, postConstruct } from 'inversify'; -import * as jsoncparser from 'jsonc-parser'; +import { JSONExt } from '@phosphor/coreutils'; +import { DisposableCollection, MaybePromise, MessageService, Resource, ResourceProvider } from '@theia/core'; +import { PreferenceProvider, PreferenceSchemaProvider, PreferenceScope, PreferenceProviderDataChange } from '@theia/core/lib/browser'; import URI from '@theia/core/lib/common/uri'; -import { Resource, ResourceProvider, MaybePromise, MessageService } from '@theia/core'; -import { PreferenceProvider } from '@theia/core/lib/browser/preferences'; +import * as jsoncparser from 'jsonc-parser'; @injectable() export abstract class AbstractResourcePreferenceProvider extends PreferenceProvider { // tslint:disable-next-line:no-any protected preferences: { [key: string]: any } = {}; + protected resource: Promise; + protected toDisposeOnWorkspaceLocationChanged: DisposableCollection = new DisposableCollection(); @inject(ResourceProvider) protected readonly resourceProvider: ResourceProvider; - @inject(MessageService) protected readonly messageService: MessageService; - - protected resource: Promise; + @inject(PreferenceSchemaProvider) protected readonly schemaProvider: PreferenceSchemaProvider; @postConstruct() protected async init(): Promise { @@ -54,14 +55,16 @@ export abstract class AbstractResourcePreferenceProvider extends PreferenceProvi const resource = await this.resource; this.toDispose.push(resource); if (resource.onDidChangeContents) { - this.toDispose.push(resource.onDidChangeContents(content => this.readPreferences())); + const onDidResourceChanged = resource.onDidChangeContents(() => this.readPreferences()); + this.toDisposeOnWorkspaceLocationChanged.pushAll([onDidResourceChanged, (await this.resource)]); + this.toDispose.push(onDidResourceChanged); } } - abstract getUri(): MaybePromise; + abstract getUri(root?: URI): MaybePromise; // tslint:disable-next-line:no-any - getPreferences(): { [key: string]: any } { + getPreferences(resourceUri?: string): { [key: string]: any } { return this.preferences; } @@ -72,7 +75,7 @@ export abstract class AbstractResourcePreferenceProvider extends PreferenceProvi const content = await this.readContents(); const formattingOptions = { tabSize: 3, insertSpaces: true, eol: '' }; try { - const edits = jsoncparser.modify(content, [key], value, { formattingOptions }); + const edits = jsoncparser.modify(content, this.getPath(key), value, { formattingOptions }); const result = jsoncparser.applyEdits(content, edits); await resource.saveContents(result); @@ -82,16 +85,26 @@ export abstract class AbstractResourcePreferenceProvider extends PreferenceProvi console.error(`${message} ${e.toString()}`); return; } + const oldValue = this.preferences[key]; + if (oldValue === value || oldValue !== undefined && value !== undefined // JSONExt.deepEqual() does not support handling `undefined` + && JSONExt.deepEqual(value, oldValue)) { + return; + } this.preferences[key] = value; - this.onDidPreferencesChangedEmitter.fire(undefined); + this.emitPreferencesChangedEvent([{ + preferenceName: key, newValue: value, oldValue, scope: this.getScope(), domain: this.getDomain() + }]); } } + protected getPath(preferenceName: string): string[] { + return [preferenceName]; + } + protected async readPreferences(): Promise { const newContent = await this.readContents(); - const strippedContent = jsoncparser.stripComments(newContent); - this.preferences = jsoncparser.parse(strippedContent) || {}; - this.onDidPreferencesChangedEmitter.fire(undefined); + const newPrefs = await this.getParsedContent(newContent); + await this.handlePreferenceChanges(newPrefs); } protected async readContents(): Promise { @@ -103,4 +116,64 @@ 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; + } + + // tslint:disable-next-line:no-any + protected async handlePreferenceChanges(newPrefs: { [key: string]: any }): Promise { + const oldPrefs = Object.assign({}, this.preferences); + this.preferences = newPrefs; + const prefNames = new Set([...Object.keys(oldPrefs), ...Object.keys(newPrefs)]); + const prefChanges: PreferenceProviderDataChange[] = []; + 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(', ')}.`); + continue; + } + } + + if (newValue === undefined && oldValue !== newValue + || oldValue === undefined && newValue !== oldValue // JSONExt.deepEqual() does not support handling `undefined` + || !JSONExt.deepEqual(oldValue, newValue)) { + prefChanges.push({ + preferenceName: prefName, newValue, oldValue, scope: this.getScope(), domain: this.getDomain() + }); + } + } + + if (prefChanges.length > 0) { // do not emit the change event if the pref value is not changed + this.emitPreferencesChangedEvent(prefChanges); + } + } + + dispose(): void { + const prefChanges: PreferenceProviderDataChange[] = []; + for (const prefName of Object.keys(this.preferences)) { + const value = this.preferences[prefName]; + if (value !== undefined || value !== null) { + prefChanges.push({ + preferenceName: prefName, newValue: undefined, oldValue: value, scope: this.getScope(), domain: this.getDomain() + }); + } + } + if (prefChanges.length > 0) { + this.emitPreferencesChangedEvent(prefChanges); + } + super.dispose(); + } } diff --git a/packages/preferences/src/browser/folder-preference-provider.ts b/packages/preferences/src/browser/folder-preference-provider.ts new file mode 100644 index 0000000000000..4f70f55af4ae2 --- /dev/null +++ b/packages/preferences/src/browser/folder-preference-provider.ts @@ -0,0 +1,73 @@ +/******************************************************************************** + * Copyright (C) 2019 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 } from 'inversify'; +import URI from '@theia/core/lib/common/uri'; +import { PreferenceScope, PreferenceProvider, PreferenceProviderPriority } from '@theia/core/lib/browser'; +import { AbstractResourcePreferenceProvider } from './abstract-resource-preference-provider'; +import { FileSystem, FileStat } from '@theia/filesystem/lib/common'; + +export const FolderPreferenceProviderFactory = Symbol('FolderPreferenceProviderFactory'); +export interface FolderPreferenceProviderFactory { + (options: FolderPreferenceProviderOptions): FolderPreferenceProvider; +} + +export const FolderPreferenceProviderOptions = Symbol('FolderPreferenceProviderOptions'); +export interface FolderPreferenceProviderOptions { + folder: FileStat; +} + +@injectable() +export class FolderPreferenceProvider extends AbstractResourcePreferenceProvider { + + private folderUri: URI | undefined; + + constructor( + @inject(FolderPreferenceProviderOptions) protected readonly options: FolderPreferenceProviderOptions, + @inject(FileSystem) protected readonly fileSystem: FileSystem + ) { + super(); + } + + get uri(): URI | undefined { + return this.folderUri; + } + + async getUri(): Promise { + this.folderUri = new URI(this.options.folder.uri); + if (await this.fileSystem.exists(this.folderUri.toString())) { + const uri = this.folderUri.resolve('.theia').resolve('settings.json'); + return uri; + } + } + + canProvide(preferenceName: string, resourceUri?: string): { priority: number, provider: PreferenceProvider } { + const value = this.get(preferenceName); + if (value === undefined || value === null || !resourceUri || !this.folderUri) { + return super.canProvide(preferenceName, resourceUri); + } + const uri = new URI(resourceUri); + return { priority: PreferenceProviderPriority.Folder + this.folderUri.path.relativity(uri.path), provider: this }; + } + + protected getScope() { + return PreferenceScope.Folder; + } + + getDomain(): string[] { + return this.folderUri ? [this.folderUri.toString()] : []; + } +} diff --git a/packages/preferences/src/browser/folders-preferences-provider.ts b/packages/preferences/src/browser/folders-preferences-provider.ts new file mode 100644 index 0000000000000..16ed7eed08ea3 --- /dev/null +++ b/packages/preferences/src/browser/folders-preferences-provider.ts @@ -0,0 +1,137 @@ +/******************************************************************************** + * Copyright (C) 2019 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 { PreferenceProvider } from '@theia/core/lib/browser'; +import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service'; +import { FolderPreferenceProvider, FolderPreferenceProviderFactory } from './folder-preference-provider'; +import { FileSystem, FileStat } from '@theia/filesystem/lib/common'; +import URI from '@theia/core/lib/common/uri'; + +@injectable() +export class FoldersPreferencesProvider extends PreferenceProvider { + + @inject(WorkspaceService) protected readonly workspaceService: WorkspaceService; + @inject(FileSystem) protected readonly fileSystem: FileSystem; + @inject(FolderPreferenceProviderFactory) protected readonly folderPreferenceProviderFactory: FolderPreferenceProviderFactory; + + private providers: FolderPreferenceProvider[] = []; + + @postConstruct() + protected async init(): Promise { + await this.workspaceService.roots; + if (this.workspaceService.saved) { + for (const root of this.workspaceService.tryGetRoots()) { + if (await this.fileSystem.exists(root.uri)) { + const provider = this.createFolderPreferenceProvider(root); + this.providers.push(provider); + } + } + } + + // Try to read the initial content of the preferences. The provider + // becomes ready even if we fail reading the preferences, so we don't + // hang the preference service. + Promise.all(this.providers.map(p => p.ready)) + .then(() => this._ready.resolve()) + .catch(() => this._ready.resolve()); + + this.workspaceService.onWorkspaceChanged(roots => { + for (const root of roots) { + if (!this.existsProvider(root.uri)) { + const provider = this.createFolderPreferenceProvider(root); + if (!this.existsProvider(root.uri)) { + this.providers.push(provider); + } else { + provider.dispose(); + } + } + } + + const numProviders = this.providers.length; + for (let ind = numProviders - 1; ind >= 0; ind--) { + const provider = this.providers[ind]; + if (roots.findIndex(r => !!provider.uri && r.uri === provider.uri.toString()) < 0) { + this.providers.splice(ind, 1); + provider.dispose(); + } + } + }); + } + + private existsProvider(folderUri: string): boolean { + return this.providers.findIndex(p => !!p.uri && p.uri.toString() === folderUri) >= 0; + } + + // tslint:disable-next-line:no-any + getPreferences(resourceUri?: string): { [p: string]: any } { + const numProviders = this.providers.length; + if (resourceUri && numProviders > 0) { + const provider = this.getProvider(resourceUri); + if (provider) { + return provider.getPreferences(); + } + } + return {}; + } + + canProvide(preferenceName: string, resourceUri?: string): { priority: number, provider: PreferenceProvider } { + if (resourceUri && this.providers.length > 0) { + const provider = this.getProvider(resourceUri); + if (provider) { + return { priority: provider.canProvide(preferenceName, resourceUri).priority, provider }; + } + } + return super.canProvide(preferenceName, resourceUri); + } + + protected getProvider(resourceUri: string): PreferenceProvider | undefined { + let provider: PreferenceProvider | undefined; + let relativity = Number.MAX_SAFE_INTEGER; + for (const p of this.providers) { + if (p.uri) { + const providerRelativity = p.uri.path.relativity(new URI(resourceUri).path); + if (providerRelativity >= 0 && providerRelativity <= relativity) { + relativity = providerRelativity; + provider = p; + } + } + } + return provider; + } + + protected createFolderPreferenceProvider(folder: FileStat): FolderPreferenceProvider { + const provider = this.folderPreferenceProviderFactory({ folder }); + this.toDispose.push(provider); + this.toDispose.push(provider.onDidPreferencesChanged(change => this.onDidPreferencesChangedEmitter.fire(change))); + return provider; + } + + // tslint:disable-next-line:no-any + async setPreference(key: string, value: any, resourceUri?: string): Promise { + if (resourceUri) { + for (const provider of this.providers) { + const providerResourceUri = await provider.getUri(); + if (providerResourceUri && providerResourceUri.toString() === resourceUri) { + return provider.setPreference(key, value); + } + } + console.error(`FoldersPreferencesProvider did not find the provider for ${resourceUri} to update the preference ${key}`); + } else { + console.error('FoldersPreferencesProvider requires resource URI to update preferences'); + } + } +} diff --git a/packages/preferences/src/browser/index.ts b/packages/preferences/src/browser/index.ts index ab2194253bca5..76ca1b008ea00 100644 --- a/packages/preferences/src/browser/index.ts +++ b/packages/preferences/src/browser/index.ts @@ -18,3 +18,5 @@ export * from '@theia/core/lib/browser/preferences'; export * from './abstract-resource-preference-provider'; export * from './user-preference-provider'; export * from './workspace-preference-provider'; +export * from './folders-preferences-provider'; +export * from './folder-preference-provider'; diff --git a/packages/preferences/src/browser/preference-editor-widget.ts b/packages/preferences/src/browser/preference-editor-widget.ts new file mode 100644 index 0000000000000..8b6ce46559126 --- /dev/null +++ b/packages/preferences/src/browser/preference-editor-widget.ts @@ -0,0 +1,141 @@ +/******************************************************************************** + * Copyright (C) 2019 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 { Title } from '@phosphor/widgets'; +import { AttachedProperty } from '@phosphor/properties'; +import { DockPanel, Menu, TabBar, Widget } from '@phosphor/widgets'; +import { CommandRegistry } from '@phosphor/commands'; +import { VirtualElement, h } from '@phosphor/virtualdom'; +import { PreferenceScope } from '@theia/core/lib/browser'; +import { EditorWidget } from '@theia/editor/lib/browser'; +import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service'; +import URI from '@theia/core/lib/common/uri'; +import { FileAccess, FileSystem } from '@theia/filesystem/lib/common'; + +export class PreferencesEditorWidgetTitle extends Title { + clickableText?: string; + clickableTextTooltip?: string; + clickableTextCallback?: (value: string) => void; +} + +export class PreferencesEditorWidget extends EditorWidget { + scope: PreferenceScope | undefined; + + get title(): PreferencesEditorWidgetTitle { + return new AttachedProperty({ + name: 'title', + create: owner => new PreferencesEditorWidgetTitle({ owner }), + }).get(this); + } +} + +export class PreferenceEditorTabHeaderRenderer extends TabBar.Renderer { + + constructor( + private readonly workspaceService: WorkspaceService, + private readonly fileSystem: FileSystem + ) { + super(); + } + + renderTab(data: TabBar.IRenderData): VirtualElement { + const title = data.title; + const key = this.createTabKey(data); + const style = this.createTabStyle(data); + const className = this.createTabClass(data); + return h.li({ + key, className, title: title.caption, style + }, + this.renderIcon(data), + this.renderLabel(data), + this.renderCloseIcon(data) + ); + } + + renderLabel(data: TabBar.IRenderData): VirtualElement { + const clickableTitle = data.title.owner.title; + if (clickableTitle.clickableText) { + return h.div( + h.span({ className: 'p-TabBar-tabLabel' }, data.title.label), + h.span({ + className: 'p-TabBar-tabLabel p-TabBar-tab-secondary-label', + title: clickableTitle.clickableTextTooltip, + onclick: event => { + const editorUri = data.title.owner.editor.uri; + this.refreshContextMenu(editorUri.parent.parent.toString(), clickableTitle.clickableTextCallback || (() => { })) + .then(menu => menu.open(event.x, event.y)); + } + }, clickableTitle.clickableText) + ); + } + return super.renderLabel(data); + } + + protected async refreshContextMenu(activeMenuId: string, menuItemAction: (value: string) => void): Promise { + const commands = new CommandRegistry(); + const menu = new Menu({ commands }); + const roots = this.workspaceService.tryGetRoots().map(r => r.uri); + for (const root of roots) { + if (await this.canAccessSettings(root)) { + const commandId = `switch_folder_pref_editor_to_${root}`; + if (!commands.hasCommand(commandId)) { + const rootUri = new URI(root); + const isActive = rootUri.toString() === activeMenuId; + commands.addCommand(commandId, { + label: rootUri.displayName, + iconClass: isActive ? 'fa fa-check' : '', + execute: () => { + if (!isActive) { + menuItemAction(root); + } + } + }); + } + + menu.addItem({ + type: 'command', + command: commandId + }); + } + } + return menu; + } + + private async canAccessSettings(folderUriStr: string): Promise { + const folderUri = new URI(folderUriStr); + const settingsUriStr = folderUri.resolve('.theia').resolve('settings.json').toString(); + if (await this.fileSystem.exists(settingsUriStr)) { + return this.fileSystem.access(settingsUriStr, FileAccess.Constants.R_OK); + } + return this.fileSystem.access(folderUriStr, FileAccess.Constants.W_OK); + } +} + +export class PreferenceEditorContainerTabBarRenderer extends DockPanel.Renderer { + + constructor( + private readonly workspaceService: WorkspaceService, + private readonly fileSystem: FileSystem + ) { + super(); + } + + createTabBar(): TabBar { + const bar = new TabBar({ renderer: new PreferenceEditorTabHeaderRenderer(this.workspaceService, this.fileSystem) }); + bar.addClass('p-DockPanel-tabBar'); + return bar; + } +} diff --git a/packages/preferences/src/browser/preference-frontend-module.ts b/packages/preferences/src/browser/preference-frontend-module.ts index 8015859dcbc29..ffc69bf4bb1f8 100644 --- a/packages/preferences/src/browser/preference-frontend-module.ts +++ b/packages/preferences/src/browser/preference-frontend-module.ts @@ -14,7 +14,7 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { ContainerModule, interfaces, } from 'inversify'; +import { Container, ContainerModule, interfaces } from 'inversify'; import { PreferenceProvider, PreferenceScope } from '@theia/core/lib/browser/preferences'; import { UserPreferenceProvider } from './user-preference-provider'; import { WorkspacePreferenceProvider } from './workspace-preference-provider'; @@ -22,8 +22,10 @@ import { bindViewContribution, WidgetFactory, FrontendApplicationContribution } import { PreferencesContribution } from './preferences-contribution'; import { createPreferencesTreeWidget } from './preference-tree-container'; import { PreferencesMenuFactory } from './preferences-menu-factory'; -import { PreferencesContainer, PreferencesEditorsContainer, PreferencesTreeWidget } from './preferences-tree-widget'; import { PreferencesFrontendApplicationContribution } from './preferences-frontend-application-contribution'; +import { PreferencesContainer, PreferencesTreeWidget, PreferencesEditorsContainer } from './preferences-tree-widget'; +import { FoldersPreferencesProvider } from './folders-preferences-provider'; +import { FolderPreferenceProvider, FolderPreferenceProviderFactory, FolderPreferenceProviderOptions } from './folder-preference-provider'; import './preferences-monaco-contribution'; @@ -32,6 +34,16 @@ export function bindPreferences(bind: interfaces.Bind, unbind: interfaces.Unbind bind(PreferenceProvider).to(UserPreferenceProvider).inSingletonScope().whenTargetNamed(PreferenceScope.User); bind(PreferenceProvider).to(WorkspacePreferenceProvider).inSingletonScope().whenTargetNamed(PreferenceScope.Workspace); + bind(PreferenceProvider).to(FoldersPreferencesProvider).inSingletonScope().whenTargetNamed(PreferenceScope.Folder); + bind(FolderPreferenceProvider).toSelf().inTransientScope(); + bind(FolderPreferenceProviderFactory).toFactory(ctx => + (options: FolderPreferenceProviderOptions) => { + const child = new Container({ defaultScope: 'Transient' }); + child.parent = ctx.container; + child.bind(FolderPreferenceProviderOptions).toConstantValue(options); + return child.get(FolderPreferenceProvider); + } + ); bindViewContribution(bind, PreferencesContribution); diff --git a/packages/preferences/src/browser/preference-service.spec.ts b/packages/preferences/src/browser/preference-service.spec.ts index 456186380a16c..9ed7308665d0f 100644 --- a/packages/preferences/src/browser/preference-service.spec.ts +++ b/packages/preferences/src/browser/preference-service.spec.ts @@ -14,6 +14,7 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ +// tslint:disable:no-any // tslint:disable:no-unused-expression import { enableJSDOM } from '@theia/core/lib/browser/test/jsdom'; @@ -26,8 +27,8 @@ import * as fs from 'fs-extra'; import * as temp from 'temp'; import { Emitter } from '@theia/core/lib/common'; import { - PreferenceService, PreferenceScope, - PreferenceProviderProvider, PreferenceServiceImpl, PreferenceProvider, bindPreferenceSchemaProvider + PreferenceService, PreferenceScope, PreferenceProviderDataChanges, + PreferenceSchemaProvider, PreferenceProviderProvider, PreferenceServiceImpl, bindPreferenceSchemaProvider } from '@theia/core/lib/browser/preferences'; import { FileSystem, FileShouldOverwrite, FileStat } from '@theia/filesystem/lib/common/'; import { FileSystemWatcher } from '@theia/filesystem/lib/browser/filesystem-watcher'; @@ -36,9 +37,12 @@ import { FileSystemPreferences, createFileSystemPreferences } from '@theia/files import { ILogger, MessageService, MessageClient } from '@theia/core'; import { UserPreferenceProvider } from './user-preference-provider'; import { WorkspacePreferenceProvider } from './workspace-preference-provider'; +import { FoldersPreferencesProvider, } from './folders-preferences-provider'; +import { FolderPreferenceProvider, FolderPreferenceProviderFactory, FolderPreferenceProviderOptions } from './folder-preference-provider'; import { ResourceProvider } from '@theia/core/lib/common/resource'; import { WorkspaceServer } from '@theia/workspace/lib/common/'; import { WindowService } from '@theia/core/lib/browser/window/window-service'; +import { MockPreferenceProvider } from '@theia/core/lib/browser/preferences/test'; import { MockFilesystem, MockFilesystemWatcherServer } from '@theia/filesystem/lib/common/test'; import { MockLogger } from '@theia/core/lib/common/test/mock-logger'; import { MockResourceProvider } from '@theia/core/lib/common/test/mock-resource-provider'; @@ -55,11 +59,11 @@ disableJSDOM(); const expect = chai.expect; let testContainer: Container; -let prefService: PreferenceService; const tempPath = temp.track().openSync().path; -const mockUserPreferenceEmitter = new Emitter(); -const mockWorkspacePreferenceEmitter = new Emitter(); +const mockUserPreferenceEmitter = new Emitter(); +const mockWorkspacePreferenceEmitter = new Emitter(); +const mockFolderPreferenceEmitter = new Emitter(); function testContainerSetup() { testContainer = new Container(); @@ -67,18 +71,42 @@ function testContainerSetup() { testContainer.bind(UserPreferenceProvider).toSelf().inSingletonScope(); testContainer.bind(WorkspacePreferenceProvider).toSelf().inSingletonScope(); + testContainer.bind(FoldersPreferencesProvider).toSelf().inSingletonScope(); + + testContainer.bind(FolderPreferenceProvider).toSelf().inSingletonScope(); + testContainer.bind(FolderPreferenceProviderOptions).toConstantValue({ folder: { uri: 'file:///home/oneFile', isDirectory: true, lastModification: 0 } }); + testContainer.bind(FolderPreferenceProviderFactory).toFactory(ctx => + (options: FolderPreferenceProviderOptions) => { + const child = new Container({ defaultScope: 'Transient' }); + child.parent = ctx.container; + child.bind(FolderPreferenceProviderOptions).toConstantValue(options); + return child.get(FolderPreferenceProvider); + } + ); testContainer.bind(PreferenceProviderProvider).toFactory(ctx => (scope: PreferenceScope) => { - const userProvider = ctx.container.get(UserPreferenceProvider); - const workspaceProvider = ctx.container.get(WorkspacePreferenceProvider); - - sinon.stub(userProvider, 'onDidPreferencesChanged').get(() => - mockUserPreferenceEmitter.event - ); - sinon.stub(workspaceProvider, 'onDidPreferencesChanged').get(() => - mockWorkspacePreferenceEmitter.event - ); - return scope === PreferenceScope.User ? userProvider : workspaceProvider; + switch (scope) { + case PreferenceScope.User: + const userProvider = ctx.container.get(UserPreferenceProvider); + sinon.stub(userProvider, 'onDidPreferencesChanged').get(() => + mockUserPreferenceEmitter.event + ); + return userProvider; + case PreferenceScope.Workspace: + const workspaceProvider = ctx.container.get(WorkspacePreferenceProvider); + sinon.stub(workspaceProvider, 'onDidPreferencesChanged').get(() => + mockWorkspacePreferenceEmitter.event + ); + return workspaceProvider; + case PreferenceScope.Folder: + const folderProvider = ctx.container.get(FoldersPreferencesProvider); + sinon.stub(folderProvider, 'onDidPreferencesChanged').get(() => + mockFolderPreferenceEmitter.event + ); + return folderProvider; + default: + return ctx.container.get(PreferenceSchemaProvider); + } }); testContainer.bind(PreferenceServiceImpl).toSelf().inSingletonScope(); @@ -137,7 +165,10 @@ function testContainerSetup() { testContainer.bind(MessageClient).toSelf().inSingletonScope(); } -describe('Preference Service', function () { +describe('Preference Service', () => { + let prefService: PreferenceService; + let prefSchema: PreferenceSchemaProvider; + const stubs: sinon.SinonStub[] = []; before(() => { disableJSDOM = enableJSDOM(); @@ -152,6 +183,7 @@ describe('Preference Service', function () { }); beforeEach(() => { + prefSchema = testContainer.get(PreferenceSchemaProvider); prefService = testContainer.get(PreferenceService); const impl = testContainer.get(PreferenceServiceImpl); impl.initialize(); @@ -159,186 +191,231 @@ describe('Preference Service', function () { afterEach(() => { prefService.dispose(); + stubs.forEach(s => s.restore()); + stubs.length = 0; }); - it('Should get notified if a provider gets a change', function (done) { - - const prefValue = true; + it('should get notified if a provider emits a change', done => { + const userProvider = testContainer.get(UserPreferenceProvider); + stubs.push(sinon.stub(userProvider, 'getPreferences').returns({ + testPref: 'oldVal' + })); prefService.onPreferenceChanged(pref => { - try { + if (pref) { expect(pref.preferenceName).eq('testPref'); - } catch (e) { - stubGet.restore(); - done(e); - return; + expect(pref.newValue).eq('newVal'); + return done(); } - expect(pref.newValue).eq(prefValue); - stubGet.restore(); - done(); + return done(new Error('onPreferenceChanged() fails to return any preference change infomation')); }); - - const userProvider = testContainer.get(UserPreferenceProvider); - const stubGet = sinon.stub(userProvider, 'getPreferences').returns({ - 'testPref': prefValue + stubs.push(sinon.stub(prefSchema, 'isValidInScope').returns(true)); + mockUserPreferenceEmitter.fire({ + testPref: { + preferenceName: 'testPref', + newValue: 'newVal', + oldValue: 'oldVal', + scope: PreferenceScope.User, + domain: [] + } }); - - mockUserPreferenceEmitter.fire(undefined); - }).timeout(2000); - it('Should return the preference from the more specific scope (user > workspace)', () => { + it('should return the preference from the more specific scope (user > workspace)', () => { const userProvider = testContainer.get(UserPreferenceProvider); const workspaceProvider = testContainer.get(WorkspacePreferenceProvider); - const stubUser = sinon.stub(userProvider, 'getPreferences').returns({ + stubs.push(sinon.stub(userProvider, 'getPreferences').returns({ 'test.boolean': true, 'test.number': 1 - }); - const stubWorkspace = sinon.stub(workspaceProvider, 'getPreferences').returns({ + })); + stubs.push(sinon.stub(workspaceProvider, 'getPreferences').returns({ 'test.boolean': false, 'test.number': 0 - }); - mockUserPreferenceEmitter.fire(undefined); - - let value = prefService.get('test.boolean'); - expect(value).to.be.false; - - value = prefService.get('test.number'); - expect(value).equals(0); + })); + stubs.push(sinon.stub(prefSchema, 'isValidInScope').returns(true)); + expect(prefService.get('test.boolean')).to.be.false; + expect(prefService.get('test.number')).equals(0); + }); - [stubUser, stubWorkspace].forEach(stub => { - stub.restore(); - }); + it('should return the preference from the more specific scope (folders > workspace)', () => { + const userProvider = testContainer.get(UserPreferenceProvider); + const workspaceProvider = testContainer.get(WorkspacePreferenceProvider); + const foldersProvider = testContainer.get(FoldersPreferencesProvider); + const oneFolderProvider = testContainer.get(FolderPreferenceProvider); + stubs.push(sinon.stub(userProvider, 'getPreferences').returns({ + 'test.string': 'userValue', + 'test.number': 1 + })); + stubs.push(sinon.stub(workspaceProvider, 'getPreferences').returns({ + 'test.string': 'wsValue', + 'test.number': 0 + })); + stubs.push(sinon.stub(foldersProvider, 'canProvide').returns({ priority: 10, provider: oneFolderProvider })); + stubs.push(sinon.stub(foldersProvider, 'getPreferences').returns({ + 'test.string': 'folderValue', + 'test.number': 20 + })); + stubs.push(sinon.stub(prefSchema, 'isValidInScope').returns(true)); + expect(prefService.get('test.string')).equals('folderValue'); + expect(prefService.get('test.number')).equals(20); }); - it('Should return the preference from the less specific scope if the value is removed from the more specific one', () => { + it('should return the preference from the less specific scope if the value is removed from the more specific one', () => { const userProvider = testContainer.get(UserPreferenceProvider); const workspaceProvider = testContainer.get(WorkspacePreferenceProvider); - const stubUser = sinon.stub(userProvider, 'getPreferences').returns({ + stubs.push(sinon.stub(userProvider, 'getPreferences').returns({ 'test.boolean': true, 'test.number': 1 - }); + })); const stubWorkspace = sinon.stub(workspaceProvider, 'getPreferences').returns({ 'test.boolean': false, 'test.number': 0 }); - mockUserPreferenceEmitter.fire(undefined); - - let value = prefService.get('test.boolean'); - expect(value).to.be.false; + stubs.push(sinon.stub(prefSchema, 'isValidInScope').returns(true)); + expect(prefService.get('test.boolean')).to.be.false; stubWorkspace.restore(); - mockUserPreferenceEmitter.fire(undefined); - - value = prefService.get('test.boolean'); - expect(value).to.be.true; - - stubUser.restore(); + stubs.push(sinon.stub(workspaceProvider, 'getPreferences').returns({})); + expect(prefService.get('test.boolean')).to.be.true; }); - it('Should throw a TypeError if the preference (reference object) is modified', () => { + it('should throw a TypeError if the preference (reference object) is modified', () => { const userProvider = testContainer.get(UserPreferenceProvider); - const stubUser = sinon.stub(userProvider, 'getPreferences').returns({ + stubs.push(sinon.stub(userProvider, 'getPreferences').returns({ 'test.immutable': [ 'test', 'test', 'test' ] - }); - mockUserPreferenceEmitter.fire(undefined); - + })); + stubs.push(sinon.stub(prefSchema, 'isValidInScope').returns(true)); const immutablePref: string[] | undefined = prefService.get('test.immutable'); expect(immutablePref).to.not.be.undefined; if (immutablePref !== undefined) { - expect(() => { - immutablePref.push('fails'); - }).to.throw(TypeError); + expect(() => immutablePref.push('fails')).to.throw(TypeError); } - stubUser.restore(); }); - it('Should still report the more specific preference even though the less specific one changed', () => { + it('should still report the more specific preference even though the less specific one changed', () => { const userProvider = testContainer.get(UserPreferenceProvider); const workspaceProvider = testContainer.get(WorkspacePreferenceProvider); - let stubUser = sinon.stub(userProvider, 'getPreferences').returns({ + const stubUser = sinon.stub(userProvider, 'getPreferences').returns({ 'test.boolean': true, 'test.number': 1 }); - const stubWorkspace = sinon.stub(workspaceProvider, 'getPreferences').returns({ + stubs.push(sinon.stub(workspaceProvider, 'getPreferences').returns({ 'test.boolean': false, 'test.number': 0 + })); + mockUserPreferenceEmitter.fire({ + 'test.number': { + preferenceName: 'test.number', + newValue: 2, + scope: PreferenceScope.User, + domain: [] + } }); - mockUserPreferenceEmitter.fire(undefined); + stubs.push(sinon.stub(prefSchema, 'isValidInScope').returns(true)); + expect(prefService.get('test.number')).equals(0); - let value = prefService.get('test.number'); - expect(value).equals(0); stubUser.restore(); - - stubUser = sinon.stub(userProvider, 'getPreferences').returns({ + stubs.push(sinon.stub(userProvider, 'getPreferences').returns({ 'test.boolean': true, 'test.number': 4 + })); + mockUserPreferenceEmitter.fire({ + 'test.number': { + preferenceName: 'test.number', + newValue: 4, + scope: PreferenceScope.User, + domain: [] + } }); - mockUserPreferenceEmitter.fire(undefined); - - value = prefService.get('test.number'); - expect(value).equals(0); - - [stubUser, stubWorkspace].forEach(stub => { - stub.restore(); - }); + expect(prefService.get('test.number')).equals(0); }); - it('Should store preference when settings file is empty', async () => { + it('should store preference when settings file is empty', async () => { const settings = '{\n "key": "value"\n}'; await prefService.set('key', 'value', PreferenceScope.User); expect(fs.readFileSync(tempPath).toString()).equals(settings); }); - it('Should store preference when settings file is not empty', async () => { + it('should store preference when settings file is not empty', async () => { const settings = '{\n "key": "value",\n "newKey": "newValue"\n}'; fs.writeFileSync(tempPath, '{\n "key": "value"\n}'); await prefService.set('newKey', 'newValue', PreferenceScope.User); expect(fs.readFileSync(tempPath).toString()).equals(settings); }); - it('Should override existing preference', async () => { + it('should override existing preference', async () => { const settings = '{\n "key": "newValue"\n}'; fs.writeFileSync(tempPath, '{\n "key": "oldValue"\n}'); await prefService.set('key', 'newValue', PreferenceScope.User); expect(fs.readFileSync(tempPath).toString()).equals(settings); }); + /** + * A slow provider that becomes ready after 1 second. + */ + class SlowProvider extends MockPreferenceProvider { + constructor() { + super(); + setTimeout(() => { + this.prefs['mypref'] = 2; + this._ready.resolve(); + }, 1000); + } + } + /** + * Default provider that becomes ready after constructor gets called + */ + class MockDefaultProvider extends MockPreferenceProvider { + constructor() { + super(); + this.prefs['mypref'] = 5; + this._ready.resolve(); + } + } + /** * Make sure that the preference service is ready only once the providers * are ready to provide preferences. */ - it('Should be ready only when all providers are ready', async () => { - /** - * A slow provider that becomes ready after 1 second. - */ - class SlowProvider extends PreferenceProvider { - // tslint:disable-next-line:no-any - readonly prefs: { [p: string]: any } = {}; - - constructor() { - super(); - setTimeout(() => { - this.prefs['mypref'] = 2; - this._ready.resolve(); - }, 1000); + it('should be ready only when all providers are ready', async () => { + const container = new Container(); + bindPreferenceSchemaProvider(container.bind.bind(container)); + container.bind(ILogger).to(MockLogger); + container.bind(PreferenceProviderProvider).toFactory(ctx => (scope: PreferenceScope) => { + if (scope === PreferenceScope.User) { + return new MockDefaultProvider(); } + return new SlowProvider(); + }); + container.bind(PreferenceServiceImpl).toSelf().inSingletonScope(); - getPreferences() { - return this.prefs; - } - } + const service = container.get(PreferenceServiceImpl); + service.initialize(); + prefSchema = container.get(PreferenceSchemaProvider); + await service.ready; + stubs.push(sinon.stub(PreferenceServiceImpl, 'doSetProvider').callsFake(() => { })); + stubs.push(sinon.stub(prefSchema, 'isValidInScope').returns(true)); + expect(service.get('mypref')).to.equal(2); + }); + it('should answer queries before all providers are ready', async () => { const container = new Container(); bindPreferenceSchemaProvider(container.bind.bind(container)); - container.bind(PreferenceProviderProvider).toFactory(ctx => (scope: PreferenceScope) => new SlowProvider()); + container.bind(ILogger).to(MockLogger); + container.bind(PreferenceProviderProvider).toFactory(ctx => (scope: PreferenceScope) => { + if (scope === PreferenceScope.User) { + return new MockDefaultProvider(); + } + return new SlowProvider(); + }); container.bind(PreferenceServiceImpl).toSelf().inSingletonScope(); const service = container.get(PreferenceServiceImpl); service.initialize(); - await service.ready; - const n = service.getNumber('mypref'); - expect(n).to.equal(2); + prefSchema = container.get(PreferenceSchemaProvider); + stubs.push(sinon.stub(PreferenceServiceImpl, 'doSetProvider').callsFake(() => { })); + stubs.push(sinon.stub(prefSchema, 'isValidInScope').returns(true)); + expect(service.get('mypref')).to.equal(5); }); }); diff --git a/packages/preferences/src/browser/preferences-decorator.ts b/packages/preferences/src/browser/preferences-decorator.ts index 718d673ab155f..f09f50a77a25f 100644 --- a/packages/preferences/src/browser/preferences-decorator.ts +++ b/packages/preferences/src/browser/preferences-decorator.ts @@ -15,7 +15,7 @@ ********************************************************************************/ import { inject, injectable } from 'inversify'; -import { Tree, TreeDecorator, TreeDecoration, PreferenceProperty, PreferenceService } from '@theia/core/lib/browser'; +import { Tree, TreeDecorator, TreeDecoration, PreferenceDataProperty, PreferenceService } from '@theia/core/lib/browser'; import { Emitter, Event, MaybePromise } from '@theia/core'; import { escapeInvisibleChars } from '@theia/core/lib/common/strings'; @@ -23,7 +23,9 @@ import { escapeInvisibleChars } from '@theia/core/lib/common/strings'; export class PreferencesDecorator implements TreeDecorator { readonly id: string = 'theia-preferences-decorator'; - protected preferences: { [id: string]: PreferenceProperty }[]; + private activeFolderUri: string | undefined; + + protected preferences: { [id: string]: PreferenceDataProperty }[]; protected preferencesDecorations: Map; protected readonly emitter: Emitter<(tree: Tree) => Map> = new Emitter(); @@ -39,14 +41,14 @@ export class PreferencesDecorator implements TreeDecorator { return this.emitter.event; } - fireDidChangeDecorations(preferences: {[id: string]: PreferenceProperty}[]): void { + fireDidChangeDecorations(preferences: { [id: string]: PreferenceDataProperty }[]): void { if (!this.preferences) { this.preferences = preferences; } this.preferencesDecorations = new Map(preferences.map(m => { const preferenceName = Object.keys(m)[0]; const preferenceValue = m[preferenceName]; - const storedValue = this.preferencesService.get(preferenceName); + const storedValue = this.preferencesService.get(preferenceName, undefined, this.activeFolderUri); return [preferenceName, { tooltip: preferenceValue.description, captionSuffixes: [ @@ -56,7 +58,7 @@ export class PreferencesDecorator implements TreeDecorator { }, { data: ' ' + preferenceValue.description, - fontData: {color: 'var(--theia-ui-font-color2)'} + fontData: { color: 'var(--theia-ui-font-color2)' } }] }] as [string, TreeDecoration.Data]; })); @@ -67,4 +69,8 @@ export class PreferencesDecorator implements TreeDecorator { return this.preferencesDecorations; } + setActiveFolder(folder: string) { + this.activeFolderUri = folder; + this.fireDidChangeDecorations(this.preferences); + } } diff --git a/packages/preferences/src/browser/preferences-frontend-application-contribution.ts b/packages/preferences/src/browser/preferences-frontend-application-contribution.ts index eedb0f48a87f4..91da9b3e14835 100644 --- a/packages/preferences/src/browser/preferences-frontend-application-contribution.ts +++ b/packages/preferences/src/browser/preferences-frontend-application-contribution.ts @@ -29,17 +29,15 @@ export class PreferencesFrontendApplicationContribution implements FrontendAppli @inject(InMemoryResources) inmemoryResources: InMemoryResources; onStart() { - this.schemaProvider.ready.then(async () => { - const serializeSchema = () => JSON.stringify(this.schemaProvider.getCombinedSchema()); - const uri = new URI('vscode://schemas/settings/user'); - this.inmemoryResources.add(uri, serializeSchema()); - this.jsonSchemaStore.registerSchema({ - fileMatch: ['.theia/settings.json', USER_PREFERENCE_URI.toString()], - url: uri.toString() - }); - this.schemaProvider.onDidPreferencesChanged(() => - this.inmemoryResources.update(uri, serializeSchema()) - ); + const serializeSchema = () => JSON.stringify(this.schemaProvider.getCombinedSchema()); + const uri = new URI('vscode://schemas/settings/user'); + this.inmemoryResources.add(uri, serializeSchema()); + this.jsonSchemaStore.registerSchema({ + fileMatch: ['.theia/settings.json', USER_PREFERENCE_URI.toString()], + url: uri.toString() }); + this.schemaProvider.onDidPreferencesChanged(() => + this.inmemoryResources.update(uri, serializeSchema()) + ); } } diff --git a/packages/preferences/src/browser/preferences-menu-factory.ts b/packages/preferences/src/browser/preferences-menu-factory.ts index a3bb00c37c702..dc1fb1da4915d 100644 --- a/packages/preferences/src/browser/preferences-menu-factory.ts +++ b/packages/preferences/src/browser/preferences-menu-factory.ts @@ -17,34 +17,34 @@ import { injectable } from 'inversify'; import { Menu } from '@phosphor/widgets'; import { CommandRegistry } from '@phosphor/commands'; -import { PreferenceProperty } from '@theia/core/lib/browser'; import { escapeInvisibleChars, unescapeInvisibleChars } from '@theia/core/lib/common/strings'; +import { PreferenceDataProperty } from '@theia/core/lib/browser'; @injectable() export class PreferencesMenuFactory { // tslint:disable-next-line:no-any - createPreferenceContextMenu(id: string, savedPreference: any, property: PreferenceProperty, execute: (property: string, value: any) => void): Menu { + createPreferenceContextMenu(id: string, savedPreference: any, property: PreferenceDataProperty, execute: (property: string, value: any) => void): Menu { const commands = new CommandRegistry(); const menu = new Menu({ commands }); if (property) { const enumConst = property.enum; if (enumConst) { enumConst.map(escapeInvisibleChars) - .forEach(enumValue => { - const commandId = id + '-' + enumValue; - if (!commands.hasCommand(commandId)) { - commands.addCommand(commandId, { - label: enumValue, - iconClass: escapeInvisibleChars(savedPreference) === enumValue || !savedPreference && property.default === enumValue ? 'fa fa-check' : '', - execute: () => execute(id, unescapeInvisibleChars(enumValue)) - }); - menu.addItem({ - type: 'command', - command: commandId - }); - } - }); + .forEach(enumValue => { + const commandId = id + '-' + enumValue; + if (!commands.hasCommand(commandId)) { + commands.addCommand(commandId, { + label: enumValue, + iconClass: escapeInvisibleChars(savedPreference) === enumValue || !savedPreference && property.default === enumValue ? 'fa fa-check' : '', + execute: () => execute(id, unescapeInvisibleChars(enumValue)) + }); + menu.addItem({ + type: 'command', + command: commandId + }); + } + }); } else if (property.type && property.type === 'boolean') { const commandTrue = id + '-true'; commands.addCommand(commandTrue, { diff --git a/packages/preferences/src/browser/preferences-tree-widget.ts b/packages/preferences/src/browser/preferences-tree-widget.ts index b535c38f673c5..74c6cecc99c1f 100644 --- a/packages/preferences/src/browser/preferences-tree-widget.ts +++ b/packages/preferences/src/browser/preferences-tree-widget.ts @@ -24,7 +24,7 @@ import { ApplicationShell, ContextMenuRenderer, ExpandableTreeNode, - PreferenceProperty, + PreferenceDataProperty, PreferenceSchemaProvider, PreferenceScope, PreferenceService, @@ -39,17 +39,16 @@ import { } from '@theia/core/lib/browser'; import { UserPreferenceProvider } from './user-preference-provider'; import { WorkspacePreferenceProvider } from './workspace-preference-provider'; +import { PreferencesEditorWidget, PreferenceEditorContainerTabBarRenderer } from './preference-editor-widget'; +import { EditorWidget, EditorManager } from '@theia/editor/lib/browser'; +import { JSONC_LANGUAGE_ID } from '@theia/json/lib/common'; import { DisposableCollection, Emitter, Event, MessageService } from '@theia/core'; import { Deferred } from '@theia/core/lib/common/promise-util'; -import { EditorWidget, EditorManager } from '@theia/editor/lib/browser'; import { FileSystem, FileSystemUtils } from '@theia/filesystem/lib/common'; import { UserStorageUri, THEIA_USER_STORAGE_FOLDER } from '@theia/userstorage/lib/browser'; +import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service'; import URI from '@theia/core/lib/common/uri'; -export interface PreferencesEditorWidget extends EditorWidget { - scope?: PreferenceScope; -} - @injectable() export class PreferencesContainer extends SplitPanel implements ApplicationShell.TrackableWidgetProvider, Saveable { @@ -57,13 +56,16 @@ export class PreferencesContainer extends SplitPanel implements ApplicationShell protected treeWidget: PreferencesTreeWidget | undefined; protected editorsContainer: PreferencesEditorsContainer; - private currentEditor: EditorWidget | undefined; - private readonly editors: EditorWidget[] = []; - private deferredEditors = new Deferred(); + private currentEditor: PreferencesEditorWidget | undefined; + private readonly editors: PreferencesEditorWidget[] = []; + private deferredEditors = new Deferred(); protected readonly onDirtyChangedEmitter = new Emitter(); readonly onDirtyChanged: Event = this.onDirtyChangedEmitter.event; + protected readonly onDidChangeTrackableWidgetsEmitter = new Emitter(); + readonly onDidChangeTrackableWidgets = this.onDidChangeTrackableWidgetsEmitter.event; + protected readonly toDispose = new DisposableCollection(); @inject(WidgetManager) @@ -78,6 +80,9 @@ export class PreferencesContainer extends SplitPanel implements ApplicationShell @inject(PreferenceService) protected readonly preferenceService: PreferenceService; + @inject(WorkspaceService) + protected readonly workspaceService: WorkspaceService; + protected _preferenceScope: PreferenceScope = PreferenceScope.User; @postConstruct() @@ -88,7 +93,7 @@ export class PreferencesContainer extends SplitPanel implements ApplicationShell this.title.closable = true; this.title.iconClass = 'fa fa-sliders'; - this.toDispose.push(this.onDirtyChangedEmitter); + this.toDispose.pushAll([this.onDirtyChangedEmitter, this.onDidChangeTrackableWidgetsEmitter]); } dispose(): void { @@ -135,34 +140,43 @@ export class PreferencesContainer extends SplitPanel implements ApplicationShell if (this.dirty) { this.messageService.warn('Preferences editor(s) has/have unsaved changes'); } else if (this.currentEditor) { - this.preferenceService.set(preferenceName, - preferenceValue, - this.currentEditor.title.label === 'User Preferences' - ? PreferenceScope.User - : PreferenceScope.Workspace); + this.preferenceService.set(preferenceName, preferenceValue, this.currentEditor.scope, this.currentEditor.editor.uri.toString()); } }); this.editorsContainer = await this.widgetManager.getOrCreateWidget(PreferencesEditorsContainer.ID); this.toDispose.push(this.editorsContainer); this.editorsContainer.activatePreferenceEditor(this.preferenceScope); - this.editorsContainer.onInit(() => { - toArray(this.editorsContainer.widgets()).forEach(editor => { - const editorWidget = editor as EditorWidget; - this.editors.push(editorWidget); - const savable = editorWidget.saveable; - savable.onDirtyChanged(() => { - this.onDirtyChangedEmitter.fire(undefined); - }); - }); + this.toDispose.push(this.editorsContainer.onInit(() => { + this.handleEditorsChanged(); this.deferredEditors.resolve(this.editors); - }); - this.editorsContainer.onEditorChanged(editor => { + })); + this.toDispose.push(this.editorsContainer.onEditorChanged(editor => { if (this.currentEditor && this.currentEditor.editor.uri.toString() !== editor.editor.uri.toString()) { this.currentEditor.saveable.save(); } + if (editor) { + this.preferenceScope = editor.scope || PreferenceScope.User; + } else { + this.preferenceScope = PreferenceScope.User; + } this.currentEditor = editor; - }); + })); + this.toDispose.push(this.editorsContainer.onFolderPreferenceEditorUriChanged(uriStr => { + if (this.treeWidget) { + this.treeWidget.setActiveFolder(uriStr); + } + this.handleEditorsChanged(); + })); + this.toDispose.push(this.workspaceService.onWorkspaceLocationChanged(async workspaceFile => { + await this.editorsContainer.refreshWorkspacePreferenceEditor(); + await this.refreshFoldersPreferencesEditor(); + this.activatePreferenceEditor(this.preferenceScope); + this.handleEditorsChanged(); + })); + this.toDispose.push(this.workspaceService.onWorkspaceChanged(async roots => { + await this.refreshFoldersPreferencesEditor(); + })); const treePanel = new BoxPanel(); treePanel.addWidget(this.treeWidget); @@ -188,11 +202,43 @@ export class PreferencesContainer extends SplitPanel implements ApplicationShell this.dispose(); } - public activatePreferenceEditor(preferenceScope: PreferenceScope) { + public activatePreferenceEditor(preferenceScope: PreferenceScope): void { + this.preferenceScope = preferenceScope; if (this.editorsContainer) { this.editorsContainer.activatePreferenceEditor(preferenceScope); } } + + protected handleEditorsChanged(): void { + const currentEditors = toArray(this.editorsContainer.widgets()); + currentEditors.forEach(editor => { + if (editor instanceof EditorWidget && this.editors.findIndex(e => e === editor) < 0) { + const editorWidget = editor as PreferencesEditorWidget; + this.editors.push(editorWidget); + const savable = editorWidget.saveable; + savable.onDirtyChanged(() => { + this.onDirtyChangedEmitter.fire(undefined); + }); + } + }); + for (let i = this.editors.length - 1; i >= 0; i--) { + if (currentEditors.findIndex(e => e === this.editors[i]) < 0) { + this.editors.splice(i, 1); + } + } + this.onDidChangeTrackableWidgetsEmitter.fire(this.editors); + this.activatePreferenceEditor(this.preferenceScope); + } + + private async refreshFoldersPreferencesEditor(): Promise { + const roots = this.workspaceService.tryGetRoots(); + if (roots.length === 0) { + this.editorsContainer.closeFoldersPreferenceEditorWidget(); + } else if (!roots.some(r => r.uri === this.editorsContainer.activeFolder)) { + const firstRoot = roots[0]; + await this.editorsContainer.refreshFoldersPreferencesEditorWidget(firstRoot ? firstRoot.uri : undefined); + } + } } @injectable() @@ -200,9 +246,6 @@ export class PreferencesEditorsContainer extends DockPanel { static ID = 'preferences_editors_container'; - @inject(FileSystem) - protected readonly fileSystem: FileSystem; - @inject(EditorManager) protected readonly editorManager: EditorManager; @@ -212,19 +255,31 @@ export class PreferencesEditorsContainer extends DockPanel { @inject(PreferenceProvider) @named(PreferenceScope.Workspace) protected readonly workspacePreferenceProvider: WorkspacePreferenceProvider; - private preferenceScope: PreferenceScope; + private userPreferenceEditorWidget: PreferencesEditorWidget; + private workspacePreferenceEditorWidget: PreferencesEditorWidget | undefined; + private foldersPreferenceEditorWidget: PreferencesEditorWidget | undefined; private readonly onInitEmitter = new Emitter(); readonly onInit: Event = this.onInitEmitter.event; - private readonly onEditorChangedEmitter = new Emitter(); - readonly onEditorChanged: Event = this.onEditorChangedEmitter.event; + private readonly onEditorChangedEmitter = new Emitter(); + readonly onEditorChanged: Event = this.onEditorChangedEmitter.event; + + private readonly onFolderPreferenceEditorUriChangedEmitter = new Emitter(); + readonly onFolderPreferenceEditorUriChanged: Event = this.onFolderPreferenceEditorUriChangedEmitter.event; protected readonly toDispose = new DisposableCollection( this.onEditorChangedEmitter, this.onInitEmitter ); + constructor( + @inject(WorkspaceService) protected readonly workspaceService: WorkspaceService, + @inject(FileSystem) protected readonly fileSystem: FileSystem + ) { + super({ renderer: new PreferenceEditorContainerTabBarRenderer(workspaceService, fileSystem) }); + } + dispose(): void { this.toDispose.dispose(); super.dispose(); @@ -236,36 +291,119 @@ export class PreferencesEditorsContainer extends DockPanel { } onUpdateRequest(msg: Message) { - this.onEditorChangedEmitter.fire(this.selectedWidgets().next() as EditorWidget); - + const editor = this.selectedWidgets().next(); + if (editor) { + this.onEditorChangedEmitter.fire(editor); + } super.onUpdateRequest(msg); } protected async onAfterAttach(msg: Message): Promise { + this.userPreferenceEditorWidget = await this.getUserPreferenceEditorWidget(); + this.addWidget(this.userPreferenceEditorWidget); + await this.refreshWorkspacePreferenceEditor(); + await this.refreshFoldersPreferencesEditorWidget(undefined); + + super.onAfterAttach(msg); + this.onInitEmitter.fire(undefined); + } + + protected async getUserPreferenceEditorWidget(): Promise { const userPreferenceUri = this.userPreferenceProvider.getUri(); const userPreferences = await this.editorManager.getOrCreateByUri(userPreferenceUri) as PreferencesEditorWidget; - userPreferences.title.label = 'User Preferences'; - userPreferences.title.caption = await this.getPreferenceEditorCaption(userPreferenceUri); + userPreferences.title.label = 'User'; + userPreferences.title.caption = `User Preferences: ${await this.getPreferenceEditorCaption(userPreferenceUri)}`; userPreferences.scope = PreferenceScope.User; - this.addWidget(userPreferences); + return userPreferences; + } + async refreshWorkspacePreferenceEditor(): Promise { + const newWorkspacePreferenceEditorWidget = await this.getWorkspacePreferenceEditorWidget(); + if (newWorkspacePreferenceEditorWidget) { + this.addWidget(newWorkspacePreferenceEditorWidget, + { ref: this.workspacePreferenceEditorWidget || this.userPreferenceEditorWidget }); + if (this.workspacePreferenceEditorWidget) { + this.workspacePreferenceEditorWidget.close(); + this.workspacePreferenceEditorWidget.dispose(); + } + this.workspacePreferenceEditorWidget = newWorkspacePreferenceEditorWidget; + } + } + + protected async getWorkspacePreferenceEditorWidget(): Promise { const workspacePreferenceUri = await this.workspacePreferenceProvider.getUri(); const workspacePreferences = workspacePreferenceUri && await this.editorManager.getOrCreateByUri(workspacePreferenceUri) as PreferencesEditorWidget; if (workspacePreferences) { - workspacePreferences.title.label = 'Workspace Preferences'; - workspacePreferences.title.caption = await this.getPreferenceEditorCaption(workspacePreferenceUri!); + workspacePreferences.title.label = 'Workspace'; + workspacePreferences.title.caption = `Workspace Preferences: ${await this.getPreferenceEditorCaption(workspacePreferenceUri!)}`; + workspacePreferences.title.iconClass = 'database-icon medium-yellow file-icon'; + workspacePreferences.editor.setLanguage(JSONC_LANGUAGE_ID); workspacePreferences.scope = PreferenceScope.Workspace; - this.addWidget(workspacePreferences); } + return workspacePreferences; + } - this.activatePreferenceEditor(this.preferenceScope); - super.onAfterAttach(msg); - this.onInitEmitter.fire(undefined); + get activeFolder(): string | undefined { + if (this.foldersPreferenceEditorWidget) { + return this.foldersPreferenceEditorWidget.editor.uri.parent.parent.toString(); + } + } + + async refreshFoldersPreferencesEditorWidget(currentFolder: string | undefined): Promise { + const folders = this.workspaceService.tryGetRoots().map(r => r.uri); + const newFolderUri = currentFolder || folders[0]; + const newFoldersPreferenceEditorWidget = await this.getFoldersPreferencesEditor(newFolderUri); + if (newFoldersPreferenceEditorWidget && // new widget is created + // the FolderPreferencesEditor is not available, OR the existing FolderPreferencesEditor is displaying the content of a different file + (!this.foldersPreferenceEditorWidget || this.foldersPreferenceEditorWidget.editor.uri.parent.parent.toString() !== newFolderUri)) { + this.addWidget(newFoldersPreferenceEditorWidget, + { ref: this.foldersPreferenceEditorWidget || this.workspacePreferenceEditorWidget || this.userPreferenceEditorWidget }); + this.closeFoldersPreferenceEditorWidget(); + this.foldersPreferenceEditorWidget = newFoldersPreferenceEditorWidget; + this.onFolderPreferenceEditorUriChangedEmitter.fire(newFoldersPreferenceEditorWidget.editor.uri.toString()); + } + } + + closeFoldersPreferenceEditorWidget(): void { + if (this.foldersPreferenceEditorWidget) { + this.foldersPreferenceEditorWidget.close(); + this.foldersPreferenceEditorWidget.dispose(); + this.foldersPreferenceEditorWidget = undefined; + } + } + + protected async getFoldersPreferencesEditor(folder: string | undefined): Promise { + if (this.workspaceService.saved) { + const settingsUri = await this.getFolderSettingsUri(folder); + const foldersPreferences = settingsUri && await this.editorManager.getOrCreateByUri(settingsUri) as PreferencesEditorWidget; + if (foldersPreferences) { + foldersPreferences.title.label = 'Folder'; + foldersPreferences.title.caption = `Folder Preferences: ${await this.getPreferenceEditorCaption(settingsUri!)}`; + foldersPreferences.title.clickableText = new URI(folder).displayName; + foldersPreferences.title.clickableTextTooltip = 'Click to manage preferences in another folder'; + foldersPreferences.title.clickableTextCallback = async (folderUriStr: string) => { + await foldersPreferences.saveable.save(); + await this.refreshFoldersPreferencesEditorWidget(folderUriStr); + this.activatePreferenceEditor(PreferenceScope.Folder); + }; + foldersPreferences.scope = PreferenceScope.Folder; + } + return foldersPreferences; + } + } + + private async getFolderSettingsUri(folder: string | undefined): Promise { + if (folder) { + const settingsUri = new URI(folder).resolve('.theia').resolve('settings.json'); + if (!(await this.fileSystem.exists(settingsUri.toString()))) { + await this.fileSystem.createFile(settingsUri.toString()); + } + return settingsUri; + } } activatePreferenceEditor(preferenceScope: PreferenceScope) { - this.preferenceScope = preferenceScope; for (const widget of toArray(this.widgets())) { const preferenceEditor = widget as PreferencesEditorWidget; if (preferenceEditor.scope === preferenceScope) { @@ -294,8 +432,9 @@ export class PreferencesTreeWidget extends TreeWidget { static ID = 'preferences_tree_widget'; + private activeFolderUri: string | undefined; private preferencesGroupNames: string[] = []; - private readonly properties: { [name: string]: PreferenceProperty }; + private readonly properties: { [name: string]: PreferenceDataProperty }; private readonly onPreferenceSelectedEmitter: Emitter<{ [key: string]: string }>; readonly onPreferenceSelected: Event<{ [key: string]: string }>; @@ -373,7 +512,7 @@ export class PreferencesTreeWidget extends TreeWidget { if (node && SelectableTreeNode.is(node)) { const contextMenu = this.preferencesMenuFactory.createPreferenceContextMenu( node.id, - this.preferenceService.get(node.id), + this.preferenceService.get(node.id, undefined, this.activeFolderUri), this.properties[node.id], (property, value) => { this.onPreferenceSelectedEmitter.fire({ [property]: value }); @@ -398,7 +537,7 @@ export class PreferencesTreeWidget extends TreeWidget { children: preferencesGroups, expanded: true, }; - const nodes: { [id: string]: PreferenceProperty }[] = []; + const nodes: { [id: string]: PreferenceDataProperty }[] = []; for (const group of this.preferencesGroupNames.sort((a, b) => a.localeCompare(b))) { const propertyNodes: SelectableTreeNode[] = []; const properties: string[] = []; @@ -433,4 +572,9 @@ export class PreferencesTreeWidget extends TreeWidget { this.decorator.fireDidChangeDecorations(nodes); this.model.root = root; } + + setActiveFolder(folder: string) { + this.activeFolderUri = folder; + this.decorator.setActiveFolder(folder); + } } diff --git a/packages/preferences/src/browser/user-preference-provider.ts b/packages/preferences/src/browser/user-preference-provider.ts index ec7e1dc2028c3..f476eb879db2b 100644 --- a/packages/preferences/src/browser/user-preference-provider.ts +++ b/packages/preferences/src/browser/user-preference-provider.ts @@ -18,6 +18,7 @@ import { injectable } from 'inversify'; import URI from '@theia/core/lib/common/uri'; import { AbstractResourcePreferenceProvider } from './abstract-resource-preference-provider'; import { UserStorageUri } from '@theia/userstorage/lib/browser'; +import { PreferenceScope, PreferenceProvider, PreferenceProviderPriority } from '@theia/core/lib/browser'; export const USER_PREFERENCE_URI = new URI().withScheme(UserStorageUri.SCHEME).withPath('settings.json'); @injectable() @@ -27,4 +28,15 @@ export class UserPreferenceProvider extends AbstractResourcePreferenceProvider { return USER_PREFERENCE_URI; } + canProvide(preferenceName: string, resourceUri?: string): { priority: number, provider: PreferenceProvider } { + const value = this.get(preferenceName); + if (value === undefined || value === null) { + return super.canProvide(preferenceName, resourceUri); + } + return { priority: PreferenceProviderPriority.User, provider: this }; + } + + protected getScope() { + return PreferenceScope.User; + } } diff --git a/packages/preferences/src/browser/workspace-preference-provider.ts b/packages/preferences/src/browser/workspace-preference-provider.ts index a326fc30d1d91..c45c49a206074 100644 --- a/packages/preferences/src/browser/workspace-preference-provider.ts +++ b/packages/preferences/src/browser/workspace-preference-provider.ts @@ -14,9 +14,10 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { inject, injectable } from 'inversify'; +import { inject, injectable, postConstruct } from 'inversify'; import URI from '@theia/core/lib/common/uri'; -import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service'; +import { PreferenceScope, PreferenceProvider, PreferenceProviderPriority } from '@theia/core/lib/browser'; +import { WorkspaceService, WorkspaceData } from '@theia/workspace/lib/browser/workspace-service'; import { AbstractResourcePreferenceProvider } from './abstract-resource-preference-provider'; @injectable() @@ -25,13 +26,72 @@ export class WorkspacePreferenceProvider extends AbstractResourcePreferenceProvi @inject(WorkspaceService) protected readonly workspaceService: WorkspaceService; + @postConstruct() + protected async init(): Promise { + await super.init(); + this.workspaceService.onWorkspaceLocationChanged(workspaceFile => { + if (workspaceFile && !workspaceFile.isDirectory) { + this.toDisposeOnWorkspaceLocationChanged.dispose(); + super.init(); + } + }); + } + async getUri(): Promise { - const workspaceFolder = (await this.workspaceService.roots)[0]; - if (workspaceFolder) { - const rootUri = new URI(workspaceFolder.uri); - return rootUri.resolve('.theia').resolve('settings.json'); + await this.workspaceService.roots; + const workspace = this.workspaceService.workspace; + if (workspace) { + const uri = new URI(workspace.uri); + return workspace.isDirectory ? uri.resolve('.theia').resolve('settings.json') : uri; } - return undefined; } + canProvide(preferenceName: string, resourceUri?: string): { priority: number, provider: PreferenceProvider } { + const value = this.get(preferenceName); + if (value === undefined || value === null) { + return super.canProvide(preferenceName, resourceUri); + } + if (resourceUri) { + const folderPaths = this.getDomain().map(f => new URI(f).path); + if (folderPaths.every(p => p.relativity(new URI(resourceUri).path) < 0)) { + return super.canProvide(preferenceName, resourceUri); + } + } + + return { priority: PreferenceProviderPriority.Workspace, provider: this }; + } + + // tslint:disable-next-line:no-any + protected async getParsedContent(content: string): Promise<{ [key: string]: any }> { + const data = await super.getParsedContent(content); + if (this.workspaceService.saved) { + if (WorkspaceData.is(data)) { + return data.settings || {}; + } + } else { + return data || {}; + } + return {}; + } + + protected getPath(preferenceName: string): string[] { + if (this.workspaceService.saved) { + return ['settings', preferenceName]; + } + return super.getPath(preferenceName); + } + + protected getScope() { + return PreferenceScope.Workspace; + } + + getDomain(): string[] { + const workspace = this.workspaceService.workspace; + if (workspace) { + return workspace.isDirectory + ? [workspace.uri] + : this.workspaceService.tryGetRoots().map(r => r.uri).concat([workspace.uri]); // workspace file is treated as part of the workspace + } + return []; + } } diff --git a/packages/workspace/src/browser/workspace-service.spec.ts b/packages/workspace/src/browser/workspace-service.spec.ts index 49a9eb0acfea2..140752f856278 100644 --- a/packages/workspace/src/browser/workspace-service.spec.ts +++ b/packages/workspace/src/browser/workspace-service.spec.ts @@ -27,8 +27,10 @@ import { DefaultWindowService, WindowService } from '@theia/core/lib/browser/win import { WorkspaceServer } from '../common'; import { DefaultWorkspaceServer } from '../node/default-workspace-server'; import { Emitter, Disposable, DisposableCollection, ILogger, Logger } from '@theia/core'; +import { PreferenceServiceImpl, PreferenceSchemaProvider } from '@theia/core/lib/browser'; import { WorkspacePreferences } from './workspace-preferences'; import { createMockPreferenceProxy } from '@theia/core/lib/browser/preferences/test'; +import * as jsoncparser from 'jsonc-parser'; import * as sinon from 'sinon'; import * as chai from 'chai'; import URI from '@theia/core/lib/common/uri'; @@ -46,6 +48,10 @@ const folderB = Object.freeze({ lastModification: 0, isDirectory: true }); +const getFormattedJson = (data: string): string => { + const edits = jsoncparser.format(data, undefined, { tabSize: 3, insertSpaces: true, eol: '' }); + return jsoncparser.applyEdits(data, edits); +}; // tslint:disable:no-any // tslint:disable:no-unused-expression @@ -66,6 +72,8 @@ describe('WorkspaceService', () => { let mockWindowService: WindowService; let mockILogger: ILogger; let mockPref: WorkspacePreferences; + let mockPreferenceServiceImpl: PreferenceServiceImpl; + let mockPreferenceSchemaProvider: PreferenceSchemaProvider; before(() => { disableJSDOM = enableJSDOM(); @@ -86,6 +94,8 @@ describe('WorkspaceService', () => { mockWindowService = sinon.createStubInstance(DefaultWindowService); mockILogger = sinon.createStubInstance(Logger); mockPref = createMockPreferenceProxy(mockPreferenceValues); + mockPreferenceServiceImpl = sinon.createStubInstance(PreferenceServiceImpl); + mockPreferenceSchemaProvider = sinon.createStubInstance(PreferenceSchemaProvider); const testContainer = new Container(); testContainer.bind(WorkspaceService).toSelf().inSingletonScope(); @@ -95,6 +105,8 @@ describe('WorkspaceService', () => { testContainer.bind(WindowService).toConstantValue(mockWindowService); testContainer.bind(ILogger).toConstantValue(mockILogger); testContainer.bind(WorkspacePreferences).toConstantValue(mockPref); + testContainer.bind(PreferenceServiceImpl).toConstantValue(mockPreferenceServiceImpl); + testContainer.bind(PreferenceSchemaProvider).toConstantValue(mockPreferenceSchemaProvider); // stub the updateTitle() & reloadWindow() function because `document` and `window` are unavailable updateTitleStub = sinon.stub(WorkspaceService.prototype, 'updateTitle').callsFake(() => { }); @@ -462,14 +474,14 @@ describe('WorkspaceService', () => { wsService['_workspace'] = workspaceFileStat; wsService['_roots'] = [folderA]; (mockFilesystem.getFileStat).resolves(folderB); + (mockFilesystem.resolveContent).resolves({ + stat: workspaceFileStat, content: JSON.stringify({ folders: [{ path: 'folderA' }] }) + }); + (mockFilesystem.exists).resolves(true); + const spyWriteFile = sinon.spy(wsService, 'writeWorkspaceFile'); await wsService.addRoot(new URI(folderB.uri)); - expect((mockFilesystem.setContent).calledWith(workspaceFileStat, - JSON.stringify({ - folders: [ - { path: 'folderA' }, { path: 'folderB' } - ] - }))).to.be.true; + expect(spyWriteFile.calledWith(workspaceFileStat, { folders: [{ path: folderA.uri }, { path: folderB.uri }] })).to.be.true; }); [true, false].forEach(existTemporaryWorkspaceFile => { @@ -517,15 +529,18 @@ describe('WorkspaceService', () => { lastModification: 0, isDirectory: false }; + const oldWorkspaceData = { folders: [{ path: 'folderA' }, { path: 'folderB' }], settings: {} }; (mockFilesystem.exists).resolves(true); (mockFilesystem.getFileStat).resolves(file); wsService['_workspace'] = file; + wsService['_roots'] = [folderA, folderB]; const stubSetContent = (mockFilesystem.setContent).resolves(file); + (mockFilesystem.resolveContent).resolves({ stat: file, content: JSON.stringify(oldWorkspaceData) }); expect(wsService.workspace && wsService.workspace.uri).to.eq(file.uri); await wsService.save(new URI(file.uri)); expect((mockWorkspaceServer.setMostRecentlyUsedWorkspace).calledWith(file.uri)).to.be.true; - expect(stubSetContent.calledWith(file, JSON.stringify({ folders: [] }))).to.be.true; + expect(stubSetContent.calledWith(file, getFormattedJson(JSON.stringify(oldWorkspaceData)))).to.be.true; expect(wsService.workspace && wsService.workspace.uri).to.eq(file.uri); }); @@ -540,6 +555,8 @@ describe('WorkspaceService', () => { lastModification: 0, isDirectory: false }; + const oldWorkspaceData = { folders: [{ path: 'folderA' }, { path: 'folderB' }], settings: {} }; + wsService['_roots'] = [folderA, folderB]; const stubExist = mockFilesystem.exists; stubExist.withArgs(oldFile.uri).resolves(true); stubExist.withArgs(newFile.uri).resolves(false); @@ -547,16 +564,17 @@ describe('WorkspaceService', () => { (mockFileSystemWatcher.watchFileChanges).resolves(new DisposableCollection()); wsService['_workspace'] = oldFile; const stubSetContent = (mockFilesystem.setContent).resolves(newFile); + (mockFilesystem.resolveContent).resolves({ stat: oldFile, content: JSON.stringify(oldWorkspaceData) }); expect(wsService.workspace && wsService.workspace.uri).to.eq(oldFile.uri); await wsService.save(new URI(newFile.uri)); expect((mockWorkspaceServer.setMostRecentlyUsedWorkspace).calledWith(newFile.uri)).to.be.true; - expect(stubSetContent.calledWith(newFile, JSON.stringify({ folders: [] }))).to.be.true; + expect(stubSetContent.calledWith(newFile, getFormattedJson(JSON.stringify(oldWorkspaceData)))).to.be.true; expect(wsService.workspace && wsService.workspace.uri).to.eq(newFile.uri); expect(updateTitleStub.called).to.be.true; }); - it('should use relative paths or translate relative paths to absolute path when necessary before saving', async () => { + it('should use relative paths or translate relative paths to absolute path when necessary before saving, and emit "savedLocationChanged" event', done => { const oldFile = { uri: 'file:///home/oldFolder/oldFile', lastModification: 0, @@ -577,6 +595,8 @@ describe('WorkspaceService', () => { lastModification: 0, isDirectory: true }; + const oldWorkspaceData = { folders: [{ path: folder1.uri }, { path: folder2.uri }], settings: {} }; + (mockFilesystem.resolveContent).resolves({ stat: oldFile, content: JSON.stringify(oldWorkspaceData) }); const stubExist = mockFilesystem.exists; stubExist.withArgs(oldFile.uri).resolves(true); stubExist.withArgs(newFile.uri).resolves(false); @@ -587,12 +607,16 @@ describe('WorkspaceService', () => { (mockFileSystemWatcher.watchFileChanges).resolves(new DisposableCollection()); expect(wsService.workspace && wsService.workspace.uri).to.eq(oldFile.uri); - await wsService.save(new URI(newFile.uri)); - expect((mockWorkspaceServer.setMostRecentlyUsedWorkspace).calledWith(newFile.uri)).to.be.true; - expect(stubSetContent.calledWith(newFile, JSON.stringify({ folders: [{ path: folder1.uri }, { path: 'folder2' }] }))).to.be.true; - expect(wsService.workspace && wsService.workspace.uri).to.eq(newFile.uri); - expect(updateTitleStub.called).to.be.true; - }); + wsService.onWorkspaceLocationChanged(() => { + done(); + }); + wsService.save(new URI(newFile.uri)).then(() => { + expect((mockWorkspaceServer.setMostRecentlyUsedWorkspace).calledWith(newFile.uri)).to.be.true; + expect(stubSetContent.calledWith(newFile, getFormattedJson(JSON.stringify({ folders: [{ path: folder1.uri }, { path: 'folder2' }], settings: {} })))).to.be.true; + expect(wsService.workspace && wsService.workspace.uri).to.eq(newFile.uri); + expect(updateTitleStub.called).to.be.true; + }); + }).timeout(2000); }); describe('saved status', () => { @@ -611,6 +635,25 @@ describe('WorkspaceService', () => { }); }); + describe('isMultiRootWorkspaceOpened status', () => { + it('should be true if there is an opened workspace and preference["workspace.supportMultiRootWorkspace"] = true, otherwise false', () => { + mockPreferenceValues['workspace.supportMultiRootWorkspace'] = true; + expect(wsService.isMultiRootWorkspaceOpened).to.be.false; + + const file = { + uri: 'file:///home/file', + lastModification: 0, + isDirectory: false + }; + wsService['_workspace'] = file; + mockPreferenceValues['workspace.supportMultiRootWorkspace'] = true; + expect(wsService.isMultiRootWorkspaceOpened).to.be.true; + + mockPreferenceValues['workspace.supportMultiRootWorkspace'] = false; + expect(wsService.isMultiRootWorkspaceOpened).to.be.false; + }); + }); + describe('containsSome() function', () => { it('should resolve false if the current workspace is not open', async () => { sinon.stub(wsService, 'roots').resolves([]); @@ -671,12 +714,14 @@ describe('WorkspaceService', () => { wsService['_roots'] = [folderA, folderB]; const stubSetContent = mockFilesystem.setContent; stubSetContent.resolves(file); + (mockFilesystem.resolveContent).resolves({ stat: file, content: JSON.stringify({ folders: [{ path: 'folderA' }, { path: 'folderB' }] }) }); + (mockFilesystem.exists).resolves(true); await wsService.removeRoots([new URI()]); - expect(stubSetContent.calledWith(file, JSON.stringify({ folders: [{ path: 'folderA' }, { path: 'folderB' }] }))).to.be.true; + expect(stubSetContent.calledWith(file, getFormattedJson(JSON.stringify({ folders: [{ path: 'folderA' }, { path: 'folderB' }] })))).to.be.true; await wsService.removeRoots([new URI(folderB.uri)]); - expect(stubSetContent.calledWith(file, JSON.stringify({ folders: [{ path: 'folderA' }] }))).to.be.true; + expect(stubSetContent.calledWith(file, getFormattedJson(JSON.stringify({ folders: [{ path: 'folderA' }] })))).to.be.true; }); }); diff --git a/packages/workspace/src/browser/workspace-service.ts b/packages/workspace/src/browser/workspace-service.ts index 8255c6fa0772c..b2225bc21e67d 100644 --- a/packages/workspace/src/browser/workspace-service.ts +++ b/packages/workspace/src/browser/workspace-service.ts @@ -20,7 +20,9 @@ import { FileSystem, FileStat } from '@theia/filesystem/lib/common'; import { FileSystemWatcher, FileChangeEvent } from '@theia/filesystem/lib/browser/filesystem-watcher'; import { WorkspaceServer, THEIA_EXT, VSCODE_EXT, getTemporaryWorkspaceFileUri } from '../common'; import { WindowService } from '@theia/core/lib/browser/window/window-service'; -import { FrontendApplicationContribution } from '@theia/core/lib/browser'; +import { + FrontendApplicationContribution, PreferenceServiceImpl, PreferenceScope, PreferenceSchemaProvider +} from '@theia/core/lib/browser'; import { Deferred } from '@theia/core/lib/common/promise-util'; import { ILogger, Disposable, DisposableCollection, Emitter, Event, MaybePromise } from '@theia/core'; import { WorkspacePreferences } from './workspace-preferences'; @@ -57,6 +59,12 @@ export class WorkspaceService implements FrontendApplicationContribution { @inject(WorkspacePreferences) protected preferences: WorkspacePreferences; + @inject(PreferenceServiceImpl) + protected readonly preferenceImpl: PreferenceServiceImpl; + + @inject(PreferenceSchemaProvider) + protected readonly schemaProvider: PreferenceSchemaProvider; + protected applicationName: string; @postConstruct() @@ -117,6 +125,11 @@ export class WorkspaceService implements FrontendApplicationContribution { return this.onWorkspaceChangeEmitter.event; } + protected readonly onWorkspaceLocationChangedEmitter = new Emitter(); + get onWorkspaceLocationChanged(): Event { + return this.onWorkspaceLocationChangedEmitter.event; + } + protected readonly toDisposeOnWorkspace = new DisposableCollection(); protected async setWorkspace(workspaceStat: FileStat | undefined): Promise { if (FileStat.equals(this._workspace, workspaceStat)) { @@ -136,16 +149,33 @@ export class WorkspaceService implements FrontendApplicationContribution { } protected async updateWorkspace(): Promise { + if (this._workspace) { + this.toFileStat(this._workspace.uri).then(stat => this._workspace = stat); + } await this.updateRoots(); this.watchRoots(); } protected async updateRoots(): Promise { - this._roots = await this.computeRoots(); - this.deferredRoots.resolve(this._roots); // in order to resolve first - this.deferredRoots = new Deferred(); - this.deferredRoots.resolve(this._roots); - this.onWorkspaceChangeEmitter.fire(this._roots); + const newRoots = await this.computeRoots(); + let rootsChanged = false; + if (newRoots.length !== this._roots.length || newRoots.length === 0) { + rootsChanged = true; + } else { + for (const newRoot of newRoots) { + if (!this._roots.some(r => r.uri === newRoot.uri)) { + rootsChanged = true; + break; + } + } + } + if (rootsChanged) { + this._roots = newRoots; + this.deferredRoots.resolve(this._roots); // in order to resolve first + this.deferredRoots = new Deferred(); + this.deferredRoots.resolve(this._roots); + this.onWorkspaceChangeEmitter.fire(this._roots); + } } protected async computeRoots(): Promise { @@ -223,7 +253,7 @@ export class WorkspaceService implements FrontendApplicationContribution { } /** - * Returns `true` if current workspace root is set. + * Returns `true` if theia has an opened workspace or folder * @returns {boolean} */ get opened(): boolean { @@ -260,7 +290,7 @@ export class WorkspaceService implements FrontendApplicationContribution { if (preserveWindow) { this._workspace = stat; } - await this.openWindow(stat, { preserveWindow }); + this.openWindow(stat, { preserveWindow }); return; } throw new Error('Invalid workspace root URI. Expected an existing directory location.'); @@ -288,7 +318,9 @@ export class WorkspaceService implements FrontendApplicationContribution { await this.save(tempFile); } } - this._workspace = await this.writeWorkspaceFile(this._workspace, [...this._roots, valid]); + const workspaceData = await this.getWorkspaceDataFromFile(); + this._workspace = await this.writeWorkspaceFile(this._workspace, + WorkspaceData.buildWorkspaceData([...this._roots, valid], workspaceData ? workspaceData.settings : undefined)); } } @@ -300,21 +332,23 @@ export class WorkspaceService implements FrontendApplicationContribution { throw new Error('Folder cannot be removed as there is no active folder in the current workspace.'); } if (this._workspace) { - this._workspace = await this.writeWorkspaceFile( - this._workspace, this._roots.filter(root => uris.findIndex(u => u.toString() === root.uri) < 0) + const workspaceData = await this.getWorkspaceDataFromFile(); + this._workspace = await this.writeWorkspaceFile(this._workspace, + WorkspaceData.buildWorkspaceData( + this._roots.filter(root => uris.findIndex(u => u.toString() === root.uri) < 0), + workspaceData!.settings + ) ); } } - private async writeWorkspaceFile(workspaceFile: FileStat | undefined, rootFolders: FileStat[]): Promise { + private async writeWorkspaceFile(workspaceFile: FileStat | undefined, workspaceData: WorkspaceData): Promise { if (workspaceFile) { - const workspaceData = WorkspaceData.transformToRelative( - WorkspaceData.buildWorkspaceData(rootFolders.map(f => f.uri)), workspaceFile - ); - if (workspaceData) { - const stat = await this.fileSystem.setContent(workspaceFile, JSON.stringify(workspaceData)); - return stat; - } + const data = JSON.stringify(WorkspaceData.transformToRelative(workspaceData, workspaceFile)); + const edits = jsoncparser.format(data, undefined, { tabSize: 3, insertSpaces: true, eol: '' }); + const result = jsoncparser.applyEdits(data, edits); + const stat = await this.fileSystem.setContent(workspaceFile, result); + return stat; } } @@ -444,10 +478,24 @@ export class WorkspaceService implements FrontendApplicationContribution { if (!await this.fileSystem.exists(uriStr)) { await this.fileSystem.createFile(uriStr); } + const workspaceData: WorkspaceData = { folders: [], settings: {} }; + if (!this.saved) { + for (const p of Object.keys(this.schemaProvider.getCombinedSchema().properties)) { + if (this.schemaProvider.isValidInScope(p, PreferenceScope.Folder)) { + continue; + } + const preferences = this.preferenceImpl.inspect(p); + if (preferences && preferences.workspaceValue) { + workspaceData.settings![p] = preferences.workspaceValue; + } + } + } let stat = await this.toFileStat(uriStr); - stat = await this.writeWorkspaceFile(stat, this._roots); + Object.assign(workspaceData, await this.getWorkspaceDataFromFile()); + stat = await this.writeWorkspaceFile(stat, WorkspaceData.buildWorkspaceData(this._roots, workspaceData ? workspaceData.settings : undefined)); await this.server.setMostRecentlyUsedWorkspace(uriStr); await this.setWorkspace(stat); + this.onWorkspaceLocationChangedEmitter.fire(stat); } protected readonly rootWatchers = new Map(); @@ -512,8 +560,9 @@ export interface WorkspaceInput { } export interface WorkspaceData { - folders: Array<{ path: string }>; - // TODO add workspace settings settings?: { [id: string]: any }; + folders: Array<{ path: string, name?: string }>; + // tslint:disable-next-line:no-any + settings?: { [id: string]: any }; } export namespace WorkspaceData { @@ -532,8 +581,13 @@ export namespace WorkspaceData { }, required: ['path'] } + }, + settings: { + description: 'Workspace preferences', + type: 'object' } - } + }, + required: ['folders'] }); // tslint:disable-next-line:no-any @@ -541,10 +595,23 @@ export namespace WorkspaceData { return !!validateSchema(data); } - export function buildWorkspaceData(folders: string[]): WorkspaceData { - return { - folders: folders.map(f => ({ path: f })) + // tslint:disable-next-line:no-any + export function buildWorkspaceData(folders: string[] | FileStat[], settings: { [id: string]: any } | undefined): WorkspaceData { + let roots: string[] = []; + if (folders.length > 0) { + if (typeof folders[0] !== 'string') { + roots = (folders).map(folder => folder.uri); + } else { + roots = folders; + } + } + const data: WorkspaceData = { + folders: roots.map(folder => ({ path: folder })) }; + if (settings) { + data.settings = settings; + } + return data; } export function transformToRelative(data: WorkspaceData, workspaceFile?: FileStat): WorkspaceData { @@ -559,7 +626,7 @@ export namespace WorkspaceData { folderUris.push(folderUri.toString()); } } - return buildWorkspaceData(folderUris); + return buildWorkspaceData(folderUris, data.settings); } export function transformToAbsolute(data: WorkspaceData, workspaceFile?: FileStat): WorkspaceData { @@ -574,7 +641,7 @@ export namespace WorkspaceData { } } - return Object.assign(data, buildWorkspaceData(folders)); + return Object.assign(data, buildWorkspaceData(folders, data.settings)); } return data; }