diff --git a/src/main.ts b/src/main.ts index 2f4f8e0e6..a10f72422 100644 --- a/src/main.ts +++ b/src/main.ts @@ -30,10 +30,11 @@ import { ProjectStatusBarObserver } from './observers/ProjectStatusBarObserver'; import CSharpExtensionExports from './CSharpExtensionExports'; import { vscodeNetworkSettingsProvider, NetworkSettingsProvider } from './NetworkSettings'; import { ErrorMessageObserver } from './observers/ErrorMessageObserver'; -import OptionStream from './observables/OptionStream'; import OptionProvider from './observers/OptionProvider'; import DotNetTestChannelObserver from './observers/DotnetTestChannelObserver'; import DotNetTestLoggerObserver from './observers/DotnetTestLoggerObserver'; +import { ShowOmniSharpConfigChangePrompt } from './observers/OptionChangeObserver'; +import createOptionStream from './observables/CreateOptionStream'; export async function activate(context: vscode.ExtensionContext): Promise { @@ -46,7 +47,7 @@ export async function activate(context: vscode.ExtensionContext): Promise { + return Observable.create((observer: Observer) => { + let disposable = vscode.workspace.onDidChangeConfiguration(e => { + //if the omnisharp or csharp configuration are affected only then read the options + if (e.affectsConfiguration('omnisharp') || e.affectsConfiguration('csharp')) { + observer.next(Options.Read(vscode)); + } + }); + + return () => disposable.dispose(); + }).publishBehavior(Options.Read(vscode)).refCount(); +} \ No newline at end of file diff --git a/src/observables/OptionStream.ts b/src/observables/OptionStream.ts deleted file mode 100644 index 20291ca67..000000000 --- a/src/observables/OptionStream.ts +++ /dev/null @@ -1,34 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Options } from "../omnisharp/options"; -import { vscode } from "../vscodeAdapter"; -import 'rxjs/add/operator/take'; -import { BehaviorSubject } from 'rxjs/BehaviorSubject'; -import Disposable, { IDisposable } from "../Disposable"; -import { Subject } from "rxjs/Subject"; - -export default class OptionStream { - private optionStream: Subject; - private disposable: IDisposable; - - constructor(vscode: vscode) { - this.optionStream = new BehaviorSubject(Options.Read(vscode)); - this.disposable = vscode.workspace.onDidChangeConfiguration(e => { - //if the omnisharp or csharp configuration are affected only then read the options - if (e.affectsConfiguration('omnisharp') || e.affectsConfiguration('csharp')) { - this.optionStream.next(Options.Read(vscode)); - } - }); - } - - public dispose = () => { - this.disposable.dispose(); - } - - public subscribe(observer: (options: Options) => void): Disposable { - return new Disposable(this.optionStream.subscribe(observer)); - } -} \ No newline at end of file diff --git a/src/observers/OptionChangeObserver.ts b/src/observers/OptionChangeObserver.ts new file mode 100644 index 000000000..717e637c7 --- /dev/null +++ b/src/observers/OptionChangeObserver.ts @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { vscode } from "../vscodeAdapter"; +import { Options } from "../omnisharp/options"; +import ShowInformationMessage from "./utils/ShowInformationMessage"; +import { Observable } from "rxjs/Observable"; +import Disposable from "../Disposable"; +import 'rxjs/add/operator/filter'; +import 'rxjs/add/operator/distinctUntilChanged'; + +function ConfigChangeObservable(optionObservable: Observable): Observable { + let options: Options; + return optionObservable. filter(newOptions => { + let changed = (options && hasChanged(options, newOptions)); + options = newOptions; + return changed; + }); +} + +export function ShowOmniSharpConfigChangePrompt(optionObservable: Observable, vscode: vscode): Disposable { + let subscription = ConfigChangeObservable(optionObservable) + .subscribe(_ => { + let message = "OmniSharp configuration has changed. Would you like to relaunch the OmniSharp server with your changes?"; + ShowInformationMessage(vscode, message, { title: "Restart OmniSharp", command: 'o.restart' }); + }); + + return new Disposable(subscription); +} + +function hasChanged(oldOptions: Options, newOptions: Options): boolean { + return (oldOptions.path != newOptions.path || + oldOptions.useGlobalMono != newOptions.useGlobalMono || + oldOptions.waitForDebugger != newOptions.waitForDebugger); +} \ No newline at end of file diff --git a/src/observers/OptionProvider.ts b/src/observers/OptionProvider.ts index 3a578b6cd..0d242162f 100644 --- a/src/observers/OptionProvider.ts +++ b/src/observers/OptionProvider.ts @@ -4,19 +4,26 @@ *--------------------------------------------------------------------------------------------*/ import { Options } from "../omnisharp/options"; -import OptionStream from "../observables/OptionStream"; +import { Subscription } from "rxjs/Subscription"; +import { Observable } from "rxjs/Observable"; export default class OptionProvider { private options: Options; + private subscription: Subscription; - constructor(optionStream: OptionStream) { - optionStream.subscribe(options => this.options = options); + constructor(optionObservable: Observable) { + this.subscription = optionObservable.subscribe(options => this.options = options); } public GetLatestOptions(): Options { if (!this.options) { throw new Error("Error reading OmniSharp options"); } + return this.options; } + + public dispose = () => { + this.subscription.unsubscribe(); + } } \ No newline at end of file diff --git a/test/unitTests/OptionObserver/OptionChangeObserver.test.ts b/test/unitTests/OptionObserver/OptionChangeObserver.test.ts new file mode 100644 index 000000000..0933f7e9e --- /dev/null +++ b/test/unitTests/OptionObserver/OptionChangeObserver.test.ts @@ -0,0 +1,127 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { use as chaiUse, expect, should } from 'chai'; +import { updateConfig, getVSCodeWithConfig } from '../testAssets/Fakes'; +import { Observable } from 'rxjs/Observable'; +import 'rxjs/add/observable/fromPromise'; +import 'rxjs/add/operator/timeout'; +import { vscode } from '../../../src/vscodeAdapter'; +import { ShowOmniSharpConfigChangePrompt } from '../../../src/observers/OptionChangeObserver'; +import { Subject } from 'rxjs/Subject'; +import { Options } from '../../../src/omnisharp/options'; +import { BehaviorSubject } from 'rxjs/BehaviorSubject'; + +chaiUse(require('chai-as-promised')); +chaiUse(require('chai-string')); + +suite("OmniSharpConfigChangeObserver", () => { + suiteSetup(() => should()); + + let doClickOk: () => void; + let doClickCancel: () => void; + let signalCommandDone: () => void; + let commandDone: Promise; + let vscode: vscode; + let infoMessage: string; + let invokedCommand: string; + let optionObservable: Subject; + + setup(() => { + vscode = getVSCode(); + optionObservable = new BehaviorSubject(Options.Read(vscode)); + ShowOmniSharpConfigChangePrompt(optionObservable, vscode); + commandDone = new Promise(resolve => { + signalCommandDone = () => { resolve(); }; + }); + }); + + [ + { config: "omnisharp", section: "path", value: "somePath" }, + { config: "omnisharp", section: "waitForDebugger", value: true }, + { config: "omnisharp", section: "useGlobalMono", value: "always" } + + ].forEach(elem => { + suite(`When the ${elem.config} ${elem.section} changes`, () => { + setup(() => { + expect(infoMessage).to.be.undefined; + expect(invokedCommand).to.be.undefined; + updateConfig(vscode, elem.config, elem.section, elem.value); + optionObservable.next(Options.Read(vscode)); + }); + + test(`The information message is shown`, async () => { + expect(infoMessage).to.be.equal("OmniSharp configuration has changed. Would you like to relaunch the OmniSharp server with your changes?"); + }); + + test('Given an information message if the user clicks cancel, the command is not executed', async () => { + doClickCancel(); + await expect(Observable.fromPromise(commandDone).timeout(1).toPromise()).to.be.rejected; + expect(invokedCommand).to.be.undefined; + }); + + test('Given an information message if the user clicks Restore, the command is executed', async () => { + doClickOk(); + await commandDone; + expect(invokedCommand).to.be.equal("o.restart"); + }); + }); + }); + + suite('Information Message is not shown on change in',() => { + [ + { config: "csharp", section: 'disableCodeActions', value: true }, + { config: "csharp", section: 'testsCodeLens.enabled', value: false }, + { config: "omnisharp", section: 'referencesCodeLens.enabled', value: false }, + { config: "csharp", section: 'format.enable', value: false }, + { config: "omnisharp", section: 'useEditorFormattingSettings', value: false }, + { config: "omnisharp", section: 'maxProjectResults', value: 1000 }, + { config: "omnisharp", section: 'projectLoadTimeout', value: 1000 }, + { config: "omnisharp", section: 'autoStart', value: false }, + { config: "omnisharp", section: 'loggingLevel', value: 'verbose' } + ].forEach(elem => { + test(`${elem.config} ${elem.section}`, async () => { + expect(infoMessage).to.be.undefined; + expect(invokedCommand).to.be.undefined; + updateConfig(vscode, elem.config, elem.section, elem.value); + optionObservable.next(Options.Read(vscode)); + expect(infoMessage).to.be.undefined; + }); + }); + }); + + teardown(() => { + infoMessage = undefined; + invokedCommand = undefined; + doClickCancel = undefined; + doClickOk = undefined; + signalCommandDone = undefined; + commandDone = undefined; + }); + + function getVSCode(): vscode { + let vscode = getVSCodeWithConfig(); + vscode.window.showInformationMessage = async (message: string, ...items: T[]) => { + infoMessage = message; + return new Promise(resolve => { + doClickCancel = () => { + resolve(undefined); + }; + + doClickOk = () => { + resolve(...items); + }; + }); + }; + + vscode.commands.executeCommand = (command: string, ...rest: any[]) => { + invokedCommand = command; + signalCommandDone(); + return undefined; + }; + + return vscode; + } +}); diff --git a/test/unitTests/OptionObserver/OptionProvider.test.ts b/test/unitTests/OptionObserver/OptionProvider.test.ts index e04a957a0..99fdd25a5 100644 --- a/test/unitTests/OptionObserver/OptionProvider.test.ts +++ b/test/unitTests/OptionObserver/OptionProvider.test.ts @@ -5,71 +5,35 @@ import { should, expect } from 'chai'; import { getVSCodeWithConfig, updateConfig } from "../testAssets/Fakes"; -import OptionStream from "../../../src/observables/OptionStream"; -import { vscode, ConfigurationChangeEvent } from "../../../src/vscodeAdapter"; -import Disposable from "../../../src/Disposable"; +import { vscode } from "../../../src/vscodeAdapter"; import OptionProvider from '../../../src/observers/OptionProvider'; +import { Subject } from 'rxjs/Subject'; +import { Options } from '../../../src/omnisharp/options'; suite('OptionProvider', () => { suiteSetup(() => should()); let vscode: vscode; - let listenerFunction: Array<(e: ConfigurationChangeEvent) => any>; let optionProvider: OptionProvider; + let optionObservable: Subject; setup(() => { - listenerFunction = new Array<(e: ConfigurationChangeEvent) => any>(); - vscode = getVSCode(listenerFunction); - let optionStream = new OptionStream(vscode); - optionProvider = new OptionProvider(optionStream); + vscode = getVSCodeWithConfig(); + optionObservable = new Subject(); + optionProvider = new OptionProvider(optionObservable); }); - test("Gives the default options if there is no change", () => { - let options = optionProvider.GetLatestOptions(); - expect(options.path).to.be.null; - options.useGlobalMono.should.equal("auto"); - options.waitForDebugger.should.equal(false); - options.loggingLevel.should.equal("information"); - options.autoStart.should.equal(true); - options.projectLoadTimeout.should.equal(60); - options.maxProjectResults.should.equal(250); - options.useEditorFormattingSettings.should.equal(true); - options.useFormatting.should.equal(true); - options.showReferencesCodeLens.should.equal(true); - options.showTestsCodeLens.should.equal(true); - options.disableCodeActions.should.equal(false); - options.disableCodeActions.should.equal(false); + test("Throws exception when no options are pushed", () => { + expect(optionProvider.GetLatestOptions).to.throw(); }); - test("Gives the latest options if there are changes in omnisharp config", () => { + test("Gives the latest options when options are changed", () => { let changingConfig = "omnisharp"; updateConfig(vscode, changingConfig, 'path', "somePath"); - listenerFunction.forEach(listener => listener(getConfigChangeEvent(changingConfig))); - let options = optionProvider.GetLatestOptions(); - expect(options.path).to.be.equal("somePath"); - }); - - test("Gives the latest options if there are changes in csharp config", () => { - let changingConfig = 'csharp'; - updateConfig(vscode, changingConfig, 'disableCodeActions', true); - listenerFunction.forEach(listener => listener(getConfigChangeEvent(changingConfig))); + optionObservable.next(Options.Read(vscode)); + updateConfig(vscode, changingConfig, 'path', "anotherPath"); + optionObservable.next(Options.Read(vscode)); let options = optionProvider.GetLatestOptions(); - expect(options.disableCodeActions).to.be.equal(true); + expect(options.path).to.be.equal("anotherPath"); }); -}); - -function getVSCode(listenerFunction: Array<(e: ConfigurationChangeEvent) => any>): vscode { - let vscode = getVSCodeWithConfig(); - vscode.workspace.onDidChangeConfiguration = (listener: (e: ConfigurationChangeEvent) => any, thisArgs?: any, disposables?: Disposable[]) => { - listenerFunction.push(listener); - return new Disposable(() => { }); - }; - - return vscode; -} - -function getConfigChangeEvent(changingConfig: string): ConfigurationChangeEvent { - return { - affectsConfiguration: (section: string) => section == changingConfig - }; -} \ No newline at end of file +}); \ No newline at end of file diff --git a/test/unitTests/logging/InformationMessageObserver.test.ts b/test/unitTests/logging/InformationMessageObserver.test.ts index b547ff544..f3abc58a9 100644 --- a/test/unitTests/logging/InformationMessageObserver.test.ts +++ b/test/unitTests/logging/InformationMessageObserver.test.ts @@ -19,9 +19,7 @@ suite("InformationMessageObserver", () => { let doClickOk: () => void; let doClickCancel: () => void; let signalCommandDone: () => void; - let commandDone = new Promise(resolve => { - signalCommandDone = () => { resolve(); }; - }); + let commandDone: Promise; let vscode = getVsCode(); let infoMessage: string; let relativePath: string; @@ -29,9 +27,6 @@ suite("InformationMessageObserver", () => { let observer: InformationMessageObserver = new InformationMessageObserver(vscode); setup(() => { - infoMessage = undefined; - relativePath = undefined; - invokedCommand = undefined; commandDone = new Promise(resolve => { signalCommandDone = () => { resolve(); }; }); @@ -80,6 +75,15 @@ suite("InformationMessageObserver", () => { }); }); }); + }); + + teardown(() => { + commandDone = undefined; + infoMessage = undefined; + relativePath = undefined; + invokedCommand = undefined; + doClickCancel = undefined; + doClickOk = undefined; }); function getVsCode() { diff --git a/test/unitTests/optionStream.test.ts b/test/unitTests/optionStream.test.ts new file mode 100644 index 000000000..831b000ab --- /dev/null +++ b/test/unitTests/optionStream.test.ts @@ -0,0 +1,100 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { should, expect } from 'chai'; +import { ConfigurationChangeEvent, vscode } from "../../src/vscodeAdapter"; +import { getVSCodeWithConfig, updateConfig } from "./testAssets/Fakes"; +import Disposable from "../../src/Disposable"; +import { Observable } from "rxjs/Observable"; +import { Options } from "../../src/omnisharp/options"; +import { GetConfigChangeEvent } from './testAssets/GetConfigChangeEvent'; +import { Subscription } from 'rxjs/Subscription'; +import createOptionStream from '../../src/observables/CreateOptionStream'; + +suite('OptionStream', () => { + suiteSetup(() => should()); + + let listenerFunction: Array<(e: ConfigurationChangeEvent) => any>; + let vscode: vscode; + let optionStream: Observable; + let disposeCalled: boolean; + + setup(() => { + listenerFunction = new Array<(e: ConfigurationChangeEvent) => any>(); + vscode = getVSCode(listenerFunction); + optionStream = createOptionStream(vscode); + disposeCalled = false; + }); + + suite('Returns the recent options to the subscriber', () => { + let subscription: Subscription; + let options: Options; + + setup(() => { + subscription = optionStream.subscribe(newOptions => options = newOptions); + }); + + test('Returns the default options if there is no change', () => { + expect(options.path).to.be.null; + options.useGlobalMono.should.equal("auto"); + options.waitForDebugger.should.equal(false); + options.loggingLevel.should.equal("information"); + options.autoStart.should.equal(true); + options.projectLoadTimeout.should.equal(60); + options.maxProjectResults.should.equal(250); + options.useEditorFormattingSettings.should.equal(true); + options.useFormatting.should.equal(true); + options.showReferencesCodeLens.should.equal(true); + options.showTestsCodeLens.should.equal(true); + options.disableCodeActions.should.equal(false); + }); + + test('Gives the changed option when the omnisharp config changes', () => { + expect(options.path).to.be.null; + let changingConfig = "omnisharp"; + updateConfig(vscode, changingConfig, 'path', "somePath"); + listenerFunction.forEach(listener => listener(GetConfigChangeEvent(changingConfig))); + options.path.should.equal("somePath"); + }); + + test('Gives the changed option when the csharp config changes', () => { + options.disableCodeActions.should.equal(false); + let changingConfig = "csharp"; + updateConfig(vscode, changingConfig, 'disableCodeActions', true); + listenerFunction.forEach(listener => listener(GetConfigChangeEvent(changingConfig))); + options.disableCodeActions.should.equal(true); + }); + + teardown(() => { + options = undefined; + listenerFunction = undefined; + subscription.unsubscribe(); + subscription = undefined; + }); + }); + + test('Dispose is called when the last subscriber unsubscribes', () => { + disposeCalled.should.equal(false); + let subscription1 = optionStream.subscribe(_ => { }); + let subscription2 = optionStream.subscribe(_ => { }); + let subscription3 = optionStream.subscribe(_ => { }); + subscription1.unsubscribe(); + disposeCalled.should.equal(false); + subscription2.unsubscribe(); + disposeCalled.should.equal(false); + subscription3.unsubscribe(); + disposeCalled.should.equal(true); + }); + + function getVSCode(listenerFunction: Array<(e: ConfigurationChangeEvent) => any>): vscode { + let vscode = getVSCodeWithConfig(); + vscode.workspace.onDidChangeConfiguration = (listener: (e: ConfigurationChangeEvent) => any, thisArgs?: any, disposables?: Disposable[]) => { + listenerFunction.push(listener); + return new Disposable(() => disposeCalled = true); + }; + + return vscode; + } +}); \ No newline at end of file diff --git a/test/unitTests/testAssets/GetConfigChangeEvent.ts b/test/unitTests/testAssets/GetConfigChangeEvent.ts new file mode 100644 index 000000000..3994d484e --- /dev/null +++ b/test/unitTests/testAssets/GetConfigChangeEvent.ts @@ -0,0 +1,12 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ConfigurationChangeEvent } from "../../../src/vscodeAdapter"; + +export function GetConfigChangeEvent(changingConfig: string): ConfigurationChangeEvent { + return { + affectsConfiguration: (section: string) => section == changingConfig + }; +} \ No newline at end of file