From b593f38e7e4787021c74c15c497a7df8a7fe7063 Mon Sep 17 00:00:00 2001 From: elaihau Date: Fri, 21 Sep 2018 06:41:59 -0400 Subject: [PATCH] preferences for multi-root workspace With changes in 543b119, URIs of root folders in a multi-root workspace are stored in a file with the extension of "theia-workspace", while the workspace preferences are in the ".theia/settings.json" under the first root. In this pull request, workspace preferences are stored - in the workspace file under the "settings" property, if a workspace file exists, or - in the ".theia/settings.json" if the workspace data is not saved in a workspace file. Also, this change supports - having 4 levels of preferences (from highest priority to the lowest): FolderPreference, WorkspacePreference, UserPreference, and DefaultPreference, with - an updated preference editor that supports viewing & editing the FolderPreferences, WorkspacePreferences, and UserPreferneces. This is the 3rd patch of #1660. Signed-off-by: elaihau --- .../browser/frontend-application-module.ts | 4 +- .../default-preference-provider.ts | 56 ++++ .../core/src/browser/preferences/index.ts | 1 + .../preferences/preference-contribution.ts | 28 +- .../preferences/preference-provider.ts | 31 ++- .../browser/preferences/preference-proxy.ts | 25 +- .../browser/preferences/preference-service.ts | 246 ++++++++++++------ .../test/mock-preference-service.ts | 7 +- .../src/browser/shell/application-shell.ts | 27 +- packages/core/src/browser/style/tabs.css | 16 ++ packages/core/src/common/path.ts | 11 + packages/cpp/src/browser/cpp-preferences.ts | 9 +- .../editor/src/browser/editor-preferences.ts | 208 ++++++++++----- .../src/browser/filesystem-preferences.ts | 8 +- .../src/browser/filesystem-watcher.ts | 14 +- packages/git/src/browser/git-preferences.ts | 14 +- packages/json/src/browser/json-preferences.ts | 12 +- .../src/browser/notification-preferences.ts | 6 +- .../src/browser/monaco-configurations.ts | 3 +- .../src/browser/monaco-editor-provider.ts | 32 ++- .../src/browser/monaco-text-model-service.ts | 23 +- .../src/browser/navigator-contribution.ts | 14 +- .../navigator/src/browser/navigator-filter.ts | 15 +- .../src/browser/navigator-preferences.ts | 8 +- .../src/browser/navigator-widget.tsx | 6 +- .../output/src/common/output-preferences.ts | 6 +- .../browser/hosted-plugin-preferences.ts | 8 +- packages/preferences/package.json | 1 + .../abstract-resource-preference-provider.ts | 100 ++++++- .../src/browser/folder-preference-provider.ts | 73 ++++++ .../browser/folders-preferences-provider.ts | 140 ++++++++++ packages/preferences/src/browser/index.ts | 2 + .../src/browser/preference-editor-widget.ts | 127 +++++++++ .../src/browser/preference-frontend-module.ts | 19 +- .../src/browser/preference-service.spec.ts | 181 +++++++------ .../src/browser/preferences-decorator.ts | 13 +- ...ences-frontend-application-contribution.ts | 14 +- .../src/browser/preferences-tree-widget.ts | 210 ++++++++++++--- .../src/browser/user-preference-provider.ts | 12 + .../browser/workspace-preference-provider.ts | 79 +++++- .../src/browser/preview-preferences.ts | 6 +- .../src/browser/typescript-preferences.ts | 9 +- .../src/browser/quick-open-workspace.ts | 8 +- .../src/browser/workspace-commands.ts | 71 +++-- .../workspace-frontend-contribution.spec.ts | 27 +- .../workspace-frontend-contribution.ts | 50 +--- .../src/browser/workspace-preferences.ts | 14 +- .../src/browser/workspace-service.ts | 140 +++++++--- 48 files changed, 1567 insertions(+), 567 deletions(-) create mode 100644 packages/core/src/browser/preferences/default-preference-provider.ts create mode 100644 packages/preferences/src/browser/folder-preference-provider.ts create mode 100644 packages/preferences/src/browser/folders-preferences-provider.ts create mode 100644 packages/preferences/src/browser/preference-editor-widget.ts diff --git a/packages/core/src/browser/frontend-application-module.ts b/packages/core/src/browser/frontend-application-module.ts index 47191f612fd0c..a0136f38da48f 100644 --- a/packages/core/src/browser/frontend-application-module.ts +++ b/packages/core/src/browser/frontend-application-module.ts @@ -49,7 +49,7 @@ import { LabelParser } from './label-parser'; import { LabelProvider, LabelProviderContribution, DefaultUriLabelProviderContribution } from './label-provider'; import { PreferenceProviderProvider, PreferenceProvider, PreferenceScope, PreferenceService, - PreferenceServiceImpl, bindPreferenceSchemaProvider + PreferenceServiceImpl, bindPreferenceSchemaProvider, bindDefaultPreferenceProvider } from './preferences'; import { ContextMenuRenderer } from './context-menu-renderer'; import { ThemingCommandContribution, ThemeService, BuiltinThemeProvider } from './theming'; @@ -154,8 +154,10 @@ export const frontendApplicationModule = new ContainerModule((bind, unbind, isBo bind(LabelProviderContribution).to(DefaultUriLabelProviderContribution).inSingletonScope(); bind(LabelProviderContribution).to(DiffUriLabelProviderContribution).inSingletonScope(); + bindDefaultPreferenceProvider(bind); bind(PreferenceProvider).toSelf().inSingletonScope().whenTargetNamed(PreferenceScope.User); bind(PreferenceProvider).toSelf().inSingletonScope().whenTargetNamed(PreferenceScope.Workspace); + bind(PreferenceProvider).toSelf().inSingletonScope().whenTargetNamed(PreferenceScope.Folders); bind(PreferenceProviderProvider).toFactory(ctx => (scope: PreferenceScope) => ctx.container.getNamed(PreferenceProvider, scope)); bind(PreferenceServiceImpl).toSelf().inSingletonScope(); bind(PreferenceService).toService(PreferenceServiceImpl); diff --git a/packages/core/src/browser/preferences/default-preference-provider.ts b/packages/core/src/browser/preferences/default-preference-provider.ts new file mode 100644 index 0000000000000..1fb0ff72494ed --- /dev/null +++ b/packages/core/src/browser/preferences/default-preference-provider.ts @@ -0,0 +1,56 @@ +/******************************************************************************** + * Copyright (C) 2018 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, named, interfaces } from 'inversify'; +import { ContributionProvider } from '../../common'; +import { PreferenceProvider } from './preference-provider'; +import { PreferenceScope } from './preference-service'; +import { PreferenceContribution } from './preference-contribution'; + +export function bindDefaultPreferenceProvider(bind: interfaces.Bind): void { + bind(PreferenceProvider).to(DefaultPrefrenceProvider).inSingletonScope().whenTargetNamed(PreferenceScope.Default); +} + +@injectable() +export class DefaultPrefrenceProvider extends PreferenceProvider { + + protected readonly preferences: { [name: string]: any } = {}; + + constructor( + @inject(ContributionProvider) @named(PreferenceContribution) + protected readonly preferenceContributions: ContributionProvider + ) { + super(); + this.preferenceContributions.getContributions().forEach(contrib => { + for (const prefName of Object.keys(contrib.schema.properties)) { + this.preferences[prefName] = contrib.schema.properties[prefName].default; + } + }); + this._ready.resolve(); + } + + 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: 0, provider: this }; + } +} diff --git a/packages/core/src/browser/preferences/index.ts b/packages/core/src/browser/preferences/index.ts index 02347d832c393..b531f39e63610 100644 --- a/packages/core/src/browser/preferences/index.ts +++ b/packages/core/src/browser/preferences/index.ts @@ -18,3 +18,4 @@ export * from './preference-service'; export * from './preference-proxy'; export * from './preference-contribution'; export * from './preference-provider'; +export * from './default-preference-provider'; diff --git a/packages/core/src/browser/preferences/preference-contribution.ts b/packages/core/src/browser/preferences/preference-contribution.ts index 7412b96815a4f..672685d7483ed 100644 --- a/packages/core/src/browser/preferences/preference-contribution.ts +++ b/packages/core/src/browser/preferences/preference-contribution.ts @@ -17,7 +17,7 @@ import * as Ajv from 'ajv'; import { inject, injectable, named, interfaces } from 'inversify'; import { ContributionProvider, bindContributionProvider } from '../../common'; -import { PreferenceProvider } from './preference-provider'; +import { PreferenceScope } from './preference-service'; // tslint:disable:no-any @@ -38,18 +38,17 @@ export interface PreferenceSchema { 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 { description: string; + scopes: PreferenceScope; } export type JsonType = 'string' | 'array' | 'number' | 'integer' | 'object' | 'boolean' | 'null'; @@ -60,22 +59,20 @@ export function bindPreferenceSchemaProvider(bind: interfaces.Bind): void { } @injectable() -export class PreferenceSchemaProvider extends PreferenceProvider { +export class PreferenceSchemaProvider { protected readonly combinedSchema: PreferenceSchema = { properties: {} }; - protected readonly preferences: { [name: string]: any } = {}; protected validateFunction: Ajv.ValidateFunction; constructor( @inject(ContributionProvider) @named(PreferenceContribution) protected readonly preferenceContributions: ContributionProvider ) { - super(); this.preferenceContributions.getContributions().forEach(contrib => { this.doSetSchema(contrib.schema); }); + this.combinedSchema.additionalProperties = false; this.updateValidate(); - this._ready.resolve(); } protected doSetSchema(schema: PreferenceSchema): void { @@ -88,10 +85,6 @@ export class PreferenceSchemaProvider extends PreferenceProvider { props.push(property); } } - // tslint:disable-next-line:forin - for (const property of props) { - this.preferences[property] = this.combinedSchema.properties[property].default; - } } protected updateValidate(): void { @@ -106,17 +99,16 @@ export class PreferenceSchemaProvider extends PreferenceProvider { return this.combinedSchema; } - getPreferences(): { [name: string]: any } { - return this.preferences; - } - setSchema(schema: PreferenceSchema): void { this.doSetSchema(schema); this.updateValidate(); } - async setPreference(): Promise { - throw new Error('Unsupported'); + isValidInScope(prefName: string, scope: PreferenceScope): boolean { + const schemaProps = this.combinedSchema.properties[prefName]; + if (schemaProps) { + return (schemaProps.scopes & scope) > 0; + } + return false; } - } diff --git a/packages/core/src/browser/preferences/preference-provider.ts b/packages/core/src/browser/preferences/preference-provider.ts index d29d0e7473fbf..07a8e681cb8a2 100644 --- a/packages/core/src/browser/preferences/preference-provider.ts +++ b/packages/core/src/browser/preferences/preference-provider.ts @@ -19,11 +19,13 @@ import { injectable } from 'inversify'; import { Disposable, DisposableCollection, Emitter, Event } from '../../common'; import { Deferred } from '../../common/promise-util'; +import { PreferenceScope, PreferenceChange } from './preference-service'; @injectable() export class PreferenceProvider implements Disposable { - protected readonly onDidPreferencesChangedEmitter = new Emitter(); - readonly onDidPreferencesChanged: Event = this.onDidPreferencesChangedEmitter.event; + + protected readonly onDidPreferencesChangedEmitter = new Emitter(); + readonly onDidPreferencesChanged: Event = this.onDidPreferencesChangedEmitter.event; protected readonly toDispose = new DisposableCollection(); @@ -41,15 +43,18 @@ export class PreferenceProvider implements Disposable { this.toDispose.dispose(); } - protected fireOnDidPreferencesChanged(): void { - this.onDidPreferencesChangedEmitter.fire(undefined); + get(preferenceName: string, resourceUri?: string): T | undefined { + const value = this.getPreferences(resourceUri)[preferenceName]; + if (value !== undefined && value !== null) { + return value; + } } - getPreferences(): { [p: string]: any } { - return []; + getPreferences(resourceUri?: string): { [p: string]: any } { + return {}; } - setPreference(key: string, value: any): Promise { + setPreference(key: string, value: any, resourceUri?: string): Promise { return Promise.resolve(); } @@ -57,4 +62,16 @@ export class PreferenceProvider implements Disposable { get ready() { return this._ready.promise; } + + canProvide(preferenceName: string, resourceUri?: string): { priority: number, provider: PreferenceProvider } { + return { priority: -1, 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..8ec40a0c3fb0e 100644 --- a/packages/core/src/browser/preferences/preference-proxy.ts +++ b/packages/core/src/browser/preferences/preference-proxy.ts @@ -17,29 +17,37 @@ // tslint:disable:no-any import { Disposable, DisposableCollection, Event, Emitter } from '../../common'; -import { PreferenceService, PreferenceChange } from './preference-service'; +import { PreferenceService, PreferenceDataChange } 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]; + canAffect(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(); + const onPreferenceChangedEmitter = new Emitter(); toDispose.push(onPreferenceChangedEmitter); toDispose.push(preferences.onPreferenceChanged(e => { if (schema.properties[e.preferenceName]) { 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 858dc360c2c54..86e7d1103e643 100644 --- a/packages/core/src/browser/preferences/preference-service.ts +++ b/packages/core/src/browser/preferences/preference-service.ts @@ -16,25 +16,51 @@ // tslint:disable:no-any -import { JSONExt } from '@phosphor/coreutils'; import { injectable, inject, postConstruct } from 'inversify'; import { FrontendApplicationContribution } from '../../browser'; import { Event, Emitter, DisposableCollection, Disposable, deepFreeze } from '../../common'; import { Deferred } from '../../common/promise-util'; import { PreferenceProvider } from './preference-provider'; import { PreferenceSchemaProvider } from './preference-contribution'; +import URI from '../../common/uri'; -export enum PreferenceScope { - User, - Workspace +export namespace PreferenceScope { + export function getScopes(): PreferenceScope[] { + return Object.keys(PreferenceScope) + .filter(k => typeof PreferenceScope[k as any] === 'string') + .map(v => Number(v)); + } + + export function getReversedScopes(): PreferenceScope[] { + return getScopes().reverse(); + } + + export function getScopeNames(scopes?: number): string[] { + const names: string[] = []; + const allNames = Object.keys(PreferenceScope) + .filter(k => typeof PreferenceScope[k as any] === 'number'); + if (scopes) { + for (const name of allNames) { + if (((PreferenceScope)[name] & scopes) > 0) { + names.push(name); + } + } + } + return names; + } } -export interface PreferenceChangedEvent { - changes: PreferenceChange[] +export enum PreferenceScope { + Default = 1, + User = 2, + Workspace = 4, + Folders = 8 } export interface PreferenceChange { readonly preferenceName: string; + readonly scope: PreferenceScope + readonly domain: string[]; readonly newValue?: any; readonly oldValue?: any; } @@ -43,14 +69,63 @@ export interface PreferenceChanges { [preferenceName: string]: PreferenceChange } +export class PreferenceDataChange implements PreferenceChange { + + constructor( + private readonly change: PreferenceChange, + private readonly providers: Map + ) { } + + get preferenceName() { + return this.change.preferenceName; + } + get scope() { + return this.change.scope; + } + get domain() { + return this.change.domain; + } + get newValue() { + return this.change.newValue; + } + get oldValue() { + return this.change.oldValue; + } + + canAffect(resourceUri?: string): boolean { + if (this.domain && resourceUri && + this.domain.length !== 0 && + this.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.Folders) { + continue; + } + const providerInfo = provider.canProvide(this.preferenceName, resourceUri); + const priority = providerInfo.priority; + if (priority >= 0 && scope > this.scope) { + return false; + } + if (scope === this.scope && this.domain.some(d => providerInfo.provider.getDomain().findIndex(pd => pd === d) < 0)) { + return false; + } + } + return true; + } +} + export const PreferenceService = Symbol('PreferenceService'); 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; - onPreferenceChanged: Event; + 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; } /** @@ -58,14 +133,14 @@ 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(); + protected readonly onPreferenceChangedEmitter = new Emitter(); readonly onPreferenceChanged = this.onPreferenceChangedEmitter.event; protected readonly onPreferencesChangedEmitter = new Emitter(); @@ -80,12 +155,11 @@ export class PreferenceServiceImpl implements PreferenceService, FrontendApplica protected readonly providerProvider: PreferenceProviderProvider; protected readonly providers: PreferenceProvider[] = []; + protected providersMap: Map; @postConstruct() protected init(): void { this.toDispose.push(Disposable.create(() => this._ready.reject())); - this.providers.push(this.schema); - this.preferences = this.parsePreferences(); } dispose(): void { @@ -100,77 +174,49 @@ export class PreferenceServiceImpl implements PreferenceService, FrontendApplica initialize(): void { this.initializeProviders(); } + protected async initializeProviders(): Promise { try { - const providers = this.createProviders(); - this.toDispose.pushAll(providers); - await Promise.all(providers.map(p => p.ready)); + this.providersMap = this.createProviders(); + for (const p of this.providersMap.values()) { + this.providers.push(p); + this.toDispose.push(p); + } + + const defaultProvider = this.providersMap.get(PreferenceScope.Default); + if (defaultProvider) { + defaultProvider.ready.then(() => this._ready.resolve()); + } + if (this.toDispose.disposed) { return; } - this.providers.push(...providers); - for (const provider of providers) { - provider.onDidPreferencesChanged(_ => this.reconcilePreferences()); + for (const [scope, provider] of this.providersMap.entries()) { + if (scope !== PreferenceScope.Default) { + provider.onDidPreferencesChanged(change => { + this.onPreferenceChangedEmitter.fire(new PreferenceDataChange(change, this.providersMap)); + }); + } } - this.reconcilePreferences(); - 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); - } - } else { - changes[preferenceName] = { preferenceName, newValue }; - this.preferences[preferenceName] = deepFreeze(newValue); - } - } - 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]); - } - } - 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]; - } - } - } - return result; + + protected createProviders(): Map { + const providers = new Map(); + PreferenceScope.getScopes().forEach(s => + providers.set(s, this.providerProvider(s)) + ); + return providers; } - getPreferences(): { [key: string]: Object | undefined } { - return this.preferences; + getPreferences(resourceUri?: string): { [key: string]: Object | undefined } { + const prefs: { [key: string]: Object | undefined } = {}; + 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 { @@ -179,26 +225,37 @@ export class PreferenceServiceImpl implements PreferenceService, FrontendApplica 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 = this.preferences.get(preferenceName, defaultValue, resourceUri); 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 = this.preferences.get(preferenceName, defaultValue, resourceUri); if (value === null || value === undefined) { return defaultValue; } @@ -210,8 +267,9 @@ export class PreferenceServiceImpl implements PreferenceService, FrontendApplica 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 = this.preferences.get(preferenceName, defaultValue, resourceUri); if (value === null || value === undefined) { return defaultValue; @@ -222,4 +280,26 @@ export class PreferenceServiceImpl implements PreferenceService, FrontendApplica return Number(value); } + inspect(preferenceName: string, resourceUri?: string): { + preferenceName: string, + values: Map + } { + const result = { preferenceName, values: new Map() }; + const schemaProps = this.schema.getCombinedSchema().properties[preferenceName]; + if (schemaProps) { + const scopes = schemaProps.scopes; + for (const s of PreferenceScope.getScopes()) { + if ((scopes & s) > 0) { + const p = this.providersMap.get(s); + if (p && p.canProvide(preferenceName, resourceUri).priority >= 0) { + const value = p.get(preferenceName, resourceUri); + if (value !== null && value !== undefined) { + result.values.set(s, value); + } + } + } + } + } + return result; + } } 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 92747dc6100e2..0b2f8b0557833 100644 --- a/packages/core/src/browser/preferences/test/mock-preference-service.ts +++ b/packages/core/src/browser/preferences/test/mock-preference-service.ts @@ -15,7 +15,7 @@ ********************************************************************************/ import { injectable } from 'inversify'; -import { PreferenceService, PreferenceChange } from '../'; +import { PreferenceService, PreferenceDataChange } from '../'; import { Emitter, Event } from '../../../common'; @injectable() @@ -24,10 +24,11 @@ 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; } set(preferenceName: string, value: any): Promise { return Promise.resolve(); } ready: Promise = Promise.resolve(); - readonly onPreferenceChanged: Event = new Emitter().event; + readonly onPreferenceChanged: Event = new Emitter().event; } diff --git a/packages/core/src/browser/shell/application-shell.ts b/packages/core/src/browser/shell/application-shell.ts index bdc49633753c5..4feb6ee49e91c 100644 --- a/packages/core/src/browser/shell/application-shell.ts +++ b/packages/core/src/browser/shell/application-shell.ts @@ -23,7 +23,7 @@ import { } from '@phosphor/widgets'; import { Message } from '@phosphor/messaging'; import { IDragEvent } from '@phosphor/dragdrop'; -import { RecursivePartial, MaybePromise } from '../../common'; +import { RecursivePartial, MaybePromise, Event as TypedEvent } from '../../common'; import { Saveable } from '../saveable'; import { StatusBarImpl, StatusBarEntry, StatusBarAlignment } from '../status-bar/status-bar'; import { SidePanelHandler, SidePanel, SidePanelHandlerFactory, TheiaDockPanel } from './side-panel-handler'; @@ -767,7 +767,27 @@ export class ApplicationShell extends Widget { this.tracker.add(widget); Saveable.apply(widget); if (ApplicationShell.TrackableWidgetProvider.is(widget)) { - for (const toTrack of await widget.getTrackableWidgets()) { + if (widget.onTrackableWidgetsChanged) { + widget.onTrackableWidgetsChanged(trackables => { + for (const tracked of this.tracker.widgets) { + if (trackables.findIndex(t => t.id === tracked.id) < 0) { + this.tracker.remove(tracked); + } + } + this.doTrackWidgets(trackables); + }); + } + this.doTrackWidgets(await widget.getTrackableWidgets()); + } + } + + /** + * Track the given array of widgets. + * Note: This function doesn't take care of the trackable widgets added to widgets in the array. + */ + private doTrackWidgets(widgets: Widget[]): void { + for (const toTrack of widgets) { + if (!this.tracker.has(toTrack)) { this.tracker.add(toTrack); Saveable.apply(toTrack); } @@ -1357,7 +1377,8 @@ export namespace ApplicationShell { * Exposes widgets which activation state should be tracked by shell. */ export interface TrackableWidgetProvider { - getTrackableWidgets(): MaybePromise + getTrackableWidgets(): MaybePromise; + readonly onTrackableWidgetsChanged?: TypedEvent; } export namespace TrackableWidgetProvider { diff --git a/packages/core/src/browser/style/tabs.css b/packages/core/src/browser/style/tabs.css index cb569f11caa63..efb2a4efecc83 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 340aad1dd7f51..f94e6b50119ee 100644 --- a/packages/core/src/common/path.ts +++ b/packages/core/src/common/path.ts @@ -143,4 +143,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/cpp/src/browser/cpp-preferences.ts b/packages/cpp/src/browser/cpp-preferences.ts index ec73e4a5ddccd..d70566e7fdecb 100644 --- a/packages/cpp/src/browser/cpp-preferences.ts +++ b/packages/cpp/src/browser/cpp-preferences.ts @@ -14,7 +14,7 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { PreferenceSchema, PreferenceProxy, PreferenceService, createPreferenceProxy, PreferenceContribution } from '@theia/core/lib/browser/preferences'; +import { PreferenceSchema, PreferenceProxy, PreferenceService, createPreferenceProxy, PreferenceContribution, PreferenceScope } from '@theia/core/lib/browser/preferences'; import { interfaces } from 'inversify'; import { CppBuildConfiguration } from './cpp-build-configurations'; @@ -50,11 +50,13 @@ export const cppPreferencesSchema: PreferenceSchema = { directory: '' } ], + scopes: PreferenceScope.Default & PreferenceScope.User & PreferenceScope.Workspace }, 'cpp.experimentalCommands': { description: 'Enable experimental commands mostly intended for Clangd developers.', default: false, - type: 'boolean' + type: 'boolean', + scopes: PreferenceScope.Default & PreferenceScope.User & PreferenceScope.Workspace }, 'cpp.trace.server': { type: 'string', @@ -64,7 +66,8 @@ export const cppPreferencesSchema: PreferenceSchema = { 'verbose' ], default: 'off', - description: 'Enable/disable tracing communications with the C/C++ language server' + description: 'Enable/disable tracing communications with the C/C++ language server', + scopes: PreferenceScope.Default & PreferenceScope.User & PreferenceScope.Workspace } } }; diff --git a/packages/editor/src/browser/editor-preferences.ts b/packages/editor/src/browser/editor-preferences.ts index 0766a1265253d..58e506d1ff826 100644 --- a/packages/editor/src/browser/editor-preferences.ts +++ b/packages/editor/src/browser/editor-preferences.ts @@ -21,7 +21,8 @@ import { PreferenceService, PreferenceContribution, PreferenceSchema, - PreferenceChangeEvent + PreferenceChangeEvent, + PreferenceScope } from '@theia/core/lib/browser/preferences'; import { isOSX } from '@theia/core/lib/common/os'; @@ -32,12 +33,14 @@ export const editorPreferenceSchema: PreferenceSchema = { 'type': 'number', 'minimum': 1, 'default': 4, - 'description': 'Configure the tab size in the editor.' + 'description': 'Configure the tab size in the editor.', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.fontSize': { 'type': 'number', 'default': (isOSX) ? 12 : 14, - 'description': 'Configure the editor font size.' + 'description': 'Configure the editor font size.', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.lineNumbers': { 'enum': [ @@ -47,7 +50,8 @@ export const editorPreferenceSchema: PreferenceSchema = { 'interval' ], 'default': 'on', - 'description': 'Control the rendering of line numbers.' + 'description': 'Control the rendering of line numbers.', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.renderWhitespace': { 'enum': [ @@ -56,7 +60,8 @@ export const editorPreferenceSchema: PreferenceSchema = { 'all' ], 'default': 'none', - 'description': 'Control the rendering of whitespaces in the editor.' + 'description': 'Control the rendering of whitespaces in the editor.', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.autoSave': { 'enum': [ @@ -64,37 +69,44 @@ export const editorPreferenceSchema: PreferenceSchema = { 'off' ], 'default': 'on', - 'description': 'Configure whether the editor should be auto saved.' + 'description': 'Configure whether the editor should be auto saved.', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.autoSaveDelay': { 'type': 'number', 'default': 500, - 'description': 'Configure the auto save delay in milliseconds.' + 'description': 'Configure the auto save delay in milliseconds.', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.rulers': { 'type': 'array', 'default': [], - 'description': 'Render vertical lines at the specified columns.' + 'description': 'Render vertical lines at the specified columns.', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.wordSeparators': { 'type': 'string', 'default': "`~!@#$%^&*()-=+[{]}\\|;:'\",.<>/", - 'description': 'A string containing the word separators used when doing word navigation.' + 'description': 'A string containing the word separators used when doing word navigation.', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.glyphMargin': { 'type': 'boolean', 'default': true, - 'description': 'Enable the rendering of the glyph margin.' + 'description': 'Enable the rendering of the glyph margin.', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.roundedSelection': { 'type': 'boolean', 'default': true, - 'description': 'Render the editor selection with rounded borders.' + 'description': 'Render the editor selection with rounded borders.', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.minimap.enabled': { 'type': 'boolean', 'default': false, - 'description': 'Enable or disable the minimap.' + 'description': 'Enable or disable the minimap.', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.minimap.showSlider': { 'enum': [ @@ -102,17 +114,20 @@ export const editorPreferenceSchema: PreferenceSchema = { 'always' ], 'default': 'mouseover', - 'description': 'Controls whether the minimap slider is automatically hidden.' + 'description': 'Controls whether the minimap slider is automatically hidden.', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.minimap.renderCharacters': { 'type': 'boolean', 'default': true, - 'description': 'Render the actual characters on a line (as opposed to color blocks).' + 'description': 'Render the actual characters on a line (as opposed to color blocks).', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.minimap.maxColumn': { 'type': 'number', 'default': 120, - 'description': 'Limit the width of the minimap to render at most a certain number of columns.' + 'description': 'Limit the width of the minimap to render at most a certain number of columns.', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.minimap.side': { 'enum': [ @@ -120,17 +135,20 @@ export const editorPreferenceSchema: PreferenceSchema = { 'left' ], 'default': 'right', - 'description': 'Control the side of the minimap in editor.' + 'description': 'Control the side of the minimap in editor.', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.overviewRulerLanes': { 'type': 'number', 'default': 2, - 'description': 'The number of vertical lanes the overview ruler should render.' + 'description': 'The number of vertical lanes the overview ruler should render.', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.overviewRulerBorder': { 'type': 'boolean', 'default': true, - 'description': 'Controls if a border should be drawn around the overview ruler.' + 'description': 'Controls if a border should be drawn around the overview ruler.', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.cursorBlinking': { 'enum': [ @@ -141,12 +159,14 @@ export const editorPreferenceSchema: PreferenceSchema = { 'solid' ], 'default': 'blink', - 'description': "Control the cursor animation style, possible values are 'blink', 'smooth', 'phase', 'expand' and 'solid'." + 'description': "Control the cursor animation style, possible values are 'blink', 'smooth', 'phase', 'expand' and 'solid'.", + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.mouseWheelZoom': { 'type': 'boolean', 'default': false, - 'description': 'Zoom the font in the editor when using the mouse wheel in combination with holding Ctrl.' + 'description': 'Zoom the font in the editor when using the mouse wheel in combination with holding Ctrl.', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.cursorStyle': { 'enum': [ @@ -154,22 +174,26 @@ export const editorPreferenceSchema: PreferenceSchema = { 'block' ], 'default': 'line', - 'description': "Control the cursor style, either 'block' or 'line'." + 'description': "Control the cursor style, either 'block' or 'line'.", + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.fontLigatures': { 'type': 'boolean', 'default': false, - 'description': 'Enable font ligatures.' + 'description': 'Enable font ligatures.', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.hideCursorInOverviewRuler': { 'type': 'boolean', 'default': false, - 'description': 'Should the cursor be hidden in the overview ruler.' + 'description': 'Should the cursor be hidden in the overview ruler.', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.scrollBeyondLastLine': { 'type': 'boolean', 'default': true, - 'description': 'Enable that scrolling can go one screen size after the last line.' + 'description': 'Enable that scrolling can go one screen size after the last line.', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.wordWrap': { 'enum': [ @@ -179,12 +203,14 @@ export const editorPreferenceSchema: PreferenceSchema = { 'bounded' ], 'default': 'off', - 'description': 'Control the wrapping of the editor.' + 'description': 'Control the wrapping of the editor.', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.wordWrapColumn': { 'type': 'number', 'default': 80, - 'description': 'Control the wrapping of the editor.' + 'description': 'Control the wrapping of the editor.', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.wrappingIndent': { 'enum': [ @@ -194,17 +220,20 @@ export const editorPreferenceSchema: PreferenceSchema = { 'none' ], 'default': 'same', - 'description': 'Control indentation of wrapped lines.' + 'description': 'Control indentation of wrapped lines.', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.links': { 'type': 'boolean', 'default': true, - 'description': 'Enable detecting links and making them clickable.' + 'description': 'Enable detecting links and making them clickable.', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.mouseWheelScrollSensitivity': { 'type': 'number', 'default': 1, - 'description': 'A multiplier to be used on the `deltaX` and `deltaY` of mouse wheel scroll events.' + 'description': 'A multiplier to be used on the `deltaX` and `deltaY` of mouse wheel scroll events.', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.multiCursorModifier': { 'enum': [ @@ -212,7 +241,8 @@ export const editorPreferenceSchema: PreferenceSchema = { 'ctrlCmd' ], 'default': 'alt', - 'description': 'The modifier to be used to add multiple cursors with the mouse.' + 'description': 'The modifier to be used to add multiple cursors with the mouse.', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.accessibilitySupport': { 'enum': [ @@ -221,52 +251,62 @@ export const editorPreferenceSchema: PreferenceSchema = { 'off' ], 'default': 'auto', - 'description': "Configure the editor's accessibility support." + 'description': "Configure the editor's accessibility support.", + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.quickSuggestions': { 'type': 'boolean', 'default': true, - 'description': 'Enable quick suggestions (shadow suggestions).' + 'description': 'Enable quick suggestions (shadow suggestions).', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.quickSuggestionsDelay': { 'type': 'number', 'default': 500, - 'description': 'Quick suggestions show delay (in ms).' + 'description': 'Quick suggestions show delay (in ms).', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.parameterHints': { 'type': 'boolean', 'default': true, - 'description': 'Enables parameter hints.' + 'description': 'Enables parameter hints.', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.autoClosingBrackets': { 'type': 'boolean', 'default': true, - 'description': 'Enable auto closing brackets.' + 'description': 'Enable auto closing brackets.', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.autoIndent': { 'type': 'boolean', 'default': false, - 'description': 'Enable auto indentation adjustment.' + 'description': 'Enable auto indentation adjustment.', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.formatOnType': { 'type': 'boolean', 'default': false, - 'description': 'Enable format on type.' + 'description': 'Enable format on type.', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.formatOnPaste': { 'type': 'boolean', 'default': false, - 'description': 'Enable format on paste.' + 'description': 'Enable format on paste.', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.dragAndDrop': { 'type': 'boolean', 'default': false, - 'description': 'Controls if the editor should allow to move selections via drag and drop.' + 'description': 'Controls if the editor should allow to move selections via drag and drop.', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.suggestOnTriggerCharacters': { 'type': 'boolean', 'default': true, - 'description': 'Enable the suggestion box to pop-up on trigger characters.' + 'description': 'Enable the suggestion box to pop-up on trigger characters.', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.acceptSuggestionOnEnter': { 'enum': [ @@ -275,12 +315,14 @@ export const editorPreferenceSchema: PreferenceSchema = { 'off' ], 'default': 'on', - 'description': 'Accept suggestions on ENTER.' + 'description': 'Accept suggestions on ENTER.', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.acceptSuggestionOnCommitCharacter': { 'type': 'boolean', 'default': true, - 'description': 'Accept suggestions on provider defined characters.' + 'description': 'Accept suggestions on provider defined characters.', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.snippetSuggestions': { 'enum': [ @@ -290,37 +332,44 @@ export const editorPreferenceSchema: PreferenceSchema = { 'none' ], 'default': 'inline', - 'description': 'Enable snippet suggestions.' + 'description': 'Enable snippet suggestions.', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.emptySelectionClipboard': { 'type': 'boolean', 'default': true, - 'description': 'Copying without a selection copies the current line.' + 'description': 'Copying without a selection copies the current line.', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.wordBasedSuggestions': { 'type': 'boolean', 'default': true, - 'description': "Enable word based suggestions. Defaults to 'true'." + 'description': "Enable word based suggestions. Defaults to 'true'.", + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.selectionHighlight': { 'type': 'boolean', 'default': true, - 'description': 'Enable selection highlight.' + 'description': 'Enable selection highlight.', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.occurrencesHighlight': { 'type': 'boolean', 'default': true, - 'description': 'Enable semantic occurrences highlight.' + 'description': 'Enable semantic occurrences highlight.', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.codeLens': { 'type': 'boolean', 'default': true, - 'description': 'Show code lens.' + 'description': 'Show code lens.', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.folding': { 'type': 'boolean', 'default': true, - 'description': 'Enable code folding.' + 'description': 'Enable code folding.', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.foldingStrategy': { 'enum': [ @@ -329,7 +378,8 @@ export const editorPreferenceSchema: PreferenceSchema = { ], 'default': 'auto', 'description': 'Selects the folding strategy.' - + '\'auto\' uses the strategies contributed for the current document, \'indentation\' uses the indentation based folding strategy. ' + + '\'auto\' uses the strategies contributed for the current document, \'indentation\' uses the indentation based folding strategy. ', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.showFoldingControls': { 'enum': [ @@ -337,22 +387,26 @@ export const editorPreferenceSchema: PreferenceSchema = { 'always' ], 'default': 'mouseover', - 'description': 'Controls whether the fold actions in the gutter stay always visible or hide unless the mouse is over the gutter.' + 'description': 'Controls whether the fold actions in the gutter stay always visible or hide unless the mouse is over the gutter.', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.matchBrackets': { 'type': 'boolean', 'default': true, - 'description': 'Enable highlighting of matching brackets.' + 'description': 'Enable highlighting of matching brackets.', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.renderControlCharacters': { 'type': 'boolean', 'default': false, - 'description': 'Enable rendering of control characters.' + 'description': 'Enable rendering of control characters.', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.renderIndentGuides': { 'type': 'boolean', 'default': false, - 'description': 'Enable rendering of indent guides.' + 'description': 'Enable rendering of indent guides.', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.renderLineHighlight': { 'enum': [ @@ -362,42 +416,50 @@ export const editorPreferenceSchema: PreferenceSchema = { 'none' ], 'default': 'all', - 'description': 'Enable rendering of current line highlight.' + 'description': 'Enable rendering of current line highlight.', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.useTabStops': { 'type': 'boolean', 'default': true, - 'description': 'Inserting and deleting whitespace follows tab stops.' + 'description': 'Inserting and deleting whitespace follows tab stops.', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.insertSpaces': { 'type': 'boolean', 'default': true, - 'description': 'Using whitespaces to replace tabs when tabbing.' + 'description': 'Using whitespaces to replace tabs when tabbing.', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.colorDecorators': { 'type': 'boolean', 'default': true, - 'description': 'Enable inline color decorators and color picker rendering.' + 'description': 'Enable inline color decorators and color picker rendering.', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.highlightActiveIndentGuide': { 'type': 'boolean', 'default': true, - 'description': 'Enable highlighting of the active indent guide.' + 'description': 'Enable highlighting of the active indent guide.', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.iconsInSuggestions': { 'type': 'boolean', 'default': true, - 'description': 'Render icons in suggestions box.' + 'description': 'Render icons in suggestions box.', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.showUnused': { 'type': 'boolean', 'default': true, 'description': 'Controls fading out of unused variables.', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.scrollBeyondLastColumn': { 'type': 'number', 'default': 5, - 'description': 'Enable that scrolling can go beyond the last column by a number of columns.' + 'description': 'Enable that scrolling can go beyond the last column by a number of columns.', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.suggestSelection': { 'enum': [ @@ -406,7 +468,8 @@ export const editorPreferenceSchema: PreferenceSchema = { 'recentlyUsedByPrefix' ], 'default': 'first', - 'description': 'The history mode for suggestions' + 'description': 'The history mode for suggestions', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'editor.fontWeight': { 'enum': [ @@ -427,37 +490,44 @@ export const editorPreferenceSchema: PreferenceSchema = { '900' ], 'default': 'normal', - 'description': 'Controls the editor\'s font weight.' + 'description': 'Controls the editor\'s font weight.', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'diffEditor.renderSideBySide': { 'type': 'boolean', 'description': 'Render the differences in two side-by-side editors.', - 'default': true + 'default': true, + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'diffEditor.ignoreTrimWhitespace': { 'type': 'boolean', 'description': 'Compute the diff by ignoring leading/trailing whitespace.', - 'default': true + 'default': true, + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'diffEditor.renderIndicators': { 'type': 'boolean', 'description': 'Render +/- indicators for added/deleted changes.', - 'default': true + 'default': true, + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'diffEditor.followsCaret': { 'type': 'boolean', 'description': 'Resets the navigator state when the user selects something in the editor.', - 'default': true + 'default': true, + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'diffEditor.ignoreCharChanges': { 'type': 'boolean', 'description': 'Jump from line to line.', - 'default': true + 'default': true, + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders }, 'diffEditor.alwaysRevealFirst': { 'type': 'boolean', 'description': 'Reveal first change.', - 'default': true + 'default': true, + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders } } }; diff --git a/packages/filesystem/src/browser/filesystem-preferences.ts b/packages/filesystem/src/browser/filesystem-preferences.ts index 607c19ead1b16..0324cc623e055 100644 --- a/packages/filesystem/src/browser/filesystem-preferences.ts +++ b/packages/filesystem/src/browser/filesystem-preferences.ts @@ -20,7 +20,8 @@ import { PreferenceProxy, PreferenceService, PreferenceSchema, - PreferenceContribution + PreferenceContribution, + PreferenceScope } from '@theia/core/lib/browser/preferences'; export const filesystemPreferenceSchema: PreferenceSchema = { @@ -35,13 +36,14 @@ export const filesystemPreferenceSchema: PreferenceSchema = { '**/.git/objects/**': true, '**/.git/subtree-cache/**': true, '**/node_modules/**': true - } + }, + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace | PreferenceScope.Folders } } }; 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..47994a58fd92f 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,16 @@ 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 { + await this.preferences.ready; + 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/git/src/browser/git-preferences.ts b/packages/git/src/browser/git-preferences.ts index c9c20d45eb27f..ff4a998c5c132 100644 --- a/packages/git/src/browser/git-preferences.ts +++ b/packages/git/src/browser/git-preferences.ts @@ -15,7 +15,7 @@ ********************************************************************************/ import { interfaces } from 'inversify'; -import { createPreferenceProxy, PreferenceProxy, PreferenceService, PreferenceContribution, PreferenceSchema } from '@theia/core/lib/browser'; +import { createPreferenceProxy, PreferenceProxy, PreferenceService, PreferenceContribution, PreferenceSchema, PreferenceScope } from '@theia/core/lib/browser'; export const GitConfigSchema: PreferenceSchema = { 'type': 'object', @@ -23,22 +23,26 @@ export const GitConfigSchema: PreferenceSchema = { 'git.decorations.enabled': { 'type': 'boolean', 'description': 'Show Git file status in the navigator.', - 'default': true + 'default': true, + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace }, 'git.decorations.colors': { 'type': 'boolean', 'description': 'Use color decoration in the navigator.', - 'default': false + 'default': false, + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace }, 'git.editor.decorations.enabled': { 'type': 'boolean', 'description': 'Show git decorations in the editor.', - 'default': true + 'default': true, + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace }, 'git.editor.dirtyDiff.linesLimit': { 'type': 'number', 'description': 'Do not show dirty diff decorations, if editor\'s line count exceeds this limit.', - 'default': 1000 + 'default': 1000, + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace } } }; diff --git a/packages/json/src/browser/json-preferences.ts b/packages/json/src/browser/json-preferences.ts index 53e7f0eece2b6..ea438baa2767c 100644 --- a/packages/json/src/browser/json-preferences.ts +++ b/packages/json/src/browser/json-preferences.ts @@ -21,7 +21,8 @@ import { PreferenceService, PreferenceContribution, PreferenceSchema, - PreferenceChangeEvent + PreferenceChangeEvent, + PreferenceScope } from '@theia/core/lib/browser/preferences'; import { JsonSchemaConfiguration } from '@theia/core/lib/browser/json-schema-store'; @@ -56,12 +57,14 @@ export const jsonPreferenceSchema: PreferenceSchema = { 'description': 'An array of file patterns to match against when resolving JSON files to schemas.' } } - } + }, + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace }, 'json.format.enable': { 'type': 'boolean', 'default': true, - 'description': 'Enable/disable default JSON formatter' + 'description': 'Enable/disable default JSON formatter', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace }, 'json.trace.server': { 'type': 'string', @@ -71,7 +74,8 @@ export const jsonPreferenceSchema: PreferenceSchema = { 'verbose' ], 'default': 'off', - 'description': 'Enable/disable tracing communications with the JSON language server' + 'description': 'Enable/disable tracing communications with the JSON language server', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace } } }; diff --git a/packages/messages/src/browser/notification-preferences.ts b/packages/messages/src/browser/notification-preferences.ts index aa4c5311bfd68..b03ab7a4f6e46 100644 --- a/packages/messages/src/browser/notification-preferences.ts +++ b/packages/messages/src/browser/notification-preferences.ts @@ -20,7 +20,8 @@ import { PreferenceProxy, PreferenceService, PreferenceContribution, - PreferenceSchema + PreferenceSchema, + PreferenceScope } from '@theia/core/lib/browser/preferences'; export const NotificationConfigSchema: PreferenceSchema = { @@ -29,7 +30,8 @@ export const NotificationConfigSchema: PreferenceSchema = { 'notification.timeout': { 'type': 'number', 'description': 'The time before auto-dismiss the notification.', - 'default': 5000 // time express in millisec. 0 means : Do not remove + 'default': 5000, // time express in millisec. 0 means : Do not remove + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace } } }; diff --git a/packages/monaco/src/browser/monaco-configurations.ts b/packages/monaco/src/browser/monaco-configurations.ts index 59cb7cba40dd8..7cd472a7bb236 100644 --- a/packages/monaco/src/browser/monaco-configurations.ts +++ b/packages/monaco/src/browser/monaco-configurations.ts @@ -39,7 +39,8 @@ export class MonacoConfigurations implements Configurations { this.preferences.onPreferencesChanged(changes => this.reconcileData(changes)); } - protected reconcileData(changes?: PreferenceChanges): void { + protected async reconcileData(changes?: PreferenceChanges): Promise { + await this.preferences.ready; this.tree = MonacoConfigurations.parse(this.preferences.getPreferences()); this.onDidChangeConfigurationEmitter.fire({ affectsConfiguration: section => this.affectsConfiguration(section, changes) diff --git a/packages/monaco/src/browser/monaco-editor-provider.ts b/packages/monaco/src/browser/monaco-editor-provider.ts index 42b46d7bc311d..84a99a65b5ed4 100644 --- a/packages/monaco/src/browser/monaco-editor-provider.ts +++ b/packages/monaco/src/browser/monaco-editor-provider.ts @@ -100,17 +100,25 @@ 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.canAffect(uri.toString())) { + this.updateMonacoEditorOptions(editor, event); + } + })); 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; + let newValue = event.newValue; + if (newValue === undefined || newValue === null) { + newValue = this.editorPreferences.get(preferenceName, undefined, editor.uri.toString()); + } editor.getControl().updateOptions(this.setOption(preferenceName, newValue, this.preferencePrefixes)); } @@ -131,23 +139,31 @@ export class MonacoEditorProvider { this.diffNavigatorFactory, options, override); - toDispose.push(this.editorPreferences.onPreferenceChanged(event => this.updateMonacoDiffEditorOptions(editor, event))); + toDispose.push(this.editorPreferences.onPreferenceChanged(event => { + if (event.canAffect(uri.toString())) { + this.updateMonacoDiffEditorOptions(editor, event); + } + })); 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; + const preferenceName = event.preferenceName; + let newValue = event.newValue; + if (newValue === undefined || newValue === null) { + newValue = this.editorPreferences.get(preferenceName, undefined, editor.uri.toString()); + } 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..666f3b6d5479d 100644 --- a/packages/monaco/src/browser/monaco-text-model-service.ts +++ b/packages/monaco/src/browser/monaco-text-model-service.ts @@ -57,13 +57,18 @@ 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()); - const disposable = this.editorPreferences.onPreferenceChanged(change => this.updateModel(model, change)); + 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 => { + if (change.canAffect(uri.toString())) { + this.updateModel(model, change); + } + }); model.onDispose(() => disposable.dispose()); return model; } @@ -77,10 +82,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 +96,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-contribution.ts b/packages/navigator/src/browser/navigator-contribution.ts index 8f0938af5520e..224b685532c51 100644 --- a/packages/navigator/src/browser/navigator-contribution.ts +++ b/packages/navigator/src/browser/navigator-contribution.ts @@ -157,15 +157,11 @@ export class FileNavigatorContribution extends AbstractViewContribution { - if (this.workspacePreferences['workspace.supportMultiRootWorkspace']) { - registry.registerMenuAction(NavigatorContextMenu.WORKSPACE, { - commandId: WorkspaceCommands.ADD_FOLDER.id - }); - registry.registerMenuAction(NavigatorContextMenu.WORKSPACE, { - commandId: WorkspaceCommands.REMOVE_FOLDER.id - }); - } + registry.registerMenuAction(NavigatorContextMenu.WORKSPACE, { + commandId: WorkspaceCommands.ADD_FOLDER.id + }); + registry.registerMenuAction(NavigatorContextMenu.WORKSPACE, { + commandId: WorkspaceCommands.REMOVE_FOLDER.id }); } diff --git a/packages/navigator/src/browser/navigator-filter.ts b/packages/navigator/src/browser/navigator-filter.ts index 491509d9c4e34..0bf6cde146e92 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,21 @@ 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 { + await this.preferences.ready; 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/navigator/src/browser/navigator-preferences.ts b/packages/navigator/src/browser/navigator-preferences.ts index 676c03b78e998..256963f588ab1 100644 --- a/packages/navigator/src/browser/navigator-preferences.ts +++ b/packages/navigator/src/browser/navigator-preferences.ts @@ -15,7 +15,7 @@ ********************************************************************************/ import { interfaces } from 'inversify'; -import { createPreferenceProxy, PreferenceProxy, PreferenceService, PreferenceContribution, PreferenceSchema } from '@theia/core/lib/browser'; +import { createPreferenceProxy, PreferenceProxy, PreferenceService, PreferenceContribution, PreferenceSchema, PreferenceScope } from '@theia/core/lib/browser'; // tslint:disable:max-line-length @@ -25,7 +25,8 @@ export const FileNavigatorConfigSchema: PreferenceSchema = { 'navigator.autoReveal': { type: 'boolean', description: 'Selects file under editing in the navigator.', - default: true + default: true, + scopes: PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace }, 'navigator.exclude': { type: 'object', @@ -33,7 +34,8 @@ export const FileNavigatorConfigSchema: PreferenceSchema = { Configure glob patterns for excluding files and folders from the navigator. A resource that matches any of the enabled patterns, will be filtered out from the navigator. For more details about the exclusion patterns, see: \`man 5 gitignore\`.`, default: { '**/.git': true - } + }, + scopes: PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace } } }; diff --git a/packages/navigator/src/browser/navigator-widget.tsx b/packages/navigator/src/browser/navigator-widget.tsx index 2264b7627f849..0893b2e537c58 100644 --- a/packages/navigator/src/browser/navigator-widget.tsx +++ b/packages/navigator/src/browser/navigator-widget.tsx @@ -27,7 +27,6 @@ import { import { FileTreeWidget, FileNode } from '@theia/filesystem/lib/browser'; import { WorkspaceService, WorkspaceCommands } from '@theia/workspace/lib/browser'; import { ApplicationShell } from '@theia/core/lib/browser/shell/application-shell'; -import { WorkspaceNode } from './navigator-tree'; import { FileNavigatorModel } from './navigator-model'; import { FileSystem } from '@theia/filesystem/lib/common/filesystem'; import * as React from 'react'; @@ -116,12 +115,9 @@ export class FileNavigatorWidget extends FileTreeWidget { protected getContainerTreeNode(): TreeNode | undefined { const root = this.model.root; - if (this.workspaceService.isMultiRootWorkspaceOpened) { + if (this.workspaceService.opened) { return root; } - if (WorkspaceNode.is(root)) { - return root.children[0]; - } return undefined; } diff --git a/packages/output/src/common/output-preferences.ts b/packages/output/src/common/output-preferences.ts index 0ddf4082944b4..eda124efec2ab 100644 --- a/packages/output/src/common/output-preferences.ts +++ b/packages/output/src/common/output-preferences.ts @@ -20,7 +20,8 @@ import { PreferenceProxy, PreferenceService, PreferenceContribution, - PreferenceSchema + PreferenceSchema, + PreferenceScope } from '@theia/core/lib/browser/preferences'; export const OutputConfigSchema: PreferenceSchema = { @@ -29,7 +30,8 @@ export const OutputConfigSchema: PreferenceSchema = { 'output.maxChannelHistory': { 'type': 'number', 'description': 'The maximum number of entries in an output channel.', - 'default': 1000 + 'default': 1000, + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace } } }; diff --git a/packages/plugin-ext/src/hosted/browser/hosted-plugin-preferences.ts b/packages/plugin-ext/src/hosted/browser/hosted-plugin-preferences.ts index 09b2b9dbd671b..0ee9893ef3f0f 100644 --- a/packages/plugin-ext/src/hosted/browser/hosted-plugin-preferences.ts +++ b/packages/plugin-ext/src/hosted/browser/hosted-plugin-preferences.ts @@ -15,7 +15,7 @@ ********************************************************************************/ import { interfaces } from 'inversify'; -import { createPreferenceProxy, PreferenceProxy, PreferenceService, PreferenceContribution, PreferenceSchema } from '@theia/core/lib/browser'; +import { createPreferenceProxy, PreferenceProxy, PreferenceService, PreferenceContribution, PreferenceSchema, PreferenceScope } from '@theia/core/lib/browser'; export const HostedPluginConfigSchema: PreferenceSchema = { 'type': 'object', @@ -23,13 +23,15 @@ export const HostedPluginConfigSchema: PreferenceSchema = { 'hosted-plugin.watchMode': { type: 'boolean', description: 'Run watcher on plugin under development', - default: true + default: true, + scopes: PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace }, 'hosted-plugin.debugMode': { type: 'string', description: 'Using inspect or inspect-brk for Node.js debug', default: 'inspect', - enum: ['inspect', 'inspect-brk'] + enum: ['inspect', 'inspect-brk'], + scopes: PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace } } }; diff --git a/packages/preferences/package.json b/packages/preferences/package.json index da041b9d20677..a57bc089f8a17 100644 --- a/packages/preferences/package.json +++ b/packages/preferences/package.json @@ -6,6 +6,7 @@ "@theia/core": "^0.3.16", "@theia/editor": "^0.3.16", "@theia/filesystem": "^0.3.16", + "@theia/json": "^0.3.16", "@theia/monaco": "^0.3.16", "@theia/userstorage": "^0.3.16", "@theia/workspace": "^0.3.16", diff --git a/packages/preferences/src/browser/abstract-resource-preference-provider.ts b/packages/preferences/src/browser/abstract-resource-preference-provider.ts index bba14b12076ff..4c692ac6056d3 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 { Disposable, ILogger, MaybePromise, Resource, ResourceProvider } from '@theia/core/lib/common'; +import { PreferenceProvider, PreferenceSchemaProvider, PreferenceScope } from '@theia/core/lib/browser'; import URI from '@theia/core/lib/common/uri'; -import { ILogger, Resource, ResourceProvider, MaybePromise } from '@theia/core/lib/common'; -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 onDidResourceChanged: Disposable | undefined; @inject(ILogger) protected readonly logger: ILogger; - @inject(ResourceProvider) protected readonly resourceProvider: ResourceProvider; - - protected resource: Promise; + @inject(PreferenceSchemaProvider) protected readonly schemaProvider: PreferenceSchemaProvider; @postConstruct() protected async init(): Promise { @@ -54,14 +55,15 @@ 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())); + this.onDidResourceChanged = resource.onDidChangeContents(content => this.readPreferences()); + this.toDispose.push(this.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; } @@ -71,20 +73,34 @@ export abstract class AbstractResourcePreferenceProvider extends PreferenceProvi if (resource.saveContents) { const content = await this.readContents(); const formattingOptions = { tabSize: 3, insertSpaces: true, eol: '' }; - 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); + const oldValue = this.preferences[key]; + if (JSONExt.deepEqual(value, oldValue)) { + return; + } this.preferences[key] = value; - this.onDidPreferencesChangedEmitter.fire(undefined); + this.onDidPreferencesChangedEmitter.fire({ + preferenceName: key, + newValue: value, + oldValue, + scope: this.getScope(), + domain: this.getDomain() + }); + this.emitPreferenceChangedEvent(key, value, oldValue); } } + 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 = this.getParsedContent(newContent); + await this.handlePreferenceChanges(newPrefs); } protected async readContents(): Promise { @@ -96,4 +112,60 @@ export abstract class AbstractResourcePreferenceProvider extends PreferenceProvi } } + // tslint:disable-next-line:no-any + protected getParsedContent(content: string): { [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)]); + 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 + this.logger.warn(`${prefNameAndFile} is invalid.`); + continue; + } + const schemaProperties = this.schemaProvider.getCombinedSchema().properties[prefName]; + if (schemaProperties) { + const scopes = schemaProperties.scopes; + // do not emit the change event if the change is made out of the defined preference scope + if (!this.schemaProvider.isValidInScope(prefName, this.getScope())) { + this.logger.warn(`${prefNameAndFile} can only be defined in scopes: ${PreferenceScope.getScopeNames(scopes).join(', ')}.`); + continue; + } + } + + if (!JSONExt.deepEqual(oldValue, newValue)) { // do not emit the change event if the pref value is not changed + this.emitPreferenceChangedEvent(prefName, newValue, oldValue); + } + } + } + + // tslint:disable-next-line:no-any + protected emitPreferenceChangedEvent(preferenceName: string, newValue: any, oldValue: any): void { + this.onDidPreferencesChangedEmitter.fire({ + preferenceName, + newValue, + oldValue, + scope: this.getScope(), + domain: this.getDomain() + }); + } + + dispose(): void { + for (const prefName of Object.keys(this.preferences)) { + const value = this.preferences[prefName]; + if (value !== undefined || value !== null) { + this.emitPreferenceChangedEvent(prefName, undefined, value); + } + } + 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..bc9c35ae7349e --- /dev/null +++ b/packages/preferences/src/browser/folder-preference-provider.ts @@ -0,0 +1,73 @@ +/******************************************************************************** + * Copyright (C) 2018 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 } 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: 3 + this.folderUri.path.relativity(uri.path), provider: this }; + } + + protected getScope() { + return PreferenceScope.Folders; + } + + 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..60a7880d58003 --- /dev/null +++ b/packages/preferences/src/browser/folders-preferences-provider.ts @@ -0,0 +1,140 @@ +/******************************************************************************** + * Copyright (C) 2018 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 { ILogger } from '@theia/core/lib/common'; +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(ILogger) protected readonly logger: ILogger; + @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 = await 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(async roots => { + for (const root of roots) { + if (!this.existsProvider(root.uri)) { + const provider = this.createFolderPreferenceProvider(root); + await provider.ready; + if (!this.existsProvider(root.uri)) { // prevent a second provider gets created while waiting on `provider.ready` + 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); + } + } + this.logger.error(`FoldersPreferencesProvider did not find the provider for ${resourceUri} to update the preference ${key}`); + } else { + this.logger.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..20ab95459cba3 --- /dev/null +++ b/packages/preferences/src/browser/preference-editor-widget.ts @@ -0,0 +1,127 @@ +/******************************************************************************** + * Copyright (C) 2018 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'; + +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 + ) { + 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 menu = this.refreshContextMenu(data.title.owner.editor.uri.parent.parent.toString(), + clickableTitle.clickableTextCallback || (() => { })); + menu.open(event.x, event.y); + } + }, clickableTitle.clickableText.toUpperCase()) + ); + } + return super.renderLabel(data); + } + + protected refreshContextMenu(activeMenuId: string, menuItemAction: (value: string) => void) { + const commands = new CommandRegistry(); + const menu = new Menu({ commands }); + const roots = this.workspaceService.tryGetRoots().map(r => r.uri); + for (const root of roots) { + 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; + } +} + +export class PreferenceEditorContainerTabBarRenderer extends DockPanel.Renderer { + + constructor( + private readonly workspaceService: WorkspaceService + ) { + super(); + } + + createTabBar(): TabBar { + const bar = new TabBar({ renderer: new PreferenceEditorTabHeaderRenderer(this.workspaceService) }); + 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 1fb97b329ae1d..b5fa994559ddb 100644 --- a/packages/preferences/src/browser/preference-frontend-module.ts +++ b/packages/preferences/src/browser/preference-frontend-module.ts @@ -14,24 +14,37 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { ContainerModule, interfaces, } from 'inversify'; -import { PreferenceProvider, PreferenceScope } from '@theia/core/lib/browser/preferences'; +import { Container, ContainerModule, interfaces } from 'inversify'; +import { PreferenceProvider, PreferenceScope, bindDefaultPreferenceProvider } from '@theia/core/lib/browser/preferences'; import { UserPreferenceProvider } from './user-preference-provider'; import { WorkspacePreferenceProvider } from './workspace-preference-provider'; import { bindViewContribution, WidgetFactory, FrontendApplicationContribution } from '@theia/core/lib/browser'; 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 './monaco-contribution'; export function bindPreferences(bind: interfaces.Bind, unbind: interfaces.Unbind): void { unbind(PreferenceProvider); + bindDefaultPreferenceProvider(bind); bind(PreferenceProvider).to(UserPreferenceProvider).inSingletonScope().whenTargetNamed(PreferenceScope.User); bind(PreferenceProvider).to(WorkspacePreferenceProvider).inSingletonScope().whenTargetNamed(PreferenceScope.Workspace); + bind(PreferenceProvider).to(FoldersPreferencesProvider).inSingletonScope().whenTargetNamed(PreferenceScope.Folders); + 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 9513a036c2a1b..29635a4cc8010 100644 --- a/packages/preferences/src/browser/preference-service.spec.ts +++ b/packages/preferences/src/browser/preference-service.spec.ts @@ -26,7 +26,7 @@ import * as fs from 'fs-extra'; import * as temp from 'temp'; import { Emitter } from '@theia/core/lib/common'; import { - PreferenceService, PreferenceScope, + PreferenceService, PreferenceScope, PreferenceChange, PreferenceDataChange, PreferenceSchemaProvider, PreferenceProviderProvider, PreferenceServiceImpl, PreferenceProvider, bindPreferenceSchemaProvider } from '@theia/core/lib/browser/preferences'; import { FileSystem, FileShouldOverwrite, FileStat } from '@theia/filesystem/lib/common/'; @@ -54,11 +54,10 @@ 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(); before(async () => { testContainer = new Container(); @@ -134,7 +133,10 @@ before(async () => { testContainer.bind(ILogger).to(MockLogger); }); -describe('Preference Service', function () { +describe('Preference Service', () => { + let prefService: PreferenceService; + let prefSchema: PreferenceSchemaProvider; + const stubs: sinon.SinonStub[] = []; before(() => { disableJSDOM = enableJSDOM(); @@ -145,6 +147,7 @@ describe('Preference Service', function () { }); beforeEach(() => { + prefSchema = testContainer.get(PreferenceSchemaProvider); prefService = testContainer.get(PreferenceService); const impl = testContainer.get(PreferenceServiceImpl); impl.initialize(); @@ -152,130 +155,115 @@ 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 - }); - - mockUserPreferenceEmitter.fire(undefined); - + mockUserPreferenceEmitter.fire(new PreferenceDataChange({ + preferenceName: 'testPref', + scope: PreferenceScope.User, + domain: ['file:///domain'], + newValue: 'newVal', + oldValue: 'oldVal' + }, new Map([[PreferenceScope.User, userProvider]]))); }).timeout(2000); 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); - - [stubUser, stubWorkspace].forEach(stub => { - stub.restore(); - }); + })); + stubs.push(sinon.stub(prefSchema, 'isValidInScope').returns(true)); + expect(prefService.get('test.boolean')).to.be.false; + expect(prefService.get('test.number')).equals(0); }); 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', () => { 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', () => { 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({ + preferenceName: 'test.number', + scope: PreferenceScope.User, + domain: ['file:///domain'], + newValue: 2 }); - 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({ + preferenceName: 'test.number', + scope: PreferenceScope.User, + domain: ['file:///domain'], + newValue: 4 }); - 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 () => { @@ -299,10 +287,9 @@ describe('Preference Service', function () { }); /** - * Make sure that the preference service is ready only once the providers - * are ready to provide preferences. + * Make sure that the preference service is ready once the default preference provider is ready */ - it('Should be ready only when all providers are ready', async () => { + it('Should be ready immediately after the default preference provider becomes ready', async () => { /** * A slow provider that becomes ready after 1 second. */ @@ -321,17 +308,53 @@ describe('Preference Service', function () { getPreferences() { return this.prefs; } + canProvide(preferenceName: string, resourceUri?: string) { + if (this.prefs[preferenceName] === undefined) { + return { priority: -1, provider: this }; + } + return { priority: 1, provider: this }; + } + } + /** + * Default provider that becomes ready after constructor gets called + */ + class MockDefaultProvider extends PreferenceProvider { + // tslint:disable-next-line:no-any + readonly prefs: { [p: string]: any } = {}; + + constructor() { + super(); + this.prefs['mypref'] = 5; + this._ready.resolve(); + } + + getPreferences() { + return this.prefs; + } + canProvide(preferenceName: string, resourceUri?: string) { + if (this.prefs[preferenceName] === undefined) { + return { priority: -1, provider: this }; + } + return { priority: 1, provider: this }; + } } const container = new Container(); bindPreferenceSchemaProvider(container.bind.bind(container)); - container.bind(PreferenceProviderProvider).toFactory(ctx => (scope: PreferenceScope) => new SlowProvider()); + container.bind(PreferenceProviderProvider).toFactory(ctx => (scope: PreferenceScope) => { + if (scope === PreferenceScope.Default) { + return new MockDefaultProvider(); + } + return new SlowProvider(); + }); container.bind(PreferenceServiceImpl).toSelf().inSingletonScope(); const service = container.get(PreferenceServiceImpl); service.initialize(); + prefSchema = container.get(PreferenceSchemaProvider); await service.ready; - const n = service.getNumber('mypref'); - expect(n).to.equal(2); + const stubPrefSchema = sinon.stub(prefSchema, 'isValidInScope').returns(true); + expect(service.get('mypref')).to.equal(5); + stubPrefSchema.restore(); }); }); diff --git a/packages/preferences/src/browser/preferences-decorator.ts b/packages/preferences/src/browser/preferences-decorator.ts index 0c8582bdc6f9b..59e40d378e1b8 100644 --- a/packages/preferences/src/browser/preferences-decorator.ts +++ b/packages/preferences/src/browser/preferences-decorator.ts @@ -22,6 +22,8 @@ import { Emitter, Event, MaybePromise } from '@theia/core'; export class PreferencesDecorator implements TreeDecorator { readonly id: string = 'theia-preferences-decorator'; + private activeFolderUri: string | undefined; + protected preferences: { [id: string]: PreferenceProperty }[]; protected preferencesDecorations: Map; @@ -38,14 +40,14 @@ export class PreferencesDecorator implements TreeDecorator { return this.emitter.event; } - fireDidChangeDecorations(preferences: {[id: string]: PreferenceProperty}[]): void { + fireDidChangeDecorations(preferences: { [id: string]: PreferenceProperty }[]): 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: [ @@ -54,7 +56,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]; })); @@ -64,4 +66,9 @@ export class PreferencesDecorator implements TreeDecorator { decorations(tree: Tree): MaybePromise> { 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 0743441363b1c..797e124ad6c2d 100644 --- a/packages/preferences/src/browser/preferences-frontend-application-contribution.ts +++ b/packages/preferences/src/browser/preferences-frontend-application-contribution.ts @@ -29,14 +29,12 @@ export class PreferencesFrontendApplicationContribution implements FrontendAppli @inject(InMemoryResources) inmemoryResources: InMemoryResources; onStart() { - this.schemaProvider.ready.then(async () => { - const schema = this.schemaProvider.getCombinedSchema(); - const uri = new URI('vscode://schemas/settings/user'); - this.inmemoryResources.add(uri, JSON.stringify(schema)); - this.jsonSchemaStore.registerSchema({ - fileMatch: ['.theia/settings.json', USER_PREFERENCE_URI.toString()], - url: uri.toString() - }); + const schema = this.schemaProvider.getCombinedSchema(); + const uri = new URI('vscode://schemas/settings/user'); + this.inmemoryResources.add(uri, JSON.stringify(schema)); + this.jsonSchemaStore.registerSchema({ + fileMatch: ['.theia/settings.json', USER_PREFERENCE_URI.toString()], + url: uri.toString() }); } } diff --git a/packages/preferences/src/browser/preferences-tree-widget.ts b/packages/preferences/src/browser/preferences-tree-widget.ts index 6d7b85f206d57..5e3047410e545 100644 --- a/packages/preferences/src/browser/preferences-tree-widget.ts +++ b/packages/preferences/src/browser/preferences-tree-widget.ts @@ -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 onTrackableWidgetsChangedEmitter = new Emitter(); + readonly onTrackableWidgetsChanged = this.onTrackableWidgetsChangedEmitter.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.onTrackableWidgetsChangedEmitter]); } 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.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.onSavedLocationChanged(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); @@ -189,10 +203,39 @@ export class PreferencesContainer extends SplitPanel implements ApplicationShell } public activatePreferenceEditor(preferenceScope: PreferenceScope) { + this.preferenceScope = preferenceScope; if (this.editorsContainer) { this.editorsContainer.activatePreferenceEditor(preferenceScope); } } + + protected handleEditorsChanged() { + 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.onTrackableWidgetsChangedEmitter.fire(this.editors); + } + + private refreshFoldersPreferencesEditor() { + const roots = this.workspaceService.tryGetRoots(); + if (!roots.some(r => r.uri === this.editorsContainer.activeFolder)) { + const firstRoot = roots[0]; + this.editorsContainer.refreshFoldersPreferencesEditorWidget(firstRoot ? firstRoot.uri : undefined); + } + } } @injectable() @@ -212,19 +255,30 @@ 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 + ) { + super({ renderer: new PreferenceEditorContainerTabBarRenderer(workspaceService) }); + } + dispose(): void { this.toDispose.dispose(); super.dispose(); @@ -236,36 +290,110 @@ 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.scope = PreferenceScope.User; - this.addWidget(userPreferences); + return userPreferences; + } + async refreshWorkspacePreferenceEditor(): Promise { + if (this._workspacePreferenceEditorWidget) { + this._workspacePreferenceEditorWidget.close(); + this._workspacePreferenceEditorWidget.dispose(); + } + this._workspacePreferenceEditorWidget = await this.getWorkspacePreferenceEditorWidget(); + if (this._workspacePreferenceEditorWidget) { + this.addWidget(this._workspacePreferenceEditorWidget, { ref: this._userPreferenceEditorWidget }); + } + } + + 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.icon = '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 { + if (this._foldersPreferenceEditorWidget) { + this._foldersPreferenceEditorWidget.close(); + this._foldersPreferenceEditorWidget.dispose(); + } + const folders = this.workspaceService.tryGetRoots().map(r => r.uri); + this._foldersPreferenceEditorWidget = await this.getFoldersPreferencesEditor(currentFolder || folders[0]); + if (this._foldersPreferenceEditorWidget) { + this.addWidget(this._foldersPreferenceEditorWidget, { ref: this._workspacePreferenceEditorWidget }); + this.onFolderPreferenceEditorUriChangedEmitter.fire(this._foldersPreferenceEditorWidget.editor.uri.toString()); + } + } + + protected async getFoldersPreferencesEditor(folder: string | undefined): Promise { + if (this.workspaceService.saved) { + const folderDisplayName = new URI(folder).displayName; + const settingsUri = await this.getFolderSettingsUri(folder); + const foldersPreferences = settingsUri && await this.editorManager.getOrCreateByUri(settingsUri) as PreferencesEditorWidget; + if (foldersPreferences) { + foldersPreferences.title.label = folderDisplayName.toUpperCase(); + foldersPreferences.title.icon = 'database-icon medium-yellow file-icon'; + foldersPreferences.title.caption = await this.getPreferenceEditorCaption(settingsUri!); + foldersPreferences.title.clickableText = 'Folders Preferences'; + 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.Folders); + }; + foldersPreferences.scope = PreferenceScope.Folders; + } + return foldersPreferences; + } + } + + private async getFolderSettingsUri(folder: string | undefined): Promise { + if (folder) { + const folderUri = new URI(folder); + const settingsUri = folderUri.resolve('.theia').resolve('settings.json').toString(); + if (!(await this.fileSystem.exists(settingsUri))) { + await this.fileSystem.createFile(settingsUri); + } + return new URI(settingsUri); + } } activatePreferenceEditor(preferenceScope: PreferenceScope) { - this.preferenceScope = preferenceScope; for (const widget of toArray(this.widgets())) { const preferenceEditor = widget as PreferencesEditorWidget; if (preferenceEditor.scope === preferenceScope) { @@ -294,6 +422,7 @@ 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 onPreferenceSelectedEmitter: Emitter<{ [key: string]: string }>; @@ -373,7 +502,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 }); @@ -433,4 +562,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..9d4e6a6fe2ec9 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 } 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: 1, 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..3b1a1ee47b602 100644 --- a/packages/preferences/src/browser/workspace-preference-provider.ts +++ b/packages/preferences/src/browser/workspace-preference-provider.ts @@ -14,10 +14,12 @@ * 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 } from '@theia/core/lib/browser'; +import { WorkspaceService, WorkspaceData } from '@theia/workspace/lib/browser/workspace-service'; import { AbstractResourcePreferenceProvider } from './abstract-resource-preference-provider'; +import * as jsoncparser from 'jsonc-parser'; @injectable() export class WorkspacePreferenceProvider extends AbstractResourcePreferenceProvider { @@ -25,13 +27,76 @@ export class WorkspacePreferenceProvider extends AbstractResourcePreferenceProvi @inject(WorkspaceService) protected readonly workspaceService: WorkspaceService; + @postConstruct() + protected async init(): Promise { + await super.init(); + this.workspaceService.onSavedLocationChanged(async workspaceFile => { + if (workspaceFile && !workspaceFile.isDirectory) { + if (this.onDidResourceChanged) { + this.onDidResourceChanged.dispose(); + } + (await this.resource).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: 2, provider: this }; + } + + // tslint:disable-next-line:no-any + protected getParsedContent(content: string): { [key: string]: any } { + const strippedContent = jsoncparser.stripComments(content); + const data = jsoncparser.parse(strippedContent) || {}; + 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/preview/src/browser/preview-preferences.ts b/packages/preview/src/browser/preview-preferences.ts index e194a2214deae..0de0173082e14 100644 --- a/packages/preview/src/browser/preview-preferences.ts +++ b/packages/preview/src/browser/preview-preferences.ts @@ -20,7 +20,8 @@ import { PreferenceProxy, PreferenceService, PreferenceContribution, - PreferenceSchema + PreferenceSchema, + PreferenceScope } from '@theia/core/lib/browser'; export const PreviewConfigSchema: PreferenceSchema = { @@ -29,7 +30,8 @@ export const PreviewConfigSchema: PreferenceSchema = { 'preview.openByDefault': { type: 'boolean', description: 'Open the preview instead of the editor by default.', - default: true + default: true, + scopes: PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace } } }; diff --git a/packages/typescript/src/browser/typescript-preferences.ts b/packages/typescript/src/browser/typescript-preferences.ts index 1c3d940913f84..1521fa30179f1 100644 --- a/packages/typescript/src/browser/typescript-preferences.ts +++ b/packages/typescript/src/browser/typescript-preferences.ts @@ -21,7 +21,8 @@ import { PreferenceService, PreferenceContribution, PreferenceSchema, - PreferenceChangeEvent + PreferenceChangeEvent, + PreferenceScope } from '@theia/core/lib/browser/preferences'; export const typescriptPreferenceSchema: PreferenceSchema = { @@ -35,7 +36,8 @@ export const typescriptPreferenceSchema: PreferenceSchema = { 'verbose' ], 'default': 'off', - 'description': 'Enable/disable tracing communications with the TS language server.' + 'description': 'Enable/disable tracing communications with the TS language server.', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace }, 'typescript.server.log': { 'type': 'string', @@ -47,7 +49,8 @@ export const typescriptPreferenceSchema: PreferenceSchema = { ], 'default': 'off', // tslint:disable:max-line-length - 'description': 'Enables logging of the TS server to a file. This log can be used to diagnose TS Server issues. The log may contain file paths, source code, and other potentially sensitive information from your project.' + 'description': 'Enables logging of the TS server to a file. This log can be used to diagnose TS Server issues. The log may contain file paths, source code, and other potentially sensitive information from your project.', + 'scopes': PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace } } }; diff --git a/packages/workspace/src/browser/quick-open-workspace.ts b/packages/workspace/src/browser/quick-open-workspace.ts index 5147a5fc27cdd..097f63fd4a607 100644 --- a/packages/workspace/src/browser/quick-open-workspace.ts +++ b/packages/workspace/src/browser/quick-open-workspace.ts @@ -17,7 +17,6 @@ import { injectable, inject } from 'inversify'; import { QuickOpenService, QuickOpenModel, QuickOpenItem, QuickOpenGroupItem, QuickOpenMode, LabelProvider } from '@theia/core/lib/browser'; import { WorkspaceService, getTemporaryWorkspaceFileUri } from './workspace-service'; -import { WorkspacePreferences } from './workspace-preferences'; import URI from '@theia/core/lib/common/uri'; import { FileSystem, FileSystemUtils } from '@theia/filesystem/lib/common'; import * as moment from 'moment'; @@ -32,7 +31,6 @@ export class QuickOpenWorkspace implements QuickOpenModel { @inject(WorkspaceService) protected readonly workspaceService: WorkspaceService; @inject(FileSystem) protected readonly fileSystem: FileSystem; @inject(LabelProvider) protected readonly labelProvider: LabelProvider; - @inject(WorkspacePreferences) protected preferences: WorkspacePreferences; async open(workspaces: string[]): Promise { this.items = []; @@ -42,13 +40,11 @@ export class QuickOpenWorkspace implements QuickOpenModel { if (home) { tempWorkspaceFile = getTemporaryWorkspaceFileUri(new URI(home)); } - await this.preferences.ready; for (const workspace of workspaces) { const uri = new URI(workspace); const stat = await this.fileSystem.getFileStat(workspace); - if (!stat || - !this.preferences['workspace.supportMultiRootWorkspace'] && !stat.isDirectory) { - continue; // skip the workspace files if multi root is not supported + if (!stat) { + continue; } if (tempWorkspaceFile && uri.toString() === tempWorkspaceFile.toString()) { continue; // skip the temporary workspace files diff --git a/packages/workspace/src/browser/workspace-commands.ts b/packages/workspace/src/browser/workspace-commands.ts index 64175cceb8059..ee49b5f7c6eb4 100644 --- a/packages/workspace/src/browser/workspace-commands.ts +++ b/packages/workspace/src/browser/workspace-commands.ts @@ -28,7 +28,6 @@ import { OpenerService, OpenHandler, open, FrontendApplication } from '@theia/co import { UriCommandHandler, UriAwareCommandHandler } from '@theia/core/lib/common/uri-command-handler'; import { WorkspaceService } from './workspace-service'; import { MessageService } from '@theia/core/lib/common/message-service'; -import { WorkspacePreferences } from './workspace-preferences'; import { WorkspaceDeleteHandler } from './workspace-delete-handler'; const validFilename: (arg: string) => boolean = require('valid-filename'); @@ -131,7 +130,6 @@ export class WorkspaceCommandContribution implements CommandContribution { @inject(OpenerService) protected readonly openerService: OpenerService; @inject(FrontendApplication) protected readonly app: FrontendApplication; @inject(MessageService) protected readonly messageService: MessageService; - @inject(WorkspacePreferences) protected readonly preferences: WorkspacePreferences; @inject(FileDialogService) protected readonly fileDialogService: FileDialogService; @inject(WorkspaceDeleteHandler) protected readonly deleteHandler: WorkspaceDeleteHandler; @@ -274,40 +272,40 @@ export class WorkspaceCommandContribution implements CommandContribution { } } })); - this.preferences.ready.then(() => { - registry.registerCommand(WorkspaceCommands.ADD_FOLDER, this.newMultiUriAwareCommandHandler({ - isEnabled: () => this.workspaceService.isMultiRootWorkspaceOpened, - isVisible: uris => !uris.length || this.areWorkspaceRoots(uris), - execute: async uris => { - const uri = await this.fileDialogService.showOpenDialog({ - title: WorkspaceCommands.ADD_FOLDER.label!, - canSelectFiles: false, - canSelectFolders: true - }); - if (!uri) { - return; - } - const workspaceSavedBeforeAdding = this.workspaceService.saved; - await this.addFolderToWorkspace(uri); - if (!workspaceSavedBeforeAdding) { - const saveCommand = registry.getCommand(WorkspaceCommands.SAVE_WORKSPACE_AS.id); - if (saveCommand && await new ConfirmDialog({ - title: 'Folder added to Workspace', - msg: 'A workspace with multiple roots was created. Do you want to save your workspace configuration as a file?', - ok: 'Yes', - cancel: 'No' - }).open()) { - registry.executeCommand(saveCommand.id); - } + + registry.registerCommand(WorkspaceCommands.ADD_FOLDER, this.newMultiUriAwareCommandHandler({ + isEnabled: () => this.workspaceService.opened, + isVisible: uris => !uris.length || this.areWorkspaceRoots(uris), + execute: async uris => { + const uri = await this.fileDialogService.showOpenDialog({ + title: WorkspaceCommands.ADD_FOLDER.label!, + canSelectFiles: false, + canSelectFolders: true + }); + if (!uri) { + return; + } + const workspaceSavedBeforeAdding = this.workspaceService.saved; + await this.addFolderToWorkspace(uri); + if (!workspaceSavedBeforeAdding) { + const saveCommand = registry.getCommand(WorkspaceCommands.SAVE_WORKSPACE_AS.id); + if (saveCommand && await new ConfirmDialog({ + title: 'Folder added to Workspace', + msg: 'A workspace with multiple roots was created. Do you want to save your workspace configuration as a file?', + ok: 'Yes', + cancel: 'No' + }).open()) { + registry.executeCommand(saveCommand.id); } } - })); - registry.registerCommand(WorkspaceCommands.REMOVE_FOLDER, this.newMultiUriAwareCommandHandler({ - execute: uris => this.removeFolderFromWorkspace(uris), - isEnabled: () => this.workspaceService.isMultiRootWorkspaceOpened, - isVisible: uris => this.areWorkspaceRoots(uris) && this.workspaceService.saved - })); - }); + } + } + )); + registry.registerCommand(WorkspaceCommands.REMOVE_FOLDER, this.newMultiUriAwareCommandHandler({ + execute: uris => this.removeFolderFromWorkspace(uris), + isEnabled: () => this.workspaceService.opened, + isVisible: uris => this.areWorkspaceRoots(uris) && this.workspaceService.saved + })); } protected newUriAwareCommandHandler(handler: UriCommandHandler): UriAwareCommandHandler { @@ -426,10 +424,7 @@ export class WorkspaceRootUriAwareCommandHandler extends UriAwareCommandHandler< protected getUri(): URI | undefined { const uri = super.getUri(); - if (this.workspaceService.isMultiRootWorkspaceOpened) { - return uri; - } - if (uri) { + if (this.workspaceService.opened || uri) { return uri; } const root = this.workspaceService.tryGetRoots()[0]; diff --git a/packages/workspace/src/browser/workspace-frontend-contribution.spec.ts b/packages/workspace/src/browser/workspace-frontend-contribution.spec.ts index 116e37160cf7a..a829b371b4a76 100644 --- a/packages/workspace/src/browser/workspace-frontend-contribution.spec.ts +++ b/packages/workspace/src/browser/workspace-frontend-contribution.spec.ts @@ -37,31 +37,24 @@ describe('workspace-frontend-contribution', () => { ([ - [OS.Type.Linux, 'browser', true, { title, canSelectFiles: true, canSelectFolders: true, filters }], - [OS.Type.Linux, 'browser', false, { title, canSelectFiles: false, canSelectFolders: true }], - [OS.Type.Linux, 'electron', true, { title, canSelectFiles: true, canSelectFolders: false, filters }], - [OS.Type.Linux, 'electron', false, { title, canSelectFiles: false, canSelectFolders: true }], + [OS.Type.Linux, 'browser', { title, canSelectFiles: true, canSelectFolders: true, filters }], + [OS.Type.Linux, 'electron', { title, canSelectFiles: true, canSelectFolders: false, filters }], - [OS.Type.Windows, 'browser', true, { title, canSelectFiles: true, canSelectFolders: true, filters }], - [OS.Type.Windows, 'browser', false, { title, canSelectFiles: false, canSelectFolders: true }], - [OS.Type.Windows, 'electron', true, { title, canSelectFiles: true, canSelectFolders: false, filters }], - [OS.Type.Windows, 'electron', false, { title, canSelectFiles: false, canSelectFolders: true }], + [OS.Type.Windows, 'browser', { title, canSelectFiles: true, canSelectFolders: true, filters }], + [OS.Type.Windows, 'electron', { title, canSelectFiles: true, canSelectFolders: false, filters }], - [OS.Type.OSX, 'browser', true, { title, canSelectFiles: true, canSelectFolders: true, filters }], - [OS.Type.OSX, 'browser', false, { title, canSelectFiles: false, canSelectFolders: true }], - [OS.Type.OSX, 'electron', true, { title, canSelectFiles: true, canSelectFolders: true, filters }], - [OS.Type.OSX, 'electron', false, { title, canSelectFiles: true, canSelectFolders: true, filters }] + [OS.Type.OSX, 'browser', { title, canSelectFiles: true, canSelectFolders: true, filters }], + [OS.Type.OSX, 'electron', { title, canSelectFiles: true, canSelectFolders: true, filters }] - ] as [OS.Type, 'browser' | 'electron', boolean, OpenFileDialogProps][]).forEach(test => { - const [type, environment, supportMultiRootWorkspace, expected] = test; + ] as [OS.Type, 'browser' | 'electron', OpenFileDialogProps][]).forEach(test => { + const [type, environment, expected] = test; const electron = environment === 'electron' ? true : false; const os = (OS.Type as any)[type]; // tslint:disable-line:no-any const actual = WorkspaceFrontendContribution.createOpenWorkspaceOpenFileDialogProps({ type, - electron, - supportMultiRootWorkspace + electron }); - it(`createOpenWorkspaceOpenFileDialogProps - OS: ${os}, Environment: ${environment}, Multi-root workspace: ${supportMultiRootWorkspace ? 'yes' : 'no'}`, () => { + it(`createOpenWorkspaceOpenFileDialogProps - OS: ${os}, Environment: ${environment}`, () => { expect(actual).to.be.deep.equal(expected); }); }); diff --git a/packages/workspace/src/browser/workspace-frontend-contribution.ts b/packages/workspace/src/browser/workspace-frontend-contribution.ts index c0a49dcc33a47..e163922674ab2 100644 --- a/packages/workspace/src/browser/workspace-frontend-contribution.ts +++ b/packages/workspace/src/browser/workspace-frontend-contribution.ts @@ -23,7 +23,6 @@ import { FileSystem } from '@theia/filesystem/lib/common'; import { WorkspaceService, THEIA_EXT, VSCODE_EXT } from './workspace-service'; import { WorkspaceCommands } from './workspace-commands'; import { QuickOpenWorkspace } from './quick-open-workspace'; -import { WorkspacePreferences } from './workspace-preferences'; import URI from '@theia/core/lib/common/uri'; @injectable() @@ -36,7 +35,6 @@ export class WorkspaceFrontendContribution implements CommandContribution, Keybi @inject(LabelProvider) protected readonly labelProvider: LabelProvider; @inject(QuickOpenWorkspace) protected readonly quickOpenWorkspace: QuickOpenWorkspace; @inject(FileDialogService) protected readonly fileDialogService: FileDialogService; - @inject(WorkspacePreferences) protected preferences: WorkspacePreferences; registerCommands(commands: CommandRegistry): void { // Not visible/enabled on Windows/Linux in electron. @@ -75,7 +73,7 @@ export class WorkspaceFrontendContribution implements CommandContribution, Keybi execute: () => this.quickOpenWorkspace.select() }); commands.registerCommand(WorkspaceCommands.SAVE_WORKSPACE_AS, { - isEnabled: () => this.workspaceService.isMultiRootWorkspaceOpened, + isEnabled: () => this.workspaceService.opened, execute: () => this.saveWorkspaceAs() }); } @@ -258,14 +256,11 @@ export class WorkspaceFrontendContribution implements CommandContribution, Keybi } protected async openWorkspaceOpenFileDialogProps(): Promise { - await this.preferences.ready; - const supportMultiRootWorkspace = this.preferences['workspace.supportMultiRootWorkspace']; const type = OS.type(); const electron = this.isElectron(); return WorkspaceFrontendContribution.createOpenWorkspaceOpenFileDialogProps({ type, - electron, - supportMultiRootWorkspace + electron }); } @@ -332,32 +327,11 @@ export namespace WorkspaceFrontendContribution { /** * Returns with an `OpenFileDialogProps` for opening the `Open Workspace` dialog. */ - export function createOpenWorkspaceOpenFileDialogProps(options: Readonly<{ type: OS.Type, electron: boolean, supportMultiRootWorkspace: boolean }>): OpenFileDialogProps { - const { electron, type, supportMultiRootWorkspace } = options; + export function createOpenWorkspaceOpenFileDialogProps(options: Readonly<{ type: OS.Type, electron: boolean }>): OpenFileDialogProps { + const { electron, type } = options; const title = WorkspaceCommands.OPEN_WORKSPACE.dialogLabel; - // If browser + // If browser, it is always folder + workspace files. if (!electron) { - // and multi-root workspace is supported, it is always folder + workspace files. - if (supportMultiRootWorkspace) { - return { - title, - canSelectFiles: true, - canSelectFolders: true, - filters: DEFAULT_FILE_FILTER - }; - } else { - // otherwise, it is always folders. No files at all. - return { - title, - canSelectFiles: false, - canSelectFolders: true - }; - } - } - - // If electron - if (OS.Type.OSX === type) { - // `Finder` can select folders and files at the same time. We allow folders and workspace files. return { title, canSelectFiles: true, @@ -366,21 +340,23 @@ export namespace WorkspaceFrontendContribution { }; } - // In electron, only workspace files can be selected when the multi-root workspace feature is enabled. - if (supportMultiRootWorkspace) { + // If electron + if (OS.Type.OSX === type) { + // `Finder` can select folders and files at the same time. We allow folders and workspace files. return { title, canSelectFiles: true, - canSelectFolders: false, + canSelectFolders: true, filters: DEFAULT_FILE_FILTER }; } - // Otherwise, it is always a folder. + // In electron, only workspace files can be selected when the multi-root workspace feature is enabled. return { title, - canSelectFiles: false, - canSelectFolders: true + canSelectFiles: true, + canSelectFolders: false, + filters: DEFAULT_FILE_FILTER }; } diff --git a/packages/workspace/src/browser/workspace-preferences.ts b/packages/workspace/src/browser/workspace-preferences.ts index 30f7f2b82a152..cbd8b677ccbd2 100644 --- a/packages/workspace/src/browser/workspace-preferences.ts +++ b/packages/workspace/src/browser/workspace-preferences.ts @@ -20,7 +20,8 @@ import { PreferenceProxy, PreferenceService, PreferenceSchema, - PreferenceContribution + PreferenceContribution, + PreferenceScope } from '@theia/core/lib/browser/preferences'; export const workspacePreferenceSchema: PreferenceSchema = { @@ -29,19 +30,14 @@ export const workspacePreferenceSchema: PreferenceSchema = { 'workspace.preserveWindow': { description: 'Enable opening workspaces in current window', type: 'boolean', - default: false - }, - 'workspace.supportMultiRootWorkspace': { - description: 'Enable the multi-root workspace support to test this feature internally', - type: 'boolean', - default: false + default: false, + scopes: PreferenceScope.Default | PreferenceScope.User | PreferenceScope.Workspace } } }; export interface WorkspaceConfiguration { - 'workspace.preserveWindow': boolean, - 'workspace.supportMultiRootWorkspace': boolean + 'workspace.preserveWindow': boolean } export const WorkspacePreferences = Symbol('WorkspacePreferences'); diff --git a/packages/workspace/src/browser/workspace-service.ts b/packages/workspace/src/browser/workspace-service.ts index 855d50600ba01..9a71d442cf43e 100644 --- a/packages/workspace/src/browser/workspace-service.ts +++ b/packages/workspace/src/browser/workspace-service.ts @@ -20,7 +20,10 @@ import { FileSystem, FileStat } from '@theia/filesystem/lib/common'; import { FileSystemWatcher, FileChangeEvent } from '@theia/filesystem/lib/browser/filesystem-watcher'; import { WorkspaceServer } from '../common'; import { WindowService } from '@theia/core/lib/browser/window/window-service'; -import { FrontendApplication, FrontendApplicationContribution } from '@theia/core/lib/browser'; +import { + FrontendApplication, FrontendApplicationContribution, + PreferenceServiceImpl, PreferenceScope, PreferenceSchemaProvider +} from '@theia/core/lib/browser'; import { Deferred } from '@theia/core/lib/common/promise-util'; import { ILogger, Disposable, DisposableCollection, Emitter, Event } from '@theia/core'; import { WorkspacePreferences } from './workspace-preferences'; @@ -66,6 +69,12 @@ export class WorkspaceService implements FrontendApplicationContribution { @inject(WorkspacePreferences) protected preferences: WorkspacePreferences; + @inject(PreferenceServiceImpl) + protected readonly preferenceImpl: PreferenceServiceImpl; + + @inject(PreferenceSchemaProvider) + protected readonly schemaProvider: PreferenceSchemaProvider; + @postConstruct() protected async init(): Promise { const workspaceUri = await this.server.getMostRecentlyUsedWorkspace(); @@ -77,12 +86,6 @@ export class WorkspaceService implements FrontendApplicationContribution { this.updateWorkspace(); } }); - this.preferences.onPreferenceChanged(event => { - const multiRootPrefName = 'workspace.supportMultiRootWorkspace'; - if (event.preferenceName === multiRootPrefName) { - this.updateWorkspace(); - } - }); } get roots(): Promise { @@ -100,6 +103,11 @@ export class WorkspaceService implements FrontendApplicationContribution { return this.onWorkspaceChangeEmitter.event; } + protected readonly onSavedLocationChangeEmitter = new Emitter(); + get onSavedLocationChanged(): Event { + return this.onSavedLocationChangeEmitter.event; + } + protected readonly toDisposeOnWorkspace = new DisposableCollection(); protected async setWorkspace(workspaceStat: FileStat | undefined): Promise { if (FileStat.equals(this._workspace, workspaceStat)) { @@ -115,16 +123,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 { @@ -209,21 +234,13 @@ 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 { return !!this._workspace; } - /** - * Returns `true` if there is an opened workspace in theia, and the workspace has more than one root. - * @returns {boolean} - */ - get isMultiRootWorkspaceOpened(): boolean { - return this.opened && this.preferences['workspace.supportMultiRootWorkspace']; - } - /** * Opens directory, or recreates a workspace from the file that `uri` points to. */ @@ -274,7 +291,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!.settings)); } } @@ -286,21 +305,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; } } @@ -419,10 +440,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.Folders)) { + continue; + } + const value = this.preferenceImpl.inspect(p).values.get(PreferenceScope.Workspace); + if (value) { + workspaceData.settings![p] = value; + } + } + } let stat = await this.toFileStat(uriStr); - stat = await this.writeWorkspaceFile(stat, await this.roots); + Object.assign(workspaceData, await this.getWorkspaceDataFromFile()); + stat = await this.writeWorkspaceFile(stat, WorkspaceData.buildWorkspaceData(await this.roots, workspaceData ? workspaceData.settings : undefined)); await this.server.setMostRecentlyUsedWorkspace(uriStr); await this.setWorkspace(stat); + this.onSavedLocationChangeEmitter.fire(stat); } protected readonly rootWatchers = new Map(); @@ -466,12 +501,13 @@ export interface WorkspaceInput { } -interface WorkspaceData { - folders: Array<{ path: string }>; - // TODO add workspace settings settings?: { [id: string]: any }; +export interface WorkspaceData { + folders: Array<{ path: string, name?: string }>; + // tslint:disable-next-line:no-any + settings?: { [id: string]: any }; } -namespace WorkspaceData { +export namespace WorkspaceData { const validateSchema = new Ajv().compile({ type: 'object', properties: { @@ -487,8 +523,13 @@ namespace WorkspaceData { }, required: ['path'] } + }, + settings: { + description: 'Workspace preferences', + type: 'object' } - } + }, + required: ['folders'] }); // tslint:disable-next-line:no-any @@ -496,10 +537,23 @@ 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 { @@ -514,7 +568,7 @@ namespace WorkspaceData { folderUris.push(folderUri.toString()); } } - return buildWorkspaceData(folderUris); + return buildWorkspaceData(folderUris, data.settings); } export function transformToAbsolute(data: WorkspaceData, workspaceFile?: FileStat): WorkspaceData { @@ -529,7 +583,7 @@ namespace WorkspaceData { } } - return Object.assign(data, buildWorkspaceData(folders)); + return Object.assign(data, buildWorkspaceData(folders, data.settings)); } return data; }